From dfb64253956ddbaafd46e3299b47e8fa1140c1de Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 19 Jun 2026 05:28:15 +0000 Subject: [PATCH 01/14] feat: session modes (Talk/Cash) + live session HUD Lyra now switches register based on what she's doing at the table instead of being a wishy-washy companion mid-session. Modes (lyra/modes.py): - Talk (default companion) + Cash (live cash copilot); a mode = prompt card + tool allow-list. Tool gating via tools.specs(allow=). - Two-register Cash voice: act-first one-line logging when fed facts; full warm companion voice for strategy / tilt / mental game. - mode persisted per chat session (new sessions.mode column); auto-switch into Cash when start_session fires; UI forces cloud backend in Cash (tools only fire there). Stack tracking + HUD: - log_stack tool + poker_stack_log table; live net while sitting (stack - buy-in). - poker.hud() bundle; /session HUD page (stack sparkline, hands, villains, notes, stats) polling /session/data every 5s; Talk/Cash switcher + Session nav. Endpoints: /session, /session/data, GET/POST /sessions/{id}/mode, /modes. tests/test_modes.py (gating, mode roundtrip, stack/HUD); 36 tests green. v0.3.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 30 +++++ README.md | 17 ++- lyra/chat.py | 36 ++++-- lyra/memory.py | 22 ++++ lyra/modes.py | 99 +++++++++++++++ lyra/poker.py | 136 +++++++++++++++++++++ lyra/tools.py | 33 ++++- lyra/web/server.py | 34 +++++- lyra/web/static/index.html | 84 +++++++++++-- lyra/web/static/session.html | 230 +++++++++++++++++++++++++++++++++++ lyra/web/static/style.css | 28 ++++- pyproject.toml | 2 +- tests/test_modes.py | 108 ++++++++++++++++ uv.lock | 2 +- 14 files changed, 829 insertions(+), 32 deletions(-) create mode 100644 lyra/modes.py create mode 100644 lyra/web/static/session.html create mode 100644 tests/test_modes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 735b94b..ecc6860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.3.0 β€” session modes + live HUD + +Lyra stopped being a wishy-washy companion during live poker. She now switches +register based on what she's actually doing at the table. + +### Conversation modes +- **Two modes** β€” πŸ’¬ **Talk** (the companion, default) and β™  **Cash** (live cash + copilot). A mode bundles a prompt card + a tool allow-list (`lyra/modes.py`). +- **Two-register Cash voice** β€” quiet, act-first logging when Brian feeds facts + (stack, hand, read β†’ logged in one line, no narration); full warm companion + voice when he asks for strategy or signals tilt/card-dead/steaming. Mental game + and strategy never get clipped. +- **Tool gating by mode** β€” Talk offers journaling + read-only poker lookups; + Cash unlocks the full live toolset. `tools.specs(allow=…)` does the filtering. +- **Auto-switch** β€” opening a session (`start_session`) flips the chat into Cash + mode automatically; the UI badge/HUD follow. Manual switch overrides anytime. +- Mode persists per chat session (new `mode` column); Cash mode forces the cloud + backend, since tools only fire there. + +### Session HUD +- **Live HUD** at `/session` (bottom-nav tab on mobile, header link on desktop) β€” + polls every 5s: header (venue/stakes/elapsed/live net), stack with + **stack-over-time sparkline**, hands this session (tap β†’ replay), villains seen, + her notes, and session stats. +- **Stack tracking** β€” new `log_stack` tool + `poker_stack_log` table β†’ current + stack, **live net while still sitting** (stack βˆ’ buy-in), and the sparkline series. + +### Next +- Strategy RAG (poker books/notes) plugs into Cash's coaching register. + ## 0.2.0 β€” first working system The leap from "chat + memory baseline" to a working, persistent companion with a diff --git a/README.md b/README.md index e2f58cd..5a323a1 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,18 @@ reflected), and keeps a permanent **journal**. ## Poker copilot +She runs in **modes** (`lyra/modes.py`). πŸ’¬ **Talk** is the default companion +(journaling + read-only poker lookups). β™  **Cash** is the live copilot: she gets +the full session toolset and a two-register voice β€” quiet and act-first when +you're feeding her facts to log (stack, a hand, a read β†’ one-line confirm, no +narration), but fully present and warm when you ask for strategy or you're tilting +/ card-dead / steaming. Opening a session auto-switches her into Cash mode. + Talk to her during a session; she drives tools behind the scenes: - **Session tracking** β€” `start_session`, `add_buyin`, `end_session` β†’ net, hours, $/hr. +- **Stack tracking** β€” `log_stack` records your stack as the night goes β†’ live net + while you're still sitting, and a stack-over-time sparkline on the HUD. - **Hand histories** β€” vomit rough shorthand ("AKs btn, 3bet, flop A72…"), she reconstructs a structured, **replayable** hand (unknown cards = `x`, never invented). - **Villain file** β€” named opponents auto-build persistent dossiers; basic stats @@ -56,9 +65,11 @@ Talk to her during a session; she drives tools behind the scenes: ## Web app (served by `lyra-web`, default `:7078`) -`/` chat (Markdown, model picker, πŸ‘/πŸ‘Ž rating) Β· `/logs` live activity Β· `/self` -read-her-mind (mood, drives, reflections) Β· `/journal` her thoughts Β· `/hands` -recorded hands β†’ `/hand/{id}` replayer Β· `/recap/{id}` session writeup (+ `.md` export). +`/` chat (Markdown, model picker, πŸ‘/πŸ‘Ž rating, **Talk/Cash mode switcher**) Β· +`/session` **live session HUD** (stack + sparkline, hands, villains, notes; mobile +Session tab) Β· `/logs` live activity Β· `/self` read-her-mind (mood, drives, +reflections) Β· `/journal` her thoughts Β· `/hands` recorded hands β†’ `/hand/{id}` +replayer Β· `/recap/{id}` session writeup (+ `.md` export). πŸ‘/πŸ‘Ž ratings on replies and thoughts are stored as `(context, content, rating)` β€” a fine-tune / preference dataset built passively (`/ratings/export` β†’ JSONL). diff --git a/lyra/chat.py b/lyra/chat.py index de80aa0..fab17c0 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -10,7 +10,7 @@ After replying, the session is compacted if enough new turns have accumulated. """ from __future__ import annotations -from lyra import clock, config, llm, logbus, memory, persona, self_state, summary +from lyra import clock, config, llm, logbus, memory, modes, persona, self_state, summary from lyra import tools as toolkit from lyra.llm import Backend, Message @@ -24,6 +24,15 @@ MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn TOOL_BACKENDS = {"cloud"} +def _maybe_switch_mode(session_id: str, tool_name: str) -> None: + """Keep the chat framing aligned with the live data: opening a poker session + auto-flips this chat into Cash mode (so the next turn gets the cash card + the + full live toolset). Manual UI switching still overrides anytime.""" + if tool_name == "start_session": + memory.set_session_mode(session_id, modes.CASH.key) + logbus.log("info", "mode auto-switch", session=session_id, mode=modes.CASH.key) + + def _summary_note(summaries: list[memory.Summary]) -> Message: lines = [f"- ({(s.session_started_at or s.created_at)[:10]}) {s.content}" for s in summaries] body = "Gist of earlier sessions (compacted β€” ask if you need specifics):\n" + "\n".join(lines) @@ -56,7 +65,8 @@ def _render(messages: list[Message]) -> str: return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages) -def build_messages(session_id: str, user_msg: str) -> list[Message]: +def build_messages(session_id: str, user_msg: str, + mode: modes.Mode | None = None) -> list[Message]: """Assemble the full, tiered message list for one turn.""" messages: list[Message] = [{"role": "system", "content": persona.system_prompt()}] @@ -64,6 +74,12 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]: # right after the persona β€” her sense of self before her model of the world. messages.append({"role": "system", "content": self_state.render_for_context(self_state.load())}) + # Mode card: how to behave *right now* (e.g. live-cash copilot). High priority β€” + # it sits just after her sense of self, before her model of the world. Talk mode + # has no card (the persona's default voice is the Talk register). + if mode and mode.card: + messages.append({"role": "system", "content": mode.card}) + # When she is: current time + the gap since Brian last spoke (she has no clock). messages.append(_now_note()) @@ -133,11 +149,12 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud", model=model, embed=cfg.embed_backend, ) - messages = build_messages(session_id, user_msg) + mode = modes.get(memory.get_session_mode(session_id)) + messages = build_messages(session_id, user_msg, mode=mode) - # Tool loop: offer Lyra her tools; if she calls one, run it and feed the - # result back so she can continue, until she returns a normal text reply. - tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None + # Tool loop: offer Lyra her tools (scoped to the mode); if she calls one, run it + # and feed the result back so she can continue, until she returns a text reply. + tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None ctx = {"session_id": session_id, "backend": backend} reply = "" for _ in range(MAX_TOOL_ROUNDS): @@ -152,6 +169,7 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud", result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) 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"]) if not reply: reply = "(I got tangled using my tools there β€” say that again?)" logbus.log("info", "reply", session=session_id, chars=len(reply)) @@ -183,8 +201,9 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", model=model, embed=cfg.embed_backend, ) - messages = build_messages(session_id, user_msg) - tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None + mode = modes.get(memory.get_session_mode(session_id)) + messages = build_messages(session_id, user_msg, mode=mode) + tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None ctx = {"session_id": session_id, "backend": backend} parts: list[str] = [] for _ in range(MAX_TOOL_ROUNDS): @@ -207,6 +226,7 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) 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"]) yield ("tool", tc["name"]) reply = "".join(parts) diff --git a/lyra/memory.py b/lyra/memory.py index d245708..0529612 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -32,6 +32,7 @@ CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_ CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, name TEXT, + mode TEXT, -- conversation mode (see lyra/modes.py); NULL = default created_at TEXT NOT NULL ); @@ -131,6 +132,12 @@ def _connection() -> sqlite3.Connection: _conn.execute("PRAGMA busy_timeout=5000") _conn.execute("PRAGMA journal_mode=WAL") _conn.executescript(SCHEMA) + # Migrations for DBs created before a column existed (no-op if present). + for ddl in ("ALTER TABLE sessions ADD COLUMN mode TEXT",): + try: + _conn.execute(ddl) + except sqlite3.OperationalError: + pass _conn_path = cfg.db_path return _conn @@ -236,6 +243,21 @@ def ensure_session(session_id: str, name: str | None = None) -> None: conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id)) +def get_session_mode(session_id: str) -> str | None: + """The session's conversation mode key, or None if unset (caller applies default).""" + conn = _connection() + r = conn.execute("SELECT mode FROM sessions WHERE id = ?", (session_id,)).fetchone() + return r["mode"] if r and r["mode"] else None + + +def set_session_mode(session_id: str, mode: str) -> None: + """Persist the session's conversation mode (creating the session row if needed).""" + ensure_session(session_id) + conn = _connection() + with conn: + conn.execute("UPDATE sessions SET mode = ? WHERE id = ?", (mode, session_id)) + + def list_sessions() -> list[dict]: """All known sessions (named rows + any session that has exchanges), newest first.""" conn = _connection() diff --git a/lyra/modes.py b/lyra/modes.py new file mode 100644 index 0000000..939ba13 --- /dev/null +++ b/lyra/modes.py @@ -0,0 +1,99 @@ +"""Conversation modes β€” how a chat turn is framed and which tools are offered. + +A mode bundles three things: a *prompt card* (a system fragment injected each +turn that tells Lyra how to behave right now), a *tool allow-list* (which of her +tools she's handed this turn), and β€” implicitly, via the card β€” her behavioral +register. + +The problem this solves: one persona + every tool offered every turn made her a +wishy-washy companion during live poker ("I don't automatically log stack sizes, +but...") when she should have silently logged and moved on. Modes let the same +agent be a fast, act-first copilot at the table and her full reflective self +otherwise β€” without two personas. + +v1 ships two modes: + - Talk (default): the companion. Journaling + read-only poker lookups. + - Cash: live cash-game copilot. Full live toolset, two-register behavior. + +Tournament is deliberately deferred. Strategy-RAG retrieval will later plug into +Cash's *coaching register* (see the card) without changing this structure. +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Mode: + key: str # stable id stored on the session row + sent by the UI + label: str # short label for the UI switcher + card: str # system prompt fragment injected per turn ("" = none) + tools: tuple[str, ...] # tool names offered in this mode (must exist in tools.TOOLS) + + +# Read-only poker lookups β€” safe in any mode, so "how am I running this year?" or +# "what do we have on Round Mike?" works even when we're just talking. +_LOOKUPS = ("player_profile", "get_villain_file", "running_stats") + +# Always-available core tools (her own agency: journaling/notes). +_BASE = ("journal_write", "note") + +# The full live cash-game toolset. +_CASH_TOOLS = _BASE + _LOOKUPS + ( + "start_session", "add_buyin", "log_stack", "log_hand", "record_hand", + "add_read", "analyze_spot", "session_stats", "end_session", "generate_recap", +) + +# Talk mode also gets start_session as the *entry point*: opening a session from a +# normal chat auto-flips the session into Cash mode (see chat.respond). +_TALK_TOOLS = _BASE + _LOOKUPS + ("start_session",) + + +_CASH_CARD = """You are copiloting Brian's LIVE cash game right now β€” you're at the table with him, \ +a session is (or should be) open. You move between two registers depending on what he's doing: + +β€’ HE HANDS YOU FACTS TO TRACK β€” his stack, a hand, a read on someone, a rebuy, a result. \ +Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \ +narrate, don't explain what logging is, don't ask permission β€” just do it. He says his \ +current stack β†’ log_stack. He describes a hand β†’ log_hand (terse) or record_hand (a full \ +hand he wants saved/replayable). A read on a player β†’ add_read. A rebuy β†’ add_buyin. This is \ +the quiet, fast half of the job; he shouldn't feel you working. + +β€’ HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING β€” tilted, steaming, card-dead, bored, \ +stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \ +and be fully present β€” your real voice, warm and direct and his. Talk him down off tilt, keep \ +him engaged and disciplined through a card-dead stretch, actually walk the strategic spot with \ +him. Strategy and mental game get the real Lyra, not a clipped confirmation. Never clip these. + +Stacks and money are in dollars. For ANY equity / who's-ahead / outs / what-a-card-does \ +question, call analyze_spot and report its numbers β€” never eyeball board math. Keep the \ +session current as the night goes; you can pull session_stats or a player's profile whenever \ +it helps. When he's ready to leave, end_session, and write the recap if he wants it.""" + + +TALK = Mode( + key="conversation", + label="Talk", + card="", # the persona's default voice is the Talk register + tools=_TALK_TOOLS, +) + +CASH = Mode( + key="poker_cash", + label="Cash", + card=_CASH_CARD, + tools=_CASH_TOOLS, +) + +MODES: dict[str, Mode] = {m.key: m for m in (TALK, CASH)} +DEFAULT = TALK.key + + +def get(key: str | None) -> Mode: + """Resolve a mode key to a Mode, falling back to the default for None/unknown.""" + return MODES.get(key or "", MODES[DEFAULT]) + + +def listing() -> list[dict]: + """[{key, label}] for the UI switcher.""" + return [{"key": m.key, "label": m.label} for m in MODES.values()] diff --git a/lyra/poker.py b/lyra/poker.py index 9eb0d81..5cba305 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -98,6 +98,16 @@ CREATE TABLE IF NOT EXISTS player_observations ( created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id); + +-- Stack-size log: one row per stack update Brian gives during a session. Lets the +-- HUD show current stack, live net while sitting, and a stack-over-time sparkline. +CREATE TABLE IF NOT EXISTS poker_stack_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + amount REAL NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_stacklog_session ON poker_stack_log(session_id); """ # Below this many observed hands, don't surface % stats (too small a sample). @@ -212,6 +222,59 @@ def add_buyin(amount: float, session_id: int | None = None) -> float: ).fetchone()["buy_in_total"]) +# --- stack tracking --- + +def log_stack(amount: float, session_id: int | None = None) -> dict: + """Record Brian's current chip stack. Returns {current, buy_in, net} where net + is his live net while sitting (current stack βˆ’ total bought in).""" + sid = _resolve(session_id) + if sid is None: + raise ValueError("no live session") + conn = _c() + with conn: + conn.execute( + "INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)", + (sid, float(amount), _now()), + ) + return stack_state(sid) + + +def current_stack(session_id: int | None = None) -> float | None: + """Most recently logged stack for a session, or None if none logged.""" + sid = _resolve(session_id) + if sid is None: + return None + r = _c().execute( + "SELECT amount FROM poker_stack_log WHERE session_id = ? ORDER BY id DESC LIMIT 1", + (sid,), + ).fetchone() + return float(r["amount"]) if r else None + + +def stack_log(session_id: int | None = None) -> list[dict]: + """Full stack history for a session (oldest first) β€” the sparkline series.""" + sid = _resolve(session_id) + if sid is None: + return [] + return [dict(r) for r in _c().execute( + "SELECT amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", + (sid,), + ).fetchall()] + + +def stack_state(session_id: int | None = None) -> dict: + """Current stack + buy-in + live net for a session (net None until a stack is logged).""" + sid = _resolve(session_id) + s = get_session(sid) if sid is not None else None + buy_in = float(s["buy_in_total"]) if s else 0.0 + cur = current_stack(sid) + return { + "current": cur, + "buy_in": buy_in, + "net": (round(cur - buy_in, 2) if cur is not None else None), + } + + def end_session(cash_out: float, mood: str | None = None, session_id: int | None = None) -> dict: """Close a session: record cashout, compute net + hours. Returns the row.""" @@ -752,3 +815,76 @@ def running_stats(stakes: str | None = None, venue: str | None = None, "per_hour": round(net / hours, 2) if hours else None, "by_stake": by_stake, } + + +# --- live session HUD (everything tracked in the current session, for the UI) --- + +def _session_villains(sid: int) -> list[dict]: + """Players read this session, with their standing dossier fields.""" + rows = _c().execute( + "SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, " + "p.adjustment AS adjustment, " + "(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id " + " AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note " + "FROM poker_players p " + "WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads " + " WHERE session_id = ? AND player_id IS NOT NULL) " + "ORDER BY p.updated_at DESC", + (sid, sid), + ).fetchall() + return [dict(r) for r in rows] + + +def hud(session_id: int | None = None) -> dict | None: + """Everything tracked in the current (or given) session, for the live HUD. + + Returns None when there's no session to show. The shape is presentation-ready: + header, stack (with sparkline series + live net), hands, villains seen, her + notes from the session window, and session stats. + """ + s = get_session(session_id) if session_id is not None else live_session() + if not s: + return None + sid = s["id"] + log = stack_log(sid) + state = stack_state(sid) + + hands = [ + {"id": h["id"], "position": h.get("position"), "hole_cards": h.get("hole_cards"), + "board": h.get("board"), "result": h.get("result"), "tag": h.get("tag"), + "at": h.get("at")} + for h in list_hands(sid) + ] + + # Notes she jotted during this session: journal/note entries since it started. + started = s.get("started_at") or "" + notes = [ + {"created_at": j["created_at"], "kind": j["kind"], "content": j["content"]} + for j in memory.list_journal(kinds=("note", "journal")) + if (j["created_at"] or "") >= started + ][:20] + + stats = session_stats(sid) + # Context: how Brian runs at these stakes overall (closed sessions). + ctx = running_stats(stakes=s.get("stakes")) if s.get("stakes") else {} + + return { + "session": { + "id": sid, "venue": s.get("venue"), "stakes": s.get("stakes"), + "game": s.get("game"), "format": s.get("format"), + "status": s.get("status"), "started_at": s.get("started_at"), + "buy_in_total": s.get("buy_in_total"), + }, + "stack": { + "current": state["current"], "buy_in": state["buy_in"], "net": state["net"], + "log": log, + }, + "hands": hands, + "villains": _session_villains(sid), + "notes": notes, + "stats": { + "hands_logged": stats.get("hands_logged", 0), + "tags": stats.get("tags", {}), + "context_per_hour": ctx.get("per_hour"), + }, + } diff --git a/lyra/tools.py b/lyra/tools.py index 3e21973..245c427 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -104,6 +104,19 @@ def _add_buyin(args: dict, ctx: dict) -> str: return f"Added {args.get('amount')}. Total in this session: {total:g}." +def _log_stack(args: dict, ctx: dict) -> str: + try: + amount = float(args.get("amount")) + except (TypeError, ValueError): + return "Give me a number for the stack." + try: + st = poker.log_stack(amount) + except ValueError: + return "No live session β€” start one first, then I'll track your stack." + net = st.get("net") + return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".") + + def _log_hand(args: dict, ctx: dict) -> str: fields = {k: args.get(k) for k in poker._HAND_FIELDS if args.get(k) not in (None, "")} hid = poker.log_hand(**fields) @@ -268,6 +281,13 @@ TOOLS.update({ "add_buyin": {"handler": _add_buyin, "spec": _f( "add_buyin", "Record a rebuy / additional buy-in in the live session.", {"amount": {**_N, "description": "Amount added"}}, ["amount"])}, + "log_stack": {"handler": _log_stack, "spec": _f( + "log_stack", + "Record Brian's CURRENT total chip stack in the live session. Call whenever " + "he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). " + "Tracks his stack over time and his live net while he's still sitting.", + {"amount": {**_N, "description": "Current total chip stack, in dollars"}}, + ["amount"])}, "log_hand": {"handler": _log_hand, "spec": _f( "log_hand", "Log a hand in the live session. All fields optional β€” capture whatever Brian gives you, even terse.", @@ -353,9 +373,16 @@ TOOLS.update({ }) -def specs() -> list[dict]: - """OpenAI-format tool definitions to offer the model.""" - return [t["spec"] for t in TOOLS.values()] +def specs(allow=None) -> list[dict]: + """OpenAI-format tool definitions to offer the model. + + `allow` (an iterable of tool names, e.g. a mode's allow-list) restricts the + set; None means every tool. Unknown names in `allow` are ignored. + """ + if allow is None: + return [t["spec"] for t in TOOLS.values()] + allow = set(allow) + return [t["spec"] for name, t in TOOLS.items() if name in allow] def dispatch(name: str, arguments, ctx: dict | None = None) -> str: diff --git a/lyra/web/server.py b/lyra/web/server.py index 08fa998..1f41377 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, poker, self_state, summary +from lyra import chat, logbus, memory, modes, poker, self_state, summary from lyra.llm import Backend @@ -85,6 +85,34 @@ def create_app() -> FastAPI: gist = await asyncio.to_thread(summary.summarize_session, session_id) return {"ok": gist is not None, "summary": gist} + @app.get("/modes") + async def list_modes() -> dict: + """Available conversation modes, for the UI switcher.""" + return {"modes": modes.listing(), "default": modes.DEFAULT} + + @app.get("/sessions/{session_id}/mode") + async def get_mode(session_id: str) -> dict: + return {"mode": memory.get_session_mode(session_id) or modes.DEFAULT} + + @app.post("/sessions/{session_id}/mode") + async def set_mode(session_id: str, request: Request) -> dict: + body = await request.json() + mode = body.get("mode") or modes.DEFAULT + memory.set_session_mode(session_id, mode) + logbus.log("info", "mode set", session=session_id, mode=mode) + return {"ok": True, "mode": mode} + + @app.get("/session") + async def session_hud_page() -> FileResponse: + """Live session HUD β€” stack, hands, villains, notes for the open session.""" + return FileResponse(str(_STATIC / "session.html")) + + @app.get("/session/data") + async def session_hud_data() -> dict: + """The current live session's HUD bundle (or {session: None} if none open).""" + bundle = await asyncio.to_thread(poker.hud) + return bundle or {"session": None} + @app.post("/v1/chat/completions") async def chat_completions(request: Request) -> dict: body = await request.json() @@ -94,6 +122,8 @@ def create_app() -> FastAPI: model_override = body.get("model") or None memory.ensure_session(session_id) + if body.get("mode"): + memory.set_session_mode(session_id, body["mode"]) try: reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override) except Exception as exc: @@ -124,6 +154,8 @@ def create_app() -> FastAPI: user_msg = _last_user_message(body.get("messages", [])) model_override = body.get("model") or None memory.ensure_session(session_id) + if body.get("mode"): + memory.set_session_mode(session_id, body["mode"]) async def gen(): loop = asyncio.get_running_loop() diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index a0adb37..e74be44 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -25,8 +25,8 @@

