"""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, )