stage-3: claude CLI wrapper with session management
Subprocess wrapper for Claude CLI with start/resume/clear sessions, personality system prompt, atomic session tracking. 38 new tests (89 total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
276
src/claude_session.py
Normal file
276
src/claude_session.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Claude CLI session manager for Echo-Core.
|
||||
|
||||
Wraps the Claude Code CLI to provide conversation management.
|
||||
Each Discord channel maps to at most one active Claude session,
|
||||
tracked in sessions/active.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants & configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
PERSONALITY_DIR = PROJECT_ROOT / "personality"
|
||||
SESSIONS_DIR = PROJECT_ROOT / "sessions"
|
||||
_SESSIONS_FILE = SESSIONS_DIR / "active.json"
|
||||
|
||||
VALID_MODELS = {"haiku", "sonnet", "opus"}
|
||||
DEFAULT_MODEL = "sonnet"
|
||||
DEFAULT_TIMEOUT = 120 # seconds
|
||||
|
||||
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
|
||||
|
||||
PERSONALITY_FILES = [
|
||||
"IDENTITY.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"AGENTS.md",
|
||||
"TOOLS.md",
|
||||
"HEARTBEAT.md",
|
||||
]
|
||||
|
||||
# Environment variables allowed through to the Claude subprocess
|
||||
_ENV_PASSTHROUGH = {
|
||||
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM",
|
||||
"SHELL", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level binary check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if not shutil.which(CLAUDE_BIN):
|
||||
logger.warning(
|
||||
"Claude CLI (%s) not found on PATH. "
|
||||
"Session functions will raise FileNotFoundError.",
|
||||
CLAUDE_BIN,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_env() -> dict[str, str]:
|
||||
"""Return a filtered copy of os.environ for subprocess calls."""
|
||||
return {k: v for k, v in os.environ.items() if k in _ENV_PASSTHROUGH}
|
||||
|
||||
|
||||
def _load_sessions() -> dict:
|
||||
"""Load sessions from active.json. Returns {} if missing or empty."""
|
||||
try:
|
||||
text = _SESSIONS_FILE.read_text(encoding="utf-8")
|
||||
if not text.strip():
|
||||
return {}
|
||||
return json.loads(text)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_sessions(data: dict) -> None:
|
||||
"""Atomically write sessions to active.json via tempfile + os.replace."""
|
||||
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=SESSIONS_DIR, prefix=".active_", suffix=".json"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
os.replace(tmp_path, _SESSIONS_FILE)
|
||||
except BaseException:
|
||||
# Clean up temp file on any failure
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _run_claude(cmd: list[str], timeout: int) -> dict:
|
||||
"""Run a Claude CLI command and return the parsed JSON output."""
|
||||
if not shutil.which(CLAUDE_BIN):
|
||||
raise FileNotFoundError(
|
||||
"Claude CLI not found. "
|
||||
"Install: https://docs.anthropic.com/en/docs/claude-code"
|
||||
)
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=_safe_env(),
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"Claude CLI timed out after {timeout}s")
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Claude CLI error (exit {proc.returncode}): "
|
||||
f"{proc.stderr[:500]}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Failed to parse Claude CLI output: {exc}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_system_prompt() -> str:
|
||||
"""Concatenate personality/*.md files into a single system prompt."""
|
||||
if not PERSONALITY_DIR.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"Personality directory not found: {PERSONALITY_DIR}"
|
||||
)
|
||||
|
||||
parts: list[str] = []
|
||||
for filename in PERSONALITY_FILES:
|
||||
filepath = PERSONALITY_DIR / filename
|
||||
if filepath.is_file():
|
||||
parts.append(filepath.read_text(encoding="utf-8"))
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
def start_session(
|
||||
channel_id: str,
|
||||
message: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> tuple[str, str]:
|
||||
"""Start a new Claude CLI session for a channel.
|
||||
|
||||
Returns (response_text, session_id).
|
||||
"""
|
||||
if model not in VALID_MODELS:
|
||||
raise ValueError(
|
||||
f"Invalid model '{model}'. Must be one of: haiku, sonnet, opus"
|
||||
)
|
||||
|
||||
system_prompt = build_system_prompt()
|
||||
|
||||
cmd = [
|
||||
CLAUDE_BIN, "-p", message,
|
||||
"--model", model,
|
||||
"--output-format", "json",
|
||||
"--system-prompt", system_prompt,
|
||||
]
|
||||
|
||||
data = _run_claude(cmd, timeout)
|
||||
|
||||
for field in ("result", "session_id"):
|
||||
if field not in data:
|
||||
raise RuntimeError(
|
||||
f"Claude CLI response missing required field: {field}"
|
||||
)
|
||||
|
||||
response_text = data["result"]
|
||||
session_id = data["session_id"]
|
||||
|
||||
# Save session metadata
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
sessions = _load_sessions()
|
||||
sessions[channel_id] = {
|
||||
"session_id": session_id,
|
||||
"model": model,
|
||||
"created_at": now,
|
||||
"last_message_at": now,
|
||||
"message_count": 1,
|
||||
}
|
||||
_save_sessions(sessions)
|
||||
|
||||
return response_text, session_id
|
||||
|
||||
|
||||
def resume_session(
|
||||
session_id: str,
|
||||
message: str,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> str:
|
||||
"""Resume an existing Claude session by ID. Returns response text."""
|
||||
cmd = [
|
||||
CLAUDE_BIN, "-p", message,
|
||||
"--resume", session_id,
|
||||
"--output-format", "json",
|
||||
]
|
||||
|
||||
data = _run_claude(cmd, timeout)
|
||||
|
||||
if "result" not in data:
|
||||
raise RuntimeError(
|
||||
"Claude CLI response missing required field: result"
|
||||
)
|
||||
|
||||
response_text = data["result"]
|
||||
|
||||
# Update session metadata
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
sessions = _load_sessions()
|
||||
for channel_id, session in sessions.items():
|
||||
if session.get("session_id") == session_id:
|
||||
session["last_message_at"] = now
|
||||
session["message_count"] = session.get("message_count", 0) + 1
|
||||
break
|
||||
_save_sessions(sessions)
|
||||
|
||||
return response_text
|
||||
|
||||
|
||||
def send_message(
|
||||
channel_id: str,
|
||||
message: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> str:
|
||||
"""High-level convenience: auto start or resume based on channel state."""
|
||||
session = get_active_session(channel_id)
|
||||
if session is not None:
|
||||
return resume_session(session["session_id"], message, timeout)
|
||||
response_text, _session_id = start_session(
|
||||
channel_id, message, model, timeout
|
||||
)
|
||||
return response_text
|
||||
|
||||
|
||||
def clear_session(channel_id: str) -> bool:
|
||||
"""Remove a channel's session entry. Returns True if removed."""
|
||||
sessions = _load_sessions()
|
||||
if channel_id not in sessions:
|
||||
return False
|
||||
del sessions[channel_id]
|
||||
_save_sessions(sessions)
|
||||
return True
|
||||
|
||||
|
||||
def get_active_session(channel_id: str) -> dict | None:
|
||||
"""Return session metadata for a channel, or None."""
|
||||
sessions = _load_sessions()
|
||||
return sessions.get(channel_id)
|
||||
|
||||
|
||||
def list_sessions() -> dict:
|
||||
"""Return all channel→session mappings from active.json."""
|
||||
return _load_sessions()
|
||||
Reference in New Issue
Block a user