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).
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.)
Consumer side of Worldtree's #347 authored-history-write (the SillyTavern
first-message primitive), shipped via direct in-session TDD:
- write_authored_history (POST /sessions/{id}/history): v1 author=assistant,
effects=none, per-session idempotency; body server-pinned (AuthoredWriteRequest
extra=forbid) so null effects/claimed_original_at are omitted; 200 replay /
201 fresh both return the AuthoredTurnResponse dict.
- AuthoredHistoryUnavailable: the hide-existence 404 (feature-absent / ungranted
/ session-absent, indistinguishable by design — INV-347-1) raised DISTINCT from
SessionApiFailed so callers branch feature-absent and never capability-probe.
- get_session_messages (GET /sessions/{id}/messages): un-deferred as the seed
read-back — confirms a seed renders as a normal role=assistant turn
(model-invisible provenance).
- --seed-first-message probe: create session -> seed -> read-back; a 404 reports
a benign feature-absent result (exit 0), never a capability-probe.
Contract #2 amended (2 FNs, validated OK). 19 new tests (12 wrapper + 7 cli),
suite 601 green. Coverage-map re-converged: REST 19/41 (the #347 route + the
messages read-back close the one gap the 2.3.0 re-vendor opened).
Live-proof pending the session.history.write grant (requested infra-ops).
#347 authored-history-write shipped — one new REST path-group
POST /sessions/{id}/history + AuthoredTurnResponse schema. OpenAPI-only:
the prose conversation-api-spec.md + server conversation_api.contract.md
are byte-unchanged since 5810a26 (empty git-log delta), SSE schema
unchanged (#347 is event-silent). Consumer side NOT yet built —
POST /sessions/{id}/history is a fresh in-scope gap that re-opens the
v1 coverage-audit (Heimdall-gated hide-existence; 404 = feature-absent).
Worldtree accepted the v1 wire validation green; contract rev 1.1 folds all three
consumer flags. Flag #1 surfaced a real provider constraint: first-message makes
the assistant seq 0 -> vLLM/openai_compat tolerate it (sindra unaffected), but
Anthropic-family providers 400 on an assistant-first array. Consumer must gate
first-message on provider compat; provider-agnostic normalization deferred.
Worldtree accepted the primitive as design item #347 (Worldtree-owned). Locked
constraint: Heimdall-gated with hide-existence — ungranted tenants get 404 (not
403), no advertised capability. Ratatoskr consumer side must tolerate per-tenant
absence: treat 404 as feature-absent -> graceful fallback, never capability-probe.
The pane now renders the LITERAL mood word + relationship directive Worldtree
context-injects into the agent — adopted from Worldtree's canon, not invented:
- canonMood(pad) mirrors Worldtree describe_pad (valence×arousal grid, ±0.3 bands);
for sindra's PAD the canonical render is "neutral" — an invented octant vocab
would have said "faintly excited" and MISLED. Adopting canonical is the point.
- canonDirective(rel) mirrors render_d2_canonical byte-exact: "...warmth is clear
warm regard; ability trust is strong; ...; speak with direct warmth; ..." — the
exact stance instruction the agent receives (which makes the WAD "stranger"
relation_context read even more incoherent, as flagged to worldtree-dev).
- Both VERIFIED byte-exact against Worldtree's OWN renderer on the live snapshot.
- Canon vendored (docs/vendor/worldtree-persona-canon/) + drift-pinned in
.corviduo-canonicals.toml (canonical_drift green); flat browser form
(static/persona_render_canon.json) regenerated by scripts/build_persona_canon.py
via Worldtree's authoritative loader. Reference-impl posture: adopt canonical.
- Fail-open (canon absent -> lines omit); INV-004 esc() preserved.
JS syntax clean. Refresh + drive turns to see the canonical NL under mood + each
relation.
The persona/affect pane read snap.valence (the pre-#265 shape) while Worldtree now
emits snap.relations (relation_edge/1) — so the whole trust/warmth model rendered as
an empty "valence (0)". Now renders the real signal, self-labelled:
- MOOD (PAD, transient): pleasure/arousal/dominance with a one-word descriptor each.
- RELATION → <target> (stage: <relation_context>): trust·ability / benevolence /
integrity + warmth, each as value + evidence_count (n=) — the durable social model.
- Per-value TREND: Δ-vs-previous (▲/▼) + a unicode sparkline auto-scaled to the value's
own observed range (flat when sub-0.01 stable, so noise isn't amplified). History
accumulates client-side, one sample/turn (deduped by emitted_at), capped at 24.
- Falls back to the legacy snap.valence for an older emitter; INV-001 (no fabricated
Tier-1 fields) + INV-004 (every cell escaped) preserved. Supersedes the #18-D2
valence assumption + retires the stale "regard dead axis" note.
Verified: render logic asserted in node against the REAL affect.db snapshot + a
perturbed 2nd sample (relations rendered, no "valence (0)", Δ ▲ shown, 2-char
sparkline builds, INV-004 holds). JS syntax clean. No server change (static served
per-request) — refresh + drive turns to watch the trends build.
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.
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.
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.
v1 coverage-audit remediation P-1: vendor the authoritative machine-
readable artifacts and pin them for drift-checking, advancing the spec
pin from v0.35.16 (f1b59f8) to v1.0.0b2 (5810a26).
- Vendor docs/conversation-api-openapi.json (OpenAPI 2.2.0, 40 path-
groups) + docs/conversation-api-sse-events.schema.json (11 events).
- Pin all three Conversation-API artifacts in .corviduo-canonicals.toml:
OpenAPI + SSE schema as strict drift gates (canonical_drift.py), the
prose markdown as tolerate_drift reference. Drift check green (10/10).
- pyproject: worldtree-spec-rev -> 5810a26, worldtree-version -> v1.0.0b2
(was stale at v0.29.0), pinned-on -> 2026-06-30.
- SPEC-PIN.md: current-pin table + history row + vendored-artifacts list.
- coverage-map.md: P-1 marked remediated; the map now audits a frozen,
diffable target.
The prose markdown is byte-identical to v0.35.16 (last WT edit
2026-05-31); the b2 surface lives only in the OpenAPI. No client-
facing code change (the b2 409/503 + unified error envelope were
already consumed in v0.18.3/.4) -> pin-only, no version bump.
First coverage map — every Worldtree v1-FROZEN I/O point x ratatoskr
status. Anchored on WT's frozen machine-readable artifacts (OpenAPI
2.2.0 = 40 REST path-groups + SSE schema = 11 events + bifrost wire
v0.6), not the stale vendored prose markdown.
- SSE 11/11 and Bifrost provider planes 8/8 covered + live-proven;
client REST 7/40 live, 11 in-scope frontier, 22 excluded-by-design.
- Scope mandate A (operator): v1 done = every frozen point classified
(covered-or-excluded-with-rationale), zero unaccounted; not a
feature-complete client.
- Finding P-1: vendored prose markdown is byte-identical to live WT
but frozen at v0.35.16-era content; b2's surface lives in the
OpenAPI 2.2.0 + SSE-schema JSON we don't vendor. Pin-remediation
pending operator nod.
No version bump (docs-only).
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.
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
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.
Clears the two ✗ FAIL (missing STEPS) the v2.1 parser surfaced.
#3: faithful STEPS for CliPresenterState.render, _format_duration_ms,
_format_usage (the two formatters also gain PRE/POST from their real
asserts). render STEPS enumerate AffectUpdate + AwaitingLlmFirstToken
as demoted telemetry (Worldtree #204/#201), extending POST-005 beyond
the issue #12 set.
#4: refresh the TUI presenter contract from the abandoned single-RichLog
double-display model to the shipped four-pane live-Markdown model
(v0.5.0-v0.14.0 + Worldtree #201/#204). Rewrites TuiPresenterState.render
and _stream_turn_worker (signature, POSTs, STEPS, TESTS), INV-005, the
[performance] constraint, the COMPOSE sketch, the CLASS block
(BRIEF/PROPERTIES/INV-WIRE-002), the resolved open_question, and the
_cancel_via_sse call site. Verified against src/ratatoskr/tui.py and the
real test names in tests/test_tui.py.
Both contracts: 0 validation errors (pre-existing multi-tracer warnings
on _run_turn / action_interrupt left untouched).
Pulls the upstream contract_parser.py canonical: v2.1 + issue-scoped
contract validation (target_module/scope/prd frontmatter, four new
test categories scenario/trace/adversarial/property, issue-aware
human/list output). Re-pins pinned_sha256_16 + pinned_at in the
manifest. canonical_drift.py reports 7/7 OK; no sync-induced contract
regressions.
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.
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).
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.
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).
Operator: "thinking tokens seem to be split by token — each on a
newline, is that correct? We don't want that."
Root cause: v0.6.5 wrote each Thinking SSE delta as its own
`thinking_log.write(event.content)` call. Worldtree emits Thinking
events at token granularity (per-token or per-few-tokens), so EACH
token became its own RichLog line — visually choppy, one short
fragment per visual row. Wrong UX.
## Fix: coalesce-on-newline
Thinking deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`
(new str field). On each Thinking event:
1. Append delta content to buffer.
2. Flush every COMPLETE line (chars before each `\n`) as one
thinking_log.write(line) call.
3. Leave the post-final-`\n` tail in the buffer for the next delta.
On any non-thinking event (run close):
1. Flush remaining buffer tail (if any) as one final line.
2. Write Rule(end).
Empty lines (blank paragraph separators in the model's `\n\n` flow)
are skipped — they'd render as no-content RichLog entries which
just add vertical noise. Natural paragraph breaks become single
visible lines; multi-paragraph thinking renders top-to-bottom.
## Verified live (tier-3 smoke against personal Worldtree)
Defined a `thinky-smoke` agent via `python -m ratatoskr.tier3 define`,
asked "What is 12 times 13?". Thinking pane rendered with natural
paragraph chunks:
── turn N · thinking #1 start ──
Thinking Process:
1. **Analyze the Request:** The user wants to know the result of $12 \times 13$.
2. **Calculate:**
* Method 1: Standard multiplication.
$$12 \times 10 = 120$$
$$12 \times 3 = 36$$
$$120 + 36 = 156$$
* Method 2: $(10 + 2)(10 + 3) = 100 + 30 + 20 + 6 = 156$.
── turn N · thinking #1 end ──
Each line = one natural paragraph or list item. No per-token fragments.
## Edge cases noted
- Long-running thinking with NO `\n` at all stays buffered until run
close → operator sees nothing until close. Possible follow-up: add
a length-threshold flush (e.g., > 500 chars → flush at the last
space). For now this is acceptable; thinking content typically has
`\n` breaks every few sentences.
- Empty deltas (`""`) are ignored implicitly — no buffer growth, no
flush.
- `\n` at the very start of a delta flushes whatever was buffered
before, then leaves the empty post-`\n` tail (empty string) in the
buffer, which doesn't show up as an empty line because of the
`if line:` guard.
## Contract amendment
docs/contracts/issues/13.contract.md INV-022 amended for v0.7.1
coalesce semantics. Drift-check clean.
## Tests
265/265 GREEN; ruff clean. Two updated tests:
- `test_thinking_streams_into_thinking_log` → renamed
`test_thinking_coalesces_until_newline`: 3 token-shaped deltas
with no `\n` → only Rule(start) writes, buffer holds accumulated.
- NEW `test_thinking_flushes_on_newline`: delta carrying `\n` →
Rule(start) + accumulated line + clear buffer.
- `test_thinking_closes_to_thinking_log`: 2 deltas "a", "b" +
close → Rule(start) + tail-flush "ab" + Rule(end) = 3 writes
(was 4 with per-delta).
Patch bump (v0.7.0 → v0.7.1) — internal presenter routing change;
no public-API or layout change.
Issue #15. Worldtree Phase 2.0 ships Tier 3 (consumer-defined)
agents at `<user_id>:<agent_name>`; ratatoskr now exposes their
lifecycle via a dedicated module + CLI tool. The picker handles
the colon-containing agent_id generically (per issue #8 out-of-
scope clause); session creation works unchanged. What was missing
was a way to DEFINE / PATCH / DELETE these agents from ratatoskr
itself — operators previously had to curl the API directly.
## Public surface (ratatoskr.tier3)
Tier3AgentInfo (frozen dataclass)
define_agent (client, *, agent_name, system_prompt, model) → Info
patch_agent (client, agent_id, *, system_prompt?, model?) → Info
delete_agent (client, agent_id) → None
Tier3QuotaExceeded — 429 agent_quota_exceeded (50-agent cap)
Tier3UserIdUnsupported — 403 tier3_user_id_unsupported
Tier3FieldNotMutable — 422 field_not_mutable (PATCH)
Tier3LayerDeferred — 422 layer_deferred (define, defense-only)
Tier3AgentNotFound — 404
SessionApiFailed (reused) — all other non-2xx
Caller-owned httpx.AsyncClient posture (same as ratatoskr.sessions).
Module is standalone — does NOT import sessions/sse_client/tui/cli
beyond reusing the USER_AGENT constant from cli.
## CLI (python -m ratatoskr.tier3 <subcommand>)
define --name <slug> --system-prompt <str> --model <id>
patch <agent_id> [--system-prompt <str>] [--model <id>]
delete <agent_id>
Auth resolution mirrors ratatoskr.cli verbatim — --api-key flag >
$WORLDTREE_API_KEY > exit 11. Server URL via --server >
$WORLDTREE_API_URL > http://localhost:8000. Exit codes follow the
cli.py matrix: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21
(network).
## Real-world finding from live smoke
Tier-3 agents do NOT appear in `GET /agents` — the public list
filters them out. The picker won't surface tier-3 agents; operators
bypass it via `ratatoskr --send "..." --new --agent ratatoskr:<n>`
directly. This contradicts the contract's acceptance assumption
("the new tier-3 agent should appear in the list") — caught at
smoke time. The picker integration was hopeful; the real shape is
"you know your tier-3 agent_id because you defined it." Adding a
ratatoskr-side `tier3 list` subcommand would need a Worldtree
endpoint that doesn't exist today; surfacing to worldtree-dev as a
followup.
## Live lifecycle smoke (personal Worldtree v0.16.2)
$ python -m ratatoskr.tier3 define --name smoke-tier3 \
--system-prompt "..." --model qwen3.6-35-a3b
→ defined ratatoskr:smoke-tier3 (qwen3.6-35-a3b)
$ ratatoskr --send "hello via tier-3" --new --agent ratatoskr:smoke-tier3
→ [done] turn_id=286 model=qwen3.6-35-a3b duration=14.2s
usage 44 in → 390 out (434 total, 0 cached)
$ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3
→ deleted ratatoskr:smoke-tier3
$ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3
→ [agent_not_found] ratatoskr:smoke-tier3 (exit 20)
The colon-containing agent_id flowed transparently through
ratatoskr.sessions.create_session, the SSE stream's text +
worker_phase + done events all rendered correctly, and the
ratatoskr.sessions module needed zero changes.
## Contract
docs/contracts/issues/15.contract.md — new module spec; drift-check
clean. Acceptance criterion about "appears in GET /agents" should be
amended in a follow-up to reflect the empirical finding.
## Tests
+26 tests (264 total GREEN, was 238). Covers all error paths via
respx mocking — quota, user_id, layer_deferred, field_not_mutable,
404, 5xx — plus CLI happy + error paths. ruff clean.
Minor bump (v0.6.5 → v0.7.0) per SemVer etiquette: new public
module + CLI surface; new caller-visible behavior.
Operator: "Why does the thinking scroll a little section at the
bottom of the thinking pane instead of scrolling the whole pane?"
Root cause: v0.6.1's thinking-current Static was docked to the
bottom of the Thinking pane and rendered the last 200 chars of
streaming content. As deltas arrived, the displayed 200-char tail
shifted — old text fell off the left, new text appeared on the
right — visually reading as "a little section scrolling at the
bottom" while the larger thinking-log RichLog above showed only
the previous run's closed content (or nothing on first turn).
## Fix: stream directly into thinking-log
The Static is gone. Thinking deltas now write straight to the
`thinking-log` RichLog (one delta = one line in the scrollable
log). The whole pane scrolls naturally as content arrives —
operator can switch to Ctrl+3 and see streaming content fill
the pane top-to-bottom.
Routing pattern:
First Thinking delta of run:
→ write Rule(title="turn N · thinking #K start") to thinking_log
→ write delta content as a line
→ set thinking_open = True
Subsequent Thinking deltas:
→ write delta content as a line
Non-thinking event (closes the run):
→ write Rule(title="turn N · thinking #K end") to thinking_log
→ reset thinking_open
The Rule(start) at the top of an in-progress run is now the
"thinking is happening" indicator. No more separate live-preview
widget required.
## Trade-off: no markdown re-render
Pre-v0.6.5 closed runs got a Markdown(full_content) render between
the start/end Rules. v0.6.5 drops that — the streamed deltas ARE
the content; re-rendering as Markdown would either need to wait
for run-end (no streaming) OR re-render incrementally per delta
(bad UX). Streaming wins for "live observability" framing.
The downside: if model thinking has Markdown structure (lists,
code), it renders as raw text. Acceptable per operator's "stream
in line" framing.
## Removed widgets
- `Static#thinking-current` (right column / Thinking pane bottom)
- `TuiPresenterState.render` no longer takes a `thinking_widget` param
- `TuiPresenterState.thinking_buffer` field dropped (no accumulation)
- `_stream_turn_worker` no longer queries `#thinking-current`
- `on_mount` no longer hides `#thinking-current`
- DEFAULT_CSS `#thinking-current` block removed
## Contract amendment
INV-022 amended: thinking now streams as raw delta lines, not
Markdown-rendered on close. INV-024 amended: thinking-current
Static removed entirely (was relocated v0.6.1, removed v0.6.5).
Drift-check clean.
## Tests
238/238 GREEN (was 241 — 3 obsolete widget tests deleted:
test_thinking_widget_truncation, test_thinking_widget_visibility_lifecycle,
test_terminal_events_belt_and_braces_widget_cleanup). 5 routing tests
rewritten for the new streaming shape (test_thinking_streams_into_thinking_log,
test_thinking_closes_to_thinking_log, test_multiple_thinking_runs_...,
test_render_exception_fallback, test_cancelled_mid_thinking_closes,
test_left_column_content_only).
ruff clean. Manual injection test confirms routing: Rule(start) +
delta lines write to thinking_log; transcript untouched.
Patch bump (v0.6.4 → v0.6.5) — internal restructure within Thinking
pane; presenter signature narrowed; no caller-visible public API
change (RatatoskrApp + AgentPickerApp surfaces identical).
Operator-driven big-batch polish + restructure:
## 1. Streaming text — no more per-token RichLog spam
Pre-v0.6.0, every Text SSE delta wrote its own RichLog line, so
"Let me read the..." became 4+ separate lines (a Worldtree-style
sentence-by-sentence reveal that read as broken). v0.6.0 adds a
`#current-text` Static docked above the prompt; TuiPresenterState
buffers Text deltas in `text_buffer` and updates the Static in
place. On terminal event the Static clears and the transcript
gets:
- raw=False: post-Done Markdown body + Rule separator
- raw=True: accumulated plain text
The Static collapses to height=0 when empty so the prompt sits at
the column bottom unchanged.
## 2. Turn-ID headers across every pane
`_stream_turn_worker` writes a `Rule(title="turn N")` to all four
log panes (transcript, tools, debug, thinking) on the first event
of each new turn. Operators can now visually correlate "what
happened in Tools during turn 42" by section markers in matching
positions across panes.
## 3. New Thinking TabPane (Ctrl+3)
Closed thinking runs now route to `#thinking-log` (a dedicated
TabPane) instead of `#debug-log`. Each closed run writes three
entries:
- Rule(title="turn N · thinking #K start")
- Markdown(thinking_content)
- Rule(title="turn N · thinking #K end")
Model reasoning often has lists/code/structure — rendering as
Markdown (instead of the previous "· thinking: ..." prefix line)
makes it scannable. The `thinking_run_index` counter scopes per
turn so multi-thinking-run turns get distinct markers.
`thinking-current` Static (live per-delta preview) stays in the
right column above TabbedContent (unchanged from v0.5.0) — live
visibility persists across tab switches.
## 4. Agent picker — multi-line items, full description visible
Pre-v0.6.0 the picker rendered each agent as a single Label with
"{id} · {name} — {description}", which truncated descriptions
visually. v0.6.0 uses two Static children per ListItem:
- bold Aurora bright-blue line: "{agent_id} · {name}"
- wrapped Sea dark-60 line(s): full description
ListItems are auto-height so long descriptions wrap as needed.
Highlighted (--highlight) row uses Sea dark-30 background instead
of Aurora blue (which the operator flagged as ugly).
## 5. Kill residual blue chrome
The user's "background is still blue" report traced to the prompt
Input's focused border, which I'd set to $primary (Aurora blue).
Switched to $au-bright-cyan (#42dcd1) — focus highlight is now
cyan, consistent with the operator's-voice accent throughout the
TUI. Also added explicit overrides for ContentTabs strip
background + active-tab underline color → Australis cyan.
## 6. Surfaced emotion-appraisal request to worldtree-dev
User asked for emotion-appraisal telemetry, but no SSE event for
this exists in the spec — persona/Vili affect lives in persona.log
(file-tail, blocked on remote-Worldtree topology) and per-character
state (poll endpoint, not per-turn). Posted an althing thread
proposing two shapes (worker_phase payload extension OR new
affect_update event type) and routing the decision to their team.
A 4th `Emotion` TabPane plugs in trivially when a wire event lands.
Low-priority / quality-of-life framing — not blocking ship.
## Contract amendment
docs/contracts/issues/13.contract.md amended in-place: INV-019
extended to 3 TabPanes; new INV-021 (Text → current_text Static),
INV-022 (thinking closed runs → thinking_log with Markdown +
start/end Rules), INV-023 (turn-ID headers across all panes),
INV-024 (thinking-current Static stays in right column with
"thinking… " prefix per v0.5.1 polish). INV-020 (render-exception
fallback routing) updated for Thinking → thinking_log. Drift-check
clean.
## Tests
241 GREEN (down from 244 in test count — 5 routing tests rewritten
for the new shape, replacing the v0.5.0 thinking-in-debug-log
assertions with the v0.6.0 thinking-log-as-Markdown shape; net
test coverage equivalent). ruff clean.
Live smoke against personal Worldtree's mimir confirmed:
- transcript: 27 lines (turn header + user echo + done +
markdown body, NO per-token spam)
- thinking_log: 19 lines (turn header + 2x thinking start/end
Rule sections with Markdown bodies)
- current_text cleared post-Done
Minor bump (v0.5.1 → v0.6.0) per SemVer etiquette: visible routing
+ new pane = operator-observable surface change.
Two operator-driven changes off v0.4.1:
1. **Main pane is content-only.** Pre-v0.5.0 the transcript mixed
assistant text with telemetry (Thinking closed runs, WorkerPhase,
TextBoundary) — only tool events were factored out per #13. The
transcript now receives ONLY: user-prompt echo, assistant Text
deltas, [done]/[error]/[cancelled] terminal labels, and the
post-Done Markdown render. All telemetry routes to a new Debug
tab in the right column.
2. **Chrome no longer blue.** Textual's default Header / Footer /
active-tab styling tints with `$primary` (Aurora blue under
Australis), which read as garish on dark terminals. Header,
Footer, and the TabbedContent tab strip get explicit
`background: $surface` (Sea bright-black #373b46) so the chrome
sits cool and unobtrusive against the Ice black background.
## Layout reshape
```
LEFT COLUMN (content only): RIGHT COLUMN (telemetry):
transcript (RichLog, 1fr) thinking-current (Static, dock top)
prompt (Input, dock bottom) TabbedContent:
Tools (tool_start, tool_result)
Debug (thinking, worker_phase,
text_boundary)
```
The thinking-current live-preview Static moves from left → right
column so the left column is genuinely content-only. Live thinking
visibility now persists across tab switches (it docks above the
TabbedContent, not inside any tab).
## Presenter routing (TuiPresenterState.render)
Signature widens with `debug_log: RichLog`. Routing matrix:
Text → log (transcript)
Done / Error / Cancelled → log (transcript) [terminal labels]
ToolStart / ToolResult → tools_log (Tools tab)
Thinking (closed run) → debug_log (Debug tab)
WorkerPhase → debug_log (Debug tab)
TextBoundary → debug_log (Debug tab)
Thinking (per-delta) → thinking_widget (live preview)
INV-009 render-exception fallback preserves routing per event class
(new INV-020) — ToolStart/Result falls back to tools_log;
Thinking/WorkerPhase/TextBoundary to debug_log; everything else to log.
## Keybindings
- Ctrl+1 → Tools tab (existing, unchanged)
- Ctrl+2 → Debug tab (NEW)
`pane-name` footer widget updates dynamically as the operator
switches tabs ("Tools" ↔ "Debug"). This was previously deferred to
"the multi-tab issue" per the Volva contract-review amendment;
multi-tab now exists, so the dynamic update lands here.
## Contract amendments
docs/contracts/issues/13.contract.md amended in-place:
- INV-015 amended: transcript is content-only; telemetry routes to
debug_log. Old routing (telemetry in transcript) retired under the
no-backwards-compat rule.
- INV-017 amended: thinking-current docks to right column (was left).
- INV-019 new: two TabPanes (Tools + Debug), Ctrl+1/Ctrl+2 bindings,
dynamic pane-name update.
- INV-020 new: render-exception fallback preserves per-event-class
routing.
- Layout-spec snapshot ASCII diagram updated.
Drift-check clean.
## Tests
239/239 GREEN (+3 new: debug_tab_exists, ctrl_2_activates_debug_tab,
pane_name_updates_on_tab_switch). 6 existing tests adjusted for the
new routing (test_thinking_closes_one_debuglog_entry,
test_multiple_thinking_runs_each_get_debuglog_entry,
test_render_exception_fallback,
test_cancelled_mid_thinking_closes,
test_worker_phase_demoted_to_debug_log,
test_left_column_content_only). ruff clean.
Live smoke against personal Worldtree: mimir KB-search turn
populated tools_log with 11 lines of tool events (search_library +
read_note); debug_log with 20 lines of worker_phase + thinking
content; transcript stayed content-only with `❯ user-prompt`
(Aurora bright-cyan) + assistant text deltas. Routing matrix
holds end-to-end. (Diagnostic note: RichLog.lines is the rendered-
output buffer; inactive TabPane content shows lines=0 until the
tab activates and renders. Internal write store is correct — this
is a Textual rendering quirk, not a routing bug.)
Minor bump (v0.4.1 → v0.5.0) per SemVer etiquette: visible routing
surface change for operators; transcript and Debug tab contents
look different from yesterday's v0.4.1.
Reshape the TUI from vertical-stack single-pane to Horizontal
two-column with TabbedContent on the right; v1 has a single Tools
tab that consumes ToolStart/ToolResult SSE events previously
rendered inline in the transcript. Foundation for the rest of
design-brief §5; subsequent panes (Persona/AdminEvents/BifrostState/
ServerLog) plug in as sibling TabPanes when their substrate
blockers resolve.
Three coupled pieces, all in-place amendments to issues #4 + #12:
- **Layout**: compose() yields Horizontal#main-row containing
Vertical#left-column (transcript + thinking-current + prompt) and
Vertical#right-column (TabbedContent#side-panes with
TabPane#tools-tab → RichLog#tools-log). Width split 2fr:1fr. CSS
dock rules narrow to per-container scope so thinking-current
toggling doesn't reflow the right column.
- **Tools pane**: TuiPresenterState.render() signature widens with
tools_log: RichLog. ToolStart/ToolResult route there per INV-014;
every other event keeps its issue-#12 routing. Plain-label
fallback under render-exception preserves routing (INV-009).
- **Ctrl+1 binding + pane-name widget**: BINDINGS gains
Binding("ctrl+1", "focus_tools") which programmatically sets
TabbedContent.active; Textual's default preserves Input focus per
INV-016 (test asserts; regression path documented).
Static#pane-name in the footer renders "Tools" v1 (static — no
tab-switch handler wiring lands in #13 per amendment-2 from
Volva paraphrase review).
CLI mode (--send) is unaffected by design per INV-018 — non-
interactive, no tabs concept; CLI keeps inline tool-event rendering.
Contract: docs/contracts/issues/13.contract.md (drift-check clean,
two amendments applied from Volva contract-paraphrase pass).
Tests: +9 net (TestLayoutShape × 7 + TestTuiPresenterState routing
× 3, minus 1 deprecated test_tool_start_demoted superseded by
test_tool_start_routes_to_tools_log). 236 total GREEN; ruff clean.
Live smoke against personal Worldtree's mimir: tool-using turn
(KB search) populated tools_log with tool_start + tool_result for
search_library + read_note; transcript stayed chat-only with
worker_phase + thinking. Routing-not-duplication confirmed
end-to-end.