114 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 ec68b1f3a5 feat(#20): worldtree-sdk cutover teardown (slice-7) + v0.22.0
The last slice of the consumer-layer cutover. Teardown only — zero
runtime-logic change; the 494-green suite is the regression gate.

- Drop `httpx-sse` from pyproject + lockfile: slice-6 deleted its last
  user, nothing imports `httpx_sse`, the SDK owns SSE parsing now.
- Module boundary (operator decision): KEEP `sessions.py` + `sse_client.py`
  as pure caller-semantic type/exception homes (no rename, no fold —
  A3 was blocked by the `AgentNotAvailable` name collision + `wt.py`
  would mis-home `endpoint_for_plane`). Docstrings updated to stop
  claiming "client"; the `AdminEvent`/`SseId`/exception homes stay put
  (resolves the slice-6 deferred-home item).
- Retire wire contracts #2 (sessions) + #15 (tier3): DEC-1 phase-2 —
  normative authority already transferred to the cutover contract; the
  code they specified is gone, so the files are deleted. #1 (SSE event
  vocab) and `first_message` stay (ratatoskr-owned, not retired).
- Final coverage-map re-anchor: tools/list_sessions re-homed to `wt.py`;
  the Last-Event-ID SSE-resume sub-gap CLOSED (folded into `stream_turn`
  auto-resume); Surface-2 SSE parsing re-anchored to the SDK.
- Stale doc-rot fix: the cli.py transport comment no longer calls
  `seed_preset_first_message` "not-yet-migrated" (it rides `wt`).
- v0.22.0 (minor, DEC-6, operator-approved): publishes the full 6-slice
  cutover milestone.
