6 Commits

Author SHA1 Message Date
serversdown abac42c344 fix: serial consolidation on GPU backends (was timing out the MI50)
summarize_all fanned out 8 concurrent workers, but the MI50 llama.cpp server runs
a single slot (--parallel 1). Firing 8 at once queued them, blew the client timeout
('summary retry … Request timed out'), and thrashed/cancelled the KV cache — wasted
compute and heat. Concurrency is now backend-aware: 8 for cloud, 1 for local/MI50.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 09:26:25 +00:00
serversdown 44bb8687f7 feat: log every LLM call at the router boundary (backend/model/tokens/ms)
Background MI50 work was invisible — the dream loop logs one line per cycle, so a
multi-minute consolidation or a chat-on-mi50 showed nothing while the GPU pegged.
Now complete/chat_call/chat_call_stream each emit 'llm call' (kind, backend, model,
~tokens) and 'llm done' (ms, output size, tools). Watch what's hitting any backend
live via journalctl --user -fu lyra-dream -u lyra-web. No signature change, so
test stubs that replace complete() are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 09:26:25 +00:00
serversdown cb4ed10c1a 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>
2026-06-27 07:26:14 +00:00
serversdown ba00530caf fix: report time in Brian's local timezone, not UTC
clock.stamp() (injected into her chat prompt via _now_note, and into reflection)
rendered UTC and ignored the configured timezone, so 'what time is it' answered in
UTC — hours off from his actual time, reading as 'she doesn't know the time'. Now
converts to config.timezone (America/New_York -> EDT/EST), UTC fallback if the zone
can't load. Storage stays UTC; this only changes what she reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:47:27 +00:00
serversdown 66dd880f93 feat: canonical structured-hand contract (Lyra->RTO transport)
Solidify hand histories into one versioned shape that gets stored, replayed, and
exported — the foundation the tap recorder will emit into and RTO consumes.

- normalize_structured(): single guarantee of the contract shape — canonical cards
  (unicode/10/case -> RankSuit tokens, unknown 'Ax'/'x' preserved), hero synced into
  players[] (RTO finds hero via pos==hero_pos), schema_version stamp, and a
  completeness summary so consumers skip suit-dependent math on partial hands.
  Idempotent; runs on store AND read (legacy rows conform on the way out).
- list_recent_hands: has_structured flag so the export/RTO knows which hands have a
  replayable body worth fetching.
- docs/HAND_HISTORY.md: the shared contract both repos cite (schema, conventions,
  ownership rule, one-way HTTP coupling, transport endpoints).
- replaces the narrow _normalize_parsed (unicode-only) everywhere.

