diff --git a/lyra/chat.py b/lyra/chat.py index a67d43f..431e98b 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -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) diff --git a/lyra/memory.py b/lyra/memory.py index 0b9d633..7d361ad 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -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]: diff --git a/lyra/transcript.py b/lyra/transcript.py new file mode 100644 index 0000000..2d505d2 --- /dev/null +++ b/lyra/transcript.py @@ -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" diff --git a/lyra/web/server.py b/lyra/web/server.py index 921f21a..7af398b 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -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. diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index cc525fc..a0db8ab 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -88,6 +88,7 @@ + @@ -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"); diff --git a/tests/test_transcript.py b/tests/test_transcript.py new file mode 100644 index 0000000..81e5811 --- /dev/null +++ b/tests/test_transcript.py @@ -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") == []