# 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."