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:
+136
@@ -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"),
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user