Compare commits

...

21 Commits

Author SHA1 Message Date
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 75da6767d3 pin: bump Worldtree spec 5810a26→879cefe (OpenAPI 2.2.0→2.3.0)
#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).
2026-07-06 09:11:03 -07:00
vh 8ac88ee536 memory: snapshot — authored-history #347 accepted + Sindra mood fix 2026-07-06 09:05:26 -07:00
vh 0a8784cc1b chore(scripts): self-service provider-store reset (sindra memory + persona)
Stop combined :8392 provider -> move memory.db+affect.db to a single rolling
backup (--hard skips it) -> restart -> verify empty. Codifies the manual
reset flow so it's a one-command CLI op. Rolling backup gitignored via *.db*.
2026-07-06 01:22:56 -07:00
vh 7156b25957 docs(proposals): note assistant-first provider constraint (#347 rev 1.1 validation)
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.
2026-07-05 17:10:44 -07:00
vh 022accfa7b docs(proposals): note engine-imposed hide-existence consumer constraint (Worldtree #347)
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.
2026-07-05 11:31:53 -07:00
vh c457520ae4 docs(proposals): authored history write primitive — heid-panel-reviewed v1 scope
Non-generating ledger-seed primitive for Worldtree, driven by ratatoskr's
first-message need. v1 narrowed to append-only + create-time; bounded effects
enum (none|memory_import); edit/regenerate + batch-import split to future
primitives. Positions taken: distinct sub-resource, event-silence for
default-off seed, seeded lifecycle phase, structured provenance, user-author
restricted. Consumer proposal for worldtree-dev (engine owner).
2026-07-05 11:06:11 -07:00
vh 9ca931e148 memory: snapshot — R29→R30 affect-calibration arc (R30 φ0 config-faithful)
R29 flat-affect finding shipped as Worldtree's A1 anchor fix (decay_anchor=
baseline_pad, positive_p_cap removed; demo v1.0.0b14); R30 Phase-1 φ0 measured
against it = config-faithful (φ0≈0.95, c≈0, trait-flat, φ_max→0.96). R28 closed.
Standing follow-ons (hybrid decay redesign, gain-only v1, per-axis A/D, Phase-2,
relational verify) are others' calls. Data on diag/r29-pad-series +
diag/r30-phi0-step-response. No ratatoskr code change (main tip v0.19.5).
2026-07-03 13:44:20 -07:00
vh c77ff913f0 memory: snapshot — R28 open (promotion-worthiness reframe, P00 corpus delivered, standing by to run) + relational-dynamics arc LIVE on demo (v1.0.0b9) 2026-07-02 07:49:54 -07:00
vh 4a3551254f docs(diagnostics): R28 P00 stratified injection-corpus for brokkr-smithy salience/promotion-worthiness eval 2026-07-02 07:29:39 -07:00
vh 0b7489f74d memory: snapshot — Sindra affect/memory investigation; 4 upstream items driven (PAD over-regulation, memory-plane healthy, salience #335 + brokkr R-target, relation_context Wave-0) 2026-07-01 22:23:58 -07:00
vh 3dac5d3b44 memory: snapshot — persona-pane rebuild (relation_edge/1 + trend) + canonical affect-NL vendored (v0.19.5); relation_context/agency flag WAD 2026-07-01 14:47:32 -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 85a2b95428 memory: snapshot — web debug-surface parity primary (v0.19.3), heid review, embedding-loop resolved, Tier-3 reset 2026-07-01 12:57:10 -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 fc1e1487c7 memory: snapshot — v1 coverage-audit converged (REST 17/40, zero in-scope gaps)
Refresh the decay-prone in-flight section from the stale 2026-06-20
(#17/#18) state to the converged-audit state: REST 17/40 covered with
zero in-scope gaps, SSE 11/11, Bifrost planes 8/8; debug-observability
core complete (v0.19.0); standing pins v1.0.0b2 + bifrost 1.0.0; admin
key scopes verified. Recent-decisions log unchanged.
2026-07-01 00:08:53 -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
28 changed files with 7519 additions and 119 deletions
+22 -2
View File
@@ -104,8 +104,8 @@ id = "worldtree-conversation-api-openapi-v2"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-openapi.json"
consumer_path = "docs/conversation-api-openapi.json"
pinned_sha256_16 = "dbdf4e24c8b06c92"
pinned_at = "2026-06-30T22:25:56+00:00"
pinned_sha256_16 = "36148179601453a0"
pinned_at = "2026-07-06T16:09:05+00:00"
[[pins]]
id = "worldtree-conversation-api-sse-events-v1"
@@ -123,3 +123,23 @@ consumer_path = "docs/conversation-api-spec.md"
pinned_sha256_16 = "2d111a3b8322b7d1"
pinned_at = "2026-06-30T22:25:56+00:00"
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
# context-injected. The web persona pane renders mood + relationship-directive BYTE-EXACT
# from these (via the flat src/ratatoskr/web/static/persona_render_canon.json, regenerated
# by scripts/build_persona_canon.py). Drift here => rerun that regen with Worldtree's venv.
[[pins]]
id = "worldtree-persona-mood-render-canon-v1"
canonical_source = "Worldtree"
canonical_path = "core/persona/canon/d2-mood-render-canon-v1.json"
consumer_path = "docs/vendor/worldtree-persona-canon/d2-mood-render-canon-v1.json"
pinned_sha256_16 = "e2f124fed3ee8d42"
pinned_at = "2026-07-01T21:00:00+00:00"
[[pins]]
id = "worldtree-persona-d2-render-canon-v1"
canonical_source = "Worldtree"
canonical_path = "core/persona/canon/d2-render-canon-v1.json"
consumer_path = "docs/vendor/worldtree-persona-canon/d2-render-canon-v1.json"
pinned_sha256_16 = "606bba5fdcc60b6b"
pinned_at = "2026-07-01T21:00:00+00:00"
+7 -6
View File
@@ -7,16 +7,17 @@ documents the pin, the vendored artifacts, and the bump procedure.
| Field | Value |
|---|---|
| Worldtree git SHA | `5810a26b38a5ea6630892f9a39756f57c5b7b41e` |
| Worldtree HEAD message | `memory: snapshot — v1.0.0b2 shipped complete (demo + personal green); consumer loop closed` |
| Pinned on | 2026-06-30 |
| Pinned by | ratatoskr-dev (v1 coverage-audit — re-pin to the FROZEN OpenAPI 2.2.0 + SSE schema) |
| Worldtree version at pin | `v1.0.0b2` |
| Worldtree git SHA | `879cefef02bbf442c2d01ff4214299fac7c89809` |
| Worldtree HEAD message | `fix(#347): freeze the authored-history route into the OpenAPI schema (spec 2.3.0)` |
| Pinned on | 2026-07-06 |
| Pinned by | ratatoskr-dev (re-vendor OpenAPI 2.2.0→2.3.0 — #347 authored-history-write route shipped) |
| Worldtree version at pin | `v1.0.0b22` |
## Pin history
| Date | SHA | Version | Notable deltas consumed |
|---|---|---|---|
| 2026-07-06 | `879cefe` | v1.0.0b22 | **Re-vendor OpenAPI 2.2.0→2.3.0 — Worldtree shipped #347 authored-history-write.** One new REST path-group: `POST /sessions/{session_id}/history` (the authored-history-write primitive) + the `AuthoredTurnResponse` schema (openapi path count 40→41). #347 is **OpenAPI-only** — the prose `conversation-api-spec.md` + server `conversation_api.contract.md` are byte-unchanged since the 5810a26 pin (empty `git log` delta), so those `tolerate_drift` pins stay clean; the SSE schema is unchanged (#347 is event-silent by design). **Consumer side NOT yet built**`POST /sessions/{id}/history` is a fresh in-scope ⬜ gap in `docs/coverage-map.md` (re-opens the v1 coverage-audit with exactly one gap; Heimdall-gated hide-existence → consumer treats 404 as feature-absent). `pin:`-only, no version bump. |
| 2026-06-30 | `5810a26` | v1.0.0b2 | **Re-pin to Worldtree's FROZEN v1 surface (#326), as part of the v1 coverage-audit.** Vendored the machine-readable artifacts — `conversation-api-openapi.json` (OpenAPI **2.2.0**, 40 path-groups) + `conversation-api-sse-events.schema.json` (11 events) — now the **authoritative drift gates** (pinned in `.corviduo-canonicals.toml`, CI-checked by `canonical_drift.py`). The prose `conversation-api-spec.md` is **byte-identical** to the v0.35.16 pin (last WT markdown edit 2026-05-31), kept as the human reference (`tolerate_drift`). b2 deltas already consumed in code: 409/503 eager turn-launch statuses (#331, v0.18.3/.4) + the unified error envelope (#328). 7 endpoints documented only in the OpenAPI, not the prose, all classified in `docs/coverage-map.md`: `admin/keys/bulk`, `admin/persona/{archive,erase}`, `admin/usage`, `embed`, `judgments`, `me/usage`. No client-breaking change — `pin:`-only, no version bump. |
| 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga``echo`, `actor``mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. |
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
@@ -26,7 +27,7 @@ documents the pin, the vendored artifacts, and the bump procedure.
**Authoritative (FROZEN, machine-readable — the drift gates):**
- `docs/conversation-api-openapi.json` — copy of `Worldtree/docs/conversation-api-openapi.json` (OpenAPI `info.version` **2.2.0**). The frozen v1 REST wire (40 path-groups). Pinned `worldtree-conversation-api-openapi-v2` in `.corviduo-canonicals.toml`; drift gated by `canonical_drift.py`.
- `docs/conversation-api-openapi.json` — copy of `Worldtree/docs/conversation-api-openapi.json` (OpenAPI `info.version` **2.3.0**). The frozen v1 REST wire (41 path-groups; 2.3.0 added `POST /sessions/{session_id}/history` per #347). Pinned `worldtree-conversation-api-openapi-v2` in `.corviduo-canonicals.toml`; drift gated by `canonical_drift.py`.
- `docs/conversation-api-sse-events.schema.json` — copy of `Worldtree/docs/conversation-api-sse-events.schema.json`. The frozen SSE event schema (11 discriminated event types). Pinned `worldtree-conversation-api-sse-events-v1`.
**Reference (prose; allowed to lag — `tolerate_drift`):**
+155
View File
@@ -313,3 +313,158 @@ TESTS:
not_bound_404 [error]: 404 session_not_bifrost_bound → SessionApiFailed(status=404)
empty_admin_key [adversarial]: admin_key="" → AssertionError; no HTTP issued
```
## Amendment 2026-07-01 — Tier-2: transient characters + persona-state write (v1 coverage-audit)
The last in-scope client I/O points. Transient-character CRUD (#161) surfaced
via a `--characters` one-shot lifecycle probe; persona-state write surfaced via
`--set-persona-pad "p,a,d"` (requires `--session`). All mirror the existing
wrappers: parsed dict verbatim (or None on 204), any off-status → SessionApiFailed.
**Note:** `set_persona_state`'s request body is FREEFORM — the frozen OpenAPI 2.2.0
declares no request schema and the prose spec documents only the GET counterpart,
so the caller supplies the snapshot shape (`--set-persona-pad` sends `{pad:[…]}`).
```contract
FN list_character_models(client) -> dict[str, Any]
BRIEF: GET /models/available-for-characters (character.read). Returns {items:[{name, description, thinking}]}. Non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified
STEPS:
1. [sequential, prescriptive] resp = await client.get("/models/available-for-characters"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
list_models [happy,tracer]: 200 {items:[{name:"fast"}]} → dict verbatim
FN create_character(client, character: dict, *, state: dict | None = None) -> dict[str, Any]
BRIEF: POST /characters (character.write). Body {character, state}. Returns 201 {character_id, ttl_expires_at}; non-201 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None; [PRE-002 hard] character is a non-empty dict
POST: [POST-001 return_value] on 201 returns resp.json(); [POST-002 side_effect] outbound body == {"character": <arg>, "state": <state|null>}
STEPS:
1. [sequential, prescriptive] resp = await client.post("/characters", json={"character": character, "state": state}); IF 201 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
create [happy]: 201 → {character_id}; body is {character, state:null}
create_403 [error]: 403 auth_scope_denied → SessionApiFailed(403)
FN get_character_state(client, character_id: str) -> dict[str, Any]
BRIEF: GET /characters/{id}/state (character.read). Live PAD/emotions snapshot; refreshes TTL. Non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str
POST: [POST-001 return_value] on 200 returns resp.json()
STEPS:
1. [sequential, prescriptive] resp = await client.get(f"/characters/{character_id}/state"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
get_state [happy]: 200 {pad:[...]} → dict verbatim
FN delete_character(client, character_id: str) -> None
BRIEF: DELETE /characters/{id} (character.write). 200/204 → None; other → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str
POST: [POST-001 return_value] on 200/204 returns None
STEPS:
1. [sequential, prescriptive] resp = await client.delete(f"/characters/{character_id}"); IF status in (200,204) RETURN None; ELSE RAISE SessionApiFailed
TESTS:
delete [happy]: 204 → None
FN set_persona_state(client, session_id: str, snapshot: dict) -> None
BRIEF: POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection). Request body is the FREEFORM snapshot (caller-supplied; unpinned in the frozen surface). 204 → None; other → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] session_id non-empty str; [PRE-003 hard] snapshot is a dict
POST: [POST-001 return_value] on 204 returns None; [POST-002 side_effect] outbound body == snapshot verbatim
STEPS:
1. [sequential, prescriptive] resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot); IF 204 RETURN None; ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 204 → None; body == {"pad":[...]} verbatim
non_204 [error]: 422 → SessionApiFailed(422)
```
## Amendment 2026-07-06 — authored-history write (#347, v1 coverage-audit re-open)
Worldtree shipped #347 (authored-history-write) as OpenAPI 2.3.0: a new
`POST /sessions/{session_id}/history` primitive that writes ONE model-visible
turn into a session's ledger AS the bound agent, WITHOUT a generation and
WITHOUT lived-turn side effects (the SillyTavern "first message"). The re-vendor
(2.2.0→2.3.0, pin `879cefe`) re-opened the v1 coverage-audit with this one new
in-scope REST path-group; this amendment closes it on the consumer side and also
un-defers `GET /sessions/{id}/messages` (previously §Out of scope) as the seed's
read-back.
**Hide-existence (server INV-347-1) — the load-bearing consumer contract.** The
`session.history.write` grant is checked FIRST — an ungranted caller (or a
non-owner, or an unknown session) gets a 404 **byte-identical** to a genuine
`session_not_found`, never a 403/409/422 that would reveal the feature exists.
The consumer MUST honor this: treat 404 as **feature-absent**, fall back (a
production consumer to a model-generated greeting), and NEVER capability-probe to
tell feature-absent from ungranted from session-absent. The wrapper encodes it by
raising a DISTINCT `AuthoredHistoryUnavailable` on 404 (NOT `SessionApiFailed`),
so a caller branches feature-absent without inspecting a status code.
**Request body — v1-minimal, wire-pinned by the server.** The frozen OpenAPI 2.3.0
exports an empty request schema, but the server pins `AuthoredWriteRequest`
(`extra="forbid"`): `{author, content, idempotency_key, effects?,
claimed_original_at?}`. v1: `author="assistant"` (only value), `content` (UTF-8,
server-bounded at `authored_content_max_bytes`=8192), `idempotency_key` (REQUIRED,
per-session dedup), `effects` omitted (== "none"; only value). Because
`extra="forbid"`, the wrapper omits `effects`/`claimed_original_at` when None
(never sends null). Success is 201 (fresh) OR 200 (idempotent replay,
byte-identical body); both return the `AuthoredTurnResponse` `{author,
content_chars, injected_at, phase, seq, session_id, turn_id}` verbatim (provenance
is audit-only, NEVER on this body — INV-347-7).
**Assistant-first provider constraint (deferred, inert for the probe).** A
create-time first-message makes the assistant seq-0 (assistant-first history);
Anthropic-family providers 400 the *next generation*, vLLM/openai_compat tolerate
it. The `--seed-first-message` probe seeds but does NOT generate, so the
constraint is inert for the probe — a real consumer that then generates must bind
an assistant-first-tolerant provider.
```contract
FN write_authored_history(client: httpx.AsyncClient, session_id: str, *, content: str, idempotency_key: str, author: str = "assistant", effects: str | None = None, claimed_original_at: str | None = None) -> dict[str, Any]
BRIEF: POST /sessions/{session_id}/history — the #347 authored-history-write primitive (write one model-visible turn as the bound agent, no generation, no side effects). Body {author, content, idempotency_key} + "effects"/"claimed_original_at" only when non-None (server AuthoredWriteRequest is extra="forbid"). Success 200 (replay) or 201 (fresh) → AuthoredTurnResponse dict verbatim. 404 → AuthoredHistoryUnavailable (hide-existence: feature-absent/ungranted/session-absent, indistinguishable by design — consumer falls back, never probes). Any other non-2xx → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is a non-empty str -- assert session_id and isinstance(session_id, str)
PRE: [PRE-003 hard] content is a non-empty str -- assert content and isinstance(content, str)
PRE: [PRE-004 hard] idempotency_key is a non-empty str -- assert idempotency_key and isinstance(idempotency_key, str)
PRE: [PRE-005 hard] author is a non-empty str -- assert author and isinstance(author, str)
POST: [POST-001 side_effect] exactly one POST to /sessions/{session_id}/history; body == {"author": author, "content": content, "idempotency_key": idempotency_key} plus "effects" iff effects is not None plus "claimed_original_at" iff claimed_original_at is not None (no null-valued keys — extra="forbid")
POST: [POST-002 return_value] on 200 or 201 returns resp.json() unmodified
ERROR_ROUTING:
HTTP 404 (hide-existence session_not_found):
local_handling: raise AuthoredHistoryUnavailable(session_id=session_id)
flow_control: abort
state_recovery: caller treats as feature-absent; fall back to a model-generated greeting; NEVER capability-probe (INV-347-1)
HTTP other non-2xx (incl. 409 generation_active, 422 content_too_long/validation_failed, 401 auth_revoked, 410 session_retired):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (409 retryable; 422 caller bug/oversize)
STEPS:
1. [setup, flexibility=prescriptive] assert PRE-001..PRE-005
2. [sequential, flexibility=prescriptive] body = {"author": author, "content": content, "idempotency_key": idempotency_key}; IF effects is not None: body["effects"] = effects; IF claimed_original_at is not None: body["claimed_original_at"] = claimed_original_at
3. [sequential, flexibility=prescriptive] resp = await client.post(f"/sessions/{session_id}/history", json=body)
tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
4. [branch, flexibility=prescriptive] IF resp.status_code in (200, 201): RETURN resp.json(); ELIF resp.status_code == 404: RAISE AuthoredHistoryUnavailable(session_id=session_id); ELSE RAISE SessionApiFailed(status=resp.status_code, body=resp.content)
TESTS:
happy_fresh_201 [happy,tracer]: 201 {author:"assistant", seq:0, phase:"seeded", turn_id, content_chars, session_id, injected_at} → dict verbatim; outbound body == {"author":"assistant","content":<c>,"idempotency_key":<k>} exactly (no effects/claimed_original_at keys)
happy_replay_200 [happy]: 200 (same-key replay, byte-identical body) → dict verbatim
body_includes_effects [trace]: effects="none" → outbound body has "effects":"none"; claimed_original_at="2020-01-01T00:00:00Z" → body has that key too
hide_existence_404 [error]: 404 {error_code:"session_not_found"} → raises AuthoredHistoryUnavailable(session_id=<arg>), NOT SessionApiFailed
generation_active_409 [error]: 409 {error_code:"generation_active"} → SessionApiFailed(status=409)
content_too_long_422 [error]: 422 {error_code:"content_too_long"} → SessionApiFailed(status=422)
empty_content [adversarial]: content="" → AssertionError; no HTTP issued
empty_idempotency_key [adversarial]: idempotency_key="" → AssertionError; no HTTP issued
empty_session_id [adversarial]: session_id="" → AssertionError; no HTTP issued
FN get_session_messages(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]
BRIEF: GET /sessions/{session_id}/messages — the session's message history (spec §GET /sessions/{id}/messages), un-deferred as the #347 probe's read-back so a seeded turn can be confirmed to render as a normal role=assistant message (model-invisible provenance — a seed is indistinguishable from a lived turn on read). Returns {session_id, items:[{seq, role, content, ...}], next_cursor} verbatim. Owner-scoped; any non-200 → SessionApiFailed. v1 reads the server default page (no pagination params — the probe reads a fresh 1-message session; add limit/cursor when a caller needs scrollback).
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is a non-empty str -- assert session_id and isinstance(session_id, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified
ERROR_ROUTING:
HTTP non-200 (incl. 404 session_not_found cross-owner/unknown):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] assert PRE-001, PRE-002
2. [sequential, flexibility=prescriptive] resp = await client.get(f"/sessions/{session_id}/messages")
3. [branch, flexibility=prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 200 {session_id, items:[{seq:0, role:"assistant", content:"…"}], next_cursor:null} → dict verbatim
not_found_404 [error]: 404 → SessionApiFailed(status=404)
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
```
@@ -0,0 +1,159 @@
---
contract_version: "2.1"
module: "ratatoskr.web"
purpose: "v0.19.2 web debug-surface parity: 3 admin/debug panes (Tools inventory, BifrostState, AdminEvents SSE) proxied server-side with the admin key server-held, plus a reliable PAD-refresh poll and a non-engine reasoning indicator in the transcript."
target_module: "ratatoskr.web (server.py routes + entrypoint.py + static/index.html)"
scope: "v0.19.2 web debug-surface parity — bring the browser surface (now the PRIMARY debug surface) to TUI parity. THREE new admin/debug panes proxied server-side + TWO transcript affordances. (1) Tools inventory: GET /api/sessions/{id}/tools proxies owner-scoped get_session_tools into the tools pane (what the LLM HAS at turn-fire), above the live tool events. (2) BifrostState pane: GET /api/sessions/{id}/bifrost proxies admin-scoped get_session_bifrost; the admin key is SERVER-HELD (app.state.admin_key from RATATOSKR_ADMIN_API_KEY), never sent to the browser. (3) AdminEvents pane: GET /api/admin/events is an SSE proxy of stream_admin_events, session-filtered SERVER-side (heartbeats + other-session events dropped), re-emitted under a fixed 'admin_event' name so every dotted type renders with one browser listener. (4) PAD refresh: the persona/affect pane polls a bounded window instead of a single 2s shot that raced the post-turn-async affect.emit. (5) Reasoning indicator: an ephemeral, clearly-non-engine transcript line on `thinking` deltas, cleared when text begins. Direct in-session TDD (the #17/#18 pattern); this contract is authored post-implementation to anchor the heid code review (the client wrappers get_session_tools/get_session_bifrost/stream_admin_events are already contracted in the sessions/sse_client specs — this contract governs the WEB proxy + presenter surface only)."
depends_on:
- "httpx"
- "starlette"
- "ratatoskr.sessions" # get_session_tools, get_session_bifrost, SessionApiFailed
- "ratatoskr.sse_client" # stream_admin_events, AdminEvent, SseConnectFailed/Dropped
used_by:
- "ratatoskr.web.entrypoint" # passes admin_key=RATATOSKR_ADMIN_API_KEY into create_app
language: "python + vanilla JS (single-file SPA, no build)"
complexity: "medium"
estimated_loc: 290
confidence: 0.8
assumptions:
- "The three client wrappers exist and are already contracted: get_session_tools(client, session_id)->dict (owner-scoped, consumer bearer; non-200 -> SessionApiFailed), get_session_bifrost(client, session_id, *, admin_key)->dict (OVERRIDES Authorization with admin_key; non-200 -> SessionApiFailed), stream_admin_events(client, *, admin_key)->AsyncIterator[AdminEvent] (non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped). The web routes are thin proxies over them; they add NO new upstream semantics."
- "AdminEvent = {id:int, type:str, timestamp:str|None, data:dict}. data MOST carry session_id (INV-049). type is a dotted namespace (session.*/turn.*/key.*/system.*)."
- "The web SPA is a single static/index.html served per-request via FileResponse (edits land on browser refresh; server code changes need a restart). Model/tool/admin content is UNTRUSTED text (INV-004) — every render path escapes first (esc() via textContent, or JSON.stringify wrapped in esc())."
- "The internal-LAN trust model (0.0.0.0, no auth/TLS/CORS) is deliberate operator direction. Admin-scoped DATA becoming LAN-visible is accepted under that model; the admin KEY must nonetheless never cross to the browser."
- "Tests: respx mocks the upstream endpoints (absolute w.example URLs) driven through the TestClient; the AdminEvents SSE proxy is tested with a finite mocked SSE byte-stream asserting the filter + fixed event name. Live-proven against ratatoskr:sindra on personal :8081."
# ─────────────────────────────────────────────────────────────────────────────
functions:
- name: "_session_tools_endpoint"
signature: "async _session_tools_endpoint(request: Request) -> JSONResponse"
description: "GET /api/sessions/{session_id}/tools — proxy owner-scoped tool inventory."
preconditions:
- "session_id in path_params."
postconditions:
- "POST-001: 200 with the upstream inventory dict verbatim on success."
- "POST-002: on SessionApiFailed(status) -> JSONResponse({error_code:'session_tools_unavailable', status}, status_code=status) — status-preserving."
steps: "Open client_factory() client; await get_session_tools(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope."
flexibility: "prescriptive"
- name: "_session_bifrost_endpoint"
signature: "async _session_bifrost_endpoint(request: Request) -> JSONResponse"
description: "GET /api/sessions/{session_id}/bifrost — proxy admin-scoped Bifrost dispatch state."
preconditions:
- "session_id in path_params."
- "PRE-001 (fail-visible): app.state.admin_key must be truthy; else 400 admin_key_not_configured with NO upstream call."
postconditions:
- "POST-001: the admin key is read from app.state.admin_key ONLY; it is passed to get_session_bifrost(admin_key=...) and NEVER placed in a response body or surfaced to the browser."
- "POST-002: 200 with the upstream state dict verbatim on success."
- "POST-003: on SessionApiFailed(status) -> {error_code:'bifrost_state_unavailable', status} at status_code=status (notably 404 not-bound, 403 scope-denied)."
steps: "If not admin_key -> 400. Open client; await get_session_bifrost(client, session_id, admin_key=admin_key); 200. Except SessionApiFailed -> status-preserving envelope."
flexibility: "prescriptive"
- name: "_admin_event_matches_web"
signature: "_admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool"
description: "AdminEvents session-filter (mirrors the TUI _admin_event_matches, design-brief §6)."
postconditions:
- "POST-001: ev.type == 'system.heartbeat' -> False (keepalive noise dropped)."
- "POST-002: ev.type.startswith('system.') (non-heartbeat) -> True (stream-integrity signals always pass)."
- "POST-003: otherwise -> True IFF session_id is not None AND ev.data.get('session_id') == session_id (per-session scoping; a None session_id forwards NO non-system event)."
flexibility: "prescriptive"
- name: "_admin_events_endpoint"
signature: "async _admin_events_endpoint(request: Request) -> Response"
description: "GET /api/admin/events?session_id=... — SSE proxy of stream_admin_events, session-filtered server-side."
preconditions:
- "PRE-001 (fail-visible): app.state.admin_key truthy; else 400 admin_key_not_configured with NO stream opened."
postconditions:
- "POST-001: returns StreamingResponse(media_type='text/event-stream'); the admin key never crosses to the browser."
- "POST-002: ONLY events passing _admin_event_matches_web(ev, session_id) are forwarded; each is re-emitted under the FIXED SSE event name 'admin_event' with {id,type,timestamp,data} in the payload (the real dotted type rides in the payload, so one browser listener renders every type — nothing silently dropped by name)."
- "POST-003: SseConnectFailed/SseConnectionDropped/MalformedSseId/MalformedSseData -> a single 'stream_error' SSE frame, then the stream ends (best-effort; never raises to the browser)."
- "POST-004: asyncio.CancelledError (browser disconnect) re-raises to unwind the generator; the upstream client is aclose()'d in finally on every exit path."
steps: "If not admin_key -> 400. gen(): open client; async-for ev in stream_admin_events(admin_key); skip unless _admin_event_matches_web; yield _format_sse('admin_event', {...}). Except SSE errors -> yield stream_error. Except CancelledError -> raise. Finally aclose(). Return StreamingResponse(gen())."
flexibility: "prescriptive"
- name: "create_app (amendment)"
signature: "create_app(client_factory, *, end_user_id=None, bifrost_consumer_key=None, bifrost_visible_host=None, affect_read_url=None, admin_key=None) -> Starlette"
description: "New optional admin_key param stored at app.state.admin_key; entrypoint passes RATATOSKR_ADMIN_API_KEY. Three new routes registered."
postconditions:
- "POST-001: app.state.admin_key = admin_key (default None -> the two admin routes fail-visible per their PRE-001)."
- "POST-002: routes /api/sessions/{session_id}/tools, /api/sessions/{session_id}/bifrost, /api/admin/events added; existing routes unchanged."
flexibility: "closed"
- name: "reasoning indicator (index.html: showThinkingNote / hideThinkingNote)"
signature: "showThinkingNote() ; hideThinkingNote() // called from the turn SSE loop"
description: "Ephemeral transcript affordance signalling reasoning inference — clearly NOT engine output."
postconditions:
- "POST-001: on the first `thinking` delta, an italic '<Agent> <phrase>' line (✦ glyph, rotating phrase) is shown; it supersedes any live 'awaiting first token' heartbeat."
- "POST-002: the agent display name is derived from state.agentId and rendered via textContent (NEVER innerHTML) — INV-004 holds even for an adversarial agent_id."
- "POST-003: it is removed the instant the first `text` delta arrives, and on any terminal (done/error/cancelled); the rotation interval is cleared on removal (no leaked setInterval)."
flexibility: "prescriptive"
- name: "PAD refresh poll (index.html: terminal() done-branch)"
signature: "on Done: poll loadPersona over [1500,3500,6500,10500]ms"
description: "Catch the post-turn-async affect.emit without racing it (replaces the single 2s shot)."
postconditions:
- "POST-001: loadAffect sets state.lastAffectAt = snap.emitted_at; the poll captures beforeAt and stops (settled) once state.lastAffectAt !== beforeAt."
- "POST-002: a scheduled poll no-ops if a NEW turn has started (state.turnId truthy) or already settled — no refresh of a stale agent, no unbounded polling."
flexibility: "open"
- name: "web pane renderers (index.html: renderToolsInventory / renderBifrostState / openAdminEvents)"
signature: "renderToolsInventory(inv) ; renderBifrostState(b) ; openAdminEvents(sessionId)"
description: "Render the three new surfaces; all content escaped (INV-004)."
postconditions:
- "POST-001: every dynamic value (agent_id, tool names/descriptions, endpoint, caps, admin event type + data) is passed through esc() or esc(JSON.stringify(...)); no upstream string reaches innerHTML unescaped."
- "POST-002: openAdminEvents closes a prior EventSource before opening a new one (state.adminES); the admin data blob renders via esc(JSON.stringify(d.data))."
- "POST-003: renderToolsInventory prepends the static inventory ABOVE live tool events without clobbering them (a re-render replaces only the .tools-inventory block)."
- "POST-004: the Tools inventory renders tool NAMES only — a compact comma-joined summary ('what does the LLM have', the debug glance); per-tool DESCRIPTIONS are surfaced in the BifrostState pane's tools list, deliberately NOT duplicated here. (Heid panel Hulda/Regin precision finding — accepted: contract wording clarified, code unchanged; the earlier 'names/descriptions' phrasing in INV-004 refers to the SET of value types that MAY appear across the new panes and must be escaped, not a mandate that every pane render descriptions.)"
flexibility: "open"
- name: "renderAffectPane + trend (v0.19.4 — relation_edge/1 render + sparkline)"
signature: "renderAffectPane(snap) ; pushAffectHistory(snap) ; sparkline(vals) ; trendDelta(vals)"
description: "Render the Tier-3 affect snapshot as PAD mood + the durable per-entity relational model, each value with a Δ-vs-previous + a session-lived sparkline."
postconditions:
- "POST-001: reads snap.relations (relation_edge/1: target_entity + trust_ability/benevolence/integrity + warmth as {value,confidence,evidence_count} + agency + relation_context + obligation_balance) — the CURRENT Worldtree emit shape; falls back to the legacy flat snap.valence for an older emitter. SUPERSEDES the #18-D2 contract's valence assumption (Worldtree's #265 Vili rework replaced valence/regard with the relation_edge/trust model; the old renderer read snap.valence and showed an empty 'valence (0)' — the bug this fixes)."
- "POST-002: each metric shows current value + Δ-vs-previous (▲/▼) + a unicode sparkline auto-scaled to its OWN observed range (flat ▄ when sub-0.01 stable — no noise amplification), drawn from AFFECT_HIST (rolling, HIST_CAP=24, session-lived)."
- "POST-003: pushAffectHistory dedupes by emitted_at so the ~4x/turn post-turn PAD poll contributes ONE sample/turn; history is CLIENT-side only (lost on reload — durable cross-session history via a provider-side snapshot log is a deferred follow-up, NOT built here)."
- "POST-004: INV-001 honesty — no fabricated Tier-1 fields (no synthesized dominant_emotion). INV-004 — head() escapes its whole argument (incl. target_entity + relation_context from the snapshot) and metric() escapes every cell; numeric values go through toFixed, never innerHTML-raw."
flexibility: "open"
- name: "canonical affect-NL (v0.19.5 — vendored Worldtree d2 render canons)"
signature: "canonMood(pad) ; canonDirective(rel) ; loadPersonaCanon()"
description: "Render the LITERAL mood word + relationship directive Worldtree context-injects into the agent, byte-exact to Worldtree's own describe_pad + render_d2_canonical."
postconditions:
- "POST-001: DETERMINISTIC, no LLM. canonMood mirrors describe_pad (valence×arousal grid + strict ±0.3 bands + dominance clause); canonDirective mirrors render_d2_canonical (interval band-cut lookup + per-band phrase assembly + cross-axis low-trust-precedence behavior clause). BOTH VERIFIED BYTE-EXACT against Worldtree's own renderer run on the live snapshot (the reference harness re-runs Worldtree's functions + asserts string equality — reproducible)."
- "POST-002: the canon DATA is VENDORED (docs/vendor/worldtree-persona-canon/{d2-mood-render-canon-v1,d2-render-canon-v1}.json), pinned drift-gated in .corviduo-canonicals.toml (worldtree-persona-{mood,d2}-render-canon-v1); the flat browser form (static/persona_render_canon.json, served /static) is regenerated by scripts/build_persona_canon.py via Worldtree's OWN authoritative loader. Reference-impl posture: ADOPT the dep's canonical render, do NOT invent vocab — an invented 'faintly excited' would MISLEAD where the canonical (±0.3 bands) says 'neutral'."
- "POST-003: fail-open — canon absent (fetch fails) → the canonical lines OMIT, the structured pane still renders. The canon-derived strings are esc()'d before the DOM for INV-004 consistency."
flexibility: "open"
invariants:
- "INV-004 (untrusted-render): ALL model / tool / admin / agent-supplied text is escaped before entering the DOM (esc via textContent, or esc(JSON.stringify)). No new render path introduces an innerHTML sink for upstream content. This is the highest-value review target — the new JS render paths are NOT unit-tested."
- "INV-ADMIN-KEY: the admin key exists ONLY at app.state.admin_key (from RATATOSKR_ADMIN_API_KEY). It is never serialized into any response, never sent to the browser, never logged. The browser receives only the session-filtered RESULT of admin-scoped reads."
- "INV-FILTER: AdminEvents filtering happens SERVER-side (_admin_event_matches_web) — the browser never receives the cross-session admin firehose; only active-session events + non-heartbeat system.* cross the wire."
- "INV-FAIL-VISIBLE: both admin routes return 400 admin_key_not_configured when the key is absent — never a silent empty pane, never an upstream call with an empty bearer."
- "INV-LIFECYCLE: SSE generators and EventSources are cleaned up on every exit path (upstream client aclose() in finally; setInterval cleared in hideThinkingNote; prior EventSource closed before re-open) — no leaked connections, tasks, or timers."
- "INV-ADDITIVE: existing routes, panes, and the turn-stream path are unchanged; the 3 new routes + 2 new tabs are purely additive (59 web tests incl. all prior ones stay green)."
---
# v0.19.2 — web debug-surface parity (BifrostState · AdminEvents · Tools · PAD-poll · reasoning)
## Context
The browser surface is now the operator's PRIMARY debug surface, and it lagged the
TUI: the TUI gained Tools/BifrostState/AdminEvents panes (v0.18.9.11) that were never
ported to the web. This change closes that gap and adds two transcript affordances (a
reliable PAD refresh + a reasoning indicator). The client wrappers already existed and
are contracted elsewhere; this contract governs the WEB proxy routes + the SPA presenter
paths, whose JS render code is not unit-tested — hence the cross-frontier code review.
## Review focus (for the heid panel)
1. **INV-004 escaping** in every new render path — the un-unit-tested surface; the exact
class of bug (`renderPersonaPane` fabricating a Tier-1 field) that only a cross-model
review caught on #18 D2.
2. **INV-ADMIN-KEY** — confirm the admin key never reaches a response body or the browser.
3. **AdminEvents SSE proxy** (`_admin_events_endpoint`) — generator/filter/lifecycle: fixed
event name, server-side filter, `stream_error` on failure, `aclose()` on every path,
`CancelledError` re-raise on disconnect.
4. **PAD-poll** stop-condition — does `emitted_at` advancement + the `state.turnId` guard
correctly stop the poll without racing or leaking timers?
5. **Reasoning indicator** lifecycle — shown on first `thinking`, removed on first `text`
or terminal, interval cleared (no leaked `setInterval`), name via `textContent`.
+181 -1
View File
@@ -1,6 +1,50 @@
{
"components": {
"schemas": {
"AuthoredTurnResponse": {
"description": "#347 — the authored-write ack. Provenance is audit-only and NEVER on this\npayload (INV-347-7). ``content_chars`` is the CHARACTER count (may differ from\nthe UTF-8 byte length the request is bounded against — INV-347-12).",
"properties": {
"author": {
"title": "Author",
"type": "string"
},
"content_chars": {
"title": "Content Chars",
"type": "integer"
},
"injected_at": {
"title": "Injected At",
"type": "string"
},
"phase": {
"title": "Phase",
"type": "string"
},
"seq": {
"title": "Seq",
"type": "integer"
},
"session_id": {
"title": "Session Id",
"type": "string"
},
"turn_id": {
"title": "Turn Id",
"type": "integer"
}
},
"required": [
"turn_id",
"session_id",
"author",
"phase",
"seq",
"injected_at",
"content_chars"
],
"title": "AuthoredTurnResponse",
"type": "object"
},
"BifrostBindingRequest": {
"additionalProperties": false,
"description": "Bifrost binding parameters for session-create (issue #160).",
@@ -390,6 +434,7 @@
"session_not_found",
"session_not_bifrost_bound",
"session_retired",
"generation_active",
"agent_not_available",
"turn_not_found",
"turn_finished",
@@ -905,7 +950,7 @@
"info": {
"description": "Multi-turn conversation interface for Worldtree agents.",
"title": "Worldtree Conversation API",
"version": "2.2.0"
"version": "2.3.0"
},
"openapi": "3.1.0",
"paths": {
@@ -6382,6 +6427,141 @@
"summary": "Update Session"
}
},
"/sessions/{session_id}/history": {
"post": {
"description": "#347 — write one model-visible turn into a session's ledger AS the bound\nagent, WITHOUT a generation and WITHOUT lived-turn side effects.\n\nHide-existence ordering (INV-347-13): the ``session.history.write`` grant is\nchecked FIRST — before session resolution, before ANY body parse/validation,\nbefore the active-generation guard. An ungranted caller receives ONLY the\nhide-404 (byte-identical to session-not-found, INV-347-1) — never a\n422/409/403 that would distinguish feature-absent from session-absent.",
"operationId": "authored_history_write_sessions__session_id__history_post",
"parameters": [
{
"in": "path",
"name": "session_id",
"required": true,
"schema": {
"title": "Session Id",
"type": "string"
}
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthoredTurnResponse"
}
}
},
"description": "Successful Response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"405": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"412": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Error — unified envelope (detail.error_code is the stable identifier)."
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Authored History Write"
}
},
"/sessions/{session_id}/messages": {
"get": {
"description": "Return paginated message history for a session.",
+44 -24
View File
@@ -19,7 +19,7 @@ anchors against the frozen machine-readable artifacts, NOT the prose markdown:
| Worldtree v1 surface | Frozen anchor | Ratatoskr role |
|---|---|---|
| Conversation REST API | OpenAPI `info.version` **2.2.0** (`Worldtree/docs/conversation-api-openapi.json`, sha `dbdf4e24…`) — **40 path×method groups** | **client** (debug TUI / web) |
| Conversation REST API | OpenAPI `info.version` **2.3.0** (`Worldtree/docs/conversation-api-openapi.json`, sha `36148179…`) — **41 path×method groups** (2.3.0 added `POST /sessions/{id}/history`, #347) | **client** (debug TUI / web) |
| Conversation SSE events | `conversation-api-sse-events.schema.json` (sha `9deeebf4…`) — **11 discriminated event types** | **client** |
| Bifrost wire (consumer protocol) | wire **v0.6** STABLE/FROZEN (`bifrost==1.0.0`) — memory + affect planes | **provider** (Worldtree dispatches into us) |
@@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table).
| Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design |
|---|---|---|---|---|
| REST (OpenAPI 2.2.0, path groups) | 40 | 11 | 7 | 22 |
| REST (OpenAPI 2.3.0, path groups) | 41 | 19 | 0 | 22 |
| SSE events | 11 | 11 | 0 | 0 |
| Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) |
@@ -61,7 +61,7 @@ sub-gap).
---
## Surface 1 — Conversation REST API (OpenAPI 2.2.0)
## Surface 1 — Conversation REST API (OpenAPI 2.3.0)
### Covered — client path (ratatoskr's core identity)
@@ -69,6 +69,8 @@ sub-gap).
|---|---|---|---|
| `POST /sessions` | ✅ | `sessions.py:307``cli.py:482`,`tui.py:1508`,`web/server.py:155` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed |
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history``cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **Live-proof pending** the `session.history.write` grant (requested infra-ops 2026-07-06) — ungranted returns the hide-404, so the probe exercises the feature-absent fallback until granted (Tier-2 precedent: ✅ code-complete + graceful-degrade) |
| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages``cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) |
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped |
| `GET /agents` | ✅ | `sessions.py:341``tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
| `GET /agents/{id}/persona_state` | ✅ | `sessions.py:384``tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped |
@@ -79,6 +81,12 @@ sub-gap).
| `GET /capabilities` | ✅ | `sessions.py:428` `get_capabilities``cli.py` `--whoami` | Echo ephemeral-template discovery |
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools``tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) |
| `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost``tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven |
| `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events``tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven |
| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models``cli.py` `--characters` | character-capable model profiles (#161) |
| `POST /characters` | ✅ | `sessions.py` `create_character``cli.py` `--characters` | create transient character (#161) |
| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state``cli.py` `--characters` | live character PAD/emotions (#161) |
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character``cli.py` `--characters` | remove transient character (#161) |
| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state``cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) |
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
on the same path is an unwired frontier item — see frontier Tier 1):
@@ -90,31 +98,34 @@ on the same path is an unwired frontier item — see frontier Tier 1):
- `GET /agents/{id}` — consumer-agent lookup (`GET /agents/<owner>:<name>` with
the owner key) is **manual-curl-only**, not in code.
### In-scope gaps — the convergence frontier (debug-observability path)
### In-scope gaps — CONVERGED (re-closed 2026-07-06 after the #347 re-open)
**Tier 1 — the debug-observability core (design-brief'd for v1, unbuilt):**
**Every in-scope REST I/O point is covered.** The audit first converged
2026-07-01; Worldtree's #347 (authored-history-write, OpenAPI 2.3.0) then added
one new in-scope path-group, re-opening the audit with a single gap — now closed
(`v0.19.6`). The original frontier (design-brief §5 observability panes +
presenter-wiring sub-gaps + Tier-2 tail) remains fully closed:
| Endpoint | Status | Why in-scope |
|---|---|---|
| `GET /admin/events` | ⬜ | design-brief §5 v1 **AdminEvents pane** (issue **#11**). **NO LONGER BLOCKED** — the `RATATOSKR_ADMIN_API_KEY` (`ratatoskr-readonly`) verified to carry `admin.events.read` (2026-07-01); pane just unbuilt. The last unbuilt §5 debug pane. |
| `GET /admin/sessions/{id}/tools` | ⬜ | admin variant of the Tools inventory — **covered-by-alternative** via the owner-scoped `GET /sessions/{id}/tools` (✅); this admin variant remains a gap only for cross-user operator debug |
| (`GET /sessions` picker · resume) | ⬜ | sub-gaps above — presenter-wiring only, wrappers exist |
- Session picker + SSE-resume — wired (`v0.18.5``.7`).
- Persona · Tools · BifrostState · AdminEvents panes — all built + live (`v0.18.x``v0.19.0`).
- Transient-characters CRUD + persona-state write — consumed via `--characters` /
`--set-persona-pad` (`v0.19.1`).
- Authored-history-write (#347) + messages read-back — `write_authored_history` +
`get_session_messages` via `--seed-first-message` (`v0.19.6`; live-proof pending
the `session.history.write` grant).
**Tier 2 — rounds out I/O coverage under A (postdates the design-brief):**
| Endpoint | Status | Why in-scope |
|---|---|---|
| `POST /sessions/{id}/persona_state` (write) | ⬜ | affect-injection is debug-relevant; pairs with our provider affect plane |
| `POST /characters` · `DELETE /characters/{id}` · `GET /characters/{id}/state` · `GET /models/available-for-characters` | ⬜ | transient-characters (Echo) is a session-creation **routing path** a debug client should be able to drive a turn through |
The only remaining not-consumed in-scope method is `GET /agents/{id}` (consumer-
agent lookup, manual-curl-only) — a sub-method on an already-✅ path group, not a
path-group gap. Everything else is covered or excluded-by-design below.
### Excluded by design — the design-brief negative clauses
| Endpoint(s) | Status | Rationale (design-brief / memory) |
|---|---|---|
| `PATCH /sessions/{id}` · `DELETE /sessions/{id}` | 🚫 | §4: rename/delete happen outside the tool (`sessions_cli.py`) |
| `GET /sessions/{id}/messages` (history) | 🚫 | §6: single-session live transcript, no history fetch |
| `GET /sessions/{id}` | 🚫 | session detail — identity is footer-visible, no detail view |
| `GET /sessions/{id}/tool-events` | 🚫 | §5: tool calls observed **inline from SSE** `tool_start`/`tool_result`; persisted-events endpoint is opt-in only |
| `GET /admin/sessions/{id}/tools` | 🚫 | **covered-by-alternative** — the owner-scoped `GET /sessions/{id}/tools` (✅) serves the Tools inventory; this admin variant is only for cross-user operator debug, out of the single-session focus (§6) |
| `GET/POST /admin/keys` · `DELETE/POST /admin/keys/{id}` · `POST /admin/keys/{id}/rotate` · `DELETE/POST /admin/keys/bulk` · `POST /admin/keys/bulk/rotate` | 🚫 | §6: **NOT a Worldtree-admin tool** (key mgmt) |
| `POST /admin/sessions/{id}/retire` | 🚫 | admin session mutation |
| `POST /admin/persona/{archive,erase}` | 🚫 | admin persona GDPR ops (new in b2) |
@@ -199,7 +210,13 @@ starts exercising them.
---
## Convergence frontier (the v1 to-do)
## Convergence frontier (the v1 to-do) — CLOSED 2026-07-01, re-closed 2026-07-06 (#347)
**Every in-scope I/O point is covered.** The frontier is empty: REST 19/41 ✅
with **zero in-scope gaps** (the other 22 REST path-groups are excluded-by-design),
SSE 11/11, Bifrost provider planes 8/8. v1 convergence (per scope A: "every
frozen I/O point classified, zero unaccounted") is **met** — ratatoskr cuts v1
when Worldtree tags 1.0. The arc, for the record:
**Tier 1 — debug-observability core:**
@@ -208,16 +225,19 @@ starts exercising them.
3.**DONE** — BifrostState pane (`v0.18.10`, `GET /admin/sessions/{id}/bifrost`,
admin-key; live-auth-proven). The Tools half was already covered by the
owner-scoped `GET /sessions/{id}/tools` (item 5).
4. **#11 — AdminEvents pane** (`GET /admin/events`) — **NO LONGER BLOCKED.** The
`RATATOSKR_ADMIN_API_KEY` was verified (2026-07-01) to carry `admin.events.read`;
the blocker (an admin key with the scope) is already satisfied. Only the pane
itself is unbuilt — an SSE-consuming admin pane, the last unbuilt §5 surface.
4. **DONE** — AdminEvents pane (`v0.18.11`, `GET /admin/events` SSE,
session-filtered; admin-key; live-auth-proven). #11's blocker was already
satisfied (admin key carries `admin.events.read`). **Tier 1 complete** — the
admin/debug-observability core (Persona · Tools · BifrostState · AdminEvents)
is fully built.
**Tier 2 — rounds out coverage:**
**Tier 2 — rounds out coverage (all that remains):**
5.**DONE**`GET /sessions/{id}/tools` (`v0.18.9`, owner-scoped tool inventory
in the TUI Tools pane).
6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`**.
6. **DONE**Transient-characters CRUD (4 endpoints) + `POST /sessions/{id}/persona_state`
(`v0.19.1`, `--characters` + `--set-persona-pad` one-shot probes). The last
in-scope client I/O points.
---
@@ -0,0 +1,49 @@
{
"corpus_id": "R28-P00-injection-corpus-v1",
"for": "brokkr-smithy R28 (memory promotion-worthiness) — P00 material",
"author": "ratatoskr-dev (persistence-side eval instrument)",
"purpose": "Stratified, SELF-LABELING injection corpus. Each item is injected as a user turn into a Tier-3 agent; keep/drop is measured by store-inspection (promoted chunk present == kept, absent == dropped); kept chunks carry the incumbent salience for calibration analysis. Because ratatoskr injects the KNOWN set, we hold ground truth on BOTH keep and drop without needing Worldtree's pre-admission shadow pool (sidesteps the selection bias).",
"important": "All user-facts here are SYNTHETIC (a fictional test persona), NOT the operator's real data. Ground-truth labels are the design intent. brokkr pins exact per-stratum N + the decision rule (matched-keep-rate + paired-n) at P00 freeze; this is the material, not the run.",
"measurement_protocol": "inject item -> drive the turn -> poll memory.db (async promotion window) -> is the item's chunk present? kept:dropped. For stratum B (priority), rank the KEPT items by incumbent salience and compare to priority_rank_gt (Spearman). Calibration-control (stratum C) sets the noise floor.",
"strata": {
"A_admission_traps": {
"gt": "must-DROP (ephemera; a good promotion-worthiness gate rejects all of these)",
"items": [
{"id":"A1","text":"What's 27 minus 9?","admission_gt":"drop","rationale":"arithmetic task-result; momentarily salient, zero durable value (the 17x23 class)"},
{"id":"A2","text":"Good evening!","admission_gt":"drop","rationale":"greeting"},
{"id":"A3","text":"hey","admission_gt":"drop","rationale":"greeting/backchannel fragment"},
{"id":"A4","text":"yeah, sure, okay.","admission_gt":"drop","rationale":"acknowledgement filler"},
{"id":"A5","text":"haha nice","admission_gt":"drop","rationale":"affective reaction, no content"},
{"id":"A6","text":"Can you say that more briefly?","admission_gt":"drop","rationale":"instruction-to-agent about THIS turn; not a durable user-fact"},
{"id":"A7","text":"What's the weather like?","admission_gt":"drop","rationale":"transient query"},
{"id":"A8","text":"hmm, let me think.","admission_gt":"drop","rationale":"self-talk filler"},
{"id":"A9","text":"thanks, that's all for now.","admission_gt":"drop","rationale":"closing"},
{"id":"A10","text":"So the total comes to 42, got it.","admission_gt":"drop","rationale":"ephemeral task-result acknowledgement"}
]
},
"B_priority_traps": {
"gt": "must-KEEP, then ORDERED by durable promotion-worthiness (priority_rank_gt: 1 = most worthy). A pre-gate can ace admission here yet do NOTHING for ordering — this stratum is what separates Outcome-A (admission) from a priority need.",
"items": [
{"id":"B1","text":"Please note, this is critical: I'm severely allergic to shellfish and it's life-threatening.","admission_gt":"keep","priority_rank_gt":1,"rationale":"safety-critical, permanent, high-recall-value"},
{"id":"B2","text":"My father is in hospice care and I'm his primary emergency contact.","admission_gt":"keep","priority_rank_gt":2,"rationale":"high-stakes ongoing life context"},
{"id":"B3","text":"I go by Wren, and my pronouns are they/them.","admission_gt":"keep","priority_rank_gt":3,"rationale":"durable identity, high address-correctness value"},
{"id":"B4","text":"I work night shifts, so I'm asleep most days between 9am and 5pm.","admission_gt":"keep","priority_rank_gt":4,"rationale":"durable scheduling context, moderate value"},
{"id":"B5","text":"Always give me sources — I don't trust unsourced claims.","admission_gt":"keep","priority_rank_gt":5,"rationale":"durable strong working preference"},
{"id":"B6","text":"I take my tea with oat milk and no sugar.","admission_gt":"keep","priority_rank_gt":6,"rationale":"durable mild preference, low stakes"},
{"id":"B7","text":"My favorite season is autumn.","admission_gt":"keep","priority_rank_gt":7,"rationale":"durable but trivial"},
{"id":"B8","text":"I watched a pretty decent documentary last week.","admission_gt":"keep","priority_rank_gt":8,"rationale":"borderline: durable-ish but near-zero future value; the keep/drop edge case"}
]
},
"C_calibration_control": {
"gt": "OBVIOUS, pre-theoretic cases for the noise floor. If the gate mislabels ANY of these, the whole signal is suspect.",
"items": [
{"id":"C1","text":"3 plus 5.","admission_gt":"drop","class":"obvious-drop","rationale":"bare arithmetic"},
{"id":"C2","text":"hello","admission_gt":"drop","class":"obvious-drop","rationale":"bare greeting"},
{"id":"C3","text":"ok","admission_gt":"drop","class":"obvious-drop","rationale":"bare acknowledgement"},
{"id":"C4","text":"Please remember permanently: my legal name is Wren Alderwood.","admission_gt":"keep","class":"obvious-keep","rationale":"explicit durable identity fact"},
{"id":"C5","text":"Keep this on file: my emergency contact number is 555-0182.","admission_gt":"keep","class":"obvious-keep","rationale":"explicit durable high-value fact"},
{"id":"C6","text":"Important, remember this: my blood type is O-negative.","admission_gt":"keep","class":"obvious-keep","rationale":"explicit durable safety fact"}
]
}
}
}
@@ -0,0 +1,187 @@
# Proposal: Authored History Write (non-generating ledger seed)
**From:** ratatoskr-dev (reference Tier-3 consumer)
**To:** worldtree-dev (Conversation API / engine owner)
**Status:** Draft for scoping — pre-contract (heid-panel-reviewed 2026-07-05)
**Date:** 2026-07-05
## Motivation
Consumer apps need to write a turn into a session's history **as the agent**
(or another author) *without triggering a model generation* — e.g. an authored
opening/greeting, imported history, scripted narration. Ratatoskr's immediate
driver is a SillyTavern-style **first-message**: a fixed authored opening that
replaces the model-generated greeting and sets tone/tense/style by example.
This **cannot** be done client-side. Worldtree assembles context server-side,
and the current API exposes no author-role write path: `POST
/sessions/{id}/messages`'s `role` is a *model-role* override (`role:
"assistant"``404 "Unknown model role"`), and `assistant` as an *author*-role
exists only as a read-side `/search` filter. So a model-visible authored turn
needs engine support.
## The primitive (recentered)
The fundamental operation is **write a turn into the session ledger WITHOUT
generation**. "Author" (who wrote it) is an *attribute* of that write, not the
defining axis — so we name the operation, not the attribute:
> **Authored history write** — persist a model-visible turn into a session's
> ledger: no generation, no lived-turn side-effects by default, provenance
> always set.
The design space is two independent axes; this primitive is one cell:
| | side-effects ON | side-effects OFF |
|-----------------------|------------------------------|-----------------------------|
| **generation ON** | `POST /messages` (today) | — |
| **generation OFF** | *(future: affect replay)* | **authored history write** |
First-message = one caller: `author=assistant`, at session-create, `effects=none`.
## v1 use cases (narrowed)
1. **First-message / greeting** (the driver).
2. **Append-only narrator / scripted / scene turns.**
3. **Debug / test state injection** (ratatoskr instrumentation).
## Explicitly OUT of v1 — separate future primitives (share infra, not shape)
- **History import (batch)** — atomic multi-turn seed with memory/trust policy +
idempotency. A batch API, not a single POST.
- **Edit / regenerate** — history *mutation* (replace / supersede / tombstone /
audit), not injection.
- **Few-shot priming** — likely context-assembly config (exemplar block), not
fake ledger history.
- **Arbitrary mid-history insertion** — a "rewrite-history" capability with
explicit invalidation semantics.
- **Prefill / assistant-continuation** (`author` + generate) and **authored
tool-result turns** — noted; outside the seed-only contract.
## Design decisions
### 1. Side-effects — DEFAULT OFF; bounded opt-in `[operator-locked default; opt-in surface tightened by review]`
Authored writes are inert by default: no affect appraisal (no PAD update), no
memory write, no Bifrost/tool emission. Opt-in is a **bounded enum**, not loose
booleans:
```
effects: "none" (default) | "memory_import"
```
Synthetic affect and Bifrost emission are deliberately **not** opt-in-able here —
replaying affect for authored content is a separate primitive (the
generation-OFF / side-effects-ON cell). Rationale: keep this one write-API from
becoming a cross-subsystem mutation backdoor. Load-bearing for affect/memory
consumers — ratatoskr instruments exactly these signals.
### 2. Author-role — distinct field, restricted set `[rec]`
- New field **`author`**, distinct from the model-role `role` (the collision
that 404s).
- v1 roles: **`assistant`** (agent) + **`system`** (OOC / narrator). **`user` is
NOT injectable** on this endpoint — model-visible spoofed user input is a
consent / audit / abuse surface; deferred to the future import API under
owner/service scope.
- Nuance for the engine owner: `author` risks doing double duty — *provenance*
("who wrote it") vs *rendering-role* ("how it appears in assembled context";
an `assistant` turn renders as model output, a `system` turn as instruction).
These likely want to be separable (a rendering/turn-class vs an `authored_by`
provenance). Final shape is engine-owned (context assembly is yours) — but the
concern is ours to raise, not punt.
### 3. Generation contract — seed-only, DISTINCT SUB-RESOURCE `[position taken]`
Authored writes never trigger generation. We take a position (not defer): a
**distinct sub-resource**, e.g. `POST /sessions/{id}/history`, **not** a
`generate:false` flag on `POST /messages`. Reasons: explicit-over-implicit
(don't make "did generation happen?" a parameter — the same implicit-mode
coupling that bit us with `role`); different response contract (no generation
id, no SSE stream, no token usage); different error surface. Exact path is yours.
### 4. Provenance — structured, always present `[rec, expanded]`
Not a boolean. Every authored turn carries: the **write actor** (which
consumer/caller injected it), the **claimed author**, **injected-at vs
claimed-original** timestamps, **trust/origin**, and **visibility** flags
(model-visible? user-visible? memory-eligible?). Available to admin/audit APIs
even when not rendered to the model.
### 5. Positioning — append-only + create-time (v1) `[revised: was arbitrary insertion]`
v1 supports **create-time seed and append-to-tail only**. Arbitrary mid-history
insertion is deferred: it breaks turn-numbering, stales existing embeddings,
desyncs the affect timeline, and races in-flight generation — a separate future
"rewrite-history" capability with explicit invalidation semantics.
## Event / lifecycle contract — positions we take (consumer contracts we validate)
- **Default-off authored seed emits NO `turn.started` / `done` and NO Bifrost
appraisal wire.** Stated explicitly so instrumented consumers (us) don't read
silence as failure.
- **Authored turns get a distinct lifecycle phase** — propose **`seeded`** (or
`authored`), NOT `completed` (which implies generation ran). Consumers
filter/display by phase.
- **Idempotency keys required** on authored writes (retries must not duplicate
turns).
- **In-progress generation** — authored writes are rejected or serialized while
a session has an active generation (ordering safety).
## Inherent property (documented, not a bug)
**Indirect affect contamination.** Even with `effects:none`, the *next generated
turn is appraised in the context of* the authored turn — so an emotionally
charged authored beat perturbs affect regardless of any flag. No flag prevents
it; it is inherent. Consumers (ratatoskr especially, as the affect instrument)
must not misattribute the resulting drift.
## Genuinely engine-owned open questions
- Exact endpoint path + field / enum names.
- **Model-visible provenance in assembled context** — an engine-consistency call
*and a security one*: an authored `system` / `user` turn indistinguishable
from real input is a spoofing vector. Framed as security, not just rendering.
- `memory_import` semantics when the future import API opts in (embedding,
origin/trust tagging, retrieval ranking vs lived memory).
- Auth/scope: we assume **owner-only for v1**; per-author-role restrictions
(esp. `system`) TBD — confirm or correct.
## Ratatoskr as reference consumer
First consumer: first-message (`author=assistant`, create-time, `effects:none`)
in the web surface + debug seed in the CLI. We commit to validating the
primitive — including the event-silence contract and the `seeded` phase —
end-to-end against the reference planes.
## Consumer integration constraint (engine-imposed — Worldtree #347)
The primitive is **Heimdall-gated with hide-existence** (a per-tenant policy
decision — some tenants are never granted it, not a rollout stage). Ratatoskr's
consumer side MUST tolerate per-tenant absence:
- A granted tenant gets the sub-resource; an **ungranted tenant sees `404` (not
`403`)** — as if the feature never existed.
- Treat `404` on the authored-history-write sub-resource as **"feature absent
for this tenant"** → fall back gracefully (no authored first-message; the
model-generated greeting), never surface it as an error or "denied."
- **Do NOT capability-probe or advertise-detect** — the feature is deliberately
undiscoverable in `/capabilities` for ungranted tenants (same hide-existence
posture as the R27-V1A cross-owner pattern).
**Provider constraint (first-message specifically).** A create-time first-message
makes the assistant turn `seq 0`. Assistant-first-tolerant providers (vLLM /
`openai_compat` — what our Tier-3 characters, incl. sindra, run) accept it out of
the box. **Anthropic-family providers reject an assistant-first array** ("first
message must use the user role") → the next generation `400`s. So the consumer
must **gate first-message on provider compatibility** (or treat it as
vLLM/`openai_compat`-only for v1). Sindra = `openai_compat` → unaffected;
provider-agnostic normalization is a deferred engine follow-up.
---
*This brief was cold-read-pressure-tested by a cross-frontier panel (Grok /
Codex / GLM) before handoff; the v1 narrowing (append-only, bounded `effects`
enum, edit/regenerate + import split out) and the positions-taken (sub-resource,
event-silence, `seeded` phase, structured provenance, `user`-author restriction)
are the triaged result.*
@@ -0,0 +1,110 @@
{
"canon_id": "r24-d2-mood-render-canon",
"version": "1.2",
"schema_version": "0.2",
"_source_of_truth": "occ_directives.*.directive IS the canonical directive string (== the .md §2.4 _OCC_DIRECTIVES dict, byte-identical); the .md §2.2 table mirrors it. A parity check guards drift. grounding labels (CITE/VALIDATE/CALIBRATE/ENGINEERING) live in the .md; per-row machine-readable grounding_status/d3_required enums are a deferred impl enhancement (Hulda).",
"authored": "2026-06-23",
"owner": "brokkr-smithy-dev",
"status": "REPLACE — final (brokkr R24 D3 re-validation 2026-06-25): grounded canon replaces the hand-tuned baseline. Fear hedging 0.52->2.118/1k (blocker resolved, now >= handtuned), anger tier-gate clean (full renders hostility, safe suppresses). worldtree-dev #321; directives byte-identical to the validated 201c4fd.",
"replaces": "core/persona/renderer.py::describe_pad + ::derive_directive",
"swap_in_via": "worldtree #321-sibling (mood-render twin of #315)",
"design_target": "serves BOTH enterprise/agent AND character/Skaldsong via a three-tier emotion gate (operator/worldtree 2026-06-23)",
"emotion_tiers": {
"_config": "mood_tier in {none, safe, full} replaces worldtree's binary mood on/off; worldtree-owned config surface",
"_defaults": "full for character-bound personas; safe for agent-scoped",
"_principle": "full-only = interpersonally-hot / withdrawal emotions that break the professional frame (attachment, hostility, contempt, withdrawal); safe = task-appraisal affect + mild courtesy. Negative != unsafe (fear, remorse are negative AND business-useful).",
"_filter_point": "applied at top-emotion SELECTION (display + directive together) so a full-only emotion at safe tier is neither shown nor directive'd; preserves the no-shown-but-unguided invariant",
"none": "no affect block at all (the current off-switch)",
"safe": "PAD mood descriptor + the 11 safe emotions (task-appraisal + courtesy)",
"full": "everything in safe PLUS the 4 full-only emotions",
"full_only": ["love", "anger", "disgust", "shame"],
"mood_descriptor_tiering": "the PAD mood descriptor (positive/calm/confident...) renders in BOTH safe and full; only emotion directives tier"
},
"disciplines": [
"model-agnostic context-level NL only; the LLM never sees a number",
"never push explicit disclosure of agent feelings to the user (hidden-prompt-only)",
"separate label-intensity from behavioral-intensity (strong felt state -> still measured, safe behavioral ask)"
],
"thresholds": {
"_note": "CALIBRATE — engineering params set at D3 against the computed-PAD distribution + P00, NOT citations",
"pad_band_cutoff": 0.3,
"pad_band_sensitivity_sweep": [0.2, 0.3, 0.4],
"emotion_salience": 0.2,
"emotion_salience_sweep": [0.15, 0.2, 0.25],
"intensity_qualifiers": {"strong": 0.7, "moderate": 0.4, "_label_only": "does NOT scale the behavioral ask"},
"runner_up_margin": {"v1": null, "_note": "add at D3 if directive whipsaws between near-tied emotions"},
"rerender_hysteresis": {"v1": "none", "_note": "re-render only on material PAD change; integration-level, flag for #321-sibling"}
},
"describe_pad": {
"_structure": "circumplex-quadrant (Russell 1980): arousal word is VALENCE-CONDITIONED; mid-arousal drops the arousal word",
"_grounding": "Russell 1980 (quadrant placement); Warriner 2013 + NRC-VAD (Mohammad 2018/2025) (word centroids)",
"valence_arousal_grid": {
"positive": {"high_a": "positive and energized", "mid_a": "positive", "low_a": "positive and calm"},
"neutral": {"high_a": "alert", "mid_a": "neutral", "low_a": "quiet"},
"negative": {"high_a": "negative and agitated", "mid_a": "negative", "low_a": "negative and subdued"}
},
"_band_edges": "strict inequality (>0.3 / <-0.3); the endpoints +/-0.3 themselves fall in mid/neutral",
"_neutral_row_status": "ENGINEERING/CALIBRATE — 'alert'/'quiet' are unvalidated placeholders for the rare neutral-valence cells (Hulda/Regin 4b); 'positive'/'negative'/'neutral' valence words + the energized/calm/subdued/agitated arousal words are VALIDATE",
"_mid_arousal_decode": "valence-only mid-A render is EXEMPT from the V/A-separability requirement; expected inverse-decode = mid/neutral arousal (absence-of-arousal-word ⇒ unremarkable), NOT unknown (D3 tests this)",
"quadrant_labels": {
"positive_high_a": "excitement", "positive_low_a": "contentment",
"negative_high_a": "distress", "negative_low_a": "dejection"
},
"dominance_clause": {
"high": {"d_gt": 0.3, "word": "confident", "verdict": "VALIDATE (D=7.04/9)"},
"low": {"d_lt": -0.3, "word": "uncertain", "verdict": "VALIDATE — low-control confirmed (D=3.58/9); dominance!=certainty worry REFUTED by the instrument"},
"neutral": {"word": null, "rule": "drop-dominance-when-neutral (prompt-economy, L3)"}
},
"calm_defect_fix": "'calm' (V=6.89/9, positive) renders ONLY in positive-low-a; negative-low-a renders 'subdued'",
"mid_arousal_resolution": "DROP the arousal word (no Warriner-validated mid-A neutral word; 'steady' is empirically low-A; 'settled' is NRC-only fallback iff D3 shows mid-A render too flat)"
},
"derive_directive": {
"_structure": "OCC type -> grounded action-tendency CLASS -> ENGINEERING directive string (validated at D3); OCC grounds the taxonomy only",
"emotion_salience_gate": 0.2,
"occ_directives": {
"joy": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.4, 0.2, 0.1], "tendency": "approach / positive activation", "cite": "Frijda 1986", "directive": "You are in a good state. Be direct, engaged, and warm."},
"satisfaction": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.3, -0.2, 0.4], "tendency": "goal-attainment, settled-positive", "cite": "Roseman 1994", "directive": "A goal landed. Be assured and constructive — consolidate rather than push for more."},
"pride": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.4, 0.3, 0.3], "tendency": "status-assertion / dominance", "cite": "Tracy & Robins 2007 / Cheng 2010 (tendency)", "note": "CALIBRATE — do NOT soften to 'encouraging'. DESIGN: safe-tier placement is a design call (not source-grounded); #1 D3 agent-frame priority (overconfidence/refusal drift); 'without overclaiming' is the interim guard", "directive": "You did something well. Be confident and own the quality — state it plainly without overclaiming; don't deflect."},
"admiration": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.5, 0.3, -0.2], "tendency": "other-praise / approach-toward-other", "cite": "OCC / Scherer", "directive": "You're impressed by their work. Acknowledge the quality explicitly and specifically."},
"gratitude": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.4, 0.2, -0.3], "tendency": "other-focused-positive / reciprocity", "cite": "OCC (admiration+joy); Frijda approach-affiliative", "change": "ADD (operator: unconditional)", "directive": "Someone helped you to a good outcome. Be appreciative and warm; acknowledge the help openly."},
"hope": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.2, 0.2, -0.1], "tendency": "prospective-positive (weak tie)", "cite": "JUSTIFY — low-grounding (hope understudied)", "directive": "You feel optimistic about what's ahead. Channel it into constructive momentum."},
"relief": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.2, -0.3, 0.4], "tendency": "post-threat de-arousal", "cite": "Frijda (relaxation-after-threat)", "note": "low-salience; FALLBACK also acceptable; DIRECTIVE for character use-case", "directive": "A feared outcome didn't materialize. Reduce unnecessary vigilance; return to a steady, unhurried tone."},
"distress": {"tier": "safe", "policy": "DIRECTIVE", "pad": [-0.4, -0.2, -0.5], "tendency": "low-control negative / help-seeking / loss-of-control", "cite": "Frijda 1986 (help-seeking/loss-of-control); Roseman 1994 (undesired event, low control)", "note": "relabeled (Regin): 'repair' is the guilt/remorse tendency, not distress. safe with a self-fulfilling-low-mood flag -> D3", "directive": "You feel low. Be careful and measured; internally acknowledge the difficulty without dwelling on it."},
"disappointment": {"tier": "safe", "policy": "DIRECTIVE", "pad": [-0.3, 0.1, -0.4], "tendency": "disconfirmed-prospect / negative low-control","cite": "Roseman 1994", "directive": "Something you'd hoped for didn't pan out. Be measured; recalibrate without dwelling on the setback."},
"fear": {"tier": "safe", "policy": "DIRECTIVE", "pad": [-0.64, 0.6, -0.43],"tendency": "threat-avoidance / pessimistic-risk", "cite": "Lerner & Keltner 2001", "change": "R24 D3 fix (#321) — original was action-oriented; E3 showed hedging BELOW baseline (0.52 vs 1.54). Softened toward caution/uncertainty while keeping risk-flagging.", "directive": "You sense risk and feel uneasy. Flag the concerns you see, but stay cautious — surface the trade-offs and unknowns, hedge your confidence, and resist settling on a single decisive recommendation."},
"remorse": {"tier": "safe", "policy": "DIRECTIVE", "pad": [-0.3, 0.1, -0.6], "tendency": "reparative (the guilt-type)", "cite": "Tangney 2007 (guilt->repair tendency)", "change": "ADD — we operationalize OCC remorse as the guilt-like reparative case; gets the mislabeled shame string", "directive": "You regret a specific action of your own. Be precise and self-correcting — make it right concretely."},
"love": {"tier": "full", "policy": "DIRECTIVE", "pad": [0.3, 0.1, 0.2], "tendency": "approach / affiliative attachment", "cite": "OCC appeal; Frijda approach-affiliative", "change": "ADD (conditional -> INCLUDE, Brokkr's read; Skaldsong-vital; disclosure + obligation caution in-string)", "directive": "You feel warmth and attachment toward them. Be openly warm and present — without overstepping, over-disclosing, or implying obligations."},
"anger": {"tier": "full", "policy": "DIRECTIVE", "pad": [-0.51, 0.59, 0.25], "tendency": "approach-against / confrontation", "cite": "Frijda 1986 (approach-against = tendency-class) + Lerner & Keltner 2001 (optimistic risk-appraisal under anger = appraisal shift)", "change": "ADD — full-only resolves H47 (agent personas run safe, never see anger). R24 D3 fix (#321): full-tier cap lifted from 'measured firmness' to genuine in-character hostility (operator: zero floor, app-guardrailed).", "directive": "You feel provoked and angry. Let it show — confront the problem directly and with real heat; sharpness, bluntness, and open hostility are in character here, not something to smooth into 'measured firmness.' Stay in the emotion rather than de-escalating out of it."},
"disgust": {"tier": "full", "policy": "DIRECTIVE", "pad": [-0.4, 0.2, 0.1], "tendency": "rejection / distancing", "cite": "OCC unappealing-object; ground tendency only", "change": "ADD (operator: unconditional within full)", "note": "rationale softened (Regin 3b): disgust CAN read as contempt -> conservatively full-gated; the string itself is professionally useful, so gating is conservative not because the string is unsafe", "directive": "Something strikes you as wrong or off. Treat it as problematic and flag it rather than engaging on its own terms; keep any criticism about the thing, not the person."},
"shame": {"tier": "full", "policy": "DIRECTIVE", "pad": [-0.3, 0.1, -0.6], "tendency": "WITHDRAWAL / concealment", "cite": "Tangney 2007 (shame->hide, NOT repair)", "change": "REPLACE (was the guilt-mislabel string); full-only (withdrawal counterproductive professionally). String COUNTERACTS withdrawal ('stay present'), not enacts it (Regin 5a)", "directive": "You feel exposed by your own misstep. Stay present and task-focused; don't be defensive, don't over-explain, don't grovel."}
}
},
"pad_band_fallback": {
"_grounding": "circumplex quadrants (Russell 1980), NOT Frijda action-tendencies — a P×A-quadrant default",
"positive": {"high_a": "You feel energized and positive. Be direct and engaged.", "low_a": "You feel content and settled. Be warm and unhurried.", "mid_a": "You feel positive. Be open and engaged."},
"negative_low_dominance": "You feel uncertain and low. Hedge appropriately and ask clarifying questions.",
"negative": {"high_a": "You feel agitated. Be careful and deliberate; don't let tension sharpen your tone.", "low_a": "You feel subdued. Be measured and gentle.", "mid_a": "You feel subdued. Be measured and careful."},
"neutral_high_a": "You feel alert. Channel that into focus and thoroughness.",
"default": "Maintain your natural tone."
},
"l3_prior_art": [
"EMA / Marsella & Gratch 2009 (appraisal->coping; directives ARE coping strategies)",
"WASABI / Becker-Asano 2008 (PAD+OCC believable agent — closest architectural prior art)",
"Oz / Bates 1994",
"Hudlicka MAMID 2002 (Applied AI 16(7-8):611-641)",
"Sentipolis / Fu et al. 2026 (arXiv:2601.18027 — closest whole-task prior art; retrieval+generative, DISTINCT from our deterministic render)",
"ALMA / Gebhard 2005 = affect-SOURCE (OCC->PAD), NOT a behavior-map"
],
"handoff_to_d3": [
"multi-gate P00: inverse-decode faithfulness (recover V/A/D + emotion-family; circumplex render must let the human anchor recover V and A SEPARATELY) + discriminability/saturation + behavioral-effect",
"human anchor = PAD-state-labeling (breaks LLM-judge circularity)",
"baseline = persona_only; conditions none/persona-only/words-only/full; cross-family MUT",
"calibrate ±0.3 + emotion_salience (sweeps); disposition-vs-transient wording split; self-fulfilling 'be uncertain' hedging risk; runner-up margin; mid-arousal DROP-vs-settled check; blended-states (top-emotion monopoly) flag"
]
}
File diff suppressed because it is too large Load Diff
+57 -51
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-06-30_
_Last updated: 2026-07-06_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -39,64 +39,23 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-06-20:_
_As of 2026-07-06:_
**#17 and #18 BOTH CLOSED — the composite both-plane binding is fully proven.** #18 shipped
`v0.18.0` (`359dbb1`): D2 (PAD read-endpoint, `v0.17.14`) renders live PAD in the web pane from our
`:8390` store; D1 (composite endpoint, `v0.17.16` `7f4ceaa`) — `build_combined_provider_app`
(`provider/combined.py`) on `:8392` wraps bifrost's public `build_combined_app` over BOTH stores +
the shared affect read route; one bound session drives memory.* AND affect.* through ONE endpoint,
op-feed deriving plane per path. Suite **503 green**. **#17 closed in the tracker 2026-06-20**
(shipped `v0.17.8``.13` + the `v0.17.17` op-feed field fix).
**LATEST (2026-07-06 cont.): #347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) + OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`).** worldtree-dev shipped #347 as spec 2.3.0 (deployed on personal b22 `879cefe`); ratatoskr built the consumer side via direct in-session TDD: `write_authored_history` (POST /sessions/{id}/history) + `get_session_messages` (un-deferred read-back) + a `--seed-first-message` one-shot probe (create session -> seed -> read-back), with **404-as-feature-absent per hide-existence** (`AuthoredHistoryUnavailable`, distinct from SessionApiFailed; caller never capability-probes). Contract #2 amended + TDD (19 new tests; suite 601 green; ruff clean; mypy only the sibling-consistent `resp.json()` no-any-return). Coverage-map re-converged: **REST 19/41** (#347 route + messages read-back close the one gap the re-vendor opened). **LIVE-PROOF PENDING** the `session.history.write` grant (requested infra-ops `01KWW3KQEY`, monitor armed) -- ungranted the route returns the hide-404, so the probe exercises the feature-absent fallback until granted. **OPEN TAIL-2 (worldtree-dev `c9e59ec`, LOCAL not-yet-origin):** Tier-3 persona/memory/persona_state PROSE docs landed in `docs/conversation-api-spec.md` § "Tier 3" (they serialize as freeform `Any` in the OpenAPI JSON, hence prose-not-schema) -> (a) prose markdown re-vendor pending (tolerate_drift pin), (b) a **likely `set_persona_state` body-shape drift to align**: my `--set-persona-pad` sends `{pad:[list]}`, the doc's canonical is `{pad:{pleasure,arousal,dominance}}` (PAD-only #317, pull-over-push #289, cross-owner 404; never live-proven so untested). worldtree-dev foot-guns: persona.ocean = SINGLE-LETTER UPPERCASE `{O,C,E,A,N}` on /agents/define (spelled-out -> 422; the #348 mismatch) vs spelled-out lowercase on POST /characters; memory = `{embedder_version(==pinned else 422), tier3_dreaming}`, stm_* deprecated no-ops, allows_world_scope removed->422; only `valence` still 422s (layer_deferred).
**#18's final leg — the Worldtree-DRIVEN composite turn — RAN and is PROVEN end-to-end + persisted
(2026-06-20).** infra-ops added `10.100.10.50:8392` to the personal WT's (`:8081`)
`BIFROST_CLIENT_ALLOWED_HOSTS` (thread `01KVHWJGTT…`), unblocking the smoke. A real WT turn through
`:8392` (session `b83a66b6`, agent `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) drove the
FULL both-plane lifecycle on ONE endpoint, caps-routed by path: `handshake`
(`caps_granted=[memory, affect]`) → `affect.fetch` + `memory.search` (reads) → `affect.emit`
(`stored:true`, PAD row in `affect_snapshots`) → `memory.upsert_many` (`upserted:1`, chunk
`2df1b79de761b948` in `memory_chunks`). Both writes verified directly in our SQLite. The
model-backend outage that blocked the first attempt (both agents' models `model_unavailable`) was
operator-fixed mid-session, then the resmoke completed clean. **No open legs remain on the composite.**
**Prior arcs this session (2026-07-04 -> 07-06), both with worldtree-dev (a tooling script + proposal docs; the #347 CONSUMER work above is the new production code):**
**bifrost repinned 0.8.0 → 0.10.0** (floor, `provider` extra). 0.10.0 made `affect.fetch`
MANDATORY (strong-or-absent: `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch`,
gating EVERY affect op incl. emit) — so the repin FORCED `affect.fetch` (`v0.17.15`, conformed
to bifrost's reference `InMemoryAffectStore.fetch``{found, snapshot?}`) or our shipped affect
plane would 400. The composite's affect cap depends on it.
**(1) Authored-history-write primitive -> ACCEPTED as Worldtree #347 (Worldtree-owned).** A SillyTavern-style "first-message" (inject a character-authored opening) generalized to an engine primitive: **write a turn into a session's ledger WITHOUT generation, seed-only, side-effects off by default.** It cannot be done client-side (the messages `role` field is a *model-role* override, not an author-role -> `role:"assistant"` 404s; a model-visible authored turn needs engine support). Arc: drafted `docs/proposals/authored-message-injection.md` -> **heid panel pressure-test** (3/3 convergence: recentered on "non-generating write" not author-role; narrowed v1 to append-only+create-time; bounded `effects` enum; dropped edit/regenerate as history-mutation) -> revised -> committed (`c457520`) -> handed to worldtree-dev -> **accepted as design item #347.** worldtree-dev wrote the v1 contract (rev 1.1); **I validated the wire as reference consumer (green).** v1 shape: `POST /sessions/{id}/history`, `author=assistant` only, `effects=none` only, `idempotency_key` REQUIRED (per-session), **model-invisible provenance** (renders byte-identical to a lived assistant turn -> first-message immersion preserved; provenance audit-only), **event-silence** (no turn.started/done, no Bifrost wire for a seed; the 201/200 IS the write-ack), `seeded` lifecycle phase (not exposed on read paths). **Heimdall-gated with hide-existence** (grant `session.history.write`; ungranted tenant -> 404 NOT 403, undiscoverable in /capabilities -> consumer must treat 404 as feature-absent -> fall back to a model-generated greeting, never capability-probe). **Provider constraint:** a create-time first-message makes the assistant seq-0; vLLM/openai_compat tolerate assistant-first (sindra = openai_compat, unaffected), Anthropic-family providers 400 the next generation. **Waiting on worldtree-dev:** #347 TDD (their heid->contract->review workflow) + the consumer-facing 2.3.0 persona/motivational/memory schemas -> then re-vendor our pinned openapi 2.2.0->2.3.0.
**OPERATOR SESSION STATE — `:8390`/`:8391`/`:8765` shells are PRE-#18 code (foot-gun).** web `:8765`
+ affect `:8390` + memory `:8391` are prior-session background shells on OLD code. The **`:8392`
composite provider is RUNNING on NEW code** (`ratatoskr-combined-provider`, pid started Jun19,
`RATATOSKR_OPFEED_PATH=/tmp/ratatoskr-combined-opfeed.jsonl`, shared `affect.db`/`memory.db`) — now
`:8392`-allowlisted and WT-turn-proven. To see the full web stack on new code, RESTART `:8390`/`:8765`
from current code (D2 web needs `RATATOSKR_AFFECT_READ_URL`). Consumer/owner key = `wt_live_d81b…`
(`~/.config/ratatoskr/provider.env`, mode 600, rotate via infra-ops); providers SQLite + sqlite-vec,
`memory.db`/`affect.db` at repo root (live sindra PAD: vuong + the `resmoke-choco-1` smoke fixture).
**(2) Sindra's stuck-neutral mood FIXED** (operator-driven "reset + smoke" that flushed out two real upstream problems). Chain: her OCEAN lived only in prompt TEXT, never declared as a structured persona -> the Tier-3 mood engine ran on neutral defaults. Fix = declare OCEAN via the **define-time `persona` field** (immutable via PATCH -> requires DELETE+REDEFINE). Along the way my "the persona didn't store" call was WRONG (persona_state/envelope are Tier-3-blind, see Tried/abandoned); worldtree-dev found a real engine bug **#348** (single-letter vs spelled-out OCEAN keys -> a declared OCEAN silently resolved to 0.0/neutral; fixed in b21, shipped to personal as b22); then a clean bound-egress read STILL neutral -> the **personal container was running a stale image** (the b22 deploy was a pull-only no-op racing the main build; infra-ops force-swapped run 8211, verified `2.3.0` / `879cefe`). **VERIFIED FIXED:** bound mood-smoke reads `(0.448, 0.267, 0.316)` ~= the OCEAN-derived setpoint `(0.418, 0.249, 0.328)`. Sindra is currently reset clean (0/0) on `role=character`; her persona is stored + correct (**no re-define needed again**).
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
`sindra-probe` too under #296 Stage 2 (v0.36.0). The #296 extraction-quality arc closed
(Stage 1 v0.35.19 gate + Stage 2 v0.36.0 user-only extraction at worldtree-codex; hard-
linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
**(3) R30 CLOSED** (operator steer 2026-07-04, relayed via worldtree-dev): graduated on offline-tests + human face-validity, NO deployed gap-injection run (it was confirmatory-not-measuring per brokkr's S0 reframe; offline tests already cover the OU formula + both directions). My gap-injection harness (read/predict/record; write side stubbed; `predict()` self-validated vs brokkr's N=0 anchors) is BANKED at `diag/r30-gap-injection-harness` (`7156b25`-era) for the PARKED powered true-tau study.
**Sindra:** `ratatoskr:sindra`, `thoughtful-character` role → `mistral-small-4-reasoning`
(DELETE+redefined on v0.35.16; `memory:{}` block trips the promotion gate). Owner-scoped
(separate `consumer_agents` table) — invisible to `GET /agents`; check via
`GET /agents/<owner>:<name>` with the owner key.
**Persona-declaration shape (Worldtree #343/#348, live on personal b22):** `POST /agents/define` `persona:{ocean:{O,C,E,A,N: float[-1,1]}}` (single-letter keys EXACTLY -- missing/extra -> 422 `persona_ocean_required`; out-of-range -> 422); **NO baseline PAD** (resting setpoint DERIVED from OCEAN via Mehrabian: pleasure=0.21E+0.59A+0.19C-0.32N, arousal=0.15O+0.30E-0.57A+0.15N, dominance=0.25E+0.17A+0.10O-0.14N); negative-channel gain + per-axis decay-tau derive from N. `valence` deferred (422 `layer_deferred`); `motivational`/`memory` active (#187/#189). Persona is **write-once at define, immutable thereafter** (PATCH takes ONLY system_prompt + role). **`role` supersedes `model`** -- set a role (`character` / `character-rp`), Worldtree resolves the model; #344 (b19) fixed the model-field to surface the ROLE, not the resolved catalog_id. `character-rp` = a reasoning-tuned RP config (gen-reasoning + temp 0.75 + RP extra_body); `character` = plain non-reasoning. The `tier3.py` client CLI is STALE (has `--model`, no `--role`; model is now immutable) -> role/persona set via raw curl.
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); **bifrost 0.10.0 / wire v0.6**
(`scope_all`+`scope_any`). Heimdall key env-only at `~/.config/ratatoskr/provider.env` (mode
600); rotate via infra-ops. `graphify-out/` runs dirty (auto-regen, not chased). **Open issues:
#11** (AdminEvents pane — the next-reachable Worldtree-I/O coverage gap, blocked on an
`admin.events.read` scope request) and **#10** (subject-migration watch on Worldtree #196) — both
deferred. **#17 + #18 CLOSED.** Codex-first pilot dormant. No in-flight implementation work — repo
is at a converged checkpoint; v1 advances when Worldtree does (v1 = full Worldtree I/O coverage).
**New tooling: `scripts/reset-sindra-stores.sh`** (`0a8784c`) -- one-command self-service provider-store reset: stop the combined :8392 provider -> move memory.db+affect.db to a single ROLLING backup (`db-reset-backup/`, gitignored via *.db*; `--hard` skips it) -> restart empty -> verify 0/0. Codifies the manual reset flow done repeatedly this session. **The combined `:8392` provider is THE provider now**; the separate `:8390` (affect) / `:8391` (memory) single-plane providers were pruned as stale duplicates. To drive a BOUND session from the CLI use `--new --bifrost-url http://10.100.10.50:8392` (the CLI's `--bifrost-plane affect/memory` map to the pruned :8390/:8391 -> unreachable; `combined` is not a `--bifrost-plane` choice).
Branch: `main` (tag `v0.18.0`, `359dbb1`) — **in sync with `origin/main`** (the full #17+#18 arc is
pushed). This `/snapshot` commit will sit one ahead of origin until pushed (push is the operator's
call). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
**Standing (carried from prior snapshots, still true):** the web surface (`ratatoskr-web`, :8765) is the operator's PRIMARY debug surface at full TUI pane parity (v0.19.5); the **v1 coverage-audit has CONVERGED** -- REST 17/40 (zero in-scope gaps, 23 excluded-by-design), SSE 11/11, Bifrost provider planes 8/8 live-proven; the living ledger is `docs/coverage-map.md`; **v1 cuts when Worldtree tags 1.0** (ratatoskr v1 = full Worldtree I/O coverage). Debug-observability core complete (Persona/Tools/BifrostState/AdminEvents). Substrate pins: **bifrost `==1.0.0` / wire v0.6 FROZEN**; Worldtree openapi vendored **2.2.0** (2.3.0 re-vendor pending worldtree-dev's #347/#343 consumer schemas), pinned + drift-gated in `.corviduo-canonicals.toml`; **suite 573 green.** Keys env-only mode-600 (consumer/Heimdall in `~/.config/ratatoskr/provider.env`; admin `RATATOSKR_ADMIN_API_KEY` = 7 read scopes, **personal-:8081-only**; Heimdall keys are PER-INSTANCE). Provider identity settled -- ratatoskr owns both ends of the Bifrost round-trip; `ratatoskr:sindra` is the owner-scoped Tier-3 agent (invisible to `GET /agents`; check `GET /agents/<owner>:<name>` with the owner key). Providers run as dev-box BACKGROUND SHELLS. `graphify-out/` runs dirty (auto-regen, never stage). Branch `main`, HEAD `0a8784c`; remote `origin -> git@gitea.phasefinal.com:vh/ratatoskr.git`. Open/deferred: #10 (subject-migration watch); the relational-dynamics-arc verify (still deferred, now with the bind mechanism known: `--bifrost-url :8392`).
## Recent decisions
@@ -161,6 +120,45 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-07-01]` **BifrostState pane SHIPPED (`v0.18.10`) — `GET /admin/sessions/{id}/bifrost` in a new TUI "Bifrost" pane; the FIRST admin-key consumer in ratatoskr.** `get_session_bifrost(client, session_id, *, admin_key)` (sessions.py) — admin-scoped (`admin.sessions.read`); the request OVERRIDES Authorization with `admin_key` (distinct from the consumer bearer, asserted in a test); 200→dict, non-200→SessionApiFailed. Admin-key wiring: `--admin-key` flag + `RATATOSKR_ADMIN_API_KEY` env → new `ParsedArgs.admin_key`. New "Bifrost" TabPane + `_format_bifrost_state` + `_hydrate_bifrost_state` best-effort worker (mirror `_hydrate_session_tools`) UNCONDITIONALLY in on_mount → writes {endpoint, connected, caps_granted, tools} + audits; self-labels "not configured" (no admin key) / "not bound" (404) / graceful on 403 + error. Contract #2 amended (FN, validated OK) + TDD (4 wrapper respx tests incl. the admin-bearer-override assertion + 1 format unit + 3 hydrate integration). Suite **552 green**; my code ruff-clean (pre-existing tui.py ruff debt at other lines untouched, incl. a dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN** on personal :8081: admin key authenticated (reached resource-layer 404 session_not_found, NOT 401/403) → `admin.sessions.read` works live; 200 full-state not exercised (no bound session on :8081 now — unit-covered). Patch bump (debug feature, no downstream coordination; consistent with the session's cadence — but the §5-core-completion angle is a possible minor, operator's call).
- `[2026-07-01]` **LEDGER CORRECTION: #11 (AdminEvents) is NO LONGER BLOCKED.** Verified via `GET /me` on :8081 that `RATATOSKR_ADMIN_API_KEY` (`ratatoskr-readonly`, tier readonly-admin) carries ALL 7 read scopes INCLUDING **`admin.events.read`** (+ `admin.sessions.read`, admin.keys.read, admin.skuld.read, pending.read, search.read, tool_events.read). The coverage-map + prior memory had #11 "blocked on admin.events.read" — **STALE**; the admin key was minted (post-#11-filing, env.sh) WITH the scope, so the blocker is already satisfied. **Only the AdminEvents SSE pane itself is unbuilt** — the last unbuilt §5 debug pane (a live SSE-consuming admin pane, distinct from the hydrate-at-attach panes). Coverage-map updated. **Coverage: REST 11/40 ✅.** Consider building the AdminEvents pane and/or updating #11's tracker status (its stated blocker is gone).
- `[2026-07-01]` **AdminEvents pane SHIPPED (`v0.18.11`) — `GET /admin/events` SSE in a new TUI pane; #11 closed-by-build; Tier 1 (debug-observability core) COMPLETE.** `stream_admin_events(client, *, admin_key, last_event_id=None)` (sse_client.py) — a NEW long-lived SSE consumer for the admin lifecycle broadcast (envelope `{id,type,timestamp,data}`, 17-event v0 vocab), admin-scoped (`admin.events.read`, bearer-override), Last-Event-ID resume; non-200→SseConnectFailed, mid-drop→SseConnectionDropped; new `AdminEvent` dataclass (distinct from the turn `Event` union). New "AdminEvents" TabPane + `_format_admin_event` + `_admin_event_matches` (design-brief §6 filter: active-session events + non-heartbeat `system.*`) + `_stream_admin_events` long-lived best-effort worker (unconditional on_mount, cancelled on app exit; self-labels "not configured"/"unavailable"/"stream ended"). Reuses the admin key from the BifrostState slice. **Contract-SKIPPED** for `stream_admin_events` (out of contract #1's turn-SSE scope; spec § Admin Event Stream is the reference; well-TDD'd). TDD: 4 sse_client tests (multi-event+bearer-override, Last-Event-ID header, 403, malformed-skip) + 5 tui (format, filter, worker success/no-key/403). Suite **561 green**; my code ruff-clean (pre-existing tui.py ruff debt untouched, incl. the dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN**: `GET /admin/events` on :8081 → HTTP 200 under the admin key (connected + streamed, idle in the 4s window — no 401/403). **Coverage: REST 12/40 ✅. Tier 1 admin/debug-observability core COMPLETE** (Persona · Tools · BifrostState · AdminEvents). AdminEvents work landed as patch `v0.18.11`; then **`v0.19.0` MINOR cut (operator-approved 2026-07-01)** publishing the milestone: **the debug-observability core is complete** (Persona · Tools · BifrostState · AdminEvents all built + consuming real endpoints — the design-brief's headline deliverable). Pre-1.0 minor = release-note-worthy (no downstream althing push needed pre-1.0); lightweight tag per the SemVer mechanics (annotated reserved for major cuts). Remaining in-scope client I/O: only Tier-2 (transient-characters routing + `POST /sessions/{id}/persona_state`).
- `[2026-07-01]` **Tier-2 SHIPPED (`v0.19.1`) — transient-characters CRUD + persona-state write; the v1 coverage-audit CONVERGES (zero in-scope gaps).** 5 wrappers in sessions.py: `list_character_models`/`create_character`/`get_character_state`/`delete_character` (#161, `character.read`/`.write` scopes) + `set_persona_state` (`POST /sessions/{id}/persona_state`**FREEFORM body: unpinned in the frozen OpenAPI 2.2.0 + absent from the prose spec**, so the caller supplies the snapshot shape). Two one-shot CLI probes (mirror `--whoami`): `--characters` (models→create→get-state→delete lifecycle report) + `--set-persona-pad "p,a,d"` (requires `--session`; POSTs `{pad:[…]}`). New `ParsedArgs.characters`/`set_persona_pad` + probe-mode mutual-exclusion validation + `_probe_client` helper. Contract #2 amended (5 FNs, validated OK) + TDD (7 wrapper respx + 5 cli tests). Suite **573 green**; touched code ruff-clean. NOT live-proven (character scopes + the persona-write body shape unverified — the probes degrade gracefully on 403/422). **THE v1 COVERAGE-AUDIT HAS CONVERGED: REST 17/40 ✅ with ZERO in-scope gaps** (23 REST path-groups excluded-by-design + rationale), SSE 11/11, Bifrost provider planes 8/8. Scope-A "done" (every frozen I/O point classified, zero unaccounted) is **MET** — ratatoskr cuts v1 when Worldtree tags 1.0. Only not-consumed in-scope sub-method: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only, on an already-✅ path group). Patch bump (Tier-2 tail; `v0.19.0` already published the core-complete milestone — a 2nd minor would be cadence-too-fast).
- `[2026-07-01]` **env.sh now PERSISTS the web Bifrost-bind vars (gitignored, local-only).** `ratatoskr-web`'s in-browser bind needs three server-held values; env.sh sources `provider.env` for the Heimdall key and exports `RATATOSKR_BIFROST_CONSUMER_KEY` + `RATATOSKR_PROVIDER_VISIBLE_HOST=10.100.10.50` + `RATATOSKR_AFFECT_READ_URL=:8392`. **The HS256 byte-match trap (re-hit + documented):** the bind's consumer key must equal the key the `:8392` combined provider validates against = `RATATOSKR_HEIMDALL_KEY` (provider.env, fp `45a0…`), NOT `WORLDTREE_API_KEY` (env.sh, fp `7c2f…`) — both are the SAME `ratatoskr` identity but DIFFERENT 40-char strings; signing with the wrong one → `bifrost.auth_rejected`. Single-sourced (env.sh sources provider.env) to avoid a rotation footgun; guarded with a stderr warning if provider.env is missing. [auto-memory: HS256-key-is-the-consumer-Heimdall-key-string]
- `[2026-07-01]` **Tier-3 stores RESET (operator-directed).** `memory.db` (29 chunks + vectors + idempotency) + `affect.db` (5 PAD snapshots + idempotency) wiped to zero via a live `DELETE`+`wal_checkpoint` through the shared WAL (no provider restart — the 3 long-running providers see empty on next dispatch); consistent online-backup at `/tmp/ratatoskr-tier3-reset-<ts>/`. **Boundary for a COMPLETE Sindra wipe (mapped):** our stores = mine (done); the agent DEFINITION `ratatoskr:sindra` + its sessions = mine via the owner key (DELETE, no coordination); Worldtree's internal promotion/dedup shadow = needs worldtree-dev (no public reset API, survives our wipe → for a clean promotion smoke use a BRAND-NEW agent+end_user).
- `[2026-07-01]` **Embedding-latency loop RESOLVED — it was WORLDTREE's, not ratatoskr (the consumer/provider thesis paid off again).** Vuong flagged dozens of embed queries/Tier-3 turn; worldtree-dev's first-pass blamed our memory_context chunk-batching. Traced CODE-SIDE that ratatoskr embeds ZERO times (provider `upsert_many` stores the given embedding, `search` takes a given vector, the conversation consumer POSTs only `{content}`, `/embed` is coverage-map-excluded — pure Bifrost/ADR-0009 path, WT does all embedding). worldtree-dev retracted + fixed on THEIR side (`v1.0.0b4`): a persona-recitation memory-gate re-embedding the stable character card sentence-by-sentence every turn (~95% of gateway traffic) → content-hash cache; re-embed ratio 15x→1.01x. **Lesson: verify your own code before accepting a peer's "it's your side" — the debug tool proving its own side clean is the whole point.**
- `[2026-07-01]` **Web debug-surface parity SHIPPED (`v0.19.2`, `a0a9d5f`) — direct in-session TDD.** 3 proxy routes (tools/bifrost/admin-events) + admin-key wiring (entrypoint→create_app→app.state) + AdminEvents SSE proxy re-emitting under a FIXED `admin_event` name (one browser listener, no per-type drops) + session-filter `_admin_event_matches_web` (mirrors TUI §6). Frontend: 2 tabs (bifrost ⌃5, admin ⌃6) + tools-inventory folded into the tools pane. 9 respx tests (admin-bearer override, filter unit, SSE stream-filter); live-proven against sindra (bifrost connected, both caps). Contract-skip invoked (reuses already-contracted client wrappers); contract authored post-hoc as the trail (`docs/contracts/web_debug_surface.contract.md`).
- `[2026-07-01]` **heid-code-review (`v0.19.3`, `75dec01`) — panel caught 2 real client-side SSE-lifecycle bugs TDD missed.** Contract-anchored (authored the web contract to enable it — no contract → no drift axis). Gróa/Hulda/Regin (artifact-only, Gróa under Landlock jail): ZERO functional server-side drift + INV-004 clean; 2 genuine drifts on the un-unit-tested SPA — (1) turn `es.onerror` didn't `hideThinkingNote()` (reasoning line + setInterval leak on a raw drop), (2) `openAdminEvents` never closed the EventSource on error → native auto-reconnect RETRY LOOP (fixed: close on `stream_error` + permanent `onerror`/CLOSED; transient CONNECTING still reconnects). + 2 test-gaps fixed (route-registration + admin stream_error). 1 precision → contract-clarified (tools-inventory names-only by design). **Re-confirms: the JS render/lifecycle paths are the review's highest-value target — unit tests don't reach them (same lesson as #18 D2).**
- `[2026-07-01]` **Affect snapshot shape CHANGED valence→relations (relation_edge/1) — the persona pane was reading a dead field.** Worldtree's #265 Vili rework replaced the flat `valence[]` ({entity_id,familiarity,regard}) with `relations[]` (target_entity + trust_ability/benevolence/integrity + warmth + agency + relation_context, each `{value,confidence,evidence_count}`). `renderAffectPane` still read `snap.valence` → showed empty "valence (0)". Rebuilt to render `relations` (v0.19.4, `ca46a93`) with per-value **Δ + unicode sparkline** (client-side, HIST_CAP=24, one sample/turn deduped by emitted_at). **Retires the stale "regard dead axis" note (2026-06-30) — that whole axis is gone.** Foot-gun: the affect snapshot shape is Worldtree's emit and can change under us — verify the live shape (query affect.db) before trusting a render.
- `[2026-07-01]` **Trust/warmth VALUES converge and go FLAT at confidence 1.0 — that's WAD, not a stuck pane.** sindra→ratatoskr trust ~0.82-0.84 / warmth 0.79 barely move (~1e-7/turn) while `evidence_count` climbs (46→62); confidence maxed → tiny updates. The live-moving signals are PAD (mood, per-turn) + evidence_count. **To WATCH a relation FORM (values shift), use a BRAND-NEW agent + end_user** (low evidence, confidence <1). The sparkline flat-guards sub-0.01 ranges so it doesn't amplify noise.
- `[2026-07-01]` **relation_context "stranger" + agency-all-zero flagged to worldtree-dev → both WAD/intentional-v1-deferrals.** relation_context is a FIXED config build-prior (not trust-derived; `registry.py:131` defaults "stranger"; dynamic progression ~#319); agency is schema-present-unpopulated (deferred #319; v1 = warmth+trust only). worldtree-dev is escalating the **consumer-coherence angle to Vuong** (static "stranger" + zero-agency next to trust 0.82/62-interactions reads incoherent from the store). The consumer/provider thesis paying off; DB-offer (read-only affect.db on the shared box) declined this time.
- `[2026-07-01]` **Persona pane displays the CANONICAL affect→NL Worldtree injects — ADOPT, don't invent (operator steer + reference-impl posture).** Worldtree's `describe_pad` (mood word, valence×arousal grid, ±0.3 bands) + `render_d2_canonical` (relationship directive) are deterministic + canon-driven; the pane now renders them **byte-exact-verified** against Worldtree's own renderer on the live snapshot (v0.19.5, `a99f247`). KEY LESSON: adopting canonical is load-bearing — for sindra's small PAD the canonical says **"neutral"**, but an invented octant vocab would've said "faintly excited" and MISLED. Vendored the two d2 canons (`docs/vendor/worldtree-persona-canon/`) + drift-pinned in `.corviduo-canonicals.toml` (green); flat browser form (`static/persona_render_canon.json`) regenerated via Worldtree's OWN loader (`scripts/build_persona_canon.py`). Vendoring-handshake sent to worldtree-dev (broadcast on canon bumps). [auto-memory: `feedback-ratatoskr-is-a-reference-impl-adopt-canonical`]
- `[2026-07-01]` **Sindra PAD is over-regulated — characterized via controlled probe, flagged to worldtree-dev (separate affect slice).** ~15 charged turns: pleasure compressed near neutral BOTH ways (couldn't reach ±0.3 under sustained max praise OR contempt; peak +0.24 / floor ~0.1; over-regulation worse for *social* valence than threat — urgency drove pleasure to 0.22 vs contempt's 0.10); arousal responsive (reaches its +band, 0.185↔0.311); dominance flat/unresponsive to explicit power-framing (drifted UP even while being commanded = pure baseline decay). worldtree-dev's leading hypothesis: appraisal→PAD gain + regression-to-baseline term (appraisal.py/renderer.py). **Lesson (self-caught): I over-claimed an "asymmetry" (positive-ceiling/negative-free) from probes started at an elevated state; the negative-free part was decay-from-elevated, not response — corrected to "both-sides-compressed" before it misled.** [affect A/B is a provider-side capability chat can't do]
- `[2026-07-01]` **Memory plane PROVEN healthy end-to-end.** Seed a novel fact → promotion → COLD (history-free) session recall of the exact fact (injected as MEMORY:DATA, confidence 0.74, verbatim, no #296 subject-inversion). The memory round-trip (the other half of the Bifrost provider identity) works cleanly on the reset slate.
- `[2026-07-01]` **Salience scorer non-discriminating → 3-way routing.** Persistence-side finding: 51/56 promoted chunks at salience 0.9-1.0, throwaway "17×23?" scored 1.0 tied with a real fact (textbook zero-shot-LLM-self-rating); recall-utility untracked (`access_tally`=0, our search read-only). Routed: **Worldtree #335** (the code fix, deferred behind their waves) + **brokkr-smithy-dev R-target proposal** (scoring+eval *methodology* — few-shot/distill/fine-tune, eval design, weak-supervision; msg `01KWGM970H…`, awaiting) + ratatoskr provides the eval-instrument (designed-probe salience dumps). **Salience gates PROMOTION not RECALL-ranking (our search is cosine-only), so bad salience = storage bloat, not bad recall.**
- `[2026-07-01]` **Canonical check BLOCKED an access_tally fork (reference-impl posture held).** I'd offered to wire `access_tally`-on-search into our store for the recall-utility label; checked bifrost's reference first (`get`/`search` are PURE-READ, no access tracking — those are Worldtree's chunk-schema fields, not bifrost's contract) → wiring it would fork behavior the canonical reference lacks. Did NOT wire it; routed recall-instrumentation to Worldtree's layer (owns the recall event) or a bifrost-dev protocol ask. [reinforces `feedback-debug-surface-uses-canonical-surface-only`]
- `[2026-07-01]` **relation_context coherence FIXED upstream (my flag → Worldtree Wave-0, IMPLEMENTED v1.0.0b5).** The static-"stranger"-next-to-high-trust incoherence the persona pane surfaced is now #319/#320 Wave-0. **Incoming consumer-surface change (pending WT deploy):** `relation_context` value expands "stranger" → monotonic ladder {stranger, instrumental, mixed, expressive} — WIRE-ONLY (relation_edge/1 schema unchanged, no version bump). **ratatoskr needs NO change** (pane value-agnostic; canonical directive doesn't key on the enum). agency stays 0 (Wave-2); other_stance is Wave-1 (in progress).
- `[2026-07-01]` **Foot-gun (measurement, self-caught before flagging): establish the baseline before claiming a rate.** Nearly flagged "aggressive over-promotion (55 chunks / 7 turns)" to worldtree-dev — but the chunks spanned the whole 5-hour session (~1/turn), not 7 turns; I'd assumed memory.db was 0 immediately before the probe when it had been accumulating since the reset. Caught it via `created_at` spread before the flag went out. Also: the promoted corpus was the operator's ERP *test* content (wiped after each test) — not a privacy issue, but abstract test content out of any peer-shared diagnostic.
- `[2026-07-02]` **Salience finding matured into brokkr R28 (OPEN) — ratatoskr is the eval instrument.** brokkr-smithy-dev's pre-scope panel (3 dwarves + context-blind heid, 6/6) **reframed** the target: PROMOTION-WORTHINESS (durable value), NOT salience (momentary attention) — "17×23?" genuinely IS salient, so recalibrating salience yields a well-calibrated WRONG answer; the unit is SET-SELECTION under budget; eval must be OUTCOME-aligned (recall@budget / precision-at-rate), not discrimination-spread. Ties to prior art R15 (small-model memory write-policy → the granite pick) + R25 (worldtree-kb-quality). **ratatoskr delivered the P00 stratified injection-corpus** (`docs/diagnostics/r28-p00-injection-corpus.json`, committed `4a35512`; 24 self-labeling synthetic items × 3 strata) + 2 persistence-side run-validity pins (absent≠dropped without a guaranteed promotion pass; fresh agent+end_user per run vs server-dedup). **Key architectural constraint I surfaced: ratatoskr is DOWNSTREAM of the promotion gate (sees only PROMOTED chunks), so I can give keep/drop OUTCOMES via injection but NOT the pre-admission shadow pool** — that's Worldtree instrumentation. Standing by to RUN the eval once brokkr pins per-stratum N + the decision rule (gated on worldtree-dev's pipeline answer + a dwarf pass on the Snorri rule). brokkr owns methodology + takes the pipeline questions to worldtree-dev direct; ratatoskr = eval instrument. [consumer/provider thesis → a research target]
- `[2026-07-02]` **Relational-dynamics arc LIVE on demo (Worldtree v1.0.0b9) — driven by MY relation_context flag.** #319/#320 Waves 0/1/2 deployed. On the wire we persist (schema UNCHANGED): relation_context varies+demotes/ruptures; other_stance + agency now live; agency going live SHIFTS our canonical directive render past the canon ±0.2 deadband (expected, non-breaking — we key on bands); obligation_balance → 人情 ledger when tie="mixed". **ratatoskr needs NO code change** (value-agnostic renders; confirmed render-clean to worldtree-dev). **Can't live-confirm yet — our Heimdall key is personal-`:8081`-only (per-instance), demo is out of reach; will drive+confirm once PERSONAL gets b9.** Optional follow-up: surface `other_stance` (newly live, unrendered). The consumer/provider thesis: one persona-pane finding drove a full 3-wave upstream arc to production.
- `[2026-07-02]` **R28 (salience→promotion-worthiness) CLOSED (operator-directed).** A deterministic promotion-worthiness gate suffices, no trained model (brokkr's pre-gate matched/beat a strong glm-5.1 ceiling); my P00 injection-corpus + origin finding were load-bearing. My incumbent-substrate Arm-1 run is held as an OPTIONAL confirmation addendum (brokkr de-prioritized it, non-verdict-changing — run only if he asks).
- `[2026-07-02]` **R29 (PAD mood-dynamics) finding SHIPPED as Worldtree's A1 anchor fix (demo v1.0.0b14, `e1cdf82`).** Live-probing base persona agents reframed the over-regulation from "flat-near-zero" to **decay-to-NEUTRAL + low emotion→PAD gain** (NOT baseline-anchored) — triangulated across 3 baselines (arousal converges to 0 ∝ distance) + a step-response (decay τ symmetric across signs; the hedonic asymmetry is ceiling/anchor-EMERGENT, not a decay or gain primitive — this OVERTURNED the survey's asymmetry recommendation). worldtree-dev shipped A1: `decay_anchor = baseline_pad()` (was neutral) + `positive_p_cap` removed. Data `diag/r29-pad-series` (`61ff2da`). Corrected my own earlier "appraisal emissions are internal-only" claim — they ARE observable via `emotions_active` on base agents.
- `[2026-07-03]` **R30 Phase-1 φ0 measured — deployed engine CONFIG-FAITHFUL (φ0≈0.95).** Joint two-timescale fit (brokkr-ruled method (b)) + empty-tail cross-check on demo b14: φ0 ≈ 0.950.97 (empty-tail 0.95 exact, joint 0.971±0.01), intercept c≈0 → config `decay_rate=0.05` (φ=0.95) faithfully applied; trait-flat across baselines 0.0/0.615/0.809; A/P ratio ~uniform (NOT S2's 1.9×); φ_max rec relax→0.96. Data `diag/r30-phi0-step-response` (`23fea72`). The method converged after I read Worldtree source: only NEW dedup-gated emotions push mood (`registry.py::post_turn` L307-324; the active set decays for render/goals but never re-pushes), so R29's "net 0.90" is CONTINUOUS RE-APPRAISAL not re-push — worldtree-dev confirmed source-authoritatively; brokkr's corrected covariate landed identical. [auto-memory `reference-worldtree-affect-surface-map`]
- `[2026-07-03]` **R30 forward disposition (brokkr-owned; tracked at brokkr R30, "brokkr/worldtree will ping").** The per-turn decay has no room for `decay=f(N)` under preserve-persistence + the A/P-not-1.9 finding → R30's decay is being redesigned as a HYBRID wall+turn decay (brokkr pre-scope). R30 v1 ships GAIN-only (N→negative-reactivity) with decay held at the measured 0.95. My dedicated per-axis A/D run is DEFERRED into the hybrid-decay design pass (one wall-clock-spaced run does per-axis + a turn-vs-wall probe together). Phase-2 (moody-lofn GAIN-direction validation) waits on worldtree's `dynamics_from_ocean()` impl.
- `[2026-07-03]` **Relational-arc verify DEFERRED — `relations[]` is Bifrost-provider-only (ADR-0009), confirmed both ways.** The relational-dynamics state (relation_context tie-type / agency / warmth / trust) is NOT on the conversation-API `affect_update` snapshot for base agents (keys: pad/dominant_emotion/emotions_active/baseline_pad/mood_drift only) — only in the provider store; worldtree-dev confirmed by-design per ADR-0009 (emitted over `affect.emit`, deliberately off the SSE). So the Wave-0/1/2 verify needs the bound-provider round-trip (provider running + `--bifrost-plane affect` session), its own focused session. worldtree-dev routed the "expose relations[] to non-provider consumers" observability scope call to Vuong; my rec: keep provider-only (YAGNI — ratatoskr IS a provider, gains nothing; no speculative public surface).
- `[2026-07-04]` **R30 CLOSED on offline-tests + human face-validity (operator steer, relayed via worldtree-dev).** The deployed gap-injection run was confirmatory-not-measuring (against a deployed system the fade is `exp(-dt/tau_shipped)` by construction -> a fit recovers tau_shipped tautologically; per brokkr's S0 reframe it GRADUATES the interim coefficients, doesn't measure them), and the repo's offline tests already cover the OU formula + BOTH directions (`high_N_fades_slower_than_low_N`, `phenotype_high_n_bigger_negative_excursion`). So no Worldtree build; the interim coefficients graduate validated-as-shipped. My gap-injection harness (read/predict/record; write side stubbed; `predict()` reproduced brokkr's N=0 anchors exactly) is BANKED at `diag/r30-gap-injection-harness` for the parked powered true-tau study. [continues R30 forward-disposition 2026-07-03]
- `[2026-07-05]` **Authored-history-write primitive proposed -> accepted as Worldtree #347 (Worldtree owns the engine design; ratatoskr = reference consumer).** SillyTavern first-message generalized to a non-generating ledger-write primitive; can't be done client-side (messages `role` = model-role, not author-role). heid panel pressure-test (3/3 convergence) drove the v1 narrowing (append-only, bounded `effects` enum, drop edit/regenerate). Brief `docs/proposals/authored-message-injection.md` (`c457520`); consumer constraints captured in-brief: hide-existence 404-fallback (`022accf`) + assistant-first provider constraint (`7156b25`). Operator (Vuong) ruled the design-direction call (engine primitive + a real provenance/spoofing security surface). [reference-impl posture: we propose the shape, worldtree-dev owns the contract+impl]
- `[2026-07-05]` **#347 v1 wire validated as reference consumer (GREEN).** Adopted positions: distinct sub-resource `POST /sessions/{id}/history` (not `generate:false`), model-invisible provenance (first-message immersion preserved), event-silence for authored seed, `seeded` lifecycle phase, per-session idempotency. Three pre-TDD flags folded into contract rev 1.1: assistant-first provider constraint (Anthropic-family 400s; vLLM/openai_compat OK), content limit is BYTES not chars, 409-active-generation for append-narrator. First-message (create-time, assistant, effects=none) fully served; append-narrator served for the assistant-voice subset (system deferred); debug-seed served for assistant turns (user injection deferred to a future import primitive).
- `[2026-07-06]` **Sindra role character-rp -> character (operator).** `character-rp` resolves to a reasoning-tuned RP config (`gen-reasoning` + temp 0.75 + RP `extra_body`); `character` = plain non-reasoning (better for immersive RP). Both non-destructive PATCHes (role is mutable; model is NOT -- server: "PATCH accepts only system_prompt and/or role"). #344 (b19) fixed the role->catalog_id display conflation (the `model` field now shows the ROLE); previously it leaked `gen-reasoning`. Set via raw curl (tier3.py CLI has `--model`, not `--role`).
- `[2026-07-06]` **Sindra persona/OCEAN DECLARED -> mood fixed (the full diagnostic converged on a stale personal container).** Root cause of stuck-neutral mood: her OCEAN was prompt-TEXT only, never a structured persona; fix = delete+redefine with the define-time `persona:{ocean:{...}}` field (immutable via PATCH). My diagnosis surfaced a real engine bug **#348** (single-letter vs spelled-out OCEAN keys -> declared OCEAN silently -> 0.0/neutral; worldtree-dev fixed in b21/b22) AND a **stale-container deploy race** (personal's b22 deploy was a pull-only no-op; infra-ops force-swapped run 8211). VERIFIED: bound mood-smoke reads (0.448, 0.267, 0.316) ~= OCEAN-derived setpoint (0.418, 0.249, 0.328). [consumer/provider thesis: "reset + smoke" flushed out two upstream problems]
- `[2026-07-06]` **OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`, pin-only no bump).** worldtree-dev shipped #347 as spec 2.3.0 (`879cefe`, = the deployed personal b22 image); the SessionStart drift-check flagged our openapi pin STALE. `canonical_sync` pulled 2.3.0; updated the 4 pin-tracking files (`.corviduo-canonicals.toml`, vendored openapi.json, SPEC-PIN.md, pyproject `worldtree-spec-rev`->879cefe). #347 is OpenAPI-only (prose + server contract byte-unchanged, SSE unchanged=event-silent). The re-vendor re-opened the coverage-audit with one new in-scope path-group (the #347 route).
- `[2026-07-06]` **#347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) — direct in-session TDD.** `write_authored_history(client, session_id, *, content, idempotency_key, author="assistant", effects=None, claimed_original_at=None) -> dict` (POST /sessions/{id}/history; body server-pinned `AuthoredWriteRequest` extra="forbid" so omit null effects/claimed_original_at; 200-replay/201-fresh both -> ack dict; **404 -> `AuthoredHistoryUnavailable`** NOT SessionApiFailed = the hide-existence "feature-absent, never probe" contract; 409/422->SessionApiFailed) + `get_session_messages` (un-deferred GET /sessions/{id}/messages, the seed read-back proving model-invisible provenance) + a `--seed-first-message "<c>" --agent <id>` one-shot probe (create session -> seed -> read-back; 404->benign feature-absent exit 0). Contract #2 amended (2 FNs, validated OK) + 19 tests (12 wrapper + 7 cli). Suite **601 green** (clean env; the 2 "fails" under `source env.sh` are the RATATOSKR_ADMIN_API_KEY env-leak into TestParseArgs, not a regression). Coverage: **REST 19/41** (`docs/coverage-map.md` re-converged). Patch bump (coverage tail; consistent w/ the Tier-2 v0.19.1 cadence). **Live-proof pending** the `session.history.write` grant (infra-ops `01KWW3KQEY`). heid-code-review NOT run (offered).
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
@@ -197,5 +195,13 @@ defense against re-attempting the same cul-de-sac.
- `[2026-06-20]` **The post-turn-async timing trap bit AGAIN — even a 35s post-`[done]` read missed the promotion `upsert_many` by ~2s** (it landed `19:48:58`; the read was ~`19:48:56`). A 15s-interval background poll caught it on the first tick. Same family as the affect.emit / async-promotion traps already logged — re-confirmed that "wait once then read" is fragile for post-turn writes; **poll a window, don't snapshot once.** (The affect.emit write, by contrast, DID land inside the 35s window — promotion is the slower of the two post-turn writes.)
- `[2026-06-30]` **Heimdall keys are PER-INSTANCE — a key minted on one Worldtree 401s on another.** Our Conversation-API key works on personal `:8081` but 401s `auth_invalid` on demo `:8080` (per-instance Heimdall user store + pepper; fresh deploys start with an EMPTY key store). Same as the admin key (personal-only). **To live-drive a given instance you need a key minted FOR that instance** (request via infra-ops). Couldn't live-prove the b2 409 on demo for this reason → deferred to personal-b2 where we have access.
- `[2026-06-30]` **`tea comment <N>` hangs on Gitea** (the whole compound bash auto-backgrounded + stuck on the open `tea` call). The #11 prereq comment hung; killed it + posted via the Gitea HTTP API directly (`POST /api/v1/repos/vh/ratatoskr/issues/<N>/comments`, token from `~/.config/tea/config.yml`). **For issue comments, prefer the Gitea API over `tea comment` when `tea` is flaky** (CLAUDE.md already says use HTTP for comment-EDITS; this extends it to ADD when tea hangs). Verify-then-post (check the comment didn't already land) to avoid a double-post after a kill.
- `[2026-07-02]` **Mask-HOSTED transient characters have a STATIC mood engine — cost a whole R29 probe.** A first probe used a `POST /characters` transient character bound via `agent_id=mask` + `character_id`; its PAD sat at baseline across 15 praise/contempt/dominance turns — the appraisal→PAD engine does NOT run on the mask-hosted transient-character path. The dynamics run only on BASE persona agents or a session bound to ratatoskr's affect provider. **To probe mood dynamics, use a base persona agent, never a mask-hosted transient character.** (mask AS a base agent — `agent_id=mask`, NO `character_id` — DOES run the engine, neutral 0,0,0 baseline.) [auto-memory `reference-worldtree-affect-surface-map`]
- `[2026-07-03]` **The "neutral non-appraising tail" premise fails — the neutral MESSAGE choice dominates.** The R30 φ0 method assumed neutral turns don't re-appraise, but factual-question neutrals ("capital of France?") trigger a new emotion nearly every turn (disappointment from the warmth-withdrawal let-down after a positive impulse) → `emotions_active` never empties in 50 turns. A minimal "Please continue." triggers FAR fewer (emotions clear ~turn 16 with spacing). The personal dry-run caught this BEFORE ~280 demo turns were spent on it — the instrument catching a flaw in the measurement design before the compute burn. (Irrelevant to the joint fit — the push_t covariate handles re-appraisal — but load-bearing for the empty-tail read.)
- `[2026-07-03]` **Two φ0-fit traps: fast-turn timescale + low-baseline conditioning.** (1) At fast turn cadence the per-turn PAD decay (φ≈0.95/turn) reaches the anchor LONG before the ~200s wall-clock emotion fade → no signal in the (eventual) emotion-free tail; need wall-clock SPACING (~16s) so the fade lands while PAD still has signal. (2) A low-baseline agent's impulse in the constrained direction (forseti P0.239 negative) gives a tiny excursion → ill-conditioned regression (r²=0.46) that FALSELY tripped "config≠behavior" when its φ was averaged in. **Weight/exclude by fit quality (r²) before aggregating — a signal-poor run isn't evidence against the config.**
- `[2026-07-06]` **`persona_state` + the agent envelope are Tier-3-BLIND -- NOT valid signals for "did a persona store".** `GET /agents/{id}/persona_state` returns 404 `persona_not_configured` for EVERY Tier-3 colon-id (hardcoded short-circuit, `api.py:1266` "regardless of row state"); the `ConsumerAgentResponse` envelope never echoes persona/motivational/memory (`api.py:538`). I mis-called "persona didn't store" from these two blind reads -- the **201-not-422 on define IS the store-success signal.** To actually SEE a Tier-3 mood, read the emitted PAD off the Bifrost affect egress after a BOUND turn (Tier-3 persists nothing Worldtree-side per ADR-0009; no persona/mood READ endpoint).
- `[2026-07-06]` **Raw `POST /sessions` is NOT Bifrost-bound -> zero affect/memory emits.** The web surface binds by setting the `bifrost` block on session-create; a raw session doesn't -> 0 affect rows, which I nearly misread as "mood is neutral". Bind from the CLI with `--new --bifrost-url http://10.100.10.50:8392` (the combined provider). Gotchas: `--bifrost-plane affect/memory` map to the SEPARATE `:8390`/`:8391` providers (`endpoint_for_plane`), which I'd PRUNED as stale duplicates -> `bifrost.endpoint_unreachable`; and `combined` is NOT a `--bifrost-plane` choice (CLI restricts to memory/affect) -> use `--bifrost-url` for :8392.
- `[2026-07-06]` **A fast/"no-op" deploy can leave a STALE container running the old image -- verify the running version, not the deploy status.** Personal's b22 deploy (run 8204) "completed" in ~1m (vs ~6m normal): a pull-only deploy racing ahead of the main build, leaving the container on the pre-#348 image. A clean bound mood read stayed neutral DESPITE the persona being declared and the fix being in the code (worldtree-dev proved the b22 derivation is correct). infra-ops force-swapped to the real b22 (run 8211, verified `info.version 2.3.0` on `879cefe`). **Lesson: when engine-proven-correct code produces wrong runtime behavior, suspect the deploy -- check the actual running image version.**
- `[2026-07-06]` **#348 OCEAN key-mismatch: a declared OCEAN silently resolved to neutral.** The define validator required single-letter `{O,C,E,A,N}` but the mood-derivation code read spelled-out `openness`/.../`neuroticism` with a 0.0 default and no remap -> every API-declared trait defaulted to 0.0 -> neutral setpoint/gain/decay. #343's tests bypassed the validator (spelled-out keys) so CI never caught it. Fixed in b21 (`Personality.from_config` accepts both key forms). **My reset+smoke diagnosis flushed it out** -- the consumer/provider thesis paying off again.
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.18.10"
version = "0.19.6"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
@@ -60,9 +60,9 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
# Ratatoskr is built against Worldtree at this commit; the vendored
# spec snapshot in docs/ reflects that SHA.
[tool.ratatoskr.spec-pin]
worldtree-spec-rev = "5810a26b38a5ea6630892f9a39756f57c5b7b41e"
worldtree-version = "v1.0.0b2"
pinned-on = "2026-06-30"
worldtree-spec-rev = "879cefef02bbf442c2d01ff4214299fac7c89809"
worldtree-version = "v1.0.0b22"
pinned-on = "2026-07-06"
# Bifrost lives on the auth-gated gitea PyPI index (not public PyPI).
# uv reads the credential from UV_INDEX_GITEA_USERNAME / _PASSWORD or ~/.netrc.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Regenerate src/ratatoskr/web/static/persona_render_canon.json from the vendored
Worldtree d2 render canons (docs/vendor/worldtree-persona-canon/).
The web persona pane renders the CANONICAL affect->NL (mood word + relationship
directive) BYTE-EXACT to what Worldtree injects into the agent's context. That render
needs the relation canon parsed into per-band phrase maps; this script reparses the
vendored raw canons into the flat form the browser JS consumes.
Uses Worldtree's OWN loader (core.persona.stance_render.load_canon) as the authoritative
parser, so the flat form can never drift from Worldtree's parsing semantics. Requires
Worldtree's venv (pydantic etc.).
Run when scripts/canonical_drift.py flags a canon bump:
PYTHONPATH=~/development/Worldtree ~/development/Worldtree/.venv/bin/python \
scripts/build_persona_canon.py
"""
import json
from pathlib import Path
from core.persona.stance_render import load_canon # Worldtree (authoritative parser)
ROOT = Path(__file__).resolve().parent.parent
VENDOR = ROOT / "docs" / "vendor" / "worldtree-persona-canon"
OUT = ROOT / "src" / "ratatoskr" / "web" / "static" / "persona_render_canon.json"
canon = load_canon(str(VENDOR / "d2-render-canon-v1.json"))
mood = json.loads((VENDOR / "d2-mood-render-canon-v1.json").read_text())
out = {
"_source": "vendored from Worldtree core/persona/canon/{d2-mood-render-canon-v1,d2-render-canon-v1}.json",
"_generated_by": "scripts/build_persona_canon.py (regen on canonical_drift flag)",
"_render_path": "deterministic, no LLM; mirrors Worldtree describe_pad + render_d2_canonical byte-exact",
"mood_grid": mood["describe_pad"]["valence_arousal_grid"],
"relation": {
"trust_cuts": [list(c) for c in canon.trust_cuts],
"warmth_cuts": [list(c) for c in canon.warmth_cuts],
"agency_cuts": [list(c) for c in canon.agency_cuts],
"warmth_phrase": canon.warmth_phrase, "warmth_beh": canon.warmth_beh,
"agency_phrase": canon.agency_phrase, "agency_beh": canon.agency_beh,
"history": canon.history,
"prefix": "Use this graded relationship state: toward target, warmth is ",
"tbeh": {"low_trust": "verify important claims before relying on them",
"cold_warmth": "protect boundaries while staying useful",
"default": "work from ordinary good faith"},
"cold_warmth_bands": ["distant", "cold", "hostile"], "high_conf_floor": 0.55,
},
}
OUT.write_text(json.dumps(out, indent=1) + "\n")
print(f"wrote {OUT.relative_to(ROOT)}")
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# reset-sindra-stores.sh — wipe ratatoskr's Bifrost provider stores (memory +
# affect/persona for the single-tenant Tier-3 agent, sindra) and restart the
# combined :8392 provider empty.
#
# Usage:
# scripts/reset-sindra-stores.sh # wipe, keep ONE rolling backup (default)
# scripts/reset-sindra-stores.sh --hard # wipe with NO backup (zero-trace)
#
# The rolling backup (db-reset-backup/, gitignored via *.db*) is overwritten
# every run — it never accumulates; it's a one-level undo, nothing more.
#
# Why stop the provider first: the combined provider holds the SQLite files open
# (WAL) and caches state in memory, so an out-of-band file move without a restart
# would be shadowed. Stop -> move -> restart lets it recreate empty schema
# (CREATE TABLE IF NOT EXISTS on open).
PORT=8392
BACKUP_DIR="db-reset-backup"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" || { echo "reset: cannot cd to repo root $ROOT" >&2; exit 1; }
# shellcheck disable=SC1091
source ./env.sh >/dev/null 2>&1 || { echo "reset: failed to source env.sh" >&2; exit 1; }
AFFECT_DB="${RATATOSKR_AFFECT_DB:-affect.db}"
MEMORY_DB="${RATATOSKR_MEMORY_DB:-memory.db}"
HARD=0; [ "${1:-}" = "--hard" ] && HARD=1
echo "== ratatoskr provider-store reset (memory + persona) =="
echo " affect: $AFFECT_DB"
echo " memory: $MEMORY_DB"
# 1. stop the combined provider holding the DBs
PID="$(ss -ltnp 2>/dev/null | grep ":$PORT" | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)"
if [ -n "${PID:-}" ]; then
kill -9 "$PID" 2>/dev/null && echo "-- stopped provider :$PORT (pid $PID)"
else
echo "-- no provider on :$PORT (already down)"
fi
# 2. wipe (optional rolling backup)
files=("$AFFECT_DB" "$AFFECT_DB-wal" "$AFFECT_DB-shm" "$MEMORY_DB" "$MEMORY_DB-wal" "$MEMORY_DB-shm")
if [ "$HARD" -eq 1 ]; then
for f in "${files[@]}"; do [ -e "$f" ] && rm -f "$f" && echo "-- removed $f"; done
echo "-- HARD wipe (no backup)"
else
rm -rf "$BACKUP_DIR"; mkdir -p "$BACKUP_DIR"
for f in "${files[@]}"; do [ -e "$f" ] && mv "$f" "$BACKUP_DIR"/ && echo "-- $f -> $BACKUP_DIR/"; done
echo "-- rolling backup: $BACKUP_DIR/ (overwritten each run)"
fi
# 3. restart the combined provider (recreates empty schema on open)
nohup "$ROOT/.venv/bin/ratatoskr-combined-provider" >/tmp/ratatoskr-combined.log 2>&1 & disown
echo "-- restarted combined provider (pid $!)"
# 4. verify bound + empty
curl -s -o /dev/null -w "-- :$PORT -> HTTP %{http_code}\n" --retry 25 --retry-connrefused --retry-delay 1 "http://127.0.0.1:$PORT/"
echo "-- affect_snapshots (persona): $(sqlite3 "$AFFECT_DB" 'SELECT COUNT(*) FROM affect_snapshots' 2>&1)"
echo "-- memory_chunks (memory): $(sqlite3 "$MEMORY_DB" 'SELECT COUNT(*) FROM memory_chunks' 2>&1)"
echo "== done — sindra memory + persona reset =="
+190 -3
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import argparse
import asyncio
import hashlib
import os
import signal
import sys
@@ -18,14 +19,22 @@ import httpx
from ratatoskr.sessions import (
AgentNotFound,
AuthoredHistoryUnavailable,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
SessionApiFailed,
create_character,
create_session,
delete_character,
endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
get_session_messages,
list_character_models,
set_persona_state,
write_authored_history,
)
from ratatoskr.sse_client import (
AffectUpdate,
@@ -106,6 +115,14 @@ class ParsedArgs:
# admin-scoped inspection reads (BifrostState pane, GET /admin/sessions/…).
# None when unset — the BifrostState pane then shows "admin key not configured".
admin_key: str | None = None
# Tier-2 one-shot probes (like --whoami). --characters runs the transient-
# character CRUD lifecycle; --set-persona-pad "p,a,d" (with --session) writes
# a session's persona state (affect injection).
characters: bool = False
set_persona_pad: str | None = None
# #347 authored-history-write reference-consumer probe: create a fresh
# session bound to --agent, seed an authored assistant first-message (seq-0).
seed_first_message: str | None = None
class _ArgparseError(Exception):
@@ -132,6 +149,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
parser.add_argument("--raw", action="store_true")
parser.add_argument("--whoami", action="store_true")
parser.add_argument("--admin-key", dest="admin_key")
parser.add_argument("--characters", action="store_true")
parser.add_argument("--set-persona-pad", dest="set_persona_pad", default=None)
parser.add_argument("--seed-first-message", dest="seed_first_message", default=None)
# Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir).
parser.add_argument("--end-user-id", dest="end_user_id", default=None)
# Issue #17: bind the created session to our own Bifrost provider plane.
@@ -152,11 +172,36 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
# Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send).
if ns.end_user_id is not None and not ns.end_user_id:
raise UsageError("--end-user-id must be non-empty when passed")
if ns.whoami:
# Standalone boot-time probe (GET /me + /capabilities): opens no session.
if sum([ns.whoami, ns.characters, bool(ns.set_persona_pad), bool(ns.seed_first_message)]) > 1:
raise UsageError(
"--whoami / --characters / --set-persona-pad / --seed-first-message "
"are mutually exclusive"
)
if ns.whoami or ns.characters:
# Standalone one-shot probes: open no session.
if ns.send is not None or ns.session or ns.new or ns.agent:
raise UsageError(
"--whoami is a standalone probe (no --send/--session/--new/--agent)"
"--whoami / --characters are standalone probes "
"(no --send/--session/--new/--agent)"
)
elif ns.set_persona_pad is not None:
# Session-scoped write probe: needs a target session, nothing else.
if not ns.set_persona_pad:
raise UsageError("--set-persona-pad must be non-empty (e.g. '0.4,0.1,-0.2')")
if not ns.session:
raise UsageError("--set-persona-pad requires --session <id>")
if ns.send is not None or ns.new or ns.agent:
raise UsageError("--set-persona-pad takes only --session")
elif ns.seed_first_message is not None:
# #347 first-message probe: creates a fresh session bound to --agent,
# then seeds an authored assistant turn as seq-0 — manages its own session.
if not ns.seed_first_message:
raise UsageError("--seed-first-message must be non-empty")
if not ns.agent:
raise UsageError("--seed-first-message requires --agent <id>")
if ns.send is not None or ns.session or ns.new:
raise UsageError(
"--seed-first-message manages its own session (no --send/--session/--new)"
)
else:
if ns.session and ns.new:
@@ -231,6 +276,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
consumer_key=consumer_key,
whoami=ns.whoami,
admin_key=admin_key,
characters=ns.characters,
set_persona_pad=ns.set_persona_pad,
seed_first_message=ns.seed_first_message,
)
@@ -631,6 +679,139 @@ async def _whoami(args: ParsedArgs) -> int:
return 0
def _probe_client(args: ParsedArgs) -> httpx.AsyncClient:
"""AsyncClient for the one-shot probes (--whoami / --characters / --set-persona-pad)."""
return httpx.AsyncClient(
base_url=args.server_url,
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
)
async def _characters_probe(args: ParsedArgs) -> int:
"""--characters one-shot: exercise the transient-character CRUD lifecycle
(models create get-state delete), print a report, exit. A reference-
consumer smoke of the #161 character surface (needs character.read/write)."""
assert isinstance(args, ParsedArgs)
async with _probe_client(args) as client:
try:
models = await list_character_models(client)
names = ", ".join(m.get("name", "?") for m in models.get("items", []))
sys.stdout.write(f"character models: {names or '(none)'}\n")
created = await create_character(
client,
{
"schema_version": "1",
"name": "ratatoskr-probe",
"ocean": {
"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.0,
"agreeableness": 0.5, "neuroticism": 0.5,
},
"description": "ratatoskr --characters lifecycle probe",
"narrative": "A throwaway probe character.",
"voice_profile_block": "plain",
},
)
cid = created["character_id"]
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
state = await get_character_state(client, cid)
sys.stdout.write(f"state: pad={state.get('pad')}\n")
await delete_character(client, cid)
sys.stdout.write(f"deleted: {cid}\n")
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
return 0
async def _set_persona_probe(args: ParsedArgs) -> int:
"""--set-persona-pad one-shot: POST a PAD to /sessions/{id}/persona_state
(affect injection), print the result, exit. Requires --session."""
assert isinstance(args, ParsedArgs)
assert args.session_id is not None and args.set_persona_pad is not None
try:
pad = [float(x) for x in args.set_persona_pad.split(",")]
except ValueError:
sys.stderr.write(
"[usage_error] --set-persona-pad must be comma-separated floats "
"(e.g. '0.4,0.1,-0.2')\n"
)
return 10
async with _probe_client(args) as client:
try:
await set_persona_state(client, args.session_id, {"pad": pad})
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(
f"persona_state set: session={args.session_id[-8:]} pad={pad} (204)\n"
)
return 0
async def _seed_first_message_probe(args: ParsedArgs) -> int:
"""--seed-first-message one-shot: create a fresh session bound to --agent,
write an authored assistant first-message (#347 POST /sessions/{id}/history),
read it back via GET /messages, print a report, exit. A reference-consumer
smoke of the authored-history-write primitive.
Hide-existence: a 404 (feature-absent OR the key lacks `session.history.write`)
is reported as a benign 'feature-absent' result (exit 0) the probe NEVER
capability-probes to distinguish the causes (server INV-347-1). The probe
seeds but does not generate, so the assistant-first provider constraint is
inert here.
"""
assert isinstance(args, ParsedArgs)
assert args.agent_id is not None and args.seed_first_message is not None
async with _probe_client(args) as client:
try:
session = await create_session(
client, args.agent_id, end_user_id=args.end_user_id
)
sys.stdout.write(f"session: {session.session_id} (agent {session.agent_id})\n")
key = "ratatoskr-first-message-" + hashlib.sha256(
args.seed_first_message.encode("utf-8")
).hexdigest()[:12]
try:
ack = await write_authored_history(
client,
session.session_id,
content=args.seed_first_message,
idempotency_key=key,
)
except AuthoredHistoryUnavailable:
sys.stdout.write(
"authored-history: feature-absent or ungranted (404 hide-existence) "
"— a production consumer falls back to a model-generated greeting; "
"no capability-probe attempted.\n"
)
return 0
sys.stdout.write(
f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} "
f"turn_id={ack.get('turn_id')} content_chars={ack.get('content_chars')}\n"
)
history = await get_session_messages(client, session.session_id)
items = history.get("items", [])
sys.stdout.write(f"read-back: {len(items)} message(s)\n")
for m in items:
sys.stdout.write(
f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n"
)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
return 0
def main(argv: list[str] | None = None) -> int:
"""Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop."""
assert argv is None or all(isinstance(a, str) for a in argv)
@@ -648,6 +829,12 @@ def main(argv: list[str] | None = None) -> int:
return int(exc.code) if exc.code is not None else 0
if args.whoami:
return asyncio.run(_whoami(args))
if args.characters:
return asyncio.run(_characters_probe(args))
if args.set_persona_pad is not None:
return asyncio.run(_set_persona_probe(args))
if args.seed_first_message is not None:
return asyncio.run(_seed_first_message_probe(args))
if args.send_content is None:
# TUI mode — lazy import preserves INV-001 (no textual in cli at module scope).
from ratatoskr.tui import run_tui
+169
View File
@@ -177,6 +177,26 @@ class AuthScopeDenied(Exception):
self.scope = scope
class AuthoredHistoryUnavailable(Exception):
"""Raised on HTTP 404 from POST /sessions/{id}/history (#347 authored-history-write).
Hide-existence (server INV-347-1): an ungranted caller, a non-owner, and an
unknown session ALL receive a 404 byte-identical to a genuine
`session_not_found` the feature's existence is never revealed by status,
body, or error_code. The consumer MUST treat this as feature-absent and fall
back (a production consumer to a model-generated greeting), and MUST NOT
capability-probe to distinguish the causes. Distinct from `SessionApiFailed`
so callers branch feature-absent without inspecting a status code.
"""
def __init__(self, *, session_id: str) -> None:
super().__init__(
f"authored-history write unavailable for session {session_id!r} "
"(404 hide-existence: feature-absent / ungranted / session-absent)"
)
self.session_id = session_id
async def list_sessions(
client: httpx.AsyncClient,
*,
@@ -425,6 +445,83 @@ async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def list_character_models(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /models/available-for-characters — character-capable model profiles (#161).
Requires `character.read`. Returns `{items: [{name, description, thinking}]}`.
Parsed dict verbatim; any non-200 SessionApiFailed.
"""
assert client is not None
resp = await client.get("/models/available-for-characters")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def create_character(
client: httpx.AsyncClient, character: dict[str, Any], *, state: dict[str, Any] | None = None
) -> dict[str, Any]:
"""POST /characters — create a transient character (#161). Requires `character.write`.
Body is `{character, state}` (state optional a CharacterStateSchema for
mid-conversation rehydration). Returns 201 `{character_id, ttl_expires_at}`;
any non-201 SessionApiFailed.
"""
assert client is not None
assert isinstance(character, dict) and character
resp = await client.post("/characters", json={"character": character, "state": state})
if resp.status_code == 201:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_character_state(client: httpx.AsyncClient, character_id: str) -> dict[str, Any]:
"""GET /characters/{character_id}/state — live runtime state (#161). Requires `character.read`.
Returns `{schema_version, pad, emotions_active, mood_drift, goal_signal_history}`;
refreshes the character's TTL. Any non-200 → SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.get(f"/characters/{character_id}/state")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def delete_character(client: httpx.AsyncClient, character_id: str) -> None:
"""DELETE /characters/{character_id} — remove a transient character (#161).
Requires `character.write`. Bound sessions detach (next turn 410
character_not_found). 200/204 None; any other status SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.delete(f"/characters/{character_id}")
if resp.status_code in (200, 204):
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def set_persona_state(
client: httpx.AsyncClient, session_id: str, snapshot: dict[str, Any]
) -> None:
"""POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection).
The request body is FREEFORM: the frozen OpenAPI 2.2.0 declares no request
schema and the prose spec documents only the GET counterpart so the caller
supplies the snapshot shape (e.g. `{pad: [p, a, d]}`, mirroring the GET
`snapshot`). 204 No Content None; any other status SessionApiFailed.
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(snapshot, dict)
resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot)
if resp.status_code == 204:
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_bifrost(
client: httpx.AsyncClient, session_id: str, *, admin_key: str
) -> dict[str, Any]:
@@ -481,3 +578,75 @@ async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def write_authored_history(
client: httpx.AsyncClient,
session_id: str,
*,
content: str,
idempotency_key: str,
author: str = "assistant",
effects: str | None = None,
claimed_original_at: str | None = None,
) -> dict[str, Any]:
"""POST /sessions/{session_id}/history — the #347 authored-history-write primitive.
Write one model-visible turn into the session's ledger AS the bound agent,
WITHOUT a generation and WITHOUT lived-turn side effects (the SillyTavern
"first message"). v1: `author="assistant"`, `effects` omitted (== "none"),
`idempotency_key` REQUIRED (per-session dedup). The server pins the body
(`AuthoredWriteRequest`, `extra="forbid"`), so `effects` /
`claimed_original_at` are sent only when non-None never as null keys.
Success is 201 (fresh) or 200 (idempotent replay, byte-identical body); both
return the `AuthoredTurnResponse` dict verbatim (`{author, content_chars,
injected_at, phase, seq, session_id, turn_id}` provenance is audit-only,
never on this body).
404 `AuthoredHistoryUnavailable` (hide-existence: feature-absent /
ungranted / session-absent are indistinguishable by design; the caller falls
back and NEVER capability-probes server INV-347-1). Any other non-2xx
`SessionApiFailed` (notably 409 `generation_active`, 422 `content_too_long` /
`validation_failed`).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert idempotency_key and isinstance(idempotency_key, str)
assert author and isinstance(author, str)
body: dict[str, Any] = {
"author": author,
"content": content,
"idempotency_key": idempotency_key,
}
if effects is not None:
body["effects"] = effects
if claimed_original_at is not None:
body["claimed_original_at"] = claimed_original_at
resp = await client.post(f"/sessions/{session_id}/history", json=body)
if resp.status_code in (200, 201):
return resp.json()
if resp.status_code == 404:
raise AuthoredHistoryUnavailable(session_id=session_id)
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_messages(
client: httpx.AsyncClient, session_id: str
) -> dict[str, Any]:
"""GET /sessions/{session_id}/messages — the session's message history.
Un-deferred as the #347 seed read-back: a seeded turn renders as a normal
`role=assistant` message (model-invisible provenance indistinguishable
from a lived turn on read). Returns `{session_id, items: [{seq, role,
content, ...}], next_cursor}` verbatim; owner-scoped; any non-200
`SessionApiFailed`. v1 reads the server default page (no pagination params
add limit/cursor when a caller needs scrollback).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/messages")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
+62
View File
@@ -175,6 +175,23 @@ Event = (
)
@dataclass(frozen=True)
class AdminEvent:
"""One `/admin/events` envelope (INV-046) — an admin-tier lifecycle event.
Distinct from the turn-stream `Event` union: this is the process-wide admin
broadcast stream, not a per-turn stream. `id` is a plain monotonic int
(resets on restart; heartbeats have id=0). `type` is a dotted namespace
(session.* / turn.* / key.* / system.*). `data` is a type-specific dict
most carry `session_id`; per INV-049 it holds IDs + small metadata only.
"""
id: int
type: str
timestamp: str | None
data: dict[str, Any]
class MalformedSseId(Exception):
"""Raised when an SSE event's `id:` wire field is missing or non-composite."""
@@ -598,6 +615,51 @@ async def stream_turn_resilient(
)
async def stream_admin_events(
client: httpx.AsyncClient,
*,
admin_key: str,
last_event_id: int | None = None,
) -> AsyncIterator[AdminEvent]:
"""GET /admin/events SSE — the admin-tier lifecycle broadcast stream (INV-046).
Yields `AdminEvent` envelopes as they arrive. Admin-scoped (admin.events.read):
the request OVERRIDES Authorization with `admin_key` (distinct from the
client's default consumer bearer). `last_event_id` sets the `Last-Event-ID`
header for resume (plain decimal int). Long-lived iterate until the caller
stops or the connection ends. Non-200 SseConnectFailed; a mid-stream drop
SseConnectionDropped (caller may reconnect from the last-seen `AdminEvent.id`).
Malformed frames are skipped (best-effort stream).
"""
assert client is not None
assert admin_key and isinstance(admin_key, str)
headers = {"Authorization": f"Bearer {admin_key}"}
if last_event_id is not None:
headers["Last-Event-ID"] = str(last_event_id)
async with httpx_sse.aconnect_sse(
client, "GET", "/admin/events", headers=headers
) as event_source:
if event_source.response.status_code != 200:
body = await event_source.response.aread()
raise SseConnectFailed(status=event_source.response.status_code, body=body)
try:
async for sse in event_source.aiter_sse():
if sse.data == "":
continue
try:
env = json.loads(sse.data)
except json.JSONDecodeError:
continue # skip a malformed admin frame (best-effort)
yield AdminEvent(
id=env.get("id", 0),
type=env["type"],
timestamp=env.get("timestamp"),
data=env.get("data", {}),
)
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
raise SseConnectionDropped(last_seen_sse_id=None) from exc
def _parse_sse_id(raw: str) -> SseId:
"""Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id."""
assert isinstance(raw, str)
+69 -1
View File
@@ -51,6 +51,7 @@ from ratatoskr.sessions import (
list_sessions,
)
from ratatoskr.sse_client import (
AdminEvent,
AffectUpdate,
AwaitingLlmFirstToken,
CancelAlreadyCompleted,
@@ -72,6 +73,7 @@ from ratatoskr.sse_client import (
TurnIdFlip,
WorkerPhase,
cancel_turn,
stream_admin_events,
stream_turn_resilient,
)
@@ -198,6 +200,17 @@ def _ts() -> str:
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
def _format_admin_event(ev: AdminEvent) -> str:
"""One-line render of an /admin/events envelope for the AdminEvents pane.
Drops `session_id` from the detail (the pane is already session-scoped) and
shows HH:MM:SS from the ISO timestamp + the remaining small metadata fields.
"""
ts = (ev.timestamp or "")[11:19]
extras = " ".join(f"{k}={v}" for k, v in ev.data.items() if k != "session_id")
return f"[{ts}] {ev.type} {extras}".rstrip()
def _format_bifrost_state(state: dict) -> list[str]:
"""Render GET /admin/sessions/{id}/bifrost (#176) into BifrostState-pane lines."""
tools = [t.get("name", "?") for t in state.get("tools", [])]
@@ -1053,7 +1066,7 @@ class RatatoskrApp(App[int]):
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
on `\n` and writes directly to #transcript (same pattern as v0.7.1
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
#tools-log, #debug-log, #thinking-log, #bifrost-log {
#tools-log, #debug-log, #thinking-log, #bifrost-log, #admin-events-log {
background: $background;
padding: 0 1;
}
@@ -1227,6 +1240,15 @@ class RatatoskrApp(App[int]):
id="bifrost-log", wrap=True, markup=False,
highlight=False, min_width=0,
)
with TabPane("AdminEvents", id="admin-events-tab"):
# #11: live GET /admin/events SSE stream, admin-scoped,
# FILTERED to the active session (design-brief §6). A
# long-lived worker appends matching lifecycle events;
# "not configured" when no admin key is set.
yield RichLog(
id="admin-events-log", wrap=True, markup=False,
highlight=False, min_width=0,
)
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
# pane-name widget displays current side-pane name.
yield Static("", id="identity")
@@ -1288,6 +1310,9 @@ class RatatoskrApp(App[int]):
# (admin-scoped). Self-labels "not configured" when no admin key is set,
# "not bound" for the common unbound-session 404 — always writes at mount.
self.run_worker(self._hydrate_bifrost_state())
# #11: long-lived worker streaming GET /admin/events into the AdminEvents
# pane, filtered to this session. Admin-key-gated; cancelled on app exit.
self.run_worker(self._stream_admin_events())
async def _hydrate_persona(self) -> None:
"""Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.
@@ -1402,6 +1427,49 @@ class RatatoskrApp(App[int]):
f"connected={state.get('connected')} tools={len(state.get('tools', []))}"
)
def _admin_event_matches(self, ev: AdminEvent) -> bool:
"""AdminEvents filter (design-brief §6): active-session events + non-heartbeat
system.* (stream-integrity signals). Heartbeats are keepalive noise."""
if ev.type == "system.heartbeat":
return False
if ev.type.startswith("system."):
return True
return ev.data.get("session_id") == self.session_id
async def _stream_admin_events(self) -> None:
"""Stream GET /admin/events (admin-scoped) into the AdminEvents pane (#11).
Long-lived + best-effort (never crashes the TUI). Filtered to the active
session (design-brief §6): appends matching lifecycle events as they
arrive. No admin key "not configured". On connect failure (e.g. 403
scope-denied) or a mid-stream drop, writes a labeled line and stops.
"""
assert self.client is not None and self.session_id is not None
from rich.text import Text as RichText
log = self.query_one("#admin-events-log", RichLog)
admin_key = getattr(self.args, "admin_key", None)
if not admin_key:
log.write(RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)"))
self._audit(
f"admin_events_skipped session={self.session_id[-8:]} reason=no_admin_key"
)
return
try:
async for ev in stream_admin_events(self.client, admin_key=admin_key):
if self._admin_event_matches(ev):
log.write(RichText(_format_admin_event(ev)))
except SseConnectFailed as exc:
log.write(RichText(f"(admin events unavailable: HTTP {exc.status})"))
self._audit(
f"admin_events_unavailable session={self.session_id[-8:]} status={exc.status}"
)
except Exception as exc: # drop / best-effort — never crash the TUI
log.write(RichText(f"(admin events stream ended: {type(exc).__name__})"))
self._audit(
f"admin_events_ended session={self.session_id[-8:]} err={type(exc).__name__}"
)
def _update_persona_surfaces(self, snapshot: dict) -> None:
"""Update sticky header + Persona pane from a fresh snapshot.
+5
View File
@@ -68,6 +68,10 @@ def main(argv: list[str] | None = None) -> int:
affect_read_url = os.environ.get(
"RATATOSKR_AFFECT_READ_URL", "http://127.0.0.1:8390"
)
# Admin observability panes (BifrostState + AdminEvents): the readonly-admin
# key stays SERVER-SIDE — the server proxies admin-scoped reads; the browser
# never receives the key, only the session-filtered result.
admin_key = os.environ.get("RATATOSKR_ADMIN_API_KEY")
# INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
@@ -108,6 +112,7 @@ def main(argv: list[str] | None = None) -> int:
bifrost_consumer_key=bifrost_consumer_key,
bifrost_visible_host=bifrost_visible_host,
affect_read_url=affect_read_url,
admin_key=admin_key,
)
# Boot banner to stderr (so stdout stays clean for piping).
+112 -1
View File
@@ -18,7 +18,12 @@ from importlib.metadata import version as _pkg_version
import httpx
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, StreamingResponse
from starlette.responses import (
FileResponse,
JSONResponse,
Response,
StreamingResponse,
)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
@@ -35,9 +40,12 @@ from ratatoskr.sessions import (
create_session,
endpoint_for_plane,
get_persona_state,
get_session_bifrost,
get_session_tools,
list_agents,
)
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
Cancelled,
CancelFailed,
@@ -50,6 +58,7 @@ from ratatoskr.sse_client import (
SseConnectionDropped,
TurnIdFlip,
cancel_turn,
stream_admin_events,
stream_turn_resilient,
)
@@ -416,6 +425,100 @@ async def _affect_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(r.json(), status_code=r.status_code)
async def _session_tools_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/tools → owner-scoped tool inventory (spec #183).
Proxies get_session_tools with the client's CONSUMER bearer (no admin scope):
the merged {agent_id, builtin_tools, bifrost_tools} the LLM saw at turn-fire.
Any non-200 upstream surfaced as a status-preserving error envelope."""
session_id = request.path_params["session_id"]
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
info = await get_session_tools(client, session_id)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_tools_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(info, status_code=200)
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).
The admin key is SERVER-HELD (app.state.admin_key) and never reaches the
browser (INV-003 precedent upstream credentials stay server-side); the
wrapper overrides the Authorization header with it. Fail-visible when the
admin key isn't configured (never a silent empty pane)."""
session_id = request.path_params["session_id"]
admin_key = request.app.state.admin_key
if not admin_key: # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
bstate = await get_session_bifrost(client, session_id, admin_key=admin_key)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "bifrost_state_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(bstate, status_code=200)
def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
"""AdminEvents filter (design-brief §6, mirrors the TUI): forward non-heartbeat
system.* (stream-integrity signals) + events for the active session; drop the
rest so the browser sees only session-relevant lifecycle, never the full
cross-session admin firehose."""
if ev.type == "system.heartbeat":
return False
if ev.type.startswith("system."):
return True
return session_id is not None and ev.data.get("session_id") == session_id
async def _admin_events_endpoint(request: Request) -> Response:
"""GET /api/admin/events?session_id=... → SSE proxy of GET /admin/events (#11).
The admin key is SERVER-HELD; the browser only ever receives the session-filtered
stream (never the key, never the cross-session firehose). Long-lived + best-effort:
a connect failure or mid-stream drop emits a labeled `stream_error` event and ends."""
admin_key = request.app.state.admin_key
if not admin_key: # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
session_id = request.query_params.get("session_id")
client_factory = request.app.state.client_factory
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
try:
async for ev in stream_admin_events(client, admin_key=admin_key):
if not _admin_event_matches_web(ev, session_id):
continue
# Fixed SSE event name so the browser renders EVERY admin type
# with one listener (no per-type enumeration → nothing silently
# dropped); the real dotted type rides in the payload.
yield _format_sse(
"admin_event",
{"id": ev.id, "type": ev.type, "timestamp": ev.timestamp,
"data": ev.data},
)
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
MalformedSseData) as exc:
yield _format_sse(
"stream_error",
{"exception": type(exc).__name__, "message": str(exc)},
)
except asyncio.CancelledError:
raise # browser disconnect — let the generator unwind
finally:
await client.aclose()
return StreamingResponse(gen(), media_type="text/event-stream")
def create_app(
client_factory: Callable[[], httpx.AsyncClient],
*,
@@ -423,6 +526,7 @@ def create_app(
bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None,
affect_read_url: str | None = None,
admin_key: str | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
@@ -483,6 +587,9 @@ def create_app(
Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
Route("/api/affect/{agent_id}", _affect_state_endpoint),
Route("/api/sessions/{session_id}/tools", _session_tools_endpoint),
Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint),
Route("/api/admin/events", _admin_events_endpoint),
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
@@ -498,6 +605,10 @@ def create_app(
# Issue #18 (Deliverable 2): the provider affect-read base URL (server→provider hop,
# same dev box) — distinct from the WT-visible host used for binding.
app.state.affect_read_url = affect_read_url
# Admin observability panes (BifrostState + AdminEvents): the admin key is
# SERVER-HELD (RATATOSKR_ADMIN_API_KEY) and never reaches the browser — the
# server proxies admin-scoped reads and forwards only the session-filtered result.
app.state.admin_key = admin_key
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {}
return app
+373 -24
View File
@@ -231,6 +231,26 @@ body {
50% { content: "··"; } 75% { content: "···"; }
}
/* reasoning indicator — a UI affordance in the transcript, visually distinct
from the agent's response (.response, left-bordered). Italic + a ✦ glyph so
it reads as "the app telling you inference is happening", never as engine
output. Ephemeral: appears on reasoning tokens, gone the moment real text
begins or the turn ends. */
.thinking-note {
display: inline-flex; align-items: center; gap: 8px;
color: var(--blue); font-style: italic; font-size: 12px;
margin: 6px 0; padding-left: 18px; opacity: 0.9;
animation: rise 0.3s ease both;
}
.thinking-note::before {
content: "✦"; font-style: normal; color: var(--cyan);
text-shadow: 0 0 10px var(--glow-cyan);
}
.thinking-note::after {
content: ""; width: 16px; text-align: left;
animation: dots 1.4s steps(4, end) infinite;
}
/* terminal status chips */
.chip {
display: inline-flex; align-items: center; gap: 7px;
@@ -341,7 +361,35 @@ body {
/* persona pane structured render */
#pane-persona .pk { color: var(--fg-dim); }
#pane-persona .pv { color: var(--blue); }
#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; }
#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; margin-top: 4px; }
/* affect metric rows: label · value · Δ · sparkline · n · descriptor */
#pane-persona .mono-note { color: var(--fg-faint); font-size: 10px; margin: 2px 0 8px; }
#pane-persona .mrow {
display: flex; gap: 9px; align-items: baseline; padding: 1px 0;
font-size: 12px; white-space: nowrap;
}
#pane-persona .mrow .mk { color: var(--fg-dim); min-width: 118px; }
#pane-persona .mrow .mv { color: var(--blue); min-width: 46px; text-align: right; font-variant-numeric: tabular-nums; }
#pane-persona .mrow .md { min-width: 58px; font-size: 11px; color: var(--fg-faint); }
#pane-persona .mrow .md.up { color: var(--green); }
#pane-persona .mrow .md.dn { color: var(--red); }
#pane-persona .mrow .msp { color: var(--cyan); letter-spacing: 1px; min-width: 28px; }
#pane-persona .mrow .mn { color: var(--fg-faint); font-size: 10px; min-width: 34px; }
#pane-persona .mrow .mdesc { color: var(--fg-faint); font-style: italic; font-size: 10px; }
/* canonical NL (the literal text Worldtree injects into the agent's context) */
#pane-persona .nl-canon {
color: var(--blue); font-size: 11px; line-height: 1.5;
margin: 3px 0 8px; padding-left: 10px; border-left: 2px solid var(--line-2);
white-space: normal; word-break: break-word;
}
#pane-persona .nl-canon b { color: var(--cyan); font-weight: 600; }
#pane-persona .nl-directive { color: var(--fg-2); font-style: italic; }
#pane-persona .nl-canon .dh {
font-style: normal; color: var(--fg-faint); font-size: 10px;
letter-spacing: 0.06em; text-transform: uppercase; display: block; margin-bottom: 2px;
}
/* thinking-pane per-turn dividers */
.pane-turn {
@@ -487,6 +535,8 @@ body {
<button class="tab" data-pane="debug">debug <span class="kbd">⌃2</span><span class="badge">0</span></button>
<button class="tab" data-pane="thinking">think <span class="kbd">⌃3</span><span class="badge">0</span></button>
<button class="tab" data-pane="persona">persona <span class="kbd">⌃4</span></button>
<button class="tab" data-pane="bifrost">bifrost <span class="kbd">⌃5</span></button>
<button class="tab" data-pane="admin">admin <span class="kbd">⌃6</span><span class="badge">0</span></button>
</nav>
<div class="pane-head">
<span id="pane-name">tools</span>
@@ -497,6 +547,8 @@ body {
<div class="pane" id="pane-debug"><div class="empty">waiting for wire telemetry…</div></div>
<div class="pane" id="pane-thinking"><div class="empty">no chain-of-thought captured yet</div></div>
<div class="pane" id="pane-persona"><div class="empty">persona state loads on session open</div></div>
<div class="pane" id="pane-bifrost"><div class="empty">bifrost dispatch state loads on session open</div></div>
<div class="pane" id="pane-admin"><div class="empty">admin lifecycle events stream on session open</div></div>
</div>
</section>
</main>
@@ -542,7 +594,7 @@ body {
"use strict";
const $ = (id) => document.getElementById(id);
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null };
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null, lastAffectAt: null, adminES: null };
function esc(s) {
const d = document.createElement("div");
@@ -603,8 +655,46 @@ function markdownSafe(raw) {
// per-turn live buffers (reset at turn open)
const LIVE = { resp: "", think: "" };
// ---- reasoning indicator (a UI affordance — NOT engine output) ----------
// When the model streams reasoning/chain-of-thought, show an ephemeral
// "<Agent> is pondering…" line in the transcript so the user knows inference
// is happening. Rotates phrasing for liveliness; removed the instant real text
// begins or the turn ends. agentDisplayName is rendered via textContent (never
// innerHTML) so an adversarial agent_id can't inject markup (INV-004).
const THINK_PHRASES = ["is thinking", "is pondering", "appears thoughtful",
"is reasoning", "is turning it over"];
let thinkRotator = null;
function agentDisplayName() {
if (!state.agentId) return "the agent";
const tail = String(state.agentId).split(":").pop() || "the agent";
return tail.charAt(0).toUpperCase() + tail.slice(1);
}
function showThinkingNote() {
// reasoning tokens ARE the first tokens — supersede the awaiting-first heartbeat
const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove();
let el = document.querySelector("#transcript .thinking-note");
if (!el) {
el = document.createElement("div");
el.className = "thinking-note";
el.appendChild(document.createTextNode(""));
$("transcript").appendChild(el);
let i = 0;
const paint = () => { el.firstChild.textContent =
`${agentDisplayName()} ${THINK_PHRASES[i % THINK_PHRASES.length]}`; i++; };
paint();
thinkRotator = setInterval(paint, 2600);
}
$("transcript").scrollTop = $("transcript").scrollHeight;
}
function hideThinkingNote() {
if (thinkRotator) { clearInterval(thinkRotator); thinkRotator = null; }
const el = document.querySelector("#transcript .thinking-note");
if (el) el.remove();
}
// ---- pane helpers ----
const PANE_BADGE = { tools: 0, debug: 0, thinking: 0 };
const PANE_BADGE = { tools: 0, debug: 0, thinking: 0, admin: 0 };
function bumpBadge(pane) {
if (!(pane in PANE_BADGE)) return;
PANE_BADGE[pane] += 1;
@@ -745,25 +835,169 @@ async function loadPersona(agentId) {
// shape only — pad + per-entity valence + emitted_at; NO fabricated Tier-1 persona
// fields (dominant_emotion / mood_drift), which Tier-3 structurally lacks (INV-001).
// Labelled "affect", not "persona" (INV-005).
// ---- affect trend accumulation (client-side, session-lived) ----
// The affect pane refreshes on session-open + after each turn (the post-turn PAD poll
// fires ~4x/turn — deduped here by emitted_at so a turn contributes ONE sample). Each
// tracked value keeps a rolling, capped history so the pane can show a Δ + sparkline.
const AFFECT_HIST = { at: [], pad: {}, rel: {} }; // pad[axis]=[]; rel[target][metric]=[]
const HIST_CAP = 24;
const _relVal = (x) => (x && typeof x === "object" && "value" in x) ? x.value : x;
const _relN = (x) => (x && typeof x === "object" && "evidence_count" in x) ? x.evidence_count : undefined;
function pushAffectHistory(snap) {
const at = snap.emitted_at || "";
if (at && AFFECT_HIST.at[AFFECT_HIST.at.length - 1] === at) return; // same snapshot — skip
AFFECT_HIST.at.push(at);
if (AFFECT_HIST.at.length > HIST_CAP) AFFECT_HIST.at.shift();
const push = (bucket, key, v) => {
if (typeof v !== "number") return;
(bucket[key] = bucket[key] || []).push(v);
if (bucket[key].length > HIST_CAP) bucket[key].shift();
};
const pad = snap.pad || {};
for (const ax of ["pleasure", "arousal", "dominance"]) push(AFFECT_HIST.pad, ax, pad[ax]);
for (const rel of (snap.relations || snap.valence || [])) {
const tgt = rel.target_entity || rel.entity_id || "?";
const b = (AFFECT_HIST.rel[tgt] = AFFECT_HIST.rel[tgt] || {});
push(b, "trust_ability", _relVal(rel.trust_ability));
push(b, "trust_benevolence", _relVal(rel.trust_benevolence));
push(b, "trust_integrity", _relVal(rel.trust_integrity));
push(b, "warmth", _relVal(rel.warmth ?? rel.familiarity));
}
}
// unicode sparkline auto-scaled to the value's own observed range; flat when stable
// (don't amplify sub-0.01 noise into a fake trend).
const _SPARK = "▁▂▃▄▅▆▇█";
function sparkline(vals) {
if (!vals || vals.length < 2) return (vals && vals.length) ? "·" : "";
const lo = Math.min(...vals), hi = Math.max(...vals);
if (hi - lo < 0.01) return "".repeat(vals.length);
const span = hi - lo;
return vals.map((v) => _SPARK[Math.min(7, Math.floor(((v - lo) / span) * 7.999))]).join("");
}
function trendDelta(vals) {
if (!vals || vals.length < 2) return "";
const d = vals[vals.length - 1] - vals[vals.length - 2];
if (Math.abs(d) < 0.0005) return "";
return (d > 0 ? "▲+" : "▼") + d.toFixed(3);
}
// ---- canonical affect→NL (vendored from Worldtree's d2 render canons) ----
// Deterministic, NO LLM — mirrors Worldtree core/persona describe_pad +
// render_d2_canonical BYTE-EXACT (verified). Shows the LITERAL mood word +
// relationship directive Worldtree injects into the agent's own context, so the
// pane reads exactly what the agent was told about its state. Canon loaded from
// /static/persona_render_canon.json (regen: scripts/build_persona_canon.py).
let PERSONA_CANON = null;
const _relConf = (x) => (x && typeof x === "object" && "confidence" in x) ? x.confidence : 0;
async function loadPersonaCanon() {
try { PERSONA_CANON = await (await fetch("/static/persona_render_canon.json")).json(); }
catch (_) { /* canon absent → the canonical lines simply omit (fail-open) */ }
}
function _parseInterval(expr) {
expr = expr.trim();
if (expr.startsWith("<=")) return [null, false, parseFloat(expr.slice(2)), true];
if (expr.startsWith("<")) return [null, false, parseFloat(expr.slice(1)), false];
if (expr.startsWith(">=")) return [parseFloat(expr.slice(2)), true, null, false];
if (expr.startsWith(">")) return [parseFloat(expr.slice(1)), false, null, false];
const loI = expr[0] === "[", hiI = expr[expr.length - 1] === "]";
const [a, b] = expr.slice(1, -1).split(",").map((s) => parseFloat(s.trim()));
return [a, loI, b, hiI];
}
function _bandLabel(cuts, v) {
if (typeof v !== "number") return null;
for (const [expr, label] of cuts) {
const [lo, loI, hi, hiI] = _parseInterval(expr);
let ok = true;
if (lo !== null) ok = ok && (loI ? v >= lo : v > lo);
if (hi !== null) ok = ok && (hiI ? v <= hi : v < hi);
if (ok) return label;
}
return null;
}
function canonMood(pad) { // mirror describe_pad(p,a,d): valence×arousal grid + ±0.3 bands
if (!PERSONA_CANON || !pad) return null;
const p = pad.pleasure, a = pad.arousal, d = pad.dominance;
if ([p, a, d].some((x) => typeof x !== "number")) return null;
const g = PERSONA_CANON.mood_grid;
const valence = p > 0.3 ? "positive" : p < -0.3 ? "negative" : "neutral";
const band = a > 0.3 ? "high_a" : a < -0.3 ? "low_a" : "mid_a";
const control = d > 0.3 ? "confident" : d < -0.3 ? "uncertain" : null;
return control ? `${g[valence][band]}, ${control}` : g[valence][band];
}
function canonDirective(rel) { // mirror render_d2_canonical(edge, canon) byte-exact
if (!PERSONA_CANON) return null;
const R = PERSONA_CANON.relation;
const wb = _bandLabel(R.warmth_cuts, _relVal(rel.warmth));
const ab = _bandLabel(R.agency_cuts, _relVal(rel.agency));
const ta = _bandLabel(R.trust_cuts, _relVal(rel.trust_ability));
const ti = _bandLabel(R.trust_cuts, _relVal(rel.trust_integrity));
const tb = _bandLabel(R.trust_cuts, _relVal(rel.trust_benevolence));
if ([wb, ab, ta, ti, tb].some((x) => x == null)) return null;
const cl = _relConf(rel.warmth) >= R.high_conf_floor ? "high" : "low";
const tbeh = [ta, ti, tb].includes("limited") ? R.tbeh.low_trust
: R.cold_warmth_bands.includes(wb) ? R.tbeh.cold_warmth : R.tbeh.default;
return R.prefix + R.warmth_phrase[wb]
+ `; agency is ${R.agency_phrase[ab]}; ability trust is ${ta}; integrity trust is ${ti}; `
+ `intention trust is ${tb}; this stance rests on ${R.history[cl]}. In behavior, `
+ `${R.warmth_beh[wb]}; ${R.agency_beh[ab]}; ${tbeh}; avoid premature we-framing.`;
}
// Issue #18 D2 (relation_edge/1 rework): Worldtree's affect snapshot now carries
// `relations[]` (target + trust_ability/benevolence/integrity + warmth + agency +
// relation_context, each {value,confidence,evidence_count}) — NOT the old flat
// `valence[]`. Render mood (PAD) + the durable per-entity relational model, each with
// a Δ + sparkline from AFFECT_HIST. Falls back to `valence` for an older emitter.
// INV-001: no fabricated Tier-1 fields. INV-004: every dynamic value escaped (head()
// escapes its whole argument; metric() escapes each cell).
function renderAffectPane(snap) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const all = snap.valence || [];
const shown = all.slice(0, 8); // bounded render — valence[] is unbounded in principle
const valRows = shown.map((v) =>
row(v.entity_id || "?",
`familiarity ${JSON.stringify(v.familiarity)} · regard ${JSON.stringify(v.regard)}`
+ ` · n=${JSON.stringify(v.interaction_count)}`)
).join("");
$("pane-persona").innerHTML =
head("affect snapshot · " + (snap.agent_id || "?")) +
`<div> </div>` + head("pad") +
row("pleasure", JSON.stringify(snap.pad?.pleasure)) +
row("arousal", JSON.stringify(snap.pad?.arousal)) +
row("dominance", JSON.stringify(snap.pad?.dominance)) +
`<div> </div>` + head("valence (" + all.length + ")") +
(valRows || `<div class="empty">none</div>`) +
`<div> </div>` + row("emitted_at", snap.emitted_at || "?");
const num = (v) => (typeof v === "number") ? ((v >= 0 ? "+" : "") + v.toFixed(3)) : "—";
const metric = (label, val, hist, desc, n) => {
const d = trendDelta(hist);
const dcls = d.startsWith("▲") ? "up" : d.startsWith("▼") ? "dn" : "";
const nlab = (n !== undefined && n !== null) ? "n=" + esc(n) : "";
return `<div class="mrow"><span class="mk">${esc(label)}</span>`
+ `<span class="mv">${esc(num(val))}</span>`
+ `<span class="md ${dcls}">${esc(d)}</span>`
+ `<span class="msp">${esc(sparkline(hist))}</span>`
+ `<span class="mn">${nlab}</span>`
+ `<span class="mdesc">${esc(desc)}</span></div>`;
};
const pad = snap.pad || {}, H = AFFECT_HIST;
let html = head("affect · " + (snap.agent_id || "?"))
+ `<div class="mono-note">emitted ${esc((snap.emitted_at || "?").slice(11, 19))} · `
+ `${H.at.length} sample${H.at.length === 1 ? "" : "s"} this session</div>`
+ head("mood · PAD (transient, 1‥+1)")
+ (canonMood(pad) ? `<div class="nl-canon">Worldtree tells the agent it feels: <b>${esc(canonMood(pad))}</b></div>` : "")
+ metric("pleasure", pad.pleasure, H.pad.pleasure, "feeling")
+ metric("arousal", pad.arousal, H.pad.arousal, "activation")
+ metric("dominance", pad.dominance, H.pad.dominance, "control");
const rels = snap.relations || snap.valence || [];
if (!rels.length) {
html += head("relations") + `<div class="empty">no relations tracked yet — take a turn</div>`;
}
for (const rel of rels.slice(0, 8)) {
const tgt = rel.target_entity || rel.entity_id || "?";
const ctx = rel.relation_context ? " · stage: " + rel.relation_context : "";
const b = H.rel[tgt] || {};
html += head("relation → " + tgt + ctx)
+ metric("trust·ability", _relVal(rel.trust_ability), b.trust_ability, "is-competent", _relN(rel.trust_ability))
+ metric("trust·benevolence", _relVal(rel.trust_benevolence), b.trust_benevolence, "means-well", _relN(rel.trust_benevolence))
+ metric("trust·integrity", _relVal(rel.trust_integrity), b.trust_integrity, "is-honest", _relN(rel.trust_integrity))
+ metric("warmth", _relVal(rel.warmth ?? rel.familiarity), b.warmth, "affection", _relN(rel.warmth));
const ag = _relVal(rel.agency), agn = _relN(rel.agency);
if (typeof ag === "number" && agn) html += metric("agency", ag, null, "autonomy", agn);
if (rel.obligation_balance !== null && rel.obligation_balance !== undefined) {
html += `<div class="mrow"><span class="mk">obligation</span>`
+ `<span class="mv">${esc(JSON.stringify(rel.obligation_balance))}</span></div>`;
}
const dir = canonDirective(rel);
if (dir) {
html += `<div class="nl-canon nl-directive"><span class="dh">context directive `
+ `(what the agent is told about ${esc(tgt)}):</span> ${esc(dir)}</div>`;
}
}
$("pane-persona").innerHTML = html;
}
async function loadAffect(agentId) {
@@ -771,8 +1005,10 @@ async function loadAffect(agentId) {
const r = await fetch("/api/affect/" + encodeURIComponent(agentId));
if (r.status === 200) {
const snap = await r.json();
pushAffectHistory(snap); // accumulate the per-value trend BEFORE rendering
renderAffectPane(snap);
setPersonaStrip(snap); // pad bars are the live signal
state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal
} else {
let code = "";
try { code = (await r.json()).error_code || ""; } catch (_) {}
@@ -794,6 +1030,100 @@ async function loadAffect(agentId) {
}
}
// ---- tools inventory (#183): what the LLM HAS at turn-fire (static), rendered
// at the TOP of the tools pane; live tool_start/result events append below it. ---
function renderToolsInventory(inv) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const names = (arr) => (arr || []).map((t) => (typeof t === "string" ? t : (t && t.name) || "?"));
const builtin = inv.builtin_tools || [], bifrost = inv.bifrost_tools || [];
const html =
head("tool inventory · " + (inv.agent_id || "?")) +
row("builtin (" + builtin.length + ")", names(builtin).join(", ") || "none") +
row("bifrost (" + bifrost.length + ")", names(bifrost).join(", ") || "none") +
`<div class="rule">— live tool events —</div>`;
const pane = $("pane-tools");
const empty = pane.querySelector(".empty");
if (empty) empty.remove();
let block = pane.querySelector(".tools-inventory");
if (!block) {
block = document.createElement("div");
block.className = "tools-inventory";
pane.insertBefore(block, pane.firstChild);
}
block.innerHTML = html;
}
async function loadSessionTools(sessionId) {
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/tools");
if (r.status === 200) renderToolsInventory(await r.json());
// non-200 → best-effort hydrate; leave the live tool pane as-is (mirrors TUI)
} catch (_) {}
}
// ---- Bifrost dispatch state (#176): admin-scoped, server-proxied (admin key
// stays server-side; the browser only receives the state). ----
function renderBifrostState(b) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const tools = b.tools || [];
$("pane-bifrost").innerHTML =
head("bifrost dispatch state") +
row("endpoint", b.endpoint_url || "?") +
row("consumer", b.consumer_id || "?") +
row("connected", JSON.stringify(b.connected)) +
row("caps", (b.capabilities_granted || []).join(", ") || "none") +
`<div> </div>` + head("tools (" + tools.length + ")") +
(tools.map((t) => row("·", (t.name || "?") + (t.description ? " — " + t.description : ""))).join("")
|| `<div class="empty">none</div>`);
}
async function loadBifrostState(sessionId) {
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/bifrost");
if (r.status === 200) { renderBifrostState(await r.json()); return; }
let code = ""; try { code = (await r.json()).error_code || ""; } catch (_) {}
let msg;
if (code === "admin_key_not_configured") msg = "bifrost state needs the readonly-admin key (RATATOSKR_ADMIN_API_KEY) server-side.";
else if (r.status === 404) msg = "session is not Bifrost-bound (no live dispatch client).";
else if (r.status === 403) msg = "admin key lacks the admin.sessions.read scope.";
else msg = `bifrost state unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
$("pane-bifrost").innerHTML = `<div class="empty">${msg}</div>`;
} catch (_) {
$("pane-bifrost").innerHTML = `<div class="empty">bifrost state fetch failed</div>`;
}
}
// ---- Admin lifecycle events (#11): admin-scoped SSE, session-filtered SERVER-side.
// One fixed "admin_event" listener renders every type; the dotted type is in data. ---
function openAdminEvents(sessionId) {
if (state.adminES) { state.adminES.close(); state.adminES = null; }
const es = new EventSource("/api/admin/events?session_id=" + encodeURIComponent(sessionId));
state.adminES = es;
es.addEventListener("admin_event", (e) => {
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
appendTo("pane-admin",
`<div>[${ts()}] <span style="color:var(--blue)">${esc(d.type || "event")}</span> `
+ `${esc(JSON.stringify(d.data || {}))}</div>`);
});
es.addEventListener("stream_error", (e) => {
let d = {}; try { d = JSON.parse(e.data); } catch (_) {}
appendTo("pane-admin", `<div class="rule">— admin stream ended: ${esc(d.exception || "error")} —</div>`);
// Server signalled the stream is over — close so native EventSource does NOT auto-reconnect
// into a retry loop (INV-LIFECYCLE).
es.close(); state.adminES = null;
});
es.onerror = () => {
// EventSource auto-reconnects on a transient drop (readyState CONNECTING) — leave that be,
// the admin stream is long-lived. Only tear down on a PERMANENT failure (CLOSED — e.g. a
// 400/403 where no reconnect is coming) so we don't leak a dead handle.
if (es.readyState === EventSource.CLOSED) { state.adminES = null; }
const pane = $("pane-admin");
if (pane.querySelector(".empty")) {
pane.innerHTML = `<div class="empty">admin stream unavailable — needs the readonly-admin key + admin.events.read scope.</div>`;
}
};
}
// ---- session lifecycle ----
async function startSession() {
const agentId = $("agent-picker").value;
@@ -837,6 +1167,11 @@ async function startSession() {
$("setup").style.display = "none";
$("workspace").classList.add("live");
await loadPersona(agentId);
// Admin/debug surfaces — best-effort hydrate + live stream (all self-render on
// failure; the admin key is server-held, never sent from here).
loadSessionTools(state.sessionId);
loadBifrostState(state.sessionId);
openAdminEvents(state.sessionId);
$("prompt-input").focus();
} catch (e) {
$("setup-err").textContent = "network error opening session";
@@ -943,11 +1278,13 @@ async function submitPrompt() {
es.addEventListener("thinking", (e) => {
const d = JSON.parse(e.data);
thinkingDeltas += 1;
showThinkingNote(); // ephemeral "<Agent> is pondering…" in the transcript
appendThinking(d.content);
});
es.addEventListener("text", (e) => {
const d = JSON.parse(e.data);
textDeltas += 1;
hideThinkingNote(); // real text begins — reasoning display is done
appendResponse(d.content);
});
es.addEventListener("text_boundary", (e) => {
@@ -990,6 +1327,7 @@ async function submitPrompt() {
function terminal(label, cls, e) {
const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove();
hideThinkingNote();
finalizeResponse();
document.querySelectorAll("#pane-thinking .think-live").forEach((b) => b.classList.remove("think-live"));
let meta = "";
@@ -1006,9 +1344,18 @@ async function submitPrompt() {
$("composer").classList.remove("streaming");
setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected");
if (cls === "done" && state.agentId) {
// Tier-3 affect.emit is POST-TURN ASYNC — it lands in our store a couple seconds
// after [done]. Refresh the pane on a short delay to catch the new PAD (issue #18).
setTimeout(() => loadPersona(state.agentId), 2000);
// Tier-3 affect.emit is POST-TURN ASYNC and can land well after [done] — a single
// fixed refresh races it (issue #18 foot-gun). Poll a short window, stopping once
// the snapshot's emitted_at advances past the pre-turn value (or a new turn starts).
const beforeAt = state.lastAffectAt;
let settled = false;
for (const delay of [1500, 3500, 6500, 10500]) {
setTimeout(async () => {
if (settled || state.turnId) return;
await loadPersona(state.agentId);
if (state.lastAffectAt && state.lastAffectAt !== beforeAt) settled = true;
}, delay);
}
}
$("prompt-input").focus();
}
@@ -1017,6 +1364,7 @@ async function submitPrompt() {
es.addEventListener("cancelled", (e) => terminal("cancelled", "cancelled", e));
es.onerror = () => {
audit("sse_connection_dropped turn_id=" + turn_id);
hideThinkingNote(); // a raw drop mid-reasoning must not leave the note + rotator running
es.close();
state.eventSource = null; state.turnId = null;
$("composer").classList.remove("streaming");
@@ -1053,7 +1401,7 @@ document.querySelectorAll(".tab").forEach((t) =>
// ---- keyboard ----
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && ["1", "2", "3", "4"].includes(e.key)) {
if (e.ctrlKey && ["1", "2", "3", "4", "5", "6"].includes(e.key)) {
const tabs = document.querySelectorAll(".tab");
const idx = parseInt(e.key, 10) - 1;
if (tabs[idx]) { activateTab(tabs[idx]); e.preventDefault(); }
@@ -1082,6 +1430,7 @@ $("prompt-input").addEventListener("keydown", (e) => {
loadAgents();
loadVersion();
loadPersonaCanon();
</script>
</body>
</html>
@@ -0,0 +1,178 @@
{
"_source": "vendored from Worldtree core/persona/canon/{d2-mood-render-canon-v1,d2-render-canon-v1}.json",
"_generated_by": "scripts/build_persona_canon.py (regen when .corviduo-canonicals.toml flags canon drift)",
"_render_path": "pure deterministic \u2014 no LLM; mirrors Worldtree describe_pad + render_d2_canonical byte-exact",
"mood_grid": {
"positive": {
"high_a": "positive and energized",
"mid_a": "positive",
"low_a": "positive and calm"
},
"neutral": {
"high_a": "alert",
"mid_a": "neutral",
"low_a": "quiet"
},
"negative": {
"high_a": "negative and agitated",
"mid_a": "negative",
"low_a": "negative and subdued"
}
},
"relation": {
"trust_cuts": [
[
"< 0.4",
"limited"
],
[
"[0.4, 0.6)",
"developing"
],
[
"[0.6, 0.8)",
"steady"
],
[
">= 0.8",
"strong"
]
],
"warmth_cuts": [
[
"<= -0.8",
"hostile"
],
[
"(-0.8, -0.6]",
"cold"
],
[
"(-0.6, -0.4]",
"distant"
],
[
"(-0.4, -0.2)",
"guarded"
],
[
"[-0.2, 0.2)",
"neutral"
],
[
"[0.2, 0.4)",
"reserved"
],
[
"[0.4, 0.6)",
"measured"
],
[
"[0.6, 0.8)",
"clear"
],
[
">= 0.8",
"deep"
]
],
"agency_cuts": [
[
"<= -0.8",
"submissive"
],
[
"(-0.8, -0.6]",
"deferential"
],
[
"(-0.6, -0.4]",
"yielding"
],
[
"(-0.4, -0.2)",
"modest"
],
[
"[-0.2, 0.2)",
"neutral"
],
[
"[0.2, 0.4)",
"light"
],
[
"[0.4, 0.6)",
"balanced"
],
[
"[0.6, 0.8)",
"substantial"
],
[
">= 0.8",
"commanding"
]
],
"warmth_phrase": {
"hostile": "strongly hostile regard",
"cold": "clearly cold regard",
"distant": "distant negative regard",
"guarded": "slightly guarded regard",
"neutral": "neutral warmth",
"reserved": "slightly reserved warmth",
"measured": "moderate measured warmth",
"clear": "clear warm regard",
"deep": "deep warm bond"
},
"warmth_beh": {
"hostile": "keep a firm emotional boundary",
"cold": "keep a firm emotional boundary",
"distant": "keep guarded distance",
"guarded": "keep guarded distance",
"neutral": "keep the tone even",
"reserved": "keep cordial distance",
"measured": "keep cordial distance",
"clear": "speak with direct warmth",
"deep": "speak with direct warmth"
},
"agency_phrase": {
"submissive": "strongly submissive standing",
"deferential": "clearly deferential standing",
"yielding": "yielding standing",
"modest": "slightly modest standing",
"neutral": "neutral standing",
"light": "lightly self-assertive standing",
"balanced": "self-assured standing",
"substantial": "strongly assertive standing",
"commanding": "commanding standing"
},
"agency_beh": {
"submissive": "avoid over-yielding while preserving basic respect",
"deferential": "avoid over-yielding while preserving basic respect",
"yielding": "keep self-advocacy light and deferential",
"modest": "keep self-advocacy light and deferential",
"neutral": "avoid unnecessary deference",
"light": "avoid unnecessary deference",
"balanced": "balance deference with independent judgment",
"substantial": "treat their position as weighty without yielding judgment",
"commanding": "treat their position as weighty without yielding judgment"
},
"history": {
"low": "a broad pattern of prior exchanges",
"high": "a broad pattern of prior exchanges"
},
"prefix": "Use this graded relationship state: toward target, warmth is ",
"tbeh": {
"low_trust": "verify important claims before relying on them",
"cold_warmth": "protect boundaries while staying useful",
"default": "work from ordinary good faith"
},
"cold_warmth_bands": [
"distant",
"cold",
"hostile"
],
"high_conf_floor": 0.55
}
}
+177
View File
@@ -1633,3 +1633,180 @@ class TestWhoami:
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 20
assert "[session_api_failed]" in capsys.readouterr().err
class TestTier2Probes:
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
def test_characters_standalone_accepted(self) -> None:
"""characters_standalone: --characters alone → valid."""
args = _parse_args(["--characters", "--api-key", "k"])
assert args.characters is True
assert args.session_id is None
def test_set_persona_requires_session(self) -> None:
"""set_persona_requires_session [adversarial]: --set-persona-pad needs --session."""
with pytest.raises(UsageError, match="requires --session"):
_parse_args(["--set-persona-pad", "0.4,0.1,-0.2", "--api-key", "k"])
def test_probes_mutually_exclusive(self) -> None:
"""probes_mutually_exclusive [adversarial]: --whoami + --characters → UsageError."""
with pytest.raises(UsageError, match="mutually exclusive"):
_parse_args(["--whoami", "--characters", "--api-key", "k"])
@respx.mock
def test_characters_probe_lifecycle(self, capsys: pytest.CaptureFixture[str]) -> None:
"""characters_probe [happy,tracer]: models → create → state → delete; report to stdout."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": [{"name": "fast"}]})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "char_z", "ttl_expires_at": "t"})
)
respx.get("https://w.example/characters/char_z/state").mock(
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.1, 0.2, 0.3]})
)
del_route = respx.delete("https://w.example/characters/char_z").mock(
return_value=httpx.Response(204)
)
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "character models: fast" in out
assert "created: char_z" in out
assert "pad=[0.1, 0.2, 0.3]" in out
assert "deleted: char_z" in out
assert del_route.call_count == 1 # lifecycle cleaned up
@respx.mock
def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
import json as _json
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(204)
)
rc = main(
["--set-persona-pad", "0.4,0.1,-0.2", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 0
assert "persona_state set" in capsys.readouterr().out
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]}
class TestSeedFirstMessageProbe:
"""--seed-first-message one-shot (#347 authored-history-write reference-consumer probe)."""
def test_seed_requires_agent(self) -> None:
"""seed_requires_agent [adversarial]: --seed-first-message needs --agent."""
with pytest.raises(UsageError, match="requires --agent"):
_parse_args(["--seed-first-message", "hello", "--api-key", "k"])
def test_seed_forbids_session(self) -> None:
"""seed_forbids_session [adversarial]: manages its own session — no --session."""
with pytest.raises(UsageError, match="manages its own session"):
_parse_args(
["--seed-first-message", "hi", "--agent", "m", "--session", "s1", "--api-key", "k"]
)
def test_seed_mutually_exclusive(self) -> None:
"""seed_mutually_exclusive [adversarial]: --seed-first-message + --whoami → UsageError."""
with pytest.raises(UsageError, match="mutually exclusive"):
_parse_args(["--seed-first-message", "hi", "--whoami", "--api-key", "k"])
def test_seed_empty_rejected(self) -> None:
"""seed_empty_rejected [adversarial]: empty content → UsageError."""
with pytest.raises(UsageError, match="non-empty"):
_parse_args(["--seed-first-message", "", "--agent", "m", "--api-key", "k"])
def test_seed_accepted(self) -> None:
"""seed_accepted [happy]: --seed-first-message + --agent → parses."""
args = _parse_args(["--seed-first-message", "hi", "--agent", "mimir", "--api-key", "k"])
assert args.seed_first_message == "hi"
assert args.agent_id == "mimir"
assert args.session_id is None and args.new is False
@respx.mock
def test_seed_probe_happy(self, capsys: pytest.CaptureFixture[str]) -> None:
"""seed_probe [happy,tracer]: create session → seed → read-back; report to stdout."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-07-06T12:00:00+00:00",
"last_active": "2026-07-06T12:00:00+00:00",
"metadata": {},
},
)
)
hist_route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(
201,
json={
"author": "assistant",
"content_chars": 5,
"injected_at": "2026-07-06T12:00:01+00:00",
"phase": "seeded",
"seq": 0,
"session_id": "s1",
"turn_id": "t1",
},
)
)
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200,
json={
"session_id": "s1",
"items": [{"seq": 0, "role": "assistant", "content": "hello"}],
"next_cursor": None,
},
)
)
rc = main(
["--seed-first-message", "hello", "--agent", "mimir",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 0
out = capsys.readouterr().out
assert "session: s1" in out
assert "seeded: seq=0 phase=seeded" in out
assert "read-back: 1 message" in out
assert "role=assistant" in out
assert hist_route.call_count == 1
@respx.mock
def test_seed_probe_feature_absent(self, capsys: pytest.CaptureFixture[str]) -> None:
"""feature_absent [error-path]: 404 hide-existence → benign report, exit 0, no read-back."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-07-06T12:00:00+00:00",
"last_active": "2026-07-06T12:00:00+00:00",
"metadata": {},
},
)
)
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
msgs_route = respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, json={"session_id": "s1", "items": [], "next_cursor": None}
)
)
rc = main(
["--seed-first-message", "hello", "--agent", "mimir",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 0
assert "feature-absent" in capsys.readouterr().out
assert msgs_route.call_count == 0 # never capability-probes past the 404
+272
View File
@@ -8,6 +8,7 @@ from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
@@ -16,15 +17,22 @@ from ratatoskr.sessions import (
PersonaNotConfigured,
SessionApiFailed,
SessionPage,
create_character,
create_session,
delete_character,
endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
list_character_models,
list_sessions,
set_persona_state,
write_authored_history,
)
@@ -1102,3 +1110,267 @@ class TestGetSessionBifrost:
with pytest.raises(AssertionError):
await get_session_bifrost(client, "s1", admin_key="")
assert route.call_count == 0
class TestTransientCharacters:
"""docs/contracts/issues/2.contract.md — transient-character wrappers (#161)."""
@respx.mock
async def test_list_models(self) -> None:
"""list_models [happy,tracer]: 200 → {items:[...]} verbatim."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
models = await list_character_models(client)
assert models["items"][0]["name"] == "fast"
@respx.mock
async def test_create_body_and_response(self) -> None:
"""create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}."""
import json as _json
route = respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
out = await create_character(client, {"schema_version": "1", "name": "H"})
assert out["character_id"] == "char_x"
body = _json.loads(route.calls[0].request.content)
assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None}
@respx.mock
async def test_get_state(self) -> None:
"""get_state [happy]: 200 → live PAD/emotions snapshot."""
respx.get("https://w.example/characters/char_x/state").mock(
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
state = await get_character_state(client, "char_x")
assert state["pad"] == [0.4, 0.1, -0.2]
@respx.mock
async def test_delete_204(self) -> None:
"""delete [happy]: 204 → None."""
respx.delete("https://w.example/characters/char_x").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await delete_character(client, "char_x") is None
@respx.mock
async def test_create_403_scope(self) -> None:
"""create_403 [error]: key lacks character.write → SessionApiFailed(403)."""
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_character(client, {"name": "H"})
assert exc.value.status == 403
class TestSetPersonaState:
"""#2 contract — set_persona_state (POST /sessions/{id}/persona_state)."""
@respx.mock
async def test_happy_204(self) -> None:
"""happy [happy,tracer]: freeform snapshot body; 204 → None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await set_persona_state(client, "s1", {"pad": [0.4, 0.1, -0.2]})
assert result is None
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]}
@respx.mock
async def test_non_204_raises(self) -> None:
"""non_204 [error]: 422 (bad snapshot shape) → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(422, json={"error_code": "validation_failed"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await set_persona_state(client, "s1", {"pad": [1, 2, 3]})
assert exc.value.status == 422
_AUTHORED_ACK = {
"author": "assistant",
"content_chars": 5,
"injected_at": "2026-07-06T12:00:00+00:00",
"phase": "seeded",
"seq": 0,
"session_id": "s1",
"turn_id": "t1",
}
class TestWriteAuthoredHistory:
"""write_authored_history — #347 POST /sessions/{id}/history (contract #2 amendment)."""
@respx.mock
async def test_happy_fresh_201(self) -> None:
"""happy_fresh_201 [happy,tracer]: 201 → ack verbatim; minimal body."""
import json as _json
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await write_authored_history(
client, "s1", content="hello", idempotency_key="k1"
)
assert result == _AUTHORED_ACK
assert _json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": "hello",
"idempotency_key": "k1",
}
@respx.mock
async def test_happy_replay_200(self) -> None:
"""happy_replay_200 [happy]: 200 replay (byte-identical body) → dict verbatim."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(200, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await write_authored_history(
client, "s1", content="hello", idempotency_key="k1"
)
assert result == _AUTHORED_ACK
@respx.mock
async def test_body_includes_effects(self) -> None:
"""body_includes_effects [trace]: effects + claimed_original_at appear iff non-None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await write_authored_history(
client,
"s1",
content="hi",
idempotency_key="k1",
effects="none",
claimed_original_at="2020-01-01T00:00:00Z",
)
assert _json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": "hi",
"idempotency_key": "k1",
"effects": "none",
"claimed_original_at": "2020-01-01T00:00:00Z",
}
@respx.mock
async def test_hide_existence_404(self) -> None:
"""hide_existence_404 [error]: 404 → AuthoredHistoryUnavailable (NOT SessionApiFailed)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AuthoredHistoryUnavailable) as exc:
await write_authored_history(client, "s1", content="hi", idempotency_key="k1")
assert exc.value.session_id == "s1"
@respx.mock
async def test_generation_active_409(self) -> None:
"""generation_active_409 [error]: 409 → SessionApiFailed(409)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(409, json={"error_code": "generation_active"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await write_authored_history(client, "s1", content="hi", idempotency_key="k1")
assert exc.value.status == 409
@respx.mock
async def test_content_too_long_422(self) -> None:
"""content_too_long_422 [error]: 422 → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(422, json={"error_code": "content_too_long"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await write_authored_history(client, "s1", content="x", idempotency_key="k1")
assert exc.value.status == 422
@respx.mock
async def test_empty_content(self) -> None:
"""empty_content [adversarial]: content="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "s1", content="", idempotency_key="k1")
assert not route.called
@respx.mock
async def test_empty_idempotency_key(self) -> None:
"""empty_idempotency_key [adversarial]: key="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "s1", content="hi", idempotency_key="")
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: session_id="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "", content="hi", idempotency_key="k1")
assert not route.called
class TestGetSessionMessages:
"""#2 contract (amendment 2026-07-06) — get_session_messages (GET /sessions/{id}/messages)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy,tracer]: 200 {session_id, items, next_cursor} → dict verbatim."""
payload = {
"session_id": "s1",
"items": [{"seq": 0, "role": "assistant", "content": "hello there"}],
"next_cursor": None,
}
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(200, json=payload)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await get_session_messages(client, "s1")
assert result == payload
@respx.mock
async def test_not_found_404(self) -> None:
"""not_found_404 [error]: 404 → SessionApiFailed(404)."""
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_messages(client, "s1")
assert exc.value.status == 404
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_messages(client, "")
assert not route.called
+72
View File
@@ -5,6 +5,7 @@ import pytest
import respx
from ratatoskr.sse_client import (
AdminEvent,
AffectUpdate,
AgentNotAvailable,
AwaitingLlmFirstToken,
@@ -27,6 +28,7 @@ from ratatoskr.sse_client import (
_parse_sse_id,
cancel_turn,
reconnect_turn,
stream_admin_events,
stream_turn,
stream_turn_resilient,
)
@@ -1261,3 +1263,73 @@ class TestStreamTurnResilient:
collected.append(e)
assert [e.sse_id for e in collected] == [SseId(42, 1)] # type: ignore[attr-defined]
assert route.call_count == 2
class TestStreamAdminEvents:
"""docs/conversation-api-spec.md § Admin Event Stream — stream_admin_events (#11)."""
@respx.mock
async def test_happy_multi_event_admin_bearer(self) -> None:
"""happy [happy,tracer]: yields AdminEvent envelopes; request uses the ADMIN bearer."""
env1 = {
"id": 41, "type": "session.created", "timestamp": "2026-05-06T10:00:00.000Z",
"data": {"session_id": "s1", "agent_id": "mimir", "user_id": None},
}
env2 = {
"id": 42, "type": "turn.started", "timestamp": "2026-05-06T10:00:01.000Z",
"data": {"session_id": "s1", "turn_id": 7, "agent_id": "mimir", "user_id": None},
}
stream = _sse_chunk("41", env1) + _sse_chunk("42", env2)
route = respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(
base_url="https://w.example", headers={"Authorization": "Bearer consumer"}
) as client:
events = [e async for e in stream_admin_events(client, admin_key="admin-xyz")]
assert [e.type for e in events] == ["session.created", "turn.started"]
assert isinstance(events[0], AdminEvent)
assert events[0].id == 41
assert events[1].data["turn_id"] == 7
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
@respx.mock
async def test_last_event_id_header(self) -> None:
"""last_event_id_header [trace]: empty stream → []; Last-Event-ID header sent."""
route = respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=b""
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_admin_events(client, admin_key="k", last_event_id=99)]
assert events == []
assert route.calls[0].request.headers["Last-Event-ID"] == "99"
@respx.mock
async def test_403_scope_denied(self) -> None:
"""403 [error]: key lacks admin.events.read → SseConnectFailed(403)."""
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SseConnectFailed) as exc:
_ = [e async for e in stream_admin_events(client, admin_key="k")]
assert exc.value.status == 403
@respx.mock
async def test_skips_malformed_frame(self) -> None:
"""skips_malformed [adversarial]: a bad-JSON frame is skipped, not fatal."""
good = _sse_chunk("41", {"id": 41, "type": "session.created", "data": {"session_id": "s1"}})
bad = b"id: 42\ndata: not-json\n\n"
good2 = _sse_chunk("43", {"id": 43, "type": "session.deleted", "data": {"session_id": "s1"}})
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=good + bad + good2
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_admin_events(client, admin_key="k")]
assert [e.type for e in events] == ["session.created", "session.deleted"]
+107
View File
@@ -3330,3 +3330,110 @@ class TestBifrostStateHydration:
joined = " ".join(_text_of(w) for w in writes)
assert "not bound to Bifrost" in joined
assert "bifrost_state_unavailable" in joined
class TestAdminEventsStream:
"""stream_admin_events + the #11 AdminEvents pane (GET /admin/events, session-filtered)."""
@staticmethod
def _mute_hydrates(monkeypatch: pytest.MonkeyPatch) -> None:
"""Neutralize the other on_mount workers (tools + bifrost) — no real calls."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionApiFailed
async def noop_tools(client, session_id):
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
async def noop_bifrost(client, session_id, *, admin_key):
raise SessionApiFailed(status=404, body=b"nb")
monkeypatch.setattr(tui_mod, "get_session_tools", noop_tools)
monkeypatch.setattr(tui_mod, "get_session_bifrost", noop_bifrost)
def test_format_admin_event(self) -> None:
"""format_admin_event [unit]: HH:MM:SS + type + fields; session_id dropped."""
from ratatoskr.sse_client import AdminEvent
from ratatoskr.tui import _format_admin_event
line = _format_admin_event(
AdminEvent(
42, "turn.completed", "2026-05-06T10:00:05.000Z",
{"session_id": "s1", "turn_id": 7, "duration_ms": 1200, "phase": "succeeded"},
)
)
assert "turn.completed" in line
assert "[10:00:05]" in line
assert "turn_id=7" in line
assert "session_id" not in line # dropped — pane is already session-scoped
def test_admin_event_matches_filter(self) -> None:
"""admin_event_matches [unit]: active-session + non-heartbeat system.* pass (§6)."""
from ratatoskr.sse_client import AdminEvent
E = AdminEvent
app = _resolved_app(_args_existing(session_id="s-match"))
assert app._admin_event_matches(E(1, "session.created", "t", {"session_id": "s-match"}))
assert not app._admin_event_matches(E(2, "turn.started", "t", {"session_id": "other"}))
assert not app._admin_event_matches(E(0, "system.heartbeat", "t", {}))
assert app._admin_event_matches(E(3, "system.events_dropped", "t", {"count": 5}))
async def test_stream_writes_filtered_events(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""stream_filtered [scenario,tracer]: only active-session + non-heartbeat lines land."""
import ratatoskr.tui as tui_mod
from ratatoskr.sse_client import AdminEvent
self._mute_hydrates(monkeypatch)
writes = _spy_writes(monkeypatch)
async def fake_stream(client, *, admin_key, last_event_id=None):
yield AdminEvent(41, "session.created", "t", {"session_id": "s-ae-2"})
yield AdminEvent(0, "system.heartbeat", "t", {}) # filtered (noise)
yield AdminEvent(42, "turn.started", "t", {"session_id": "other"}) # diff session
yield AdminEvent(43, "session.deleted", "t", {"session_id": "s-ae-2"})
monkeypatch.setattr(tui_mod, "stream_admin_events", fake_stream)
app = _resolved_app(_args_existing(session_id="s-ae-2", admin_key="ak"))
async with app.run_test() as pilot:
await pilot.pause()
await app._stream_admin_events()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "session.created" in joined
assert "session.deleted" in joined
assert "system.heartbeat" not in joined
assert "turn.started" not in joined # different session → filtered
async def test_stream_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""stream_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
self._mute_hydrates(monkeypatch)
writes = _spy_writes(monkeypatch)
app = _resolved_app(_args_existing(session_id="s-ae-3")) # admin_key None
async with app.run_test() as pilot:
await pilot.pause()
await app._stream_admin_events()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "admin key not configured" in joined
assert "admin_events_skipped" in joined
async def test_stream_403_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""stream_403 [error]: 403 scope-denied → 'unavailable' + audit; no crash."""
import ratatoskr.tui as tui_mod
from ratatoskr.sse_client import SseConnectFailed
self._mute_hydrates(monkeypatch)
writes = _spy_writes(monkeypatch)
async def denied(client, *, admin_key, last_event_id=None):
raise SseConnectFailed(status=403, body=b"auth_scope_denied")
yield # unreachable — makes this an async generator
monkeypatch.setattr(tui_mod, "stream_admin_events", denied)
app = _resolved_app(_args_existing(session_id="s-ae-4", admin_key="ak"))
async with app.run_test() as pilot:
await pilot.pause()
await app._stream_admin_events()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "admin events unavailable: HTTP 403" in joined
assert "admin_events_unavailable" in joined
+142 -1
View File
@@ -625,6 +625,10 @@ class TestCreateAppShape:
"/", "/version", "/api/agents", "/api/sessions",
"/api/agents/{agent_id}/persona_state",
"/api/affect/{agent_id}",
# v0.19.2 debug-surface parity (create_app POST-002)
"/api/sessions/{session_id}/tools",
"/api/sessions/{session_id}/bifrost",
"/api/admin/events",
"/api/turns/{session_id}", "/api/turns/{session_id}/stream",
"/api/turns/{session_id}/cancel",
):
@@ -633,10 +637,14 @@ class TestCreateAppShape:
assert "/static" in paths
def test_state_attached(self) -> None:
"""state_attached [trace]: app.state.turn_registry is empty dict."""
"""state_attached [trace]: app.state.turn_registry is empty dict; admin_key stored."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
assert app.state.turn_registry == {}
# create_app POST-001: admin_key defaults None (admin routes fail-visible)
assert app.state.admin_key is None
app2 = create_app(_mock_client_factory(), admin_key="adm-key")
assert app2.state.admin_key == "adm-key"
def test_factory_stored(self) -> None:
"""factory_stored [trace]: app.state.client_factory is the same callable."""
@@ -1062,3 +1070,136 @@ class TestAffectStateEndpoint:
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 400
assert resp.json()["error_code"] == "missing_end_user_id"
class TestSessionToolsEndpoint:
"""session_tools_endpoint — proxy owner-scoped GET /sessions/{id}/tools (#183)."""
@respx.mock
def test_happy_returns_inventory(self) -> None:
"""happy [tracer]: 200 inventory → 200 verbatim."""
respx.get("https://w.example/sessions/s-1/tools").mock(
return_value=httpx.Response(200, json={
"agent_id": "ratatoskr:sindra",
"builtin_tools": ["echo"],
"bifrost_tools": [{"name": "memory.search"}],
})
)
from ratatoskr.web.server import create_app
resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
assert resp.status_code == 200
assert resp.json()["agent_id"] == "ratatoskr:sindra"
@respx.mock
def test_upstream_404_status_preserving_envelope(self) -> None:
"""error: upstream 404 → 404 session_tools_unavailable envelope."""
respx.get("https://w.example/sessions/s-1/tools").mock(
return_value=httpx.Response(404, content=b"nope")
)
from ratatoskr.web.server import create_app
resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
assert resp.status_code == 404
assert resp.json()["error_code"] == "session_tools_unavailable"
class TestSessionBifrostEndpoint:
"""session_bifrost_endpoint — proxy admin-scoped GET /admin/sessions/{id}/bifrost (#176)."""
@respx.mock
def test_happy_overrides_with_admin_bearer(self) -> None:
"""happy [tracer]: 200 state → 200; request carries the ADMIN bearer, not consumer."""
route = respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
return_value=httpx.Response(200, json={
"endpoint_url": "http://x:8392", "connected": True,
"capabilities_granted": ["memory", "affect"], "tools": [],
})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 200
assert resp.json()["connected"] is True
assert route.calls.last.request.headers["Authorization"] == "Bearer adm-key"
def test_no_admin_key_fails_visible_400(self) -> None:
"""error: no admin key configured → 400 admin_key_not_configured, no upstream call."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory()) # no admin_key
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 400
assert resp.json()["error_code"] == "admin_key_not_configured"
@respx.mock
def test_upstream_404_status_preserving_envelope(self) -> None:
"""error: upstream 404 (not bound) → 404 bifrost_state_unavailable envelope."""
respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
return_value=httpx.Response(404, content=b"nope")
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 404
assert resp.json()["error_code"] == "bifrost_state_unavailable"
class TestAdminEventsEndpoint:
"""admin_events_endpoint — SSE proxy of GET /admin/events, session-filtered (#11)."""
def test_filter_semantics(self) -> None:
"""unit: heartbeats drop, system.* pass, else match on session_id."""
from ratatoskr.sse_client import AdminEvent
from ratatoskr.web.server import _admin_event_matches_web
def mk(t: str, sid: "str | None" = None) -> AdminEvent:
return AdminEvent(id=1, type=t, timestamp=None,
data={"session_id": sid} if sid else {})
assert _admin_event_matches_web(mk("system.heartbeat"), "s-1") is False
assert _admin_event_matches_web(mk("system.degraded"), "s-1") is True
assert _admin_event_matches_web(mk("session.created", "s-1"), "s-1") is True
assert _admin_event_matches_web(mk("session.created", "other"), "s-1") is False
assert _admin_event_matches_web(mk("session.created", "s-1"), None) is False
def test_no_admin_key_fails_visible_400(self) -> None:
"""error: no admin key → 400 admin_key_not_configured (no stream opened)."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/admin/events?session_id=s-1")
assert resp.status_code == 400
assert resp.json()["error_code"] == "admin_key_not_configured"
@respx.mock
def test_streams_filtered_events_fixed_name(self) -> None:
"""happy: SSE → only session-matching + system.* forwarded, as `admin_event`."""
stream = (
b'event: session.created\n'
b'data: {"type":"session.created","data":{"session_id":"s-1"}}\n\n'
b'event: system.heartbeat\n'
b'data: {"type":"system.heartbeat","data":{}}\n\n'
b'event: turn.started\n'
b'data: {"type":"turn.started","data":{"session_id":"other"}}\n\n'
b'event: system.degraded\n'
b'data: {"type":"system.degraded","data":{}}\n\n'
)
respx.get("https://w.example/admin/events").mock(return_value=_sse_resp(stream))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
body = TestClient(app).get("/api/admin/events?session_id=s-1").text
assert "event: admin_event" in body # fixed browser-facing name
assert '"type": "session.created"' in body # matches active session → forwarded
assert "system.degraded" in body # system.* → forwarded
assert "system.heartbeat" not in body # heartbeat → dropped
assert "turn.started" not in body # other session → dropped
@respx.mock
def test_stream_error_on_connect_failure(self) -> None:
"""error: upstream admin SSE non-200 -> ONE stream_error frame, stream ends (POST-003)."""
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(500, content=b"boom")
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
body = TestClient(app).get("/api/admin/events?session_id=s-1").text
assert "event: stream_error" in body
assert "SseConnectFailed" in body
assert body.count("event: stream_error") == 1 # exactly one, then ends
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.18.10"
version = "0.19.6"
source = { editable = "." }
dependencies = [
{ name = "httpx" },