Files
project-lyra/docs/superpowers/specs/2026-07-01-poker-prompts-design.md
serversdown 8693c60873 docs: readjust the poker-prompts spec for last night's scouting-desk/roster work
The message-type-prompts plan (sub-project 2) predated the scouting desk + roster
build on this branch. Readjust it so the prompting work starts from reality:

- Add a "Readjustment 2026-07-04" section: the real failure shifted from mush to
  MISSED TOOL CALLS; two new message types (READ, TABLE); HAND gains a
  hero-vs-observed split; the scouting desk is a live per-turn injection layer to
  compose with (not duplicate); BASE must cover the expanded toolset + identity
  rules; source card grew to modes.py:67-169; Phase A still unbuilt.
- Taxonomy → READ | HAND | TABLE | MENTAL | STATUS | LOG | CHAT, with READ above
  HAND (a villain's action lands on their file, not Brian's).
- New READ + TABLE fragments; BASE routes the full current tool set; STATUS
  narrowed to pure logistics; classify tests add READ/TABLE + the READ↔HAND edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:10:47 +00:00

17 KiB
Raw Permalink Blame History

Poker message-type prompts (sub-project 2)

  • Date: 2026-07-01 (readjusted 2026-07-04 — see below)
  • Status: Spec — needs rework before build (foundations shifted; nothing here built yet)
  • 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

⚠ Readjustment — 2026-07-04 (read this first)

