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:
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 => `
|
||||||
|
|||||||
Reference in New Issue
Block a user