Update main. #6

Merged
serversdown merged 70 commits from dev into main 2026-07-10 15:17:09 -04:00
6 changed files with 242 additions and 3 deletions
Showing only changes of commit a4412aa023 - Show all commits
+9 -2
View File
@@ -69,6 +69,7 @@ def _mind_loop(messages, backend: Backend, model: str | None, tool_specs,
messages.append(assistant_msg) messages.append(assistant_msg)
for tc in tool_calls: for tc in tool_calls:
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) 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]) 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}) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
_maybe_switch_mode(session_id, tc["name"]) _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 tool_specs = toolkit.specs(turn.mode.tools) if backend in TOOL_BACKENDS else None
ctx = {"session_id": session_id, "backend": backend} 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) reply, _ = _mind_loop(messages, backend, model, tool_specs, ctx, session_id)
mouth = _mouth_target(cfg, backend, model) mouth = _mouth_target(cfg, backend, model)
if mouth and reply: if mouth and reply:
@@ -107,7 +111,6 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
reply = _TANGLED reply = _TANGLED
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth)) 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) memory.remember(session_id, "assistant", reply)
summary.maybe_summarize_async(session_id) # compact once enough new turns pile up summary.maybe_summarize_async(session_id) # compact once enough new turns pile up
return reply 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} ctx = {"session_id": session_id, "backend": backend}
mouth = _mouth_target(cfg, backend, model) 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: if mouth is None:
# No separate voice: stream the mind directly (the original path, unchanged). # No separate voice: stream the mind directly (the original path, unchanged).
parts: list[str] = [] parts: list[str] = []
@@ -149,6 +156,7 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
messages.append(assistant_msg) messages.append(assistant_msg)
for tc in tool_calls: for tc in tool_calls:
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) 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]) 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}) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
_maybe_switch_mode(session_id, tc["name"]) _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) yield ("delta", reply)
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth)) 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) memory.remember(session_id, "assistant", reply)
summary.maybe_summarize_async(session_id) summary.maybe_summarize_async(session_id)
yield ("done", reply) yield ("done", reply)
+50
View File
@@ -29,6 +29,21 @@ CREATE TABLE IF NOT EXISTS exchanges (
); );
CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_at); 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 ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT, 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: def delete_session(session_id: str) -> None:
"""Remove a session and all its exchanges.""" """Remove a session and all its exchanges."""
conn = _connection() 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 exchanges WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE 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 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]: def recall(query: str, k: int = 5, session_id: str | None = None) -> list[Exchange]:
+77
View File
@@ -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
View File
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles 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 from lyra.llm import Backend
@@ -62,6 +62,15 @@ def create_app() -> FastAPI:
async def get_session(session_id: str) -> list[dict]: async def get_session(session_id: str) -> list[dict]:
return [{"role": ex.role, "content": ex.content} for ex in memory.history(session_id)] 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}") @app.post("/sessions/{session_id}")
async def save_session(session_id: str, request: Request) -> dict: async def save_session(session_id: str, request: Request) -> dict:
# Messages are already persisted by chat.respond; just ensure the row exists. # Messages are already persisted by chat.respond; just ensure the row exists.
+14
View File
@@ -88,6 +88,7 @@
<select id="sessions"></select> <select id="sessions"></select>
<button id="newSessionBtn"> New</button> <button id="newSessionBtn"> New</button>
<button id="renameSessionBtn">✏️ Rename</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> <button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
</div> </div>
@@ -987,6 +988,19 @@
addMessage("system", `Session renamed to: ${newName}`); 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 // Settings Modal
const settingsModal = document.getElementById("settingsModal"); const settingsModal = document.getElementById("settingsModal");
const settingsBtn = document.getElementById("settingsBtn"); const settingsBtn = document.getElementById("settingsBtn");
+82
View File
@@ -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") == []