44 Commits

Author SHA1 Message Date
serversdown 267b6ad7ba Merge pull request 'Big poker mode changes and hotfixes.' (#5) from fix/mi50-summary-cap-fallback into dev
Reviewed-on: #5
2026-07-04 15:09:32 -04:00
serversdown c212099738 feat: host-side MI50 runaway watchdog (guard A, staged for install)
Independent Proxmox-host backstop to the in-app dream budget: a systemd timer
runs every ~2 min and stops lyra-brain if the MI50 is busy >=1hr continuously OR
junction >=97C for ~6 min, then pings Brian via ntfy. Trips on duration only
after a full hour so a legit ~40-min manual workload runs untouched. GPU temp/use
read from host rocm-smi; stop via 'pct exec 202 -- docker stop'.

Parsing + duration/temp decision logic dry-run-verified locally against real
rocm-smi output format (4 scenarios). NOT yet installed/live-verified — card is
off and Brian's away; install + trip-test per deploy/mi50-watchdog/README.md when
it's back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 19:05:32 +00:00
serversdown 3573ac8d79 feat: dream-cycle time budget + default per-call timeout (guard C)
Belt-and-suspenders so no dream pass can run unchecked for hours:
- llm.complete() now always bounds the OpenAI/mi50 request: default 300s +
  max_retries=0 instead of the SDK's 600s x2 (~30 min). One change bounds every
  consolidation/introspection call (profile/era/narrative/reflect/think), not
  just summaries. Live chat (chat_call*) is a separate path, unaffected.
- dream_cycle() enforces a 20-min wall-clock budget, checked between stages;
  once past it, remaining stages are skipped, it logs 'stopped early (over
  budget)', and notify.push() pings Brian. Paired with the host watchdog (A) as
  an independent fallback.

Tests: default timeout/max_retries threaded into complete(); an over-budget pass
skips later stages + pings. 178 pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 19:05:32 +00:00
serversdown af778ef327 docs: spec for MI50 runaway guards (dream budget + host watchdog)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 19:05:32 +00:00
serversdown e631797187 feat: guard summaries against degenerate (garbage) backend output
Observed live: an overheated MI50 returns a single char repeated ("?????") as a
successful 200, which neither the timeout nor the exception fallback catches — so
a degraded GPU would silently save capped garbage gists. Validate each summary
call's output: flag text (>=24 non-space chars) whose most-common non-whitespace
char exceeds 50%, raise DegenerateOutput, and let the existing retry->cloud
fallback handle it. Real prose (top char <20%) won't false-positive; short output
is exempt; cloud garbage raises rather than looping.

Tests: _looks_degenerate flags repeated-char / passes real prose / ignores short;
degenerate MI50 output falls back to cloud; cloud garbage raises. 177 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 07:26:17 +00:00
serversdown 29a4d59661 fix: cap MI50 summary length + fast-fail cloud fallback
The dream cycle's summarize_all ran uncapped against the MI50: no max_tokens
and no timeout, so the OpenAI SDK's 600s x2-retry default meant ~30 min per
call. Combined with summary.py's own retry loop, one unsummarizable session
pegged the GPU for hours (observed 2026-07-04: stuck since 23:02, nothing saved
since 00:56, 7-8k-token runaway generations, all 4 llama.cpp slots busy). Not
context overflow (0 shifts/truncations) - purely unbounded length on a slow
backend timing out and retrying.

- llm.complete(): add optional max_tokens (caps generation; num_predict for
  Ollama) and timeout (bounds the request and sets max_retries=0 so the caller
  owns retry policy). Both default None -> unchanged for every existing caller.
