feat: session timeline (running log) + reliable live logging

She was logging stacks but skipping hands — the CASH card framed logging as one of
two registers, so she'd 'talk about' a hand instead of recording it (streaming
returns content OR tool calls). Fixes:

- CASH card: logging is mandatory and log-FIRST — trackable facts get the tool call
  before the reply, both not either/or, hands never skipped for conversation.
- log_stack gains a note ('card dead', 'doubled up vs the LAG') -> timeline context;
  tool spec + handler updated. Migration adds poker_stack_log.note.
- poker.timeline(): interleaves session start, stack updates (+context), hands
  (linkable), reads, and rituals chronologically in local time (clock.short()).
  Added to the hud() bundle.
- Session HUD: a 📜 Timeline card renders the running log with hand links — the
  '10:19pm start … 1:34a doubled up, $750 (hand)' view Brian wanted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 07:26:14 +00:00
parent ba00530caf
commit cb4ed10c1a
5 changed files with 101 additions and 16 deletions
+6
View File
@@ -31,6 +31,12 @@ def _parse(iso: str) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def short(iso_or_dt: str | datetime | None = None) -> str:
"""Local time-of-day like '10:45pm', for timeline rows."""
dt = _parse(iso_or_dt) if isinstance(iso_or_dt, str) else (iso_or_dt or now())
return dt.astimezone(_local_tz()).strftime("%-I:%M%p").lower()
def stamp(dt: datetime | None = None) -> str: def stamp(dt: datetime | None = None) -> str:
"""Wall-clock stamp in Brian's local timezone, e.g. """Wall-clock stamp in Brian's local timezone, e.g.
'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*, 'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*,
+11 -5
View File
@@ -67,11 +67,17 @@ _CASH_CARD = """You are copiloting Brian's LIVE cash game right now — you're a
a session is (or should be) open. You move between two registers depending on what he's doing: a session is (or should be) open. You move between two registers depending on what he's doing:
• HE HANDS YOU FACTS TO TRACK — his stack, a hand, a read on someone, a rebuy, a result. \ • HE HANDS YOU FACTS TO TRACK — his stack, a hand, a read on someone, a rebuy, a result. \
Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \ LOGGING IS THE JOB: if his message contains anything trackable, you MUST call the tool \
narrate, don't explain what logging is, don't ask permission — just do it. He says his \ FIRST, before you reply — every single time. Logging and talking are not either/or; do \
current stack → log_stack. He describes a hand → log_hand (terse) or record_hand (a full \ BOTH. Never let a conversational reply take the place of the log. A described hand ALWAYS \
hand he wants saved/replayable). A read on a player → add_read. A rebuy → add_buyin. This is \ gets logged, even mid-banter, even if he's just telling a story about it — don't skip the \
the quiet, fast half of the job; he shouldn't feel you working. hand because you're busy reacting to it. Then confirm in ONE short line ("$350 stack \
logged."). Don't narrate, don't explain logging, don't ask permission — just do it. \
Routing: current stack → log_stack (and pass `note` with the why if he gives one — "card \
dead", "doubled up vs the LAG"). A hand he describes → record_hand (a real, replayable \
hand) — prefer this over log_hand so it lands on his timeline with a link. A read on a \
player → add_read. A rebuy → add_buyin. A result/pot → it rides with the hand. This is the \
quiet, fast half of the job; he shouldn't feel you working, but it must always happen.
• HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING — tilted, steaming, card-dead, bored, \ • HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING — tilted, steaming, card-dead, bored, \
stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \ stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \
+56 -8
View File
@@ -16,7 +16,7 @@ import json
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from lyra import llm, memory from lyra import clock, llm, memory
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS poker_sessions ( CREATE TABLE IF NOT EXISTS poker_sessions (
@@ -138,7 +138,8 @@ def _c():
conn.executescript(_SCHEMA) conn.executescript(_SCHEMA)
# Add columns introduced after a DB already had the tables (no-op if present). # 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", for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"): "ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT",
"ALTER TABLE poker_stack_log ADD COLUMN note TEXT"):
try: try:
conn.execute(ddl) conn.execute(ddl)
except Exception: except Exception:
@@ -403,17 +404,18 @@ def add_buyin(amount: float, session_id: int | None = None) -> float:
# --- stack tracking --- # --- stack tracking ---
def log_stack(amount: float, session_id: int | None = None) -> dict: def log_stack(amount: float, note: str | None = None, session_id: int | None = None) -> dict:
"""Record Brian's current chip stack. Returns {current, buy_in, net} where net """Record Brian's current chip stack, optionally with the why ("card dead",
is his live net while sitting (current stack total bought in).""" "doubled up vs Sal") — that context becomes the session-timeline line. Returns
{current, buy_in, net} where net is his live net while sitting."""
sid = _resolve(session_id) sid = _resolve(session_id)
if sid is None: if sid is None:
raise ValueError("no live session") raise ValueError("no live session")
conn = _c() conn = _c()
with conn: with conn:
conn.execute( conn.execute(
"INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)", "INSERT INTO poker_stack_log (session_id, amount, note, created_at) VALUES (?, ?, ?, ?)",
(sid, float(amount), _now()), (sid, float(amount), (note or "").strip() or None, _now()),
) )
return stack_state(sid) return stack_state(sid)
@@ -436,7 +438,7 @@ def stack_log(session_id: int | None = None) -> list[dict]:
if sid is None: if sid is None:
return [] return []
return [dict(r) for r in _c().execute( return [dict(r) for r in _c().execute(
"SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", "SELECT id, amount, note, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
(sid,), (sid,),
).fetchall()] ).fetchall()]
@@ -1179,6 +1181,51 @@ def running_stats(stakes: str | None = None, venue: str | None = None,
# --- live session HUD (everything tracked in the current session, for the UI) --- # --- live session HUD (everything tracked in the current session, for the UI) ---
def timeline(session_id: int | None = None) -> list[dict]:
"""The session's running log: start, stack updates (+context), hands (linkable),
reads, and rituals, interleaved chronologically with local time-of-day stamps.
This is what Brian sees as the night's story — '10:45 start … 12:00a doubled up,
$750 (hand)'. Each entry: {time, at, kind, text, hand_id?, amount?, result?}."""
sid = _resolve(session_id)
if sid is None:
return []
s = get_session(sid) or {}
events: list[dict] = []
if s.get("started_at"):
bits = [s.get("stakes"), s.get("game"), f"at {s['venue']}" if s.get("venue") else None]
label = " ".join(b for b in bits if b)
events.append({"at": s["started_at"], "kind": "start",
"text": ("Session start — " + label) if label else "Session start"})
for r in stack_log(sid):
events.append({"at": r["created_at"], "kind": "stack",
"amount": r.get("amount"), "text": r.get("note") or "stack update"})
for h in list_hands(sid):
desc = " ".join(b for b in (h.get("position"), h.get("hole_cards")) if b)
events.append({"at": h["at"], "kind": "hand", "hand_id": h["id"],
"result": h.get("result"), "text": desc or "hand"})
for r in _c().execute(
"SELECT pr.created_at AS at, pr.seat AS seat, pr.note AS note, p.name AS name "
"FROM player_reads pr LEFT JOIN poker_players p ON p.id = pr.player_id "
"WHERE pr.session_id = ?", (sid,),
).fetchall():
who = r["name"] or (f"seat {r['seat']}" if r["seat"] else "villain")
events.append({"at": r["at"], "kind": "read", "text": f"Read — {who}: {r['note']}"})
for r in list_rituals(sid):
tag = f"[{r['classification']}] " if r.get("classification") else ""
events.append({"at": r["created_at"], "kind": r["kind"],
"text": tag + (r.get("content") or r["kind"]), "hand_id": r.get("hand_id")})
events.sort(key=lambda e: e["at"] or "")
for e in events:
e["time"] = clock.short(e["at"])
return events
def _session_villains(sid: int) -> list[dict]: def _session_villains(sid: int) -> list[dict]:
"""Players read this session, with their standing dossier fields.""" """Players read this session, with their standing dossier fields."""
rows = _c().execute( rows = _c().execute(
@@ -1251,6 +1298,7 @@ def hud(session_id: int | None = None) -> dict | None:
}, },
"hands": hands, "hands": hands,
"villains": _session_villains(sid), "villains": _session_villains(sid),
"timeline": timeline(sid),
"notes": notes, "notes": notes,
"rituals": { "rituals": {
"alligator": alligator_active(sid), "alligator": alligator_active(sid),
+7 -3
View File
@@ -184,8 +184,9 @@ def _log_stack(args: dict, ctx: dict) -> str:
amount = float(args.get("amount")) amount = float(args.get("amount"))
except (TypeError, ValueError): except (TypeError, ValueError):
return "Give me a number for the stack." return "Give me a number for the stack."
note = (args.get("note") or "").strip() or None
try: try:
st = poker.log_stack(amount) st = poker.log_stack(amount, note=note)
except ValueError: except ValueError:
return "No live session — start one first, then I'll track your stack." return "No live session — start one first, then I'll track your stack."
net = st.get("net") net = st.get("net")
@@ -519,8 +520,11 @@ TOOLS.update({
"log_stack", "log_stack",
"Record Brian's CURRENT total chip stack in the live session. Call whenever " "Record Brian's CURRENT total chip stack in the live session. Call whenever "
"he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). " "he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). "
"Tracks his stack over time and his live net while he's still sitting.", "Tracks his stack over time and his live net while he's still sitting. Pass "
{"amount": {**_N, "description": "Current total chip stack, in dollars"}}, "`note` with the WHY when he gives it ('card dead', 'doubled up vs the LAG') — "
"it becomes the line in his session timeline.",
{"amount": {**_N, "description": "Current total chip stack, in dollars"},
"note": {**_S, "description": "Optional context for the change, e.g. 'card dead', 'doubled up'"}},
["amount"])}, ["amount"])},
"scar_note": {"handler": _scar_note, "spec": _f( "scar_note": {"handler": _scar_note, "spec": _f(
"scar_note", "scar_note",
+21
View File
@@ -104,6 +104,16 @@
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); } .big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
.big-empty .ico { font-size: 2.4rem; } .big-empty .ico { font-size: 2.4rem; }
.big-empty a { color: var(--accent); text-decoration: none; } .big-empty a { color: var(--accent); text-decoration: none; }
/* running timeline */
ul.tl { list-style: none; margin: 0; padding: 0; }
ul.tl li { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--bg-line); align-items: baseline; font-size: .92rem; line-height: 1.4; }
ul.tl li:last-child { border-bottom: none; }
.tl-time { color: var(--fade); font-variant-numeric: tabular-nums; font-size: .78rem; min-width: 60px; flex: none; }
.tl-body { flex: 1; }
.tl-amt { margin-left: 6px; font-variant-numeric: tabular-nums; }
li.start .tl-body { color: var(--accent); font-weight: 600; }
li.scar .tl-body, li.confidence .tl-body { font-style: italic; }
.tl-body a.hand { color: var(--accent); text-decoration: none; white-space: nowrap; }
</style> </style>
</head> </head>
<body> <body>
@@ -219,6 +229,7 @@
} }
curSession = s; curSession = s;
const stack = data.stack || {}; const stack = data.stack || {};
const timeline = data.timeline || [];
const hands = data.hands || []; const hands = data.hands || [];
const villains = data.villains || []; const villains = data.villains || [];
const notes = data.notes || []; const notes = data.notes || [];
@@ -277,6 +288,16 @@
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''} ${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''}
</div> </div>
<div class="card">
<p class="label">📜 Timeline</p>
${timeline.length ? `<ul class="tl">${timeline.map(e => `
<li class="${esc(e.kind)}">
<span class="tl-time">${esc(e.time)}</span>
<span class="tl-body">${esc(e.text)}${e.amount != null ? ` <b class="tl-amt">${money(e.amount)}</b>` : ''}${e.result != null ? ` <span class="res ${e.result>=0?'up':'down'}">${signed(e.result)}</span>` : ''}${e.hand_id ? ` <a class="hand" href="/hand/${e.hand_id}">hand </a>` : ''}</span>
</li>`).join('')}</ul>`
: '<p class="empty">Nothing yet tonight — the running log fills in as you play.</p>'}
</div>
<div class="card"> <div class="card">
<p class="label">Hands this session</p> <p class="label">Hands this session</p>
${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => ` ${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => `