First-PR scope from #1. Single-process Python daemon that relays between Claude Code instances and chat-Claude (Anthropic API). Components: * relay.config — .env + config.yaml loader. Auto-generates ntfy topic on first run and persists it back to .env. * relay.state — atomic file I/O via tempfile + rename, advisory flock at state/.lock to enforce single-instance. * relay.conversation — append-only history with summarization. Triggers a summarize call when total chars exceed HISTORY_CHAR_CAP (default 400k); replaces history with the summary plus the most recent 10 turns. * relay.anthropic_client — SDK wrapper. Marks the system prompt cacheable (5-min ephemeral cache); concatenates text blocks; estimates per-call cost from the Anthropic price table with cache-write/read accounted for. * relay.queue — JSON envelope intake; oldest-by-mtime; malformed envelopes moved to queue/.rejected/. * relay.dispatch — one-input-at-a-time per session (dispatch/<session_id>/input.txt). Won't overwrite a pending dispatch; queues internally and waits for CC to delete. * relay.ntfy — best-effort POST to https://ntfy.sh/<topic>; failures logged but never block the main loop. * relay.daemon — main loop. Polls jc_input.txt (priority) then queue/. Detects [NEEDS-JC] in the first 200 chars of any response and pauses dispatch until JC writes jc_input.txt. JC override supports @session-N: prefix for direct dispatch without an API call. * relay.__main__ — CLI: relay run / relay status / relay topic. Tests: 57 unit tests pass (config, state, conversation, queue, dispatch, anthropic_client, ntfy, full daemon loop with a fake client). One real-API smoke test marked real_api, opt-in via pytest -m real_api; skips cleanly on credit-balance errors. Out of scope for this PR (deferred to follow-ups): Flask status endpoint, multi-session config in production, exponential backoff, systemd unit, cost-tracking aggregation. Closes #1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
5.4 KiB
Python
164 lines
5.4 KiB
Python
"""Settings + config.yaml loader.
|
|
|
|
The relay reads two config sources:
|
|
|
|
1. ``.env`` for secrets (API key) and per-host overrides (status port,
|
|
history cap). The ntfy topic is auto-generated on first run and
|
|
written back to ``.env`` so it persists across restarts.
|
|
|
|
2. ``config.yaml`` for project-level settings: the registered CC
|
|
sessions, the system prompt, the summarization prompt. First-PR
|
|
default config is generated if the file doesn't exist, with one
|
|
``session-1`` registered.
|
|
|
|
Settings are read once at startup; the daemon does not hot-reload them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from dotenv import dotenv_values, set_key
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
ENV_FILE = REPO_ROOT / ".env"
|
|
CONFIG_FILE = REPO_ROOT / "config.yaml"
|
|
|
|
DEFAULT_SYSTEM_PROMPT = (
|
|
"You are the chat-side counterpart to Claude Code instances working on the "
|
|
"risv3 project. CC sends you its progress; you respond as a project lead "
|
|
"would: check decisions, ask clarifying questions, approve or correct. "
|
|
"When a CC turn raises a question only JC (the human owner) can answer, "
|
|
"begin your response with the literal token [NEEDS-JC] so the relay daemon "
|
|
"pauses and notifies JC. Otherwise reply normally and the relay forwards "
|
|
"your reply to the originating CC session."
|
|
)
|
|
|
|
DEFAULT_SUMMARIZATION_PROMPT = (
|
|
"Summarize the conversation so far. Preserve project context, decisions "
|
|
"made, open work items, and any outstanding [NEEDS-JC] questions. The "
|
|
"summary will replace earlier turns in the conversation history; the most "
|
|
"recent turns will be retained verbatim. Be specific where specificity "
|
|
"matters (file paths, issue numbers, decisions); be brief on routine "
|
|
"back-and-forth."
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SessionConfig:
|
|
"""One registered CC session."""
|
|
|
|
session_id: str
|
|
working_dir: str | None = None
|
|
description: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Settings:
|
|
api_key: str
|
|
model: str
|
|
ntfy_topic: str
|
|
status_port: int
|
|
history_char_cap: int
|
|
repo_root: Path
|
|
queue_dir: Path
|
|
dispatch_dir: Path
|
|
state_dir: Path
|
|
logs_dir: Path
|
|
system_prompt: str
|
|
summarization_prompt: str
|
|
sessions: tuple[SessionConfig, ...] = field(default_factory=tuple)
|
|
|
|
|
|
def _ensure_runtime_dirs(repo_root: Path) -> None:
|
|
for sub in ("queue", "dispatch", "state", "logs"):
|
|
(repo_root / sub).mkdir(exist_ok=True)
|
|
|
|
|
|
def _generate_ntfy_topic() -> str:
|
|
"""Cryptographically random 16-character topic. Functionally a password."""
|
|
return secrets.token_urlsafe(12)
|
|
|
|
|
|
def _load_or_init_ntfy_topic(env_values: dict[str, str | None]) -> str:
|
|
raw = (env_values.get("NTFY_TOPIC") or "").strip()
|
|
# Defensive: dotenv treats everything after = as the value, so an
|
|
# inline `#` comment ends up as the topic. Treat any value that
|
|
# starts with `#` (or contains whitespace, which a real topic never
|
|
# does) as empty.
|
|
if raw and not raw.startswith("#") and " " not in raw:
|
|
return raw
|
|
topic = _generate_ntfy_topic()
|
|
set_key(str(ENV_FILE), "NTFY_TOPIC", topic, quote_mode="never")
|
|
return topic
|
|
|
|
|
|
def _load_yaml_config() -> dict:
|
|
if not CONFIG_FILE.exists():
|
|
default = {
|
|
"system_prompt": DEFAULT_SYSTEM_PROMPT,
|
|
"summarization_prompt": DEFAULT_SUMMARIZATION_PROMPT,
|
|
"sessions": [
|
|
{
|
|
"session_id": "session-1",
|
|
"working_dir": None,
|
|
"description": "Default CC session",
|
|
}
|
|
],
|
|
}
|
|
CONFIG_FILE.write_text(yaml.safe_dump(default, sort_keys=False))
|
|
return default
|
|
with CONFIG_FILE.open() as f:
|
|
return yaml.safe_load(f) or {}
|
|
|
|
|
|
def load_settings() -> Settings:
|
|
"""Read .env + config.yaml. Mutates .env on first run to record the ntfy topic."""
|
|
|
|
env_values = dotenv_values(ENV_FILE)
|
|
api_key = (
|
|
env_values.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or ""
|
|
).strip()
|
|
if not api_key:
|
|
raise RuntimeError(f"ANTHROPIC_API_KEY missing from {ENV_FILE}")
|
|
|
|
model = (env_values.get("ANTHROPIC_MODEL") or "claude-opus-4-7").strip()
|
|
status_port = int((env_values.get("STATUS_PORT") or "8765").strip())
|
|
history_char_cap = int((env_values.get("HISTORY_CHAR_CAP") or "400000").strip())
|
|
ntfy_topic = _load_or_init_ntfy_topic(env_values)
|
|
|
|
yaml_cfg = _load_yaml_config()
|
|
system_prompt = str(yaml_cfg.get("system_prompt") or DEFAULT_SYSTEM_PROMPT)
|
|
summarization_prompt = str(yaml_cfg.get("summarization_prompt") or DEFAULT_SUMMARIZATION_PROMPT)
|
|
sessions_cfg = yaml_cfg.get("sessions") or []
|
|
sessions = tuple(
|
|
SessionConfig(
|
|
session_id=str(s["session_id"]),
|
|
working_dir=s.get("working_dir"),
|
|
description=s.get("description"),
|
|
)
|
|
for s in sessions_cfg
|
|
)
|
|
|
|
_ensure_runtime_dirs(REPO_ROOT)
|
|
|
|
return Settings(
|
|
api_key=api_key,
|
|
model=model,
|
|
ntfy_topic=ntfy_topic,
|
|
status_port=status_port,
|
|
history_char_cap=history_char_cap,
|
|
repo_root=REPO_ROOT,
|
|
queue_dir=REPO_ROOT / "queue",
|
|
dispatch_dir=REPO_ROOT / "dispatch",
|
|
state_dir=REPO_ROOT / "state",
|
|
logs_dir=REPO_ROOT / "logs",
|
|
system_prompt=system_prompt,
|
|
summarization_prompt=summarization_prompt,
|
|
sessions=sessions,
|
|
)
|