From e1e89c07e4bad12850d591dff3e05f2ead580f49 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 20 Jun 2026 00:21:48 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20poker=20session=20history=20=E2=80=94?= =?UTF-8?q?=20browse,=20delete,=20and=20Lyra=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers three gaps: no way to delete a single poker session (only clear_all), no way to browse past sessions, and Lyra could only see aggregate stats. - poker.list_sessions() (per-session summary + hand count + recap flag) and poker.delete_session() (removes a session + its hands/reads/observations/ stacks/rituals; keeps the persistent villain file). - /history page (date, stakes, venue, net, hours, recap link, per-row delete with confirm) + /history/data + DELETE /history/{id}. Nav links from chat + HUD. - recent_sessions read tool, added to the shared lookups so Lyra can answer "how'd my last few sessions go?" in either mode. - Delete is UI/CLI only — deliberately not a Lyra tool. - test_modes.py +2 (list/delete, recent_sessions); 44 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/modes.py | 7 ++- lyra/poker.py | 37 +++++++++++++ lyra/tools.py | 28 ++++++++++ lyra/web/server.py | 15 +++++ lyra/web/static/history.html | 104 +++++++++++++++++++++++++++++++++++ lyra/web/static/index.html | 5 ++ lyra/web/static/session.html | 1 + tests/test_modes.py | 29 ++++++++++ 8 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 lyra/web/static/history.html diff --git a/lyra/modes.py b/lyra/modes.py index 0214061..49e4ccb 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -31,9 +31,10 @@ class Mode: 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") +# Read-only poker lookups — safe in any mode, so "how am I running this year?", +# "what do we have on Round Mike?", or "how'd my last few sessions go?" all work +# even when we're just talking. +_LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessions") # Always-available core tools (her own agency: journaling/notes). _BASE = ("journal_write", "note") diff --git a/lyra/poker.py b/lyra/poker.py index 8c2a9a1..d0bea6b 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -205,6 +205,43 @@ def clear_all() -> dict: return counts +def list_sessions(limit: int | None = None, include_review: bool = False) -> list[dict]: + """Past + live sessions (newest first), each with a hand count + recap flag. + Excludes the standing 'Hand Reviews' bucket unless include_review.""" + sql = "SELECT * FROM poker_sessions" + if not include_review: + sql += " WHERE status != 'review'" + sql += " ORDER BY started_at DESC, id DESC" + if limit: + sql += f" LIMIT {int(limit)}" + rows = [dict(r) for r in _c().execute(sql).fetchall()] + for r in rows: + r["hands"] = _c().execute( + "SELECT COUNT(*) n FROM poker_hands WHERE session_id = ?", (r["id"],) + ).fetchone()["n"] + r["has_recap"] = bool(r.get("recap_md")) + return rows + + +def delete_session(session_id: int) -> dict: + """Delete one session and its hands/reads/observations/stack/rituals. Leaves the + persistent villain file (poker_players) intact. Returns rows removed per table.""" + conn = _c() + counts: dict[str, int] = {} + with conn: + for t in ("poker_hands", "player_observations", "player_reads", + "poker_stack_log", "poker_rituals"): + counts[t] = conn.execute( + f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,) + ).fetchone()["n"] + conn.execute(f"DELETE FROM {t} WHERE session_id = ?", (session_id,)) + counts["poker_sessions"] = conn.execute( + "SELECT COUNT(*) n FROM poker_sessions WHERE id = ?", (session_id,) + ).fetchone()["n"] + conn.execute("DELETE FROM poker_sessions WHERE id = ?", (session_id,)) + return counts + + def live_session() -> dict | None: """The current open session, if any.""" r = _c().execute( diff --git a/lyra/tools.py b/lyra/tools.py index 1afb114..8326f39 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -221,6 +221,27 @@ def _session_stats(args: dict, ctx: dict) -> str: f"{st['hands_logged']} hands logged (tags: {tags}).") +def _recent_sessions(args: dict, ctx: dict) -> str: + try: + n = int(args.get("limit") or 8) + except (TypeError, ValueError): + n = 8 + rows = poker.list_sessions(limit=n) + if not rows: + return "No sessions logged yet." + out = [] + for s in rows: + net = s.get("net") + netstr = (f"{net:+.0f}" if net is not None + else "live" if s.get("status") == "live" else "—") + hrs = f", {s['hours']:g}h" if s.get("hours") else "" + recap = " · recap" if s.get("has_recap") else "" + out.append(f"#{s['id']} {(s.get('started_at') or '')[:10]} " + f"{s.get('stakes') or '?'} {s.get('game') or ''} @ {s.get('venue') or '?'} " + f"— net {netstr}{hrs} ({s.get('hands', 0)} hands){recap}") + return "\n".join(out) + + def _running_stats(args: dict, ctx: dict) -> str: rs = poker.running_stats(stakes=args.get("stakes"), venue=args.get("venue"), game=args.get("game"), since=args.get("since")) @@ -432,6 +453,13 @@ TOOLS.update({ "confidence-bank entries so far. Use whenever he asks where he's at, what's in " "the bank, his stack or net, or if gator mode is on — answer from THIS, not memory.", {}, [])}, + "recent_sessions": {"handler": _recent_sessions, "spec": _f( + "recent_sessions", + "List Brian's recent poker sessions — date, stakes, venue, net, hours, hand " + "count. Use when he asks about past sessions, how recent ones went, or to find " + "a session to review. Answer from this, not memory.", + {"limit": {**_N, "description": "How many recent sessions (default 8)"}}, + [])}, "running_stats": {"handler": _running_stats, "spec": _f( "running_stats", "Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.", diff --git a/lyra/web/server.py b/lyra/web/server.py index 1f41377..444d1fc 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -113,6 +113,21 @@ def create_app() -> FastAPI: bundle = await asyncio.to_thread(poker.hud) return bundle or {"session": None} + @app.get("/history") + async def history_page() -> FileResponse: + """Browsable list of past poker sessions.""" + return FileResponse(str(_STATIC / "history.html")) + + @app.get("/history/data") + async def history_data(limit: int = 100, include_review: bool = False) -> dict: + return {"sessions": poker.list_sessions(limit=limit, include_review=include_review)} + + @app.delete("/history/{session_id}") + async def history_delete(session_id: int) -> dict: + removed = await asyncio.to_thread(poker.delete_session, session_id) + logbus.log("info", "poker session deleted", id=session_id, removed=removed) + return {"ok": True, "removed": removed} + @app.post("/v1/chat/completions") async def chat_completions(request: Request) -> dict: body = await request.json() diff --git a/lyra/web/static/history.html b/lyra/web/static/history.html new file mode 100644 index 0000000..167cf0c --- /dev/null +++ b/lyra/web/static/history.html @@ -0,0 +1,104 @@ + + + + + + + Lyra — Sessions + + + +
+
+

📚 Sessions

+ ← Chat + 🎬 Live + +
+
+

Loading…

+ + + + diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index e74be44..40ab89e 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -40,6 +40,7 @@

Actions

+ @@ -81,6 +82,7 @@ ⛶ Full Log 🎬 Session + 📚 Sessions 🧠 Mind 🃏 Hands
@@ -1081,6 +1083,9 @@ document.getElementById("mobileSessionBtn").addEventListener("click", () => { closeMobileMenu(); window.location.href = "/session"; }); + document.getElementById("mobileHistoryBtn").addEventListener("click", () => { + closeMobileMenu(); window.location.href = "/history"; + }); // Connect to the global live log on page load. connectThinkingStream(); diff --git a/lyra/web/static/session.html b/lyra/web/static/session.html index e46d4a5..f7cac0f 100644 --- a/lyra/web/static/session.html +++ b/lyra/web/static/session.html @@ -91,6 +91,7 @@

🎬 Session

← Chat + 📚 Sessions 🃏 Hands diff --git a/tests/test_modes.py b/tests/test_modes.py index c42662a..e5366b5 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -175,6 +175,35 @@ def test_session_state_readback(lyra): assert "great river fold" in out +def test_list_and_delete_session(lyra): + _, poker, _, tools = lyra + keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300) + poker.end_session(cash_out=400, session_id=keep) + drop = poker.start_session(venue="Bellagio", stakes="2/5", buy_in=500) + poker.log_hand(position="BTN", hole_cards="AKs", session_id=drop) + poker.log_stack(620, session_id=drop) + poker.log_ritual("scar", content="punt", session_id=drop) + + sessions = poker.list_sessions() + assert {s["id"] for s in sessions} == {keep, drop} + assert next(s for s in sessions if s["id"] == drop)["hands"] == 1 + + removed = poker.delete_session(drop) + assert removed["poker_sessions"] == 1 and removed["poker_hands"] == 1 + assert removed["poker_stack_log"] == 1 and removed["poker_rituals"] == 1 + assert {s["id"] for s in poker.list_sessions()} == {keep} # only the survivor + assert poker.get_session(drop) is None + + +def test_recent_sessions_tool(lyra): + _, poker, modes, tools = lyra + assert "recent_sessions" in modes.TALK.tools # available even when just talking + poker.import_session(date="2026-06-01", venue="Meadows", stakes="1/3", + buy_in_total=300, cash_out=520, hours=5) + out = tools.dispatch("recent_sessions", {}, {}) + assert "Meadows" in out and "+220" in out + + def test_rituals_require_live_session(lyra): _, poker, _, tools = lyra # tools degrade gracefully (no exception) when nothing is open