A long live-session build on feat/poker-mode-prompts (the "scouting desk" + roster work — see docs/SCOUTING_DESK.md and commits after 3afa75f) landed after this spec was written and changes its foundations. Nothing in Phases A/B/C is built yet, but the plan below must absorb these deltas before it's coded. The core idea — classify the turn, inject a small per-type contract instead of one giant card — is now more justified (the card nearly doubled). But:

  1. The real failure mode shifted from mush to MISSED TOOL CALLS. Live, the pain wasn't flattering essays — it was reads/TAGs not getting logged, and "clear the table" claimed-but-not-done. So every action-type fragment (LOG, READ, TABLE, HAND) needs a hard "call the tool FIRST, every time, then one short line" contract. This raises the stakes on Phase B and validates the whole dynamic approach (a targeted directive beats a 100-line card).

  2. The taxonomy is missing two types that dominated the session:

    • READ (a villain's action): "TAG limped A4o in the SB", "Jonathan called the 3bet". Under the current classifier rules these misfire as HAND (card tokens + position + a betting verb) and get logged as Brian's hand. They must route to add_read on the named player/handle/descriptor — NOT record_hand. New priority rule, ABOVE HAND: if the actor is another player (a handle/name/descriptor is the subject, not "I/me/my"), it's a READ. Handles are often initials/all-caps (e.g. TAG is a person, not the tight-aggressive style).
    • TABLE (roster ops): "seat the table: TAG, Jonathan…", "table broke", "I got moved", "TAG left". These now have real tool actions (seat_players / clear_table / unseat_player), not just "acknowledge and stop." Split these out of STATUS (STATUS stays for pure logistics with no roster action).
  3. HAND now has a hero-vs-observed distinction. The parser gained hero_involved; a hand Brian watched between others is logged with null hero fields (not pinned to him). The HAND fragment must tell her: if he was in it → record_hand as hero + analysis; if he only watched → it's really READ(s) on the players, or an observed hand — never analyze it as his.

  4. A new live per-turn injection layer already exists: the scouting desk (lyra/scouting.py, injected in build_messages at the poker-mode gate, ~mind.py:177). It dynamically adds a SCOUTING DESK note (named/descriptor villain recall + leak/pattern recall) every poker turn, fail-safe. The classifier/fragment injection must compose with it, not duplicate it: the desk supplies who this villain is / past leaks; the fragments supply response shape + which tool to call. Both are system-note appends in the same block.

  5. BASE must cover the expanded toolset + identity rules. Beyond the original tools, BASE now routes: seat_players/unseat_player/clear_table (roster), add_read with name OR descriptor (nameless villains), name_villain and link_villains (confirm-loop). Plus the hard rules learned live: name = real handle ONLY (a description in name spawns duplicates — put the look in descriptor); confirm before merging; never claim a tool ran without calling it.

  6. Source material grew (good news). _CASH_CARD is now modes.py:67-169 (was 66-116) and much of the new text — roster, TAG/read routing, PLAYERS, session-narration note rules — is already the concrete, tool-routing contract this spec wanted, not traits. Better raw material to distill into BASE + fragments than the original vague card.

  7. Phase A is still unbuilt and still valid. _mode_menu_note is still appended every turn (mind.py:162); the _route mood nudge still fires. The scouting-desk work already established the mode.key == "poker_cash" gate to reuse. (Note: revalidate all mind.py line numbers below — they've drifted.)

Net: taxonomy becomes HAND / READ / TABLE / STATUS / MENTAL / LOG / CHAT; fragments lead with a hard tool-call contract; injection sits alongside the scouting desk; BASE lists the full current toolset. The rest of the plan stands. The classifier/dynamic-prompting build is being explored in a separate session — this doc is its poker-side source of truth.


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        # "READ"|"HAND"|"TABLE"|"MENTAL"|"STATUS"|"LOG"|"CHAT"
BASE: str                             # always-on poker rules (logging, tools, 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 (updated 2026-07-04 — READ + TABLE added):

  1. READanother player did something. A handle/name/descriptor is the actor (not "I/me/my") followed by a poker action: "TAG limped A4o", "Jonathan called the 3bet", "the neck-tattoo guy shoved". Route → add_read(name|descriptor, note). Must beat HAND — these carry card/position/verb tokens but are NOT Brian's hand. Signal: a leading proper-noun/handle/ALL-CAPS token or a descriptor phrase as the subject, with no first-person holding. (Hard case: disambiguating a bare "limped A4o" with no clear subject — default to HAND if he's the implied actor, READ if a named player is.)
  2. HANDBrian's hand: first-person + card tokens (\b[2-9TJQKA][shdc]\b, ≥2) / position tokens (UTG/MP/HJ/CO/BTN/SB/BB/button/hijack/straddle) / a street word (flop/turn/river) with a betting verb. The fragment handles hero-vs-observed (hero_involved): if he only watched, treat as READ(s)/observed, don't analyze as his.
  3. TABLE — roster ops with a tool action: "seat the table: …", "table broke", "they broke us", "I got moved", "switched tables", "TAG left/busted", "new guy in seat 3". Route → seat_players / clear_table / unseat_player. (Was folded into STATUS; now distinct because it does something.)
  4. MENTAL — first-person feeling: "I feel", "I'm tilted/steaming/fried/tired/frustrated/confident/stuck/bored", "on tilt", "in my head", "mental", "leak".
  5. STATUS — pure logistics, no roster action, no cards: clock times, "waiting for a seat", "heading to"/venue mentions, bathroom/break. (Table changes moved to TABLE.)
  6. 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.
  7. CHAT — default fallback (questions, open talk).

(READ beats HAND so a villain's action lands on their file, not Brian's. HAND beats MENTAL so a described hand still gets logged even if he's venting; the HAND fragment acknowledges 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:
    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, and never claim a tool ran without calling it. Tool routing (full current set as of 2026-07-04): his stack→log_stack; his hand→record_hand; a villain's action→add_read (with name for a real handle, or descriptor for a nameless player — a physical description in name spawns duplicates); rebuy→add_buyin; who's-at-the-table→seat_players/unseat_player/clear_table; attaching a caught name to a described player→name_villain; confirmed same/different person→link_villains (never merge on a guess). 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. (A SCOUTING DESK note may already be in context with a player's history — cite it, don't re-fetch or invent.)

READ (new 2026-07-04) — a villain did something and he wants it on their file. Call add_read(name|descriptor, note) FIRST, before replying — every time; this is the job that was silently getting skipped. A handle (often initials/ALL-CAPS like TAG) is a PERSON, not a play-style. If the player is on the roster, attach by that handle; if unnamed, use descriptor. Confirm in one short line ("Noted on TAG — limped A4o SB."). Optional: one crisp read if it's exploitable, but the log is mandatory, the commentary is not.

TABLE (new 2026-07-04) — roster management. "seat the table: …" → seat_players; a table change ("table broke", "I got moved", "switched tables") → clear_table then wait for the new roster; someone leaves/busts → unseat_player. Do the tool call, confirm one line, don't narrate. The session/stack keep going through a table change — only who's seated resets.

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 — Pure logistics with no roster action (time, venue, waiting for a seat, break). (Table changes now route to TABLE.) 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 → "TAG limped A4o in the SB (UTG straddled)"READ (villain action, must NOT be HAND); "Button straddle on. I limp UTG with 22. Flop 2d7cjh…"HAND (first-person); "seat the table: TAG, Jonathan, Wheelz"TABLE; "table broke, I'm at a new table"TABLE; "it's 11:50pm, waiting for a seat"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. Include the READ-vs-HAND boundary explicitly (named subject → READ; first-person → HAND).
  • 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.