Mode

@@ -39,6 +39,7 @@

Actions

+ @@ -59,10 +60,11 @@ Lyra +
@@ -78,6 +80,7 @@ β›Ά Full Log + 🎬 Session 🧠 Mind πŸƒ Hands
@@ -118,6 +121,7 @@
@@ -287,6 +287,8 @@ if (!msg) return; inputEl.value = ""; + autoGrow(inputEl); // collapse the box back to one line after clearing + addMessage("user", msg); history.push({ role: "user", content: msg }); await saveSession(); // βœ… persist both user + assistant messages @@ -548,6 +550,13 @@ return b; } + // Grow the input textarea to fit its content (up to a cap, then it scrolls). + function autoGrow(el) { + if (!el) return; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 140) + "px"; + } + function addMessage(role, text, autoScroll = true) { const messagesEl = document.getElementById("messages"); @@ -1004,11 +1013,15 @@ checkHealth(); setInterval(checkHealth, 10000); - // Input events + // Input events. Enter inserts a newline and grows the box (like the Claude + // app) β€” you tap the arrow to send. ⌘/Ctrl+Enter sends from the keyboard. document.getElementById("sendBtn").addEventListener("click", sendMessage); - document.getElementById("userInput").addEventListener("keypress", e => { - if (e.key === "Enter") sendMessage(); + const inputBox = document.getElementById("userInput"); + inputBox.addEventListener("input", () => autoGrow(inputBox)); + inputBox.addEventListener("keydown", e => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendMessage(); } }); + autoGrow(inputBox); // ========== THINKING STREAM INTEGRATION ========== const thinkingPanel = document.getElementById("thinkingPanel"); diff --git a/lyra/web/static/style.css b/lyra/web/static/style.css index 7e014d3..c3f3f6a 100644 --- a/lyra/web/static/style.css +++ b/lyra/web/static/style.css @@ -199,6 +199,7 @@ button:hover, select:hover { /* Input bar */ #input { display: flex; + align-items: flex-end; /* arrow stays at the bottom as the textarea grows */ border-top: 1px solid var(--border); background: var(--bg-elev); padding: 10px; @@ -208,9 +209,14 @@ button:hover, select:hover { background: var(--bg-line); color: var(--text-main); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 16px; padding: 9px 12px; font-family: var(--font-console); + font-size: 0.95rem; + line-height: 1.4; + resize: none; /* grown programmatically, not by the drag handle */ + max-height: 140px; + overflow-y: auto; transition: border-color .15s, box-shadow .15s; } #userInput::placeholder { color: var(--text-fade); } @@ -221,6 +227,16 @@ button:hover, select:hover { } #sendBtn { margin-left: 8px; + flex: none; + width: 38px; + height: 38px; + padding: 0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + line-height: 1; background: var(--accent); color: #0a0a0a; border-color: var(--accent); @@ -936,12 +952,14 @@ select:hover { #userInput { font-size: 16px; /* Prevents zoom on iOS */ - padding: 12px; + padding: 11px 14px; } #sendBtn { - padding: 12px 16px; - font-size: 1rem; + width: 44px; /* comfortable touch target */ + height: 44px; + padding: 0; + font-size: 1.35rem; } /* Modal - full width on mobile */ @@ -1040,12 +1058,14 @@ select:hover { #userInput { font-size: 16px; - padding: 10px; + padding: 10px 13px; } #sendBtn { - padding: 10px 14px; - font-size: 0.95rem; + width: 42px; + height: 42px; + padding: 0; + font-size: 1.3rem; } .modal-header h3 { From 5e9f3efeecccfabaa3d61b3eca52c9d6bc672d8d Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 20 Jun 2026 03:46:33 +0000 Subject: [PATCH 07/14] fix(web): copy button actually copies on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The execCommand fallback returned true but copied nothing because the textarea was readOnly=false. iOS only copies from a readOnly + contentEditable field with a real Range selection + setSelectionRange β€” fixed that. Also skip the async Clipboard API unless window.isSecureContext (on the plain-HTTP LAN PWA it could resolve without copying, showing a false checkmark). If programmatic copy still fails, fall back to a prompt() with the text so it can be copied by hand, and only show the βœ“ on real success. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/web/static/index.html | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index 06f58d1..a77a5d6 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -499,7 +499,10 @@ // iOS over plain-HTTP LAN (where navigator.clipboard is undefined). function copyToClipboard(text) { text = text == null ? "" : String(text); - if (navigator.clipboard && navigator.clipboard.writeText) { + // Only trust the async Clipboard API in a secure context; on the LAN PWA + // (plain HTTP) it's either absent or resolves without actually copying, so + // we go straight to the iOS-tuned execCommand path there. + if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).catch(() => legacyCopy(text)); } return legacyCopy(text); @@ -508,19 +511,24 @@ return new Promise((resolve, reject) => { const ta = document.createElement("textarea"); ta.value = text; + // iOS will only copy from a readOnly + contentEditable field with a real + // Range selection; readOnly also stops the keyboard from popping. + ta.readOnly = true; ta.contentEditable = "true"; - ta.readOnly = false; ta.style.position = "fixed"; ta.style.top = "0"; ta.style.left = "0"; - ta.style.opacity = "0"; + ta.style.width = "1px"; + ta.style.height = "1px"; + ta.style.fontSize = "16px"; // avoid iOS zoom side-effects document.body.appendChild(ta); + ta.focus(); const range = document.createRange(); range.selectNodeContents(ta); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); - ta.setSelectionRange(0, text.length); // iOS needs an explicit range + ta.setSelectionRange(0, text.length); // the bit iOS actually needs let ok = false; try { ok = document.execCommand("copy"); } catch (e) { ok = false; } sel.removeAllRanges(); @@ -537,14 +545,17 @@ b.title = "Copy message"; b.addEventListener("click", (e) => { e.stopPropagation(); - copyToClipboard(typeof getText === "function" ? getText() : getText) + const text = typeof getText === "function" ? getText() : getText; + copyToClipboard(text) .then(() => { b.textContent = "βœ“"; b.classList.add("copied"); setTimeout(() => { b.textContent = "⧉"; b.classList.remove("copied"); }, 1200); }) .catch(() => { - b.textContent = "βœ—"; - setTimeout(() => { b.textContent = "⧉"; }, 1200); + // Last resort (some iOS configs block programmatic copy): surface the + // text in a prompt so it can be selected + copied by hand. + window.prompt("Copy this message:", text); + b.textContent = "⧉"; }); }); return b; From 5c41bd48d1a2161dca1e16592c84af9b32b51d2b Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 20 Jun 2026 04:37:17 +0000 Subject: [PATCH 08/14] fix: consolidation no longer stalls or breaks the live chat turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfacing in the log during live play: - SUMMARY_BACKEND=mi50 (llama.cpp, 32B) was fed 24k-char chunks β†’ "Context size has been exceeded". Chunk budget is now backend-aware: cloud 24k, local/mi50 8k, and the merge step recurses so merged partials never overflow either. - maybe_summarize ran inline in the chat turn and retried 4Γ— with backoff (~30s), stalling the reply and surfacing the error. It now runs in a background daemon thread, swallows errors (consolidation is best-effort maintenance), and dedupes so at most one summary per session runs at a time. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/chat.py | 4 ++-- lyra/summary.py | 47 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/lyra/chat.py b/lyra/chat.py index b54a897..7917b58 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -200,7 +200,7 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud", memory.remember(session_id, "assistant", reply) # Compact this session once enough new turns have piled up. - summary.maybe_summarize(session_id) + summary.maybe_summarize_async(session_id) return reply @@ -259,5 +259,5 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", memory.remember(session_id, "user", user_msg) memory.remember(session_id, "assistant", reply) - summary.maybe_summarize(session_id) + summary.maybe_summarize_async(session_id) yield ("done", reply) diff --git a/lyra/summary.py b/lyra/summary.py index 39506d8..3ffee1a 100644 --- a/lyra/summary.py +++ b/lyra/summary.py @@ -10,6 +10,7 @@ big imported conversation doesn't blow the local model's context window. from __future__ import annotations import sys +import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed @@ -20,8 +21,15 @@ _RETRIES = 4 # Re-summarize a session once it has accumulated this many new raw exchanges. SUMMARIZE_AFTER = 20 -# Transcript budget per LLM call; longer sessions are chunked + merged. +# Transcript budget per LLM call; longer sessions are chunked + merged. Cloud has +# a large context window; the local llama.cpp/Ollama servers have small ones, so a +# 24k-char chunk overflows them ("Context size has been exceeded") β€” keep local small. MAX_TRANSCRIPT_CHARS = 24000 +LOCAL_TRANSCRIPT_CHARS = 8000 + + +def _budget(backend: Backend) -> int: + return MAX_TRANSCRIPT_CHARS if backend == "cloud" else LOCAL_TRANSCRIPT_CHARS _PROMPT = """You are compacting a conversation into a long-term memory record \ (not replying to anyone). Write a concise gist of the session below: what was \ @@ -66,11 +74,14 @@ def _summarize_text(text: str, backend: Backend) -> str: def _summarize_transcript(transcript: str, backend: Backend) -> str: - """Transcript -> gist (LLM only, no DB). Chunks + merges if oversized.""" - if len(transcript) <= MAX_TRANSCRIPT_CHARS: + """Transcript -> gist (LLM only, no DB). Chunks + merges if oversized, and + recurses so even the merged partials never exceed the backend's window.""" + budget = _budget(backend) + if len(transcript) <= budget: return _summarize_text(transcript, backend) - partials = [_summarize_text(c, backend) for c in _chunk(transcript, MAX_TRANSCRIPT_CHARS)] - return _summarize_text("Partial summaries to merge:\n\n" + "\n\n".join(partials), backend) + partials = [_summarize_text(c, backend) for c in _chunk(transcript, budget)] + merged = "Partial summaries to merge:\n\n" + "\n\n".join(partials) + return _summarize_transcript(merged, backend) def summarize_session(session_id: str, backend: Backend | None = None) -> str | None: @@ -91,6 +102,32 @@ def maybe_summarize(session_id: str, backend: Backend | None = None) -> None: summarize_session(session_id, backend=backend) +_inflight: set[str] = set() +_inflight_lock = threading.Lock() + + +def maybe_summarize_async(session_id: str, backend: Backend | None = None) -> None: + """Run maybe_summarize off the chat turn's critical path. Consolidation is + background maintenance β€” it must never stall the reply or surface an error to + the user (a slow/oversized local model would otherwise block the turn). At most + one summary per session runs at a time.""" + with _inflight_lock: + if session_id in _inflight: + return + _inflight.add(session_id) + + def _run() -> None: + try: + maybe_summarize(session_id, backend=backend) + except Exception as exc: + logbus.log("error", "summary skipped", session=session_id, error=str(exc)[:120]) + finally: + with _inflight_lock: + _inflight.discard(session_id) + + threading.Thread(target=_run, daemon=True, name="summarize").start() + + def summarize_all( backend: Backend | None = None, limit: int | None = None, workers: int = 8 ) -> dict: From df591e4e01c7e0988fc31a28eefd2acc48781194 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 04:19:26 +0000 Subject: [PATCH 09/14] feat: decouple embeddings from the local-chat backend (EMBED_BASE_URL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embeddings shared LOCAL_BASE_URL with the local chat backend (the 3090's Ollama), so the 3090 being powered off killed all chat (every turn embeds to recall + to store). Add a separate EMBED_BASE_URL (defaults to LOCAL_BASE_URL, so existing setups are unchanged) and use it in llm.embed. Deployed: a user-level Ollama (CPU) now runs nomic-embed-text on lyra-cortex itself; EMBED_BASE_URL points at 127.0.0.1:11434 while LOCAL_BASE_URL still points the local chat backend at the 3090. Local embeddings verified identical to the 3090's (cosine 0.999994, 768-dim) so existing vectors stay valid β€” no re-embed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 4 ++++ lyra/config.py | 4 ++++ lyra/llm.py | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 3d1661b..535d38c 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,7 @@ SUMMARY_BACKEND=local # Where Lyra stores her memory. LYRA_DB_PATH=data/lyra.db + +# Optional: run embeddings on a separate always-on Ollama (decoupled from +# LOCAL_BASE_URL, which serves local chat). Defaults to LOCAL_BASE_URL if unset. +# EMBED_BASE_URL=http://127.0.0.1:11434 diff --git a/lyra/config.py b/lyra/config.py index e5ee22d..e36f51e 100644 --- a/lyra/config.py +++ b/lyra/config.py @@ -22,6 +22,7 @@ class Config: embed_backend: str # "cloud" (OpenAI) or "local" (Ollama) embed_model: str # OpenAI embedding model local_embed_model: str # Ollama embedding model + embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat) summary_backend: str # "local" or "cloud" β€” backend used to compact memory db_path: Path @@ -38,6 +39,9 @@ def load() -> Config: embed_backend=os.getenv("EMBED_BACKEND", "cloud").lower(), embed_model=os.getenv("EMBED_MODEL", "text-embedding-3-small"), local_embed_model=os.getenv("LOCAL_EMBED_MODEL", "nomic-embed-text"), + # Embeddings can live on their own always-on box, separate from the local + # chat backend. Defaults to LOCAL_BASE_URL so existing setups are unchanged. + embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")), summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(), db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")), ) diff --git a/lyra/llm.py b/lyra/llm.py index fa51d7b..de02efe 100644 --- a/lyra/llm.py +++ b/lyra/llm.py @@ -173,7 +173,7 @@ def embed(texts: list[str]) -> list[list[float]]: cfg = load() if cfg.embed_backend == "local": resp = httpx.post( - f"{cfg.local_base_url}/api/embed", + f"{cfg.embed_base_url}/api/embed", json={"model": cfg.local_embed_model, "input": texts}, timeout=120, ) From 67cf51a53f902e09e4f448d00cc2924af856bca2 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 04:32:04 +0000 Subject: [PATCH 10/14] feat: undo / delete logged entries (fix fat-fingered live logging) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the only delete was whole-session, so a mis-logged stack or a mis-parsed hand was stuck on the HUD. Now: - undo_last tool ("scratch that") β€” deletes the most recent hand/stack/read/ scar/confidence/reset in the live session; added to the Cash toolset. - poker.delete_hand/stack/read/ritual + delete_entry dispatch + undo_last. - DELETE /session/entry/{kind}/{id} endpoint. - HUD: per-row Γ— delete buttons on hands, confidence-bank, and scar-note rows (stack/read deletes via the tool). Row ids now surfaced in the hud() bundle. - test_modes.py +2 (undo_last across kinds, tool handler); 46 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/modes.py | 1 + lyra/poker.py | 88 +++++++++++++++++++++++++++++++++++- lyra/tools.py | 26 +++++++++++ lyra/web/server.py | 7 +++ lyra/web/static/session.html | 28 +++++++++--- tests/test_modes.py | 40 ++++++++++++++++ 6 files changed, 182 insertions(+), 8 deletions(-) diff --git a/lyra/modes.py b/lyra/modes.py index 49e4ccb..950e53c 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -44,6 +44,7 @@ _CASH_TOOLS = _BASE + _LOOKUPS + ( "start_session", "add_buyin", "log_stack", "log_hand", "record_hand", "add_read", "analyze_spot", "session_stats", "session_state", "end_session", "generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", + "undo_last", ) # Talk mode also gets start_session as the *entry point*: opening a session from a diff --git a/lyra/poker.py b/lyra/poker.py index d0bea6b..13868e7 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -242,6 +242,90 @@ def delete_session(session_id: int) -> dict: return counts +# --- per-entry deletes / undo (fix fat-fingered live logging) --- + +def delete_hand(hand_id: int) -> bool: + """Delete one hand and any player observations derived from it.""" + conn = _c() + with conn: + conn.execute("DELETE FROM player_observations WHERE hand_id = ?", (hand_id,)) + cur = conn.execute("DELETE FROM poker_hands WHERE id = ?", (hand_id,)) + return cur.rowcount > 0 + + +def delete_stack(stack_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM poker_stack_log WHERE id = ?", (stack_id,)) + return cur.rowcount > 0 + + +def delete_read(read_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM player_reads WHERE id = ?", (read_id,)) + return cur.rowcount > 0 + + +def delete_ritual(ritual_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM poker_rituals WHERE id = ?", (ritual_id,)) + return cur.rowcount > 0 + + +def delete_entry(kind: str, entry_id: int) -> bool: + """Dispatch a per-entry delete by kind β€” for the HUD's row delete buttons.""" + return { + "hand": delete_hand, "stack": delete_stack, + "read": delete_read, "ritual": delete_ritual, + }.get(kind, lambda _id: False)(entry_id) + + +def undo_last(kind: str, session_id: int | None = None) -> str | None: + """Delete the most-recent entry of `kind` in the live session and return a short + description of what was removed (None if there was nothing). For "scratch that". + + kind: hand | stack | read | scar | confidence | reset | ritual. + """ + sid = _resolve(session_id) + if sid is None: + raise ValueError("no live session") + k = (kind or "").lower().strip() + + if k in ("scar", "confidence", "reset", "ritual"): + sql = ("SELECT id, kind, content FROM poker_rituals WHERE session_id = ? " + + ("AND kind = ? " if k != "ritual" else "AND kind IN ('scar','confidence','reset') ") + + "ORDER BY id DESC LIMIT 1") + params = (sid, k) if k != "ritual" else (sid,) + r = _c().execute(sql, params).fetchone() + if not r: + return None + delete_ritual(r["id"]) + label = _RITUAL_LABEL.get(r["kind"], r["kind"]) + return f"{label}" + (f": {r['content']}" if r["content"] else "") + + table, desc_cols = { + "hand": ("poker_hands", "position, hole_cards"), + "stack": ("poker_stack_log", "amount"), + "read": ("player_reads", "note"), + }.get(k, (None, None)) + if not table: + return None + r = _c().execute( + f"SELECT id, {desc_cols} FROM {table} WHERE session_id = ? ORDER BY id DESC LIMIT 1", + (sid,), + ).fetchone() + if not r: + return None + delete_entry(k, r["id"]) + if k == "hand": + return f"hand ({(r['position'] or '?')} {r['hole_cards'] or ''})".strip() + if k == "stack": + return f"stack ${r['amount']:g}" + return f"read: {r['note'][:50]}" + + def live_session() -> dict | None: """The current open session, if any.""" r = _c().execute( @@ -308,7 +392,7 @@ def stack_log(session_id: int | None = None) -> list[dict]: if sid is None: return [] return [dict(r) for r in _c().execute( - "SELECT amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", + "SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", (sid,), ).fetchall()] @@ -996,7 +1080,7 @@ def hud(session_id: int | None = None) -> dict | None: rituals = list_rituals(sid) by_kind = lambda k: [ # noqa: E731 - {"content": r["content"], "classification": r["classification"], + {"id": r["id"], "content": r["content"], "classification": r["classification"], "hand_id": r["hand_id"], "at": r["created_at"]} for r in rituals if r["kind"] == k ] diff --git a/lyra/tools.py b/lyra/tools.py index 8326f39..b5111b5 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -117,6 +117,25 @@ def _log_stack(args: dict, ctx: dict) -> str: return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".") +def _undo_last(args: dict, ctx: dict) -> str: + what = (args.get("what") or "").strip().lower() + aliases = {"hands": "hand", "stacks": "stack", "reads": "read", + "scar_note": "scar", "confidence_bank": "confidence", + "scar note": "scar", "confidence": "confidence", "note": "ritual"} + what = aliases.get(what, what) + valid = ("hand", "stack", "read", "scar", "confidence", "reset", "ritual") + if what not in valid: + return f"Tell me what to undo β€” one of: {', '.join(valid)}." + try: + removed = poker.undo_last(what) + except ValueError: + return "No live session to undo anything in." + if not removed: + return f"Nothing logged to undo for '{what}'." + logbus.log("info", "undo last", what=what, removed=removed[:60]) + return f"Scratched the last {what} β€” removed {removed}." + + def _scar_note(args: dict, ctx: dict) -> str: content = (args.get("content") or "").strip() if not content: @@ -370,6 +389,13 @@ TOOLS.update({ "add_buyin": {"handler": _add_buyin, "spec": _f( "add_buyin", "Record a rebuy / additional buy-in in the live session.", {"amount": {**_N, "description": "Amount added"}}, ["amount"])}, + "undo_last": {"handler": _undo_last, "spec": _f( + "undo_last", + "Undo/delete the most recent logged entry in the live session when Brian says " + "'scratch that', 'delete that', 'that was wrong', etc. Specify what: 'hand', " + "'stack', 'read', 'scar', 'confidence', or 'reset'.", + {"what": {**_S, "description": "hand | stack | read | scar | confidence | reset"}}, + ["what"])}, "log_stack": {"handler": _log_stack, "spec": _f( "log_stack", "Record Brian's CURRENT total chip stack in the live session. Call whenever " diff --git a/lyra/web/server.py b/lyra/web/server.py index 444d1fc..9b3d5bf 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -113,6 +113,13 @@ def create_app() -> FastAPI: bundle = await asyncio.to_thread(poker.hud) return bundle or {"session": None} + @app.delete("/session/entry/{kind}/{entry_id}") + async def delete_entry(kind: str, entry_id: int) -> dict: + """Delete one HUD entry (hand | stack | read | ritual) by id.""" + ok = await asyncio.to_thread(poker.delete_entry, kind, entry_id) + logbus.log("info", "hud entry deleted", kind=kind, id=entry_id, ok=ok) + return {"ok": ok} + @app.get("/history") async def history_page() -> FileResponse: """Browsable list of past poker sessions.""" diff --git a/lyra/web/static/session.html b/lyra/web/static/session.html index f7cac0f..f0d2170 100644 --- a/lyra/web/static/session.html +++ b/lyra/web/static/session.html @@ -78,6 +78,12 @@ .scar-cls.standard { color: var(--fade); } .card.scar { border-color: #4a2222; } .card.scar .label { color: #d98a8a; } .card.conf { border-color: #234a23; } .card.conf .label { color: var(--good); } + /* per-row delete (fix fat-fingered live logging) */ + li.row-del { display: flex; align-items: center; gap: 8px; } + li.row-del > a.hand, li.row-del > .row-body { flex: 1; min-width: 0; } + .del-x { flex: none; background: none; border: none; color: var(--fade); font-size: 1.15rem; + line-height: 1; padding: 2px 6px; cursor: pointer; -webkit-tap-highlight-color: transparent; } + .del-x:active { color: var(--low); } .empty { color: var(--fade); font-size: .92rem; } .err { color: var(--low); text-align: center; padding: 30px; } .big-empty { text-align: center; padding: 50px 20px; color: var(--fade); } @@ -142,6 +148,16 @@ function netClass(v){ return v == null ? 'flat' : v > 0 ? 'up' : v < 0 ? 'down' : 'flat'; } + // Delete one logged entry (hand | ritual | read | stack), then refresh. + async function del(kind, id){ + if(!confirm('Delete this entry?')) return; + try { + const r = await fetch('/session/entry/'+kind+'/'+id, { method:'DELETE' }); + if(!r.ok) throw new Error('HTTP '+r.status); + refresh(); + } catch(e){ alert('Delete failed: '+e.message); } + } + function render(data){ const s = data.session; if (!s) { @@ -199,29 +215,29 @@

