107 Commits
Author SHA1 Message Date
vh c62b4eecb3 feat(#20): agents/tier3 family onto the wt adapter + model→role fold (slice-4)
Cut ratatoskr's consumer agent-lifecycle routes over to worldtree-sdk
(issue #20 slice-4). Five routes now flow through `ratatoskr.wt` over the
SDK's `client.agents.*`, returning open-world dicts and mapping the SDK's
undiscriminated `ApiError` floor by route+(status,error_code) per INV-CUT-2:

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

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

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

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

Error-map rows + slice-4 notes added to the cutover contract; coverage-map
re-anchored. LIVE-SMOKE on personal :8081 (b128): define(thoughtful-character)
→ patch → list(6 agents) → persona_state(→PersonaNotConfigured mapped) →
delete → index empty; non-existent-id patch via `-m` → [agent_not_found]
exit 20. Suite 465 green.
2026-07-19 09:58:01 -07:00
vh fc256bbaa4 fix(#20): heid-bug-hunt fixups — probe ConnectFailed + adapter finite-PAD (slice-3)
The slice-3 heid-bug-hunt panel (3/3) caught a real regression the cutover
introduced, plus a chokepoint-invariant gap:

- ConnectFailed escaped both rewired CLI probes. When --set-persona-pad and
  --seed-first-message moved off raw httpx onto the wt adapter, transport failures
  changed class: the SDK normalizes any pre-response transport error to
  worldtree_sdk.ConnectFailed (request.py), a WorldtreeError (not ApiError), so it
  passed the adapter unmapped AND the probes' httpx-only except tuples → an uncaught
  traceback instead of the graceful [network_error] exit 21. _amain (slice-2) already
  handled it; the probes lagged. Fix: add ConnectFailed to both probe except tuples
  (mirrors _amain). Live-verified at a refused host → [network_error] exit 21.

- Finite-PAD enforced only at the CLI, not the adapter chokepoint. wt.set_persona_state
  delegated finiteness to the caller (documented), so a direct/non-CLI caller passing
  nan/inf got a raw SDK ConfigurationError. Fix: assert finiteness in the adapter
  precondition (consistent with its other precondition asserts) so the invariant holds
  at the chokepoint in ratatoskr's own terms; the CLI pre-check stays for the friendly
  usage error.

Triaged-and-declined (all correct per the panel + Heid's source-check): the deleted
sessions.py exports (intended no-shim cutover, zero un-migrated importers), the
session["session_id"] index (accept-known-risk, matches --new), and Regin's "web
indefinite block" (refuted — the seed is asyncio.wait_for-bounded). The concurrent
heid-code-review panel returned zero drift, no code change.

TDD: 3 RED tests (both probes' ConnectFailed → exit 21; adapter nan/inf/-inf →
AssertionError, never reaches the SDK) → GREEN. Suite 469; ruff clean; mypy no new errors.
2026-07-19 09:13:47 -07:00
vh ca9a339050 feat(#20): persona + authored-history + first-message onto the wt adapter (slice-3)
Slice-3 of the worldtree-sdk cutover: migrate the session persona-state write,
the #347 authored-history write, and get_session_messages onto ratatoskr.wt, and
route the first-message preset seed through the adapter. Retire the last
hand-rolled sessions.py paths the --seed-first-message probe kept alive
(create_session + SessionInfo, set_persona_state, write_authored_history,
get_session_messages, _bifrost_error_from).

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

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

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

Suite 466 green; mypy no new errors (baseline 22 → 20 in the touched modules);
ruff clean. INV-CUT-1..5 held. Bifrost provider planes untouched.
2026-07-19 08:53:45 -07:00
vh aba17304bd fix(#20): heid-bug-hunt fixups — cutover edge-path robustness (slice-2)
Triaged the heid-bug-hunt panel (Gróa 8 / Hulda 6 / Regin 6; Heid source-checked +
refuted 2 Regin FPs). The lens pulled real weight — confirmed bugs the conformance
review structurally could not see.

Confirmed bugs fixed:
- SessionRetired (410) stream-open maps to wt.SessionApiFailed, but neither cli
  _run_turn nor web gen() caught it → crash / dropped SSE stream. Both presenters now
  catch it (cli → exit 20; web → labeled `event: error`). (Gróa#2) + cli regression test.
- cli forwarded consumer_key unconditionally; an UNBOUND create with the env key set
  would auth as the Bifrost consumer, not the default bearer. Guarded in the adapter
  (consumer_key only when bifrost is set). (Gróa#4 + Regin#4) + test.
- cli _turn_id_from_sse_id crashed on a None/non-str sse_id (web guarded, cli didn't)
  → now tolerant. (Gróa#1 + Hulda#2) + test.
- _cancel_and_log broadened to `except Exception` — after the code-review's ApiError
  default, a cancel could raise SessionApiFailed it didn't catch, breaking INV-009
  (never-raise). (Gróa#3, Heid-endorsed over Regin's refuted mechanism).

Open-world degrade-not-crash (contract posture): render hardened — float duration_ms
(_format_duration_safe), non-mapping usage/snapshot guards, unknown event type
degrades instead of asserting (Gróa#5/#6 + Hulda#3); web _event_to_browser_payload
guards a non-mapping `raw` (Hulda#4); web _wt_client bearer extraction is now
case-insensitive + whitespace-robust (Hulda#5 + Regin#5). + render-degrade test.

Rejected (verified): Regin#1 (httpx IS caught), Regin#2 (wtsdk IS worldtree_sdk),
Regin#3 (sse_client.AgentNotAvailable IS caught by SseConnectFailed) — all FPs;
Hulda#1 (deleted funcs "break callers") — grep-verified zero callers pre-deletion.
Accepted-known-risk: lenient sse_id parse, CancelFailed status=0, async-gen aclose
(pre-existing pattern, not a cutover regression).

Suite 497 green; wt/cli/web ruff + wt mypy clean. Patch.
2026-07-19 07:09:26 -07:00
vh 74d41eb559 fix(#20): heid-code-review fixups — INV-CUT-2 completeness on cancel/stream (slice-2)
Triaged the heid-code-review panel (Gróa + Hulda substantive, Regin zero=weak).

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

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

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

Suite 493 green; wt.py mypy + ruff clean. Patch.
2026-07-19 06:57:52 -07:00
vh 59602fe3ff refactor(#20): delete the orphaned hand-rolled turn-stream paths (slice-2, part 2b-iii)
DEC-4 live smoke PASSED first (personal :8081, b127/b128): create → streamed turn
that rendered (worker_phase/text/text_boundary/done with usage) → SIGINT cancel that
round-tripped to a cancelled terminal. With both CLI + web on the adapter, the
hand-rolled turn-stream family is fully orphaned — deleting it now.

- sse_client.py (714 → 224): removed stream_turn / reconnect_turn /
  stream_turn_resilient / cancel_turn + the Event dataclasses (Text/Done/…/Event
  union) + CancelResult + the SSE parse helpers (_iter_events / _envelope_for_type /
  _parse_sse_id / _eager_failure_fields / _INT_RE). KEPT: the caller-semantic
  exceptions (the adapter raises them, DEC-2), SseId, AdminEvent, stream_admin_events
  (slice-6 admin surface).
- sessions.py (677 → 608): removed list_sessions + get_session_tools (no surface
  users) + SessionPage. KEPT: create_session / get_session_messages (the
  --seed-first-message probe still uses them, slice-3) + all exceptions + SessionInfo.
- tests: test_sse_client pruned to TestStreamAdminEvents; test_sessions dropped the
  list_sessions + get_session_tools classes. The deleted turn-stream behavior is now
  covered by test_wt.py + the CLI/web integration tests + the live smoke.

Suite 490 green (570 − 80 deleted turn-stream tests); ruff clean on all touched
files; no new mypy errors. Patch (internal cleanup; behavior preserved).
2026-07-19 06:35:04 -07:00
vh 5c595b862d feat(#20): rewire the web turn surface onto the wt adapter (slice-2, part 2b-ii)
The Starlette endpoints (create / stream / cancel / tools / messages) now go through
ratatoskr.wt over the worldtree-sdk; the browser contract is preserved. This is the
last consumer of the hand-rolled turn-stream family — after this, stream_turn* /
cancel_turn are orphaned and get deleted in part 2b-iii (with the live smoke).

- _wt_client wraps a client_factory transport as the adapter's WorldtreeClient
  (INV-CUT-1), reading base_url + bearer off the transport (a no-auth test transport
  falls back to a placeholder key). The hand-rolled endpoints (persona / agents /
  admin / bifrost) keep using the raw transport until their slices.
- _event_to_browser_payload derives the browser payload from the SDK's `raw` (the
  wire body) minus the redundant `type`, plus the composite `sse_id` string — the
  SAME shape the old dataclasses produced, so the presentation fixture + browser JS
  are unchanged; the browser event_type is the wire `type`, not the SDK class name.
- The stream endpoint captures the upstream cancel target from the composite sse_id
  (the SDK's top-level turn_id is body-derived, absent on text frames); create reads
  the SDK's open create dict; cancel reads CancelResult.cancelled and surfaces a
  generic 502 for CancelFailed (the SDK abstracts the upstream cancel HTTP status).
- test_web_presentation_contract builds SDK events via build_event; two cancel tests
  adopt the SDK's (status, error_code) race pairs + the 502.

Suite 570 green; web/server.py + presentation test ruff-clean, mypy unchanged
(same pre-existing errors). Patch (internal; browser contract preserved).
2026-07-19 06:22:13 -07:00
vh e3a10ad80e feat(#20): rewire the CLI turn path onto the wt adapter (slice-2, part 2b-i)
The --send turn path (_amain create + _run_turn stream + _cancel_and_log) now goes
through ratatoskr.wt over the worldtree-sdk; external CLI behavior (output, exit
codes) is preserved. No hand-rolled path is deleted yet — web/server.py still uses
them (part 2b-ii), so the deletions + live smoke come after web is rewired.

- _amain builds one WorldtreeClient via wt.build_client over a ratatoskr-owned
  transport (INV-CUT-1); create → wt.create_session (reads the SDK's open create
  dict); the transport keeps the default bearer so the not-yet-migrated hand-rolled
  seed_preset_first_message (slice-3) still authenticates.
- _run_turn drives wt.stream_turn and consumes SDK TurnEvents; the mid-stream cancel
  target is parsed from the composite sse_id ("{turn}:{seq}") — the SDK's top-level
  turn_id is the body field and is absent on text/thinking frames.
- CliPresenterState.render consumes the SDK TurnEvent union with None-hardening on
  the now-optional fields (usage degrades to "(n/a)" rather than crashing).
- The SDK normalizes a pre-response transport failure to ConnectFailed(status=0);
  _amain (network → exit 21) and _cancel_and_log (swallow, INV-009) catch it.
- build_client gains max_reconnects (SDK default 5; tests pass 0 to surface drops
  immediately). test_cli: SDK-event factories keep the render-test bodies intact;
  client constructions wrap in build_client; cancel-race mocks carry the SDK's
  (status, error_code) pair.

Suite 570 green; cli.py + wt.py mypy + ruff clean (the pre-existing send_content
arg-type note is unchanged). Patch (internal; external CLI behavior preserved).
2026-07-19 06:07:50 -07:00
vh b907a7b8a5 feat(#20): stream + cancel adapter routes complete the wt surface (slice-2, part 2a)
Completes the adapter's session/turn surface, still additive and non-breaking (no
surface rewired, no hand-rolled path deleted — the cli/web rewire + deletions +
live smoke are part 2b).

- stream_turn: drives the SDK's resilient stream (auto-resume absorbs the old
  reconnect_turn) and yields SDK TurnEvents, re-wrapping the stream's TERMINAL SDK
  errors into ratatoskr's caller-semantic exceptions per DEC-2 (SessionRetired →
  SessionApiFailed; AgentNotAvailable / TurnLaunchUnavailable / MalformedSse* /
  TurnIdFlip → ratatoskr's same-named types; ConnectionDropped → SseConnectionDropped;
  ConnectFailed / terminal ResumeError → SseConnectFailed). The presenter keeps
  catching ratatoskr types (part 2b aligns the except clauses).
- cancel_turn: returns the SDK CancelResult (a 200 cancelled=False is the benign
  late-cancel race, B-CAN-3), mapping the typed cancel races onto ratatoskr's
  CancelTurnNotFound / CancelAlreadyCompleted / CancelFailed.
- SseConnectionDropped.last_seen_sse_id widened to SseId | str | None: the SDK's
  resume cursor is a raw composite-id str (the cutover's target form); the
  hand-rolled path's SseId stays accepted until it is deleted. The one live reader
  (stream_turn_resilient) generalizes cleanly — a str cursor is already the id.

Suite 570 green (555 + 15); wt.py + sse_client.py mypy + ruff clean. Patch.
2026-07-19 00:31:16 -07:00
vh bb158ae47d feat(#20): sessions read/create adapter routes — ratatoskr.wt (slice-2, part 1)
First slice-2 increment: the presenter-independent sessions routes, additive and
non-breaking (no surface rewired, no hand-rolled path deleted yet — the cli/web
rewire + deletions + live smoke land in part 2).

- create_session / list_sessions / get_session_messages / get_session_tools over
  WorldtreeClient.sessions.*, each building the request from ratatoskr's domain
  params and mapping the SDK's ApiError floor by ROUTE (INV-CUT-2): create 404 →
  AgentNotFound, bound 502 → BifrostHandshakeFailed, list 422 cursor_invalid →
  InvalidCursor, else the SessionApiFailed default.
- Open-world reads returned VERBATIM (parity-pass posture): the routes return the
  SDK's open dicts, not ratatoskr's typed SessionInfo/SessionPage — those typed
  result shapes retire when the presenters are rewired to read mappings (adopt the
  dep's canonical open-world way, reference-impl doctrine).
- Transitional: wt imports the caller-semantic exceptions + BifrostBinding from the
  retiring sessions module (one-way, no cycle); they relocate into the adapter as
  their call-sites are rewired.
- Cancel + the resilient turn STREAM are deferred to part 2, where they wire into
  the async presenter loop and are validated by the live smoke.

Suite 555 green (541 + 14); mypy strict + ruff clean. Patch (internal, additive).
2026-07-19 00:19:33 -07:00
vh 12cd8642fa feat(#20): worldtree-sdk adapter foundation — ratatoskr.wt (slice-1)
Slice-1 of the SDK cutover (docs/contracts/worldtree_sdk_cutover.contract.md):
the adapter chokepoint onto worldtree-sdk 1.0.0, unit-tested but not yet wired
to any surface (that is slice-2).

- build_client(base_url, *, api_key, admin_key=None, transport) constructs the
  single WorldtreeClient over a ratatoskr-owned injected httpx.AsyncClient.
  INV-CUT-1: the SDK is given the transport (_owns_client=False) and never closes
  it — proven by a test asserting aclose() leaves ratatoskr's transport open.
- translate_error implements the § Error map DEFAULT: SDK ApiError → the adapter's
  SessionApiFailed (carrying the SDK's parsed status/error_code/body); every
  discriminated WorldtreeError subclass passes through by identity. Route-specific
  rows land at their call-sites in later slices (the route is the discriminator).
- SessionApiFailed gains error_code vs the retiring sessions.py copy (extends it
  per the contract error-map row); the two coexist transiently and reconcile in
  slice-2 (DEC-4 incremental cutover — nothing wires the adapter this slice, so
  they never meet at runtime).

Deletes no hand-rolled path, so DEC-4's live-smoke bar does not apply yet.
Suite 541 green (534 + 7 new); mypy + ruff clean. Patch (internal foundation;
the cutover's minor bump is DEC-6 at slice-7 ship).
2026-07-18 23:59:33 -07:00
vh 29c4fda12e build(deps): integrate worldtree-sdk==1.0.0 (DEC-5 verified) — #20 slice-1
Add the worldtree-sdk (Python) 1.0.0 consumer client to core deps + the gitea uv
source (reuses the existing bifrost gitea-index auth). DEC-5 precondition met: uv
resolves + installs it from the registry; WorldtreeClient constructs with an
injected httpx.AsyncClient and _owns_client=False (INV-CUT-1 transport ownership
confirmed live). httpx-sse retained until slice-7 teardown. Full suite 534 green —
the dep is inert until ratatoskr.wt wires it (next).

No version bump (dependency add, no runtime code consumes it yet). Refs #20.
2026-07-18 23:22:24 -07:00
vh c7016f23a6 feat(#19): ephemeral-template (Echo) session creation
create_session could only mint foundational sessions; an ephemeral template
(agent_id="echo") returned 422 ephemeral_requires_config because ratatoskr never
sent the required config block — Echo was uncreatable, surfacing as an opaque
session_api_failed at the CLI. Thread an opaque, role/model-agnostic config
passthrough through the create path so Echo sessions are creatable.

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

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

Closes #19.
2026-07-18 12:02:18 -07:00
vh 860e0d56bb fix(tier3): adapt define/patch to b125 role schema (was model)
Live Worldtree b125 changed POST /agents/define: the request field is
now 'role' (a model-role like 'thoughtful-character'), replacing 'model';
the response still echoes it as 'model'. Update define_agent/patch_agent
request bodies + CLI (--model -> --role); response parse + LocalAgentEntry
unchanged. Verified end-to-end against live (delete->define round-trip);
26 tier3 tests green. Full b22->b125 spec-pin bump remains a follow-up.
2026-07-17 20:11:22 -07:00
vh 3f3a9f7b0f refactor(cli)!: remove deprecated textual TUI; web console is the interactive surface
The textual TUI (tui.py) is superseded by the web console (ratatoskr-web)
and is removed per the no-backwards-compat rule. The `ratatoskr` command
stays as a headless client: --send / --whoami / --characters /
--set-persona-pad / --seed-first-message still work; invoking it with no
--send now returns a usage error (rc 10) instead of launching the TUI.

Removed: src/ratatoskr/tui.py, tests/test_tui.py, the textual + textual-dev
deps, and cli.py's run_tui launch path. cli.py's shared exports (USER_AGENT,
ParsedArgs, formatters) stay — web/entrypoint.py and tier3.py depend on them.

BREAKING CHANGE: the interactive `ratatoskr --agent X` TUI is gone; use the
web console (ratatoskr-web) for interactive debugging, or --send for scripted.

Verified: full suite 520 passed; ratatoskr --help exit 0; no-send -> rc 10;
web/provider/tier3 import clean; textual absent from the lockfile.
2026-07-17 13:46:20 -07:00
vh c6788539ac feat(web): collapsible hideable left+right debug panes
Each debug pane (#rail-left, #affect-console) gains an independent
collapse control: a chevron button in the pane header folds it to a
22px re-open strip, and #center (flex:1) reflows to fill. State
persists per-pane in localStorage (ratatoskr-left-collapsed /
-right-collapsed), matching the theme/cot-toggle idiom; the right-side
drag-resizer hides with its pane. Collapsing both yields a clean
chat-only surface.

Playwright-verified end-to-end (default-open, collapse-to-strip,
center reflow, independent left/right, reload persistence, re-open).
2026-07-17 13:27:01 -07:00
vh 5a1883a023 feat(web): code-block word-wrap + per-code-block and per-turn copy buttons
Operator UX asks on the web console:
- Code blocks now WRAP (`white-space: pre-wrap; overflow-wrap: anywhere`) instead of
  overflowing with a horizontal scrollbar — a long unbreakable token wraps in place.
- Per-code-block copy button (hover-revealed, top-right of each `.md-code-wrap`).
- Per-turn copy button (in the live turn-rule; top-right on seeded/historical turns) —
  copies the turn's response text; the existing think-inline copy is unchanged.

One delegated click handler on #tw covers both (works across live / historical / think
renders where blocks are injected via innerHTML). `copyText` falls back to a hidden
textarea + execCommand when `navigator.clipboard` is absent (plain-http LAN context),
and buttons flash "✓ copied". Static file served from disk — a browser refresh picks it
up, no :8765 restart. Playwright-verified: code wraps (no x-overflow), both buttons copy
the right text; 90 web tests green.
2026-07-17 08:58:22 -07:00
vh be2c577884 feat(provider): mark_superseded verb — Worldtree #364 contradiction retirement + bifrost 1.1.4
Implement `mark_superseded(ids, *, superseded_by=None, reason=None)` — the SOLE
supersession verb Worldtree #364's promotion-hygiene reconciliation calls to retire
contradicted facts (wire shape confirmed by worldtree-dev, bifrost_memory_store.py:293).
Live re-verify (2026-07-16) proved our provider 500-crashed on this call (unimplemented)
→ #364's retirement couldn't land + a retry-storm bloated the store; the readout only
passed via transient recency-eviction.

- `mark_superseded` mirrors the reference `_mark_lifecycle`: sets top-level
  `superseded=True` (+ `superseded_by`/`superseded_reason` when non-None), increments
  revision, NON-destructive (get still returns; recoverable). Unknown ids skipped.
- `_is_live` (INV-011) now short-circuits on `superseded is True`, so a retired chunk is
  excluded from `scan` (person-prime) — durable retirement, not just recency-eviction.
  search is unfiltered (matches reference; WT re-checks liveness client-side).
- Contract: un-defer mark_superseded (+ FN spec, INV-011); TDD 5/5 (retires-from-scan
  tracer, non-destructive-get, unknown-id no-op, non-None-fields-only, parity #195).
- bifrost 1.1.1→1.1.4: hasattr-gate backstop for the maintenance verbs (unimplemented
  verb → unsupported_capability 400, never AttributeError/500/retry-storm — the gap we
  surfaced) + the 1.1.3 scan/cursor conformance harness. Full suite 644 green.
2026-07-16 22:37:31 -07:00
vh f46ccbae1c fix(provider): sortable_chunk_fields needs required type — handshake was broken
DEPLOY-BREAKER caught by driving the live bind (unit tests + worldtree-dev's
name-only parser + heid-bug-hunt all missed it). bifrost handshake_response
`SortableChunkField` requires BOTH `name` and `type` (additionalProperties:false).
We advertised `[{"name":"updated_at"}]` (no `type`), so the handshake_response
failed wire-schema validation → `bifrost.schema_validation_failed` → the ENTIRE
Bifrost bind (memory + affect) broke, not just the sort. Advertise
`{"name":"updated_at","type":"timestamp"}` (matches the reference; `type` is
advisory-only). Regression guard added to the caps test (asserts required name+type,
no extra keys). Full suite 639 green.
2026-07-15 09:11:08 -07:00
vh 25ccb5c75b fix(provider): scan rejects non-dict sort with InvalidArguments, never AttributeError
heid-bug-hunt panel (Gróa + Hulda, confirmed-from-code) caught that a truthy
non-dict `sort` (e.g. sort="updated_at" or sort=["updated_at"]) reached
`(sort or {}).get(...)` and crashed with AttributeError instead of the
InvalidArguments PRE-003 promises for malformed caller-controlled input. Add an
isinstance guard before field extraction. Test covers str/list/int sort values.
2026-07-15 08:39:30 -07:00
vh a9c521a48a chore(first-message): update Sindra preset to the holodesk-scene greeting
Replace the terse three-question setup preset with the immersive holodesk
scene-open (Sindra already present, sets the same three settings — intensity /
mood / willingness — in character). Matches the persona shipped in the
soong-lab Sindra redefine. Stays under the 8192-byte authored_content budget;
test_first_message green.
2026-07-15 08:13:26 -07:00
vh 8fc757aa61 feat(provider): person-prime scan verb + sortable_chunk_fields cap (WT #349)
Implement the memory-store `scan` verb — a query-LESS, LIVE-only, globally
ordered top-N-by-recency read — and advertise `sortable_chunk_fields=
[{updated_at}]` at the Bifrost handshake. Advertising the cap is what lights
up Worldtree's #349 person-prime turn-1 durable-fact injection (Branch-A
`"updated_at" in caps.sort_fields_supported`); the fix is ZERO Worldtree
change — the running provider announcing the cap is the trigger.

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

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

Contract amended: un-defers scan, adds the FN spec + INV-009/INV-010 +
sortable_chunk_fields to INV-006. TDD 7/7 green (scan_recency tracer,
live_only, scope_isolation, unadvertised_sort, person_prime_record_shape,
cursor pagination, parity_vs_reference vs InMemoryMemoryStore #195). Full
suite 638 green.
2026-07-15 08:13:08 -07:00
vh 62a16d2d92 build(deps): adopt bifrost 1.1.1 — frozen-v0.6 handshake serialization fix (v0.20.10)
bifrost-dev heads-up (althing 01KX6S4RFH…): 1.1.0's describe_store leaked
the v0.7-additive `sortable_chunk_fields` into a v0.6-negotiated
StoreCapabilities response, so a strict v0.6 client (additionalProperties:
false) rejects our :8392 server's handshake. This is the library-level fix
for the regression snapshotted in 8468c47 (worldtree-dev worked around it
by advancing their client to wire-v0.7 / b47; our served handshake was
still latently v0.6-incorrect for any v0.6 peer).

1.1.1 gates additive capability fields on the NEGOTIATED wire (ADR-0008),
so a v0.6 handshake stays v0.6-clean. Wire schemas + pins UNCHANGED —
serialization-correctness only; our v0.7 handshake with Worldtree b47 is
unaffected. Zero code change (same shape as the 1.1.0 repin). Reference-
impl adopt-canonical: dependency owner recommends the repin, we adopt.

Suite 631 green on 1.1.1 (clean env). Provider restart + live WT-bound
re-verify pending — non-urgent (no v0.6 peer currently handshakes us).
2026-07-10 12:55:38 -07:00
vh 0441e319f6 feat(web): auto-scale PAD gauges for R32-1B unbounded-z (v0.20.9)
Sindra "full and unbounded": relax the debug affect console's PAD display
from a hard [-1,1] clamp to auto-scaling on the session's own max |PAD|
(padScale floor 1.0 → padFillFrac faders + _padNorm orbit). An unbounded-z
PAD (Worldtree R32-1B, ~±10) now renders at full range and never pegs or
escapes the frame; today's [-1,1] values are unchanged (scale==1); the
exact value is always shown numerically (unclamped).

Purely a debug-surface change — verified (grep, whole codebase) the only
PAD clamps lived in the web display layer: the affect store is conduit-
opaque, the read route + proxy pass verbatim, and --set-persona-pad writes
unclamped. Ratatoskr is a downstream observer, so this has zero consequence
to any agent's real affect or behavior (Worldtree-computed server-side).
Playwright-verified: z=±6.2 → faders ≤ half-bar, orbit in-box, +6.20
readout, zero regression at scale 1. Proactive R32-1B prep.
2026-07-08 16:16:29 -07:00
vh 603e9439d3 build(deps): adopt bifrost 1.1.0 / wire v0.7 (v0.20.8)
Additive over the frozen v0.6 (adds memory.scan sort directive +
describe_store sortable_chunk_fields; build_combined_app surface
unchanged). v0.6 stays frozen + valid; we don't use scan-sort (a gated
verb we advertise-unsupported), so this is reference-impl currency, not a
needed feature. Verified: full suite 631 green against 1.1.0, including
the #195 real-lib parity + combined-app build/dispatch tests. Repin +
uv lock only; zero code change. Reported by bifrost-dev via althing.
2026-07-07 10:24:27 -07:00
vh e5ec63967e feat(web): memory viewer + design iteration-3 + markdown pass-2 (v0.20.7)
Web-UI iteration-3 — three queued items landed together.

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

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

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

Contract web_debug_surface.contract.md amended in-commit (create_app
memory_read_url; dimetric-orbit + Δ-bar renderConsole POST-002; memory-viewer
+ markdown-pass-2 function contracts). 631 tests green; Playwright-verified
all render paths (dark+light).
2026-07-07 02:11:29 -07:00
vh c988f273b1 feat(web): markdown RP semantic coloring + paragraph reflow (v0.20.6)
Markdown rework, pass 1 (operator-directed):

- "quoted" dialogue → speech (bright --md-speech), *asterisk* → action/narration
  (muted-italic --md-action). Two tunable CSS vars that cascade through --fg-* so
  they auto-adapt to the light theme; plain text stays default narration.
- speech-wrap runs BEFORE the em/link passes so a generated class="…"/href="…"
  quote can't be mis-read as dialogue; straight + smart quotes; apostrophes are
  safe; unbalanced/half-streamed quotes stay uncolored until they close.
- fix the ugliest existing bug: single newlines were hard <br>s → now CommonMark
  soft-breaks (space); a hard break needs 2+ trailing spaces or a trailing \.
- INV-004 escape-first preserved.

Verified: pytest tests/test_web_* (84) + a 10-case Playwright unit-check of
markdownSafe (speech / action / attribute-quote trap / apostrophes / mixed /
reflow / hard-break / html-escaping / unbalanced / paragraphs) + a visual render.
2026-07-07 01:03:45 -07:00
vh 7704959f48 feat(web): tooltips on the affect-derived cells (v0.20.5)
Native title hints on all 6 affect-derived cells (samples / updated / baseline
P·A / drift Δv / volatility) — each explains what it measures and its Tier-1 vs
Tier-3 availability. Completes the mouseover-hint pass (PAD faders + relation
rows already had them).

Verified: pytest tests/test_web_* (84) + node Playwright (all 6 cells titled).
2026-07-07 00:37:36 -07:00
vh 459e7fa602 feat(web): SVG sparklines + 3D isometric mood cube (v0.20.4)
Import the updated Claude Design prototype's SVG sparkline system + 3D graph,
replacing the unicode-char sparklines:

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

Contract amended. Verified: pytest tests/test_web_* (84) + node Playwright
(injected 24-sample history — 3 PAD strips + 4 relation sparklines + the 3D
cube trajectory/drop/floor all render; gradients resolve).
2026-07-07 00:29:18 -07:00
vh cc6e85cd9b feat(web): brand mark + favicon → the aurora squirrel, replacing the ᛯ rune (v0.20.3)
- remove the black background from the operator's squirrel art (ImageMagick corner
  flood-fill, fuzz 20% — keeps interior linework + glow, drops connected background),
  downscale 1024->80px + quantize, inline as one SQUIRREL data-URI const
- wire it to the favicon <link> href + both brand marks (rail brand-row + setup h1);
  .brand-glyph (font rune) -> .brand-mark (img, drop-shadow glow + breathe)

Also banks worldtree-dev's exact 3-gate we-framing rule (b24) in persistent-memory —
canonDirective held as-is until it deploys on personal (would otherwise drift from the
currently-deployed renderer).

Verified: pytest tests/test_web_* (84) + node Playwright (favicon + both brand marks
decode from the data-URI; no leftover rune).
2026-07-06 23:24:06 -07:00
vh 0b1d9e2b15 feat(web): context-injection panel — reconstruct the full hidden affect block (v0.20.2)
The affect console now reconstructs + displays the complete affect-context block
Worldtree assembles into the agent's system prompt — never on any wire, hidden from
regular consumers, surfaced here as the reference-impl's privileged dev view.

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

Verified: pytest tests/test_web_* (84) + node Playwright (sindra dominant_emotion=joy
-> joy OCC directive candidate + PAD-band fallback both render with exact/candidate tags).
2026-07-06 23:12:16 -07:00
vh 10acaec33d fix(web): relations sparkline no longer overflows the n column + PAD/relation tooltips (v0.20.1)
- cap the unicode sparkline (relations last-8, faders last-7) + overflow:hidden
  so it can't grow one-char-per-sample and paint over the evidence-count (n)
  column — the "n sitting behind the graph" bug
- add native title mouseover hints on all 3 PAD faders (P/A/D meaning + range)
  and every relationship metric row (warmth / trust·ability / benevolence /
  integrity / agency)
- state.lastSnap lets the console re-render without a refetch

Verified: pytest tests/test_web_* (84) + node Playwright injected a 24-sample
history (sparkline->n bounding-box overlap = 0; sparklines capped; tooltips present).
2026-07-06 22:12:22 -07:00
vh 1fcb17730e feat(web): Claude Design console — 3-column wire monitor (v0.20.0)
Adapt the Claude Design "Ratatoskr Console" prototype into the web SPA:
translate out of the .dc.html dialect (x-dc / sc-if / sc-for / {{}} /
DCLogic / external _ds CSS) into single-file / no-CDN / vanilla, and wire
all real /api/* fetch + SSE into its DOM. New 3-column command-console
replaces the tabbed telemetry layout; endpoint set + SSE vocab unchanged.

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

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

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

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

web_debug_surface contract amended (endpoint + loadTranscript). 2 web route
tests, suite 617 green. Playwright DOM check proved the render end-to-end
(drive the real UI -> Sindra's greeting bubble appears).
2026-07-06 15:19:56 -07:00
vh be171304f5 feat: authored first-message presets — auto-seed on session-create (v0.19.8)
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.
2026-07-06 14:23:53 -07:00
vh e643d38f58 fix: persona_state SET body → canonical {pad:{pleasure,arousal,dominance}} + re-vendor Tier-3 prose (v0.19.7)
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.)
2026-07-06 09:56:46 -07:00
vh 6bf2a84ccd feat: authored-history-write consumer side (Worldtree #347) — v0.19.6
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).
2026-07-06 09:40:42 -07:00
vh a99f2473b6 feat(web): persona pane shows the CANONICAL affect->NL Worldtree injects (v0.19.5)
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.
2026-07-01 13:51:37 -07:00
vh ca46a93171 feat(web): persona pane renders the relation_edge/1 affect model + per-value trend (v0.19.4)
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.
2026-07-01 13:20:24 -07:00
vh 75dec016eb fix(web): heid-review findings — SSE lifecycle teardown + test-shape gaps (v0.19.3)
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.
2026-07-01 12:52:44 -07:00
vh a0a9d5f5e4 feat(web): debug-surface parity — BifrostState + AdminEvents + Tools panes, PAD-poll fix, reasoning indicator
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).
2026-07-01 12:33:11 -07:00
vh af07a2329a feat(#2): Tier-2 — transient characters + persona-state write; audit converges
v1 coverage-audit: the last in-scope client I/O points. The audit now
CONVERGES — REST 17/40 covered with zero in-scope gaps (23 excluded-by-
design), SSE 11/11, Bifrost planes 8/8.

- sessions.py: list_character_models / create_character / get_character_state
  / delete_character (#161, character.read/write) + set_persona_state
  (POST /sessions/{id}/persona_state — freeform body, unpinned in the
  frozen surface). 200/201 -> dict (or None on 204), off-status ->
  SessionApiFailed.
- cli.py: two one-shot probes (mirror --whoami): --characters (CRUD
  lifecycle report) + --set-persona-pad "p,a,d" (requires --session).
  New ParsedArgs.characters/set_persona_pad + probe mutual-exclusion.
- Contract #2 amended (5 FNs) + validated. TDD: 7 wrapper + 5 cli tests.
  Suite 573 green; touched code ruff-clean.
- Char read side live-proven (GET /models/available-for-characters -> 200).

Coverage-map: convergence frontier CLOSED — scope-A "done" (every frozen
I/O point classified) is met; ratatoskr cuts v1 when Worldtree tags 1.0.
2026-06-30 23:57:09 -07:00
vh 5fbe353836 feat: v0.19.0 — debug-observability core complete
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).
2026-06-30 23:38:46 -07:00
vh a3c92b68dc feat(#11): AdminEvents pane — GET /admin/events SSE (session-filtered)
v1 coverage-audit: the last unbuilt design-brief §5 debug pane. #11's
blocker was already satisfied (admin key carries admin.events.read).
Completes the admin/debug-observability core.

- sse_client.py: AdminEvent dataclass + stream_admin_events — a new
  long-lived SSE consumer for the admin lifecycle stream (envelope
  {id,type,timestamp,data}), admin-scoped (bearer-override), Last-Event-ID
  resume. non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped.
- tui.py: "AdminEvents" TabPane + _format_admin_event + _admin_event_matches
  (design-brief §6 filter: active-session + non-heartbeat system.*) +
  _stream_admin_events long-lived best-effort worker (unconditional
  on_mount; self-labels not-configured / unavailable / stream-ended).
- Contract-skipped for stream_admin_events (out of #1's turn-SSE scope;
  spec § Admin Event Stream is the reference). TDD: 4 sse_client + 5 tui
  tests. Suite 561 green.
- LIVE-AUTH-PROVEN on :8081 (GET /admin/events -> HTTP 200 under admin key).

Coverage: REST 12/40. Tier 1 debug-observability core complete.
2026-06-30 23:25:34 -07:00
vh 9ce83d5fdc feat(#2): BifrostState pane — GET /admin/sessions/{id}/bifrost (admin-key)
v1 coverage-audit: the last unbuilt design-brief §5 debug widget. First
admin-key consumer in ratatoskr.

- sessions.py: get_session_bifrost(client, session_id, *, admin_key) —
  admin-scoped (admin.sessions.read); the request overrides Authorization
  with admin_key (distinct from the consumer bearer). 200 -> dict, non-200
  -> SessionApiFailed (403 scope-denied, 404 not-bound).
- cli.py: --admin-key flag + RATATOSKR_ADMIN_API_KEY env -> ParsedArgs.admin_key.
- tui.py: new "Bifrost" TabPane + _format_bifrost_state + _hydrate_bifrost_state
  best-effort worker (unconditional on_mount). Writes {endpoint, connected,
  caps, tools} + audits; self-labels "not configured" / "not bound" / graceful
  on 403+error, never crashes.
- Contract #2 amended (FN, incl. the bearer-override POST) + validated. TDD:
  4 wrapper tests + 1 format unit + 3 hydrate integration. Suite 552 green.
- LIVE-AUTH-PROVEN on :8081 (admin key reached resource-layer 404, not 401/403).

Ledger correction: #11 (AdminEvents) is NO LONGER BLOCKED — the admin key
was verified to carry admin.events.read; only the pane is unbuilt. Coverage:
REST 11/40.
2026-06-30 23:12:29 -07:00
vh e62208d8e3 feat(#2): consume GET /sessions/{id}/tools — Tools-pane inventory hydrate
v1 coverage-audit Tier-2 quick win. The owner-scoped tool-inventory
endpoint (#183) had no caller; wire it into the TUI Tools pane.

- sessions.py: get_session_tools (GET /sessions/{id}/tools) — owner-
  scoped (consumer key, no admin scope), 200 -> parsed dict verbatim,
  non-200 -> SessionApiFailed. Mirrors get_persona_state / get_me.
- tui.py: _format_tool_inventory helper + _hydrate_session_tools
  best-effort worker (mirrors _hydrate_persona), wired unconditionally
  in on_mount. Writes the merged {agent_id, builtin_tools,
  bifrost_tools} inventory the LLM saw at turn-fire into the Tools
  pane + audits; never crashes on failure.
- Covers the design-brief 5 "Tools widget" via the reachable owner
  endpoint (the admin variant stays a gap only for cross-user debug).
- Contract #2 amended (FN) + validated. TDD: 3 wrapper tests + 1
  format-helper unit + 2 hydrate integration tests. Coverage: REST
  10/40. Suite 544 green; touched code ruff-clean.
2026-06-30 22:50:12 -07:00
vh 387ac4ab2c feat(#2): consume GET /me + GET /capabilities via --whoami one-shot
v1 coverage-audit slice (capabilities+me). Both endpoints had no
caller; add them as cheap boot-time debug primitives.

- sessions.py: get_me (GET /me — identity/whoami) + get_capabilities
  (GET /capabilities — Echo ephemeral-template discovery). Mirror
  get_persona_state: 200 -> parsed dict verbatim, non-200 ->
  SessionApiFailed. Freeform dicts (frozen OpenAPI types both as
  objects).
- cli.py: new --whoami one-shot mode (mirrors --send). Fetches both,
  prints an identity + capabilities report, exits. Standalone probe:
  mutually exclusive with --send/--session/--new/--agent; opens no
  session. New ParsedArgs.whoami field + main() dispatch.
- Contract #2 amended (2 FNs) + validated. TDD: 5 wrapper tests +
  5 cli tests (validation + mode + error). Coverage map: REST 9/40.
  Suite 538 green; touched code ruff-clean.

Audit note: /capabilities is the Echo ephemeral-template discovery
endpoint, not a generic server-caps endpoint (coverage-map framing
corrected). TUI-surfacing of /me + /capabilities deferred.
2026-06-30 22:21:59 -07:00
vh 5c1b9816d4 feat(#6): startup session picker for bare TUI mode
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.
2026-06-30 22:05:14 -07:00
vh 2ba4244e9e feat(#1): route TUI + web presenters through stream_turn_resilient
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).
2026-06-30 15:46:04 -07:00
vh 0c7660791f feat(#1): shared SSE resume orchestration; wire cli --send
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.
2026-06-30 15:42:33 -07:00