"""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"