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:
@@ -0,0 +1,77 @@
|
||||
"""Full-fidelity conversation export: interleave what was *said* (chat exchanges)
|
||||
with what Lyra *did* (tool calls) in chronological order.
|
||||
|
||||
The chat only ever lives in SQLite (`exchanges` + `tool_events`); this is the one
|
||||
place that renders a whole session back out as a portable artifact — Markdown for
|
||||
reading / pasting into RTO or another model, JSON for machine reprocessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from lyra import clock, memory
|
||||
|
||||
# How roles/actions are labeled in the Markdown transcript.
|
||||
_SPEAKER = {"user": "Brian", "assistant": "Lyra"}
|
||||
|
||||
|
||||
def _merged(session_id: str) -> list[dict]:
|
||||
"""Speech + actions for a session, merged oldest-first by wall-clock time."""
|
||||
events: list[dict] = []
|
||||
for e in memory.history(session_id):
|
||||
events.append({"type": "message", "role": e.role, "content": e.content,
|
||||
"ts": e.created_at})
|
||||
for t in memory.tool_events(session_id):
|
||||
events.append({"type": "tool", "tool": t["tool"], "args": t["args"],
|
||||
"result": t["result"], "ts": t["created_at"]})
|
||||
# created_at is an ISO string; lexicographic sort == chronological sort.
|
||||
events.sort(key=lambda ev: ev["ts"])
|
||||
return events
|
||||
|
||||
|
||||
def _fmt_args(args) -> str:
|
||||
"""Compact one-line rendering of a tool call's arguments."""
|
||||
if isinstance(args, dict):
|
||||
return ", ".join(f"{k}={json.dumps(v, default=str)}" for k, v in args.items())
|
||||
return "" if args is None else str(args)
|
||||
|
||||
|
||||
def as_markdown(session_id: str, name: str | None = None) -> str:
|
||||
events = _merged(session_id)
|
||||
title = name or session_id
|
||||
lines = [f"# Conversation — {title}",
|
||||
f"_Exported {clock.stamp()} · session `{session_id}` · "
|
||||
f"{len(events)} events_", ""]
|
||||
for ev in events:
|
||||
stamp = clock.short(ev["ts"])
|
||||
if ev["type"] == "message":
|
||||
who = _SPEAKER.get(ev["role"], ev["role"].capitalize())
|
||||
lines.append(f"**{who}** · {stamp}")
|
||||
lines.append((ev["content"] or "").rstrip())
|
||||
lines.append("")
|
||||
else:
|
||||
result = (ev["result"] or "").strip().replace("\n", " ")
|
||||
if len(result) > 200:
|
||||
result = result[:197] + "…"
|
||||
lines.append(f" ⚙ `{ev['tool']}({_fmt_args(ev['args'])})` → {result}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def as_json(session_id: str, name: str | None = None) -> dict:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"name": name,
|
||||
"exported_at": clock.stamp(),
|
||||
"events": _merged(session_id),
|
||||
}
|
||||
|
||||
|
||||
def build(session_id: str, fmt: str = "md", name: str | None = None):
|
||||
"""Return (content_str, media_type, filename) for the requested format."""
|
||||
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)[:60]
|
||||
if fmt == "json":
|
||||
body = json.dumps(as_json(session_id, name), indent=2, ensure_ascii=False)
|
||||
return body, "application/json", f"lyra_{safe}.json"
|
||||
body = as_markdown(session_id, name)
|
||||
return body, "text/markdown; charset=utf-8", f"lyra_{safe}.md"
|
||||
Reference in New Issue
Block a user