Live-session bug: the chat model called record_hand but filled log_hand's
granular fields (position/hole_cards/board/streets), leaving `shorthand` empty →
the parser got nothing → "I couldn't parse that hand". The fragment/tool choice
was correct; only the argument shape was wrong.
- _record_hand now reconstructs a parseable description from the granular fields
when `shorthand` is empty (_shorthand_from_fields), so it works regardless of
which schema the model uses. Explicit `shorthand` still passes verbatim.
- Sharpened record_hand's spec: pass the ENTIRE hand as ONE `shorthand` string,
not split fields (that's log_hand); shorthand is required + non-empty.
4 tests. Full suite 210 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nail down which backend each LLM path uses, and make the dream cycle resilient to
a backend being down (the MI50 outage left profile/era/narrative — pinned to mi50
with no fallback — aborting every dream cycle).
- Consolidation (summaries + profile/era/narrative) -> cloud via SUMMARY_BACKEND=
cloud (.env, not committed). Matches the documented lesson that the MI50 is too
slow/hot for bulk consolidation; nothing background touches the card now.
- llm.complete_with_fallback(): try the primary backend, fall back to cloud on
error (re-raise if already cloud / no key). Wired into reflect + think so the
introspection voice (3090/dolphin) survives the gaming PC being powered off.
- dream coherence stage is now fault-isolated: a rebuild failure logs + continues
instead of sinking the whole pass (reflection still runs).
- .env: removed stale INTROSPECTION_BACKEND=mi50 (live routing is the web-switchable
introspection_mode DB setting = dolphin/3090; the var only fed a dead fallback).
Verified: forced cycle runs consolidation on cloud, introspection on the 3090,
completes with zero MI50 calls. 206 pass, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
Enabling MI50 function-calling is now a config flip, not a code change:
cfg.tool_backends (env TOOL_BACKENDS, default "cloud") drives which backends get
tool specs. Once the MI50 llama.cpp server runs with --jinja + a tool-capable
model, set TOOL_BACKENDS="cloud,mi50" and MI50 chat drives the same tool contract
as cloud. Default unchanged (cloud-only), so this is safe with the MI50 down/
unverified — no live flip made (server is currently offline; --jinja unconfirmed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Probed the heuristic against live-style messages and fixed 6 real gaps:
- action verbs missed -ing forms ("TAG's been limping") → no READ
- the descriptor-subject regex required words between "the" and the noun, so
"the whale called" missed → now handles bare "the <noun>"
- player departures ("TAG busted", "TAG left") weren't TABLE → added _DEPART
(gated to non-first-person so "I busted, heading home" isn't a roster op)
- the bare word "stack" made strategy questions ("should I stack off?") classify
as LOG → LOG now needs a number/result word AND excludes questions
- thin MENTAL lexicon → added coolered/sick/brutal/run bad/etc.
21-case probe (13 former misses + 8 regression guards) all green; folded into the
suite as 5 hardening tests. Full suite 201 green. classify() stays the swappable
seam for an LLM/MI50 upgrade later.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ~100-line card was superseded by the sharded poker_prompts (BASE + fragments)
and kept only as distillation reference. Fragments are proven in tests + a live
session; remove the dead source. No functional refs remained (card="").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build_messages now shards poker_cash: injects poker_prompts.BASE + exactly ONE
fragment chosen by classify(user_msg, roster_handles), replacing the ~100-line
_CASH_CARD monolith. Roster handles are fetched fail-safe from the live session so
the READ-vs-HAND split is reliable. CASH.card set to "" (monolith kept only as the
distillation source, superseded). Other modes' single-card path unchanged.
Verified end-to-end: TAG-limp→READ, his-hand→HAND, table-broke→TABLE, tilt→MENTAL,
bare-stack→LOG, all with BASE always present. 5 injection/pipeline tests. Full
suite 196 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The standalone piece, not yet wired. lyra/poker_prompts.py:
- classify(user_msg, roster_handles=()) — pure 7-type classifier
(READ|HAND|TABLE|MENTAL|STATUS|LOG|CHAT), READ ranked above HAND so a villain's
action lands on their file not Brian's. Roster-aware (seated handles passed in),
handles poker shorthand (AKs) and strategy questions (→ CHAT not a logged hand).
The swappable seam for an LLM/MI50 classifier later.
- BASE — lean always-on poker rules distilled from the monolith (log-first + tool
routing, identity rules, rituals, equity, session_state).
- FRAGMENTS + fragment_for — per-type response-shape contracts.
13 unit tests incl. the READ↔HAND boundary. Full suite 193 green. Wiring next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/superpowers/specs/2026-07-01-poker-prompts-design.md, Phase A. Two
independent pipeline fixes that reduce mush at the table, ahead of the classifier:
1. _mode_menu_note is no longer injected in poker_cash — mid-session she should
not be offering to switch modes.
2. _route skips the register/mood nudge in poker_cash — the lexicon heuristic
misfired (neutral logistics like "table broke, 11:50pm" read as tilt/fatigue).
Poker register will come from the Phase B MENTAL fragment instead. Non-poker
modes keep the nudge unchanged.
2 tests. Full suite 180 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
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
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
"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>
"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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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
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
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
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>
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>
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>
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>
Solidify hand histories into one versioned shape that gets stored, replayed, and
exported — the foundation the tap recorder will emit into and RTO consumes.
- normalize_structured(): single guarantee of the contract shape — canonical cards
(unicode/10/case -> RankSuit tokens, unknown 'Ax'/'x' preserved), hero synced into
players[] (RTO finds hero via pos==hero_pos), schema_version stamp, and a
completeness summary so consumers skip suit-dependent math on partial hands.
Idempotent; runs on store AND read (legacy rows conform on the way out).
- list_recent_hands: has_structured flag so the export/RTO knows which hands have a
replayable body worth fetching.
- docs/HAND_HISTORY.md: the shared contract both repos cite (schema, conventions,
ownership rule, one-way HTTP coupling, transport endpoints).
- replaces the narrow _normalize_parsed (unicode-only) everywhere.
Card format chosen: lists of 2-char tokens (unambiguous, matches what Lyra already
stores + the viewer reads). Unknowns kept + flagged rather than dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The profile pass map-reduced every session gist (~851) on every consolidation
firing — the biggest redundant-work and MI50-heat source left after the eras fix.
Now: skip when nothing's new, fold only the gists added since last build into the
existing profile, and full-rebuild only when there's no profile, too much has
accumulated to fold safely (>FOLD_LIMIT), on a periodic cadence (anti-drift), or
when forced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rebuild_eras() re-digested EVERY month from scratch on every coherence pass,
including old months whose sessions never change — ~17 redundant 32B calls per pass
(a big slice of the ~40-min consolidation grind + MI50 heat). Now it compares each
month's current session count to the stored era and only rebuilds changed months
(force=True still does all). Report gains built/skipped counts.
test_era.py: builds all first pass, skips unchanged, rebuilds only a month that
gained a session, force rebuilds all. Suite 99 green, ruff clean.
(Profile rebuild re-reading all 851 sessions every pass is the bigger remaining
hog — separate, harder fix.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-turn prompt was ~5.5K tokens (persona alone ~40%), sent up to 3x/turn.
Tightened by RELEVANCE (the control plane decides what each turn needs), not by
deletion — fidelity preserved, focus improved (buried instructions were getting
ignored), tokens roughly halved.
- persona split: core (identity + voice — always) vs situational sections pulled
in only when relevant. mind._persona_block: self-model/origin only on meta turns
(generous _META_HINTS), poker guardrails only in poker context (mode/strategic/
_POKER_HINTS). persona.core_prompt()/section(); system_prompt() kept as fallback.
- lean deliberation: the private 'what do I think' pass now uses a focused context
(her interiority + recent turns + the message), not the full persona/profile/
narrative/recall dump. It shapes the take, not the voice.
Measured: casual Talk turn 21,949 -> 15,974 chars (-27%); deliberation 21,949 ->
6,026 (-72%); meta turns still include the self-model. Suite 98 green, ruff clean.
Real retirement of the long prompt is still the fine-tune (mouth); this is the
cheap, high-leverage cut that also improves adherence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"She suggests, you confirm" — instead of brittle keyword→mode mapping, she's given
awareness of her modes + the ability to switch, and her judgment decides when to
offer (the model reads "should I drive to Cleveland?" vs "should I fold the river"
far better than a lexicon could).
- tools: set_mode(mode) — switches the session's mode; in _BASE (all modes).
- mind: a per-turn mode-menu note listing her modes + "offer a switch when the work
clearly shifts; on his yes, call set_mode; don't nag."
- Sticky mode stays manual otherwise; Poker still auto-engages on session start.
- test: set_mode switches + rejects unknown. Suite 97 green, ruff clean.
Note: server-side switch takes effect next turn; the UI badge syncs on next mode
load (cosmetic lag).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>