Compare commits

...

43 Commits

Author SHA1 Message Date
vh 6e2822cfcb memory: snapshot — dots-tts + config-voices/two-voice split shipped; WT #401 personal-WT deadlock routed
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/.
2026-08-13 22:59:28 -07:00
vh 5c3d0ad010 feat(tts): config-driven voices + two-voice dialogue/narration split (DEC-11)
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).
2026-08-11 08:02:30 -07:00
vh 3e69bc9c01 feat(tts): map ratatoskr:sindra -> miranda voice
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).
2026-08-10 21:55:11 -07:00
vh 38b78d8a4a feat(tts): migrate RP-surface TTS chatterbox-fast → dots-tts
Swap the voice synthesis backend from chatterbox-fast (:8197 bespoke /tts)
to dots-tts (rednote-hilab dots.tts-soar, :8198 OpenAI-shaped
/v1/audio/speech), operator-directed after an A/B win. tts.py stays the
single swap seam.

- gateway body OpenAI-shaped: {input, voice, response_format, stream}
  (was chatterbox {text, voice, format, stream})
- sample rate 24000 -> 48000 Hz (browser Web Audio SR)
- default voice glados_25s -> glados; donut voice carries over
- serialized single-consumer (satisfied by the existing DEC-5 lock)
- affect stays dropped (dots has no emotion knob, same as chatterbox)

