feat: poker session history — browse, delete, and Lyra lookup

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 00:21:48 +00:00
parent 35c973df05
commit e1e89c07e4
8 changed files with 223 additions and 3 deletions
+4 -3
View File
@@ -31,9 +31,10 @@ class Mode:
tools: tuple[str, ...] # tool names offered in this mode (must exist in tools.TOOLS) 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 # Read-only poker lookups — safe in any mode, so "how am I running this year?",
# "what do we have on Round Mike?" works even when we're just talking. # "what do we have on Round Mike?", or "how'd my last few sessions go?" all work
_LOOKUPS = ("player_profile", "get_villain_file", "running_stats") # 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). # Always-available core tools (her own agency: journaling/notes).
_BASE = ("journal_write", "note") _BASE = ("journal_write", "note")
+37
View File
@@ -205,6 +205,43 @@ def clear_all() -> dict:
return counts 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: def live_session() -> dict | None:
"""The current open session, if any.""" """The current open session, if any."""
r = _c().execute( r = _c().execute(
+28
View File
@@ -221,6 +221,27 @@ def _session_stats(args: dict, ctx: dict) -> str:
f"{st['hands_logged']} hands logged (tags: {tags}).") 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: def _running_stats(args: dict, ctx: dict) -> str:
rs = poker.running_stats(stakes=args.get("stakes"), venue=args.get("venue"), rs = poker.running_stats(stakes=args.get("stakes"), venue=args.get("venue"),
game=args.get("game"), since=args.get("since")) 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 " "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.", "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": {"handler": _running_stats, "spec": _f(
"running_stats", "running_stats",
"Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.", "Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.",
+15
View File
@@ -113,6 +113,21 @@ def create_app() -> FastAPI:
bundle = await asyncio.to_thread(poker.hud) bundle = await asyncio.to_thread(poker.hud)
return bundle or {"session": None} 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") @app.post("/v1/chat/completions")
async def chat_completions(request: Request) -> dict: async def chat_completions(request: Request) -> dict:
body = await request.json() body = await request.json()
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#070707" />
<title>Lyra — Sessions</title>
<style>
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;
--fade:#8a8a8a;--accent:#ff7a00;--good:#8fd694;--low:#ff6b6b;--mid:#ffb347;}
*{box-sizing:border-box;}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
padding:env(safe-area-inset-top) 14px 0;}
.topbar{display:flex;align-items:center;gap:10px;padding:13px 0;}
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
main{max-width:640px;margin:0 auto;padding:12px 12px 40px;}
.summary{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;}
.pill{font-size:.8rem;color:var(--fade);background:var(--bg-elev);border:1px solid var(--border);
border-radius:999px;padding:4px 11px;} .pill b{color:var(--text);}
.row{display:flex;align-items:center;gap:12px;background:var(--bg-elev);border:1px solid var(--border);
border-radius:10px;padding:10px 12px;margin-bottom:8px;}
.row .body{flex:1;min-width:0;text-decoration:none;color:var(--text);}
.row .body:active{opacity:.7;}
.ln1{font-size:.95rem;} .ln1 .live{color:var(--accent);font-size:.7rem;border:1px solid var(--accent);
border-radius:999px;padding:0 6px;margin-left:6px;text-transform:uppercase;letter-spacing:.4px;}
.ln2{font-size:.76rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.net{flex:none;font-variant-numeric:tabular-nums;font-weight:700;}
.net.up{color:var(--good);} .net.down{color:var(--low);} .net.flat{color:var(--fade);}
.del{flex:none;background:none;border:1px solid var(--border);color:var(--fade);border-radius:8px;
padding:6px 9px;cursor:pointer;-webkit-tap-highlight-color:transparent;font-size:.9rem;}
.del:active{background:#3a1414;color:var(--low);border-color:var(--low);}
.empty{color:var(--fade);text-align:center;padding:46px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>📚 Sessions</h1>
<a class="back" href="/">← Chat</a>
<a class="back" href="/session">🎬 Live</a>
<span class="count" id="count"></span>
</div>
</header>
<main id="root"><p class="empty">Loading…</p></main>
<script>
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?'+$':n<0?'-$':'$')+Math.abs(n).toLocaleString();}
function netClass(v){return v==null?'flat':v>0?'up':v<0?'down':'flat';}
async function del(id, label){
if(!confirm(`Delete session ${label}? This removes its hands, reads, stacks and rituals. Can't be undone.`)) return;
try{
const r=await fetch(`/history/${id}`,{method:'DELETE'});
if(!r.ok) throw new Error('HTTP '+r.status);
load();
}catch(e){alert('Delete failed: '+e.message);}
}
async function load(){
const root=document.getElementById('root');
try{
const r=await fetch('/history/data',{cache:'no-store'});
const sessions=(await r.json()).sessions||[];
document.getElementById('count').textContent=`${sessions.length} session${sessions.length===1?'':'s'}`;
if(!sessions.length){root.innerHTML='<p class="empty">No sessions yet. Start one from chat in ♠ Cash mode.</p>';return;}
const closed=sessions.filter(s=>s.net!=null);
const totNet=closed.reduce((a,s)=>a+(s.net||0),0);
const totHrs=closed.reduce((a,s)=>a+(s.hours||0),0);
const summary=`<div class="summary">
<span class="pill"><b>${sessions.length}</b> sessions</span>
<span class="pill">net <b>${money(totNet)}</b></span>
${totHrs?`<span class="pill"><b>${totHrs.toFixed(1)}h</b></span>`:''}
${totHrs?`<span class="pill">${money(Math.round(totNet/totHrs))}/hr</span>`:''}
</div>`;
root.innerHTML=summary+sessions.map(s=>{
const title=[s.stakes,s.game].filter(Boolean).join(' ')||'Session';
const live=s.status==='live'?'<span class="live">live</span>':'';
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 net=s.net!=null?money(s.net):(s.status==='live'?'live':'—');
return `<div class="row">
<a class="body" href="${href}">
<div class="ln1">${esc(title)} <span style="color:var(--fade)">@ ${esc(s.venue||'?')}</span>${live}</div>
<div class="ln2">${esc(meta)}${s.has_recap?' · recap ✓':''}</div>
</a>
<span class="net ${netClass(s.net)}">${net}</span>
<button class="del" title="Delete session" onclick="del(${s.id}, '#${s.id} ${esc(title)}')">🗑</button>
</div>`;
}).join('');
}catch(e){root.innerHTML='<p class="empty">Couldn\'t load sessions.</p>';}
}
load();
</script>
</body>
</html>
+5
View File
@@ -40,6 +40,7 @@
<div class="mobile-menu-section"> <div class="mobile-menu-section">
<h4>Actions</h4> <h4>Actions</h4>
<button id="mobileSessionBtn">🎬 Session HUD</button> <button id="mobileSessionBtn">🎬 Session HUD</button>
<button id="mobileHistoryBtn">📚 Past Sessions</button>
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button> <button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
<button id="mobileFullLogBtn">⛶ Full Log</button> <button id="mobileFullLogBtn">⛶ Full Log</button>
<button id="mobileJournalBtn">📔 Journal</button> <button id="mobileJournalBtn">📔 Journal</button>
@@ -81,6 +82,7 @@
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button> <button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a> <a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
<a id="sessionBtn" href="/session" target="_blank" rel="noopener" title="Live session HUD" role="button">🎬 Session</a> <a id="sessionBtn" href="/session" target="_blank" rel="noopener" title="Live session HUD" role="button">🎬 Session</a>
<a id="historyBtn" href="/history" target="_blank" rel="noopener" title="Past sessions" role="button">📚 Sessions</a>
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a> <a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
<a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a> <a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
</div> </div>
@@ -1081,6 +1083,9 @@
document.getElementById("mobileSessionBtn").addEventListener("click", () => { document.getElementById("mobileSessionBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/session"; closeMobileMenu(); window.location.href = "/session";
}); });
document.getElementById("mobileHistoryBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/history";
});
// Connect to the global live log on page load. // Connect to the global live log on page load.
connectThinkingStream(); connectThinkingStream();
+1
View File
@@ -91,6 +91,7 @@
<span class="dot" id="dot"></span> <span class="dot" id="dot"></span>
<h1>🎬 Session</h1> <h1>🎬 Session</h1>
<a class="back" href="/">← Chat</a> <a class="back" href="/">← Chat</a>
<a class="back" href="/history" title="Past sessions">📚 Sessions</a>
<a class="back" href="/hands" title="All recorded hands">🃏 Hands</a> <a class="back" href="/hands" title="All recorded hands">🃏 Hands</a>
<span class="updated" id="updated"></span> <span class="updated" id="updated"></span>
</div> </div>
+29
View File
@@ -175,6 +175,35 @@ def test_session_state_readback(lyra):
assert "great river fold" in out 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): def test_rituals_require_live_session(lyra):
_, poker, _, tools = lyra _, poker, _, tools = lyra
# tools degrade gracefully (no exception) when nothing is open # tools degrade gracefully (no exception) when nothing is open