11 Commits

Author SHA1 Message Date
serversdown 865834a8ae feat: hide stack quick-logger unless a poker session is live
Poll /session/data on load, every 10s, and on foreground; show the
stack box only when session.is_live, hide it otherwise (logging with no
live session just errors). Hidden by default until confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-30 05:55:33 +00:00
serversdown fb6b44a82e fix: color-match the iOS home-indicator strip to the bottom bar
iOS won't render interactive content in the bottom ~59px (home-indicator
zone) and underreports innerHeight by the top inset, so the bar can't be
pushed lower. Instead, keep the shell at 100dvh (no clipping) and paint
the strip below it the same color as the tab bar (html/body bg + a
lighter --bg-line bar + matched theme-color) so it reads as the bar
continuing to the edge rather than an empty gap. Cache-bust the
stylesheet so the PWA picks it up.

Tried the boolinator innerHeight-correction approach (adding the top
inset back via --actual-vh); it clipped the icons on-device, so reverted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-30 05:27:44 +00:00
serversdown 393ca65dee fix: tab bar fills iOS home-indicator zone (no empty band below icons)
Switch the mobile #chat shell from 100dvh to full-height (100vh/100lvh)
and pad the tab bar by env(safe-area-inset-bottom) so the bar reaches the
physical bottom and its icons sit above the home indicator — instead of
leaving a same-color band below the icons. Keyboard-pinning path
(body.kb #chat) unchanged. Per building-ios-pwas skill; needs on-device
verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-29 00:30:27 +00:00
serversdown 14480c40b2 feat: HUD quick-capture (stack/buyin/cashout) + villain rename
Direct-capture inputs on the Stack card and a per-villain rename control
(fixes mislabeled players); expose player id in the HUD villains bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-29 00:27:04 +00:00
serversdown 8d709b9554 feat: stack quick-capture box on chat page (no LLM)
Slim numeric input below the message box; posts to /session/stack and
drops a confirmation line into the Live Log without a chat turn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-29 00:25:18 +00:00
serversdown 52839a9bc8 feat: reads/players API + REST route conformance test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-29 00:02:25 +00:00
serversdown 8ad4bc4ce0 feat: hands API — log_hand endpoint, update_hand store fn, edit/delete routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-29 00:01:16 +00:00
serversdown 8031c277a2 feat: direct REST endpoints for stack/buyin/start-session (no LLM)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-29 00:00:21 +00:00
serversdown 36f2aa76b3 feat: poker operation contract + tool-spec conformance test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-28 23:59:14 +00:00
serversdown a2835500bc docs: implementation plan for poker logging service (sub-project 1)
Seven tasks: contract module + conformance test, direct capture
endpoints (stack/buyin/start), hands API, reads/players API + route
conformance, chat-page stack quick-capture box, HUD quick inputs +
villain rename, and the iOS-PWA bottom safe-area fix. TDD with real
test/impl code grounded in the actual poker.py signatures and FastAPI
route patterns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-28 23:27:58 +00:00
serversdown 5c63175a3c docs: restructure spec — poker logging service first, then Lyra wiring
Decompose into two sub-projects: (1) harden poker.py into a standalone
logging service with a complete REST API, a first-class documented
tool/API contract, and a human UI to log/edit/correct everything —
usable by Brian alone, zero LLM dependency; (2) wire Lyra in as a
client (classifier + message-type prompts), parked. Contract is
first-class because it's the shared seam for the cloud model, a
fine-tuned MI50 poker model, RTO, the human UI, and a future MCP wrap.
MCP deferred until a second host app exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-28 20:59:52 +00:00
10 changed files with 1263 additions and 89 deletions
@@ -0,0 +1,720 @@
# 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,115 +1,158 @@
# Poker mode: message-type-specific prompts + dumb capture # Poker logging service + message-type prompts
- **Date:** 2026-06-28 - **Date:** 2026-06-28
- **Status:** Draft for review - **Status:** Sub-project 1 spec ready for review; sub-project 2 parked.
- **Scope:** Poker (`poker_cash`) mode only. This is the template; the same pattern will later extend to Build/Explore/Study/Decide in a separate pass. - **Branch:** `feat/poker-mode-prompts`
## Problem ## Origin
In poker mode Lyra routes correctly but her replies are generic — "stringing poker words together." Evidence from the 2026-06-27 (Meadows) and 2026-06-28 (Wheeling) sessions in chat `sess-dff2s91c`: 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:
- **Every turn is a coaching essay, including pure data.** A stack update (`Stack=$685`) draws 46 sentences of momentum filler ("keep that momentum rolling," "you've got this"). Bare facts don't need a brain. - 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).
- **She projects tilt/fatigue onto neutral facts.** "Table broke, it's 11:50pm" → three replies about "late-night fatigue-driven decisions… mental reset." Brian had to say *"I'm not upset… you seem to be reading me as tilted."* Cause: the `_route` mood nudge (`lyra/mind.py:328`) firing on non-mood messages. - The data stops being hostage to the agent. Lyra becomes one client among potentially several.
- **She doesn't reason about bet intent.** On a hand where Brian flopped bottom set, turned a full house, value bet $40 and got 88 to fold, she said *"his fold… shows the power of representing something stronger… well executed."* That was a **value bet that got no value** — money left on the table — not a successful rep. She pattern-matched "bet → fold → good." - 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.
- **She never calls `analyze_spot`; she eyeballs.** The 77 multiway spot got *"a disciplined fold might have been the better path"* — pure hedge, no math, violating the persona's "never eyeball poker math" rule.
- **Even her best reply leaks bad poker logic.** The sharp Connie read included "limp-checking in position" — contradictory nonsense — proving "sounds sharp" ≠ "is correct."
Root cause: one large per-turn card (`_CASH_CARD`, `lyra/modes.py:66`) describes **traits** ("be decisive," "be present"). The model satisfies trait language with safe, flattering abstraction. Pure-data turns shouldn't reach the model at all; judgment turns need concrete response contracts, not adjectives. ## 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 ## Goals
1. Pure data entry (stack, buy-in, cash-out) never touches the LLM. 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. Conversational turns get a **message-type-specific** prompt instead of one broad card. 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. Kill the false tilt/fatigue reads. 3. A human UI to **log** (fast capture) and **edit/correct** (fix Lyra's mistakes) every entity.
4. On hands, reason about **bet intent** and lean on `analyze_spot` — flag missed value, value-owning, and sizing rather than reflexively praising. 4. The 2nd input box (stack quick-capture) and the iOS-PWA bottom safe-area fix.
5. Fix the iOS-PWA bottom safe-area gap surfaced in testing. 5. Pure data capture never touches the LLM.
## Non-goals ## Non-goals
- Rewriting the other five modes (separate pass). - Lyra's classifier / prompt fragments (sub-project 2).
- Changing the tool handlers themselves (`poker.log_stack`, etc. already exist and are LLM-independent). - Enabling tools on the MI50 backend (sub-project 2).
- An LLM-based classifier in v1 (the design leaves the seam for it; v1 ships heuristics). - MCP wrapper (deferred until a second host app exists).
- Itemized buy-in history (buy-ins remain a single `buy_in_total`). - Itemized buy-in history buy-ins stay a single `buy_in_total` scalar.
- Rewriting the SQLite schema; we build on the existing tables.
## Architecture ## Current state (what exists)
Split poker input by whether it needs judgment. - **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.
- **Track 1 — dumb capture:** pure data → direct HTTP endpoint → tool handler. No prompt, no reply. ## Design
- **Track 2 — conversational:** anything needing a brain → a classifier tags the message type → a type-specific prompt fragment is injected in place of the giant card.
- **PWA fix:** correct the bottom safe-area inset.
### Deliverable 1 — Track 1: dumb capture (no LLM) ### 1. Store layer — harden `poker.py`
**Backend (new endpoints in `lyra/web/server.py`).** Today the *only* write path for a stack/buy-in is chat → tool-calling; no direct endpoint exists. Add: 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.
- `POST /session/stack``poker.log_stack(amount)` → returns updated `stack_state()`. Server stamps the time. ### 2. The tool/API contract *(first-class deliverable)*
- `POST /session/buyin``poker.add_buyin(amount)` → returns new `buy_in_total`.
- Cash-out reuses the existing `PATCH /session/{id}` with `{cash_out}` (already recomputes `net`).
Both resolve the live session via `poker._resolve(None)` and return `{"ok": ..., "stack": ...}`. On "no live session" return a clear error the UI can show. 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).
**Frontend — the 2nd input box (`lyra/web/static/index.html`).** A slim quick-capture row **below the message input, above the bottom nav icons**. Behavior: Operations (the canonical set):
- Type a number (e.g. `685`) → `POST /session/stack` → on success, append a one-line confirmation to the **Live Log** (`$685 logged · 11:34pm`) and update the HUD. **No chat message, no LLM call, main message box untouched.** | Operation | Args | Entity |
- Scope: **stack only** in v1. Buy-in and cash-out live on the Session HUD widget below. |---|---|---|
- Empty/non-numeric input is ignored (or shows inline hint). Decimal and `$`-prefixed input tolerated (`$685`, `685`). | `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 |
**Frontend — HUD widget (`lyra/web/static/session.html`).** In the Stack card (`session.html:280`), mirroring the existing `saveEdit()` PATCH pattern (`session.html:192`): a stack field (`POST /session/stack`), a buy-in field (`POST /session/buyin`), and a cash-out field (existing PATCH). Each updates the sparkline on success. ### 3. REST API — complete the surface (`lyra/web/server.py`)
### Deliverable 2 — Track 2: classifier + type fragments Add the missing **create/update** routes so the human UI (and any non-LLM client) can do everything:
**Classifier (`lyra/poker_classify.py`, new).** A function `classify(message: str) -> str` returning one of `HAND | STATUS | MENTAL | LOG | CHAT`. v1 is heuristic; the signature is the swappable seam — an LLM/MI50 implementation can replace the body later with no other changes. - `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`.
Heuristic signals (first match wins, in priority order): All return `{ok, ...}` and a clear error on "no live session." Routes are thin wrappers over the store, mirroring the contract one-to-one.
1. **HAND** — card-token regex (`\b[2-9TJQKA][shdc]\b` appearing ≥2×), or position tokens (UTG/MP/HJ/CO/BTN/SB/BB/"button"/"hijack"), or street words (flop/turn/river) combined with betting verbs (bet/raise/call/fold/check/shove/limp). ### 4. Human UI — log + edit/correct
2. **MENTAL** — first-person feeling: "I feel", "I'm tilted/steaming/fried/tired/frustrated/confident", "in my head", "mental", "leak", "on tilt".
3. **STATUS** — time/venue/logistics with no cards: "table broke", "new table", "waiting for a seat", "seat opened", clock times, venue names.
4. **LOG** — prose money/result that slipped past the 2nd box: "I'm at 350", "stack is", "out for", "cashed", "rebought" with a number.
5. **CHAT** — default fallback (questions, open talk).
Ambiguous → `CHAT` (safe full-voice default). **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).
**Pipeline integration (`lyra/mind.py`).** Add a `_classify` step to `PIPELINE` (after `_route`, before `_compose`) that sets `ctx.msg_type` when `ctx.mode.key == "poker_cash"`. In `build_messages` (`mind.py:137`), when in poker mode, inject the **type fragment** in the seat currently held by `mode.card` (`mind.py:152`) instead of the whole `_CASH_CARD`. Non-poker modes are unchanged. **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.
**Fragment registry (new `lyra/poker_prompts.py`).** `POKER_FRAGMENTS: dict[str, str]` keyed by type. Each fragment is short and concrete (rules + response shape), not trait language. Persona guardrails ("never eyeball math," "don't invent reads") stay sourced from `personas/lyra.md` via `_persona_block`. ### 5. iOS-PWA bottom safe-area fix
Response contracts: 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.
- **HAND.** Steps, in order: (1) log it (`record_hand` preferred, or `log_hand`); (2) if the decision is close or equity matters, call `analyze_spot` — do **not** eyeball; (3) reason about **bet intent** — for each meaningful bet, name what it was for (value / bluff / protection) and whether it achieved that (a fold to a value bet = value lost — flag it; a call of a bluff = bluff failed); (4) name leaks plainly (value-owning, missed value, bad sizing); (5) one clear opinion. **No reflexive praise** — do not open with "nice hand." If a named villain is referenced, call `player_profile`/`get_villain_file` first; never invent a read. Talk in prose, not a listicle. Seed with 2 exemplars (below).
- **STATUS.** 12 sentences. Acknowledge, log a stack only if a bare number is present, then stop. **No coaching. No tilt/fatigue reads.**
- **MENTAL.** Full presence, real voice, an actual point of view. (This register already works — preserve it.)
- **LOG.** One line. `$317 logged.` Nothing else.
- **CHAT.** Full voice, a real opinion, no filler sign-offs.
**HAND exemplars** (embedded in the fragment):
> *Brian:* "Flopped bottom set with 22, turned a full house, bet $40 on the river, he folded 88."
> *Lyra:* "That's a value bet that got no value — 88 was never folding to a smaller stab. $40 into that pot was too much; ~$2025 gets called by the over-pair and the worse two-pairs. The hand's a monster, the leak is the sizing."
> *Brian:* "77 in a multiway pot, flop 4h5c3c, HJ bets 75, BTN shoves 74, I call. Turn/river check through, HJ wins with 64."
> *Lyra:* "Against a bet and a shove on 4-5-3 you're drawing thin — sets, two pair, and the made wheel are all ahead, and you block almost none of it. The stack-depth logic (he only had ~150 behind) is real, but that's a reason to fold and wait, not to call off light. This is the value-owning spot you flagged yourself."
**Pipeline fixes:**
- Remove/gate the `_route` mood nudge (`mind.py:328`) so a steady→"steady" / up→"hype" note is **not** appended on non-MENTAL messages. Mental-game register comes only from the MENTAL fragment.
- Trim the always-on mode-menu note (`mind.py:77`) inside poker mode — it dilutes every turn.
### Deliverable 3 — PWA bottom safe-area fix
The band below the nav icons is an iOS-PWA safe-area issue (likely `100vh` not accounting for `env(safe-area-inset-bottom)` / the home indicator). Fix the layout container and bottom nav in the web CSS so the app fills the viewport with the icons seated above the home indicator. Use the `building-ios-pwas` skill at implementation time.
## Testing / verification ## Testing / verification
- **Classifier unit tests:** feed the real user turns from both transcripts and assert the expected type (e.g. "Stack is at $373" → LOG/handled by box, "Button straddle… I limp UTG with 22…" → HAND, "table broke, it's 11:50pm" → STATUS, "I feel like I'm being mean when I raise" → MENTAL). - **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 tests:** `POST /session/stack` / `/session/buyin` against a live test session; assert the row is written, time stamped, and `stack_state()` updates; assert a clean error when no live session. - **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.
- **Transcript replay (eyeball eval):** run yesterday's + today's user turns through the new classifier + fragments and compare replies to the logged mush — confirm stack updates produce no essay, the $40 hand is flagged as missed value, and STATUS turns carry no tilt-reading. - **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:** verify on the iOS PWA that the gap is gone and the new input box sits above the nav with the keyboard open. - **PWA:** on the iOS PWA, confirm the bottom gap is gone and the 2nd input box sits above the nav with the keyboard open.
## Out of scope / future ---
- LLM classifier behind the same interface (if heuristics prove brittle). # Sub-project 2 — Lyra wiring *(parked)*
- Extending message-type fragments to the other five modes.
- Itemized buy-in history / per-table stack series. Detail preserved here; gets its own spec → plan after sub-project 1 is MVP'd.
- Buy-in/cash-out in the chat-page 2nd box (HUD only for v1).
## 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."
+36 -1
View File
@@ -558,6 +558,22 @@ 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:
@@ -1007,6 +1023,25 @@ 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."""
@@ -1229,7 +1264,7 @@ def timeline(session_id: int | None = None) -> list[dict]:
def _session_villains(sid: int) -> list[dict]: def _session_villains(sid: int) -> list[dict]:
"""Players read this session, with their standing dossier fields.""" """Players read this session, with their standing dossier fields."""
rows = _c().execute( rows = _c().execute(
"SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, " "SELECT p.id AS id, 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 "
+19
View File
@@ -0,0 +1,19 @@
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}")},
}
+88
View File
@@ -121,6 +121,94 @@ 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."""
+74 -2
View File
@@ -3,14 +3,14 @@
<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" /> <link rel="stylesheet" href="style.css?v=8" />
<!-- 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="#070707" /> <meta name="theme-color" content="#141414" />
<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,6 +124,12 @@
<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>
@@ -209,6 +215,72 @@
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);
} }
+44 -1
View File
@@ -114,6 +114,16 @@
li.start .tl-body { color: var(--accent); font-weight: 600; } li.start .tl-body { color: var(--accent); font-weight: 600; }
li.scar .tl-body, li.confidence .tl-body { font-style: italic; } li.scar .tl-body, li.confidence .tl-body { font-style: italic; }
.tl-body a.hand { color: var(--accent); text-decoration: none; white-space: nowrap; } .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>
@@ -217,6 +227,30 @@
} 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) {
@@ -285,7 +319,15 @@
<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 — tell Lyra your stack ("I\'m at 350").</p>' : ''} ${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>' : ''}
<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>
<div class="card"> <div class="card">
@@ -332,6 +374,7 @@
${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>`
+44 -5
View File
@@ -62,6 +62,12 @@ 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);
@@ -830,15 +836,17 @@ select:hover {
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
body { body {
padding: 0; padding: 0;
background: var(--bg-elev); /* matches the tab bar so any strip below #chat is seamless */ background: var(--bg-line); /* 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: 100dvh; /* the *visible* viewport (excludes the home-indicator zone); height: 100vh; /* fallback for old browsers */
overrides the base 95vh. Body bg matches the bar below it. */ height: 100dvh; /* the *visible* viewport — keep all content (incl. the tab bar)
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;
@@ -922,8 +930,11 @@ 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-elev); background: var(--bg-line); /* lighter than the page so it reads as a solid bar */
padding-bottom: 6px; /* 100dvh already excludes the home-indicator zone */ /* Shell is 100dvh, so the bar sits at the bottom of the rendered area with the icons
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);
} }
@@ -1231,3 +1242,31 @@ 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;
}
+82
View File
@@ -0,0 +1,82 @@
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
@@ -0,0 +1,33 @@
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"