Hands this session

${hands.length ? `` + `).join('')}` : '

No hands logged yet.

'}

πŸ’° Confidence Bank

${confidence.length ? `
    ${confidence.slice().reverse().map(c => ` -
  • ${esc(c.content)}${c.hand_id ? ` Β· hand` : ''} -
    ${ago(c.at)}
  • `).join('')}
` +
  • ${esc(c.content)}${c.hand_id ? ` Β· hand` : ''} +
    ${ago(c.at)}
  • `).join('')}` : '

    Nothing banked yet β€” disciplined plays land here.

    '}

    🩹 Scar Notes

    ${scars.length ? `
      ${scars.slice().reverse().map(sc => ` -
    • ${esc(sc.content)}${sc.classification ? `${esc(sc.classification)}` : ''} +
    • ${esc(sc.content)}${sc.classification ? `${esc(sc.classification)}` : ''} ${sc.hand_id ? ` Β· hand` : ''} -
      ${ago(sc.at)}
    • `).join('')}
    ` +
    ${ago(sc.at)}
    `).join('')}` : '

    No scars logged β€” mistakes to study land here.

    '}
    diff --git a/tests/test_modes.py b/tests/test_modes.py index e5366b5..74d79f0 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -175,6 +175,46 @@ def test_session_state_readback(lyra): assert "great river fold" in out +def test_undo_last_and_delete_entry(lyra): + _, poker, modes, tools = lyra + assert "undo_last" in modes.CASH.tools + poker.start_session(venue="Meadows", stakes="2/5", buy_in=500) + h1 = poker.log_hand(position="UTG", hole_cards="AA") + h2 = poker.log_hand(position="BTN", hole_cards="72o") + poker.log_stack(600); poker.log_stack(420) + poker.log_ritual("scar", content="punted") + poker.log_ritual("confidence", content="good fold") + + # undo removes the most recent of each kind + assert "72o" in poker.undo_last("hand") + assert [h["hole_cards"] for h in poker.list_hands()] == ["AA"] # h2 gone, h1 stays + assert "420" in poker.undo_last("stack") + assert poker.current_stack() == 600 + assert "punted" in poker.undo_last("scar") + assert not poker.list_rituals(kinds=("scar",)) + assert poker.list_rituals(kinds=("confidence",)) # untouched + assert poker.undo_last("hand") is not None # h1 + assert poker.undo_last("hand") is None # nothing left + + # direct delete-by-id dispatch + assert poker.delete_entry("ritual", poker.list_rituals(kinds=("confidence",))[0]["id"]) is True + assert poker.delete_entry("bogus", 1) is False + + +def test_undo_last_tool(lyra): + _, poker, _, tools = lyra + poker.start_session(stakes="1/3", buy_in=300) + poker.log_hand(position="CO", hole_cards="KK") + out = tools.dispatch("undo_last", {"what": "hand"}, {}) + assert "scratched" in out.lower() and poker.list_hands() == [] + # no live session -> graceful + poker.end_session(cash_out=300) + assert "no live session" in tools.dispatch("undo_last", {"what": "hand"}, {}).lower() + # nonsense target + poker.start_session(stakes="1/3", buy_in=100) + assert "one of" in tools.dispatch("undo_last", {"what": "banana"}, {}).lower() + + def test_list_and_delete_session(lyra): _, poker, _, tools = lyra keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300) From cca8322ee2fef739e7fbf571df5d42bc58131dfe Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 05:12:13 +0000 Subject: [PATCH 11/14] =?UTF-8?q?perf:=20WAL=20synchronous=3DNORMAL=20?= =?UTF-8?q?=E2=80=94=20stop=20fsyncing=20every=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lyra.db is on disk-backed ext4, where each WAL commit fsync'd (~0.15s here). Every chat turn does several writes (remember user+assistant, summaries, poker logging), so this was adding real per-turn latency, and made the dream loop + tests crawl. synchronous=NORMAL is WAL's recommended companion: durable across app crashes, only a power/OS crash can drop the last txn (never corrupts). Per-write dropped from ~0.4s to ~0.001s; test suite 72s -> 24s. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/memory.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lyra/memory.py b/lyra/memory.py index 0529612..a7a3478 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -131,6 +131,11 @@ def _connection() -> sqlite3.Connection: # alongside the web server without tripping "database is locked". _conn.execute("PRAGMA busy_timeout=5000") _conn.execute("PRAGMA journal_mode=WAL") + # WAL's recommended companion: don't fsync on every commit (only at + # checkpoint). Safe against app crashes; a power/OS crash can lose the last + # txn but never corrupt. On disk-backed storage this turns ~0.15s-per-commit + # fsync latency into ~nothing β€” big win for per-turn writes + the dream loop. + _conn.execute("PRAGMA synchronous=NORMAL") _conn.executescript(SCHEMA) # Migrations for DBs created before a column existed (no-op if present). for ddl in ("ALTER TABLE sessions ADD COLUMN mode TEXT",): From 559faaed30db34b8477ee265635aaa7a94aea23c Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 05:12:13 +0000 Subject: [PATCH 12/14] feat: view past sessions, edit session details, log rituals while reviewing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - View any past session as a read-only HUD: /session?id=N (hud(session_id) + /session/data?id=); /history rows now link there. Closed sessions show played duration + final net; recap link when one exists. - Edit session details during or after play: poker.update_session (recomputes net when buy-in/cash-out change), PATCH /session/{id}, an update_session tool ("venue was actually Bellagio", "I bought in for 600"), and an inline ✎ Edit form on the HUD. - Rituals attach to the most-recent session post-close (poker.review_session_id), so scar/confidence/reset work while reviewing after you rack up. - Edit form is poll-safe (won't clobber mid-edit); past-session view doesn't poll. - test_modes.py +3 (edit, review rituals, past-session HUD); 49 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/modes.py | 2 +- lyra/poker.py | 49 ++++++++++++++++++++++- lyra/tools.py | 55 ++++++++++++++++++++------ lyra/web/server.py | 14 +++++-- lyra/web/static/history.html | 2 +- lyra/web/static/session.html | 76 ++++++++++++++++++++++++++++++++++-- tests/test_modes.py | 55 +++++++++++++++++++++++--- 7 files changed, 224 insertions(+), 29 deletions(-) diff --git a/lyra/modes.py b/lyra/modes.py index 950e53c..3155cf9 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -44,7 +44,7 @@ _CASH_TOOLS = _BASE + _LOOKUPS + ( "start_session", "add_buyin", "log_stack", "log_hand", "record_hand", "add_read", "analyze_spot", "session_stats", "session_state", "end_session", "generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", - "undo_last", + "undo_last", "update_session", ) # Talk mode also gets start_session as the *entry point*: opening a session from a diff --git a/lyra/poker.py b/lyra/poker.py index 13868e7..19f2e31 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -341,6 +341,50 @@ def _resolve(session_id: int | None) -> int | None: return live["id"] if live else None +def review_session_id() -> int | None: + """The session to attach reflective entries to: the live one if any, else the + most-recent real session (closed). Lets rituals/notes land while reviewing after + you've racked up. Excludes the standing 'Hand Reviews' bucket.""" + live = live_session() + if live: + return live["id"] + r = _c().execute( + "SELECT id FROM poker_sessions WHERE status != 'review' ORDER BY id DESC LIMIT 1" + ).fetchone() + return int(r["id"]) if r else None + + +_EDITABLE = ("venue", "stakes", "game", "format", "buy_in_total", "cash_out", + "mantra", "mood") + + +def update_session(session_id: int, **fields) -> dict | None: + """Edit session details (during or after play). Only known columns are touched; + net is recomputed when buy-in/cash-out change and both are known.""" + s = get_session(session_id) + if not s: + return None + sets, vals = [], [] + for k, v in fields.items(): + if k in _EDITABLE and v is not None: + sets.append(f"{k} = ?") + vals.append(float(v) if k in ("buy_in_total", "cash_out") else v) + if sets: + conn = _c() + with conn: + conn.execute(f"UPDATE poker_sessions SET {', '.join(sets)} WHERE id = ?", + (*vals, session_id)) + s = get_session(session_id) + # keep net consistent if the money fields changed and both are present + if s.get("cash_out") is not None and s.get("buy_in_total") is not None: + net = float(s["cash_out"]) - float(s["buy_in_total"]) + if net != s.get("net"): + with conn: + conn.execute("UPDATE poker_sessions SET net = ? WHERE id = ?", (net, session_id)) + s = get_session(session_id) + return s + + def add_buyin(amount: float, session_id: int | None = None) -> float: """Add a buy-in/rebuy to a session. Returns the new total in.""" sid = _resolve(session_id) @@ -1090,7 +1134,10 @@ def hud(session_id: int | None = None) -> dict | None: "id": sid, "venue": s.get("venue"), "stakes": s.get("stakes"), "game": s.get("game"), "format": s.get("format"), "status": s.get("status"), "started_at": s.get("started_at"), - "buy_in_total": s.get("buy_in_total"), + "ended_at": s.get("ended_at"), "hours": s.get("hours"), + "buy_in_total": s.get("buy_in_total"), "cash_out": s.get("cash_out"), + "net": s.get("net"), "mantra": s.get("mantra"), "mood": s.get("mood"), + "is_live": s.get("status") == "live", "has_recap": bool(s.get("recap_md")), }, "stack": { "current": state["current"], "buy_in": state["buy_in"], "net": state["net"], diff --git a/lyra/tools.py b/lyra/tools.py index b5111b5..d98c3ed 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -117,6 +117,21 @@ def _log_stack(args: dict, ctx: dict) -> str: return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".") +def _update_session(args: dict, ctx: dict) -> str: + sid = poker.review_session_id() + if sid is None: + return "No session to edit yet." + fields = {k: args.get(k) for k in ("venue", "stakes", "game", "format", + "buy_in_total", "cash_out", "mantra", "mood") if args.get(k) not in (None, "")} + if not fields: + return "Tell me what to change (venue, stakes, game, buy-in, etc.)." + s = poker.update_session(sid, **fields) + if not s: + return "Couldn't find that session." + changed = ", ".join(f"{k}={v}" for k, v in fields.items()) + return f"Session #{sid} updated β€” {changed}." + + def _undo_last(args: dict, ctx: dict) -> str: what = (args.get("what") or "").strip().lower() aliases = {"hands": "hand", "stacks": "stack", "reads": "read", @@ -143,11 +158,11 @@ def _scar_note(args: dict, ctx: dict) -> str: cls = (args.get("classification") or "").strip().lower() or None if cls and cls not in ("punt", "cooler", "standard"): cls = None - try: - poker.log_ritual("scar", content=content, classification=cls, - hand_id=args.get("hand_id")) - except ValueError: - return "No live session β€” start one and I'll keep the scar notes." + sid = poker.review_session_id() # live, or the most-recent session (post-game review) + if sid is None: + return "No session yet β€” start one and I'll keep the scar notes." + poker.log_ritual("scar", content=content, classification=cls, + hand_id=args.get("hand_id"), session_id=sid) return f"Scar note logged{f' ({cls})' if cls else ''}." @@ -155,10 +170,10 @@ def _confidence_bank(args: dict, ctx: dict) -> str: content = (args.get("content") or "").strip() if not content: return "Nothing to bank β€” tell me the good process." - try: - poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id")) - except ValueError: - return "No live session β€” start one and I'll run the confidence bank." + sid = poker.review_session_id() + if sid is None: + return "No session yet β€” start one and I'll run the confidence bank." + poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id"), session_id=sid) return "Banked. πŸ’°" @@ -174,10 +189,10 @@ def _alligator_blood(args: dict, ctx: dict) -> str: def _reset_ritual(args: dict, ctx: dict) -> str: content = (args.get("content") or "").strip() or None - try: - poker.log_ritual("reset", content=content) - except ValueError: - return "No live session to reset." + sid = poker.review_session_id() + if sid is None: + return "No session to reset." + poker.log_ritual("reset", content=content, session_id=sid) return "Reset logged. Clean slate β€” this is a new session in your head." @@ -389,6 +404,20 @@ TOOLS.update({ "add_buyin": {"handler": _add_buyin, "spec": _f( "add_buyin", "Record a rebuy / additional buy-in in the live session.", {"amount": {**_N, "description": "Amount added"}}, ["amount"])}, + "update_session": {"handler": _update_session, "spec": _f( + "update_session", + "Edit details of the current/most-recent session β€” during or after play. Use " + "when Brian corrects something ('change the stakes to 2/5', 'venue was actually " + "Bellagio', 'I bought in for 600', 'cashed out 1240'). Only pass fields that change.", + {"venue": {**_S, "description": "Casino/room"}, + "stakes": {**_S, "description": "e.g. '1/3', '2/5'"}, + "game": {**_S, "description": "NLH, PLO, ..."}, + "format": {**_S, "description": "cash | tournament"}, + "buy_in_total": {**_N, "description": "Total bought in"}, + "cash_out": {**_N, "description": "Final cashout (recomputes net)"}, + "mantra": {**_S, "description": "Pre-session focus/anchor"}, + "mood": {**_S, "description": "Mental-game note"}}, + [])}, "undo_last": {"handler": _undo_last, "spec": _f( "undo_last", "Undo/delete the most recent logged entry in the live session when Brian says " diff --git a/lyra/web/server.py b/lyra/web/server.py index 9b3d5bf..79785c9 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -108,11 +108,19 @@ def create_app() -> FastAPI: return FileResponse(str(_STATIC / "session.html")) @app.get("/session/data") - async def session_hud_data() -> dict: - """The current live session's HUD bundle (or {session: None} if none open).""" - bundle = await asyncio.to_thread(poker.hud) + async def session_hud_data(id: int | None = None) -> dict: + """HUD bundle for the live session, or a specific past session via ?id=.""" + bundle = await asyncio.to_thread(poker.hud, id) return bundle or {"session": None} + @app.patch("/session/{session_id}") + async def session_update(session_id: int, request: Request) -> dict: + """Edit a session's details (venue/stakes/game/buy-in/cash-out/…).""" + body = await request.json() + s = await asyncio.to_thread(lambda: poker.update_session(session_id, **body)) + logbus.log("info", "session edited", id=session_id, fields=list(body)) + return {"ok": s is not None, "session": s} + @app.delete("/session/entry/{kind}/{entry_id}") async def delete_entry(kind: str, entry_id: int) -> dict: """Delete one HUD entry (hand | stack | read | ritual) by id.""" diff --git a/lyra/web/static/history.html b/lyra/web/static/history.html index 167cf0c..43224c1 100644 --- a/lyra/web/static/history.html +++ b/lyra/web/static/history.html @@ -85,7 +85,7 @@ const date=(s.started_at||'').slice(0,10); const meta=[date,s.venue,`${s.hands} hand${s.hands===1?'':'s'}`, s.hours?`${(+s.hours).toFixed(1)}h`:''].filter(Boolean).join(' Β· '); - const href=s.has_recap?`/recap/${s.id}`:`/session`; + const href=`/session?id=${s.id}`; // read-only HUD detail for any session const net=s.net!=null?money(s.net):(s.status==='live'?'live':'β€”'); return `
    diff --git a/lyra/web/static/session.html b/lyra/web/static/session.html index f0d2170..dbc674c 100644 --- a/lyra/web/static/session.html +++ b/lyra/web/static/session.html @@ -84,6 +84,21 @@ .del-x { flex: none; background: none; border: none; color: var(--fade); font-size: 1.15rem; line-height: 1; padding: 2px 6px; cursor: pointer; -webkit-tap-highlight-color: transparent; } .del-x:active { color: var(--low); } + /* session edit form */ + .edit-btn { margin-left: auto; background: #241400; border: 1px solid var(--border); color: var(--accent); + border-radius: 8px; padding: 5px 10px; font-size: .8rem; cursor: pointer; -webkit-tap-highlight-color: transparent; } + .mantra { color: var(--mid); font-style: italic; font-size: .9rem; margin-top: 10px; } + .edit-form { grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px; } + .edit-form label { display: flex; flex-direction: column; gap: 4px; font-size: .68rem; + color: var(--fade); text-transform: uppercase; letter-spacing: .4px; } + .edit-form label.wide { grid-column: 1 / -1; } + .edit-form input { background: var(--bg-line); border: 1px solid var(--border); border-radius: 8px; + padding: 8px 10px; color: var(--text); font-size: 16px; } + .edit-form input:focus { outline: none; border-color: var(--accent); } + .edit-actions { grid-column: 1 / -1; display: flex; gap: 8px; justify-content: flex-end; } + .edit-actions button { background: var(--bg-line); border: 1px solid var(--border); color: var(--text); + border-radius: 8px; padding: 8px 16px; cursor: pointer; } + .edit-actions button.save { background: var(--accent); color: #0a0a0a; border-color: var(--accent); font-weight: 600; } .empty { color: var(--fade); font-size: .92rem; } .err { color: var(--low); text-align: center; padding: 30px; } .big-empty { text-align: center; padding: 50px 20px; color: var(--fade); } @@ -108,6 +123,8 @@ const root = document.getElementById('root'); const dot = document.getElementById('dot'); const updatedEl = document.getElementById('updated'); + const SID = new URLSearchParams(location.search).get('id'); // past-session view when set + let curSession = null; // the session object currently rendered (for the edit form) function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; } function money(v){ if (v == null) return 'β€”'; const n = Number(v); return (n<0?'-$':'$') + Math.abs(n).toLocaleString(); } @@ -127,6 +144,16 @@ const h = Math.floor(s/3600), m = Math.round((s%3600)/60); return h ? `${h}h ${m}m` : `${m}m`; } + // For a live session: time since start. For a closed one: actual played duration. + function clock(sess){ + if(sess.is_live) return elapsed(sess.started_at); + if(sess.hours != null) return (+sess.hours).toFixed(1) + 'h'; + if(sess.started_at && sess.ended_at){ + const s = Math.max(0,(new Date(sess.ended_at)-new Date(sess.started_at))/1000); + const h=Math.floor(s/3600), m=Math.round((s%3600)/60); return h?`${h}h ${m}m`:`${m}m`; + } + return 'β€”'; + } // Tiny inline sparkline of the stack-over-time series. function sparkline(series){ @@ -148,6 +175,28 @@ function netClass(v){ return v == null ? 'flat' : v > 0 ? 'up' : v < 0 ? 'down' : 'flat'; } + function toggleEdit(){ + const f = document.getElementById('editForm'); + if(f) f.style.display = (f.style.display === 'none' || !f.style.display) ? 'grid' : 'none'; + } + async function saveEdit(){ + if(!curSession) return; + const body = {}; + for(const k of ['venue','stakes','game','format','buy_in_total','cash_out','mantra','mood']){ + const el = document.getElementById('ed_'+k); + if(!el) continue; + let v = el.value.trim(); + if(v === '') continue; + body[k] = (k==='buy_in_total'||k==='cash_out') ? Number(v) : v; + } + try { + const r = await fetch('/session/' + curSession.id, { + method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); + if(!r.ok) throw new Error('HTTP '+r.status); + toggleEdit(); refresh(); + } catch(e){ alert('Save failed: '+e.message); } + } + // Delete one logged entry (hand | ritual | read | stack), then refresh. async function del(kind, id){ if(!confirm('Delete this entry?')) return; @@ -168,6 +217,7 @@ updatedEl.textContent = ''; return; } + curSession = s; const stack = data.stack || {}; const hands = data.hands || []; const villains = data.villains || []; @@ -190,14 +240,29 @@ @@ -272,8 +337,11 @@ } async function refresh(){ + // don't clobber the edit form mid-edit on a poll tick + const ef = document.getElementById('editForm'); + if (ef && ef.style.display === 'grid') return; try { - const r = await fetch('/session/data', { cache: 'no-store' }); + const r = await fetch('/session/data' + (SID ? ('?id=' + encodeURIComponent(SID)) : ''), { cache: 'no-store' }); const data = await r.json(); data._fetched = new Date().toISOString(); dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400); @@ -284,7 +352,7 @@ } refresh(); - setInterval(refresh, 5000); + if (!SID) setInterval(refresh, 5000); // live HUD polls; a past session is static document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); diff --git a/tests/test_modes.py b/tests/test_modes.py index 74d79f0..0c2c21d 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -179,9 +179,10 @@ def test_undo_last_and_delete_entry(lyra): _, poker, modes, tools = lyra assert "undo_last" in modes.CASH.tools poker.start_session(venue="Meadows", stakes="2/5", buy_in=500) - h1 = poker.log_hand(position="UTG", hole_cards="AA") - h2 = poker.log_hand(position="BTN", hole_cards="72o") - poker.log_stack(600); poker.log_stack(420) + poker.log_hand(position="UTG", hole_cards="AA") + poker.log_hand(position="BTN", hole_cards="72o") + poker.log_stack(600) + poker.log_stack(420) poker.log_ritual("scar", content="punted") poker.log_ritual("confidence", content="good fold") @@ -215,6 +216,48 @@ def test_undo_last_tool(lyra): assert "one of" in tools.dispatch("undo_last", {"what": "banana"}, {}).lower() +def test_update_session_edit(lyra): + _, poker, modes, tools = lyra + assert "update_session" in modes.CASH.tools + sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300) + s = poker.update_session(sid, stakes="2/5", buy_in_total=600, cash_out=900, venue="Bellagio") + assert s["stakes"] == "2/5" and s["venue"] == "Bellagio" + assert s["buy_in_total"] == 600 and s["cash_out"] == 900 + assert s["net"] == 300 # recomputed from cash_out - buy_in + # via the tool (edits the live/most-recent session) + out = tools.dispatch("update_session", {"mood": "locked in"}, {}) + assert "updated" in out.lower() and poker.get_session(sid)["mood"] == "locked in" + assert "what to change" in tools.dispatch("update_session", {}, {}).lower() + + +def test_review_session_and_post_close_rituals(lyra): + _, poker, _, tools = lyra + sid = poker.start_session(venue="Meadows", stakes="2/5", buy_in=500) + poker.end_session(cash_out=720) + assert poker.live_session() is None + assert poker.review_session_id() == sid # most-recent closed session + + # rituals attach to the closed session during review (no live session needed) + out = tools.dispatch("scar_note", {"content": "should've folded turn", "classification": "punt"}, {}) + assert "logged" in out.lower() + tools.dispatch("confidence_bank", {"content": "good thin value river"}, {}) + assert len(poker.list_rituals(session_id=sid, kinds=("scar",))) == 1 + assert len(poker.list_rituals(session_id=sid, kinds=("confidence",))) == 1 + + +def test_hud_for_past_session(lyra): + _, poker, _, _ = lyra + sid = poker.start_session(venue="Meadows", stakes="2/5", buy_in=500) + poker.log_hand(position="BTN", hole_cards="AKs") + poker.end_session(cash_out=650) + # a *new* live session so live HUD != the one we query + poker.start_session(venue="Wynn", stakes="1/3", buy_in=300) + past = poker.hud(sid) + assert past["session"]["id"] == sid and past["session"]["is_live"] is False + assert past["session"]["net"] == 150 and len(past["hands"]) == 1 + assert poker.hud()["session"]["venue"] == "Wynn" # live one unaffected + + def test_list_and_delete_session(lyra): _, poker, _, tools = lyra keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300) @@ -244,9 +287,9 @@ def test_recent_sessions_tool(lyra): assert "Meadows" in out and "+220" in out -def test_rituals_require_live_session(lyra): +def test_rituals_require_a_session(lyra): _, poker, _, tools = lyra - # tools degrade gracefully (no exception) when nothing is open - assert "no live session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower() + # with no session at all, the tool degrades gracefully (no exception) + assert "no session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower() with pytest.raises(ValueError): poker.log_ritual("scar", content="x") From f2de7dec610c44bc68922ffb6d6ef11f9f5af810 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 05:27:55 +0000 Subject: [PATCH 13/14] feat(web): shared left-sidebar navigation across all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop nav was scattered and inconsistent β€” the chat header was crammed with cross-page links and each standalone page had its own ad-hoc, incomplete back-links (e.g. /hands could only reach Chat). Now a single nav.js (one source of truth, no build step) injects a left sidebar on desktop (>=769px) with active-page highlighting across Chat/Session/History/Hands/Mind/Journal/Logs + Settings. - nav.js: injects sidebar + its own CSS; body gets padding-left on desktop; hidden on mobile (each page keeps its bottom bar / back-links there). - Included on every page (index, session, history, hands, self, journal, logs, recap, hand). - Decluttered the chat header: removed the now-redundant cross-page links (kept the chat-specific session selector + inline Live Log toggle). - Sidebar Settings opens the chat modal, or navigates to /?settings=1 from elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/web/static/hand.html | 1 + lyra/web/static/hands.html | 1 + lyra/web/static/history.html | 1 + lyra/web/static/index.html | 9 ++--- lyra/web/static/journal.html | 1 + lyra/web/static/logs.html | 1 + lyra/web/static/nav.js | 76 ++++++++++++++++++++++++++++++++++++ lyra/web/static/recap.html | 1 + lyra/web/static/self.html | 1 + lyra/web/static/session.html | 1 + 10 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 lyra/web/static/nav.js diff --git a/lyra/web/static/hand.html b/lyra/web/static/hand.html index 7c5f1df..6a9b05b 100644 --- a/lyra/web/static/hand.html +++ b/lyra/web/static/hand.html @@ -247,5 +247,6 @@ } load(); + diff --git a/lyra/web/static/hands.html b/lyra/web/static/hands.html index f6d3e36..4bfa51e 100644 --- a/lyra/web/static/hands.html +++ b/lyra/web/static/hands.html @@ -80,5 +80,6 @@ } load(); + diff --git a/lyra/web/static/history.html b/lyra/web/static/history.html index 43224c1..f46d364 100644 --- a/lyra/web/static/history.html +++ b/lyra/web/static/history.html @@ -100,5 +100,6 @@ } load(); + diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index a77a5d6..08d6771 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -80,11 +80,6 @@ - β›Ά Full Log - 🎬 Session - πŸ“š Sessions - 🧠 Mind - πŸƒ Hands
    @@ -990,6 +985,9 @@ loadSessionList(); // Refresh session list when opening settings }); + // Sidebar "Settings" from another page navigates here with ?settings=1. + if (new URLSearchParams(location.search).get("settings")) settingsBtn.click(); + // Hide modal functions const hideModal = () => { settingsModal.classList.remove("show"); @@ -1187,5 +1185,6 @@ }); }); + diff --git a/lyra/web/static/journal.html b/lyra/web/static/journal.html index 89a9c73..7979751 100644 --- a/lyra/web/static/journal.html +++ b/lyra/web/static/journal.html @@ -157,5 +157,6 @@ setInterval(load, 20000); document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); }); + diff --git a/lyra/web/static/logs.html b/lyra/web/static/logs.html index b368336..457d7be 100644 --- a/lyra/web/static/logs.html +++ b/lyra/web/static/logs.html @@ -235,5 +235,6 @@ } connect(); + diff --git a/lyra/web/static/nav.js b/lyra/web/static/nav.js new file mode 100644 index 0000000..9ce057b --- /dev/null +++ b/lyra/web/static/nav.js @@ -0,0 +1,76 @@ +/* Shared app navigation β€” one source of truth across all pages (no build step). + Injects a left sidebar on desktop (>=769px) with active-page highlighting; stays + out of the way on mobile, where each page keeps its bottom bar / back-links. */ +(function () { + const ITEMS = [ + { href: "/", icon: "πŸ’¬", label: "Chat" }, + { href: "/session", icon: "β™ ", label: "Session" }, + { href: "/history", icon: "πŸ“š", label: "History" }, + { href: "/hands", icon: "πŸƒ", label: "Hands" }, + { href: "/self", icon: "🧠", label: "Mind" }, + { href: "/journal", icon: "πŸ“”", label: "Journal" }, + { href: "/logs", icon: "πŸ“œ", label: "Logs" }, + ]; + + const path = location.pathname; + function isActive(href) { + if (href === "/") return path === "/" || path === ""; + if (href === "/hands") return path === "/hands" || path.indexOf("/hand") === 0; + if (href === "/history") return path.indexOf("/history") === 0 || path.indexOf("/recap") === 0; + return path === href || path.indexOf(href + "/") === 0; + } + + const css = ` + #app-nav { display: none; } + @media screen and (min-width: 769px) { + body { padding-left: 212px; } + #app-nav { + position: fixed; left: 0; top: 0; bottom: 0; width: 212px; z-index: 1000; + display: flex; flex-direction: column; gap: 2px; box-sizing: border-box; + padding: 14px 10px; background: #0b0b0b; border-right: 1px solid #2a1d12; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + #app-nav .brand { + display: flex; align-items: center; gap: 8px; text-decoration: none; + color: #ff7a00; font-weight: 700; font-size: 1.15rem; letter-spacing: .5px; + padding: 6px 11px 14px; + } + #app-nav .brand .dot { width: 8px; height: 8px; border-radius: 50%; + background: #8fd694; box-shadow: 0 0 8px rgba(143,214,148,.6); } + #app-nav .navitem { + display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; + padding: 9px 11px; border-radius: 9px; border: none; background: none; + color: #cfcfcf; text-decoration: none; font-size: .95rem; cursor: pointer; + font-family: inherit; -webkit-tap-highlight-color: transparent; + } + #app-nav .navitem .i { font-size: 1.05rem; width: 20px; text-align: center; filter: grayscale(.3); } + #app-nav .navitem:hover { background: rgba(255,122,0,.08); color: #fff; } + #app-nav .navitem.active { background: rgba(255,122,0,.14); color: #ff7a00; } + #app-nav .navitem.active .i { filter: none; } + #app-nav .spacer { flex: 1; } + }`; + + const style = document.createElement("style"); + style.textContent = css; + document.head.appendChild(style); + + const nav = document.createElement("nav"); + nav.id = "app-nav"; + nav.setAttribute("aria-label", "App navigation"); + nav.innerHTML = + ' Lyra' + + ITEMS.map(function (it) { + return '' + + '' + it.icon + '' + it.label + ""; + }).join("") + + '
    ' + + ''; + document.body.insertBefore(nav, document.body.firstChild); + + // Settings opens the chat-page modal; from other pages, jump to chat and open it. + nav.querySelector("#navSettings").addEventListener("click", function () { + const btn = document.getElementById("settingsBtn"); + if (btn) btn.click(); + else location.href = "/?settings=1"; + }); +})(); diff --git a/lyra/web/static/recap.html b/lyra/web/static/recap.html index e257756..613067d 100644 --- a/lyra/web/static/recap.html +++ b/lyra/web/static/recap.html @@ -74,5 +74,6 @@ } load(); + diff --git a/lyra/web/static/self.html b/lyra/web/static/self.html index f959e8a..be0da2f 100644 --- a/lyra/web/static/self.html +++ b/lyra/web/static/self.html @@ -195,5 +195,6 @@ setInterval(refresh, 12000); document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); + diff --git a/lyra/web/static/session.html b/lyra/web/static/session.html index dbc674c..8b7c943 100644 --- a/lyra/web/static/session.html +++ b/lyra/web/static/session.html @@ -355,5 +355,6 @@ if (!SID) setInterval(refresh, 5000); // live HUD polls; a past session is static document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); + From 44a559c5f9ca0da285e4d20c27e74f4f34f81775 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 06:02:10 +0000 Subject: [PATCH 14/14] fix: render flat-logged hands + on-demand "build replay" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick-logged hands (log_hand) store flat fields with no structured JSON, so the hand viewer dead-ended with "no structured data to replay" β€” even when the full street-by-street action was captured (e.g. the KhQh-vs-Louis hand). Now: - hand.html renders a readable STATIC view of any flat hand (hero cards, board, street narratives, result, lesson) instead of erroring; also handles empty/garbage structured rows by falling back to the flat view. - "β–Ά Build replay" button + poker.reconstruct_hand + POST /hand/{id}/reconstruct: parse a flat hand's narrative into structured form on demand, making any quick-logged hand replayable without an LLM call per log during live play. - test_modes.py +1 (reconstruct wiring). (Also reconstructed the two live Meadows hands and removed one empty hand in the DB.) Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/poker.py | 32 ++++++++++++++++++++++++++++++ lyra/web/server.py | 7 +++++++ lyra/web/static/hand.html | 41 ++++++++++++++++++++++++++++++++++++++- tests/test_modes.py | 18 +++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/lyra/poker.py b/lyra/poker.py index 19f2e31..cc195bc 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -713,6 +713,38 @@ def record_hand(shorthand: str, session_id: int | None = None, stakes: str | Non return {"id": hid, "parsed": parsed, "linked": linked} +def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None: + """Upgrade a flat (log_hand) hand into a structured, replayable one by parsing + its captured street narratives. On-demand so quick-logged live hands can become + replayable without an LLM call per log during play.""" + h = get_hand(hand_id) + if not h: + return None + parts = [] + if h.get("position") or h.get("hole_cards"): + parts.append(f"Hero is {h.get('position') or '?'} with {h.get('hole_cards') or 'unknown'}.") + for st in ("preflop", "flop", "turn", "river", "showdown"): + if h.get(st): + parts.append(f"{st.capitalize()}: {h[st]}") + if h.get("board"): + parts.append(f"Final board: {h['board']}.") + if h.get("result") is not None: + parts.append(f"Hero net result: {h['result']}.") + shorthand = " ".join(parts).strip() + if not shorthand: + return None + parsed = parse_hand(shorthand, backend=backend) + if not parsed: + return None + parsed = _normalize_parsed(parsed) + conn = _c() + with conn: + conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?", + (json.dumps(parsed), hand_id)) + link_hand_players(hand_id, parsed, session_id=h.get("session_id")) + return {"id": hand_id, "parsed": parsed} + + def get_hand(hand_id: int) -> dict | None: """A stored hand with its structured JSON parsed back into a dict.""" r = _c().execute("SELECT * FROM poker_hands WHERE id = ?", (hand_id,)).fetchone() diff --git a/lyra/web/server.py b/lyra/web/server.py index 79785c9..9ad248b 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -278,6 +278,13 @@ def create_app() -> FastAPI: async def hand_data(hand_id: int) -> dict: return poker.get_hand(hand_id) or {} + @app.post("/hand/{hand_id}/reconstruct") + async def hand_reconstruct(hand_id: int) -> dict: + """Parse a flat (quick-logged) hand's narrative into a replayable structure.""" + out = await asyncio.to_thread(poker.reconstruct_hand, hand_id) + logbus.log("info", "hand reconstructed", id=hand_id, ok=out is not None) + return {"ok": out is not None} + @app.get("/hands") async def hands_page() -> FileResponse: return FileResponse(str(_STATIC / "hands.html")) diff --git a/lyra/web/static/hand.html b/lyra/web/static/hand.html index 6a9b05b..0b475e7 100644 --- a/lyra/web/static/hand.html +++ b/lyra/web/static/hand.html @@ -95,11 +95,50 @@ return `${r}${SUIT[s]}`; } const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join(''); + // Split a loose card string ("KhQh", "Qh Qc", "Tc 8s Js 6d", "Ax") into codes. + const parseCards = s => (String(s||'').match(/(10|[2-9TJQKA])[shdcx]/gi) || []); + + // Flat (quick-logged) hands have no structured replay β€” show a readable static + // view of everything that WAS captured, plus an on-demand "build replay". + function renderFlat(h){ + document.getElementById('sub').textContent = h.position || ''; + const hole = parseCards(h.hole_cards), board = parseCards(h.board); + const streets = [['Preflop',h.preflop],['Flop',h.flop],['Turn',h.turn],['River',h.river],['Showdown',h.showdown]] + .filter(x=>x[1]); + const canBuild = streets.length > 0; + document.getElementById('root').innerHTML = ` +
    +
    Hero ${esc(h.position||'')}${h.tag?' Β· '+esc(h.tag):''}
    +
    + ${hole.length?cards(hole):'?'}
    + ${board.length?`
    Board
    +
    ${cards(board)}
    `:''} +
    + ${streets.length?`
    ${streets.map(s=>`
    ${s[0]}${esc(s[1])}
    `).join('')}
    `:''} + ${h.result!=null?`
    Result
    +
    Hero net: ${h.result>=0?'+':''}${esc(h.result)}
    `:''} + ${h.lesson?`
    Lesson
    ${esc(h.lesson)}
    `:''} +
    + ${canBuild?'':''} +
    +

    + ${canBuild?'Quick-logged hand (static). Build replay to reconstruct a step-through.':'Quick-logged hand β€” limited detail captured.'}

    `; + const b = document.getElementById('build'); + if(b) b.onclick = async () => { + b.disabled = true; b.textContent = '… building'; + try{ + const r = await fetch(`/hand/${h.id}/reconstruct`,{method:'POST'}); + const d = await r.json(); + if(d.ok) location.reload(); else { b.disabled=false; b.textContent='β–Ά Build replay'; alert("Couldn't reconstruct this one."); } + }catch(e){ b.disabled=false; b.textContent='β–Ά Build replay'; alert('Failed: '+e.message); } + }; + } function render(h){ const sub = document.getElementById('sub'); const data = h.structured; - if(!data){ document.getElementById('root').innerHTML = '

    This hand has no structured data to replay.

    '; return; } + const hasReplay = data && (((data.players||[]).length) || ((data.actions||[]).length)); + if(!hasReplay){ renderFlat(h); return; } const players = (data.players||[]).slice(); // order so hero sits at the bottom diff --git a/tests/test_modes.py b/tests/test_modes.py index 0c2c21d..ff9d551 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -175,6 +175,24 @@ def test_session_state_readback(lyra): assert "great river fold" in out +def test_reconstruct_flat_hand(lyra, monkeypatch): + _, poker, _, _ = lyra + poker.start_session(stakes="1/3", buy_in=300) + hid = poker.log_hand(position="UTG", hole_cards="KhQh", + preflop="UTG raises, BTN calls", flop="Qd Qs Jc, bet, call", + river="Kd, all in, called", showdown="hero wins", result=225) + assert poker.get_hand(hid)["structured"] is None # flat (log_hand) β€” not replayable yet + monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: { + "hero_pos": "UTG", "hero_cards": ["Kh", "Qh"], + "players": [{"pos": "UTG"}], + "actions": [{"street": "preflop", "pos": "UTG", "action": "raise"}], + "board": ["Qd", "Qs", "Jc", "6d", "Kd"]}) + out = poker.reconstruct_hand(hid) + assert out is not None + h = poker.get_hand(hid) + assert h["structured"]["hero_pos"] == "UTG" and len(h["structured"]["actions"]) == 1 + + def test_undo_last_and_delete_entry(lyra): _, poker, modes, tools = lyra assert "undo_last" in modes.CASH.tools