diff --git a/deploy/mi50-watchdog/README.md b/deploy/mi50-watchdog/README.md new file mode 100644 index 0000000..c961a9e --- /dev/null +++ b/deploy/mi50-watchdog/README.md @@ -0,0 +1,54 @@ +# MI50 runaway watchdog (fallback layer "A") + +Independent host-side backstop to Lyra's in-app dream-cycle budget (layer "C", +`lyra/dream.py`). Stops the llama.cpp backend if the MI50 is busy too long or too +hot, and pings Brian. See +`docs/superpowers/specs/2026-07-04-mi50-runaway-guards-design.md`. + +## What it does + +Runs on the **Proxmox host** (`10.0.0.4`) via a systemd timer, every ~2 min: + +- **Duration:** if the GPU is busy (`rocm-smi` use% > 0) for **3600s continuously**, + it stops the container. Any idle read resets the streak, so a legitimate ~40-min + manual workload never trips it. +- **Temperature:** if junction ≥ **97°C** for **3 consecutive checks (~6 min)**, it + stops the container — independent of duration. +- On either trip: `pct exec 202 -- docker stop lyra-brain`, clear state, `logger` a + line, and POST to your ntfy topic. + +All thresholds are `Environment=` overrides in the `.service`. + +## Install (on the Proxmox host, as root) + +```sh +# copy the three files up (from the repo, on lyra-cortex): +scp -i ~/.ssh/id_lyra_proxmox deploy/mi50-watchdog/mi50-watchdog.sh \ + root@10.0.0.4:/usr/local/sbin/mi50-watchdog.sh +scp -i ~/.ssh/id_lyra_proxmox deploy/mi50-watchdog/mi50-watchdog.{service,timer} \ + root@10.0.0.4:/etc/systemd/system/ + +# on the host: +chmod +x /usr/local/sbin/mi50-watchdog.sh +# set your ntfy topic (same one Lyra uses) in the service: +sed -i 's/CHANGE_ME/YOUR_NTFY_TOPIC/' /etc/systemd/system/mi50-watchdog.service +systemctl daemon-reload +systemctl enable --now mi50-watchdog.timer +``` + +## Verify (when the card is back and healthy) + +```sh +# dry run once, watch what it decides: +NTFY_URL= /usr/local/sbin/mi50-watchdog.sh; echo "exit $?" +journalctl -t mi50-watchdog -n 20 --no-pager + +# force a trip test with tiny thresholds (won't touch a healthy idle card unless busy): +MAX_BUSY_SEC=60 TEMP_KILL_C=40 TEMP_KILL_STREAK=1 /usr/local/sbin/mi50-watchdog.sh +# confirm it stopped lyra-brain + sent the ntfy, then restart the container. + +systemctl list-timers mi50-watchdog.timer # confirm it's scheduled +``` + +**Not yet installed / live-verified** — staged here on 2026-07-04 while the card is +off and Brian is away. Install + trip-test when the MI50 is back. diff --git a/deploy/mi50-watchdog/mi50-watchdog.service b/deploy/mi50-watchdog/mi50-watchdog.service new file mode 100644 index 0000000..8dbdeee --- /dev/null +++ b/deploy/mi50-watchdog/mi50-watchdog.service @@ -0,0 +1,16 @@ +[Unit] +Description=MI50 runaway watchdog (stop the llama.cpp backend if the GPU is busy too long or too hot) +After=network-online.target + +[Service] +Type=oneshot +# Fill in your ntfy topic so it can ping Brian when it trips (leave URL empty to log only). +Environment=NTFY_URL=https://ntfy.sh +Environment=NTFY_TOPIC=CHANGE_ME +# Optional overrides (defaults shown): +# Environment=MAX_BUSY_SEC=3600 +# Environment=TEMP_KILL_C=97 +# Environment=TEMP_KILL_STREAK=3 +# Environment=CTID=202 +# Environment=CONTAINER=lyra-brain +ExecStart=/usr/local/sbin/mi50-watchdog.sh diff --git a/deploy/mi50-watchdog/mi50-watchdog.sh b/deploy/mi50-watchdog/mi50-watchdog.sh new file mode 100755 index 0000000..cf5b64c --- /dev/null +++ b/deploy/mi50-watchdog/mi50-watchdog.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# MI50 runaway watchdog — fallback layer "A". +# +# Runs on the Proxmox HOST (10.0.0.4) via a systemd timer (every ~2 min). It is the +# independent backstop to Lyra's own in-app dream-cycle budget ("C", in lyra/dream.py): +# if the MI50 is busy too LONG or runs too HOT, it stops the llama.cpp backend and +# pings Brian — regardless of what caused it. Trips on duration only after a full hour +# of *continuous* busy, so a legitimate ~40-min manual workload runs untouched. +# +# The GPU lives on the host; the llama.cpp container ("lyra-brain") runs inside LXC +# CT202. So temp/use come from host rocm-smi, and the stop goes via `pct exec`. +# +# See docs/superpowers/specs/2026-07-04-mi50-runaway-guards-design.md +set -uo pipefail + +# --- tunables (override in the .service via Environment=) --- +CTID="${CTID:-202}" # LXC holding the docker container +CONTAINER="${CONTAINER:-lyra-brain}" +MAX_BUSY_SEC="${MAX_BUSY_SEC:-3600}" # 1 hr continuous busy -> stop +TEMP_KILL_C="${TEMP_KILL_C:-97}" # junction >= this ... +TEMP_KILL_STREAK="${TEMP_KILL_STREAK:-3}" # ... for this many consecutive checks (~6 min) +NTFY_URL="${NTFY_URL:-}" # e.g. https://ntfy.sh (empty => log only) +NTFY_TOPIC="${NTFY_TOPIC:-}" +BUSY_STATE="${BUSY_STATE:-/run/mi50-watchdog.busy_since}" +HOT_STATE="${HOT_STATE:-/run/mi50-watchdog.hot_streak}" + +now="$(date +%s)" + +alert() { # $1 title, $2 message + logger -t mi50-watchdog "$2" + if [[ -n "$NTFY_URL" && -n "$NTFY_TOPIC" ]]; then + curl -s -m 8 -H "Title: $1" -H "Priority: urgent" -H "Tags: warning" \ + -d "$2" "$NTFY_URL/$NTFY_TOPIC" >/dev/null 2>&1 || true + fi +} + +stop_backend() { # $1 reason + pct exec "$CTID" -- docker stop "$CONTAINER" >/dev/null 2>&1 || true + rm -f "$BUSY_STATE" "$HOT_STATE" + alert "MI50 watchdog stopped the card" "$1" +} + +# Nothing to guard if the backend isn't even running. +running="$(pct exec "$CTID" -- docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" +if [[ "$running" != "true" ]]; then + rm -f "$BUSY_STATE" "$HOT_STATE" + exit 0 +fi + +use="$(rocm-smi --showuse 2>/dev/null | awk -F: '/GPU use \(%\)/ {gsub(/[^0-9]/, "", $NF); print $NF; exit}')" +junction="$(rocm-smi --showtemp 2>/dev/null | awk -F: '/junction/ {gsub(/[^0-9.]/, "", $NF); print $NF; exit}')" + +# --- duration rule: accumulate continuous busy time in a state file --- +busy=0 +[[ "${use:-}" =~ ^[0-9]+$ ]] && (( use > 0 )) && busy=1 +if (( busy )); then + [[ -f "$BUSY_STATE" ]] || echo "$now" > "$BUSY_STATE" + since="$(cat "$BUSY_STATE" 2>/dev/null || echo "$now")" + elapsed=$(( now - since )) + if (( elapsed >= MAX_BUSY_SEC )); then + stop_backend "MI50 busy ${elapsed}s continuously (>= ${MAX_BUSY_SEC}s) — stopped ${CONTAINER}." + exit 0 + fi +else + rm -f "$BUSY_STATE" # idle breaks the streak +fi + +# --- temperature rule: independent of duration --- +if [[ "${junction:-}" =~ ^[0-9.]+$ ]]; then + jint="${junction%.*}" + if (( jint >= TEMP_KILL_C )); then + streak=$(( $(cat "$HOT_STATE" 2>/dev/null || echo 0) + 1 )) + echo "$streak" > "$HOT_STATE" + if (( streak >= TEMP_KILL_STREAK )); then + stop_backend "MI50 junction ${jint}C >= ${TEMP_KILL_C}C for ${streak} checks — stopped ${CONTAINER}." + exit 0 + fi + else + rm -f "$HOT_STATE" # cooled off, reset the streak + fi +fi +exit 0 diff --git a/deploy/mi50-watchdog/mi50-watchdog.timer b/deploy/mi50-watchdog/mi50-watchdog.timer new file mode 100644 index 0000000..2b91115 --- /dev/null +++ b/deploy/mi50-watchdog/mi50-watchdog.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run the MI50 runaway watchdog every 2 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=2min +AccuracySec=15s + +[Install] +WantedBy=timers.target diff --git a/docs/SCOUTING_DESK.md b/docs/SCOUTING_DESK.md new file mode 100644 index 0000000..4b746a2 --- /dev/null +++ b/docs/SCOUTING_DESK.md @@ -0,0 +1,167 @@ +# The Scouting Desk — proactive poker recall + villain identity resolution + +*Design spec. Not built yet. Companion to the "she remembers" north star in the +`poker-copilot` memory. Written 2026-07-03, before the trial-by-fire session.* + +## Purpose + +Turn the copilot from a logbook into a copilot that **remembers across sessions, +unprompted** — the way a broadcast stats desk slides a note to the color +commentator: *"he mentioned the guy's hot streak → here are his last 10 games."* + +Target moments: +- *"you had this exact leak last week too, remember?"* +- *"neck-tattoo guy just 3-bet you — last time he did that at the Meadows he had it."* +- *"Sleepy John was here two weeks ago; you stacked off AK into his set."* + +The failure mode to avoid at all costs: **confident-but-wrong.** A stats desk that +guesses gets the commentator burned on air. **Silence is the default; the desk +speaks only when there's real signal.** + +## What already exists (don't rebuild it) + +`mind.build_messages()` already runs a recall pass on **every** message: +`memory.recall(user_msg)` over past exchanges + `memory.recall_summaries(user_msg)` +over session gists, injected as system notes before she replies. The +"slide-a-note-in-before-she-speaks" machinery is already the architecture. This +spec **adds a poker desk** to that pass — it does not build a new RAG system. + +Episodic links also already exist: `link_hand_players` writes a +`player_observations` row per named villain in a recorded hand, carrying +`hand_id` AND `session_id`; `player_reads` carry `session_id`. So villain → +observation → hand → session/date is reconstructable today. + +## Two retrieval channels (don't conflate them) + +1. **Entity desk — deterministic.** A known **name** in the message → exact/fuzzy + SQL match on `poker_players` → pull dossier + your history vs him. ~1ms, no + hallucination. This is the "hears the name, pulls last 10 games" case. +2. **Pattern desk — semantic.** No entity to key on ("I keep punting these river + bluffs") → embed the message, retrieve similar **scar notes / hands / recap + passages** by meaning. This is where embeddings earn their keep. Also the + backbone of nameless-villain matching (below). + +Both feed one injected **STATS DESK** system note, relevance-gated. + +## The hard part: nameless villains + +Most live villains have no name. Brian identifies them by **physical descriptor** +("guy with the lips/neck tattoo"), by **seat** ("seat 4", "two to my left"), or — +uselessly — **generically** ("mid-aged white dude with glasses"). + +### Current gap +`poker_players.name` is `NOT NULL` and identity is an **exact name match** +(`upsert_player` → `WHERE name = ?`). The `description` column exists but is dead +weight: not a key, not embedded, never matched. **Nameless villains can't exist +today.** This is the core schema fix. + +### Identity model — descriptor as a fuzzy primary key +Store a villain as: +- `name` — now **optional**. +- `descriptors` — accumulated distinctive physical tags heard over time + ("neck tattoo", "lips ink", "heavyset", "bald+beard"). +- `descriptor_embedding` — embedding of the accumulated distinctive tags, for + semantic match against drifting phrasings. +- `venue` — a strong disambiguator (the neck-tattoo reg at the Meadows ≠ the one + at Wheeling, unless Brian travels). +- `distinctiveness` — a weight; distinctive features (tattoos, scars, a name) + score high, generic ones (age/race/glasses) near zero. + +### Resolver — matching an incoming reference +1. **Name present** → exact/fuzzy SQL match (entity desk). Done. +2. **Descriptor present** → embed it, compare to `descriptor_embedding` of known + villains **scoped to the current venue**, weighted by distinctiveness. +3. **Confidence bands:** + - **High** (distinctive + strong match) → surface the file; if live, a light + confirm ("the neck-tattoo LAG from 3 weeks ago?"). + - **Medium/ambiguous** (several candidates, or a middling score) → **do NOT + interrupt.** File a `needs_clarification` task to the review queue and stay + quiet, OR ask only if it's decision-relevant right now. + - **Generic-only** (no distinctive signal) → **refuse to guess.** Stay silent + or ask for one distinctive detail ("anything that stands out — ink, chips, + how he plays?"). Wrong-guy citation is worse than nothing. + - **No match** → new villain; open a descriptor-keyed dossier. + +### Seat = within-session alias only +The live session keeps a `seat → villain` map so reads accumulate whether Brian +says "seat 4" or "the tattoo guy." Seats evaporate when the session ends — they +mean nothing next week. + +## Confirmation loop (live, in chat) + +Auto-merging on a fuzzy match is dangerous, so she **proposes and Brian confirms** +in natural language: + +> Brian: "neck tattoo guy just 3-bet me again" +> Lyra: "The neck-tattoo LAG from the Meadows three weeks ago — the one who +> stacked you with the flush? Or new guy?" +> Brian: "yeah him" → reinforce identity · "nah different" → split, and learn +> what distinguishes them. + +Handles name-arrives-later for free: catch his name off Bravo → "merge neck-tattoo +guy into 'Danny'" → history follows. + +## The review interface (async, out-of-band) + +Silence at the table ≠ forget it → it routes to a queue Brian clears at his pace. + +### `/players` — villain file browser +List: name-or-lead-descriptor, venue, category (feeder/risky/reg), hands +observed, VPIP/PFR (when sample is real), last seen, distinctive tags. Detail +view: reads, showdowns, notable hands (link to `/hand/{id}`), sessions seen, +stats. Edit / rename / retag / delete / manual-merge. + +### Resolution queue — two lanes +- **Possible merges** — two profiles likely one person (high descriptor + similarity + same venue, below auto-merge). Side-by-side → **Same guy** (merge) + / **Different** (split). +- **Needs clarification** — a descriptor that matched several candidates, or a + nameless villain the resolver couldn't place → pick match / **New guy**. + +### Two rules that keep the queue from rotting +1. **A rejected merge stays rejected** — record the pair as *known-distinct* so it + never re-surfaces; start tracking the distinguishing tell. +2. **Merge-candidate scan runs in the dream cycle**, not the hot path — nightly, + compare descriptor embeddings within each venue, file new maybes. Zero live + latency. + +## Injection format & gating + +A single system note, clearly marked as structured fact so she cites it (not +confabulates), e.g.: + +``` +STATS DESK — Neck-tattoo guy (Meadows, LAG/reg): seen 3×, last 2wk ago. + vs you: hand #38 (AK, stacked off into his set). Reads: overfolds turn, + 3-bets light from the CO. Sample: 22 hands — VPIP 41 / PFR 28. +``` + +Gate hard: inject only on a confident entity hit or a strong semantic score. +Default to nothing. Never inject a generic-only guess. + +## Honest limits + +Never perfect. Some players are genuinely indistinguishable — fine. The system's +only job: **right when there's signal, quiet when there isn't.** + +## New data model (sketch) + +- `poker_players`: `name` → nullable; add `descriptors TEXT`, + `descriptor_embedding BLOB`, `distinctiveness REAL`. +- `player_distinct_pairs(a_id, b_id, note, created_at)` — rejected merges. +- `identity_queue(id, kind, player_ids, descriptor, context, session_id, + confidence, status, resolution, created_at)` — kind ∈ {merge_candidate, + needs_clarification}. +- Live-session `seat → player_id` alias map (in-session only). + +## Sequencing (after the trial-by-fire session — recall feeds on real data) + +1. **Nameless identity + resolver** — schema, descriptor embedding, venue-scoped + semantic match, distinctiveness gate. (Unblocks everything.) +2. **Scouting-desk injection** — wire entity + pattern recall into + `build_messages` as the gated STATS DESK note. +3. **Confirmation loop** — the live propose/confirm/merge/split UX in the persona. +4. **`/players` browser + resolution queue UI** — the async review interface. +5. **Dream-cycle merge scan** — nightly candidate generation. +6. **Pattern desk** — semantic recall over scars/notes/recaps for "this leak + again." diff --git a/docs/superpowers/plans/2026-06-28-poker-logging-service.md b/docs/superpowers/plans/2026-06-28-poker-logging-service.md new file mode 100644 index 0000000..cc55b71 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-poker-logging-service.md @@ -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 `