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).
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.
1.1.2 is the same-day fix for the residual we caught in 1.1.1: the inline
turn-stream URL construction (turn_stream.py:185) now uses
quote(session_id, safe="") — the _build_path fix had missed that parallel
site. Verified present (turn_stream.py:192). Built byte-exact from
python-v1.1.2 (4f5e30f), infra-ops acceptance-verified; provenance
otherwise unchanged from 1.1.1 (openapi 2.3.0, same frozen wire), drop-in.
Suite 494 green — carried over from 1.1.1 unchanged. Release-only cadence:
no tag. Supersedes d3a36f7's known-residual note (now closed upstream).
worldtree-sdk 1.1.1 fixes the _build_path encoder (quote safe="" — a
caller-shaped `/`/`../` in a path param no longer traverses the URL).
Our 1.0.0 carried the bug and we DO pass caller-shaped params (web
hands request.path_params session_id/agent_id to the SDK; tier3 CLI
takes agent_id as an operator arg) — auth-rejected, but a real client-
correctness bug. Same frozen wire as 1.0.0 (drop-in); suite 494 green.
Also brings additive define_or_reuse + set_persona_state_raw (our
parity #6) + a 64 KiB error-body alloc cap.
KNOWN RESIDUAL (flagged to wtsdk-dev): 1.1.1's fix patched _build_path
(request.py) but MISSED the parallel inline URL construction in
turn_stream.py:185 (`/sessions/{quote(session_id)}/messages`, still bare
quote, default safe="/"). No upstream session_id format validation (only
a non-empty-str type check at :153). So our highest-traffic path — the
SSE turn stream, which the web console feeds a caller-shaped session_id —
remains traversable until wtsdk patches it. Release-only cadence: no tag.
Adopt bifrost 1.1.5 (reference-impl posture — take the dep's canonical
hardening). 1.1.5 is a clean patch on the frozen v0.6 wire (no schema /
handshake change):
- fix: gate ALL optional store verbs → clean `unsupported_capability`
(not 500), extending 1.1.4's maintenance-verb hasattr-backstop to the
full optional-verb set. Directly improves our provider's error
semantics on any unimplemented optional verb.
- feat: v0.6 memory verb-floor conformance harness (test infra).
- two docs clarifications (no runtime impact).
Verified: uv lock + sync resolved 1.1.5 from the gitea index; suite
494 green on 1.1.5; the :8392 combined provider restarted onto 1.1.5
(clean uvicorn boot, stores preserved). Patch bump v0.22.0→v0.22.1.
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.
Cold spec-free panel (Gróa + Hulda + Regin, source-verified by Heid): the adapter's
core re-wrap is sound, but 4 real hardening gaps the conformance CR couldn't see —
all in failure-path normalization + open-world degrade, judged against the general
ConnectFailed floor + the degrade-never-crash promise. All fixed:
- [bug, 3/3] `stream_admin_events` never mapped `ConnectFailed` — the SDK admin-stream
open raises it on a connect-time / auth-resolution failure (the general transport
floor; confirmed in the SDK source), and `stream_turn` + the bifrost GET both catch
it, and this endpoint's OWN comment claimed it did. An unmapped ConnectFailed escaped
the web gen's `except (Sse*)` and aborted the SSE with no `stream_error`. Now mapped
→ `SseConnectFailed`, mirroring stream_turn.
- [bug, 2/3] non-str `type` crashed the web filter — the re-wrap used `ev.type or ""`
(falsy-only), so a truthy non-str `type` (123, a list) reached `.startswith` →
AttributeError. Now `ev.type if isinstance(ev.type, str) else ""` (matches the
admin_id/data isinstance guards — same container-type class as slice-5).
- [robustness] `_session_bifrost_endpoint` did `dict(bstate)` on the open-world 200
body — a non-mapping (list/scalar) → TypeError/500. Now degrades to `{}` (I introduced
this in slice-6 by changing `JSONResponse(bstate)` → `dict(bstate)`).
- [robustness] `_admin_events_endpoint.gen` allocated the transport + built `_wt_client`
BEFORE the try/finally — a construction failure would leak the httpx transport. Moved
`_wt_client` inside the try so the finally always closes it.
Voided (Heid): Regin's `dict(ev.data)` TypeError — the `isinstance(_, Mapping)` guard
already routes non-mappings to `{}` before `dict()`.
Added adapter tests (ConnectFailed→SseConnectFailed; non-str type→"") + a web test
(non-mapping bifrost body → 200 {}). Suite 494 green; my code ruff-clean (13 E501/F841
in test_web_server.py are PRE-EXISTING, HEAD-identical, untouched); mypy clean on wt.py.
Live smoke re-run clean (real session.created event re-wrapped; bifrost 404 envelope).
Patch bump 0.21.19 → 0.21.20.
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).
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.
Panel: Gróa + Regin returned zero (adapter/route-map/error-map faithful);
Hulda flagged two source-confirmed open-world-presenter crash holes — the same
class the slice-4 bug-hunt found in the agents presenters. Both fixed:
- `_format_whoami` scopes (`cli.py`): `', '.join(me.get('scopes', []))` crashes on
a present-null `scopes` (`.get(k, [])` returns None, not the default) or a
non-string element. Now `', '.join(str(s) for s in (me.get('scopes') or []))` —
matching the `allowed_roles` hardening on the same function. The contract names
`_format_whoami` as the degrade-not-crash exemplar (contract:144-146); the cited
exemplar had an un-hardened line.
- `_characters_probe` model items (`cli.py`): the slice-5 `or []` guarded the
list-level null but not each entry — `[None]` / `["x"]` / `[{"name":123}]` would
raise. Now guards each item is a dict and str-coerces `name` (element-level
completion of the list-level guard).
Hulda #3 (live-smoke not in the reviewed file set) → accept: the smoke WAS run and
is recorded in deab762 + coverage-map (artifact-only review couldn't see it).
Added CLI tests for both hardened paths (present-null/non-string scopes; malformed
model items). Suite 485 green; ruff clean; live smoke re-run clean (identical
happy-path output). Patch bump 0.21.16 → 0.21.17.
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).
Panel (Gróa+Hulda+Regin, 5/5/5, no false positives) confirmed two 3/3 crash
sites where open-world dict reads violate the declared "degrade, never crash the
presenter" invariant — the wt adapter tests + the live smoke used full server
dicts, so partial/drifted wire responses were never exercised:
- FIX (tier3.py _run_define/_run_patch): the CLI hard-indexed the open-world
define/patch dicts (`info["agent_id"]` / `["role"]` / `["agent_name"]`), so a
partial 2xx → KeyError escaping main()'s exit matrix as a raw traceback (exit 1);
and `make_description(info.get("system_prompt", ""))` fed None to .splitlines()
on a present-but-null field → AttributeError. Now reads via `_str_field` (absent/
null/non-str → default), degrades role to '?', indexes only a well-formed identity,
and maps a no-usable-agent_id 2xx to [api_failed] exit 20 (controlled, not a crash).
- FIX (web/server.py _agents_endpoint): the upstream dedup hard-indexed each item
(`{a["agent_id"] for a in upstream}` + `_as_dict`), so a malformed item (`[{}]`,
`["str"]`, `{"name":…}`, non-str agent_id) or a non-list envelope → 500 before the
local fallback merged. Now filters to well-formed mappings first; a non-list
upstream degrades to the local-only list.
- FIX (wt.py _error_field_from_body): type-check the parsed `field` is a str (the
exception surface is `field: str | None`, the CLI prints it) — restores the retired
hand-rolled `_extract_error_field` isinstance guard.
Held (triaged, no change): the 429→Tier3QuotaExceeded / bare-404→Tier3AgentNotFound
maps are ungated-by-error_code BY CONTRACT DESIGN (§ Error map route+status rows; the
SDK's ApiError floor drops Retry-After, so retry_after=0 is canonical) — the arms
flagged them spec-free; Heid's source-check confirmed intended. Dual-keying define's
429 for full row consistency is an available tightening (contract amendment), surfaced
not applied. The persona-endpoint SessionApiFailed gap the arms also caught was
already closed in the prior code-review fixup (aed9429).
Suite 475 green (+5).
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).
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.
The slice-3 heid-bug-hunt panel (3/3) caught a real regression the cutover
introduced, plus a chokepoint-invariant gap:
- ConnectFailed escaped both rewired CLI probes. When --set-persona-pad and
--seed-first-message moved off raw httpx onto the wt adapter, transport failures
changed class: the SDK normalizes any pre-response transport error to
worldtree_sdk.ConnectFailed (request.py), a WorldtreeError (not ApiError), so it
passed the adapter unmapped AND the probes' httpx-only except tuples → an uncaught
traceback instead of the graceful [network_error] exit 21. _amain (slice-2) already
handled it; the probes lagged. Fix: add ConnectFailed to both probe except tuples
(mirrors _amain). Live-verified at a refused host → [network_error] exit 21.
- Finite-PAD enforced only at the CLI, not the adapter chokepoint. wt.set_persona_state
delegated finiteness to the caller (documented), so a direct/non-CLI caller passing
nan/inf got a raw SDK ConfigurationError. Fix: assert finiteness in the adapter
precondition (consistent with its other precondition asserts) so the invariant holds
at the chokepoint in ratatoskr's own terms; the CLI pre-check stays for the friendly
usage error.
Triaged-and-declined (all correct per the panel + Heid's source-check): the deleted
sessions.py exports (intended no-shim cutover, zero un-migrated importers), the
session["session_id"] index (accept-known-risk, matches --new), and Regin's "web
indefinite block" (refuted — the seed is asyncio.wait_for-bounded). The concurrent
heid-code-review panel returned zero drift, no code change.
TDD: 3 RED tests (both probes' ConnectFailed → exit 21; adapter nan/inf/-inf →
AssertionError, never reaches the SDK) → GREEN. Suite 469; ruff clean; mypy no new errors.
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.
Triaged the heid-bug-hunt panel (Gróa 8 / Hulda 6 / Regin 6; Heid source-checked +
refuted 2 Regin FPs). The lens pulled real weight — confirmed bugs the conformance
review structurally could not see.
Confirmed bugs fixed:
- SessionRetired (410) stream-open maps to wt.SessionApiFailed, but neither cli
_run_turn nor web gen() caught it → crash / dropped SSE stream. Both presenters now
catch it (cli → exit 20; web → labeled `event: error`). (Gróa#2) + cli regression test.
- cli forwarded consumer_key unconditionally; an UNBOUND create with the env key set
would auth as the Bifrost consumer, not the default bearer. Guarded in the adapter
(consumer_key only when bifrost is set). (Gróa#4 + Regin#4) + test.
- cli _turn_id_from_sse_id crashed on a None/non-str sse_id (web guarded, cli didn't)
→ now tolerant. (Gróa#1 + Hulda#2) + test.
- _cancel_and_log broadened to `except Exception` — after the code-review's ApiError
default, a cancel could raise SessionApiFailed it didn't catch, breaking INV-009
(never-raise). (Gróa#3, Heid-endorsed over Regin's refuted mechanism).
Open-world degrade-not-crash (contract posture): render hardened — float duration_ms
(_format_duration_safe), non-mapping usage/snapshot guards, unknown event type
degrades instead of asserting (Gróa#5/#6 + Hulda#3); web _event_to_browser_payload
guards a non-mapping `raw` (Hulda#4); web _wt_client bearer extraction is now
case-insensitive + whitespace-robust (Hulda#5 + Regin#5). + render-degrade test.
Rejected (verified): Regin#1 (httpx IS caught), Regin#2 (wtsdk IS worldtree_sdk),
Regin#3 (sse_client.AgentNotAvailable IS caught by SseConnectFailed) — all FPs;
Hulda#1 (deleted funcs "break callers") — grep-verified zero callers pre-deletion.
Accepted-known-risk: lenient sse_id parse, CancelFailed status=0, async-gen aclose
(pre-existing pattern, not a cutover regression).
Suite 497 green; wt/cli/web ruff + wt mypy clean. Patch.
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.
DEC-4 live smoke PASSED first (personal :8081, b127/b128): create → streamed turn
that rendered (worker_phase/text/text_boundary/done with usage) → SIGINT cancel that
round-tripped to a cancelled terminal. With both CLI + web on the adapter, the
hand-rolled turn-stream family is fully orphaned — deleting it now.
- sse_client.py (714 → 224): removed stream_turn / reconnect_turn /
stream_turn_resilient / cancel_turn + the Event dataclasses (Text/Done/…/Event
union) + CancelResult + the SSE parse helpers (_iter_events / _envelope_for_type /
_parse_sse_id / _eager_failure_fields / _INT_RE). KEPT: the caller-semantic
exceptions (the adapter raises them, DEC-2), SseId, AdminEvent, stream_admin_events
(slice-6 admin surface).
- sessions.py (677 → 608): removed list_sessions + get_session_tools (no surface
users) + SessionPage. KEPT: create_session / get_session_messages (the
--seed-first-message probe still uses them, slice-3) + all exceptions + SessionInfo.
- tests: test_sse_client pruned to TestStreamAdminEvents; test_sessions dropped the
list_sessions + get_session_tools classes. The deleted turn-stream behavior is now
covered by test_wt.py + the CLI/web integration tests + the live smoke.
Suite 490 green (570 − 80 deleted turn-stream tests); ruff clean on all touched
files; no new mypy errors. Patch (internal cleanup; behavior preserved).
The Starlette endpoints (create / stream / cancel / tools / messages) now go through
ratatoskr.wt over the worldtree-sdk; the browser contract is preserved. This is the
last consumer of the hand-rolled turn-stream family — after this, stream_turn* /
cancel_turn are orphaned and get deleted in part 2b-iii (with the live smoke).
- _wt_client wraps a client_factory transport as the adapter's WorldtreeClient
(INV-CUT-1), reading base_url + bearer off the transport (a no-auth test transport
falls back to a placeholder key). The hand-rolled endpoints (persona / agents /
admin / bifrost) keep using the raw transport until their slices.
- _event_to_browser_payload derives the browser payload from the SDK's `raw` (the
wire body) minus the redundant `type`, plus the composite `sse_id` string — the
SAME shape the old dataclasses produced, so the presentation fixture + browser JS
are unchanged; the browser event_type is the wire `type`, not the SDK class name.
- The stream endpoint captures the upstream cancel target from the composite sse_id
(the SDK's top-level turn_id is body-derived, absent on text frames); create reads
the SDK's open create dict; cancel reads CancelResult.cancelled and surfaces a
generic 502 for CancelFailed (the SDK abstracts the upstream cancel HTTP status).
- test_web_presentation_contract builds SDK events via build_event; two cancel tests
adopt the SDK's (status, error_code) race pairs + the 502.
Suite 570 green; web/server.py + presentation test ruff-clean, mypy unchanged
(same pre-existing errors). Patch (internal; browser contract preserved).
The --send turn path (_amain create + _run_turn stream + _cancel_and_log) now goes
through ratatoskr.wt over the worldtree-sdk; external CLI behavior (output, exit
codes) is preserved. No hand-rolled path is deleted yet — web/server.py still uses
them (part 2b-ii), so the deletions + live smoke come after web is rewired.
- _amain builds one WorldtreeClient via wt.build_client over a ratatoskr-owned
transport (INV-CUT-1); create → wt.create_session (reads the SDK's open create
dict); the transport keeps the default bearer so the not-yet-migrated hand-rolled
seed_preset_first_message (slice-3) still authenticates.
- _run_turn drives wt.stream_turn and consumes SDK TurnEvents; the mid-stream cancel
target is parsed from the composite sse_id ("{turn}:{seq}") — the SDK's top-level
turn_id is the body field and is absent on text/thinking frames.
- CliPresenterState.render consumes the SDK TurnEvent union with None-hardening on
the now-optional fields (usage degrades to "(n/a)" rather than crashing).
- The SDK normalizes a pre-response transport failure to ConnectFailed(status=0);
_amain (network → exit 21) and _cancel_and_log (swallow, INV-009) catch it.
- build_client gains max_reconnects (SDK default 5; tests pass 0 to surface drops
immediately). test_cli: SDK-event factories keep the render-test bodies intact;
client constructions wrap in build_client; cancel-race mocks carry the SDK's
(status, error_code) pair.
Suite 570 green; cli.py + wt.py mypy + ruff clean (the pre-existing send_content
arg-type note is unchanged). Patch (internal; external CLI behavior preserved).
Completes the adapter's session/turn surface, still additive and non-breaking (no
surface rewired, no hand-rolled path deleted — the cli/web rewire + deletions +
live smoke are part 2b).
- stream_turn: drives the SDK's resilient stream (auto-resume absorbs the old
reconnect_turn) and yields SDK TurnEvents, re-wrapping the stream's TERMINAL SDK
errors into ratatoskr's caller-semantic exceptions per DEC-2 (SessionRetired →
SessionApiFailed; AgentNotAvailable / TurnLaunchUnavailable / MalformedSse* /
TurnIdFlip → ratatoskr's same-named types; ConnectionDropped → SseConnectionDropped;
ConnectFailed / terminal ResumeError → SseConnectFailed). The presenter keeps
catching ratatoskr types (part 2b aligns the except clauses).
- cancel_turn: returns the SDK CancelResult (a 200 cancelled=False is the benign
late-cancel race, B-CAN-3), mapping the typed cancel races onto ratatoskr's
CancelTurnNotFound / CancelAlreadyCompleted / CancelFailed.
- SseConnectionDropped.last_seen_sse_id widened to SseId | str | None: the SDK's
resume cursor is a raw composite-id str (the cutover's target form); the
hand-rolled path's SseId stays accepted until it is deleted. The one live reader
(stream_turn_resilient) generalizes cleanly — a str cursor is already the id.
Suite 570 green (555 + 15); wt.py + sse_client.py mypy + ruff clean. Patch.
First slice-2 increment: the presenter-independent sessions routes, additive and
non-breaking (no surface rewired, no hand-rolled path deleted yet — the cli/web
rewire + deletions + live smoke land in part 2).
- create_session / list_sessions / get_session_messages / get_session_tools over
WorldtreeClient.sessions.*, each building the request from ratatoskr's domain
params and mapping the SDK's ApiError floor by ROUTE (INV-CUT-2): create 404 →
AgentNotFound, bound 502 → BifrostHandshakeFailed, list 422 cursor_invalid →
InvalidCursor, else the SessionApiFailed default.
- Open-world reads returned VERBATIM (parity-pass posture): the routes return the
SDK's open dicts, not ratatoskr's typed SessionInfo/SessionPage — those typed
result shapes retire when the presenters are rewired to read mappings (adopt the
dep's canonical open-world way, reference-impl doctrine).
- Transitional: wt imports the caller-semantic exceptions + BifrostBinding from the
retiring sessions module (one-way, no cycle); they relocate into the adapter as
their call-sites are rewired.
- Cancel + the resilient turn STREAM are deferred to part 2, where they wire into
the async presenter loop and are validated by the live smoke.
Suite 555 green (541 + 14); mypy strict + ruff clean. Patch (internal, additive).
Slice-1 of the SDK cutover (docs/contracts/worldtree_sdk_cutover.contract.md):
the adapter chokepoint onto worldtree-sdk 1.0.0, unit-tested but not yet wired
to any surface (that is slice-2).
- build_client(base_url, *, api_key, admin_key=None, transport) constructs the
single WorldtreeClient over a ratatoskr-owned injected httpx.AsyncClient.
INV-CUT-1: the SDK is given the transport (_owns_client=False) and never closes
it — proven by a test asserting aclose() leaves ratatoskr's transport open.
- translate_error implements the § Error map DEFAULT: SDK ApiError → the adapter's
SessionApiFailed (carrying the SDK's parsed status/error_code/body); every
discriminated WorldtreeError subclass passes through by identity. Route-specific
rows land at their call-sites in later slices (the route is the discriminator).
- SessionApiFailed gains error_code vs the retiring sessions.py copy (extends it
per the contract error-map row); the two coexist transiently and reconcile in
slice-2 (DEC-4 incremental cutover — nothing wires the adapter this slice, so
they never meet at runtime).
Deletes no hand-rolled path, so DEC-4's live-smoke bar does not apply yet.
Suite 541 green (534 + 7 new); mypy + ruff clean. Patch (internal foundation;
the cutover's minor bump is DEC-6 at slice-7 ship).
Add the worldtree-sdk (Python) 1.0.0 consumer client to core deps + the gitea uv
source (reuses the existing bifrost gitea-index auth). DEC-5 precondition met: uv
resolves + installs it from the registry; WorldtreeClient constructs with an
injected httpx.AsyncClient and _owns_client=False (INV-CUT-1 transport ownership
confirmed live). httpx-sse retained until slice-7 teardown. Full suite 534 green —
the dep is inert until ratatoskr.wt wires it (next).
No version bump (dependency add, no runtime code consumes it yet). Refs #20.
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.
Live Worldtree b125 changed POST /agents/define: the request field is
now 'role' (a model-role like 'thoughtful-character'), replacing 'model';
the response still echoes it as 'model'. Update define_agent/patch_agent
request bodies + CLI (--model -> --role); response parse + LocalAgentEntry
unchanged. Verified end-to-end against live (delete->define round-trip);
26 tier3 tests green. Full b22->b125 spec-pin bump remains a follow-up.
The textual TUI (tui.py) is superseded by the web console (ratatoskr-web)
and is removed per the no-backwards-compat rule. The `ratatoskr` command
stays as a headless client: --send / --whoami / --characters /
--set-persona-pad / --seed-first-message still work; invoking it with no
--send now returns a usage error (rc 10) instead of launching the TUI.
Removed: src/ratatoskr/tui.py, tests/test_tui.py, the textual + textual-dev
deps, and cli.py's run_tui launch path. cli.py's shared exports (USER_AGENT,
ParsedArgs, formatters) stay — web/entrypoint.py and tier3.py depend on them.
BREAKING CHANGE: the interactive `ratatoskr --agent X` TUI is gone; use the
web console (ratatoskr-web) for interactive debugging, or --send for scripted.
Verified: full suite 520 passed; ratatoskr --help exit 0; no-send -> rc 10;
web/provider/tier3 import clean; textual absent from the lockfile.
Each debug pane (#rail-left, #affect-console) gains an independent
collapse control: a chevron button in the pane header folds it to a
22px re-open strip, and #center (flex:1) reflows to fill. State
persists per-pane in localStorage (ratatoskr-left-collapsed /
-right-collapsed), matching the theme/cot-toggle idiom; the right-side
drag-resizer hides with its pane. Collapsing both yields a clean
chat-only surface.
Playwright-verified end-to-end (default-open, collapse-to-strip,
center reflow, independent left/right, reload persistence, re-open).
Operator UX asks on the web console:
- Code blocks now WRAP (`white-space: pre-wrap; overflow-wrap: anywhere`) instead of
overflowing with a horizontal scrollbar — a long unbreakable token wraps in place.
- Per-code-block copy button (hover-revealed, top-right of each `.md-code-wrap`).
- Per-turn copy button (in the live turn-rule; top-right on seeded/historical turns) —
copies the turn's response text; the existing think-inline copy is unchanged.
One delegated click handler on #tw covers both (works across live / historical / think
renders where blocks are injected via innerHTML). `copyText` falls back to a hidden
textarea + execCommand when `navigator.clipboard` is absent (plain-http LAN context),
and buttons flash "✓ copied". Static file served from disk — a browser refresh picks it
up, no :8765 restart. Playwright-verified: code wraps (no x-overflow), both buttons copy
the right text; 90 web tests green.
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.
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.
heid-bug-hunt panel (Gróa + Hulda, confirmed-from-code) caught that a truthy
non-dict `sort` (e.g. sort="updated_at" or sort=["updated_at"]) reached
`(sort or {}).get(...)` and crashed with AttributeError instead of the
InvalidArguments PRE-003 promises for malformed caller-controlled input. Add an
isinstance guard before field extraction. Test covers str/list/int sort values.
Replace the terse three-question setup preset with the immersive holodesk
scene-open (Sindra already present, sets the same three settings — intensity /
mood / willingness — in character). Matches the persona shipped in the
soong-lab Sindra redefine. Stays under the 8192-byte authored_content budget;
test_first_message green.
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.
bifrost-dev heads-up (althing 01KX6S4RFH…): 1.1.0's describe_store leaked
the v0.7-additive `sortable_chunk_fields` into a v0.6-negotiated
StoreCapabilities response, so a strict v0.6 client (additionalProperties:
false) rejects our :8392 server's handshake. This is the library-level fix
for the regression snapshotted in 8468c47 (worldtree-dev worked around it
by advancing their client to wire-v0.7 / b47; our served handshake was
still latently v0.6-incorrect for any v0.6 peer).
1.1.1 gates additive capability fields on the NEGOTIATED wire (ADR-0008),
so a v0.6 handshake stays v0.6-clean. Wire schemas + pins UNCHANGED —
serialization-correctness only; our v0.7 handshake with Worldtree b47 is
unaffected. Zero code change (same shape as the 1.1.0 repin). Reference-
impl adopt-canonical: dependency owner recommends the repin, we adopt.
Suite 631 green on 1.1.1 (clean env). Provider restart + live WT-bound
re-verify pending — non-urgent (no v0.6 peer currently handshakes us).
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.
Additive over the frozen v0.6 (adds memory.scan sort directive +
describe_store sortable_chunk_fields; build_combined_app surface
unchanged). v0.6 stays frozen + valid; we don't use scan-sort (a gated
verb we advertise-unsupported), so this is reference-impl currency, not a
needed feature. Verified: full suite 631 green against 1.1.0, including
the #195 real-lib parity + combined-app build/dispatch tests. Repin +
uv lock only; zero code change. Reported by bifrost-dev via althing.
Markdown rework, pass 1 (operator-directed):
- "quoted" dialogue → speech (bright --md-speech), *asterisk* → action/narration
(muted-italic --md-action). Two tunable CSS vars that cascade through --fg-* so
they auto-adapt to the light theme; plain text stays default narration.
- speech-wrap runs BEFORE the em/link passes so a generated class="…"/href="…"
quote can't be mis-read as dialogue; straight + smart quotes; apostrophes are
safe; unbalanced/half-streamed quotes stay uncolored until they close.
- fix the ugliest existing bug: single newlines were hard <br>s → now CommonMark
soft-breaks (space); a hard break needs 2+ trailing spaces or a trailing \.
- INV-004 escape-first preserved.
Verified: pytest tests/test_web_* (84) + a 10-case Playwright unit-check of
markdownSafe (speech / action / attribute-quote trap / apostrophes / mixed /
reflow / hard-break / html-escaping / unbalanced / paragraphs) + a visual render.
Native title hints on all 6 affect-derived cells (samples / updated / baseline
P·A / drift Δv / volatility) — each explains what it measures and its Tier-1 vs
Tier-3 availability. Completes the mouseover-hint pass (PAD faders + relation
rows already had them).
Verified: pytest tests/test_web_* (84) + node Playwright (all 6 cells titled).
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).
- remove the black background from the operator's squirrel art (ImageMagick corner
flood-fill, fuzz 20% — keeps interior linework + glow, drops connected background),
downscale 1024->80px + quantize, inline as one SQUIRREL data-URI const
- wire it to the favicon <link> href + both brand marks (rail brand-row + setup h1);
.brand-glyph (font rune) -> .brand-mark (img, drop-shadow glow + breathe)
Also banks worldtree-dev's exact 3-gate we-framing rule (b24) in persistent-memory —
canonDirective held as-is until it deploys on personal (would otherwise drift from the
currently-deployed renderer).
Verified: pytest tests/test_web_* (84) + node Playwright (favicon + both brand marks
decode from the data-URI; no leftover rune).
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).
- cap the unicode sparkline (relations last-8, faders last-7) + overflow:hidden
so it can't grow one-char-per-sample and paint over the evidence-count (n)
column — the "n sitting behind the graph" bug
- add native title mouseover hints on all 3 PAD faders (P/A/D meaning + range)
and every relationship metric row (warmth / trust·ability / benevolence /
integrity / agency)
- state.lastSnap lets the console re-render without a refetch
Verified: pytest tests/test_web_* (84) + node Playwright injected a 24-sample
history (sparkline->n bounding-box overlap = 0; sparklines capped; tooltips present).
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).
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).
Codifies 'give an agent a first message' (Worldtree #347): new module
ratatoskr.first_message (FIRST_MESSAGE_PRESETS + seed_preset_first_message)
seeds a preset agent's opening as a #347 authored turn-0 on every new session,
wired into all three create paths — cli._amain (--send --new), tui._resolve_then_run
(bare --new), web._create_session_endpoint (POST /api/sessions).
seed_preset_first_message is strictly best-effort (INV-001): it soft-guards its
inputs (return None, never assert), bounds the write with asyncio.wait_for so a
stalled /history can't block create (the CLI/TUI clients disable read timeout for
SSE), and swallows every exception except asyncio.CancelledError (which
propagates) — so it can NEVER raise into or block the session-create path it is
wired into. Per-content idempotency key → idempotent replay, no dup.
Seeded with ratatoskr:sindra, whose opening greeting moved out of her card:
her live system_prompt was PATCHed (non-destructive) to drop the Startup
workaround the #347 first-message now replaces.
Quality gate (both cross-frontier panels): heid-code-review returned zero
implementation drift (2 test-only fixups applied); heid-bug-hunt caught the
gap the conformance lens can't see — code matched the contract's narrow
ERROR_ROUTING but INV-001's 'never raises' is broader — driving the broad-except
+ soft-guard + wait_for hardening above.
Contract docs/contracts/first_message.contract.md (module-scoped, validated).
TDD: 12 unit + 1 web wire-in; the 3 existing sindra bind tests gained a
history-endpoint mock (creating a preset agent now auto-seeds). Suite 615 green,
ruff+mypy clean. Auto-seed live-proven generation-free against personal :8081.
worldtree-dev landed the Tier-3 persona/memory/persona_state prose docs
(c9e59ec) — shapes that serialize as freeform Any in the OpenAPI, so the
prose markdown is their source of truth. Re-vendored docs/conversation-api-spec.md
(tolerate_drift markdown pin; worldtree-spec-rev 879cefe→c9e59ec).
Consumer alignment: --set-persona-pad / _set_persona_probe was building
{pad:[list]}, but the canonical POST /sessions/{id}/persona_state body (#317)
is {pad:{pleasure,arousal,dominance}} (named dict). Aligned the probe to the
named dict + a len!=3 guard; updated contract #2's note, the set_persona_state
docstring, and the tests. The set_persona_state wrapper was already correct
(freeform pass-through) — only the CLI probe's body construction drifted.
Suite 602 green. (Also this session: heid-code-review on the #347 slice
returned unanimous zero drift across all three panel arms.)