Cross-frontier panel (Gróa/Hulda/Regin) on the v0.19.2 web surface, triaged:
- FIX (Gróa #1, drift): the turn EventSource `onerror` (raw transport drop)
now calls hideThinkingNote() — a drop mid-reasoning no longer leaves the
"<Agent> is pondering…" line + its setInterval running (INV-LIFECYCLE).
- FIX (Gróa #4 + Hulda #1, convergent drift): openAdminEvents now closes the
EventSource + clears state.adminES on `stream_error` (server signalled end)
and on a PERMANENT onerror (readyState CLOSED) — native EventSource no longer
auto-reconnects into a retry loop; transient CONNECTING drops still reconnect.
- TEST (Gróa #2 + Hulda #3): test_routes_registered asserts the 3 new routes;
test_state_attached asserts app.state.admin_key (create_app POST-001/002).
- TEST (Gróa #3 + Regin #3): AdminEvents stream_error-on-connect-failure test —
upstream non-200 -> exactly one `stream_error` frame, then ends (POST-003).
- CONTRACT (Hulda #2 + Regin #2, accepted): clarified the Tools inventory
renders NAMES only by design (descriptions live in the BifrostState pane);
code unchanged. Also lands the web_debug_surface contract as the trail.
Accepted-no-op: 403-bifrost / non-404-tools tests (identical code path to the
tested 404). Panel found ZERO functional server-side drift; INV-004 escaping
confirmed clean across the new panes. 60 web tests pass; JS + ruff clean.
Bring the browser surface to TUI parity as the primary debug surface:
- Tools inventory (GET /sessions/{id}/tools) folded into the tools pane —
what the LLM has at turn-fire, above the live tool events.
- BifrostState pane (GET /admin/sessions/{id}/bifrost) — admin-scoped
dispatch state; the admin key stays server-side (app.state.admin_key),
never reaches the browser (INV-003 precedent).
- AdminEvents pane (GET /admin/events SSE) — admin lifecycle, session-
filtered SERVER-side (heartbeats + other-session events dropped); one
fixed "admin_event" browser event so every type renders (no drops).
- PAD refresh: poll a window (1.5/3.5/6.5/10.5s) instead of a single 2s
shot that raced the post-turn-async affect.emit (issue #18 foot-gun).
- Reasoning indicator: ephemeral "<Agent> is pondering…" in the transcript
on `thinking` deltas, cleared when text begins — clearly non-engine.
Admin key wired through entrypoint -> create_app. 9 new respx/route tests
(admin-bearer override, filter unit, SSE stream-filter); 59 web tests pass.
Live-proven against ratatoskr:sindra (bifrost connected, both caps; 253
thinking events -> indicator fires; affect emit lands -> PAD poll catches it).
Milestone minor (operator-approved). Publishes the design-brief's
headline deliverable: the multi-pane debug-observability dashboard is
complete — all four observability panes are built and consuming their
real Worldtree endpoints:
- Persona → GET /agents/{id}/persona_state
- Tools → GET /sessions/{id}/tools
- BifrostState → GET /admin/sessions/{id}/bifrost
- AdminEvents → GET /admin/events (SSE)
v1 client-REST coverage is 12/40; both non-REST surfaces (SSE 11/11,
Bifrost provider planes 8/8) already complete. Only Tier-2 client I/O
(transient-characters routing, persona_state-write) remains in scope;
everything else is covered or excluded-by-design in docs/coverage-map.md.
Version bump only (the feature arc landed across v0.18.5–v0.18.11).
v1 coverage-audit slice b2. The audit found list_sessions had no
caller — the startup session picker (design-brief §4) was never built;
bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors
AgentPickerApp) and resolve bare mode in _resolve_then_run.
- Bare TUI mode (no --session/--new) now valid → session picker.
Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per
§4 "no in-app creation, --new only"); exactly 1 -> auto-resume
(§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick
(Esc/Ctrl-D -> exit 0).
- cli._parse: bare TUI valid; --send still requires one flag; --agent
forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive.
- Contract #6 amended (SessionPickerApp + bare-mode resolution) +
validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli
validation tests. Suite 528 green; touched code ruff-clean.
Design note: bare + 0 sessions errors (honors §4's no-in-app-creation
clause); the friendlier auto-fall-through-to-new is deferred pending
operator preference.
Complete b1's design-brief §8b promise ("all presenters share the
consumer"): the TUI and web SSE consumers now resume transparently on
a mid-stream drop, same as cli --send (v0.18.5). The TUI is the primary
beneficiary — long-lived dev sessions across laptop suspend.
Name-for-name swap of stream_turn -> stream_turn_resilient at the two
remaining consumer loops (tui.py:1321, web/server.py:294) + their
imports. No behavioral change on the happy path (resilient == stream
when there is no drop); suite 518 green; touched lines ruff+mypy clean
(pre-existing tui/web lint debt left untouched per surgical rule).
v1 coverage-audit slice b1. The audit found reconnect_turn had no
caller — every presenter dropped the stream on disconnect instead of
resuming, leaving the "reference SSE-resume implementation" (design-
brief §3/§8d) unreachable. Add stream_turn_resilient as the single
shared resume surface (design-brief §8b "share the consumer, branch
the presenter") and route cli --send through it.
- stream_turn_resilient wraps stream_turn + reconnect_turn: on
SseConnectionDropped (mid-stream drop or clean EOF before terminal),
resume from the last-seen sse_id via reconnect_turn (Last-Event-ID),
up to max_reconnects (default 5). last_seen persists across attempts.
- Non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed)
propagate unchanged, per contract #1's "surface, not recover".
- cli.py: --send consumer now drives stream_turn_resilient (transparent
reconnect). tui/web still consume bare stream_turn (follow-up).
- Contract #1 amended (FN stream_turn_resilient) + validated; 8 TDD
cases (happy, resume-after-1/2-drops, clean-EOF resume, unresumable
zero-event, max-reconnects-exhausted, zero-budget, buffer-expired-
propagates). Suite 518 green; ruff + mypy clean on touched code.
worldtree-dev finalized the #331 503 turn-launch error_code as `not_ready`
(re-pinned from internal_error; retryable, matching the /readyz 503 sense)
and re-froze the OpenAPI at 2.2.0 documenting the 409/503 statuses our
v0.18.3 mapping already handles. Tighten our fallback default from the
placeholder `turn_launch_unavailable` to the canonical `not_ready` (the
default only fires when the body omits error_code — the real code is
surfaced verbatim regardless). +1 test, suite 510 green.
NOTE: a full conversation-api-spec.md re-vendor to the b2 era is a separate,
larger pin-refresh (ratatoskr vendors the markdown spec, not the OpenAPI
JSON) — deferred, to bundle with the v1 coverage-audit / when personal is on b2.
Worldtree v1.0.0b1 (#331) decoupled turn execution from the SSE connection,
so turn-launch failures now arrive EAGERLY as an HTTP status before any
stream: 409 agent_not_available (pre-b1 was a 200 + in-stream error event)
and 503 (retryable turn-launch / infra failure). stream_turn previously
funneled both into a generic SseConnectFailed.
Map them to typed SseConnectFailed subclasses — AgentNotAvailable (409) and
TurnLaunchUnavailable (503, retryable=True) — carrying the parsed
error_code/message from the {detail:{error_code,message}} envelope.
Subclassing keeps existing `except SseConnectFailed` handlers working with
zero changes (POST-003 preserved — no synthetic event yielded; raise mirrors
reconnect_turn's 400/410/412 pattern).
worldtree-dev confirmed 409/503 are real runtime statuses; the OpenAPI 2.1.0
gap (not enumerating them) is theirs to fix (doc-completeness, not a wire
break). The 503 error_code is being re-pinned upstream (today internal_error
-> likely not_ready); our handling keys on STATUS so it's robust to the final
code — tighten the 503 default once they confirm.
Body shape live-confirmed against demo b1's 404/401 responses. Suite 509 green.
Contract docs/contracts/issues/1.contract.md updated.
Bifrost shipped 1.0.0 — first stable release, freezing wire v0.6 (the
surface ratatoskr's combined-builder consumer #18 already adopted).
Non-breaking: byte-identical on the wire to the prior >=0.10.0 pin.
Switched floor pin -> exact pin per the stable-substrate posture.
Suite 506 green against bifrost 1.0.0.
Publishing-minor for the #18 arc: ratatoskr now exposes a COMPOSITE Bifrost
endpoint (build_combined_app, :8392) so one bound Worldtree session drives BOTH
the memory.* and affect.* planes through a single endpoint — completing the
Tier-3 consumer round-trip (durable memory + live PAD from one binding).
Shipped as patches v0.17.15 (affect.fetch prerequisite) → v0.17.16 (composite) →
v0.17.17 (#17 op-feed fix); this minor publishes the milestone.
Live-smoke (against real stores + bifrost 0.10.0 on a running :8392): handshake
grants BOTH caps by store presence; the PAD read route serves real sindra PAD;
both planes dispatch through the one endpoint at a single bound session_id with
the op-feed deriving plane per path. The remaining WT-driven turn is gated on
infra-ops adding :8392 to Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS (requested).
The dispatch-layer op-feed's handshake req-summary read req.get("capabilities_requested"),
a field that never exists on the wire — bifrost's handshake handler reads
request_body["capabilities"] (reference_server/_protocol.py:181). So the op-feed's
caps_requested was silently ALWAYS None on every handshake. Read the real field.
Surfaced by the heid-code-review panel (Regin) during the #18 D1 review — a latent
#17 observability bug, not D1 drift. Regression test asserts caps_requested is
populated from a handshake body's capabilities.
Suite 502 -> 503 green.
One ASGI app fronting BOTH the memory.* and affect.* planes (:8392), so a single
bound Worldtree session both remembers AND shows live PAD. Closes#18 end-to-end
(D2 PAD read-endpoint shipped v0.17.14; D1 was bifrost-blocked, now unparked by
bifrost 0.10.0's public build_combined_app + FR-1 resolved — zero Worldtree change).
- provider/combined.py: build_combined_provider_app wraps bifrost.consumer.build_combined_app
over both stores + mounts the shared affect read route. Advertises both caps by store
presence; per-route call-time isolation is bifrost's (INV-013).
- affect_store.py: extract add_affect_read_route shared helper (the D2 INV-007 promise —
composite + standalone mount the SAME read route over the same affect.db, INV-011).
- opfeed.py: plane='combined' derives the OpEvent plane per request path
(memory-call->memory, affect-call->affect, handshake->combined; INV-012).
- serve_combined.py + ratatoskr-combined-provider console script on :8392 (additive —
standalone :8390/:8391 untouched, INV-014).
- contract: 18.contract.md § Deliverable 1 (INV-009..INV-014); D1 un-deferred.
Latent bug fixed (exposed by the contract-mandated memory `search` dispatch test running
through TestClient = a worker thread): open_memory_store lacked check_same_thread=False —
the SAME sqlite thread-safety bug already fixed in the affect store (D2). The composite
serves the memory plane over HTTP, so a memory-call on uvicorn's threadpool would trip it.
Fix: check_same_thread=False + PRAGMA busy_timeout=5000 (memory contract Concurrency note).
heid-code-review panel (Groa/Hulda/Regin): ZERO drift findings; the implementation matches
INV-009..INV-014 at function-block level. Folded the genuine test-fidelity fix (memory leg
describe_store -> search per the contract TEST) + added the PRE-001/PRE-002 guard tests.
Suite 486 -> 502 green.
bifrost 0.10.0's _supports_affect_plane (bifrost/affect.py:75-80) now requires a
callable fetch for the affect capability to advertise/dispatch at all (INV-012
strong-or-absent), so an emit-only store 400s on EVERY affect op — repinning past
the affect.fetch release (#12/#13) breaks our shipped affect plane until fetch
exists. Implement affect.fetch as a thin async wrapper over the existing get()
read seam, conformed verbatim to the reference InMemoryAffectStore.fetch:
{"found": False} or {"found": True, "snapshot": <verbatim>}, AffectInvalidArguments
on empty ids, opaque (INV-001 — never reads pad/valence).
This is the forced prerequisite for the #18 D1 composite (build_combined_app),
and a new Worldtree I/O point consumed (affect read-back over bifrost).
- Repin bifrost>=0.8.0 -> >=0.10.0 (uv lock: 0.8.0 -> 0.10.0)
- affect_store.py: add async fetch() over get()
- contract bifrost_affect_provider v1.2: fetch FN block + INV-010 (cap = supported+emit+fetch)
- tests: 3 fetch unit + parity_vs_reference_fetch through dispatch_affect_call
- suite 482 -> 486 green
Completes slice 3c: the browser-facing trigger for the web bind. A 'Bifrost
binding' <select> (none / memory / affect) on the setup panel; startSession
sends bifrost_plane in the create body (the consumer key stays server-held,
never sent from the browser). On a bound 201 the identity line renders the
bound-state indicator (plane + endpoint, never the key); bind failures surface
the error_code + bifrost_error in the setup error line.
Closes the persistent-memory caveat: web chat can now bind its own Tier-3
provider (memory persistence + affect telemetry), not persona+debug only.
Web suites 63 green (presentation-contract included); pure static HTML/JS.
Slice 3c of issue #17 — the web surface of the bind trigger, server side. Closes
the INV-008 lockstep (CLI + TUI + web all carry the bind now). Implements the
contract's "web bind split": the browser selects only the PLANE; the consumer key
and the Worldtree-visible host are SERVER-HELD config and never reach the browser.
- create_app gains bifrost_consumer_key + bifrost_visible_host (server-held,
from env via the entrypoint: RATATOSKR_BIFROST_CONSUMER_KEY /
RATATOSKR_PROVIDER_VISIBLE_HOST).
- _create_session_endpoint reads an optional `bifrost_plane` from the browser
body, builds the BifrostBinding SERVER-SIDE via endpoint_for_plane(plane,
visible_host), and calls create_session(bifrost=, consumer_key=). The 201
response echoes bound-state {plane, endpoint, status: bound} for the UI
indicator — never the key (INV-008/INV-009).
- Error routing: invalid plane / unconfigured server -> 400; BifrostHandshakeFailed
-> 502 {bifrost_error}; BifrostConsumerKeyMissing (server misconfig) -> 400.
5 new web bind tests (server constructs binding + key-never-leaks + upstream
carries bifrost body + consumer-key bearer; unconfigured -> 400; invalid plane;
handshake 502; no-plane unbound regression). Full suite 470 green; added lines
ruff + mypy clean (pre-existing web-file backlog untouched).
Follow-on: the index.html plane selector (UI trigger) — the server capability is
complete and TDD'd; the browser-side dropdown is a thin separate change.
LIVE-SMOKE PROVEN (this session): the CLI bind drove a bound sindra session
against personal Worldtree :8081 -> handshake 200 -> the op-feed captured 2
recall searches correlated to the EXACT bound session_id (2c0c7482), with the
real #297/#298 union-recall scopes. Bind + observe proven end-to-end live.
Slice 3b of issue #17 — the TUI surface of the bind trigger (web is 3c). The TUI
consumes the same ParsedArgs the cli already parses (--bifrost-plane / --bifrost-url
/ consumer key from RATATOSKR_BIFROST_CONSUMER_KEY), so this wires the bind into
_resolve_then_run's pre-flight create_session:
- bifrost + consumer_key threaded into create_session at the pre-alt-screen
resolution layer, so bind failures land on the operator's REAL stderr BEFORE
the Textual alt-screen opens (INV-002, mirrors issue #6's pre-alt-screen
routing) — never eaten by the alt-screen teardown.
- BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit 23 with the
same 401-scoping hint, keyed on bifrost_error == bifrost.auth_rejected. Exit
codes + label vocabulary match cli._amain exactly (INV-006).
- Bound-state indicator on success (pre-alt-screen): ". bifrost: status=bound
plane=... endpoint=...".
3 new TUI bind tests (handshake-fail / consumer-key-missing / bound-create carries
binding + indicator, run_async stubbed). Full suite 465 green; added lines ruff +
mypy clean (pre-existing tui.py lint/type backlog untouched per surgical-changes).
Slice 3a of issue #17 — the CLI surface of the bind trigger (TUI + web follow,
INV-008 lockstep). ratatoskr can now self-drive a bound session from the CLI:
- New flags: --bifrost-plane {memory,affect} (dev shortcut -> endpoint_for_plane
over --bifrost-host / RATATOSKR_PROVIDER_VISIBLE_HOST) and --bifrost-url (the
direct HTTPS/prod endpoint, bypassing the plane shortcut). Mutually exclusive;
a binding is a session-CREATE concern (forbidden with --session).
- Consumer key resolved from RATATOSKR_BIFROST_CONSUMER_KEY only (the privileged
handshake identity — never a CLI flag, distinct from the canary WORLDTREE_API_KEY).
- _amain threads bifrost + consumer_key into create_session and routes the bind
failures: BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit
23 with the 401-scoping hint ("use the consumer key, not WORLDTREE_API_KEY")
keyed on bifrost_error == bifrost.auth_rejected.
- Bound-state indicator on success: ". bifrost: status=bound plane=... endpoint=..."
— shows WHICH identity/endpoint bound, not a bare boolean.
Also fixes a pre-existing test-isolation bug: test_no_textual_import did a live
importlib.reload(ratatoskr.cli) that mutated the shared module in place, breaking
class identity (isinstance / pytest.raises) for every test after it. The real
check is the static source grep; the reload was vestigial and is removed.
9 new CLI bind tests; full suite 462 green; ruff clean (no new mypy errors).
Slice 2 of issue #17 — the OBSERVE half. New ratatoskr.provider.opfeed:
- OpEvent{ts, plane, op, session_id, status, req_summary, resp_summary,
turn_id=None} — scope-only summaries, never record bodies / PAD content
- OpSink Protocol + JsonlOpSink (continuous append-only JSONL, INV-007)
- instrument_provider_app(app, *, plane, sink): an ASGI middleware over the
built bifrost provider app. Buffers+replays the request, captures the
response, reads session_id off the dispatch JWT's "sub" claim (INV-005 —
present for ALL verbs incl. search/get/delete, which bifrost withholds from
the store method), emits exactly one OpEvent per inbound bifrost-call incl.
handshake + errors. Read-only over dispatch; store scope semantics untouched
(INV-004). A sink/summary failure is swallowed + logged, never breaks serve
(POST-003).
- Per-verb summaries: search {scope_all,scope_any,top_k}->{hit_count,hits};
upsert_many {record_count,scopes}->{upserted,replayed}; get/get_many/
delete_many {ids}->{found_count|deleted}; emit (affect, opaque)->{stored};
handshake {caps_requested}->{caps_granted,ok}; error->{error: code}
- serve_memory/serve wired: opt-in via RATATOSKR_OPFEED_PATH (maybe_instrument)
Resolves the contract's open question: the dispatch JWT DOES carry session_id
(= the "sub" claim). Tests drive the REAL bifrost dispatch end-to-end with
minted JWTs. 11 new tests; full suite 453 green; ruff + mypy clean (opfeed.py).
Slice 1 of issue #17 (Bifrost-binding the chat client) — the client-side
BIND primitive, TDD'd against docs/contracts/issues/17.contract.md.
- BifrostBinding{endpoint_url, scope=None} frozen dataclass (#160 shape)
- create_session(..., bifrost=, consumer_key=): carries the bifrost body
field and OVERRIDES the bearer to the consumer key per-request (INV-001 —
never falls back to the canary key)
- BifrostConsumerKeyMissing: raised BEFORE any HTTP when a binding lacks a
non-empty key (PRE-001)
- BifrostHandshakeFailed: 502 on a BOUND create -> carries detail.bifrost_error
(both-shape unwrap per the persona_state wire lesson); gated on bifrost!=None
so an unbound 502 stays SessionApiFailed (INV-002)
- endpoint_for_plane: memory->:8391 / affect->:8390, invalid->ValueError
7 new tests; full suite 442 green; ruff clean.
persona_state hard-404s every Tier-3 (colon-id) agent by design upstream
(WT api.py:1220, "Phase 2.0 has no Tier 3 persona") — so the Persona pane
showed "persona not available (HTTP 404)" for consumer-defined characters.
loadPersona now reads error_code + renders a clear Tier-3-aware message
(she still responds in character; only the affect/OCEAN readout is gated),
with distinct text for persona_not_configured / 403 / other.
Also (snapshot): sindra switched to thoughtful-character role
(mistral-small-4-reasoning); worldtree-dev pinged re Tier-3 persona_state
roadmap (thread 01KVCR6P); #17 (bifrost-binding the chat client) teed up as
the next-context target.
v0.17.7
Repin bifrost 0.7.0→0.8.0 and reimplement the memory store's search scope
filter to the v0.6 split (#11): scope_all (AND/intersection) + scope_any
(OR/union over a list of conjunctive scopes), at parity with the v0.6
reference _matches_scope / _validate_scope. No-compat: scope_filter removed.
scope_any is the union-visibility primitive that resolves the #295/#297
silent-zero AND foot-gun — a subset-scoped chunk now recalls via an OR
member. End-to-end cold recall now gated only on Worldtree emitting
scope_any on its recall path (#297, upstream).
- store: search(scope_all, scope_any); _scope_subset + _matches_scope + _validate_scope
- contract v1.2: search FN sig, INV-005 recomposed, PRE-003 both fields, scope_any_union test
- tests: scope_any union, scope_all∧scope_any compose, both-empty match-all; parity vs real 0.8.0 dispatch (433 green)
- #17 contract: sync stale scope_filter/_scope_matches-AND refs to scope_all/scope_any
- runbook + persistent-memory updated; provider bounced onto 0.8.0 (fresh empty db)
v0.17.6
bifrost 0.7.0 (wire v0.5) makes agent_self canonical: the scope lattice is
now {end_user, group, tenant, agent_self}. Our store was MORE permissive than
bifrost's reference (no _validate_scope_filter), which silently 0-zeroed the
#295 cold recall instead of a loud 400. Now matched: search rejects an
out-of-lattice axis with InvalidFilter (-> memory.invalid_filter 400), agent_self
admitted. Purely additive — everything that validated before still validates.
Closes the parity gap our own foot-gun flag opened (bifrost-dev shipped the
lattice add #10 off it). Pin bumped bifrost>=0.6.1 -> >=0.7.0. Contract
search PRE-003 + lattice_axes test; 2 new store tests; full suite 429 green.
Structured [memory-provider] request/response logging on the memory-call
path: search REQUEST (scope_filter, top_k) + RESPONSE (chunk_ids, scores,
scope), plus concise entry lines on upsert_many/delete_many. Self-contained
stdout handler so the lines reach the provider stdout under uvicorn.
Additive observability only — no search-semantics change (AND-parity with
bifrost's reference store holds). This is the first concrete brick of #17's
observe half, and the lens that root-caused #295's cold-recall miss (the
persist/recall scope-axis asymmetry) from the provider side.
The second plane of ratatoskr's Tier-3 Bifrost consumer: a durable memory
store Worldtree writes agent memory chunks into (upsert_many) and recalls
by vector similarity (search), with point reads + deletes. Implements
bifrost's own MemoryDataStore Protocol; conformance is #195 parity vs
InMemoryMemoryStore through the real dispatch_memory_call.
Store (memory_store.py): open_memory_store, describe_store, upsert_many
(replay/conflict idempotency, optimistic locking, injection rule, atomic
batch), search (cosine over sqlite-vec vec0, scope isolation INV-005,
over-fetch-then-filter so top_k counts in-scope), get/get_many,
delete_many, build_memory_provider_app. Dev shell (serve_memory.py):
ratatoskr-memory-provider entrypoint, port 8391.
TDD + heid-code-review (panel Groa/Hulda/Regin, zero true drift). Adopted
fixups: scope_filter dict guard, top_k<=0 -> [], stronger scope-isolation
+ delete-hit-search + handshake-POST tests. Partial-map optimistic-lock
semantics pinned against the reference via a new expected_revisions
parity test.
26 memory + 4 serve tests; #195 parity (upsert/search/expected_revisions)
green; ruff clean. Deps: +sqlite-vec.
Stand-up glue for the live affect negotiation smoke vs Worldtree v0.35.2.
- ratatoskr.provider.serve: build_app_from_env (tested env->app seam) + main
(uvicorn shell). Config from env: RATATOSKR_HEIMDALL_KEY (required),
RATATOSKR_AFFECT_DB, RATATOSKR_CONSUMER_ID, RATATOSKR_PROVIDER_HOST/PORT.
- console-script ratatoskr-provider = ratatoskr.provider.serve:main.
- Heimdall key utf-8-encoded into HS256 key_bytes; revisit if Worldtree signs
with base64/raw bytes (confirm format at wire-up).
The first slice of ratatoskr-as-Tier-3-Bifrost-consumer: a SQLite-backed,
conduit-opaque affect store Worldtree emits Tier-3 persona/affect snapshots
into, plus the thin build_affect_provider_app wiring. The bifrost library
owns the wire; this owns only the store + glue.
- ratatoskr.provider.affect_store: open_affect_store / emit / get /
build_affect_provider_app. Two-table SQLite schema (snapshots + idempotency).
- Conduit-opaque (INV-001): reads only the two addressing keys; whole-blob
hash only. LWW-by-arrival across distinct keys; replay-or-conflict
idempotency (actor-scoped from ctx), raising bifrost's typed exceptions.
- Conformance: #195 parity vs InMemoryAffectStore through the real
dispatch_affect_call engine. 17 provider tests; 395 full suite.
- Contract v1.1: idempotency model corrected to bifrost's actual semantics
(caught by real-lib TDD; the artifact-only review structurally could not).
Heid-panel reviewed (contract + code); idempotency-cache TTL pruning,
memory.* plane, and the combined two-plane server deferred (see Out of scope).
Onboard ratatoskr as the Tier-3 Bifrost consumer (durable memory +
persona/affect persistence Worldtree writes into). Lands the dependency
and the reviewed affect-plane spec; no production code yet — the patch
bump fires when the store lands at TDD-green.
- pyproject: bifrost>=0.6.1 in a `provider` optional-extra (+ starlette,
jsonschema); gitea PyPI index wired, bifrost sourced from it.
- uv.lock: bifrost 0.6.1 + jsonschema resolved from the gitea registry.
- docs/contracts/bifrost_affect_provider.contract.md: SQLite-backed,
conduit-opaque affect store (emit + ASGI wiring). Heid-panel-reviewed
(Groa/Hulda/Regin), amended for 8 text-ambiguity findings.
A design pass through /frontend-design on the web companion to retain
all debugging richness while raising usability and polish. Single-file
vanilla HTML/CSS/JS; no build, no CDN, no node_modules. HTTP surface,
endpoints, presentation contract, and INV-001..009 all unchanged.
Aesthetic direction: "Aurora telemetry instrument."
- Runic glyph + wordmark, live connection dot (idle = aurora-green,
streaming = pulsing cyan, error = dawn-red), session identity right-
aligned. Persona summary lives inline in the top bar with labeled
P/A/D micro-bars (centered baseline, [-1,1] mapped).
- Aurora signature band — thin cyan→blue→green shimmer animation
at the top edge, echoed on the setup card.
- Conversation column with turn-divider rules, cyan ❯ prompt echoes,
assistant text with a live cyan left-rule. Terminal events become
status chips (done = aurora-green, error = dawn-red, cancelled =
dawn-yellow) with metadata. Animated "awaiting first token · Ns"
indicator with elapsed counter.
- Telemetry column: tabs with live count badges that flash on new
events, sticky pane header carries the active pane name + copy
button, new-line flash highlight on each pane append, structured
empty states, persona pane structured render.
- Composer: real input, send/cancel buttons, streaming-lock state.
- Status line: keyboard legend + version footer.
- Centered setup card overlay with styled agent select on first open.
- All-monospace by intent (no-CDN constraint + right for a wire-
monitor tool); system mono stack. CSS-only motion (pulse, shimmer,
staggered rise, flash, awaiting dots).
Live Markdown — transcript response + thinking panes:
- Hand-rolled markdownSafe() renderer: escape-FIRST (INV-004
preserved), then a whitelist subset (headings, bold, italic, inline
code, fenced code blocks, ordered/unordered lists, blockquotes,
links). No raw HTML passthrough. Link href restricted to http(s):// +
conservative charset (rejects javascript:, attribute-breakout URLs).
- Per-turn live buffer; each text/thinking delta re-renders the
accumulated buffer in place (same pattern as the TUI's v0.9.0 live
MD rendering).
- Verified under node: rendering (bold/italic/code/lists/headings/
fences/safe links) + XSS neutralization (script tags, javascript:
schemes, attribute-breakout URLs, img onerror) all behave correctly.
- Tools/Debug/Persona panes stay literal monospace by deliberate
choice: they carry our structured audit lines + JSON, where MD would
corrupt readability (underscores in tool names, JSON braces, etc.).
Thinking pane per-turn breaks:
- Each turn lays down a labeled `── turn N ──` divider in the
thinking pane. The prior turn's live block is closed and a fresh
MD-rendered block opens below the divider, so each turn's chain-
of-thought is its own break-separated section.
Tests: 378 passing (no test change — server-side surface unchanged).
Markdown safety verified via standalone node harness exercising
rendering + 4 XSS vectors.
Minor per SemVer discipline: substantial new browser-side behavior
(live Markdown rendering, redesigned presentation) that consumers
would opt into via the next launch. No HTTP-caller adapts.
Second Heid panel pass (thread 01KSPBMFRRQE) on the v0.16.0 tree:
Gróa returned zero findings; Hulda surfaced two minor tightening
items, both closed here.
1. test-gap — TestStreamFullEventVocab drove only 8 of 11 Event types
through the stream endpoint (omitted Error, Cancelled, AffectUpdate).
Serialization for all 11 was already covered by the presentation-
contract fixture tests; this was a stream-integration coverage gap.
- Added AffectUpdate to the vocab stream (non-terminal, coexists
with done).
- Added dedicated test_error_terminal_event + test_cancelled_terminal_event
(terminal events are mutually exclusive with done, so they can't
share one stream).
2. precision — the disconnect-cancel path caught bare `except Exception:
pass`, silently swallowing real CancelFailed / transport errors. The
contract intent is to swallow only the cooperative race
(CancelAlreadyCompleted). Narrowed: swallow CancelAlreadyCompleted /
CancelTurnNotFound as the no-op race; log unexpected cancel failures
as a structured stderr line for diagnosability. Never re-raises (we're
unwinding the cancelled generator and must not mask CancelledError).
Tests: +2 (376 → 378). Patch per SemVer discipline — coverage +
diagnosability tightening, no behavior change observable to callers.
Heid panel review (Gróa + Hulda, thread 01KSP5P6CSJH) on v0.15.0/
v0.15.1 surfaced one load-bearing bug + several precision items. This
pass closes them.
Load-bearing fix — cancel paths targeted the wrong turn_id:
- `_TURN_COUNTER` allocates browser-local ids (1, 2, 3…); the real
upstream Worldtree turn_id (e.g. 799) only arrives in the first SSE
event. The v0.15.x cancel/disconnect/shutdown paths posted to
/sessions/{sid}/turns/{LOCAL_ID}/cancel — wrong URL upstream.
- TurnHandle.upstream_response (dead field) → upstream_turn_id: int|None.
Captured from the first event's sse_id.turn_id in the stream
generator. All cancel paths now target it. Cancel before the upstream
stream starts (upstream_turn_id None) is a no-op
({"cancelled": false, "reason": "not_started"}).
- The old cancel tests mocked the local-id URL, so they encoded the bug;
rewritten to assert the UPSTREAM id is targeted.
Behavior change (minor-bump driver) — server-side end_user_id:
- create_app gains end_user_id kwarg; entrypoint reads
RATATOSKR_END_USER_ID and threads it in. POST /api/sessions uses
app.state.end_user_id, IGNORING any browser-supplied value (a client
can't impersonate an arbitrary end-user partition). JS no longer
sends end_user_id.
Precision fixes:
- Entrypoint missing-extras ImportError catch scoped to starlette/
uvicorn ONLY; baseline-dep / first-party import failures now
propagate as real tracebacks instead of masking as exit-12.
- Lifespan shutdown logs per-pending session_id + upstream_turn_id
(was a single aggregate count).
Tests (+18; 376 total):
- disconnect_triggers_upstream_cancel (INV-005 load-bearing — drives
the stream generator directly + cancels the consuming task; would
have caught the turn_id bug)
- cancel_targets_upstream_turn_id, cancel_before_started_is_noop,
cancel_failed_500
- server-side end_user_id: uses / ignores-body / omits-when-unset
- create_app: routes_registered / state_attached / factory_stored
- entrypoint: default_host / port_zero / happy_argv / open / no-open
- real_import_bug_propagates (precision guard)
- full_event_vocab at the stream-endpoint layer
Contract #16 amended: v0.16.0 amendment banner + INV-005/006 reworded
for upstream_turn_id + FN sketches corrected (server-side end_user_id,
upstream_response→upstream_turn_id, manual client lifecycle vs the
non-executable async-with sketch, not-started cancel branch).
Live smoke against personal:8081 during the v0.15.0 web-companion
verification surfaced that real Worldtree returns persona_state
errors in the FastAPI default envelope shape:
{"detail": {"error_code": "auth_scope_denied", "message": "..."}}
The v0.12.0 `get_persona_state` parser only inspected the top-level
`error_code` key. When the field was nested under `detail`, the
typed exception (AuthScopeDenied / PersonaNotConfigured /
AgentNotAvailable) wasn't raised; the call fell through to
SessionApiFailed, which then surfaced through the web companion as
an opaque HTTP 500 on /api/agents/{id}/persona_state.
The original test_sessions.py mocks used the flat-shape envelope, so
the bug was invisible in unit tests until the real-wire smoke.
Fix: extract error_code from either `err.get("error_code")` (flat)
OR `err.get("detail", {}).get("error_code")` (FastAPI default).
Patch per SemVer discipline — bug fix to v0.12.0 surface, no public
signature change, no new behavior. Callers that were getting the
wrong exception now get the right one; callers that were already
getting the right exception (flat-shape paths) are unchanged.
Tests: 2 new regression cases in TestGetPersonaState — one each for
the detail-envelope shape of 403 auth_scope_denied and 404
persona_not_configured. Suite: 358 passing.
The four right-column panes (tools/debug/thinking/persona) have all
carried `wrap=True` since their introduction, but long lines were
still horizontally scrolling instead of wrapping. Root cause: Textual's
RichLog defaults `min_width=78`, and the App's render path takes
`max(renderable_width, min_width)` after the shrink step. The right
column is 1fr against the left column's 2fr, so at common terminal
widths (≤120 cols) the panes are narrower than 78 cells — the 78-cell
floor was forcing content to render at 78 wide and horizontally scroll
instead of wrapping at the actual pane width.
Set `min_width=0` on all four right-column RichLog instances so
shrink-to-widget-width can actually shrink. `wrap=True` now takes
effect on long lines as expected.
Patch per SemVer discipline: bug fix to a long-standing visible-UX
defect; no public API change, no behavior change for callers, every
existing caller continues to work — the substrate is more correct.
The CLI presenter at cli.py:201 carries its own isinstance check on
the Event union (mirroring the TUI presenter's same pattern). v0.11.0
+ v0.14.0 added AffectUpdate + AwaitingLlmFirstToken to the wire layer
but only updated the TUI presenter, leaving the CLI presenter stuck
on the pre-v0.11.0 event vocabulary.
Effect: `ratatoskr --send` crashes with AssertionError on any v0.28.0+
server emitting either of those events. Persona-enabled agents
(affect_update fires on every qualifying turn) and slow-first-token
turns (awaiting_llm_first_token heartbeats fire at 5s intervals) are
both reliably broken. Surfaced while running a wire-trace smoke test
against a Gemma4-based Tier 3 agent.
Patch-bump per SemVer discipline: corrects drift on the just-shipped
surface (v0.11.0 / v0.14.0 wire layer); no public signature change,
no new behavior, existing callers don't care — the bug fix lets them
keep working against current servers.
Routing additions in cli.py:
- AffectUpdate: stderr line with status + (for current) dominant_emotion
- AwaitingLlmFirstToken: stderr line with turn_id + elapsed (seconds)
Spec pin moved da93ca7 (v0.28.0) → 562001a (v0.29.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted.
The only material delta is Worldtree #201's awaiting_llm_first_token
SSE heartbeat — a top-level event (NOT a worker_phase extension, per
INV-053's three-field stability) that fires at a configurable interval
(default 5s) during the BuildingPrompt → CallingLLM gap.
Wire layer (sse_client.py):
- New `AwaitingLlmFirstToken` dataclass: sse_id / turn_id /
elapsed_ms_since_building_prompt (server-authoritative monotonic)
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any slow-first-token turn
from a v0.29.0 server (unknown SSE event type → ValueError)
TUI layer (tui.py):
- Audit pipeline: per-event debug-pane line with elapsed in seconds
- Live transcript indicator: first heartbeat mounts a Static
("awaiting first token · 5.0s"); subsequent heartbeats update it
in place; any non-heartbeat event removes it (the gap closed)
- Turn-summary line now carries heartbeat count
- Indicator demoted via .awaiting-label CSS so it reads as ambient
progress, not content
Tests: 2 wire-layer (single + monotonic sequence) + 3 presenter
(audit line shape, single-mount semantic, indicator removal on gap
close). Suite: 318 passing.
Step 3 of the Worldtree #204 integration: visible persona-state UX.
Pairs with v0.11.0's AffectUpdate SSE event + v0.12.0's
get_persona_state HTTP client — together those gave the data; this
bump surfaces it.
Two surfaces (Option C: both):
Sticky persona-header (top of screen, dock=top, height=1):
- Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions
active` — concise enough for at-a-glance scan above the chat
- Starts hidden via `.empty` CSS class; height collapses to 0 when
the agent has no persona surface
- Refreshes on AffectUpdate(status="current") snapshots
Persona TabPane (Ctrl+4):
- Full snapshot detail: dominant emotion, PAD axes with baseline +
delta, mood drift, active emotions with intensity + decay
(minutes-rounded), last_updated_at footer
- Replaced (not appended) on each new snapshot — snapshots are
absolute state, not incremental
Lifecycle:
- on_mount spawns a Textual worker that calls get_persona_state to
hydrate header + pane before turn 1
- PersonaNotConfigured (domari, muninn, Tier 3) → pane carries an
italic placeholder, header stays empty
- AgentNotAvailable / AuthScopeDenied / network error → italic
failure placeholder; audit-logged; never crashes
- Presenter's render() takes an optional `on_persona_snapshot`
callback so AffectUpdate(current) refreshes both surfaces during
a live turn (no widget coupling — App owns the callback)
Tests: 10 new (4 formatters, 3 presenter callback, 4 layout/binding/
hydration). Full suite: 313 passing.
Adds the read-side half of Worldtree #204's persona-state observability
surface. Pairs with v0.11.0's AffectUpdate SSE event — together they
let a consumer hydrate a persona pane on session-open (this GET) and
keep it live as turns fire (the SSE event).
Public surface:
- `get_persona_state(client, agent_id) -> dict[str, Any]` — GET
/agents/{agent_id}/persona_state, returns the same `snapshot` dict
shape as AffectUpdate.snapshot
- New exception types mapped from the spec's documented 4xx error_codes:
- `PersonaNotConfigured` (404 persona_not_configured) — agent has
no persona surface (domari, muninn, all Tier 3 in Phase 2.0)
- `AgentNotAvailable` (404 agent_not_available) — unknown agent_id
- `AuthScopeDenied` (403 auth_scope_denied) — key lacks the
requested scope (persona.read here; reusable for future scoped
endpoints)
- Other non-2xx falls through to the existing SessionApiFailed
precedent so novel failure modes aren't silently absorbed
Tests: 6 new cases covering happy snapshot return, each typed 4xx
sub-code, unknown 404 fall-through, and 5xx SessionApiFailed parity.
Not yet consumed: TUI persona surface (Persona TabPane / sticky
header line). UX shape pending operator direction — step 3.
Spec pin moved 55101e9 (v0.19.0) → da93ca7 (v0.28.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted
from Worldtree at the new SHA. The only material delta consumed in
this bump is Worldtree #204's affect_update SSE event surface.
Wire layer (sse_client.py):
- New AffectUpdate dataclass: sse_id / status / turn_id / snapshot
(snapshot is None for status="scheduled")
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any persona-enabled turn
from a v0.28.0 server (unknown SSE event type → ValueError)
TUI layer (tui.py):
- AffectUpdate routes through the v0.10.0 audit pipeline only — one
debug-pane line per arrival with dominant_emotion + PAD for
status="current", lightweight status+turn_id for status="scheduled"
- No transcript / tools / thinking pane writes — the persona UX shape
(Persona TabPane vs sticky header line) is deferred to a separate
bump pending operator direction
Tests: 2 new wire-layer tests for current+scheduled parsing + 2 new
presenter audit tests for routing and audit-line shape.
Not yet consumed: GET /agents/{id}/persona_state endpoint (step 2 of
the integration plan).
Replaces v0.8.2's drop-Markdown patch with proper in-place Markdown
rendering. The transcript becomes a VerticalScroll container; each
turn's response body lives as a single Static widget whose content
is updated as Text deltas arrive — Markdown is re-rendered in place
rather than re-printed on Done. Eliminates the v0.8.x double-print
without sacrificing rich formatting.
- transcript: RichLog → VerticalScroll (#transcript-scroll)
- Text deltas: mount Static(Markdown(buffer)) on first delta;
Static.update(Markdown(buffer)) on subsequent deltas
- --raw mode: bypass Markdown, mount Static(plain_str) for the same
in-place update semantics
- Terminal events (Done/Error/Cancelled) mount styled label Statics
- _cancel_via_sse: write → mount Static on the new container
- _write_turn_headers: transcript gets a styled RichText Static
("── turn N ──"); other panes still receive Rule renderables
- Test suite reshape: bulk rename `log` → `transcript` for the
presenter contract, `_mounted_renderables` helper extracts
Static.content for assertion, `_spy_writes` captures both
RichLog.write and VerticalScroll.mount
Operator: "first turn double prints agent's turn."
Root cause: v0.8.1 wrote both the streamed Text lines AND the post-
Done `Markdown(event.response)` body into the transcript. Same
content rendered twice — once as plain streaming, once as a full
markdown re-render. The v0.8.1 commit message documented this as
"some duplication is acceptable" but the live UX read as a bug.
## Fix
Drop the post-Done `Rule + Markdown(response)` writes in non-raw
mode. The streamed text IS the response; whatever the model emitted
flows into the transcript line-by-line via coalesce-on-newline.
Markdown formatting (bold, lists, code blocks) renders as plain
text — a known regression from v0.8.1's polished output but the
right tradeoff vs the duplication bug.
## What this loses temporarily
Pre-v0.8.2 (after Done):
[done] turn_id=... ───
─── (Rule separator) ───
**Bold text** rendered bold, `code` highlighted, lists as bullets, etc.
v0.8.2 (after Done):
[done] turn_id=... ───
**Bold text** as plain asterisks, `code` as backticks, lists as plain dashes
## v0.9.0 plan
Restore markdown rendering via LIVE rendering during the stream
(not post-Done re-render). Replace `RichLog#transcript` with a
`VerticalScroll` container that mounts a fresh `Markdown` widget
per turn; Text deltas update the widget; markdown renders as
content arrives. No duplication, no snap, full formatting.
Operator-confirmed direction (2026-05-25 AskUserQuestion).
## Tests
287/287 GREEN; ruff clean. Two tests updated for the new shape:
- test_done_renders_markdown_after_label → renamed
test_done_flushes_tail_and_writes_label; asserts NO Markdown, NO
Rule (post-Done) in the writes.
- test_happy_text_done_renders_markdown → renamed
test_happy_text_done_no_double_print; asserts NO Markdown in the
spy.
Patch bump (v0.8.1 → v0.8.2): bug fix; no public API change.
Two related fixes for the same user-reported bug pattern from a
running session against ratatoskr:sindra (qwen3.6-35-a3b-heretic):
## 1. Streaming text overlapping the transcript
Operator: "new text comes at the bottom and overwrites the existing
pane information instead of pushing it up naturally."
Root cause: the v0.6.0 `#current-text` Static was `dock: bottom`
with `height: auto`, sitting between the transcript RichLog (1fr)
and the prompt Input (dock: bottom). As text streamed, the Static
grew UPWARD but Textual didn't dynamically resize the 1fr transcript
to accommodate — the growing Static visually OVERLAPPED the
transcript's bottom rows. On Done, `current_text.update("")` snapped
it to height 0 and the transcript re-laid-out — "boom, everything
updates."
Fix: remove `#current-text` Static entirely. Apply the same
coalesce-on-newline pattern v0.7.1 used for thinking — Text deltas
accumulate in `TuiPresenterState.text_chunk_buffer`, flushing whole
lines (each `\n` boundary) directly to `log` (transcript). On Done:
flush remaining tail, then [done] label + Rule + Markdown body.
Trade-off accepted: streamed lines + post-Done Markdown body are
both in the transcript (some content duplication). The Markdown
body re-renders the same content with proper formatting (lists,
bold, code blocks). Acceptable — operator gets both the live-progress
streaming AND the canonical rendered version.
## 2. MalformedSseId raw='' crashing every turn
Operator: "current session is erroring on every turn with
[malformed_sse_id] raw=''"
Worldtree's qwen3.6-35-a3b-heretic provider emits some events
without `id:` lines (observed 2026-05-25 mid-stream). When the FIRST
such event arrives before any prior id has been seen, httpx_sse's
`ServerSentEvent.id` is `""`. `_parse_sse_id('')` raised ValueError
→ MalformedSseId → turn worker bailed → operator saw the label
every turn.
Per SSE RFC, events without `id:` are legitimate (they just don't
update Last-Event-ID). Issue #7 already covered the empty-DATA
keepalive case with skip-silently semantics. Empty-id is the same
shape of wire weirdness; same fix shape:
if sse.id == "":
continue # treat as keepalive
Ordered AFTER the empty-data branch so an empty-data + empty-id
event still gets skipped on the data check.
## Tests + smoke
287/287 GREEN (was 286, +1 for empty-id skip; +1 net Text-flow test
adjustments). Ruff clean.
Verified Worldtree alive when the user hit the empty-id bug
(/healthz returned ok in 18ms) — not a server-down issue, just
wire-format mid-stream.
## Caveats
The fix doesn't recover content from the dropped empty-id event.
If the event happened to carry meaningful data (not a true
keepalive), we silently lose it. Acceptable trade-off: pre-v0.8.1
EVERY turn died on the offending agent; post-v0.8.1 the turn
continues and any single dropped frame is recoverable from logs if
debugging. Worldtree-side fix (always emit ids) is the right
upstream answer; ratatoskr just stops panicking on wire weirdness.
Patch bump (v0.8.0 → v0.8.1) — both fixes are bug fixes; no public
API change. The `TuiPresenterState.render` signature loses the
`current_text` parameter (was added v0.6.0), but presenter is an
internal contract; no external callers.
Worldtree's GET /agents doesn't return consumer-defined (tier-3)
agents — the public list excludes them by design. Confirmed live in
v0.7.0's smoke. Without server-side knowledge, ratatoskr's picker
couldn't show tier-3 agents the operator had defined; the workflow
was "remember the agent_id, pass --agent ratatoskr:<name>
explicitly." Friction grows with every tier-3 agent.
## Fix: client-side index, merged at picker time
New module `ratatoskr.local_agents` maintains a JSON-backed index at
$XDG_CONFIG_HOME/ratatoskr/local_agents.json (override via
$RATATOSKR_LOCAL_AGENTS). `tier3` CLI define / patch / delete update
the index as side-effects. `tui._resolve_then_run` loads the index
after `list_agents(client)` and appends entries not already in the
remote list (dedup by agent_id; remote wins on conflict).
Library-level `tier3.define_agent` / `patch_agent` / `delete_agent`
stay pure — local persistence lives in the CLI layer (`_run_define`
etc.), not in the library functions. Tests of the library don't
touch the filesystem.
## Public surface
ratatoskr.local_agents:
LocalAgentEntry (frozen dataclass)
load_local_agents() -> list[LocalAgentEntry]
add_local_agent(entry)
update_local_agent(entry) # same semantics as add (agent_id key)
remove_local_agent(agent_id)
make_description(system_prompt) -> str # synthetic picker label
Failure modes are lenient: missing file → empty index; corrupt JSON
or schema mismatch → empty index (no crash). The picker continues
to show foundational agents either way; tier-3 surface degrades to
the pre-v0.8.0 workflow.
## Picker integration
Local entries convert to ratatoskr.sessions.AgentInfo with synthetic
fields:
name = agent_name (from LocalAgentEntry)
description = "(tier 3) <first non-empty line of system prompt>"
version, capabilities, supported_models, persona_traits, ui_hints
= None / [] / [] / {} / {}
If Worldtree later starts returning tier-3 in GET /agents, this
module's role narrows to redundant local cache; can be removed
cleanly since the dedup-by-agent-id keeps remote-wins behavior.
## Tests
286/286 GREEN (was 265, +21: 20 local_agents + 1 picker-merge
integration). Ruff clean. Tests isolate the index via
$RATATOSKR_LOCAL_AGENTS pointed at pytest's tmp_path — no pollution
of operator's real ~/.config/ratatoskr/.
## Manual smoke
Sindra-like define against personal Worldtree:
python -m ratatoskr.tier3 define --name foo --system-prompt "..." --model X
cat ~/.config/ratatoskr/local_agents.json
# ratatoskr --new picker now shows ratatoskr:foo alongside mimir et al.
Cross-machine: the file is per-host. Operator can sync via dotfiles
if needed; out of scope for this commit.
Minor bump (v0.7.1 → v0.8.0) — new public module + new picker
behavior (more agents shown). No caller-side breaking changes.