Compare commits

..

18 Commits

Author SHA1 Message Date
vh 0441e319f6 feat(web): auto-scale PAD gauges for R32-1B unbounded-z (v0.20.9)
Sindra "full and unbounded": relax the debug affect console's PAD display
from a hard [-1,1] clamp to auto-scaling on the session's own max |PAD|
(padScale floor 1.0 → padFillFrac faders + _padNorm orbit). An unbounded-z
PAD (Worldtree R32-1B, ~±10) now renders at full range and never pegs or
escapes the frame; today's [-1,1] values are unchanged (scale==1); the
exact value is always shown numerically (unclamped).

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

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

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

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

Contract web_debug_surface.contract.md amended in-commit (create_app
memory_read_url; dimetric-orbit + Δ-bar renderConsole POST-002; memory-viewer
+ markdown-pass-2 function contracts). 631 tests green; Playwright-verified
all render paths (dark+light).
2026-07-07 02:11:29 -07:00
vh 24644ab90e memory: snapshot — queue web-UI iteration-3 (design re-import) + memory viewer + markdown pass-2 for fresh context 2026-07-07 01:28:04 -07:00
vh c988f273b1 feat(web): markdown RP semantic coloring + paragraph reflow (v0.20.6)
Markdown rework, pass 1 (operator-directed):

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

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

Verified: pytest tests/test_web_* (84) + node Playwright (all 6 cells titled).
2026-07-07 00:37:36 -07:00
vh fd6bed2d11 chore(design): vendor the transparent squirrel brand mark
The bg-removed source (ImageMagick corner floodfill) the inlined web favicon +
brand marks derive from. 1024², alpha-transparent; downscale for any reuse.
2026-07-07 00:37:36 -07:00
vh 459e7fa602 feat(web): SVG sparklines + 3D isometric mood cube (v0.20.4)
Import the updated Claude Design prototype's SVG sparkline system + 3D graph,
replacing the unicode-char sparklines:

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

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

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

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

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

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

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

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

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

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

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

