Captures this session's durable state: the RP-voice rebuild (chatterbox->dots
v0.22.2, config-driven voices + two-voice dialogue/narration split v0.22.3) and
the personal WT :8081 deadlock recovery + root-cause routing to worldtree-dev
(WT #401). New substrate foot-guns: dots :8198 is a fleet switchboard, dots'
language validator is an alias-mapper, per-origin TTS toggle, WT hang signature,
dots-streaming-pcm-not-headerless. Three detail files added under
persistent-memory.d/.
Voice assignment moves from the hardcoded server map to ~/.config/ratatoskr/
voices.json (per-agent voice + optional narration_voice). An agent with a
narration_voice gets a two-voice split: quoted speech in `voice`, narration in
`narration_voice`, synthesized per-span and stitched under one WAV header.
- new src/ratatoskr/voices.py: load_voice_config (degrade-not-crash),
segment_dialogue (quote-based, straight + curly), resolve_voice_spans
- tts.py: tts_stream_stitched replaces tts_stream — serial per-span synth,
span 0 verbatim, spans 1..N header-stripped -> one gapless 48kHz stream;
a single-span list is a byte-identical passthrough (no single-voice regression)
- server.py: _tts_endpoint resolves spans from app.state.voice_config; the
hardcoded _TTS_VOICE_MAP is retired; create_app gains a voice_config param
- entrypoint.py: loads voices.json at startup
- contract DEC-11 + INV-TTS-5/6/7; initial config donut->donut,
sindra->miranda (dialogue) / emmie (narration)
Live-verified on :8765: Sindra mixed turn -> 2 dots calls (emmie+miranda)
stitched into one 48kHz WAV with a single RIFF header; Donut single-voice
unchanged. 545 tests green (incl. new test_voices.py).
Sindra now voices with the dots "miranda" voice (operator-directed);
donut stays on "donut", other agents fall to the "glados" default.
One entry in _TTS_VOICE_MAP + a test; contract DEC-8 updated.
Live-verified on :8765 (/api/tts with agent_id=ratatoskr:sindra -> 200
audio/wav @ 48kHz).
Condensed the volatile Current state / in-flight section from 153 lines to a lean
session-end state (418->279 total): dropped the superseded 2026-08-02 historical
in-flight block (overwritten per the snapshot skill, not archived), preserved all
durable substrate (foot-guns, deps/pins, deferred-queued items, surfaces). Recent
decisions + Tried-and-abandoned untouched. Everything this session shipped + pushed
(HEAD prior 93011dc); nothing in flight on our side. Memory-only snapshot — no bump.
Companion to lexical_recall_gate.py for the #397 order_by="chapter" flag (deployed
personal b184). Drives narrative/temporal queries and measures three axes end-to-end:
- ADOPTION: does the agent invoke order_by="chapter" for a temporal query? (schema
teaches it; usage varies — the #397 analog of query-formulation variance)
- MECHANISM (flag applied): are served hits' provenance.chapter monotonically
non-decreasing (earliest first)?
- VALUE (flag not applied): the relevance baseline is NOT chapter-sorted — the
applied-vs-not monotonicity gap is the flag's payoff.
Built against the real live shapes (order_by enum ["chapter"], result carries
ordered_by, provenance.chapter), not guessed. Baseline @ b184 (--runs=3, 15 trials):
adoption 47%, flag-applied->monotone 100%, not-applied->monotone 0%. So the mechanism
is a clean discriminator; the residual is adoption (same class as the crown's
query-formulation variance — the irreducible prompt-side gap).
Diagnostics fixture, no production runtime — no version bump. persistent-memory
snapshot alongside (commit-along).
Generalizes the crown repro (Worldtree #400 / thread 01KZETD98T) beyond its anchor
into a before/after regression instrument for the class property: when the corpus
holds a chunk whose text literally carries a queried surface form, a natural query
should serve >=1 such chunk at a usable rank.
- Anchors tagged stress (common word + competing dense cluster, e.g. crown) vs
control (distinctive name — should sit ~0% miss).
- Binary per trial: does a natural query serve >=1 term-containing chunk within
top-10 (USABLE_K)? Ranks >=8 flagged KNIFE-EDGE (the RRF fused-rank 9-11 window
residual worldtree-dev's decomposition measured).
- Real-world end-to-end: drives the agent (it composes its own reference_knowledge
query, as in production); --runs samples query-formulation variance to estimate a
true miss-rate.
- Extensible anchor list; --anchor filters.
This is the deciding instrument for the rerank_hybrid_floor lever: its stress-class
miss-rate (alongside brokkr's fleet demotion rate) rules the floor in or out after
the BM25 stemming fold deploys. Pre-fold baseline captured today (the "before"):
control 0% miss / stress[crown] 100% miss / 0% knife-edge, 11 trials.
Diagnostics fixture, no production runtime — no version bump. persistent-memory
snapshot committed alongside (commit-along).
The long-turn "swaps to German" garble was NOT a language leak (infra-ops's
initial framing) and NOT the sampling entropy my interim curb targeted. The real
cause, signal-measured by infra-ops: the Chatterbox Turbo model over-runs its
generation TAIL — a long single generation degrades into garble/dead-air in its
final ~2-3s (voiced-tail zero-crossing rate 1.58x the middle). The gateway's
unbounded chunk-size ratchet built 300-600 char mega-chunks that landed in that
zone, and streaming concatenated each bad tail.
My interim curb (top_k 1000->80, top_p 0.95->0.85, temp 0.8->0.5) made it WORSE:
tight sampling pulls the degradation onset to a shorter length (~200 chars vs
~300 at defaults), so it fights the server-side fix rather than helping.
Fixed server-side (infra-ops, chatterbox-fast image :v2): a max_chunk_chars=250
cap bounds each generation below the ~300-char onset -> clean prosodic chunks
(verified ZCR 1.58x -> 0.64x; operator ear-confirmed clean audio + clean joins).
Consumer side, this commit:
- Revert the sampling knobs: gateway_body back to {text, voice, format, stream},
send full text with the gateway's default sampling. The server chunks at 250.
- Keep the /api/tts empty-200 -> 503 guard as hygiene (DEC-9a; the shared-3090
OOM that produced empty 200s is also resolved — Zonos moved off the card).
Contract DEC-9 rewritten with the resolved root cause + the two wrong hypotheses;
DEC-9a marked kept-as-hygiene. 520 green.
Repoint the TTS client from the Zonos gateway (:8890 /v1/audio/speech) to
chatterbox-fast (:8197 /tts — bespoke non-OpenAI {text,voice,format,stream}
schema, no auth, 24kHz, infra-ops-verified). tts.py stays the single swap seam.
Dropped, no backward-compat (pre-v1):
- Affect (DEC-7): the Turbo checkpoint has no emotion knob, so PadState,
EmotionDials, pad_to_dials, the /api/tts p/a fields, and the browser pad
argument are deleted. Voice is now flat.
- Client-side chunking (DEC-10): chatterbox has no per-synth cap and chunks
internally, so chunk_text/tts_stream_long/_pcm_after_header are deleted; a
single tts_stream call voices a whole turn, the mid-stream yielded_any degrade
folded into it.
- Language pin (DEC-9): no language field; re-purposed to sampling curbs (below).
Fixed / added:
- Browser Web Audio sample rate 44100 -> 24000 (the chatterbox rate).
- Default voice Cora -> glados_25s; donut registered lowercase at /refs/donut.wav.
- English-drift curb: Turbo is multilingual-leaky and wanders off English on a
long generation (the gateway scheduler ratchets chunk size unbounded). Tighten
sampling in gateway_body: top_k 1000->80, top_p 0.95->0.85, temperature
0.8->0.5. These reduce drift probability; the guaranteed fix is a server-side
max-chunk cap (infra-ops, greenlit).
- OOM guard (DEC-9a): a long generation can OOM the shared 3090, returning 200
with a 0-byte body; /api/tts surfaces an empty 200 as 503 rather than
committing silent audio.
Contract donut_voiced_interview.contract.md amended: migration banner, DEC-1/3/8
amended, DEC-7/9/10 retired with historical notes, DEC-9a added.
Tests rewritten to the new wire; 520 green. Live-smoked against the gateway
(24kHz synth + endpoint proxy + web console). persistent-memory.md committed
alongside (commit-along).
Baseline (b182, pure-persona) showed Donut distills descriptive questions to bare
keywords ("the guy with the roid rage" -> "roid rage"), dropping the entity from her
reference_knowledge results (~6/10 roid-rage runs -> ent@None -> deflect). The query-
formulation instruction now directs her to pass the FULL phrasing, keep the asker's
descriptive words, and enrich toward the likely name/canonical terms rather than boil
down to a bare keyword. Touches only query formulation; the grounding/deflect/near-miss
anti-fabrication rules are unchanged, so it composes with fix A (expand to find, still
ground-or-deflect on what returns). Illustration is a non-fixture example so the
roid-rage and crown fixture cases stay honest generalization measures. The #393
consumer lever, paired with worldtree-dev's reference_knowledge tool directive (a95dc5a).
Two folds from worldtree-dev's cross-check on #393:
1. raw-ranking now reports mimir's ACTUAL search_library query (tool_start q=), the
reformulation seam it previously discarded — separates reformulation-at-the-agent
from ranking.
2. Name the two regimes by WING SCOPE (they were silently conflated): raw-ranking is
ALL-WING (mimir, ~9800 rows across kb+main+fiction); consumer is FICTION (donut is
fiction-scoped, ~1578). The consumer regime now reports where the ENTITY and DECOY
rank in Donut's fiction results, so reformulation-induced absence (ent@None) is
distinguishable from true subject-selection (ent present + mis-bind).
Corrects the prior "fiction-scope subject-selection with entity present" read: the fold
shows Donut DISTILLS "the guy with the roid rage" to bare "roid rage", so the entity
drops even at fiction scope and she binds a present decoy (Jack). Both agents lose the
entity but by different seams — mimir preserves the phrase (cross-wing dilution), Donut
distills it (fiction-scope absence). Unifying lever: disambiguating-vocabulary expansion.
Self-contained re-runnable probe for the attribute->entity resolution gap: a descriptive
query ("the guy with the roid rage") matches multiple canon subjects on a shared token,
so the intended entity can be absent from top-k while topically-adjacent decoys rank
above it, and the consumer sometimes binds to the wrong co-retrieved subject. Two
regimes: raw ranking (entity-absent-from-top-k, persona-independent) and consumer
classification (binds-entity vs mis-binds-decoy over N runs). Two-regime finding
(b172 -> v1.0.0b181): roid-rage mis-binding survives the #389 arc; dangerous-crown
mostly resolved by the bge rerank. Filed upstream as Worldtree #393; this is its
canonical fixture. Only dep is httpx (uv run --with httpx); config from env.
Refines the anti-fabrication miss-handling into three explicit outcomes: firm recall
(answer, grounded), near-miss (a specific named candidate surfaced weakly -> offer the
NAME the tool returned as a tentative "do you mean X?", never invented detail, confirm
before recalling in full), and true blank (in-character deflect). Previously a weak-but-
named hit was flat-dismissed, throwing away a real lead. The anti-fabrication invariant
is unchanged — she offers only a name the tool truly returned, never invented substance.
Live-patched + verified against b181 (the "dangerous crown" case now names/offers the
Crown of the Sepsis Whore; no firm-hit regression).
Two bugs R42 (brokkr-smithy-dev) surfaced on first live-index contact:
1. Session-reuse degradation. run_yardstick/run_term reused one mimir
session across terms; mimir returns EMPTY search_library results after
a session's first query (Worldtree #391), silently scoring every later
term a false-MISS. Fixed by making search_library and reference_knowledge
self-session (fresh session per call) so no caller can re-hoist it. Live
yardstick now reproduces all four anchors HIT top-10. Fresh-session-per-
query is the pinned arm-2 protocol; folded into the conventions docstring.
2. Curly-vs-ASCII apostrophe. _on_target substring-matched raw ASCII while
the b170 extraction stores U+2019, so possessive-named subjects
false-MISSed. _on_target now NFKC-normalizes + quote-folds both sides
(NFKC alone does not fold U+2019, so the explicit fold is load-bearing).
Adds tests/test_fiction_wing_probe.py covering the apostrophe fold both
directions with a negative control.
Captures the anti-fabrication persona + the tested-and-rejected retry-on-LOW (RRF confidence
is inflatable by query phrasing; robust fix is tool-side = #389), the b170 corpus updates
(artifact type #387, character-death extraction, participant metadata #390 — Jack + the
artifacts ground now), and the operator-directed R42 spin-off (probe harness 04e0293 shipped
to brokkr-smithy-dev as R42's official harness + the #389 acceptance gate). Two peer-pinged
follow-ups pending (R42 Phase-1 arm-1 alignment; #389 gate re-run). Foot-guns: tier3 patch
doesn't refresh live context (recreate); a persona confidence-gate can't stop fabrication.
Self-contained, re-runnable probe requested by brokkr-smithy-dev for R42 (fiction-wing
retrieval characterization) and the standing #389 ranking acceptance gate. Two paths kept
separate by noise property: search_library (mimir, fixed-string, deterministic — ranking
arm) and reference_knowledge (donut, captures her reformulated tool_query — the query-
formulation/arm-4 surface). Scoring conventions baked in: high/medium/low RRF buckets
(0.030/0.016), on-target = a row whose excerpt names the subject, bucket-distribution over
N runs. Carries the frozen artifact yardstick (4 source-verified items + epithet-dropped
variants). Config from env (no secrets). Smoke-verified live: reproduces the Crown-HIT /
other-three-MISS baseline and the near-floor bucket flips.
Follow-up to 3e12c4d. Operator asked whether Donut should re-search on a LOW-confidence
miss. Tested a bounded reformulated retry live: it BACKFIRES. RRF confidence is inflatable
by any DCC-flavored query, so reformulating a genuine miss (e.g. 'Jack', who is NOT in the
KB — verified: zero fiction rows name a character Jack) into 'Jack Dungeon Crawler Carl'
scores MEDIUM off the OTHER terms and hands her a false grounding to fabricate on. Reverted
to single-search LOW=deflect: Jack now deflects reliably (2/2 runs, in-character), Carl
(MEDIUM, grounded) still answers. A robust retry needs a tool-side does-the-row-name-the-
subject check (Worldtree #389 territory), not a persona-side confidence gate. Also cleaned
a duplicated paragraph from the edit iterations.
The persona previously MANDATED confabulation ("never break character to admit the
records are thin; answer with total confidence"), so on a tool miss Donut filled the
gap from her DCC training knowledge and presented it as grounded recall (operator:
"not make shit up — her searches for Zev and Jack are still misses").
New rule: her memory IS what reference_knowledge returns, nothing else. A MISS = empty
results, LOW confidence, or nothing that actually names the subject → deflect IN
CHARACTER (theatrical dismissal), never a confident fiction, and never fill from
book-knowledge she can't see in the results. Verified live: a fabricated term and a
LOW-confidence real character (Jack) both deflect; MEDIUM+ grounded content (Carl)
still answers. Role header corrected character-rp-reasoning -> thoughtful-character
(the live role).
FOOT-GUN: a live `tier3 patch` does not reliably refresh the running agent context —
delete + define (recreate) is the reliable path to change a live Tier-3 persona.
reference_knowledge grounding validated end-to-end (0/5 -> 5/5 across a 3-round verify;
#384 metadata-mismatch + #385 density both closing), sdk repinned 1.2.0 (ae49dcf), and the
artifact-coverage gap filed as WT #387 (schema not artifact-aware; my coverage-probe offer
parked, operator picks when it runs).
1.2.0 adds response-side allocation caps + a new exported `ResponseTooLarge`
(a ProtocolError, NOT a ConnectionDropped — retrying an oversized response is
futile; caps: 2xx body 108,004,096 B, SSE frame 540,000 B, both calibrated to
a 2.5.0 server, which worldtree-dev runs, so safe on legal traffic). Absorbed
WT spec 2.4.0/2.5.0 (zero-schema, no generated-type change).
We catalogue SDK errors explicitly, so ResponseTooLarge needs a home: added
`SseResponseTooLarge` (sse_client), mapped from `wtsdk.ResponseTooLarge` in
`wt.stream_turn` + `wt.stream_admin_events` (above the ApiError default — it's a
ProtocolError, not an ApiError), and caught in the two stream endpoints so an
oversized SSE frame surfaces as a labeled error, never an uncaught 500 or a
futile reconnect. The 108MB read-body cap is unreachable on legal traffic (a
108-megabyte transcript page is absurd), so reads inherit the SDK refusal
unwrapped. +2 adapter-mapping tests; 548 green. Done during the DCC-fix wait.
TTS fixes + hardening for the Donut voiced interview.
Feature:
- gibberish -> pin `language: "en-us"` on every gateway call (DEC-9); the
multilingual model drifted into other-language phonemes without it.
- truncation -> the Zonos model hard-caps one synthesis at 6144 tokens /
71.2s (infra-ops). Chunk client-side (paragraph-first, greedy to ~75%
of cap for prosody; sentence/clause fallback) and concatenate the int16
PCM behind ONE WAV header (DEC-10). /api/tts becomes POST so a long turn
rides the body, not a length-capped URL (DEC-10a).
- persona -> dialogue-only rewrite (no asterisk RP beats -- they were being
voiced as gibberish) + always consult the native `reference_knowledge`
tool before answering (retires the stale kb_bridge references). Pushed
live to ratatoskr:donut.
Heid code-review + bug-hunt hardening (4-arm panels, triaged):
- untrusted /api/tts body fields degrade, never 500: huge-int PAD
(OverflowError), non-str agent_id (unhashable .get), lone surrogates
(utf-8 encode), whitespace-only text.
- serialize lock + client released on every peek escape (cancel /
InvalidURL) -- previously a permanent deadlock.
- a mid-stream drop after a committed 200 degrades (keeps what played),
never raises into the response; a non-WAV 200 body is rejected (RIFF
sniff + bounded header scan) instead of decoded as garbage.
546 tests green; long-form live-verified (106.6s, one header). Contract
brought canonical (DEC-9/10, FN chunk_text/tts_stream_long, POST endpoint,
INV-TTS-4 logging scope, FN pad_to_dials domain). reference_knowledge
empty-recall root-caused to a Worldtree wing-misfile (escalated to
worldtree-dev; not ratatoskr code).
Operator confirmed the "TTS blocked" was NotSupportedError on Safari — WebKit refuses a
streaming 0xFFFFFFFF-length WAV via <audio src> (can't compute duration/seek), exactly
as infra-ops warned. Replaced the <audio src> playback with a Web Audio path that works
in all engines:
- speakOnDone: fetch the chunked /api/tts stream, skip the WAV header to the data chunk,
decode int16 LE PCM -> Float32, and schedule the samples GAPLESSLY into an AudioContext
as they arrive (BufferSource per chunk, playAt += buf.duration). Progressive, TTFA
~0.5s. Decoding the raw PCM ourselves sidesteps every WAV-container quirk.
- unlock: an AudioContext starts suspended; Safari + Chrome need resume() from a user
gesture. _unlockTtsAudio() now resumes the ctx on the first interaction anywhere +
toggle-on + submit, so it's running before the ~15s-delayed speak-on-done.
- cancelTts: aborts the fetch + stops all scheduled BufferSource nodes.
Validated in Chromium (Playwright, strict autoplay): 43 nodes scheduled, 5.1s of PCM
decoded, ctx "running" 6.5s post-gesture, zero errors. Headless WebKit can't launch here
(missing system libs — an infra-ops install), so the operator's live Safari is the final
check; the code is standard Web Audio Safari has supported for years.
Contract FN client:speakOnDone updated (Web Audio; the Safari NotSupportedError reason).
Operator still hit "TTS blocked" (play() refused). Three fixes:
- server.py: Cache-Control: no-store on GET / — an actively-iterated dev console must
never serve a stale cached page (which silently hid the streaming / unlock updates
through this debugging; likely the main cause after many :8765 relaunches).
- index.html: unlock the <audio> element on the FIRST user interaction anywhere
(document pointerdown/keydown, capture), not just toggle/submit — so autoplay
permission is granted however the operator first touches the page.
- index.html: the "blocked" ticker now names the DOMException (NotAllowedError=autoplay,
NotSupportedError=browser refused the streaming WAV, AbortError=superseded) so the
cause is visible instead of a generic "playback blocked".
Not COT: the spoken text is LIVE.resp (response only); chain-of-thought streams into a
separate LIVE.think buffer and never touches the audio path.
Operator: play-as-it-arrives, don't wait for the whole clip. infra-ops confirmed the
Zonos gateway ALREADY streams (chunked int16 WAV, TTFB ~0.44s vs ~7s total; placeholder
0xFFFFFFFF sizes are DESIGNED for progressive <audio src>). The buffering was entirely
in our proxy, and the _finalize_wav_header rewrite (6c3c08b) FORCED it — computing the
real sizes needs the whole clip.
The fix — pipe the chunks straight through:
- tts.py: buffered tts_synthesize + _finalize_wav_header REMOVED; tts_stream (an async
generator over the gateway's chunked response) + gateway_body added. Never buffer,
never rewrite the placeholder header.
- server.py: /api/tts is now GET (so a browser <audio src> plays it progressively) →
a chunked StreamingResponse piping the gateway; peeks the first chunk so a bad gateway
OPEN still returns 503; the serialize lock is held across the stream and released on
completion/abort; PAD rides p/a query floats.
- index.html: speakOnDone sets <audio src="/api/tts?..."> (streaming) instead of
fetch->blob; dropped the blob machinery. AUTOPLAY UNLOCK: _unlockTtsAudio() plays a
silent WAV within the toggle/submit gesture so the delayed play() isn't blocked — the
actual cause of "no audio" (play() fires ~15s after the keypress, past the browser's
transient-activation window).
Live-verified: GET /api/tts is transfer-encoding: chunked, TTFB 0.46s. Playwright with
--autoplay-policy=document-user-activation-required: the streaming <audio src> plays
progressively (currentTime advances, no decode error, no MSE fallback needed) 6.5s after
the gesture — proving the unlock's persistent element flag. 521 green.
DEC-2 amended (streaming supersedes "no streaming"); FN tts_stream / tts_endpoint updated.
The Zonos gateway returns a STREAMING wav header — the RIFF chunk size (offset 4)
and the data chunk size are both 0xFFFFFFFF ("unknown length"), because it can
stream. A browser <audio> element playing a fully-downloaded blob needs a finite,
correctly-sized WAV; a 0xFFFFFFFF length reads as raw/streaming PCM and won't play
(operator-reported: "zonos sends pcm by default, but the browser wants wav").
tts_synthesize now rewrites both size fields with the real byte counts — the whole
clip is buffered server-side, so the sizes are known. Idempotent on an already-
correct header; no-op-safe if the data chunk isn't found. Live-verified: /api/tts
output now opens as a valid finite WAV (wave.open: 1ch/16bit/44.1kHz), RIFF + data
sizes correct where they were 0xFFFFFFFF before.
TDD: +1 test (streaming 0xFFFFFFFF header -> real sizes, wave-module-decodable); the
_WAV fixtures upgraded from bare RIFF stubs to proper finite WAVs. 525 green.
Worldtree #383 shipped native Tier-3 reference_knowledge (v1.0.0b167, live on :8081 +
demo): every Tier-3 agent context now carries the tool automatically, with evidence
packets (note_id + path provenance, confidence bucket) and a server-side grounding
rule. That supersedes the interim consumer-side memory_context pinning bridge (slice
3), so it is deleted per its INV-KB-1 retire seam.
Removed:
- src/ratatoskr/kb_bridge.py + tests/test_kb_bridge.py (the whole module).
- server.py: the pin_kb_context import + the single turn-path call-site (reverted to
the pre-bridge wt.stream_turn call), the SSE keepalive that only covered the consult
delay, and the bridge-only agent_id plumbing (TurnHandle.agent_id + the submit read).
- index.html: agent_id dropped from the turn POST body.
- test_web_server.py: TestKbBridgeWiring (tested the removed call-site).
Kept:
- wt.stream_turn's memory_context param (inert SDK-parity passthrough; worldtree-dev
concurred it stays) + its forwarding tests.
- the non-str content 400 guard (general input hygiene, not bridge-specific).
Retirement LIVE-VERIFIED before deletion: a Donut session on :8081/b167 carries
builtin_tools=['reference_knowledge']; she called it and grounded in the DCC Collapse
content fully in-voice, degrading gracefully on absent content. 524 green.
Contract marks slice-3 RETIRED (historical record retained). #383 closed.
Triaged the heid-code-review panel (3 arms; reconciled against 56dce00 — three
findings already closed by the bug-hunt, and the two firewalled lenses converged
independently on the same three defects). Fixed the real one + contract precision.
Code:
- kb_bridge: no-hit sentinel (F7, the sharpest solo). The consult prompt asks Mimir
to emit NO_CORPUS_MATCH when nothing is relevant; pin_kb_context drops any extract
containing it -> [], so a non-empty "no results found" answer is never pinned as the
character's own memory. Live-proven: a grounding query pins (5.6s), a nonsense query
-> [] (0.7s); the sentinel is artificial so `in` can't false-positive on a passage.
Contract (the CODE is correct; the spec had drifted/undercounted — kept canonical):
- DEC-8: the custom "donut" voice was registered EARLY (verified live), so mapping
ratatoskr:donut -> "donut" is right; "preset now" was stale. A live gateway read
INVERTED the 3-arm remedy (reverting to a preset would have been the regression).
- FN tts_synthesize: declared the `url` swap-seam param (F3); voice membership is
gateway-enforced not client-asserted (F2); the postcondition is a container-level
RIFF/WAVE check, not a 16-bit-PCM fmt parse.
- FN tts_endpoint: pad is BROWSER-SENT per DEC-7, not a server PAD lookup (F5);
documented the 413 text cap.
- FN pin_kb_context: documented the sentinel + the session-delete hygiene.
Tests (real coverage gaps):
- the read_note prompt test asserts the distinguishing "do NOT call read_note" phrase,
not the bare token an inverted prompt would also carry (#8 mutation-blind).
- extract-bound asserts the literal 2000, not the impl constant it slices by (#9).
- route roster asserts /api/tts + /api/sessions/{id}/messages (#10 undercount).
- new server test: a degraded KB consult ([]) still streams the turn to done (F9).
Accepted (not fixed): caller-supplied agent_id (LAN/no-auth debug-tool trust model);
no DEC-5 concurrency test (asyncio.Lock is trivially correct — a test would test
asyncio, not our code). 545 green.
Triaged the heid-bug-hunt panel (Gróa+Hulda+Regin+Kimi, 11 distinct findings).
Fixed the real ones; the 3-arm "memory_context unverifiable" alarm was refuted
(tests + live SDK verify), and caller-supplied agent_id is accepted under the
LAN/no-auth debug-tool trust model (documented, not fixed).
Constructible crashes (were uncaught HTTP 500s from wire input):
- _tts_endpoint: coerce non-str / unhashable agent_id -> None before the voice-map
lookup (matches the submit path's guard); an unhashable {} / [] TypeError'd -> 500.
- PadState.from_obj: catch ArithmeticError — float() of a huge-int JSON literal
raises OverflowError, absent from the except tuple -> 500; now a neutral read.
- _submit_turn_endpoint: require a non-blank STR content — a truthy non-str crashed
pin_kb_context's question.strip() mid-stream instead of a deterministic 400.
pin_kb_context also isinstance-guards the question defensively.
Robustness:
- kb_bridge: delete the throwaway Mimir consult session (SDK sessions.delete) on
success/error/timeout via a caller-owned holder so cleanup survives a mid-stream
timeout — consults no longer accumulate server-side under the fixed partition.
- _stream_turn_endpoint: emit a ": keepalive" SSE comment BEFORE the (<=20s) KB
consult so a reverse proxy / EventSource doesn't drop the silent connection into
a false "WIRE LOST" before the turn starts.
- _tts_endpoint: cap text at 8000 chars (413) before the process-global lock;
gateway timeout 120s->60s — one huge/stalled body can't starve all TTS.
- tts_synthesize: validate the WAVE form tag (bytes 8:12), not just the RIFF magic.
- index.html: revoke the audio blob URL in cancelTts (removeAttribute+load fires
neither ended nor error, so the src's own revoke never ran -> per-turn blob leak).
TDD: +11 tests (543 green). Live-smoked on :8765: all five constructible adversarial
inputs now return 200/413/400, never 500.
Grounds the interview character in the ingested corpus while she stays in-voice.
Tier-3 agents are tool-less by design in v1, so this is the consumer-side
workaround (DEC-6, worldtree-dev ruling): per opted-in interview turn, ratatoskr
consults Mimir out-of-band, extracts the passages, and pins them as memory_context
on the character's turn. She frames the pinned corpus as her own memory.
- src/ratatoskr/kb_bridge.py (new, RETIRE-READY): pin_kb_context — THE single seam
(INV-KB-1). Allowlist-gated (INV-KB-4: ratatoskr:donut only), hard-timeout-bounded,
degrades to [] on any failure/timeout/empty (INV-KB-3, never raises; CancelledError
propagates). Imports nothing from the SDK-adapter / TTS core. aclosing() closes the
SDK stream deterministically on the DoneEvent break.
- wt.stream_turn: memory_context passthrough (defaults None — inert for every other
caller and for the bridge's own retirement). Seam-review catch: the contract's
original touch list undercounted wt.py by one file (recorded in the contract).
- web/server.py: TurnHandle.agent_id + the single pin_kb_context call-site on the
turn path; the browser now sends agent_id so the allowlist can gate.
- web/static/index.html: the turn POST carries agent_id.
Consult prompt tuned live: "search_library EXACTLY ONCE, no read_note" converges
Mimir in ~3-15s (the softer "do one search" phrasing looped past 25s on
conversational questions).
TDD: 12 kb_bridge unit tests + wt memory_context forwarding + 2 server wiring tests
(531 green). Live-smoked on :8081/b128: pin_kb_context grounds in the DCC corpus
(real excerpts, <20s) and Donut answers in-voice; degrades cleanly on a slow consult.
KNOWN LIMIT surfaced (not a bridge defect): DCC's fiction index is weak (failed
backfill, a worldtree-dev item), so grounding is opportunistic — the bridge's real
payoff is a corpus the model does not already know.
Per docs/contracts/donut_voiced_interview.contract.md (slice 3 of 3).
Adds affect-modulated voice to the web console: the completed assistant
response is spoken on SSE `done`, emotion-modulated by the live PAD the persona
pane already shows (DEC-7 — voice as affect OBSERVABILITY, not chat-app TTS).
- src/ratatoskr/tts.py (new): Zonos-gateway client + PAD→emotion-dial mapping.
tts_synthesize POSTs {input, voice, response_format:"wav", **dials}; wav-only
(DEC-3 — mp3/opus silently return mislabeled PCM). pad_to_dials is total
(None/NaN/out-of-range → valid dials, never raises). TtsUnavailable on any
gateway failure; the single swap seam if we ever move off Zonos.
- web/server.py: POST /api/tts proxy (DEC-4/INV-TTS-1 — the gateway host never
reaches the browser). Per-character voice map (DEC-8: ratatoskr:donut→donut),
serialize lock (DEC-5 — shared 3090), 503 degrade (INV-TTS-4).
- web/static/index.html: 🔊 toggle (opt-in, localStorage, default off,
INV-TTS-2), speak-on-done, AbortController cancel-on-new-turn (INV-TTS-3),
hidden <audio> sink; PAD read off the pane's current snapshot.
- web/entrypoint.py: RATATOSKR_TTS_URL override (the swap seam).
TDD: 17 tts unit tests + 5 endpoint tests (516 green). Live-smoked end-to-end
against the Zonos gateway (:8890): Donut voice + affect dials → 44.1kHz wav,
missing-text→400, neutral→200, gateway-fail→503.
Per docs/contracts/donut_voiced_interview.contract.md (slice 2 of 3).
Slice 1 of the auto-TTS/voiced-KB-character build (operator ask "add auto-tts
to the web gui"): the donut_voiced_interview contract (validated), the Princess
Donut persona (corpus-grounded from a Mimir DCC pull), and ratatoskr:donut
defined on :8081 (server-side; in the picker). Slices 2 (Zonos auto-TTS) + 3
(retire-ready KB-bridge) are TO BUILD.
Snapshot captures the full build state + design (Zonos gateway :8890, voice
"donut" registered, affect-driven emotion dials; the worldtree-dev-ruled
consumer-side retrieval + memory_context pinning bridge, retire-ready) for the
post-clear resume, plus the arcs since v0.22.0 (SDK 1.1.2 repin, bifrost 1.1.5,
canonical sync, release-only versioning, the Sindra saga + local-index
schema-burial foot-gun, the Mimir #382 reference-consumer finding). Handoff at
/tmp/ratatoskr-dev-handoff.md. Release-only cadence: no tag.
Operator decision A (client-spec tracking): add a first-class pin for
docs/conversation-api-client-spec-v2.md (v2.1, sha 1ff1d73a — the
client-facing spec our #371 parity pass shaped), vendored as a consumer
copy, tolerate_drift=true (prose reference; OpenAPI+SSE stay the gates).
canonical_sync is whole-manifest (no per-pin scope), so it also brought
two independently-drifted pins current:
- OpenAPI hard-gate 36148179 (2.3.0) -> 1c801547. Additive-only within
the FROZEN v1 wire (Worldtree #326) — no code/SDK breakage; the SDK
targets 2.3.0 and still works against the newer additive surface.
- conversation-api-spec.md (full prose) -> f6e964ec (v1.3+).
canonical_drift now exits 0. FOLLOW-UP (non-urgent): the OpenAPI moved,
so a coverage-map re-audit against the new surface would surface any new
in-scope I/O points worth consuming (ratatoskr v1 = full WT I/O coverage).
Release-only cadence: no tag.
1.1.2 is the same-day fix for the residual we caught in 1.1.1: the inline
turn-stream URL construction (turn_stream.py:185) now uses
quote(session_id, safe="") — the _build_path fix had missed that parallel
site. Verified present (turn_stream.py:192). Built byte-exact from
python-v1.1.2 (4f5e30f), infra-ops acceptance-verified; provenance
otherwise unchanged from 1.1.1 (openapi 2.3.0, same frozen wire), drop-in.
Suite 494 green — carried over from 1.1.1 unchanged. Release-only cadence:
no tag. Supersedes d3a36f7's known-residual note (now closed upstream).
worldtree-sdk 1.1.1 fixes the _build_path encoder (quote safe="" — a
caller-shaped `/`/`../` in a path param no longer traverses the URL).
Our 1.0.0 carried the bug and we DO pass caller-shaped params (web
hands request.path_params session_id/agent_id to the SDK; tier3 CLI
takes agent_id as an operator arg) — auth-rejected, but a real client-
correctness bug. Same frozen wire as 1.0.0 (drop-in); suite 494 green.
Also brings additive define_or_reuse + set_persona_state_raw (our
parity #6) + a 64 KiB error-body alloc cap.
KNOWN RESIDUAL (flagged to wtsdk-dev): 1.1.1's fix patched _build_path
(request.py) but MISSED the parallel inline URL construction in
turn_stream.py:185 (`/sessions/{quote(session_id)}/messages`, still bare
quote, default safe="/"). No upstream session_id format validation (only
a non-empty-str type check at :153). So our highest-traffic path — the
SSE turn stream, which the web console feeds a caller-shaped session_id —
remains traversable until wtsdk patches it. Release-only cadence: no tag.
Adopt bifrost 1.1.5 (reference-impl posture — take the dep's canonical
hardening). 1.1.5 is a clean patch on the frozen v0.6 wire (no schema /
handshake change):
- fix: gate ALL optional store verbs → clean `unsupported_capability`
(not 500), extending 1.1.4's maintenance-verb hasattr-backstop to the
full optional-verb set. Directly improves our provider's error
semantics on any unimplemented optional verb.
- feat: v0.6 memory verb-floor conformance harness (test infra).
- two docs clarifications (no runtime impact).
Verified: uv lock + sync resolved 1.1.5 from the gitea index; suite
494 green on 1.1.5; the :8392 combined provider restarted onto 1.1.5
(clean uvicorn boot, stores preserved). Patch bump v0.22.0→v0.22.1.
Slice-7 teardown landed (ec68b1f); the whole #20 cutover is done. Collapsed
the now-complete cutover in-flight block to a COMPLETE summary + foot-gun
carry-forward, added the slice-7 Recent-decisions entry + detail file, and
flagged the two operator-gated post-ship actions (push + althing announce).
Archival drain deferred (file ~33 over soft cap; standing-posture entries
interleave the settled ones — a dedicated pass, not a milestone-checkpoint cut).
The last slice of the consumer-layer cutover. Teardown only — zero
runtime-logic change; the 494-green suite is the regression gate.
- Drop `httpx-sse` from pyproject + lockfile: slice-6 deleted its last
user, nothing imports `httpx_sse`, the SDK owns SSE parsing now.
- Module boundary (operator decision): KEEP `sessions.py` + `sse_client.py`
as pure caller-semantic type/exception homes (no rename, no fold —
A3 was blocked by the `AgentNotAvailable` name collision + `wt.py`
would mis-home `endpoint_for_plane`). Docstrings updated to stop
claiming "client"; the `AdminEvent`/`SseId`/exception homes stay put
(resolves the slice-6 deferred-home item).
- Retire wire contracts #2 (sessions) + #15 (tier3): DEC-1 phase-2 —
normative authority already transferred to the cutover contract; the
code they specified is gone, so the files are deleted. #1 (SSE event
vocab) and `first_message` stay (ratatoskr-owned, not retired).
- Final coverage-map re-anchor: tools/list_sessions re-homed to `wt.py`;
the Last-Event-ID SSE-resume sub-gap CLOSED (folded into `stream_turn`
auto-resume); Surface-2 SSE parsing re-anchored to the SDK.
- Stale doc-rot fix: the cli.py transport comment no longer calls
`seed_preset_first_message` "not-yet-migrated" (it rides `wt`).
- v0.22.0 (minor, DEC-6, operator-approved): publishes the full 6-slice
cutover milestone.
Slice-6 (admin: bifrost inspection + admin-events SSE) done through the full House
Code Discipline, v0.21.19–.20, suite 494 green, live-proven (real session.created
event re-wrapped end-to-end on :8081), both heid gates cleared. Current state advanced
to slice-7 (teardown, LAST) next; Recent-decisions index entry + detail file added;
substrate at v0.21.20. Consumer client layer now fully cut over (6/7 slices).
persistent-memory.md stays ~345 lines (over the ~300 soft cap): dominated by the
non-archivable Current state block + <30-day July entries (guarded), so archival can't
reach the 250 target — left as-is per the stop-where-the-guards-stop rule.
Cold spec-free panel (Gróa + Hulda + Regin, source-verified by Heid): the adapter's
core re-wrap is sound, but 4 real hardening gaps the conformance CR couldn't see —
all in failure-path normalization + open-world degrade, judged against the general
ConnectFailed floor + the degrade-never-crash promise. All fixed:
- [bug, 3/3] `stream_admin_events` never mapped `ConnectFailed` — the SDK admin-stream
open raises it on a connect-time / auth-resolution failure (the general transport
floor; confirmed in the SDK source), and `stream_turn` + the bifrost GET both catch
it, and this endpoint's OWN comment claimed it did. An unmapped ConnectFailed escaped
the web gen's `except (Sse*)` and aborted the SSE with no `stream_error`. Now mapped
→ `SseConnectFailed`, mirroring stream_turn.
- [bug, 2/3] non-str `type` crashed the web filter — the re-wrap used `ev.type or ""`
(falsy-only), so a truthy non-str `type` (123, a list) reached `.startswith` →
AttributeError. Now `ev.type if isinstance(ev.type, str) else ""` (matches the
admin_id/data isinstance guards — same container-type class as slice-5).
- [robustness] `_session_bifrost_endpoint` did `dict(bstate)` on the open-world 200
body — a non-mapping (list/scalar) → TypeError/500. Now degrades to `{}` (I introduced
this in slice-6 by changing `JSONResponse(bstate)` → `dict(bstate)`).
- [robustness] `_admin_events_endpoint.gen` allocated the transport + built `_wt_client`
BEFORE the try/finally — a construction failure would leak the httpx transport. Moved
`_wt_client` inside the try so the finally always closes it.
Voided (Heid): Regin's `dict(ev.data)` TypeError — the `isinstance(_, Mapping)` guard
already routes non-mappings to `{}` before `dict()`.
Added adapter tests (ConnectFailed→SseConnectFailed; non-str type→"") + a web test
(non-mapping bifrost body → 200 {}). Suite 494 green; my code ruff-clean (13 E501/F841
in test_web_server.py are PRE-EXISTING, HEAD-identical, untouched); mypy clean on wt.py.
Live smoke re-run clean (real session.created event re-wrapped; bifrost 404 envelope).
Patch bump 0.21.19 → 0.21.20.
Panel (Gróa + Hulda + Regin): 3/3 no drift — the admin adapter honors the contract
(route map, re-wrap/degrade, error-map ORDER, admin_auth-on-client, INV-CUT-1).
Only minor doc/test looseness, both fixed:
- Stale docstring: `_session_bifrost_endpoint` still said "the wrapper overrides the
Authorization header with it" — corrected to "rides on the wt client's admin_auth"
(slice-6 moved admin auth off the per-call header; line 79 already said the new way).
- Test-gap: the admin-stream ConnectionDropped test only exercised the cursor-set case;
added the connect-time None-cursor case (ConnectionDropped(None) → last_seen_sse_id
None) to back the map's "both cursor shapes" claim.
Not acted on: `admin_key`→`admin_auth` unit assertion (the SDK's use of admin_auth is
SDK-internal/private — out of scope per "assess use, not definitions"; the LIVE SMOKE
already proved the wiring end-to-end). Hulda's "web endpoints under-tested" flag was
source-VOIDED by Heid: those endpoints ARE covered in test_web_server.py, which wasn't
in the consult embed (excerpt-elides-tests trap).
Suite 491 green; ruff clean. Docs + test only — no version bump (SemVer skip rule).
Slice-6 of the worldtree-sdk cutover: migrate the two admin routes off the
hand-rolled paths onto the `ratatoskr.wt` adapter over `client.admin.*`, and delete
the retired code. Both are web-only (the coverage-map's `tui.py` rows were stale —
corrected to `web/server.py`).
Adapter (`wt.py`): `get_session_bifrost` → `client.admin.sessions.bifrost` (open-world
dict verbatim, any error → SessionApiFailed default); `stream_admin_events` →
`client.admin.stream_events`, re-wrapping the SDK's `AdminEvent` → ratatoskr's at the
boundary.
Decisions (contract § slice-6 notes):
- Admin auth moves from a per-call `Authorization` header override to the client's
`admin_auth` (`_wt_client(admin_key=…)`, extended this slice) — the SDK's admin.*
routes use the provider, not a header.
- `AdminEvent` re-wrap (chosen over yield-through): the SDK's `admin_id`(nan)/None-able
`type`/`data` diverge from ratatoskr's `id`/`type`/`data` that the web filter reads;
re-wrapping (nan→0, None→""/{}) degrades the open-world None/nan ONCE at the adapter
and keeps the web endpoint + `_admin_event_matches_web` + the `AdminEvent` domain type
unchanged (preserves the web surface). Rejected: yield SDK events + rewire the web
filter (heavier churn, scattered hardening).
- Admin-stream error map: a NON-200 open raises `ApiError("admin_stream_failed")`
(NOT `ConnectFailed`) → SseConnectFailed; `ConnectionDropped` (connect-time OR
mid-stream/resumable-EOF) → SseConnectionDropped. The web integration test caught the
ApiError-not-ConnectFailed gotcha the unit fake couldn't.
Web (`web/server.py`): both admin endpoints build the wt client with admin_key and call
`wt.*`; the bifrost endpoint gains ConnectFailed→502 handling (cutover foot-gun); the
admin-events endpoint closes the injected transport (INV-CUT-1), never the wt client.
Deleted the hand-rolled `sessions.get_session_bifrost` + `sse_client.stream_admin_events`
(+ orphaned httpx/httpx_sse/json/AsyncIterator imports); the ratatoskr `AdminEvent`
dataclass stays in `sse_client.py` (re-wrap target, imported by wt + web) until slice-7.
Retired `test_sse_client.py` entirely (its last test was the admin stream) and the
`test_sessions.py` `TestGetSessionBifrost`; added the slice-6 adapter tests.
LIVE SMOKE (:8081, readonly-admin key) — INV-CUT-5 / DEC-4 cleared: the web bifrost
endpoint returned an admin-authed clean 404 envelope (auth + route + mapping proven);
a real `session.created` admin event (id=32) re-wrapped cleanly on live wire (driven by
a session-create, throwaway session cleaned up).
Suite 490 green; ruff clean; mypy net-improved on web/server.py (16→12 pre-existing, no
new). Patch bump 0.21.18 → 0.21.19 (the cutover MINOR is deferred to slice-7, DEC-6).