This repository has been archived on 2026-05-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
risv3-relay/relay/config.py
ac 540b4f5b01 feat: relay daemon skeleton — queue, dispatch, conversation, ntfy (#1)
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>
2026-05-02 15:24:47 +00:00

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