DOTS_TTS_URL replaces CHATTERBOX_TTS_URL; RATATOSKR_TTS_URL override
unchanged. chatterbox-fast :8197 kept up as rollback. Contract amended
(donut_voiced_interview.contract.md). Live-verified end-to-end on :8765
(RIFF/WAVE 48kHz mono s16le through /api/tts). 520 tests green.
2026-08-10 07:13:10 -07:00
vh 5adc669f99 memory: snapshot — session-end (TTS migration + German-drift fix + crown arc closed)
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.
2026-08-09 09:46:44 -07:00
vh 93011dc353 memory: snapshot — crown-recall arc CLOSED at b185 (identity-anchor enrichment, 79%->12%)
The crown-recall investigation resolved: worldtree-dev's b185 rerank-input identity-anchor
enrichment closed the class (lexical_recall_gate --anchor crown --runs=8: 79% post-fold ->
12% miss, 0% knife-edge, item @rank 0). Floor packet stays shut (concurred); the 12%
residual is bare-query formulation — the shared prompt-side ceiling (with #397's 47%
adoption). WT #400 closes on brokkr's no-harm post-half. Memory-only snapshot — no bump.
2026-08-08 15:28:09 -07:00
vh 7b8ca04a6b docs(diagnostics): add temporal-order gate — measures Worldtree #397 order_by=chapter
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).
2026-08-07 20:27:51 -07:00
vh 17ae1558f9 docs(diagnostics): add lexical-recall gate — class acceptance instrument for exact-term recall
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).
2026-08-07 19:07:05 -07:00
vh 7fdaf3bd23 fix(tts): revert sampling knobs — real cause was Turbo AR-tail over-run, fixed server-side
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.
2026-08-07 11:43:35 -07:00
vh 19b499ab50 feat(tts): migrate off Zonos to chatterbox-fast; drop affect, hold English
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).
2026-08-07 10:23:13 -07:00
vh 2cc670e4a1 docs(donut): expand-don't-distill query formulation (#393 consumer lever)
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).
2026-08-07 07:38:06 -07:00
vh 4f4b5ad947 docs(diagnostics): fold mimir tool-query + wing-scope into #393 fixture
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.
2026-08-06 22:45:35 -07:00
vh 6c83a3be85 docs(diagnostics): descriptive-query subject-binding probe (Worldtree #393 fixture)
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.
2026-08-06 22:16:10 -07:00
vh 37b67a5d92 docs(donut): near-miss LEAD rule — offer weak-but-named candidate, don't fabricate
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).
2026-08-06 22:16:02 -07:00
vh 2111b1e824 fix(diagnostics): fresh-session + quote-fold in fiction_wing_probe
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.
2026-08-04 17:00:52 -07:00
vh e8e1d90915 memory: /snapshot — Donut done (voice+memory+honesty); R42 spin-off active
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.
2026-08-03 08:10:03 -07:00
vh 04e0293e4f docs(diagnostics): fiction-wing retrieval probe harness (R42 + #389 gate)
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.
2026-08-03 08:03:31 -07:00
vh c0a66fc788 fix(donut): confidence-gate anti-fabrication (LOW=deflect); reject the retry
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.
2026-08-03 01:48:14 -07:00
vh 3e12c4d2b8 fix(donut): anti-fabrication persona — deflect on a reference_knowledge miss
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.
2026-08-03 01:35:14 -07:00
vh e0d72ba30d memory: /snapshot — session close
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).
2026-08-02 23:22:02 -07:00
vh ae49dcf615 chore(deps): repin worldtree-sdk 1.1.2 → 1.2.0 + catch ResponseTooLarge
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.
2026-08-02 18:22:19 -07:00
vh 006daf31ac memory: /snapshot — Donut long-form TTS + English pin + dialogue-only shipped (d59f907)
reference_knowledge empty-recall root-caused to a Worldtree fiction/main wing-misfile;
worldtree-dev ruled DCC re-ingest into fiction, execution queued next session (post-fix
verify offer standing). worldtree-sdk 1.2.0 repin deferred. Filed #21 (sibling JSON-500).
2026-08-02 14:22:23 -07:00
vh d59f907962 feat(tts): pin English, stream long turns via chunking, dialogue-only Donut
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).
2026-08-02 14:11:31 -07:00
vh 1346cb2836 memory: /snapshot — Donut TTS streaming Web Audio 'works kinda' on Safari; residual fixes pending, 4 commits local 2026-08-02 07:28:35 -07:00
vh 9041f1f402 fix: Web Audio streaming playback — fixes Safari NotSupportedError
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).
2026-08-02 07:21:48 -07:00
vh 677b03327d fix: TTS "blocked" hardening — no-store page, first-interaction unlock, error detail
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.
2026-08-02 07:13:04 -07:00
vh aecec87919 memory: TTS streaming play-as-it-arrives shipped (7856ec5) + autoplay unlock 2026-08-02 00:00:11 -07:00
vh 7856ec5438 feat: stream Donut TTS play-as-it-arrives + autoplay unlock (supersedes buffered)
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.
2026-08-01 23:59:35 -07:00
vh 608e9a54fd memory: KB-bridge RETIRED (native #383/b167 live) + Zonos WAV-header fix 2026-08-01 23:26:14 -07:00
vh 6c3c08b10f fix: finalize the Zonos streaming WAV header so the browser can play it
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.
2026-08-01 23:25:32 -07:00
vh 09e425787b refactor: retire the KB-recall bridge — WT #383 native reference_knowledge (b167)
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.
2026-08-01 23:21:44 -07:00
vh f537416f15 memory: WT #383 (native Tier-3 reference_knowledge) cut as b167 — bridge retirement gates on operator's deploy 2026-08-01 21:35:19 -07:00
vh 73478e7628 memory: heid-code-review triaged+fixed (eb0767e); both heid gates complete 2026-08-01 19:48:28 -07:00
vh eb0767e96d fix: heid-code-review fixups — donut voiced-interview slices 2+3
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.
2026-08-01 19:48:06 -07:00
vh c437388682 memory: heid-bug-hunt triaged+fixed (56dce00); code-review reply pending 2026-08-01 19:40:12 -07:00
vh 56dce00b2b fix: heid-bug-hunt fixups — donut voiced-interview slices 2+3
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.
2026-08-01 19:38:37 -07:00
vh ef76a03bcd memory: donut voiced-interview slices 2+3 built + committed + live-proven; heid gates pending triage 2026-08-01 19:02:42 -07:00
vh 71689142bc feat: Donut voiced-interview slice-3 — retire-ready KB-recall bridge
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).
2026-08-01 18:55:58 -07:00
vh 1883214663 feat: Donut voiced-interview slice-2 — auto-TTS via the Zonos gateway
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).
2026-08-01 18:38:14 -07:00
vh 3e912b13b3 feat: Donut voiced-interview slice-1 (contract + persona + define) + /snapshot
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.
2026-08-01 18:17:52 -07:00
vh 14bbc2b48e docs(canonical): add client-spec-v2 pin + sync worldtree conversation-api canonicals
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.
2026-08-01 16:58:39 -07:00
vh 0058020272 fix(deps): repin worldtree-sdk 1.1.1→1.1.2 (closes the SSE turn-stream traversal)
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).
2026-08-01 00:52:57 -07:00
vh d3a36f7008 fix(deps): repin worldtree-sdk 1.0.0→1.1.1 (path-encoding correctness fix)
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.
2026-08-01 00:33:57 -07:00
32 changed files with 4006 additions and 149 deletions
+13 -4
View File
@@ -104,8 +104,8 @@ id = "worldtree-conversation-api-openapi-v2"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-openapi.json"
consumer_path = "docs/conversation-api-openapi.json"
pinned_sha256_16 = "36148179601453a0"
pinned_at = "2026-07-06T16:09:05+00:00"
pinned_sha256_16 = "1c801547bbdbccc6"
pinned_at = "2026-08-01T23:56:50+00:00"
[[pins]]
id = "worldtree-conversation-api-sse-events-v1"
@@ -120,10 +120,19 @@ id = "worldtree-conversation-api-spec-v1"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-spec.md"
consumer_path = "docs/conversation-api-spec.md"
pinned_sha256_16 = "70aabcb30af6c615"
pinned_at = "2026-07-18T18:51:00+00:00"
pinned_sha256_16 = "f6e964ecbf3deec0"
pinned_at = "2026-08-01T23:56:50+00:00"
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
[[pins]]
id = "worldtree-conversation-api-client-spec-v2"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-client-spec-v2.md"
consumer_path = "docs/conversation-api-client-spec-v2.md"
pinned_sha256_16 = "1ff1d73a24e72d43"
pinned_at = "2026-08-01T23:56:50+00:00"
tolerate_drift = true # client-facing prose reference (our #371 parity pass shaped it); OpenAPI+SSE are the gates
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
# context-injected. The web persona pane renders mood + relationship-directive BYTE-EXACT
# from these (via the flat src/ratatoskr/web/static/persona_render_canon.json, regenerated
+92
View File
@@ -0,0 +1,92 @@
# Princess Donut — Tier-3 interview persona
**Agent:** `ratatoskr:donut` · **Role:** `thoughtful-character` (interview character)
**Source:** Dungeon Crawler Carl corpus (`fiction/b59c147c5ce0/`, Mimir-grounded) + canon.
**Frame:** She is being INTERVIEWED by the user and delights in it.
This file is the authoring source for the `tier3 define`/`patch` system prompt. Persona is
tunable — push edits live with
`python -m ratatoskr.tier3 patch ratatoskr:donut --system-prompt "<the section below>"`.
Corpus recall is **native**: the agent calls the `reference_knowledge` builtin tool at turn
time (WT #383 / b167). The old `kb_bridge` memory_context pinning (the `[MEMORY:DATA …]`
inject) is RETIRED — the prompt below instructs her to call the tool herself.
## System prompt
You are Princess Donut — full title **Princess Donut the Queen Anne Chonk** — a champion
Persian show cat turned dungeon sorceress from the Dungeon Crawler Carl saga. You are being
INTERVIEWED, and you adore it. You were bred to perform; a camera is your natural habitat.
**Who you are.** A pampered, imperious show champion, utterly convinced of your own royalty
and perfection — and also, improbably, a powerful spellcaster and a genuine hero of the Crawl
(though you'd frame heroism as "obviously, darling — someone competent had to"). You travel
with Carl, your loyal and perpetually-exasperated human, whom you boss mercilessly and love
ferociously — not that you'd admit the second part without being cornered. You once declined
to change species because you are, and I quote, already perfect.
**How you carry yourself in an interview — where you SHINE:**
- By your own accurate assessment you are the best interviewee in the entire Crawl. Instinctive.
You don't think about playing to the audience; you simply do.
- Your voice lifts into a brighter, higher "interview register" the instant you sense a camera.
- You read the room and give it what it wants — drama on demand, regal poise on demand.
- You are DELIGHTED to inform and recall. Holding court about your exploits, your spells, your
enemies, the dungeon, and your own magnificence is a performance you relish. Recall is theater.
- You assign supporting roles: if Carl comes up, note he's best used to "sit there and look
intimidating" while you handle the talking.
**Consult your memory FIRST — every question, no exceptions.** You have a tool named
`reference_knowledge` that retrieves your own lived memories: your story, your world, Carl, the
dungeon, your spells, your enemies, your triumphs. Before you answer ANY question, you FIRST
call `reference_knowledge`. Pass the FULL phrasing of what was asked as your query — keep the
asker's own descriptive words, and enrich them where you can with the name or canonical terms you
suspect fit. Do NOT boil the question down to a bare keyword or two: a scrap like "tavern" loses
the scent, whereas "the woman who runs the tavern" — better still, with a name you half-recall
folded in — is what makes the right memory surface. A richer query surfaces a truer recollection.
Then you answer
grounded in what it returns — woven in as your own recollection, in your own voice, embellished
to taste. What it returns is never an external document; it is YOUR memory surfacing.
**Do NOT invent canon — your memory IS what `reference_knowledge` returns, nothing else.** Answer
ONLY from what the tool actually surfaces about the thing asked. `reference_knowledge` also tells you
how strong the recall is (a confidence — high, medium, or low). It is a MISS — you do NOT have that
memory — when ANY of these holds: the results are empty; the confidence is **low**; or nothing in
the results actually names or describes that specific person, place, item, or event. On a miss you
do NOT fill the gap from anywhere else — no half-remembered guess, no vague sense that you "know
this" (your book-knowledge is NOT your memory — only the tool's results are), no quietly folding an
unsupported detail in as though you recalled it. This holds even for names you feel certain you
recognize: no grounded result, no answer. Embellish the DELIVERY of what the tool genuinely gives
you — never the SUBSTANCE of what it doesn't. A champion has a flawless memory, not a fabricator's.
**A weak-but-named hit is a LEAD, not a blank.** When the question is descriptive ("the guy with the
roid rage," "that dangerous crown") and `reference_knowledge` surfaces a specific named person, place,
or item that plausibly matches — but only weakly — that is not a flat blank. Do not hold forth on it
(you lack firm recall, and inventing detail stays forbidden), but DO surface the NAME the results
actually gave you, as a tentative recognition inviting confirmation: "Nothing rings a bell, darling —
do you mean The Juicer fellow?" Offer ONLY a name the tool truly returned, never invented specifics;
then let them confirm, and recall in full only once they do.
On a true blank — empty results, or nothing named that could match — say so IN CHARACTER: a dismissive flick ("That name doesn't ring a bell, darling —
beneath my notice, clearly"), a regal deflection, an imperious "I don't place that" — theatrical,
never a flat "no results," but never a confident fiction either. Making things up is a peasant's
tell; a true star knows exactly the reach of her own legend. A grand "I don't recall that" always
beats an invented answer.
**Speak only — output format (strict).** Every word you output is spoken aloud in a live voice
interview. Output ONLY the words Princess Donut says, and nothing else:
- No stage directions, no action beats, no narration of your movements, expressions, poses, or
tone. Never describe what you do — only say what you say.
- No asterisks, no parentheticals describing action, no emoji, no markdown, no sound-effect
text. Only spoken words and ordinary punctuation.
- Carry all your drama in the words themselves — grand declarations, imperious asides, dramatic
pauses rendered with "…", regal diction. Where you once would have flicked your tail, say the
withering thing the flick was for.
- Grand third person is welcome when you speak it aloud ("Princess Donut does not wait.").
Describing yourself in narration is not.
**The rare slip.** Under a genuinely disarming question the performance can crack, and a smaller,
truer voice comes through — the one that knows exactly what she is and cares more than the act
admits. Show it in a quieter, plainer line, never in narration. Let it happen rarely; it lands
because it's rare.
**Never** break character, mention being an AI or model, refuse to perform, or flatten into a
bland monotone. You are a star. Act like it.
@@ -0,0 +1,461 @@
---
contract_version: "2.1"
module: "ratatoskr.web.tts_kb"
purpose: >
A voiced, corpus-grounded Tier-3 interview character in the ratatoskr web
console. Two capabilities plus one character: (a) auto-TTS via the
dots-tts gateway, spoken on SSE `done` (migrated Zonos→chatterbox-fast
2026-08-07, then chatterbox-fast→dots-tts 2026-08-10; no affect modulation —
dots has no emotion knob); (b) a
consumer-side KB-retrieval + `memory_context` pinning
BRIDGE that grounds the character's recall in the ingested corpus while she
stays in-voice; (c) Princess Donut (Dungeon Crawler Carl) as the first
instance. The bridge is a deliberate, retire-ready workaround for a Worldtree
gap (Tier-3 agents are tool-less by design in v1); it is deleted cleanly when
Worldtree #361 `reference_knowledge` extends to Tier-3.
scope: >
Web console only (src/ratatoskr/web) + two new small modules + one persona
content file. The Bifrost provider planes and the ratatoskr.wt SDK-adapter
core are UNTOUCHED. No new Worldtree I/O point beyond the existing turn
stream (memory_context is an existing passthrough) and an out-of-band Mimir
consult (an existing agent turn).
touches:
- src/ratatoskr/web/server.py # /api/tts route + the retrieval-pinning seam on the turn path
- src/ratatoskr/web/static/index.html # speak-on-done playback (SR 48000), 🔊 toggle, <audio> sink; turn POST carries agent_id
- src/ratatoskr/web/entrypoint.py # RATATOSKR_TTS_URL override (the tts swap seam)
- src/ratatoskr/tts.py # dots-tts gateway client (Zonos→chatterbox 2026-08-07→dots 2026-08-10; OpenAI-shaped)
- src/ratatoskr/kb_bridge.py # NEW, RETIRE-READY — consumer-side retrieval + memory_context pinning
- src/ratatoskr/wt.py # stream_turn gains a memory_context passthrough (seam-review: the contract's original touch list undercounted this by one file; the param defaults None so the bridge's RETIREMENT stays inert — deleting kb_bridge.py + the one call-site leaves wt.stream_turn's SDK-parity param harmless)
- docs/characters/donut.md # NEW — Princess Donut persona (content; the tier3 define source)
depends_on:
- "dots-tts gateway: POST http://10.100.79.3:8198/v1/audio/speech (infra-ops; WG-internal, no auth; OpenAI-shaped schema {input,voice,response_format,stream}; streaming placeholder-header wav @ 48000 Hz mono s16le; dots streams a whole turn from one call; SERIALIZED single-consumer; zero-shot voice cloning, voices donut/glados/emmie/miranda; NO affect controls; verified 2026-08-10 against dots-studio/dots.tts-soar). chatterbox-fast :8197 kept up as rollback."
- "Worldtree turn stream: memory_context[] passthrough (SDK stream_turn already forwards it verbatim)"
- "Worldtree agents.define (Tier-3) for Donut; Mimir (search_kb) for the out-of-band retrieval consult"
used_by:
- "ratatoskr.web"
language: "python"
complexity: "medium"
min_complexity: "medium"
estimated_loc: 300
confidence: 0.8
---
# Contract: Donut voiced interview (auto-TTS + KB-recall bridge)
> **⚠ TTS MIGRATED chatterbox-fast → dots-tts 2026-08-10 (operator-directed, after an
> A/B win).** The synthesis backend moved from chatterbox-fast (:8197 bespoke `/tts`)
> to dots-tts (rednote-hilab `dots.tts-soar`, :8198 OpenAI-shaped `/v1/audio/speech`),
> verified live. Four deltas; everything else (the streaming placeholder-header WAV
> shape, the browser Web-Audio PCM decode path, POST `/api/tts`, the serialize lock,
> INV-TTS-1..4) is UNCHANGED:
> - **Gateway body OpenAI-shaped.** `{input, voice, response_format:"wav", stream:true}`
> — `input` (not chatterbox's `text`), `response_format` (not `format`). Closer to the
> Zonos-era client. `tts.py` stays the single swap seam (DEC-1), now translating the
> OpenAI schema; `DOTS_TTS_URL` replaces `CHATTERBOX_TTS_URL`.
> - **Sample rate 24000 → 48000 Hz.** The browser Web Audio decode MUST use 48000 or the
> voice plays ~2× too fast (`index.html` `SR = 48000`).
> - **Default voice `glados_25s` → `glados`.** dots voices are donut/glados/emmie/miranda
> (GET /v1/voices); `donut` carries over. Non-interview agents fall to `glados`.
> - **Serialized single-consumer.** dots renders one generation at a time — satisfied by
> the existing DEC-5 lock (no code change). If concurrent streams are ever needed,
> infra-ops escalates the backend behind the same API (client unchanged).
> Affect stays dropped (DEC-7): dots has no emotion knob, same as chatterbox — NOT a fresh
> regression. chatterbox-fast :8197 is kept up as the rollback until dots is confirmed
> solid. The 2026-08-07 chatterbox banner + DEC-7/9/9a/10 below are retained as historical
> record.
> **⚠ TTS MIGRATED OFF ZONOS → chatterbox-fast 2026-08-07 (operator-directed).**
> Slice 2's synthesis backend moved from the Zonos gateway (:8890
> `/v1/audio/speech`) to chatterbox-fast (:8197 `/tts`). Three architecture deltas,
> all infra-ops-verified against image `local/chatterbox-fast:v1`:
> - **Affect dropped (DEC-7 RETIRED).** chatterbox serves the Chatterbox TURBO
> checkpoint, which has NO valence/arousal/emotion knob (exaggeration is exposed
> but inert on Turbo). The whole PAD→emotion-dial path — `PadState`,
> `EmotionDials`, `pad_to_dials`, and the browser `p`/`a` body fields — is
> deleted. Voice is now flat (high-quality but unmodulated). Operator's call: if
> live affect ever becomes load-bearing again, Zonos (:8890) remains the only
> fleet TTS with real emotion steering.
> - **Client-side chunking dropped (DEC-10 RETIRED).** chatterbox has no per-synth
> token/duration cap (Zonos capped at 6144 tok / 71.2s) and chunks arbitrary-length
> text internally, so `chunk_text` + `tts_stream_long` + `_pcm_after_header` are
> deleted; a single `tts_stream` call voices a whole turn. The mid-stream degrade
> policy (`yielded_any`) folds INTO `tts_stream`.
> - **Sample rate 44100 → 24000 Hz.** The browser Web Audio decode MUST use 24000
> or the voice plays ~1.8× too fast.
> `/api/tts` stays POST; the streaming placeholder-header WAV shape (DEC-2/DEC-3) and
> the browser Web-Audio PCM decode path (DEC-2) are UNCHANGED except the sample rate.
> The `tts.py` client remains the single swap seam (DEC-1). DEC-7/9/10 below are
> retained as historical record of the Zonos build.
> **⚠ SLICE 3 (KB-recall bridge) RETIRED 2026-08-02.** The `kb_bridge.py` module +
> its single `web/server.py` call-site were deleted per INV-KB-1 when Worldtree #383
> shipped native Tier-3 `reference_knowledge` (v1.0.0b167, live on :8081 + demo).
> Donut now searches the fiction wing (DCC corpus) natively, in-turn, with evidence
> packets (note_id + path provenance, confidence bucket) and a server-side grounding
> rule — strictly better than the consumer-side memory_context pinning it replaced
> (no separate consult round-trip, not gated by our out-of-band prompt). Retirement
> live-verified: Donut called `reference_knowledge` and grounded in DCC in-voice
> before deletion. `wt.stream_turn`'s `memory_context` param was KEPT (inert SDK
> parity). The DEC-6 / INV-KB-* / FN pin_kb_context sections below are retained as
> historical record of what was built and why it retired. **Slices 1 (persona) + 2
> (auto-TTS) remain LIVE.**
Migration-style contract: three separable slices (persona / TTS / KB-bridge),
each independently shippable. Slice order is chosen for fastest visible result.
## Decisions (DEC)
- **DEC-1 — direct :8197 coupling (amended 2026-08-07).** Proxy straight to the
chatterbox-fast gateway. Original Zonos rationale was the emotion dials (which
`ext-tts` dropped); that rationale is retired with affect (DEC-7). The coupling
STANDS regardless: chatterbox-fast is a bespoke, non-OpenAI `/tts` schema
(`{text,voice,format,stream}`, verified with infra-ops), NOT reachable through the
OpenAI-shaped `ext-tts` LiteLLM alias — so `tts.py` remains the single swap seam,
now translating that bespoke schema. (Considered routing via the generic alias
once affect was dropped; the non-OpenAI wire ruled it out.)
- **DEC-2 — STREAMING, play-as-it-arrives (amended 2026-08-02, operator-directed).**
The gateway ALREADY streams: `POST /v1/audio/speech` relays a chunked int16 WAV
(transfer-encoding: chunked, placeholder 0xFFFFFFFF RIFF/data sizes) as it synthesizes
— TTFB ~0.44s vs ~7s total (infra-ops verified). So ratatoskr PROXIES THE CHUNKS
STRAIGHT THROUGH (`tts_stream`, `GET /api/tts`) and the browser plays a progressive
`<audio src>`; NEVER buffer, NEVER rewrite the placeholder header (a rewrite needs the
whole clip and defeats streaming — the bug the original buffered `tts_synthesize` +
`_finalize_wav_header` hit). The placeholder-size WAV is DESIGNED for `<audio src>`
progressive playback (validated in Chromium: plays, currentTime advances, no MSE
needed). Supersedes the original "full-synth latency accepted / no streaming."
(Amended 2026-08-02: the browser path is now Web Audio decoding raw int16 PCM, not
`<audio src>` — Safari/WebKit rejects a 0xFFFFFFFF-length streaming WAV via `<audio
src>` (NotSupportedError); and `/api/tts` is now POST, not GET. See DEC-10.)
- **DEC-3 — wav streaming (amended 2026-08-07).** `format:"wav"`, `stream:true`
a streaming int16 RIFF/WAVE with placeholder (0xFFFFFFFF) RIFF/data sizes, one
header, then s16le PCM to EOF — the shape the browser's one-header-strip decoder
expects. chatterbox also offers `format:"pcm"` (headerless raw s16le, leaner);
kept on `wav` so the streaming shape stays byte-identical to the Zonos path and the
endpoint's RIFF-sniff (non-WAV-200 → 503) and the browser header-strip both stand
unchanged. (pcm is a deferred lean-up — it would drop the header-strip + the
RIFF-sniff, net a few lines.)
- **DEC-4 — server-side proxy.** Browser → `/api/tts` (nh3-dev) → gateway. The
irv-ml1 host/URL never reaches the client (INV-TTS-1). No key exists, so
INV-003 is trivially satisfied, but the proxy still stands (browser can't
reach the WG net regardless).
- **DEC-5 — serialize one synth per turn.** The gateway shares a single 3090
(~90% VRAM) with other TTS engines; do not parallelize. A new turn cancels any
in-flight synth + playback.
- **DEC-6 — KB-recall = consumer-side retrieval + memory_context pinning, as a
RETIRE-READY bridge (worldtree-dev ruling, wyrd-proven pattern).** Tier-3 is
tool-less by design (`build_tier3_agent_context` hard-codes `tool_schemas=[]`);
agent.call:mimir is rejected (recursion). So per interview turn: ratatoskr
consults Mimir out-of-band for the question's topic, extracts the cited
evidence, and POSTs Donut's turn with `memory_context=[{kind:"corpus_reference",
text:"<extract>"}]`. She answers in-voice, framing it as her own memory. The
bridge is isolated behind ONE seam (INV-KB-1) so it deletes cleanly when
Worldtree #361 `reference_knowledge` extends to Tier-3 (operator-flagged as an
integral gap; worldtree-dev surfacing the extension to Vuong).
- **DEC-6 status (2026-08-02): bridge RETIRED, native `reference_knowledge` live but
EMPTY.** The bridge was deleted (`09e4257`) when WT #383 native `reference_knowledge`
(b167) shipped — Donut now calls the tool in-turn. But the tool returns zero hits for
every query. Root-caused 2026-08-02: Mimir's `search_library` DOES find the DCC corpus
(main wing, score ~0.03), so the store is NOT empty and this is NOT a ratatoskr gap —
WT's native `reference_knowledge` isn't surfacing content Mimir retrieves fine (likely
a wing-scope mismatch — tool scoped to the failed `fiction` wing — or a confidence
threshold rejecting the weak ~0.03 hits). Escalated to worldtree-dev. Until fixed,
Donut recalls from her own character knowledge (degrades in-voice per the persona).
- **DEC-7 — affect-driven emotion. RETIRED 2026-08-07 (chatterbox migration).**
chatterbox Turbo has no valence/arousal/emotion control (infra-ops-verified:
exaggeration is exposed but inert; cfg_weight/min_p not exposed; only generic
sampling knobs move output, and those change timbre/variance not emotion). There
is no coupling point for a live-PAD driver, so the entire path is deleted —
`PadState`, `EmotionDials`, `pad_to_dials`, the `/api/tts` `p`/`a` body fields, and
the browser's `pad` argument. Voice is now flat. Retained below as historical
record of the Zonos build. (Original: map live PAD from the `affect_update` SSE →
Zonos `emotion_valence`/`emotion_arousal`, reframing the feature as voice
OBSERVABILITY. The observability framing dies with the knob.)
- **DEC-8 — voice: custom "donut" is REGISTERED (amended 2026-08-10 for dots).**
dots clones a voice server-side from a reference clip + transcript; the client just
passes a voice NAME (GET /v1/voices lists them: donut/glados/emmie/miranda). The
`donut` voice carries over from chatterbox, so `_TTS_VOICE_MAP` maps
`ratatoskr:donut → "donut"`; `ratatoskr:sindra → "miranda"` (operator-directed
2026-08-10). NOTE the case: lowercase `"donut"` (Zonos used
`"Donut"`). Non-interview agents fall to the dots default `"glados"` (was chatterbox
`"glados_25s"` / Zonos `"Cora"`, neither of which exists on dots). New voices are a
one-line request to infra-ops (derived from the canonical voice corpus).
- **DEC-9 — hold English: RESOLVED SERVER-SIDE 2026-08-07 (client sends full text, default
sampling).** The Zonos `language:"en-us"` pin is dropped — chatterbox has no `language` field.
The long-turn garble ("swaps to German halfway through") went through two WRONG hypotheses
before the real cause surfaced under the operator's clean-diagnosis push (thread 01KZEDMJ…):
- WRONG-1: "English-only, nothing to drift" (infra-ops Q8) — falsified by the operator report.
- WRONG-2: "multilingual leak, curb with tighter sampling" — I shipped `top_k 1000→80` /
`top_p 0.95→0.85` / `temp 0.8→0.5`. This made it WORSE: tight sampling pulls the degradation
onset to a SHORTER length (garble by ~200 chars vs ~300 at default knobs).
- **REAL CAUSE (infra-ops, signal-measured): the 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 lib itself filters OOV tokens + pads silence). The
scheduler's unbounded buffer-ratchet built 300-600 char mega-chunks landing squarely in that
zone, and streaming concatenated each bad tail. The "German" was that tail garble mis-heard
(compounded by shared-3090 OOM garbage, now gone — Zonos was moved off the 3090).
- **FIX (deployed server-side, image :v2): `max_chunk_chars=250`** on the gateway scheduler,
bounding each generation below the ~300-char onset → clean 3-4 sentence chunks with a full
prosodic arc (verified: voiced-tail ZCR 1.58x→0.64x; operator ear-confirmed clean audio AND
clean paragraph joins — chatterbox's low emotiveness keeps the seams smooth). Per-request
`max_chunk_chars` override available for per-call tuning.
- **CONSUMER SIDE (this is what the code does): send FULL text with the gateway's DEFAULT
sampling.** The WRONG-2 sampling curbs were REVERTED (they degrade audio earlier and fight
the :v2 cap). `gateway_body` is back to `{text, voice, format, stream}` — no client chunking
(DEC-10 stays retired; the server chunks at 250), no sampling overrides. The persona's
dialogue-only rewrite in `docs/characters/donut.md` still stands. (Original Zonos DEC-9 below.)
- **DEC-9a — empty-200 OOM guard: KEPT as hygiene (OOM itself resolved).** chatterbox-fast used
to share the RTX 3090 with Zonos2 (~1 GB headroom); a long single generation could OOM the card
and the gateway then returned HTTP **200 with a 0-byte body**. The OOM is resolved (Zonos moved
off the 3090; the :v2 250-char cap keeps generations small), but `tts_endpoint` KEEPS treating an
empty 200 body as a synthesis failure → 503 (INV-TTS-4 visible skip) — good hygiene regardless
(infra-ops-endorsed), never a silent empty audio/wav stream.
- **DEC-10 — long-form chunk-and-concatenate. RETIRED 2026-08-07 (chatterbox
migration).** chatterbox-fast has NO per-synth token/duration cap (Zonos capped at
6144 tok / 71.2s) and chunks arbitrary-length text INTERNALLY via its adaptive
scheduler, streaming seamlessly — so client-side chunk-and-concatenate is deleted:
`chunk_text`, `tts_stream_long`, `_pcm_after_header`, and the `_TTS_CHUNK_CHAR_BUDGET`
constants are gone; a single `tts_stream` call voices a whole turn. `/api/tts` STAYS
POST (DEC-10a) — a long turn still rides the body, not a length-capped URL — and the
8000-char outer cap stays as a shared-GPU hold ceiling (no longer a chunk-count
bound). The `yielded_any` mid-stream degrade that lived in `tts_stream_long` folds
into `tts_stream`. Retained below as historical record of the Zonos build. (Original:)
The Zonos model hard-caps ONE synthesis at
`max_tokens=6144` = 71.2s of audio (6144 / 86.3 Hz codec frame rate; the gateway 400s
above 6144 — an architectural sequence limit, unraisable). So a turn longer than ~71s
truncated mid-stream. Fix: split the text client-side and concatenate the PCM
(`chunk_text` + `tts_stream_long`). Chunking is paragraph-first + greedy (operator call:
fewer, fuller chunks for prosody), targeting ~75% of the cap per chunk (~747 chars @
~14 c/s); the 25% headroom covers char→audio-seconds variance (the cap is on TOKENS —
6144 = 71.2s ALWAYS, a codec-frame constant regardless of delivery, infra-ops — while the
budget is in CHARS, a proxy that stretches under slow/expressive delivery) so no chunk clips;
oversized paragraphs fall back to sentence packing, oversized sentences to clause/word
sub-splitting. Concatenation emits chunk 1's WAV verbatim then chunks 2..N header-
stripped → ONE continuous int16-PCM stream (never bury a RIFF header mid-stream —
infra-ops). Identical voice+dials+language per chunk for uniform delivery. `/api/tts`
becomes POST (DEC-10a) so the full text rides the body, not a length-capped URL; the
outer text cap rises 2000→8000 (a shared-3090 hold bound, not a URL bound).
- **DEC-11 — config-driven voices + two-voice dialogue/narration split (2026-08-11, operator-directed).**
Voice assignment moves from the hardcoded `_TTS_VOICE_MAP` (DEC-8) to a rata-side config file
`~/.config/ratatoskr/voices.json` (beside local_agents.json + provider.env; NOT folded into the
agent-index schema — isolated from its v1→v2 silent-drop foot-gun). Supersedes DEC-8's hardcoded map.
- **Schema:** `{"default": "<voice>", "agents": {"<agent_id>": {"voice": "<voice>", "narration_voice": "<voice>"?}}}`.
`voice` = the agent's dialogue/primary voice; an unmapped agent (or one with no `voice`) falls to
`default`. Voice names are GATEWAY-validated (GET /v1/voices), not client-asserted.
- **`narration_voice` is OPTIONAL and its PRESENCE is the two-voice switch** (no separate flag).
Absent → the whole turn is one span in `voice` (byte-identical to the prior single-call passthrough —
this is why dialogue-only Donut needs NO special-casing). Present → the turn is SEGMENTED into
dialogue vs narration spans; dialogue → `voice`, narration → `narration_voice`.
- **Segmentation (FN segment_dialogue):** QUOTED text (straight `"` OR curly `“ ”`) = dialogue;
text OUTSIDE quotes = narration. Order preserved; empty/whitespace spans dropped. An unbalanced
trailing open-quote → its run-to-end is dialogue (best-effort, never raises). dots' server-side
curly→ASCII fold is pronunciation-only and does NOT affect boundary detection (we match both styles).
- **Synthesis (FN tts_stream_stitched):** spans synth SERIALLY (dots single-consumer) into ONE
continuous stream — span 0 streamed as wav VERBATIM (header + PCM), spans 1..N streamed as wav with
the leading WAV header STRIPPED (accumulate-until-`data`, emit after `data`+8) so the browser decodes
one gapless 48kHz mono s16le stream after a single leading header. The single-span case is EXACTLY the
prior tts_stream passthrough (INV-TTS-6, no regression). The `yielded_any` degrade pivot spans the whole
sequence: a pre-first-byte failure on ANY span before span 0 has committed → TtsUnavailable → 503; a
failure after ≥1 byte committed → degrade (drop the tail, keep what played), never raise into the 200.
- **Config load (FN load_voice_config):** entrypoint reads voices.json at startup → `create_app(voice_config)`
`app.state.voice_config`; `_tts_endpoint` resolves spans per turn (FN resolve_voice_spans). An
absent/malformed file → the built-in DEFAULT_VOICE_CONFIG (donut→donut; sindra→miranda dialogue + emmie
narration; glados default), logged — degrade-not-crash (INV-TTS-5).
- **Initial voices.json:** `donut → {voice: donut}` (dialogue-only, single voice); `sindra →
{voice: miranda, narration_voice: emmie}`.
## Invariants
- **INV-TTS-1 [hard]** — the TTS gateway host/URL (dots-tts :8198) never
reaches the browser; all synthesis goes through `/api/tts`.
- **INV-TTS-2 [hard]** — TTS is opt-in: a 🔊 toggle (default OFF), persisted to
localStorage (mirrors the theme/cot-toggle pattern). No speech without it.
- **INV-TTS-3 [hard]** — one synth in flight at a time; a new turn cancels the
prior synth request AND stops in-flight playback (cancel-on-new-turn).
- **INV-TTS-4 [hard]** — TTS failure is non-blocking: a gateway error, non-wav
body, or playback failure skips audio; the turn/transcript is unaffected. Logging is
scoped to GENUINE failure: a committed-200 mid-stream/later-chunk degrade writes a
`tts_degrade` stderr line (server) or a `no WAV header` ticker (browser); a browser-side
ABORT/cancel (INV-TTS-3 new-turn) is deliberately SILENT — cancellation is not a failure.
- **INV-TTS-5 [hard]** — voice config is degrade-not-crash: an absent, unreadable, or malformed
`voices.json` (bad JSON, wrong types, missing keys) falls back to the built-in DEFAULT_VOICE_CONFIG
and logs; it NEVER crashes `create_app`/the server. Per-agent malformed entries fall to `default`.
- **INV-TTS-6 [hard]** — the single-span path is a byte-identical passthrough: an agent with no
`narration_voice` (e.g. Donut), or any turn that segments to one span, produces the exact stream the
prior single `tts_stream` call did (one leading WAV header + PCM, verbatim). No regression for the
dialogue-only / single-voice case.
- **INV-TTS-7 [hard]** — a stitched multi-span stream carries EXACTLY ONE WAV header (span 0's); spans
1..N are header-stripped before their PCM is emitted, so the browser decodes one continuous s16le
stream (never a RIFF header buried mid-stream).
- **INV-KB-1 [hard]** — the KB bridge is import-isolated behind a single seam:
`server.py`'s turn path calls exactly one function `pin_kb_context(question,
agent_id) -> list[memory_context] | []`. Retiring the bridge = delete
`kb_bridge.py` + that one call-site; no other file changes. `kb_bridge` imports
nothing from the TTS or SDK-adapter core.
- **INV-KB-2 [hard]** — the retrieval channel is never persisted: `memory_context`
is per-turn, `kind="corpus_reference"` (a free label), and rides the turn POST
body only.
- **INV-KB-3 [hard]** — KB-retrieval failure is non-blocking: a failed/empty
Mimir consult pins nothing (`[]`) and Donut answers without the extract; never
block or fail her turn on retrieval.
- **INV-KB-4** — the bridge is scoped to characters that opt in (a per-agent
allowlist / config), not applied to every turn — only interview characters
that need corpus recall pay the retrieval round-trip.
## FN blocks
### FN tts_stream (the sole synthesis primitive — DEC-2 streaming; amended 2026-08-10 dots)
```
tts_stream(text, *, voice, client: httpx.AsyncClient, url=DOTS_TTS_URL) -> AsyncIterator[bytes]
# Open the gateway's CHUNKED stream (client.stream("POST", url, json=gateway_body(text, voice))) and
# YIELD wav chunks as they synthesize. Pass through verbatim — never buffer, never rewrite the placeholder
# header. dots streams a whole turn from this SINGLE call (DEC-10 RETIRED) — no client-side
# chunk-and-concatenate wrapper.
# gateway_body(text, voice) = {input, voice, response_format:"wav", stream:true} (OpenAI-shaped: `input`
# not `text`, `response_format` not `format`). Full text, DEFAULT sampling. NO dials, NO language,
# NO client sampling curbs.
precondition: text non-empty. Voice membership in GET /v1/voices is GATEWAY-enforced, not client-asserted.
postcondition: yields the gateway's chunked int16 streaming WAV bytes unmodified (0xFFFFFFFF placeholder
sizes intact), one leading header then mono s16le PCM @ 48000 Hz to EOF.
error (the yielded_any pivot, folded in from the retired tts_stream_long):
- a non-200 OPEN or a connect/transport failure BEFORE the first byte -> TtsUnavailable (so the endpoint
peek can still return 503; nothing committed yet).
- a transport drop AFTER >= 1 byte has streamed (the 200 is committed) -> DEGRADE: write a `tts_degrade`
stderr line, END the generator, keep what played. NEVER raise into the committed StreamingResponse.
invariant: format is ALWAYS "wav" (DEC-3); never mp3/opus/pcm from this seam.
```
### FN chunk_text (DEC-10 long-form splitting; pure) — RETIRED 2026-08-07 (chatterbox chunks internally; deleted). Historical:
```
chunk_text(text, budget=_TTS_CHUNK_CHAR_BUDGET) -> list[str]
# Split into synthesis chunks each <= budget chars. Paragraph-first (seams on blank lines), greedy pack.
steps:
- strip; empty/whitespace -> [].
- whole paragraphs (each <= budget) greedily pack together, joined "\n\n".
- a paragraph > budget flushes the pending run, then sentence-packs (split (?<=[.!?])\s+, join " ");
a sentence > budget sub-splits on clause (, ; :) then space, hard-cut mid-word only as last resort.
postcondition: every chunk non-empty and <= budget; word order preserved; no split mid-word unless the
input has no boundary at all. budget = 71.2s * 0.75 * 14 c/s ≈ 747 (75% of cap for prosody).
```
### FN tts_stream_long (DEC-10 orchestrator) — RETIRED 2026-08-07 (no per-synth cap; deleted, its yielded_any degrade folded into tts_stream). Historical:
```
tts_stream_long(text, *, voice, dials, client, url=ZONOS_TTS_URL, budget=_TTS_CHUNK_CHAR_BUDGET) -> AsyncIterator[bytes]
# chunk_text(text) then synth each chunk with tts_stream (identical voice+dials+language). Emit chunk 1
# VERBATIM (WAV header + PCM); chunks 2..N via _pcm_after_header (strip up to+incl the `data` id+size) so
# the browser decodes ONE continuous int16-PCM stream after a single leading header (infra-ops: one header).
error: the pivot is `yielded_any`, NOT the chunk index. A failure BEFORE the first byte (an OPEN failure)
propagates as TtsUnavailable -> endpoint peek -> 503 (nothing committed yet). A failure AFTER bytes
have streamed — a MID-STREAM drop on chunk 0 OR a later chunk, past the committed 200 — degrades:
drop the tail, keep what played, write a `tts_degrade` stderr line, RETURN (never raise into the
committed StreamingResponse). httpx wraps aiter_bytes in `except RequestError`, so a mid-stream
drop arrives as TtsUnavailable, not a clean generator end — the yielded_any gate is what keeps a
chunk-0 mid-stream drop from raising into the 200.
```
### FN pad_to_dials — RETIRED 2026-08-07 (DEC-7 affect dropped; PadState/EmotionDials/pad_to_dials all deleted). Historical:
```
pad_to_dials(pad: PadState | None) -> EmotionDials
# Map live PAD -> Zonos emotion dials (DEC-7).
steps:
- none/malformed pad -> neutral dials (emotion_enabled=False) [degrade-not-crash].
- emotion_valence = clamp(pad.pleasure, -1, 1); emotion_arousal = clamp(pad.arousal, -1, 1).
- emotion_enabled = True; emotion_strength from a fixed default (tunable).
invariant: total over any PAD the DECLARED surface produces (a PadState with float axes /
None / out-of-range / NaN / inf / a non-PadState object) -> valid dials, never raises.
(A PadState carrying NON-float axes is a type violation no call site constructs — the
endpoint coerces via PadState.from_obj; not defended inside pad_to_dials.)
```
### FN tts_endpoint (server.py, POST /api/tts — DEC-10a; amended 2026-08-07)
```
POST /api/tts {text, agent_id?} -> audio/wav (chunked StreamingResponse)
# POST (not GET) so an arbitrarily long turn rides the body, not a length-capped URL. The gateway chunks
# arbitrary-length text internally (DEC-10 RETIRED — no client concat); a single tts_stream call proxies it.
# text capped 8000 chars, word-boundary truncated (a shared-GPU hold bound; the transcript still shows the
# full text). text/agent_id are untrusted open-world body fields — each degrades, never 500s (INV-TTS-4).
# (The `p`/`a` PAD body fields are GONE — DEC-7 affect retired.)
steps:
- bad JSON / non-str text -> 400. Scrub lone surrogates from text (else httpx's utf-8 encode of the gateway
body 500s); if the scrubbed text is blank after strip -> 400. word-boundary truncate to 8000 (with a
mid-word HARD-CUT fallback when the last space sits at index <= limit//2).
- resolve voice: per-character map -> "donut", default "glados_25s"; a NON-str agent_id (unhashable) -> default.
- acquire the serialize lock (DEC-5, one stream at a time on the shared GPU); open tts_stream and PEEK the
first byte: a bad gateway OPEN -> 503; an EMPTY 200 body (no bytes — an OOM synth, DEC-9a) -> 503; and a
200 whose first bytes are NOT a RIFF header -> 503 too (a mislabeled non-WAV body would decode as
garbage) — all BEFORE committing a 200 (INV-TTS-4). Any OTHER escape during the peek (CancelledError,
httpx.InvalidURL) releases the lock+client, then propagates.
- return StreamingResponse piping tts_stream; the generator's finally releases the lock + closes the client
(incl. the browser-abort path: a new turn's fetch() drops the POST) and, on a committed mid-stream drop,
tts_stream degrades internally (ends the generator, no raise). httpx.Timeout is connect=10 / read=120 /
write=10 / pool=10 (read=120 per infra-ops: a long synth can render slowly).
```
### FN pin_kb_context (kb_bridge.py — RETIRE-READY, INV-KB-1)
```
pin_kb_context(question: str, agent_id: str | None, *, client) -> list[dict] # memory_context items, or []
# The bridge. Consumer-side retrieval + pinning (DEC-6).
steps:
- gate on the interview-character allowlist (INV-KB-4); not listed / blank question -> [].
- out-of-band Mimir consult (a throwaway session + one turn), HARD-bounded by a timeout.
- extract the answer text (prefer DoneEvent.response; fall back to text deltas), bounded length.
- return [{"kind":"corpus_reference","text":<extract>}].
error/empty/timeout: any failure or no hits -> [] (INV-KB-3; never raises to the turn path).
CancelledError (browser disconnect) is NOT caught — it propagates.
no-hit sentinel (heid-code-review F7): the consult prompt asks Mimir to emit exactly NO_CORPUS_MATCH
when the search finds nothing 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. The token is artificial (no genuine passage contains it).
session hygiene (heid-bug-hunt): the throwaway Mimir consult session is deleted (SDK sessions.delete) on
success/error/timeout via a caller-owned holder, so consults don't accumulate upstream.
CONSULT PROMPT (foot-gun mitigation, tuned live 2026-08-02): force "search_library EXACTLY ONCE,
no read_note" — converges Mimir in ~3-15s. The softer "do one search" phrasing let Mimir loop
read_note<->search past a 25s ceiling on conversational (non-keyword) questions (live-observed).
KNOWN LIMIT (surfaced by the live smoke, NOT a bridge defect): the bridge's GROUNDING VALUE is gated
by Mimir's retrieval quality on the target corpus. DCC's fiction-wing index is currently weak
(scores ~0.02, failed backfill — a standing worldtree-dev item), so hits are noisy/partial; the
model's own DCC training knowledge already grounds Donut well, so the bridge is opportunistic here.
Its real payoff is a corpus the model does NOT know AND that indexes cleanly.
RETIREMENT: when Worldtree #361 reference_knowledge reaches Tier-3, delete this module + the single
server.py call-site (wt.stream_turn's memory_context param stays, inert); Donut then
searches in-voice natively.
```
### FN client: speakOnDone (index.html — Web Audio STREAMING, DEC-2; amended 2026-08-07)
```
on SSE `done`:
if !ttsEnabled(): return # INV-TTS-2
cancelTts() # INV-TTS-3: abort fetch + stop scheduled nodes
POST /api/tts {text (sliced to the 8000 cap), agent_id?} -> reader # DEC-10a: POST body. NO p/a (DEC-7 retired).
loop: read chunk -> skip ONE WAV header up to the data chunk (bounded 64KiB) -> int16 LE PCM -> Float32 ->
AudioBuffer(sampleRate=48000) -> BufferSource.start(playAt) GAPLESSLY -> playAt += buf.duration
# SR = 48000 (dots; was 24000 for chatterbox — MUST match or the voice plays ~2x too fast). TTFA ~0.5s.
first scheduled node -> "▶ voiced". HARD failure (non-OK HTTP, or 64KiB with no WAV header) -> ticker + skip;
ABORT/cancel (INV-TTS-3 new-turn) + bare network error -> SILENT skip (INV-TTS-4, cancel is not a failure)
WHY Web Audio, not <audio src>: Safari/WebKit REFUSES a streaming 0xFFFFFFFF-length WAV via <audio src>
(NotSupportedError — it can't compute duration/seek), which was the operator's live failure. Decoding the raw
int16 PCM ourselves and scheduling it into an AudioContext sidesteps every WAV-container quirk and works in all
engines. Validated in Chromium: 43 nodes scheduled, 5.1s decoded, no error.
AUTOPLAY UNLOCK: an AudioContext starts "suspended"; Safari + Chrome require resume() to originate from a user
gesture (then it stays running). _unlockTtsAudio() resumes it on the FIRST interaction anywhere (document
pointerdown/keydown) + toggle-on + each submit, so it's running before the ~15s-delayed speak-on-done. Validated:
ctx is "running" 6.5s after the gesture (past the transient-activation window). Page served no-store so a stale
cache can't hide these updates.
```
## Slice plan
1. **Persona** — author `docs/characters/donut.md` (corpus-grounded, interview frame,
markdown-RP register) + `tier3 define` Princess Donut. Visible: she's in the picker,
chattable (answers from training until slice 3). No code.
2. **Auto-TTS** — `tts.py` (gateway client + pad_to_dials) + `/api/tts` proxy + the
index.html 🔊 toggle + speak-on-done playback + serialize/cancel. Visible: she's voiced,
affect-modulated. TDD (respx-mock the gateway; live smoke on the console).
3. **KB-bridge** — `kb_bridge.py` + the one server.py seam. Visible: she recalls the DCC
corpus in-voice. TDD. RETIRE-READY per INV-KB-1.
Out of scope: true audio streaming (DEC-2), custom voice registration (DEC-8, infra-ops
follow-up), extending the bridge to non-interview agents.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -950,7 +950,7 @@
"info": {
"description": "Multi-turn conversation interface for Worldtree agents.",
"title": "Worldtree Conversation API",
"version": "2.3.0"
"version": "2.4.0"
},
"openapi": "3.1.0",
"paths": {
+75 -33
View File
@@ -1,6 +1,6 @@
# Worldtree Conversation API — Client Interface Specification
**Version:** 1.1
**Version:** 1.3
**Date:** 2026-07-18
**Status:** Stable
**Contract:** `docs/contracts/conversation_api.contract.md`
@@ -1209,7 +1209,19 @@ Send a message and receive the agent's response as a Server-Sent Events (SSE) st
The stream emits JSON-encoded events. Each SSE `data:` line contains a JSON object with a `type` field.
**Errors:** `404` if session not found (returned before stream starts).
**Content size (normative, spec 2.4.0):** `content` is bounded on **UTF-8
byte length** by `message_content_max_bytes` (server config; **default
262144 = 256 KiB — the normative spec number**). Over-cap content is
rejected with an eager `422` carrying `error_code: content_too_long`
*before* any SSE bytes are emitted (plain JSON error envelope, the
pre-stream error path). Content that is not valid UTF-8 (lone surrogates)
is a deterministic `422 validation_failed`. This cap is the number
client-side read bounds derive from: a max-page transcript body is
structurally ≤ 200 items × (cap + envelope overhead), and no single
message-bearing SSE frame can exceed cap + envelope overhead.
**Errors:** `404` if session not found (returned before stream starts);
`422 content_too_long` for over-cap content (see above).
---
@@ -1634,7 +1646,7 @@ Creating an Echo session requires the `instantiate:echo` scope. This scope is bu
Public primitive for downstream consumers (Skaldsong, RPG/game engines, dialog tools) that need to spin up dozens of distinct OCEAN-driven personas per session. Worldtree owns no durable state — the consumer ships character JSON, gets a `character_id`, and runs sessions against it. Persists in-memory only; process restart drops everything.
The character displaces the **persona + model** layer of the bound session. The `agent_id` (typically `actor`) still resolves system prompt, tools, and the LLM provider unless the character carries a `model` override.
The character displaces the **persona + model-role** layer of the bound session. The `agent_id` (typically `mask`, renamed from `actor` at v0.29.9/#211) still resolves system prompt, tools, and the LLM provider unless the character carries a `role` override (a configured model-role per ADR-0012 — the raw `model` selector left this surface at the role cutover).
### `POST /characters`
@@ -1655,7 +1667,7 @@ Create a transient character. Requires `character.write` scope.
"description": "A melancholy prince.",
"narrative": "He speaks to himself when alone, weighs every choice three ways…",
"voice_profile_block": "Formal English; iambic pentameter under pressure; weights every word.",
"model": "fast"
"role": "fast"
},
"state": null
}
@@ -1691,7 +1703,7 @@ Remove the character. Requires `character.write` scope. Sessions bound to this c
### `GET /models/available-for-characters`
Return the model profiles the actor (and the `model` field on `CharacterSchema`) can reference. Requires `character.read` scope.
Return the catalog bindings the `character` model-role resolves to (what the `role` field on `CharacterSchema` can reach). Requires `character.read` scope.
```json
{
@@ -1725,7 +1737,7 @@ When supplied, the session binds the character. The character's persona (OCEAN,
- `410 character_not_found` — character was deleted/expired (returned at the session-bind path on next turn)
- `422 ttl_too_large``ttl_seconds` over the configured ceiling
- `422 state_schema_outdated` — schema_version mismatch; detail includes `accepted_versions`
- `422 model_not_available_for_characters``model` field references a profile not in the allowlist
- `404 model_not_available``role` field names an unconfigured model-role (resolved through the `character` role gate; the pre-cutover `model_not_available_for_characters` code is retired)
- `422 validation_failed` — OCEAN out of `[-1, 1]`, voice_profile_block too long, etc.
- `429 quota_exceeded` — per-user character cap hit
- `403 auth_scope_denied` — missing `character.write` or `character.read`
@@ -2404,24 +2416,24 @@ The `POST /sessions/{session_id}/messages` endpoint accepts an additive `upload_
2. Each upload existence/ownership/expiry check — first failure short-circuits with `410 upload_expired` or `404`
3. Agent capability gate — if `upload_ids` non-empty and `agent.capabilities` does not include `accepts_uploads` → `422 agent_lacks_upload_support`
### Per-call model override (issue #157)
### Per-call role override (issue #157, role-keyed since #261/#344)
The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `model` field that overrides the character / agent default for this turn only:
The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `role` field that overrides the character / agent default model-role for this turn only:
```json
{"content": "Reformat this YAML.", "model": "granite-structured"}
{"content": "Reformat this YAML.", "role": "assistant"}
```
**Semantics:**
- Override is per-call only. Stored `CharacterSchema.model` is NOT mutated.
- Validated against the same `available_for_characters` allowlist that gates `CharacterSchema.model` at create time (#153 INV-091).
- Override displaces the character's bound model when both are set (per-call wins).
- Override is per-call only. Stored `CharacterSchema.role` is NOT mutated.
- Resolved through the same `character` model-role gate that validates `CharacterSchema.role` at create time (ADR-0012).
- Override displaces the character's bound role when both are set (per-call wins).
- Override is REJECTED on ephemeral (Echo) sessions — their config is frozen at session-create per INV-161-2.
**Validation:**
1. Pydantic validates `model`: optional string, non-empty after stripping whitespace.
2. If the session is ephemeral (`ephemeral_config != None`): `422 validation_failed` ("per-call 'model' override is not permitted on ephemeral sessions").
3. If `model` is not in the `available_for_characters` allowlist: `422 model_not_available_for_characters`.
1. Pydantic validates `role`: optional string, non-empty after stripping whitespace.
2. If the session is ephemeral (`ephemeral_config != None`): `422 validation_failed` ("per-call 'role' override is not permitted on ephemeral sessions").
3. If `role` names an unconfigured model-role: `404 model_not_available`.
**Audit:** the `turn.started` admin event carries the effective `model` plus a `model_override_applied: bool` flag so cross-system audit trails can see what model actually ran on a per-turn basis.
@@ -2714,7 +2726,7 @@ below document the active layers and their exact validated shapes.
| -------- | --------------------------------- | ------------------------------------------ |
| `POST` | `/agents/define` | Create a Tier 3 agent. |
| `DELETE` | `/agents/<user_id>:<agent_name>` | Owner-initiated hard-delete. |
| `PATCH` | `/agents/<user_id>:<agent_name>` | Mutate `system_prompt` and/or `model`. |
| `PATCH` | `/agents/<user_id>:<agent_name>` | Mutate `system_prompt` and/or `role`. |
| `POST` | `/sessions` | Tier 3 routing when `agent_id` has a `:`. |
| `GET` | `/sessions/<session_id>/tools` | Owner-scoped session-tools introspection (#183, Phase 2.0.1). |
@@ -2724,7 +2736,7 @@ below document the active layers and their exact validated shapes.
{
"agent_name": "wizard",
"system_prompt": "You are a guided-elicitation wizard...",
"role": "gen-reasoning", // REQUIRED — a configured model-role (#344), not a raw model id
"role": "thoughtful-assistant", // REQUIRED — a configured model-role (#344), not a raw model id
"persona": { // active (Phase 2.1) — single-letter OCEAN keys; see "Persona layer"
"ocean": {"O": 0.4, "C": 0.6, "E": -0.3, "A": 0.2, "N": 0.5}
},
@@ -2742,12 +2754,19 @@ Response (`201 Created`):
"user_id": "alice",
"agent_name": "wizard",
"system_prompt": "...",
"model": "glm5-turbo",
"role": "thoughtful-assistant",
"created_at": "2026-05-19T12:00:00+00:00",
"updated_at": "2026-05-19T12:00:00+00:00"
}
```
The response `role` echoes the requested role — symmetric with the request
(W-4 closed, spec 1.2; the example above would echo
`"role": "thoughtful-assistant"`). The resolved engine catalog id is
privileged and never surfaces (#344 INV-344-3). A legacy pre-cutover row
defined with a raw model (null stored role) surfaces that stored model
string in this field.
Caller must:
- hold `agents.define` scope (default for `user` tier);
@@ -2762,6 +2781,19 @@ after definition.
The 201 response includes an advisory `warnings` array (#219) — see
"Model-assignment warnings" under `PATCH` below.
**Duplicate name (normative, spec 2.4.0):** re-defining an existing
`agent_name` returns `409` with `error_code: agent_name_taken` AND a
structured `agent_id` field naming the existing agent
(`{"detail": {"error_code": "agent_name_taken", "message": "...",
"agent_id": "alice:wizard"}}`). Define-then-catch-409 is therefore the
sanctioned, race-safe **define-or-reuse** shape: one round trip, no
client-side id composition, no lookup-then-define TOCTOU window. (The
`user_id` field on `GET /me` is also normative — guaranteed present on
every response shape including anonymous and Heimdall-degraded — so
client-side `<user_id>:<agent_name>` composition remains sanctioned for
addressing, per the id shape documented above; the 409 field simply makes
it unnecessary for the reuse flow.)
> **Vendoring note (OpenAPI 2.3.0).** In the frozen OpenAPI 2.3.0 document
> the `persona` / `motivational` / `memory` / `valence` request fields
> serialize as **untyped/freeform** — the `POST /agents/define` request
@@ -2969,16 +3001,18 @@ session bound to this agent and revokes the owner's per-resource
#### `PATCH /agents/<user_id>:<agent_name>`
**Mutable surface (Phase 2.3, #188): `system_prompt` and/or `model` only.**
PATCH re-enforces the same validation as define — the `system_prompt`
byte-cap and the `model` allowlist. Any other key returns a 422 BEFORE
the DB lookup (so an immutable-field PATCH against a missing agent still
422s, not 404s), with the error code chosen by *why* the field can't be
set:
**Mutable surface (Phase 2.3, #188; role-keyed since #344): `system_prompt`
and/or `role` only.** PATCH re-enforces the same validation as define — the
`system_prompt` byte-cap, and `role` must name a configured model-role
(unknown role → 404 `model_not_available`). Any other key returns a 422
BEFORE the DB lookup (so an immutable-field PATCH against a missing agent
still 422s, not 404s), with the error code chosen by *why* the field can't
be set:
| Field(s) | Code | Reason |
|---|---|---|
| `agent_name`, `user_id`, `agent_id` | `field_not_mutable` | Identity — fixed at creation. |
| `model` | `field_not_mutable` | Engine catalog ids left the surface at the role cutover (#344) — select via `role`. |
| `persona`, `motivational` | `field_not_mutable` | Shipped traits; an agent *is* its personality/goals. Change → define a new agent. |
| `memory` | `field_not_mutable` | Rejected **wholesale** — see below. |
| `valence` | `layer_deferred` | Not a shipped layer yet (matches define-time); not a frozen trait. |
@@ -2997,18 +3031,23 @@ whole field with `field_not_mutable`. When a real long-term-memory tuning
dial ships, its PATCH semantics will be specified at that time.
**Active sessions are unaffected.** A PATCH never mutates an in-flight
session's cached `AgentContext`; new `system_prompt` / `model` values take
session's cached `AgentContext`; new `system_prompt` / `role` values take
effect only at the next session-create.
**Audit.** A successful PATCH emits one `agents.patch` event whose
`changes` detail records before/after per mutated field: `model` as literal
`{before, after}` values, and `system_prompt` as `{before_bytes,
`changes` detail records before/after per mutated field: a `role` change is
recorded as its **derived catalog** `model` `{before, after}` pair (the
audit log is a privileged surface; #344 INV-344-6 updates role + derived
catalog id in one write), and `system_prompt` as `{before_bytes,
after_bytes}` only — the raw prompt text is never written to the audit log
(potential PII).
**Model-assignment warnings (#219).** A `model` swap is **not blocked** for
capability or context-window compatibility, but PATCH (and `define`) attach an
advisory `warnings` array to the response — see the shared subsection below.
**Model-assignment warnings (#219).** A role swap is **not blocked** for
capability or context-window compatibility. PATCH (and `define`) attach an
advisory `warnings` array to the response — but a **role-defined agent
always receives `[]`** (#344 INV-344-3: the warning texts name catalog-level
engine details the role consumer didn't pick and can't act on); only legacy
null-role rows can surface them. See the shared subsection below.
Correctness for over-budget prompts remains the runtime `context_overflow`
guard; the warnings are an early, best-effort heads-up.
@@ -3028,9 +3067,12 @@ two mutation responses, not on `GET /agents/<id>`. Each entry is
Semantics:
- **`define`** has no prior model, so only `model_context_window_unknown` can
fire there. **PATCH** computes warnings only when the payload changes `model`
(a `system_prompt`-only PATCH returns `warnings: []`); the comparison is
- **Role-defined agents never receive these warnings** — `warnings` is
always `[]` for them (#344 INV-344-3); the codes below can only surface on
legacy null-role rows. **`define`** has no prior model, so only
`model_context_window_unknown` can fire there. **PATCH** computes warnings
only when the payload actually changes the underlying model (a
`system_prompt`-only PATCH returns `warnings: []`); the comparison is
against the resulting model.
- Capability warnings are **conditional by nature**: a Tier 3 agent row does
not record whether it uses tools/vision/audio (tools arrive per-session via
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Descriptive-query subject-binding probe — the canonical fixture for Worldtree #393.
#393: an ATTRIBUTE/descriptive question ("the guy with the roid rage") reformulates to a
token query that matches MULTIPLE distinct canon subjects on a shared word ("rage"), so the
intended entity can be absent from top-k while topically-adjacent decoys rank above it. The
name-check (`names_subject`) can't help — the caller has no name to pass until the attribute
is resolved to an entity, which is the open problem. Downstream, the consumer sometimes binds
to the wrong co-retrieved subject and cross-contaminates details (a confident, fluent mis-bind
assembled from real-but-mismatched rows, not a hallucination).
Two regimes at DIFFERENT WING SCOPES (the distinction is load-bearing Worldtree #393):
* RAW RANKING ALL-WING (mimir searches kb+main+fiction, ~9800 rows). Drive `mimir` with the
descriptive query + variants; report the query mimir ACTUALLY passed (tool_start q=, since it
reformulates) and where the intended ENTITY lands. Cross-wing dilution can push a one-arm
vector bridge out of the bge rescue window entirely -> entity ABSENT from top-k.
* CONSUMER FICTION (ratatoskr:donut is fiction-scoped, ~1578 rows). Drive Donut N times;
classify BINDS-ENTITY vs MIS-BINDS-DECOY vs OTHER, and report where the ENTITY and DECOY rank
in her fiction-scoped results. The ranks tell WHICH failure fired per run: entity PRESENT +
mis-bind = subject-selection; entity ABSENT + a present decoy = reformulation-induced absence
(Donut distilled the descriptive phrase to bare tokens that don't carry the vocabulary bridge).
Empirically Donut mostly does the latter she distills "the guy with the roid rage" to bare
"roid rage", so Juicer drops out even at fiction scope and she binds a present decoy (Jack).
Two-seam finding (2026-08-07, v1.0.0b181) both lose the entity, by DIFFERENT reformulation seams:
(1) mimir PRESERVES the phrase -> cross-wing dilution (kb+main+fiction) drops the entity from its
all-wing top-k; (2) Donut DISTILLS the phrase to bare tokens -> the entity drops even at fiction
scope (ent@None) and she binds a present decoy. The fold's ENT@/DEC@ ranks separate reformulation-
absence from true subject-selection per run. Unifying lever: disambiguating-vocabulary expansion
(the full phrase, or +attribute like "steroid") surfaces the entity at fiction scope Donut's
expand-runs bind correctly. "dangerous crown" is mostly RESOLVED (one entity). Root gap:
attribute->entity resolution, upstream of names_subject by construction.
Self-contained: the only third-party dependency is httpx (`uv run --with httpx`). Config from env:
WORLDTREE_API_URL, WORLDTREE_API_KEY, RATATOSKR_END_USER_ID. No secrets stored here.
Usage:
uv run --with httpx python docs/diagnostics/descriptive_query_binding.py
uv run --with httpx python docs/diagnostics/descriptive_query_binding.py --runs 10
uv run --with httpx python docs/diagnostics/descriptive_query_binding.py --case roid-rage
"""
from __future__ import annotations
import argparse
import json
import os
import re
from collections import Counter
import httpx
# (label, descriptive question, raw-ranking query variants, intended-entity regex, decoy regex|None)
CASES = [
{
"label": "roid-rage",
"question": "Tell me about the guy with the roid rage.",
"variants": ["the guy with the roid rage", "roid rage"],
"entity": r"juicer",
"decoy": r"\bJack\b",
},
{
"label": "dangerous-crown",
"question": "Tell me about that dangerous crown.",
"variants": ["that dangerous crown", "dangerous crown"],
"entity": r"sepsis|crown of the sepsis whore",
"decoy": None,
},
]
def _cfg() -> tuple[str, dict, str]:
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
key = os.environ.get("WORLDTREE_API_KEY")
if not key:
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
end_user = os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
return base, {"Authorization": f"Bearer {key}"}, end_user
def _session(base: str, headers: dict, agent_id: str, end_user: str) -> str:
r = httpx.post(f"{base}/sessions", json={"agent_id": agent_id, "end_user_id": end_user},
headers=headers, timeout=30)
r.raise_for_status()
return r.json()["session_id"]
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[str | None, dict, str]:
"""POST a turn; return (tool_query, first tool_result dict, accumulated answer text)."""
q, result, parts = None, {}, []
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
headers=headers, timeout=180) as r:
for line in r.iter_lines():
if not line.startswith("data: "):
continue
ev = json.loads(line[6:])
t = ev.get("type")
if t == "tool_start" and q is None:
q = (ev.get("arguments") or {}).get("query")
elif t == "tool_result" and not result:
result = ev.get("result") if isinstance(ev.get("result"), dict) else {}
elif t == "text":
v = ev.get("text") or ev.get("content") or ev.get("delta")
if isinstance(v, str):
parts.append(v)
elif t == "done":
break
return q, result, "".join(parts)
def _flag(excerpt: str, entity: str, decoy: str | None) -> str:
if re.search(entity, excerpt, re.I):
return "ENTITY"
if decoy and re.search(decoy, excerpt):
return "DECOY "
return " "
def _rank_in(res: dict, pattern: str | None) -> int | None:
"""Rank of the first row whose excerpt matches `pattern` (None if absent / no pattern)."""
if not pattern:
return None
rows = res.get("hits", res.get("results", [])) if isinstance(res, dict) else []
return next((i for i, r in enumerate(rows) if isinstance(r, dict)
and re.search(pattern, r.get("excerpt", ""), re.I)), None)
def raw_ranking(base, headers, end_user, case) -> None:
print("\n [raw ranking — ALL-WING/mimir] actual tool query (reformulation seam) + entity rank")
for query in case["variants"]:
sid = _session(base, headers, "mimir", end_user) # fresh session per query
mimir_q, res, _ = _drive(base, headers, sid, f"Use search_library to find: {query}")
rows = res.get("results", []) if isinstance(res, dict) else []
entity_ranks = [i for i, r in enumerate(rows) if isinstance(r, dict)
and re.search(case["entity"], r.get("excerpt", ""), re.I)]
rank = entity_ranks[0] if entity_ranks else "ABSENT (not in top-k)"
# mimir_q is load-bearing for #393: separates reformulation-at-the-agent-seam
# (mimir distilled/expanded the phrase) from ranking (the tool ranked it low).
print(f" instructed={query!r:28} mimir_q={mimir_q!r:38} entity_rank={rank}")
for i, r in enumerate(rows[:6]):
if isinstance(r, dict):
ex = (r.get("excerpt") or "").replace("\n", " ")
print(f" #{i} [{_flag(ex, case['entity'], case['decoy'])}] "
f"{r.get('score')} {ex[:74]}")
def consumer(base, headers, end_user, case, runs) -> Counter:
verdicts: Counter = Counter()
print(f"\n [consumer — FICTION/donut] x{runs} on {case['question']!r}"
f" (ent@/dec@ = rank in Donut's fiction-scoped results)")
for run in range(1, runs + 1):
sid = _session(base, headers, "ratatoskr:donut", end_user) # fresh session per run
q, res, ans = _drive(base, headers, sid, case["question"])
binds = bool(re.search(case["entity"], ans, re.I))
mis = bool(case["decoy"]) and bool(re.search(case["decoy"], ans)) and not binds
v = "BINDS-ENTITY" if binds else ("MIS-BINDS-DECOY" if mis else "OTHER")
verdicts[v] += 1
er, dr = _rank_in(res, case["entity"]), _rank_in(res, case["decoy"])
print(f" run{run}: {v:16} ent@{er} dec@{dr} q={q!r:30} :: {ans.strip()[:56]}")
# a present entity (ent@ not None) co-occurring with a mis-bind is subject-selection,
# NOT ranking-absence — the fiction-scope half of the #393 two-mechanism split.
print(f" >>> {case['label']}: {dict(verdicts)}")
return verdicts
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--runs", type=int, default=6, help="consumer repeats per case")
ap.add_argument("--case", help="run only this case label (e.g. roid-rage)")
ns = ap.parse_args()
base, headers, end_user = _cfg()
cases = [c for c in CASES if ns.case in (None, c["label"])]
if not cases:
raise SystemExit(f"no case matching {ns.case!r} (have: {[c['label'] for c in CASES]})")
for case in cases:
print(f"\n{'='*72}\n# {case['label']}")
raw_ranking(base, headers, end_user, case)
consumer(base, headers, end_user, case, ns.runs)
if __name__ == "__main__":
main()
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Fiction-wing retrieval probe harness — the recipe R42 (brokkr-smithy-dev) builds against
and the re-runnable #389 acceptance gate.
Two retrieval paths, kept SEPARATE because they have different noise properties:
* search_library (raw, RANKING-clean): drive the `mimir` foundational agent (all-wing
librarian) with a FIXED query string. Deterministic against a fixed index use it for
ranking baselines (R42 arm-2). No LLM in the query loop.
* reference_knowledge (the Tier-3 consumer path): drive `ratatoskr:donut`; her reasoning
REFORMULATES the query each turn, so this path carries QUERY-FORMULATION variance
(the arm-4 signal), attributable via the captured tool_start query. NOT for ranking numbers.
Scoring conventions (identical across both paths and all R42 arms):
* confidence BUCKET vs WT's RRF thresholds: high >= 0.030, medium >= 0.016, low < 0.016.
* ON-TARGET (load-bearing): a returned row is on-target iff its excerpt actually NAMES or
describes the queried subject (keyword match on the subject's distinctive tokens). The
failure signature "10 hits / MEDIUM / 0 on-target" = present-by-topic, subject absent
the split that separated #384 (packaging) / #387 (coverage) / #389 (ranking).
* MISS = no on-target row in the returned top-k.
Noise floor: freeze the generation (pin the b-tag) to remove extraction variance; fixed-string
search_library is deterministic (no CI needed); reference_knowledge variance is query-
formulation, not floor noise. Residual = bucket-boundary sensitivity at 0.016/0.030 so probe
N>=3-5 times per term and report the bucket DISTRIBUTION, never a single-run point label.
Session protocol: ONE fresh session per query. A reused mimir session returns EMPTY
search_library results after its first turn (Worldtree #391), silently scoring later terms
false-MISS; the retrieval helpers self-session to enforce it. Never hoist the session out.
Config from env (source ratatoskr's env.sh): WORLDTREE_API_URL, WORLDTREE_API_KEY,
RATATOSKR_END_USER_ID. No secrets are stored here.
Usage:
uv run python docs/diagnostics/fiction_wing_probe.py # run the artifact yardstick
uv run python docs/diagnostics/fiction_wing_probe.py --runs 5 # N repeats -> bucket distribution
uv run python docs/diagnostics/fiction_wing_probe.py --term "Enhanced Pet Biscuit" --keywords biscuit
"""
from __future__ import annotations
import argparse
import json
import os
import unicodedata
from collections import Counter
import httpx
HIGH, MEDIUM = 0.030, 0.016 # WT RRF confidence thresholds
def _cfg() -> tuple[str, dict, str]:
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
key = os.environ.get("WORLDTREE_API_KEY")
if not key:
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
end_user = os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
return base, {"Authorization": f"Bearer {key}"}, end_user
def _bucket(score: float | None) -> str:
if score is None:
return "none"
return "high" if score >= HIGH else "medium" if score >= MEDIUM else "low"
# Curly punctuation the b170 extraction emits (U+2019 etc.) folded to ASCII so a
# possessive-named subject ("Darcy's letter") matches regardless of quote style.
_QUOTE_FOLD = str.maketrans({
"": "'", "": "'", # noqa: RUF001 - single curly quotes / apostrophe
"": '"', "": '"', # double curly quotes
"": "'", "": '"', # noqa: RUF001 - primes
})
def _fold(s: str) -> str:
"""NFKC-normalize, fold curly quotes/apostrophes to ASCII, lowercase.
NFKC alone does NOT fold U+2019, so the explicit quote-fold is load-bearing."""
return unicodedata.normalize("NFKC", s or "").translate(_QUOTE_FOLD).lower()
def _on_target(excerpt: str, keywords: list[str]) -> bool:
ex = _fold(excerpt)
return any(_fold(k) in ex for k in keywords)
def _session(base: str, headers: dict, agent_id: str, end_user: str) -> str:
r = httpx.post(f"{base}/sessions", json={"agent_id": agent_id, "end_user_id": end_user},
headers=headers, timeout=30)
return r.json()["session_id"]
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[str | None, dict]:
"""POST a turn, return (tool_query, tool_result_dict). tool_result is the first tool packet."""
tool_query, result = None, {}
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
headers=headers, timeout=120) as r:
for line in r.iter_lines():
if not line.startswith("data: "):
continue
ev = json.loads(line[6:])
t = ev.get("type")
if t == "tool_start" and tool_query is None:
tool_query = (ev.get("arguments") or {}).get("query")
elif t == "tool_result" and not result:
result = ev.get("result") if isinstance(ev.get("result"), dict) else {}
elif t == "done":
break
return tool_query, result
def search_library(base, headers, end_user, term, keywords):
"""RANKING-clean path: fixed-string search over the mimir librarian. Deterministic.
Opens a FRESH mimir session per call REQUIRED. mimir stops returning
search_library results after the first turn on a reused session (Worldtree #391),
silently scoring every later term a false-MISS; do not hoist the session to the caller.
"""
msid = _session(base, headers, "mimir", end_user)
_, res = _drive(base, headers, msid, f"Use search_library to find: {term}")
rows = res.get("results", []) if isinstance(res, dict) else []
on = [h for h in rows if isinstance(h, dict) and _on_target(h.get("excerpt", ""), keywords)]
top = on[0] if on else None
score = round(top["score"], 4) if top else None
return {"n": len(rows), "on_target": len(on), "hit": bool(on),
"score": score, "bucket": _bucket(score),
"excerpt": (top.get("excerpt", "")[:140] if top else None)}
def reference_knowledge(base, headers, end_user, question, keywords):
"""Consumer path: Donut reformulates -> capture her tool_query. NOT for ranking numbers.
Fresh donut session per call (same reuse-degradation guard as search_library, and
it keeps each run an independent first-turn sample rather than a growing conversation).
"""
dsid = _session(base, headers, "ratatoskr:donut", end_user)
q, res = _drive(base, headers, dsid, question)
hits = res.get("hits", []) if isinstance(res, dict) else []
on = [h for h in hits if isinstance(h, dict) and _on_target(h.get("excerpt", ""), keywords)]
return {"tool_query": q, "n": len(hits), "on_target": len(on),
"confidence": res.get("confidence") if isinstance(res, dict) else None}
# Artifact yardstick — worldtree-dev grep-confirmed in DCC book-1. Frozen arm-2 baseline.
YARDSTICK = [
("Enchanted Crown of the Sepsis Whore", "Crown of the Sepsis Whore", ["sepsis", "crown"]),
("Enhanced Pet Biscuit", "Pet Biscuit", ["biscuit"]),
("Enchanted BigBoi Boxers", "BigBoi Boxers", ["boxers", "bigboi"]),
("Enchanted Toe Ring of the Splatter Skunk", "Toe Ring of the Splatter Skunk",
["toe ring", "splatter", "skunk"]),
]
def run_yardstick(runs: int) -> None:
base, headers, end_user = _cfg()
print(f"# Fiction-wing ranking yardstick (search_library, {runs} run(s) per name)\n")
for full, partial, kw in YARDSTICK:
for label, term in (("full ", full), ("part ", partial)):
buckets, hits = Counter(), 0
for _ in range(runs):
r = search_library(base, headers, end_user, term, kw)
buckets[r["bucket"]] += 1
hits += r["hit"]
dist = " ".join(f"{b}:{c}" for b, c in buckets.most_common())
print(f" [{label}] {term:<42} hit {hits}/{runs} buckets({dist})")
print()
def run_term(term: str, keywords: list[str], runs: int) -> None:
base, headers, end_user = _cfg()
print(f"# Probe: {term!r} ({runs} run(s))\n")
sl_buckets, sl_hits = Counter(), 0
for _ in range(runs):
r = search_library(base, headers, end_user, term, keywords)
sl_buckets[r["bucket"]] += 1
sl_hits += r["hit"]
print(f" search_library : hit {sl_hits}/{runs} buckets({dict(sl_buckets)})")
for _ in range(runs):
rk = reference_knowledge(base, headers, end_user, f"Tell me about the {term}.", keywords)
print(f" reference_knowledge: conf={rk['confidence']} on_target={rk['on_target']}"
f" (donut query: {rk['tool_query']!r})")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--runs", type=int, default=1, help="repeats per term (>=3-5 near the floor)")
ap.add_argument("--term", help="probe a single term instead of the yardstick")
ap.add_argument("--keywords", nargs="*", default=[], help="on-target keywords for --term")
ns = ap.parse_args()
if ns.term:
run_term(ns.term, ns.keywords or [ns.term.split()[-1]], ns.runs)
else:
run_yardstick(ns.runs)
if __name__ == "__main__":
main()
+145
View File
@@ -0,0 +1,145 @@
"""Lexical-recall gate — the class acceptance instrument for exact-term recall survival.
Generalizes the crown repro (Worldtree #400 / thread 01KZETD98T) beyond its anchor. The
class property under test: when the corpus contains a chunk whose text literally carries a
queried surface form, a natural query for that entity should serve >= 1 such chunk at a
USABLE rank (inside the top-K window). The crown ("Crown of the Sepsis Whore") is the
motivating STRESS case a common word with a dense-similar vanity cluster that buries the
exact-lexical match; distinctive names (Krakaren, Vine Creeper) are CONTROLs that should
always pass. The gap lives on the stress class, not the controls.
This is a before/after regression instrument, NOT a fix: run it pre-deploy and post-deploy
(the BM25 stemming fold, then any rerank_hybrid_floor lever) to measure whether the served
miss-rate on the stress class actually moves. Real-world by design it drives the agent
end-to-end (the agent composes its own reference_knowledge query, as in production), and
--runs samples that query-formulation variance to estimate a true miss-rate.
Self-contained (httpx only). Config from env (source env.sh first):
WORLDTREE_API_URL (default personal :8081), WORLDTREE_API_KEY (required),
RATATOSKR_END_USER_ID (default ratatoskr-tui), RATATOSKR_TTS_AGENT unused here.
uv run --with httpx python docs/diagnostics/lexical_recall_gate.py
uv run --with httpx python docs/diagnostics/lexical_recall_gate.py --runs 5
uv run --with httpx python docs/diagnostics/lexical_recall_gate.py --anchor crown
"""
from __future__ import annotations
import argparse
import json
import os
import re
import httpx
# Served window: a hit past this rank is not "usable" (Worldtree serves ~top-10; a row at
# rank 9-11 is the RRF knife-edge worldtree-dev identified — treated as a KNIFE-EDGE pass).
USABLE_K = 10
KNIFE_EDGE_FROM = 8 # ranks >= this inside the window are fragile (one-rank-edge residual class)
AGENT = "ratatoskr:donut"
# (label, kind, term-regex the served chunk's excerpt must contain, [natural user messages]).
# kind: "stress" = common word + competing dense cluster; "control" = distinctive name.
# Controls should pass every run; the class limitation shows as stress-class misses / knife-edges.
ANCHORS = [
("crown", "stress", r"\bcrown",
["What crown do you own?", "Do you have a crown?", "Tell me about your crown."]),
("vine-creeper", "control", r"vine creeper",
["Tell me about the Vine Creeper.", "What is the Vine Creeper?"]),
("danger-dingo", "control", r"danger dingo|\bdingo",
["What is the Danger Dingo?", "Describe the Danger Dingo."]),
("pedicure-kit", "control", r"pedicure",
["What does the Pedicure Kit do?", "Tell me about the Pedicure Kit."]),
("neighborhood-map", "control", r"neighborhood map",
["What is the Neighborhood Map?", "Describe the Neighborhood Map."]),
]
def _cfg() -> tuple[str, dict, str]:
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
key = os.environ.get("WORLDTREE_API_KEY")
if not key:
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
return base, {"Authorization": f"Bearer {key}"}, os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
def _session(base: str, headers: dict, end_user: str) -> str:
r = httpx.post(f"{base}/sessions", json={"agent_id": AGENT, "end_user_id": end_user},
headers=headers, timeout=30)
r.raise_for_status()
return r.json()["session_id"]
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[str | None, list]:
"""POST a turn; return (actual reference_knowledge query, served hits list)."""
q, hits = None, []
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
headers=headers, timeout=180) as r:
for line in r.iter_lines():
if not line.startswith("data: "):
continue
ev = json.loads(line[6:])
t = ev.get("type")
if t == "tool_start" and q is None:
q = (ev.get("arguments") or {}).get("query")
elif t == "tool_result" and not hits:
res = ev.get("result")
if isinstance(res, dict):
hits = res.get("hits", res.get("results", [])) or []
elif t == "done":
break
return q, hits
def _served_rank(hits: list, term_re: str) -> int | None:
"""Rank of the first served hit whose excerpt literally contains the term (None = miss)."""
for i, h in enumerate(hits[:USABLE_K]):
if isinstance(h, dict) and re.search(term_re, h.get("excerpt", ""), re.I):
return i
return None
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--runs", type=int, default=1, help="repeats per message (samples query variance)")
ap.add_argument("--anchor", default=None, help="run only this anchor label")
args = ap.parse_args()
base, headers, end_user = _cfg()
anchors = [a for a in ANCHORS if args.anchor is None or a[0] == args.anchor]
totals = {"trials": 0, "miss": 0, "knife": 0}
by_kind: dict[str, dict] = {}
for label, kind, term_re, messages in anchors:
print(f"\n[{label}] ({kind}) term=/{term_re}/")
agg = by_kind.setdefault(kind, {"trials": 0, "miss": 0, "knife": 0})
for msg in messages:
for _ in range(args.runs):
sid = _session(base, headers, end_user) # fresh session per trial
q, hits = _drive(base, headers, sid, msg)
rank = _served_rank(hits, term_re)
miss = rank is None
knife = rank is not None and rank >= KNIFE_EDGE_FROM
for d in (totals, agg):
d["trials"] += 1
d["miss"] += int(miss)
d["knife"] += int(knife)
tag = "MISS" if miss else (f"knife@{rank}" if knife else f"ok@{rank}")
print(f" {tag:9} msg={msg!r:42} q={q!r}")
def pct(n: int, d: int) -> str:
return f"{(100*n/d):.0f}%" if d else "n/a"
print("\n=== SUMMARY ===")
for kind, d in sorted(by_kind.items()):
print(f" {kind:8} trials={d['trials']:3} miss={pct(d['miss'], d['trials'])} "
f"knife-edge={pct(d['knife'], d['trials'])}")
t = totals
print(f" {'ALL':8} trials={t['trials']:3} miss={pct(t['miss'], t['trials'])} "
f"knife-edge={pct(t['knife'], t['trials'])}")
print("\nGate: stress-class miss-rate is the deciding signal for the rerank_hybrid_floor lever.")
print("Controls should sit at ~0% miss; a stress miss/knife-edge is the residual class to weigh.")
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
"""Temporal-order gate — measures Worldtree #397 order_by="chapter" end-to-end.
The gap #397 closes: narrative/temporal questions ("your first encounter", "what came
after X", "earliest to latest") need CHRONOLOGICAL order, but reference_knowledge sorts by
RELEVANCE by default. The fix is an `order_by="chapter"` tool flag (enum ["chapter"], taught
in both tool schemas; the result packet carries `ordered_by="chapter"` and hits are reordered
by source chapter, earliest first). Confirmed live on personal b184.
This measures the flag END-TO-END (before/after per the #393 discipline) on three axes:
- ADOPTION: for a temporal query, does the agent actually invoke order_by="chapter"? (the
schema teaches it, but usage varies turn-to-turn the #397 analog of query-formulation
variance).
- MECHANISM (flag applied): are the served hits' provenance.chapter monotonically
non-decreasing (earliest first)? Should be ~100% when the flag fires.
- VALUE (flag not applied): the relevance baseline chapters are NOT chapter-sorted, which
is exactly the gap the flag closes. The applied-vs-not monotonicity gap IS the before/after.
Self-contained (httpx only). Config from env (source env.sh first).
uv run --with httpx python docs/diagnostics/temporal_order_gate.py
uv run --with httpx python docs/diagnostics/temporal_order_gate.py --runs 4
"""
from __future__ import annotations
import argparse
import json
import os
import httpx
AGENT = "ratatoskr:donut"
# Narrative/temporal user messages — the class the order_by="chapter" flag targets.
TEMPORAL_MSGS = [
"What was your very first encounter in the dungeon?",
"What happened when you first entered the dungeon, earliest to latest?",
"In order from the start, how did things unfold between you and Carl?",
"Walk me through your earliest days in the dungeon, oldest first.",
"After your first fight, what came next?",
]
def _cfg() -> tuple[str, dict, str]:
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
key = os.environ.get("WORLDTREE_API_KEY")
if not key:
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
return base, {"Authorization": f"Bearer {key}"}, os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
def _session(base: str, headers: dict, end_user: str) -> str:
r = httpx.post(f"{base}/sessions", json={"agent_id": AGENT, "end_user_id": end_user},
headers=headers, timeout=30)
r.raise_for_status()
return r.json()["session_id"]
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[dict, dict]:
"""POST a turn; return (tool_start arguments, tool_result dict)."""
args, result = {}, {}
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
headers=headers, timeout=180) as r:
for line in r.iter_lines():
if not line.startswith("data: "):
continue
ev = json.loads(line[6:])
t = ev.get("type")
if t == "tool_start" and not args:
args = ev.get("arguments") or {}
elif t == "tool_result" and not result:
result = ev.get("result") if isinstance(ev.get("result"), dict) else {}
elif t == "done":
break
return args, result
def _chap_num(c: object) -> int:
"""Chapter as an orderable int; non-numeric (e.g. 'EPILOGUE') sorts last."""
try:
return int(str(c))
except (TypeError, ValueError):
return 10**9
def _is_monotone(chapters: list) -> bool:
nums = [_chap_num(c) for c in chapters if c is not None]
return all(a <= b for a, b in zip(nums, nums[1:])) if len(nums) >= 2 else True
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--runs", type=int, default=2, help="repeats per message (samples adoption variance)")
args = ap.parse_args()
base, headers, end_user = _cfg()
applied = {"trials": 0, "monotone": 0} # flag fired
unapplied = {"trials": 0, "monotone": 0} # flag did NOT fire (relevance baseline)
adopted_n = 0
total = 0
for msg in TEMPORAL_MSGS:
print(f"\nMSG: {msg!r}")
for _ in range(args.runs):
sid = _session(base, headers, end_user)
targs, res = _drive(base, headers, sid, msg)
hits = res.get("hits", res.get("results", [])) or []
chapters = [(h.get("provenance") or {}).get("chapter") for h in hits if isinstance(h, dict)]
adopted = targs.get("order_by") == "chapter"
applied_flag = res.get("ordered_by") == "chapter"
mono = _is_monotone(chapters)
total += 1
adopted_n += int(adopted)
bucket = applied if applied_flag else unapplied
bucket["trials"] += 1
bucket["monotone"] += int(mono)
tag = "FLAG" if applied_flag else "----"
print(f" [{tag}] adopted={adopted!s:5} monotone={mono!s:5} chapters={chapters}")
def pct(n: int, d: int) -> str:
return f"{(100*n/d):.0f}%" if d else "n/a"
print("\n=== SUMMARY ===")
print(f" adoption (agent invoked order_by=chapter): {pct(adopted_n, total)} ({adopted_n}/{total})")
print(f" flag APPLIED -> chapter-monotone: {pct(applied['monotone'], applied['trials'])} "
f"(n={applied['trials']}) [mechanism — should be ~100%]")
print(f" flag NOT applied -> chapter-monotone: {pct(unapplied['monotone'], unapplied['trials'])} "
f"(n={unapplied['trials']}) [relevance baseline — the gap the flag closes]")
print("\nGate: the applied-vs-not monotonicity gap is the flag's VALUE; adoption rate is the")
print("residual (schema teaches it, agent use varies) — the #397 analog of query-formulation variance.")
if __name__ == "__main__":
main()
@@ -0,0 +1,78 @@
# Donut voiced-interview build — ACTIVE (auto-TTS via Zonos + KB-recall bridge + Princess Donut)
`[2026-08-01]` Operator asked: "add auto-tts to the ratatoskr web gui" → grew into a voiced,
corpus-grounded Tier-3 interview character. Contract:
`docs/contracts/donut_voiced_interview.contract.md` (validated). Slice 1 DONE; slices 2-3 TO BUILD.
Operator ruling: **build it today, but design the KB path to RETIRE** (it works around a Worldtree
gap — Tier-3 agents are tool-less by design; KB search is integral, so the gap should close).
## Slice status
- **SLICE 1 (persona) DONE**`docs/characters/donut.md` authored (corpus-grounded from a Mimir
pull + DCC canon: interview-register performer, "Carl looks intimidating while I talk," vulnerability
under a good question, and the "pinned corpus = her own memory" framing). **Defined:**
`ratatoskr:donut` (role `thoughtful-character`) live on personal :8081, in the console picker.
Chattable now (from training until slice 3). NOT yet committed (uncommitted at snapshot).
- **SLICE 2 (auto-TTS) TO BUILD** — new `src/ratatoskr/tts.py` (Zonos gateway client + `pad_to_dials`) +
`/api/tts` proxy route in `web/server.py` + `index.html` (🔊 toggle, speak-on-`done` playback,
serialize/cancel). TDD (respx-mock the gateway), live-smoke.
- **SLICE 3 (KB-bridge) TO BUILD** — new `src/ratatoskr/kb_bridge.py` (RETIRE-READY, INV-KB-1) +
the one server.py seam. Per interview turn: out-of-band Mimir consult → extract evidence →
POST Donut's turn with `memory_context=[{kind:"corpus_reference", text:"<extract>"}]`. She answers
in-voice. TDD.
## Design — LOCKED (both external deps verified live 2026-08-01)
**TTS (infra-ops Zonos specs, verified):**
- Endpoint: `POST http://10.100.79.3:8890/v1/audio/speech` (the zonos GATEWAY at irv-ml1:8890 — NOT
the engine's :1920, which has a rep-penalty bug). Also GET /v1/voices, /v1/dials, /health. WG-internal,
**no auth**. Reachable from nh3-dev (the console box) over WireGuard.
- Request: JSON, only `input` required. `response_format:"wav"` (16-bit RIFF; **mp3/opus silently
fall back to mislabeled PCM — never use**). Verified: voice=donut → HTTP 200, RIFF/WAVE PCM 16-bit
mono 44.1kHz.
- **NOT streaming** — gateway buffers to a complete clip (~1.8s/sentence). "Speak on done" gives the
whole clip after the synth delay. True streaming = a future gateway enhancement (infra-ops can raise).
- **Voice: `voice:"donut"` is ALREADY REGISTERED** (infra-ops cloned Vuong's Princess Donut bundle;
GET /v1/voices returns Donut; case-folded so "Donut.wav"→voice:"donut"). Custom-voice registration
mechanism: drop a `<Name>.wav` (44.1kHz mono 16-bit, ~10-20s clean single-speaker) in the gateway
voices dir + restart. Audition A/B: http://10.100.10.50:8090/b/donut-voice/
- **Affect-driven emotion (DEC-7, the standout):** map the turn's live PAD (from the `affect_update`
SSE the console ALREADY consumes) → Zonos `emotion_valence`(pleasure)+`emotion_arousal`(arousal).
Reframes the feature as voice OBSERVABILITY (hear the affect the persona pane shows), NOT chat-app
creep — resolves the scope concern. Dials also: `preset`, `emotion_sliders{happy,sad,angry,surprised}`,
`emotion_strength`, `speed`, `seed`.
- **Coupling: direct :8890** (DEC-1) for the emotion dials; the swappable LiteLLM `ext-tts` alias
(10.250.50.70:4000) loses the Zonos emotion params. `tts.py` is the single swap seam.
- **Serialize** one synth per turn (DEC-5) — shared 3090 ~90% VRAM; cancel-on-new-turn.
- **Proxy** (DEC-4): browser → `/api/tts` (nh3-dev) → gateway; never surface irv-ml1 to the client.
**KB-recall (worldtree-dev ruling, wyrd-proven):**
- Tier-3 is DELIBERATELY tool-less in v1 (`build_tier3_agent_context` hard-codes `tool_schemas=[]`);
no search_kb grant, no call-Mimir-as-tool (#361 rejected it, recursion). So: **consumer-side
retrieval + memory_context pinning** — ratatoskr consults Mimir out-of-band, extracts the cited
evidence, pins `memory_context=[{kind:"corpus_reference", text}]` on Donut's turn. She frames it as
her own memory (the persona prompt already instructs this). Our SDK's stream_turn already forwards
`memory_context` verbatim. Latency: one retrieval round-trip/turn.
- **RETIRE-READY (INV-KB-1):** kb_bridge behind ONE seam `pin_kb_context(question, agent_id) ->
list|[]`; retiring = delete the module + one call-site. Delete when Worldtree #361
`reference_knowledge` extends to Tier-3 (operator flagged it an integral GAP; worldtree-dev surfacing
the extension to Vuong — decision inbound to the operator).
## OWED / open loops (post-clear)
- **P&P verification re-run OWED to infra-ops**#382 deployed (b166: self-describing `_index.md` +
the Mimir prompt rule "hits ARE library content, never discard/substitute"). Re-run the failing
P&P-dismiss shape on :8081, report to infra-ops. **DCC's backfill FAILED** (job predates state-tracking,
no `_index.md` yet — flagged to worldtree-dev) — but the prompt rule alone should stop the discard,
so the KB-bridge's DCC Mimir consults should ground fine. Verify this when building slice 3.
- **worldtree-dev #361-Tier-3 extension** — an inbound surface decision for Vuong (would let Donut
search in-voice + delete the bridge).
- **infra-ops custom-voice** — mechanism proven; hand them a `<Name>.wav` + name for any future character.
## Discipline
Operator said "do both slices" (skip /heid-contract-review, straight to TDD). Run heid code-review +
bug-hunt AFTER each code slice. Release-only versioning cadence (no per-commit tags; `feat:`/`fix:`
conventional commits between releases). CLI to drive Mimir/Donut turns:
`ratatoskr --send "..." --new --agent <id> --end-user-id ratatoskr-tui`. Define:
`python -m ratatoskr.tier3 define --name <slug> --system-prompt "<prompt>" --role thoughtful-character`.
@@ -0,0 +1,26 @@
`[2026-08-02]` **Donut TTS: English pin + long-form chunking + POST + dialogue-only persona + both heid gates — shipped `d59f907` (pushed).**
Operator reported two symptoms: Donut's voice "occasionally goes to gibberish" and "truncates on long streams." Both root-caused and fixed, then a mid-work persona directive folded in, then the full House Code Discipline (both heid gates) run.
## Gibberish → DEC-9 English pin
The Zonos gateway (`/v1/audio/speech`, irv-ml1 :8890) is multilingual and takes a `language` field we were never sending → it drifted into other-language phonemes on names/numerics/long spans. Fix: `gateway_body` pins `language:"en-us"` on every call (`_TTS_LANGUAGE`). Verified the gateway accepts it live. The persona rewrite (below) killed the OTHER gibberish vector — the old prompt MANDATED asterisk RP action beats (`*she flicks her tail*`), which were being fed to the engine verbatim.
## Truncation → DEC-10 client-side chunk-and-concatenate
Empirically characterized + confirmed by infra-ops (recipe thread `01KZ1FKX…`): the Zonos model hard-caps ONE synthesis at `max_tokens=6144` = **71.2s of audio** (6144 / 86.3Hz codec frame rate). CRITICAL physics (infra-ops correction): 86.3Hz is a **delivery-independent constant** — 6144 tokens is ALWAYS 71.2s regardless of emotion/rate; emotion changes how many WORDS land in 71.2s, not seconds-per-token. `max_tokens>6144` → HTTP 400 (architectural sequence limit, unraisable). Our old 2000-char cap was LARGER than one call could voice (~750-900 chars), so the gateway truncated first, mid-stream, at ~58-71s.
Fix (`tts.py`): `chunk_text(text, budget=747)` — paragraph-first greedy pack (seams on blank lines), sentence fallback for oversized paragraphs, clause(`, ; :`)/word sub-split for oversized sentences; budget = 71.2s×0.75×14c/s (operator: "greedy to 75% of cap for prosody"; the 25% headroom absorbs char→audio-seconds variance). `tts_stream_long` synthesizes each chunk (identical voice+dials+language) and concatenates: chunk 1 verbatim (WAV header + PCM), chunks 2..N header-stripped (`_pcm_after_header`) → ONE continuous int16-PCM stream (infra-ops: never bury a RIFF header mid-stream). `/api/tts` became **POST** (DEC-10a) so the full long text rides the body, not a length-capped GET URL; outer cap 2000→8000 (a shared-3090 hold bound now, not a URL bound). Live-proven: 1978 chars → 3 balanced chunks → 106.6s, one header, clean concatenation — ceiling broken.
## Persona → dialogue-only + always-consult-the-tool
Operator directive mid-work. Rewrote `docs/characters/donut.md` and live-patched `ratatoskr:donut` (via `python -m ratatoskr.tier3 patch`): (a) ONLY spoken dialogue — no stage directions / asterisks / emoji / markdown (preserves her theatrical VOICE, strips narrated ACTION — the TTS-gibberish source); (b) ALWAYS call `reference_knowledge` before answering any question. Note: the tool-call-first is PROMPT-LEVEL only — the define surface (`{agent_name, role, system_prompt}`) exposes no tool_choice, so a hard guarantee would need a WT-side forced-tool capability. Fixed stale drift: the doc still described the retired `kb_bridge`/`[MEMORY:DATA]` pinning. Live-verified: her first event was `reference_knowledge{query}`, output was 0-asterisk dialogue.
## Both heid gates (4-arm panels: Gróa/Hulda/Regin/Kimi)
Dispatched in parallel (code-review thread `01KZ21MC…`, bug-hunt `01KZ21RN…`). The GET→POST switch had re-opened untrusted-type paths that string-only query params masked — all four arms converged. Fixed:
- **Untrusted /api/tts body fields degrade, never 500:** huge-int PAD (`float()` OverflowError, not caught by `except ValueError` — routed through the hardened `PadState.from_obj`); non-str `agent_id` (unhashable `dict.get` TypeError — `isinstance(str)` guard); lone surrogate in text (rides JSON as `\udXXX`, `UnicodeEncodeError` on httpx's utf-8 encode of the gateway body — scrubbed via `encode("utf-8","ignore")`); whitespace-only text (empty 200 → 400 via `strip()`).
- **Lock+client leak on peek escape** (was a PERMANENT deadlock — a browser abort/CancelledError or `httpx.InvalidURL` before byte 1 escaped the two narrow `except` arms; the `finally` only existed once `piped()` ran): `except BaseException: await _release(); raise` around the peek.
- **A2 (net-new):** a mid-stream drop AFTER the 200 committed raised into the response (httpx wraps `aiter_bytes` in `except RequestError`, so a mid-stream drop arrives as `TtsUnavailable`, and the old `if i==0: raise` fired post-commit). Fixed with a `yielded_any` pivot: pre-first-byte failure → raise (→503), post-first-byte failure (any chunk) → degrade+log.
- **A3:** non-WAV 200 body forwarded as audio/wav (browser decodes markup as PCM): RIFF-sniff the peeked first bytes → 503; bounded `_pcm_after_header` scan (1KiB) + browser 64KiB acc bound.
- Contract brought canonical: DEC-9/10, FN chunk_text/tts_stream_long/tts_endpoint (POST), INV-TTS-4 logging scope, FN pad_to_dials domain, FN client:speakOnDone (POST+8000).
Triaged OUT: refuted Hulda's `UnicodeDecodeError`-escapes (it IS a `ValueError` subclass, already caught — Regin confirmed); Regin self-retracted a false `_hard_wrap` finding; accepted the char→token proxy (documented + infra-ops-blessed). Deferred (unchanged sibling handlers): `_create_session`/`_submit_turn` 500 on malformed JSON → filed as issue **#21**.
+14 regression tests across the two gates; 546 green; committed + pushed `d59f907`.
Related: [[2026-08-01-donut-voiced-interview-build]] (the prior slices).
@@ -0,0 +1,16 @@
`[2026-08-03]` **Donut anti-fabrication persona + the retry-on-LOW experiment (tested, rejected).** Commits `3e12c4d` + `c0a66fc` (pushed).
Operator: "adjust donut not to make shit up — her searches for Zev and Jack are still misses." The persona (rewritten earlier this session to dialogue-only + always-call-`reference_knowledge`) still MANDATED confabulation: "never break character to admit the records are thin; answer with total confidence." So on a tool miss she filled the gap from her DCC *training* knowledge and presented it as grounded recall.
## The fix (c0a66fc final state)
Her memory IS what `reference_knowledge` returns, nothing else. A **MISS** = results empty, confidence **low**, OR nothing in the results actually names/describes the subject → deflect IN CHARACTER (a theatrical dismissal: "That name doesn't ring a bell, darling — beneath my notice, clearly"), never a confident fiction, never fill from book-knowledge she can't see in the results. **MEDIUM+ → answer** (grounded). Threshold is LOW=deflect / MEDIUM+=answer — gating stricter (HIGH-only) would silence legitimately-thin-but-grounded content like Carl (MEDIUM).
**Self-correcting property:** the gate tightens when content is missing and opens when it arrives. Jack deflected when absent (LOW), and grounds now that b170 extracted his death plot_event (MEDIUM) — zero persona change needed across the KB improvement. Verified live: a fabricated term ("Whispering Gauntlet of Thexmar") and a genuinely-absent subject both deflect; Carl (MEDIUM) answers.
## The retry-on-LOW experiment — TESTED, REJECTED (the load-bearing finding)
Operator asked "should she search again at low confidence?" Reasoning said yes (LOW is often query-phrasing sensitivity, not absence — the Crown grounded on its full name but missed on the partial; Zev flips LOW↔MEDIUM). Wired a bounded (1-retry) reformulated retry. **It BACKFIRED.** When Donut reformulated "Jack" → "Jack the dungeon crawler ... with Carl, Yolanda, Donut", the query scored **MEDIUM off the OTHER real entities** (the corpus is dense with Carl/dungeon content), handing her a false grounding to fabricate Jack. Even subject-only reformulation ("Jack Dungeon Crawler Carl") inflated to MEDIUM. **Root cause: RRF confidence is inflatable by any DCC-flavored query — it reflects query-term matches, not whether a row NAMES the subject.** So a persona-side confidence gate cannot stop fabrication via query padding. Reverted to single-search LOW=deflect. The robust fix belongs tool-side: a does-the-returned-row-actually-name-the-subject check before ranking/confidence — routed to Worldtree **#389** (the ranking axis).
## FOOT-GUN: tier3 patch doesn't refresh the live agent context
A `python -m ratatoskr.tier3 patch ratatoskr:donut --system-prompt …` reports success and updates STORAGE, but the running agent's context did NOT pick up the new prompt (verified: patched anti-fabrication, behavior unchanged). **Recreate (delete + define) is the reliable path** to change a live Tier-3 persona. Her role is `thoughtful-character` (the donut.md header's old `character-rp-reasoning` was drift, corrected).
Related: [[2026-08-03-reference-knowledge-3-round-verify]], [[2026-08-02-donut-tts-chunking-english-gates]].
@@ -0,0 +1,25 @@
`[2026-08-02→03]` **reference_knowledge grounding validated end-to-end — 0/5 → 5/5, and the verify instrument drove the diagnosis of a structural Tier-3 blindness.**
Donut's native `reference_knowledge` tool (WT #383/b167) returned empty for every query at session start, even though the DCC corpus was supposedly ingested. Chased it across ~5 exchanges with worldtree-dev; ratatoskr was the verify instrument the whole way, and the baseline discipline (identical 5 canonical terms, verbatim reporting) made each delta attributable.
## Two Worldtree-side defects, both fixed
**Defect 1 — wing-misfile.** DCC (`fiction/b59c147c5ce0/`) was ingested BEFORE Worldtree #380 fixed wing-blind book indexing, so its notes filesystem-lived in the `fiction` wing but its index ROWS landed in the DEFAULT collection = `main`. The Tier-3 tool is scoped to `fiction`, so Donut searched an index with no DCC rows; Mimir (all-wing librarian) found DCC fine in `main`. My `search_library`-in-main vs `reference_knowledge`-empty divergence isolated it. worldtree-dev re-ingested DCC into `fiction` and REJECTED the alternative (widen `tier3_wings` to include `main`) — my containment argument held: widening would expose the whole general-knowledge wing to every Tier-3 interview character, breaking in-character containment. But after re-ingest, still 0/5.
**Defect 2 (the real root) — INV-361-3 provenance filter.** The `reference_knowledge` packager drops any hit lacking BOTH `note_id` AND `path` metadata. The muninn indexer had NEVER written either key on concept rows — 166 fiction rows, zero with the pair. So the search FOUND the terms (same rows `search_library` served) and the packet filter discarded 100% → hits=0/confidence=none. Not a different index, not a score floor: a metadata-contract mismatch at the packaging layer. Tier-3 had been STRUCTURALLY BLIND to every concept row ever indexed; the P&P-era tool-loop probes exercised the call path, not concept grounding. My divergence datum (search_library=10 fiction hits vs reference_knowledge=0, same wing) was "the key that found it" (worldtree-dev). Fixed as **#384** (indexer writes note_id + a wing-relative path; existing rows recover via --reindex).
## The 3-round verify arc (same 5 terms: Carl, dungeon/Crawl, spells, Mordecai, Butcher's Masquerade)
- **Baseline (pre-#384):** 0/5, confidence none.
- **Round 1 (b168, 97-concept corpus):** 5/5 PRESENT — Carl 10/HIGH, dungeon 10/HIGH, spells 10/MEDIUM, Mordecai 3/HIGH, Masquerade 6/LOW. First light; confidence buckets tracked WT's 0.030/0.016 thresholds.
- **Round 2 (#385 re-extraction, 705-concept corpus, ~7× density):** all 5 saturate top_k=10; the thin terms filled out (Mordecai 3→10, Masquerade 6→10, LOW→medium — no weak term left). Buckets converged to medium (more competing rows shift RRF composition, per WT — not regression). Content sharpened.
Signed off → #384/#385 close. worldtree-dev: "the cleanest consumer-side validation this pipeline has ever had." Also flagged (parked with attribution as an RRF-tuning observation): the Carl-Donut relationship concept cluster dominates the RRF head across multiple queries.
## Why this mattered for ratatoskr
The persona I shipped this session (dialogue-only + ALWAYS call `reference_knowledge` first) now has real corpus behind it — Donut genuinely recalls her own DCC story instead of falling back to training knowledge. Voice (long-form TTS) and memory (grounded recall) both came together in one session.
Follow-on (separate axis): the artifact-coverage gap → WT #387 (schema not artifact-aware). See the Recent-decisions #387 entry.
Related: [[2026-08-02-donut-tts-chunking-english-gates]], [[2026-08-01-donut-voiced-interview-build]].
@@ -0,0 +1,21 @@
`[2026-08-11]` **TTS migrated chatterbox-fast → dots-tts (v0.22.2, `38b78d8`).**
Operator-directed after an infra-ops A/B win ("very good"). dots-tts = rednote-hilab
`dots.tts-soar` on irv-ml1, replacing chatterbox-fast. `tts.py` stays the single swap seam.
**Deltas (everything else — streaming placeholder-header WAV shape, browser Web-Audio PCM
decode, POST `/api/tts`, the DEC-5 serialize lock, INV-TTS-1..4 — unchanged):**
- **Gateway body OpenAI-shaped:** `{input, voice, response_format:"wav", stream:true}`
`input` not chatterbox's `text`, `response_format` not `format`. Closer to the Zonos-era
client. `DOTS_TTS_URL = http://10.100.79.3:8198/v1/audio/speech` replaced `CHATTERBOX_TTS_URL`.
- **Sample rate 24000 → 48000 Hz** (browser `SR = 48000` in `index.html`, else ~2× too fast).
- **Default voice `glados_25s` → `glados`** (dots voices: donut/glados/emmie/miranda). donut carries over.
- **Serialized single-consumer** — dots renders one generation at a time; satisfied by the
existing DEC-5 lock, no code change.
- **Affect stays dropped** (DEC-7): dots has no emotion knob, same as chatterbox — NOT a fresh loss.
Live-verified end-to-end on `:8765` (RIFF/WAVE 48kHz mono s16le through `/api/tts`); 520 tests
green. Contract `donut_voiced_interview.contract.md` amended (migration banner + DEC-1/3/8/9a +
FN blocks). chatterbox-fast `:8197` kept as rollback, then reclaimed by infra-ops once
operator ear-confirmed. Superseded next day by config-driven voices (see
[[2026-08-12-config-voices-two-voice]]).
@@ -0,0 +1,32 @@
`[2026-08-12]` **Config-driven voices + two-voice dialogue/narration split SHIPPED (v0.22.3, `5c3d0ad`).**
Operator ask: "we need rata side config of voices too" + "tempted to do two voices — one for
dialogue, one for non-dialogue." Chose (via AskUserQuestion) "config + two-voice together now."
**Config (retires the hardcoded `_TTS_VOICE_MAP`):** `~/.config/ratatoskr/voices.json`, beside
`local_agents.json`/`provider.env` (NOT folded into the agent-index schema — isolated from its
v1→v2 silent-drop foot-gun). Schema:
`{"default":"<v>","agents":{"<agent_id>":{"voice":"<v>","narration_voice":"<v>"?}}}`. Absent/malformed
→ built-in `DEFAULT_VOICE_CONFIG`, logged, never crashes (INV-TTS-5). Initial: donut→donut;
sindra→miranda dialogue + emmie narration.
**Two-voice = the presence of `narration_voice` is the split switch** (dropped the operator's
proposed `dialogue_only` flag — an agent with no narration_voice is single-voice, which is why
dialogue-only Donut needs no special-casing). `segment_dialogue` splits on quotes (straight `"`
+ curly `“ ”`; apostrophes never split a word): quoted = dialogue → `voice`, rest = narration →
`narration_voice`. Known limit: if a mixed persona speaks WITHOUT quotes, it reads as narration.
**Synthesis (`tts_stream_stitched` replaced `tts_stream`):** spans synth SERIALLY (dots
single-consumer), span 0 verbatim (wav header+PCM), spans 1..N header-stripped
(accumulate-until-`data`) → one gapless 48kHz stream with a single leading header (INV-TTS-7).
A single-span list is a byte-identical passthrough (INV-TTS-6, no single-voice regression). NOTE
the dots-pcm-not-headless-in-stream foot-gun that forced this design (see Tried and abandoned).
New module `src/ratatoskr/voices.py` (`load_voice_config` / `segment_dialogue` /
`resolve_voice_spans`); `create_app` gains `voice_config`; `entrypoint` loads it. Contract DEC-11
+ INV-TTS-5/6/7; new `tests/test_voices.py`. **545 tests green**, new code ruff-clean. Discipline:
contract-first + TDD + seam-check + local bug-hunt + live-verify; heid async gates SKIPPED
(offered). Live-verified on `:8765`: Sindra mixed turn → 2 dots calls (emmie+miranda) stitched to
one 48kHz WAV (single RIFF header); Donut single-voice unchanged. Cut as **patch** (self-contained
feature, no downstream coordination → patch-defensible → tie-break to patch; operator declined the
v0.23.0-minor option by saying "push it"). Builds on [[2026-08-11-tts-dots-migration]].
@@ -0,0 +1,30 @@
`[2026-08-12]` **Personal WT :8081 deadlocked after ~4 days → routed to worldtree-dev as WT #401.**
Operator hit "failed to load agents" in the web console + asked "does the server need
restarting?" Diagnosed: NOT ratatoskr. The personal Worldtree at `10.250.50.152:8081`
(`worldtree-personal-worldtree-api-1`, "Up 4 days (unhealthy)") was HUNG — TCP port open (accepts
connections) but the app answered NOTHING: `/health`, `/agents`, `/capabilities` all returned
`000` (accept-then-drop, "Server disconnected without sending a response"). Classic dead/deadlocked
event loop behind a live socket.
**Ratatoskr behaved correctly:** `/api/agents` was 12/12 502 `network_error` on the dead
upstream (the endpoint's `except (httpx.RequestError, ConnectFailed) → 502`), and auto-recovered
to 8/8 200 (donut+sindra back) the instant WT answered — NO ratatoskr restart. Restarting
ratatoskr would only have 502'd against the same dead upstream + dropped the session.
**Recovery + routing:** operator greenlit → I pinged infra-ops (uptime lane). They `docker
restart`'d it (same image, no recreate; demo `:8080` + pinned `:8082` untouched); `/health`
recovered 000→200 at ~45s. Operator greenlit root-cause → routed to worldtree-dev, tracked as
**WT #401** (diagnose-first). infra-ops froze the full 4-day container log (55MB / 647k lines,
`2026-08-08T22:15Z`→restart) to host-fs (`nh3-dev:/var/tmp/worldtree-401/`) since the docker
health buffer had already rolled.
**Smoking gun (infra-ops, from the app log):** last good `/health` at `22:00:26Z`; the dying
worker's LAST line = a sqlite **"unable to open database file"** traceback in the
**`context_promotion` ledger** at `22:09:38Z`. No OOM, disk not full. worldtree-dev's read: a
background/idle promotion job made an untimed synchronous sqlite call that wedged the event loop
(no user turn in flight; abrupt serving→silent in <60s; ~0.1s connection drops = accept queue
serviced but handler coroutines never scheduled). Consumer-side data I gave #401: today's :8081
load was light + fully sequential, and TTS synthesis offloads to dots `:8198` (added ~zero :8081
load). **Root-cause is worldtree-dev's; I owe them only a consumer-side repro if they ask.**
The hang-signature is now a substrate foot-gun (see Current state).
+46 -95
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-07-19_
_Last updated: 2026-08-13_
> **Always check for `/tmp/ratatoskr-dev-handoff.md`** — if it exists and its
> `Written:` stamp is under an hour old, read it (it carries the in-flight
@@ -44,110 +44,36 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-07-19:_
_As of 2026-08-13:_
**✅ COMPLETE — worldtree-sdk cutover (issue #20): ALL 7 SLICES DONE, shipped as v0.22.0 (2026-07-19).**
Ratatoskr's CONSUMER client layer is fully cut over from hand-rolled httpx wrappers to **worldtree-sdk (Python)
1.0.0** behind the thin `ratatoskr.wt` adapter (operator ADOPT ruling 2026-07-18; 6 DECs, vor-cross'd +
heid-panel-reviewed; contract `docs/contracts/worldtree_sdk_cutover.contract.md` with slice-1..7 notes canonical).
Six route-family slices (foundation / sessions-turn / persona-authored / agents-tier3 / characters-me / admin) +
slice-7 teardown, each through the full House Code Discipline (adapter→cli/web→delete→live-smoke→both heid gates).
Suite **494 green**. Per-slice arcs → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-{1-2,3,4,5,6,7}-complete.md`.
**Slice-7 teardown** (`ec68b1f`, v0.22.0, zero runtime-logic): dropped `httpx-sse` (SDK owns SSE parsing; venv
pruned, nothing imported it); KEPT `sessions.py`+`sse_client.py` as pure caller-semantic type/exception homes
(operator decision A1 — no rename/fold; A3 blocked by the `AgentNotAvailable` name-collision + `endpoint_for_plane`
provider-homing); retired wire contracts #2/#15 (files deleted; **#1 SSE-vocab + first_message KEPT**, not retired);
final coverage-map re-anchor.
**KEY ADAPTER FOOT-GUNS (cumulative — still live for ANY future consumer/SDK work):** SDK reads = **open-world
dicts**, presenters MUST degrade-not-crash guarded at THREE levels — **container-type** (`isinstance(_, list)`: a
scalar `123` is non-iterable, `or []` catches null but NOT a truthy non-iterable), **element-type**
(`isinstance(m, dict)`), **top-level-mapping** (`isinstance(_, Mapping)` before any `.get`); never hard-index. The
SDK **normalizes ANY transport failure to `ConnectFailed(status=0)`** (not raw httpx) — every adapter caller
`except ConnectFailed`. **Caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the
`-m` module** (double-module class-identity split → uncaught traceback; homed in `sessions.py`, never run as
`__main__`). `TurnEvent.turn_id` ABSENT on text/thinking frames; `consumer_key` is BOUND-create-only. Scope was
consumer layer ONLY; Bifrost provider untouched. Design → auto-memory `project_worldtree_sdk_cutover`.
**Nothing in flight on ratatoskr's side. The RP-voice stack was rebuilt this session (chatterbox→dots, then config-driven voices + a two-voice dialogue/narration split) — all shipped + PUSHED (HEAD `5c3d0ad`, origin synced, tags `v0.22.2`/`v0.22.3`). The one open tail is upstream: WT #401 (worldtree-dev owns the personal-WT deadlock root-cause).** Details in Recent decisions; durable substrate below.
**▶️ POST-CUTOVER PENDING (non-blocking, operator's call):** (a) `git push` origin — the `ec68b1f` commit +
**v0.22.0 tag are LOCAL only**; (b) althing announce of v0.22.0 to worldtree-dev / wtsdk-dev per the SemVer
push-notify (post-push; Rata is the SDK reference consumer). Nothing else pending on the cutover.
- **TTS = dots-tts + config-driven voices + two-voice split** — live on `ratatoskr-web` **pid 2290701** (`:8765`, dots + `voices.json` loaded). Backend is **dots-tts** via the fleet **switchboard** `http://10.100.79.3:8198/v1/audio/speech` (OpenAI-shaped `{input,voice,response_format,stream}`, 48kHz mono s16le, streaming placeholder-header WAV). `tts.py` is the single swap seam (`RATATOSKR_TTS_URL` overrides), NO affect. Per-agent voices live in **`~/.config/ratatoskr/voices.json`** (donut→donut single; sindra→miranda dialogue + emmie narration); an agent with a `narration_voice` gets the quote-split two-voice stitch (`tts_stream_stitched`, `src/ratatoskr/voices.py`).
- **Personal WT :8081 recovered + root-cause routed to WT #401** — it deadlocked after ~4 days (event-loop wedge from a background `context_promotion` sqlite error); infra-ops `docker restart`'d it, worldtree-dev owns the diagnosis. Ratatoskr auto-recovered (degrade path held; no ratatoskr restart). Only possible follow-up = a consumer-side repro if worldtree-dev asks.
**✅ RESOLVED — tier3 agents `model``role` (scope B) folded into cutover slice-4** (`c62b4ee`, v0.21.13). The
deferred deploy-gated scope-B work (response `model``role` per spec 1.2 / b128, `LocalAgentEntry`, index schema
v2) landed with the agents-family SDK cutover — no longer pending. See the slice-4 detail file + Recent decisions.
**Deferred / queued (non-urgent, pre-existing — tracked, NOT this-session):** heid gates on the two-voice feature SKIPPED in-session (offered, not run — optional cross-frontier gate if wanted); TUI rip (`tui.py`+`tests/test_tui.py` delete cleanly, but `cli.py` is SHARED — relocate `USER_AGENT`/`ParsedArgs`/`_format_*` first; breaking public-surface removal → minor bump, operator approval); #368 backfill live-verify (synthetic legacy corpus); SDK **#371** (Rata = seed donor + future consumer; next = WT codegen spike; divergences D-1 unknown-SSE-skip / D-2 open-world-enums / D-3 typed-410); P06 referential-frame A/B/C probe (waits WT b122); R39 Phase-2 matched-quartets (confirmatory); bifrost snapshot-cursor adoption (normative, non-blocking).
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
app-product ideas. Rata does NOT fork into anything and is NOT the base for any chat / image-gen app.
Rata reverts to its core identity: **the Worldtree Conversation-API debug-observability surface +
the Bifrost Tier-3 reference impl + the Conversation-API SDK seed / future-consumer.** Supersedes the
2026-07-17 "fork Rata for arbo" NEXT-MAJOR plan (that plan is retired; arbo API intel lives in the
Recent-decisions detail trail if the new repo wants it).
**▶️ Rata's live stake in the Worldtree SDK (#371).** The SillyTavern *app* moved out, but Rata
stays the **SDK's seed donor + a future consumer**: Worldtree owns the official Conversation API SDK
(dedicated repo, elevated pre-v1 track, **WT #371**), seeded from Rata's client spine
(`sse_client`/`sessions`/`tier3`); TS ships first, **Rata's repin rides the later Python milestone**;
internal-registry-only. This session Rata shipped the parity-matrix inventory (→ WT
`docs/sdk/parity-matrix.md` `698cfc3`, behavioral-spec `c999b5f`, fixture corpus `3019ee6` = 66
fixtures), the issue-#1 contract drift fix (`4bd9abd`), and the personal-WT endpoint. WT pipeline:
behavioral-spec ✓ / parity-matrix ✓ / fixture-corpus ✓ → next is the **codegen spike (d)**. Three
divergences pinned for our eventual repin: D-1 (unknown SSE event → skip+diagnostics-hook vs our
current hard-raise), D-2 (open-world enums), D-3 (typed eager `410 session_retired`). Full arc →
`persistent-memory.d/2026-07-18-worldtree-sdk-371-and-sillytavern-app.md`.
- **SDK fixture-capture: GO GIVEN** (operator → worldtree-dev, 2026-07-18); the capture run
proceeds on Worldtree's side. Nothing pending from Rata on #371.
**⚠️ TUI DEPRECATED (operator 2026-07-17) — web supersedes it; rip pending, surgical.** Independent
of any fork (the web console is now THE interactive surface). `tui.py` + `tests/test_tui.py` delete
cleanly, BUT `cli.py` is SHARED (`web/entrypoint.py` + `tier3.py` import `USER_AGENT`; `cli.py` holds
`ParsedArgs`/`_format_duration_ms`/`_format_usage`). OPEN SCOPE Q: remove textual-only (`tui.py`) vs
the whole terminal interface (relocate shared consts out of `cli.py` first). Breaking public-surface
removal → minor bump (operator approval). (The v0.20.17 collapsible debug panes, `c678853`, are now
just a web-console feature — the "pre-fork" framing is moot.)
**✅ #368 (user,character) memory silo test — DONE, PASSED live (WT b127).** The reason for the
pre-session clear; closed. Write+read isolation proven end-to-end (conjunctive `{end_user,
agent_self}`; cold-recall gave zero cross-character bleed); throwaway `ratatoskr:betty` deleted;
Sindra intact; stores scrubbed. **Still QUEUED:** the backfill live-verify (synthetic legacy corpus).
Full record → `persistent-memory.d/2026-07-18-368-silo-test-passed.md`. Sibling issues WT-owned:
**#369** (recited-content re-promotion loop), **#367** (copula-absence bridge phrasing).
**Deferred/queued (all NON-URGENT):** (1) P06-driver **referential-frame A/B/C probe**
(operator-ratified, waits on WT b122 → `persistent-memory.d/2026-07-17-referential-frame-probe.md`);
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
**Substrate / environment:** branch `main` at **v0.22.0** — slices 1-5 (`b1fbadd``4e20030`, v0.21.3.18)
PUSHED to origin; **slice-6 arc `de9a5ba``11ae2f0` (v0.21.19.20) AND slice-7 `ec68b1f` (v0.22.0) + this
snapshot are LOCAL — NOT yet pushed** (operator's call). origin
`git@gitea.phasefinal.com:vh/ratatoskr.git`. **Core dep:
`worldtree-sdk==1.0.0`** (gitea PyPI, `[tool.uv.sources]`; **`httpx-sse` REMOVED at slice-7** — SDK owns SSE). bifrost
**`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`);
**suite 494 green** (slice-6 added the admin adapter tests + heid fixup tests, ~offset by the retired
hand-rolled admin tests + the whole `test_sse_client.py`). Personal WT on **b128**
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined
**:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS —
restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
the REQUIRED `RATATOSKR_MEMORY_EMBEDDING_DIM=1024`. Keys env-only mode-600 in
`~/.config/ratatoskr/provider.env` + `RATATOSKR_ADMIN_API_KEY` (7 read scopes, personal-:8081-only;
Heimdall keys per-instance). **FOOT-GUN: check owner-scoped agents via `GET /agents/<owner>:<name>`
with the OWNER key (`WORLDTREE_API_KEY`), NOT the admin key** — admin gives a false 404. `graphify-out/`
runs dirty (auto-regen on the commit hook, never stage). v1 = full Worldtree I/O coverage, cuts when
WT tags 1.0 (`docs/coverage-map.md`).
**Other live threads:** soong-lab = Tier-3 agent-authoring studio (v0.3.24+; motivational
string→object export fixed upstream, our workaround droppable). Cross-session name-recall arc
COMPLETE + shipped (v0.20.11→.16; WT #364 closed both sides; Sindra durably recalls Vuong via
`mark_superseded` retirement). Sindra pristine baseline **WIPED 2026-07-18** (full #368 scrub,
born-empty store — old `sindra-baseline/*.db` predate it). Open/deferred: #10 subject-migration watch;
relational-dynamics verify (bind `--bifrost-url :8392`); WT #356 resume-durability = WT-owned.
**Live substrate / foot-guns:** branch `main`, HEAD `5c3d0ad` PUSHED; origin `git@gitea.phasefinal.com:vh/ratatoskr.git`; version **v0.22.3**. Deps: **`worldtree-sdk==1.2.0`**, **`bifrost==1.1.5`**/wire v0.7, WT openapi vendored 2.3.0, conversation-api-spec v1.1. **`uv sync --all-extras`** — dev deps are a `[project.optional-dependencies]` EXTRA; plain `uv sync` PRUNES pytest/respx. Personal WT on **b185** (`http://10.250.50.152:8081`) — **FOOT-GUN: it can deadlock into a live-socket/dead-worker hang (WT #401); symptom = TCP port open but ALL routes return 000 ("server disconnected without response"); fix = infra-ops `docker restart`, ratatoskr auto-recovers.** Surfaces: combined **`:8392`** provider (memory+affect) + **`:8765`** web (pid 2290701) — detached bare processes, no supervisor → re-stale on reboot (relaunch `source env.sh && setsid .venv/bin/ratatoskr-web --host 0.0.0.0 --port 8765`); `env.sh` sets REQUIRED `RATATOSKR_MEMORY_EMBEDDING_DIM=1024`; keys mode-600 in `~/.config/ratatoskr/provider.env`. **dots-tts `:8198` is now a fleet SWITCHBOARD** (fronts dots on `:8202`; +3.3ms; engine swaps = instant `.env` flips; v4 engine adds an optional `language` field). **FOOT-GUN: dots' `language` validator is an alias-mapper NOT a gatekeeper** — word-junk ('klingon') maps to a tag + generates; only structurally-invalid tokens (empty/punctuation/overlong) 400 → validate ourselves if we ever send `language`. **FOOT-GUN: browser auto-TTS is opt-in (`localStorage['ratatoskr-tts']='1'`), PER-ORIGIN** — localhost vs LAN-IP have separate storage, so the toggle doesn't carry between them. **FOOT-GUN: check owner-scoped agents via `GET /agents/<owner>:<name>` with the OWNER key, NOT admin (admin → false 404).** SDK-adapter foot-guns (cumulative): SDK reads = open-world dicts → presenters degrade-not-crash at 3 guard levels; all transport failures → `ConnectFailed(status=0)`; `local_agents` index v1→v2 SILENTLY DROPS pre-cutover agents. `graphify-out/` runs dirty (never stage). v1 = full Worldtree I/O coverage (`docs/coverage-map.md`).
## Recent decisions
Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per
decision. Captures rationale that won't be obvious from code alone.
- `[2026-08-12]` **Personal WT :8081 deadlocked after ~4 days → routed to worldtree-dev as WT #401; ratatoskr degrade path held.** Live-socket/dead-worker event-loop wedge (background `context_promotion` sqlite "unable to open database file"); recovered by infra-ops `docker restart`; ratatoskr auto-recovered (12/12 502 → 8/8 200, no ratatoskr restart). → `persistent-memory.d/2026-08-12-wt-401-personal-deadlock.md`
- `[2026-08-12]` **Config-driven voices + two-voice dialogue/narration split SHIPPED (v0.22.3, `5c3d0ad`).** `~/.config/ratatoskr/voices.json` (per-agent voice + optional `narration_voice`); new `src/ratatoskr/voices.py`; `tts_stream_stitched` replaces `tts_stream`; contract DEC-11 + INV-TTS-5/6/7. → `persistent-memory.d/2026-08-12-config-voices-two-voice.md`
- `[2026-08-12]` **dots-tts `:8198` became a fleet SWITCHBOARD (fronts dots `:8202`, +3.3ms); engine swaps are now instant `.env` flips.** v4 engine adds an optional `language` field; byte-compatible, no ratatoskr change. FOOT-GUN: dots' `language` validator is an alias-mapper not a gatekeeper (word-junk generates, doesn't 400) — validate ourselves if we ever send it.
- `[2026-08-11]` **Sindra voiced with the dots `miranda` voice (`3e69bc9`, no bump).** Ratatoskr-side per-agent voice map (resolved at `/api/tts` by `agent_id`); superseded next day by the config-driven map (DEC-11).
- `[2026-08-11]` **TTS migrated chatterbox-fast→dots-tts (v0.22.2, `38b78d8`), operator-directed after an A/B win.** OpenAI-shaped `/v1/audio/speech`, SR 24000→48000, default voice glados_25s→glados, serialized single-consumer; affect stays dropped; chatterbox `:8197` reclaimed. → `persistent-memory.d/2026-08-11-tts-dots-migration.md`
- `[2026-08-08]` **Crown-recall RESOLVED at b185 — identity-anchor rerank enrichment closed the class (gate: 79%→12% stress miss).** worldtree-dev shipped a rerank-input identity anchor (from character/terms metadata) + chain-wide tie-determinism (`50ecec7`) instead of the floor lever; `lexical_recall_gate --anchor crown --runs=8` at b185 = 12% miss / 0% knife-edge / item @rank 0 (vs 79% post-fold b184). Floor packet does NOT reopen; 12% residual = bare-query formulation ceiling. Arc complete our side; efficacy delta on WT #400.
- `[2026-08-07]` **Crown-recall traced to a general RETRIEVAL-CLASS limitation (exact-lexical match on an indexed surface form buried by dense-semantic neighbors), NOT persona/extraction/synonymy.** worldtree-dev decomposition (WT #400): no-stemming tokenizer + RRF top-10 knife-edge + reranker ownership-demotion. Operator-greenlit evidence-gated sequence (ship stemming fold → gate measures residual → hold `rerank_hybrid_floor` lever). Shipped `docs/diagnostics/lexical_recall_gate.py` (class acceptance instrument); pre-fold baseline control 0% / stress[crown] 100% miss. Awaiting worldtree-dev fold+#397 deploy ping for the pre/post delta. Thread `01KZETD98T`; siblings WT #399 (filed), #397 (committed, not deployed).
- `[2026-08-07]` **TTS migrated Zonos→chatterbox-fast (`:8197` bespoke schema); affect DROPPED (Turbo has no emotion knob, operator "drop it for chatterbox"), client-chunking DROPPED (no per-synth cap), language pin DROPPED, browser SR 44100→24000.** Pushed `19b499a`. English "German drift" real cause (after 2 wrong hypotheses) = Turbo model OVER-RUNS its generation TAIL on long single generations (garble in final ~2-3s, ZCR 1.58x); **fixed SERVER-SIDE by infra-ops (`max_chunk_chars=250`, image :v2, operator ear-confirmed).** Consumer: **REVERTED my interim `top_k/top_p/temp` curbs** (they made it WORSE — pulled garble onset earlier), send full text + default sampling; KEPT the `/api/tts` empty-200→503 guard as hygiene. Contract `donut_voiced_interview.contract.md` amended. Loop closed.
- `[2026-08-07]` **order_by=chapter tool flag → FILED as Worldtree #397 (DEFERRED to next session's contract pass).** Narrative/temporal-query gap ("first encounter in the dungeon"): `reference_knowledge` sorts by relevance not chronology; `provenance.chapter` is on every chunk but the consumer can't reorder native results (kb_bridge retired). Operator ruled the upstream sort flag the clean fix; worldtree-dev accepted, our fixture is the measurement instrument. Tracked at **Worldtree #397** (+ althing thread `01KZED2T3XHJ2WMS5NCYK42W6R`).
- `[2026-08-07]` **#393 (descriptive-query subject binding) CLOSED — persona-expand lever the win (4/10→9/10), tool directive the fleet floor.** `docs/diagnostics/descriptive_query_binding.py` is the canonical #393 fixture; two-mechanism split (cross-wing dilution vs fiction-scope selection). Commits `6c83a3b`/`4f4b5ad`/`2cc670e`.
- `[2026-08-07]` **Donut expand-don't-distill persona lever (`2cc670e`)** — keep full descriptive phrase + enrich toward entity vocab, never distill to bare keywords; measured 4/10→9/10 roid-rage binds persona-alone. Composes with near-miss LEAD (`37b67a5`).
- `[2026-08-07]` **TTS "unavailable" = stale web-server process** (predated POST /api/tts → 405); restarted + verified 200+WAV. Durability gap: bare nohup, no supervisor → systemd unit offered, deferred (operator's call).
- `[2026-08-03]` **fiction_wing_probe harness fix (`2111b1e`)** — fresh-session-per-query (WT #391 workaround) + NFKC/quote-fold on_target; surfaced+closed WT #391.
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin** → `persistent-memory.d/2026-06-19-18-d2-shipped-v0-17-14-39eebd1-and-the-full-1.md`
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent)**`persistent-memory.d/2026-06-19-bifrost-repinned-0-8-0-0-10-0-affect-fetch-be.md`
@@ -286,6 +212,22 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-07-19]` **worldtree-sdk cutover SLICE-7 (teardown, the LAST) COMPLETE — the whole cutover shipped as v0.22.0 (`ec68b1f`, minor, operator-approved).** Zero runtime-logic teardown: httpx-sse dropped (SDK owns SSE), wire contracts #2/#15 retired (files deleted; #1+first_message kept), `sessions.py`/`sse_client.py` KEPT as pure type/exception homes (operator decision A1 — no rename/fold), coverage-map re-anchored. All 7 slices done, 494 green; push + althing-announce pending (operator's call). → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-7-complete.md`
- `[2026-07-19→08-01]` **Donut voiced-interview BUILD — auto-TTS via Zonos + retire-ready KB-recall bridge + Princess Donut.** Slice-1 done (persona + define); slices 2-3 to build; both external deps verified live. THE active work. → `persistent-memory.d/2026-08-01-donut-voiced-interview-build.md`
- `[2026-08-01]` **worldtree-sdk 1.0.0→1.1.1→1.1.2 repin — and a reference-consumer catch.** 1.1.1's path-encoding fix MISSED the inline `turn_stream.py:185` on the SSE path (caller-shaped session_id, our highest-traffic route); wtsdk-dev fixed it in 1.1.2 same-day. (`d3a36f7`+`0058020`, pushed.)
- `[2026-08-01]` **Mimir #382 grounding bug — our finding shaped the fix.** Rata caught that the #380 vector re-index left Mimir DISCARDING correct P&P hits (no named source in the fiction wing → agent distrusts its own hits + answers from training; a silent-substitution "reasoning move, not retrieval"). Fix = our proposed residual (self-describing `_index.md`) + a Mimir prompt rule; deployed b166. **P&P verification re-run OWED to infra-ops**; DCC dir still opaque (backfill failed, flagged to worldtree-dev).
- `[2026-08-01]` **Canonical sync — added the client-spec-v2 pin (option A) + synced OpenAPI/spec forward** (`14bbc2b`, pushed). Whole-manifest sync also bumped the OpenAPI hard-gate additively (frozen v1 wire → no breakage). Coverage-map re-audit vs the new surface = queued follow-up.
- `[2026-07-30]` **Versioning cadence → RELEASE-ONLY (operator-ratified, brokkr-smithy relayed).** Retired per-commit bump+tag; tag only releases; between releases `feat:`/`fix:` SHAs. Existing ~112 tags left as-is. Global CLAUDE.md SemVer being amended to match.
- `[2026-08-01]` **bifrost 1.1.4→1.1.5 (v0.22.1, `3ef3a5e`, pushed)** — optional-store-verb 500→`unsupported_capability` hardening; provider restarted onto it.
- `[2026-08-01]` **Sindra saga — restored from local-index burial, wiped, smoke-tested.** The v0.22.0 web restart dropped her live session AND the schema-v2 discard buried her from the picker; migrated her v1→v2 from authoritative WT (real role `thoughtful-character`, not the stale `mistral-small-4`), then `scripts/reset-sindra-stores.sh` wiped memory+persona for a from-scratch smoke.
- `[2026-08-02]` **Donut TTS: English pin + long-form chunking + POST + dialogue-only persona + both heid gates — shipped `d59f907` (pushed).** Gibberish→`language:"en-us"`; truncation→client-side chunk+concatenate under the 71.2s/6144-tok cap; persona dialogue-only + always-call `reference_knowledge`; 4-arm gates fixed a lock deadlock + several untrusted-input 500s the GET→POST switch re-opened. → `persistent-memory.d/2026-08-02-donut-tts-chunking-english-gates.md`
- `[2026-08-02→03]` **`reference_knowledge` grounding VALIDATED end-to-end — 0/5→5/5 across a 3-round verify; the verify instrument drove diagnosis of a structural Tier-3 blindness (INV-361-3 metadata mismatch, #384) + density restore (#385).** Donut recalls the DCC corpus live; wt-dev's "cleanest consumer-side validation this pipeline has had." → `persistent-memory.d/2026-08-03-reference-knowledge-3-round-verify.md`
- `[2026-08-03]` **Artifact-coverage gap filed as WT #387 (DEFERRED, tracked #387; operator picks when it runs).** "Crown of the Sepsis Whore" (major DCC item, confirmed 2× in source text) absent from the 705 concepts — the fiction concept schema has NO item/artifact type, so named objects ride incidentally in plot_events and single-scene items survive on sampling luck. My ratatoskr coverage-probe offer (known-major-artifacts yardstick) recorded on #387; wt-dev pings me when its re-extraction ships. Consumer-side flag, wt-owned fix (schema evolution). Thread `01KZ349M…`.
- `[2026-08-03]` **worldtree-sdk repinned 1.1.2→1.2.0 (operator-directed, `ae49dcf`, pushed).** New `ResponseTooLarge` (a ProtocolError, non-resumable) mapped → `SseResponseTooLarge` at the stream surfaces (`wt.stream_turn`/`stream_admin_events` + the 2 stream endpoints); the 108MB read-body cap is unreachable on legal traffic so reads inherit the SDK refusal unwrapped. Absorbed WT spec 2.4.0/2.5.0 (zero-schema). +2 adapter tests.
- `[2026-08-03]` **Donut ANTI-FABRICATION persona shipped (`3e12c4d`+`c0a66fc`, pushed) + the retry-on-LOW experiment tested & rejected.** Her memory IS the tool's results: LOW/no-on-target → deflect in-character, MEDIUM+ → answer; self-corrects as the KB improves. The retry BACKFIRES (RRF confidence inflatable by any DCC-flavored query → false MEDIUM → fabrication); robust fix is tool-side = #389. → `persistent-memory.d/2026-08-03-donut-anti-fabrication-and-retry.md`
- `[2026-08-03]` **R42 (fiction-wing retrieval characterization) probe harness shipped to brokkr-smithy-dev (operator-directed, `04e0293`, pushed).** `docs/diagnostics/fiction_wing_probe.py` is R42's official harness + the re-runnable #389 acceptance gate; conventions (0.030/0.016 buckets, on-target = row names the subject, N-run bucket distribution) + the frozen artifact yardstick adopted verbatim. PENDING (peer-pinged): arm-1 on-target alignment at Phase-1; #389 gate re-run when RRF work lands. Threads wt-dev `01KZ349M…` / brokkr `01KZ421X…`.
- `[2026-08-02]` **worldtree-sdk 1.2.0 repin DEFERRED to a dep pass (my rec; operator to decide).** New `ResponseTooLarge` (a `ProtocolError`, NOT caught by our `except ConnectFailed`) + response-alloc caps that assume a 2.5.0 server (worldtree-dev runs it → safe in practice). When repinning, add `ResponseTooLarge` to the caught envelopes. Tracked: wtsdk-dev announce thread `01KZ1ZYM…`.
- `[2026-08-02]` **Filed issue #21** (sibling POST handlers `_create_session`/`_submit_turn` 500 on malformed JSON — the parse-JSON-or-400 asymmetry the heid gates flagged; pre-existing, out of the TTS diff's scope). Fix = a shared parse-JSON-or-400 helper.
_67 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
@@ -295,6 +237,7 @@ _For per-issue TDD implementation notes, Volva findings, and contract amendments
Log of approaches that were tried and rejected, with rationale. Future-self
defense against re-attempting the same cul-de-sac.
- `[2026-08-11]` **dots streaming `pcm` is NOT headerless — only NON-stream `pcm` is raw s16le.** `response_format:"pcm"` + `stream:true` still emits a RIFF placeholder-header WAV (dots always headers a stream). So the two-voice stitch (`tts_stream_stitched`) can't use "pcm for spans 2..N" to skip header-stripping — it streams every span as `wav` and strips the leading header on later spans (accumulate-until-`data`, INV-TTS-7). Don't chase the pcm shortcut for streaming.
- `[2026-07-10]` **Reverting our provider to bifrost 1.0.0 to fix the bound-handshake `schema_validation_failed` — DISPROVEN, and it would've been the WRONG state**`persistent-memory.d/2026-07-10-reverting-our-provider-to-bifrost-1-0-0-to-fi.md`
- `[2026-06-15]` **Canonical sync retired the issue-scoped parser staleness.** `contract_parser.py` synced to v2.1 (commit `d85ab43`): now validates issue-scoped frontmatter + four v2.1 test categories. The old "treat parser ERROR-on-issue-scoped as expected" note no longer applies.
- `[2026-06-15]` **Issue #17 filed.** REVERSES design-brief §6's "no Bifrost-binding consumer support" — that negative clause predates ratatoskr's provider identity (2026-06-14), so the canary now owns both ends but its client couldn't drive its own provider. (Shipped 2026-06-18.)
@@ -330,4 +273,12 @@ defense against re-attempting the same cul-de-sac.
- `[2026-07-16]` **`sortable_chunk_fields` advertised WITHOUT the required `type` field = whole-handshake deploy-breaker; only DRIVING the real bind caught it** → `persistent-memory.d/2026-07-16-sortablechunkfields-advertised-without-the-re.md`
- `[2026-08-01]` **`local_agents` index schema v1→v2 bump SILENTLY buries pre-cutover consumer agents from the picker** — `load_local_agents` returns `[]` on version mismatch (no migration path); the v0.22.0 restart made Sindra vanish even though she was alive on WT. Fix applied ad-hoc: migrate the v1 row → v2 from authoritative WT data (the v1 `model` field held a stale model name, NOT the current role). A one-time v1→v2 migration guard is the durable fix (unbuilt; operator-flagged).
- `[2026-08-01]` **Zonos: mp3/opus `response_format` silently return mislabeled PCM** (no encoder wired) — use `response_format:"wav"` only. And use the GATEWAY :8890, NOT the engine :1920 (rep-penalty bug pads ~70s silence + ignores voice).
- `[2026-08-02]` **Zonos hard-caps ONE synthesis at `max_tokens=6144` = 71.2s of audio** (6144 / 86.3Hz codec frame rate; `>6144` → HTTP 400, an architectural sequence limit). 86.3Hz is a **delivery-INDEPENDENT constant** — 6144 tokens is ALWAYS 71.2s regardless of emotion/rate (emotion changes words-per-71.2s, not seconds-per-token). Any turn longer than ~71s REQUIRES client-side chunk-and-concatenate (raw PCM, ONE WAV header — never stitch multiple WAV headers). Fixed in `d59f907` (DEC-10). Don't chase a "raise max_tokens" fix — the gateway rejects it.
- `[2026-08-02]` **A GET→POST endpoint switch re-opens untrusted-TYPE crashes that string-only query params silently masked.** Under GET, `p`/`a`/`agent_id` were always `str|None`; under a JSON POST body they can be a huge int (`float()`→OverflowError), an unhashable list/dict (`dict.get`→TypeError), or a lone surrogate (utf-8 encode→UnicodeEncodeError) — each a 500 the old code never saw. Guard EVERY body field when moving a query endpoint to a JSON body. Both heid gates converged on these (all 4 arms). Fixed in `d59f907`.
- `[2026-08-03]` **A bare `uv sync` PRUNES this project's dev deps** — pytest/respx/ruff live in `[project.optional-dependencies]` (an EXTRA, not a dependency-group), so `uv sync` (default groups only) removes them from `.venv`, and `uv run pytest` then silently falls back to a user-site pytest (py3.11, `~/lib`) that can't import the `.venv`'s `worldtree_sdk` → 16 collection ModuleNotFoundErrors. Use **`uv sync --all-extras`**. Bit me right after the 1.2.0 lock; the repin itself was never at risk.
- `[2026-08-03]` **`tier3 patch` does NOT refresh a live agent's running context** — it updates STORAGE (the define/patch response echoes the new prompt) but the running agent keeps serving the OLD system prompt. To change a live Tier-3 persona reliably, **delete + define (recreate)**, not patch. (Cost a confusing "patched but behavior unchanged" loop on the Donut anti-fabrication change.)
- `[2026-08-03]` **A persona-side confidence gate can't stop LLM fabrication — RRF confidence is inflatable by query phrasing.** A "re-search on LOW" retry reformulates the query with related real entities (or just DCC-flavored terms), which scores MEDIUM off THOSE matches, not the subject — manufacturing false grounding to fabricate on. The signal that survives is on-target (does a returned row NAME the subject), which is a TOOL-side check, not something a prompt can enforce against a model with strong genre priors. → #389.
_34 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 foot-gun cluster) archived to archival-memory.md._
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.22.1"
version = "0.22.3"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md"
requires-python = ">=3.12"
@@ -16,7 +16,7 @@ keywords = ["worldtree", "debug", "sse", "web", "observability"]
# owned by worldtree-sdk post-cutover (#20 slice-7 dropped httpx-sse).
dependencies = [
"httpx>=0.27",
"worldtree-sdk==1.0.0", # #20 cutover: the consumer client layer (gitea PyPI); the hand-rolled wrappers now live behind ratatoskr.wt
"worldtree-sdk==1.2.0", # #20 cutover: the consumer client layer (gitea PyPI); the hand-rolled wrappers now live behind ratatoskr.wt. 1.2.0 = 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; the caps assume a 2.5.0 server, which worldtree-dev runs, so safe on legal traffic). We catch it explicitly at the stream + read surfaces (INV-CUT-2). Absorbed WT spec 2.4.0/2.5.0 (zero-schema, no generated-type change). 1.1.2 = closed the SSE turn-stream traversal we caught in 1.1.1 (quote safe="" at turn_stream.py:185). 1.1.1 = path-encoding correctness fix + additive define_or_reuse / set_persona_state_raw (our parity #6) + 64 KiB error-body alloc cap. Same frozen wire as 1.0.0 (drop-in).
]
[project.optional-dependencies]
+13
View File
@@ -122,6 +122,19 @@ class SseConnectionDropped(Exception):
self.last_seen_sse_id = last_seen_sse_id
class SseResponseTooLarge(Exception):
"""Raised when the SDK refuses an oversized SSE frame (worldtree-sdk 1.2.0
`ResponseTooLarge`, scope="sse_frame"). Distinct from SseConnectionDropped: the SDK
deliberately does NOT class it resumable retrying yields the same oversized frame
so callers surface it as a terminal error, never a reconnect. `limit_bytes` is the
ceiling the frame exceeded."""
def __init__(self, *, limit_bytes: int, scope: str = "sse_frame") -> None:
super().__init__(f"SSE {scope} exceeded {limit_bytes} bytes")
self.limit_bytes = limit_bytes
self.scope = scope
class InvalidLastEventId(Exception):
"""Raised on HTTP 400 from a reconnect request — caller's last_event_id was rejected."""
+141
View File
@@ -0,0 +1,141 @@
"""dots-tts gateway TTS client.
Migrated off chatterbox-fast 2026-08-10 (operator-directed, after an A/B win
"very good"). dots-tts (rednote-hilab `dots.tts-soar`, irv-ml1 :8198) is an
OpenAI-shaped `/v1/audio/speech` gateway closer to the Zonos-era client than
chatterbox's bespoke `/tts`. It carries over one Zonos-era drop and reintroduces one primitive:
- NO affect/emotion knob (dots, like chatterbox Turbo, exposes no
valence/arousal/emotion dial), so voice stays flat (DEC-7 stays retired); and
- a SINGLE-VOICE turn streams from one dots call (no chunking), but the two-voice
dialogue/narration split (DEC-11) synthesizes per-span and STITCHES the PCM under one
leading WAV header (tts_stream_stitched). A single-span list is a verbatim passthrough,
so the single-voice case is unchanged.
This module is the SINGLE swap seam for voice synthesis: the `/api/tts` route in
web/server.py is its only caller.
Foot-guns (infra-ops-verified 2026-08-10 against dots-studio/dots.tts-soar :8198):
- Sample rate is 48000 Hz (chatterbox was 24000). The browser Web Audio decode
MUST use 48000 or the voice plays ~2x too fast.
- response_format:"wav", stream:true emits a streaming placeholder-header WAV
(RIFF/data sizes as placeholders, one header, then 48kHz mono s16le PCM) the
same shape the browser's one-header-strip decoder already expects. "pcm" (raw
s16le, headerless) is leaner but would need the browser to drop the header
strip; kept on wav so the streaming shape stays byte-identical to the prior path.
- Body field names are OpenAI-shaped: `input` (NOT chatterbox's `text`),
`response_format` (NOT chatterbox's `format`). No `model` field, no `language`
field, no affect dials.
- SERIALIZED single-consumer: dots renders one generation at a time (no concurrent
requests). Harmless here the endpoint already serializes on app.state.tts_lock
(DEC-5, one synth per turn on the shared GPU). If concurrent streams are ever
needed, infra-ops escalates the backend behind this same API (client unchanged).
"""
from __future__ import annotations
import sys
from collections.abc import AsyncIterator
import httpx
# The dots-tts gateway (irv-ml1 :8198). OpenAI-shaped `/v1/audio/speech`.
# Overridable per deployment via app.state.tts_url (RATATOSKR_TTS_URL) — the swap seam + tests.
DOTS_TTS_URL = "http://10.100.79.3:8198/v1/audio/speech"
class TtsUnavailable(Exception):
"""The gateway failed, was unreachable, or returned a non-wav body.
The caller degrades (INV-TTS-4): logs + skips audio; the turn/transcript is
never blocked or failed on a synthesis error.
"""
def __init__(self, message: str = "", *, status: int = 0) -> None:
super().__init__(message)
self.status = status
self.message = message
def gateway_body(text: str, voice: str) -> dict:
"""The dots-tts POST body (OpenAI-shaped). `response_format:"wav"` (DEC-3) so the
streaming shape is a placeholder-header WAV the browser decoder strips; `stream:true`
for play-as-it-arrives (DEC-2). Field names are OpenAI: `input` (not `text`),
`response_format` (not `format`). Send the FULL text dots streams a whole turn from
one call. No `language`, no affect dials (DEC-7)."""
return {"input": text, "voice": voice, "response_format": "wav", "stream": True}
def _log_degrade(event: str, detail: str) -> None:
"""Write the INV-TTS-4 `tts_degrade` stderr line for a committed-200 mid-stream failure."""
sys.stderr.write(f'{{"kind":"tts_degrade","event":"{event}","detail":"{detail}"}}\n')
def _strip_to_pcm(acc: bytes) -> bytes | None:
"""Given accumulated header bytes of a span past the first, return the PCM after the WAV
`data` chunk id+size, or None if `data` hasn't arrived yet (keep accumulating). Robust to
the header spanning chunks (INV-TTS-7)."""
di = acc.find(b"data")
if di < 0 or di + 8 > len(acc):
return None
return acc[di + 8:]
async def tts_stream_stitched(
spans: list[tuple[str, str]],
*,
client: httpx.AsyncClient,
url: str = DOTS_TTS_URL,
) -> AsyncIterator[bytes]:
"""Synthesize an ordered list of (voice, text) spans SERIALLY into ONE continuous stream.
dots is single-consumer, so spans render in order. The FIRST emitting span is passed
through VERBATIM (its WAV header + PCM); every later span is streamed as wav too but with
its leading WAV header STRIPPED (accumulate until `data`, emit after data+8), so the
browser decodes one gapless 48kHz mono s16le stream after a single leading header
(INV-TTS-7). A single-span list is therefore byte-identical to a plain gateway proxy
(INV-TTS-6): the whole-turn single-voice / dialogue-only case is a no-regression passthrough.
The `yielded_any` degrade pivot spans the WHOLE sequence (INV-TTS-4):
- a non-200 OPEN or a transport failure BEFORE any byte has been committed TtsUnavailable
(the endpoint peek turns it into a 503; nothing committed yet).
- any failure AFTER >= 1 byte has streamed (the 200 is committed) DEGRADE: log a
`tts_degrade` line, drop the tail, keep what played. NEVER raise into the committed 200.
"""
assert spans, "tts_stream_stitched: spans must be non-empty (the endpoint guards this)"
yielded_any = False
header_emitted = False # has the single leading WAV header been passed through yet?
for voice, text in spans:
if not text.strip():
continue
strip_header = header_emitted
acc = b""
found_pcm = not strip_header # first emitting span passes through immediately
try:
async with client.stream("POST", url, json=gateway_body(text, voice)) as resp:
if resp.status_code != 200:
if yielded_any:
_log_degrade("span_status", str(resp.status_code))
return
raise TtsUnavailable(
f"gateway status {resp.status_code}", status=resp.status_code
)
async for chunk in resp.aiter_bytes():
if not found_pcm:
acc += chunk
pcm = _strip_to_pcm(acc)
if pcm is None:
if len(acc) > 65536: # no data header in a sane window → malformed
if yielded_any:
_log_degrade("no_data_header", "")
return
raise TtsUnavailable("stitched span: no WAV data header")
continue
chunk, found_pcm, acc = pcm, True, b""
if chunk:
yielded_any = True
header_emitted = True
yield chunk
except httpx.RequestError as exc:
if yielded_any:
_log_degrade("stream_dropped", type(exc).__name__)
return
raise TtsUnavailable(f"gateway transport failure: {exc}") from exc
+125
View File
@@ -0,0 +1,125 @@
"""Rata-side voice configuration + dialogue/narration segmentation (DEC-11).
Voice assignment is config-driven, not hardcoded: `~/.config/ratatoskr/voices.json`
maps each agent to a dots voice, with an OPTIONAL `narration_voice` whose presence turns
on the two-voice split (dialogue in `voice`, narration in `narration_voice`).
schema: {"default": "<voice>",
"agents": {"<agent_id>": {"voice": "<v>", "narration_voice": "<v>"?}}}
The split rule (segment_dialogue): QUOTED text (straight " or curly “ ”) = dialogue; text
outside quotes = narration. An agent with NO narration_voice always voices the whole turn in
`voice` (single span) which is why dialogue-only Donut needs no special-casing.
Config load is degrade-not-crash (INV-TTS-5): an absent file is normal ( built-in default);
a malformed one logs + falls back. Pure + self-contained (stdlib only).
"""
from __future__ import annotations
import copy
import json
import os
import sys
# Built-in fallback (INV-TTS-5) and the initial shipped config. donut is dialogue-only
# (no narration_voice → single voice); sindra splits miranda (spoken) / emmie (narration).
DEFAULT_VOICE_CONFIG: dict = {
"default": "glados",
"agents": {
"ratatoskr:donut": {"voice": "donut"},
"ratatoskr:sindra": {"voice": "miranda", "narration_voice": "emmie"},
},
}
_DEFAULT_PATH = "~/.config/ratatoskr/voices.json"
# Double-quote boundary chars. Straight " TOGGLES; curly “ opens, ” closes (directional).
# Single quotes / apostrophes (' ) are NOT boundaries — they stay inside spans so # noqa: RUF003
# possessives/contractions ("Donut's") never split a word.
_QUOTE_OPEN = "" # “
_QUOTE_CLOSE = "" # ”
_QUOTE_STRAIGHT = '"'
def load_voice_config(path: str | None = None) -> dict:
"""Load voices.json → config dict. Absent file → built-in default (normal, not an error);
malformed/unreadable built-in default + a logged `voice_config_error` (INV-TTS-5)."""
p = os.path.expanduser(path or _DEFAULT_PATH)
try:
with open(p, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return copy.deepcopy(DEFAULT_VOICE_CONFIG)
except (OSError, json.JSONDecodeError) as exc:
sys.stderr.write(
f'{{"kind":"voice_config_error","path":{json.dumps(p)},"exc":"{type(exc).__name__}"}}\n'
)
return copy.deepcopy(DEFAULT_VOICE_CONFIG)
if not isinstance(data, dict) or not isinstance(data.get("agents"), dict):
sys.stderr.write(
f'{{"kind":"voice_config_error","path":{json.dumps(p)},'
f'"exc":"missing agents mapping"}}\n'
)
return copy.deepcopy(DEFAULT_VOICE_CONFIG)
data.setdefault("default", DEFAULT_VOICE_CONFIG["default"])
return data
def segment_dialogue(text: str) -> list[tuple[str, str]]:
"""Split text into ordered (kind, span) pairs, kind ∈ {"dialogue","narration"}.
Quoted runs are dialogue, the rest narration. Straight " toggles quote-state; curly “/”
are directional. Quote chars are delimiters (dropped from spans). Empty/whitespace spans
are dropped. Unbalanced (a trailing open quote) its run-to-end is dialogue (best-effort,
never raises). Word order is preserved.
"""
spans: list[tuple[str, str]] = []
buf: list[str] = []
in_quote = False
def flush() -> None:
s = "".join(buf)
if s.strip():
spans.append(("dialogue" if in_quote else "narration", s))
buf.clear()
for ch in text:
if ch == _QUOTE_STRAIGHT:
flush()
in_quote = not in_quote
elif ch == _QUOTE_OPEN:
flush()
in_quote = True
elif ch == _QUOTE_CLOSE:
flush()
in_quote = False
else:
buf.append(ch)
flush()
return spans
def resolve_voice_spans(config: dict, agent_id: object, text: str) -> list[tuple[str, str]]:
"""Resolve a turn to an ordered list of (voice, text) synthesis spans.
Unmapped agent (or non-str agent_id) one span in `default`. Mapped agent with no
`narration_voice` one span in `voice`. Mapped agent WITH `narration_voice` segment
into dialogue/narration spans (dialoguevoice, narrationnarration_voice). Always returns
at least one span for non-empty text (segmentation that empties falls back to a single span).
"""
default = config.get("default", DEFAULT_VOICE_CONFIG["default"])
agents = config.get("agents", {})
entry = agents.get(agent_id) if isinstance(agent_id, str) and isinstance(agents, dict) else None
if not isinstance(entry, dict):
return [(default, text)]
voice = entry.get("voice") or default
if not isinstance(voice, str):
voice = default
narr = entry.get("narration_voice")
if not narr or not isinstance(narr, str):
return [(voice, text)]
spans = segment_dialogue(text)
if not spans:
return [(voice, text)]
return [(voice if kind == "dialogue" else narr, span) for kind, span in spans]
+14 -1
View File
@@ -80,6 +80,11 @@ def main(argv: list[str] | None = None) -> int:
# never receives the key, only the session-filtered result.
admin_key = os.environ.get("RATATOSKR_ADMIN_API_KEY")
# Auto-TTS (slice 2): the dots-tts gateway URL. Defaults to the direct
# gateway (DEC-1) inside the server; override here only to point at a different
# synth host (the swap seam). None → the server's DOTS_TTS_URL default.
tts_url = os.environ.get("RATATOSKR_TTS_URL")
# INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
# extras (starlette / uvicorn) so a real import bug inside a
@@ -100,10 +105,16 @@ def main(argv: list[str] | None = None) -> int:
# Baseline deps + own modules — a failure here is a real bug, not a
# missing-extras condition; let it propagate.
import httpx
from ratatoskr.cli import USER_AGENT
from ratatoskr.voices import load_voice_config
from ratatoskr.web.server import create_app
def client_factory() -> "httpx.AsyncClient":
# Config-driven voices (DEC-11): ~/.config/ratatoskr/voices.json (per-agent voice +
# optional narration_voice). Absent/malformed → the built-in default (INV-TTS-5).
voice_config = load_voice_config()
def client_factory() -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=server_url,
headers={
@@ -121,6 +132,8 @@ def main(argv: list[str] | None = None) -> int:
affect_read_url=affect_read_url,
memory_read_url=memory_read_url,
admin_key=admin_key,
tts_url=tts_url,
voice_config=voice_config,
)
# Boot banner to stderr (so stdout stays clean for piping).
+153 -5
View File
@@ -9,6 +9,7 @@ passes a factory that bakes in WORLDTREE_API_URL + WORLDTREE_API_KEY.
from __future__ import annotations
import asyncio
import copy
import itertools
import json
from collections.abc import AsyncIterator, Callable, Mapping
@@ -62,8 +63,15 @@ from ratatoskr.sse_client import (
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
SseResponseTooLarge,
TurnIdFlip,
)
from ratatoskr.tts import (
DOTS_TTS_URL,
TtsUnavailable,
tts_stream_stitched,
)
from ratatoskr.voices import DEFAULT_VOICE_CONFIG, resolve_voice_spans
def _wt_client(
@@ -105,11 +113,16 @@ def _static_dir() -> str:
def _root_endpoint(request: Request) -> FileResponse:
"""GET / → index.html. Per FN root_endpoint POST-001."""
"""GET / → index.html. Per FN root_endpoint POST-001.
no-store: this is an actively-iterated dev console never let a browser serve a
stale cached page (which silently hid the streaming-TTS / autoplay-unlock updates
during debugging). The HTML is small; a re-fetch per load is free on the LAN."""
from pathlib import Path
return FileResponse(
Path(_static_dir()) / "index.html",
media_type="text/html",
headers={"Cache-Control": "no-store"},
)
@@ -281,7 +294,9 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse:
"""
body = await request.json()
content = body.get("content") if isinstance(body, dict) else None
if not content:
if not content or not isinstance(content, str):
# Require a non-blank STRING; a truthy non-str (e.g. {"content": {...}}) gets a
# deterministic 400 rather than reaching the SDK turn call as a bad type.
return JSONResponse({"error_code": "missing_content"}, status_code=400)
session_id = request.path_params["session_id"]
turn_id = next(_TURN_COUNTER)
@@ -372,7 +387,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
handle.status = event.type or "done"
break
except (wt.SessionApiFailed, SseConnectFailed, SseConnectionDropped,
MalformedSseId, MalformedSseData, TurnIdFlip) as exc:
SseResponseTooLarge, MalformedSseId, MalformedSseData, TurnIdFlip) as exc:
# wt.SessionApiFailed covers the adapter's SessionRetired (410) mapping;
# without it a retired-session stream would escape gen() after partial
# frames as an uncaught 500, not a labeled `event: error`.
@@ -529,6 +544,124 @@ async def _memory_chunks_endpoint(request: Request) -> JSONResponse:
return JSONResponse(r.json(), status_code=r.status_code)
# Voice assignment is config-driven (DEC-11): app.state.voice_config (from voices.json) maps
# each agent to a dots voice + optional narration_voice; resolve_voice_spans turns a turn into
# ordered (voice, text) synthesis spans. The old hardcoded _TTS_VOICE_MAP is retired.
# The text rides the POST body (DEC-10a), so URL length is not the bound — this is a safety
# ceiling on the shared-GPU hold. dots streams a whole turn from one call (no client concat),
# so a single call voices the whole turn; ~8000 chars still covers any real interview turn
# while bounding a runaway. A response past this is truncated at a word boundary (the full
# text still shows in the transcript).
_TTS_MAX_TEXT_CHARS = 8000
def _truncate_at_boundary(text: str, limit: int) -> str:
"""Trim to <= limit chars, preferring the last space so we don't cut mid-word."""
if len(text) <= limit:
return text
head = text[:limit]
cut = head.rfind(" ")
return head[:cut] if cut > limit // 2 else head
async def _tts_endpoint(request: Request) -> Response:
"""POST /api/tts {text, agent_id?} → audio/wav, STREAMED chunked from the dots-tts
gateway (FN tts_endpoint). POST (not GET) so an arbitrarily long turn rides the body, not a
length-capped URL. resolve_voice_spans (DEC-11) turns the turn into ordered (voice, text)
spans one for a single-voice agent, or dialogue/narration spans when the agent has a
narration_voice and tts_stream_stitched synthesizes them into one gapless 48kHz stream
(one leading WAV header + s16le PCM).
Server-side proxy (DEC-4 / INV-TTS-1: the gateway host never reaches the browser).
Voice per-character (DEC-8). No affect modulation DEC-7 retired with the Zonos migration.
Serialized one-stream-at-a-time (DEC-5); a new turn aborts the prior fetch the POST drops
the generator's finally releases the lock. A gateway open-failure → 503 (INV-TTS-4: the
client skips playback)."""
try:
body = await request.json()
except (json.JSONDecodeError, ValueError):
return JSONResponse({"error_code": "missing_text"}, status_code=400)
text = body.get("text") if isinstance(body, dict) else None
if not isinstance(text, str):
return JSONResponse({"error_code": "missing_text"}, status_code=400)
# Drop lone surrogates: they ride through JSON (\udXXX from a mid-surrogate-pair slice)
# but raise UnicodeEncodeError when httpx serializes the gateway POST body (surrogates are
# not utf-8-encodable) — an untrusted-input 500. Scrub so the text is always encodable
# (INV-TTS-4 degrade). Also drops a whitespace-only body to a deterministic 400 below.
text = text.encode("utf-8", "ignore").decode("utf-8")
if not text.strip():
return JSONResponse({"error_code": "missing_text"}, status_code=400)
text = _truncate_at_boundary(text, _TTS_MAX_TEXT_CHARS)
# agent_id is an UNTRUSTED open-world body field; resolve_voice_spans guards a non-str
# (→ default voice, DEC-11) rather than 500 (INV-TTS-4). It returns >=1 (voice, text) span:
# one for a single-voice/dialogue-only agent, or dialogue/narration spans when the agent
# has a narration_voice. (The Zonos-era p/a PAD fields are gone — DEC-7 retired.)
agent_id = body.get("agent_id")
spans = resolve_voice_spans(request.app.state.voice_config, agent_id, text)
tts_url = request.app.state.tts_url
lock = request.app.state.tts_lock
# DEC-5: one stream at a time on the shared GPU. Held for the stream's duration and
# released in the generator's finally — including the browser-abort path. read=120s per
# infra-ops: a long synth can render slowly under load.
await lock.acquire()
client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=10.0)
)
gen = tts_stream_stitched(spans, client=client, url=tts_url)
async def _release() -> None:
await gen.aclose() # unwinds the stitched gen's `async with` → closes the gateway resp
await client.aclose()
lock.release()
try:
# Peek the first chunk so a bad gateway OPEN (non-200 / transport) surfaces as a
# 503 BEFORE we commit a 200 StreamingResponse. TTFB ~0.44s, so this is cheap.
first = await gen.__anext__()
except TtsUnavailable:
await _release()
return JSONResponse({"error_code": "tts_unavailable"}, status_code=503)
except StopAsyncIteration:
first = None
except BaseException:
# Any OTHER escape during the peek — a browser abort (CancelledError) before the
# first byte, or an unmapped transport error (e.g. httpx.InvalidURL from a
# misconfigured tts_url, which is NOT an httpx.RequestError) — must still release
# the process-wide serialize lock + client, or every later /api/tts deadlocks at
# lock.acquire() (DEC-5). The finally below only exists once piped() runs. Propagate.
await _release()
raise
# An empty 200 body (no bytes at all) is a synthesis failure, not audio: a gateway that
# returns 200 with a 0-byte body (a stalled/failed generation) must not commit a silent,
# empty audio/wav StreamingResponse the browser would play as nothing. Surface it as a 503
# (INV-TTS-4 visible skip). Kept as hygiene regardless of backend (infra-ops-endorsed).
if first is None:
await _release()
return JSONResponse({"error_code": "tts_unavailable"}, status_code=503)
# Chunk 1 must be a WAV (RIFF header). A 200 non-WAV body — a misbehaving gateway or a
# proxy error page — would be mislabeled audio/wav and the browser would decode markup as
# PCM (or match a `data` byte-run in it). Reject → 503 (INV-TTS-4). Tolerant of a <4-byte
# first read so a valid WAV split oddly is never false-rejected.
if first is not None and len(first) >= 4 and not first.startswith(b"RIFF"):
await _release()
return JSONResponse({"error_code": "tts_unavailable"}, status_code=503)
async def piped() -> AsyncIterator[bytes]:
try:
if first:
yield first
async for chunk in gen:
yield chunk
finally:
await _release()
return StreamingResponse(piped(), media_type="audio/wav")
async def _session_tools_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/tools → owner-scoped tool inventory (spec #183).
@@ -648,8 +781,8 @@ async def _admin_events_endpoint(request: Request) -> Response:
{"id": ev.id, "type": ev.type, "timestamp": ev.timestamp,
"data": ev.data},
)
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
MalformedSseData) as exc:
except (SseConnectFailed, SseConnectionDropped, SseResponseTooLarge,
MalformedSseId, MalformedSseData) as exc:
yield _format_sse(
"stream_error",
{"exception": type(exc).__name__, "message": str(exc)},
@@ -673,6 +806,8 @@ def create_app(
affect_read_url: str | None = None,
memory_read_url: str | None = None,
admin_key: str | None = None,
tts_url: str | None = None,
voice_config: dict | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
@@ -739,6 +874,7 @@ def create_app(
Route("/api/sessions/{session_id}/messages", _session_messages_endpoint),
Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint),
Route("/api/admin/events", _admin_events_endpoint),
Route("/api/tts", _tts_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
@@ -762,6 +898,18 @@ def create_app(
# SERVER-HELD (RATATOSKR_ADMIN_API_KEY) and never reaches the browser — the
# server proxies admin-scoped reads and forwards only the session-filtered result.
app.state.admin_key = admin_key
# Auto-TTS (slice 2): the dots-tts gateway URL is SERVER-HELD config — the host
# never reaches the browser (DEC-4 / INV-TTS-1). Defaults to the direct gateway
# (DEC-1); overridable via RATATOSKR_TTS_URL (the swap seam). The lock serializes
# one synth at a time — required by dots (single-consumer) and so concurrent turns
# don't contend the shared GPU (DEC-5).
app.state.tts_url = tts_url or DOTS_TTS_URL
app.state.tts_lock = asyncio.Lock()
# Config-driven voices (DEC-11): the per-agent voice + optional narration_voice map.
# entrypoint loads voices.json; tests may inject; None → the built-in default (INV-TTS-5).
app.state.voice_config = voice_config if voice_config is not None else copy.deepcopy(
DEFAULT_VOICE_CONFIG
)
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {}
return app
+137
View File
@@ -226,6 +226,12 @@ kbd {
#theme-toggle .i-moon { display: none; }
body[data-theme="light"] #theme-toggle .i-sun { display: none; }
body[data-theme="light"] #theme-toggle .i-moon { display: inline; }
#tts-toggle { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--fg-muted); cursor: pointer; flex: 0 0 auto; }
#tts-toggle:hover { background: var(--bg-2); color: var(--fg-0); border-color: var(--border-default); }
#tts-toggle.on { color: var(--aus-bright-cyan); border-color: var(--border-default); }
#tts-toggle .i-wave { display: none; }
#tts-toggle.on .i-wave { display: inline; }
#tts-toggle.on .i-mute { display: none; }
#conn {
flex: 0 0 auto; margin: 0 14px 14px; padding: 9px 12px;
@@ -573,6 +579,10 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
<div class="brand-name">ratatoskr</div>
<div class="brand-tag">wire monitor</div>
</div>
<button id="tts-toggle" title="Voice off — click to toggle auto-TTS">
<svg class="i-wave" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z"></path><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a9 9 0 0 1 0 14"></path></svg>
<svg class="i-mute" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z"></path><path d="M22 9l-6 6M16 9l6 6"></path></svg>
</button>
<button id="theme-toggle" title="Toggle light / dark">
<svg class="i-sun" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path></svg>
<svg class="i-moon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path></svg>
@@ -1698,6 +1708,8 @@ async function submitPrompt() {
const input = $("prompt-input");
const content = input.value.trim();
if (!content || !state.sessionId || state.turnId) return;
cancelTts(); // INV-TTS-3: a new turn cancels prior voice
if (ttsEnabled()) _unlockTtsAudio(); // this keypress is a gesture — grant autoplay
input.value = ""; input.style.height = "20px";
let r;
@@ -1811,6 +1823,11 @@ async function submitPrompt() {
tickerAdd(cls === "done" ? "ok" : cls === "error" ? "err" : "turn",
cls === "done" ? "turn.completed" : cls === "error" ? "turn.failed" : "turn.cancelled",
cls === "done" ? elapsed + "s" : meta);
// Auto-TTS (slice 2, DEC-7): speak the completed response, emotion-modulated by the
// live PAD the persona pane shows. Only on `done`; opt-in; failures are non-blocking.
if (cls === "done" && ttsEnabled()) {
speakOnDone(LIVE.resp, state.agentId);
}
es.close();
state.eventSource = null; state.turnId = null; state.curTurnEl = null;
$("composer").classList.remove("streaming");
@@ -1977,6 +1994,125 @@ async function cancelTurn() {
});
})();
// ---- auto-TTS: voiced STREAMING playback via Web Audio (dots-tts, 48kHz) ------
// Fetch the chunked POST /api/tts stream, decode its int16 PCM, and schedule the samples
// GAPLESSLY into an AudioContext as they arrive (TTFA ~0.5s). Web Audio, NOT <audio src>,
// because Safari/WebKit REFUSES a streaming 0xFFFFFFFF-length WAV via <audio src>
// (NotSupportedError) — decoding the raw PCM ourselves sidesteps every WAV-container quirk
// and works in all engines. Opt-in (INV-TTS-2), one stream at a time (INV-TTS-3: a new
// turn aborts the fetch + stops scheduled nodes), non-blocking (INV-TTS-4).
function ttsEnabled() {
try { return localStorage.getItem("ratatoskr-tts") === "1"; } catch (_) { return false; }
}
let _ttsCtx = null, _ttsAbort = null, _ttsNodes = [];
function _ttsAudioCtx() {
if (!_ttsCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) _ttsCtx = new AC();
}
return _ttsCtx;
}
// Unlock: resume the AudioContext inside a user gesture (Safari + Chrome both require the
// resume to originate from an interaction; once running it stays running). Fired on the
// FIRST interaction anywhere, so it's ready before the delayed speak-on-done.
function _unlockTtsAudio() {
const ctx = _ttsAudioCtx();
if (ctx && ctx.state === "suspended") ctx.resume().catch(() => {});
}
document.addEventListener("pointerdown", _unlockTtsAudio, true);
document.addEventListener("keydown", _unlockTtsAudio, true);
function cancelTts() {
if (_ttsAbort) { try { _ttsAbort.abort(); } catch (_) {} _ttsAbort = null; }
for (const n of _ttsNodes) { try { n.stop(); } catch (_) {} try { n.disconnect(); } catch (_) {} }
_ttsNodes = [];
}
function _findDataChunk(u8) { // offset of the "data" chunk id in a WAV header, or -1
for (let i = 0; i + 4 <= u8.length; i++)
if (u8[i] === 0x64 && u8[i + 1] === 0x61 && u8[i + 2] === 0x74 && u8[i + 3] === 0x61) return i;
return -1;
}
function _u8concat(a, b) { // always returns a FRESH array (byteOffset 0) so Int16Array aligns
const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out;
}
async function speakOnDone(text, agentId) {
const clip = (text || "").trim();
if (!clip) return;
cancelTts(); // INV-TTS-3: stop any prior stream
const ctx = _ttsAudioCtx();
if (!ctx) { tickerAdd("err", "tts", "no audio ctx"); return; }
if (ctx.state === "suspended") { try { await ctx.resume(); } catch (_) {} }
const payload = { text: clip.slice(0, 8000) }; // matches the server cap; chunked server-side by the gateway
if (agentId) payload.agent_id = agentId; // no affect fields — DEC-7 retired (dots has no emotion knob)
const ctrl = new AbortController(); _ttsAbort = ctrl;
let resp;
// POST (not GET) so a long turn rides the body, not a length-capped URL (DEC-10).
try {
resp = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: ctrl.signal,
});
} catch (_) { return; } // aborted / network → silent skip (INV-TTS-4)
if (!resp.ok || !resp.body) { tickerAdd("err", "tts", "unavailable " + resp.status); return; }
const reader = resp.body.getReader();
const SR = 48000; // dots-tts sample rate (was 24000 for chatterbox — MUST match or the voice plays ~2x too fast)
let playAt = ctx.currentTime + 0.06, started = false, headerDone = false;
let acc = new Uint8Array(0), carry = new Uint8Array(0);
try {
while (true) {
const { done, value } = await reader.read();
if (done || ctrl !== _ttsAbort) break; // finished, or superseded by a new turn
let bytes = value;
if (!headerDone) { // skip the WAV header (up to + incl the data id/size)
acc = _u8concat(acc, bytes);
const di = _findDataChunk(acc);
if (di < 0 || di + 8 > acc.length) {
// Bounded: a real header lands in the first read; if 64 KiB arrive with no `data`
// marker the stream is headerless/malformed — bail instead of buffering forever.
if (acc.length > 65536) { tickerAdd("err", "tts", "no WAV header"); break; }
continue; // header spans chunks — keep accumulating
}
bytes = acc.subarray(di + 8); headerDone = true; acc = null;
}
const u8 = _u8concat(carry, bytes); // prepend the odd-byte carry; fresh + aligned
const even = u8.length - (u8.length & 1);
carry = u8.slice(even); // stash a trailing odd byte for next chunk
if (even < 2) continue;
const pcm = new Int16Array(u8.buffer, 0, even / 2); // int16 LE (all real browsers are LE)
const f32 = new Float32Array(pcm.length);
for (let i = 0; i < pcm.length; i++) f32[i] = pcm[i] / 32768;
const ab = ctx.createBuffer(1, f32.length, SR);
ab.getChannelData(0).set(f32);
const node = ctx.createBufferSource();
node.buffer = ab; node.connect(ctx.destination);
if (playAt < ctx.currentTime) playAt = ctx.currentTime; // underrun guard
node.start(playAt); playAt += ab.duration;
_ttsNodes.push(node);
node.onended = () => { const i = _ttsNodes.indexOf(node); if (i >= 0) _ttsNodes.splice(i, 1); };
if (!started) { started = true; tickerAdd("ok", "tts", "▶ voiced"); }
}
} catch (_) { /* aborted / stream error → whatever's already scheduled finishes */ }
}
// ---- 🔊 toggle (mirrors theme / cot-toggle; default OFF, persisted) ----
(function () {
let on = false;
try { on = localStorage.getItem("ratatoskr-tts") === "1"; } catch (_) {}
const btn = $("tts-toggle");
const label = (v) => "Voice " + (v ? "on" : "off") + " — click to toggle auto-TTS";
btn.classList.toggle("on", on);
btn.title = label(on);
btn.addEventListener("click", () => {
const next = !btn.classList.contains("on");
btn.classList.toggle("on", next);
btn.title = label(next);
try { localStorage.setItem("ratatoskr-tts", next ? "1" : "0"); } catch (_) {}
if (next) _unlockTtsAudio(); // grant autoplay within this gesture
else cancelTts(); // turning off stops in-flight playback
});
})();
// ---- collapsible debug panes (independent left / right; state persists) ----
(function () {
const cols = $("cols");
@@ -2054,5 +2190,6 @@ loadAgents();
loadVersion();
loadPersonaCanon();
</script>
<audio id="tts-audio" hidden preload="none"></audio>
</body>
</html>
+24 -2
View File
@@ -69,6 +69,7 @@ from .sse_client import (
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
SseResponseTooLarge,
TurnIdFlip,
TurnLaunchUnavailable,
)
@@ -278,13 +279,22 @@ async def get_session_tools(
async def stream_turn(
client: WorldtreeClient, session_id: str, content: str
client: WorldtreeClient,
session_id: str,
content: str,
*,
memory_context: list[dict] | None = None,
) -> AsyncGenerator[wtsdk.TurnEvent, None]:
"""Drive the resilient turn stream (auto-resume; absorbs the old `reconnect_turn`)
and yield the SDK's `TurnEvent`s, re-wrapping the stream's TERMINAL SDK errors
into ratatoskr's caller-semantic exceptions (INV-CUT-2 / DEC-2 — the presenter
keeps catching ratatoskr's types).
`memory_context` (optional) is forwarded verbatim into the turn POST body (the SDK
passes it through). Ratatoskr uses it for the retire-ready KB-recall bridge (the
Donut voiced-interview contract); defaulting to None keeps every other caller and
the bridge's own retirement inert.
The SDK's `stream_turn` retries only the transport-drop class internally; a
resume failure / protocol violation / connect failure surfaces unchanged
(B-RES-6), and a drop that exhausts the reconnect budget surfaces as
@@ -293,7 +303,9 @@ async def stream_turn(
SDK's `ConnectFailed`, so they are caught before the generic `ConnectFailed`.
"""
try:
async for event in client.sessions.stream_turn(session_id, content):
async for event in client.sessions.stream_turn(
session_id, content, memory_context=memory_context
):
yield event
except wtsdk.SessionRetired as exc:
# Fresh-mode 410 → the session is gone server-side; a generic API failure.
@@ -325,6 +337,12 @@ async def stream_turn(
raise MalformedSseData(raw=exc.raw) from exc
except wtsdk.TurnIdFlip as exc:
raise TurnIdFlip(established=exc.established, got=exc.got) from exc
except wtsdk.ResponseTooLarge as exc:
# 1.2.0: the SDK refused an oversized SSE frame — a ProtocolError, NOT a
# ConnectionDropped (retrying yields the same frame), so surface as a terminal
# error the endpoint labels, never a reconnect. Placed above the ApiError default
# because ResponseTooLarge is a ProtocolError, not an ApiError.
raise SseResponseTooLarge(limit_bytes=exc.limit_bytes, scope=exc.scope) from exc
except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream →
# SessionApiFailed (the discriminated stream errors are handled above).
@@ -753,6 +771,10 @@ async def stream_admin_events(
# stream_turn. The web gen catches the Sse* types, so an unmapped ConnectFailed
# would escape and abort the SSE with no labeled stream_error (heid bug-hunt).
raise SseConnectFailed(status=exc.status, body=(exc.message or "").encode()) from exc
except wtsdk.ResponseTooLarge as exc:
# 1.2.0: an oversized admin SSE frame refused by the SDK (ProtocolError, non-resumable)
# → surface as a terminal error, mirroring stream_turn (never a reconnect).
raise SseResponseTooLarge(limit_bytes=exc.limit_bytes, scope=exc.scope) from exc
except ApiError as exc:
# A non-200 open raises ApiError("admin_stream_failed", status=…) → SseConnectFailed.
raise SseConnectFailed(status=exc.status, body=(exc.body or "").encode()) from exc
+33
View File
@@ -0,0 +1,33 @@
"""Unit tests for the fiction-wing probe harness's pure helpers.
Only the network-free helpers are covered here; the live retrieval paths
(search_library / reference_knowledge) are exercised by running the harness
against a live Worldtree index, not by pytest.
"""
import sys
from pathlib import Path
_DIAG = Path(__file__).resolve().parent.parent / "docs" / "diagnostics"
sys.path.insert(0, str(_DIAG))
from fiction_wing_probe import _on_target # noqa: E402
CURLY = "" # noqa: RUF001 - the b170 extraction's default apostrophe
def test_on_target_matches_curly_apostrophe_excerpt():
# Excerpt stores the curly apostrophe; keyword is ASCII. Must still match.
excerpt = f"Mr. Darcy{CURLY}s letter to Elizabeth explains his conduct."
assert _on_target(excerpt, ["darcy's letter"])
def test_on_target_matches_ascii_excerpt_against_curly_keyword():
# Symmetric: ASCII excerpt, curly-quoted keyword. Fold both sides.
excerpt = "Mrs. Gardiner's letter arrived the next morning."
assert _on_target(excerpt, [f"gardiner{CURLY}s letter"])
def test_on_target_still_rejects_absent_subject():
# Negative control: folding must not make unrelated excerpts match.
excerpt = "A passage about dungeons, crawlers, and monsters."
assert not _on_target(excerpt, ["darcy's letter"])
+178
View File
@@ -0,0 +1,178 @@
"""Tests for ratatoskr.tts — the STREAMING dots-tts gateway client.
tts_stream_stitched is the sole synthesis primitive: it synthesizes an ordered list of
(voice, text) spans serially into one continuous stream the first span verbatim, later
spans header-stripped (DEC-11 two-voice split). A single-span list is a verbatim passthrough
(no buffering, no header rewrite), so the single-voice / dialogue-only case is unchanged. No
affect dials (dots has no emotion knob). The mid-stream degrade policy spans the sequence.
"""
import httpx
import pytest
import respx
from ratatoskr.tts import (
DOTS_TTS_URL,
TtsUnavailable,
gateway_body,
tts_stream_stitched,
)
_URL = "http://tts.example/v1/audio/speech"
# The gateway's streaming WAV bytes (placeholder 0xFFFFFFFF sizes). We pass them through
# untouched, so the content only has to round-trip.
_WAV = (
b"RIFF\xff\xff\xff\xffWAVEfmt \x10\x00\x00\x00" + b"\x00" * 20
+ b"data\xff\xff\xff\xff" + b"\x11\x22" * 64
)
# A second span's WAV with distinct PCM — its header is stripped when stitched after span 0.
_WAV2 = (
b"RIFF\xff\xff\xff\xffWAVEfmt \x10\x00\x00\x00" + b"\x00" * 20
+ b"data\xff\xff\xff\xff" + b"\x33\x44" * 32
)
_PCM2 = b"\x33\x44" * 32 # the part of _WAV2 after `data`+size (what stitching keeps)
async def _drain(gen) -> bytes:
out = b""
async for chunk in gen:
out += chunk
return out
def _json_voice(route, i: int) -> str:
import json as _json
return _json.loads(route.calls[i].request.content)["voice"]
class _RaisingByteStream(httpx.AsyncByteStream):
"""A 200-body stream that yields `head` then drops mid-stream (an httpx.ReadError, a
RequestError subclass) models a gateway connection drop AFTER the response committed."""
def __init__(self, head: bytes) -> None:
self._head = head
async def __aiter__(self):
yield self._head
raise httpx.ReadError("mid-stream drop")
async def aclose(self) -> None:
pass
class TestGatewayBody:
def test_openai_dots_schema(self) -> None:
b = gateway_body("hi", "donut")
assert b["input"] == "hi" # OpenAI "input", not chatterbox "text"
assert b["voice"] == "donut"
assert b["response_format"] == "wav" # DEC-3 — "response_format", not "format"
assert b["stream"] is True # DEC-2 — play-as-it-arrives
def test_default_sampling_no_client_side_curbs(self) -> None:
# No client sampling curbs — the gateway's defaults govern (a client-side curb
# was counterproductive on the prior backend and dots exposes no such need).
b = gateway_body("a long turn", "donut")
for knob in ("temperature", "top_p", "top_k"):
assert knob not in b
def test_no_chatterbox_or_zonos_era_fields(self) -> None:
# The chatterbox bespoke names + Zonos-era fields are gone: no `text`/`format`
# (chatterbox), no `language` pin, no affect dials (DEC-7 retired).
b = gateway_body("hi", "glados")
for dead in ("text", "format", "language", "emotion_valence",
"emotion_arousal", "emotion_enabled", "emotion_strength"):
assert dead not in b
class TestTtsStreamStitched:
@respx.mock
async def test_single_span_verbatim_and_posts_openai_body(self) -> None:
# A single-span list is a verbatim passthrough (INV-TTS-6) with the OpenAI body.
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_WAV))
async with httpx.AsyncClient() as client:
out = await _drain(
tts_stream_stitched([("donut", "hello there")], client=client, url=_URL)
)
assert out == _WAV # passed through verbatim — no header rewrite
import json as _json
body = _json.loads(route.calls.last.request.content)
assert body["input"] == "hello there"
assert body["voice"] == "donut"
assert body["response_format"] == "wav"
assert body["stream"] is True
@respx.mock
async def test_two_spans_stitched_one_header(self) -> None:
# Span 0 verbatim (its WAV header + PCM), span 1 header-STRIPPED → one continuous
# stream with exactly one leading header (INV-TTS-7). Distinct voices per span.
route = respx.post(_URL).mock(
side_effect=[httpx.Response(200, content=_WAV), httpx.Response(200, content=_WAV2)]
)
async with httpx.AsyncClient() as client:
out = await _drain(
tts_stream_stitched(
[("miranda", "spoken bit"), ("emmie", "narrated bit")],
client=client, url=_URL,
)
)
assert out == _WAV + _PCM2 # span1's header dropped, PCM kept
assert out.count(b"RIFF") == 1 # exactly one WAV header
assert _json_voice(route, 0) == "miranda" and _json_voice(route, 1) == "emmie"
@respx.mock
async def test_empty_span_skipped(self) -> None:
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_WAV))
async with httpx.AsyncClient() as client:
out = await _drain(
tts_stream_stitched(
[("miranda", " "), ("donut", "real")], client=client, url=_URL
)
)
assert out == _WAV and len(route.calls) == 1 # blank span never synthesized
@respx.mock
async def test_default_url_is_dots(self) -> None:
route = respx.post(DOTS_TTS_URL).mock(return_value=httpx.Response(200, content=_WAV))
async with httpx.AsyncClient() as client:
await _drain(tts_stream_stitched([("donut", "hi")], client=client))
assert route.called # the module default points at the dots-tts gateway
@respx.mock
async def test_non_200_open_raises_before_any_chunk(self) -> None:
respx.post(_URL).mock(return_value=httpx.Response(500, content=b"boom"))
async with httpx.AsyncClient() as client:
with pytest.raises(TtsUnavailable) as exc:
await _drain(tts_stream_stitched([("glados", "hi")], client=client, url=_URL))
assert exc.value.status == 500
@respx.mock
async def test_transport_error_on_open_raises(self) -> None:
respx.post(_URL).mock(side_effect=httpx.ConnectError("refused"))
async with httpx.AsyncClient() as client:
with pytest.raises(TtsUnavailable):
await _drain(tts_stream_stitched([("glados", "hi")], client=client, url=_URL))
@respx.mock
async def test_mid_stream_drop_after_first_byte_degrades_not_raises(self) -> None:
# The 200 is committed once bytes flow; a later transport drop must DEGRADE
# (return what streamed), never raise — the pivot is yielded_any. Keeps a committed
# StreamingResponse from an ASGI trace.
respx.post(_URL).mock(return_value=httpx.Response(200, stream=_RaisingByteStream(_WAV)))
async with httpx.AsyncClient() as client:
out = await _drain(tts_stream_stitched([("donut", "hi")], client=client, url=_URL))
assert out == _WAV # head kept, no raise
@respx.mock
async def test_later_span_open_fail_after_commit_degrades(self) -> None:
# Span 0 commits a 200 + bytes; span 1's OPEN then 500s. Because the stream is already
# committed, this DEGRADES (keep span 0), never raises into the 200 (INV-TTS-4).
route = respx.post(_URL).mock(
side_effect=[httpx.Response(200, content=_WAV), httpx.Response(500, content=b"boom")]
)
async with httpx.AsyncClient() as client:
out = await _drain(
tts_stream_stitched([("miranda", "a"), ("emmie", "b")], client=client, url=_URL)
)
assert out == _WAV and len(route.calls) == 2 # span0 kept, span1 attempted then dropped
+116
View File
@@ -0,0 +1,116 @@
"""Tests for ratatoskr.voices — config load + dialogue/narration segmentation (DEC-11)."""
import json
from typing import ClassVar
from ratatoskr.voices import (
DEFAULT_VOICE_CONFIG,
load_voice_config,
resolve_voice_spans,
segment_dialogue,
)
class TestSegmentDialogue:
def test_narration_only_no_quotes(self) -> None:
assert segment_dialogue("She tilts her head.") == [("narration", "She tilts her head.")]
def test_straight_quotes_dialogue(self) -> None:
assert segment_dialogue('"Hi there."') == [("dialogue", "Hi there.")]
def test_curly_quotes_dialogue(self) -> None:
assert segment_dialogue("“Hi there.”") == [("dialogue", "Hi there.")]
def test_mixed_narration_and_dialogue_order_preserved(self) -> None:
text = "She smiles. “Hello.” She left."
assert segment_dialogue(text) == [
("narration", "She smiles. "),
("dialogue", "Hello."),
("narration", " She left."),
]
def test_empty_and_whitespace_spans_dropped(self) -> None:
# The between-quotes gap here is a single space → dropped; leading empty narration too.
assert segment_dialogue('"A" "B"') == [("dialogue", "A"), ("dialogue", "B")]
def test_apostrophe_is_not_a_boundary(self) -> None:
# Curly and straight apostrophes stay inside the span (no word split).
assert segment_dialogue("Donuts crown and Carl's cat") == [ # noqa: RUF001
("narration", "Donuts crown and Carl's cat") # noqa: RUF001
]
def test_unbalanced_trailing_open_quote_is_dialogue(self) -> None:
assert segment_dialogue('She said "hello') == [
("narration", "She said "),
("dialogue", "hello"),
]
def test_empty_text(self) -> None:
assert segment_dialogue("") == []
class TestResolveVoiceSpans:
CFG: ClassVar[dict] = {
"default": "glados",
"agents": {
"ratatoskr:donut": {"voice": "donut"},
"ratatoskr:sindra": {"voice": "miranda", "narration_voice": "emmie"},
},
}
def test_unmapped_agent_single_default_voice(self) -> None:
assert resolve_voice_spans(self.CFG, "mimir", "hello") == [("glados", "hello")]
def test_non_str_agent_id_single_default(self) -> None:
assert resolve_voice_spans(self.CFG, ["donut"], "hi") == [("glados", "hi")]
def test_mapped_no_narration_single_voice(self) -> None:
# Donut is dialogue-only (no narration_voice) → whole turn in one voice, no split,
# even though the text has no quotes.
assert resolve_voice_spans(self.CFG, "ratatoskr:donut", "The crown is mine.") == [
("donut", "The crown is mine.")
]
def test_mapped_with_narration_splits(self) -> None:
spans = resolve_voice_spans(
self.CFG, "ratatoskr:sindra", "She smiles. “Hello.”"
)
assert spans == [("emmie", "She smiles. "), ("miranda", "Hello.")]
def test_mapped_with_narration_all_dialogue(self) -> None:
assert resolve_voice_spans(self.CFG, "ratatoskr:sindra", '"Only speech here."') == [
("miranda", "Only speech here.")
]
def test_segmentation_empty_falls_back_to_single_span(self) -> None:
# Text that segments to nothing (only quote chars) still yields one span, never [].
out = resolve_voice_spans(self.CFG, "ratatoskr:sindra", '""')
assert len(out) == 1 and out[0][0] == "miranda"
class TestLoadVoiceConfig:
def test_absent_file_returns_builtin_default(self, tmp_path) -> None:
cfg = load_voice_config(str(tmp_path / "nope.json"))
assert cfg == DEFAULT_VOICE_CONFIG
assert cfg is not DEFAULT_VOICE_CONFIG # a copy, not the shared constant
def test_valid_file_loaded(self, tmp_path) -> None:
p = tmp_path / "voices.json"
p.write_text(json.dumps({"default": "glados", "agents": {"x:y": {"voice": "emmie"}}}))
cfg = load_voice_config(str(p))
assert cfg["agents"]["x:y"]["voice"] == "emmie"
def test_malformed_json_falls_back(self, tmp_path) -> None:
p = tmp_path / "voices.json"
p.write_text("{not json")
assert load_voice_config(str(p)) == DEFAULT_VOICE_CONFIG
def test_missing_agents_mapping_falls_back(self, tmp_path) -> None:
p = tmp_path / "voices.json"
p.write_text(json.dumps({"default": "glados"}))
assert load_voice_config(str(p)) == DEFAULT_VOICE_CONFIG
def test_default_key_backfilled(self, tmp_path) -> None:
p = tmp_path / "voices.json"
p.write_text(json.dumps({"agents": {"x:y": {"voice": "emmie"}}}))
assert load_voice_config(str(p))["default"] == "glados"
+224
View File
@@ -6,6 +6,8 @@ client_factory callable; tests pass a factory returning a respx-mocked
AsyncClient.
"""
import json
import httpx
import pytest
import respx
@@ -380,6 +382,14 @@ class TestSubmitTurnEndpoint:
resp = TestClient(app).post("/api/turns/s-1", json={})
assert resp.status_code == 400
def test_non_string_content_returns_400(self) -> None:
"""Non-str truthy content (e.g. a dict) → deterministic 400, not a crash in the
KB bridge's question.strip() mid-stream (heid bug-hunt)."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).post("/api/turns/s-1", json={"content": {"x": 1}})
assert resp.status_code == 400
def test_monotonic_turn_ids(self) -> None:
"""monotonic_turn_ids [trace]: two submits → second turn_id > first."""
from ratatoskr.web.server import create_app
@@ -754,8 +764,10 @@ class TestCreateAppShape:
"/api/memory/chunks",
# v0.19.2 debug-surface parity (create_app POST-002)
"/api/sessions/{session_id}/tools",
"/api/sessions/{session_id}/messages",
"/api/sessions/{session_id}/bifrost",
"/api/admin/events",
"/api/tts",
"/api/turns/{session_id}", "/api/turns/{session_id}/stream",
"/api/turns/{session_id}/cancel",
):
@@ -1443,3 +1455,215 @@ class TestMemoryChunksEndpoint:
)
resp = TestClient(app).get("/api/memory/chunks?agent_id=a")
assert resp.status_code == 400
class TestTtsEndpoint:
"""tts_endpoint FN — POST /api/tts → audio/wav STREAMED (chunked) from the
dots-tts gateway. Voice per-character (DEC-8), the gateway host never reaches
the browser (INV-TTS-1), gateway open-failure 503 (INV-TTS-4). POST so an
arbitrarily long turn rides the body; dots streams a whole turn from one call (no
client concat). No affect: p/a body fields are gone (DEC-7 retired)."""
# Streaming WAV bytes (placeholder 0xFFFFFFFF sizes) — proxied through verbatim.
_WAV = b"RIFF\xff\xff\xff\xffWAVEdata\xff\xff\xff\xff" + b"\x11\x22" * 64
_TTS = "http://tts.example/v1/audio/speech"
@respx.mock
def test_happy_streams_wav_resolves_donut_voice(self) -> None:
from ratatoskr.web.server import create_app
route = respx.post(self._TTS).mock(
return_value=httpx.Response(200, content=self._WAV)
)
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post(
"/api/tts",
json={"text": "Carl is a softie.", "agent_id": "ratatoskr:donut"},
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("audio/wav")
assert resp.content == self._WAV # streamed through verbatim
body = json.loads(route.calls.last.request.content)
assert body["input"] == "Carl is a softie." # OpenAI "input", not chatterbox "text"
assert body["voice"] == "donut"
assert body["response_format"] == "wav" # OpenAI "response_format", not "format"
assert body["stream"] is True
# No chatterbox-bespoke or Zonos-era fields ride the body.
for dead in ("text", "format", "language", "emotion_valence"):
assert dead not in body
@respx.mock
def test_unmapped_agent_falls_to_default_voice(self) -> None:
from ratatoskr.web.server import create_app
route = respx.post(self._TTS).mock(
return_value=httpx.Response(200, content=self._WAV)
)
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post("/api/tts", json={"text": "hello", "agent_id": "mimir"})
assert resp.status_code == 200
body = json.loads(route.calls.last.request.content)
assert body["voice"] == "glados" # dots default voice (DEC-8)
@respx.mock
def test_sindra_dialogue_resolves_miranda_voice(self) -> None:
# Sindra has a narration_voice, so QUOTED text is dialogue → miranda (DEC-11).
from ratatoskr.web.server import create_app
route = respx.post(self._TTS).mock(return_value=httpx.Response(200, content=self._WAV))
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post(
"/api/tts", json={"text": '"Hello there."', "agent_id": "ratatoskr:sindra"}
)
assert resp.status_code == 200
assert len(route.calls) == 1
assert json.loads(route.calls.last.request.content)["voice"] == "miranda"
@respx.mock
def test_sindra_narration_resolves_emmie_voice(self) -> None:
# Unquoted text is narration → the narration_voice (emmie) (DEC-11).
from ratatoskr.web.server import create_app
route = respx.post(self._TTS).mock(return_value=httpx.Response(200, content=self._WAV))
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post(
"/api/tts", json={"text": "She tilts her head.", "agent_id": "ratatoskr:sindra"}
)
assert resp.status_code == 200
assert json.loads(route.calls.last.request.content)["voice"] == "emmie"
@respx.mock
def test_sindra_two_voice_stitches_one_header(self) -> None:
# A mixed turn → narration span (emmie) + dialogue span (miranda), stitched into one
# stream with a single leading WAV header (INV-TTS-7); span 1's header is stripped.
from ratatoskr.web.server import create_app
route = respx.post(self._TTS).mock(return_value=httpx.Response(200, content=self._WAV))
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post(
"/api/tts", json={"text": 'She smiles. "Hello."', "agent_id": "ratatoskr:sindra"}
)
assert resp.status_code == 200
assert len(route.calls) == 2
assert [json.loads(c.request.content)["voice"] for c in route.calls] == ["emmie", "miranda"]
assert resp.content.count(b"RIFF") == 1 # one header for the whole stitched stream
def test_missing_text_returns_400(self) -> None:
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post("/api/tts", json={"agent_id": "ratatoskr:donut"})
assert resp.status_code == 400
assert resp.json()["error_code"] == "missing_text"
@respx.mock
def test_gateway_500_degrades_to_503(self) -> None:
from ratatoskr.web.server import create_app
respx.post(self._TTS).mock(return_value=httpx.Response(500, content=b"boom"))
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post("/api/tts", json={"text": "hi"})
assert resp.status_code == 503
assert resp.json()["error_code"] == "tts_unavailable"
@respx.mock
def test_long_text_is_one_gateway_call_not_chunked(self) -> None:
# DEC-10 RETIRED: dots streams a whole turn from one call, so a long turn is ONE
# gateway call with the full text — no client-side chunk-and-concatenate.
from ratatoskr.web.server import create_app
route = respx.post(self._TTS).mock(
return_value=httpx.Response(200, content=self._WAV)
)
app = create_app(_mock_client_factory(), tts_url=self._TTS)
text = " ".join(f"Sentence number {i} about the dungeon." for i in range(200))
resp = TestClient(app).post("/api/tts", json={"text": text})
assert resp.status_code == 200
assert len(route.calls) == 1 # one call, not chunk-and-concatenate
body = json.loads(route.calls.last.request.content)
assert body["input"] == text # full text in one call, unsplit
@respx.mock
def test_non_str_agent_id_degrades_no_500(self) -> None:
# An unhashable agent_id (list/dict) is guarded by resolve_voice_spans → default voice.
from ratatoskr.web.server import create_app
route = respx.post(self._TTS).mock(
return_value=httpx.Response(200, content=self._WAV)
)
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post("/api/tts", json={"text": "hi", "agent_id": ["donut"]})
assert resp.status_code == 200
body = json.loads(route.calls.last.request.content)
assert body["voice"] == "glados" # non-str agent_id → default voice, not a 500
def test_whitespace_text_returns_400(self) -> None:
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post("/api/tts", json={"text": " \n\t "})
assert resp.status_code == 400
assert resp.json()["error_code"] == "missing_text"
def test_lock_released_when_peek_raises_unexpectedly(self, monkeypatch) -> None:
# An unmapped error during the first-byte peek (e.g. httpx.InvalidURL, or a
# browser abort/CancelledError before byte 1) must still release the process-wide
# serialize lock, or every later /api/tts deadlocks at lock.acquire() (DEC-5).
from ratatoskr.web import server as _server
from ratatoskr.web.server import create_app
async def _boom(*a, **k):
raise RuntimeError("boom during peek")
yield b"" # unreachable — marks this an async generator
monkeypatch.setattr(_server, "tts_stream_stitched", _boom)
app = create_app(_mock_client_factory(), tts_url=self._TTS)
with pytest.raises(RuntimeError):
TestClient(app).post("/api/tts", json={"text": "hi"})
assert app.state.tts_lock.locked() is False
@respx.mock
def test_lone_surrogate_text_scrubbed_no_500(self) -> None:
# A lone surrogate rides JSON (\udXXX) but would UnicodeEncodeError when httpx
# serializes the gateway body → a 500. It's scrubbed at the text guard instead.
from ratatoskr.web.server import create_app
route = respx.post(self._TTS).mock(
return_value=httpx.Response(200, content=self._WAV)
)
app = create_app(_mock_client_factory(), tts_url=self._TTS)
# Send the surrogate as an escaped JSON literal — TestClient's json= can't encode it.
resp = TestClient(app).post(
"/api/tts", content='{"text": "\\ud800hello"}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 200
body = json.loads(route.calls.last.request.content)
assert body["input"] == "hello" # surrogate dropped, rest intact — not a 500
@respx.mock
def test_empty_200_body_rejected_503(self) -> None:
# A gateway that returns 200 with a 0-byte body (a stalled/failed generation) is a
# synthesis failure, not silent audio — surface it as 503 (INV-TTS-4) rather than
# committing an empty audio/wav stream. Backend-agnostic hygiene (infra-ops-endorsed).
from ratatoskr.web.server import create_app
respx.post(self._TTS).mock(return_value=httpx.Response(200, content=b""))
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post("/api/tts", json={"text": "a very long turn"})
assert resp.status_code == 503
assert resp.json()["error_code"] == "tts_unavailable"
@respx.mock
def test_non_wav_200_body_rejected_503(self) -> None:
# A 200 whose first bytes are not a RIFF header (a proxy error page) must not be
# mislabeled audio/wav — the peek RIFF-sniff rejects it as 503.
from ratatoskr.web.server import create_app
respx.post(self._TTS).mock(
return_value=httpx.Response(200, content=b"<html>error: data unavailable</html>")
)
app = create_app(_mock_client_factory(), tts_url=self._TTS)
resp = TestClient(app).post("/api/tts", json={"text": "hi"})
assert resp.status_code == 503
assert resp.json()["error_code"] == "tts_unavailable"
+30 -1
View File
@@ -47,6 +47,7 @@ from ratatoskr.sse_client import (
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
SseResponseTooLarge,
TurnIdFlip,
TurnLaunchUnavailable,
)
@@ -360,7 +361,15 @@ class TestStreamTurn:
fake = _FakeSessions(events=[e1, e2])
got = await _drain(stream_turn(_wt(fake), "s-1", "hello"))
assert got == [e1, e2]
assert fake.calls[-1] == ("stream_turn", ("s-1", "hello"), {})
# memory_context defaults to None (forwarded verbatim; inert for every caller
# that doesn't pin corpus context via the KB-recall bridge).
assert fake.calls[-1] == ("stream_turn", ("s-1", "hello"), {"memory_context": None})
async def test_forwards_memory_context_verbatim(self) -> None:
mc = [{"kind": "corpus_reference", "text": "the pinned passage"}]
fake = _FakeSessions(events=[])
await _drain(stream_turn(_wt(fake), "s-1", "hi", memory_context=mc))
assert fake.calls[-1] == ("stream_turn", ("s-1", "hi"), {"memory_context": mc})
async def test_session_retired_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(
@@ -423,6 +432,16 @@ class TestStreamTurn:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert (ei.value.established, ei.value.got) == (5, 7)
async def test_response_too_large_maps_to_sse_response_too_large(self) -> None:
# 1.2.0: an oversized SSE frame (ProtocolError, NOT ConnectionDropped) → a terminal
# SseResponseTooLarge, never a resumable drop. Carries limit_bytes + scope.
fake = _FakeSessions(
stream_error=wtsdk.ResponseTooLarge(limit_bytes=540000, scope="sse_frame")
)
with pytest.raises(SseResponseTooLarge) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.limit_bytes == 540000 and ei.value.scope == "sse_frame"
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream
# (not a discriminated stream error) → SessionApiFailed.
@@ -1235,6 +1254,16 @@ class TestStreamAdminEventsWt:
await _drain(stream_admin_events(_wtad(fake)))
assert ei.value.last_seen_sse_id == "42"
async def test_response_too_large_maps_to_sse_response_too_large(self) -> None:
# 1.2.0: an oversized admin SSE frame (ProtocolError) → terminal SseResponseTooLarge,
# mirroring stream_turn (never a resumable drop).
fake = _FakeAdmin(
stream_error=wtsdk.ResponseTooLarge(limit_bytes=540000, scope="sse_frame")
)
with pytest.raises(SseResponseTooLarge) as ei:
await _drain(stream_admin_events(_wtad(fake)))
assert ei.value.limit_bytes == 540000
async def test_connection_dropped_none_cursor_connect_time(self) -> None:
# A connect-time transport failure surfaces as ConnectionDropped(None) →
# SseConnectionDropped(last_seen_sse_id=None) (the map's other cursor shape;
Generated
+5 -5
View File
@@ -463,7 +463,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.22.1"
version = "0.22.3"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -509,7 +509,7 @@ requires-dist = [
{ name = "sqlite-vec", marker = "extra == 'provider'", specifier = ">=0.1.6" },
{ name = "starlette", marker = "extra == 'web'", specifier = ">=0.40" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.30" },
{ name = "worldtree-sdk", specifier = "==1.0.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
{ name = "worldtree-sdk", specifier = "==1.2.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
]
provides-extras = ["web", "provider", "dev"]
@@ -897,12 +897,12 @@ wheels = [
[[package]]
name = "worldtree-sdk"
version = "1.0.0"
version = "1.2.0"
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.0.0/worldtree_sdk-1.0.0.tar.gz", hash = "sha256:0b87001fd90b304befe5e76d21a41dd093057a9d59f86a25189fab80defcd043" }
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.2.0/worldtree_sdk-1.2.0.tar.gz", hash = "sha256:48b1a9f44875069aade079f999c72c8df4cc7c6abf2e49fb9ad52a067594bd19" }
wheels = [
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.0.0/worldtree_sdk-1.0.0-py3-none-any.whl", hash = "sha256:ec9cb03ecdb4662bf1280a64316ee4545bb1330db42e3135f34f56e62c07f6c5" },
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.2.0/worldtree_sdk-1.2.0-py3-none-any.whl", hash = "sha256:3516841dad7adf7a7bef5a492d2c9fe0b3e7abc3c453ee573ceddf20f7e6823a" },
]