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