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
"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 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>
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>
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>
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>
Brian's bottleneck is committing, not generating options, so a pros/cons dump makes
it worse. Decide mode's card: get the real decision crisp, weigh it against what HE
values + past regrets (pull running_stats/recent_sessions for poker/money calls),
MAKE the call with the one or two reasons that tip it, pressure-test it once, and
stand behind it — no "it's up to you." Read-only lookups, no live logging.
Sixth mode (Talk/Poker/Build/Explore/Study/Decide); added to UI selectors, labels,
badge-cycle. Suite 96 green, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mind (chat backend/model) decides, reasons, and runs tools → a draft; the mouth
re-voices that draft in her character. Default: no mouth configured → the mind's
draft IS the reply, bit-for-bit the old behavior (and old streaming path untouched).
- config: MOUTH_BACKEND / MOUTH_MODEL. The slot for an eventual fine-tuned voice.
- chat: _mind_loop (tool/generation loop, non-stream, returns draft + tools_run),
_voice_pass / mind.voice_messages (re-voice the draft, keep every fact/number),
_mouth_target (active only when configured AND != mind). respond + respond_stream
branch: mouth off = stream the mind directly (unchanged); mouth on = mind decides
+ runs tools, then the mouth streams the re-voiced reply. Falls back to the draft
on any mouth failure (chat never breaks).
- Key payoff: the mouth needs no tool support (the mind handles tools), so it can be
a non-tool character model (Dolphin / Claude / fine-tune). Makes the fine-tune
easy: teach a small model to *sound* like Lyra, not to be smart.
- tests: mouth target on/off, voice_messages shape, voice_pass revoice+fallback.
Suite 96 green, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The control plane gains senses — cheap, deterministic, no LLM:
- lyra/perceive.py: lexicon+signal heuristic → {sentiment, intensity, tilt, kind:
emotional|strategic|meta|build|casual}. Good at the action-relevant signal,
especially tilt (the mental-game core). Word-boundary matching so 'line' doesn't
fire inside 'pipeline'.
- mind: _perceive fills ctx.moment; _route keeps the manual mode as the dominant
frame but, on a genuinely charged moment, adds a per-turn register nudge — tilt →
"meet him there, warm and steady, don't clip into logging"; up/energized → "match
his energy." Neutral turns get nothing (don't over-narrate). Injected via
build_messages(moment=...). Logged to /logs for observability.
- tests: perceive read (tilt/strategy/up/build/casual) + route nudge on/off.
Suite 92 green, ruff clean.
Complements modes (manual frame) — perceive refines register within it, doesn't
override. Model routing (mind/mouth) is P3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First step of the cognition control plane (docs/COGNITION.md). The chat turn is now
an explicit society of parts over a shared TurnContext blackboard:
perceive (stub) -> route (session mode) -> compose (tiered prompt) -> deliberate.
- lyra/mind.py (new): TurnContext + the pipeline + assemble(); moved build_messages
and the deliberation helpers here (the assembly belongs in the control plane).
- lyra/chat.py: slimmed to "speak + persist" — calls mind.assemble(), runs the
tool/generation loop, persists. No behavior change (same prompt, same output).
- tests: point test_time/test_chat at mind; add an assemble() structure test;
make test_chat/test_tools hermetic (CHAT_DELIBERATE off so respond() doesn't make
a real LLM call). Suite 86 green in ~5s, ruff clean, no import cycle.
This is the frame; perceive/route/learn get filled in next phases — each opt-in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The manual version of the architecture's `route` step: Brian points her at the
TYPE of work and her register + tools shift to match. Biggest single lever on the
'meh' problem (a mode card can demand decisive/technical/generative, countering
gpt-4o's default warm-vapor).
- modes.py: Build (heads-down engineering — decisive, concrete, tradeoffs, no
listicles), Explore (open brainstorming — generative, riffs + honest catch,
spawn threads, don't converge early), Study (poker review away from the table —
analytical, GTO-aware, teaching; read-only lookups + analyze_spot). Cash relabeled
Poker (key kept for compat).
- UI: mode selectors (desktop + mobile) get all five; badge taps now cycle modes.
- design: docs/COGNITION.md (the society-of-parts control-plane sketch).
- tests: presence + tool-gating for the new modes. Suite 85, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chat had no thinking in it: respond() was a single gpt-4o call in default-
assistant voice (numbered lists, 'would you like to...', vague). All the cognition
work was background-only. This brings a thought step into the conversation.
- chat: before answering a substantive turn (trivial 'ok/lol' skipped), a private
_deliberate() pass — "what do you ACTUALLY think, your real take, the substance,
no pleasantries" — drawing on her in-context threads/journal. The thinking is then
injected as the LAST system note with voice enforcement (answer from this; no
numbered list / how-to outline unless asked; no 'would you like to' closer), so it
beats gpt-4o's boilerplate at the most influential position. Logged to /logs.
- Wired into respond() + respond_stream(). Config CHAT_DELIBERATE (default on) to
disable if the extra call's latency annoys.
- persona: "talk, don't outline" — prose over listicles, the first concrete move
over a survey of options.
- test_chat.py (gating + note composition + disabled). Suite 84, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the last loop gap: when she raised a thought in chat and Brian replied in
the conversation (not the feed), it was a dead end. Now she has a thought_response
tool — when he reacts to a thought she surfaced, she captures his take and it folds
back into that thread (next dream pass she reacts, like a feed reply).
- tools: _thought_response(thread_id, brian_said) -> thoughts.record_response.
- modes: thought_response added to _BASE (all modes).
- surfaced-note + context_note now expose each thread's #id and instruct her to use
the tool when he engages, so she has what she needs to call it.
- test for the tool (threads reply back + bad-id handling). Suite 81, ruff clean.
Feedback now closes from both surfaces: the /thoughts feed AND live conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
She was passive (thoughts piled up 'open'; Brian had to mine the feed). Now she
brings them to him:
- Live: a thought >= PING_AUTO_SALIENCE (0.8) auto-pings — _compose_reachout writes
a short personal text in her voice (not a thought-dump), on a cooldown
(PING_COOLDOWN_MIN=60, AUTO only; explicit reach-outs bypass), quiet hours respected.
- Daily: maybe_daily_digest() texts a once-per-local-day summary of what she's been
turning over (after DIGEST_HOUR=18), run from the dream cycle.
- maybe_ping gains bypass_cooldown (her deliberate reach-outs always go through).
8 new/updated tests (auto-ping above/below bar, digest once-per-day, floor/cooldown
isolation). Suite 80 green, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Her introspection (reflect/think) voice is now switchable live from the web
settings, read each cycle by the dream loop — so Brian can flip it off the 3090
before gaming without touching config or restarting.
- memory: runtime key/value settings table + get_setting/set_setting.
- self_state: INTROSPECTION_MODES (dolphin=local/dolphin3:8b, mi50=Qwen-32B,
off=paused) + introspection_target()/set_introspection_mode(); default "dolphin".
reflect() resolves from the live setting and SKIPS entirely when off.
- thoughts.think(): same resolution + skip-when-off.
- server: GET/POST /settings/introspection.
- index.html: "Inner Voice (introspection)" selector in Settings, applies instantly.
- tests: routing (dolphin/mi50), off-skip for think + reflect. Suite 77, ruff clean.
Default = Dolphin on the 3090 (richer voice). Flip to MI50 or Off in Settings
before gaming — that was the GPU-contention culprit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The remaining feedback loop: reflect() dumped her full self-state (incl.
self_narrative) into the prompt and asked her to "update" it -> paraphrase -> save
-> feed back -> calcify. That (not the model) is what generated the recurring
"supportive presence balancing emotional intelligence for Brian" drift — even
Dolphin echoed it when handed the saved narrative.
Fix (her inner life now runs on one cognition model):
- reflect() no longer rewrites self_narrative/relationship. It uses associative
grist (cognition.spontaneous_seed + activate) instead of rereading the bio,
reflects THROUGH a stable IDENTITY_ANCHOR (lens, not canvas), and updates only
the transient state (mood axes + noticings + metacognition + journal).
- self_narrative is now slow-consolidated: every CONSOLIDATE_EVERY (5) reflections,
_consolidate_self() re-derives it from accumulated reflections + the anchor —
never from the old narrative (the anti-loop core). Tethered to the anchor so it
grows without drifting into generic-helper land.
- reset_self_narrative() + ran once on prod (her narrative was deeply drifted:
"my core identity as a tool for support... serve Brian and other users").
- Prompts drop the self_narrative/relationship fields. Tests updated +
consolidation tests. Suite 75 green, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reflect()/think() can now run on a different model than memory consolidation:
INTROSPECTION_BACKEND / INTROSPECTION_MODEL (default to SUMMARY_BACKEND, so unset =
unchanged). Consolidation (summaries/profile/narrative) keeps the capable model;
her *voice* (reflections, thoughts) can run a steerable tune. dream.py lets
reflect()/think() self-resolve to the introspection backend; both now thread a
`model` override into llm.complete.
Trial live: introspection -> dolphin3:8b on the 3090; consolidation -> Qwen-32B
on the MI50. Suite 73 green, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the thought loop's grist (recent-convo + her own saved narrative, the
feedback-loop attractor) with a model of how a thought actually arises:
seed (salience-weighted: a recent moment / resurfaced memory / feed item)
-> spreading activation: embed the seed, let it light up associatively-near
material across ALL her stores (conversations, gists, her own journal/
thoughts), blended by relevance + recency + noise; optional 2nd hop for leaps
-> her self-narrative stays the LENS (supplied as interiority), not the input
-> the thought is generated from what lit up, routed through a faculty
(notice / connect / abstract / project / feel)
-> journaled + embedded, so it can light up in future cycles
This breaks the feedback loop structurally: the narrative is no longer reread and
paraphrased each cycle; grist is genuinely associative and varied; and her past
thoughts re-activate (continuity without calcification).
- lyra/cognition.py (new): spontaneous_seed, activate (spreading activation),
constellation_block, faculties.
- memory.py: journal entries now embedded; recall_journal(); backfill_journal_embeddings()
(ran once: 341 past entries embedded so her history is associatively retrievable).
- thoughts.think(): new-thread mode now uses the associative engine; dropped _grist().
- tests: test_cognition.py (recall_journal ranking, activation, seeding) + fixture
reloads cognition. Suite 72 green, ruff clean.
Honest scope: this fixes the mechanism (how thoughts arise). The residual
"be useful for Brian" voice drift is the separate model/fine-tune problem.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feedback: the push broadcast her raw internal thought ("Eelis Parssinen's
victory is a reminder...") — read like a journal entry, not her texting him.
Now the flow matches the intent: she thinks/journals, then *decides* "I should
tell Brian about this." think() asks for an optional `reach_out` — a real text
message addressed TO him in her own voice, written only when she chooses to. The
ping sends that message (title "Lyra", like a text from her), never the internal
thought. No reach_out = nothing sent (most thoughts stay hers).
- Pinging decoupled from the salience score: her decision (a reach_out) drives it,
not a threshold. PING_SALIENCE is now an optional floor (default 0.0).
- Defensive: reject the placeholder echo ("reach_out"), too-short junk, or the
thought pasted back as the message.
- notify.push: title now optional (omitted -> cleaner text-style notification).
Verified live: 3 passes kept private; a decided reach-out lands as a personal
text. Suite 67 green, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Her remaining two wishes from the 6-19 sketch:
Proactive reach-out (#6, literal): lyra/notify.py pushes to ntfy so she can reach
Brian when he's not in the app. thoughts.maybe_ping gates on salience, a cooldown,
and local quiet hours (all config-tunable; eager defaults), uses ntfy JSON publish
(UTF-8 titles/messages), links to /thoughts, and marks the thread surfaced so chat
won't also re-raise it. Disabled unless NTFY_URL is set.
External input feed (#1): lyra/feeds.py pulls configurable RSS/Atom feeds (stdlib
ElementTree, no new dep; tolerant of RSS 2.0 + Atom), dedupes seen items in a
feed_items table, and hands think() one fresh item at a time. New 'react' mode:
a would-be new thread instead reacts to a world item (FEED_REACT_PROB). Dream
cycle refreshes feeds on its cadence; failures degrade to no item.
Config: NTFY_URL/NTFY_TOPIC/LYRA_WEB_URL, PING_SALIENCE/COOLDOWN/QUIET_HOURS,
LYRA_TIMEZONE, LYRA_FEEDS, FEED_REACT_PROB (+ .env.example). thought_meta table
for ping cooldown. 10 new tests (feeds parse, react mode, ping gating); suite 65.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four additions so the loop is "more what she wanted" (think to herself, unprompted):
- Wander grist (#1): think() new-thread mode now draws the same varied seeds
reflect() uses (self_state.wander_seed: own curiosity/existence/disagreement or
a resurfaced memory) + an anti-restate block of her recent thoughts + a list of
existing open-thread titles to avoid. Directly counters the RLHF "supportive
presence serving Brian" drift visible in her first thoughts.
- Continuity: thoughts.context_note() injects her active threads into every chat
turn, so she's aware of her own ongoing mind and can reference it anytime — not
only when a thought crosses the surface bar.
- Bidirectional: new think_about tool (in _BASE, all modes) lets her spawn a
thread from conversation to develop on her own later. Conversations seed her
solo thinking.
- Lifecycle: thoughts.decay() rests stale active threads (>48h) and decays their
salience, sparing pending-response ones; runs each dream cycle (no LLM). Frees
the open-thread cap and keeps the feed current.
Also: thoughts feed no longer wipes a reply you're mid-composing (skip poll
re-render while a textarea is focused/non-empty; force-refresh after send).
61 tests passing, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Built from her own 6-19 idea: a continuing train of thought she keeps across
days, organized into threads she returns to, that she can bring TO Brian and
that his feedback advances or closes. Where the dream cycle's reflect() gives
isolated, overwriting reflections, the thought loop adds continuity (threads),
surfacing (#6 — she leads with a thought when Brian returns after a gap), and a
feedback loop (his reply folds in next pass).
- lyra/thoughts.py: thought_threads + thoughts tables; think() with
new/continue/respond modes; salience-gated maybe_surface(); record_response()
feedback; lazy-schema _c() mirroring poker.
- dream.py: curiosity stage advances the loop after reflecting (error-isolated).
- chat.py: build_messages surfaces the top thread after a >=90min gap, once.
- web: /thoughts feed (page + data + respond + status routes), thoughts.html,
nav 💭 entry. lyra-think entry point. Every thought also lands in her journal.
- clock.gap_seconds(); tests/test_thoughts.py (8 tests). Full suite 58 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Quick-logged hands (log_hand) store flat fields with no structured JSON, so the
hand viewer dead-ended with "no structured data to replay" — even when the full
street-by-street action was captured (e.g. the KhQh-vs-Louis hand). Now:
- hand.html renders a readable STATIC view of any flat hand (hero cards, board,
street narratives, result, lesson) instead of erroring; also handles empty/garbage
structured rows by falling back to the flat view.
- "▶ Build replay" button + poker.reconstruct_hand + POST /hand/{id}/reconstruct:
parse a flat hand's narrative into structured form on demand, making any
quick-logged hand replayable without an LLM call per log during live play.
- test_modes.py +1 (reconstruct wiring).
(Also reconstructed the two live Meadows hands and removed one empty hand in the DB.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- View any past session as a read-only HUD: /session?id=N (hud(session_id) +
/session/data?id=); /history rows now link there. Closed sessions show played
duration + final net; recap link when one exists.
- Edit session details during or after play: poker.update_session (recomputes net
when buy-in/cash-out change), PATCH /session/{id}, an update_session tool ("venue
was actually Bellagio", "I bought in for 600"), and an inline ✎ Edit form on the HUD.
- Rituals attach to the most-recent session post-close (poker.review_session_id),
so scar/confidence/reset work while reviewing after you rack up.
- Edit form is poll-safe (won't clobber mid-edit); past-session view doesn't poll.
- test_modes.py +3 (edit, review rituals, past-session HUD); 49 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously the only delete was whole-session, so a mis-logged stack or a
mis-parsed hand was stuck on the HUD. Now:
- undo_last tool ("scratch that") — deletes the most recent hand/stack/read/
scar/confidence/reset in the live session; added to the Cash toolset.
- poker.delete_hand/stack/read/ritual + delete_entry dispatch + undo_last.
- DELETE /session/entry/{kind}/{id} endpoint.
- HUD: per-row × delete buttons on hands, confidence-bank, and scar-note rows
(stack/read deletes via the tool). Row ids now surfaced in the hud() bundle.
- test_modes.py +2 (undo_last across kinds, tool handler); 46 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Answers three gaps: no way to delete a single poker session (only clear_all),
no way to browse past sessions, and Lyra could only see aggregate stats.
- poker.list_sessions() (per-session summary + hand count + recap flag) and
poker.delete_session() (removes a session + its hands/reads/observations/
stacks/rituals; keeps the persistent villain file).
- /history page (date, stakes, venue, net, hours, recap link, per-row delete with
confirm) + /history/data + DELETE /history/{id}. Nav links from chat + HUD.
- recent_sessions read tool, added to the shared lookups so Lyra can answer
"how'd my last few sessions go?" in either mode.
- Delete is UI/CLI only — deliberately not a Lyra tool.
- test_modes.py +2 (list/delete, recent_sessions); 44 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
She could write everything the HUD shows but not read most of it back (stack,
live net, alligator state, scar/confidence entries) — so "what's my live net?"
or "what's in my confidence bank?" was a memory guess.
- session_state tool returns the same bundle the HUD renders, as a readable
summary; added to the Cash toolset.
- Cash card tells her the HUD exists, that she and it share the same data, and to
answer where-am-I questions from session_state, never memory.
- test_modes.py +1; 42 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brian's own rituals (mined from his logs) become first-class, live tools instead
of post-hoc recap sections:
- Scar Note — instructive mistakes with the punt/cooler/standard distinction.
- Confidence Bank — good process, banked regardless of result.
- Alligator Blood — invokable adversity state; she suggests it when he's
card-dead/short/stuck, and her coaching register shifts while it's on (live
state injected into context per-turn via chat._mode_state_note).
- Reset — tilt circuit-breaker; mental marker only, stats stay continuous.
poker_rituals table + log_ritual/list_rituals/set_alligator/alligator_active;
4 tools added to the Cash toolset and taught in the mode card; HUD gains a 🐊
banner + Confidence Bank + Scar Notes panels; recap grounded via _rituals_block.
tests/test_modes.py +5 ritual tests; 41 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brian can rate Lyra's outputs as he uses her; each rating is stored as a
(context, content, rating) triple — the shape a future fine-tune / preference
dataset wants, collected passively during real use.
- memory: ratings table + add_rating (upsert: one row per item, re-rating
replaces), list_ratings, rating_counts
- server: POST /rate, GET /ratings/counts, GET /ratings/export (JSONL download)
- chat UI: subtle 👍/👎 on each assistant reply, captures the prompting message
as context
- journal/reflection UI: 👍/👎 on each thought
- tests: counts + upsert-replace behavior
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lyra was hallucinating poker facts — phantom flushes, missed straights, wrong
equity, only correcting when spoon-fed. Board reading + equity are combinatorial
facts an LLM can't do reliably; this is exactly the "math via deterministic
tools, never the LLM" principle.
- lyra/equity.py: treys-backed analyze(hero, villain, board) -> made hands,
who's ahead, EXACT equity (enumerated), and outs (one to come). Handles 'Jx'
unknown suits (assigned rainbow to avoid phantom flushes); rejects 'x'/dupes.
- analyze_spot tool wired into chat; persona MANDATES it for any equity/board/
who's-ahead/outs question — never eyeballed.
- tests on the real JJ-vs-65 hand: flop 78.7%, turn villain straight + hero 6.8%
with outs "9s 9h 9c" (correctly excludes 9d, which makes villain a flush).
Verified live: she now calls the tool and reports exact numbers, no hallucinated
flush.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>