2026-07-19 13:41:34 -07:00
vh de9a5baf45 feat(#20): admin (bifrost inspection + admin-events stream) onto the wt adapter (slice-6)
Slice-6 of the worldtree-sdk cutover: migrate the two admin routes off the
hand-rolled paths onto the `ratatoskr.wt` adapter over `client.admin.*`, and delete
the retired code. Both are web-only (the coverage-map's `tui.py` rows were stale —
corrected to `web/server.py`).

Adapter (`wt.py`): `get_session_bifrost` → `client.admin.sessions.bifrost` (open-world
dict verbatim, any error → SessionApiFailed default); `stream_admin_events` →
`client.admin.stream_events`, re-wrapping the SDK's `AdminEvent` → ratatoskr's at the
boundary.

Decisions (contract § slice-6 notes):
- Admin auth moves from a per-call `Authorization` header override to the client's
  `admin_auth` (`_wt_client(admin_key=…)`, extended this slice) — the SDK's admin.*
  routes use the provider, not a header.
- `AdminEvent` re-wrap (chosen over yield-through): the SDK's `admin_id`(nan)/None-able
  `type`/`data` diverge from ratatoskr's `id`/`type`/`data` that the web filter reads;
  re-wrapping (nan→0, None→""/{}) degrades the open-world None/nan ONCE at the adapter
  and keeps the web endpoint + `_admin_event_matches_web` + the `AdminEvent` domain type
  unchanged (preserves the web surface). Rejected: yield SDK events + rewire the web
  filter (heavier churn, scattered hardening).
- Admin-stream error map: a NON-200 open raises `ApiError("admin_stream_failed")`
  (NOT `ConnectFailed`) → SseConnectFailed; `ConnectionDropped` (connect-time OR
  mid-stream/resumable-EOF) → SseConnectionDropped. The web integration test caught the
  ApiError-not-ConnectFailed gotcha the unit fake couldn't.

Web (`web/server.py`): both admin endpoints build the wt client with admin_key and call
`wt.*`; the bifrost endpoint gains ConnectFailed→502 handling (cutover foot-gun); the
admin-events endpoint closes the injected transport (INV-CUT-1), never the wt client.

Deleted the hand-rolled `sessions.get_session_bifrost` + `sse_client.stream_admin_events`
(+ orphaned httpx/httpx_sse/json/AsyncIterator imports); the ratatoskr `AdminEvent`
dataclass stays in `sse_client.py` (re-wrap target, imported by wt + web) until slice-7.
Retired `test_sse_client.py` entirely (its last test was the admin stream) and the
`test_sessions.py` `TestGetSessionBifrost`; added the slice-6 adapter tests.

LIVE SMOKE (:8081, readonly-admin key) — INV-CUT-5 / DEC-4 cleared: the web bifrost
endpoint returned an admin-authed clean 404 envelope (auth + route + mapping proven);
a real `session.created` admin event (id=32) re-wrapped cleanly on live wire (driven by
a session-create, throwaway session cleaned up).

Suite 490 green; ruff clean; mypy net-improved on web/server.py (16→12 pre-existing, no
new). Patch bump 0.21.18 → 0.21.19 (the cutover MINOR is deferred to slice-7, DEC-6).
2026-07-19 12:48:04 -07:00
vh 4e20030229 fix(#20): heid-bug-hunt fixups — CLI open-world container-type hardening (slice-5)
Panel (Gróa + Hulda + Regin, source-verified by Heid): adapter/route-map/
ConnectFailed-at-call-sites sound against the declared invariants; 4 real
robustness findings, all in the CLI open-world presenter/probe paths — the
container-type layer BELOW the null/element holes the code-review already fixed.

Fixed (findings 1-3):
- `_format_whoami` (`cli.py`): a non-iterable `scopes`/`allowed_roles` scalar
  (`{"scopes": 123}`) made `x or []` yield `123` → `for s in 123` TypeError. New
  `_display_seq` helper degrades any non-list (scalar / bare string / null / absent)
  to empty; applied to both `scopes` and `allowed_roles`.
- `_characters_probe` (`cli.py`): same class on the model catalog `items` (`{"items":
  123}`) — now guards `models` is a Mapping and `items` is a list before iterating.
- `_characters_probe`: the top-level open-world reads `created` / `state` are now
  `isinstance(_, Mapping)`-guarded before any `.get` — a non-mapping SDK passthrough
  (`created=[...]`) aborts cleanly (exit 20) / renders `pad=None` instead of an
  AttributeError.

Accepted (finding 4, documented in contract § slice-5 notes): the `--characters`
probe leaks its transient character on a mid-lifecycle failure. PRE-EXISTING (the
retired probe had the identical linear no-`finally` structure — cutover did not
worsen it), TTL-bounded, one-shot diagnostic; a `try/finally` would swallow a
happy-path delete-failure (delete is both teardown and a tested step). Gróa + Heid
concur accept is defensible.

Dismissed (finding 5): Hulda flagged `sessions.py` dropping `get_me`/etc. as a
caller-contract break — it is the intended DEC-3 no-backwards-compat migration (all
in-repo callers rewired same-diff); Heid labels it intended-surface-change.

Added CLI tests for the three hardened paths (scalar scopes/roles; scalar items +
non-mapping state; non-mapping create abort). Suite 488 green; ruff clean; live
smoke re-run clean (identical happy-path output). Patch bump 0.21.17 → 0.21.18.
2026-07-19 11:29:10 -07:00
vh deab7627eb feat(#20): characters + me/capabilities/models onto the wt adapter (slice-5)
Slice-5 of the worldtree-sdk cutover: migrate the remaining consumer READS +
transient-character CRUD off the hand-rolled httpx wrappers onto the
`ratatoskr.wt` adapter over the SDK, and delete the retired path.

Adapter (`wt.py`): add `get_me` / `get_capabilities` / `list_character_models`
/ `create_character` / `get_character_state` / `delete_character` over
`client.me` / `client.capabilities` / `client.models` / `client.characters.*`.
All six are open-world reads/acks returned verbatim; none carries a
discriminated SDK error, so each maps any `ApiError` → the `SessionApiFailed`
default (INV-CUT-2) — exact parity with the retired path. No new Error-map rows.

Decisions (contract § slice-5 notes): `create_character` omits `state` when None
(SDK-idiomatic inline literal, server-equivalent to the retired explicit null);
`delete_character` returns the SDK's open ACK verbatim (`-> Mapping|None`, not
normalized to None).

CLI rewire (`cli.py`): `--whoami` (me + capabilities) and `--characters`
(models → create → state → delete) build a `wt.build_client` over the injected
probe transport and catch `wt.SessionApiFailed` + `ConnectFailed`. Open-world
degrade-not-crash carried (cumulative cutover foot-gun): `_characters_probe`
reads `items` null-safe and extracts `character_id` defensively (clean abort,
no hard-index KeyError); `_format_whoami` widened to `Mapping`.

Deleted the six hand-rolled `sessions.py` wrappers (net -5 mypy no-any-return);
`endpoint_for_plane` + `get_session_bifrost` (slice-6) + the exception classes
stay. Retired the corresponding `test_sessions.py` classes; added the slice-5
adapter tests + a CLI malformed-create-abort test.

LIVE SMOKE (:8081, b128) — INV-CUT-5 / DEC-4 cleared: `--whoami` rendered real
identity + capabilities; `--characters` drove the full lifecycle end-to-end
(char-rp catalog → created char_8c00006e… → PAD read-back → deleted).

Suite 483 green; ruff clean; mypy at the 2 pre-existing baseline errors.
Patch bump 0.21.15 → 0.21.16 (the cutover MINOR is deferred to slice-7, DEC-6).
2026-07-19 11:00:41 -07:00
vh aed942972f fix(#20): heid-code-review fixups — persona-endpoint SessionApiFailed parity (slice-4)
Panel (Gróa+Hulda+Regin) returned zero adapter / error-map / model→role drift;
three actionable items triaged as genuine adds:

- FIX: `_persona_state_endpoint` now catches `wt.SessionApiFailed` and returns the
  `session_api_failed` envelope with the upstream status, for parity with
  `_agents_endpoint` / session-create / admin (2/3 arms flagged it; it was the lone
  sibling letting an unmatched upstream ApiError escape as a raw 500). Confirmed
  NOT a slice-4 regression — the pre-cutover persona endpoint had the same latent
  gap — but closed here since the endpoint's error surface is already being hardened
  (it gained the ConnectFailed catch this slice).
- TESTS: dual-key NEGATIVE rows — a wrong error_code at the same status defaults to
  SessionApiFailed for `define_agent` (403, 422) and `patch_agent` (422); plus the
  flat-`field` body-parse shape for `_error_field_from_body` (only the nested
  detail.field form was exercised). Closes the assertion-symmetry gap with the
  persona route's existing negative test.
- AMEND: contract slice-4 notes document the intentional client-side `":" in
  agent_id` PRE on patch/delete (a Tier-3 id is always <user>:<name>, ADR-0019).

Suite 470 green (+5).
2026-07-19 10:13:11 -07:00
vh c62b4eecb3 feat(#20): agents/tier3 family onto the wt adapter + model→role fold (slice-4)
Cut ratatoskr's consumer agent-lifecycle routes over to worldtree-sdk
(issue #20 slice-4). Five routes now flow through `ratatoskr.wt` over the
SDK's `client.agents.*`, returning open-world dicts and mapping the SDK's
undiscriminated `ApiError` floor by route+(status,error_code) per INV-CUT-2:

- `list_agents`      → `agents.list`
- `get_persona_state`→ `agents.persona_state` (404 persona_not_configured /
                        404 agent_not_available / 403 auth_scope_denied)
- `define_agent`     → `agents.define` (429→Tier3QuotaExceeded(retry_after=0),
                        403→Tier3UserIdUnsupported, 422 layer_deferred→…)
- `patch_agent`      → `agents.patch`  (404→Tier3AgentNotFound, 422 field_not_mutable)
- `delete_agent`     → `agents.delete` (404→Tier3AgentNotFound; NOT hide-existence)

Rewired call-sites: the `python -m ratatoskr.tier3` CLI (define/patch/delete)
and the web `_agents_endpoint` / `_persona_state_endpoint`, both catching the
SDK's `ConnectFailed` transport-failure normalization. Deleted the hand-rolled
paths: `sessions.list_agents` / `get_persona_state` / `AgentInfo`, and
`tier3.define/patch/delete_agent` / `Tier3AgentInfo` / parse+extract helpers.

model→role fold (scope B): the define/patch response echoes `role` (spec 1.2 /
b128), read off the open-world dict; `LocalAgentEntry.model`→`.role`,
local-index schema v1→2 (old index discarded, no-backwards-compat).

The Tier-3 caller-semantic exceptions move to `sessions.py`: running the CLI
as `__main__` while `wt` imports `ratatoskr.tier3` bound two copies of each
exception class, so a raised `Tier3AgentNotFound` escaped the CLI's `except`
as an uncaught traceback. Homing them in `sessions` (never `__main__`) makes
the class identity single. The live smoke — not the unit tests, which call
`main()` in-process — caught this.

Error-map rows + slice-4 notes added to the cutover contract; coverage-map
re-anchored. LIVE-SMOKE on personal :8081 (b128): define(thoughtful-character)
→ patch → list(6 agents) → persona_state(→PersonaNotConfigured mapped) →
delete → index empty; non-existent-id patch via `-m` → [agent_not_found]
exit 20. Suite 465 green.
2026-07-19 09:58:01 -07:00
vh ca9a339050 feat(#20): persona + authored-history + first-message onto the wt adapter (slice-3)
Slice-3 of the worldtree-sdk cutover: migrate the session persona-state write,
the #347 authored-history write, and get_session_messages onto ratatoskr.wt, and
route the first-message preset seed through the adapter. Retire the last
hand-rolled sessions.py paths the --seed-first-message probe kept alive
(create_session + SessionInfo, set_persona_state, write_authored_history,
get_session_messages, _bifrost_error_from).

- wt.set_persona_state (SDK PadState) — the CLI passes three finite PAD axes; the
  SDK owns the {"pad": {...}} wire (#317). No route-specific error row → the
  SessionApiFailed default.
- wt.write_authored_history (SDK write_history) — v1 author=assistant; 404 →
  AuthoredHistoryUnavailable (hide-existence; the route is the discriminator,
  never the body); every other ApiError → the default. Drops the unused
  author/effects/claimed_original_at params (no caller uses them).
- first_message.seed_preset_first_message now takes a WorldtreeClient and routes
  through wt.write_authored_history; the best-effort invariants (INV-001..004,
  never-raise/never-block/one-write/zero-worldtree-source-import) are unchanged.
  Tests drive a fake WorldtreeClient — the wire is the SDK's to prove.
- CLI --set-persona-pad / --seed-first-message + the _amain and web create-path
  first-message seeds rewired onto the adapter. --set-persona-pad pre-validates
  PAD finiteness (clean usage_error, never a crash on the SDK ConfigurationError).

LIVE-SMOKE on personal :8081 (b128, INV-CUT-5): --seed-first-message → 201
(seq=0, phase=seeded) → read-back verbatim; --set-persona-pad → 204; the --new
create-path preset seed observed routing through the adapter. All slice-3 route
families proven end-to-end through the ratatoskr surface.

docs/coverage-map.md + first_message.contract.md re-anchored onto the adapter;
the slice-2 create/stream/cancel rows re-anchored too (they still named the
deleted sse_client/sessions symbols).

Suite 466 green; mypy no new errors (baseline 22 → 20 in the touched modules);
ruff clean. INV-CUT-1..5 held. Bifrost provider planes untouched.
2026-07-19 08:53:45 -07:00
vh 74d41eb559 fix(#20): heid-code-review fixups — INV-CUT-2 completeness on cancel/stream (slice-2)
Triaged the heid-code-review panel (Gróa + Hulda substantive, Regin zero=weak).

Adopted (genuine adds):
- cancel_turn + stream_turn gain a defensive `except ApiError -> SessionApiFailed`
  default after their discriminated branches. INV-CUT-2 ("every ApiError is mapped;
  default SessionApiFailed") now holds STRUCTURALLY on those routes, not by coupling
  to the SDK's internal guarantee that it maps them to discriminated types. + tests.
- get_session_tools error-path test (symmetric with messages).
- Contract § Error map amended: added the stream ProtocolError rows
  (Malformed*/TurnIdFlip -> ratatoskr same-named), clarified the cancel row (the SDK
  RAISES the typed races -> ratatoskr exceptions, only a 200/cancelled=False is a
  CancelResult; caller surface stays exception-based per DEC-2), and noted the
  ApiError default holds on stream+cancel too.

Rejected (category-5, wrong-grounding) — 2/3 arms flagged create's bound-502 as
"should gate on error_code like list's 422+cursor_invalid". Verified against the SDK
parser (not in the arms' file set): the bound-502 body is
{"error_code":"bifrost_handshake_failed","detail":{"bifrost_error":...}}, and the
SDK's envelope parser PREFERS the nested detail (which lacks error_code), so
ApiError.error_code resolves to "unknown" — gating would REGRESS handshake detection
(the cli/web integration tests caught it). INV-002 also makes the handshake the sole
bound-502 cause. Kept the any-bound-502 mapping; documented WHY in code + contract.

Accepted-as-is: create_session -> Mapping annotation (intentional open-world
passthrough, already documented in the route-map note; category 3).

Suite 493 green; wt.py mypy + ruff clean. Patch.
2026-07-19 06:57:52 -07:00
vh e45640c4da docs(contract): worldtree-sdk cutover — SDK-adapter contract (refs #20)
Consumer-layer cutover to worldtree-sdk (Python) 1.0.0: retire the hand-rolled
httpx wrappers (sessions/sse_client/tier3) behind a thin ratatoskr.wt adapter over
the SDK. Carries the 6 locked DECs (vor-cross'd with worldtree-codex), the route
map (21 wrappers -> SDK methods), the Error map table (heid-panel find:
route-as-discriminator, default -> SessionApiFailed), INV-CUT-1..5, and the 7-slice
plan. Adapter design: caller-injected transport (never closed), thin semantic error
adapter, Bifrost provider planes untouched.

heid-contract-review clean after fixups (3/3 error-map convergence + 6
clarifications folded in). No version bump (.contract.md, no code).

Refs #20.
2026-07-18 23:15:17 -07:00
vh b1fbadde3c docs(coverage): track v1 coverage against worldtree-sdk's 41-op ratified surface
worldtree-sdk v1.0.0 (wtsdk-dev, althing 01KXVF24WQD2T5ZCS49KKFCCMH) ratifies the
same 41-op surface from the identical OpenAPI 2.3.0 (sha 36148179601453a0) this
ledger already tracks — record it as the external parity authority. Ratatoskr is
the parallel Python/httpx reference-consumer (no TS adoption); the forthcoming
worldtree-sdk Python spine is the future consumable, noted as a repin candidate.

Fold in v0.21.2: POST /sessions row now notes ephemeral-Echo config passthrough
(role not model, W-4 cross-validated by the SDK); GET /capabilities row notes the
--whoami allowed_roles/default_role fix + spec v1.1. REST count unchanged (19/41 —
ephemeral is a depth enhancement to an already-covered route).

No version bump (docs-only coverage-ledger update).
2026-07-18 13:44:04 -07:00
vh c7016f23a6 feat(#19): ephemeral-template (Echo) session creation
create_session could only mint foundational sessions; an ephemeral template
(agent_id="echo") returned 422 ephemeral_requires_config because ratatoskr never
sent the required config block — Echo was uncreatable, surfacing as an opaque
session_api_failed at the CLI. Thread an opaque, role/model-agnostic config
passthrough through the create path so Echo sessions are creatable.

- sessions.py: create_session(config=...) verbatim passthrough (PRE-004 Mapping /
  PRE-005 config-xor-bifrost guards); SessionInfo gains kind + config, captured
  defensively (.get) on both create and list.
- cli.py: --system-prompt flag builds config={"system_prompt": ...} (validation:
  non-empty, requires --new+--agent, xor bifrost); _amain surfaces kind=; the
  --whoami renderer now reads allowed_roles/default_role (was reading the dead
  allowed_models/default_model) and tolerates a malformed capabilities shape.
- contract #2 amended (Amendment 2026-07-18); Heid-panel contract-reviewed +
  diff-scoped bug-hunted (one whoami null-join gap found + fixed).

Canonical grounding: config.role, never config.model (worldtree-dev althing
01KXT976NN91DRBZBPXNZ2BVZR; ADR-0012 role cutover). Verified end-to-end against
the live v0.16.2 target. TDD across create + CLI; full suite green (534).

Closes #19.
2026-07-18 12:02:18 -07:00
vh 5d06a274bf chore(canonicals): re-sync conversation-api-spec to v1.1 + affect-egress
Pull Worldtree main's role/model-cutover doc correction (worldtree commit
b4a278c) into the pinned conversation-api-spec: the Echo ephemeral-template
section now documents allowed_roles/default_role, config.role (omitted ->
default_role "echo"), the repurposed model_not_allowed (any non-empty
config.model hard-rejects), and the new role_required error. Frozen OpenAPI
untouched. affect-egress-consumer-reference re-synced in the same pass;
re-pin hashes in .corviduo-canonicals.toml.

Resolves the role<->model drift ratatoskr-dev raised on althing (thread
01KXT976NN91DRBZBPXNZ2BVZR); worldtree-dev cleared the re-sync.

No version bump (vendored-canonical docs sync).
2026-07-18 12:01:48 -07:00
vh 80c8d58b83 chore(canonicals): sync soong-lab export + importer contracts
Refresh vendored soong-lab-bundle canonical copies against upstream and
re-pin hashes in .corviduo-canonicals.toml.

- export: open_question B resolved — the 4 role labels map 1:1 to WT
  model-role slugs by exact name (assistant/thoughtful-assistant under
  the `foundational` grant, character/thoughtful-character under
  `character`), so ship.native.role is directly define-valid. Also,
  motivational goals/fears are now structured objects (WT #187) with
  id/type/salience/description and validate_exportable gates
  (description >=20, type in GOAL_TYPES, salience in [0,1]).
- importer: adds _coerce_goal/_coerce_fear totality path (INV-I-6) with
  legacy bare-string back-compat and strict re-validate (no silent loss).

No ratatoskr code impact: the motivational Tier-3 layer is schema-deferred
(Phase 2.0 baseline-only) and no goals/fears string-consumers exist. No
version bump (vendored-canonical docs sync, skip-the-bump per SemVer).
2026-07-18 02:22:58 -07:00
vh 4bd9abdebc docs(contract): re-canonicalize #1 SSE event vocab against code
Add awaiting_llm_first_token (#201) and affect_update (#204) to issue #1's
Event union and TESTS via a dated amendment. Both events are parsed by
_envelope_for_type and covered in tests/test_sse_client.py, but issue #1's
Output union + full_event_vocab test were frozen at the v0.19.0 baseline's
8-event set — contract-vs-code drift surfaced during the Worldtree #371 SDK
parity-matrix pass. Documentation-only: no code change, no version bump.
2026-07-18 00:15:30 -07:00
vh be2c577884 feat(provider): mark_superseded verb — Worldtree #364 contradiction retirement + bifrost 1.1.4
Implement `mark_superseded(ids, *, superseded_by=None, reason=None)` — the SOLE
supersession verb Worldtree #364's promotion-hygiene reconciliation calls to retire
contradicted facts (wire shape confirmed by worldtree-dev, bifrost_memory_store.py:293).
Live re-verify (2026-07-16) proved our provider 500-crashed on this call (unimplemented)
→ #364's retirement couldn't land + a retry-storm bloated the store; the readout only
passed via transient recency-eviction.

- `mark_superseded` mirrors the reference `_mark_lifecycle`: sets top-level
  `superseded=True` (+ `superseded_by`/`superseded_reason` when non-None), increments
  revision, NON-destructive (get still returns; recoverable). Unknown ids skipped.
- `_is_live` (INV-011) now short-circuits on `superseded is True`, so a retired chunk is
  excluded from `scan` (person-prime) — durable retirement, not just recency-eviction.
  search is unfiltered (matches reference; WT re-checks liveness client-side).
- Contract: un-defer mark_superseded (+ FN spec, INV-011); TDD 5/5 (retires-from-scan
  tracer, non-destructive-get, unknown-id no-op, non-None-fields-only, parity #195).
- bifrost 1.1.1→1.1.4: hasattr-gate backstop for the maintenance verbs (unimplemented
  verb → unsupported_capability 400, never AttributeError/500/retry-storm — the gap we
  surfaced) + the 1.1.3 scan/cursor conformance harness. Full suite 644 green.
2026-07-16 22:37:31 -07:00
vh f46ccbae1c fix(provider): sortable_chunk_fields needs required type — handshake was broken
DEPLOY-BREAKER caught by driving the live bind (unit tests + worldtree-dev's
name-only parser + heid-bug-hunt all missed it). bifrost handshake_response
`SortableChunkField` requires BOTH `name` and `type` (additionalProperties:false).
We advertised `[{"name":"updated_at"}]` (no `type`), so the handshake_response
failed wire-schema validation → `bifrost.schema_validation_failed` → the ENTIRE
Bifrost bind (memory + affect) broke, not just the sort. Advertise
`{"name":"updated_at","type":"timestamp"}` (matches the reference; `type` is
advisory-only). Regression guard added to the caps test (asserts required name+type,
no extra keys). Full suite 639 green.
2026-07-15 09:11:08 -07:00
vh 8199774405 docs(contract): mark scan cursor v1-provisional (offset, not snapshot); route conformance gap to bifrost-dev
Operator accepted offset-cursor for v1 (person-prime single-page is
conformant). INV-010 now documents the KNOWN DEVIATION: multi-page
continuation diverges from bifrost's protocol snapshot-cursor contract
(dispatch drops sort on continuation, ScanCursorExpired normative) — our
offset cursor doesn't snapshot (dup/drop under concurrent write) and never
expires. Durable fix routed to bifrost-dev as a conformance-coverage gap
(scan/cursor is untested); ratatoskr will adopt reference snapshot-cursors
if bifrost rules them normative.
2026-07-15 08:52:29 -07:00
vh 8fc757aa61 feat(provider): person-prime scan verb + sortable_chunk_fields cap (WT #349)
Implement the memory-store `scan` verb — a query-LESS, LIVE-only, globally
ordered top-N-by-recency read — and advertise `sortable_chunk_fields=
[{updated_at}]` at the Bifrost handshake. Advertising the cap is what lights
up Worldtree's #349 person-prime turn-1 durable-fact injection (Branch-A
`"updated_at" in caps.sort_fields_supported`); the fix is ZERO Worldtree
change — the running provider announcing the cap is the trigger.

scan is:
- LIVE-only server-side (INV-009): superseded/tombstoned excluded — a dead
  fact can never inject; person-prime's `lifecycle_state=live` does not ride
  the scan wire, so server-side is authoritative.
- Globally ordered before pagination (INV-010): the full scope-filtered live
  set is ordered by (sort.field, direction) globally; missing value LAST,
  chunk_id tiebreak. Backed by an expression index on
  json_extract(record_json,'$.updated_at') to stay in the 500ms budget.
- Cursor = offset into the global order; emits a next cursor only when a
  further match exists (no empty trailing page — matches the reference).

Sort is dispatch-gated: an unadvertised sort.field raises InvalidArguments,
never a silent unsorted fallback.

Contract amended: un-defers scan, adds the FN spec + INV-009/INV-010 +
sortable_chunk_fields to INV-006. TDD 7/7 green (scan_recency tracer,
live_only, scope_isolation, unadvertised_sort, person_prime_record_shape,
cursor pagination, parity_vs_reference vs InMemoryMemoryStore #195). Full
suite 638 green.
2026-07-15 08:13:08 -07:00
vh 39050c333f chore(canonicals): vendor + pin soong-lab bundle contracts (export + importer @ f434016) 2026-07-14 10:17:22 -07:00
vh 5f321b968a chore(canonicals): vendor brokkr R34 psych-profile canon; re-sync R32-1B doc drift
Vendor the R34/R35 psych-profile reference (Vuong-directed via brokkr) as two
pinned canonicals under docs/vendor/brokkr-r34-psych-profile/:
- brokkr-psych-profile-authoring-spec-v1 (governs on conflict)
- brokkr-psych-profile-parameters-v1 (builder-facing distillation)
Both canonical_source=brokkr-smithy, tolerate_drift; drift-clean.

Re-sync the two tolerate_drift worldtree prose pins (affect-egress-consumer-
reference, conversation-api-spec): the drift was a benign 2-line R32-1B note
(unbounded-z PAD range) documenting a change already adopted in v0.20.9, not
the anticipated we-framing conditional. All canonicals now drift-clean.

Snapshot persistent-memory.md for the execution arc: P06 memory-half driven
(308/308 clean) + scored by brokkr (R35.45) — the authored psychological_profile
is the validated mechanism for memory-salience divergence (authored 0.618 vs
stripped 0.235 null, delta +0.382); memory extraction now reasoning-off; WT #355
root-caused via ratatoskr telemetry.

No version bump: docs/vendoring + memory-snapshot only, no runtime code change.
2026-07-13 07:02:22 -07:00
vh 0441e319f6 feat(web): auto-scale PAD gauges for R32-1B unbounded-z (v0.20.9)
Sindra "full and unbounded": relax the debug affect console's PAD display
from a hard [-1,1] clamp to auto-scaling on the session's own max |PAD|
(padScale floor 1.0 → padFillFrac faders + _padNorm orbit). An unbounded-z
PAD (Worldtree R32-1B, ~±10) now renders at full range and never pegs or
escapes the frame; today's [-1,1] values are unchanged (scale==1); the
exact value is always shown numerically (unclamped).

Purely a debug-surface change — verified (grep, whole codebase) the only
PAD clamps lived in the web display layer: the affect store is conduit-
opaque, the read route + proxy pass verbatim, and --set-persona-pad writes
unclamped. Ratatoskr is a downstream observer, so this has zero consequence
to any agent's real affect or behavior (Worldtree-computed server-side).
Playwright-verified: z=±6.2 → faders ≤ half-bar, orbit in-box, +6.20
readout, zero regression at scale 1. Proactive R32-1B prep.
2026-07-08 16:16:29 -07:00
vh e5ec63967e feat(web): memory viewer + design iteration-3 + markdown pass-2 (v0.20.7)
Web-UI iteration-3 — three queued items landed together.

(A) Design iteration-3 into static/index.html:
- sparkline grid background (<pattern id=sparkGrid> + bg rect behind each
  relation-row spark polyline);
- PAD strips → per-turn Δ bars: replace the vertical polyline strip
  (stripPoints/proj3 removed) with padDeltas→deltaStrip, a 12-cell HTML
  column of diverging bars (newest at bottom, magnitude→width, age→opacity);
- mood orbit → dimetric open box (viewBox 124×140, az35/el25,
  D-right/A-left-back/P-up): ghost A×P wall + D×A floor +
  orbitProj/orbitShadowY/orbitWallPt/orbitAxisPt, JS-driven animated replay
  (orbitFrame per rAF via a singleton startOrbitAnim reading live ORBIT_HIST;
  reduced-motion → static final-state).

(B) Memory viewer — a non-bifrost debug read on OUR own store, mirroring the
    #18-D2 affect read:
- provider: RatatoskrMemoryStore.list_chunks + count_chunks + shared
  add_memory_read_route (GET /memory/chunks?agent_id=&end_user_id=), wired
  into build_memory_provider_app + the combined :8392 provider. end_user
  strict, agent_id lenient (an {end_user}-only chunk stays visible);
  {chunks,count,total}, empty match = 200 (not 404);
- web: _memory_chunks_endpoint (GET /api/memory/chunks) supplies end_user_id
  server-side, forwards the browser agent_id, proxies to memory_read_url;
  create_app gains memory_read_url, entrypoint reads RATATOSKR_MEMORY_READ_URL;
- pane: loadMemory/renderMemory/setMemHead — a live-polling MEMORY console
  pane (content·scope·origin·revision per chunk; count/total distinguish
  empty-store from scope-mismatch), polled on open + post-turn.

(C) Markdown pass-2 in markdownSafe: GFM pipe tables (mdTable, alignment
    colons), indentation-nested lists (child list inside the open <li>),
    ordered-list start=N numbering, streaming robustness (partial fence →
    code block; header-without-delimiter → paragraph until it streams in).
    esc-first → INV-004 held.

Contract web_debug_surface.contract.md amended in-commit (create_app
memory_read_url; dimetric-orbit + Δ-bar renderConsole POST-002; memory-viewer
+ markdown-pass-2 function contracts). 631 tests green; Playwright-verified
all render paths (dark+light).
2026-07-07 02:11:29 -07:00
vh fd6bed2d11 chore(design): vendor the transparent squirrel brand mark
The bg-removed source (ImageMagick corner floodfill) the inlined web favicon +
brand marks derive from. 1024², alpha-transparent; downscale for any reuse.
2026-07-07 00:37:36 -07:00
vh 459e7fa602 feat(web): SVG sparklines + 3D isometric mood cube (v0.20.4)
Import the updated Claude Design prototype's SVG sparkline system + 3D graph,
replacing the unicode-char sparklines:

- per-PAD-fader vertical SVG strips (stripPoints, 26x132 beside each bar — time
  down Y newest-at-bottom, value on X, gradient-faded, dot at newest); also
  answers the earlier "next to each meter" ask
- relation-row horizontal SVG sparklines (sparkPointsH, 56x13, auto-scaled,
  gradient + end dot) — fixed-width, so the old unicode overflow onto the n
  column can't recur
- mood-orbit reworked from a 2D P×A scatter into a 3D ISOMETRIC P×A×D cube
  (proj3: P right-down / A left-down / D up, 2:1 iso, scale 26, reverse-derived
  from the design's now-point + verified) with the trajectory, a pulsing
  now-marker, and a drop line + floor-shadow ellipse for depth
- gradients in one hidden <defs> svg; removed the orphaned sparkline()/_SPARK

Contract amended. Verified: pytest tests/test_web_* (84) + node Playwright
(injected 24-sample history — 3 PAD strips + 4 relation sparklines + the 3D
cube trajectory/drop/floor all render; gradients resolve).
2026-07-07 00:29:18 -07:00
vh 0b1d9e2b15 feat(web): context-injection panel — reconstruct the full hidden affect block (v0.20.2)
The affect console now reconstructs + displays the complete affect-context block
Worldtree assembles into the agent's system prompt — never on any wire, hidden from
regular consumers, surfaced here as the reference-impl's privileged dev view.

- extend build_persona_canon.py to emit mood_directive {occ_directives (15),
  pad_band_fallback, salience, pad_band_cutoff, full_only} into the browser canon
  (strings were already in the pinned d2-mood-render-canon; regen via Worldtree loader)
- canonPadFallback(pad) + canonEmotionDirective(type): byte-exact mirrors of Worldtree
  core/persona/renderer._pad_band_fallback + derive_directive
- renderDirective -> a "CONTEXT INJECTION · reconstructed · hidden from consumers" panel:
  mood descriptor [exact] + mood directive [candidate] + relationship directive [exact]
- honest-partial (affect-egress-reference sec 3): affect.emit is type-only (no
  intensity), so the salience gate can't be evaluated -> show BOTH the OCC emotion
  directive AND the PAD-band fallback with the "injected if intensity >= 0.2" caveat,
  never asserting which fires; fallback alone is exact when no dominant_emotion
- vendor + pin affect-egress-consumer-reference.md (tolerate_drift; worldtree-dev
  co-signs + pings on change). drift 6/6 green
- contract amended for the new reconstruction fns + honest-partial provenance

Verified: pytest tests/test_web_* (84) + node Playwright (sindra dominant_emotion=joy
-> joy OCC directive candidate + PAD-band fallback both render with exact/candidate tags).
2026-07-06 23:12:16 -07:00
vh 1fcb17730e feat(web): Claude Design console — 3-column wire monitor (v0.20.0)
Adapt the Claude Design "Ratatoskr Console" prototype into the web SPA:
translate out of the .dc.html dialect (x-dc / sc-if / sc-for / {{}} /
DCLogic / external _ds CSS) into single-file / no-CDN / vanilla, and wire
all real /api/* fetch + SSE into its DOM. New 3-column command-console
replaces the tabbed telemetry layout; endpoint set + SSE vocab unchanged.

- left engine-ticker rail: DEBUG + ADMIN + tool/turn-lifecycle merged into
  one timeline (tickerAdd); tools-armed chips; full-detail Bifrost rail pane
  (endpoint / connected / consumer / caps / tools)
- center conversation: per-turn INLINE chain-of-thought
- right resizable affect console: dominant / canonical-mood centerpiece;
  bipolar PAD faders EACH with a turn-to-turn delta + sparkline; P×A mood
  orbit; relations metric rows; canonical directive
- light / dark theme toggle (dark default; full token override —
  surfaces + fg + borders + accent-as-text)
- inlined data-URI favicon (downscaled 1024->64px), kills /favicon.ico 404
- ticker spine re-anchored to a content-height wrapper (was scrolling out of
  view on auto-scroll)
- honest-shape (INV-001): dominant-emotion shows a real OCC emotion (Tier-1)
  or the canonical mood word (Tier-3), never a fabricated one; affect-derived
  grid drops non-emitted metrics (intensity / decay-tau)

All server routes unchanged. web_debug_surface.contract.md amended for the
presenter renames (renderBifrostState->renderBifrost, renderAffectPane->
renderConsole, setPersonaStrip removed).

Verified: pytest tests/test_web_* (84 passed) + node Playwright end-to-end
against personal :8081 (session open, Sindra seeded greeting, live turn SSE,
affect console + relations + bifrost detail, theme toggle, PAD deltas,
no favicon 404).
2026-07-06 21:50:22 -07:00
vh 263ec2917b fix: render the seeded first-message on the web UI (v0.19.9)
The #347 auto-seed worked (the greeting was in the session ledger at seq-0),
but the web UI never showed it: there was no GET /api/sessions/{id}/messages
route and startSession() went straight from create to persona/tools/admin
hydration, so the transcript only filled from the live turn stream + user
echoes — a seeded turn-0 was invisible.

- server: new proxy route GET /api/sessions/{id}/messages -> get_session_messages
  (mirrors the tools/bifrost proxies; status-preserving envelope).
- SPA: loadTranscript(sessionId) fetches it on open and renders existing turns
  (assistant -> .response .md-body via markdownSafe escape-first; user ->
  .prompt-echo via textContent), called after the workspace opens. Best-effort.

web_debug_surface contract amended (endpoint + loadTranscript). 2 web route
tests, suite 617 green. Playwright DOM check proved the render end-to-end
(drive the real UI -> Sindra's greeting bubble appears).
2026-07-06 15:19:56 -07:00