Card format chosen: lists of 2-char tokens (unambiguous, matches what Lyra already
stores + the viewer reads). Unknowns kept + flagged rather than dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:36:11 +00:00
serversdown a7901a66ae fix: phone app view zoom corrects 2026-06-26 20:44:11 +00:00
11 changed files with 452 additions and 51 deletions
+72
View File
@@ -0,0 +1,72 @@
# Hand-history contract (Lyra → RTO)
The canonical structured shape for a poker hand. **Lyra owns hands** — it produces this
shape (LLM parser today; the tap recorder natively, going forward), stores it, replays it
in the viewer, and exports it. **RTO consumes it** over HTTP and never reaches into Lyra.
Ownership rule: whoever owns the data owns the tools that produce it. Lyra owns the hand
DB, the viewer, and the copilot loop, so hand capture lives here. RTO is a pure engine.
Coupling: **one arrow, Lyra → RTO, HTTP only.** RTO is a standalone service (solve /
exploit / estimate); Lyra POSTs to it when it wants analysis. No shared package, no shared
DB, no shared UI components. If RTO is down, Lyra skips analysis and nothing breaks.
## Schema (`schema_version: 1`)
```jsonc
{
"schema_version": 1,
"game": "NLH", // NLH | PLO | ...
"stakes": "1/3", // or null
"hero_pos": "BTN", // one of POSITIONS
"hero_cards": ["Ah", "Kh"], // convenience mirror of the hero's players[].cards
"players": [ // every player in the hand, incl. hero
{"pos": "BTN", "stack": 300, "name": "Hero", "cards": ["Ah","Kh"], "hero": true},
{"pos": "BB", "stack": 250, "name": "Sal", "cards": null} // cards: null unless shown
],
"actions": [ // one flat chronological list across all streets
{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 15},
{"street": "flop", "board": ["7d","2c","5h"]}, // a street begins with its board reveal
{"street": "flop", "pos": "BB", "action": "check"}
],
"board": ["7d","2c","5h"], // full final board, 05 cards
"result": {"pot": 40, "hero_net": 25, "summary": "one line"},
"completeness": {"cards": true, "board": true, "actions": true}
}
```
### Conventions (load-bearing)
- **Cards are lists of 2-char tokens**, `RankSuit`: rank in `23456789TJQKA` (ten = `T`),
suit in `c d h s` (lowercase). E.g. `["As","5d","2c"]`. RTO maps each token via
`pokercore.parse_card`. *(Chosen over space-joined strings: unambiguous, no re-splitting,
and it's what Lyra already stores + what the viewer reads.)*
- **Unknown cards are kept, not dropped:** `"Ax"` = known rank / unknown suit, `"x"` =
fully unknown card. The LLM parser emits these when Brian didn't state suits. The tap
recorder won't — it captures complete cards by construction — so `"x"` is an
import/parser-only concern.
- **`completeness`** tells a consumer what's safe to use: `cards`/`board` are `true` only
when every relevant card is fully specified (no `"x"`). RTO uses `false`-card hands for
positions/frequencies/pairs and skips suit-dependent math (flushes).
- **Hero appears in `players[]`** with `"hero": true` and is findable via `pos == hero_pos`.
`hero_cards` is a mirror for the viewer; `players[].cards` is the source of truth.
- **Positions:** `UTG UTG1 UTG2 MP LJ HJ CO BTN SB BB`.
- **Actions:** `post fold check call bet raise allin`. `amount` is a plain number (no `$`),
null for non-sized actions (fold/check). Street boards appear as `{street, board}` entries.
- **Streets:** `preflop flop turn river`.
`lyra/poker.py:normalize_structured()` is the single function that guarantees this shape.
It runs on store and on read, and is idempotent.
## Transport (HTTP, Lyra serves on :7078)
- `GET /hands/data?limit=N` → `{ "hands": [ {id, position, hole_cards, board, result, tag,
at, lesson, venue, stakes, has_structured}, ... ] }` — flat list for browsing. Use
`has_structured` to pick which hands have a replayable body worth fetching.
- `GET /hand/{id}/data` → the full hand row; `structured` is the object above (or `null`
for a flat quick-log that hasn't been reconstructed).
RTO's "Lyra bridge" (its `docs/estimator-design.md`, Phase B) walks `structured.actions`
to classify each villain decision into `checked_to` / `facing_bet` / `facing_raise`, and
uses shown `cards` + that street's `board` for board-relative categories. Everything that
walk needs is in the schema above.
+21 -2
View File
@@ -9,20 +9,39 @@ a long silence *means* to her is left to her own reflection, not prescribed here
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from lyra import config
def now() -> datetime: def now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _local_tz() -> ZoneInfo | timezone:
"""Brian's configured local zone (falls back to UTC if it can't be loaded)."""
try:
return ZoneInfo(config.load().timezone)
except Exception:
return timezone.utc
def _parse(iso: str) -> datetime: def _parse(iso: str) -> datetime:
dt = datetime.fromisoformat(iso) dt = datetime.fromisoformat(iso)
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, e.g. 'Wednesday, 17 Jun 2026, 01:50 UTC'.""" """Wall-clock stamp in Brian's local timezone, e.g.
return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC") 'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*,
so 'what time is it' answers in his time, not UTC."""
return (dt or now()).astimezone(_local_tz()).strftime("%A, %d %b %Y, %H:%M %Z")
def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None: def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None:
+49 -13
View File
@@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time
from typing import Iterator, Literal, TypedDict from typing import Iterator, Literal, TypedDict
import httpx import httpx
from openai import OpenAI from openai import OpenAI
from lyra import logbus
from lyra.config import load from lyra.config import load
@@ -18,30 +20,54 @@ class Message(TypedDict):
Backend = Literal["local", "cloud", "mi50"] Backend = Literal["local", "cloud", "mi50"]
def _approx_tok(messages: list) -> int:
"""Rough prompt size (chars/4) — enough to see what's loading a backend."""
total = 0
for m in messages or []:
if isinstance(m, dict) and isinstance(m.get("content"), str):
total += len(m["content"])
return total // 4
def _resolved_model(cfg, backend: Backend, model: str | None) -> str:
if backend == "cloud":
return model or cfg.cloud_model
if backend == "mi50":
return model or cfg.mi50_model
return model or cfg.local_model
def complete(messages: list[Message], backend: Backend = "local", model: str | None = None) -> str: def complete(messages: list[Message], backend: Backend = "local", model: str | None = None) -> str:
"""Generate a completion. `model` overrides the backend's default model """Generate a completion. `model` overrides the backend's default model
(used so live chat can run a stronger cloud model than bulk consolidation).""" (used so live chat can run a stronger cloud model than bulk consolidation)."""
cfg = load() cfg = load()
mdl = _resolved_model(cfg, backend, model)
logbus.log("info", "llm call", kind="complete", backend=backend, model=mdl, tok=_approx_tok(messages))
t0 = time.monotonic()
if backend == "cloud": if backend == "cloud":
if not cfg.openai_api_key: if not cfg.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is not set") raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=cfg.openai_api_key) client = OpenAI(api_key=cfg.openai_api_key)
resp = client.chat.completions.create(model=model or cfg.cloud_model, messages=messages) resp = client.chat.completions.create(model=mdl, messages=messages)
return resp.choices[0].message.content or "" out = resp.choices[0].message.content or ""
elif backend == "mi50":
if backend == "mi50":
# MI50 box runs an OpenAI-compatible llama.cpp server; key is unused. # MI50 box runs an OpenAI-compatible llama.cpp server; key is unused.
client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url) client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url)
resp = client.chat.completions.create(model=model or cfg.mi50_model, messages=messages) resp = client.chat.completions.create(model=mdl, messages=messages)
return resp.choices[0].message.content or "" out = resp.choices[0].message.content or ""
else:
resp = httpx.post(
f"{cfg.local_base_url}/api/chat",
json={"model": mdl, "messages": messages, "stream": False},
timeout=120,
)
resp.raise_for_status()
out = resp.json()["message"]["content"]
resp = httpx.post( logbus.log("info", "llm done", kind="complete", backend=backend,
f"{cfg.local_base_url}/api/chat", ms=int((time.monotonic() - t0) * 1000), out=len(out))
json={"model": model or cfg.local_model, "messages": messages, "stream": False}, return out
timeout=120,
)
resp.raise_for_status()
return resp.json()["message"]["content"]
def chat_call( def chat_call(
@@ -68,6 +94,8 @@ def chat_call(
kwargs: dict = {"model": mdl, "messages": messages} kwargs: dict = {"model": mdl, "messages": messages}
if tools: if tools:
kwargs["tools"] = tools kwargs["tools"] = tools
logbus.log("info", "llm call", kind="chat", backend=backend, model=mdl, tok=_approx_tok(messages))
t0 = time.monotonic()
msg = client.chat.completions.create(**kwargs).choices[0].message msg = client.chat.completions.create(**kwargs).choices[0].message
tcs = None tcs = None
if getattr(msg, "tool_calls", None): if getattr(msg, "tool_calls", None):
@@ -75,6 +103,9 @@ def chat_call(
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments} {"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
for tc in msg.tool_calls for tc in msg.tool_calls
] ]
logbus.log("info", "llm done", kind="chat", backend=backend,
ms=int((time.monotonic() - t0) * 1000), out=len(msg.content or ""),
tools=[t["name"] for t in tcs] if tcs else None)
return msg.model_dump(), tcs return msg.model_dump(), tcs
# local (Ollama): no tool-calling here — return plain content. # local (Ollama): no tool-calling here — return plain content.
@@ -105,6 +136,8 @@ def chat_call_stream(
kwargs: dict = {"model": mdl, "messages": messages, "stream": True} kwargs: dict = {"model": mdl, "messages": messages, "stream": True}
if tools: if tools:
kwargs["tools"] = tools kwargs["tools"] = tools
logbus.log("info", "llm call", kind="chat-stream", backend=backend, model=mdl, tok=_approx_tok(messages))
t0 = time.monotonic()
parts: list[str] = [] parts: list[str] = []
frags: dict[int, dict] = {} # tool-call fragments accumulated by index frags: dict[int, dict] = {} # tool-call fragments accumulated by index
for chunk in client.chat.completions.create(**kwargs): for chunk in client.chat.completions.create(**kwargs):
@@ -123,6 +156,9 @@ def chat_call_stream(
if tc.function and tc.function.arguments: if tc.function and tc.function.arguments:
slot["arguments"] += tc.function.arguments slot["arguments"] += tc.function.arguments
content = "".join(parts) content = "".join(parts)
logbus.log("info", "llm done", kind="chat-stream", backend=backend,
ms=int((time.monotonic() - t0) * 1000), out=len(content),
tools=[frags[i]["name"] for i in sorted(frags)] if frags else None)
if frags: if frags:
calls = [frags[i] for i in sorted(frags)] calls = [frags[i] for i in sorted(frags)]
assistant = { assistant = {
+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 \
+145 -23
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()]
@@ -651,38 +653,102 @@ def _review_session_id() -> int:
return int(cur.lastrowid) return int(cur.lastrowid)
# --- the canonical structured-hand contract (see docs/HAND_HISTORY.md) ---------
# This is the single shape that gets stored, replayed by the viewer, and exported to
# RTO. The LLM parser produces it today; the tap recorder will produce it natively.
HAND_SCHEMA_VERSION = 1
POSITIONS = ("UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB")
ACTION_VERBS = ("post", "fold", "check", "call", "bet", "raise", "allin")
STREETS = ("preflop", "flop", "turn", "river")
_SUIT_SYM = {"": "h", "": "d", "": "c", "": "s"} _SUIT_SYM = {"": "h", "": "d", "": "c", "": "s"}
def _norm_card(c): def _norm_card(c):
"""Canonicalize one card string: unicode suit -> letter, '10' -> 'T', rank upper,
suit lower (e.g. '10♥' -> 'Th', 'as' -> 'As'). Unknown placeholders are preserved:
'Ax' = known rank/unknown suit, 'x' = fully unknown card."""
if not isinstance(c, str): if not isinstance(c, str):
return c return c
s = c.strip() s = c.strip()
for sym, ltr in _SUIT_SYM.items(): for sym, ltr in _SUIT_SYM.items():
s = s.replace(sym, ltr) s = s.replace(sym, ltr)
s = s.replace("10", "T")
if len(s) == 2:
s = s[0].upper() + s[1].lower() # 'Ax' stays 'Ax'; 'x' (len 1) untouched
return s return s
def _normalize_parsed(p: dict) -> dict: def _card_known(c) -> bool:
"""Normalize card strings (unicode suits -> letters) across a parsed hand.""" """True only for a fully specified card (rank+suit, no 'x' placeholder)."""
if not isinstance(p, dict): return isinstance(c, str) and len(c) == 2 and "x" not in c.lower()
return p
for key in ("hero_cards", "board"):
if isinstance(p.get(key), list): def _completeness(p: dict) -> dict:
p[key] = [_norm_card(c) for c in p[key]] """Which parts of the hand are fully specified — lets a consumer (RTO) use what it
can and skip suit-dependent math (flushes) on hands where suits weren't recorded."""
shown = [c for pl in (p.get("players") or []) if isinstance(pl.get("cards"), list)
for c in pl["cards"]]
hole = list(p.get("hero_cards") or []) + shown
return {
"cards": bool(hole) and all(_card_known(c) for c in hole),
"board": all(_card_known(c) for c in (p.get("board") or [])),
"actions": bool(p.get("actions")),
}
def normalize_structured(parsed: dict) -> dict:
"""Canonicalize a structured hand — from the LLM parser OR (later) the tap recorder —
into the versioned contract shape: normalized cards, the hero synced into players[]
(RTO finds the hero via pos == hero_pos), a schema_version stamp, and a completeness
summary. Idempotent — the single shape stored, replayed, and exported."""
if not isinstance(parsed, dict):
return parsed
p = dict(parsed)
p["schema_version"] = HAND_SCHEMA_VERSION
p["hero_cards"] = [_norm_card(c) for c in (p.get("hero_cards") or [])]
p["board"] = [_norm_card(c) for c in (p.get("board") or [])]
players = []
for pl in p.get("players") or []: for pl in p.get("players") or []:
if isinstance(pl, dict) and isinstance(pl.get("cards"), list): if not isinstance(pl, dict):
continue
pl = dict(pl)
if isinstance(pl.get("cards"), list):
pl["cards"] = [_norm_card(c) for c in pl["cards"]] pl["cards"] = [_norm_card(c) for c in pl["cards"]]
pl.pop("hero", None) # recomputed below so it can't go stale
players.append(pl)
# Hero must appear in players[] (with cards) — RTO reads the hero off pos==hero_pos.
hero_pos = p.get("hero_pos")
if hero_pos:
hero = next((pl for pl in players if pl.get("pos") == hero_pos), None)
if hero is None:
hero = {"pos": hero_pos}
players.insert(0, hero)
hero["hero"] = True
if p["hero_cards"] and not hero.get("cards"):
hero["cards"] = list(p["hero_cards"])
p["players"] = players
actions = []
for a in p.get("actions") or []: for a in p.get("actions") or []:
if isinstance(a, dict) and isinstance(a.get("board"), list): if not isinstance(a, dict):
continue
a = dict(a)
if isinstance(a.get("board"), list):
a["board"] = [_norm_card(c) for c in a["board"]] a["board"] = [_norm_card(c) for c in a["board"]]
actions.append(a)
p["actions"] = actions
p["completeness"] = _completeness(p)
return p return p
def store_hand_history(parsed: dict, session_id: int | None = None, def store_hand_history(parsed: dict, session_id: int | None = None,
tag: str | None = None, lesson: str | None = None) -> int: tag: str | None = None, lesson: str | None = None) -> int:
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing.""" """Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
parsed = _normalize_parsed(parsed) parsed = normalize_structured(parsed)
sid = _resolve(session_id) or _review_session_id() sid = _resolve(session_id) or _review_session_id()
hero_cards = parsed.get("hero_cards") or [] hero_cards = parsed.get("hero_cards") or []
board = parsed.get("board") or [] board = parsed.get("board") or []
@@ -736,7 +802,7 @@ def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None:
parsed = parse_hand(shorthand, backend=backend) parsed = parse_hand(shorthand, backend=backend)
if not parsed: if not parsed:
return None return None
parsed = _normalize_parsed(parsed) parsed = normalize_structured(parsed)
conn = _c() conn = _c()
with conn: with conn:
conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?", conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?",
@@ -751,19 +817,29 @@ def get_hand(hand_id: int) -> dict | None:
if not r: if not r:
return None return None
d = dict(r) d = dict(r)
d["structured"] = json.loads(d["structured"]) if d.get("structured") else None # Normalize on read too: legacy rows predate the contract, and it's idempotent for
# new ones — so /hand/{id}/data always serves the current versioned shape.
d["structured"] = normalize_structured(json.loads(d["structured"])) if d.get("structured") else None
return d return d
def list_recent_hands(limit: int = 60) -> list[dict]: def list_recent_hands(limit: int = 60) -> list[dict]:
"""Recent recorded hands with their session's venue/stakes, for browsing.""" """Recent recorded hands with their session's venue/stakes, for browsing. Each carries
has_structured so a consumer (the export, RTO) knows which hands have a replayable
structured body worth fetching via /hand/{id}/data vs. flat quick-logs."""
rows = _c().execute( rows = _c().execute(
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, " "SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
"h.lesson, s.venue AS venue, s.stakes AS stakes " "h.lesson, (h.structured IS NOT NULL) AS has_structured, "
"s.venue AS venue, s.stakes AS stakes "
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id " "FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
"ORDER BY h.id DESC LIMIT ?", (limit,), "ORDER BY h.id DESC LIMIT ?", (limit,),
).fetchall() ).fetchall()
return [dict(r) for r in rows] out = []
for r in rows:
d = dict(r)
d["has_structured"] = bool(d["has_structured"])
out.append(d)
return out
# --- session recap (.md generation on top of structured data + conversation) --- # --- session recap (.md generation on top of structured data + conversation) ---
@@ -1105,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(
@@ -1177,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),
+9 -5
View File
@@ -129,16 +129,20 @@ def maybe_summarize_async(session_id: str, backend: Backend | None = None) -> No
def summarize_all( def summarize_all(
backend: Backend | None = None, limit: int | None = None, workers: int = 8 backend: Backend | None = None, limit: int | None = None, workers: int | None = None
) -> dict: ) -> dict:
"""Summarize every session that needs it. Idempotent and resumable. """Summarize every session that needs it. Idempotent and resumable.
LLM summarization runs concurrently across `workers` threads (great for a Concurrency is backend-aware: the cloud API parallelizes happily, but the
cloud backend). DB reads (loading transcripts) and writes (store_summary, local/MI50 GPU servers run a single slot (llama.cpp --parallel 1) firing N
which also embeds) happen on the main thread, so the single SQLite requests at them just queues, blows the client timeout, and thrashes the KV
connection is never touched from multiple threads. cache (wasted compute + heat). So GPU backends run serially unless overridden.
DB reads/writes (store_summary embeds) stay on the main thread, so the single
SQLite connection is never touched from multiple threads.
""" """
backend = backend or config.load().summary_backend backend = backend or config.load().summary_backend
if workers is None:
workers = 8 if backend == "cloud" else 1
# Main thread: collect the work (transcripts) for sessions needing a summary. # Main thread: collect the work (transcripts) for sessions needing a summary.
todo: list[tuple[str, str, int]] = [] todo: list[tuple[str, str, int]] = []
+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",
+10
View File
@@ -701,6 +701,16 @@
window.addEventListener("resize", nudgeAppHeight); window.addEventListener("resize", nudgeAppHeight);
window.addEventListener("orientationchange", nudgeAppHeight); window.addEventListener("orientationchange", nudgeAppHeight);
// A rotation reflows the chat and iOS drops the scroll to mid-history. If we
// were pinned to the latest message, snap back there once the layout settles
// (re-fire across the reflow since iOS reports stale dimensions mid-rotate).
window.addEventListener("orientationchange", () => {
const m = document.getElementById("messages");
const wasAtBottom = m.scrollHeight - m.scrollTop - m.clientHeight < 90;
if (!wasAtBottom) return; // respect the user's scroll-up position
[100, 300, 600].forEach((t) => setTimeout(() => { m.scrollTop = m.scrollHeight; }, t));
});
// Keep the latest message in view when the keyboard opens/closes. // Keep the latest message in view when the keyboard opens/closes.
const userInputEl = document.getElementById("userInput"); const userInputEl = document.getElementById("userInput");
userInputEl.addEventListener("focus", () => { userInputEl.addEventListener("focus", () => {
+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 => `
+4
View File
@@ -56,6 +56,10 @@ body.dark {
html { html {
overscroll-behavior: none; overscroll-behavior: none;
/* Stop iOS from inflating font sizes when the device rotates to landscape (and
leaving them big on rotate back). Every other page sets this; the chat didn't. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
} }
body { body {
+103
View File
@@ -0,0 +1,103 @@
"""The canonical structured-hand contract (docs/HAND_HISTORY.md): normalize + export.
normalize_structured() is the single guarantee that every stored / replayed / exported
hand has the versioned shape RTO consumes.
"""
from __future__ import annotations
import importlib
import pytest
@pytest.fixture
def poker(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
return poker
def _full_hand():
return {
"game": "NLH", "stakes": "1/3", "hero_pos": "BTN",
"hero_cards": ["ah", "kh"],
"players": [
{"pos": "BTN", "stack": 300, "name": "Hero"},
{"pos": "BB", "stack": 250, "name": "Sal", "cards": ["qs", "qd"]},
],
"actions": [
{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 15},
{"street": "flop", "board": ["7♦", "2♣", "5♥"]},
{"street": "flop", "pos": "BB", "action": "check"},
],
"board": ["7♦", "2♣", "5♥"],
"result": {"pot": 40, "hero_net": 25, "summary": "won at showdown"},
}
def test_stamps_version(poker):
out = poker.normalize_structured({"hero_pos": "CO"})
assert out["schema_version"] == poker.HAND_SCHEMA_VERSION
def test_card_normalization(poker):
out = poker.normalize_structured(_full_hand())
assert out["hero_cards"] == ["Ah", "Kh"] # lowercased input -> canonical
assert out["board"] == ["7d", "2c", "5h"] # unicode suits -> letters
assert out["actions"][1]["board"] == ["7d", "2c", "5h"]
# ten + suit symbol together
assert poker.normalize_structured({"board": ["10♠"]})["board"] == ["Ts"]
def test_unknown_cards_preserved(poker):
out = poker.normalize_structured({"hero_cards": ["Ax", "x"], "board": ["Ax", "4x", "x"]})
assert out["hero_cards"] == ["Ax", "x"] # placeholders kept, not dropped
assert out["completeness"]["cards"] is False
assert out["completeness"]["board"] is False
def test_hero_synced_into_players(poker):
out = poker.normalize_structured(_full_hand())
hero = next(p for p in out["players"] if p["pos"] == "BTN")
assert hero["hero"] is True
assert hero["cards"] == ["Ah", "Kh"] # mirrored from hero_cards
assert sum(1 for p in out["players"] if p.get("hero")) == 1
def test_hero_inserted_when_missing_from_players(poker):
out = poker.normalize_structured({"hero_pos": "SB", "hero_cards": ["As", "Ad"], "players": []})
assert out["players"] == [{"pos": "SB", "hero": True, "cards": ["As", "Ad"]}]
def test_completeness_full_hand(poker):
c = poker.normalize_structured(_full_hand())["completeness"]
assert c == {"cards": True, "board": True, "actions": True}
def test_idempotent(poker):
once = poker.normalize_structured(_full_hand())
twice = poker.normalize_structured(once)
assert once == twice
def test_store_and_get_roundtrip_is_normalized(poker):
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
hid = poker.store_hand_history(_full_hand(), session_id=sid, tag="well_played")
got = poker.get_hand(hid)["structured"]
assert got["schema_version"] == poker.HAND_SCHEMA_VERSION
assert got["board"] == ["7d", "2c", "5h"]
assert got["completeness"]["cards"] is True
def test_list_recent_hands_flags_structured(poker):
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
structured_id = poker.store_hand_history(_full_hand(), session_id=sid)
flat_id = poker.log_hand(session_id=sid, position="CO", hole_cards="Jc Jd")
rows = {r["id"]: r for r in poker.list_recent_hands()}
assert rows[structured_id]["has_structured"] is True
assert rows[flat_id]["has_structured"] is False