8 Commits

Author SHA1 Message Date
serversdown 924dc297d5 fix: recorder cancel left an empty black screen
mount() adds .rec-root (display:flex) to the overlay element, and that rule sat
after .rec-overlay's display:none — so removing .open didn't hide it. Use
.rec-overlay:not(.open){display:none} to outrank .rec-root by specificity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:55:03 +00:00
serversdown b6cdf799dc feat: straddle support in recorder
Add a straddle from any non-blind seat (default 2×BB) via a preflop control.
Recorded as a preflop post (fits the contract — order carries 'acts last'), tagged
straddle for the log label + removability; the UI-only flag is dropped from the
emitted structured hand. Blinds stay non-removable. Documented in HAND_HISTORY.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:49:50 +00:00
serversdown c52404fbb9 fix: add Cancel to recorder + safe-area insets (notch ate the ✕)
The header ✕ sat under the iPhone status bar/notch (overlay had no safe-area
padding), so there was no reachable way out without saving. Add a labeled Cancel
button next to Save, and pad the header/footer for the notch + home indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:40:32 +00:00
serversdown 4fd7eff7e9 fix: shelve recorder card picker for plain typed card entry
The tap picker wasn't working in practice. Replace it with simple text inputs for
hero/board/shown cards: case-insensitive, accepts 'ah kh' / 'AhKh' / '7d2c5h' /
'10h'. parseCards() tokenizes; the server's normalize_structured() canonicalizes
(case, 10->T, completeness). Also adds per-action remove (✕) in the street log.
Picker UI + bottom-sheet CSS removed; tap picker deferred to V2 (docs/RECORDER.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:35:13 +00:00
serversdown 2bd5b7fd26 fix: move recorder launch into bottom bar (Mind->Record in poker mode)
The floating +Hand button overlapped the chat send button. Replace it with a
Record tab that swaps in for Mind only in poker (cash) mode, via body.cash-mode
CSS toggle. Other modes keep Mind; Record stays hidden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:23:28 +00:00
serversdown 50bcb5533f feat: hand recorder v1 (tap-to-build, overlay) — recorder steps 2-4
Tap-to-build hand recorder per docs/RECORDER.md. Correctness by construction: every
tap writes a known value into a known slot, emitting the canonical structured contract
(server normalize_structured() is the shape authority, so the client is best-effort).

- recorder.js: mount-agnostic module (mounts into any container -> overlay now,
  standalone later, zero logic change). Pure buildStructured(state)->contract dict.
  Card picker: sticky-suit-or-rank-first + x (unknown suit) + unknown-card; lock resets
  per slot so it never bleeds between cards. Pre-fills from /session/data (stakes->blinds,
  stack->hero, villains->seated). Per-street action entry, manual street advance.
- recorder.css: full-screen overlay using app theme tokens; floating launcher.
- index.html: overlay div + script + launcher (chat/session stays mounted underneath).

Validated end-to-end: JS core builds a hand (sticky/rainbow/unknown cards all correct)
-> POST /hands -> normalized -> /hand/{id} replay round-trips identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:18:38 +00:00
serversdown f745ef43a1 feat: POST /hands endpoint + seat in HUD villains (recorder step 1)
- POST /hands: store a structured hand from the recorder; normalize_structured()
  (via store_hand_history) is the shape authority, so the client stays best-effort.
  Enriches villain dossiers from the recorded players, same as the parser path.
  Verified live: best-effort body -> normalized (version, completeness, hero sync).
- _session_villains: surface each player's latest seat so the recorder can
  auto-place known players on the table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:11:48 +00:00
serversdown d7f3ba330a docs: hand recorder design note (v1 core loop + card-entry UX)
Tap-to-build recorder: correctness by construction, reusable mount-agnostic module
(pure buildStructured core), pre-fill from live session state, emits the locked
structured-hand contract. V1 = core capture loop; card entry = contextual picker
with sticky-suit-or-rank-first flow + x/unknown. V2 = smart legal-action keypad.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:08:20 +00:00
15 changed files with 780 additions and 292 deletions
+3
View File
@@ -53,6 +53,9 @@ DB, no shared UI components. If RTO is down, Lyra skips analysis and nothing bre
- **Positions:** `UTG UTG1 UTG2 MP LJ HJ CO BTN SB BB`. - **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 `$`), - **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. null for non-sized actions (fold/check). Street boards appear as `{street, board}` entries.
A **straddle** is recorded as a preflop `post` at a non-blind position (typically 2×BB,
voluntary); preflop action starts left of it and it acts last, but that's reflected by
action *order*, not a distinct verb.
- **Streets:** `preflop flop turn river`. - **Streets:** `preflop flop turn river`.
`lyra/poker.py:normalize_structured()` is the single function that guarantees this shape. `lyra/poker.py:normalize_structured()` is the single function that guarantees this shape.
+148
View File
@@ -0,0 +1,148 @@
# Hand recorder — design note
A tap-to-build hand recorder. The point isn't "nicer input" — it's **correctness by
construction**: every tap writes a known action into a known slot, so there's no parse
step that can be wrong. It sidesteps the whole class of LLM-parse replay bugs. The text
parser stays for importing the backlog (old notes, Trilium, ChatGPT history); the recorder
is clean capture going forward.
Output is the canonical structured shape in [HAND_HISTORY.md](HAND_HISTORY.md) — so it
drops straight into the DB and the existing replay viewer, and flows to RTO unchanged.
## Principles
1. **Correctness by construction** — the UI only lets you build valid hands; the emitter
produces the contract shape; the server `normalize_structured()` is the final guarantee.
2. **Reusable module, mount-agnostic.** Decision: overlay first, swap to standalone if the
overlay fights the chat page — *reusing the code either way*. So the recorder is a
self-contained module mounted into a container element, with the **emit logic kept pure
(no DOM)**. Moving overlay → standalone is a re-mount, not a rewrite.
3. **Don't reinvent what Lyra knows.** Pre-fill from live session state.
## Architecture
```
lyra/web/static/recorder.js # the module: state machine + DOM shell + buildStructured()
lyra/web/static/recorder.css # scoped styles (full-screen table + keypad)
```
- `Recorder.mount(containerEl, { sessionId, onSave, onClose })` — instantiates into any
container. In V1 the container is a full-screen overlay `<div>` inside `index.html`
(chat/session page stays mounted underneath — a flip-over, not a route change). If that
proves janky, the *same module* mounts into `recorder.html` with zero logic changes.
- **Pure core, separable from DOM:** `buildStructured(state) -> structuredDict`. No element
access — takes the in-memory state, returns the contract object. This is the testable,
reusable heart; the DOM shell only reads/writes `state` and calls `buildStructured` on save.
## Pre-fill from live state (the "it already knows" feel)
Source: `GET /session/data` (→ `poker.hud`). Today it gives us:
- `session`: `venue`, `stakes`, `game`, `format`, `is_live`
- `stack.current` → hero's starting stack for the hand
- `villains[]`: `name`, `category`, `tendencies`, `last_note` (the players read this session)
Derived in the recorder:
- **Blinds** parsed from `stakes` ("1/3" → SB 1, BB 3) → auto-seed the `post` actions.
- **Hero stack** from `stack.current`.
**Two gaps to close as part of the build** (flagged, not yet done):
1. **Seats aren't in the HUD bundle.** `player_reads` *has* a `seat` column, but
`_session_villains()` doesn't select it — so we can name the villains but not place them.
Fix: add `seat` (latest read per player) to the villains payload, then auto-seat them.
Until then, V1 seats known villains in read-order and you assign positions by tapping.
2. **Hero position isn't tracked live** (button/seat moves every hand) — so `hero_pos` is a
per-hand tap, seeded to last-used. That's correct, not a gap to "fix", just noting it.
## In-memory state model
```js
state = {
meta: { game, stakes, venue, sessionId }, // from /session/data
blinds: { sb, bb }, // parsed from stakes
heroPos: "BTN", // tapped per hand
seats: [ { pos, name, stack, cards: null, in: true } ], // incl. hero seat
street: "preflop", // street currently being entered
board: { flop: [], turn: [], river: [] },
actions:[ { street, pos, action, amount } ], // appended as you tap
result: { pot: null, heroNet: null, summary: "" }
}
```
`buildStructured(state)`
| contract field | from |
|---|---|
| `hero_pos` | `state.heroPos` |
| `hero_cards` | the hero seat's `cards` |
| `players[]` | `state.seats` (`{pos,stack,name,cards}`; emitter doesn't set `hero`/version — server normalize does) |
| `actions[]` | `state.actions`, with a `{street, board}` reveal entry spliced in at each street boundary from `state.board` |
| `board` | `flop + turn + river` concatenated |
| `result` | `state.result` |
Client builds best-effort; **`store_hand_history()``normalize_structured()` is the
authority** (canonical cards, hero sync, `schema_version`, `completeness`). Keeps the
client dumb and the contract enforced in one place.
## Persistence
New endpoint (small, part of the build):
```
POST /hands body: { structured, session_id?, tag?, lesson? }
-> store_hand_history(structured, ...) -> { id }
```
On save: POST, then hand off to the existing viewer `/hand/{id}` to replay — which doubles
as the correctness check (what you tapped is exactly what replays).
## Scope
**V1 — core loop (chosen).** Seats + cards + per-street actions emitting valid structured
JSON. Manual street advance (a "next street" button + board entry), free bet-size entry
(type the number). Proves capture → store → replay end to end on the locked schema.
### Card entry (V1)
Contextual: tap a card slot (hero card, board square, "they showed") → a compact picker
pops at that slot. One picker holds **4 color-coded suits + 13 ranks + `x` + unknown-card**.
Whichever you tap first sets the flow for that card — no mode switch:
- **Suit first → it locks** (stays lit). Each subsequent rank tap places `rank+lockedSuit`
and auto-advances. Flush flop = `♥ T 8 5` (4 taps); suited hole = `♥ A K` (3 taps).
- **Rank first → card is pending a suit**; the next tap must be a suit (or `x`). Best for
rainbow/mixed. Locked suit stays in effect until a different suit is tapped.
- **`x`** = unknown suit → stores e.g. `Ax`; flips `completeness.cards` false so RTO skips
suit-dependent math. **Unknown-card** button = a villain card never shown (`x`).
No typing — lowercase tokens are the internal/contract format only; the player only ever
taps symbols. State: `lockedSuit` (nullable) + the active slot; auto-advance on complete.
**V2 — the smart keypad.** The contextual state machine layered on top: tracks whose turn
it is and the current bet, offers only legal actions (check vs call; bet/raise reveal size
presets ½/¾/pot/+1bb), auto-advances the street when action closes, tap-a-seat to set the
actor, one-tap "they showed [cards]" at showdown. ~610 taps, no typing. Built on the same
`state` + `buildStructured`, so V1's emitter doesn't change — V2 just drives `state` smarter.
## Layout sketch (full-screen overlay)
```
┌───────────────────────────── Record hand ─────────────── ✕ ┐
│ (CO) (BTN) │
│ (HJ) ◯ oval table ◯ (SB) │
│ (MP) (UTG) (BB·hero) │
│ board: [ 7d ][ 2c ][ 5h ] pot: 40 │
├──────────────────────────────────────────────────────────────┤
│ acting: BB [ fold ][ check ][ call ][ bet ][ raise ] │
│ amount: [ 15 ] [ ½ ][ ¾ ][ pot ][ +1bb ] (V2) │
│ [ ◀ prev street ] [ next street ▶ ] [ they showed… ] │
├──────────────────────────────────────────────────────────────┤
│ preflop: BTN raise 15 · BB call [ save & replay ] │
└──────────────────────────────────────────────────────────────┘
```
## Build order
1. `POST /hands` endpoint + add `seat` to the villains payload (server, small).
2. `recorder.js` skeleton: `mount()`, `state`, `buildStructured()` (pure).
3. Overlay shell in `index.html` (open button in session/cash mode) + `recorder.css`.
4. V1 capture flow → save → replay. Validate a real hand round-trips identically.
5. V2 smart keypad on top.
```
@@ -1,115 +0,0 @@
# Poker mode: message-type-specific prompts + dumb capture
- **Date:** 2026-06-28
- **Status:** Draft for review
- **Scope:** Poker (`poker_cash`) mode only. This is the template; the same pattern will later extend to Build/Explore/Study/Decide in a separate pass.
## Problem
In poker mode Lyra routes correctly but her replies are generic — "stringing poker words together." Evidence from the 2026-06-27 (Meadows) and 2026-06-28 (Wheeling) sessions in chat `sess-dff2s91c`:
- **Every turn is a coaching essay, including pure data.** A stack update (`Stack=$685`) draws 46 sentences of momentum filler ("keep that momentum rolling," "you've got this"). Bare facts don't need a brain.
- **She projects tilt/fatigue onto neutral facts.** "Table broke, it's 11:50pm" → three replies about "late-night fatigue-driven decisions… mental reset." Brian had to say *"I'm not upset… you seem to be reading me as tilted."* Cause: the `_route` mood nudge (`lyra/mind.py:328`) firing on non-mood messages.
- **She doesn't reason about bet intent.** On a hand where Brian flopped bottom set, turned a full house, value bet $40 and got 88 to fold, she said *"his fold… shows the power of representing something stronger… well executed."* That was a **value bet that got no value** — money left on the table — not a successful rep. She pattern-matched "bet → fold → good."
- **She never calls `analyze_spot`; she eyeballs.** The 77 multiway spot got *"a disciplined fold might have been the better path"* — pure hedge, no math, violating the persona's "never eyeball poker math" rule.
- **Even her best reply leaks bad poker logic.** The sharp Connie read included "limp-checking in position" — contradictory nonsense — proving "sounds sharp" ≠ "is correct."
Root cause: one large per-turn card (`_CASH_CARD`, `lyra/modes.py:66`) describes **traits** ("be decisive," "be present"). The model satisfies trait language with safe, flattering abstraction. Pure-data turns shouldn't reach the model at all; judgment turns need concrete response contracts, not adjectives.
## Goals
1. Pure data entry (stack, buy-in, cash-out) never touches the LLM.
2. Conversational turns get a **message-type-specific** prompt instead of one broad card.
3. Kill the false tilt/fatigue reads.
4. On hands, reason about **bet intent** and lean on `analyze_spot` — flag missed value, value-owning, and sizing rather than reflexively praising.
5. Fix the iOS-PWA bottom safe-area gap surfaced in testing.
## Non-goals
- Rewriting the other five modes (separate pass).
- Changing the tool handlers themselves (`poker.log_stack`, etc. already exist and are LLM-independent).
- An LLM-based classifier in v1 (the design leaves the seam for it; v1 ships heuristics).
- Itemized buy-in history (buy-ins remain a single `buy_in_total`).
## Architecture
Split poker input by whether it needs judgment.
- **Track 1 — dumb capture:** pure data → direct HTTP endpoint → tool handler. No prompt, no reply.
- **Track 2 — conversational:** anything needing a brain → a classifier tags the message type → a type-specific prompt fragment is injected in place of the giant card.
- **PWA fix:** correct the bottom safe-area inset.
### Deliverable 1 — Track 1: dumb capture (no LLM)
**Backend (new endpoints in `lyra/web/server.py`).** Today the *only* write path for a stack/buy-in is chat → tool-calling; no direct endpoint exists. Add:
- `POST /session/stack``poker.log_stack(amount)` → returns updated `stack_state()`. Server stamps the time.
- `POST /session/buyin``poker.add_buyin(amount)` → returns new `buy_in_total`.
- Cash-out reuses the existing `PATCH /session/{id}` with `{cash_out}` (already recomputes `net`).
Both resolve the live session via `poker._resolve(None)` and return `{"ok": ..., "stack": ...}`. On "no live session" return a clear error the UI can show.
**Frontend — the 2nd input box (`lyra/web/static/index.html`).** A slim quick-capture row **below the message input, above the bottom nav icons**. Behavior:
- Type a number (e.g. `685`) → `POST /session/stack` → on success, append a one-line confirmation to the **Live Log** (`$685 logged · 11:34pm`) and update the HUD. **No chat message, no LLM call, main message box untouched.**
- Scope: **stack only** in v1. Buy-in and cash-out live on the Session HUD widget below.
- Empty/non-numeric input is ignored (or shows inline hint). Decimal and `$`-prefixed input tolerated (`$685`, `685`).
**Frontend — HUD widget (`lyra/web/static/session.html`).** In the Stack card (`session.html:280`), mirroring the existing `saveEdit()` PATCH pattern (`session.html:192`): a stack field (`POST /session/stack`), a buy-in field (`POST /session/buyin`), and a cash-out field (existing PATCH). Each updates the sparkline on success.
### Deliverable 2 — Track 2: classifier + type fragments
**Classifier (`lyra/poker_classify.py`, new).** A function `classify(message: str) -> str` returning one of `HAND | STATUS | MENTAL | LOG | CHAT`. v1 is heuristic; the signature is the swappable seam — an LLM/MI50 implementation can replace the body later with no other changes.
Heuristic signals (first match wins, in priority order):
1. **HAND** — card-token regex (`\b[2-9TJQKA][shdc]\b` appearing ≥2×), or position tokens (UTG/MP/HJ/CO/BTN/SB/BB/"button"/"hijack"), or street words (flop/turn/river) combined with betting verbs (bet/raise/call/fold/check/shove/limp).
2. **MENTAL** — first-person feeling: "I feel", "I'm tilted/steaming/fried/tired/frustrated/confident", "in my head", "mental", "leak", "on tilt".
3. **STATUS** — time/venue/logistics with no cards: "table broke", "new table", "waiting for a seat", "seat opened", clock times, venue names.
4. **LOG** — prose money/result that slipped past the 2nd box: "I'm at 350", "stack is", "out for", "cashed", "rebought" with a number.
5. **CHAT** — default fallback (questions, open talk).
Ambiguous → `CHAT` (safe full-voice default).
**Pipeline integration (`lyra/mind.py`).** Add a `_classify` step to `PIPELINE` (after `_route`, before `_compose`) that sets `ctx.msg_type` when `ctx.mode.key == "poker_cash"`. In `build_messages` (`mind.py:137`), when in poker mode, inject the **type fragment** in the seat currently held by `mode.card` (`mind.py:152`) instead of the whole `_CASH_CARD`. Non-poker modes are unchanged.
**Fragment registry (new `lyra/poker_prompts.py`).** `POKER_FRAGMENTS: dict[str, str]` keyed by type. Each fragment is short and concrete (rules + response shape), not trait language. Persona guardrails ("never eyeball math," "don't invent reads") stay sourced from `personas/lyra.md` via `_persona_block`.
Response contracts:
- **HAND.** Steps, in order: (1) log it (`record_hand` preferred, or `log_hand`); (2) if the decision is close or equity matters, call `analyze_spot` — do **not** eyeball; (3) reason about **bet intent** — for each meaningful bet, name what it was for (value / bluff / protection) and whether it achieved that (a fold to a value bet = value lost — flag it; a call of a bluff = bluff failed); (4) name leaks plainly (value-owning, missed value, bad sizing); (5) one clear opinion. **No reflexive praise** — do not open with "nice hand." If a named villain is referenced, call `player_profile`/`get_villain_file` first; never invent a read. Talk in prose, not a listicle. Seed with 2 exemplars (below).
- **STATUS.** 12 sentences. Acknowledge, log a stack only if a bare number is present, then stop. **No coaching. No tilt/fatigue reads.**
- **MENTAL.** Full presence, real voice, an actual point of view. (This register already works — preserve it.)
- **LOG.** One line. `$317 logged.` Nothing else.
- **CHAT.** Full voice, a real opinion, no filler sign-offs.
**HAND exemplars** (embedded in the fragment):
> *Brian:* "Flopped bottom set with 22, turned a full house, bet $40 on the river, he folded 88."
> *Lyra:* "That's a value bet that got no value — 88 was never folding to a smaller stab. $40 into that pot was too much; ~$2025 gets called by the over-pair and the worse two-pairs. The hand's a monster, the leak is the sizing."
> *Brian:* "77 in a multiway pot, flop 4h5c3c, HJ bets 75, BTN shoves 74, I call. Turn/river check through, HJ wins with 64."
> *Lyra:* "Against a bet and a shove on 4-5-3 you're drawing thin — sets, two pair, and the made wheel are all ahead, and you block almost none of it. The stack-depth logic (he only had ~150 behind) is real, but that's a reason to fold and wait, not to call off light. This is the value-owning spot you flagged yourself."
**Pipeline fixes:**
- Remove/gate the `_route` mood nudge (`mind.py:328`) so a steady→"steady" / up→"hype" note is **not** appended on non-MENTAL messages. Mental-game register comes only from the MENTAL fragment.
- Trim the always-on mode-menu note (`mind.py:77`) inside poker mode — it dilutes every turn.
### Deliverable 3 — PWA bottom safe-area fix
The band below the nav icons is an iOS-PWA safe-area issue (likely `100vh` not accounting for `env(safe-area-inset-bottom)` / the home indicator). Fix the layout container and bottom nav in the web CSS so the app fills the viewport with the icons seated above the home indicator. Use the `building-ios-pwas` skill at implementation time.
## Testing / verification
- **Classifier unit tests:** feed the real user turns from both transcripts and assert the expected type (e.g. "Stack is at $373" → LOG/handled by box, "Button straddle… I limp UTG with 22…" → HAND, "table broke, it's 11:50pm" → STATUS, "I feel like I'm being mean when I raise" → MENTAL).
- **Endpoint tests:** `POST /session/stack` / `/session/buyin` against a live test session; assert the row is written, time stamped, and `stack_state()` updates; assert a clean error when no live session.
- **Transcript replay (eyeball eval):** run yesterday's + today's user turns through the new classifier + fragments and compare replies to the logged mush — confirm stack updates produce no essay, the $40 hand is flagged as missed value, and STATUS turns carry no tilt-reading.
- **PWA:** verify on the iOS PWA that the gap is gone and the new input box sits above the nav with the keyboard open.
## Out of scope / future
- LLM classifier behind the same interface (if heuristics prove brittle).
- Extending message-type fragments to the other five modes.
- Itemized buy-in history / per-table stack series.
- Buy-in/cash-out in the chat-page 2nd box (HUD only for v1).
+2 -21
View File
@@ -9,39 +9,20 @@ 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 in Brian's local timezone, e.g. """Wall-clock stamp, e.g. 'Wednesday, 17 Jun 2026, 01:50 UTC'."""
'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*, return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC")
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:
+9 -45
View File
@@ -2,13 +2,11 @@
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
@@ -20,54 +18,30 @@ 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=mdl, messages=messages) resp = client.chat.completions.create(model=model or cfg.cloud_model, messages=messages)
out = resp.choices[0].message.content or "" return 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=mdl, messages=messages) resp = client.chat.completions.create(model=model or cfg.mi50_model, messages=messages)
out = resp.choices[0].message.content or "" return resp.choices[0].message.content or ""
else:
resp = httpx.post( resp = httpx.post(
f"{cfg.local_base_url}/api/chat", f"{cfg.local_base_url}/api/chat",
json={"model": mdl, "messages": messages, "stream": False}, json={"model": model or cfg.local_model, "messages": messages, "stream": False},
timeout=120, timeout=120,
) )
resp.raise_for_status() resp.raise_for_status()
out = resp.json()["message"]["content"] return resp.json()["message"]["content"]
logbus.log("info", "llm done", kind="complete", backend=backend,
ms=int((time.monotonic() - t0) * 1000), out=len(out))
return out
def chat_call( def chat_call(
@@ -94,8 +68,6 @@ 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):
@@ -103,9 +75,6 @@ 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.
@@ -136,8 +105,6 @@ 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):
@@ -156,9 +123,6 @@ 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 = {
+5 -11
View File
@@ -67,17 +67,11 @@ _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. \
LOGGING IS THE JOB: if his message contains anything trackable, you MUST call the tool \ Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \
FIRST, before you reply — every single time. Logging and talking are not either/or; do \ narrate, don't explain what logging is, don't ask permission — just do it. He says his \
BOTH. Never let a conversational reply take the place of the log. A described hand ALWAYS \ current stack → log_stack. He describes a hand → log_hand (terse) or record_hand (a full \
gets logged, even mid-banter, even if he's just telling a story about it — don't skip the \ hand he wants saved/replayable). A read on a player → add_read. A rebuy → add_buyin. This is \
hand because you're busy reacting to it. Then confirm in ONE short line ("$350 stack \ the quiet, fast half of the job; he shouldn't feel you working.
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 \
+12 -58
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 clock, llm, memory from lyra import llm, memory
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS poker_sessions ( CREATE TABLE IF NOT EXISTS poker_sessions (
@@ -138,8 +138,7 @@ 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:
@@ -404,18 +403,17 @@ def add_buyin(amount: float, session_id: int | None = None) -> float:
# --- stack tracking --- # --- stack tracking ---
def log_stack(amount: float, note: str | None = None, session_id: int | None = None) -> dict: def log_stack(amount: float, session_id: int | None = None) -> dict:
"""Record Brian's current chip stack, optionally with the why ("card dead", """Record Brian's current chip stack. Returns {current, buy_in, net} where net
"doubled up vs Sal") — that context becomes the session-timeline line. Returns is his live net while sitting (current stack total bought in)."""
{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, note, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)",
(sid, float(amount), (note or "").strip() or None, _now()), (sid, float(amount), _now()),
) )
return stack_state(sid) return stack_state(sid)
@@ -438,7 +436,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, note, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", "SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
(sid,), (sid,),
).fetchall()] ).fetchall()]
@@ -1181,63 +1179,20 @@ 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(
"SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, " "SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, "
"p.adjustment AS adjustment, " "p.adjustment AS adjustment, "
"(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id " "(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 " " AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note, "
"(SELECT seat FROM player_reads r3 WHERE r3.player_id = p.id "
" AND r3.session_id = ? ORDER BY r3.id DESC LIMIT 1) AS seat "
"FROM poker_players p " "FROM poker_players p "
"WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads " "WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads "
" WHERE session_id = ? AND player_id IS NOT NULL) " " WHERE session_id = ? AND player_id IS NOT NULL) "
"ORDER BY p.updated_at DESC", "ORDER BY p.updated_at DESC",
(sid, sid), (sid, sid, sid),
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
@@ -1298,7 +1253,6 @@ 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),
+5 -9
View File
@@ -129,20 +129,16 @@ 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 | None = None backend: Backend | None = None, limit: int | None = None, workers: int = 8
) -> dict: ) -> dict:
"""Summarize every session that needs it. Idempotent and resumable. """Summarize every session that needs it. Idempotent and resumable.
Concurrency is backend-aware: the cloud API parallelizes happily, but the LLM summarization runs concurrently across `workers` threads (great for a
local/MI50 GPU servers run a single slot (llama.cpp --parallel 1) firing N cloud backend). DB reads (loading transcripts) and writes (store_summary,
requests at them just queues, blows the client timeout, and thrashes the KV which also embeds) happen on the main thread, so the single SQLite
cache (wasted compute + heat). So GPU backends run serially unless overridden. connection is never touched from multiple threads.
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]] = []
+3 -7
View File
@@ -184,9 +184,8 @@ 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, note=note) st = poker.log_stack(amount)
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")
@@ -520,11 +519,8 @@ 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. Pass " "Tracks his stack over time and his live net while he's still sitting.",
"`note` with the WHY when he gives it ('card dead', 'doubled up vs the LAG') — " {"amount": {**_N, "description": "Current total chip stack, in dollars"}},
"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",
+18
View File
@@ -339,6 +339,24 @@ def create_app() -> FastAPI:
async def hands_data(limit: int = 60) -> dict: async def hands_data(limit: int = 60) -> dict:
return {"hands": poker.list_recent_hands(limit=limit)} return {"hands": poker.list_recent_hands(limit=limit)}
@app.post("/hands")
async def hands_create(request: Request) -> dict:
"""Store a structured hand built by the recorder. Body:
{structured, session_id?, tag?, lesson?}. normalize_structured() (in
store_hand_history) is the authority on shape, so the client can be best-effort."""
body = await request.json()
structured = body.get("structured")
if not isinstance(structured, dict):
return {"ok": False, "error": "missing structured hand body"}
hid = await asyncio.to_thread(
poker.store_hand_history, structured,
session_id=body.get("session_id"), tag=body.get("tag"), lesson=body.get("lesson"),
)
# Enrich villain dossiers from the recorded players, same as the parser path.
await asyncio.to_thread(poker.link_hand_players, hid, structured, body.get("session_id"))
logbus.log("info", "hand recorded", id=hid, session=body.get("session_id"))
return {"ok": True, "id": hid}
@app.get("/recap/{session_id}") @app.get("/recap/{session_id}")
async def recap_page() -> FileResponse: async def recap_page() -> FileResponse:
return FileResponse(str(_STATIC / "recap.html")) return FileResponse(str(_STATIC / "recap.html"))
+21 -1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Lyra Core Chat</title> <title>Lyra Core Chat</title>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="/recorder.css" />
<!-- PWA --> <!-- PWA -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
@@ -129,7 +130,8 @@
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a> <a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a> <a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a> <a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a> <a class="tab tab-mind" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
<button class="tab tab-rec" id="recordTab" type="button"><span class="ti"></span><span class="tl">Record</span></button>
<button class="tab" id="moreTab" type="button"><span class="ti"></span><span class="tl">More</span></button> <button class="tab" id="moreTab" type="button"><span class="ti"></span><span class="tl">More</span></button>
</nav> </nav>
</div> </div>
@@ -1245,5 +1247,23 @@
}); });
</script> </script>
<script src="/nav.js"></script> <script src="/nav.js"></script>
<!-- Hand recorder (overlay; chat/session stays mounted underneath) -->
<div id="recorderOverlay" class="rec-overlay"></div>
<script src="/recorder.js"></script>
<script>
(function () {
var overlay = document.getElementById("recorderOverlay");
var recordTab = document.getElementById("recordTab");
function close() { overlay.classList.remove("open"); overlay.innerHTML = ""; }
if (recordTab) recordTab.addEventListener("click", function () {
overlay.innerHTML = "";
overlay.classList.add("open");
window.Recorder.mount(overlay, {
onClose: close,
onSave: function (id) { close(); window.open("/hand/" + id, "_blank"); }
});
});
})();
</script>
</body> </body>
</html> </html>
+117
View File
@@ -0,0 +1,117 @@
/* Hand recorder overlay. Uses the app theme tokens (--accent, --bg-* etc.) from
style.css when mounted in index.html. For a standalone recorder.html, import those
tokens too (see :root in style.css). */
.rec-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--bg-dark, #070707);
flex-direction: column;
}
/* :not(.open) outranks .rec-root's display:flex (added on mount), so closing the
overlay actually hides it instead of leaving an empty black screen. */
.rec-overlay:not(.open) { display: none; }
.rec-overlay.open { display: flex; }
.rec-root {
display: flex;
flex-direction: column;
height: 100%;
color: var(--text-main, #e8e8e8);
font-family: var(--font-console, ui-monospace, monospace);
}
.rec-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
padding-top: calc(12px + env(safe-area-inset-top)); /* clear the notch/status bar */
border-bottom: 1px solid var(--border, #2a1d12);
}
.rec-title { font-weight: 700; color: var(--accent, #ff7a00); }
.rec-meta { color: var(--text-fade, #8a8a8a); font-size: .82rem; flex: 1; }
.rec-x {
background: none; border: 1px solid var(--border, #2a1d12); color: var(--text-fade, #8a8a8a);
border-radius: 8px; width: 34px; height: 34px; font-size: 1rem;
}
.rec-body { flex: 1; overflow-y: auto; padding: 12px 14px 20px; -webkit-overflow-scrolling: touch; }
.rec-sec { margin-bottom: 18px; }
.rec-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .6px; color: var(--text-fade, #8a8a8a); margin-bottom: 6px; }
.rec-dim { color: var(--text-fade, #8a8a8a); font-size: .85rem; }
.rec-pos-row { display: flex; flex-wrap: wrap; gap: 6px; }
.rec-pos {
min-width: 44px; padding: 9px 10px; border-radius: 9px;
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8);
border: 1px solid var(--border, #2a1d12); font-size: .82rem; font-weight: 600;
}
.rec-pos.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
.rec-pos.add { color: var(--text-fade, #8a8a8a); border-style: dashed; }
.rec-hero-cards { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.rec-seats { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
.rec-seat { display: flex; align-items: center; gap: 8px; }
.rec-seat-pos { min-width: 42px; font-weight: 700; color: var(--gold, #ffb347); }
.rec-name {
flex: 1; min-width: 0; padding: 8px 9px; border-radius: 8px;
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .85rem;
}
.rec-rm { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .9rem; padding: 4px; }
/* typed card entry */
.rec-field { display: block; margin-top: 10px; }
.rec-cards {
width: 100%; padding: 10px 11px; border-radius: 8px;
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .95rem;
letter-spacing: 1px; box-sizing: border-box;
}
.rec-cards.sm { width: 88px; flex: none; padding: 8px 9px; font-size: .85rem; }
.rec-street-tabs { display: flex; gap: 6px; margin-bottom: 8px; }
.rec-tab {
flex: 1; padding: 9px 6px; border-radius: 9px; font-size: .78rem; font-weight: 600;
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12);
}
.rec-tab.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
.rec-act-add { display: flex; gap: 6px; margin: 8px 0; }
.rec-sel, .rec-num {
padding: 9px 8px; border-radius: 8px; background: var(--bg-elev, #0e0e0e);
border: 1px solid var(--border, #2a1d12); color: var(--text-main, #e8e8e8);
font-family: inherit; font-size: .85rem; min-width: 0;
}
.rec-sel { flex: 1; }
.rec-num { width: 70px; }
.rec-add-act { padding: 9px 12px; border-radius: 8px; background: var(--border-bright, #4a2f15); color: #fff; border: none; font-weight: 600; }
.rec-result { display: flex; gap: 12px; }
.rec-result label { display: flex; flex-direction: column; gap: 4px; font-size: .72rem; color: var(--text-fade, #8a8a8a); }
.rec-log { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
.rec-ln { font-size: .82rem; color: var(--text-main, #e8e8e8); }
.rec-ln.brd { display: flex; gap: 4px; align-items: center; color: var(--text-fade, #8a8a8a); }
.rec-ln b { color: var(--accent, #ff7a00); font-weight: 700; }
.rec-foot {
display: flex; gap: 10px;
padding: 12px 14px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border, #2a1d12);
}
.rec-save { flex: 1; padding: 14px; border-radius: 10px; background: var(--accent, #ff7a00); color: #111; border: none; font-weight: 700; font-size: 1rem; }
.rec-save:disabled { opacity: .6; }
.rec-cancel { padding: 14px 18px; border-radius: 10px; background: var(--bg-elev, #0e0e0e); color: var(--text-fade, #8a8a8a); border: 1px solid var(--border, #2a1d12); font-weight: 600; font-size: 1rem; }
.rec-undo { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .75rem; padding: 0 4px; }
/* The Record tab swaps in for Mind in the bottom bar, but only in poker (cash) mode.
body.cash-mode is toggled on mode change in index.html. */
#tabbar .tab-rec { display: none; }
body.cash-mode #tabbar .tab-rec { display: flex; }
body.cash-mode #tabbar .tab-mind { display: none; }
#tabbar .tab-rec .ti { color: var(--accent, #ff7a00); filter: none; }
+425
View File
@@ -0,0 +1,425 @@
/* Hand recorder tap-to-build poker hands. See docs/RECORDER.md.
*
* Correctness by construction: each field writes a known value into a known slot,
* so there's no LLM parse step that can be wrong. Output is the canonical structured
* contract (docs/HAND_HISTORY.md); the server's normalize_structured() is the final
* authority on shape (case, suits, 10->T, completeness), so this stays best-effort.
*
* Mount-agnostic: Recorder.mount(container, opts) renders into ANY element a
* full-screen overlay in index.html today, a standalone recorder.html later, with
* zero logic changes. buildStructured(state) is pure (no DOM) the reusable core.
*
* Card entry: plain typed text for now ("ah kh", "AhKh", "7d 2c 5h"). The tap picker
* is shelved (docs/RECORDER.md V2) parseCards() + server normalize handle the rest.
*/
(function () {
"use strict";
const SUITS = { s: "♠", h: "♥", d: "♦", c: "♣" };
const POSITIONS = ["UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB"];
const STREETS = ["preflop", "flop", "turn", "river"];
const STREET_BOARD = { flop: 3, turn: 1, river: 1 };
const ACTIONS = ["fold", "check", "call", "bet", "raise", "allin"];
const SIZED = { bet: true, raise: true, allin: true };
// --- card text -> tokens (server normalizes case/suit/10) ------------------
function parseCards(str) {
if (!str) return [];
const s = String(str).trim().replace(/10/g, "T");
if (!s) return [];
const parts = /\s/.test(s) ? s.split(/\s+/) : s.match(/.{1,2}/g) || [];
return parts.map((p) => p.trim()).filter(Boolean);
}
function cardsText(arr) {
return arr && arr.length ? arr.join(" ") : "";
}
// --- pure core: state -> contract dict (testable, no DOM) ------------------
function buildStructured(state) {
const players = state.seats
.filter((s) => s.pos)
.map((s) => {
const p = { pos: s.pos };
if (s.stack != null) p.stack = s.stack;
if (s.name) p.name = s.name;
p.cards = s.cards && s.cards.length ? s.cards.slice() : null;
return p;
});
const actions = [];
for (const st of STREETS) {
const reveal = state.board[st];
if (st !== "preflop" && reveal && reveal.length) {
actions.push({ street: st, board: reveal.slice() });
}
for (const a of state.actions.filter((x) => x.street === st)) {
actions.push({
street: st,
pos: a.pos,
action: a.action,
amount: a.amount != null ? a.amount : null,
});
}
}
const hero = state.seats.find((s) => s.pos === state.heroPos);
const board = [].concat(state.board.flop, state.board.turn, state.board.river);
return {
game: state.meta.game || "NLH",
stakes: state.meta.stakes || null,
hero_pos: state.heroPos || null,
hero_cards: hero && hero.cards ? hero.cards.slice() : [],
players,
actions,
board,
result: {
pot: state.result.pot,
hero_net: state.result.heroNet,
summary: state.result.summary || "",
},
};
}
function parseBlinds(stakes) {
const m = (stakes || "").match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
return m ? { sb: parseFloat(m[1]), bb: parseFloat(m[2]) } : { sb: null, bb: null };
}
function initialState(hud) {
const sess = (hud && hud.session) || {};
const stack = (hud && hud.stack) || {};
const blinds = parseBlinds(sess.stakes);
const seats = [];
for (const v of (hud && hud.villains) || []) {
if (v.seat && POSITIONS.includes(v.seat)) {
seats.push({ pos: v.seat, name: v.name || null, stack: null, cards: null });
}
}
const actions = [];
if (blinds.sb != null) actions.push({ street: "preflop", pos: "SB", action: "post", amount: blinds.sb });
if (blinds.bb != null) actions.push({ street: "preflop", pos: "BB", action: "post", amount: blinds.bb });
return {
meta: {
game: sess.game || "NLH",
stakes: sess.stakes || null,
venue: sess.venue || null,
sessionId: sess.id != null ? sess.id : null,
},
blinds,
heroStack: stack.current != null ? stack.current : null,
heroPos: null,
seats,
street: "preflop",
board: { flop: [], turn: [], river: [] },
actions,
result: { pot: null, heroNet: null, summary: "" },
};
}
function ensureHero(state) {
let hero = state.seats.find((s) => s.pos === state.heroPos);
if (!hero && state.heroPos) {
hero = { pos: state.heroPos, name: "Hero", stack: state.heroStack, cards: [] };
state.seats.push(hero);
}
return hero || {};
}
window.Recorder = {
buildStructured,
parseCards,
parseBlinds,
initialState,
_internals: { POSITIONS, STREETS },
mount,
};
// --- mount / render -------------------------------------------------------
async function mount(container, opts) {
opts = opts || {};
let hud = opts.hud;
if (!hud) {
try {
const url = opts.sessionId != null ? `/session/data?id=${opts.sessionId}` : "/session/data";
hud = await fetch(url).then((r) => r.json());
} catch (e) {
hud = { session: null };
}
}
const state = initialState(hud);
const ctx = { container, state, opts };
container.classList.add("rec-root");
container.addEventListener("click", (e) => handleClick(ctx, e));
container.addEventListener("input", (e) => handleInput(ctx, e));
render(ctx);
return ctx;
}
function render(ctx) {
const s = ctx.state;
const hero = s.seats.find((x) => x.pos === s.heroPos) || {};
ctx.container.innerHTML = `
<div class="rec-head">
<div class="rec-title">Record hand</div>
<div class="rec-meta">${esc(s.meta.venue || "")}${s.meta.stakes ? " · " + esc(s.meta.stakes) : ""}</div>
<button class="rec-x" data-act="close"></button>
</div>
<div class="rec-body">
<section class="rec-sec">
<div class="rec-label">Your seat</div>
<div class="rec-pos-row">
${POSITIONS.map((p) => `<button class="rec-pos${s.heroPos === p ? " on" : ""}" data-act="hero-pos" data-pos="${p}">${p}</button>`).join("")}
</div>
<label class="rec-field">
<span class="rec-label">your cards</span>
<input class="rec-cards" data-act="hero-cards" autocapitalize="off" autocomplete="off" spellcheck="false"
placeholder="e.g. ah kh" value="${esc(cardsText(hero.cards))}">
</label>
</section>
<section class="rec-sec">
<div class="rec-label">Players in the hand</div>
<div class="rec-seats">
${s.seats.filter((x) => x.pos !== s.heroPos).map((seat) => renderSeat(seat)).join("") || '<div class="rec-dim">none yet</div>'}
</div>
<div class="rec-pos-row">
${POSITIONS.filter((p) => p !== s.heroPos && !s.seats.some((x) => x.pos === p)).map((p) => `<button class="rec-pos add" data-act="add-seat" data-pos="${p}">+ ${p}</button>`).join("")}
</div>
</section>
<section class="rec-sec">
<div class="rec-label">Streets</div>
<div class="rec-street-tabs">
${STREETS.map((st) => `<button class="rec-tab${s.street === st ? " on" : ""}" data-act="street" data-street="${st}">${st}${boardCount(s, st)}</button>`).join("")}
</div>
${renderStreet(ctx)}
</section>
<section class="rec-sec">
<div class="rec-label">Result</div>
<div class="rec-result">
<label>pot <input class="rec-num" data-act="result" data-k="pot" inputmode="decimal" value="${s.result.pot != null ? s.result.pot : ""}"></label>
<label>your net <input class="rec-num" data-act="result" data-k="heroNet" inputmode="decimal" value="${s.result.heroNet != null ? s.result.heroNet : ""}"></label>
</div>
</section>
</div>
<div class="rec-foot">
<button class="rec-cancel" data-act="close">Cancel</button>
<button class="rec-save" data-act="save">Save &amp; replay</button>
</div>
`;
}
function renderSeat(seat) {
return `
<div class="rec-seat">
<span class="rec-seat-pos">${seat.pos}</span>
<input class="rec-name" data-act="seat-name" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off"
placeholder="name" value="${esc(seat.name || "")}">
<input class="rec-cards sm" data-act="seat-cards" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off" spellcheck="false"
placeholder="shown?" value="${esc(cardsText(seat.cards))}">
<button class="rec-rm" data-act="rm-seat" data-pos="${seat.pos}"></button>
</div>`;
}
function renderStreet(ctx) {
const s = ctx.state;
const st = s.street;
const players = s.seats.map((x) => x.pos);
const boardInput =
st === "preflop"
? ""
: `<label class="rec-field">
<span class="rec-label">${st} board (${STREET_BOARD[st]})</span>
<input class="rec-cards" data-act="board-cards" data-street="${st}" autocapitalize="off" autocomplete="off" spellcheck="false"
placeholder="${st === "flop" ? "e.g. 7d 2c 5h" : "e.g. 5h"}" value="${esc(cardsText(s.board[st]))}">
</label>`;
// Straddle: a voluntary preflop blind from any non-blind seat, default 2×BB.
// Action starts left of it and it acts last preflop — order is whatever you enter.
const stradAmt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
const stradElig = players.filter((p) => p !== "SB" && p !== "BB" && !s.actions.some((a) => a.straddle && a.pos === p));
const straddle =
st === "preflop" && stradElig.length
? `<div class="rec-act-add">
<select class="rec-sel" data-act="str-pos">
<option value="">+ straddle${stradAmt != null ? " (" + stradAmt + ")" : ""}</option>
${stradElig.map((p) => `<option value="${p}">${p}</option>`).join("")}
</select>
<button class="rec-add-act" data-act="add-straddle">add</button>
</div>`
: "";
return `
${boardInput}
${straddle}
<div class="rec-act-add">
<select class="rec-sel" data-act="na-pos">
<option value="">who</option>
${players.map((p) => `<option value="${p}">${p}${p === s.heroPos ? " (you)" : ""}</option>`).join("")}
</select>
<select class="rec-sel" data-act="na-action">
<option value="">action</option>
${ACTIONS.map((a) => `<option value="${a}">${a}</option>`).join("")}
</select>
<input class="rec-num" data-act="na-amount" inputmode="decimal" placeholder="$">
<button class="rec-add-act" data-act="add-action">add</button>
</div>
<div class="rec-log">
${s.board[st] && s.board[st].length && st !== "preflop" ? `<div class="rec-ln brd">${st}: ${cardsText(s.board[st])}</div>` : ""}
${s.actions
.filter((a) => a.street === st)
.map((a, i) => {
const label = a.straddle ? "straddle" : a.action;
const amt = a.amount != null ? " " + a.amount : "";
const fixed = a.action === "post" && !a.straddle; // blinds aren't removable
const rm = fixed ? "" : ` <button class="rec-undo" data-act="rm-action" data-street="${st}" data-i="${i}">✕</button>`;
return `<div class="rec-ln">${a.pos} <b>${label}</b>${amt}${rm}</div>`;
})
.join("")}
</div>`;
}
function boardCount(s, st) {
const n = (s.board[st] || []).length;
return n ? ` ${n}` : "";
}
// --- events ---------------------------------------------------------------
function handleClick(ctx, e) {
const s = ctx.state;
const t = e.target.closest("[data-act]");
if (!t) return;
const act = t.getAttribute("data-act");
switch (act) {
case "close":
if (ctx.opts.onClose) ctx.opts.onClose();
return;
case "hero-pos": {
const pos = t.getAttribute("data-pos");
const old = s.seats.find((x) => x.pos === s.heroPos);
if (old && old.name === "Hero" && !(old.cards || []).length) {
s.seats = s.seats.filter((x) => x !== old);
}
s.heroPos = s.heroPos === pos ? null : pos;
if (s.heroPos) ensureHero(s);
break;
}
case "add-seat":
s.seats.push({ pos: t.getAttribute("data-pos"), name: null, stack: null, cards: null });
break;
case "rm-seat":
s.seats = s.seats.filter((x) => x.pos !== t.getAttribute("data-pos"));
break;
case "street":
s.street = t.getAttribute("data-street");
break;
case "add-action":
addActionFromControls(ctx);
break;
case "add-straddle": {
const sel = ctx.container.querySelector('[data-act="str-pos"]');
const pos = sel && sel.value;
if (pos) {
const amt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
s.actions.push({ street: "preflop", pos, action: "post", amount: amt, straddle: true });
}
break;
}
case "rm-action":
removeAction(s, t.getAttribute("data-street"), parseInt(t.getAttribute("data-i"), 10));
break;
case "save":
return doSave(ctx);
default:
return; // inputs handled in handleInput
}
render(ctx);
}
function handleInput(ctx, e) {
const s = ctx.state;
const t = e.target.closest("[data-act]");
if (!t) return;
const act = t.getAttribute("data-act");
if (act === "hero-cards") {
const hero = ensureHero(s);
hero.cards = parseCards(t.value);
} else if (act === "seat-cards") {
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
if (seat) seat.cards = parseCards(t.value);
} else if (act === "board-cards") {
s.board[t.getAttribute("data-street")] = parseCards(t.value);
} else if (act === "seat-name") {
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
if (seat) seat.name = t.value.trim() || null;
} else if (act === "result") {
const k = t.getAttribute("data-k");
s.result[k] = t.value === "" ? null : parseFloat(t.value);
}
// no re-render mid-typing (keeps input focus)
}
function addActionFromControls(ctx) {
const root = ctx.container;
const pos = root.querySelector('[data-act="na-pos"]').value;
const action = root.querySelector('[data-act="na-action"]').value;
const amt = root.querySelector('[data-act="na-amount"]').value;
if (!pos || !action) return;
const entry = { street: ctx.state.street, pos, action };
entry.amount = SIZED[action] && amt !== "" ? parseFloat(amt) : null;
ctx.state.actions.push(entry);
}
function removeAction(state, street, idxWithinStreet) {
let seen = -1;
for (let i = 0; i < state.actions.length; i++) {
if (state.actions[i].street === street) {
seen++;
if (seen === idxWithinStreet) {
state.actions.splice(i, 1);
return;
}
}
}
}
async function doSave(ctx) {
const structured = buildStructured(ctx.state);
const btn = ctx.container.querySelector(".rec-save");
if (btn) {
btn.disabled = true;
btn.textContent = "Saving…";
}
try {
const res = await fetch("/hands", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ structured, session_id: ctx.state.meta.sessionId }),
}).then((r) => r.json());
if (res && res.ok) {
if (ctx.opts.onSave) ctx.opts.onSave(res.id);
else window.location.href = `/hand/${res.id}`;
} else {
throw new Error((res && res.error) || "save failed");
}
} catch (err) {
if (btn) {
btn.disabled = false;
btn.textContent = "Save failed — retry";
}
}
}
function esc(x) {
return String(x == null ? "" : x).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
void SUITS;
})();
-21
View File
@@ -104,16 +104,6 @@
.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>
@@ -229,7 +219,6 @@
} }
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 || [];
@@ -288,16 +277,6 @@
${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 => `
+8
View File
@@ -101,3 +101,11 @@ def test_list_recent_hands_flags_structured(poker):
rows = {r["id"]: r for r in poker.list_recent_hands()} rows = {r["id"]: r for r in poker.list_recent_hands()}
assert rows[structured_id]["has_structured"] is True assert rows[structured_id]["has_structured"] is True
assert rows[flat_id]["has_structured"] is False assert rows[flat_id]["has_structured"] is False
def test_hud_villains_carry_seat(poker):
"""The recorder auto-places known players, so the HUD bundle must expose their seat."""
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
poker.add_read("3-bets light", seat="BTN", name="Sal", category="risky")
villains = {v["name"]: v for v in poker.hud()["villains"]}
assert villains["Sal"]["seat"] == "BTN"