feat: full-fidelity conversation export (chat + tool calls)

Chat only ever lived in SQLite's `exchanges` table (what was *said*); tool
calls were transient — logged to the in-memory ring buffer and gone at
end-of-turn. This adds a persistent record of what Lyra *did* and exports the
two merged into one transcript.

- memory: new `tool_events` table + `add_tool_event`/`tool_events` accessors;
  `delete_session` cascades to it.
- chat: persist each tool call (name/args/result) right where it fires, in both
  the non-stream and stream paths. Move the user `remember` to just after
  assembly so its timestamp precedes mid-turn tool events — keeps the export in
  true chronological order (and records when the message actually arrived).
- transcript: new module renders a session as Markdown (Brian/Lyra speech with
  ⚙ tool-call lines interleaved) or JSON (machine-readable event stream).
- server: GET /sessions/{id}/export?format=md|json (attachment download).
- ui: ⬇ Export button by the session selector (Markdown or JSON).

Doubles as the receipt for "did the tool actually fire?" — the thing that was
invisible when she'd reply about a hand without logging it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 19:22:03 +00:00
parent 3afa75f4be
commit a4412aa023
6 changed files with 242 additions and 3 deletions
+10 -1
View File
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts
from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts, transcript
from lyra.llm import Backend
@@ -62,6 +62,15 @@ def create_app() -> FastAPI:
async def get_session(session_id: str) -> list[dict]:
return [{"role": ex.role, "content": ex.content} for ex in memory.history(session_id)]
@app.get("/sessions/{session_id}/export")
async def export_session(session_id: str, format: str = "md") -> Response:
"""Full transcript — chat + interleaved tool calls — as Markdown or JSON."""
name = next((s["name"] for s in memory.list_sessions() if s["id"] == session_id), None)
body, media_type, filename = await asyncio.to_thread(
transcript.build, session_id, format, name)
return Response(content=body, media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'})
@app.post("/sessions/{session_id}")
async def save_session(session_id: str, request: Request) -> dict:
# Messages are already persisted by chat.respond; just ensure the row exists.