diff --git a/lyra/clock.py b/lyra/clock.py index 9a2d5b8..c8fc559 100644 --- a/lyra/clock.py +++ b/lyra/clock.py @@ -31,6 +31,12 @@ def _parse(iso: str) -> datetime: 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: """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*, diff --git a/lyra/modes.py b/lyra/modes.py index 175d7ee..a1c9f0d 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -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: • 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 \ -narrate, don't explain what logging is, don't ask permission — just do it. He says his \ -current stack → log_stack. He describes a hand → log_hand (terse) or record_hand (a full \ -hand he wants saved/replayable). A read on a player → add_read. A rebuy → add_buyin. This is \ -the quiet, fast half of the job; he shouldn't feel you working. +LOGGING IS THE JOB: if his message contains anything trackable, you MUST call the tool \ +FIRST, before you reply — every single time. Logging and talking are not either/or; do \ +BOTH. Never let a conversational reply take the place of the log. A described hand ALWAYS \ +gets logged, even mid-banter, even if he's just telling a story about it — don't skip the \ +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, \ stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \ diff --git a/lyra/poker.py b/lyra/poker.py index e215295..f4f83d8 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -16,7 +16,7 @@ import json import re from datetime import datetime, timezone -from lyra import llm, memory +from lyra import clock, llm, memory _SCHEMA = """ CREATE TABLE IF NOT EXISTS poker_sessions ( @@ -138,7 +138,8 @@ def _c(): 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"): + "ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT", + "ALTER TABLE poker_stack_log ADD COLUMN note TEXT"): try: conn.execute(ddl) except Exception: @@ -403,17 +404,18 @@ def add_buyin(amount: float, session_id: int | None = None) -> float: # --- 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).""" +def log_stack(amount: float, note: str | None = None, session_id: int | None = None) -> dict: + """Record Brian's current chip stack, optionally with the why ("card dead", + "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) 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()), + "INSERT INTO poker_stack_log (session_id, amount, note, created_at) VALUES (?, ?, ?, ?)", + (sid, float(amount), (note or "").strip() or None, _now()), ) return stack_state(sid) @@ -436,7 +438,7 @@ def stack_log(session_id: int | None = None) -> list[dict]: if sid is None: return [] 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,), ).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) --- +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]: """Players read this session, with their standing dossier fields.""" rows = _c().execute( @@ -1251,6 +1298,7 @@ def hud(session_id: int | None = None) -> dict | None: }, "hands": hands, "villains": _session_villains(sid), + "timeline": timeline(sid), "notes": notes, "rituals": { "alligator": alligator_active(sid), diff --git a/lyra/tools.py b/lyra/tools.py index 5ecbd94..74fc8c4 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -184,8 +184,9 @@ def _log_stack(args: dict, ctx: dict) -> str: amount = float(args.get("amount")) except (TypeError, ValueError): return "Give me a number for the stack." + note = (args.get("note") or "").strip() or None try: - st = poker.log_stack(amount) + st = poker.log_stack(amount, note=note) except ValueError: return "No live session — start one first, then I'll track your stack." net = st.get("net") @@ -519,8 +520,11 @@ TOOLS.update({ "log_stack", "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'). " - "Tracks his stack over time and his live net while he's still sitting.", - {"amount": {**_N, "description": "Current total chip stack, in dollars"}}, + "Tracks his stack over time and his live net while he's still sitting. Pass " + "`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"])}, "scar_note": {"handler": _scar_note, "spec": _f( "scar_note", diff --git a/lyra/web/static/session.html b/lyra/web/static/session.html index 8b7c943..a3b46eb 100644 --- a/lyra/web/static/session.html +++ b/lyra/web/static/session.html @@ -104,6 +104,16 @@ .big-empty { text-align: center; padding: 50px 20px; color: var(--fade); } .big-empty .ico { font-size: 2.4rem; } .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; }
@@ -219,6 +229,7 @@ } curSession = s; const stack = data.stack || {}; + const timeline = data.timeline || []; const hands = data.hands || []; const villains = data.villains || []; const notes = data.notes || []; @@ -277,6 +288,16 @@ ${stack.current == null ? 'No stack logged yet — tell Lyra your stack ("I\'m at 350").
' : ''} +📜 Timeline
+ ${timeline.length ? `Nothing yet tonight — the running log fills in as you play.
'} +Hands this session
${hands.length ? `