update to 0.2.0 stable #2

Merged
serversdown merged 51 commits from dev into main 2026-06-18 15:39:46 -04:00
7 changed files with 353 additions and 12 deletions
Showing only changes of commit 7b65f81d7e - Show all commits
+119 -8
View File
@@ -33,8 +33,9 @@ CREATE TABLE IF NOT EXISTS poker_sessions (
hours REAL,
mantra TEXT,
mood TEXT,
status TEXT NOT NULL DEFAULT 'live', -- live | closed
recap_md TEXT
status TEXT NOT NULL DEFAULT 'live', -- live | closed | review
recap_md TEXT,
chat_session_id TEXT -- links to the chat where it was played, for recap
);
CREATE TABLE IF NOT EXISTS poker_hands (
@@ -91,8 +92,10 @@ def _c():
if _ensured_for is not conn:
conn.executescript(_SCHEMA)
# Add columns introduced after a DB already had the tables (no-op if present).
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"):
try:
conn.execute("ALTER TABLE poker_hands ADD COLUMN structured TEXT")
conn.execute(ddl)
except Exception:
pass
_ensured_for = conn
@@ -106,20 +109,25 @@ def _now() -> str:
# --- sessions ---
def start_session(venue: str | None = None, stakes: str | None = None,
game: str = "NLH", fmt: str = "cash",
buy_in: float = 0.0, mantra: str | None = None) -> int:
game: str = "NLH", fmt: str = "cash", buy_in: float = 0.0,
mantra: str | None = None, chat_session_id: str | None = None) -> int:
"""Open a new live session. Returns its id."""
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO poker_sessions "
"(started_at, venue, game, stakes, format, buy_in_total, mantra, status) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'live')",
(_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra),
"(started_at, venue, game, stakes, format, buy_in_total, mantra, status, chat_session_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'live', ?)",
(_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra, chat_session_id),
)
return int(cur.lastrowid)
def get_session(session_id: int) -> dict | None:
r = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (session_id,)).fetchone()
return dict(r) if r else None
def live_session() -> dict | None:
"""The current open session, if any."""
r = _c().execute(
@@ -326,6 +334,109 @@ def get_hand(hand_id: int) -> dict | None:
return d
def list_recent_hands(limit: int = 60) -> list[dict]:
"""Recent recorded hands with their session's venue/stakes, for browsing."""
rows = _c().execute(
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
"h.lesson, s.venue AS venue, s.stakes AS stakes "
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
"ORDER BY h.id DESC LIMIT ?", (limit,),
).fetchall()
return [dict(r) for r in rows]
# --- session recap (.md generation on top of structured data + conversation) ---
_RECAP_PROMPT = """You are writing Brian's structured poker session log in Markdown, in his \
established format, from the session DATA and CONVERSATION provided. Output ONLY the Markdown \
— no preamble, no code fences.
Use these sections (skip any with no material; don't pad):
# YYYY-MM-DD — <venue + game/stakes>
## Session Header
* Date / Casino / Game & stakes / StartEnd / Buy-in(s) / Cash-out / Net result
## Money Flow
(totals; break out by variant if multiple games were played)
## Session Overview
(1-2 short narrative paragraphs)
## Timeline
(bullets of how it went)
## Key Hands
(### per notable hand — Action recap → brief analysis → **Assessment:** Well Played / Leak Candidate / Cooler / Confidence Bank)
## Table Dynamics & Villain Notes
(### per opponent — profile + exploit)
## Confidence Bank
(disciplined / good process plays)
## Scar Notes
(mistakes and study points)
## Mental Game Notes
## Final Assessment
(overall quality of play; biggest strength; biggest thing to improve; did the result match decision quality?)
Base everything on the actual data and conversation — do NOT invent hands, villains, or results. \
Address Brian as "you" or "Brian", coach-to-player. Be concise but complete."""
def _resolve_recap(session_id: int | None) -> int | None:
if session_id is not None:
return session_id
live = live_session()
if live:
return live["id"]
r = _c().execute(
"SELECT id FROM poker_sessions WHERE status = 'closed' ORDER BY id DESC LIMIT 1"
).fetchone()
return int(r["id"]) if r else None
def _hand_line(h: dict) -> str:
bits = [h.get("position"), h.get("hole_cards"),
(f"board {h['board']}") if h.get("board") else None,
(f"result {h['result']:+g}") if h.get("result") is not None else None,
(f"[{h['tag']}]") if h.get("tag") else None, h.get("lesson")]
return " | ".join(str(b) for b in bits if b)
def generate_recap(session_id: int | None = None, backend: str | None = None) -> dict | None:
"""Generate Brian's .md recap from a session's structured data + conversation, store it."""
backend = backend or "cloud"
sid = _resolve_recap(session_id)
if sid is None:
return None
s = get_session(sid)
hands = list_hands(sid)
reads = [dict(r) for r in _c().execute(
"SELECT seat, note FROM player_reads WHERE session_id = ?", (sid,)).fetchall()]
stats = session_stats(sid)
convo = ""
if s.get("chat_session_id"):
exs = [e for e in memory.history(s["chat_session_id"])
if (e.created_at or "") >= (s.get("started_at") or "")]
convo = "\n".join(f"{e.role}: {e.content}" for e in exs)[-12000:]
body = (
"SESSION DATA:\n"
f"- venue: {s.get('venue')} | game: {s.get('game')} | stakes: {s.get('stakes')} | format: {s.get('format')}\n"
f"- started: {s.get('started_at')} | ended: {s.get('ended_at')} | hours: {s.get('hours')}\n"
f"- buy-in total: {s.get('buy_in_total')} | cash out: {s.get('cash_out')} | net: {s.get('net')}\n"
f"- mantra: {s.get('mantra')} | mood: {s.get('mood')} | "
f"{stats.get('per_hour')}/hr | hands logged: {stats.get('hands_logged')} | tags: {stats.get('tags')}\n\n"
"HANDS:\n" + ("\n".join("- " + _hand_line(h) for h in hands) or "(none logged)") + "\n\n"
"READS:\n" + ("\n".join(f"- seat {r.get('seat')}: {r['note']}" for r in reads) or "(none)") + "\n\n"
"CONVERSATION DURING SESSION:\n" + (convo or "(none captured)")
)
md = llm.complete(
[{"role": "system", "content": _RECAP_PROMPT}, {"role": "user", "content": body}],
backend=backend,
)
conn = _c()
with conn:
conn.execute("UPDATE poker_sessions SET recap_md = ? WHERE id = ?", (md, sid))
return {"id": sid, "markdown": md}
# --- villain file ---
def upsert_player(name: str, venue: str | None = None, description: str | None = None,
+16
View File
@@ -90,6 +90,7 @@ def _start_session(args: dict, ctx: dict) -> str:
venue=args.get("venue"), stakes=args.get("stakes"),
game=args.get("game") or "NLH", fmt=args.get("format") or "cash",
buy_in=args.get("buy_in") or 0, mantra=args.get("mantra"),
chat_session_id=ctx.get("session_id"),
)
logbus.log("info", "poker session started", id=sid, stakes=args.get("stakes"))
return (f"Session #{sid} started — {args.get('stakes') or '?'} "
@@ -163,6 +164,15 @@ def _record_hand(args: dict, ctx: dict) -> str:
f"{cards}. View/replay it at /hand/{out['id']}")
def _generate_recap(args: dict, ctx: dict) -> str:
out = poker.generate_recap()
if not out:
return "No session to recap yet — start (and ideally finish) one first."
logbus.log("info", "recap generated", id=out["id"], chars=len(out["markdown"]))
return (f"Recap written for session #{out['id']} — view or download the .md "
f"at /recap/{out['id']}")
def _villain_file(args: dict, ctx: dict) -> str:
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
if not vs:
@@ -255,6 +265,12 @@ TOOLS.update({
"tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"},
"lesson": {**_S, "description": "Takeaway, if he stated one"}},
["shorthand"])},
"generate_recap": {"handler": _generate_recap, "spec": _f(
"generate_recap",
"Write up the full session recap (.md) in Brian's format from the logged "
"data + this conversation. Use when he asks for the recap/writeup, usually "
"after ending a session.",
{}, [])},
"get_villain_file": {"handler": _villain_file, "spec": _f(
"get_villain_file",
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
+27 -1
View File
@@ -14,7 +14,7 @@ import json
import time
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
@@ -150,6 +150,32 @@ def create_app() -> FastAPI:
async def hand_data(hand_id: int) -> dict:
return poker.get_hand(hand_id) or {}
@app.get("/hands")
async def hands_page() -> FileResponse:
return FileResponse(str(_STATIC / "hands.html"))
@app.get("/hands/data")
async def hands_data(limit: int = 60) -> dict:
return {"hands": poker.list_recent_hands(limit=limit)}
@app.get("/recap/{session_id}")
async def recap_page() -> FileResponse:
return FileResponse(str(_STATIC / "recap.html"))
@app.get("/recap/{session_id}/data")
async def recap_data(session_id: int) -> dict:
s = poker.get_session(session_id) or {}
return {"session": s, "markdown": s.get("recap_md")}
@app.get("/recap/{session_id}/download")
async def recap_download(session_id: int) -> Response:
s = poker.get_session(session_id) or {}
md = s.get("recap_md") or "# No recap generated yet\n"
date = (s.get("started_at") or "session")[:10]
fname = f"pokerlog_{date}_s{session_id}.md"
return Response(content=md, media_type="text/markdown",
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
@app.get("/stream/logs")
async def stream_logs(request: Request) -> StreamingResponse:
"""Live activity feed: replay the recent buffer, then stream new events."""
+84
View File
@@ -0,0 +1,84 @@
<!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="#0b0d12" />
<title>Lyra — Hands</title>
<style>
:root{--bg:#0b0d12;--bg-elev:#141821;--bg-line:#11141b;--border:#232936;--text:#e6e9ef;--fade:#8b93a7;--accent:#7aa2ff;}
*{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;}
a.hand{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--text);
background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
a.hand:active{background:#1b2333;}
.cards{display:flex;gap:4px;flex:none;}
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
width:24px;height:33px;background:#f4f4f0;color:#111;border-radius:4px;font-weight:700;font-size:.72rem;line-height:1;}
.card.red{color:#c8102e;} .card.unknown{background:#2a3550;color:#7c879e;}
.card .nosuit{color:#9aa3b5;}
.mid{flex:1;min-width:0;}
.ln1{font-size:.92rem;}
.ln2{font-size:.74rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.res{flex:none;font-variant-numeric:tabular-nums;font-weight:600;}
.pos-res{color:#5ad1a0;} .neg-res{color:#ff6b6b;}
.tag{font-size:.62rem;text-transform:uppercase;letter-spacing:.4px;color:var(--accent);}
.empty{color:var(--fade);text-align:center;padding:46px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>🃏 Hands</h1>
<a class="back" href="/">← Chat</a>
<span class="count" id="count"></span>
</div>
</header>
<main id="root"><p class="empty">Loading…</p></main>
<script>
const SUIT={s:"♠",h:"♥",d:"♦",c:"♣"}, RED=new Set(["h","d"]);
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function cardEl(code){
if(!code) return '';
const c=String(code).trim();
if(c.toLowerCase()==='x') return '<span class="card unknown">?</span>';
const m=c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
if(!m) return `<span class="card">${esc(c)}</span>`;
const r=m[1].toUpperCase().replace('10','T'), s=m[2].toLowerCase();
if(s==='x') return `<span class="card"><span>${r}</span><span class="nosuit">·</span></span>`;
return `<span class="card${RED.has(s)?' red':''}"><span>${r}</span><span>${SUIT[s]}</span></span>`;
}
const cards=str=>(str?String(str).trim().split(/\s+/):[]).map(cardEl).join('');
async function load(){
try{
const r=await fetch('/hands/data',{cache:'no-store'});
const hands=(await r.json()).hands||[];
document.getElementById('count').textContent=`${hands.length} hand${hands.length===1?'':'s'}`;
if(!hands.length){document.getElementById('root').innerHTML='<p class="empty">No hands recorded yet. Tell Lyra: "log this hand: …"</p>';return;}
document.getElementById('root').innerHTML=hands.map(h=>{
const res=h.result!=null?`<span class="res ${h.result>=0?'pos-res':'neg-res'}">${h.result>=0?'+':''}${h.result}</span>`:'';
const meta=[h.stakes,h.venue,(h.at||'').slice(0,10)].filter(Boolean).join(' · ');
const tag=h.tag?` · <span class="tag">${esc(h.tag)}</span>`:'';
return `<a class="hand" href="/hand/${h.id}">
<span class="cards">${cards(h.hole_cards)||'<span class="card unknown">?</span>'}</span>
<span class="mid">
<div class="ln1">${esc(h.position||'')} ${h.board?'· '+'<span class="cards" style="display:inline-flex">'+cards(h.board)+'</span>':''}</div>
<div class="ln2">${esc(meta)}${tag}</div>
</span>${res}</a>`;
}).join('');
}catch(e){document.getElementById('root').innerHTML='<p class="empty">Couldn\'t load hands.</p>';}
}
load();
</script>
</body>
</html>
+5
View File
@@ -39,6 +39,7 @@
<button id="mobileFullLogBtn">⛶ Full Log</button>
<button id="mobileMindBtn">🧠 Read Her Mind</button>
<button id="mobileJournalBtn">📔 Journal</button>
<button id="mobileHandsBtn">🃏 Hands</button>
<button id="mobileSettingsBtn">⚙ Settings</button>
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
@@ -74,6 +75,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="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>
<!-- Status -->
@@ -827,6 +829,9 @@
document.getElementById("mobileJournalBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/journal";
});
document.getElementById("mobileHandsBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/hands";
});
// Connect to the global live log on page load.
connectThinkingStream();
+78
View File
@@ -0,0 +1,78 @@
<!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="#0b0d12" />
<title>Lyra — Recap</title>
<style>
:root{--bg:#0b0d12;--bg-elev:#141821;--bg-line:#11141b;--border:#232936;--text:#e6e9ef;--fade:#8b93a7;--accent:#7aa2ff;}
*{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:12px 0;flex-wrap:wrap;}
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.dl{margin-left:auto;background:#1b2333;border:1px solid var(--border);color:var(--accent);
border-radius:8px;padding:7px 12px;font-size:.85rem;text-decoration:none;}
main{max-width:740px;margin:0 auto;padding:18px 16px 48px;line-height:1.6;}
h1,h2,h3,h4{line-height:1.3;color:var(--text);}
main>h1:first-child{margin-top:0;}
h2{font-size:1.18rem;border-bottom:1px solid var(--border);padding-bottom:5px;margin-top:26px;color:var(--accent);}
h3{font-size:1.04rem;margin-top:18px;}
ul{padding-left:22px;} li{margin:3px 0;}
strong{color:var(--text);} hr{border:none;border-top:1px solid var(--border);margin:20px 0;}
code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;font-size:.9em;}
.err{color:var(--fade);text-align:center;padding:46px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>📋 Recap</h1>
<a class="back" href="/">← Chat</a>
<a class="back" href="/hands">Hands</a>
<a class="dl" id="dl">⬇ .md</a>
</div>
</header>
<main id="root"><p class="err">Loading recap…</p></main>
<script>
const bt = String.fromCharCode(96);
function esc(s){return String(s==null?'':s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
function inline(s){
const codeRe = new RegExp(bt+"([^"+bt+"]+)"+bt,"g");
return esc(s).replace(codeRe,"<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g,"<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*/g,"$1<em>$2</em>");
}
function md(src){
const lines=String(src||"").replace(/\r\n/g,"\n").split("\n");
const out=[]; let list=null;
const flush=()=>{if(list){out.push("<ul>"+list.map(i=>"<li>"+inline(i)+"</li>").join("")+"</ul>");list=null;}};
for(const raw of lines){
const t=raw.replace(/\s+$/,""); let m;
if(!t.trim()){flush();continue;}
if(/^(-{3,}|\*{3,}|_{3,})$/.test(t.trim())){flush();out.push("<hr>");continue;}
if((m=t.match(/^(#{1,6})\s+(.*)$/))){flush();const n=m[1].length;out.push(`<h${n}>${inline(m[2])}</h${n}>`);continue;}
if((m=t.match(/^\s*[-*+]\s+(.*)$/))){(list=list||[]).push(m[1]);continue;}
flush();out.push("<p>"+inline(t)+"</p>");
}
flush(); return out.join("\n");
}
async function load(){
const id=location.pathname.split('/')[2];
document.getElementById('dl').href=`/recap/${id}/download`;
try{
const r=await fetch(`/recap/${id}/data`,{cache:'no-store'});
const d=await r.json();
if(!d.markdown){document.getElementById('root').innerHTML='<p class="err">No recap yet for this session. Ask Lyra to write one ("generate the recap").</p>';return;}
document.getElementById('root').innerHTML=md(d.markdown);
}catch(e){document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the recap.</p>';}
}
load();
</script>
</body>
</html>
+21
View File
@@ -95,6 +95,27 @@ def test_record_hand_tool_parses_and_stores(lyra, monkeypatch):
assert h["result"] == -300
def test_generate_recap(lyra, monkeypatch):
poker = lyra
from lyra import llm
monkeypatch.setattr(llm, "complete",
lambda messages, backend=None, model=None: "# Recap\n## Final Assessment\nGood session.")
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
poker.log_hand(position="BTN", hole_cards="AKs", result=180, tag="confidence")
poker.end_session(540, session_id=sid)
out = poker.generate_recap(session_id=sid)
assert out["id"] == sid and "Final Assessment" in out["markdown"]
assert "Recap" in poker.get_session(sid)["recap_md"]
def test_list_recent_hands(lyra):
poker = lyra
poker.start_session(stakes="1/3", buy_in=300)
poker.log_hand(position="CO", hole_cards="QQ", result=-50)
hh = poker.list_recent_hands()
assert hh and hh[0]["hole_cards"] == "QQ" and hh[0]["stakes"] == "1/3"
def test_poker_tools_dispatch(lyra):
from lyra import tools
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})