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:
+4
-3
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -40,6 +40,7 @@
|
||||
<div class="mobile-menu-section">
|
||||
<h4>Actions</h4>
|
||||
<button id="mobileSessionBtn">🎬 Session HUD</button>
|
||||
<button id="mobileHistoryBtn">📚 Past Sessions</button>
|
||||
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
|
||||
<button id="mobileFullLogBtn">⛶ Full Log</button>
|
||||
<button id="mobileJournalBtn">📔 Journal</button>
|
||||
@@ -81,6 +82,7 @@
|
||||
<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="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="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
|
||||
</div>
|
||||
@@ -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();
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<span class="dot" id="dot"></span>
|
||||
<h1>🎬 Session</h1>
|
||||
<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>
|
||||
<span class="updated" id="updated">—</span>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user