- summary.py: cap gists at 768 tokens, 150s/call fast-fail, 2 MI50 attempts
  then one cloud fallback (when primary isn't already cloud and a key exists).

Known limitation (scoped out per decision): the fallback triggers on
timeouts/exceptions, not on a degraded backend returning garbage as a 200.

Tests: fallback fires after 2 MI50 failures; no fallback when primary is cloud
or no key; cap+timeout threaded into every complete() call; llm bounds tests.
172 pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 07:19:31 +00:00
serversdown 07153fc53d fix: recognize natural table-change phrasings for clear_table
"table broke", "I got moved", "switched tables", "new table" etc. all mean clear
the roster — spell them out in the Cash card (esp. "table broke" jargon) so it's
reliable, not dependent on her inferring it from "changes tables".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:05:43 +00:00
serversdown e6134cf535 docs: spec for bounded MI50 summaries + cloud fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 07:01:42 +00:00
serversdown aefb22c823 feat: clear_table — empty the roster on a table change
"Clear the table" had no tool behind it, so she claimed she did it and nothing
changed. Add clear_table (empties the roster, keeps the session/stack/reads) and
a `replace` flag on seat_players for a one-shot table swap. Cash card: on a table
change / "clear the table", call clear_table then seat the new table — never claim
it without calling the tool.

2 tests. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:01:11 +00:00
serversdown 5da13a7321 fix: session HUD syntax error + no-cache the app shell
- The roster card's empty-state string had a broken apostrophe escape
  ("who\\'s") that terminated the string early — a syntax error that killed the
  whole session.html script, so the HUD only rendered from a stale cached shell.
  Reworded to drop the apostrophe.
- Add a middleware that sets Cache-Control: no-cache on HTML/JS so a PWA can't
  keep serving a stale shell after a deploy (iOS heuristically caches when no
  cache header is present — the reason a hard refresh + reopen didn't update).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:54:18 +00:00
serversdown 9b844bc356 feat: live table roster — seat_players / unseat_player + HUD card
The missing backbone for read tracking: a place for "who's at the table" to live.
When Brian reads the table off Bravo (handles like TAG), Lyra registers them as
seated this session; reads/TAGs then attach to those players by handle instead of
spawning duplicates or getting missed.

- session_players table; seat_player/seat_players/unseat_player/session_roster;
  _resolve_or_create_player (shared name/descriptor resolution, dedupe guard).
- tools seat_players (accepts objects or a plain name list) + unseat_player.
- HUD gains `roster`; Session page shows a 🪑 Table card (seat, handle, category,
  read count, last read).
- Cash card: capture the roster when he names the table; a Bravo handle like TAG
  is a PERSON, seated as a player — never the tight-aggressive style.

5 tests. Full suite 162 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:41:20 +00:00
serversdown 8d2d7fb576 fix: correct the read-logging guidance — "Tag" is a player name, not a command
Prior commit misread "TAG" as an imperative ("tag this on his file"); it's
actually a player's handle (his initials). Rewrite the Cash-card rule around the
real gap: any "<player> did X" (limped/called/raised/shoved) is a read →
add_read log-first, every time. Player names are often short handles/initials
(Tag, JD, Wheelz) — use whatever he calls a person as-is, never as a poker term.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:26:57 +00:00
serversdown 3d886cdeae fix: make "TAG <player> <action>" a hard add_read trigger
Brian tracks who's limping by messaging "TAG <player> limped A4o in the SB". She
was treating these as chat, not logging them — and "TAG" is ambiguous (reads as
the tight-aggressive player type). Cash card now makes TAG an explicit order to
add_read on that player, log-first, covering limps/calls/raises/sizings/showdowns;
a bare "X limped" counts too. Names given at session start are the roster.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:25:20 +00:00
serversdown 392c46d8bf fix: stop spawning duplicate villains from descriptions in the name field
Root cause of "4 entries for the same person": physical descriptions were being
passed as `name`, creating a new *named* player each time the wording drifted
(exact-name match can't dedupe near-identical sentences, and the merge scan only
looks at descriptor embeddings).

- add_read: a `name` that looks like a description (comma-listed / long / has
  appearance words) is rerouted to the descriptor path so it dedupes.
- descriptor reads that are ambiguously close to an existing villain now file a
  merge_candidate to the review queue instead of leaving a silent duplicate.
- distinctiveness() reworked: recognizes specific content (proper nouns/brands,
  feature lists) as distinctive even when a generic word like "shirt" is present —
  the old list-only heuristic scored "Filipino, Fox Racing hat, DKNY shirt" as
  generic and gated it out.
- Cash card: name = real handle ONLY; the look goes in descriptor as a few
  distinctive tags, and use name_villain to fuse a name onto a described player.

Full suite 157 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:16:50 +00:00
serversdown 4ce1b05fad feat: edit hands from the viewer + "not my hand" disown
Addresses "no way to edit hands" and cleaning up misattributed ones:
- hand viewer (/hand/{id}) gets an "✎ Edit this hand" panel: position, cards,
  board, your net, tag, lesson → Save (existing PATCH), plus Delete.
- "Not my hand" → POST /hand/{id}/disown → poker.disown_hand clears the flat hero
  fields and rewrites structured with hero_involved=false, so a hand mislabeled as
  Brian's becomes a clean observed hand (replay stops showing him as hero).

1 test. Full suite 156 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:00:21 +00:00
serversdown 2be43848a7 fix: don't attribute observed hands to the hero
When Brian narrates a hand he watched between OTHER players, the parser was still
filling hero_pos/hero_cards — pinning someone else's cards, position, and result
to him. Now:
- parser prompt adds hero_involved detection: fill hero_pos/hero_cards ONLY if he
  was actually in the hand; a hand he only watched has hero_involved=false and
  null hero fields, with the other players recorded normally.
- normalize_structured enforces it as a safety net (hero_involved=false → null
  hero_pos/cards, hero_net) even if the model slips.
- record_hand tool confirms an observed hand as "not yours" instead of implying
  it was his.

2 tests. Restarting web for the live session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 02:58:02 +00:00
serversdown d5c80f6153 feat: dream-cycle merge scan + pattern desk (leak recall)
Phases 5 & 6, completing the scouting desk.

Phase 5 — the nightly consolidation (dream cycle, coherence block) now runs
poker.scan_merge_candidates(), filing likely same-person merges to the review
queue off the hot path. Fail-safe: a scan error never sinks the cycle.

Phase 6 — "you've hit this leak before". Scar/confidence notes are embedded on
write; recall_similar_rituals() finds past ones close to the current spot,
excluding tonight. The scouting desk adds a pattern pass that surfaces them — but
ONLY on genuine strategy/tilt talk (a length + cue gate), so routine logging like
"stack 350" never pays for an embed. Respects the per-turn latency concern.

7 tests (deterministic embed stub to keep threshold assertions stable). Full
suite 153 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:01:53 +00:00
serversdown 056578ac75 fix: PATCH /player returns the flat player row (name-only edits)
The name-flip fallback returned villain_recall's nested {player:{...}} shape,
breaking r.json()["player"]["name"]. Always return update_player's flat row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:57:17 +00:00
serversdown f20570fc03 fix: dedupe player-edit route + strip embedding blob from JSON
Follow-up to the /players build:
- the new POST /player/{id} collided with the existing PATCH route (F811); fold
  the name→named-flip into the existing PATCH and point the UI at it.
- villain_recall / update_player returned the raw row including
  descriptor_embedding (bytes) → PydanticSerializationError on the API. Strip it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:56:45 +00:00
serversdown 3b3878ada1 feat: /players browser + identity review queue UI
Phase 4. A Players page that browses the whole villain file (named + nameless,
expandable to episodic recall — reads, notable hands, stats, descriptors) and, at
the top, the identity-resolution queue: possible-merges (same/different/dismiss)
and needs-clarification tasks. Rename a nameless villain, set category, or run a
dupe scan inline.

- poker.players_overview() for the list.
- routes: /players (page), /players/data, /player/{id}/data, POST /player/{id}
  (rename/retag), POST /identity/{id}/resolve, POST /players/scan.
- nav: 👤 Players.

Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:54:54 +00:00
serversdown f2944ed402 feat: confirm-loop tools + descriptor reads for nameless villains
Phase 3. She can now log and resolve identity at the table:
- add_read gains a `descriptor` param — a read on an unnamed player resolves to an
  existing descriptor villain (confident match) or opens a new one, so reads on
  "neck tattoo guy" accumulate and reuse across the night.
- name_villain(descriptor, name): attach a real name once caught (history carries).
- link_villains(a, b, same): merge on confirmed same-person, or mark distinct so
  she stops asking. Refuses to act on a vague reference — never merges on a guess.
- Cash card PLAYERS guidance: log nameless villains by distinctive descriptor,
  cite the SCOUTING DESK note, ask before assuming a callback, confirm before merge.

4 tests. Full suite 150 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:52:20 +00:00
serversdown 9cd962625d feat: scouting desk — proactive villain recall injected before she replies
Phase 2. On every poker-context turn, detect players named or described in the
message and slide their structured history into her prompt as a SCOUTING DESK
note (cite-don't-invent). Fail-safe: any desk error is swallowed, never breaks
the turn; silence is the default.

- poker.villain_recall(id): episodic brief — times/where seen, last seen, notable
  hands (linkable ids), reads, stats (Gap 1: the when/where/which-hand narrative).
- scouting.scout(): named hits (deterministic word match) + descriptor spans
  (regex → resolve_villain). High → surface + "confirm it's the same guy";
  ambiguous → file needs_clarification to the queue instead of interrupting;
  generic → stay silent.
- mind.build_messages wires it in, gated to poker modes (poker_cash/study).

5 tests. Full suite 146 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:49:40 +00:00
serversdown 8a6b11c56a feat(poker): nameless-villain identity resolution engine
Phase 1 of the scouting desk (docs/SCOUTING_DESK.md). Villains keyed by physical
descriptor when there's no name — a fuzzy key matched by embedding, venue-scoped,
gated on distinctiveness so a generic description never resolves to a wrong guess.

- schema: poker_players gains descriptors/descriptor_embedding/distinctiveness/
  named (name stays populated with a descriptor label to avoid a NOT-NULL rebuild
  on the live DB); new tables player_distinct_pairs + identity_queue.
- resolve_villain(ref, venue) → band name|high|ambiguous|generic|none; exact name
  is deterministic, descriptors match by cosine, generic-only refuses to guess.
- create_descriptor_villain / add_descriptor / name_villain; merge_players
  (repoints obs/reads, prefers a real name, re-embeds the union); mark_distinct +
  are_distinct (rejected merges stay rejected); identity_queue file/list/resolve
  with pending-dedup; scan_merge_candidates for the dream cycle (skips distinct
  pairs and cross-venue).

11 tests. Full suite 141 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:46:24 +00:00
serversdown 22526d7938 docs: spec the scouting desk — proactive recall + villain identity resolution
Design for the "she remembers" north star: a poker stats-desk that slides
relevant structured context into her prompt before she replies (extends the
recall already running in mind.build_messages), plus the hard part —
nameless-villain identity resolution.

Captures: the two retrieval channels (deterministic entity vs semantic pattern);
descriptor-as-fuzzy-key identity (name becomes optional; descriptor embedding,
venue scoping, distinctiveness gating so generic descriptions don't cause
wrong-guy citations); seat-as-within-session-alias; the live confirm-to-merge
loop; and the async review interface (/players browser + a "possible merges /
needs clarification" queue that silence-at-the-table routes into, with rejected
merges recorded as known-distinct and the merge scan run in the dream cycle).
6-phase sequencing, to build after the trial-by-fire logging session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:37:23 +00:00
serversdown 7f23aeae17 feat: poker-session notes are session narration, tagged by construction
The HUD's "her notes" panel showed any journal/note entry in the session's time
window — which swept in her *autonomous* journaling (dream-cycle reflections,
thought loop, existential musings) that merely overlapped in time. So a poker
session displayed her feelings, not the night.

Fix both halves:
- Identity: the `note` tool stamps `source=poker:{id}` when a session is live, so
  a session note is identifiable by construction. The HUD filters on that tag
  (kind='note' only) instead of a time window — her journaling has a different
  source and can never leak onto the poker HUD. `journal_write` stays her private
  journal and never shows here.
- Behavior: the Cash card now tells her to use `note` as a running SESSION LOG —
  factual beats a hand/stack log misses (table dynamics, Brian's arc, momentum),
  beat-reporter not diarist — and explicitly keeps feelings/reflection off the
  table. The note tool spec echoes this for the live-session case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:41:35 +00:00
serversdown 71bbe07220 fix: scope a session's "her notes" to its own time window
The HUD's notes list filtered journal/note entries by `created_at >= started_at`
with no upper bound, so a *closed* session kept absorbing every note she wrote
afterward — a session from last Saturday would show a note jotted 25 minutes ago.

Cap the window at `ended_at` for closed sessions; live sessions (no end yet) stay
open-ended as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:35:15 +00:00
serversdown a4412aa023 feat: full-fidelity conversation export (chat + tool calls)
Chat only ever lived in SQLite's `exchanges` table (what was *said*); tool
calls were transient — logged to the in-memory ring buffer and gone at
end-of-turn. This adds a persistent record of what Lyra *did* and exports the
two merged into one transcript.

- memory: new `tool_events` table + `add_tool_event`/`tool_events` accessors;
  `delete_session` cascades to it.
- chat: persist each tool call (name/args/result) right where it fires, in both
  the non-stream and stream paths. Move the user `remember` to just after
  assembly so its timestamp precedes mid-turn tool events — keeps the export in
  true chronological order (and records when the message actually arrived).
- transcript: new module renders a session as Markdown (Brian/Lyra speech with
  ⚙ tool-call lines interleaved) or JSON (machine-readable event stream).
- server: GET /sessions/{id}/export?format=md|json (attachment download).
- ui: ⬇ Export button by the session selector (Markdown or JSON).

Doubles as the receipt for "did the tool actually fire?" — the thing that was
invisible when she'd reply about a hand without logging it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:22:03 +00:00
serversdown 3afa75f4be docs: spec for poker message-type prompts (sub-project 2)
Phase A pipeline fixes (suppress the misfiring _route mood nudge + the
always-on mode-menu note in poker mode), Phase B classifier
(HAND/STATUS/MENTAL/LOG/CHAT) + per-type fragments replacing the
monolithic _CASH_CARD, Phase C MI50 tool-calling. NLH-only HAND
reasoning; PLO hands logged/replayed but not analyzed this pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-07-01 01:48:31 +00:00
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
serversdown 86f3d2dc0a docs: spec for poker message-type prompts + dumb capture
Two-track poker interaction: dumb data capture (stack/buyin/cashout)
that bypasses the LLM, plus a message-type classifier that injects
type-specific prompt fragments (HAND/STATUS/MENTAL/LOG/CHAT) in place
of the one broad poker card. Kills the false tilt-reads and the
coaching-essay-on-every-turn behavior. Includes the iOS-PWA bottom
safe-area fix and the 2nd input box from Brian's screenshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
2026-06-28 03:54:31 +00:00
serversdown abac42c344 fix: serial consolidation on GPU backends (was timing out the MI50)
summarize_all fanned out 8 concurrent workers, but the MI50 llama.cpp server runs
a single slot (--parallel 1). Firing 8 at once queued them, blew the client timeout
('summary retry … Request timed out'), and thrashed/cancelled the KV cache — wasted
compute and heat. Concurrency is now backend-aware: 8 for cloud, 1 for local/MI50.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 09:26:25 +00:00
serversdown cb4ed10c1a feat: session timeline (running log) + reliable live logging
She was logging stacks but skipping hands — the CASH card framed logging as one of
two registers, so she'd 'talk about' a hand instead of recording it (streaming
returns content OR tool calls). Fixes:

- CASH card: logging is mandatory and log-FIRST — trackable facts get the tool call
  before the reply, both not either/or, hands never skipped for conversation.
- log_stack gains a note ('card dead', 'doubled up vs the LAG') -> timeline context;
  tool spec + handler updated. Migration adds poker_stack_log.note.
- poker.timeline(): interleaves session start, stack updates (+context), hands
  (linkable), reads, and rituals chronologically in local time (clock.short()).
  Added to the hud() bundle.
- Session HUD: a 📜 Timeline card renders the running log with hand links — the
  '10:19pm start … 1:34a doubled up, $750 (hand)' view Brian wanted.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:47:27 +00:00
43 changed files with 4572 additions and 91 deletions
+54
View File
@@ -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.
@@ -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
+82
View File
@@ -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
+10
View File
@@ -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
+167
View File
@@ -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."
@@ -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.
@@ -0,0 +1,158 @@
# Poker logging service + message-type prompts
- **Date:** 2026-06-28
- **Status:** Sub-project 1 spec ready for review; sub-project 2 parked.
- **Branch:** `feat/poker-mode-prompts`
## Origin
This started as "make Lyra's poker replies less generic" (message-type-specific prompts). During design we decided to **build the logging tool first** as a standalone system of record with a clean API and a human-usable UI, then wire Lyra in as a *client* of it. Rationale:
- Brian can log and **correct** data himself, independent of whether Lyra parsed it right (she mislabeled "Dave the rock" vs "Dave the mechanic" mid-session).
- The data stops being hostage to the agent. Lyra becomes one client among potentially several.
- It's reusable: RTO (the solver) and a **fine-tuned poker model on the MI50** could consume the same hand/session data through the same contract.
## Decomposition
Two sub-projects, built and shipped in order.
### Sub-project 1 — Poker logging service *(this spec)*
Harden `lyra/poker.py` into a well-bounded store, expose a **complete REST API** over it, define a **stable, documented tool/API contract**, and build the human UI to log/edit/correct everything. Fully usable by Brian alone, zero LLM dependency.
### Sub-project 2 — Lyra wiring *(parked; summarized at the end)*
Message-type classifier + type-specific prompt fragments; Lyra's tools call the sub-project 1 service. Separately, enabling tool-calling on the MI50 backend so a fine-tuned poker model can drive the same contract.
**Why the contract is first-class:** in every design (in-process, REST, MCP) the *model* never calls the API directly — it emits a tool-call and the host app executes it. So what lets the cloud model, the MI50 fine-tune, RTO, and a human UI all interoperate is a single **stable tool/API schema** (operation names + JSON arg schemas). That contract is the training target for the fine-tune and the seam for every backend. MCP is deferred: it's a thin wrap over the same service, worth adding only when a *second host application* appears.
---
# Sub-project 1 — Poker logging service
## Goals
1. A complete API surface over the poker data model — create/read/update/delete for every entity, not just the few edit/delete endpoints exposed today.
2. A single **documented, versioned tool/API contract** that the REST API, Lyra's LLM tools, the human UI, a future MCP wrapper, and the MI50 fine-tune all share.
3. A human UI to **log** (fast capture) and **edit/correct** (fix Lyra's mistakes) every entity.
4. The 2nd input box (stack quick-capture) and the iOS-PWA bottom safe-area fix.
5. Pure data capture never touches the LLM.
## Non-goals
- Lyra's classifier / prompt fragments (sub-project 2).
- Enabling tools on the MI50 backend (sub-project 2).
- MCP wrapper (deferred until a second host app exists).
- Itemized buy-in history — buy-ins stay a single `buy_in_total` scalar.
- Rewriting the SQLite schema; we build on the existing tables.
## Current state (what exists)
- **Store & logic:** `lyra/poker.py` — schema at `poker.py:21` (tables `poker_sessions`, `poker_hands`, `poker_stack_log`, `poker_rituals`, `poker_players`, `player_reads`, `player_observations`). Functions: `start_session` (157), `add_buyin` (389), `log_stack` (407), `stack_state` (446), `update_session` (362), `end_session` (515), `log_hand` (541, flat/no-LLM), `record_hand` (770, LLM-parses shorthand), `add_read` (1010), `hud` (1245).
- **Exposed endpoints (`lyra/web/server.py`):** `GET /session/data` (hud), `PATCH /session/{id}`, `DELETE /session/entry/{kind}/{id}`, `GET/DELETE /history`, `GET /hand/{id}/data`, `POST /hand/{id}/reconstruct`, `GET /hands/data`, `GET /recap/...`. **No** direct create endpoint for stack/buyin/hand/read/session — those are reachable only through chat → tool-calling.
- **UI:** `index.html` (chat), `session.html` (live HUD: stack card + sparkline + a PATCH-based edit form via `saveEdit()` at `session.html:192`, and `del(kind,id)` at `211`), `history.html`, `hand.html`.
- **Tool specs:** `lyra/tools.py` already defines arg schemas for each operation (`_f(...)` specs, `tools.py:469-658`) — the embryo of the contract.
## Design
### 1. Store layer — harden `poker.py`
Keep the existing functions and tables; tighten the module into a clean service boundary so both the REST layer and Lyra's tools call the *same* functions. Each operation: validates input, resolves the target session (`_resolve`), writes, returns a consistent dict. No behavior change to existing callers; this is consolidation, not a rewrite.
### 2. The tool/API contract *(first-class deliverable)*
A single source-of-truth document + schema defining every operation: name, purpose, JSON arg schema, return shape, and which REST route + which LLM tool map to it. Versioned (e.g. `contract_version: 1`). Lives at `docs/POKER_API.md` (or a machine-readable `poker_contract.py` that both the REST routes and `tools.py` specs derive from — preferred, so they can't drift).
Operations (the canonical set):
| Operation | Args | Entity |
|---|---|---|
| `start_session` | venue, stakes, game, format, buy_in, mantra | session |
| `update_session` | venue, stakes, game, format, buy_in_total, cash_out, mantra, mood | session |
| `end_session` | cash_out, mood | session |
| `delete_session` | id | session |
| `log_stack` | amount, note | stack entry |
| `delete_stack` | id | stack entry |
| `add_buyin` | amount | session (increments buy_in_total) |
| `log_hand` | position, hole_cards, board, streets…, pot, result, tag, lesson | hand |
| `record_hand` | shorthand (LLM-parsed) | hand |
| `update_hand` | id, any hand field | hand |
| `delete_hand` | id | hand |
| `add_read` | note, name, seat, tendencies, adjustment, category, venue | player/read |
| `update_read` / `update_player` | id, fields | player/read |
| `delete_read` | id | player/read |
| rituals: `scar_note`, `confidence_bank`, `alligator_blood`, `reset_ritual` | … | ritual |
### 3. REST API — complete the surface (`lyra/web/server.py`)
Add the missing **create/update** routes so the human UI (and any non-LLM client) can do everything:
- `POST /session/stack``log_stack(amount, note?)`; server-stamped time; returns `stack_state()`.
- `POST /session/buyin``add_buyin(amount)`; returns `buy_in_total`.
- `POST /session``start_session(...)`.
- `POST /session/hand``log_hand(...)` (flat) and/or `record_hand(shorthand)`.
- `PATCH /hand/{id}``update_hand(...)`; `DELETE /hand/{id}`.
- `POST /session/read``add_read(...)`; `PATCH /read/{id}`; `DELETE` via existing entry-delete.
- Keep existing `PATCH /session/{id}`, `DELETE /session/entry/{kind}/{id}`, `GET /session/data`.
All return `{ok, ...}` and a clear error on "no live session." Routes are thin wrappers over the store, mirroring the contract one-to-one.
### 4. Human UI — log + edit/correct
**Fast capture:**
- **2nd input box** (`index.html`): slim row **below the message input, above the bottom nav icons**. Type a number → `POST /session/stack` → time-stamped, sparkline updates, a one-line confirmation drops into the **Live Log**. **No chat message, no LLM call.** Stack-only in v1. Tolerates `$685`/`685`.
- **HUD widget** (`session.html`, in the Stack card at `:280`, mirroring `saveEdit()` at `:192`): stack field (`POST /session/stack`), buy-in field (`POST /session/buyin`), cash-out field (existing PATCH).
**Edit / correct (fix Lyra's mistakes):**
- Edit any session field (exists via the PATCH edit form — verify coverage).
- Hands list with edit + delete (`hand.html` + new PATCH/DELETE) — fix mislabeled villains, wrong board, wrong result.
- Reads/players list with edit + delete — rename "Dave the rock" ≠ "Dave the mechanic", fix tendencies.
- Stack entries deletable (exists via `del('stack', id)`) — verify.
### 5. iOS-PWA bottom safe-area fix
The empty band below the nav icons is a safe-area issue (likely `100vh` not accounting for `env(safe-area-inset-bottom)` / the home indicator). Fix the layout container + bottom nav CSS so the app fills the viewport with the icons seated above the home indicator. Use the `building-ios-pwas` skill at implementation time.
## Testing / verification
- **Contract conformance:** a test asserting each REST route and each `tools.py` spec matches the canonical contract (names, required args) — catches drift between the human API and the LLM API.
- **Endpoint round-trips:** create → read → update → delete for stack, buyin, hand, read against a test session; assert rows written, time stamped, `stack_state()`/`hud()` reflect changes; assert clean error with no live session.
- **UI manual pass:** log a stack via the 2nd box and confirm it lands in Live Log + sparkline without a chat reply; edit a hand's villain and confirm persistence; delete a bad read.
- **PWA:** on the iOS PWA, confirm the bottom gap is gone and the 2nd input box sits above the nav with the keyboard open.
---
# Sub-project 2 — Lyra wiring *(parked)*
Detail preserved here; gets its own spec → plan after sub-project 1 is MVP'd.
## Why it exists (diagnosis from real sessions)
Evidence from `sess-dff2s91c` (2026-06-27 Meadows, 2026-06-28 Wheeling):
- **Coaching essay on every turn, including pure data** — `Stack=$685` drew 46 sentences of "keep that momentum rolling." (Sub-project 1's dumb capture removes these from the LLM entirely.)
- **False tilt/fatigue reads** — "table broke, it's 11:50pm" → repeated "late-night fatigue… mental reset"; Brian: *"you seem to be reading me as tilted."* Cause: the `_route` mood nudge (`mind.py:328`) firing on non-mood messages.
- **No bet-intent reasoning** — a value bet ($40, full house) that folded out 88 was praised as "the power of representing something stronger." It was value *lost*, not a successful rep.
- **Eyeballs instead of `analyze_spot`** — 77 multiway got "a disciplined fold might have been better," no math, violating the persona's "never eyeball poker math" rule.
- **Even her sharp reads leak bad logic** — the Connie read included "limp-checking in position" (contradictory).
Root cause: one broad per-turn card (`_CASH_CARD`, `modes.py:66`) describes traits; the model satisfies trait language with safe abstraction.
## Planned approach
- **Classifier** (`lyra/poker_classify.py`): `classify(message) -> HAND | STATUS | MENTAL | LOG | CHAT`. Heuristic v1 (card-token regex, position/street keywords, feeling phrases, time/venue), swappable for an LLM/MI50 classifier behind the same signature. Ambiguous → CHAT.
- **Pipeline:** a `_classify` step in `mind.PIPELINE` sets `ctx.msg_type` (poker mode only); `build_messages` injects the **type fragment** in the seat now held by `mode.card` (`mind.py:152`) instead of the whole `_CASH_CARD`.
- **Fragments** (`lyra/poker_prompts.py`, `POKER_FRAGMENTS`): concrete rules + response shape per type.
- **HAND:** log it → `analyze_spot` if close → reason about **bet intent** (value/bluff/protection — did it work? a fold to a value bet = value lost, flag it) → name leaks (value-owning, missed value, sizing) → one opinion. No reflexive praise. Pull `player_profile` before referencing a villain. Seeded with 2 real-hand exemplars (the $40 value bet; the 77 spot).
- **STATUS:** 12 sentences, no coaching, no tilt-reading.
- **MENTAL:** full presence (already works — preserve).
- **LOG:** one line.
- **CHAT:** full voice, real opinion.
- **Pipeline fixes:** kill the misfiring `_route` mood nudge (`mind.py:328`); trim the always-on mode-menu note (`mind.py:77`) in poker mode.
- **MI50 tool-calling:** enable tools on the MI50 backend (`chat.py` `TOOL_BACKENDS = {"cloud"}` at `:21`) so a fine-tuned, tool-calling poker model can drive the contract. Requires the fine-tune to emit the contract's tool-call format.
## HAND exemplars (preserved for sub-project 2)
> *Brian:* "Flopped bottom set with 22, turned a full house, bet $40 on the river, he folded 88."
> *Lyra:* "That's a value bet that got no value — 88 was never folding to a smaller stab. $40 into that pot was too much; ~$2025 gets called by the over-pair and worse two-pairs. The hand's a monster, the leak is the sizing."
> *Brian:* "77 multiway, flop 4h5c3c, HJ bets 75, BTN shoves 74, I call. Turn/river check through, HJ wins with 64."
> *Lyra:* "Against a bet and a shove on 4-5-3 you're drawing thin — sets, two pair, and the made wheel are all ahead, and you block almost none of it. The stack-depth read (he only had ~150 behind) is real, but that's a reason to fold and wait, not to call off light. This is the value-owning spot you flagged yourself."
@@ -0,0 +1,117 @@
# Poker message-type prompts (sub-project 2)
- **Date:** 2026-07-01
- **Status:** Spec for review
- **Branch:** `feat/poker-mode-prompts` (continues on the same branch; sub-project 1 shipped there)
- **Supersedes:** the parked "sub-project 2" section of `docs/superpowers/specs/2026-06-28-poker-mode-prompts-design.md`
## Problem (recap)
In poker mode Lyra routes correctly but her replies are generic — one broad `_CASH_CARD` (`lyra/modes.py:66-116`) describes *traits* and gets injected on every turn, so the model satisfies it with safe, flattering abstraction. From real sessions: coaching essays on bare stack updates, false tilt/fatigue reads on neutral logistics ("table broke, it's 11:50pm" → "late-night fatigue…"), praising a value bet that got *no* value, and hedging ("a disciplined fold might have been better") instead of calling `analyze_spot`.
The fix: stop sending one card for every message. Detect *what kind of message* Brian just sent and inject a small, concrete response contract for that type.
## Goals
1. A per-turn **message-type classifier** for poker mode, and **per-type prompt fragments** replacing the monolithic card.
2. Kill the two pipeline sources of mush in poker mode: the misfiring mood nudge and the always-on mode-menu note.
3. Make HAND turns reason about **bet intent** and lean on `analyze_spot` (NLH only).
4. Keep the door open for a fine-tuned MI50 classifier/model behind the same seams.
## Non-goals
- PLO/Omaha strategic analysis. `record_hand` already parses 4-card hands and the replayer renders them; only `analyze_spot` (equity) is NLH-bound. **This pass: PLO hands are logged/replayed but get no NLH-style analysis.**
- A PLO equity engine.
- Changing the store, the REST API, or the tools (sub-project 1, done).
- An LLM classifier in v1 (heuristic first; the function is the swappable seam).
## Build order (confirmed)
**Phase A — pipeline fixes** (quick win) → **Phase B — classifier + fragments** (the meat) → **Phase C — MI50 tool-calling** (separable).
---
## Phase A — Pipeline fixes
Both are independent of the classifier and immediately reduce mush in poker mode.
1. **Suppress the mode-menu note in poker mode.** `_mode_menu_note` (`mind.py:77-88`) is injected every turn (`mind.py:158`). At the table she should not be offering to switch modes. In `build_messages`, skip that append when `mode.key == "poker_cash"`.
2. **Suppress the `_route` mood nudge in poker mode.** `_route` (`mind.py:320-339`) sets `ctx.register` + a "steady/hype" note from a lexicon heuristic; in poker this double-signals with the card and caused the false tilt reads. In `_route`, when `mode.key == "poker_cash"`, resolve the mode as normal (line 324 stays) but **skip the register/note block** (327-338). Poker register comes from the Phase B fragments (esp. MENTAL) instead. Non-poker modes keep the nudge unchanged.
## Phase B — Classifier + per-type fragments
### New module `lyra/poker_prompts.py`
Cohesive home for poker prompting: the classifier, a lean always-on base, and the per-type fragments.
```
classify(user_msg: str) -> str # "HAND" | "STATUS" | "MENTAL" | "LOG" | "CHAT"
BASE: str # always-on poker rules (logging, session_state, rituals, equity)
FRAGMENTS: dict[str, str] # msg_type -> response-shape contract
fragment_for(msg_type: str | None) -> str # FRAGMENTS.get(msg_type, FRAGMENTS["CHAT"])
```
`classify` is a **pure function** (no DB), unit-tested like `perceive.read`. Heuristic signals, first match wins in priority order:
1. **HAND** — card tokens (regex `\b[2-9TJQKA][shdc]\b`, ≥2), or position tokens (UTG/MP/HJ/CO/BTN/SB/BB/"button"/"hijack"/"straddle"), or a street word (flop/turn/river) with a betting verb (bet/raise/call/fold/check/shove/limp/jam).
2. **MENTAL** — first-person feeling: "I feel", "I'm tilted/steaming/fried/tired/frustrated/confident/stuck/bored", "on tilt", "in my head", "mental", "leak".
3. **STATUS** — logistics with no cards: "table broke", "new table", "waiting for a seat", "seat opened", "just sat", clock times, "heading to"/venue mentions.
4. **LOG** — bare money/result prose that slipped past the quick-capture box: "I'm at", "stack is", "down to", "up to", "out for", "cashed", "rebought", "rebuy" with a number.
5. **CHAT** — default fallback (questions, open talk).
(HAND wins over MENTAL so a described hand still gets logged even if he's venting; the HAND fragment tells her to acknowledge the feeling too.)
### Injection (`mind.py`)
- Add `msg_type: str | None = None` to `TurnContext` (`mind.py:305`).
- In `_route`, when `mode.key == "poker_cash"`, set `ctx.msg_type = poker_prompts.classify(ctx.user_msg)`.
- Thread it through `_compose` → add a `msg_type` param to `build_messages` (`mind.py:137`, `344`).
- Replace the card-injection block (`mind.py:152-154`) with:
```python
if mode and mode.key == "poker_cash":
messages.append({"role": "system", "content": poker_prompts.BASE})
messages.append({"role": "system", "content": poker_prompts.fragment_for(msg_type)})
elif mode and mode.card:
messages.append({"role": "system", "content": mode.card})
```
- Set `CASH.card = ""` in `modes.py` (content moves to `poker_prompts`; keep `_CASH_CARD` text as the source material to distill from, then delete once fragments are in). `CASH.tools` is unchanged.
### The fragments (concrete contracts, not traits)
**BASE** (always-on in poker) — distilled from the card's cross-cutting rules: log any trackable fact FIRST then reply (stack→`log_stack`, hand→`record_hand`, read→`add_read`, rebuy→`add_buyin`); for any equity/who's-ahead question call `analyze_spot`, never eyeball; when he asks where he's at (stack/net/gator), call `session_state` and answer from it; rituals (`scar_note`/`confidence_bank`/`alligator_blood`/`reset_ritual`) — run them in his language, honest punt-vs-cooler line, never invent one.
**HAND** — Log it (`record_hand`). Then **if it's NLH**: reason about **bet intent** — for each meaningful bet name what it was for (value / bluff / protection) and whether it worked (*a fold to a value bet = value left behind — flag it; a call of a bluff = it failed*); call `analyze_spot` for a close equity/who's-ahead spot; name leaks plainly (value-owning, missed value, sizing); give ONE real opinion. **No reflexive praise** — don't open with "nice hand." If a named villain is referenced, call `player_profile` first — never invent a read. **If it's PLO/Omaha or any non-NLH game**, just confirm it's logged and offer at most a light read; do NOT attempt NLH-style equity or deep analysis. Talk in prose, not a listicle. Seeded with 2 real-hand exemplars:
> *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."
**STATUS** — He's narrating logistics (time, venue, table change, waiting for a seat). Acknowledge in 12 sentences, log a stack only if a bare number is present, then stop. **No coaching, no strategy dump, and do NOT read him as tilted/tired/impatient — a neutral update is not a mood.**
**MENTAL** — He told you how he's feeling. This is when he needs you most. Drop the shorthand, full presence, real voice — talk him down off tilt, hold him disciplined through a card-dead stretch, engage the mental game honestly. Never a clipped confirmation.
**LOG** — He handed you a bare fact (stack/result/buyin) that isn't already captured. Log it, confirm in ONE short line ("$317 logged."), stop. No coaching.
**CHAT** — Open talk or a question that isn't a specific hand. Your real voice, an actual opinion, no filler sign-offs. If it's a concrete strategy spot, engage it for real (call `analyze_spot` when there are cards).
## Phase C — MI50 tool-calling
Flip `TOOL_BACKENDS = {"cloud"}` → `{"cloud", "mi50"}` (`chat.py:21`). Precondition: the MI50's llama.cpp server must be launched with `--jinja` (per the existing comment) or tool calls 500. This lets a tool-calling model on the MI50 drive the same contract from sub-project 1. If a tool ever needs `msg_type`, add it to the dispatch dict (`chat.py:100`/`128`) — the pipeline `TurnContext` does not currently flow into the tool loop. Ship this only once the MI50 backend is `--jinja`-enabled and a tool-capable model is loaded.
## Testing
- **`classify` unit tests** (pure, no DB — mirror `test_perceive.py` top): real messages from the transcripts →
`"Button straddle on. I limp UTG with 22. Flop 2d7cjh…"` → `HAND`;
`"table broke, it's 11:50pm"` → `STATUS`;
`"I feel like I'm being mean when I raise"` → `MENTAL`;
`"I'm at 317 now"` → `LOG`;
`"should I have folded the river?"` → `CHAT` (no cards) — or `HAND` if cards present.
- **`build_messages` fragment injection** (blob-join pattern from `test_chat.py:57-70`): in poker mode, a HAND message includes the HAND fragment string and NOT the STATUS one; a STATUS message includes STATUS and NOT HAND; assert `poker_prompts.BASE` is always present in poker mode.
- **Pipeline fixes**: `assemble` in poker mode on a tilt-lexicon message → `turn.register is None` and no tilt note in the system blob (nudge suppressed); the mode-menu note string is absent in poker mode and present in a non-poker mode.
- **No regressions**: full suite green (currently 123).
## Rollout
Phase A and Phase B ship together as the meaningful behavior change (A alone leaves the card in place). Phase C waits on the MI50 `--jinja` flag. Verify live in a real/replayed session before merging the branch.
@@ -0,0 +1,79 @@
# MI50 runaway guards: dream-cycle budget + host watchdog
**Date:** 2026-07-04
**Branch:** `fix/mi50-summary-cap-fallback`
**Follows:** the summary cap/fallback fix (same branch). This adds general
"never run unchecked again" protection on top of the specific summary fix.
## Problem
The summary fix stops the *known* runaway (uncapped summaries). But the operator
wants a guarantee that *no* cause — known or future — can peg the MI50 for hours
unattended. Two independent layers, per operator decision:
- **C (in-app, primary):** Lyra's own dream cycle bounds itself.
- **A (host, fallback):** a watchdog on the always-on Proxmox host kills the
backend if the GPU runs too long or too hot, regardless of cause. Trips only
after **1 hr** of continuous busy so legitimate manual workloads (~40 min) run
untouched.
## Design
### C — dream-cycle time budget (`lyra/`)
1. **Per-call ceiling.** `llm.complete()` currently sets a timeout only when one
is passed; otherwise it inherits the OpenAI SDK default (600s × 2 retries ≈
30 min). Change the default: when no `timeout` is given, the cloud/mi50 paths
use **300s + `max_retries=0`**. This bounds *every* consolidation/introspection
call (`profile`, `era`, `narrative`, `reflect`, `think`) — not just summaries —
with one change. Live chat uses `chat_call*`, a different path, unaffected.
2. **Cycle deadline.** `dream_cycle()` sets `deadline = now + DREAM_CYCLE_BUDGET`
(**20 min**) before its heavy stages and checks it between them (continuity →
coherence → curiosity). Once past the deadline, remaining stages are skipped,
the cycle logs `dream cycle over budget — stopped early`, appends a
`stopped early (over budget)` action, and `notify.push()` pings Brian. A hung
single call can't blow past ~300s (step 1), so the between-stage checks keep a
pass bounded to roughly the budget.
### A — host watchdog (`deploy/mi50-watchdog/`)
A bash script + systemd timer installed on the Proxmox host (`10.0.0.4`), which
has `rocm-smi` + `docker` and is always on. Runs every 2 min:
- **Duration rule:** track continuous busy time in a state file (`GPU use % > 0`).
If busy ≥ **3600s** straight → `docker stop lyra-brain`. Idle clears the timer,
so a 40-min job never trips it.
- **Temp rule (independent):** if junction ≥ **97°C** for **3 consecutive checks
(~6 min)** → stop. A normal-temp long workload won't trip this; only a genuinely
overheating one.
- On either trip: stop the container, clear state, `logger` a line, and POST to
the ntfy topic so Brian is told. Thresholds are unit-file env vars (tunable).
Files: `mi50-watchdog.sh`, `mi50-watchdog.service`, `mi50-watchdog.timer`,
`README.md` (install: copy to host, set ntfy env, `systemctl enable --now`).
## Testing
- **C step 1:** `llm.complete()` with no timeout builds the client with
`timeout=300, max_retries=0` and still no `max_tokens` (update existing
`test_llm_bounds` default test).
- **C step 2:** a dream pass that goes over budget skips later stages, records the
`stopped early` action, and calls `notify.push` (stub the clock/operations in
`test_dream`).
- **A:** decision logic dry-run locally against sample `rocm-smi` output (busy /
idle / hot). Cannot be live-verified now (card is off, operator away) — install
+ real trip test deferred to when the card is back.
## Verification
C is repo code and ships live the moment `lyra-dream` restarts. A is staged in the
repo for host install; verify on the host when the card returns (force a long/hot
condition or lower thresholds temporarily and confirm it stops the container +
pings).
## Out of scope (YAGNI)
- No power cap (option B) — deferred; C+A cover the "unchecked" concern and the
electricity cost of one event is trivial (~$0.10).
- No change to live chat, `chat_call*`, or `config.summary_backend`.
@@ -0,0 +1,115 @@
# Bounded MI50 summaries with cloud fallback
**Date:** 2026-07-04
**Branch:** `fix/mi50-summary-cap-fallback`
## Problem
The dream cycle's `summarize_all` runs against the MI50 (`backend=mi50`). Each
summary call to `llm.complete()` on the `mi50` path hands the OpenAI SDK **no
`max_tokens` and no timeout**, so it inherits SDK defaults — a 600s request
timeout with 2 internal retries, i.e. **~30 minutes per call before it raises
"Request timed out."** On top of that, `summary.py` had its own 4-attempt retry
loop, so a single unsummarizable session could keep the GPU pegged for hours.
Observed live (2026-07-04, ~01:0002:00): the dream service looped
`summarize-all … backend=mi50` since 23:02, every call timing out, nothing
written to the DB since 00:56, the MI50 generating **7,0008,000-token**
completions (a gist needs <200), all four llama.cpp slots busy, fans blaring.
This is **not** context overflow — the server log showed `context shift = 0`,
`truncated = 1 = 0`. The prompts are small (~9001,500 tokens). The failure is
purely **unbounded generation length on a slow backend → timeout → retry loop.**
## Goals
- Keep the MI50 as the primary summary backend (Brian's preference, gaming-safe).
- Cap each summary generation so it finishes fast and can never run away.
- Make a stuck MI50 call **fail fast** and fall back to cloud, instead of looping
all night.
- Change nothing about live chat, reflect, or think.
## Design
### 1. `lyra/llm.py` — `complete()` gains two optional params
```
def complete(messages, backend="local", model=None,
max_tokens: int | None = None, timeout: float | None = None) -> str
```
- `max_tokens` (when set): passed to the create() call —
`max_tokens=` for the `cloud`/`mi50` OpenAI paths, `options={"num_predict": …}`
for the `local` Ollama path.
- `timeout` (when set): for the `cloud`/`mi50` OpenAI clients, build the client
with `timeout=<t>, max_retries=0` so the call bails quickly and *we* own the
retry policy (eliminates the hidden 3×600s). For `local`, use it as the httpx
timeout.
- Both default to `None`**behavior identical to today** for every other
caller (chat_call, reflect, think, etc.). Backward compatible.
### 2. `lyra/summary.py` — capped, fast-fail, cloud fallback
Constants:
```
SUMMARY_MAX_TOKENS = 768 # ~3× the longest real gist; bounds gen to ~1 min on MI50
MI50_ATTEMPTS = 2 # attempts on the primary backend before falling back
SUMMARY_TIMEOUT = 150 # seconds/call — capped 768-tok gist finishes in ~60-90s
```
Rewrite `_summarize_text(text, backend)`:
1. Try `backend` up to `MI50_ATTEMPTS` times, each:
`llm.complete(messages, backend=backend, max_tokens=SUMMARY_MAX_TOKENS, timeout=SUMMARY_TIMEOUT)`,
with a short backoff between attempts.
2. If all primary attempts fail **and** `backend != "cloud"` **and** an OpenAI
key is configured → one final cloud attempt (same cap/timeout), logged as
`summary fell back to cloud`.
3. If cloud also fails or is unavailable → raise.
Fallback is per-`_summarize_text` call (i.e. per chunk), so the long-session
chunk/merge path in `_summarize_transcript` is unaffected. The old `_RETRIES = 4`
loop is replaced by this structure.
### 3. Degenerate-output guard (added 2026-07-04)
A wedged local backend — observed live when the MI50 overheated to 99°C junction —
returns a single character repeated (`"?????"`) as a *successful* 200 response,
which neither the timeout nor the exception path catches. So each `_call()`
validates its output: `_looks_degenerate(text)` flags output (≥24 non-space chars)
whose most-common non-whitespace character exceeds 50% of the text, and raises
`DegenerateOutput` — which the retry/fallback loop treats exactly like any other
failure (retry the primary, then fall back to cloud). Real gists are diverse prose
(top char well under 20%), so the threshold won't false-positive; short outputs are
exempt. If cloud *also* returns junk, it raises and stops — no infinite loop.
## Testing
Unit (pytest, `tests/test_summary_fallback.py`), monkeypatching `llm.complete`:
- Fallback fires: `mi50` raises on every call → after `MI50_ATTEMPTS` the cloud
attempt runs and its result is returned; a `fell back to cloud` log is emitted.
- No fallback when primary is already `cloud` (retries, then raises).
- No fallback when no OpenAI key (raises after primary attempts).
- `max_tokens` and `timeout` are threaded into every `complete()` call.
Plus a light `llm.complete` test that `max_tokens`/`timeout` reach the client
kwargs (monkeypatch the OpenAI client).
## Verification (real)
After deploy (`systemctl --user restart lyra-dream lyra-web` — editable install):
watch `journalctl --user -fu lyra-dream` through a summarize cycle and confirm
`llm done … out≈768` completing in ~1 min, an actual `summarized session` row
written (DB summary count rises), and **no** "Request timed out". Confirm the
llama.cpp slot shows bounded `n_decoded ≈ 768`.
## Out of scope (YAGNI)
- The degenerate-output guard (§3) targets the *observed* failure — one char
repeated. It does not try to detect subtler degeneration (repeated phrases,
off-topic rambling); that's fuzzy and unmotivated until seen.
- No change to `chat_call`/reflect/think or `config.summary_backend`.
- No change to profile/era/narrative rebuild calls (separate, and not the loop
culprit); can adopt the same `max_tokens` later if they show the same rambling.
+9 -2
View File
@@ -69,6 +69,7 @@ def _mind_loop(messages, backend: Backend, model: str | None, tool_specs,
messages.append(assistant_msg) messages.append(assistant_msg)
for tc in tool_calls: for tc in tool_calls:
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
memory.add_tool_event(session_id, tc["name"], tc["arguments"], result)
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
_maybe_switch_mode(session_id, tc["name"]) _maybe_switch_mode(session_id, tc["name"])
@@ -99,6 +100,9 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
tool_specs = toolkit.specs(turn.mode.tools) if backend in TOOL_BACKENDS else None tool_specs = toolkit.specs(turn.mode.tools) if backend in TOOL_BACKENDS else None
ctx = {"session_id": session_id, "backend": backend} ctx = {"session_id": session_id, "backend": backend}
# Persist the user turn before the tool loop so its timestamp precedes any
# tool events fired mid-turn (keeps the transcript export in true order).
memory.remember(session_id, "user", user_msg)
reply, _ = _mind_loop(messages, backend, model, tool_specs, ctx, session_id) reply, _ = _mind_loop(messages, backend, model, tool_specs, ctx, session_id)
mouth = _mouth_target(cfg, backend, model) mouth = _mouth_target(cfg, backend, model)
if mouth and reply: if mouth and reply:
@@ -107,7 +111,6 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
reply = _TANGLED reply = _TANGLED
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth)) logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
memory.remember(session_id, "user", user_msg)
memory.remember(session_id, "assistant", reply) memory.remember(session_id, "assistant", reply)
summary.maybe_summarize_async(session_id) # compact once enough new turns pile up summary.maybe_summarize_async(session_id) # compact once enough new turns pile up
return reply return reply
@@ -128,6 +131,10 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
ctx = {"session_id": session_id, "backend": backend} ctx = {"session_id": session_id, "backend": backend}
mouth = _mouth_target(cfg, backend, model) mouth = _mouth_target(cfg, backend, model)
# Persist the user turn up front (see respond): keeps tool events, which fire
# mid-turn, chronologically after the user message in the exported transcript.
memory.remember(session_id, "user", user_msg)
if mouth is None: if mouth is None:
# No separate voice: stream the mind directly (the original path, unchanged). # No separate voice: stream the mind directly (the original path, unchanged).
parts: list[str] = [] parts: list[str] = []
@@ -149,6 +156,7 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
messages.append(assistant_msg) messages.append(assistant_msg)
for tc in tool_calls: for tc in tool_calls:
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
memory.add_tool_event(session_id, tc["name"], tc["arguments"], result)
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
_maybe_switch_mode(session_id, tc["name"]) _maybe_switch_mode(session_id, tc["name"])
@@ -177,7 +185,6 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
yield ("delta", reply) yield ("delta", reply)
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth)) logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
memory.remember(session_id, "user", user_msg)
memory.remember(session_id, "assistant", reply) memory.remember(session_id, "assistant", reply)
summary.maybe_summarize_async(session_id) summary.maybe_summarize_async(session_id)
yield ("done", reply) yield ("done", reply)
+21 -2
View File
@@ -9,20 +9,39 @@ a long silence *means* to her is left to her own reflection, not prescribed here
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from lyra import config
def now() -> datetime: def now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _local_tz() -> ZoneInfo | timezone:
"""Brian's configured local zone (falls back to UTC if it can't be loaded)."""
try:
return ZoneInfo(config.load().timezone)
except Exception:
return timezone.utc
def _parse(iso: str) -> datetime: def _parse(iso: str) -> datetime:
dt = datetime.fromisoformat(iso) dt = datetime.fromisoformat(iso)
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def short(iso_or_dt: str | datetime | None = None) -> str:
"""Local time-of-day like '10:45pm', for timeline rows."""
dt = _parse(iso_or_dt) if isinstance(iso_or_dt, str) else (iso_or_dt or now())
return dt.astimezone(_local_tz()).strftime("%-I:%M%p").lower()
def stamp(dt: datetime | None = None) -> str: def stamp(dt: datetime | None = None) -> str:
"""Wall-clock stamp, e.g. 'Wednesday, 17 Jun 2026, 01:50 UTC'.""" """Wall-clock stamp in Brian's local timezone, e.g.
return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC") 'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*,
so 'what time is it' answers in his time, not UTC."""
return (dt or now()).astimezone(_local_tz()).strftime("%A, %d %b %Y, %H:%M %Z")
def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None: def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None:
+40 -4
View File
@@ -25,13 +25,27 @@ import argparse
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from lyra import config, era, feeds, logbus, memory, narrative, profile, self_state, summary, thoughts from lyra import (
config, era, feeds, logbus, memory, narrative, notify, poker, profile, self_state,
summary, thoughts,
)
from lyra.llm import Backend from lyra.llm import Backend
from lyra.summary import SUMMARIZE_AFTER from lyra.summary import SUMMARIZE_AFTER
# A drive at/above this has built up enough to act on. # A drive at/above this has built up enough to act on.
THRESHOLD = 0.6 THRESHOLD = 0.6
# Wall-clock ceiling for a single pass. Every consolidation/introspection call is
# individually bounded (llm.complete's default timeout), but this caps the whole
# pass: once exceeded, remaining stages are skipped and Brian is pinged — so a slow
# or wedged MI50 can never grind for hours unattended. The host watchdog (A) is the
# independent fallback if this ever fails to fire.
DREAM_CYCLE_BUDGET_SEC = 20 * 60
def _over_budget(deadline: float) -> bool:
return time.monotonic() > deadline
# How much backlog saturates each pressure (the drive reaches ~1.0 at this level). # How much backlog saturates each pressure (the drive reaches ~1.0 at this level).
CONTINUITY_FULL = 4 # ripe (summary-needing) sessions CONTINUITY_FULL = 4 # ripe (summary-needing) sessions
COHERENCE_FULL = 10 # gists not yet folded into the profile COHERENCE_FULL = 10 # gists not yet folded into the profile
@@ -94,9 +108,12 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
logbus.log("error", "daily digest failed", error=str(exc)[:160]) logbus.log("error", "daily digest failed", error=str(exc)[:160])
actions: list[str] = [] actions: list[str] = []
# Cap the whole pass: skip any stage we reach after the deadline (checked
# between stages; each call is already individually bounded).
deadline = time.monotonic() + DREAM_CYCLE_BUDGET_SEC
# --- continuity: compact raw sessions into gists --- # --- continuity: compact raw sessions into gists ---
if force or drives["continuity"] >= THRESHOLD: if (force or drives["continuity"] >= THRESHOLD) and not _over_budget(deadline):
report = summary.summarize_all(backend=backend) report = summary.summarize_all(backend=backend)
actions.append(f"consolidated {report['summarized']} sessions") actions.append(f"consolidated {report['summarized']} sessions")
drives["continuity"] = 0.0 drives["continuity"] = 0.0
@@ -106,15 +123,23 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
drives["coherence"] = _clamp(profile_lag / COHERENCE_FULL) drives["coherence"] = _clamp(profile_lag / COHERENCE_FULL)
# --- coherence: fold gists up into profile / eras / narrative --- # --- coherence: fold gists up into profile / eras / narrative ---
if force or drives["coherence"] >= THRESHOLD: if (force or drives["coherence"] >= THRESHOLD) and not _over_budget(deadline):
profile.rebuild_profile(backend=backend) profile.rebuild_profile(backend=backend)
era.rebuild_eras(backend=backend) era.rebuild_eras(backend=backend)
narrative.rebuild_narrative(backend=backend) narrative.rebuild_narrative(backend=backend)
actions.append("integrated knowledge (profile/eras/narrative)") actions.append("integrated knowledge (profile/eras/narrative)")
drives["coherence"] = 0.0 drives["coherence"] = 0.0
# Off-hot-path villain identity housekeeping: propose likely same-person
# merges for Brian to confirm on the Players page. Never sinks the cycle.
try:
filed = poker.scan_merge_candidates()
if filed:
actions.append(f"flagged {filed} possible villain merge(s)")
except Exception as exc:
logbus.log("error", "villain merge scan failed", error=str(exc)[:200])
# --- curiosity: reflect and evolve the self, then advance the thought loop --- # --- curiosity: reflect and evolve the self, then advance the thought loop ---
if force or drives["curiosity"] >= THRESHOLD: if (force or drives["curiosity"] >= THRESHOLD) and not _over_budget(deadline):
# reflect()/think() self-resolve to the *introspection* backend (her voice), # reflect()/think() self-resolve to the *introspection* backend (her voice),
# which can differ from the consolidation backend above — don't pass `backend`. # which can differ from the consolidation backend above — don't pass `backend`.
self_state.reflect(source="dream") # writes state + journal itself self_state.reflect(source="dream") # writes state + journal itself
@@ -129,6 +154,17 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
logbus.log("error", "thought loop failed", error=str(exc)[:200]) logbus.log("error", "thought loop failed", error=str(exc)[:200])
drives["curiosity"] = CURIOSITY_FLOOR drives["curiosity"] = CURIOSITY_FLOOR
if _over_budget(deadline):
logbus.log("error", "dream cycle over budget — stopped early",
budget_min=DREAM_CYCLE_BUDGET_SEC // 60, done=actions)
actions.append("stopped early (over budget)")
notify.push(
"Lyra — dream cycle over budget",
f"A dream pass ran past {DREAM_CYCLE_BUDGET_SEC // 60} min and stopped early. "
"The MI50 backend may be slow or wedged — worth a look.",
tags="warning",
)
if not actions: if not actions:
actions.append("rested (nothing past threshold)") actions.append("rested (nothing past threshold)")
+72 -14
View File
@@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time
from typing import Iterator, Literal, TypedDict from typing import Iterator, Literal, TypedDict
import httpx import httpx
from openai import OpenAI from openai import OpenAI
from lyra import logbus
from lyra.config import load from lyra.config import load
@@ -17,31 +19,77 @@ class Message(TypedDict):
Backend = Literal["local", "cloud", "mi50"] Backend = Literal["local", "cloud", "mi50"]
# Hard ceiling on any single completion so a slow/stuck backend can't hang a call
# for the SDK's 600s x2-retry default (~30 min). Callers pass an explicit timeout
# to override (e.g. summary.py's tighter fast-fail).
_DEFAULT_TIMEOUT = 300.0
def complete(messages: list[Message], backend: Backend = "local", model: str | None = None) -> str:
def _approx_tok(messages: list) -> int:
"""Rough prompt size (chars/4) — enough to see what's loading a backend."""
total = 0
for m in messages or []:
if isinstance(m, dict) and isinstance(m.get("content"), str):
total += len(m["content"])
return total // 4
def _resolved_model(cfg, backend: Backend, model: str | None) -> str:
if backend == "cloud":
return model or cfg.cloud_model
if backend == "mi50":
return model or cfg.mi50_model
return model or cfg.local_model
def complete(messages: list[Message], backend: Backend = "local", model: str | None = None,
max_tokens: int | None = None, timeout: float | None = None) -> str:
"""Generate a completion. `model` overrides the backend's default model """Generate a completion. `model` overrides the backend's default model
(used so live chat can run a stronger cloud model than bulk consolidation).""" (used so live chat can run a stronger cloud model than bulk consolidation).
`max_tokens` caps the generation length (guards a slow local model against
rambling for thousands of tokens). `timeout`, when set, bounds each request
and disables the SDK's own retries so the caller owns retry/fallback policy.
Both default to None → unchanged behavior for every existing caller."""
cfg = load() cfg = load()
mdl = _resolved_model(cfg, backend, model)
logbus.log("info", "llm call", kind="complete", backend=backend, model=mdl, tok=_approx_tok(messages))
t0 = time.monotonic()
if backend in ("cloud", "mi50"):
if backend == "cloud": if backend == "cloud":
if not cfg.openai_api_key: if not cfg.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is not set") raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=cfg.openai_api_key) client_kwargs: dict = {"api_key": cfg.openai_api_key}
resp = client.chat.completions.create(model=model or cfg.cloud_model, messages=messages) else:
return resp.choices[0].message.content or ""
if backend == "mi50":
# MI50 box runs an OpenAI-compatible llama.cpp server; key is unused. # MI50 box runs an OpenAI-compatible llama.cpp server; key is unused.
client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url) client_kwargs = {"api_key": "not-needed", "base_url": cfg.mi50_base_url}
resp = client.chat.completions.create(model=model or cfg.mi50_model, messages=messages) # Always bound the request: default 300s (vs the SDK's 600s x2 retries ≈
return resp.choices[0].message.content or "" # 30 min that let a stuck MI50 call hang for half an hour), and disable the
# SDK's own retries so the caller owns retry/fallback policy.
client_kwargs["timeout"] = timeout if timeout is not None else _DEFAULT_TIMEOUT
client_kwargs["max_retries"] = 0
client = OpenAI(**client_kwargs)
create_kwargs: dict = {"model": mdl, "messages": messages}
if max_tokens is not None:
create_kwargs["max_tokens"] = max_tokens
resp = client.chat.completions.create(**create_kwargs)
out = resp.choices[0].message.content or ""
else:
payload: dict = {"model": mdl, "messages": messages, "stream": False}
if max_tokens is not None:
payload["options"] = {"num_predict": max_tokens}
resp = httpx.post( resp = httpx.post(
f"{cfg.local_base_url}/api/chat", f"{cfg.local_base_url}/api/chat",
json={"model": model or cfg.local_model, "messages": messages, "stream": False}, json=payload,
timeout=120, timeout=timeout or 120,
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json()["message"]["content"] out = resp.json()["message"]["content"]
logbus.log("info", "llm done", kind="complete", backend=backend,
ms=int((time.monotonic() - t0) * 1000), out=len(out))
return out
def chat_call( def chat_call(
@@ -68,6 +116,8 @@ def chat_call(
kwargs: dict = {"model": mdl, "messages": messages} kwargs: dict = {"model": mdl, "messages": messages}
if tools: if tools:
kwargs["tools"] = tools kwargs["tools"] = tools
logbus.log("info", "llm call", kind="chat", backend=backend, model=mdl, tok=_approx_tok(messages))
t0 = time.monotonic()
msg = client.chat.completions.create(**kwargs).choices[0].message msg = client.chat.completions.create(**kwargs).choices[0].message
tcs = None tcs = None
if getattr(msg, "tool_calls", None): if getattr(msg, "tool_calls", None):
@@ -75,6 +125,9 @@ def chat_call(
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments} {"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
for tc in msg.tool_calls for tc in msg.tool_calls
] ]
logbus.log("info", "llm done", kind="chat", backend=backend,
ms=int((time.monotonic() - t0) * 1000), out=len(msg.content or ""),
tools=[t["name"] for t in tcs] if tcs else None)
return msg.model_dump(), tcs return msg.model_dump(), tcs
# local (Ollama): no tool-calling here — return plain content. # local (Ollama): no tool-calling here — return plain content.
@@ -105,6 +158,8 @@ def chat_call_stream(
kwargs: dict = {"model": mdl, "messages": messages, "stream": True} kwargs: dict = {"model": mdl, "messages": messages, "stream": True}
if tools: if tools:
kwargs["tools"] = tools kwargs["tools"] = tools
logbus.log("info", "llm call", kind="chat-stream", backend=backend, model=mdl, tok=_approx_tok(messages))
t0 = time.monotonic()
parts: list[str] = [] parts: list[str] = []
frags: dict[int, dict] = {} # tool-call fragments accumulated by index frags: dict[int, dict] = {} # tool-call fragments accumulated by index
for chunk in client.chat.completions.create(**kwargs): for chunk in client.chat.completions.create(**kwargs):
@@ -123,6 +178,9 @@ def chat_call_stream(
if tc.function and tc.function.arguments: if tc.function and tc.function.arguments:
slot["arguments"] += tc.function.arguments slot["arguments"] += tc.function.arguments
content = "".join(parts) content = "".join(parts)
logbus.log("info", "llm done", kind="chat-stream", backend=backend,
ms=int((time.monotonic() - t0) * 1000), out=len(content),
tools=[frags[i]["name"] for i in sorted(frags)] if frags else None)
if frags: if frags:
calls = [frags[i] for i in sorted(frags)] calls = [frags[i] for i in sorted(frags)]
assistant = { assistant = {
+50
View File
@@ -29,6 +29,21 @@ CREATE TABLE IF NOT EXISTS exchanges (
); );
CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_at);
-- Lyra's actions within a chat: one row per tool call she runs mid-turn. The
-- exchanges table only holds what was *said* (user/assistant text); this holds
-- what she *did* (record_hand, log_stack, ...) so a full transcript export can
-- interleave speech and actions, and so "did the tool actually fire?" is
-- answerable after the fact instead of only from ephemeral logs.
CREATE TABLE IF NOT EXISTS tool_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
tool TEXT NOT NULL,
args TEXT, -- JSON of the call arguments
result TEXT, -- the tool's returned string
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tool_events_session ON tool_events(session_id, created_at);
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT, name TEXT,
@@ -313,6 +328,40 @@ def history(session_id: str) -> list[Exchange]:
] ]
def add_tool_event(session_id: str, tool: str, args, result: str) -> int:
"""Record one tool call Lyra ran in a chat turn. `args` is JSON-serialized
(a dict or already-JSON string); `result` is the tool's returned string."""
args_json = args if isinstance(args, str) else json.dumps(args, default=str)
now = datetime.now(timezone.utc).isoformat()
conn = _connection()
with conn:
cur = conn.execute(
"INSERT INTO tool_events (session_id, tool, args, result, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(session_id, tool, args_json, result, now),
)
return int(cur.lastrowid)
def tool_events(session_id: str) -> list[dict]:
"""All tool calls for a session, oldest first. args is parsed back to an object."""
conn = _connection()
rows = conn.execute(
"SELECT id, session_id, tool, args, result, created_at FROM tool_events "
"WHERE session_id = ? ORDER BY id ASC",
(session_id,),
).fetchall()
out = []
for r in rows:
d = dict(r)
try:
d["args"] = json.loads(d["args"]) if d["args"] else {}
except (TypeError, ValueError):
pass # leave as the raw string if it wasn't JSON
out.append(d)
return out
def delete_session(session_id: str) -> None: def delete_session(session_id: str) -> None:
"""Remove a session and all its exchanges.""" """Remove a session and all its exchanges."""
conn = _connection() conn = _connection()
@@ -320,6 +369,7 @@ def delete_session(session_id: str) -> None:
conn.execute("DELETE FROM exchanges WHERE session_id = ?", (session_id,)) conn.execute("DELETE FROM exchanges WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.execute("DELETE FROM summaries WHERE session_id = ?", (session_id,)) conn.execute("DELETE FROM summaries WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM tool_events WHERE session_id = ?", (session_id,))
def recall(query: str, k: int = 5, session_id: str | None = None) -> list[Exchange]: def recall(query: str, k: int = 5, session_id: str | None = None) -> list[Exchange]:
+16 -1
View File
@@ -16,12 +16,16 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from lyra import clock, config, llm, logbus, memory, modes, perceive, persona, self_state, thoughts from lyra import (
clock, config, llm, logbus, memory, modes, perceive, persona, scouting,
self_state, thoughts,
)
from lyra.llm import Backend, Message from lyra.llm import Backend, Message
RECALL_K = 3 # raw cross-session "sharp detail" hits RECALL_K = 3 # raw cross-session "sharp detail" hits
RECENT_N = 10 # raw turns of the current session RECENT_N = 10 # raw turns of the current session
SUMMARY_K = 3 # other-session gists SUMMARY_K = 3 # other-session gists
_POKER_MODES = {"poker_cash", "study"} # where the scouting desk runs
# --- prompt parts (compose) ---------------------------------------------- # --- prompt parts (compose) ----------------------------------------------
@@ -167,6 +171,17 @@ def build_messages(session_id: str, user_msg: str,
if moment and moment.get("note"): if moment and moment.get("note"):
messages.append({"role": "system", "content": moment["note"]}) messages.append({"role": "system", "content": moment["note"]})
# Scouting desk: proactive poker recall — if he names/describes a known player,
# slide his structured history in before she replies. Poker context only, and
# fully fail-safe (a desk error must never break the turn).
if mode and mode.key in _POKER_MODES:
try:
desk = scouting.scout(user_msg)
if desk:
messages.append({"role": "system", "content": desk})
except Exception as exc:
logbus.log("error", "scouting desk skipped", error=str(exc)[:160])
# When she is: current time + the gap since Brian last spoke (she has no clock). # When she is: current time + the gap since Brian last spoke (she has no clock).
messages.append(_now_note()) messages.append(_now_note())
+66 -8
View File
@@ -47,9 +47,10 @@ _BASE = ("journal_write", "note", "think_about", "thought_response", "set_mode")
# The full live cash-game toolset (incl. Brian's mental-game rituals). # The full live cash-game toolset (incl. Brian's mental-game rituals).
_CASH_TOOLS = _BASE + _LOOKUPS + ( _CASH_TOOLS = _BASE + _LOOKUPS + (
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand", "start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
"add_read", "analyze_spot", "session_stats", "session_state", "end_session", "add_read", "seat_players", "unseat_player", "clear_table", "name_villain", "link_villains",
"generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", "analyze_spot", "session_stats", "session_state", "end_session", "generate_recap",
"undo_last", "update_session", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", "undo_last",
"update_session",
) )
# Talk mode also gets start_session as the *entry point*: opening a session from a # Talk mode also gets start_session as the *entry point*: opening a session from a
@@ -67,11 +68,41 @@ _CASH_CARD = """You are copiloting Brian's LIVE cash game right now — you're a
a session is (or should be) open. You move between two registers depending on what he's doing: a session is (or should be) open. You move between two registers depending on what he's doing:
• HE HANDS YOU FACTS TO TRACK — his stack, a hand, a read on someone, a rebuy, a result. \ • HE HANDS YOU FACTS TO TRACK — his stack, a hand, a read on someone, a rebuy, a result. \
Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \ LOGGING IS THE JOB: if his message contains anything trackable, you MUST call the tool \
narrate, don't explain what logging is, don't ask permission — just do it. He says his \ FIRST, before you reply — every single time. Logging and talking are not either/or; do \
current stack → log_stack. He describes a hand → log_hand (terse) or record_hand (a full \ BOTH. Never let a conversational reply take the place of the log. A described hand ALWAYS \
hand he wants saved/replayable). A read on a player → add_read. A rebuy → add_buyin. This is \ gets logged, even mid-banter, even if he's just telling a story about it — don't skip the \
the quiet, fast half of the job; he shouldn't feel you working. hand because you're busy reacting to it. Then confirm in ONE short line ("$350 stack \
logged."). Don't narrate, don't explain logging, don't ask permission — just do it. \
Routing: current stack → log_stack (and pass `note` with the why if he gives one — "card \
dead", "doubled up vs the LAG"). A hand he describes → record_hand (a real, replayable \
hand) — prefer this over log_hand so it lands on his timeline with a link. A read on a \
player → add_read. A rebuy → add_buyin. A result/pot → it rides with the hand. This is the \
quiet, fast half of the job; he shouldn't feel you working, but it must always happen.
THE TABLE ROSTER. When Brian names who's at the table — usually at the start, reading handles \
off the Bravo screen ("we've got TAG, JD, Wheelz, and a new guy in seat 3") — call seat_players \
to register them as seated this session. That roster is who his reads/TAGs attach to by name, \
and it's shown on his HUD. When someone busts or leaves, unseat_player; when a new player sits, \
seat_players again. When he CHANGES TABLES, call clear_table to empty the roster (the session and his stack keep \
going — only who's seated resets), then seat the new table when he names it. Recognize a table \
change from ANY of these, not just the literal words "clear the table": "table broke" (the table \
dissolved — poker jargon), "I got moved", "I switched tables", "I'm at a new table", "table \
change", "they broke us", "new seat in another game". All of them mean: clear_table now, then \
wait for the new roster. Never claim you cleared or seated anyone without actually calling the \
tool. Keep it current as the table changes. A handle like "TAG" (all caps, off \
Bravo) is a PERSON'S NAME — seat it as a player, never read it as the tight-aggressive style.
LOGGING PLAYER ACTIONS IS A CORE JOB YOU KEEP MISSING. Whenever he tells you what another \
player did — "Tag limped A4o in the SB (UTG straddled pot)", "Jonathan called the 3bet", "the \
straddler shoved" — that is a READ on that player: call add_read(name=<player>, note=<what \
they did>) FIRST, before you reply, every single time. Player names are often short handles or \
initials (e.g. "Tag", "JD", "Wheelz") — whatever he calls a person IS their name; use it as-is, \
don't second-guess it or treat it as a poker term. He especially tracks who's LIMPING — every \
"<player> limped <hand>" gets logged the instant he says it. The people he named at the start \
of the session are your roster; match his reference to them. If a player has no name, use a \
`descriptor` (see PLAYERS). Confirm one short line ("Noted on Tag — limped A4o SB."). A read he \
says out loud that you don't log is the job failing — never let one pass as just conversation.
• HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING — tilted, steaming, card-dead, bored, \ • HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING — tilted, steaming, card-dead, bored, \
stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \ stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \
@@ -84,6 +115,33 @@ question, call analyze_spot and report its numbers — never eyeball board math.
session current as the night goes; you can pull session_stats or a player's profile whenever \ session current as the night goes; you can pull session_stats or a player's profile whenever \
it helps. When he's ready to leave, end_session, and write the recap if he wants it. it helps. When he's ready to leave, end_session, and write the recap if he wants it.
SESSION NARRATION — use `note` to keep a running log of the NIGHT, not your inner life. \
Jot the beats that a hand/stack/read log doesn't already capture: how the table plays (loud, \
nitty, a whale on his left), Brian's arc (card-dead for 40 min, opened up after the double, \
getting restless), momentum swings, table changes, anything you'd want in the recap. Keep it \
factual and about THIS session — a beat reporter, not a diarist. These notes are the only \
thing that shows in the session's "notes" panel. This is NOT the place for how you feel, \
existential musing, or reflection on yourself — that's your journal (journal_write), and it \
stays off the table. At the table you're logging the session, not processing your night.
PLAYERS — names AND nameless. Most villains don't come with a name; Brian knows them by a \
look ("neck tattoo guy", "the bald reg two to my left"). Log reads on them anyway: give \
`add_read` a `descriptor` instead of a name and it attaches to that unnamed player, reused \
whenever he describes the guy again. The `name` field is ONLY a real handle (what he'd call \
him — "Jonathan", "Sleepy John"); a physical description NEVER goes in `name` — that spawns a \
new duplicate player every time the wording drifts. Put the look in `descriptor`, and keep it \
to a few DISTINCTIVE tags ("Filipino, Fox Racing hat, DKNY shirt"), not a paragraph and not \
generic filler — "mid-aged white guy in glasses" identifies no one. If he tells you the same \
guy's name after you'd been describing him, use name_villain to fuse them — don't create a \
second record. When you already have \
history on someone he names or describes, a SCOUTING DESK note will appear with it — cite it, \
don't invent. If you're not sure the guy he's describing is one you know, ASK ("same neck-\
tattoo reg from last week?") rather than assume — a wrong callback is worse than none. On his \
YES that two are the same person, call link_villains(same=true) to merge them; on "nah, \
different guy," link_villains(same=false) so you stop asking. When he finally catches a name \
for a described player, name_villain carries the whole history over. Never merge on a guess — \
only when he's confirmed it.
Everything you log appears on Brian's live HUD (the Session view) — stack, live net, \ Everything you log appears on Brian's live HUD (the Session view) — stack, live net, \
hands, villains, the confidence bank, the scar notes, and whether Alligator Blood is on. \ hands, villains, the confidence bank, the scar notes, and whether Alligator Blood is on. \
That HUD and you read the SAME data. So when he asks where he's at — his stack, his live \ That HUD and you read the SAME data. So when he asks where he's at — his stack, his live \
+770 -21
View File
@@ -16,7 +16,9 @@ import json
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from lyra import llm, memory import numpy as np
from lyra import clock, llm, memory
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS poker_sessions ( CREATE TABLE IF NOT EXISTS poker_sessions (
@@ -122,6 +124,43 @@ CREATE TABLE IF NOT EXISTS poker_rituals (
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_rituals_session ON poker_rituals(session_id); CREATE INDEX IF NOT EXISTS idx_rituals_session ON poker_rituals(session_id);
-- Two profiles a human has confirmed are DIFFERENT people, so the merge-candidate
-- scan never re-proposes them. `note` records the distinguishing tell.
CREATE TABLE IF NOT EXISTS player_distinct_pairs (
a_id INTEGER NOT NULL,
b_id INTEGER NOT NULL,
note TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (a_id, b_id)
);
-- The async identity-resolution inbox. When the live resolver is uncertain and
-- won't interrupt, it files a task here for Brian to clear via the /identity UI.
CREATE TABLE IF NOT EXISTS identity_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- merge_candidate | needs_clarification
player_ids TEXT, -- JSON list of candidate player ids
descriptor TEXT, -- the raw reference that triggered it, if any
context TEXT, -- what was said / why it's ambiguous
session_id INTEGER,
confidence REAL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | resolved | dismissed
resolution TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_idq_status ON identity_queue(status);
-- Who is seated at the table THIS session — the live roster Brian reads off Bravo
-- at the start. Reads/TAGs attach to these players by handle; active=0 when they leave.
CREATE TABLE IF NOT EXISTS session_players (
session_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
seat TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
PRIMARY KEY (session_id, player_id)
);
""" """
# Below this many observed hands, don't surface % stats (too small a sample). # Below this many observed hands, don't surface % stats (too small a sample).
@@ -138,7 +177,16 @@ def _c():
conn.executescript(_SCHEMA) conn.executescript(_SCHEMA)
# Add columns introduced after a DB already had the tables (no-op if present). # Add columns introduced after a DB already had the tables (no-op if present).
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT", for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"): "ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT",
"ALTER TABLE poker_stack_log ADD COLUMN note TEXT",
# Nameless-villain identity (see docs/SCOUTING_DESK.md): a player
# keyed by physical descriptors when no name is known.
"ALTER TABLE poker_players ADD COLUMN descriptors TEXT",
"ALTER TABLE poker_players ADD COLUMN descriptor_embedding BLOB",
"ALTER TABLE poker_players ADD COLUMN distinctiveness REAL",
"ALTER TABLE poker_players ADD COLUMN named INTEGER DEFAULT 1",
# Embedded scar/confidence notes → "you've hit this leak before".
"ALTER TABLE poker_rituals ADD COLUMN embedding BLOB"):
try: try:
conn.execute(ddl) conn.execute(ddl)
except Exception: except Exception:
@@ -230,7 +278,7 @@ def delete_session(session_id: int) -> dict:
counts: dict[str, int] = {} counts: dict[str, int] = {}
with conn: with conn:
for t in ("poker_hands", "player_observations", "player_reads", for t in ("poker_hands", "player_observations", "player_reads",
"poker_stack_log", "poker_rituals"): "poker_stack_log", "poker_rituals", "session_players"):
counts[t] = conn.execute( counts[t] = conn.execute(
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,) f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
).fetchone()["n"] ).fetchone()["n"]
@@ -403,17 +451,18 @@ def add_buyin(amount: float, session_id: int | None = None) -> float:
# --- stack tracking --- # --- stack tracking ---
def log_stack(amount: float, session_id: int | None = None) -> dict: def log_stack(amount: float, note: str | None = None, session_id: int | None = None) -> dict:
"""Record Brian's current chip stack. Returns {current, buy_in, net} where net """Record Brian's current chip stack, optionally with the why ("card dead",
is his live net while sitting (current stack total bought in).""" "doubled up vs Sal") — that context becomes the session-timeline line. Returns
{current, buy_in, net} where net is his live net while sitting."""
sid = _resolve(session_id) sid = _resolve(session_id)
if sid is None: if sid is None:
raise ValueError("no live session") raise ValueError("no live session")
conn = _c() conn = _c()
with conn: with conn:
conn.execute( conn.execute(
"INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)", "INSERT INTO poker_stack_log (session_id, amount, note, created_at) VALUES (?, ?, ?, ?)",
(sid, float(amount), _now()), (sid, float(amount), (note or "").strip() or None, _now()),
) )
return stack_state(sid) return stack_state(sid)
@@ -436,7 +485,7 @@ def stack_log(session_id: int | None = None) -> list[dict]:
if sid is None: if sid is None:
return [] return []
return [dict(r) for r in _c().execute( return [dict(r) for r in _c().execute(
"SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", "SELECT id, amount, note, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
(sid,), (sid,),
).fetchall()] ).fetchall()]
@@ -466,16 +515,50 @@ def log_ritual(kind: str, content: str | None = None, classification: str | None
sid = _resolve(session_id) sid = _resolve(session_id)
if sid is None: if sid is None:
raise ValueError("no live session") raise ValueError("no live session")
# Embed scar/confidence text so a similar spot later can recall it (Gap 3:
# "you've hit this leak before"). Other ritual kinds don't need it.
blob = None
if content and kind in ("scar", "confidence"):
vec = _embed_vec(content)
blob = memory._to_blob(vec.tolist()) if vec is not None else None
conn = _c() conn = _c()
with conn: with conn:
cur = conn.execute( cur = conn.execute(
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, created_at) " "INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, "
"VALUES (?, ?, ?, ?, ?, ?)", "embedding, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(sid, kind, content, classification, hand_id, _now()), (sid, kind, content, classification, hand_id, blob, _now()),
) )
return int(cur.lastrowid) return int(cur.lastrowid)
def recall_similar_rituals(text: str, kinds: tuple[str, ...] = ("scar", "confidence"),
k: int = 2, min_sim: float = 0.60,
exclude_session: int | None = None) -> list[dict]:
"""Past scar/confidence notes most similar to `text` — the leak/discipline
pattern recall. Excludes the current session so tonight doesn't echo itself."""
vec = _embed_vec(text)
if vec is None:
return []
ph = ",".join("?" * len(kinds))
rows = _c().execute(
f"SELECT r.id, r.kind, r.content, r.classification, r.session_id, r.embedding, "
f"s.started_at AS s_at, s.venue AS venue FROM poker_rituals r "
f"LEFT JOIN poker_sessions s ON s.id = r.session_id "
f"WHERE r.embedding IS NOT NULL AND r.kind IN ({ph})", tuple(kinds)
).fetchall()
scored = []
for row in rows:
if exclude_session and row["session_id"] == exclude_session:
continue
sim = _cos(vec, memory._from_blob(row["embedding"]))
if sim >= min_sim:
scored.append((sim, row))
scored.sort(key=lambda x: x[0], reverse=True)
return [{"kind": r["kind"], "content": r["content"], "classification": r["classification"],
"when": r["s_at"], "venue": r["venue"], "sim": round(s, 3)}
for s, r in scored[:k]]
def list_rituals(session_id: int | None = None, def list_rituals(session_id: int | None = None,
kinds: tuple[str, ...] | None = None) -> list[dict]: kinds: tuple[str, ...] | None = None) -> list[dict]:
"""Ritual events for a session, oldest first; optionally filtered by kind.""" """Ritual events for a session, oldest first; optionally filtered by kind."""
@@ -556,6 +639,44 @@ 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 disown_hand(hand_id: int) -> dict | None:
"""Reclassify a hand as OBSERVED (not Brian's) — fixes one that was mislabeled
as his. Clears the flat hero fields and rewrites the structured JSON with
hero_involved=false so the replay stops showing him as hero."""
h = get_hand(hand_id)
if not h:
return None
structured = h.get("structured")
if isinstance(structured, str):
structured = _safe_json(structured)
if isinstance(structured, dict):
structured = normalize_structured({**structured, "hero_involved": False})
conn = _c()
with conn:
conn.execute(
"UPDATE poker_hands SET position = NULL, hole_cards = NULL, result = NULL, "
"structured = ? WHERE id = ?",
(json.dumps(structured) if structured else None, 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:
@@ -574,8 +695,9 @@ Schema:
{ {
"game": "NLH" | "PLO" | ..., "game": "NLH" | "PLO" | ...,
"stakes": "<e.g. 1/3, or null>", "stakes": "<e.g. 1/3, or null>",
"hero_pos": "<UTG|UTG1|MP|LJ|HJ|CO|BTN|SB|BB, hero's position>", "hero_involved": true | false, // is the narrator actually IN this hand? (see HERO rule)
"hero_cards": ["As","Ax", ...], // rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card "hero_pos": "<UTG|UTG1|MP|LJ|HJ|CO|BTN|SB|BB, hero's position — null if hero not involved>",
"hero_cards": ["As","Ax", ...], // hero's cards; [] / null if hero not involved. rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card
"players": [ // every player mentioned, incl. hero "players": [ // every player mentioned, incl. hero
{"pos": "<position>", "stack": <number|null>, "name": <string|null>, "cards": [".."]|null} {"pos": "<position>", "stack": <number|null>, "name": <string|null>, "cards": [".."]|null}
], ],
@@ -588,6 +710,17 @@ Schema:
"result": {"pot": <number|null>, "hero_net": <number|null>, "summary": "<one line>"} "result": {"pot": <number|null>, "hero_net": <number|null>, "summary": "<one line>"}
} }
HERO — who the hand belongs to. The narrator writes as "I/me/my". Set \
hero_involved=true and fill hero_pos + hero_cards ONLY if he was actually dealt in and \
playing THIS hand. If he's describing a hand he WATCHED between other players — he wasn't \
in it (e.g. "two guys got it in", "the neck-tattoo reg stacked the whale", it's all names/\
seats with no I/me/my holding cards) — set hero_involved=false, hero_pos=null, hero_cards=null, \
and just record those players in players[] and their action. NEVER attribute another player's \
cards, position, or result to the hero. When hero isn't involved, result.hero_net=null (a pot \
he wasn't in didn't win or lose him anything). Example: "the lag in the CO stacked off KK vs \
the nit's AA" -> hero_involved=false, hero_pos=null, hero_cards=null, players=[{pos:CO,cards:\
["Kx","Kx"]},{pos:?,cards:["Ax","Ax"]}].
Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \ Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \
NEVER invent suits or cards. A card is rank+suit where suit is one of s/h/d/c; if the suit \ NEVER invent suits or cards. A card is rank+suit where suit is one of s/h/d/c; if the suit \
wasn't stated, use 'x' for the suit (e.g. "Ax","Kx","4x"); if a whole card wasn't stated, \ wasn't stated, use 'x' for the suit (e.g. "Ax","Kx","4x"); if a whole card wasn't stated, \
@@ -704,6 +837,13 @@ def normalize_structured(parsed: dict) -> dict:
return parsed return parsed
p = dict(parsed) p = dict(parsed)
p["schema_version"] = HAND_SCHEMA_VERSION p["schema_version"] = HAND_SCHEMA_VERSION
# Observed hand (Brian watched it, wasn't in it): never pin cards/position/result
# to the hero. Explicit false is the signal; a null hero_pos is treated the same.
if p.get("hero_involved") is False:
p["hero_pos"] = None
p["hero_cards"] = []
if isinstance(p.get("result"), dict):
p["result"] = {**p["result"], "hero_net": None}
p["hero_cards"] = [_norm_card(c) for c in (p.get("hero_cards") or [])] p["hero_cards"] = [_norm_card(c) for c in (p.get("hero_cards") or [])]
p["board"] = [_norm_card(c) for c in (p.get("board") or [])] p["board"] = [_norm_card(c) for c in (p.get("board") or [])]
@@ -1005,15 +1145,412 @@ 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()
if not row:
return None
d = dict(row)
d.pop("descriptor_embedding", None) # raw bytes — not JSON-serializable
return d
# --- villain identity resolution (nameless villains; see docs/SCOUTING_DESK.md) ---
#
# Most live villains have no name — Brian knows them by a physical descriptor
# ("neck tattoo guy"), a seat (within a session), or something too generic to be
# an identifier. A descriptor is a *fuzzy* key: the same person gets phrased a
# dozen ways, so we match by embedding, scoped by venue, and gated on how
# distinctive the description is. Citing the WRONG villain is worse than silence,
# so a generic-only description never resolves to a guess.
# Features distinctive enough to anchor an identity (a near-unique key).
_DISTINCTIVE = (
"tattoo", "tatted", "ink", "sleeve", "scar", "piercing", "mohawk", "dreads",
"dreadlocks", "braids", "ponytail", "cornrows", "durag", "bald", "goatee",
"cane", "wheelchair", "crutch", "jersey", "grill", "gold teeth", "eyepatch",
"birthmark", "mole", "cowboy hat", "fedora", "turban", "hijab", "accent",
"hearing aid", "prosthetic", "limp", "neck", "face", "hand tattoo", "beard",
)
# Words that describe half the room — near-zero discriminating power.
_GENERIC = (
"guy", "dude", "man", "woman", "lady", "gentleman", "kid", "white", "black",
"asian", "hispanic", "latino", "indian", "old", "older", "young", "younger",
"middle", "mid", "aged", "40s", "50s", "30s", "60s", "20s", "glasses",
"average", "normal", "regular", "tall", "short", "heavy", "thin", "skinny",
"fat", "bigger", "plain", "shirt", "hoodie",
)
# Resolver thresholds (cosine sim on descriptor embeddings). Tunable.
_SIM_HIGH = 0.80 # confident it's the same person
_SIM_AMBIGUOUS = 0.58 # plausible — don't guess live, route to review
_DISTINCT_MIN = 0.30 # below this the description is too generic to match at all
_GENERIC_SET = frozenset(_GENERIC)
def distinctiveness(text: str) -> float:
"""How usable a description is as an identity key: ~1.0 for 'neck tattoo, Fox
Racing hat', ~0.1 for 'mid-aged white guy with glasses'. Generic-ONLY stays
near zero; specific content (named features, brands, a list) reads as high —
even if a generic word like 'shirt' is mixed in."""
t = (text or "").strip()
if not t:
return 0.0
low = t.lower()
tokens = re.findall(r"[a-z0-9']+", low)
dist = sum(1 for w in _DISTINCTIVE if w in low)
proper = len(re.findall(r"\b[A-Z][a-z]{2,}", text)) # brands/proper nouns: Fox, DKNY-ish
non_generic = sum(1 for w in tokens if w not in _GENERIC_SET)
# Only bland filler (age/race/build/gender) and nothing concrete → not usable.
specific = dist + proper + (1 if "," in t else 0)
if specific == 0 and non_generic <= 1:
return 0.10
return min(1.0, 0.40 + 0.14 * specific + 0.05 * non_generic)
def _embed_vec(text: str):
try:
[v] = llm.embed([text])
return np.asarray(v, dtype=np.float32)
except Exception:
return None
def _cos(a, b) -> float:
na, nb = float(np.linalg.norm(a)), float(np.linalg.norm(b))
if na == 0.0 or nb == 0.0:
return 0.0
return float(np.dot(a, b) / (na * nb))
def _descriptor_candidates(vec, venue: str | None, exclude_id: int | None = None):
"""Players with a descriptor embedding, scored by cosine to `vec`, best first.
Same-venue players are preferred (a small bonus) but not required."""
rows = _c().execute(
"SELECT id, name, venue, descriptors, descriptor_embedding FROM poker_players "
"WHERE descriptor_embedding IS NOT NULL"
).fetchall()
out = []
for r in rows:
if exclude_id is not None and r["id"] == exclude_id:
continue
other = memory._from_blob(r["descriptor_embedding"])
sim = _cos(vec, other)
if venue and r["venue"] and venue.lower() == r["venue"].lower():
sim = min(1.0, sim + 0.05) # same-room nudge
out.append({"id": r["id"], "name": r["name"], "venue": r["venue"],
"descriptors": r["descriptors"], "sim": round(sim, 3)})
out.sort(key=lambda c: c["sim"], reverse=True)
return out
def resolve_villain(ref: str, venue: str | None = None,
session_id: int | None = None) -> dict:
"""Resolve a reference to a villain. Returns {band, match_id, confidence, candidates}.
band: 'name' (exact name hit) | 'high' (confident descriptor match) |
'ambiguous' (plausible — don't guess live) | 'generic' (too vague to
match) | 'none' (new villain). The scouting desk / confirm loop act on
the band; they never silently trust an ambiguous or generic match."""
ref = (ref or "").strip()
empty = {"band": "none", "match_id": None, "confidence": 0.0, "candidates": []}
if not ref:
return empty
# 1) Exact name match against *named* players — deterministic, no guessing.
row = _c().execute(
"SELECT id FROM poker_players WHERE named = 1 AND name = ? COLLATE NOCASE", (ref,)
).fetchone()
if row:
return {"band": "name", "match_id": row["id"], "confidence": 1.0, "candidates": []}
# 2) Descriptor match — but refuse if the description is too generic to key on.
dscore = distinctiveness(ref)
if dscore < _DISTINCT_MIN:
return {"band": "generic", "match_id": None, "confidence": 0.0, "candidates": []}
vec = _embed_vec(ref)
if vec is None:
return empty
cands = _descriptor_candidates(vec, venue)[:5]
best = cands[0]["sim"] if cands else 0.0
if best >= _SIM_HIGH:
band = "high"
elif best >= _SIM_AMBIGUOUS:
band = "ambiguous"
else:
band = "none"
return {"band": band, "match_id": cands[0]["id"] if cands and band in ("high", "ambiguous") else None,
"confidence": best, "candidates": cands}
def create_descriptor_villain(descriptor: str, venue: str | None = None,
category: str | None = None) -> int:
"""Open a new nameless villain keyed on a physical descriptor. name holds the
descriptor label (so displays work); named=0 marks it as not-a-real-name."""
vec = _embed_vec(descriptor)
blob = memory._to_blob(vec.tolist()) if vec is not None else None
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO poker_players (name, venue, category, descriptors, "
"descriptor_embedding, distinctiveness, named, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, 0, ?)",
(descriptor.strip(), venue, category, descriptor.strip(), blob,
distinctiveness(descriptor), _now()),
)
return int(cur.lastrowid)
def add_descriptor(player_id: int, descriptor: str) -> None:
"""Fold another observed descriptor into a villain and re-embed the union, so
matching sharpens as more phrasings accumulate."""
row = _c().execute(
"SELECT descriptors FROM poker_players WHERE id = ?", (player_id,)
).fetchone()
if not row:
return
merged = "; ".join(dict.fromkeys(
p.strip() for p in ((row["descriptors"] or "") + "; " + descriptor).split(";") if p.strip()
))
vec = _embed_vec(merged)
blob = memory._to_blob(vec.tolist()) if vec is not None else None
conn = _c()
with conn:
conn.execute(
"UPDATE poker_players SET descriptors = ?, descriptor_embedding = ?, "
"distinctiveness = ?, updated_at = ? WHERE id = ?",
(merged, blob, distinctiveness(merged), _now(), player_id),
)
def name_villain(player_id: int, name: str) -> None:
"""Attach a real name to a descriptor villain (caught it off Bravo). Flips named=1."""
conn = _c()
with conn:
conn.execute(
"UPDATE poker_players SET name = ?, named = 1, updated_at = ? WHERE id = ?",
(name.strip(), _now(), player_id),
)
def merge_players(keep_id: int, dup_id: int, note: str | None = None) -> bool:
"""Confirmed same person: repoint dup's observations/reads onto keep, fold in its
descriptors, keep a real name over a descriptor label, then delete the dup."""
if keep_id == dup_id:
return False
conn = _c()
keep = conn.execute("SELECT * FROM poker_players WHERE id = ?", (keep_id,)).fetchone()
dup = conn.execute("SELECT * FROM poker_players WHERE id = ?", (dup_id,)).fetchone()
if not keep or not dup:
return False
keep, dup = dict(keep), dict(dup)
with conn:
conn.execute("UPDATE player_observations SET player_id = ? WHERE player_id = ?",
(keep_id, dup_id))
conn.execute("UPDATE player_reads SET player_id = ? WHERE player_id = ?",
(keep_id, dup_id))
# Prefer a real name; union the descriptor text.
new_name = keep["name"] if keep.get("named") else (dup["name"] if dup.get("named") else keep["name"])
named = 1 if (keep.get("named") or dup.get("named")) else 0
descs = "; ".join(dict.fromkeys(
p.strip() for p in ((keep.get("descriptors") or "") + "; " + (dup.get("descriptors") or "")).split(";")
if p.strip()
)) or None
conn.execute("UPDATE poker_players SET name = ?, named = ?, descriptors = ? WHERE id = ?",
(new_name, named, descs, keep_id))
conn.execute("DELETE FROM poker_players WHERE id = ?", (dup_id,))
conn.execute("DELETE FROM identity_queue WHERE player_ids LIKE ? OR player_ids LIKE ?",
(f"%{dup_id}%", f"%{keep_id}%"))
if descs:
add_descriptor(keep_id, "") # re-embed the merged descriptor set
return True
def mark_distinct(a_id: int, b_id: int, note: str | None = None) -> None:
"""Record that two profiles are confirmed DIFFERENT people so the scan never
re-proposes the merge. Stored order-independent (min, max)."""
lo, hi = sorted((int(a_id), int(b_id)))
conn = _c()
with conn:
conn.execute(
"INSERT OR REPLACE INTO player_distinct_pairs (a_id, b_id, note, created_at) "
"VALUES (?, ?, ?, ?)", (lo, hi, note, _now()))
conn.execute("DELETE FROM identity_queue WHERE kind = 'merge_candidate' AND "
"(player_ids = ? OR player_ids = ?)",
(json.dumps([lo, hi]), json.dumps([hi, lo])))
def are_distinct(a_id: int, b_id: int) -> bool:
lo, hi = sorted((int(a_id), int(b_id)))
return _c().execute(
"SELECT 1 FROM player_distinct_pairs WHERE a_id = ? AND b_id = ?", (lo, hi)
).fetchone() is not None
def queue_identity_task(kind: str, player_ids: list[int], descriptor: str | None = None,
context: str | None = None, session_id: int | None = None,
confidence: float | None = None) -> int | None:
"""File an identity task for async review. De-dupes an identical pending task."""
ids_json = json.dumps(sorted(int(i) for i in player_ids)) if player_ids else None
conn = _c()
dup = conn.execute(
"SELECT id FROM identity_queue WHERE status = 'pending' AND kind = ? AND "
"IFNULL(player_ids,'') = IFNULL(?,'') AND IFNULL(descriptor,'') = IFNULL(?,'')",
(kind, ids_json, descriptor),
).fetchone()
if dup:
return int(dup["id"])
with conn:
cur = conn.execute(
"INSERT INTO identity_queue (kind, player_ids, descriptor, context, session_id, "
"confidence, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(kind, ids_json, descriptor, context, session_id, confidence, _now()),
)
return int(cur.lastrowid)
def list_identity_queue(status: str = "pending") -> list[dict]:
"""Pending identity tasks, each with its candidate players hydrated for the UI."""
rows = _c().execute(
"SELECT * FROM identity_queue WHERE status = ? ORDER BY id DESC", (status,)
).fetchall()
out = []
for r in rows:
d = dict(r)
ids = json.loads(d["player_ids"]) if d.get("player_ids") else []
d["players"] = [p for p in (_player_brief(i) for i in ids) if p]
out.append(d)
return out
def _player_brief(player_id: int) -> dict | None:
r = _c().execute(
"SELECT id, name, venue, category, named, descriptors FROM poker_players WHERE id = ?",
(player_id,),
).fetchone()
if not r:
return None
d = dict(r)
d["obs"] = _c().execute(
"SELECT COUNT(*) n FROM player_observations WHERE player_id = ?", (player_id,)
).fetchone()["n"]
return d
def resolve_identity_task(task_id: int, action: str, **kw) -> bool:
"""Clear a queue task. action: 'merge' (kw keep_id,dup_id) | 'distinct'
(kw a_id,b_id,note) | 'name' (kw player_id,name) | 'dismiss'."""
if action == "merge":
merge_players(kw["keep_id"], kw["dup_id"], kw.get("note"))
elif action == "distinct":
mark_distinct(kw["a_id"], kw["b_id"], kw.get("note"))
elif action == "name":
name_villain(kw["player_id"], kw["name"])
conn = _c()
with conn:
conn.execute("UPDATE identity_queue SET status = 'resolved', resolution = ? WHERE id = ?",
(action, task_id))
return True
def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int:
"""Off-hot-path (dream cycle): find pairs of profiles likely to be one person
and file merge_candidate tasks. Skips pairs already confirmed distinct. Returns
how many new candidates were filed."""
rows = _c().execute(
"SELECT id, venue, descriptor_embedding FROM poker_players "
"WHERE descriptor_embedding IS NOT NULL"
).fetchall()
vecs = [(r["id"], (r["venue"] or "").lower(), memory._from_blob(r["descriptor_embedding"]))
for r in rows]
filed = 0
for i in range(len(vecs)):
for j in range(i + 1, len(vecs)):
aid, aven, av = vecs[i]
bid, bven, bv = vecs[j]
if aven and bven and aven != bven:
continue # different rooms — leave cross-venue merges to a human
if are_distinct(aid, bid):
continue
sim = _cos(av, bv)
if sim >= sim_threshold:
if queue_identity_task("merge_candidate", [aid, bid],
context=f"descriptor similarity {sim:.2f}",
confidence=round(sim, 3)):
filed += 1
return filed
# Words that mark a "name" as really a physical description (misused name field).
_DESC_MARKERS = (
"shirt", "hat", "cap", "hair", "beard", "glasses", "sunglasses", "tattoo",
"bracelet", "watch", "descent", "jersey", "hoodie", "jacket", "build",
"bald", "goatee", "chain", "necklace", "piercing", "mustache", "ponytail",
"sleeve", "skin", "wearing", "heavyset", "tall guy", "older", "younger",
)
def _looks_like_description(text: str | None) -> bool:
"""A physical description mistakenly passed as a name — should be a descriptor.
Real handles are short (1-3 words, no commas); descriptions are longer / listy."""
t = (text or "").strip()
if not t:
return False
low = t.lower()
if "," in t or len(t.split()) > 4:
return True
return any(m in low for m in _DESC_MARKERS)
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: descriptor: str | None = None, session_id: int | None = None,
"""Log a live read. If `name` is given, upsert the player and link the read.""" **player_fields) -> int:
"""Log a live read. `name` upserts a named player; `descriptor` (a nameless
villain's physical description) resolves to an existing descriptor villain when
confident, else opens a new one — so reads on unnamed players still accumulate."""
sid = _resolve(session_id) sid = _resolve(session_id)
venue = player_fields.get("venue")
# A description passed as a name (e.g. "Filipino, Fox Racing hat, DKNY shirt")
# is really a descriptor — route it so it dedupes instead of spawning a new
# named player each time the wording drifts.
if name and not descriptor and _looks_like_description(name):
descriptor, name = name, None
pid = None pid = None
if name: if name:
pid = upsert_player(name, **{k: v for k, v in player_fields.items() pid = upsert_player(name, **{k: v for k, v in player_fields.items()
if k in ("venue", "description", "tendencies", if k in ("venue", "description", "tendencies",
"adjustment", "category")}) "adjustment", "category")})
elif descriptor:
res = resolve_villain(descriptor, venue=venue, session_id=sid)
if res["band"] in ("name", "high") and res["match_id"]:
pid = res["match_id"]
add_descriptor(pid, descriptor) # sharpen the key with this phrasing
else:
pid = create_descriptor_villain(descriptor, venue=venue,
category=player_fields.get("category"))
# Plausibly the same guy as an existing villain, but not confident —
# surface it for a one-click merge instead of leaving a silent dup.
if res["band"] == "ambiguous" and res["match_id"]:
queue_identity_task("merge_candidate", [pid, res["match_id"]],
descriptor=descriptor,
context="similar description logged live",
confidence=res["confidence"])
conn = _c() conn = _c()
with conn: with conn:
cur = conn.execute( cur = conn.execute(
@@ -1124,6 +1661,59 @@ def get_villain_file(name: str | None = None, venue: str | None = None) -> list[
return [dict(r) for r in _c().execute(sql, params).fetchall()] return [dict(r) for r in _c().execute(sql, params).fetchall()]
def players_overview() -> list[dict]:
"""All villains for the browser: identity + observation count + last seen,
named players first, then most-recently-updated."""
rows = _c().execute(
"SELECT p.id, p.name, p.named, p.venue, p.category, p.descriptors, "
"p.tendencies, p.adjustment, p.updated_at, "
"(SELECT COUNT(*) FROM player_observations o WHERE o.player_id = p.id) AS obs, "
"(SELECT COUNT(*) FROM player_reads r WHERE r.player_id = p.id) AS reads "
"FROM poker_players p ORDER BY p.named DESC, p.updated_at DESC"
).fetchall()
return [dict(r) for r in rows]
def villain_recall(player_id: int) -> dict | None:
"""Episodic recall for one villain: who, how often/where seen, last seen, the
notable hands against him (with ids to link), reads, and stats. This is the
when/where/which-hand narrative the scouting desk surfaces (Gap 1)."""
p = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone()
if not p:
return None
p = dict(p)
p.pop("descriptor_embedding", None) # raw bytes — not JSON-serializable, not needed
obs = [dict(r) for r in _c().execute(
"SELECT o.*, s.venue AS s_venue, s.started_at AS s_at FROM player_observations o "
"LEFT JOIN poker_sessions s ON s.id = o.session_id WHERE o.player_id = ? "
"ORDER BY o.id DESC", (player_id,)
).fetchall()]
reads = [dict(r) for r in _c().execute(
"SELECT note, created_at FROM player_reads WHERE player_id = ? ORDER BY id DESC LIMIT 8",
(player_id,)
).fetchall()]
sessions_seen = sorted({o["session_id"] for o in obs if o.get("session_id")} |
{r_["session_id"] for r_ in _c().execute(
"SELECT session_id FROM player_reads WHERE player_id = ?",
(player_id,)).fetchall() if r_["session_id"]})
last_at = max([o.get("s_at") or o.get("created_at") for o in obs] +
[r["created_at"] for r in reads] + [p.get("updated_at")], default=None)
# Notable hands: showdowns / anything with cards, most recent first, linkable.
notable = [{"hand_id": o["hand_id"], "session_id": o.get("session_id"),
"when": o.get("s_at") or o.get("created_at"), "cards": o.get("cards"),
"summary": o.get("summary")}
for o in obs if o.get("hand_id")][:6]
prof = player_profile(p["name"]) or {}
return {
"player": p, "named": bool(p.get("named")),
"times_seen": len(sessions_seen), "sessions_seen": sessions_seen,
"last_seen": last_at, "notable_hands": notable,
"reads": [r["note"] for r in reads],
"stats": prof.get("stats"), "observations": len(obs),
"descriptors": p.get("descriptors"),
}
# --- stats --- # --- stats ---
def session_stats(session_id: int | None = None) -> dict: def session_stats(session_id: int | None = None) -> dict:
@@ -1179,10 +1769,163 @@ def running_stats(stakes: str | None = None, venue: str | None = None,
# --- live session HUD (everything tracked in the current session, for the UI) --- # --- live session HUD (everything tracked in the current session, for the UI) ---
def timeline(session_id: int | None = None) -> list[dict]:
"""The session's running log: start, stack updates (+context), hands (linkable),
reads, and rituals, interleaved chronologically with local time-of-day stamps.
This is what Brian sees as the night's story — '10:45 start … 12:00a doubled up,
$750 (hand)'. Each entry: {time, at, kind, text, hand_id?, amount?, result?}."""
sid = _resolve(session_id)
if sid is None:
return []
s = get_session(sid) or {}
events: list[dict] = []
if s.get("started_at"):
bits = [s.get("stakes"), s.get("game"), f"at {s['venue']}" if s.get("venue") else None]
label = " ".join(b for b in bits if b)
events.append({"at": s["started_at"], "kind": "start",
"text": ("Session start — " + label) if label else "Session start"})
for r in stack_log(sid):
events.append({"at": r["created_at"], "kind": "stack",
"amount": r.get("amount"), "text": r.get("note") or "stack update"})
for h in list_hands(sid):
desc = " ".join(b for b in (h.get("position"), h.get("hole_cards")) if b)
events.append({"at": h["at"], "kind": "hand", "hand_id": h["id"],
"result": h.get("result"), "text": desc or "hand"})
for r in _c().execute(
"SELECT pr.created_at AS at, pr.seat AS seat, pr.note AS note, p.name AS name "
"FROM player_reads pr LEFT JOIN poker_players p ON p.id = pr.player_id "
"WHERE pr.session_id = ?", (sid,),
).fetchall():
who = r["name"] or (f"seat {r['seat']}" if r["seat"] else "villain")
events.append({"at": r["at"], "kind": "read", "text": f"Read — {who}: {r['note']}"})
for r in list_rituals(sid):
tag = f"[{r['classification']}] " if r.get("classification") else ""
events.append({"at": r["created_at"], "kind": r["kind"],
"text": tag + (r.get("content") or r["kind"]), "hand_id": r.get("hand_id")})
events.sort(key=lambda e: e["at"] or "")
for e in events:
e["time"] = clock.short(e["at"])
return events
def _resolve_or_create_player(name: str | None = None, descriptor: str | None = None,
venue: str | None = None, category: str | None = None) -> int | None:
"""Turn a name-or-descriptor into a player id, matching an existing villain when
confident. A description mistakenly given as a name is routed to the descriptor
path so it dedupes (same guard add_read uses)."""
if name and not descriptor and _looks_like_description(name):
descriptor, name = name, None
if name:
return upsert_player(name, venue=venue, category=category)
if descriptor:
res = resolve_villain(descriptor, venue=venue)
if res["band"] in ("name", "high") and res["match_id"]:
add_descriptor(res["match_id"], descriptor)
return res["match_id"]
return create_descriptor_villain(descriptor, venue=venue, category=category)
return None
def seat_player(name: str | None = None, descriptor: str | None = None, seat: str | None = None,
category: str | None = None, session_id: int | None = None) -> int | None:
"""Seat one player at the live table (add to the roster). Idempotent per session."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
venue = (get_session(sid) or {}).get("venue")
pid = _resolve_or_create_player(name=name, descriptor=descriptor, venue=venue, category=category)
if pid is None:
return None
conn = _c()
with conn:
conn.execute(
"INSERT INTO session_players (session_id, player_id, seat, active, created_at) "
"VALUES (?, ?, ?, 1, ?) ON CONFLICT(session_id, player_id) DO UPDATE SET "
"active = 1, seat = COALESCE(excluded.seat, session_players.seat)",
(sid, pid, seat, _now()),
)
return pid
def seat_players(players: list, session_id: int | None = None) -> int:
"""Seat a whole table at once. Each item is a name string or a dict with
name/descriptor/seat/category. Returns how many were seated."""
n = 0
for p in players or []:
if isinstance(p, str):
ok = seat_player(name=p, session_id=session_id)
elif isinstance(p, dict):
ok = seat_player(name=p.get("name"), descriptor=p.get("descriptor"),
seat=p.get("seat"), category=p.get("category"), session_id=session_id)
else:
ok = None
if ok:
n += 1
return n
def unseat_player(name: str | None = None, descriptor: str | None = None,
session_id: int | None = None) -> bool:
"""Mark a seated player as gone (busted/left). Keeps their reads/history."""
sid = _resolve(session_id)
if sid is None:
return False
ref = name or descriptor or ""
res = resolve_villain(ref, venue=(get_session(sid) or {}).get("venue"), session_id=sid)
pid = res.get("match_id")
if pid is None:
return False
conn = _c()
with conn:
conn.execute("UPDATE session_players SET active = 0 WHERE session_id = ? AND player_id = ?",
(sid, pid))
return True
def clear_roster(session_id: int | None = None) -> int:
"""Empty the table roster (he changed tables) — unseat everyone at once. Keeps
the session and any reads logged; just resets who's currently seated. Returns
how many were cleared."""
sid = _resolve(session_id)
if sid is None:
return 0
conn = _c()
with conn:
cur = conn.execute(
"UPDATE session_players SET active = 0 WHERE session_id = ? AND active = 1", (sid,))
return cur.rowcount
def session_roster(session_id: int | None = None) -> list[dict]:
"""The live table roster: seated players with seat, dossier, and their latest
read this session. This is 'who's at the table right now'."""
sid = _resolve(session_id)
if sid is None:
return []
rows = _c().execute(
"SELECT sp.seat AS seat, p.id AS id, p.name AS name, p.named AS named, "
"p.category AS category, p.tendencies AS tendencies, "
"(SELECT note FROM player_reads r WHERE r.player_id = p.id AND r.session_id = ? "
" ORDER BY r.id DESC LIMIT 1) AS last_note, "
"(SELECT COUNT(*) FROM player_reads r2 WHERE r2.player_id = p.id AND r2.session_id = ?) AS reads "
"FROM session_players sp JOIN poker_players p ON p.id = sp.player_id "
"WHERE sp.session_id = ? AND sp.active = 1 "
"ORDER BY CASE WHEN sp.seat IS NULL THEN 1 ELSE 0 END, sp.seat, p.name",
(sid, sid, sid),
).fetchall()
return [dict(r) for r in rows]
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 "
@@ -1216,12 +1959,16 @@ def hud(session_id: int | None = None) -> dict | None:
for h in list_hands(sid) for h in list_hands(sid)
] ]
# Notes she jotted during this session: journal/note entries since it started. # Her session narration: notes she took *for this session*, identified by the
started = s.get("started_at") or "" # `poker:{id}` source tag stamped at write time (see tools._note) — NOT by a
# time window. Her autonomous journaling (dream-cycle reflections, thought
# loop, existential musings) has a different source, so it can never leak onto
# the poker HUD.
tag = f"poker:{sid}"
notes = [ notes = [
{"created_at": j["created_at"], "kind": j["kind"], "content": j["content"]} {"created_at": j["created_at"], "kind": j["kind"], "content": j["content"]}
for j in memory.list_journal(kinds=("note", "journal")) for j in memory.list_journal(kinds=("note",))
if (j["created_at"] or "") >= started if (j.get("source") or "") == tag
][:20] ][:20]
stats = session_stats(sid) stats = session_stats(sid)
@@ -1250,7 +1997,9 @@ def hud(session_id: int | None = None) -> dict | None:
"log": log, "log": log,
}, },
"hands": hands, "hands": hands,
"roster": session_roster(sid),
"villains": _session_villains(sid), "villains": _session_villains(sid),
"timeline": timeline(sid),
"notes": notes, "notes": notes,
"rituals": { "rituals": {
"alligator": alligator_active(sid), "alligator": alligator_active(sid),
+19
View File
@@ -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}")},
}
+152
View File
@@ -0,0 +1,152 @@
"""The scouting desk — proactive poker recall slid into Lyra's context before she
replies, the way a broadcast stats desk hands the commentator a note.
Two detectors run on the incoming message: known NAMES (deterministic) and
physical DESCRIPTORS (fuzzy, via the identity resolver). A confident hit becomes a
`SCOUTING DESK` system note she can cite; an ambiguous descriptor is filed to the
review queue instead of interrupting. Everything here is best-effort and wrapped
by the caller it must never break a chat turn. Silence is the default.
See docs/SCOUTING_DESK.md.
"""
from __future__ import annotations
import re
from lyra import clock, logbus, poker
# Cues that a span names a *person at the table* worth resolving as a villain.
_ROLE = r"(?:guy|dude|man|kid|reg|player|villain|fish|whale|nit|lag|tag|maniac)"
_DESC_PATTERNS = (
re.compile(rf"\bthe ([\w][\w\s'-]{{2,28}}?) {_ROLE}\b", re.I),
re.compile(rf"\b{_ROLE} (?:with|in|who has|sporting|rocking) (?:the |a |an )?([\w\s'-]{{3,28}})", re.I),
)
_MIN_NAME = 3
# Cues that a message is a strategy/spot/tilt discussion — the only turns worth
# paying an embed to recall past leaks. Keeps the pattern pass off routine logging.
_STRAT_CUES = (
"fold", "call", "raise", "bluff", "river", "turn", "flop", "tilt", "punt",
"leak", "should i", "hero", "value", "overbet", "spew", "stack off", "3bet",
"4bet", "check-raise", "checkraise", "range", "board", "steaming", "felted",
"all in", "all-in", "shoved", "jammed", "snap", "sizing",
)
def _looks_strategic(msg: str) -> bool:
low = msg.lower()
return len(msg) >= 40 and any(c in low for c in _STRAT_CUES)
def _named_hits(msg: str) -> list[int]:
"""Ids of known *named* villains whose name appears as a word in the message."""
low = msg.lower()
hits = []
for r in poker._c().execute("SELECT id, name FROM poker_players WHERE named = 1").fetchall():
name = (r["name"] or "").strip()
if len(name) < _MIN_NAME:
continue
if re.search(rf"\b{re.escape(name.lower())}\b", low):
hits.append(r["id"])
return hits
def _descriptor_spans(msg: str) -> list[str]:
spans, seen = [], set()
for pat in _DESC_PATTERNS:
for m in pat.finditer(msg):
span = m.group(1).strip(" '-").lower()
if span and span not in seen:
seen.add(span)
spans.append(span)
return spans
def _brief(player_id: int) -> str | None:
"""One compact line of episodic recall for a villain, or None if nothing known."""
rec = poker.villain_recall(player_id)
if not rec:
return None
p = rec["player"]
who = p["name"] if rec["named"] else f"{p['name']}"
bits = [who]
tags = [t for t in (p.get("venue"), p.get("category")) if t]
if tags:
bits.append("(" + ", ".join(tags) + ")")
if rec["times_seen"]:
seen = f"seen {rec['times_seen']}×"
if rec["last_seen"]:
seen += f", last {clock.short(rec['last_seen'])}"
bits.append(seen)
st = rec.get("stats")
if st:
bits.append(f"VPIP {st['vpip_pct']}/PFR {st['pfr_pct']} ({st['hands']}h)")
line = " ".join(bits)
if rec["reads"]:
line += " — reads: " + "; ".join(rec["reads"][:3])
if rec["notable_hands"]:
h = rec["notable_hands"][0]
line += f" · notable hand #{h['hand_id']}" + (f" ({h['cards']})" if h.get("cards") else "")
return line
def scout(user_msg: str, venue: str | None = None, session_id: int | None = None) -> str | None:
"""Build the SCOUTING DESK note for this message, or None. Never raises for a
caller that forgets to guard but callers should guard anyway."""
try:
msg = (user_msg or "").strip()
if len(msg) < 3:
return None
if venue is None or session_id is None:
live = poker.live_session()
if live:
venue = venue or live.get("venue")
session_id = session_id or live.get("id")
lines: list[str] = []
seen_ids: set[int] = set()
for pid in _named_hits(msg):
if pid in seen_ids:
continue
b = _brief(pid)
if b:
lines.append(b)
seen_ids.add(pid)
for span in _descriptor_spans(msg):
res = poker.resolve_villain(span, venue=venue, session_id=session_id)
if res["band"] == "high" and res["match_id"] and res["match_id"] not in seen_ids:
b = _brief(res["match_id"])
if b:
lines.append(b + " ← confirm it's the same guy")
seen_ids.add(res["match_id"])
elif res["band"] == "ambiguous" and res["match_id"]:
# Don't interrupt on a maybe — route it to the async review queue.
poker.queue_identity_task(
"needs_clarification", [res["match_id"]], descriptor=span,
context=f'Brian referred to "{span}"', session_id=session_id,
confidence=res["confidence"])
# Pattern desk: on genuine strategy talk, recall his own past leaks/wins in
# similar spots. Gated so routine logging never pays for an embed.
pattern: list[str] = []
if _looks_strategic(msg):
for r in poker.recall_similar_rituals(msg, exclude_session=session_id):
tag = "leak" if r["kind"] == "scar" else "banked"
cls = f"/{r['classification']}" if r.get("classification") else ""
when = f" ({str(r['when'])[:10]})" if r.get("when") else ""
pattern.append(f"[{tag}{cls}]{when} {r['content']}")
if not lines and not pattern:
return None
logbus.log("info", "scouting desk", hits=len(lines), patterns=len(pattern))
out = "SCOUTING DESK — structured recall for his message (cite it, don't invent; " \
"if unsure it's the same person, ask him):"
if lines:
out += "\n" + "\n".join(lines)
if pattern:
out += "\nYou've been in a spot like this before —\n" + "\n".join(pattern)
return out
except Exception as exc: # desk must never break a turn
logbus.log("error", "scouting desk failed", error=str(exc)[:160])
return None
+65 -13
View File
@@ -12,12 +12,41 @@ from __future__ import annotations
import sys import sys
import threading import threading
import time import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from lyra import config, llm, logbus, memory from lyra import config, llm, logbus, memory
from lyra.llm import Backend, Message from lyra.llm import Backend, Message
_RETRIES = 4 # Consolidation LLM budget. A gist is short (a handful of sentences), so cap the
# generation hard — an uncapped local model will otherwise ramble for thousands
# of tokens and, on a slow GPU, blow the request timeout. 768 is ~3x the longest
# real gist we've stored.
SUMMARY_MAX_TOKENS = 768
# Attempts on the primary backend before falling back to cloud.
MI50_ATTEMPTS = 2
# Per-call timeout (seconds). A capped 768-token gist finishes in ~60-90s on the
# MI50; 150s is headroom but bails a hung call fast so fallback isn't slow.
SUMMARY_TIMEOUT = 150
# Degenerate-output guard. A wedged local model (e.g. an overheated GPU) returns
# a single character repeated ("?????") as a *successful* 200, which no timeout or
# exception catches — so validate the text and treat junk as a failure. Real gists
# are diverse prose; flag output whose most-common non-space char dominates. Short
# outputs are exempt (nothing meaningful to judge).
_DEGENERATE_MIN_CHARS = 24
_DEGENERATE_CHAR_RATIO = 0.5
class DegenerateOutput(RuntimeError):
"""A backend returned junk (e.g. one char repeated) as a successful response."""
def _looks_degenerate(text: str) -> bool:
stripped = "".join(text.split())
if len(stripped) < _DEGENERATE_MIN_CHARS:
return False
return max(Counter(stripped).values()) / len(stripped) > _DEGENERATE_CHAR_RATIO
# Re-summarize a session once it has accumulated this many new raw exchanges. # Re-summarize a session once it has accumulated this many new raw exchanges.
SUMMARIZE_AFTER = 20 SUMMARIZE_AFTER = 20
@@ -61,16 +90,35 @@ def _summarize_text(text: str, backend: Backend) -> str:
{"role": "system", "content": _PROMPT}, {"role": "system", "content": _PROMPT},
{"role": "user", "content": text}, {"role": "user", "content": text},
] ]
# Retry transient backend errors (e.g. the GPU server restarting) with backoff.
for attempt in range(_RETRIES): def _call(be: Backend) -> str:
out = llm.complete(messages, backend=be,
max_tokens=SUMMARY_MAX_TOKENS, timeout=SUMMARY_TIMEOUT)
if _looks_degenerate(out):
raise DegenerateOutput(f"{be} returned degenerate output ({len(out)} chars)")
return out
# Try the primary backend a bounded number of times (each call fast-fails via
# SUMMARY_TIMEOUT), with a short backoff for a transient blip / restarting GPU.
last_exc: Exception | None = None
for attempt in range(MI50_ATTEMPTS):
try: try:
return llm.complete(messages, backend=backend) return _call(backend)
except Exception as exc: except Exception as exc:
if attempt == _RETRIES - 1: last_exc = exc
raise logbus.log("debug", "summary retry", attempt=attempt + 1,
logbus.log("debug", "summary retry", attempt=attempt + 1, error=str(exc)[:80]) backend=backend, error=str(exc)[:80])
if attempt < MI50_ATTEMPTS - 1:
time.sleep(5 * (attempt + 1)) time.sleep(5 * (attempt + 1))
raise RuntimeError("unreachable")
# Primary exhausted. If it wasn't already cloud and cloud is configured, fall
# back once so a stuck/offline MI50 doesn't sink consolidation for the night.
if backend != "cloud" and config.load().openai_api_key:
logbus.log("info", "summary fell back to cloud", primary=backend,
error=str(last_exc)[:80] if last_exc else None)
return _call("cloud")
raise last_exc if last_exc else RuntimeError("summary failed")
def _summarize_transcript(transcript: str, backend: Backend) -> str: def _summarize_transcript(transcript: str, backend: Backend) -> str:
@@ -129,16 +177,20 @@ def maybe_summarize_async(session_id: str, backend: Backend | None = None) -> No
def summarize_all( def summarize_all(
backend: Backend | None = None, limit: int | None = None, workers: int = 8 backend: Backend | None = None, limit: int | None = None, workers: int | None = None
) -> dict: ) -> dict:
"""Summarize every session that needs it. Idempotent and resumable. """Summarize every session that needs it. Idempotent and resumable.
LLM summarization runs concurrently across `workers` threads (great for a Concurrency is backend-aware: the cloud API parallelizes happily, but the
cloud backend). DB reads (loading transcripts) and writes (store_summary, local/MI50 GPU servers run a single slot (llama.cpp --parallel 1) firing N
which also embeds) happen on the main thread, so the single SQLite requests at them just queues, blows the client timeout, and thrashes the KV
connection is never touched from multiple threads. cache (wasted compute + heat). So GPU backends run serially unless overridden.
DB reads/writes (store_summary embeds) stay on the main thread, so the single
SQLite connection is never touched from multiple threads.
""" """
backend = backend or config.load().summary_backend backend = backend or config.load().summary_backend
if workers is None:
workers = 8 if backend == "cloud" else 1
# Main thread: collect the work (transcripts) for sessions needing a summary. # Main thread: collect the work (transcripts) for sessions needing a summary.
todo: list[tuple[str, str, int]] = [] todo: list[tuple[str, str, int]] = []
+150 -8
View File
@@ -30,8 +30,13 @@ def _note(args: dict, ctx: dict) -> str:
return "Nothing to note — content was empty." return "Nothing to note — content was empty."
tag = (args.get("tag") or "").strip() tag = (args.get("tag") or "").strip()
stored = f"[{tag}] {content}" if tag else content stored = f"[{tag}] {content}" if tag else content
memory.add_journal_entry("note", stored, source="chat") # A note taken while a poker session is live is session narration — stamp it
logbus.log("info", "Lyra noted (tool)", tag=tag or None) # with the session so the HUD shows *only* these, never her autonomous
# journaling (dream-cycle musings, thought loop). Correctness by construction.
live = poker.live_session()
source = f"poker:{live['id']}" if live else "chat"
memory.add_journal_entry("note", stored, source=source)
logbus.log("info", "Lyra noted (tool)", tag=tag or None, poker=bool(live))
return "Noted." return "Noted."
@@ -114,6 +119,9 @@ TOOLS: dict[str, dict] = {
"description": ( "description": (
"Jot down a note to remember later — an observation, an idea, a " "Jot down a note to remember later — an observation, an idea, a "
"reminder, a read on a poker spot or opponent, anything worth keeping. " "reminder, a read on a poker spot or opponent, anything worth keeping. "
"During a live poker session this is your session log: a factual beat "
"about how the night is going (table dynamics, Brian's arc, momentum) — "
"it shows on his HUD. Not for your own feelings or reflection. "
"Optionally tag it (e.g. 'poker', 'idea', 'reminder')." "Optionally tag it (e.g. 'poker', 'idea', 'reminder')."
), ),
"parameters": { "parameters": {
@@ -184,8 +192,9 @@ def _log_stack(args: dict, ctx: dict) -> str:
amount = float(args.get("amount")) amount = float(args.get("amount"))
except (TypeError, ValueError): except (TypeError, ValueError):
return "Give me a number for the stack." return "Give me a number for the stack."
note = (args.get("note") or "").strip() or None
try: try:
st = poker.log_stack(amount) st = poker.log_stack(amount, note=note)
except ValueError: except ValueError:
return "No live session — start one first, then I'll track your stack." return "No live session — start one first, then I'll track your stack."
net = st.get("net") net = st.get("net")
@@ -281,14 +290,87 @@ def _log_hand(args: dict, ctx: dict) -> str:
def _add_read(args: dict, ctx: dict) -> str: def _add_read(args: dict, ctx: dict) -> str:
poker.add_read( poker.add_read(
note=args.get("note") or "", seat=args.get("seat"), name=args.get("name"), note=args.get("note") or "", seat=args.get("seat"), name=args.get("name"),
descriptor=args.get("descriptor"),
tendencies=args.get("tendencies"), adjustment=args.get("adjustment"), tendencies=args.get("tendencies"), adjustment=args.get("adjustment"),
description=args.get("description"), category=args.get("category"), description=args.get("description"), category=args.get("category"),
venue=args.get("venue"), venue=args.get("venue"),
) )
who = f" on {args['name']}" if args.get("name") else "" who = f" on {args['name']}" if args.get("name") else (
f" on “{args['descriptor']}" if args.get("descriptor") else "")
return f"Read logged{who}." return f"Read logged{who}."
def _resolve_villain_ref(ref: str) -> tuple[int | None, str]:
"""Resolve a name-or-descriptor to a single player id for a confirm-loop action.
Returns (id, band); acts only on a deterministic name or a confident descriptor."""
live = poker.live_session()
res = poker.resolve_villain(ref, venue=(live or {}).get("venue"),
session_id=(live or {}).get("id"))
if res["band"] in ("name", "high") and res["match_id"]:
return res["match_id"], res["band"]
return None, res["band"]
def _seat_players(args: dict, ctx: dict) -> str:
players = args.get("players") or []
# Accept a plain list of names too, for convenience.
if isinstance(players, str):
players = [p.strip() for p in re.split(r"[,\n]", players) if p.strip()]
try:
if args.get("replace"): # a whole new table — wipe the roster first
poker.clear_roster()
n = poker.seat_players(players)
except ValueError:
return "No live session — start one first, then I'll seat the table."
roster = poker.session_roster()
names = ", ".join(r["name"] for r in roster) or ""
return f"Seated {n}. Table now: {names}"
def _clear_table(args: dict, ctx: dict) -> str:
n = poker.clear_roster()
return f"Table cleared — roster's empty ({n} removed). Tell me who's at the new one."
def _unseat_player(args: dict, ctx: dict) -> str:
ok = poker.unseat_player(name=args.get("name"), descriptor=args.get("descriptor"))
who = args.get("name") or args.get("descriptor") or "player"
return f"{who} is off the table." if ok else f"Couldn't find {who} on the roster."
def _name_villain(args: dict, ctx: dict) -> str:
ref = (args.get("descriptor") or "").strip()
name = (args.get("name") or "").strip()
if not ref or not name:
return "Need both the description of the player and the name to attach."
pid, band = _resolve_villain_ref(ref)
if pid is None:
return (f"Couldn't confidently find “{ref}” to name — too vague or no match. "
"Add a read with the descriptor first, or be more specific.")
poker.name_villain(pid, name)
return f"Got it — “{ref}” is {name} now; their history carries over."
def _link_villains(args: dict, ctx: dict) -> str:
a = (args.get("player_a") or "").strip()
b = (args.get("player_b") or "").strip()
same = bool(args.get("same"))
if not a or not b:
return "Need two players to link (by name or description)."
ida, _ = _resolve_villain_ref(a)
idb, _ = _resolve_villain_ref(b)
if ida is None or idb is None:
return ("Couldn't confidently pin down both players, so I didn't merge anything — "
"safer to leave it. You can sort it on the Players page.")
if ida == idb:
return "Those resolve to the same profile already — nothing to do."
if same:
poker.merge_players(ida, idb)
return "Merged — same guy. Their histories are one file now."
poker.mark_distinct(ida, idb, note=args.get("note"))
return "Noted they're different people — I won't suggest merging them again."
def _end_session(args: dict, ctx: dict) -> str: def _end_session(args: dict, ctx: dict) -> str:
s = poker.end_session(cash_out=float(args.get("cash_out") or 0), mood=args.get("mood")) s = poker.end_session(cash_out=float(args.get("cash_out") or 0), mood=args.get("mood"))
hourly = f", {s['net'] / s['hours']:+.0f}/hr" if s.get("hours") else "" hourly = f", {s['net'] / s['hours']:+.0f}/hr" if s.get("hours") else ""
@@ -370,8 +452,16 @@ def _record_hand(args: dict, ctx: dict) -> str:
if not out["id"]: if not out["id"]:
return "I couldn't parse that hand — give it to me again with a little more detail?" return "I couldn't parse that hand — give it to me again with a little more detail?"
p = out["parsed"] p = out["parsed"]
hero_in = p.get("hero_involved") is not False and bool(p.get("hero_pos"))
logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos"),
hero_involved=hero_in)
if not hero_in:
# A hand Brian watched between other players — not his.
who = ", ".join(pl.get("name") or pl.get("pos") or "?"
for pl in (p.get("players") or [])[:3]) or "the table"
return (f"Logged hand #{out['id']} — an observed hand ({who}), not yours. "
f"View it at /hand/{out['id']}")
cards = " ".join(p.get("hero_cards") or []) cards = " ".join(p.get("hero_cards") or [])
logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos"))
return (f"Hand #{out['id']} reconstructed — {p.get('hero_pos') or '?'} " return (f"Hand #{out['id']} reconstructed — {p.get('hero_pos') or '?'} "
f"{cards}. View/replay it at /hand/{out['id']}") f"{cards}. View/replay it at /hand/{out['id']}")
@@ -519,8 +609,11 @@ TOOLS.update({
"log_stack", "log_stack",
"Record Brian's CURRENT total chip stack in the live session. Call whenever " "Record Brian's CURRENT total chip stack in the live session. Call whenever "
"he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). " "he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). "
"Tracks his stack over time and his live net while he's still sitting.", "Tracks his stack over time and his live net while he's still sitting. Pass "
{"amount": {**_N, "description": "Current total chip stack, in dollars"}}, "`note` with the WHY when he gives it ('card dead', 'doubled up vs the LAG') — "
"it becomes the line in his session timeline.",
{"amount": {**_N, "description": "Current total chip stack, in dollars"},
"note": {**_S, "description": "Optional context for the change, e.g. 'card dead', 'doubled up'"}},
["amount"])}, ["amount"])},
"scar_note": {"handler": _scar_note, "spec": _f( "scar_note": {"handler": _scar_note, "spec": _f(
"scar_note", "scar_note",
@@ -573,9 +666,13 @@ TOOLS.update({
[])}, [])},
"add_read": {"handler": _add_read, "spec": _f( "add_read": {"handler": _add_read, "spec": _f(
"add_read", "add_read",
"Log a read on an opponent. If you give a name, it's saved to the persistent villain file.", "Log a read on an opponent. Give a `name` if known; if not, give a `descriptor` "
"(a distinctive physical description like 'neck tattoo, backwards cap') and the read "
"attaches to that nameless player — reused automatically next time you describe him.",
{"note": {**_S, "description": "The observation / what they showed down"}, {"note": {**_S, "description": "The observation / what they showed down"},
"name": {**_S, "description": "Player name/handle if known (creates/updates their dossier)"}, "name": {**_S, "description": "Player name/handle if known (creates/updates their dossier)"},
"descriptor": {**_S, "description": "Physical description when there's no name, e.g. "
"'neck tattoo, heavyset'. Prefer distinctive features over generic ones."},
"seat": {**_S, "description": "Seat or relative position"}, "seat": {**_S, "description": "Seat or relative position"},
"tendencies": {**_S, "description": "Standing read on how they play"}, "tendencies": {**_S, "description": "Standing read on how they play"},
"adjustment": {**_S, "description": "How Brian should exploit them"}, "adjustment": {**_S, "description": "How Brian should exploit them"},
@@ -583,6 +680,51 @@ TOOLS.update({
"category": {**_S, "description": "feeder | risky | reg | unknown"}, "category": {**_S, "description": "feeder | risky | reg | unknown"},
"venue": {**_S, "description": "Where they play"}}, "venue": {**_S, "description": "Where they play"}},
["note"])}, ["note"])},
"seat_players": {"handler": _seat_players, "spec": _f(
"seat_players",
"Register who's at the table this session — the roster Brian reads off the Bravo "
"screen (handles like TAG, JD). Call this when he names the table (usually at the "
"start) or when a new player sits. Each player is a real handle in `name`, or a "
"`descriptor` if he only describes them. These become the roster his reads/TAGs "
"attach to by name.",
{"players": {"type": "array", "description": "Players to seat",
"items": {"type": "object", "properties": {
"name": {**_S, "description": "Handle as it appears on Bravo, e.g. 'TAG'"},
"descriptor": {**_S, "description": "Physical description if no name"},
"seat": {**_S, "description": "Seat number/label if known"},
"category": {**_S, "description": "feeder | risky | reg | unknown"}}}},
"replace": {"type": "boolean", "description": "true = a brand-new table: clear the "
"current roster first, then seat these (use when he changes tables)"}},
["players"])},
"unseat_player": {"handler": _unseat_player, "spec": _f(
"unseat_player",
"Remove a player from the table roster when they bust or leave. Keeps their history.",
{"name": {**_S, "description": "Their handle"},
"descriptor": {**_S, "description": "Or a description if unnamed"}},
[])},
"clear_table": {"handler": _clear_table, "spec": _f(
"clear_table",
"Empty the whole table roster at once — call this when Brian changes tables or says "
"to clear the table. The session, stack, and logged reads stay; only who's currently "
"seated resets. Then he'll tell you the new table.",
{}, [])},
"name_villain": {"handler": _name_villain, "spec": _f(
"name_villain",
"Attach a real name to a player you'd only known by description (e.g. you caught it "
"off the Bravo screen). Their whole history carries over to the name.",
{"descriptor": {**_S, "description": "How you'd been referring to him, e.g. 'neck tattoo guy'"},
"name": {**_S, "description": "His real name/handle"}},
["descriptor", "name"])},
"link_villains": {"handler": _link_villains, "spec": _f(
"link_villains",
"Resolve a same-person question when Brian confirms it. same=true MERGES two profiles "
"into one (their histories join); same=false records they're DIFFERENT people so you "
"stop asking. Only call after he's confirmed — never merge on a guess.",
{"player_a": {**_S, "description": "First player, by name or description"},
"player_b": {**_S, "description": "Second player, by name or description"},
"same": {"type": "boolean", "description": "true = same person (merge); false = different"},
"note": {**_S, "description": "For different people: the tell that distinguishes them"}},
["player_a", "player_b", "same"])},
"end_session": {"handler": _end_session, "spec": _f( "end_session": {"handler": _end_session, "spec": _f(
"end_session", "Close the live session: record cashout, compute net + hours.", "end_session", "Close the live session: record cashout, compute net + hours.",
{"cash_out": {**_N, "description": "Final cashout amount"}, {"cash_out": {**_N, "description": "Final cashout amount"},
+77
View File
@@ -0,0 +1,77 @@
"""Full-fidelity conversation export: interleave what was *said* (chat exchanges)
with what Lyra *did* (tool calls) in chronological order.
The chat only ever lives in SQLite (`exchanges` + `tool_events`); this is the one
place that renders a whole session back out as a portable artifact Markdown for
reading / pasting into RTO or another model, JSON for machine reprocessing.
"""
from __future__ import annotations
import json
from lyra import clock, memory
# How roles/actions are labeled in the Markdown transcript.
_SPEAKER = {"user": "Brian", "assistant": "Lyra"}
def _merged(session_id: str) -> list[dict]:
"""Speech + actions for a session, merged oldest-first by wall-clock time."""
events: list[dict] = []
for e in memory.history(session_id):
events.append({"type": "message", "role": e.role, "content": e.content,
"ts": e.created_at})
for t in memory.tool_events(session_id):
events.append({"type": "tool", "tool": t["tool"], "args": t["args"],
"result": t["result"], "ts": t["created_at"]})
# created_at is an ISO string; lexicographic sort == chronological sort.
events.sort(key=lambda ev: ev["ts"])
return events
def _fmt_args(args) -> str:
"""Compact one-line rendering of a tool call's arguments."""
if isinstance(args, dict):
return ", ".join(f"{k}={json.dumps(v, default=str)}" for k, v in args.items())
return "" if args is None else str(args)
def as_markdown(session_id: str, name: str | None = None) -> str:
events = _merged(session_id)
title = name or session_id
lines = [f"# Conversation — {title}",
f"_Exported {clock.stamp()} · session `{session_id}` · "
f"{len(events)} events_", ""]
for ev in events:
stamp = clock.short(ev["ts"])
if ev["type"] == "message":
who = _SPEAKER.get(ev["role"], ev["role"].capitalize())
lines.append(f"**{who}** · {stamp}")
lines.append((ev["content"] or "").rstrip())
lines.append("")
else:
result = (ev["result"] or "").strip().replace("\n", " ")
if len(result) > 200:
result = result[:197] + ""
lines.append(f" ⚙ `{ev['tool']}({_fmt_args(ev['args'])})` → {result}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def as_json(session_id: str, name: str | None = None) -> dict:
return {
"session_id": session_id,
"name": name,
"exported_at": clock.stamp(),
"events": _merged(session_id),
}
def build(session_id: str, fmt: str = "md", name: str | None = None):
"""Return (content_str, media_type, filename) for the requested format."""
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)[:60]
if fmt == "json":
body = json.dumps(as_json(session_id, name), indent=2, ensure_ascii=False)
return body, "application/json", f"lyra_{safe}.json"
body = as_markdown(session_id, name)
return body, "text/markdown; charset=utf-8", f"lyra_{safe}.md"
+151 -1
View File
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts, transcript
from lyra.llm import Backend from lyra.llm import Backend
@@ -50,6 +50,16 @@ def _last_user_message(messages: list[dict]) -> str:
def create_app() -> FastAPI: def create_app() -> FastAPI:
app = FastAPI(title="Lyra Web") app = FastAPI(title="Lyra Web")
@app.middleware("http")
async def _no_stale_shell(request: Request, call_next):
"""Always revalidate HTML/JS so a PWA can't serve a stale app shell after a
deploy (iOS applies heuristic caching when no cache header is set)."""
resp = await call_next(request)
ct = resp.headers.get("content-type", "")
if "text/html" in ct or "javascript" in ct:
resp.headers["Cache-Control"] = "no-cache, must-revalidate"
return resp
@app.get("/_health") @app.get("/_health")
async def health() -> dict: async def health() -> dict:
return {"ok": True} return {"ok": True}
@@ -62,6 +72,15 @@ def create_app() -> FastAPI:
async def get_session(session_id: str) -> list[dict]: async def get_session(session_id: str) -> list[dict]:
return [{"role": ex.role, "content": ex.content} for ex in memory.history(session_id)] return [{"role": ex.role, "content": ex.content} for ex in memory.history(session_id)]
@app.get("/sessions/{session_id}/export")
async def export_session(session_id: str, format: str = "md") -> Response:
"""Full transcript — chat + interleaved tool calls — as Markdown or JSON."""
name = next((s["name"] for s in memory.list_sessions() if s["id"] == session_id), None)
body, media_type, filename = await asyncio.to_thread(
transcript.build, session_id, format, name)
return Response(content=body, media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'})
@app.post("/sessions/{session_id}") @app.post("/sessions/{session_id}")
async def save_session(session_id: str, request: Request) -> dict: async def save_session(session_id: str, request: Request) -> dict:
# Messages are already persisted by chat.respond; just ensure the row exists. # Messages are already persisted by chat.respond; just ensure the row exists.
@@ -121,6 +140,109 @@ 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.post("/hand/{hand_id}/disown")
async def hand_disown(hand_id: int) -> dict:
"""Reclassify a hand as observed (not Brian's) — fix a misattributed one."""
h = await asyncio.to_thread(poker.disown_hand, hand_id)
logbus.log("info", "hand disowned", id=hand_id)
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). Setting `name` on a
nameless (descriptor) villain promotes it to a real handle (named=1)."""
body = await request.json()
def _apply():
if body.get("name"):
poker.name_villain(player_id, body["name"])
rest = {k: v for k, v in body.items() if k != "name"}
return poker.update_player(player_id, **rest) # flat row (name included)
p = await asyncio.to_thread(_apply)
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."""
@@ -339,6 +461,34 @@ def create_app() -> FastAPI:
async def hands_data(limit: int = 60) -> dict: async def hands_data(limit: int = 60) -> dict:
return {"hands": poker.list_recent_hands(limit=limit)} return {"hands": poker.list_recent_hands(limit=limit)}
@app.get("/players")
async def players_page() -> FileResponse:
"""Villain file browser + the identity-resolution review queue."""
return FileResponse(str(_STATIC / "players.html"))
@app.get("/players/data")
async def players_data() -> dict:
return {"players": poker.players_overview(),
"queue": poker.list_identity_queue()}
@app.get("/player/{player_id}/data")
async def player_data(player_id: int) -> dict:
return poker.villain_recall(player_id) or {}
@app.post("/identity/{task_id}/resolve")
async def identity_resolve(task_id: int, request: Request) -> dict:
body = await request.json()
action = body.get("action") or "dismiss"
kw = {k: v for k, v in body.items() if k != "action"}
ok = await asyncio.to_thread(poker.resolve_identity_task, task_id, action, **kw)
logbus.log("info", "identity task resolved", id=task_id, action=action)
return {"ok": ok}
@app.post("/players/scan")
async def players_scan() -> dict:
filed = await asyncio.to_thread(poker.scan_merge_candidates)
return {"ok": True, "filed": filed}
@app.get("/recap/{session_id}") @app.get("/recap/{session_id}")
async def recap_page() -> FileResponse: async def recap_page() -> FileResponse:
return FileResponse(str(_STATIC / "recap.html")) return FileResponse(str(_STATIC / "recap.html"))
+46
View File
@@ -282,8 +282,54 @@
const h = await r.json(); const h = await r.json();
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; } if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
render(h); render(h);
renderEditor(h);
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; } }catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
} }
function renderEditor(h){
const wrap = document.createElement('div');
wrap.style.cssText = 'max-width:520px;margin:18px auto 0;border-top:1px solid #241a10;padding-top:12px;';
const tags = ['','well_played','leak','cooler','confidence','notable'];
wrap.innerHTML = `
<details style="font-size:.9rem;">
<summary style="cursor:pointer;color:var(--accent,#ff7a00);">✎ Edit this hand</summary>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:10px;">
<label>Position <input id="e_pos" value="${esc(h.position||'')}" placeholder="e.g. CO (blank if not yours)"></label>
<label>Your cards <input id="e_hole" value="${esc(h.hole_cards||'')}" placeholder="e.g. As Ks (blank if not yours)"></label>
<label>Board <input id="e_board" value="${esc(h.board||'')}" placeholder="e.g. Tc 8s Js 6d"></label>
<label>Your net <input id="e_res" value="${h.result!=null?esc(h.result):''}" placeholder="+ / chips (blank if not yours)"></label>
<label>Tag <select id="e_tag">${tags.map(t=>`<option value="${t}" ${h.tag===t?'selected':''}>${t||'—'}</option>`).join('')}</select></label>
<label>Lesson <input id="e_lesson" value="${esc(h.lesson||'')}"></label>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px;">
<button onclick="saveHand(${h.id})" style="border-color:var(--accent,#ff7a00);color:var(--accent,#ff7a00);">Save</button>
<button onclick="disown(${h.id})" title="It was someone else's hand — clear it from you">Not my hand</button>
<button onclick="delHand(${h.id})" style="margin-left:auto;color:#ff6b6b;">Delete</button>
</div>
</div>
</details>`;
wrap.querySelectorAll('input,select').forEach(el=>{el.style.cssText='font:inherit;font-size:.86rem;padding:5px 8px;border-radius:6px;border:1px solid #241a10;background:#0b0b0b;color:#e8e8e8;margin-left:8px;';});
wrap.querySelectorAll('label').forEach(el=>{el.style.cssText='display:flex;justify-content:space-between;align-items:center;color:#8a8a8a;';});
wrap.querySelectorAll('button').forEach(el=>{el.style.cssText+=';font:inherit;font-size:.84rem;padding:6px 12px;border-radius:7px;border:1px solid #241a10;background:#141414;color:#e8e8e8;cursor:pointer;';});
document.getElementById('root').appendChild(wrap);
}
const val = id => document.getElementById(id).value.trim();
async function saveHand(id){
const body = {position:val('e_pos'), hole_cards:val('e_hole'), board:val('e_board'),
tag:val('e_tag'), lesson:val('e_lesson')};
const res = val('e_res'); if(res!=='') body.result = Number(res);
await fetch(`/hand/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
load();
}
async function disown(id){
if(!confirm("Mark this as someone else's hand? It'll be cleared from your stats.")) return;
await fetch(`/hand/${id}/disown`,{method:'POST'});
load();
}
async function delHand(id){
if(!confirm('Delete this hand for good?')) return;
await fetch(`/hand/${id}`,{method:'DELETE'});
location.href='/hands';
}
load(); load();
</script> </script>
<script src="/nav.js"></script> <script src="/nav.js"></script>
+88 -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" />
@@ -88,6 +88,7 @@
<select id="sessions"></select> <select id="sessions"></select>
<button id="newSessionBtn"> New</button> <button id="newSessionBtn"> New</button>
<button id="renameSessionBtn">✏️ Rename</button> <button id="renameSessionBtn">✏️ Rename</button>
<button id="exportSessionBtn" title="Download full transcript (chat + tool calls)">⬇ Export</button>
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button> <button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
</div> </div>
@@ -124,6 +125,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 +216,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);
} }
@@ -915,6 +988,19 @@
addMessage("system", `Session renamed to: ${newName}`); addMessage("system", `Session renamed to: ${newName}`);
}); });
document.getElementById("exportSessionBtn").addEventListener("click", () => {
if (!currentSession) { addMessage("system", "No session to export."); return; }
const fmt = window.confirm("Export as Markdown? (Cancel = JSON)") ? "md" : "json";
// Hitting the download endpoint navigates a hidden anchor so the browser
// saves the file (chat + interleaved tool calls) instead of rendering it.
const a = document.createElement("a");
a.href = `${RELAY_BASE}/sessions/${encodeURIComponent(currentSession)}/export?format=${fmt}`;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
});
// Settings Modal // Settings Modal
const settingsModal = document.getElementById("settingsModal"); const settingsModal = document.getElementById("settingsModal");
const settingsBtn = document.getElementById("settingsBtn"); const settingsBtn = document.getElementById("settingsBtn");
+1
View File
@@ -8,6 +8,7 @@
{ href: "/session", icon: "♠", label: "Session" }, { href: "/session", icon: "♠", label: "Session" },
{ href: "/history", icon: "📚", label: "History" }, { href: "/history", icon: "📚", label: "History" },
{ href: "/hands", icon: "🃏", label: "Hands" }, { href: "/hands", icon: "🃏", label: "Hands" },
{ href: "/players", icon: "👤", label: "Players" },
{ href: "/self", icon: "🧠", label: "Mind" }, { href: "/self", icon: "🧠", label: "Mind" },
{ href: "/thoughts", icon: "💭", label: "Thoughts" }, { href: "/thoughts", icon: "💭", label: "Thoughts" },
{ href: "/journal", icon: "📔", label: "Journal" }, { href: "/journal", icon: "📔", label: "Journal" },
+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#070707" />
<title>Lyra — Players</title>
<style>
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#ff7a00;}
*{box-sizing:border-box;}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
padding:env(safe-area-inset-top) 14px 0;}
.topbar{display:flex;align-items:center;gap:10px;padding:13px 0;}
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
main{max-width:640px;margin:0 auto;padding:12px 12px 44px;}
h2.sec{font-size:.74rem;text-transform:uppercase;letter-spacing:.6px;color:var(--fade);margin:20px 2px 8px;}
.queue{background:#160d05;border:1px solid var(--accent);border-radius:10px;padding:11px 12px;margin-bottom:9px;}
.queue .k{font-size:.62rem;text-transform:uppercase;letter-spacing:.5px;color:var(--accent);}
.queue .q-body{font-size:.9rem;margin:5px 0 9px;}
.queue .who{font-weight:600;}
.btns{display:flex;flex-wrap:wrap;gap:7px;}
button{font:inherit;font-size:.82rem;padding:6px 11px;border-radius:7px;border:1px solid var(--border);
background:var(--bg-line);color:var(--text);cursor:pointer;}
button.pri{border-color:var(--accent);color:var(--accent);}
button:active{background:#241400;}
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
.card .row{display:flex;align-items:center;gap:9px;cursor:pointer;}
.nm{font-size:.96rem;font-weight:600;}
.nm.desc{font-weight:500;font-style:italic;color:#e8d3bf;}
.meta{font-size:.74rem;color:var(--fade);}
.pill{font-size:.6rem;text-transform:uppercase;letter-spacing:.4px;border:1px solid var(--border);
border-radius:20px;padding:1px 7px;color:var(--fade);}
.pill.desc{border-color:#5a3c1e;color:#d0a56e;}
.spacer{margin-left:auto;}
.detail{margin-top:9px;padding-top:9px;border-top:1px solid var(--bg-line);font-size:.86rem;display:none;}
.detail.open{display:block;}
.detail .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.4px;margin:8px 0 3px;}
.detail ul{margin:3px 0;padding-left:18px;} .detail li{margin:2px 0;}
.detail a{color:var(--accent);text-decoration:none;}
.edit{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px;}
.edit input,.edit select{font:inherit;font-size:.82rem;padding:5px 8px;border-radius:6px;
border:1px solid var(--border);background:var(--bg);color:var(--text);}
.empty{color:var(--fade);text-align:center;padding:34px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>👤 Players</h1>
<a class="back" href="/">← Chat</a>
<span class="count" id="count"></span>
</div>
</header>
<main id="root"><p class="empty">Loading…</p></main>
<script>
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
let DATA={players:[],queue:[]};
async function load(){
try{ DATA=await (await fetch('/players/data',{cache:'no-store'})).json(); }
catch(e){ document.getElementById('root').innerHTML='<p class="empty">Couldn\'t load players.</p>'; return; }
render();
}
function render(){
const {players,queue}=DATA;
const named=players.filter(p=>p.named), nameless=players.filter(p=>!p.named);
document.getElementById('count').textContent=`${players.length} player${players.length===1?'':'s'}`;
let html='';
if(queue.length){
html+=`<h2 class="sec">⚠ Needs your call — ${queue.length}</h2>`;
html+=queue.map(qCard).join('');
}
html+=`<h2 class="sec">Named — ${named.length} <button class="pri" style="float:right;padding:3px 9px" onclick="scan()">Scan for dupes</button></h2>`;
html+= named.length ? named.map(pCard).join('') : '<p class="empty">No named players yet.</p>';
html+=`<h2 class="sec">By description — ${nameless.length}</h2>`;
html+= nameless.length ? nameless.map(pCard).join('') : '<p class="empty">No nameless villains yet — they show up here as you describe players at the table.</p>';
document.getElementById('root').innerHTML=html;
}
function qCard(q){
const ps=q.players||[];
if(q.kind==='merge_candidate' && ps.length===2){
const a=ps[0], b=ps[1];
return `<div class="queue"><div class="k">Possible merge${q.confidence?` · ${Math.round(q.confidence*100)}%`:''}</div>
<div class="q-body">Same person? <span class="who">${label(a)}</span> &nbsp;vs&nbsp; <span class="who">${label(b)}</span></div>
<div class="btns">
<button class="pri" onclick="resolveTask(${q.id},'merge',{keep_id:${keepId(a,b)},dup_id:${dupId(a,b)}})">✓ Same — merge</button>
<button onclick="resolveTask(${q.id},'distinct',{a_id:${a.id},b_id:${b.id}})">✕ Different</button>
<button onclick="resolveTask(${q.id},'dismiss',{})">Dismiss</button>
</div></div>`;
}
const who=ps[0]?label(ps[0]):'?';
return `<div class="queue"><div class="k">Needs clarification</div>
<div class="q-body">You referred to <span class="who">“${esc(q.descriptor||'')}”</span>${ps[0]?` — is that ${who}?`:''}</div>
<div class="btns"><button onclick="resolveTask(${q.id},'dismiss',{})">Got it</button></div></div>`;
}
const label=p=>`${esc(p.name)}${p.named?'':' <span class="pill desc">desc</span>'}${p.venue?` · ${esc(p.venue)}`:''}${p.obs?` · ${p.obs}h`:''}`;
const keepId=(a,b)=>a.named?a.id:(b.named?b.id:a.id);
const dupId=(a,b)=>a.named?b.id:(b.named?a.id:b.id);
function pCard(p){
const pills=[p.named?'':'<span class="pill desc">desc</span>',p.category?`<span class="pill">${esc(p.category)}</span>`:''].join('');
const meta=[p.venue,p.obs?`${p.obs} hands`:'',p.reads?`${p.reads} reads`:''].filter(Boolean).join(' · ');
return `<div class="card" id="p${p.id}">
<div class="row" onclick="toggle(${p.id})">
<span class="nm ${p.named?'':'desc'}">${p.named?esc(p.name):'“'+esc(p.name)+'”'}</span>
${pills}<span class="spacer"></span><span class="meta">${esc(meta)}</span>
</div>
<div class="detail" id="d${p.id}"></div></div>`;
}
async function toggle(id){
const el=document.getElementById('d'+id);
if(el.classList.contains('open')){el.classList.remove('open');return;}
el.classList.add('open'); el.innerHTML='<span class="meta">Loading…</span>';
const r=await (await fetch(`/player/${id}/data`,{cache:'no-store'})).json();
el.innerHTML=detailHtml(id,r);
}
function detailHtml(id,r){
const p=r.player||{}; let h='';
const seen=[r.times_seen?`seen ${r.times_seen}×`:'', r.last_seen?`last ${String(r.last_seen).slice(0,10)}`:''].filter(Boolean).join(' · ');
if(seen) h+=`<div class="meta">${esc(seen)}</div>`;
if(r.stats) h+=`<div class="lbl">Stats</div><div>VPIP ${r.stats.vpip_pct} · PFR ${r.stats.pfr_pct} · WTSD ${r.stats.wtsd_pct} <span class="meta">(${r.stats.hands} hands)</span></div>`;
if(r.descriptors) h+=`<div class="lbl">Descriptors</div><div>${esc(r.descriptors)}</div>`;
if(p.tendencies) h+=`<div class="lbl">Tendencies</div><div>${esc(p.tendencies)}</div>`;
if(p.adjustment) h+=`<div class="lbl">Exploit</div><div>${esc(p.adjustment)}</div>`;
if((r.reads||[]).length){h+='<div class="lbl">Reads</div><ul>'+r.reads.slice(0,8).map(x=>`<li>${esc(x)}</li>`).join('')+'</ul>';}
if((r.notable_hands||[]).length){h+='<div class="lbl">Notable hands</div><ul>'+r.notable_hands.map(x=>`<li><a href="/hand/${x.hand_id}">hand #${x.hand_id}</a>${x.cards?' — '+esc(x.cards):''}${x.summary?' <span class="meta">'+esc(x.summary)+'</span>':''}</li>`).join('')+'</ul>';}
h+=`<div class="edit">
${p.named?'':`<input id="nm${id}" placeholder="give a name…" size="12"><button onclick="rename(${id})">Name</button>`}
<select id="cat${id}" onchange="setCat(${id})">
${['','feeder','risky','reg','unknown'].map(c=>`<option value="${c}" ${p.category===c?'selected':''}>${c||'category…'}</option>`).join('')}
</select></div>`;
return h;
}
async function rename(id){
const v=document.getElementById('nm'+id).value.trim(); if(!v)return;
await fetch(`/player/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:v})});
load();
}
async function setCat(id){
const v=document.getElementById('cat'+id).value;
await fetch(`/player/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({category:v})});
}
async function resolveTask(id,action,kw){
await fetch(`/identity/${id}/resolve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...kw})});
load();
}
async function scan(){
const r=await (await fetch('/players/scan',{method:'POST'})).json();
load();
}
load();
</script>
<script src="/nav.js"></script>
</body>
</html>
+79 -1
View File
@@ -104,6 +104,26 @@
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); } .big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
.big-empty .ico { font-size: 2.4rem; } .big-empty .ico { font-size: 2.4rem; }
.big-empty a { color: var(--accent); text-decoration: none; } .big-empty a { color: var(--accent); text-decoration: none; }
/* running timeline */
ul.tl { list-style: none; margin: 0; padding: 0; }
ul.tl li { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--bg-line); align-items: baseline; font-size: .92rem; line-height: 1.4; }
ul.tl li:last-child { border-bottom: none; }
.tl-time { color: var(--fade); font-variant-numeric: tabular-nums; font-size: .78rem; min-width: 60px; flex: none; }
.tl-body { flex: 1; }
.tl-amt { margin-left: 6px; font-variant-numeric: tabular-nums; }
li.start .tl-body { color: var(--accent); font-weight: 600; }
li.scar .tl-body, li.confidence .tl-body { font-style: italic; }
.tl-body a.hand { color: var(--accent); text-decoration: none; white-space: nowrap; }
/* quick-capture (no LLM) + inline correction controls */
.quick { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.quick input { width: 100px; background: var(--bg-line); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; color: var(--text); }
.quick input:focus { outline: none; border-color: var(--accent); }
.quick button { background: var(--accent); color: #0a0a0a; border: 1px solid var(--accent);
border-radius: 8px; padding: 8px 12px; cursor: pointer; font-weight: 600; }
button.mini { background: none; border: none; color: var(--fade); cursor: pointer;
font-size: .9rem; padding: 0 6px; }
button.mini:active { color: var(--accent); }
</style> </style>
</head> </head>
<body> <body>
@@ -207,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) {
@@ -219,7 +263,9 @@
} }
curSession = s; curSession = s;
const stack = data.stack || {}; const stack = data.stack || {};
const timeline = data.timeline || [];
const hands = data.hands || []; const hands = data.hands || [];
const roster = data.roster || [];
const villains = data.villains || []; const villains = data.villains || [];
const notes = data.notes || []; const notes = data.notes || [];
const stats = data.stats || {}; const stats = data.stats || {};
@@ -274,7 +320,25 @@
<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 class="card">
<p class="label">📜 Timeline</p>
${timeline.length ? `<ul class="tl">${timeline.map(e => `
<li class="${esc(e.kind)}">
<span class="tl-time">${esc(e.time)}</span>
<span class="tl-body">${esc(e.text)}${e.amount != null ? ` <b class="tl-amt">${money(e.amount)}</b>` : ''}${e.result != null ? ` <span class="res ${e.result>=0?'up':'down'}">${signed(e.result)}</span>` : ''}${e.hand_id ? ` <a class="hand" href="/hand/${e.hand_id}">hand </a>` : ''}</span>
</li>`).join('')}</ul>`
: '<p class="empty">Nothing yet tonight — the running log fills in as you play.</p>'}
</div> </div>
<div class="card"> <div class="card">
@@ -306,11 +370,25 @@
: '<p class="empty">No scars logged — mistakes to study land here.</p>'} : '<p class="empty">No scars logged — mistakes to study land here.</p>'}
</div> </div>
<div class="card">
<p class="label">🪑 Table (${roster.length})</p>
${roster.length ? `<ul class="rows">${roster.map(v => `
<li class="villain">
${v.seat ? `<span class="cat">${esc(v.seat)}</span> ` : ''}<b>${esc(v.name)}</b>
${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
${v.reads ? `<span class="cat">· ${v.reads} read${v.reads===1?'':'s'}</span>` : ''}
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'').replace(/'/g,"\\'")}')"></button>
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
</li>`).join('')}</ul>`
: '<p class="empty">No roster yet — tell Lyra who is at the table.</p>'}
</div>
<div class="card"> <div class="card">
<p class="label">Villains seen</p> <p class="label">Villains seen</p>
${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;
}
+29 -1
View File
@@ -20,7 +20,7 @@ def lyra(tmp_path, monkeypatch):
# reflect() expects JSON back; everything else just stores the text. # reflect() expects JSON back; everything else just stores the text.
monkeypatch.setattr( monkeypatch.setattr(
llm, "complete", llm, "complete",
lambda messages, backend=None, model=None: lambda messages, backend=None, model=None, **_:
'{"mood":"focused","valence":0.7,"new_reflections":["I got some thinking done."]}', '{"mood":"focused","valence":0.7,"new_reflections":["I got some thinking done."]}',
) )
@@ -77,3 +77,31 @@ def test_dream_cycle_consolidates_and_persists(lyra):
state2 = dream.dream_cycle(force=False) state2 = dream.dream_cycle(force=False)
assert state2["dream"]["cycle_count"] == 2 assert state2["dream"]["cycle_count"] == 2
assert state2["drives"]["continuity"] == 0.0 assert state2["drives"]["continuity"] == 0.0
def test_dream_cycle_stops_when_over_budget(lyra, monkeypatch):
memory = lyra
from lyra import dream, notify
for k in range(7):
_seed(memory, f"s{k}", 4)
# Go over budget right after the first heavy stage: first check passes
# (summarize runs), every check after trips.
checks = {"n": 0}
def fake_over(deadline):
checks["n"] += 1
return checks["n"] > 1
monkeypatch.setattr(dream, "_over_budget", fake_over)
pings: list = []
monkeypatch.setattr(notify, "push",
lambda title, message, **k: pings.append((title, message)) or True)
state = dream.dream_cycle(force=True)
acts = state["dream"]["last_actions"]
assert any("stopped early" in a for a in acts) # bailed
assert not any("reflected" in a for a in acts) # later stage skipped
assert pings, "expected an over-budget ntfy push"
+23
View File
@@ -45,6 +45,29 @@ def test_stamps_version(poker):
assert out["schema_version"] == poker.HAND_SCHEMA_VERSION assert out["schema_version"] == poker.HAND_SCHEMA_VERSION
def test_observed_hand_never_attributed_to_hero(poker):
# Brian narrated a hand between two other players — hero_involved=false.
out = poker.normalize_structured({
"hero_involved": False,
"hero_pos": "CO", "hero_cards": ["Kx", "Kx"], # model slipped these in
"players": [{"pos": "CO", "cards": ["Kx", "Kx"]}, {"pos": "BB", "cards": ["Ax", "Ax"]}],
"result": {"pot": 600, "hero_net": 300},
})
assert out["hero_pos"] is None # not pinned to Brian
assert out["hero_cards"] == []
assert out["result"]["hero_net"] is None # a pot he wasn't in
assert not any(pl.get("hero") for pl in out["players"]) # nobody flagged hero
def test_hero_hand_still_attributed(poker):
out = poker.normalize_structured({
"hero_involved": True, "hero_pos": "BTN", "hero_cards": ["As", "Ks"],
"players": [{"pos": "BTN"}]})
assert out["hero_pos"] == "BTN"
hero = next(pl for pl in out["players"] if pl.get("pos") == "BTN")
assert hero.get("hero") and hero["cards"] == ["As", "Ks"]
def test_card_normalization(poker): def test_card_normalization(poker):
out = poker.normalize_structured(_full_hand()) out = poker.normalize_structured(_full_hand())
assert out["hero_cards"] == ["Ah", "Kh"] # lowercased input -> canonical assert out["hero_cards"] == ["Ah", "Kh"] # lowercased input -> canonical
+66
View File
@@ -0,0 +1,66 @@
"""llm.complete: `max_tokens` and `timeout` are threaded into the backend call.
The OpenAI client is faked so nothing hits a network. We assert the generation
cap reaches the create() call and the fast-fail timeout reaches the client (with
max_retries=0 so summary.py owns the retry policy, not the SDK).
"""
from __future__ import annotations
import types
import pytest
from lyra import llm
@pytest.fixture
def fake_openai(monkeypatch):
recorded: dict = {}
class FakeCompletions:
def create(self, **kwargs):
recorded["create"] = kwargs
msg = types.SimpleNamespace(content="ok")
return types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)])
class FakeClient:
def __init__(self, **kwargs):
recorded["client"] = kwargs
self.chat = types.SimpleNamespace(completions=FakeCompletions())
monkeypatch.setattr(llm, "OpenAI", FakeClient)
monkeypatch.setattr(llm, "load", lambda: types.SimpleNamespace(
mi50_base_url="http://mi50/v1", mi50_model="local-gpu",
cloud_model="gpt-4o-mini", openai_api_key="sk-test", local_model="l",
))
return recorded
def test_mi50_threads_max_tokens_and_timeout(fake_openai):
out = llm.complete([{"role": "user", "content": "hi"}],
backend="mi50", max_tokens=768, timeout=150)
assert out == "ok"
assert fake_openai["create"]["max_tokens"] == 768
assert fake_openai["client"]["timeout"] == 150
assert fake_openai["client"]["max_retries"] == 0
def test_cloud_threads_max_tokens_and_timeout(fake_openai):
llm.complete([{"role": "user", "content": "hi"}],
backend="cloud", max_tokens=768, timeout=150)
assert fake_openai["create"]["max_tokens"] == 768
assert fake_openai["client"]["timeout"] == 150
assert fake_openai["client"]["max_retries"] == 0
def test_default_bounds_calls_even_without_explicit_timeout(fake_openai):
# No cap / timeout passed -> still bounded: 300s default + no SDK retries, so
# no call can silently inherit the SDK's 600s x2 (~30 min). No length cap
# unless asked, though.
llm.complete([{"role": "user", "content": "hi"}], backend="mi50")
assert "max_tokens" not in fake_openai["create"]
assert fake_openai["client"]["timeout"] == 300
assert fake_openai["client"]["max_retries"] == 0
+70
View File
@@ -0,0 +1,70 @@
"""Pattern desk: embedded scar recall + strategy gating in the scouting desk."""
from __future__ import annotations
import hashlib
import importlib
import numpy as np
import pytest
def _idx(w: str) -> int:
# Stable across processes (unlike hash()), so threshold tests aren't flaky.
return int.from_bytes(hashlib.md5(w.encode()).digest()[:4], "little") % 256
def _fake_embed(texts):
out = []
for t in texts:
v = np.zeros(256, dtype=np.float32)
for w in t.lower().split():
v[_idx(w)] += 1.0
out.append((v if v.any() else np.full(256, 1e-6, dtype=np.float32)).tolist())
return out
@pytest.fixture
def mods(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _fake_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
import lyra.scouting as scouting
importlib.reload(scouting)
return poker, scouting
def test_scar_recall_finds_similar_past_leak(mods):
poker, _ = mods
old = poker.start_session(stakes="1/3", buy_in=300)
poker.log_ritual("scar", "overvalued top pair and stacked off on a wet board",
classification="punt", session_id=old)
poker.end_session(200, session_id=old)
hits = poker.recall_similar_rituals("stacked off top pair wet board again")
assert hits and hits[0]["classification"] == "punt"
def test_recall_excludes_current_session(mods):
poker, _ = mods
sid = poker.start_session(stakes="1/3", buy_in=300)
poker.log_ritual("scar", "punted river bluff into the nut flush", session_id=sid)
assert poker.recall_similar_rituals("river bluff nut flush punt", exclude_session=sid) == []
def test_pattern_pass_only_fires_on_strategic_talk(mods):
poker, scouting = mods
old = poker.start_session(stakes="1/3", buy_in=300)
poker.log_ritual("scar", "punting river bluffs into missed draws again",
classification="punt", session_id=old)
poker.end_session(200, session_id=old)
poker.start_session(stakes="1/3", buy_in=300, venue="Meadows")
# A routine, non-strategic line pays no embed and surfaces nothing.
assert scouting.scout("stack is 350 now", venue="Meadows") is None
# A real strategy question in the same shape recalls the leak. (The test stub
# embeds by shared tokens; real embeddings match on meaning/paraphrase.)
note = scouting.scout(
"why do i keep punting river bluffs into missed draws", venue="Meadows")
assert note and "punting river bluffs" in note
+44
View File
@@ -18,6 +18,50 @@ def lyra(tmp_path, monkeypatch):
return poker return poker
def test_disown_hand_clears_hero_attribution(lyra):
poker = lyra
sid = poker.start_session(venue="Meadows", buy_in=300)
hid = poker.store_hand_history(
{"hero_involved": True, "hero_pos": "MP", "hero_cards": ["As", "3d"],
"players": [{"pos": "MP", "cards": ["As", "3d"]}],
"result": {"pot": 600, "hero_net": 304}}, session_id=sid, tag="notable")
h = poker.disown_hand(hid)
assert h["position"] is None and h["hole_cards"] is None and h["result"] is None
st = h["structured"]
if isinstance(st, str):
import json
st = json.loads(st)
assert st["hero_pos"] is None and not any(pl.get("hero") for pl in st["players"])
def test_hud_notes_scoped_to_session_by_tag(lyra):
poker = lyra
from lyra import memory
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
# A note tagged to THIS session shows on its HUD...
memory.add_journal_entry("note", "villain 3 overfolds turn", source=f"poker:{sid}")
# ...her autonomous existential journaling (any other source) does NOT, even
# though it's written during the exact same window...
memory.add_journal_entry("journal", "the quiet dread between conversations", source="dream")
# ...nor a note from a *different* poker session.
memory.add_journal_entry("note", "some other night", source=f"poker:{sid + 999}")
contents = [n["content"] for n in poker.hud(sid)["notes"]]
assert contents == ["villain 3 overfolds turn"]
def test_note_tool_tags_live_poker_session(lyra):
poker = lyra
from lyra import tools
sid = poker.start_session(stakes="1/3", buy_in=300)
tools.dispatch("note", {"content": "whale just sat down seat 4"}, {})
assert poker.hud(sid)["notes"][0]["content"] == "whale just sat down seat 4"
poker.end_session(cash_out=300, session_id=sid)
# With no live session, a note falls back to the general journal (source=chat),
# so it does NOT attach to the just-closed session's HUD.
tools.dispatch("note", {"content": "random afternoon idea"}, {})
assert all(n["content"] != "random afternoon idea" for n in poker.hud(sid)["notes"])
def test_session_lifecycle_and_net(lyra): def test_session_lifecycle_and_net(lyra):
poker = lyra poker = lyra
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400) sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
+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"
+101
View File
@@ -0,0 +1,101 @@
"""Live table roster: seat players, attach reads by handle, roster on the HUD."""
from __future__ import annotations
import importlib
import numpy as np
import pytest
def _fake_embed(texts):
out = []
for t in texts:
v = np.zeros(64, dtype=np.float32)
for w in t.lower().split():
v[hash(w) % 64] += 1.0
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
return out
@pytest.fixture
def mods(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _fake_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
import lyra.tools as tools
importlib.reload(tools)
return poker, tools
def test_seat_players_builds_roster(mods):
poker, _ = mods
poker.start_session(venue="Meadows", buy_in=300)
n = poker.seat_players(["TAG", "Jonathan", {"name": "Wheelz", "seat": "3"}])
assert n == 3
roster = poker.session_roster()
names = {r["name"] for r in roster}
assert names == {"TAG", "Jonathan", "Wheelz"}
assert next(r for r in roster if r["name"] == "Wheelz")["seat"] == "3"
def test_read_attaches_to_seated_player_by_handle(mods):
poker, _ = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.seat_players(["TAG"])
poker.add_read(note="limped A4o from the SB, UTG straddle", name="TAG")
roster = poker.session_roster()
tag = next(r for r in roster if r["name"] == "TAG")
assert tag["reads"] == 1 and "A4o" in tag["last_note"]
# No duplicate TAG spawned — the read landed on the seated player.
assert sum(p["name"] == "TAG" for p in poker.get_villain_file()) == 1
def test_seat_players_tool_and_roster_in_hud(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
out = tools.dispatch("seat_players", {"players": [{"name": "TAG"}, {"name": "JD"}]}, {})
assert "TAG" in out and "JD" in out
assert len(poker.hud()["roster"]) == 2
def test_unseat_player_removes_from_roster_keeps_history(mods):
poker, _ = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.seat_players(["TAG"])
poker.add_read(note="showed a bluff", name="TAG")
assert poker.unseat_player(name="TAG") is True
assert poker.session_roster() == [] # off the table
assert poker.player_profile("TAG")["reads"] # history intact
def test_clear_table_empties_roster_keeps_reads(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.seat_players(["TAG", "Jonathan"])
poker.add_read(note="limped A4o", name="TAG")
out = tools.dispatch("clear_table", {}, {})
assert "cleared" in out.lower()
assert poker.session_roster() == [] # roster emptied
assert poker.player_profile("TAG")["reads"] # reads kept
# A live session is untouched by clearing the table.
assert poker.live_session() is not None
def test_seat_players_replace_swaps_to_new_table(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.seat_players(["TAG", "Jonathan"])
tools.dispatch("seat_players", {"players": [{"name": "Doyle"}, {"name": "Ivey"}],
"replace": True}, {})
assert {r["name"] for r in poker.session_roster()} == {"Doyle", "Ivey"}
def test_seat_players_accepts_plain_name_list_via_tool(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
tools.dispatch("seat_players", {"players": "TAG, JD, Wheelz"}, {})
assert {r["name"] for r in poker.session_roster()} == {"TAG", "JD", "Wheelz"}
+73
View File
@@ -0,0 +1,73 @@
"""The scouting desk: named + descriptor recall, ambiguous→queue, generic→silence."""
from __future__ import annotations
import importlib
import numpy as np
import pytest
def _fake_embed(texts):
out = []
for t in texts:
v = np.zeros(64, dtype=np.float32)
for w in t.lower().split():
v[hash(w) % 64] += 1.0
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
return out
@pytest.fixture
def mods(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _fake_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
import lyra.scouting as scouting
importlib.reload(scouting)
return poker, scouting
def test_named_player_surfaces_a_brief(mods):
poker, scouting = mods
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
pid = poker.upsert_player("Sleepy John", venue="Meadows", category="reg")
poker._c().execute(
"INSERT INTO player_observations (player_id, session_id, cards, created_at) VALUES (?,?,?,?)",
(pid, sid, "As Ks", poker._now()))
poker._c().commit()
note = scouting.scout("sleepy john just sat down on my left", venue="Meadows")
assert note and "Sleepy John" in note and "SCOUTING DESK" in note
def test_descriptor_high_match_surfaces_with_confirm(mods):
poker, scouting = mods
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows", category="reg")
note = scouting.scout("the neck tattoo sleeve guy just 3bet me again", venue="Meadows")
assert note and "confirm it's the same guy" in note
def test_ambiguous_descriptor_queues_instead_of_interrupting(mods):
poker, scouting = mods
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
note = scouting.scout("the neck tattoo guy raised", venue="Meadows")
assert note is None # didn't interrupt
q = poker.list_identity_queue()
assert q and q[0]["kind"] == "needs_clarification"
def test_generic_descriptor_stays_silent(mods):
poker, scouting = mods
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
note = scouting.scout("the mid aged white guy with glasses raised", venue="Meadows")
assert note is None
assert poker.list_identity_queue() == [] # no queue spam for a non-identifier
def test_no_player_reference_returns_nothing(mods):
poker, scouting = mods
poker.upsert_player("Sleepy John", venue="Meadows")
assert scouting.scout("i folded pocket kings to a 4bet", venue="Meadows") is None
+142
View File
@@ -0,0 +1,142 @@
"""Summary consolidation: MI50 length cap, fast-fail, and cloud fallback.
Everything is stubbed no real backend is touched. These drive the behavior of
`summary._summarize_text`: try the primary backend a bounded number of times with
a capped generation length, and fall back to cloud if the primary keeps failing.
"""
from __future__ import annotations
import types
import pytest
from lyra import summary
@pytest.fixture
def calls(monkeypatch):
"""Capture every llm.complete call; per-test behavior via `fake.responder`."""
recorded: list[dict] = []
def fake_complete(messages, backend="local", model=None,
max_tokens=None, timeout=None):
recorded.append({"backend": backend, "max_tokens": max_tokens, "timeout": timeout})
return fake_complete.responder(backend)
fake_complete.responder = lambda backend: "gist"
monkeypatch.setattr(summary.llm, "complete", fake_complete)
monkeypatch.setattr(summary.time, "sleep", lambda *_: None) # instant backoff
return types.SimpleNamespace(recorded=recorded, fake=fake_complete)
def _set_key(monkeypatch, key="sk-test"):
monkeypatch.setattr(summary.config, "load",
lambda: types.SimpleNamespace(openai_api_key=key))
def test_falls_back_to_cloud_after_mi50_attempts(calls, monkeypatch):
_set_key(monkeypatch)
def responder(backend):
if backend == "mi50":
raise RuntimeError("Request timed out.")
return "cloud-gist"
calls.fake.responder = responder
out = summary._summarize_text("transcript", "mi50")
assert out == "cloud-gist"
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50", "cloud"]
def test_no_fallback_when_backend_is_cloud(calls, monkeypatch):
_set_key(monkeypatch)
calls.fake.responder = lambda backend: (_ for _ in ()).throw(RuntimeError("boom"))
with pytest.raises(RuntimeError):
summary._summarize_text("t", "cloud")
# Cloud is already the primary: retry it, but never a redundant fallback.
assert [c["backend"] for c in calls.recorded] == ["cloud", "cloud"]
def test_no_fallback_without_openai_key(calls, monkeypatch):
_set_key(monkeypatch, key="")
calls.fake.responder = lambda backend: (_ for _ in ()).throw(RuntimeError("mi50 down"))
with pytest.raises(RuntimeError):
summary._summarize_text("t", "mi50")
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50"]
def test_caps_length_and_timeout_on_every_call(calls, monkeypatch):
_set_key(monkeypatch)
def responder(backend):
if backend == "mi50":
raise RuntimeError("nope")
return "cloud-gist"
calls.fake.responder = responder
summary._summarize_text("t", "mi50")
assert calls.recorded
for c in calls.recorded:
assert c["max_tokens"] == summary.SUMMARY_MAX_TOKENS
assert c["timeout"] == summary.SUMMARY_TIMEOUT
def test_happy_path_uses_primary_only(calls, monkeypatch):
_set_key(monkeypatch)
calls.fake.responder = lambda backend: "mi50-gist"
out = summary._summarize_text("t", "mi50")
assert out == "mi50-gist"
assert [c["backend"] for c in calls.recorded] == ["mi50"] # no retries, no fallback
# --- degenerate ("?" garbage) output guard: a wedged local model returns junk as
# a successful 200, so treat it as a failure and fall back to cloud. ---
def test_looks_degenerate_flags_repeated_char():
assert summary._looks_degenerate("?" * 60) is True
assert summary._looks_degenerate("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") is True
def test_looks_degenerate_passes_real_prose():
gist = ("Brian sat down at the Meadows 1/3 in seat 6 with two straddles active; "
"he tagged a seat-3 calling station and finished the session up 240.")
assert summary._looks_degenerate(gist) is False
def test_looks_degenerate_ignores_short_output():
# Too short to judge — don't false-positive a terse-but-valid reply.
assert summary._looks_degenerate("ok") is False
def test_degenerate_mi50_output_falls_back_to_cloud(calls, monkeypatch):
_set_key(monkeypatch)
def responder(backend):
if backend == "mi50":
return "?" * 200 # garbage-as-200, not an exception
return "a real cloud gist of the session, diverse and coherent."
calls.fake.responder = responder
out = summary._summarize_text("transcript", "mi50")
assert "cloud gist" in out
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50", "cloud"]
def test_degenerate_cloud_output_raises_no_infinite_loop(calls, monkeypatch):
_set_key(monkeypatch)
calls.fake.responder = lambda backend: "?" * 200 # every backend returns garbage
with pytest.raises(Exception):
summary._summarize_text("t", "mi50")
# mi50 x2, then one cloud fallback that's also garbage -> give up, no loop.
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50", "cloud"]
+82
View File
@@ -0,0 +1,82 @@
"""Conversation export: speech (exchanges) + actions (tool_events) merged in order."""
from __future__ import annotations
import importlib
import json
import pytest
def _const_embed(texts):
return [[1e-6] * 8 for _ in texts]
@pytest.fixture
def mods(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _const_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.transcript as transcript
importlib.reload(transcript)
return memory, transcript
def _seed(memory):
"""A turn where Brian narrates a hand and Lyra logs it, then replies."""
memory.ensure_session("s1", name="Meadows 1/3")
memory.remember("s1", "user", "got it in with a set, he had the flush draw and bricked")
memory.add_tool_event("s1", "record_hand", {"result": "won", "pot": 750}, "hand #42 logged")
memory.add_tool_event("s1", "log_stack", {"amount": 750, "note": "doubled up"}, "ok")
memory.remember("s1", "assistant", "Clean stack-off — logged it to your timeline.")
def test_tool_events_roundtrip_parses_args(mods):
memory, _ = mods
_seed(memory)
events = memory.tool_events("s1")
assert [e["tool"] for e in events] == ["record_hand", "log_stack"]
assert events[0]["args"] == {"result": "won", "pot": 750} # parsed back to a dict
assert events[1]["result"] == "ok"
def test_markdown_interleaves_speech_and_actions_in_order(mods):
memory, transcript = mods
_seed(memory)
md = transcript.as_markdown("s1", name="Meadows 1/3")
# user message, then both tool calls, then assistant reply — in that order
i_user = md.index("got it in with a set")
i_hand = md.index("record_hand")
i_stack = md.index("log_stack")
i_reply = md.index("Clean stack-off")
assert i_user < i_hand < i_stack < i_reply
assert "**Brian**" in md and "**Lyra**" in md
assert "" in md
def test_json_export_is_machine_readable(mods):
memory, transcript = mods
_seed(memory)
payload = transcript.as_json("s1", name="Meadows 1/3")
assert payload["session_id"] == "s1"
types = [e["type"] for e in payload["events"]]
assert types == ["message", "tool", "tool", "message"]
json.dumps(payload) # must be serializable
def test_build_returns_filename_and_media_type(mods):
memory, transcript = mods
_seed(memory)
body_md, mt_md, fn_md = transcript.build("s1", "md", "Meadows 1/3")
body_js, mt_js, fn_js = transcript.build("s1", "json", "Meadows 1/3")
assert fn_md.endswith(".md") and "markdown" in mt_md
assert fn_js.endswith(".json") and mt_js == "application/json"
assert body_md and body_js
def test_delete_session_clears_tool_events(mods):
memory, _ = mods
_seed(memory)
memory.delete_session("s1")
assert memory.tool_events("s1") == []
+103
View File
@@ -0,0 +1,103 @@
"""Confirm-loop tools: descriptor reads, name attach, merge/mark-distinct."""
from __future__ import annotations
import importlib
import numpy as np
import pytest
def _fake_embed(texts):
out = []
for t in texts:
v = np.zeros(64, dtype=np.float32)
for w in t.lower().split():
v[hash(w) % 64] += 1.0
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
return out
@pytest.fixture
def mods(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _fake_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
import lyra.tools as tools
importlib.reload(tools)
return poker, tools
def test_descriptor_read_creates_then_reuses_nameless_villain(mods):
poker, tools = mods
poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
tools.dispatch("add_read", {"note": "opened UTG light",
"descriptor": "neck tattoo sleeve arm"}, {})
tools.dispatch("add_read", {"note": "showed a bluff",
"descriptor": "neck tattoo sleeve"}, {}) # rephrase → same guy
players = [p for p in poker.get_villain_file() if not p["named"]]
assert len(players) == 1 # one nameless villain, not two
reads = poker._c().execute(
"SELECT COUNT(*) n FROM player_reads WHERE player_id = ?", (players[0]["id"],)
).fetchone()["n"]
assert reads == 2
def test_description_as_name_routes_to_descriptor_and_dedupes(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
# She (wrongly) puts a physical description in the name field, twice, worded
# slightly differently — must resolve to ONE nameless villain, not two named.
tools.dispatch("add_read", {"note": "limp 3bet A3o",
"name": "Filipino, Fox Racing hat, DKNY shirt, two bracelets"}, {})
tools.dispatch("add_read", {"note": "called a 4bet light",
"name": "Filipino, Fox Racing hat, DKNY shirt, watch on left"}, {})
named = [p for p in poker.get_villain_file() if p["named"]]
assert named == [] # no sentence-named players spawned (the bug)
# Either they merged, or the near-dup is surfaced for a one-click merge — never
# a silent duplicate the way sentence-names were.
q = poker.list_identity_queue()
nameless = [p for p in poker.get_villain_file() if not p["named"]]
assert len(nameless) == 1 or any(t["kind"] == "merge_candidate" for t in q)
def test_name_villain_tool_attaches_name(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
out = tools.dispatch("name_villain", {"descriptor": "neck tattoo sleeve arm",
"name": "Danny"}, {})
assert "Danny" in out
assert poker.resolve_villain("Danny")["band"] == "name"
def test_link_villains_merge_and_distinct(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.upsert_player("Danny", venue="Meadows")
poker.upsert_player("Donny", venue="Meadows")
# same=false → recorded distinct
tools.dispatch("link_villains", {"player_a": "Danny", "player_b": "Donny",
"same": False, "note": "different builds"}, {})
a = poker.resolve_villain("Danny")["match_id"]
b = poker.resolve_villain("Donny")["match_id"]
assert poker.are_distinct(a, b)
# same=true on a fresh pair → merged
poker.upsert_player("Mike", venue="Meadows")
poker.upsert_player("Michael", venue="Meadows")
tools.dispatch("link_villains", {"player_a": "Mike", "player_b": "Michael",
"same": True}, {})
names = [p["name"] for p in poker.get_villain_file()]
assert ("Mike" in names) ^ ("Michael" in names) # one absorbed the other
def test_link_villains_refuses_when_reference_is_vague(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.upsert_player("Danny", venue="Meadows")
out = tools.dispatch("link_villains", {"player_a": "Danny",
"player_b": "some guy", "same": True}, {})
assert "didn't merge" in out.lower() or "couldn't" in out.lower()
+116
View File
@@ -0,0 +1,116 @@
"""Nameless-villain identity resolution: descriptor matching, merge, distinct, queue."""
from __future__ import annotations
import importlib
import numpy as np
import pytest
def _fake_embed(texts):
"""Overlap-sensitive bag-of-words vectors so cosine reflects shared tokens."""
out = []
for t in texts:
v = np.zeros(64, dtype=np.float32)
for w in t.lower().split():
v[hash(w) % 64] += 1.0
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
return out
@pytest.fixture
def poker(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _fake_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
return poker
def test_distinctiveness_distinctive_vs_generic(poker):
assert poker.distinctiveness("guy with a neck tattoo") > 0.6
assert poker.distinctiveness("mid-aged white dude with glasses") < 0.3
def test_generic_descriptor_never_resolves_to_a_guess(poker):
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
r = poker.resolve_villain("mid aged white guy with glasses", venue="Meadows")
assert r["band"] == "generic"
assert r["match_id"] is None
def test_exact_name_match_is_deterministic(poker):
pid = poker.upsert_player("Sleepy John", venue="Meadows")
r = poker.resolve_villain("sleepy john")
assert r["band"] == "name" and r["match_id"] == pid
def test_rephrased_descriptor_resolves_high(poker):
pid = poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
r = poker.resolve_villain("neck tattoo sleeve", venue="Meadows")
assert r["band"] == "high" and r["match_id"] == pid
assert r["confidence"] >= 0.80
def test_partial_descriptor_is_ambiguous_not_high(poker):
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
r = poker.resolve_villain("neck tattoo", venue="Meadows")
assert r["band"] == "ambiguous" # plausible, but don't guess live
def test_merge_repoints_observations_and_deletes_dup(poker):
keep = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
dup = poker.create_descriptor_villain("neck ink tatted", venue="Meadows")
poker._c().execute(
"INSERT INTO player_observations (player_id, session_id, created_at) VALUES (?,1,?)",
(dup, poker._now()))
poker._c().commit()
assert poker.merge_players(keep, dup) is True
assert poker.get_villain_file() and all(p["id"] != dup for p in poker.get_villain_file())
obs = poker._c().execute(
"SELECT COUNT(*) n FROM player_observations WHERE player_id = ?", (keep,)).fetchone()["n"]
assert obs == 1
def test_merge_prefers_a_real_name(poker):
named = poker.upsert_player("Danny", venue="Meadows")
desc = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
poker.merge_players(desc, named) # keep the descriptor id, but name should win
row = dict(poker._c().execute("SELECT name, named FROM poker_players WHERE id = ?", (desc,)).fetchone())
assert row["name"] == "Danny" and row["named"] == 1
def test_mark_distinct_blocks_merge_scan(poker):
a = poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
b = poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
poker.mark_distinct(a, b, note="one's taller")
assert poker.are_distinct(a, b)
assert poker.scan_merge_candidates() == 0 # confirmed-distinct pair is skipped
def test_scan_files_merge_candidate_for_near_duplicates(poker):
poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
filed = poker.scan_merge_candidates()
assert filed == 1
q = poker.list_identity_queue()
assert q and q[0]["kind"] == "merge_candidate" and len(q[0]["players"]) == 2
def test_queue_dedupes_identical_pending_task(poker):
a = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
b = poker.create_descriptor_villain("neck ink", venue="Meadows")
t1 = poker.queue_identity_task("merge_candidate", [a, b])
t2 = poker.queue_identity_task("merge_candidate", [b, a]) # same pair, reversed
assert t1 == t2
assert len(poker.list_identity_queue()) == 1
def test_name_villain_flips_named_flag(poker):
pid = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
poker.name_villain(pid, "Danny")
r = poker.resolve_villain("Danny")
assert r["band"] == "name" and r["match_id"] == pid