Files
amc/amc_server/mixins/conversation.py
teernisse a7b2b3b902 refactor(server): extract amc_server package from monolithic script
Split the 860+ line bin/amc-server into a modular Python package:

  amc_server/
    __init__.py         - Package marker
    context.py          - Shared constants (DATA_DIR, PORT, CLAUDE_PROJECTS_DIR, etc.)
    handler.py          - AMCHandler class using mixin composition
    logging_utils.py    - Structured logging setup with signal handlers
    server.py           - Main entry point (ThreadingHTTPServer)
    mixins/
      __init__.py       - Mixin package marker
      control.py        - Session control (dismiss, respond via Zellij)
      conversation.py   - Conversation history parsing (Claude JSONL format)
      discovery.py      - Session discovery (Codex pane inspection, Zellij cache)
      http.py           - HTTP response helpers (CORS, JSON, static files)
      parsing.py        - Session state parsing and aggregation
      state.py          - Session state endpoint logic

The monolithic bin/amc-server becomes a thin launcher that just imports
and calls main(). This separation enables:

- Easier testing of individual components
- Better IDE support (proper Python package structure)
- Cleaner separation of concerns (discovery vs parsing vs control)
- ThreadingHTTPServer instead of single-threaded (handles concurrent requests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:01:26 -05:00

176 lines
7.6 KiB
Python

import json
import os
from amc_server.context import EVENTS_DIR
class ConversationMixin:
def _serve_events(self, session_id):
# Sanitize session_id to prevent path traversal
safe_id = os.path.basename(session_id)
event_file = EVENTS_DIR / f"{safe_id}.jsonl"
events = []
if event_file.exists():
try:
for line in event_file.read_text().splitlines():
if line.strip():
try:
events.append(json.loads(line))
except json.JSONDecodeError:
continue
except OSError:
pass
self._send_json(200, {"session_id": safe_id, "events": events})
def _serve_conversation(self, session_id, project_dir, agent="claude"):
"""Serve conversation history from Claude Code or Codex JSONL file."""
safe_id = os.path.basename(session_id)
messages = []
if agent == "codex":
messages = self._parse_codex_conversation(safe_id)
else:
messages = self._parse_claude_conversation(safe_id, project_dir)
self._send_json(200, {"session_id": safe_id, "messages": messages})
def _parse_claude_conversation(self, session_id, project_dir):
"""Parse Claude Code JSONL conversation format."""
messages = []
conv_file = self._get_claude_conversation_file(session_id, project_dir)
if conv_file and conv_file.exists():
try:
for line in conv_file.read_text().splitlines():
if not line.strip():
continue
try:
entry = json.loads(line)
if not isinstance(entry, dict):
continue
msg_type = entry.get("type")
if msg_type == "user":
content = entry.get("message", {}).get("content", "")
# Only include actual human messages (strings), not tool results (arrays)
if content and isinstance(content, str):
messages.append({
"role": "user",
"content": content,
"timestamp": entry.get("timestamp", ""),
})
elif msg_type == "assistant":
# Assistant messages have structured content
message = entry.get("message", {})
if not isinstance(message, dict):
continue
raw_content = message.get("content", [])
if not isinstance(raw_content, list):
continue
text_parts = []
tool_calls = []
thinking_parts = []
for part in raw_content:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
text_parts.append(part.get("text", ""))
elif ptype == "tool_use":
tool_calls.append({
"name": part.get("name", "unknown"),
"input": part.get("input", {}),
})
elif ptype == "thinking":
thinking_parts.append(part.get("thinking", ""))
elif isinstance(part, str):
text_parts.append(part)
if text_parts or tool_calls or thinking_parts:
msg = {
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else "",
"timestamp": entry.get("timestamp", ""),
}
if tool_calls:
msg["tool_calls"] = tool_calls
if thinking_parts:
msg["thinking"] = "\n\n".join(thinking_parts)
messages.append(msg)
except json.JSONDecodeError:
continue
except OSError:
pass
return messages
def _parse_codex_conversation(self, session_id):
"""Parse Codex JSONL conversation format."""
messages = []
conv_file = self._find_codex_transcript_file(session_id)
if conv_file and conv_file.exists():
try:
for line in conv_file.read_text().splitlines():
if not line.strip():
continue
try:
entry = json.loads(line)
if not isinstance(entry, dict):
continue
# Codex format: type="response_item", payload.type="message"
if entry.get("type") != "response_item":
continue
payload = entry.get("payload", {})
if not isinstance(payload, dict):
continue
if payload.get("type") != "message":
continue
role = payload.get("role", "")
content_parts = payload.get("content", [])
if not isinstance(content_parts, list):
continue
# Skip developer role (system context/permissions)
if role == "developer":
continue
# Extract text from content array
text_parts = []
for part in content_parts:
if isinstance(part, dict):
# Codex uses "input_text" for user, "output_text" for assistant
text = part.get("text", "")
if text:
# Skip injected context (AGENTS.md, environment, permissions)
skip_prefixes = (
"<INSTRUCTIONS>",
"<environment_context>",
"<permissions instructions>",
"# AGENTS.md instructions",
)
if any(text.startswith(p) for p in skip_prefixes):
continue
text_parts.append(text)
if text_parts and role in ("user", "assistant"):
messages.append({
"role": role,
"content": "\n".join(text_parts),
"timestamp": entry.get("timestamp", ""),
})
except json.JSONDecodeError:
continue
except OSError:
pass
return messages