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
+15
View File
@@ -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()
+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">
<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();
+1
View File
@@ -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>