feat: session modes (Talk/Cash) + live session HUD

Lyra now switches register based on what she's doing at the table instead of
being a wishy-washy companion mid-session.

Modes (lyra/modes.py):
- Talk (default companion) + Cash (live cash copilot); a mode = prompt card +
  tool allow-list. Tool gating via tools.specs(allow=).
- Two-register Cash voice: act-first one-line logging when fed facts; full warm
  companion voice for strategy / tilt / mental game.
- mode persisted per chat session (new sessions.mode column); auto-switch into
  Cash when start_session fires; UI forces cloud backend in Cash (tools only
  fire there).

Stack tracking + HUD:
- log_stack tool + poker_stack_log table; live net while sitting (stack - buy-in).
- poker.hud() bundle; /session HUD page (stack sparkline, hands, villains, notes,
  stats) polling /session/data every 5s; Talk/Cash switcher + Session nav.

Endpoints: /session, /session/data, GET/POST /sessions/{id}/mode, /modes.
tests/test_modes.py (gating, mode roundtrip, stack/HUD); 36 tests green. v0.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 05:28:15 +00:00
parent d9f5055ec1
commit dfb6425395
14 changed files with 829 additions and 32 deletions
+136
View File
@@ -98,6 +98,16 @@ CREATE TABLE IF NOT EXISTS player_observations (
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id);
-- Stack-size log: one row per stack update Brian gives during a session. Lets the
-- HUD show current stack, live net while sitting, and a stack-over-time sparkline.
CREATE TABLE IF NOT EXISTS poker_stack_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
amount REAL NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_stacklog_session ON poker_stack_log(session_id);
"""
# Below this many observed hands, don't surface % stats (too small a sample).
@@ -212,6 +222,59 @@ def add_buyin(amount: float, session_id: int | None = None) -> float:
).fetchone()["buy_in_total"])
# --- stack tracking ---
def log_stack(amount: float, session_id: int | None = None) -> dict:
"""Record Brian's current chip stack. Returns {current, buy_in, net} where net
is his live net while sitting (current stack total bought in)."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
conn = _c()
with conn:
conn.execute(
"INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)",
(sid, float(amount), _now()),
)
return stack_state(sid)
def current_stack(session_id: int | None = None) -> float | None:
"""Most recently logged stack for a session, or None if none logged."""
sid = _resolve(session_id)
if sid is None:
return None
r = _c().execute(
"SELECT amount FROM poker_stack_log WHERE session_id = ? ORDER BY id DESC LIMIT 1",
(sid,),
).fetchone()
return float(r["amount"]) if r else None
def stack_log(session_id: int | None = None) -> list[dict]:
"""Full stack history for a session (oldest first) — the sparkline series."""
sid = _resolve(session_id)
if sid is None:
return []
return [dict(r) for r in _c().execute(
"SELECT amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
(sid,),
).fetchall()]
def stack_state(session_id: int | None = None) -> dict:
"""Current stack + buy-in + live net for a session (net None until a stack is logged)."""
sid = _resolve(session_id)
s = get_session(sid) if sid is not None else None
buy_in = float(s["buy_in_total"]) if s else 0.0
cur = current_stack(sid)
return {
"current": cur,
"buy_in": buy_in,
"net": (round(cur - buy_in, 2) if cur is not None else None),
}
def end_session(cash_out: float, mood: str | None = None,
session_id: int | None = None) -> dict:
"""Close a session: record cashout, compute net + hours. Returns the row."""
@@ -752,3 +815,76 @@ def running_stats(stakes: str | None = None, venue: str | None = None,
"per_hour": round(net / hours, 2) if hours else None,
"by_stake": by_stake,
}
# --- live session HUD (everything tracked in the current session, for the UI) ---
def _session_villains(sid: int) -> list[dict]:
"""Players read this session, with their standing dossier fields."""
rows = _c().execute(
"SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, "
"p.adjustment AS adjustment, "
"(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id "
" AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note "
"FROM poker_players p "
"WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads "
" WHERE session_id = ? AND player_id IS NOT NULL) "
"ORDER BY p.updated_at DESC",
(sid, sid),
).fetchall()
return [dict(r) for r in rows]
def hud(session_id: int | None = None) -> dict | None:
"""Everything tracked in the current (or given) session, for the live HUD.
Returns None when there's no session to show. The shape is presentation-ready:
header, stack (with sparkline series + live net), hands, villains seen, her
notes from the session window, and session stats.
"""
s = get_session(session_id) if session_id is not None else live_session()
if not s:
return None
sid = s["id"]
log = stack_log(sid)
state = stack_state(sid)
hands = [
{"id": h["id"], "position": h.get("position"), "hole_cards": h.get("hole_cards"),
"board": h.get("board"), "result": h.get("result"), "tag": h.get("tag"),
"at": h.get("at")}
for h in list_hands(sid)
]
# Notes she jotted during this session: journal/note entries since it started.
started = s.get("started_at") or ""
notes = [
{"created_at": j["created_at"], "kind": j["kind"], "content": j["content"]}
for j in memory.list_journal(kinds=("note", "journal"))
if (j["created_at"] or "") >= started
][:20]
stats = session_stats(sid)
# Context: how Brian runs at these stakes overall (closed sessions).
ctx = running_stats(stakes=s.get("stakes")) if s.get("stakes") else {}
return {
"session": {
"id": sid, "venue": s.get("venue"), "stakes": s.get("stakes"),
"game": s.get("game"), "format": s.get("format"),
"status": s.get("status"), "started_at": s.get("started_at"),
"buy_in_total": s.get("buy_in_total"),
},
"stack": {
"current": state["current"], "buy_in": state["buy_in"], "net": state["net"],
"log": log,
},
"hands": hands,
"villains": _session_villains(sid),
"notes": notes,
"stats": {
"hands_logged": stats.get("hands_logged", 0),
"tags": stats.get("tags", {}),
"context_per_hour": ctx.get("per_hour"),
},
}