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:
+9
-2
@@ -69,6 +69,7 @@ def _mind_loop(messages, backend: Backend, model: str | None, tool_specs,
|
||||
messages.append(assistant_msg)
|
||||
for tc in tool_calls:
|
||||
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
||||
memory.add_tool_event(session_id, tc["name"], tc["arguments"], result)
|
||||
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
||||
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
||||
_maybe_switch_mode(session_id, tc["name"])
|
||||
@@ -99,6 +100,9 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
tool_specs = toolkit.specs(turn.mode.tools) if backend in TOOL_BACKENDS else None
|
||||
ctx = {"session_id": session_id, "backend": backend}
|
||||
|
||||
# Persist the user turn before the tool loop so its timestamp precedes any
|
||||
# tool events fired mid-turn (keeps the transcript export in true order).
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
reply, _ = _mind_loop(messages, backend, model, tool_specs, ctx, session_id)
|
||||
mouth = _mouth_target(cfg, backend, model)
|
||||
if mouth and reply:
|
||||
@@ -107,7 +111,6 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
reply = _TANGLED
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
|
||||
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
memory.remember(session_id, "assistant", reply)
|
||||
summary.maybe_summarize_async(session_id) # compact once enough new turns pile up
|
||||
return reply
|
||||
@@ -128,6 +131,10 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
ctx = {"session_id": session_id, "backend": backend}
|
||||
mouth = _mouth_target(cfg, backend, model)
|
||||
|
||||
# Persist the user turn up front (see respond): keeps tool events, which fire
|
||||
# mid-turn, chronologically after the user message in the exported transcript.
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
|
||||
if mouth is None:
|
||||
# No separate voice: stream the mind directly (the original path, unchanged).
|
||||
parts: list[str] = []
|
||||
@@ -149,6 +156,7 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
messages.append(assistant_msg)
|
||||
for tc in tool_calls:
|
||||
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
||||
memory.add_tool_event(session_id, tc["name"], tc["arguments"], result)
|
||||
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
||||
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
||||
_maybe_switch_mode(session_id, tc["name"])
|
||||
@@ -177,7 +185,6 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
yield ("delta", reply)
|
||||
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
memory.remember(session_id, "assistant", reply)
|
||||
summary.maybe_summarize_async(session_id)
|
||||
yield ("done", reply)
|
||||
|
||||
@@ -29,6 +29,21 @@ CREATE TABLE IF NOT EXISTS exchanges (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_at);
|
||||
|
||||
-- Lyra's actions within a chat: one row per tool call she runs mid-turn. The
|
||||
-- exchanges table only holds what was *said* (user/assistant text); this holds
|
||||
-- what she *did* (record_hand, log_stack, ...) so a full transcript export can
|
||||
-- interleave speech and actions, and so "did the tool actually fire?" is
|
||||
-- answerable after the fact instead of only from ephemeral logs.
|
||||
CREATE TABLE IF NOT EXISTS tool_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
tool TEXT NOT NULL,
|
||||
args TEXT, -- JSON of the call arguments
|
||||
result TEXT, -- the tool's returned string
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_events_session ON tool_events(session_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
@@ -313,6 +328,40 @@ def history(session_id: str) -> list[Exchange]:
|
||||
]
|
||||
|
||||
|
||||
def add_tool_event(session_id: str, tool: str, args, result: str) -> int:
|
||||
"""Record one tool call Lyra ran in a chat turn. `args` is JSON-serialized
|
||||
(a dict or already-JSON string); `result` is the tool's returned string."""
|
||||
args_json = args if isinstance(args, str) else json.dumps(args, default=str)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = _connection()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tool_events (session_id, tool, args, result, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(session_id, tool, args_json, result, now),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def tool_events(session_id: str) -> list[dict]:
|
||||
"""All tool calls for a session, oldest first. args is parsed back to an object."""
|
||||
conn = _connection()
|
||||
rows = conn.execute(
|
||||
"SELECT id, session_id, tool, args, result, created_at FROM tool_events "
|
||||
"WHERE session_id = ? ORDER BY id ASC",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["args"] = json.loads(d["args"]) if d["args"] else {}
|
||||
except (TypeError, ValueError):
|
||||
pass # leave as the raw string if it wasn't JSON
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def delete_session(session_id: str) -> None:
|
||||
"""Remove a session and all its exchanges."""
|
||||
conn = _connection()
|
||||
@@ -320,6 +369,7 @@ def delete_session(session_id: str) -> None:
|
||||
conn.execute("DELETE FROM exchanges WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM summaries WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM tool_events WHERE session_id = ?", (session_id,))
|
||||
|
||||
|
||||
def recall(query: str, k: int = 5, session_id: str | None = None) -> list[Exchange]:
|
||||
|
||||
@@ -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"
|
||||
+10
-1
@@ -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.
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
<select id="sessions"></select>
|
||||
<button id="newSessionBtn">➕ New</button>
|
||||
<button id="renameSessionBtn">✏️ Rename</button>
|
||||
<button id="exportSessionBtn" title="Download full transcript (chat + tool calls)">⬇ Export</button>
|
||||
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
|
||||
</div>
|
||||
|
||||
@@ -987,6 +988,19 @@
|
||||
addMessage("system", `Session renamed to: ${newName}`);
|
||||
});
|
||||
|
||||
document.getElementById("exportSessionBtn").addEventListener("click", () => {
|
||||
if (!currentSession) { addMessage("system", "No session to export."); return; }
|
||||
const fmt = window.confirm("Export as Markdown? (Cancel = JSON)") ? "md" : "json";
|
||||
// Hitting the download endpoint navigates a hidden anchor so the browser
|
||||
// saves the file (chat + interleaved tool calls) instead of rendering it.
|
||||
const a = document.createElement("a");
|
||||
a.href = `${RELAY_BASE}/sessions/${encodeURIComponent(currentSession)}/export?format=${fmt}`;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
|
||||
// Settings Modal
|
||||
const settingsModal = document.getElementById("settingsModal");
|
||||
const settingsBtn = document.getElementById("settingsBtn");
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Conversation export: speech (exchanges) + actions (tool_events) merged in order."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _const_embed(texts):
|
||||
return [[1e-6] * 8 for _ in texts]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mods(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", _const_embed)
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
import lyra.transcript as transcript
|
||||
importlib.reload(transcript)
|
||||
return memory, transcript
|
||||
|
||||
|
||||
def _seed(memory):
|
||||
"""A turn where Brian narrates a hand and Lyra logs it, then replies."""
|
||||
memory.ensure_session("s1", name="Meadows 1/3")
|
||||
memory.remember("s1", "user", "got it in with a set, he had the flush draw and bricked")
|
||||
memory.add_tool_event("s1", "record_hand", {"result": "won", "pot": 750}, "hand #42 logged")
|
||||
memory.add_tool_event("s1", "log_stack", {"amount": 750, "note": "doubled up"}, "ok")
|
||||
memory.remember("s1", "assistant", "Clean stack-off — logged it to your timeline.")
|
||||
|
||||
|
||||
def test_tool_events_roundtrip_parses_args(mods):
|
||||
memory, _ = mods
|
||||
_seed(memory)
|
||||
events = memory.tool_events("s1")
|
||||
assert [e["tool"] for e in events] == ["record_hand", "log_stack"]
|
||||
assert events[0]["args"] == {"result": "won", "pot": 750} # parsed back to a dict
|
||||
assert events[1]["result"] == "ok"
|
||||
|
||||
|
||||
def test_markdown_interleaves_speech_and_actions_in_order(mods):
|
||||
memory, transcript = mods
|
||||
_seed(memory)
|
||||
md = transcript.as_markdown("s1", name="Meadows 1/3")
|
||||
# user message, then both tool calls, then assistant reply — in that order
|
||||
i_user = md.index("got it in with a set")
|
||||
i_hand = md.index("record_hand")
|
||||
i_stack = md.index("log_stack")
|
||||
i_reply = md.index("Clean stack-off")
|
||||
assert i_user < i_hand < i_stack < i_reply
|
||||
assert "**Brian**" in md and "**Lyra**" in md
|
||||
assert "⚙" in md
|
||||
|
||||
|
||||
def test_json_export_is_machine_readable(mods):
|
||||
memory, transcript = mods
|
||||
_seed(memory)
|
||||
payload = transcript.as_json("s1", name="Meadows 1/3")
|
||||
assert payload["session_id"] == "s1"
|
||||
types = [e["type"] for e in payload["events"]]
|
||||
assert types == ["message", "tool", "tool", "message"]
|
||||
json.dumps(payload) # must be serializable
|
||||
|
||||
|
||||
def test_build_returns_filename_and_media_type(mods):
|
||||
memory, transcript = mods
|
||||
_seed(memory)
|
||||
body_md, mt_md, fn_md = transcript.build("s1", "md", "Meadows 1/3")
|
||||
body_js, mt_js, fn_js = transcript.build("s1", "json", "Meadows 1/3")
|
||||
assert fn_md.endswith(".md") and "markdown" in mt_md
|
||||
assert fn_js.endswith(".json") and mt_js == "application/json"
|
||||
assert body_md and body_js
|
||||
|
||||
|
||||
def test_delete_session_clears_tool_events(mods):
|
||||
memory, _ = mods
|
||||
_seed(memory)
|
||||
memory.delete_session("s1")
|
||||
assert memory.tool_events("s1") == []
|
||||
Reference in New Issue
Block a user