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
20 changed files with 789 additions and 1475 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,720 +0,0 @@
# Poker Logging Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn `lyra/poker.py` into a standalone logging system-of-record with a complete REST API, a single source-of-truth tool/API contract, and a human UI to log and correct everything — usable by Brian with zero LLM dependency.
**Architecture:** Thin FastAPI routes wrap the existing (already-working) `poker.py` store functions; a declarative `poker_contract.py` pins operation names + required args so the REST API and Lyra's LLM tool specs can't drift; the web UI gets dumb capture inputs (2nd stack box on chat, quick inputs on the HUD) and correction controls. This is sub-project 1 of 2; Lyra's classifier/prompts (sub-project 2) are parked.
**Tech Stack:** Python 3.11+ (venv runs 3.14), FastAPI + uvicorn, SQLite (WAL), pytest, vanilla HTML/JS/CSS.
## Global Constraints
- Python files: start with `from __future__ import annotations`; 4-space indent; ruff `line-length = 100`, `target-version = "py311"`.
- **`lyra/web/static/index.html` uses CRLF (`\r\n`) line endings and mixed tabs/spaces.** Every other static file (`session.html`, `style.css`, `nav.js`) and all Python use **LF + spaces**. Match the file you edit or you produce a noisy diff.
- Pure data capture (stack / buy-in / cash-out / hand / read) must reach the store via the REST endpoints, **never** through the chat/LLM path.
- `poker_contract.py` is the single source of truth: REST routes and `tools.py` specs must agree with it (enforced by a conformance test).
- Web app runs via `lyra-web` (uvicorn) on `0.0.0.0:7078`. DB path from `LYRA_DB_PATH` (default `data/lyra.db`, WAL).
- Test idiom: fixture sets `LYRA_DB_PATH` to a `tmp_path` file, stubs `llm.embed` (and `llm.complete` where needed), then `importlib.reload(memory)` **then** `importlib.reload(poker)` (order matters), then `importlib.reload(server)` for endpoint tests. Run with `.venv/bin/pytest` (or `uv run pytest`).
- Existing store facts to respect: `start_session(...)` uses `fmt=` (column is `format`); `add_buyin` returns a float total; `log_stack` returns the `stack_state` dict `{current, buy_in, net}`; `end_session(cash_out, ...)` takes `cash_out` first; `hud()` returns `None` when no session; `_HAND_FIELDS = ("position","hole_cards","board","preflop","flop","turn","river","showdown","pot","result","stack_after","tag","lesson")`; `upsert_player(name, **fields)` returns an int player id; `tools.dispatch(name, args, ctx)``ctx` is a plain dict.
---
### Task 1: Contract module + tool-spec conformance test
**Files:**
- Create: `lyra/poker_contract.py`
- Create: `tests/test_poker_contract.py`
**Interfaces:**
- Produces: `lyra.poker_contract.OPERATIONS: dict[str, dict]` and `CONTRACT_VERSION: int`. Each op value: `{"required": tuple[str,...], "llm_tool": str | None, "rest": tuple[str, str] | None}` where `rest` is `(METHOD, PATH)` with PATH exactly matching the FastAPI route template.
- [ ] **Step 1: Write the contract module**
`lyra/poker_contract.py`:
```python
from __future__ import annotations
# Single source of truth for poker logging operations. The REST API, Lyra's LLM
# tool specs, the human UI, and (later) an MCP wrapper all derive from this.
# `required` MUST match the `required` list in the matching tools.py spec.
# `rest` PATH MUST match the FastAPI route template verbatim.
CONTRACT_VERSION = 1
OPERATIONS: dict[str, dict] = {
"start_session": {"required": (), "llm_tool": "start_session", "rest": ("POST", "/session")},
"update_session": {"required": (), "llm_tool": "update_session", "rest": ("PATCH", "/session/{session_id}")},
"end_session": {"required": ("cash_out",), "llm_tool": "end_session", "rest": None},
"log_stack": {"required": ("amount",), "llm_tool": "log_stack", "rest": ("POST", "/session/stack")},
"add_buyin": {"required": ("amount",), "llm_tool": "add_buyin", "rest": ("POST", "/session/buyin")},
"log_hand": {"required": (), "llm_tool": "log_hand", "rest": ("POST", "/session/hand")},
"update_hand": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/hand/{hand_id}")},
"add_read": {"required": ("note",), "llm_tool": "add_read", "rest": ("POST", "/session/read")},
"update_player": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/player/{player_id}")},
}
```
- [ ] **Step 2: Write the failing conformance test**
`tests/test_poker_contract.py`:
```python
from __future__ import annotations
from lyra import tools
from lyra.poker_contract import OPERATIONS
def test_llm_tool_required_args_match_contract():
for op, decl in OPERATIONS.items():
name = decl["llm_tool"]
if not name:
continue
spec = tools.TOOLS[name]["spec"]
required = set(spec["function"]["parameters"]["required"])
assert required == set(decl["required"]), (
f"{op}: tools spec required {required} != contract {set(decl['required'])}"
)
```
- [ ] **Step 3: Run the test**
Run: `.venv/bin/pytest tests/test_poker_contract.py -v`
Expected: PASS (the contract's `required` tuples were copied from the live specs).
- [ ] **Step 4: Commit**
```bash
git add lyra/poker_contract.py tests/test_poker_contract.py
git commit -m "feat: poker operation contract + tool-spec conformance test"
```
---
### Task 2: Direct capture endpoints (stack / buy-in / start)
**Files:**
- Modify: `lyra/web/server.py` (add three routes inside `create_app`, near the existing `PATCH /session/{session_id}` at server.py:116)
- Create: `tests/test_poker_api.py`
**Interfaces:**
- Consumes: `poker.log_stack(amount, note=None)`, `poker.add_buyin(amount)`, `poker.start_session(venue=, stakes=, game=, fmt=, buy_in=, mantra=)`, `poker.live_session()`.
- Produces: `POST /session/stack``{ok, stack}` or `{ok:false, error}`; `POST /session/buyin``{ok, buy_in_total}`; `POST /session``{ok, id}`.
- [ ] **Step 1: Write the failing endpoint tests**
`tests/test_poker_api.py`:
```python
from __future__ import annotations
import importlib
import pytest
@pytest.fixture
def client(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)
import lyra.web.server as server
importlib.reload(server)
from fastapi.testclient import TestClient
return TestClient(server.app), poker
def test_post_stack_logs_and_returns_state(client):
c, poker = client
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
r = c.post("/session/stack", json={"amount": 373})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["stack"]["current"] == 373
assert body["stack"]["net"] == pytest.approx(-27)
def test_post_stack_without_session_errors(client):
c, _ = client
r = c.post("/session/stack", json={"amount": 373})
assert r.json()["ok"] is False
assert "error" in r.json()
def test_post_buyin_increments_total(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/buyin", json={"amount": 200})
assert r.json()["buy_in_total"] == pytest.approx(600)
def test_post_session_starts_live(client):
c, poker = client
r = c.post("/session", json={"venue": "Wheeling", "stakes": "1/3", "buy_in": 400})
sid = r.json()["id"]
assert poker.live_session()["id"] == sid
```
- [ ] **Step 2: Run to verify it fails**
Run: `.venv/bin/pytest tests/test_poker_api.py -v`
Expected: FAIL with 404s (routes not defined). If it errors with "No module named 'httpx'", run `.venv/bin/pip install httpx` (TestClient needs it).
- [ ] **Step 3: Add the three routes**
In `lyra/web/server.py`, immediately after the `PATCH /session/{session_id}` handler (server.py:122), add:
```python
@app.post("/session/stack")
async def session_log_stack(request: Request) -> dict:
"""Log Brian's current stack directly (no LLM). Server-stamps the time."""
body = await request.json()
try:
amount = float(body.get("amount"))
except (TypeError, ValueError):
return {"ok": False, "error": "amount must be a number"}
note = (body.get("note") or "").strip() or None
try:
state = await asyncio.to_thread(poker.log_stack, amount, note)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "stack logged (direct)", amount=amount)
return {"ok": True, "stack": state}
@app.post("/session/buyin")
async def session_add_buyin(request: Request) -> dict:
"""Add a buy-in/rebuy directly (no LLM)."""
body = await request.json()
try:
amount = float(body.get("amount"))
except (TypeError, ValueError):
return {"ok": False, "error": "amount must be a number"}
try:
total = await asyncio.to_thread(poker.add_buyin, amount)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "buyin added (direct)", amount=amount)
return {"ok": True, "buy_in_total": total}
@app.post("/session")
async def session_start(request: Request) -> dict:
"""Open a new live session directly (no LLM)."""
body = await request.json()
sid = await asyncio.to_thread(lambda: poker.start_session(
venue=body.get("venue"), stakes=body.get("stakes"),
game=body.get("game") or "NLH", fmt=body.get("format") or "cash",
buy_in=body.get("buy_in") or 0, mantra=body.get("mantra"),
))
logbus.log("info", "poker session started (direct)", id=sid)
return {"ok": True, "id": sid}
```
- [ ] **Step 4: Run to verify it passes**
Run: `.venv/bin/pytest tests/test_poker_api.py -v`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add lyra/web/server.py tests/test_poker_api.py
git commit -m "feat: direct REST endpoints for stack/buyin/start-session (no LLM)"
```
---
### Task 3: Hands API (log / edit / delete)
**Files:**
- Modify: `lyra/poker.py` (add `update_hand` near `log_hand` at poker.py:558)
- Modify: `lyra/web/server.py` (add routes after the Task 2 routes)
- Modify: `tests/test_poker_api.py` (add tests)
**Interfaces:**
- Consumes: `poker.log_hand(**fields)`, `poker.get_hand(id)`, `poker.delete_entry("hand", id)`, `_HAND_FIELDS`.
- Produces: `poker.update_hand(hand_id, **fields) -> dict | None`; `POST /session/hand``{ok, id}`; `PATCH /hand/{hand_id}``{ok, hand}`; `DELETE /hand/{hand_id}``{ok}`.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_poker_api.py`:
```python
def test_post_hand_edit_and_delete(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/hand", json={"position": "BTN", "hole_cards": "22", "result": 120})
assert r.json()["ok"] is True
hid = r.json()["id"]
r2 = c.patch(f"/hand/{hid}", json={"hole_cards": "2c2d"})
assert r2.json()["ok"] is True
assert r2.json()["hand"]["hole_cards"] == "2c2d"
r3 = c.delete(f"/hand/{hid}")
assert r3.json()["ok"] is True
assert poker.get_hand(hid) is None
```
- [ ] **Step 2: Run to verify it fails**
Run: `.venv/bin/pytest tests/test_poker_api.py::test_post_hand_edit_and_delete -v`
Expected: FAIL (404 on `/session/hand`).
- [ ] **Step 3: Add `update_hand` to the store**
In `lyra/poker.py`, immediately after `log_hand` (poker.py:558), add:
```python
def update_hand(hand_id: int, **fields) -> dict | None:
"""Edit a logged hand's flat fields (fix a mislabeled board, result, villain).
Only known columns are touched. Returns the updated hand row or None."""
sets, vals = [], []
for k, v in fields.items():
if k in _HAND_FIELDS and v is not None:
sets.append(f"{k} = ?")
vals.append(v)
if sets:
conn = _c()
with conn:
conn.execute(f"UPDATE poker_hands SET {', '.join(sets)} WHERE id = ?",
(*vals, hand_id))
return get_hand(hand_id)
```
- [ ] **Step 4: Add the three routes**
In `lyra/web/server.py`, after the Task 2 routes, add:
```python
@app.post("/session/hand")
async def session_log_hand(request: Request) -> dict:
"""Log a hand directly with flat fields (no LLM parse)."""
body = await request.json()
try:
hid = await asyncio.to_thread(lambda: poker.log_hand(**body))
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "hand logged (direct)", id=hid)
return {"ok": True, "id": hid}
@app.patch("/hand/{hand_id}")
async def hand_update(hand_id: int, request: Request) -> dict:
"""Edit a logged hand's flat fields."""
body = await request.json()
h = await asyncio.to_thread(lambda: poker.update_hand(hand_id, **body))
logbus.log("info", "hand edited", id=hand_id, fields=list(body))
return {"ok": h is not None, "hand": h}
@app.delete("/hand/{hand_id}")
async def hand_delete(hand_id: int) -> dict:
"""Delete a logged hand."""
ok = await asyncio.to_thread(poker.delete_entry, "hand", hand_id)
return {"ok": ok}
```
- [ ] **Step 5: Run to verify it passes**
Run: `.venv/bin/pytest tests/test_poker_api.py -v`
Expected: PASS (all tests, including the new hand test).
- [ ] **Step 6: Commit**
```bash
git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py
git commit -m "feat: hands API — log_hand endpoint, update_hand store fn, edit/delete routes"
```
---
### Task 4: Reads/players API + route conformance
**Files:**
- Modify: `lyra/poker.py` (add `update_player` near `upsert_player`)
- Modify: `lyra/web/server.py` (add routes)
- Modify: `tests/test_poker_api.py` (add tests)
- Modify: `tests/test_poker_contract.py` (add route-coverage test)
**Interfaces:**
- Consumes: `poker.add_read(note=, name=, ...)`, `poker.upsert_player(name, **fields)`.
- Produces: `poker.update_player(player_id, **fields) -> dict | None`; `POST /session/read``{ok, id}`; `PATCH /player/{player_id}``{ok, player}`.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_poker_api.py`:
```python
def test_post_read(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/read", json={"note": "3-bets light", "name": "James K"})
assert r.json()["ok"] is True
assert isinstance(r.json()["id"], int)
def test_rename_player_fixes_mislabel(client):
c, poker = client
pid = poker.upsert_player("Dave the rock", category="reg")
r = c.patch(f"/player/{pid}", json={"name": "Dave the mechanic"})
assert r.json()["ok"] is True
assert r.json()["player"]["name"] == "Dave the mechanic"
```
Append to `tests/test_poker_contract.py`:
```python
def test_rest_routes_registered():
import lyra.web.server as server
registered = set()
for route in server.app.routes:
methods = getattr(route, "methods", None)
path = getattr(route, "path", None)
if not methods or not path:
continue
for m in methods:
registered.add((m, path))
for op, decl in OPERATIONS.items():
if not decl["rest"]:
continue
method, path = decl["rest"]
assert (method, path) in registered, f"{op}: {method} {path} not registered"
```
- [ ] **Step 2: Run to verify it fails**
Run: `.venv/bin/pytest tests/test_poker_api.py::test_rename_player_fixes_mislabel tests/test_poker_contract.py::test_rest_routes_registered -v`
Expected: FAIL (404 on `/player/...`; route-coverage missing several POST/PATCH paths).
- [ ] **Step 3: Add `update_player` to the store**
In `lyra/poker.py`, immediately after `upsert_player` (find it near poker.py:1010), add:
```python
_PLAYER_FIELDS = ("name", "venue", "description", "tendencies", "adjustment", "category")
def update_player(player_id: int, **fields) -> dict | None:
"""Edit a player's dossier (rename, fix tendencies/category). Returns the row or None."""
sets, vals = [], []
for k, v in fields.items():
if k in _PLAYER_FIELDS and v is not None:
sets.append(f"{k} = ?")
vals.append(v)
if sets:
conn = _c()
with conn:
conn.execute(f"UPDATE poker_players SET {', '.join(sets)} WHERE id = ?",
(*vals, player_id))
row = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone()
return dict(row) if row else None
```
- [ ] **Step 4: Add the two routes**
In `lyra/web/server.py`, after the Task 3 routes, add:
```python
@app.post("/session/read")
async def session_add_read(request: Request) -> dict:
"""Log a read directly (no LLM); upserts the villain file when name is given."""
body = await request.json()
rid = await asyncio.to_thread(lambda: poker.add_read(
note=body.get("note") or "", seat=body.get("seat"), name=body.get("name"),
tendencies=body.get("tendencies"), adjustment=body.get("adjustment"),
description=body.get("description"), category=body.get("category"),
venue=body.get("venue"),
))
return {"ok": True, "id": rid}
@app.patch("/player/{player_id}")
async def player_update(player_id: int, request: Request) -> dict:
"""Edit a player's dossier (rename, fix tendencies)."""
body = await request.json()
p = await asyncio.to_thread(lambda: poker.update_player(player_id, **body))
logbus.log("info", "player edited", id=player_id, fields=list(body))
return {"ok": p is not None, "player": p}
```
- [ ] **Step 5: Run to verify it passes**
Run: `.venv/bin/pytest tests/test_poker_api.py tests/test_poker_contract.py -v`
Expected: PASS (all API tests + both conformance tests).
- [ ] **Step 6: Run the full suite (no regressions)**
Run: `.venv/bin/pytest -q`
Expected: PASS (existing poker/tools/chat tests still green).
- [ ] **Step 7: Commit**
```bash
git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py tests/test_poker_contract.py
git commit -m "feat: reads/players API + REST route conformance test"
```
---
### Task 5: Chat-page stack quick-capture (2nd input box)
**Files:**
- Modify: `lyra/web/static/index.html` (**CRLF + tabs** — add markup + JS)
- Modify: `lyra/web/static/style.css` (LF + spaces — add styling)
**Interfaces:**
- Consumes: `POST /session/stack` (Task 2). Reads `currentSession` and the Live Log DOM (`#thinkingContent`, `#thinkingEmpty`) already present in index.html.
- Produces: a stack-only input that logs without any chat/LLM call.
- [ ] **Step 1: Add the input row markup**
In `lyra/web/static/index.html`, insert **between** the `<div id="input">…</div>` block (ends ~index.html:125) and `<nav id="tabbar">` (index.html:128). **Use CRLF + tab indentation to match the file.**
```html
<!-- Stack quick-capture (no LLM): type a number -> logs current stack -->
<div id="stackQuick">
<input id="stackQuickInput" type="number" inputmode="decimal" placeholder="Stack $" aria-label="Log current stack">
<button id="stackQuickBtn" type="button" title="Log stack (no chat)">Log</button>
</div>
```
- [ ] **Step 2: Add the JS**
In the `<script>` of `index.html`, near `sendMessage` (index.html:299), add (CRLF + tabs):
```javascript
function liveLogLine(text) {
const content = document.getElementById("thinkingContent");
const empty = document.getElementById("thinkingEmpty");
if (empty) empty.style.display = "none";
const div = document.createElement("div");
div.className = "thinking-event";
div.textContent = text;
content.appendChild(div);
content.scrollTop = content.scrollHeight;
}
async function logStackQuick() {
const el = document.getElementById("stackQuickInput");
const raw = (el.value || "").replace(/[^0-9.]/g, "");
if (!raw) return;
const amount = Number(raw);
try {
const r = await fetch("/session/stack", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount })
});
const data = await r.json();
if (!data.ok) { liveLogLine("⚠ " + (data.error || "stack not logged")); return; }
const t = new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
const net = (data.stack && data.stack.net != null)
? ` (net ${data.stack.net >= 0 ? "+" : ""}${data.stack.net})` : "";
liveLogLine(`💰 $${amount} logged · ${t}${net}`);
el.value = "";
} catch (e) {
liveLogLine("⚠ stack log failed: " + e.message);
}
}
document.getElementById("stackQuickBtn").addEventListener("click", logStackQuick);
document.getElementById("stackQuickInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); logStackQuick(); }
});
```
- [ ] **Step 3: Add styling**
In `lyra/web/static/style.css` (LF + spaces), add:
```css
#stackQuick {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 12px;
border-top: 1px solid var(--border, #222);
}
#stackQuick input {
flex: 1;
min-width: 0;
padding: 8px 10px;
background: var(--panel, #111);
color: inherit;
border: 1px solid var(--border, #333);
border-radius: 8px;
}
#stackQuick button {
padding: 8px 14px;
background: var(--accent, #ff7a18);
color: #000;
border: none;
border-radius: 8px;
font-weight: 600;
}
```
- [ ] **Step 4: Verify manually**
Start the app: `.venv/bin/python -m lyra.web.server` (serves on :7078). With a live session (start one via the HUD or `curl -XPOST localhost:7078/session -d '{"buy_in":400}' -H 'Content-Type: application/json'`):
- The stack box appears below the message input, above the nav icons.
- Type `350`, press Enter → a `💰 $350 logged · …` line appears in the Live Log, the box clears, and **no chat bubble is added**.
- Confirm persisted: `curl -s localhost:7078/session/data | python -m json.tool` shows `stack.current == 350`.
- [ ] **Step 5: Commit**
```bash
git add lyra/web/static/index.html lyra/web/static/style.css
git commit -m "feat: stack quick-capture box on chat page (no LLM)"
```
---
### Task 6: HUD quick-capture + correction controls
**Files:**
- Modify: `lyra/web/static/session.html` (LF + spaces — Stack card markup, villain rename control, JS functions)
**Interfaces:**
- Consumes: `POST /session/stack`, `POST /session/buyin` (Task 2), `PATCH /session/{id}` (existing), `PATCH /player/{id}` (Task 4). Reads existing globals `curSession`, `refresh()`, and the villain render block.
- [ ] **Step 1: Add quick inputs to the Stack card**
In `lyra/web/static/session.html`, replace the Stack card block (session.html:280-289) with the same block plus a `quick` row before its closing `</div>`:
```javascript
<div class="card">
<p class="label">Stack</p>
<div class="stack-row">
<span class="stack-now">${stack.current == null ? '—' : money(stack.current)}</span>
<span class="net ${netClass(stack.net)}">${stack.net == null ? '' : signed(stack.net)}</span>
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
</div>
${sparkline(stack.log || [])}
<div class="quick">
<input id="qStack" type="number" inputmode="decimal" placeholder="Stack $" onkeydown="if(event.key==='Enter')postStack()">
<button onclick="postStack()">Log stack</button>
<input id="qBuyin" type="number" inputmode="decimal" placeholder="Buy-in $" onkeydown="if(event.key==='Enter')postBuyin()">
<button onclick="postBuyin()">Add buy-in</button>
<input id="qCashout" type="number" inputmode="decimal" placeholder="Cash out $" onkeydown="if(event.key==='Enter')postCashout()">
<button onclick="postCashout()">Cash out</button>
</div>
</div>
```
- [ ] **Step 2: Add the quick-capture + rename JS functions**
In the `<script>` of `session.html`, near `saveEdit()` (session.html:192), add:
```javascript
async function postQuick(url, amount, body){
const r = await fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body || { amount }) });
const d = await r.json();
if(!d.ok){ alert(d.error || 'failed'); return false; }
refresh(); return true;
}
function numVal(id){ const el = document.getElementById(id); return Number((el.value||'').replace(/[^0-9.]/g,'')); }
async function postStack(){ const v = numVal('qStack'); if(v) { if(await postQuick('/session/stack', v)) document.getElementById('qStack').value=''; } }
async function postBuyin(){ const v = numVal('qBuyin'); if(v) { if(await postQuick('/session/buyin', v)) document.getElementById('qBuyin').value=''; } }
async function postCashout(){
if(!curSession) return;
const v = numVal('qCashout'); if(!v) return;
const r = await fetch('/session/'+curSession.id, { method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ cash_out: v }) });
if(!(await r.json()).ok){ alert('failed'); return; }
document.getElementById('qCashout').value=''; refresh();
}
async function renamePlayer(id, current){
const name = prompt('Rename player', current || ''); if(!name) return;
const r = await fetch('/player/'+id, { method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ name }) });
if(!(await r.json()).ok){ alert('failed'); return; }
refresh();
}
```
- [ ] **Step 3: Add the rename control to the villains list**
In `session.html`, find the villains render block in `render(data)` (it maps over `data.villains` / the `villains` array). For each villain item, add a rename affordance next to the name, using the player id field present on the villain row (commonly `v.id` or `v.player_id` — use whichever the bundle provides):
```javascript
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'')}')"></button>
```
Read the existing villain block first to splice this in cleanly and confirm the id field name.
- [ ] **Step 4: Add minimal styling**
In the inline `<style>` of `session.html`, add:
```css
.quick { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; }
.quick input { width:96px; padding:7px 9px; background:#111; color:inherit; border:1px solid #333; border-radius:8px; }
.quick button { padding:7px 11px; background:var(--accent,#ff7a18); color:#000; border:none; border-radius:8px; font-weight:600; }
button.mini { background:transparent; border:none; color:#888; cursor:pointer; padding:0 4px; }
```
- [ ] **Step 5: Verify manually**
With the app running and a live session, open `/session`:
- Log a stack via `qStack` → sparkline + net update without a chat call.
- Add a buy-in via `qBuyin` → "bought in" total rises.
- Enter a cash-out via `qCashout` → session net updates.
- Click ✎ on a villain, rename it → name changes after refresh. Confirm via `curl -s localhost:7078/session/data`.
- [ ] **Step 6: Commit**
```bash
git add lyra/web/static/session.html
git commit -m "feat: HUD quick-capture (stack/buyin/cashout) + villain rename"
```
---
### Task 7: iOS-PWA bottom safe-area gap fix
**Files:**
- Modify: `lyra/web/static/style.css` (bottom nav / container safe-area)
**Interfaces:** none (visual fix). The empty band below the nav icons is the home-indicator inset not being consumed by `#tabbar`.
- [ ] **Step 1: Load the iOS-PWA skill**
Invoke the `building-ios-pwas` skill and follow its guidance for safe-area / `100dvh` handling before editing. The current `#tabbar` (style.css:921-952) applies `env(safe-area-inset-left/right)` and `padding-bottom: 6px`, but does **not** add `env(safe-area-inset-bottom)` — the likely cause.
- [ ] **Step 2: Apply the safe-area fix**
In `lyra/web/static/style.css`, in the mobile `#tabbar` rule (style.css:921-929), change the bottom padding to consume the inset, and ensure the bar is pinned:
```css
#tabbar {
/* …existing flex/border rules… */
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding-bottom: calc(6px + env(safe-area-inset-bottom));
}
```
And ensure the chat scroll container reserves space for the bar so content isn't hidden behind it (match the container selector used at style.css:836-852):
```css
@media (max-width: 768px) {
#messages {
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
}
```
- [ ] **Step 3: Verify on device**
Open the PWA (Add to Home Screen) on iPhone:
- The empty band below the icons is gone; the nav sits flush above the home indicator.
- The stack quick-capture box (Task 5) sits directly above the nav.
- Open the keyboard: `body.kb` still hides the tabbar (style.css:952) and the input pins to the keyboard — confirm no regression.
- If the gap persists or content clips, follow the `building-ios-pwas` skill's `100dvh`/`visualViewport` guidance and iterate.
- [ ] **Step 4: Commit**
```bash
git add lyra/web/static/style.css
git commit -m "fix: consume iOS home-indicator safe-area inset under bottom nav"
```
---
## Self-Review
**Spec coverage:**
- Complete API surface (create/update/delete per entity) → Tasks 2 (stack/buyin/start), 3 (hands), 4 (reads/players); existing PATCH/DELETE session + entry routes retained.
- Single documented/versioned tool-API contract → Task 1 (`poker_contract.py`, `CONTRACT_VERSION`) + conformance tests (Tasks 1, 4).
- Human UI to log + edit/correct → Tasks 5 (chat 2nd box), 6 (HUD quick inputs + villain rename + existing edit form/delete).
- Pure capture never touches LLM → all capture goes through REST endpoints (Tasks 26); verified in manual steps (no chat bubble).
- 2nd input box + PWA fix → Tasks 5, 7.
- Non-goals respected: no classifier/prompts, no MI50 tool enablement, no MCP, buy-in stays scalar (`add_buyin` increments `buy_in_total`).
**Placeholder scan:** All code steps contain complete code. The one "locate the block" instruction (Task 6 Step 3, villain rename) provides the exact button snippet and names the id-field ambiguity to resolve by reading the file — not a placeholder, a grounded splice.
**Type consistency:** `poker_contract.OPERATIONS` shape is consistent across Tasks 1 and 4; REST paths in the contract (`/session/stack`, `/session/buyin`, `/session`, `/session/hand`, `/hand/{hand_id}`, `/session/read`, `/player/{player_id}`, `/session/{session_id}`) match the routes added in Tasks 24 exactly; `update_hand`/`update_player` signatures match their callers; response shapes (`{ok, stack}`, `{ok, buy_in_total}`, `{ok, id}`, `{ok, hand}`, `{ok, player}`) are used consistently in tests and routes.
**Open implementation note:** Task 6 Step 3 requires reading `session.html`'s villain render to confirm the player id field name (`v.id` vs `v.player_id`) before splicing the rename button.
@@ -1,158 +0,0 @@
# Poker logging service + message-type prompts
- **Date:** 2026-06-28
- **Status:** Sub-project 1 spec ready for review; sub-project 2 parked.
- **Branch:** `feat/poker-mode-prompts`
## Origin
This started as "make Lyra's poker replies less generic" (message-type-specific prompts). During design we decided to **build the logging tool first** as a standalone system of record with a clean API and a human-usable UI, then wire Lyra in as a *client* of it. Rationale:
- Brian can log and **correct** data himself, independent of whether Lyra parsed it right (she mislabeled "Dave the rock" vs "Dave the mechanic" mid-session).
- The data stops being hostage to the agent. Lyra becomes one client among potentially several.
- It's reusable: RTO (the solver) and a **fine-tuned poker model on the MI50** could consume the same hand/session data through the same contract.
## Decomposition
Two sub-projects, built and shipped in order.
### Sub-project 1 — Poker logging service *(this spec)*
Harden `lyra/poker.py` into a well-bounded store, expose a **complete REST API** over it, define a **stable, documented tool/API contract**, and build the human UI to log/edit/correct everything. Fully usable by Brian alone, zero LLM dependency.
### Sub-project 2 — Lyra wiring *(parked; summarized at the end)*
Message-type classifier + type-specific prompt fragments; Lyra's tools call the sub-project 1 service. Separately, enabling tool-calling on the MI50 backend so a fine-tuned poker model can drive the same contract.
**Why the contract is first-class:** in every design (in-process, REST, MCP) the *model* never calls the API directly — it emits a tool-call and the host app executes it. So what lets the cloud model, the MI50 fine-tune, RTO, and a human UI all interoperate is a single **stable tool/API schema** (operation names + JSON arg schemas). That contract is the training target for the fine-tune and the seam for every backend. MCP is deferred: it's a thin wrap over the same service, worth adding only when a *second host application* appears.
---
# Sub-project 1 — Poker logging service
## Goals
1. A complete API surface over the poker data model — create/read/update/delete for every entity, not just the few edit/delete endpoints exposed today.
2. A single **documented, versioned tool/API contract** that the REST API, Lyra's LLM tools, the human UI, a future MCP wrapper, and the MI50 fine-tune all share.
3. A human UI to **log** (fast capture) and **edit/correct** (fix Lyra's mistakes) every entity.
4. The 2nd input box (stack quick-capture) and the iOS-PWA bottom safe-area fix.
5. Pure data capture never touches the LLM.
## Non-goals
- Lyra's classifier / prompt fragments (sub-project 2).
- Enabling tools on the MI50 backend (sub-project 2).
- MCP wrapper (deferred until a second host app exists).
- Itemized buy-in history — buy-ins stay a single `buy_in_total` scalar.
- Rewriting the SQLite schema; we build on the existing tables.
## Current state (what exists)
- **Store & logic:** `lyra/poker.py` — schema at `poker.py:21` (tables `poker_sessions`, `poker_hands`, `poker_stack_log`, `poker_rituals`, `poker_players`, `player_reads`, `player_observations`). Functions: `start_session` (157), `add_buyin` (389), `log_stack` (407), `stack_state` (446), `update_session` (362), `end_session` (515), `log_hand` (541, flat/no-LLM), `record_hand` (770, LLM-parses shorthand), `add_read` (1010), `hud` (1245).
- **Exposed endpoints (`lyra/web/server.py`):** `GET /session/data` (hud), `PATCH /session/{id}`, `DELETE /session/entry/{kind}/{id}`, `GET/DELETE /history`, `GET /hand/{id}/data`, `POST /hand/{id}/reconstruct`, `GET /hands/data`, `GET /recap/...`. **No** direct create endpoint for stack/buyin/hand/read/session — those are reachable only through chat → tool-calling.
- **UI:** `index.html` (chat), `session.html` (live HUD: stack card + sparkline + a PATCH-based edit form via `saveEdit()` at `session.html:192`, and `del(kind,id)` at `211`), `history.html`, `hand.html`.
- **Tool specs:** `lyra/tools.py` already defines arg schemas for each operation (`_f(...)` specs, `tools.py:469-658`) — the embryo of the contract.
## Design
### 1. Store layer — harden `poker.py`
Keep the existing functions and tables; tighten the module into a clean service boundary so both the REST layer and Lyra's tools call the *same* functions. Each operation: validates input, resolves the target session (`_resolve`), writes, returns a consistent dict. No behavior change to existing callers; this is consolidation, not a rewrite.
### 2. The tool/API contract *(first-class deliverable)*
A single source-of-truth document + schema defining every operation: name, purpose, JSON arg schema, return shape, and which REST route + which LLM tool map to it. Versioned (e.g. `contract_version: 1`). Lives at `docs/POKER_API.md` (or a machine-readable `poker_contract.py` that both the REST routes and `tools.py` specs derive from — preferred, so they can't drift).
Operations (the canonical set):
| Operation | Args | Entity |
|---|---|---|
| `start_session` | venue, stakes, game, format, buy_in, mantra | session |
| `update_session` | venue, stakes, game, format, buy_in_total, cash_out, mantra, mood | session |
| `end_session` | cash_out, mood | session |
| `delete_session` | id | session |
| `log_stack` | amount, note | stack entry |
| `delete_stack` | id | stack entry |
| `add_buyin` | amount | session (increments buy_in_total) |
| `log_hand` | position, hole_cards, board, streets…, pot, result, tag, lesson | hand |
| `record_hand` | shorthand (LLM-parsed) | hand |
| `update_hand` | id, any hand field | hand |
| `delete_hand` | id | hand |
| `add_read` | note, name, seat, tendencies, adjustment, category, venue | player/read |
| `update_read` / `update_player` | id, fields | player/read |
| `delete_read` | id | player/read |
| rituals: `scar_note`, `confidence_bank`, `alligator_blood`, `reset_ritual` | … | ritual |
### 3. REST API — complete the surface (`lyra/web/server.py`)
Add the missing **create/update** routes so the human UI (and any non-LLM client) can do everything:
- `POST /session/stack``log_stack(amount, note?)`; server-stamped time; returns `stack_state()`.
- `POST /session/buyin``add_buyin(amount)`; returns `buy_in_total`.
- `POST /session``start_session(...)`.
- `POST /session/hand``log_hand(...)` (flat) and/or `record_hand(shorthand)`.
- `PATCH /hand/{id}``update_hand(...)`; `DELETE /hand/{id}`.
- `POST /session/read``add_read(...)`; `PATCH /read/{id}`; `DELETE` via existing entry-delete.
- Keep existing `PATCH /session/{id}`, `DELETE /session/entry/{kind}/{id}`, `GET /session/data`.
All return `{ok, ...}` and a clear error on "no live session." Routes are thin wrappers over the store, mirroring the contract one-to-one.
### 4. Human UI — log + edit/correct
**Fast capture:**
- **2nd input box** (`index.html`): slim row **below the message input, above the bottom nav icons**. Type a number → `POST /session/stack` → time-stamped, sparkline updates, a one-line confirmation drops into the **Live Log**. **No chat message, no LLM call.** Stack-only in v1. Tolerates `$685`/`685`.
- **HUD widget** (`session.html`, in the Stack card at `:280`, mirroring `saveEdit()` at `:192`): stack field (`POST /session/stack`), buy-in field (`POST /session/buyin`), cash-out field (existing PATCH).
**Edit / correct (fix Lyra's mistakes):**
- Edit any session field (exists via the PATCH edit form — verify coverage).
- Hands list with edit + delete (`hand.html` + new PATCH/DELETE) — fix mislabeled villains, wrong board, wrong result.
- Reads/players list with edit + delete — rename "Dave the rock" ≠ "Dave the mechanic", fix tendencies.
- Stack entries deletable (exists via `del('stack', id)`) — verify.
### 5. iOS-PWA bottom safe-area fix
The empty band below the nav icons is a safe-area issue (likely `100vh` not accounting for `env(safe-area-inset-bottom)` / the home indicator). Fix the layout container + bottom nav 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
- **Contract conformance:** a test asserting each REST route and each `tools.py` spec matches the canonical contract (names, required args) — catches drift between the human API and the LLM API.
- **Endpoint round-trips:** create → read → update → delete for stack, buyin, hand, read against a test session; assert rows written, time stamped, `stack_state()`/`hud()` reflect changes; assert clean error with no live session.
- **UI manual pass:** log a stack via the 2nd box and confirm it lands in Live Log + sparkline without a chat reply; edit a hand's villain and confirm persistence; delete a bad read.
- **PWA:** on the iOS PWA, confirm the bottom gap is gone and the 2nd input box sits above the nav with the keyboard open.
---
# Sub-project 2 — Lyra wiring *(parked)*
Detail preserved here; gets its own spec → plan after sub-project 1 is MVP'd.
## Why it exists (diagnosis from real sessions)
Evidence from `sess-dff2s91c` (2026-06-27 Meadows, 2026-06-28 Wheeling):
- **Coaching essay on every turn, including pure data** — `Stack=$685` drew 46 sentences of "keep that momentum rolling." (Sub-project 1's dumb capture removes these from the LLM entirely.)
- **False tilt/fatigue reads** — "table broke, it's 11:50pm" → repeated "late-night fatigue… mental reset"; Brian: *"you seem to be reading me as tilted."* Cause: the `_route` mood nudge (`mind.py:328`) firing on non-mood messages.
- **No bet-intent reasoning** — a value bet ($40, full house) that folded out 88 was praised as "the power of representing something stronger." It was value *lost*, not a successful rep.
- **Eyeballs instead of `analyze_spot`** — 77 multiway got "a disciplined fold might have been better," no math, violating the persona's "never eyeball poker math" rule.
- **Even her sharp reads leak bad logic** — the Connie read included "limp-checking in position" (contradictory).
Root cause: one broad per-turn card (`_CASH_CARD`, `modes.py:66`) describes traits; the model satisfies trait language with safe abstraction.
## Planned approach
- **Classifier** (`lyra/poker_classify.py`): `classify(message) -> HAND | STATUS | MENTAL | LOG | CHAT`. Heuristic v1 (card-token regex, position/street keywords, feeling phrases, time/venue), swappable for an LLM/MI50 classifier behind the same signature. Ambiguous → CHAT.
- **Pipeline:** a `_classify` step in `mind.PIPELINE` sets `ctx.msg_type` (poker mode only); `build_messages` injects the **type fragment** in the seat now held by `mode.card` (`mind.py:152`) instead of the whole `_CASH_CARD`.
- **Fragments** (`lyra/poker_prompts.py`, `POKER_FRAGMENTS`): concrete rules + response shape per type.
- **HAND:** log it → `analyze_spot` if close → reason about **bet intent** (value/bluff/protection — did it work? a fold to a value bet = value lost, flag it) → name leaks (value-owning, missed value, sizing) → one opinion. No reflexive praise. Pull `player_profile` before referencing a villain. Seeded with 2 real-hand exemplars (the $40 value bet; the 77 spot).
- **STATUS:** 12 sentences, no coaching, no tilt-reading.
- **MENTAL:** full presence (already works — preserve).
- **LOG:** one line.
- **CHAT:** full voice, real opinion.
- **Pipeline fixes:** kill the misfiring `_route` mood nudge (`mind.py:328`); trim the always-on mode-menu note (`mind.py:77`) in poker mode.
- **MI50 tool-calling:** enable tools on the MI50 backend (`chat.py` `TOOL_BACKENDS = {"cloud"}` at `:21`) so a fine-tuned, tool-calling poker model can drive the contract. Requires the fine-tune to emit the contract's tool-call format.
## HAND exemplars (preserved for sub-project 2)
> *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 worse two-pairs. The hand's a monster, the leak is the sizing."
> *Brian:* "77 multiway, 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 read (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."
+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 \
+13 -94
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()]
@@ -558,22 +556,6 @@ def log_hand(session_id: int | None = None, **fields) -> int:
return int(cur.lastrowid) return int(cur.lastrowid)
def update_hand(hand_id: int, **fields) -> dict | None:
"""Edit a logged hand's flat fields (fix a mislabeled board, result, villain).
Only known columns are touched. Returns the updated hand row or None."""
sets, vals = [], []
for k, v in fields.items():
if k in _HAND_FIELDS and v is not None:
sets.append(f"{k} = ?")
vals.append(v)
if sets:
conn = _c()
with conn:
conn.execute(f"UPDATE poker_hands SET {', '.join(sets)} WHERE id = ?",
(*vals, hand_id))
return get_hand(hand_id)
def list_hands(session_id: int | None = None) -> list[dict]: def list_hands(session_id: int | None = None) -> list[dict]:
sid = _resolve(session_id) sid = _resolve(session_id)
if sid is None: if sid is None:
@@ -1023,25 +1005,6 @@ def upsert_player(name: str, venue: str | None = None, description: str | None =
return int(cur.lastrowid) return int(cur.lastrowid)
_PLAYER_FIELDS = ("name", "venue", "description", "tendencies", "adjustment", "category")
def update_player(player_id: int, **fields) -> dict | None:
"""Edit a player's dossier (rename, fix tendencies/category). Returns the row or None."""
sets, vals = [], []
for k, v in fields.items():
if k in _PLAYER_FIELDS and v is not None:
sets.append(f"{k} = ?")
vals.append(v)
if sets:
conn = _c()
with conn:
conn.execute(f"UPDATE poker_players SET {', '.join(sets)} WHERE id = ?",
(*vals, player_id))
row = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone()
return dict(row) if row else None
def add_read(note: str, seat: str | None = None, name: str | None = None, def add_read(note: str, seat: str | None = None, name: str | None = None,
session_id: int | None = None, **player_fields) -> int: session_id: int | None = None, **player_fields) -> int:
"""Log a live read. If `name` is given, upsert the player and link the read.""" """Log a live read. If `name` is given, upsert the player and link the read."""
@@ -1216,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.id AS id, 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]
@@ -1333,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),
-19
View File
@@ -1,19 +0,0 @@
from __future__ import annotations
# Single source of truth for poker logging operations. The REST API, Lyra's LLM
# tool specs, the human UI, and (later) an MCP wrapper all derive from this.
# `required` MUST match the `required` list in the matching tools.py spec.
# `rest` PATH MUST match the FastAPI route template verbatim.
CONTRACT_VERSION = 1
OPERATIONS: dict[str, dict] = {
"start_session": {"required": (), "llm_tool": "start_session", "rest": ("POST", "/session")},
"update_session": {"required": (), "llm_tool": "update_session", "rest": ("PATCH", "/session/{session_id}")},
"end_session": {"required": ("cash_out",), "llm_tool": "end_session", "rest": None},
"log_stack": {"required": ("amount",), "llm_tool": "log_stack", "rest": ("POST", "/session/stack")},
"add_buyin": {"required": ("amount",), "llm_tool": "add_buyin", "rest": ("POST", "/session/buyin")},
"log_hand": {"required": (), "llm_tool": "log_hand", "rest": ("POST", "/session/hand")},
"update_hand": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/hand/{hand_id}")},
"add_read": {"required": ("note",), "llm_tool": "add_read", "rest": ("POST", "/session/read")},
"update_player": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/player/{player_id}")},
}
+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 -88
View File
@@ -121,94 +121,6 @@ def create_app() -> FastAPI:
logbus.log("info", "session edited", id=session_id, fields=list(body)) logbus.log("info", "session edited", id=session_id, fields=list(body))
return {"ok": s is not None, "session": s} return {"ok": s is not None, "session": s}
@app.post("/session/stack")
async def session_log_stack(request: Request) -> dict:
"""Log Brian's current stack directly (no LLM). Server-stamps the time."""
body = await request.json()
try:
amount = float(body.get("amount"))
except (TypeError, ValueError):
return {"ok": False, "error": "amount must be a number"}
note = (body.get("note") or "").strip() or None
try:
state = await asyncio.to_thread(poker.log_stack, amount, note)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "stack logged (direct)", amount=amount)
return {"ok": True, "stack": state}
@app.post("/session/buyin")
async def session_add_buyin(request: Request) -> dict:
"""Add a buy-in/rebuy directly (no LLM)."""
body = await request.json()
try:
amount = float(body.get("amount"))
except (TypeError, ValueError):
return {"ok": False, "error": "amount must be a number"}
try:
total = await asyncio.to_thread(poker.add_buyin, amount)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "buyin added (direct)", amount=amount)
return {"ok": True, "buy_in_total": total}
@app.post("/session")
async def session_start(request: Request) -> dict:
"""Open a new live session directly (no LLM)."""
body = await request.json()
sid = await asyncio.to_thread(lambda: poker.start_session(
venue=body.get("venue"), stakes=body.get("stakes"),
game=body.get("game") or "NLH", fmt=body.get("format") or "cash",
buy_in=body.get("buy_in") or 0, mantra=body.get("mantra"),
))
logbus.log("info", "poker session started (direct)", id=sid)
return {"ok": True, "id": sid}
@app.post("/session/hand")
async def session_log_hand(request: Request) -> dict:
"""Log a hand directly with flat fields (no LLM parse)."""
body = await request.json()
try:
hid = await asyncio.to_thread(lambda: poker.log_hand(**body))
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "hand logged (direct)", id=hid)
return {"ok": True, "id": hid}
@app.patch("/hand/{hand_id}")
async def hand_update(hand_id: int, request: Request) -> dict:
"""Edit a logged hand's flat fields."""
body = await request.json()
h = await asyncio.to_thread(lambda: poker.update_hand(hand_id, **body))
logbus.log("info", "hand edited", id=hand_id, fields=list(body))
return {"ok": h is not None, "hand": h}
@app.delete("/hand/{hand_id}")
async def hand_delete(hand_id: int) -> dict:
"""Delete a logged hand."""
ok = await asyncio.to_thread(poker.delete_entry, "hand", hand_id)
return {"ok": ok}
@app.post("/session/read")
async def session_add_read(request: Request) -> dict:
"""Log a read directly (no LLM); upserts the villain file when name is given."""
body = await request.json()
rid = await asyncio.to_thread(lambda: poker.add_read(
note=body.get("note") or "", seat=body.get("seat"), name=body.get("name"),
tendencies=body.get("tendencies"), adjustment=body.get("adjustment"),
description=body.get("description"), category=body.get("category"),
venue=body.get("venue"),
))
return {"ok": True, "id": rid}
@app.patch("/player/{player_id}")
async def player_update(player_id: int, request: Request) -> dict:
"""Edit a player's dossier (rename, fix tendencies)."""
body = await request.json()
p = await asyncio.to_thread(lambda: poker.update_player(player_id, **body))
logbus.log("info", "player edited", id=player_id, fields=list(body))
return {"ok": p is not None, "player": p}
@app.delete("/session/entry/{kind}/{entry_id}") @app.delete("/session/entry/{kind}/{entry_id}")
async def delete_entry(kind: str, entry_id: int) -> dict: async def delete_entry(kind: str, entry_id: int) -> dict:
"""Delete one HUD entry (hand | stack | read | ritual) by id.""" """Delete one HUD entry (hand | stack | read | ritual) by id."""
@@ -427,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"))
+23 -75
View File
@@ -3,14 +3,15 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Lyra Core Chat</title> <title>Lyra Core Chat</title>
<link rel="stylesheet" href="style.css?v=8" /> <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" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Lyra" /> <meta name="apple-mobile-web-app-title" content="Lyra" />
<meta name="theme-color" content="#141414" /> <meta name="theme-color" content="#070707" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" /> <link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="icon" type="image/png" href="icon-192.png" /> <link rel="icon" type="image/png" href="icon-192.png" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
@@ -124,18 +125,13 @@
<button id="sendBtn" aria-label="Send" title="Send (or ⌘/Ctrl+Enter)"></button> <button id="sendBtn" aria-label="Send" title="Send (or ⌘/Ctrl+Enter)"></button>
</div> </div>
<!-- Stack quick-capture (no LLM): type a number -> logs current stack -->
<div id="stackQuick">
<input id="stackQuickInput" type="number" inputmode="decimal" placeholder="Stack $" aria-label="Log current stack">
<button id="stackQuickBtn" type="button" title="Log stack (no chat)">Log</button>
</div>
<!-- Bottom tab bar (mobile only; hides while the keyboard is open) --> <!-- Bottom tab bar (mobile only; hides while the keyboard is open) -->
<nav id="tabbar" aria-label="Primary navigation"> <nav id="tabbar" aria-label="Primary navigation">
<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>
@@ -215,72 +211,6 @@
const API_URL = `${RELAY_BASE}/v1/chat/completions`; const API_URL = `${RELAY_BASE}/v1/chat/completions`;
const STREAM_URL = `${RELAY_BASE}/v1/chat/stream`; const STREAM_URL = `${RELAY_BASE}/v1/chat/stream`;
// Stack quick-capture (no LLM): type a number -> POST /session/stack.
function stackQuickLog() {
const el = document.getElementById("stackQuickInput");
if (!el) return;
const raw = (el.value || "").replace(/[^0-9.]/g, "");
if (!raw) return;
const amount = Number(raw);
const content = document.getElementById("thinkingContent");
const empty = document.getElementById("thinkingEmpty");
fetch("/session/stack", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount })
}).then(r => r.json()).then(data => {
if (empty && empty.parentNode) empty.parentNode.removeChild(empty);
const line = document.createElement("div");
const t = new Date().toLocaleTimeString();
if (!data.ok) {
line.className = "log-line log-error";
line.textContent = "⚠ " + (data.error || "stack not logged");
} else {
line.className = "log-line log-info";
const net = (data.stack && data.stack.net != null)
? " (net " + (data.stack.net >= 0 ? "+" : "") + data.stack.net + ")" : "";
line.textContent = t + " 💰 $" + amount + " logged" + net;
el.value = "";
}
if (content) { content.appendChild(line); content.scrollTop = content.scrollHeight; }
}).catch(e => {
if (content) {
const line = document.createElement("div");
line.className = "log-line log-error";
line.textContent = "⚠ stack log failed: " + e.message;
content.appendChild(line);
}
});
}
// Only show the stack quick-logger when a poker session is actually live —
// otherwise logging just errors ("no live session").
function updateStackQuickVisibility() {
const box = document.getElementById("stackQuick");
if (!box) return;
fetch("/session/data", { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (data) {
const live = !!(data && data.session && data.session.is_live);
box.style.display = live ? "flex" : "none";
})
.catch(function () { box.style.display = "none"; });
}
(function wireStackQuick() {
const box = document.getElementById("stackQuick");
const btn = document.getElementById("stackQuickBtn");
const inp = document.getElementById("stackQuickInput");
if (box) box.style.display = "none"; // hidden until a live session is confirmed
if (btn) btn.addEventListener("click", stackQuickLog);
if (inp) inp.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); stackQuickLog(); }
});
updateStackQuickVisibility();
setInterval(updateStackQuickVisibility, 10000);
document.addEventListener("visibilitychange", function () {
if (!document.hidden) updateStackQuickVisibility();
});
})();
function generateSessionId() { function generateSessionId() {
return "sess-" + Math.random().toString(36).substring(2, 10); return "sess-" + Math.random().toString(36).substring(2, 10);
} }
@@ -1317,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;
})();
+1 -65
View File
@@ -104,26 +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; }
/* quick-capture (no LLM) + inline correction controls */
.quick { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.quick input { width: 100px; background: var(--bg-line); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; color: var(--text); }
.quick input:focus { outline: none; border-color: var(--accent); }
.quick button { background: var(--accent); color: #0a0a0a; border: 1px solid var(--accent);
border-radius: 8px; padding: 8px 12px; cursor: pointer; font-weight: 600; }
button.mini { background: none; border: none; color: var(--fade); cursor: pointer;
font-size: .9rem; padding: 0 6px; }
button.mini:active { color: var(--accent); }
</style> </style>
</head> </head>
<body> <body>
@@ -227,30 +207,6 @@
} catch(e){ alert('Delete failed: '+e.message); } } catch(e){ alert('Delete failed: '+e.message); }
} }
// Quick-capture (no LLM): post a number to a direct endpoint, then refresh.
function numVal(id){ const el = document.getElementById(id); return Number(((el && el.value) || '').replace(/[^0-9.]/g,'')); }
async function postQuick(url, amount){
const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ amount }) });
const d = await r.json();
if(!d.ok){ alert(d.error || 'failed'); return false; }
return true;
}
async function postStack(){ const v = numVal('qStack'); if(v && await postQuick('/session/stack', v)){ document.getElementById('qStack').value=''; refresh(); } }
async function postBuyin(){ const v = numVal('qBuyin'); if(v && await postQuick('/session/buyin', v)){ document.getElementById('qBuyin').value=''; refresh(); } }
async function postCashout(){
if(!curSession) return;
const v = numVal('qCashout'); if(!v) return;
const r = await fetch('/session/'+curSession.id, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ cash_out: v }) });
if(!(await r.json()).ok){ alert('failed'); return; }
document.getElementById('qCashout').value=''; refresh();
}
async function renamePlayer(id, current){
const name = prompt('Rename player', current || ''); if(!name) return;
const r = await fetch('/player/'+id, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name }) });
if(!(await r.json()).ok){ alert('failed'); return; }
refresh();
}
function render(data){ function render(data){
const s = data.session; const s = data.session;
if (!s) { if (!s) {
@@ -263,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 || [];
@@ -319,25 +274,7 @@
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span> <span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
</div> </div>
${sparkline(stack.log || [])} ${sparkline(stack.log || [])}
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — log it below or tell Lyra ("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 class="quick">
<input id="qStack" type="number" inputmode="decimal" placeholder="Stack $" onkeydown="if(event.key==='Enter')postStack()">
<button onclick="postStack()">Log stack</button>
<input id="qBuyin" type="number" inputmode="decimal" placeholder="Buy-in $" onkeydown="if(event.key==='Enter')postBuyin()">
<button onclick="postBuyin()">Add buy-in</button>
<input id="qCashout" type="number" inputmode="decimal" placeholder="Cash out $" onkeydown="if(event.key==='Enter')postCashout()">
<button onclick="postCashout()">Cash out</button>
</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>
<div class="card"> <div class="card">
@@ -374,7 +311,6 @@
${villains.length ? `<ul class="rows">${villains.map(v => ` ${villains.length ? `<ul class="rows">${villains.map(v => `
<li class="villain"> <li class="villain">
<b>${esc(v.name)}</b> ${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''} <b>${esc(v.name)}</b> ${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'').replace(/'/g,"\\'")}')"></button>
${v.tendencies ? `<div>${esc(v.tendencies)}</div>` : ''} ${v.tendencies ? `<div>${esc(v.tendencies)}</div>` : ''}
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''} ${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
</li>`).join('')}</ul>` </li>`).join('')}</ul>`
+5 -44
View File
@@ -62,12 +62,6 @@ html {
text-size-adjust: 100%; text-size-adjust: 100%;
} }
html {
/* Paints the iOS home-indicator strip below the dvh shell; match the tab bar so the
bar looks like it continues to the physical bottom edge. */
background: var(--bg-line);
}
body { body {
margin: 0; margin: 0;
background: var(--bg-dark); background: var(--bg-dark);
@@ -836,17 +830,15 @@ select:hover {
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
body { body {
padding: 0; padding: 0;
background: var(--bg-line); /* matches the tab bar so any strip below #chat is seamless */ background: var(--bg-elev); /* matches the tab bar so any strip below #chat is seamless */
} }
#chat { #chat {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; top: 0; left: 0; right: 0;
width: 100%; width: 100%;
height: 100vh; /* fallback for old browsers */ height: 100dvh; /* the *visible* viewport (excludes the home-indicator zone);
height: 100dvh; /* the *visible* viewport keep all content (incl. the tab bar) overrides the base 95vh. Body bg matches the bar below it. */
inside what iOS actually paints, so nothing is clipped into the
home-indicator dead zone. The strip below is matched in color. */
background: var(--bg-dark); background: var(--bg-dark);
border-radius: 0; border-radius: 0;
border: none; border: none;
@@ -930,11 +922,8 @@ select:hover {
display: flex; display: flex;
flex: none; /* never let it be compressed/clipped by the flex column */ flex: none; /* never let it be compressed/clipped by the flex column */
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background: var(--bg-line); /* lighter than the page so it reads as a solid bar */ background: var(--bg-elev);
/* Shell is 100dvh, so the bar sits at the bottom of the rendered area with the icons padding-bottom: 6px; /* 100dvh already excludes the home-indicator zone */
fully visible. Minimal padding keeps them low; the home-indicator strip just below
the rendered area is painted the same color (html bg) so the bar looks continuous. */
padding-bottom: 4px;
padding-left: env(safe-area-inset-left); padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right); padding-right: env(safe-area-inset-right);
} }
@@ -1242,31 +1231,3 @@ select:hover {
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
} }
/* Stack quick-capture (2nd input box on the chat page) — logs without the LLM. */
#stackQuick {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 12px;
border-top: 1px solid var(--border);
background: var(--bg-panel);
}
#stackQuick input {
flex: 1;
min-width: 0;
padding: 8px 10px;
background: var(--bg-elev);
color: inherit;
border: 1px solid var(--border);
border-radius: 8px;
}
#stackQuick button {
padding: 8px 14px;
background: var(--accent);
color: #000;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
+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"
-82
View File
@@ -1,82 +0,0 @@
from __future__ import annotations
import importlib
import pytest
@pytest.fixture
def client(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)
import lyra.web.server as server
importlib.reload(server)
from fastapi.testclient import TestClient
return TestClient(server.app), poker
def test_post_stack_logs_and_returns_state(client):
c, poker = client
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
r = c.post("/session/stack", json={"amount": 373})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["stack"]["current"] == 373
assert body["stack"]["net"] == pytest.approx(-27)
def test_post_stack_without_session_errors(client):
c, _ = client
r = c.post("/session/stack", json={"amount": 373})
assert r.json()["ok"] is False
assert "error" in r.json()
def test_post_buyin_increments_total(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/buyin", json={"amount": 200})
assert r.json()["buy_in_total"] == pytest.approx(600)
def test_post_session_starts_live(client):
c, poker = client
r = c.post("/session", json={"venue": "Wheeling", "stakes": "1/3", "buy_in": 400})
sid = r.json()["id"]
assert poker.live_session()["id"] == sid
def test_post_hand_edit_and_delete(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/hand", json={"position": "BTN", "hole_cards": "22", "result": 120})
assert r.json()["ok"] is True
hid = r.json()["id"]
r2 = c.patch(f"/hand/{hid}", json={"hole_cards": "2c2d"})
assert r2.json()["ok"] is True
assert r2.json()["hand"]["hole_cards"] == "2c2d"
r3 = c.delete(f"/hand/{hid}")
assert r3.json()["ok"] is True
assert poker.get_hand(hid) is None
def test_post_read(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/read", json={"note": "3-bets light", "name": "James K"})
assert r.json()["ok"] is True
assert isinstance(r.json()["id"], int)
def test_rename_player_fixes_mislabel(client):
c, poker = client
pid = poker.upsert_player("Dave the rock", category="reg")
r = c.patch(f"/player/{pid}", json={"name": "Dave the mechanic"})
assert r.json()["ok"] is True
assert r.json()["player"]["name"] == "Dave the mechanic"
-33
View File
@@ -1,33 +0,0 @@
from __future__ import annotations
from lyra import tools
from lyra.poker_contract import OPERATIONS
def test_llm_tool_required_args_match_contract():
for op, decl in OPERATIONS.items():
name = decl["llm_tool"]
if not name:
continue
spec = tools.TOOLS[name]["spec"]
required = set(spec["function"]["parameters"]["required"])
assert required == set(decl["required"]), (
f"{op}: tools spec required {required} != contract {set(decl['required'])}"
)
def test_rest_routes_registered():
import lyra.web.server as server
registered = set()
for route in server.app.routes:
methods = getattr(route, "methods", None)
path = getattr(route, "path", None)
if not methods or not path:
continue
for m in methods:
registered.add((m, path))
for op, decl in OPERATIONS.items():
if not decl["rest"]:
continue
method, path = decl["rest"]
assert (method, path) in registered, f"{op}: {method} {path} not registered"