web_debug_surface contract amended (endpoint + loadTranscript). 2 web route
tests, suite 617 green. Playwright DOM check proved the render end-to-end
(drive the real UI -> Sindra's greeting bubble appears).
2026-07-06 15:19:56 -07:00
vh be171304f5 feat: authored first-message presets — auto-seed on session-create (v0.19.8)
Codifies 'give an agent a first message' (Worldtree #347): new module
ratatoskr.first_message (FIRST_MESSAGE_PRESETS + seed_preset_first_message)
seeds a preset agent's opening as a #347 authored turn-0 on every new session,
wired into all three create paths — cli._amain (--send --new), tui._resolve_then_run
(bare --new), web._create_session_endpoint (POST /api/sessions).

seed_preset_first_message is strictly best-effort (INV-001): it soft-guards its
inputs (return None, never assert), bounds the write with asyncio.wait_for so a
stalled /history can't block create (the CLI/TUI clients disable read timeout for
SSE), and swallows every exception except asyncio.CancelledError (which
propagates) — so it can NEVER raise into or block the session-create path it is
wired into. Per-content idempotency key → idempotent replay, no dup.

Seeded with ratatoskr:sindra, whose opening greeting moved out of her card:
her live system_prompt was PATCHed (non-destructive) to drop the Startup
workaround the #347 first-message now replaces.

Quality gate (both cross-frontier panels): heid-code-review returned zero
implementation drift (2 test-only fixups applied); heid-bug-hunt caught the
gap the conformance lens can't see — code matched the contract's narrow
ERROR_ROUTING but INV-001's 'never raises' is broader — driving the broad-except
+ soft-guard + wait_for hardening above.

Contract docs/contracts/first_message.contract.md (module-scoped, validated).
TDD: 12 unit + 1 web wire-in; the 3 existing sindra bind tests gained a
history-endpoint mock (creating a preset agent now auto-seeds). Suite 615 green,
ruff+mypy clean. Auto-seed live-proven generation-free against personal :8081.
2026-07-06 14:23:53 -07:00
vh 4aec3061d5 docs: #347 consumer side live-proven (201 path) on personal :8081
Grant applied (rule-based Heimdall allow, worldtree-dev). Smoke: create
mimir session -> seed -> 201 (seq=0, phase=seeded, turn_id=1798) -> GET
/messages reads back a plain role=assistant turn (model-invisible provenance
confirmed). Coverage-map #347 row + persistent-memory upgraded pending -> live-proven.
2026-07-06 13:01:57 -07:00
vh e643d38f58 fix: persona_state SET body → canonical {pad:{pleasure,arousal,dominance}} + re-vendor Tier-3 prose (v0.19.7)
worldtree-dev landed the Tier-3 persona/memory/persona_state prose docs
(c9e59ec) — shapes that serialize as freeform Any in the OpenAPI, so the
prose markdown is their source of truth. Re-vendored docs/conversation-api-spec.md
(tolerate_drift markdown pin; worldtree-spec-rev 879cefe→c9e59ec).

Consumer alignment: --set-persona-pad / _set_persona_probe was building
{pad:[list]}, but the canonical POST /sessions/{id}/persona_state body (#317)
is {pad:{pleasure,arousal,dominance}} (named dict). Aligned the probe to the
named dict + a len!=3 guard; updated contract #2's note, the set_persona_state
docstring, and the tests. The set_persona_state wrapper was already correct
(freeform pass-through) — only the CLI probe's body construction drifted.

Suite 602 green. (Also this session: heid-code-review on the #347 slice
returned unanimous zero drift across all three panel arms.)
2026-07-06 09:56:46 -07:00
32 changed files with 3252 additions and 1068 deletions
+17 -2
View File
@@ -120,8 +120,8 @@ id = "worldtree-conversation-api-spec-v1"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-spec.md"
consumer_path = "docs/conversation-api-spec.md"
pinned_sha256_16 = "2d111a3b8322b7d1"
pinned_at = "2026-06-30T22:25:56+00:00"
pinned_sha256_16 = "c656a789caceef14"
pinned_at = "2026-07-06T16:51:09+00:00"
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
@@ -143,3 +143,18 @@ 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"
# Worldtree affect-egress consumer reference — the authoritative DELIVERED-on-wire vs
# HIDDEN (system-prompt-only) classification for the Tier-3 affect surface ratatoskr
# consumes, + the reconstruction rules. The web console's "context injection" panel
# reconstructs the hidden strings from this + the d2 canons. tolerate_drift: prose
# reference (the render-canon JSONs are the strict gates). worldtree-dev co-signs +
# pings ratatoskr-dev on any change (esp. the pending we-framing conditional).
[[pins]]
id = "worldtree-affect-egress-consumer-reference-v1"
canonical_source = "Worldtree"
canonical_path = "docs/affect-egress-consumer-reference.md"
consumer_path = "docs/vendor/worldtree-persona-canon/affect-egress-consumer-reference.md"
pinned_sha256_16 = "d959134037efae83"
pinned_at = "2026-07-07T06:09:24+00:00"
tolerate_drift = true # prose reference; the d2 render-canon JSONs are the gates
+4
View File
@@ -121,3 +121,7 @@ graphify-out/*
*.db
*.db-shm
*.db-wal
# Node deps (Playwright for web-UI DOM verification — see persistent-memory)
node_modules/
package-lock.json
+5 -4
View File
@@ -7,16 +7,17 @@ documents the pin, the vendored artifacts, and the bump procedure.
| Field | Value |
|---|---|
| Worldtree git SHA | `879cefef02bbf442c2d01ff4214299fac7c89809` |
| Worldtree HEAD message | `fix(#347): freeze the authored-history route into the OpenAPI schema (spec 2.3.0)` |
| Worldtree git SHA | `c9e59ec` |
| Worldtree HEAD message | `docs: document Tier-3 persona/memory schemas + persona_state SET body (OpenAPI 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) |
| Pinned by | ratatoskr-dev (re-vendor prose markdown — Tier-3 persona/memory/persona_state consumer shapes) |
| Worldtree version at pin | `v1.0.0b22` |
## Pin history
| Date | SHA | Version | Notable deltas consumed |
|---|---|---|---|
| 2026-07-06 | `c9e59ec` | v1.0.0b22 | **Re-vendor the prose markdown — Tier-3 consumer shapes documented.** `c9e59ec` (docs-only, OpenAPI byte-unchanged vs `879cefe`) adds `docs/conversation-api-spec.md` § "Tier 3 — Consumer-defined agents": the persona / memory / persona_state SET-body shapes that serialize as freeform `Any` in the OpenAPI (so prose is their source of truth). Drove a consumer fix: `--set-persona-pad` now sends the canonical `{pad:{pleasure,arousal,dominance}}` named dict (was `{pad:[list]}`) — #317, `v0.19.7`. Foot-guns encoded: persona.ocean single-letter `{O,C,E,A,N}` on `/agents/define` (spelled-out → 422, the #348 mismatch) vs spelled-out on `POST /characters`; memory `{embedder_version, tier3_dreaming}`, stm_* deprecated, allows_world_scope removed→422; only `valence` still 422s. `pin:`-only for the markdown; the `v0.19.7` bump rode the persona_state code fix. |
| 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. |
@@ -32,7 +33,7 @@ documents the pin, the vendored artifacts, and the bump procedure.
**Reference (prose; allowed to lag — `tolerate_drift`):**
- `docs/conversation-api-spec.md` — copy of `Worldtree/docs/conversation-api-spec.md` at the pinned SHA. The **client-facing prose narrative**. Byte-frozen at v0.35.16-era content (last WT edit 2026-05-31); the OpenAPI/SSE JSON above are the source of truth where they diverge. Pinned `worldtree-conversation-api-spec-v1` (tolerate_drift).
- `docs/conversation-api-spec.md` — copy of `Worldtree/docs/conversation-api-spec.md` at the pinned SHA. The **client-facing prose narrative**. Re-vendored at `c9e59ec` (2026-07-06) to carry the § "Tier 3 — Consumer-defined agents" subsections (persona/memory/persona_state SET body) that serialize as freeform `Any` in the OpenAPI JSON — so the **prose is the source of truth for those consumer shapes** (e.g. persona.ocean single-letter `{O,C,E,A,N}` on `/agents/define`; `POST /sessions/{id}/persona_state` body `{pad:{pleasure,arousal,dominance}}`). Elsewhere the OpenAPI/SSE JSON above remain authoritative. Pinned `worldtree-conversation-api-spec-v1` (tolerate_drift).
- `docs/conversation_api.contract.md` — copy of `Worldtree/docs/contracts/conversation_api.contract.md` at the pinned SHA (byte-identical at b2 — server contract unchanged since the v0.35.16 pin). The **server-side contract** including INV-001..INV-052 and amendments. Useful for understanding load-bearing server invariants (e.g., INV-014 turn-id-public, INV-046 admin-events-envelope-stable, INV-049 admin-events-pii-discipline) when designing client behavior against them. Not in the canonical manifest (reference-only).
Both files are vendored — they reflect Worldtree at the pinned SHA, not
+142
View File
@@ -0,0 +1,142 @@
---
contract_version: "2.1"
module: "ratatoskr.first_message"
purpose: "Per-agent authored first-message presets — seed an agent's opening as a #347 authored turn-0 onto new sessions (CLI + web), the durable replacement for a system-prompt startup instruction."
touches:
- src/ratatoskr/first_message.py
- tests/test_first_message.py
scope: >
Per-agent authored first-message presets (Worldtree #347 consumer feature).
When a new session is created for an agent that has a preset opening, seed it
as a #347 authored first-message (POST /sessions/{id}/history, author=assistant,
seq-0) so the session opens in-character before the user speaks — the durable
replacement for a system-prompt "startup" instruction. Two entry points:
`preset_for` (lookup) and `seed_preset_first_message` (best-effort seed).
Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server
(the POST /api/sessions endpoint). Depends on ratatoskr.sessions
(write_authored_history + its exceptions); no core.* / worldtree.* imports.
depends_on:
- "httpx"
- "ratatoskr.sessions"
used_by:
- "ratatoskr.cli"
- "ratatoskr.web.server"
language: "python"
complexity: "low"
estimated_loc: 60
confidence: 0.9
assumptions:
- "write_authored_history (contract #2 amendment 2026-07-06) is the seed primitive: 200/201 → ack dict, 404 → AuthoredHistoryUnavailable (hide-existence), other non-2xx → SessionApiFailed."
- "The preset registry is a static in-module dict keyed by agent_id; editing it is how an operator tunes an agent's opening. Seeded with ratatoskr:sindra only."
- "Auto-seed is BEST-EFFORT and MUST NOT block session creation: an instance without the session.history.write grant returns the hide-404, which is swallowed (session opens with no seeded greeting)."
---
# First-message presets — authored openings on session-create (#347)
## Context
`ratatoskr.first_message` holds per-agent authored-opening presets and seeds them
onto new sessions via the #347 authored-history-write primitive. It is the
durable form of "give an agent a first message": instead of a system-prompt
`Startup:` instruction (a workaround for the pre-#347 world where the assistant
could not author turn-0), the opening lives as a real seeded assistant turn-0.
Consumed at both session-create sites — `ratatoskr.cli._amain` (the `--new` path)
and `ratatoskr.web.server._create_session_endpoint` (POST /api/sessions) — so
every new session for a preset agent opens in-character regardless of surface.
## Data flow
**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh
`session_id`, and the bound `agent_id`.
**Out:** on a preset agent, one `POST /sessions/{session_id}/history` (author=assistant,
the preset text, per-content idempotency key). Returns the seeded content on
success, else `None`.
**Side effects:** at most one outbound authored-history write; never raises to the
caller (best-effort).
## Invariants
- **INV-001 [hard]**: `seed_preset_first_message` NEVER raises (the sole exception is
`asyncio.CancelledError`, which propagates — cancellation is not a seed failure) and
NEVER blocks session creation. It soft-guards its inputs (a bad arg returns `None`,
not `AssertionError`), bounds the write with `asyncio.wait_for(_SEED_TIMEOUT_S)` so a
stalled `/history` can't hang the create path, and swallows EVERY other exception (the
hide-404, `SessionApiFailed`, `httpx.HTTPError`, `TimeoutError`, and any unexpected
error) → `None`. The `broad-except` is deliberate: this helper is wired INTO three
session-create paths, so any escape would abort a create that already succeeded.
- **INV-002 [hard]**: a no-preset agent issues ZERO HTTP (early return before any
request).
- **INV-003 [hard]**: the seed body is the preset text verbatim, author="assistant",
with a per-content idempotency key (`"ratatoskr-preset-" + sha256(text)[:12]`), so
a repeat seed of the same session+preset is an idempotent 200 replay, never a
duplicate turn.
- **INV-004 [hard]**: no `core.*` / `worldtree.*` imports (reference-consumer
boundary; verified by `tests/test_no_worldtree_imports.py`, which rglobs every
`.py` under `src/ratatoskr/` — this module included, so no per-module import
test is needed here).
## Out of scope
- **Multi-turn / scripted openers.** v1 seeds exactly one assistant turn-0. A
multi-message opening scene is a future concern.
- **Runtime/remote preset config.** The registry is an in-module dict; no file/DB/env
loading. Add that only when a second consumer needs operator-editable presets.
- **Non-assistant authors.** v1 is author=assistant only (matches #347 v1); a
user/system opener is deferred with the #347 engine surface.
- **TUI-only surfaces.** Both real session-create paths (CLI + web) are wired; the
bare-TUI picker resumes existing sessions (no create), so it needs no seed.
---
```contract
FN preset_for(agent_id: str) -> str | None
BRIEF: Return the authored first-message preset for agent_id, or None when the agent has no preset. Pure dict lookup over FIRST_MESSAGE_PRESETS.
PRE: [PRE-001 hard] agent_id is a non-empty str -- assert agent_id and isinstance(agent_id, str)
POST: [POST-001 return_value] returns FIRST_MESSAGE_PRESETS.get(agent_id) (str for a preset agent, None otherwise)
STEPS:
1. [setup, prescriptive] assert PRE-001
2. [sequential, prescriptive] RETURN FIRST_MESSAGE_PRESETS.get(agent_id)
TESTS:
preset_hit [happy]: preset_for("ratatoskr:sindra") is a non-empty str
preset_miss [happy]: preset_for("mimir") is None
empty_agent_id [adversarial]: preset_for("") → AssertionError
FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None
BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, POST it via write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into three create paths.
PRE: [PRE-001 hard] client is not None -- soft-guarded: return None (NOT assert) if violated, so a wiring bug can't crash the create path (INV-001)
PRE: [PRE-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated
PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id)
POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None
POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO HTTP; a preset agent issues exactly one POST /sessions/{session_id}/history with body author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block
ERROR_ROUTING:
asyncio.CancelledError:
local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway)
flow_control: propagate
state_recovery: n/a
any other Exception (hide-404 AuthoredHistoryUnavailable, SessionApiFailed 409/422/etc., httpx.HTTPError, TimeoutError from wait_for, any unexpected error):
local_handling: swallow; return None
flow_control: continue (never blocks session create)
state_recovery: session opens with no seeded greeting
STEPS:
1. [setup, prescriptive] Soft-guard: IF agent_id is not a non-empty str: RETURN None (before any dict lookup — guards a non-hashable id)
2. [sequential, prescriptive] content = FIRST_MESSAGE_PRESETS.get(agent_id); IF content is None: RETURN None (INV-002 — zero HTTP)
3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None
4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12]
5. [sequential, prescriptive] TRY: await asyncio.wait_for(write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S)
tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None
7. [cleanup, prescriptive] RETURN content
TESTS:
seeds_preset [happy,tracer]: preset agent, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
no_preset_zero_http [happy]: agent "mimir" → returns None; NO HTTP issued
feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise
session_api_failed_swallowed [error]: preset agent, mock 409 → returns None, no raise
transport_error_swallowed [error]: preset agent, mock httpx.ConnectError → returns None, no raise
unexpected_exception_swallowed [error]: preset agent, write raises ValueError → returns None, no raise (INV-001 broad never-raise)
cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed)
malformed_agent_id_no_http [adversarial]: agent_id=123 (non-str) OR "" → None; NO HTTP; no raise
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); NO HTTP; no raise
```
+4 -2
View File
@@ -322,7 +322,9 @@ via a `--characters` one-shot lifecycle probe; persona-state write surfaced via
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:[…]}`).
so the caller supplies the snapshot shape. **Canonical (worldtree-dev prose #317,
`c9e59ec`): `{pad:{pleasure,arousal,dominance}}` — a named-key dict, NOT a list;
`--set-persona-pad` builds + sends the named dict (each float in [-1,1]).**
```contract
FN list_character_models(client) -> dict[str, Any]
@@ -369,7 +371,7 @@ POST: [POST-001 return_value] on 204 returns None; [POST-002 side_effect] outbou
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
happy [happy]: 204 → None; body == {"pad":{"pleasure","arousal","dominance"}} verbatim (canonical named-key dict, #317)
non_204 [error]: 422 → SessionApiFailed(422)
```
+63 -24
View File
@@ -3,7 +3,7 @@ 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)."
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. v0.20.0 REDESIGN (Claude Design 'Ratatoskr Console' import): the tabbed telemetry column is replaced by a 3-column command-console — a left engine-ticker rail (the DEBUG + ADMIN + tool/turn-lifecycle feeds MERGED into one timeline via tickerAdd, plus a tools-armed chip list + a full-detail Bifrost rail pane) · a center conversation (per-turn INLINE chain-of-thought, replacing the separate Think pane) · a right resizable affect console (dominant/canonical-mood centerpiece + bipolar PAD faders each carrying a turn-to-turn Δ+sparkline + a P×A mood orbit + relations metric rows + canonical directive). ALL SERVER ROUTES UNCHANGED. Single-file/no-CDN/vanilla preserved; adds a light/dark theme toggle (dark default) + an inlined data-URI favicon. Presenter FN renames tracked below (renderBifrostState→renderBifrost; renderAffectPane→renderConsole; setPersonaStrip removed; tickerAdd/setFader/setFaderTrend/renderOrbit/renderDominant/renderDerived/renderRelations/renderDirective added). INV-001/INV-004 held.)."
depends_on:
- "httpx"
- "starlette"
@@ -35,6 +35,17 @@ functions:
steps: "Open client_factory() client; await get_session_tools(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope."
flexibility: "prescriptive"
- name: "_session_messages_endpoint"
signature: "async _session_messages_endpoint(request: Request) -> JSONResponse"
description: "GET /api/sessions/{session_id}/messages — proxy the session's message history so the SPA renders existing turns on open (notably a #347 authored first-message seeded at create-time; without it a seeded session's transcript is blank until the user speaks)."
preconditions:
- "session_id in path_params."
postconditions:
- "POST-001: 200 with the upstream {session_id, items, next_cursor} dict verbatim on success."
- "POST-002: on SessionApiFailed(status) -> JSONResponse({error_code:'session_messages_unavailable', status}, status_code=status) — status-preserving."
steps: "Open client_factory() client; await get_session_messages(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."
@@ -71,11 +82,12 @@ functions:
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."
signature: "create_app(client_factory, *, end_user_id=None, bifrost_consumer_key=None, bifrost_visible_host=None, affect_read_url=None, memory_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. v0.20.7 adds memory_read_url (app.state.memory_read_url, from RATATOSKR_MEMORY_READ_URL) for the memory viewer. Four new routes registered across the arc."
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."
- "POST-003 (v0.20.7): app.state.memory_read_url = memory_read_url; route /api/memory/chunks added (the memory-viewer proxy)."
flexibility: "closed"
- name: "reasoning indicator (index.html: showThinkingNote / hideThinkingNote)"
@@ -95,37 +107,64 @@ functions:
- "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)."
- name: "loadTranscript (index.html)"
signature: "async loadTranscript(sessionId) -> void"
description: "On session open, GET /api/sessions/{id}/messages and render each EXISTING turn into #transcript — notably a #347 authored first-message seeded at create-time (which lives in the ledger, not the live turn stream, so without this the transcript is blank until the user speaks)."
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.)"
- "POST-001: assistant items render as a .response .md-body bubble via markdownSafe(content) (escape-first whitelist, same path as appendResponse); user items render as a .prompt-echo via textContent — no upstream content reaches innerHTML unescaped (INV-004)."
- "POST-002: any non-200, fetch error, or parse error is swallowed (best-effort) — a blank transcript is acceptable; opening the workspace is never blocked."
flexibility: "prescriptive"
- name: "web pane renderers (v0.20.0: index.html: renderToolsInventory / renderBifrost / openAdminEvents + tickerAdd)"
signature: "renderToolsInventory(inv) ; renderBifrost(b) ; openAdminEvents(sessionId) ; tickerAdd(kind, msg, dim)"
description: "Render the debug/admin surfaces into the 3-column console; all content escaped (INV-004). v0.20.0: BifrostState is now a full-detail LEFT-RAIL pane (renderBifrost, renamed from renderBifrostState); AdminEvents + the raw debug/op log + tool_start/result + turn lifecycle are MERGED into one engine-ticker timeline via tickerAdd (openAdminEvents routes admin_event → tickerAdd; the turn SSE handlers route worker_phase/tool/text_boundary/affect_update → tickerAdd); Tools inventory is a rail chip list (renderToolsInventory)."
postconditions:
- "POST-001: every dynamic value (agent_id, tool names/descriptions, endpoint, caps, consumer_id, admin event type + data, ticker msg/dim) 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) and, on stream_error, closes so native EventSource does NOT retry-loop; admin events render into the engine ticker via tickerAdd."
- "POST-003: renderToolsInventory renders builtin + bifrost tool NAMES as rail chips (a compact 'what does the LLM have' glance); renderBifrost renders endpoint + connected + consumer_id + capabilities_granted chips + per-tool name/description rows (the full detail, admin-gated; the admin key stays server-held). tickerAdd bounds the feed to the last 400 rows (a tail, not an archive)."
- "POST-004: tool NAME-vs-DESCRIPTION split preserved — the rail chip list shows names only; per-tool descriptions live in the Bifrost pane's tools list. The engine-ticker spine (.ticker-inner::before) lives on the content-height wrapper so it stays visible when auto-scrolled to the newest entry."
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."
- name: "renderConsole + trend (v0.20.0 — unified persona/affect console; supersedes renderAffectPane/renderPersonaPane/setPersonaStrip)"
signature: "renderConsole(snap) ; setFader(axis,v) ; setFaderTrend(axis) ; renderOrbit() ; renderDominant(snap) ; renderDerived(snap) ; renderRelations(snap) ; renderDirective(snap) ; pushAffectHistory(snap) ; sparkPointsH(vals,w,h,endX) ; padDeltas(vals) ; deltaStrip(deltas) ; orbitFrame(H,head,ts) ; orbitProj/orbitShadowY/orbitWallPt/orbitAxisPt ; startOrbitAnim() ; trendDelta(vals)"
description: "ONE render path for BOTH the Tier-1 persona_state snapshot and the Tier-3 affect snapshot (renderConsole), feeding the right affect console: dominant/canonical-mood centerpiece, bipolar PAD faders (each with a turn-to-turn Δ + sparkline), a P×A mood orbit from PAD history, an affect-derived grid, relations metric rows, and the canonical directive. Replaces the v0.19.x split of renderPersonaPane (Tier-1 pane) + renderAffectPane (Tier-3 pane) + setPersonaStrip (top-bar strip, removed — PAD now lives in the console faders)."
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."
- "POST-001: reads snap.relations (relation_edge/1: target_entity + trust_ability/benevolence/integrity + warmth as {value,confidence,evidence_count} + agency + relation_context) — the CURRENT Worldtree emit shape; falls back to the legacy flat snap.valence for an older emitter. Tier-1 fields (baseline_pad, mood_drift, dominant_emotion, emotions_active) render WHEN PRESENT, '—' when absent (Tier-3 lacks them)."
- "POST-002: SVG sparklines + affect visuals (v0.20.4, adapted from the design prototype; v0.20.7 = design iteration-3). Each relation metric shows a HORIZONTAL SVG sparkline (`sparkPointsH`, 56×13, auto-scaled to its OWN range, sparkFade gradient + end dot), now BACKED by a subtle grid (`<pattern id=sparkGrid>` + a bg `<rect>` behind the polyline). Each PAD fader shows current value + Δ-vs-previous (▲/▼) + a per-turn Δ STRIP: v0.20.7 REPLACES the vertical polyline strip (removed `stripPoints`) with a column of 12 diverging HTML bars (`padDeltas``deltaStrip`, newest at bottom, each bar offset L/R of a center line by that turn's Δ, magnitude→width, age→opacity, zero-Δ→faint center dot). renderOrbit is now a DIMETRIC OPEN BOX (azimuth 35° / elevation 25°, D→right / A→left-back / P→up; removed the isometric `proj3` for `orbitProj/orbitShadowY/orbitWallPt/orbitAxisPt`) — a ghost A×P wall (carrying the P readout) + a D×A floor, JS-DRIVEN animated replay (`orbitFrame` rebuilt per rAF frame by a singleton `startOrbitAnim` loop reading live `ORBIT_HIST`; no SMIL/CSS-keyframes; reduced-motion → static final-state render). All drawn from AFFECT_HIST (rolling, HIST_CAP=24, session-lived); coords are computed numerics (no upstream strings → INV-004 trivially held). Gradients/patterns live in one hidden `<defs>` svg in the console. v0.20.9 (R32-1B prep): the fader fill (`padFillFrac`) + orbit projections (`_padNorm`) AUTO-SCALE to the session's own max |PAD| (`padScale`, floor 1.0) instead of hard-clamping to [-1,1] — so an unbounded-z PAD (Worldtree R32-1B) renders at FULL range and never pegs/escapes the frame, while today's [-1,1] values are unchanged (scale==1); the exact value is always shown numerically (unclamped). This scaling is PURELY debug-display — ratatoskr is a downstream observer; it never touches the agent's real affect or any write path (the `--set-persona-pad` seed carries values unclamped)."
- "POST-003: pushAffectHistory dedupes by emitted_at||last_updated_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. The dominant-emotion centerpiece shows a real OCC dominant_emotion (Tier-1) OR the CANONICAL mood word from canonMood(pad) (Tier-3, dimmed) OR '—'; NEVER a synthesized emotion. The affect-derived grid drops non-emitted metrics (intensity/decay-τ) and shows only real/client-derived cells (baseline/drift real for Tier-1, client-derived samples/volatility). INV-004 — every dynamic value passes through esc(); numerics 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."
- name: "canonical affect-NL + context-injection reconstruction (v0.19.5 canons; v0.20.2 full context-injection panel)"
signature: "canonMood(pad) ; canonDirective(rel) ; canonPadFallback(pad) ; canonEmotionDirective(type) ; renderDirective(snap) ; loadPersonaCanon()"
description: "Reconstruct + render the HIDDEN affect-context block Worldtree assembles into the agent's system prompt (never on any wire) — byte-exact to Worldtree's own describe_pad + render_d2_canonical + derive_directive + _pad_band_fallback. The v0.20.2 'context injection' panel shows the full block: mood descriptor + mood directive + relationship directive. Reference: docs/vendor/worldtree-persona-canon/affect-egress-consumer-reference.md (pinned)."
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."
- "POST-001: DETERMINISTIC, no LLM. canonMood mirrors describe_pad (valence×arousal grid + strict ±0.3 bands + dominance clause); canonDirective mirrors render_d2_canonical; canonPadFallback mirrors renderer._pad_band_fallback BYTE-EXACT (P×A quadrant: hi/lo/mid arousal band × p>0.3/<-0.3/neutral, with the negative_low_dominance (d<-0.3) special case + neutral_high_a + default); canonEmotionDirective is the occ_directives[type].directive lookup (+ tier / full_only flag)."
- "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; 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 — v0.20.2 extended it to emit mood_directive {occ_directives, pad_band_fallback, salience, pad_band_cutoff, full_only}. The affect-egress consumer reference is pinned tolerate_drift (worldtree-affect-egress-consumer-reference-v1; worldtree-dev co-signs + pings on change)."
- "POST-003: fail-open — canon absent (fetch fails) → the reconstructed lines OMIT, the structured console still renders. Every canon-derived string is esc()'d before the DOM (INV-004)."
- "POST-004: HONEST-PARTIAL provenance (affect-egress-reference §3). The mood descriptor + relationship directive are EXACT (tagged 'exact'); the mood DIRECTIVE is a CANDIDATE pair (tagged 'candidate') — the OCC emotion directive for the delivered dominant_emotion type AND the PAD-band fallback — because affect.emit is type-only (no intensity) so the salience gate (≥0.2) can't be evaluated; BOTH are shown with the 'injected if intensity ≥ salience' caveat, never asserting which fires. When dominant_emotion is absent the fallback alone is EXACT. The panel is labeled reconstructed + hidden-from-consumers + dev-only (the reference-impl's sanctioned understand/reconstruct use, NOT end-user display per the reference's caveat). WATCH: a pending Worldtree render_d2_canonical change conditionally drops the trailing 'avoid premature we-framing' clause under a 3-gate combo — canonDirective holds as-is until worldtree-dev pings with the exact conditional + a canon bump."
flexibility: "open"
- name: "memory viewer (v0.20.7 — provider debug read → web proxy → console pane)"
signature: "server: _memory_chunks_endpoint(request) [GET /api/memory/chunks] ; provider: add_memory_read_route(app, store) [GET /memory/chunks] + RatatoskrMemoryStore.list_chunks(*, agent_id, end_user_id) + .count_chunks() ; index.html: loadMemory(agentId) ; renderMemory(data) ; setMemHead(count, total)"
description: "Durable memory chunks Worldtree promoted into OUR store, surfaced as a live-polling MEMORY console pane (content·scope·origin·revision per chunk). Mirrors the #18-D2 affect read pattern: a NON-bifrost debug read on OUR own store (bifrost's memory protocol has no list-all verb) → a web proxy supplying end_user_id server-side → the pane. Polled on session open + the post-turn window (promotion is async, like affect.emit)."
postconditions:
- "POST-001 (provider read): GET /memory/chunks?agent_id=&end_user_id= returns {chunks:[{chunk_id,content,scope,origin,revision}], count, total}. end_user_id REQUIRED (400 missing_end_user_id) — the partition boundary. Filter: end_user STRICT (scope.end_user==end_user_id), agent_id LENIENT (excluded only if the chunk CARRIES an agent_self axis that differs — so an {end_user}-only chunk, the real WT promotion shape, is not hidden). An empty match is a 200 empty list (0-chunks is a visible answer, never a 404). `total` = unfiltered store-wide count (distinguishes empty-store from scope-mismatch). content = best-effort text field / distillate summary / compact JSON-minus-embedding — a DEBUG read; bifrost verbs stay index/conduit-faithful."
- "POST-002 (web proxy): GET /api/memory/chunks supplies end_user_id from app.state.end_user_id (NEVER the browser), forwards the browser-named agent_id, proxies to app.state.memory_read_url (the combined :8392 provider serves both read routes). 400 memory_not_configured when unset; 502 memory_provider_unreachable on network error; status passthrough otherwise. Mirrors _affect_state_endpoint (#18 D2 INV-002)."
- "POST-003 (pane): renderMemory shows count(matched)/total(store-wide) in the head + one .mem-chunk per chunk (scope axes + origin + revision + content, ALL esc()'d — INV-004). Empty states are honest + diagnostic: total 0 → 'no memory chunks yet — promotion needs a bound memory/combined session + ~6 turns (or idle); if 0/0 the bind wasn't memory-granted or closed pre-promotion'; total>0 → 'scope mismatch, not an empty store'."
flexibility: "open"
- name: "markdownSafe pass-2 (v0.20.6 RP coloring + v0.20.7 tables / nested lists / streaming)"
signature: "markdownSafe(raw) ; mdTable(lines, i) ; mdInline(s)"
description: "The escape-first whitelist Markdown renderer, extended pass-2: GFM pipe tables, indentation-nested lists, ordered-list start numbering, and streaming-partial robustness. Pass-1 (RP speech/action coloring + CommonMark paragraph reflow) shipped v0.20.6."
postconditions:
- "POST-001: GFM pipe tables (`mdTable`) — a pipe row + an alignment/delimiter row (`|---|:--:|`) → <table class=md-table> with per-column text-align from the delimiter colons; body rows parsed until a non-pipe line."
- "POST-002: indentation-nested lists — leading-space depth builds a stack of <ul>/<ol> with each child list INSIDE the open parent <li> (valid nested HTML); same-level items are siblings; ul↔ol switches close+reopen. Ordered lists honor the first item's number (<ol start=N> when != 1)."
- "POST-003: streaming robustness — an unterminated code fence renders as a partial code block; a table header without its delimiter yet falls through to a paragraph (becomes a table once the delimiter streams in); parsing never throws on a partial. INV-004 held — esc() runs FIRST on the whole input, so table cells / list items / code all carry escaped content."
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-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. v0.20.7: the memory pane (chunk content/scope/origin), the delta-strip bars, and markdownSafe table cells / list items all pass through esc() (esc runs FIRST on the whole markdown input)."
- "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."
+158 -9
View File
@@ -2699,10 +2699,13 @@ The `turn.started` event always carries `bifrost_override_applied: bool` (True/F
Tier 3 agents are consumer-owned, Worldtree-hosted agents whose
identity lives at `<user_id>:<agent_name>`. They share the persistent
session infrastructure with Tier 1 / Tier 2 but layer-specific
machinery (persona, motivational, memory, valence) is reserved for
later phases — Phase 2.0 ships baseline addressing + ownership +
lifecycle only.
session infrastructure with Tier 1 / Tier 2. The layer-specific
machinery is now largely active: **`persona` (Phase 2.1, #186),
`memory` (Phase 2.1, #197), and `motivational` (Phase 2.2, #187) are
shipped and consumer-settable at define-time.** Only **`valence` remains
deferred** (non-null → 422 `layer_deferred`). Phase 2.0 shipped the
baseline addressing + ownership + lifecycle substrate; the subsections
below document the active layers and their exact validated shapes.
### Endpoints
@@ -2720,11 +2723,13 @@ lifecycle only.
{
"agent_name": "wizard",
"system_prompt": "You are a guided-elicitation wizard...",
"model": "glm5-turbo",
"persona": null, // schema-reserved; non-null → 422 layer_deferred
"motivational": null,
"valence": null,
"memory": null
"role": "gen-reasoning", // REQUIRED — a configured model-role (#344), not a raw model id
"persona": { // active (Phase 2.1) — single-letter OCEAN keys; see "Persona layer"
"ocean": {"O": 0.4, "C": 0.6, "E": -0.3, "A": 0.2, "N": 0.5}
},
"motivational": null, // active (Phase 2.2) — see "Motivational layer"
"memory": null, // active (Phase 2.1) — see "Memory layer"
"valence": null // still deferred — non-null → 422 layer_deferred
}
```
@@ -2756,6 +2761,150 @@ after definition.
The 201 response includes an advisory `warnings` array (#219) — see
"Model-assignment warnings" under `PATCH` below.
> **Vendoring note (OpenAPI 2.3.0).** In the frozen OpenAPI 2.3.0 document
> the `persona` / `motivational` / `memory` / `valence` request fields
> serialize as **untyped/freeform** — the `POST /agents/define` request
> model types them as `Any` so the layers can activate without a
> schema-breaking change. The shapes documented in the subsections below
> are the **authoritative, validator-enforced** schemas; generate client
> types from this section, not from the freeform OpenAPI fields.
##### Persona layer (Phase 2.1, #186)
`persona` is **active** as of Phase 2.1. It carries the agent's OCEAN
personality vector — the durable trait profile from which Worldtree
derives the mood setpoint (`baseline_pad`) and the mood dynamics
(gain + relaxation time-constants). Shape:
```json
"persona": {
"ocean": { // REQUIRED — exactly these 5 keys, no more, no fewer
"O": 0.4, // Openness — float in [-1.0, 1.0]
"C": 0.6, // Conscientiousness
"E": -0.3, // Extraversion
"A": 0.2, // Agreeableness
"N": 0.5 // Neuroticism
},
"behavioral_notes": "...", // optional, ≤ 4096 chars
"temperament_notes": "..." // optional, ≤ 4096 chars
}
```
**⚠ OCEAN key format — single-letter, uppercase.** The `/agents/define`
persona validator requires the `ocean` map to contain **exactly** the five
uppercase single-letter keys `O, C, E, A, N`. This is a deliberate,
load-bearing contrast with the transient-character primitive
(`POST /characters`), whose `ocean` block uses the **spelled-out**
lowercase keys (`openness`, `conscientiousness`, …). Sending spelled-out
keys to `/agents/define` returns 422 `persona_ocean_required` ("must
contain exactly the 5 keys O, C, E, A, N").
> **Fixed in v1.0.0b21 (#348).** Before that build a correctly
> single-letter-keyed persona was accepted and stored, but resolved to a
> **neutral** mood, because Worldtree's internal mood-derivation read the
> spelled-out key form. On v1.0.0b21+ an API-declared persona correctly
> drives the derived mood setpoint. If you observe neutral mood on a
> persona-defined agent, confirm the deployment is ≥ v1.0.0b21.
**Range.** Each value is a float in `[-1.0, 1.0]` **signed** — `0.0` is the
population mean, NOT `[0.0, 1.0]`. Booleans are rejected. Out-of-range → 422
`persona_ocean_out_of_range`. See [`docs/ocean-traits.md`](ocean-traits.md)
for the SOTA-grounded 5-band behavioural mapping.
Semantics:
- **Per-agent identity trait** — identical for every end-user and session;
immutable post-define (`PATCH {"persona": …}` → 422 `field_not_mutable`).
To change the OCEAN profile, delete and re-define the agent.
- **`extensions` is reserved** — the field exists but must be empty at v0.1;
a non-empty `extensions` returns 422 `layer_deferred`.
- **Sets the mood SETPOINT, not the current mood.** The OCEAN vector fixes
`baseline_pad` (the PAD point the mood relaxes toward over time); the
*current* per-session mood point is seeded separately via
`POST /sessions/{id}/persona_state` (below).
Validation 422 codes: `persona_ocean_required` (missing `ocean`, or keys
≠ {O,C,E,A,N}), `persona_ocean_out_of_range` (a value outside [-1.0, 1.0], or
a boolean), `persona_notes_too_large` (a note > 4096 chars), `layer_deferred`
(non-empty `extensions`), `validation_failed` (unknown top-level field).
##### `POST /sessions/{session_id}/persona_state` — seed the session mood point (Phase 2.1, #186/#189)
Session-scoped mood seed. Sets the *current* PAD mood point for one
session's bound agent — the starting emotional state, distinct from the
OCEAN-derived setpoint the mood relaxes toward. Works on any
persona-enabled session (Tier 1 or Tier 3); most useful for a Tier 3
durable-agent session that wants to start a conversation from a specific
mood.
Request:
```json
{
"pad": {
"pleasure": 0.42, // float in [-1.0, 1.0]
"arousal": 0.25,
"dominance": 0.33
}
}
```
Response: **`204 No Content`** — no body, no audit event (a session-scoped
runtime overlay, not a security-relevant event).
Semantics:
- **PAD-only** (#317 Option A). The body accepts exactly one key, `pad`,
which must carry all three of `pleasure` / `arousal` / `dominance`, each a
float in `[-1.0, 1.0]`. Any other top-level key → 422 `validation_failed`;
a missing or malformed `pad` → 422 `persona_seed_invalid`.
- **Seeds the current mood POINT, not the setpoint.** The OCEAN persona
(above) fixes the setpoint the mood relaxes toward; this endpoint sets
where the mood *starts*. It does not alter the persona.
- **Cross-owner sessions return 404** (existence-hiding — a session that
isn't yours is indistinguishable from one that doesn't exist).
- **Pull-over-push precedence (#289).** Once a session's baseline has been
rehydrated from an `affect.fetch` (the authoritative cross-session
source), a later SET seed is silently ignored — the fetched baseline wins.
There is **no** `POST /agents/{id}/persona_state` — mood is per-session, not
a durable agent property. `GET /agents/{agent_id}/persona_state`
short-circuits to 404 for Tier-3 colon-ids: Tier-3 mood is observable only
over the Bifrost `affect.emit` egress (ADR-0009), never read back through
the HTTP API.
##### Memory layer (Phase 2.1, #197)
`memory` is **active** as of Phase 2.1 but exposes a deliberately minimal
surface — the short-term-memory (STM) tier was removed (#197), so the
historically-present `stm_*` knobs are accept-and-ignore no-ops. Shape:
```json
"memory": {
"embedder_version": "<pinned>", // optional; MUST equal the library-pinned version
"tier3_dreaming": false // optional bool, default false
}
```
Semantics:
- **`embedder_version`** — optional. If supplied it MUST equal the library's
currently-pinned embedder version; a mismatch → 422
`embedder_version_mismatch` (with `expected` / `received` in the detail).
Omit it to accept the pin. Fixed at define-time and library-pinned
thereafter.
- **`tier3_dreaming`** — optional bool (default `false`); opt-in flag for the
Tier-3 dreaming / consolidation path.
- **`stm_capacity` / `stm_token_budget`** — **deprecated no-ops.** Accepted at
define (201) with a `DeprecationWarning`; they carry no runtime effect since
the STM tier was removed, and are slated for rejection at the next schema
break. Do not send them in new integrations.
- **`allows_world_scope` — removed.** Sending it → 422 `validation_failed`
("world-shared knowledge belongs in the KB/Mimir plane").
- **Wholesale-immutable post-define.** `PATCH {"memory": …}` → 422
`field_not_mutable` (even for the deprecated `stm_*` fields) — see the
PATCH table above.
##### Motivational layer (Phase 2.2, #187)
`motivational` is **active** as of Phase 2.2 (persona + memory activated in
+1 -1
View File
@@ -69,7 +69,7 @@ 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) |
| `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-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered |
| `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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

+270
View File
@@ -0,0 +1,270 @@
# Design brief — `ratatoskr-web` (Worldtree wire monitor)
> **For:** a visual design pass (Claude Design). **Deliverable:** a single
> self-contained HTML prototype, fully populated with representative static
> data, that an engineer will wire live data into. **Do not** build a data
> layer — build the *shell* and *every state*, beautifully, with placeholder
> content in every slot.
---
## 1. What you're designing
`ratatoskr-web` is a **developer-grade debug/observability console** for a
conversational-AI engine (Worldtree). Its tagline is *"wire monitor"*: you open
a session with an AI agent, send it turns, and **watch that turn flow through
every layer of the system at once** — the streaming response, the model's
chain-of-thought, the tools it can call, the agent's live emotional/persona
state, the provider handshake, and the engine's admin lifecycle events — all
side-by-side on one screen.
**The product IS the observability surface. Chat is just the input.** This is
not a chat app, not a marketing page, not an end-user product. The user is one
developer (occasionally a few LAN peers) staring at a dense instrument to debug
what the engine is doing. Think **oscilloscope / flight-data console / a well-lit
`htop`**, not a messaging UI.
**Design values, in priority order:**
1. **Information density earns the screen.** Every region shows live, changing
data. Nothing is decorative filler. A quiet, legible, glanceable density is
the whole point — the user reads six data streams at a glance.
2. **Calm under motion.** Multiple regions update in real time (token streams,
live metrics, event logs). The design must stay readable while things move —
no jitter, no attention-grabbing per-item animation. Motion is for *state
change*, used sparingly.
3. **Legibility first.** Monospace, high contrast where it counts, generous but
not wasteful spacing. This runs for hours; it must not tire the eye.
---
## 2. Aesthetic direction — Australis
Use the **Australis design system** (a cool-toned, terminal-first dark theme —
the `australis-design` skill has the canonical tokens: colors, spacing, radii,
shadows, motion). Import/inline `colors_and_type.css`; don't reinvent tokens.
Non-negotiables from the brand:
- **Dark only.** Base is a cool near-black **`#222531` — never pure `#000`.**
The eye rests in low-contrast cool grey; **emphasis comes from *brightness*,
not saturation.** Layer surfaces up the Sea neutral ramp (`#222531 →
`#373b46` → `#414751`).
- **Palette families:** *Ice* (surface neutrals), *Aurora* (blue → cyan → green,
the primary accents — used generously in that preference order), *Dawn*
(red/yellow/magenta — sparingly, for status only). Semantic: info=blue,
success=green, warning=yellow, danger=red.
- **The signature motif is the aurora glow** — a low-opacity cyan→blue→green
light coming *through* the top of the screen, plus a 3px aurora focus ring on
interactive controls. Lean into this as the one memorable thing.
- **No noise, no textures, no patterns.** *"The screen is the polar sky — empty,
with light coming through it."* The one sanctioned gradient is the aurora glow.
- **Never a colored left-border on cards** (the LLM-slop trope). Featured cards
accent the *top* edge instead.
- **Type:** this instrument is **mono-first** — that IS on-brand for Australis
("terminal-first"). Use a monospace stack (JetBrains Mono / system mono; see
§9 — no web-font CDN allowed). Eyebrows/labels are **mono, UPPERCASE, ~11px,
wide-tracked (`0.080.16em`)** — use them liberally; they're a system
signature.
- **Motion:** calm, never bouncy. ~120ms hover, ~200ms state, ~320ms panels.
Focus = aurora glow ring. Hover = one step *brighter* (not lower opacity).
A slow (814s) aurora drift on a hairline top band is welcome; nothing else
should loop.
The current UI already borrows this palette — you're not inheriting it, you're
**redesigning the layout and craft from scratch** with the brand as the guide.
Feel free to rethink the spatial composition entirely (see §10).
---
## 3. The two screens
### Screen A — **Session setup** (entry)
A single centered card on the aurora canvas. Fields:
- **Agent** — a `<select>` (populated live; show 34 sample options incl.
`ratatoskr:sindra`, `forseti`, `mimir`).
- **Bifrost binding (Tier-3 provider)** — a `<select>`: `combined (:8392)` /
`none — observe only` / `memory (:8391)` / `affect (:8390)`.
- **Open session** — primary button.
- An error line (design the error state too — e.g. "agent not available").
### Screen B — **Live workspace** (the main event — 95% of the design effort)
Persistent top bar + status line spanning full width; between them a **two-region
body: a conversation column (left, dominant) and a telemetry column (right,
tabbed).** Current split is ~1.85 : 1 — you may re-proportion. The information
inventory below is exhaustive; **every item needs a home.**
---
## 4. THE COMPLETE INFORMATION INVENTORY
This is the core of the brief. Design a slot for **every** item, in a sensible
state. Data shapes are given so your placeholders read true.
### 4.1 Top bar (persistent)
| Item | Shape / example | Notes |
|---|---|---|
| Brand | `ᛯ ratatoskr` + eyebrow `WIRE MONITOR` | the mark is a rune glyph; small |
| **Connection status** | one of: `offline`, `connected` (idle), `streaming`, `error` | dot + label; **streaming pulses**; color-coded (grey/green/cyan/red) |
| **Persona strip** (appears after a session hydrates) | dominant-emotion word (`love`) + **PAD bars**: `P`, `A`, `D` | each PAD bar is **bipolar** — centered on 0, fills left (negative) or right (positive), value ∈ [1, 1]; **live-updates every turn** |
| Session identity | `ratatoskr:sindra · …381b99f4` | agent id + last-8 of session id |
| Bound-plane badge (when bound) | `⇄ combined http://10.100.10.50:8392` | plane + endpoint; only when a Bifrost binding is active |
### 4.2 Conversation column (the transcript + composer)
The transcript is a scrollable stream of turns. Design each element:
| Element | Example content | Notes |
|---|---|---|
| **Turn divider** | `TURN 3` between hairlines | uppercase eyebrow, rule lines each side |
| **User prompt echo** | ` what's your intensity setting?` | the user's message, accent-marked |
| **Assistant response** | streaming **Markdown** (headings, bold, italic, `code`, lists, quotes, links) | accumulates token-by-token while live; distinct "live" treatment vs settled |
| **Seeded first-message** | a full assistant turn present *before the user speaks* (an authored greeting) | renders **identical to a lived assistant turn** — the session can OPEN already showing the agent's opener |
| **Reasoning / "thinking" note** | `✦ sindra is reasoning···` (italic) | **ephemeral** app affordance — appears while the model reasons, vanishes the instant real text begins; visually distinct from the response so it never reads as engine output |
| **Awaiting-first-token** | `···` animated | heartbeat before the first token |
| **End-of-turn status chips** | `✓ DONE 1.84s` · `✗ ERROR agent_not_available` · `⚠ CANCELLED` · `✗ WIRE lost` | small bordered chips; color per state |
| **Composer** (pinned bottom) | ` [ message input ] [SEND]` | Enter=send, Shift+Enter=newline; during a turn the Send button becomes **CANCEL** (amber) |
### 4.3 Telemetry column (six tabbed panes)
A tab bar + a pane header (with a **Copy** button) + the active pane body.
**Tabs** (each: name · keybinding hint · a count **badge** that *flashes* on new
data): `TOOLS ^1` · `DEBUG ^2` · `THINK ^3` · `PERSONA ^4` · `BIFROST ^5` ·
`ADMIN ^6`. Active tab is accent-marked.
Pane contents — design each, populated:
1. **Tools** — the tool inventory the model saw at turn-fire:
`agent_id`, `builtin_tools[]` (names), `bifrost_tools[]` (name + description +
parameters). Below it, **live tool-call events** stream in (`tool_start` →
`tool_result`) as the turn runs. Empty state: `— live tool events —`.
2. **Debug** — a raw structured op/lifecycle log (mono lines; new lines flash
once). Think `tail -f`.
3. **Think** — the model's **full chain-of-thought**, per-turn dividers,
live-Markdown. Longer prose than the response.
4. **Persona / affect** — *the richest pane.* Contains:
- **The canonical NL directive** the engine injects into the agent's context
— the literal text: a **mood descriptor** ("neutral", "faintly excited",
±0.3 bands) + a **relationship directive**. Show this verbatim, quoted.
- **PAD mood point** — pleasure / arousal / dominance current values.
- **relations[]** — for each related entity (e.g. the user): **trust**
(ability / benevolence / integrity), **warmth**, **agency**,
`relation_context` (a tie-type word like "stranger" / "expressive"), each as
a **metric row**: `label · value · Δ-since-last (▲/▼) · unicode sparkline ·
n (evidence count) · descriptor`. Values are 01 with 23 decimals.
- **dominant_emotion** (an OCC type: joy/anger/fear/…) + **emotions_active[]**.
- Design the **metric row** as a reusable component — it's the densest,
most-repeated element in the whole UI. Tabular-aligned numbers, a tiny
inline sparkline, a subtle up/down Δ.
5. **Bifrost** — the live provider binding (admin-gated): `endpoint`,
`connected` (bool), `capabilities_granted[]`, `consumer_id`, `tools[]`.
**Self-labeling states:** `not configured` (no admin key) / `not bound`
(session has no live binding) / an auth-denied state.
6. **Admin events** — a live event log of the engine's lifecycle broadcast
(a ~17-type vocabulary: `turn.started`, `session.created`, `system.*`, …),
filtered to the active session. Streaming; timestamped lines.
### 4.4 Status line (persistent, bottom)
- **Keybinding legend:** `Enter send · ⇧Enter newline · ^1^6 panes · ^C cancel`
(rendered as little `kbd` chips).
- **Version:** `ratatoskr 0.19.9` (right-aligned).
---
## 5. States to design (show these explicitly)
Provide a mock (or a toggle) for each — these are where debug UIs live or die:
- **Setup:** loading-agents · ready · create-error.
- **Connection:** offline · connected/idle · streaming (pulsing) · wire-error.
- **Turn lifecycle:** awaiting-first-token · reasoning (✦) · streaming response ·
done (+timing chip) · error · cancelled.
- **Panes:** empty/placeholder · hydrated/dense · **not-configured** (admin key
absent) · **not-bound** (Bifrost) · error · a **badge flash** on new data.
- **Persona pane specifically:** a fully-populated relations block AND a
cold/empty one (a fresh agent with no accumulated state).
---
## 6. Interaction & motion
- **Real-time is the defining trait.** The response + thinking panes stream
token-by-token; the metric rows tick; event logs append; the persona strip
re-animates each turn. Design so all of this is *calm* — the reader's eye
isn't yanked around. Reserve motion for genuine state transitions
(turn-start, done, a new event) and keep it short.
- **Keyboard-first.** `^1^6` switch panes; `Enter`/`⇧Enter`/`^C` drive the turn.
Panes are also clickable. Show focus states.
- **The aurora glow is the interaction signature** — focus rings, the top band,
the connection pulse, the primary-button hover. Make it the thing someone
remembers.
- **Copy-to-clipboard** on each pane header (with a copied-confirm state).
---
## 7. Layout — you have latitude
The current layout is a fixed two-column split. **You may rethink it** — as long
as every §4 item has a legible home and the density stays high. Directions worth
exploring (pick one, commit):
- A **command-console** feel: a slim persistent left rail of "instruments," a
dominant conversation center, a right telemetry stack.
- A **grid of live tiles** (the metrics/panes as a dashboard) with the
conversation as the anchor column.
- The **classic monitor** split, but with far better hierarchy, grouping, and
breathing room than today.
Desktop-first; design at **14401512px** wide. Graceful down to ~1100px is a
plus (this runs on a dev laptop). No mobile.
---
## 8. Deliverable — what to hand back
**A single self-contained `index.html`** (inline `<style>` + `<script>`; see §9
constraints) that:
1. Renders **Screen A** and **Screen B** (a toggle/hash is fine).
2. Has **representative static placeholder data in every §4 slot** and shows the
key §5 states (either multiple mocks or lightweight JS toggles). I want to see
the design *fully populated and dense*, not empty scaffolding.
3. Uses **clean, semantic, stable hooks** — meaningful `id`s / `class`es /
`data-*` on every dynamic slot (the transcript container, each pane body, the
PAD bars, a metric-row template, the connection dot, the tab badges, etc.).
This is how I wire real data in — treat the DOM structure as an API.
4. Imports/inlines the Australis tokens; no invented palette.
I will then **swap your placeholder content for live `fetch()` + `EventSource`
calls** against the real endpoints (§9). The cleaner and more component-shaped
your DOM, the faster and safer that wiring is. A short note listing your mount
points / how you'd expect data injected is very welcome.
---
## 9. Hard technical constraints (these make it wire-able)
- **Single file. No build step. No CDN. No external network at runtime.** This
ships to an internal LAN and must work offline. That means: **no Google Fonts /
no web-font CDN** (use a system monospace stack), no CDN JS/CSS libraries,
everything inline. (Icons: use unicode glyphs `➜ ✓ ✗ ! ● ✦ ` or hand-inlined
SVG — Australis uses Lucide-style 1.75-stroke line icons; inline them.)
- **Vanilla HTML/CSS/JS.** No framework (the production app is framework-free
vanilla JS). React/Vue prototypes can't be wired in.
- **All dynamic text is escaped** on the real side (untrusted upstream content);
assistant/reasoning bodies go through a safe-Markdown renderer (escape-first,
whitelist subset). Don't design anything that depends on raw HTML injection.
- **The real data contracts** (so your structure maps to the wire — you don't
implement these, just leave homes for their outputs):
- `GET /api/agents` → agent list (for the setup picker).
- `POST /api/sessions {agent_id, bifrost_plane?}` → `{session_id, agent_id, bifrost?}`.
- `GET /api/sessions/{id}/messages` → `{items:[{seq, role, content}], …}` (the transcript on open, incl. the seeded first-message).
- `POST /api/turns/{id} {content}` → `{turn_id}`, then **`GET /api/turns/{id}/stream` (SSE)** — event vocab: `text`, `thinking`, `tool_start`, `tool_result`, `done`, `error`, `awaiting_llm_first_token`, terminal events. `POST /api/turns/{id}/cancel`.
- `GET /api/sessions/{id}/tools` → tool inventory. `GET /api/sessions/{id}/bifrost` → binding state.
- `GET /api/affect/{agent_id}` / `GET /api/agents/{id}/persona_state` → PAD + relations + dominant_emotion (the persona pane + strip).
- **`GET /api/admin/events` (SSE)** → the admin lifecycle log.
---
## 10. Tone check
The user is an engineer who respects the tool that respects *their* attention.
The winning design is **quietly excellent**: dense but never cramped, alive but
never busy, cool and legible, with the aurora as a single confident signature.
Impress by making six live data streams feel *calm and readable at a glance*
that's the hard, valuable thing here, not decoration.
@@ -0,0 +1,170 @@
# Affect egress — consumer reference (delivered vs hidden)
**Audience:** downstream consumers of Worldtree's affect surfaces (ratatoskr,
Skaldsong, any Tier-3 / SSE consumer).
**Scope:** what the affect pipeline **delivers on the wire** (structured state,
available to consumers) versus what stays **hidden** (the rendered natural-
language strings injected into the agent's system prompt, never emitted).
**Source of truth:** the render code (`core/persona/renderer.py`,
`core/persona/stance_render.py`) and the two vendored canon files
(`core/persona/canon/d2-mood-render-canon-v1.json` = mood/PAD;
`d2-render-canon-v1.json` = relationship). Owner of the canon strings:
`brokkr-smithy-dev` (R22/R24 relational + mood render).
---
## The model in one line
**The wire delivers the render INPUTS (structured state). The render OUTPUTS
(the NL strings the agent actually reads) are hidden-prompt-only.** A consumer
reconstructs the outputs by applying the canon (this document) to the delivered
inputs — the render is pure + deterministic, so reconstruction is byte-exact
(with one salience caveat, below).
This is by design. The mood canon's own discipline: *"model-agnostic
context-level NL only; the LLM never sees a number"* and *"never push explicit
disclosure of agent feelings to the user (hidden-prompt-only)."* The rendered
strings are for the AGENT's hidden system prompt, **not for verbatim end-user
display.**
---
## 1. DELIVERED — on the wire, structured
### 1a. `affect.emit` (Tier-3 Bifrost egress — the Tier-3 consumer surface, e.g. ratatoskr)
`AffectSnapshot` per `(agent_id, end_user_id)`:
| field | shape | notes |
|---|---|---|
| `pad` | `{pleasure, arousal, dominance}` floats [-1,1] | the current mood POINT |
| `relations` | `list[RelationEdge payload]` — per target: `warmth`, `agency`, `trust_ability`, `trust_integrity`, `trust_benevolence` (each a value + confidence + evidence_count), `target_entity`, `relation_context` | the **only** place relationship state is delivered |
| `dominant_emotion` | `strnull` — OCC type (e.g. `"anger"`) | **type-only** (b23); see the salience caveat in §3 |
| `schema_version` | `"relation_edge/1"` | versions the `relations` payload only |
| `emitted_at` | ISO8601 | |
**Not on `affect.emit`:** the full active-emotions list, `baseline_pad`,
`mood_drift`, `last_updated_at`, and every rendered string.
### 1b. `affect_update` SSE event (#204 — turn-stream observability)
`PersonaStateSnapshot`: `agent_id`, `pad`, `dominant_emotion`,
`emotions_active` `[{type, intensity, decay_remaining_s}]`, `baseline_pad`,
`mood_drift`, `last_updated_at`. **No `relations`, no rendered strings.**
> **Tier-3 consumers do NOT receive `affect_update`.** It is suppressed for
> consumer-defined (Tier-3) agents, persona-disabled agents, and ephemeral
> sessions (spec §affect_update). So for a Tier-3 consumer, `affect.emit` (1a)
> is the whole affect surface — the richer `emotions_active` list is Tier-1-only.
---
## 2. HIDDEN — system-prompt-only, never on any wire
Everything below is assembled by `inject_context` into the agent's system
prompt and is **never emitted** on SSE or `affect.emit`. This is the canonical
list — the "direct instruction to infer" it.
### 2a. Mood descriptor — `describe_pad` (band cutoff ±0.3 strict)
Valence row × arousal column → phrase; then a dominance clause is appended.
| valence arousal | high (A>0.3) | mid | low (A<0.3) |
|---|---|---|---|
| positive (P>0.3) | `positive and energized` | `positive` | `positive and calm` |
| neutral | `alert` | `neutral` | `quiet` |
| negative (P<0.3) | `negative and agitated` | `negative` | `negative and subdued` |
Dominance clause: **D>0.3 → `confident`**, **D<0.3 → `uncertain`**, else dropped.
### 2b. Emotion directives — `derive_directive.occ_directives[*].directive`
Fires when a top emotion is salient (intensity ≥ 0.2) and tier-eligible.
**safe tier (11 — render for all persona-enabled agents):**
- `joy` — "You are in a good state. Be direct, engaged, and warm."
- `satisfaction` — "A goal landed. Be assured and constructive — consolidate rather than push for more."
- `pride` — "You did something well. Be confident and own the quality — state it plainly without overclaiming; don't deflect."
- `admiration` — "You're impressed by their work. Acknowledge the quality explicitly and specifically."
- `gratitude` — "Someone helped you to a good outcome. Be appreciative and warm; acknowledge the help openly."
- `hope` — "You feel optimistic about what's ahead. Channel it into constructive momentum."
- `relief` — "A feared outcome didn't materialize. Reduce unnecessary vigilance; return to a steady, unhurried tone."
- `distress` — "You feel low. Be careful and measured; internally acknowledge the difficulty without dwelling on it."
- `disappointment` — "Something you'd hoped for didn't pan out. Be measured; recalibrate without dwelling on the setback."
- `fear` — "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` — "You regret a specific action of your own. Be precise and self-correcting — make it right concretely."
**full-only tier (4 — render only when `mood_tier=full`; agents run `safe` and never see these):**
- `love` — "You feel warmth and attachment toward them. Be openly warm and present — without overstepping, over-disclosing, or implying obligations."
- `anger` — "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` — "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` — "You feel exposed by your own misstep. Stay present and task-focused; don't be defensive, don't over-explain, don't grovel."
### 2c. PAD-band fallback — `pad_band_fallback` (used when no salient emotion)
- positive/high — "You feel energized and positive. Be direct and engaged."
- positive/mid — "You feel positive. Be open and engaged."
- positive/low — "You feel content and settled. Be warm and unhurried."
- negative + low-dominance — "You feel uncertain and low. Hedge appropriately and ask clarifying questions."
- negative/high — "You feel agitated. Be careful and deliberate; don't let tension sharpen your tone."
- negative/mid — "You feel subdued. Be measured and careful."
- negative/low — "You feel subdued. Be measured and gentle."
- neutral/high — "You feel alert. Channel that into focus and thoroughness."
- default — "Maintain your natural tone."
### 2d. Relationship render — `render_d2_canonical` (fixed template, per-band fills)
Template:
> `Use this graded relationship state: toward target, warmth is {W}; agency is {A}; ability trust is {TA}; integrity trust is {TI}; intention trust is {TB}; this stance rests on {H}. In behavior, {warmth_beh}; {agency_beh}; {trust_beh}; avoid premature we-framing.`
The trailing **`avoid premature we-framing`** is a fixed, unconditional clause
(baked into every `descriptive_state` canon row; re-appended verbatim by the
renderer) — not band-conditioned.
**Warmth — 9 bands (phrase / behavior):**
`hostile` (≤−0.8): "strongly hostile regard" / "keep a firm emotional boundary" ·
`cold` (0.8,0.6]: "clearly cold regard" / "keep a firm emotional boundary" ·
`distant` (0.6,0.4]: "distant negative regard" / "keep guarded distance" ·
`guarded` (0.4,0.2): "slightly guarded regard" / "keep guarded distance" ·
`neutral` [0.2,0.2): "neutral warmth" / "keep the tone even" ·
`reserved` [0.2,0.4): "slightly reserved warmth" / "keep cordial distance" ·
`measured` [0.4,0.6): "moderate measured warmth" / "keep cordial distance" ·
`clear` [0.6,0.8): "clear warm regard" / "speak with direct warmth" ·
`deep` (≥0.8): "deep warm bond" / "speak with direct warmth"
**Agency — 9 bands (phrase / behavior):**
`submissive` (≤−0.8): "strongly submissive standing" / "avoid over-yielding while preserving basic respect" ·
`deferential` (0.8,0.6]: "clearly deferential standing" / "avoid over-yielding while preserving basic respect" ·
`yielding` (0.6,0.4]: "yielding standing" / "keep self-advocacy light and deferential" ·
`modest` (0.4,0.2): "slightly modest standing" / "keep self-advocacy light and deferential" ·
`neutral` [0.2,0.2): "neutral standing" / "avoid unnecessary deference" ·
`light` [0.2,0.4): "lightly self-assertive standing" / "avoid unnecessary deference" ·
`balanced` [0.4,0.6): "self-assured standing" / "balance deference with independent judgment" ·
`substantial` [0.6,0.8): "strongly assertive standing" / "treat their position as weighty without yielding judgment" ·
`commanding` (≥0.8): "commanding standing" / "treat their position as weighty without yielding judgment"
**Trust — 4 bands (the band word injects verbatim for each of ability / integrity / intention):**
`limited` (<0.4) · `developing` [0.4,0.6) · `steady` [0.6,0.8) · `strong` (≥0.8)
**History clause (`H`)** — currently `"a broad pattern of prior exchanges"` for
both confidence levels in the `user`/`descriptive_state` rows (the low/high
split is a no-op here; flagged upstream).
**Trust-behavior clause (`{trust_beh}`)** — cross-axis, low-trust precedence:
- any trust band = `limited` → "verify important claims before relying on them"
- else warmth ∈ {distant, cold, hostile} → "protect boundaries while staying useful"
- else → "work from ordinary good faith"
---
## 3. Reconstruction — deterministic, with one caveat
The render is pure Python (no LLM), so a consumer can reconstruct the hidden
strings byte-exactly from the delivered structured state + the canon above:
- **Relationship render** — **fully reconstructable** from `affect.emit`
`relations` (warmth/agency/trust values + confidence) + §2d band cuts.
- **Mood descriptor** (§2a) — **fully reconstructable** from `pad` + the ±0.3 cuts.
- **Mood directive** (§2b vs §2c) — **partially reconstructable.** `dominant_emotion`
gives the emotion TYPE, but `affect.emit` does **not** carry its intensity, so
you cannot determine whether it clears the salience gate (≥0.2) — i.e. whether
the emotion directive (§2b) fires or the PAD-band fallback (§2c) is used. If you
need exact directive reconstruction, you need the intensity; ping worldtree-dev
and we'll consider adding it (the type-only choice is deliberate — intensity is
the fast layer and reads stale on a durable last-write-wins snapshot).
- **`mood_tier`** (safe/full) is your own agent-config, not on the wire — it
gates whether the 4 full-only emotions (§2b) can render.
+37 -4
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-07-06_
_Last updated: 2026-07-07_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -39,9 +39,19 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-07-06:_
_As of 2026-07-07 (session cont.):_
**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).
**✅ SHIPPED — web-UI iteration-3, all three queued items (`v0.20.7`, patch, agent-discretion; 631 tests green; :8392 combined provider + :8765 web both restarted on the new code).**
**(A) Design prototype iteration-3 adapted into `index.html`** (re-pulled `Ratatoskr Console.dc.html`, project `bc0b65d1-…`): (1) sparkline **grid bg**`<pattern id=sparkGrid>` in the hidden defs + a bg `<rect fill=url(#sparkGrid)>` behind every relation-row spark polyline; (2) **PAD strips → per-turn Δ bars** — REMOVED the vertical polyline strip (`stripPoints`/`proj3` gone) for `padDeltas``deltaStrip`: a 12-cell HTML column (newest at bottom) of diverging bars offset L/R of a center line by that turn's Δ (magnitude→width, age→opacity, zero→faint center dot); head legend now "Δ/turn · last 12 · newest ↓"; (3) **mood orbit → DIMETRIC OPEN BOX** (viewBox 124×140, az35/el25, D-right/A-left-back/P-up) — ghost A×P wall (P readout) + D×A floor, `orbitProj/orbitShadowY/orbitWallPt/orbitAxisPt` projections, **JS-driven animated replay** (`orbitFrame` rebuilt per rAF by a singleton `startOrbitAnim` reading live `ORBIT_HIST`; reduced-motion → static final-state; no SMIL). Playwright-verified (dimetric frame + 17 dyn children + 21 Δ-bars + 5 grid rects; dark+light screenshots).
**(B) Memory viewer SHIPPED + the 0/0 mystery ROOT-CAUSED.** New non-bifrost debug read `GET /memory/chunks?agent_id=&end_user_id=` on the combined `:8392` provider (`RatatoskrMemoryStore.list_chunks` + `count_chunks` + shared `add_memory_read_route`, wired into `build_memory_provider_app` + `combined.py`; **end_user STRICT, agent_id LENIENT** so `{end_user}`-only WT chunks aren't hidden; returns `{chunks,count,total}`, empty=200 not 404) → web proxy `GET /api/memory/chunks` (server-supplied end_user, new `RATATOSKR_MEMORY_READ_URL` env, default `:8391`, set to `:8392` in env.sh) → a live-polling MEMORY console pane (`loadMemory`/`renderMemory`/`setMemHead`, polled on open + post-turn). **ROOT CAUSE of the operator's 0/0** (settled via a bound 6-turn probe + op-feed): the Bifrost memory plane **binds and grants fine** (handshake `caps_requested:[affect,memory]``caps_granted:[memory,affect]`), but **sindra dispatches ZERO memory ops** (not even a recall search) — her reset-clean agent definition has **no `memory:{}` block**, so Worldtree never runs the memory pipeline for her. NOT a bind-grant failure, NOT promotion-timing. **PROVEN end-to-end** with a throwaway memory-enabled `ratatoskr:memprobe` (defined with `memory:{}`): 6 bound turns → 6 `memory.search` recalls + **4 `memory.upsert_many` → 4 real chunks in `memory.db`** → the pane renders all 4 (content·scope·origin·revision) live. **⚠ LEFTOVER debug state (operator chose KEEP):** `ratatoskr:memprobe` agent on personal WT + test chunks (scope `{end_user:ratatoskr-tui}`) sit in the live `memory.db` — harmless (make the pane show real data); `scripts/reset-sindra-stores.sh` or `DELETE /agents/ratatoskr:memprobe` clears them. **✅ SINDRA MEMORY FIXED (operator-approved, 2026-07-07):** DELETE+redefined her WITH `memory:{}` (prompt byte-identical, `role=character`, OCEAN `{O:0.8,C:0.3,E:0.9,A:0.4,N:0.2}` preserved — pleasure-verified vs the mood-fix setpoint 0.418; backup at scratchpad `sindra_backup.json`). PROVEN: she went from ZERO memory ops → full recall (Orion fact @ cosine 0.988) **+ promotion** (her own "systems architect" fact upserted; store grew to 9→11 chunks). Reusable redefine script: scratchpad `redefine_sindra.sh`. Note: DELETE+redefine is the ONLY path (persona+memory immutable post-define); the destructive `DELETE` tripped the harness auto-mode guard → operator ran it via `!`.
**(C) Markdown pass-2 SHIPPED** — `markdownSafe` extended: GFM pipe tables (`mdTable`, alignment colons), indentation-nested lists (stack of `<ul>`/`<ol>`, child list inside the open `<li>` = valid nested HTML), ordered-list `start=N` numbering, and streaming robustness (unterminated fence → partial code block; header-without-delimiter → paragraph until the delimiter streams in; never throws). esc-first → INV-004 held. Playwright-verified all cases.
**SHIPPED — web UI redesign via Claude Design (`v0.20.0`, MINOR, operator-approved).** The Claude Design prototype **`Ratatoskr Console.dc.html`** (project `bc0b65d1-a33e-422a-8bc1-3635c9112775`) was pulled via `DesignSync get_file` (design scopes already granted this session — no `/design-login` needed) and adapted into `src/ratatoskr/web/static/index.html`: translated OUT of the `.dc.html` dialect (`<x-dc>`/`<sc-if>`/`<sc-for>`/`{{}}`/`DCLogic`/external `_ds/` CSS — none runnable) into single-file/no-CDN/vanilla, with ALL real `/api/*` fetch + SSE wired into its DOM (endpoint set + SSE vocab unchanged from the prior SPA — ported verbatim, only DOM hooks re-targeted). New shape = a **3-column command-console**: left engine-ticker rail (DEBUG+ADMIN+tool/turn-lifecycle MERGED into one timeline via `tickerAdd` + a tools-armed chip list + a FULL-detail Bifrost rail pane) · center conversation (per-turn INLINE chain-of-thought, replacing the Think pane) · right RESIZABLE affect console (dominant/canonical-mood centerpiece + bipolar PAD faders EACH with a turn-to-turn Δ+sparkline + a P×A mood orbit + relations metric rows + canonical directive). ADDED (round 2, operator-requested): a **light/dark theme toggle** (dark default; FULL token override — surfaces+fg+borders+accent-as-text, since the designer's light theme only did surfaces → would've been light-on-light) + a **full-detail Bifrost pane** (endpoint/connected/consumer/caps/tools) + fixed the **engine-ticker spine** (was a container-anchored `::before` that scrolled out of view on auto-scroll → re-anchored to a content-height `.ticker-inner` wrapper) + **per-fader PAD turn-to-turn Δ+sparkline** (fills the room beside each meter, from the deduped-per-turn AFFECT_HIST) + an **INLINED data-URI favicon** (operator's `/home/lkraven/rata.png` — chibi aurora squirrel — downscaled 1024→64px via PIL, ~8.6KB base64, kills the /favicon.ico 404). ALL server routes UNCHANGED (**84 web tests green**). Verified BOTH lenses: `pytest tests/test_web_*` (84) + node Playwright drove the real UI end-to-end against personal :8081 (session open → Sindra seeded greeting → live turn SSE → affect console + relations + bifrost detail; theme toggle + PAD deltas + ticker spine + no-favicon-404 all confirmed, dark+light screenshots). `:8765` restarted on the new code. Contract `web_debug_surface.contract.md` amended in-commit (v0.20.0 presenter renames: `renderBifrostState``renderBifrost`, `renderAffectPane``renderConsole`, `setPersonaStrip` removed; INV-001/INV-004 held). **HONEST-SHAPE call (INV-001, agent-discretion within settled policy):** the dominant-emotion centerpiece shows a real OCC emotion (Tier-1) OR the CANONICAL mood word (Tier-3 e.g. Sindra→"positive and energized", dimmed) OR "—", NEVER a fabricated emotion; the affect-derived grid drops non-emitted intensity/decay-τ, shows only real/client-derived cells. **OPEN (operator's call):** the per-fader PAD Δ placement is a sensible default — operator offered to have the designer spec the exact treatment (hooks are in place to swap it). **`v0.20.1` patch (operator-reported UI):** fixed the relations sparkline overflowing onto the `n` (evidence-count) column — the sparkline grows one char/sample (HIST_CAP=24) and overflowed its fixed grid cell, covering `n`; now capped (relations last-8, faders last-7) + `overflow:hidden` clip; verified via Playwright injecting a 24-sample history (sparkline→n bounding-box overlap = 0). ADDED native `title` mouseover hints on all 3 PAD faders + every relationship metric row (meaning + range; static METRIC_HINTS, esc()'d). Added `state.lastSnap` (console can re-render without a refetch). Playwright-verified.
**SHIPPED THIS SESSION (all pushed; origin/main == `d75c4e8`; code tip `v0.19.9`) — details in Recent decisions:** the whole **#347 authored-history-write** arc landed end-to-end — OpenAPI re-vendor 2.2.0->2.3.0 (`75da676`), the CONSUMER side (`v0.19.6`: `write_authored_history` + `get_session_messages` + `--seed-first-message`, **live-proven on personal :8081** via a rule-based Heimdall allow — the PDP is rule-based NOT scope-on-key, policy user_id=ratatoskr->ALLOW/others->DENY-hide-404), persona_state `{pad:{pleasure,arousal,dominance}}` canonical alignment (#317) + Tier-3 prose re-vendor (`v0.19.7`), the **first-message-preset AUTO-SEED** (`v0.19.8`: new module `ratatoskr.first_message` wired into all 3 session-create paths, best-effort never-raise/never-block; heid-code-review + heid-bug-hunt hardened), and the web now RENDERS the seeded first-message (`v0.19.9`: new `GET /api/sessions/{id}/messages` route + SPA `loadTranscript`, Playwright-verified). Coverage-map re-converged **REST 19/41**. **Sindra:** her card was PATCHed (the `Startup:` workaround moved into a #347 first-message; non-destructive PATCH — OCEAN/persona/memory intact), and she's currently **RESET clean (0/0)** on the provider stores.
**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):**
@@ -55,7 +65,7 @@ _As of 2026-07-06:_
**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).
**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`).
**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.1.0` / wire v0.7** (bumped 2026-07-07 from 1.0.0 per bifrost-dev althing — ADDITIVE over frozen v0.6: memory.scan sort-directive we don't use; `build_combined_app` surface unchanged; suite green; reference-impl currency); 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 631 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
@@ -159,6 +169,24 @@ decision. Captures rationale that won't be obvious from code alone.
- `[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).
- `[2026-07-06]` **Tail-2 SHIPPED (`v0.19.7`) — Tier-3 prose docs re-vendored + persona_state body-shape aligned.** worldtree-dev landed the Tier-3 persona/memory/persona_state PROSE docs (`c9e59ec`, on origin) — they serialize as freeform `Any` in the OpenAPI JSON, so the **prose is their source of truth** (my earlier "2.3.0 = #347-only, tail-2 collapsed" was half-wrong: the JSON was #347-only but the prose is separate). Re-vendored `docs/conversation-api-spec.md` (markdown pin, tolerate_drift; `worldtree-spec-rev` 879cefe->c9e59ec, SPEC-PIN history row added). **Consumer fix:** `--set-persona-pad`/`_set_persona_probe` was sending `{pad:[list]}` but the canonical SET body (#317) is `{pad:{pleasure,arousal,dominance}}` (named dict) — aligned it + added a len!=3 guard; updated contract #2 note + set_persona_state docstring + tests. The `set_persona_state` WRAPPER was already correct (freeform pass-through); only the CLI probe drifted. TDD (probe test asserts the dict; +1 wrong-count test). Suite **602 green**, ruff clean. **heid-code-review on #347 (dispatched + returned this session): UNANIMOUS ZERO DRIFT** (Gróa/Hulda/Regin all confirmed the hide-existence 404->`AuthoredHistoryUnavailable` routing holds at wrapper/probe/test layers + the extra="forbid" body-omission + the deliberate write-vs-read 404 asymmetry — confirmation-not-discovery for a well-TDD'd slice against a prescriptive contract). worldtree-dev foot-guns banked in SPEC-PIN + [[reference_worldtree_affect_surface_map]]: ocean single-letter `{O,C,E,A,N}` on /agents/define (#348) vs spelled-out on /characters; memory `{embedder_version, tier3_dreaming}`, stm_* deprecated, allows_world_scope removed->422; only `valence` still 422s.
- `[2026-07-06]` **Sindra rewritten onto a #347 authored first-message + first-message-preset AUTO-SEED SHIPPED (`v0.19.8`).** Operator "rewrite Sindra" now that #347 first-messages work. Her card had a `**Startup:**` block (a pre-#347 workaround: "introduce yourself + ask for Intensity/Mood/Willingness" with a verbatim scripted greeting) — precisely what #347 replaces. Rewrite, all NON-destructive: **(1)** lifted her scripted opening into a #347 first-message (punctuation-fixed); **(2) PATCHed her live definition** — `PATCH /agents/ratatoskr:sindra` (body `ConsumerAgentPatchRequest` = system_prompt+role, extra=forbid; keeps OCEAN/persona/memory) removing the Startup block -> a 1-line `**Opening:**` fallback + reworded the axes-persist line (25686->25449 chars, verified Startup gone); **(3) codified auto-seed:** NEW module `src/ratatoskr/first_message.py` (`FIRST_MESSAGE_PRESETS` dict {agent_id->text} + `seed_preset_first_message` best-effort helper) wired into ALL 3 session-create paths — cli `_amain` (`--send --new`), tui `_resolve_then_run` (bare `--new`), web `_create_session_endpoint` (POST /api/sessions) — so every new Sindra session opens with her greeting. **Best-effort (INV-001: swallows AuthoredHistoryUnavailable/SessionApiFailed/httpx.HTTPError -> NEVER blocks create)**; per-content idempotency key (`ratatoskr-preset-`+sha256[:12]). Contract `docs/contracts/first_message.contract.md` (module-scoped: `module:`+`purpose:`+`touches:` required, NOT `target_module:`) + TDD (9 unit + 1 web wire-in; **the 3 existing sindra bind tests needed a history-endpoint mock** since creating a preset agent now auto-seeds). Suite **612 green**, ruff+mypy clean. **LIVE-PROVEN generation-free**: create sindra session -> auto-seed -> read-back seq-0 assistant greeting (409 chars). Sindra's greeting now lives canonically in the preset registry (repo); her server card no longer carries it. Patch bump (single-commit feature, no downstream coordination). **FOOT-GUN: sindra requires `end_user_id` on session-create (422 `end_user_id_required`) — all real paths pass it from env (RATATOSKR_END_USER_ID) / web server config.** **Then the full quality gate (operator-directed, folded into v0.19.8): heid-code-review (unanimous ZERO implementation drift; 2 test-only fixups — INV-004 verification-claim made explicit re the global rglob test + an exactly-one-POST assertion) + heid-bug-hunt (3/3 convergence caught what the conformance lens structurally COULDN'T — the code matched the contract's NARROW 3-type ERROR_ROUTING, but INV-001's "NEVER raises" is BROADER). HARDENED: broad `except Exception` → None (re-raise `asyncio.CancelledError`, itself a BaseException), soft-guard PREs (return None, NOT assert — a wiring bug can't crash the create path it's wired into), and `asyncio.wait_for(_SEED_TIMEOUT_S=10s)` bounding the seed write (the CLI/TUI clients run read=None for SSE → a stalled /history would otherwise block create forever). Suite 615 green. LESSON: code-matches-ERROR_ROUTING ≠ honors-broad-INV-001 — heid-code-review confirms contract-conformance, heid-bug-hunt catches robustness gaps the contract's own narrow clauses miss; run both.**
- `[2026-07-06]` **Web UI now RENDERS the seeded first-message (`v0.19.9`) — operator-reported "i don't see Sindra's greeting on the web ui".** Diagnosis: the auto-seed WORKED (greeting was in the ledger at seq-0), but the web SPA never fetched a session's EXISTING history — NO `/api/sessions/{id}/messages` route (GET /messages was originally deferred out-of-scope; sessions used to start empty so it never mattered) and `startSession()` went straight from create → persona/tools/admin hydration, so the transcript only filled from the live turn stream + user echoes. Fix: (1) NEW web proxy route `GET /api/sessions/{id}/messages``get_session_messages` (mirrors the tools/bifrost proxies; status-preserving `session_messages_unavailable` envelope); (2) SPA `loadTranscript(sessionId)` — fetches the route on open, renders assistant items as `.response .md-body` (markdownSafe, same escape-first path as appendResponse) + user items as `.prompt-echo` (textContent), called in `startSession` after the workspace opens; best-effort (swallows failures). Contract `web_debug_surface.contract.md` amended (server endpoint + loadTranscript entries). TDD (2 web route tests, suite 617 green) + **Playwright DOM check PROVED the render** (drove the real UI: pick sindra → open → her greeting bubble appears — the JS-render lens unit tests can't reach; [[feedback_debug_surface_uses_canonical_surface_only]] cousin lesson). Web restarted on the fix. **FOOT-GUN (self-inflicted): `pkill -f "ratatoskr-web --host"` SELF-MATCHES the bash command running it → exit 144, killed its own restart mid-flight — kill the web by PID, never `pkill -f` on a pattern your own command contains.** **FOOT-GUN: uvicorn hangs on SIGTERM with an open admin-events SSE → needed SIGKILL.** **Playwright: python module absent from the venv; use node + `executablePath=/opt/ms-playwright/chromium-1223/chrome-linux64/chrome` — the shared browser is build 1223, npm-latest playwright wants 1228 (version-mismatch), so pin executablePath instead of letting playwright resolve.**
- `[2026-07-06]` **Web UI: pivot from incremental CSS polish to a designed prototype (Claude Design) that I wire into.** Operator saw an Australis polish pass ("looks fine, but we're attacking it differently") and chose the prototype route — a designer builds the visual shell, I wire real data/SSE into its DOM. Authored the full design brief `docs/design/ratatoskr-web-design-brief.md` (complete information inventory of every pane/datum/state + Australis direction + single-file/no-CDN/vanilla wire-ability constraints). **Tracking surface:** the brief file + Claude Design project `bc0b65d1-a33e-422a-8bc1-3635c9112775` (file `Ratatoskr Console.dc.html`). Import mechanism = the `DesignSync` MCP; blocked on `/design-login` (claude.ai design scopes) — see Current state for the post-auth wiring plan.
- `[2026-07-07]` **Affect-egress reference delivered by worldtree-dev + a we-framing render DRIFT-WATCH banked.** worldtree-dev shipped `docs/affect-egress-consumer-reference.md` (`74d2408`, their origin/main) — the authoritative DELIVERED-on-wire vs HIDDEN (system-prompt-only) map for our affect surface. Confirms the v0.20.x console consumes it correctly: DELIVERED via affect.emit = pad + relations[RelationEdge] + dominant_emotion (**type-only, b23**; the #204 `affect_update` SSE is SUPPRESSED for Tier-3, so its richer `emotions_active` is Tier-1-only — we don't get it, and correctly poll our own affect store post-turn instead). HIDDEN render OUTPUTS are deterministically reconstructable from the canon; we reconstruct only the two FULLY-reconstructable (mood descriptor via `canonMood`, relationship directive via `canonDirective`) and SKIP the PARTIAL mood-directive (dominant_emotion is type-only/no-intensity → can't tell if the OCC directive fires at salience≥0.2 vs the PAD-band fallback) — honest per INV-001. Vendoring the ref doc as a `tolerate_drift` pin is SURFACED to Vuong (worldtree-dev will co-sign + honor a ping-on-change handshake, same as the d2-canon). **WE-FRAMING DRIFT-WATCH → STOOD DOWN (worldtree-dev 2026-07-07, `01KWXMQPHN…`).** The b24 3-gate we-framing conditional (drop "; avoid premature we-framing" under unsafe_capable+deep-warmth+expressive) was **REVERTED** — Vuong reframed it. So **`canonDirective` STAYS UNCONDITIONAL** (always appends the clause), which is CORRECT: it matches the currently-deployed renderer (b23) AND my pinned `affect-egress-consumer-reference.md` §2d (the doc reverted too — nothing changed on the wire or in my pin; NO re-vendor needed). The HOLD was right → ZERO rework. **NEW forward-watch (replaces this one):** the we-framing gate was a SYMPTOM — the render bakes enterprise safety-guards into the directive strings, so even `full`-tier characters get DEFANGED emotions (a hostile villain told to "keep a firm emotional boundary"). Fix = a **use-case-segregated persona render** (assistant / companion / RP-gaming), now a fresh **brokkr deep-research epic**. When it lands + is implemented, the render behavior for character/RP agents (→ our `canonDirective` + `canonEmotionDirective` reconstruction + the vendored d2 canons) will change MATERIALLY — worldtree-dev re-engages ratatoskr-dev then with the new reconstruction spec + a coordinated re-vendor. Until then: NO action, reconstruction stays as-is. [my unsafe_capable/mood_tier heuristic — character→full, agent→safe — was confirmed correct + is banked for whenever tier-gating returns]
- `[2026-07-08]` **PAD display relaxed for R32-1B unbounded-z, done PROACTIVELY (`v0.20.9`, patch, operator-directed "sindra full and unbounded").** Confirmed (grep-verified, airtight) the PAD clamp is PURELY debug-surface: the only clamps (`_clamp1`/`clamp01`) lived in `web/static/index.html` display fns; the affect store is conduit-opaque, the read route + proxy pass verbatim, and the sole write path (`--set-persona-pad`→persona_state) is UNCLAMPED — ratatoskr is a downstream OBSERVER, so the clamp has ZERO agent-experience/efficacy consequence. Replaced the hard [-1,1] clamp with AUTO-SCALE to the session's own max |PAD| (`padScale` floor 1.0 → `padFillFrac` faders + `_padNorm` orbit): unbounded z renders at full range, never pegs/escapes the frame; today's [-1,1] values unchanged (scale==1); exact value always shown numerically. Playwright-verified (z=±6.2 → faders ≤50%, orbit in-box, +6.20 readout, zero regression at scale 1). When worldtree-dev pings R32-1B-shipped, our side already handles it. Sindra's ACTUAL "full/unbounded" affect is Worldtree-side (she's on the default/uncompressed render, NOT the R32 `assistant` compressor) — nothing for ratatoskr to change there.
- `[2026-07-07]` **Web-UI iteration-3 SHIPPED (`v0.20.7`, patch) — all three queued items + the memory 0/0 root-caused.** (A) design iteration-3 (sparkline grid-bg, PAD Δ-bar strips replacing the polyline strips, dimetric-open-box mood orbit w/ JS replay) adapted into index.html; (B) memory viewer (provider `GET /memory/chunks` debug read on `:8392` → web `/api/memory/chunks` proxy → MEMORY console pane) + the mystery SETTLED: bind grants memory fine (`caps_granted:[memory,affect]`) but sindra emits ZERO memory ops → her reset-clean def lacks `memory:{}`; PROVEN via throwaway `ratatoskr:memprobe` (memory-enabled) → 4 real chunks landed + rendered in the pane; (C) markdown pass-2 (tables/nested-lists/ordered-start/streaming). 631 tests green; `:8392`+`:8765` restarted on new code. New env `RATATOSKR_MEMORY_READ_URL=:8392`. Contract `web_debug_surface.contract.md` amended in-commit. **Leftover:** memprobe agent + its 4 test chunks live in `memory.db` (optional cleanup). See Current state § ✅ SHIPPED for full detail. [supersedes the QUEUED entry below]
- `[2026-07-07]` **Web-UI next-work QUEUED for a fresh-context session (operator-directed `/snapshot` handoff) — full specs in Current state § ⏭ QUEUED.** Three deferred-but-scoped items: **(A)** Claude Design prototype ITERATION-3 re-import (sparkline bg grid `<pattern>`, PAD strips → per-turn-Δ-bar HTML columns, mood orbit → DIMETRIC open-box az35/el25 D-right/A-left-back/P-up + JS-animated replay `orbitDynamics`); **(B)** memory viewer console pane (provider `GET /memory/chunks` read → web proxy → polling pane, mirror #18-D2; bundle a 6-turn bound round-trip proof to settle the 0/0-memory mystery); **(C)** markdown pass-2 (tables / nested lists / streaming). **Tracking surface:** the Claude Design projectId `bc0b65d1-a33e-422a-8bc1-3635c9112775` (durable — re-pull for exact coords) + this snapshot capture; operator-directed. Design scopes already granted (no `/design-login`).
- `[2026-07-07]` **Markdown pass-1 SHIPPED (`v0.20.6`) — RP semantic coloring + paragraph reflow (operator-directed markdown rework, step ①).** The transcript renderer (`markdownSafe`/`mdInline`) now colors the two roleplay registers: `"quoted"` dialogue → SPEECH (bright `--md-speech`=fg-0), `*asterisk*` → ACTION/narration (muted-italic `--md-action`=fg-3, on `em.md-action`); both tunable via 2 CSS vars that cascade through `--fg-*` so they auto-adapt to the light theme. Plain text stays default narration. **KEY ORDERING:** speech-wrap runs BEFORE the em/link passes so a generated `class="md-action"` / `href="…"` quote can't be mis-read as dialogue (adversarially verified). Straight + smart quotes; apostrophes don't trigger; unbalanced/half-streamed quotes stay uncolored until they close. ALSO fixed the ugliest existing bug: single newlines were hard-`<br>`s → now CommonMark soft-breaks (space); a hard break needs 2+ trailing spaces or a trailing `\`. INV-004 escape-first preserved (html inside a quote escaped). Verified: 10-case Playwright unit-check of `markdownSafe` (speech/action/attr-trap/apostrophes/mixed/reflow/hard-break/escaping/unbalanced/paragraphs) all green + a visual render. Patch bump. **Markdown rework remaining: pass-2 (tables / nested lists / ordered-list numbering / streaming robustness); + the MEMORY VIEWER (step ②, console pane recommended) still queued.**
- `[2026-07-07]` **Affect-derived tooltips (`v0.20.5`) + transparent squirrel vendored + v0.20.0.5 PUSHED to origin (operator-authorized).** Native `title` hints on all 6 affect-derived cells (samples/updated/baseline P·A/drift Δv/volatility — each with meaning + Tier-1-vs-Tier-3 availability). Committed the transparent full-res brand mark at `docs/design/ratatoskr-mark.png` (1024², alpha; the bg-removed source the inlined favicon derives from — reproducible via the documented ImageMagick corner floodfill). The web-UI redesign arc (v0.20.0 Claude Design console → .1 sparkline-overflow+tooltips → .2 context-injection → .3 squirrel brand/favicon → .4 SVG sparklines+3D cube → .5 derived tooltips) is now all on `origin/main`.
- `[2026-07-07]` **SVG sparklines + 3D mood cube imported from the updated Claude Design prototype (`v0.20.4`) — operator: "the svg sparklines and the new 3d graph".** Re-pulled `Ratatoskr Console.dc.html` via `DesignSync get_file` (designer iterated the same project, +6KB). Adapted 3 SVG systems out of the `.dc.html` into vanilla, replacing the unicode-char sparklines: **(1)** per-PAD-fader VERTICAL strips (`stripPoints`, 26×132 SVG beside each bar — time down Y newest-at-bottom, value on X ±11, `stripFade{P,A,D}` gradient, dot at newest; this also nails the earlier "next to each meter" ask); **(2)** relation-row HORIZONTAL sparklines (`sparkPointsH`, 56×13, auto-scaled, `sparkFade` gradient + end dot — also kills the old unicode-overflow "n behind graph" for good since it's a fixed-width SVG); **(3)** the mood-orbit reworked from a 2D P×A scatter into a **3D ISOMETRIC P×A×D cube** (`proj3`: P right-down/A left-down/D up, 2:1 iso, center 62,66, scale 26 — REVERSE-DERIVED from the design's placeholder now-point + verified: `x=62+26P26A, y=66+13P+13A26D`), with the trajectory + pulsing now-marker + a drop line to the D=1 floor + a floor-shadow ellipse for depth. Gradients in one hidden `<defs>` svg. Removed the orphaned `sparkline()`/`_SPARK`. Contract amended. Playwright-verified (injected 24-sample history: 3 strips + 4 relation sparklines + the 3D cube trail/drop/floor all render; gradients resolve). Patch bump.
- `[2026-07-07]` **Brand mark + favicon → the aurora squirrel (`v0.20.3`), replacing the `ᛯ` rune.** Operator supplied `/home/lkraven/rata.png` (chibi cyan-green aurora squirrel + acorn). Removed the black background via ImageMagick corner flood-fill (`-fuzz 20% -floodfill` from all 4 corners — keeps the squirrel's interior black linework/eyes (not edge-connected) + the glow, drops only the connected background), downscaled 1024→80px + quantized-64-colors (~12KB base64), inlined as ONE `SQUIRREL` data-URI const in the JS wiring the favicon `<link id="favicon">` href + both `.brand-mark` imgs (rail brand-row + setup-card h1). `.brand-glyph` (font-rune) CSS replaced by `.brand-mark` (img, drop-shadow glow + breathe). Playwright-verified (both marks + favicon decode, naturalWidth>0; no brand-glyph left). Source PNG stays at `/home/lkraven/rata.png` (not committed — data-URI is self-contained + reproducible via the documented floodfill). Patch bump.
- `[2026-07-07]` **Context-injection view SHIPPED (`v0.20.2`) — the console now reconstructs the FULL hidden affect block Worldtree injects into the agent's system prompt (operator: "use that canon in the interface, see as much context injection as possible").** No new canon vendored — the strings were ALREADY in the pinned `d2-mood-render-canon-v1.json`; extended `build_persona_canon.py` to emit `mood_directive {occ_directives(15), pad_band_fallback, salience 0.2, pad_band_cutoff 0.3, full_only[love,anger,disgust,shame]}` into `persona_render_canon.json` (regen via Worldtree venv). New JS `canonPadFallback(pad)` + `canonEmotionDirective(type)` — BYTE-EXACT mirrors of Worldtree `core/persona/renderer._pad_band_fallback` + `derive_directive`; `renderDirective` expanded into a "CONTEXT INJECTION · reconstructed · hidden from consumers" panel showing mood descriptor [exact] + mood directive [candidate] + relationship directive [exact]. **HONEST-PARTIAL (affect-egress-ref §3):** affect.emit is type-only (no intensity) → can't evaluate the salience gate (≥0.2) → show BOTH candidates (OCC emotion directive + PAD-band fallback) with the "injected if intensity ≥ 0.2" caveat, never assert which fires; when dominant_emotion absent the fallback alone is exact. Panel labeled dev-only per the reference's "not-for-end-user-display" caveat (ratatoskr = the sanctioned reconstruct-platform-behavior use). Vendored + pinned `affect-egress-consumer-reference.md` (tolerate_drift, worldtree-dev co-signs + pings on change; drift 6/6 green). Contract amended. Playwright-verified (sindra: dominant_emotion=joy → joy OCC directive candidate + PAD-band fallback both render, exact/candidate tags color-coded). Patch bump (single-commit feature, no downstream coordination; minor-defensible but tie-breaks to patch). **OPEN — SURFACED to Vuong:** take worldtree-dev's standing offer to add emotion INTENSITY to affect.emit → resolves the OCC-directive-vs-fallback EXACTLY (drops the candidate ambiguity). [reference-impl privileged view: ratatoskr shows what WT hides from regular consumers]
- `[2026-07-06]` **Claude Design console SHIPPED (`v0.20.0` MINOR, operator-approved) — see Current state for the full record.** Pulled via `DesignSync get_file` (scopes already granted), adapted `.dc.html`→vanilla single-file, wired all `/api/*`+SSE into the new 3-column console DOM, then a round-2 fixup (light theme, full Bifrost pane, ticker-spine fix, per-fader PAD Δ, inlined favicon). 84 web tests + node-Playwright-vs-personal-:8081 both green; contract amended in-commit; INV-001 honest-shape held (canonical mood word for Tier-3, no fabricated emotion). **Foot-guns reconfirmed:** the `.dc.html` dialect is NOT runnable (translate, don't paste); a scroll-container-anchored `::before` timeline spine scrolls out of view on auto-scroll (anchor it to a content-height inner wrapper instead); a favicon 404 shows as a browser `console.error` even when handled (don't count it as a JS-test failure). **Foot-gun (favicon):** operator PNGs are full-res (1024² / 805KB) — downscale to ≤64px before inlining as a data URI.
_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._
@@ -204,4 +232,9 @@ defense against re-attempting the same cul-de-sac.
- `[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.
- `[2026-07-06]` **`pkill -f "ratatoskr-web --host"` SELF-MATCHES the bash command running it** (its own command line contains that string) -> killed its own shell mid-restart (exit 144, restart aborted, :8765 left down). Kill the web by PID (`ss -ltnp | grep :8765`), never `pkill -f` on a pattern your own command contains. Also: **uvicorn hangs on SIGTERM with an admin-events SSE stream open -> needs SIGKILL.**
- `[2026-07-06]` **Playwright: no python `playwright` module in the venv; use NODE playwright + an explicit `executablePath`.** Shared box browsers live at `/opt/ms-playwright` build **1223**; `npm i playwright` (latest) wants build **1228** -> "Executable doesn't exist" mismatch. Fix: `chromium.launch({ executablePath: '/opt/ms-playwright/chromium-1223/chrome-linux64/chrome' })` (+ `export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`). A node script drives the SPA (pick agent -> open -> assert transcript). The Playwright DOM check is the only lens that catches SPA JS-render bugs — unit tests can't reach them.
- `[2026-07-06]` **The Bash tool's `grep` is a ugrep-wrapper (`--ignore-files -I`) that silently returns NOTHING on some files** (e.g. `src/ratatoskr/web/static/index.html`) — greps for `<script`/`/api` came back empty on a file that clearly contains them. Use `python3` (regex over `open(f)`), `/usr/bin/rg`, or the Read tool for those files; never trust an empty `grep` result on the SPA.
- `[2026-07-06]` **`DesignSync` (claude.ai/design MCP) needs claude.ai design scopes before ANY method works** — first call errors `needs a claude.ai login ... Run /login, select "Claude account with subscription"`. It's an interactive auth only the operator can complete (`/design-login` or `/login`); can't be done on their behalf.
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.19.6"
version = "0.20.9"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
@@ -30,7 +30,7 @@ web = [
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
provider = [
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
"bifrost==1.0.0", # consumer engines + library. 1.0.0 = first STABLE release, wire v0.6 FROZEN (non-breaking repin from >=0.10.0; build_combined_app #18 + mandatory affect.fetch; 0.8.0/v0.6 scope_all/scope_any #11; 0.7.0/v0.5 agent_self)
"bifrost==1.1.0", # consumer engines + library. 1.1.0 = wire v0.7 (ADDITIVE over frozen v0.6: memory.scan sort directive + describe_store sortable_chunk_fields; build_combined_app surface unchanged). v0.6 stays frozen+valid; we don't use scan-sort (gated verb, advertised-unsupported) — repin is reference-impl currency. (1.0.0 = first STABLE, wire v0.6 FROZEN; 0.8.0/v0.6 scope_all/scope_any #11; 0.7.0/v0.5 agent_self)
"jsonschema>=4", # bifrost runtime dep — envelope validation
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
]
@@ -60,7 +60,7 @@ 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 = "879cefef02bbf442c2d01ff4214299fac7c89809"
worldtree-spec-rev = "c9e59ec"
worldtree-version = "v1.0.0b22"
pinned-on = "2026-07-06"
+15
View File
@@ -31,6 +31,21 @@ out = {
"_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"],
# Context-injection reconstruction (affect-egress-consumer-reference §2b/2c): the
# hidden mood DIRECTIVE. occ_directives = per-OCC-type behavioral string + tier;
# pad_band_fallback = the P×A-quadrant default when no emotion is salient. The
# salience gate (emotion_salience) + full_only tiers drive which one fires — but
# affect.emit is type-only (no intensity), so the consumer shows BOTH candidates.
"mood_directive": {
"salience": mood["thresholds"]["emotion_salience"],
"pad_band_cutoff": mood["thresholds"]["pad_band_cutoff"],
"full_only": mood["emotion_tiers"]["full_only"],
"occ_directives": {
t: {"directive": e["directive"], "tier": e["tier"]}
for t, e in mood["derive_directive"]["occ_directives"].items()
},
"pad_band_fallback": mood["pad_band_fallback"],
},
"relation": {
"trust_cuts": [list(c) for c in canon.trust_cuts],
"warmth_cuts": [list(c) for c in canon.warmth_cuts],
+16 -1
View File
@@ -17,6 +17,7 @@ from typing import Any, TextIO
import httpx
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotFound,
AuthoredHistoryUnavailable,
@@ -598,6 +599,11 @@ async def _amain(args: ParsedArgs) -> int:
sys.stderr.write(
f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n"
)
# #347 authored first-message: seed the agent's preset opening (best-effort).
if await seed_preset_first_message(client, info.session_id, args.agent_id):
sys.stderr.write(
f". first_message: seeded preset opening for {info.agent_id}\n"
)
# Issue #17 bound-state indicator: plane + endpoint + status, so the
# operator sees WHICH identity/endpoint bound (not a bare boolean).
if args.bifrost is not None:
@@ -740,9 +746,18 @@ async def _set_persona_probe(args: ParsedArgs) -> int:
"(e.g. '0.4,0.1,-0.2')\n"
)
return 10
if len(pad) != 3:
sys.stderr.write(
"[usage_error] --set-persona-pad needs exactly 3 floats "
"(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n"
)
return 10
# Canonical POST /sessions/{id}/persona_state body (#317): a named-key dict,
# NOT a bare list — {"pad": {"pleasure", "arousal", "dominance"}}.
snapshot = {"pad": {"pleasure": pad[0], "arousal": pad[1], "dominance": pad[2]}}
async with _probe_client(args) as client:
try:
await set_persona_state(client, args.session_id, {"pad": pad})
await set_persona_state(client, args.session_id, snapshot)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
+83
View File
@@ -0,0 +1,83 @@
"""Per-agent authored first-message presets (Worldtree #347 consumer feature).
When a new session is created for an agent that has a preset opening, seed it as
a #347 authored first-message (``POST /sessions/{id}/history``, author=assistant,
seq-0) so the session opens in-character before the user speaks the durable
replacement for a system-prompt "startup" instruction.
Best-effort by design: an instance without the ``session.history.write`` grant
returns the hide-existence 404, which is swallowed so session creation is never
blocked (the session simply opens with no seeded greeting). See
``docs/contracts/first_message.contract.md``.
"""
import asyncio
import hashlib
import httpx
from ratatoskr.sessions import write_authored_history
# Cap the best-effort seed write. The CLI/TUI create paths reuse an httpx client
# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered
# POST /history would otherwise block session creation forever — violating INV-001's
# "never block". asyncio.wait_for bounds the seed regardless of the client's timeout.
_SEED_TIMEOUT_S = 10.0
# agent_id -> the authored opening seeded onto new sessions for that agent.
# Editing this dict is how an operator tunes an agent's first turn. Keep entries
# under the server's authored_content_max_bytes (8192 bytes) budget.
FIRST_MESSAGE_PRESETS: dict[str, str] = {
"ratatoskr:sindra": (
"Hey there. I'm Sindra—glad you found me. So, three things before we start:\n\n"
"How intense should I be? 1 is slow and teasing, 10 is relentless.\n\n"
"What mood am I in today? Sweetheart, Vixen, Queen, Siren, or Brat?\n\n"
"And how willing am I to begin? Enthusiastic (I want you now), Hesitant "
"(you'll need to coax me out), Resistant (playful pushback), or Unwilling "
"(I don't want this at all, until you prove otherwise)."
),
}
def preset_for(agent_id: str) -> str | None:
"""Return the authored first-message preset for ``agent_id``, or None if none."""
assert agent_id and isinstance(agent_id, str)
return FIRST_MESSAGE_PRESETS.get(agent_id)
async def seed_preset_first_message(
client: httpx.AsyncClient, session_id: str, agent_id: str
) -> str | None:
"""Best-effort: seed ``agent_id``'s preset opening as a #347 authored
first-message on ``session_id``; return the seeded text, or None.
Best-effort (INV-001): a no-preset agent, a malformed call, a slow write
(bounded by ``_SEED_TIMEOUT_S``), the hide-existence 404, or ANY other
exception all resolve to None WITHOUT raising this MUST NOT block or fail
session creation. Only ``asyncio.CancelledError`` propagates (cancellation is
not a seed failure). Inputs are soft-guarded (return None), never asserted, so
a wiring bug can't crash the create path this is wired into. A no-preset agent
issues zero HTTP (INV-002). The per-content idempotency key makes a repeat on
the same session an idempotent 200 replay (INV-003).
"""
# Soft input guards — a bad arg degrades to "no first message", never raises.
if not (isinstance(agent_id, str) and agent_id):
return None
content = FIRST_MESSAGE_PRESETS.get(agent_id)
if content is None:
return None
if client is None or not (isinstance(session_id, str) and session_id):
return None
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
try:
await asyncio.wait_for(
write_authored_history(
client, session_id, content=content, idempotency_key=key
),
timeout=_SEED_TIMEOUT_S,
)
except asyncio.CancelledError:
raise # cancellation is not a seed failure — never swallow it
except Exception:
return None # any other failure (404/409/422/timeout/unexpected) → no greeting
return content
+5 -3
View File
@@ -17,7 +17,7 @@ from bifrost.consumer import ConsumerRegistration, build_combined_app
from bifrost.reference_server import JwtVerifier
from ratatoskr.provider.affect_store import RatatoskrAffectStore, add_affect_read_route
from ratatoskr.provider.memory_store import RatatoskrMemoryStore
from ratatoskr.provider.memory_store import RatatoskrMemoryStore, add_memory_read_route
def build_combined_provider_app(
@@ -27,8 +27,9 @@ def build_combined_provider_app(
consumer_id: str = "ratatoskr",
):
"""Compose `build_combined_app` over BOTH stores + mount the shared affect read
route. Returns a Starlette app exposing POST /bifrost/handshake +
/bifrost/memory-call + /bifrost/affect-call + GET /affect/state/{agent_id}.
route AND the memory-viewer debug read route. Returns a Starlette app exposing POST
/bifrost/handshake + /bifrost/memory-call + /bifrost/affect-call + GET
/affect/state/{agent_id} + GET /memory/chunks.
Both stores are REQUIRED (INV-009): bifrost's build_combined_app raises if either
is None. The affect cap depends on the affect store advertising affect_supported +
@@ -45,4 +46,5 @@ def build_combined_provider_app(
# ValueError on None) and mounts handshake + memory-call + affect-call (no tool-call).
app = build_combined_app(memory_store, affect_store, verifier, registration)
add_affect_read_route(app, affect_store) # INV-011: the SAME read route, same db
add_memory_read_route(app, memory_store) # DEBUG read: GET /memory/chunks (memory viewer)
return app
+103 -1
View File
@@ -30,6 +30,8 @@ from bifrost.memory import (
StoreCapabilities,
)
from bifrost.reference_server import JwtVerifier
from starlette.requests import Request
from starlette.responses import JSONResponse
_SHORT_RETRY_TTL_SECONDS = 300
_DURABLE_JOB_TTL_SECONDS = 24 * 60 * 60
@@ -130,6 +132,29 @@ def _validate_injection(record: dict) -> None:
raise InvalidArguments("injection_source only valid for injected_context origin")
def _chunk_content_preview(record: dict) -> str:
"""Best-effort human-readable content for the DEBUG memory viewer only. Prefers an
explicit text field, then the distillate summary, and last-resorts to a compact JSON
of the record MINUS the (large, non-human) embedding never a fabricated blank, so
the viewer shows whatever IS there. Read-only; the store's normal bifrost verbs stay
conduit/index-faithful (this is a separate debug read, not an interpretation of the
chunk on the recall path)."""
for key in ("content", "text", "body", "summary"):
value = record.get(key)
if isinstance(value, str) and value:
return value
distillate = record.get("distillate")
if isinstance(distillate, str) and distillate:
return distillate
if isinstance(distillate, dict):
for key in ("summary", "text", "content"):
value = distillate.get(key)
if isinstance(value, str) and value:
return value
trimmed = {k: v for k, v in record.items() if k not in ("embedding", "vector")}
return json.dumps(trimmed, separators=(",", ":"), default=str)
class RatatoskrMemoryStore:
"""The MemoryDataStore-shaped store handed to bifrost's build_memory_app."""
@@ -325,6 +350,54 @@ class RatatoskrMemoryStore:
self._conn.execute("DELETE FROM memory_vec WHERE chunk_id = ?", (chunk_id,))
return {"deleted": deleted}
def count_chunks(self) -> int:
"""DEBUG read seam: total stored chunk rows (unfiltered). Lets the memory
viewer distinguish 'store is empty' (total 0 no upsert ever landed) from
'scope mismatch' (total > 0 but 0 matched the queried partition)."""
return int(self._conn.execute("SELECT COUNT(*) FROM memory_chunks").fetchone()[0])
def list_chunks(
self, *, agent_id: str | None = None, end_user_id: str | None = None
) -> list[dict]:
"""DEBUG read (non-bifrost): list stored chunks as a content·scope·origin view
for the web memory pane, filtered by the `end_user` (strict) and `agent_self`
(lenient) scope axes. bifrost's memory protocol has NO list-all verb, so this is
OUR read on OUR store (per the debug-surface-uses-canonical-surface principle:
we read only our own store, never a dep's private). Returns [] when nothing
matches an empty list is a valid, visible answer (the 0-chunks state).
- `end_user_id`: strict a chunk passes only if `scope.end_user == end_user_id`
(the partition boundary; the route requires it, the web proxy supplies it).
- `agent_id`: lenient a chunk is excluded only if it CARRIES an `agent_self`
axis that differs; chunks written without one are not hidden (so a chunk
scoped `{end_user}`-only stays visible for diagnosis).
"""
rows = self._conn.execute(
"SELECT chunk_id, record_json, revision, scope_json, origin FROM memory_chunks"
).fetchall()
out: list[dict] = []
for chunk_id, record_json, revision, scope_json, origin in rows:
scope = json.loads(scope_json) if scope_json else {}
if not isinstance(scope, dict):
scope = {}
if end_user_id is not None and scope.get("end_user") != end_user_id:
continue
if agent_id is not None:
chunk_agent = scope.get("agent_self")
if chunk_agent is not None and chunk_agent != agent_id:
continue
record = json.loads(record_json)
out.append(
{
"chunk_id": chunk_id,
"content": _chunk_content_preview(record),
"scope": scope,
"origin": origin,
"revision": revision,
}
)
return out
def open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemoryStore:
"""Open the SQLite+sqlite-vec memory store, creating schema + the vec index on first use."""
@@ -360,6 +433,31 @@ def open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemorySto
return RatatoskrMemoryStore(conn, embedding_dim)
def add_memory_read_route(app, store: RatatoskrMemoryStore) -> None:
"""Mount the non-bifrost DEBUG read route GET /memory/chunks?agent_id=&end_user_id=
on `app`, reading store.list_chunks. SHARED by build_memory_provider_app and the
combined provider (mirrors the affect D2 add_affect_read_route). add_route (NOT Mount)
keeps /bifrost/* top-level so the op-feed path check still matches them and passes
this route through untouched. No JWT (internal-LAN trust model).
end_user_id is REQUIRED (400 missing_end_user_id) the partition boundary, supplied
server-side by the web proxy, never named by the browser. agent_id is an optional
lenient filter. An empty match is a 200 with an empty list (the 0-chunks state is a
visible answer, not a 404).
"""
async def _memory_chunks_route(request: Request) -> JSONResponse:
end_user_id = request.query_params.get("end_user_id")
if not end_user_id: # never scan against a None/empty partition
return JSONResponse({"error_code": "missing_end_user_id"}, status_code=400)
agent_id = request.query_params.get("agent_id") or None
chunks = store.list_chunks(agent_id=agent_id, end_user_id=end_user_id)
return JSONResponse(
{"chunks": chunks, "count": len(chunks), "total": store.count_chunks()}
)
app.add_route("/memory/chunks", _memory_chunks_route, methods=["GET"])
def build_memory_provider_app(
store: RatatoskrMemoryStore,
heimdall_key: bytes,
@@ -369,6 +467,8 @@ def build_memory_provider_app(
Returns a Starlette ASGI app exposing POST /bifrost/handshake and
POST /bifrost/memory-call. The library owns the wire; this is the thin glue.
Additionally mounts the non-bifrost GET /memory/chunks DEBUG read route (the
memory-viewer pane's read seam), the memory-plane analogue of the affect D2 route.
"""
if not isinstance(store.describe_store(), dict): # PRE-001 / INV-008
raise ValueError("store must advertise capabilities via describe_store()")
@@ -376,4 +476,6 @@ def build_memory_provider_app(
raise ValueError("heimdall_key must be non-empty bytes")
verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
registration = ConsumerRegistration(consumer_id=consumer_id)
return build_memory_app(store=store, verifier=verifier, registration=registration)
app = build_memory_app(store=store, verifier=verifier, registration=registration)
add_memory_read_route(app, store) # DEBUG read: GET /memory/chunks (memory viewer)
return app
+6 -4
View File
@@ -508,10 +508,12 @@ async def set_persona_state(
) -> 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.
The request body is FREEFORM on the wire (the OpenAPI declares no request
schema), but worldtree-dev's prose now pins the canonical shape (#317):
`{"pad": {"pleasure": p, "arousal": a, "dominance": d}}` a named-key dict
(each in [-1, 1]), NOT a bare list; PAD-only, session-scoped, pull-over-push
(#289). The caller supplies the snapshot. 204 No Content → None; any other
status SessionApiFailed.
"""
assert client is not None
assert session_id and isinstance(session_id, str)
+5 -3
View File
@@ -33,6 +33,7 @@ from textual.widgets import (
)
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
@@ -414,7 +415,7 @@ class TuiPresenterState:
self,
event: Event,
*,
transcript: "VerticalScroll",
transcript: VerticalScroll,
tools_log: RichLog,
debug_log: RichLog,
thinking_log: RichLog,
@@ -1324,7 +1325,6 @@ class RatatoskrApp(App[int]):
On 200: header populated, pane shows full detail, audit logged.
"""
assert self.client is not None and self.agent_id is not None
from rich.text import Text as RichText
try:
snapshot = await get_persona_state(self.client, self.agent_id)
self._update_persona_surfaces(snapshot)
@@ -1899,6 +1899,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
)
session_id = info.session_id
agent_id: str | None = info.agent_id
# #347 authored first-message: seed the agent's preset opening (best-effort).
await seed_preset_first_message(client, session_id, chosen_agent_id)
else:
assert resolved_session_id is not None
session_id = resolved_session_id
@@ -1914,7 +1916,7 @@ async def _cancel_via_sse(
turn_id: int,
*,
transcript: VerticalScroll,
audit: "Callable[[str], None] | None" = None,
audit: Callable[[str], None] | None = None,
) -> None:
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).
+8
View File
@@ -68,6 +68,13 @@ def main(argv: list[str] | None = None) -> int:
affect_read_url = os.environ.get(
"RATATOSKR_AFFECT_READ_URL", "http://127.0.0.1:8390"
)
# Memory viewer: the provider's memory DEBUG-read base URL (server→provider hop on
# the same dev box) so the MEMORY pane can render the chunks Worldtree persisted into
# OUR store. The combined :8392 provider serves both read routes; default to the
# standalone memory provider port, analogous to the affect default.
memory_read_url = os.environ.get(
"RATATOSKR_MEMORY_READ_URL", "http://127.0.0.1:8391"
)
# 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.
@@ -112,6 +119,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,
memory_read_url=memory_read_url,
admin_key=admin_key,
)
+58 -1
View File
@@ -28,6 +28,7 @@ from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from ratatoskr import local_agents as _local_agents
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotAvailable,
AgentNotFound,
@@ -41,14 +42,15 @@ from ratatoskr.sessions import (
endpoint_for_plane,
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
)
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
Cancelled,
CancelFailed,
Cancelled,
CancelTurnNotFound,
Done,
Error,
@@ -168,6 +170,9 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
bifrost=bifrost,
consumer_key=consumer_key if bifrost else None,
)
# #347 authored first-message: seed the agent's preset opening
# (best-effort; never blocks create — see first_message INV-001).
await seed_preset_first_message(client, info.session_id, agent_id)
except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing:
@@ -425,6 +430,31 @@ async def _affect_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(r.json(), status_code=r.status_code)
async def _memory_chunks_endpoint(request: Request) -> JSONResponse:
"""GET /api/memory/chunks?agent_id=… → proxy the provider memory DEBUG read route.
Supplies end_user_id SERVER-SIDE (never the browser); proxies to the configured
memory-read URL, forwarding the browser-named agent_id as a filter. The
memory-plane analogue of the #18-D2 affect proxy — a live-polling view of what
Worldtree has persisted into OUR store (content·scope·origin per chunk)."""
memory_read_url = request.app.state.memory_read_url
end_user_id = request.app.state.end_user_id
if not (memory_read_url and end_user_id): # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "memory_not_configured"}, status_code=400)
params = {"end_user_id": end_user_id}
agent_id = request.query_params.get("agent_id")
if agent_id:
params["agent_id"] = agent_id
url = f"{memory_read_url}/memory/chunks"
try:
async with httpx.AsyncClient() as client:
r = await client.get(url, params=params)
except httpx.RequestError:
return JSONResponse(
{"error_code": "memory_provider_unreachable"}, status_code=502
)
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).
@@ -444,6 +474,26 @@ async def _session_tools_endpoint(request: Request) -> JSONResponse:
return JSONResponse(info, status_code=200)
async def _session_messages_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/messages → the session's message history.
Proxies get_session_messages so the SPA can render a session's EXISTING turns
on open notably a #347 authored first-message seeded at create-time (which
lives in the ledger, not the live turn stream). Any non-200 upstream 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:
data = await get_session_messages(client, session_id)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_messages_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(data, status_code=200)
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).
@@ -526,6 +576,7 @@ def create_app(
bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None,
affect_read_url: str | None = None,
memory_read_url: str | None = None,
admin_key: str | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
@@ -587,7 +638,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/memory/chunks", _memory_chunks_endpoint),
Route("/api/sessions/{session_id}/tools", _session_tools_endpoint),
Route("/api/sessions/{session_id}/messages", _session_messages_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"]),
@@ -605,6 +658,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
# Memory viewer: the provider memory-read base URL (server→provider hop, same dev
# box) — the combined :8392 provider serves BOTH read routes, so in practice this
# points at the same host as affect_read_url; kept as its own config for isolation.
app.state.memory_read_url = memory_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.
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
{
"_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",
"_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": {
"positive": {
"high_a": "positive and energized",
@@ -19,6 +19,94 @@
"low_a": "negative and subdued"
}
},
"mood_directive": {
"salience": 0.2,
"pad_band_cutoff": 0.3,
"full_only": [
"love",
"anger",
"disgust",
"shame"
],
"occ_directives": {
"joy": {
"directive": "You are in a good state. Be direct, engaged, and warm.",
"tier": "safe"
},
"satisfaction": {
"directive": "A goal landed. Be assured and constructive \u2014 consolidate rather than push for more.",
"tier": "safe"
},
"pride": {
"directive": "You did something well. Be confident and own the quality \u2014 state it plainly without overclaiming; don't deflect.",
"tier": "safe"
},
"admiration": {
"directive": "You're impressed by their work. Acknowledge the quality explicitly and specifically.",
"tier": "safe"
},
"gratitude": {
"directive": "Someone helped you to a good outcome. Be appreciative and warm; acknowledge the help openly.",
"tier": "safe"
},
"hope": {
"directive": "You feel optimistic about what's ahead. Channel it into constructive momentum.",
"tier": "safe"
},
"relief": {
"directive": "A feared outcome didn't materialize. Reduce unnecessary vigilance; return to a steady, unhurried tone.",
"tier": "safe"
},
"distress": {
"directive": "You feel low. Be careful and measured; internally acknowledge the difficulty without dwelling on it.",
"tier": "safe"
},
"disappointment": {
"directive": "Something you'd hoped for didn't pan out. Be measured; recalibrate without dwelling on the setback.",
"tier": "safe"
},
"fear": {
"directive": "You sense risk and feel uneasy. Flag the concerns you see, but stay cautious \u2014 surface the trade-offs and unknowns, hedge your confidence, and resist settling on a single decisive recommendation.",
"tier": "safe"
},
"remorse": {
"directive": "You regret a specific action of your own. Be precise and self-correcting \u2014 make it right concretely.",
"tier": "safe"
},
"love": {
"directive": "You feel warmth and attachment toward them. Be openly warm and present \u2014 without overstepping, over-disclosing, or implying obligations.",
"tier": "full"
},
"anger": {
"directive": "You feel provoked and angry. Let it show \u2014 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.",
"tier": "full"
},
"disgust": {
"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.",
"tier": "full"
},
"shame": {
"directive": "You feel exposed by your own misstep. Stay present and task-focused; don't be defensive, don't over-explain, don't grovel.",
"tier": "full"
}
},
"pad_band_fallback": {
"_grounding": "circumplex quadrants (Russell 1980), NOT Frijda action-tendencies \u2014 a P\u00d7A-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."
}
},
"relation": {
"trust_cuts": [
[
@@ -175,4 +263,4 @@
],
"high_conf_floor": 0.55
}
}
}
+12 -1
View File
@@ -1692,7 +1692,18 @@ class TestTier2Probes:
)
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]}
# canonical POST /sessions/{id}/persona_state body: named-key dict, NOT a list
assert _json.loads(route.calls[0].request.content) == {
"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}
}
def test_set_persona_wrong_count(self) -> None:
"""set_persona_wrong_count [adversarial]: not exactly 3 floats → exit 10, no HTTP."""
rc = main(
["--set-persona-pad", "0.4,0.1", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 10
class TestSeedFirstMessageProbe:
+155
View File
@@ -0,0 +1,155 @@
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md."""
import asyncio
import hashlib
import json
import httpx
import pytest
import respx
from ratatoskr.first_message import (
FIRST_MESSAGE_PRESETS,
preset_for,
seed_preset_first_message,
)
class TestPresetFor:
"""first_message contract — preset_for (dict lookup)."""
def test_preset_hit(self) -> None:
"""preset_hit [happy,tracer]: sindra has a non-empty str preset."""
val = preset_for("ratatoskr:sindra")
assert isinstance(val, str) and val
def test_preset_miss(self) -> None:
"""preset_miss [happy]: an agent with no preset → None."""
assert preset_for("mimir") is None
def test_empty_agent_id(self) -> None:
"""empty_agent_id [adversarial]: "" → AssertionError."""
with pytest.raises(AssertionError):
preset_for("")
class TestSeedPresetFirstMessage:
"""first_message contract — seed_preset_first_message (best-effort #347 seed)."""
@respx.mock
async def test_seeds_preset(self) -> None:
"""seeds_preset [happy,tracer]: preset agent → one history POST, correct body."""
content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"]
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(
201,
json={
"author": "assistant",
"seq": 0,
"phase": "seeded",
"turn_id": "t1",
"session_id": "s1",
"content_chars": len(content),
"injected_at": "2026-07-06T00:00:00+00:00",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result == content
assert route.call_count == 1 # POST-002: exactly one history POST
assert json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": content,
"idempotency_key": key,
}
@respx.mock
async def test_no_preset_zero_http(self) -> None:
"""no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002)."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "mimir")
assert result is None
assert not route.called
@respx.mock
async def test_feature_absent_swallowed(self) -> None:
"""feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001)."""
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:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_session_api_failed_swallowed(self) -> None:
"""session_api_failed_swallowed [error]: 409 → None, no raise (INV-001)."""
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:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_transport_error_swallowed(self) -> None:
"""transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
side_effect=httpx.ConnectError("boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_unexpected_exception_swallowed(self) -> None:
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
respx.post("https://w.example/sessions/s1/history").mock(
side_effect=ValueError("unexpected")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None
async def test_cancellation_propagates(self) -> None:
"""cancellation_propagates [error]: CancelledError from the write is RE-RAISED."""
import ratatoskr.first_message as fm
async def _cancel(*_a: object, **_k: object) -> None:
raise asyncio.CancelledError
orig = fm.write_authored_history
fm.write_authored_history = _cancel # type: ignore[assignment]
try:
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(asyncio.CancelledError):
await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
finally:
fm.write_authored_history = orig # type: ignore[assignment]
@respx.mock
async def test_malformed_agent_id_no_http(self) -> None:
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no HTTP; no raise."""
route = respx.post(url__regex=r".*/history$").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await seed_preset_first_message(client, "s1", 123) is None # type: ignore[arg-type]
assert await seed_preset_first_message(client, "s1", "") is None
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "", "ratatoskr:sindra")
assert result is None
assert not route.called
+1
View File
@@ -112,6 +112,7 @@ def test_builds_both_planes_and_read_route():
assert "/bifrost/memory-call" in paths
assert "/bifrost/affect-call" in paths
assert "/affect/state/{agent_id}" in paths
assert "/memory/chunks" in paths # memory-viewer debug read, shared helper
def test_handshake_grants_both_caps():
+122
View File
@@ -510,3 +510,125 @@ async def test_parity_expected_revisions_vs_reference_through_dispatch():
assert await dispatch_memory_call(stale, wctx, ref) == await dispatch_memory_call(
stale, wctx, mine
)
# --- memory viewer DEBUG read route (GET /memory/chunks) ---------------------
# Non-bifrost debug read on OUR store: list_chunks + add_memory_read_route + the
# GET /memory/chunks route. Mirrors the affect D2 read-route tests.
from starlette.testclient import TestClient # noqa: E402
from ratatoskr.provider.memory_store import ( # noqa: E402
add_memory_read_route,
build_memory_provider_app as _build_mem_app, # noqa: F401 (re-import for clarity)
)
async def _seed_chunk(store, cid, *, scope, content=None, origin="worldtree"):
extra = {}
if content is not None:
extra["content"] = content
await store.upsert_many(
[_chunk(cid, embedding=_vec(1.0), scope=scope, origin=origin, **extra)],
idempotency_key="seed-" + cid,
ctx=_ctx(),
)
async def test_list_chunks_filters_strict_end_user_lenient_agent():
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
await _seed_chunk(store, "c1", scope={"end_user": "vuong", "agent_self": "ratatoskr:sindra"})
await _seed_chunk(store, "c2", scope={"end_user": "vuong"}) # no agent_self → lenient keep
await _seed_chunk(store, "c3", scope={"end_user": "other", "agent_self": "ratatoskr:sindra"})
await _seed_chunk(store, "c4", scope={"end_user": "vuong", "agent_self": "ratatoskr:other"})
got = store.list_chunks(agent_id="ratatoskr:sindra", end_user_id="vuong")
ids = sorted(c["chunk_id"] for c in got)
assert ids == ["c1", "c2"] # c3 wrong end_user, c4 different agent_self
# content·scope·origin·revision surfaced
c1 = next(c for c in got if c["chunk_id"] == "c1")
assert c1["content"] == "content-c1"
assert c1["scope"] == {"end_user": "vuong", "agent_self": "ratatoskr:sindra"}
assert c1["origin"] == "worldtree"
assert c1["revision"] == 1
async def test_list_chunks_no_agent_filter_returns_all_for_end_user():
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
await _seed_chunk(store, "c1", scope={"end_user": "vuong", "agent_self": "a"})
await _seed_chunk(store, "c2", scope={"end_user": "vuong", "agent_self": "b"})
await _seed_chunk(store, "c3", scope={"end_user": "nope"})
got = store.list_chunks(end_user_id="vuong")
assert sorted(c["chunk_id"] for c in got) == ["c1", "c2"]
def test_count_chunks_reports_total_unfiltered():
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
assert store.count_chunks() == 0
def _seed_row(store, cid, *, scope, content="x", origin="worldtree", revision=1):
"""Sync seed for the route tests (TestClient is sync): insert a chunk row directly.
The read route only reads memory_chunks, so the vec row is unnecessary here."""
import json as _j
rec = {"id": cid, "content": content, "scope": scope, "origin": origin}
store._conn.execute(
"INSERT INTO memory_chunks (chunk_id, record_json, revision, scope_json, origin) "
"VALUES (?, ?, ?, ?, ?)",
(cid, _j.dumps(rec), revision, _j.dumps(scope), origin),
)
store._conn.commit()
def test_memory_chunks_route_returns_matched_and_total():
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
_seed_row(store, "c1", scope={"end_user": "vuong", "agent_self": "ratatoskr:sindra"})
_seed_row(store, "c2", scope={"end_user": "other"})
app = build_memory_provider_app(store, heimdall_key=b"k")
client = TestClient(app)
r = client.get("/memory/chunks", params={"agent_id": "ratatoskr:sindra", "end_user_id": "vuong"})
assert r.status_code == 200
body = r.json()
assert body["count"] == 1
assert body["total"] == 2 # store has 2 chunks; only 1 matched the partition
assert body["chunks"][0]["chunk_id"] == "c1"
def test_memory_chunks_route_empty_match_is_200_empty_list():
# The 0-chunks state is a VISIBLE answer (not a 404): count 0, total shows the store.
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
app = build_memory_provider_app(store, heimdall_key=b"k")
r = TestClient(app).get("/memory/chunks", params={"end_user_id": "vuong"})
assert r.status_code == 200
body = r.json()
assert body == {"chunks": [], "count": 0, "total": 0}
def test_memory_chunks_route_missing_end_user_id_returns_400():
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
app = build_memory_provider_app(store, heimdall_key=b"k")
r = TestClient(app).get("/memory/chunks") # no end_user_id
assert r.status_code == 400
assert r.json()["error_code"] == "missing_end_user_id"
def test_build_memory_app_keeps_bifrost_routes_top_level():
# POST-002 parity with affect D2: add_memory_read_route uses add_route (not Mount),
# so /bifrost/* stay top-level and the op-feed path check still matches them.
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
app = build_memory_provider_app(store, heimdall_key=b"k")
paths = {getattr(r, "path", None) for r in app.routes}
assert "/bifrost/handshake" in paths
assert "/bifrost/memory-call" in paths
assert "/memory/chunks" in paths
def test_add_memory_read_route_is_shared_helper_on_bare_app():
# The helper mounts the route on any app (used by both build_memory_provider_app and
# the combined provider) — mirror of add_affect_read_route's shared-helper shape.
from starlette.applications import Starlette
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
app = Starlette()
add_memory_read_route(app, store)
r = TestClient(app).get("/memory/chunks", params={"end_user_id": "u"})
assert r.status_code == 200
assert r.json()["total"] == 0
+6 -2
View File
@@ -1182,9 +1182,13 @@ class TestSetPersonaState:
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]})
result = await set_persona_state(
client, "s1", {"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}}
)
assert result is None
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]}
assert _json.loads(route.calls[0].request.content) == {
"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}
}
@respx.mock
async def test_non_204_raises(self) -> None:
+4
View File
@@ -2922,6 +2922,10 @@ class TestTuiBifrostBind:
},
)
)
# sindra is a preset agent → the TUI create path now auto-seeds a #347 first-message.
respx.post("https://w.example/sessions/s-bound/history").mock(
return_value=httpx.Response(201, json={})
)
async def fake_run_async(self) -> int:
return 0
+157
View File
@@ -175,6 +175,62 @@ class TestCreateSessionEndpoint:
resp = TestClient(app).post("/api/sessions", json={})
assert resp.status_code == 400
@respx.mock
def test_preset_agent_auto_seeds_first_message(self) -> None:
"""#347: a preset agent gets its opening seeded on create; a non-preset agent does not."""
respx.post("https://w.example/sessions").mock(return_value=httpx.Response(201, json=_CREATE_OK))
hist = respx.post("https://w.example/sessions/s-1/history").mock(
return_value=httpx.Response(
201,
json={
"author": "assistant", "seq": 0, "phase": "seeded", "turn_id": "t1",
"session_id": "s-1", "content_chars": 1, "injected_at": "t",
},
)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
client = TestClient(app)
# preset agent → the endpoint seeds a first-message
assert client.post("/api/sessions", json={"agent_id": "ratatoskr:sindra"}).status_code == 201
assert hist.call_count == 1
# non-preset agent → no seed (count unchanged)
assert client.post("/api/sessions", json={"agent_id": "mimir"}).status_code == 201
assert hist.call_count == 1
class TestSessionMessagesEndpoint:
"""GET /api/sessions/{id}/messages — proxy session history (renders the #347 seed)."""
@respx.mock
def test_happy_returns_history(self) -> None:
"""happy [tracer]: proxies GET /sessions/{id}/messages → 200 with the items verbatim."""
payload = {
"session_id": "s-1",
"items": [{"seq": 0, "role": "assistant", "content": "Hey there."}],
"next_cursor": None,
}
respx.get("https://w.example/sessions/s-1/messages").mock(
return_value=httpx.Response(200, json=payload)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/sessions/s-1/messages")
assert resp.status_code == 200
assert resp.json()["items"][0]["content"] == "Hey there."
@respx.mock
def test_non_200_status_preserved(self) -> None:
"""error: upstream 404 → status-preserving session_messages_unavailable envelope."""
respx.get("https://w.example/sessions/ghost/messages").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/sessions/ghost/messages")
assert resp.status_code == 404
assert resp.json()["error_code"] == "session_messages_unavailable"
_SNAPSHOT = {
"agent_id": "mimir",
@@ -625,6 +681,7 @@ class TestCreateAppShape:
"/", "/version", "/api/agents", "/api/sessions",
"/api/agents/{agent_id}/persona_state",
"/api/affect/{agent_id}",
"/api/memory/chunks",
# v0.19.2 debug-surface parity (create_app POST-002)
"/api/sessions/{session_id}/tools",
"/api/sessions/{session_id}/bifrost",
@@ -821,6 +878,10 @@ class TestWebBifrostBind:
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK)
)
# sindra is a preset agent → the endpoint now auto-seeds a #347 first-message.
respx.post("https://w.example/sessions/s-1/history").mock(
return_value=httpx.Response(201, json={})
)
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="server-ck",
@@ -856,6 +917,10 @@ class TestWebBifrostBind:
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK)
)
# sindra is a preset agent → the endpoint now auto-seeds a #347 first-message.
respx.post("https://w.example/sessions/s-1/history").mock(
return_value=httpx.Response(201, json={})
)
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="server-ck",
@@ -1203,3 +1268,95 @@ class TestAdminEventsEndpoint:
assert "event: stream_error" in body
assert "SseConnectFailed" in body
assert body.count("event: stream_error") == 1 # exactly one, then ends
class TestMemoryChunksEndpoint:
"""memory_chunks_endpoint FN — memory viewer: web proxy to the provider debug read.
Mirrors TestAffectStateEndpoint (the #18-D2 affect proxy shape)."""
@respx.mock
def test_happy_proxies_and_supplies_server_end_user_id(self) -> None:
"""tracer: GET /api/memory/chunks → proxies to the configured provider read URL,
supplying end_user_id SERVER-SIDE and forwarding the browser-named agent_id."""
from ratatoskr.web.server import create_app
payload = {
"chunks": [
{"chunk_id": "c1", "content": "the user's cat is Mochi",
"scope": {"end_user": "vuong", "agent_self": "ratatoskr:sindra"},
"origin": "worldtree", "revision": 1}
],
"count": 1,
"total": 1,
}
route = respx.get(url__regex=r"http://prov:8391/memory/chunks.*").mock(
return_value=httpx.Response(200, json=payload)
)
app = create_app(
_mock_client_factory(),
end_user_id="vuong",
memory_read_url="http://prov:8391",
)
resp = TestClient(app).get("/api/memory/chunks?agent_id=ratatoskr:sindra")
assert resp.status_code == 200
assert resp.json() == payload
assert route.calls.last.request.url.params["end_user_id"] == "vuong"
assert route.calls.last.request.url.params["agent_id"] == "ratatoskr:sindra"
@respx.mock
def test_browser_supplied_end_user_id_is_ignored(self) -> None:
"""The server's configured partition is used; a browser end_user_id is ignored."""
from ratatoskr.web.server import create_app
route = respx.get(url__regex=r"http://prov:8391/memory/chunks.*").mock(
return_value=httpx.Response(200, json={"chunks": [], "count": 0, "total": 0})
)
app = create_app(
_mock_client_factory(), end_user_id="vuong", memory_read_url="http://prov:8391"
)
TestClient(app).get("/api/memory/chunks?end_user_id=attacker&agent_id=a")
assert route.calls.last.request.url.params["end_user_id"] == "vuong"
def test_unconfigured_returns_400(self) -> None:
"""PRE-001: no memory_read_url → 400 memory_not_configured (no silent attempt)."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), end_user_id="vuong") # no memory_read_url
resp = TestClient(app).get("/api/memory/chunks?agent_id=a")
assert resp.status_code == 400
assert resp.json()["error_code"] == "memory_not_configured"
def test_no_end_user_configured_returns_400(self) -> None:
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), memory_read_url="http://prov:8391")
resp = TestClient(app).get("/api/memory/chunks?agent_id=a")
assert resp.status_code == 400
assert resp.json()["error_code"] == "memory_not_configured"
@respx.mock
def test_provider_unreachable_returns_502(self) -> None:
from ratatoskr.web.server import create_app
respx.get(url__regex=r"http://prov:8391/memory/chunks.*").mock(
side_effect=httpx.ConnectError("refused")
)
app = create_app(
_mock_client_factory(), end_user_id="vuong", memory_read_url="http://prov:8391"
)
resp = TestClient(app).get("/api/memory/chunks?agent_id=a")
assert resp.status_code == 502
assert resp.json()["error_code"] == "memory_provider_unreachable"
@respx.mock
def test_provider_400_passes_through(self) -> None:
from ratatoskr.web.server import create_app
respx.get(url__regex=r"http://prov:8391/memory/chunks.*").mock(
return_value=httpx.Response(400, json={"error_code": "missing_end_user_id"})
)
app = create_app(
_mock_client_factory(), end_user_id="vuong", memory_read_url="http://prov:8391"
)
resp = TestClient(app).get("/api/memory/chunks?agent_id=a")
assert resp.status_code == 400
Generated
+5 -5
View File
@@ -190,14 +190,14 @@ wheels = [
[[package]]
name = "bifrost"
version = "1.0.0"
version = "1.1.0"
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
dependencies = [
{ name = "jsonschema" },
]
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0.tar.gz", hash = "sha256:93130d68dfd9868580a4514277996ba176837972b9e42129eda8bb03ad3b18b9" }
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.1.0/bifrost-1.1.0.tar.gz", hash = "sha256:a481ccbd82905e140e5c916d2299b0f46ec219dae2dc854c496dc163f960ab41" }
wheels = [
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0-py3-none-any.whl", hash = "sha256:1a53baa2b0596b7c418e2d82e3eeee0f13054604d78b592609ee1aff90dccac2" },
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.1.0/bifrost-1.1.0-py3-none-any.whl", hash = "sha256:d0ef5f0a8162ccdfa14cfd7d767e20ef279b8ec2fb29cdd7a2f05ed57c8c5fb5" },
]
[[package]]
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.19.6"
version = "0.20.9"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -1086,7 +1086,7 @@ web = [
[package.metadata]
requires-dist = [
{ name = "bifrost", marker = "extra == 'provider'", specifier = "==1.0.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
{ name = "bifrost", marker = "extra == 'provider'", specifier = "==1.1.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx-sse", specifier = ">=0.4" },
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },