Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0441e319f6 | |||
| 48d51023f2 | |||
| 603e9439d3 | |||
| e5ec63967e | |||
| 24644ab90e | |||
| c988f273b1 |
@@ -82,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)"
|
||||
@@ -125,11 +126,11 @@ functions:
|
||||
flexibility: "open"
|
||||
|
||||
- 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) ; stripPoints(vals) ; proj3(p,a,d) ; trendDelta(vals)"
|
||||
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) — 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 (v0.20.4, adapted from the design prototype; replace the unicode strings). Each PAD fader shows current value + Δ-vs-previous (▲/▼) + a VERTICAL SVG strip (`stripPoints`, 26×132, time down Y newest-at-bottom, value on X ±11, gradient-faded via stripFade{P,A,D}, dot at newest). Each relation metric shows a HORIZONTAL SVG sparkline (`sparkPointsH`, 56×13, auto-scaled to its OWN range, sparkFade gradient + end dot). renderOrbit is now a 3D ISOMETRIC P×A×D cube (`proj3`: P right-down / A left-down / D up, 2:1 iso, center 62,66, scale 26) plotting the trajectory + a pulsing now-marker + a drop line to the D=−1 floor + a floor shadow ellipse (depth cue). All drawn from AFFECT_HIST (rolling, HIST_CAP=24, session-lived); sparkline coords are computed numerics (no upstream strings → INV-004 trivially held). Gradients live in one hidden `<defs>` svg in the console."
|
||||
- "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"
|
||||
@@ -144,8 +145,26 @@ functions:
|
||||
- "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."
|
||||
|
||||
+15
-3
@@ -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,7 +39,15 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-07-06 (session cont.):_
|
||||
_As of 2026-07-07 (session cont.):_
|
||||
|
||||
**✅ 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.
|
||||
|
||||
@@ -57,7 +65,7 @@ _As of 2026-07-06 (session cont.):_
|
||||
|
||||
**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
|
||||
|
||||
@@ -169,6 +177,10 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[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+26P−26A, y=66+13P+13A−26D`), 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.
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.20.5"
|
||||
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)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -430,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).
|
||||
|
||||
@@ -551,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.
|
||||
@@ -612,6 +638,7 @@ 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),
|
||||
@@ -631,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.
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
--fg-muted: var(--aus-dark-50);
|
||||
--fg-on-blue: var(--aus-black);
|
||||
|
||||
/* RP semantic-render colors (transcript). Retune these two to taste — they cascade
|
||||
through the --fg-* indirection so they auto-adapt to the light theme. */
|
||||
--md-speech: var(--fg-0); /* "quoted" dialogue — the said words, bright */
|
||||
--md-action: var(--fg-3); /* *action* / narration — italic, receded */
|
||||
|
||||
/* ---- ratatoskr console surfaces (deeper than aus-black for monitor depth) ---- */
|
||||
--rk-deep: #14161d; /* left rail / deepest well */
|
||||
--rk-canvas: #171a23; /* page canvas */
|
||||
@@ -300,10 +305,19 @@ body[data-theme="light"] #theme-toggle .i-moon { display: inline; }
|
||||
.md-body .md-h4, .md-body .md-h5, .md-body .md-h6 { font-size: 14px; }
|
||||
.md-body strong { color: var(--fg-0); }
|
||||
.md-body em { font-style: italic; }
|
||||
/* RP semantic registers: "quoted" dialogue vs *action* narration */
|
||||
.md-body .md-speech { color: var(--md-speech); }
|
||||
.md-body em.md-action { color: var(--md-action); }
|
||||
.md-body code { font-family: var(--font-mono); font-size: .86em; background: var(--rk-well); color: var(--aus-bright-cyan); border: 1px solid var(--border-subtle); padding: 1px 6px; border-radius: 4px; }
|
||||
.md-body pre.md-code { font-family: var(--font-mono); font-size: 12px; background: var(--rk-well); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 10px 13px; overflow-x: auto; color: var(--fg-1); margin: 8px 0; }
|
||||
.md-body ul, .md-body ol { margin: 4px 0 10px; padding-left: 22px; }
|
||||
.md-body li ul, .md-body li ol { margin: 2px 0 2px; } /* nested lists: tighter */
|
||||
.md-body li { margin: 2px 0; }
|
||||
/* GFM tables */
|
||||
.md-body .md-table { border-collapse: collapse; margin: 8px 0 12px; font-size: 13.5px; display: block; overflow-x: auto; max-width: 100%; }
|
||||
.md-body .md-table th, .md-body .md-table td { border: 1px solid var(--border-subtle); padding: 5px 10px; text-align: left; }
|
||||
.md-body .md-table th { background: var(--rk-well); color: var(--fg-0); font-weight: 600; }
|
||||
.md-body .md-table tbody tr:nth-child(even) td, .md-body .md-table tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--rk-well) 45%, transparent); }
|
||||
.md-body .md-quote { border-left: 2px solid var(--border-default); padding-left: 12px; color: var(--fg-2); font-style: italic; margin: 6px 0; }
|
||||
.md-body a { color: var(--aus-bright-blue); text-decoration: none; }
|
||||
.md-body a:hover { color: var(--aus-bright-cyan); }
|
||||
@@ -386,11 +400,16 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
|
||||
.pad-row { display: flex; gap: 16px; align-items: stretch; }
|
||||
.faders { flex: 1; display: flex; justify-content: space-between; padding: 0 2px; }
|
||||
.fader-viz { display: flex; gap: 4px; align-items: stretch; }
|
||||
.strip { display: block; flex: 0 0 auto; }
|
||||
.strip-axis { stroke: var(--border-subtle); }
|
||||
#strip-p .strip-dot { fill: var(--aus-bright-green); }
|
||||
#strip-a .strip-dot { fill: var(--aus-bright-blue); }
|
||||
#strip-d .strip-dot { fill: var(--aus-bright-cyan); }
|
||||
/* per-turn Δ strips: a column of 12 diverging bars (newest at bottom), offset L/R
|
||||
of a center line by that turn's Δ. HTML (not SVG) so bars key off inline geometry. */
|
||||
.strip { position: relative; width: 26px; height: 132px; flex: 0 0 auto; }
|
||||
.strip .strip-mid { position: absolute; left: 12.5px; top: 4px; bottom: 4px; width: 1px; background: var(--border-subtle); }
|
||||
.strip .strip-col { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: space-between; padding: 4px 0; }
|
||||
.strip .strip-cell { position: relative; height: 8px; }
|
||||
.strip .strip-cell > i { position: absolute; top: 50%; transform: translateY(-50%); border-radius: 2px; display: block; }
|
||||
#strip-p .strip-cell > i { background: var(--aus-bright-green); }
|
||||
#strip-a .strip-cell > i { background: var(--aus-bright-blue); }
|
||||
#strip-d .strip-cell > i { background: var(--aus-bright-cyan); }
|
||||
#affect-console.narrow .strip { display: none; }
|
||||
.fader { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.fader .bar { position: relative; width: 12px; height: 132px; background: var(--rk-canvas); border: 1px solid var(--border-subtle); border-radius: 4px; }
|
||||
@@ -447,6 +466,16 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
|
||||
.ci-note b { color: var(--fg-2); font-style: normal; }
|
||||
.nl-fallback { opacity: 0.82; }
|
||||
|
||||
/* memory viewer — durable chunks Worldtree persisted into OUR store (content·scope·origin) */
|
||||
#pane-memory .head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
|
||||
#pane-memory .head .sub { margin-left: auto; font-family: var(--font-mono); font-size: 9px; color: var(--fg-muted); }
|
||||
#mem-count { color: var(--aus-bright-cyan); font-variant-numeric: tabular-nums; }
|
||||
.mem-chunk { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 8px 10px; margin-bottom: 8px; background: var(--rk-canvas); }
|
||||
.mem-chunk .mem-meta { display: flex; gap: 8px; align-items: baseline; font-family: var(--font-mono); font-size: 9px; margin-bottom: 5px; }
|
||||
.mem-chunk .mem-scope { color: var(--fg-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mem-chunk .mem-origin { margin-left: auto; flex: 0 0 auto; color: var(--aus-bright-blue); }
|
||||
.mem-chunk .mem-content { font-family: var(--font-sans); font-size: 12px; color: var(--fg-1); line-height: 1.5; word-break: break-word; }
|
||||
|
||||
/* generic empty / notes */
|
||||
.empty { color: var(--fg-muted); font-style: italic; font-size: 11px; text-align: center; padding: 14px 8px; line-height: 1.6; }
|
||||
.mono-note { font-family: var(--font-mono); font-size: 9.5px; color: var(--fg-muted); margin: 2px 0 8px; }
|
||||
@@ -566,6 +595,7 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
|
||||
<aside id="affect-console">
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
|
||||
<linearGradient id="sparkFade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" style="stop-color:var(--aus-bright-cyan);stop-opacity:0.12"></stop><stop offset="1" style="stop-color:var(--aus-bright-cyan);stop-opacity:0.95"></stop></linearGradient>
|
||||
<pattern id="sparkGrid" width="10" height="6.5" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 6.5" fill="none" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.6"></path></pattern>
|
||||
<linearGradient id="stripFadeP" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:var(--aus-bright-green);stop-opacity:0.06"></stop><stop offset="1" style="stop-color:var(--aus-bright-green);stop-opacity:0.95"></stop></linearGradient>
|
||||
<linearGradient id="stripFadeA" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:var(--aus-blue);stop-opacity:0.06"></stop><stop offset="1" style="stop-color:var(--aus-bright-blue);stop-opacity:0.95"></stop></linearGradient>
|
||||
<linearGradient id="stripFadeD" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:var(--aus-bright-cyan);stop-opacity:0.06"></stop><stop offset="1" style="stop-color:var(--aus-bright-cyan);stop-opacity:0.95"></stop></linearGradient>
|
||||
@@ -580,14 +610,14 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
|
||||
<div class="ac-sec pad-block">
|
||||
<div class="head">
|
||||
<span class="eyebrow">mood · pad</span>
|
||||
<span class="sub" id="pad-samples">— samples —</span>
|
||||
<span class="sub">Δ/turn · last 12 · newest ↓</span>
|
||||
</div>
|
||||
<div class="pad-row">
|
||||
<div class="faders">
|
||||
<div class="fader" title="Pleasure (valence) — how positive vs negative the mood feels. −1 miserable ‥ 0 neutral ‥ +1 elated. Beside the bar: last 12 turns (newest at bottom). Below: Δ since last turn.">
|
||||
<div class="fader-viz">
|
||||
<div class="bar" id="fader-p"><div class="fill"></div><div class="mid"></div></div>
|
||||
<svg class="strip" id="strip-p" width="26" height="132" viewBox="0 0 26 132"><line class="strip-axis" x1="13" y1="4" x2="13" y2="128" stroke-dasharray="2 4"></line><polyline class="strip-line" points="" fill="none" stroke="url(#stripFadeP)" stroke-width="1.5" stroke-linejoin="round"></polyline><circle class="strip-dot" cx="13" cy="124" r="2.4" style="display:none"></circle></svg>
|
||||
<div class="strip" id="strip-p"><div class="strip-mid"></div><div class="strip-col"></div></div>
|
||||
</div>
|
||||
<span class="k">P</span><span class="v" id="pad-p-val">—</span>
|
||||
<span class="d" id="pad-p-d"></span>
|
||||
@@ -595,7 +625,7 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
|
||||
<div class="fader" title="Arousal (activation) — how energized vs calm the agent is. −1 sleepy ‥ 0 neutral ‥ +1 excited. Beside the bar: last 12 turns (newest at bottom). Below: Δ since last turn.">
|
||||
<div class="fader-viz">
|
||||
<div class="bar" id="fader-a"><div class="fill"></div><div class="mid"></div></div>
|
||||
<svg class="strip" id="strip-a" width="26" height="132" viewBox="0 0 26 132"><line class="strip-axis" x1="13" y1="4" x2="13" y2="128" stroke-dasharray="2 4"></line><polyline class="strip-line" points="" fill="none" stroke="url(#stripFadeA)" stroke-width="1.5" stroke-linejoin="round"></polyline><circle class="strip-dot" cx="13" cy="124" r="2.4" style="display:none"></circle></svg>
|
||||
<div class="strip" id="strip-a"><div class="strip-mid"></div><div class="strip-col"></div></div>
|
||||
</div>
|
||||
<span class="k">A</span><span class="v" id="pad-a-val">—</span>
|
||||
<span class="d" id="pad-a-d"></span>
|
||||
@@ -603,28 +633,45 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
|
||||
<div class="fader" title="Dominance (control) — how in-control vs submissive the agent feels. −1 submissive ‥ 0 neutral ‥ +1 dominant. Beside the bar: last 12 turns (newest at bottom). Below: Δ since last turn.">
|
||||
<div class="fader-viz">
|
||||
<div class="bar" id="fader-d"><div class="fill"></div><div class="mid"></div></div>
|
||||
<svg class="strip" id="strip-d" width="26" height="132" viewBox="0 0 26 132"><line class="strip-axis" x1="13" y1="4" x2="13" y2="128" stroke-dasharray="2 4"></line><polyline class="strip-line" points="" fill="none" stroke="url(#stripFadeD)" stroke-width="1.5" stroke-linejoin="round"></polyline><circle class="strip-dot" cx="13" cy="124" r="2.4" style="display:none"></circle></svg>
|
||||
<div class="strip" id="strip-d"><div class="strip-mid"></div><div class="strip-col"></div></div>
|
||||
</div>
|
||||
<span class="k">D</span><span class="v" id="pad-d-val">—</span>
|
||||
<span class="d" id="pad-d-d"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mood-orbit">
|
||||
<svg width="124" height="132" viewBox="0 0 124 132" style="display:block">
|
||||
<rect x="1" y="1" width="122" height="122" rx="6" fill="var(--rk-canvas)" stroke="var(--border-subtle)"></rect>
|
||||
<!-- isometric cube: P→right-down, A→left-down, D→up (center 62,66) -->
|
||||
<polygon points="62,118 114,92 114,40 62,14 10,40 10,92" fill="none" stroke="var(--border-subtle)"></polygon>
|
||||
<line x1="62" y1="66" x2="114" y2="92" stroke="var(--border-subtle)" stroke-dasharray="2 4"></line>
|
||||
<line x1="62" y1="66" x2="10" y2="92" stroke="var(--border-subtle)" stroke-dasharray="2 4"></line>
|
||||
<line x1="62" y1="66" x2="62" y2="14" stroke="var(--border-subtle)" stroke-dasharray="2 4"></line>
|
||||
<line x1="62" y1="66" x2="114" y2="40" stroke="var(--border-default)"></line>
|
||||
<line x1="62" y1="66" x2="10" y2="40" stroke="var(--border-default)"></line>
|
||||
<line x1="62" y1="66" x2="62" y2="118" stroke="var(--border-default)"></line>
|
||||
<text x="117" y="38" text-anchor="end" fill="var(--fg-muted)" font-family="var(--font-mono)" font-size="7.5">+P</text>
|
||||
<text x="8" y="38" fill="var(--fg-muted)" font-family="var(--font-mono)" font-size="7.5">+A</text>
|
||||
<text x="66" y="20" fill="var(--fg-muted)" font-family="var(--font-mono)" font-size="7.5">+D</text>
|
||||
<svg width="124" height="140" viewBox="0 0 124 140" style="display:block">
|
||||
<rect x="1" y="1" width="122" height="129" rx="6" fill="var(--rk-canvas)" stroke="var(--border-subtle)"></rect>
|
||||
<!-- dimetric open box · azimuth 35° elevation 25° · D→right, A→left-back, P→up -->
|
||||
<!-- ghost wall · A × P plane at d=−1 · carries the P readout -->
|
||||
<polygon points="53.7,44.0 14.6,67.5 14.6,36.7 53.7,13.2" fill="var(--aus-green)" fill-opacity="0.05"></polygon>
|
||||
<line x1="53.7" y1="28.6" x2="14.6" y2="52.1" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.45"></line>
|
||||
<line x1="53.7" y1="44.0" x2="14.6" y2="67.5" stroke="var(--border-subtle)" stroke-width="0.75" stroke-opacity="0.8"></line>
|
||||
<line x1="53.7" y1="59.4" x2="14.6" y2="82.9" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.45"></line>
|
||||
<line x1="43.9" y1="19.1" x2="43.9" y2="80.7" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.45"></line>
|
||||
<line x1="34.2" y1="24.9" x2="34.2" y2="86.5" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.45"></line>
|
||||
<line x1="24.4" y1="30.8" x2="24.4" y2="92.4" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.45"></line>
|
||||
<polygon points="14.6,36.7 53.7,13.2 53.7,74.8 14.6,98.3" fill="none" stroke="var(--border-subtle)" stroke-opacity="0.55"></polygon>
|
||||
<!-- P axis · the wall's rear edge -->
|
||||
<line x1="53.7" y1="13.2" x2="53.7" y2="74.8" stroke="var(--border-default)" stroke-opacity="0.9"></line>
|
||||
<line x1="51.5" y1="44.0" x2="55.9" y2="44.0" stroke="var(--border-default)" stroke-opacity="0.9"></line>
|
||||
<!-- floor · spans D × A · positive halves shaded -->
|
||||
<polygon points="81.5,83.0 109.4,91.3 70.3,114.8 42.5,106.6" fill="var(--aus-cyan)" fill-opacity="0.07"></polygon>
|
||||
<polygon points="34.2,86.5 89.8,103.1 70.3,114.8 14.6,98.3" fill="var(--aus-blue)" fill-opacity="0.07"></polygon>
|
||||
<line x1="81.5" y1="83.0" x2="42.5" y2="106.6" stroke="var(--border-subtle)" stroke-width="0.75" stroke-opacity="1"></line>
|
||||
<line x1="95.4" y1="87.2" x2="56.4" y2="110.7" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.7"></line>
|
||||
<line x1="67.6" y1="78.9" x2="28.6" y2="102.4" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.7"></line>
|
||||
<line x1="34.2" y1="86.5" x2="89.8" y2="103.1" stroke="var(--border-subtle)" stroke-width="0.75" stroke-opacity="1"></line>
|
||||
<line x1="24.4" y1="92.4" x2="80.1" y2="109.0" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.7"></line>
|
||||
<line x1="43.9" y1="80.7" x2="99.6" y2="97.2" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.7"></line>
|
||||
<polygon points="70.3,114.8 109.4,91.3 53.7,74.8 14.6,98.3" fill="none" stroke="var(--border-subtle)" stroke-opacity="0.9"></polygon>
|
||||
<!-- axis labels at their vertices -->
|
||||
<text x="112" y="100" text-anchor="middle" fill="var(--aus-bright-cyan)" font-family="var(--font-mono)" font-size="7.5">+D</text>
|
||||
<text x="14" y="108" text-anchor="middle" fill="var(--aus-bright-blue)" font-family="var(--font-mono)" font-size="7.5">+A</text>
|
||||
<text x="54" y="10.5" text-anchor="middle" fill="var(--aus-bright-green)" font-family="var(--font-mono)" font-size="7.5">+P</text>
|
||||
<!-- animated trajectory + shadow + P readout (JS-driven replay) -->
|
||||
<g id="orbit-dyn"></g>
|
||||
<text x="8" y="128" fill="var(--fg-muted)" font-family="var(--font-mono)" font-size="7.5">MOOD ORBIT · P×A×D</text>
|
||||
<text x="6" y="139" fill="var(--fg-muted)" font-family="var(--font-mono)" font-size="7.5">MOOD ORBIT · P×A×D</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -639,6 +686,15 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
|
||||
<div id="directive-body"><div class="empty">the hidden mood descriptor + directive + relationship block appears here</div></div>
|
||||
</div>
|
||||
|
||||
<div class="ac-sec" id="pane-memory">
|
||||
<div class="head">
|
||||
<span class="eyebrow">memory · chunks <span id="mem-count">—</span></span>
|
||||
<span class="sub" id="mem-total"></span>
|
||||
</div>
|
||||
<div class="ci-sub">durable memory Worldtree promoted into OUR store (a bound memory/combined session; ~6 turns or idle to promote) — a debug read on our own store, content·scope·origin per chunk</div>
|
||||
<div id="memory-body"><div class="empty">memory loads on session open</div></div>
|
||||
</div>
|
||||
|
||||
<div id="ac-foot">
|
||||
<span><kbd>drag ◂▸</kbd> resize</span>
|
||||
<span class="grow"></span>
|
||||
@@ -698,35 +754,125 @@ function ts() {
|
||||
|
||||
// ---- safe live Markdown (escape-first, whitelist subset; no CDN) -----------
|
||||
function mdInline(s) {
|
||||
// Order matters: strong first, then SPEECH (must run before any pass that emits a
|
||||
// quoted attribute — em's class="…" and the link's href="…" — or those quotes would
|
||||
// be mis-read as dialogue), then em, then links.
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
||||
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
||||
s = s.replace(/(^|[^_\w])_([^_\n]+)_/g, "$1<em>$2</em>");
|
||||
// "quoted" dialogue → speech (RP semantic color). Straight + smart quotes, balanced,
|
||||
// within one line; the quote glyphs are kept inside the span. Unbalanced/half-streamed
|
||||
// quotes have no closer → left uncolored until they close.
|
||||
s = s.replace(/"([^"\n]*)"/g, '<span class="md-speech">"$1"</span>');
|
||||
s = s.replace(/[“]([^”\n]*)[”]/g, '<span class="md-speech">“$1”</span>');
|
||||
// *action* / _action_ → italic, tagged as RP action/narration (semantic color)
|
||||
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em class="md-action">$2</em>');
|
||||
s = s.replace(/(^|[^_\w])_([^_\n]+)_/g, '$1<em class="md-action">$2</em>');
|
||||
// [text](url) — http(s) only, conservative charset (no quotes/brackets/ws)
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, text, url) =>
|
||||
/^https?:\/\/[^\s"'<>)]+$/.test(url)
|
||||
? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`
|
||||
: text);
|
||||
return s;
|
||||
}
|
||||
function markdownSafe(raw) {
|
||||
let s = esc(raw); // escape < > & FIRST
|
||||
const cb = [], ic = [];
|
||||
s = s.replace(/```[^\n`]*\n([\s\S]*?)```/g, (m, body) => { cb.push(body); return ` CB${cb.length - 1} `; });
|
||||
s = s.replace(/`([^`\n]+)`/g, (m, body) => { ic.push(body); return ` IC${ic.length - 1} `; });
|
||||
const out = [];
|
||||
let para = [], list = null;
|
||||
const flushPara = () => { if (para.length) { out.push(`<div class="md-p">${para.map(mdInline).join("<br>")}</div>`); para = []; } };
|
||||
const closeList = () => { if (list) { out.push(`</${list}>`); list = null; } };
|
||||
for (const line of s.split("\n")) {
|
||||
let m;
|
||||
if ((m = /^(#{1,6})\s+(.*)$/.exec(line))) { flushPara(); closeList(); out.push(`<div class="md-h md-h${m[1].length}">${mdInline(m[2])}</div>`); }
|
||||
else if ((m = /^>\s?(.*)$/.exec(line))) { flushPara(); closeList(); out.push(`<div class="md-quote">${mdInline(m[1])}</div>`); }
|
||||
else if ((m = /^\s*[-*+]\s+(.*)$/.exec(line))) { flushPara(); if (list !== "ul") { closeList(); out.push("<ul>"); list = "ul"; } out.push(`<li>${mdInline(m[1])}</li>`); }
|
||||
else if ((m = /^\s*\d+\.\s+(.*)$/.exec(line))) { flushPara(); if (list !== "ol") { closeList(); out.push("<ol>"); list = "ol"; } out.push(`<li>${mdInline(m[1])}</li>`); }
|
||||
else if (/^\s*$/.test(line)) { flushPara(); closeList(); }
|
||||
else { closeList(); para.push(line); }
|
||||
// GFM pipe table: header row + `---` delimiter row (with alignment colons) + body rows.
|
||||
// Streaming-safe — parses only the body rows present so far; called only once a header +
|
||||
// delimiter pair is confirmed. Returns {html, consumed} (# of source lines eaten).
|
||||
function mdTable(lines, i) {
|
||||
const splitRow = (row) => {
|
||||
let r = row.trim();
|
||||
if (r.startsWith("|")) r = r.slice(1);
|
||||
if (r.endsWith("|")) r = r.slice(0, -1);
|
||||
return r.split("|").map((c) => c.trim());
|
||||
};
|
||||
const header = splitRow(lines[i]);
|
||||
const aligns = splitRow(lines[i + 1]).map((c) => {
|
||||
const l = c.startsWith(":"), r = c.endsWith(":");
|
||||
return l && r ? "center" : r ? "right" : l ? "left" : "";
|
||||
});
|
||||
let j = i + 2;
|
||||
const rows = [];
|
||||
while (j < lines.length && /\S/.test(lines[j]) && lines[j].includes("|")) {
|
||||
rows.push(splitRow(lines[j]));
|
||||
j++;
|
||||
}
|
||||
flushPara(); closeList();
|
||||
const al = (k) => (aligns[k] ? ` style="text-align:${aligns[k]}"` : "");
|
||||
let html = "<table class=\"md-table\"><thead><tr>";
|
||||
header.forEach((c, k) => { html += `<th${al(k)}>${mdInline(c)}</th>`; });
|
||||
html += "</tr></thead><tbody>";
|
||||
for (const row of rows) {
|
||||
html += "<tr>";
|
||||
for (let k = 0; k < header.length; k++) html += `<td${al(k)}>${mdInline(row[k] || "")}</td>`;
|
||||
html += "</tr>";
|
||||
}
|
||||
return { html: html + "</tbody></table>", consumed: j - i };
|
||||
}
|
||||
function markdownSafe(raw) {
|
||||
let s = esc(raw); // escape < > & FIRST (INV-004)
|
||||
const cb = [], ic = [];
|
||||
// closed fenced code blocks, then an UNTERMINATED fence (streaming) → partial code block
|
||||
s = s.replace(/```[^\n`]*\n([\s\S]*?)```/g, (m, body) => { cb.push(body); return ` CB${cb.length - 1} `; });
|
||||
s = s.replace(/```[^\n`]*\n([\s\S]*)$/, (m, body) => { cb.push(body); return ` CB${cb.length - 1} `; });
|
||||
s = s.replace(/`([^`\n]+)`/g, (m, body) => { ic.push(body); return ` IC${ic.length - 1} `; });
|
||||
const lines = s.split("\n");
|
||||
const out = [];
|
||||
let para = [];
|
||||
const listStack = []; // [{type:'ul'|'ol', indent}], each open <li> left dangling for nesting
|
||||
// CommonMark soft-break: a single newline inside a paragraph is a SPACE, not a line
|
||||
// break. A hard break needs the line to end in 2+ spaces or a backslash.
|
||||
const flushPara = () => {
|
||||
if (!para.length) return;
|
||||
const seg = [];
|
||||
let prevHard = false;
|
||||
para.forEach((ln, i) => {
|
||||
const hard = /( +|\\)\s*$/.test(ln);
|
||||
const clean = ln.replace(/(\s*\\|\s{2,})\s*$/, "");
|
||||
if (i > 0) seg.push(prevHard ? "<br>" : " ");
|
||||
seg.push(mdInline(clean));
|
||||
prevHard = hard;
|
||||
});
|
||||
out.push(`<div class="md-p">${seg.join("")}</div>`);
|
||||
para = [];
|
||||
};
|
||||
const closeAllLists = () => { while (listStack.length) out.push(`</li></${listStack.pop().type}>`); };
|
||||
const openList = (type, num) => {
|
||||
out.push(type === "ol" && num != null && num !== 1 ? `<ol start="${num}">` : `<${type}>`);
|
||||
};
|
||||
// Indentation-nested list items: deeper indent → a child list inside the open <li>;
|
||||
// shallower → pop; same level → sibling. Ordered lists honor their first item's number.
|
||||
const pushListItem = (type, indent, content, num) => {
|
||||
flushPara();
|
||||
while (listStack.length && listStack[listStack.length - 1].indent > indent) {
|
||||
out.push(`</li></${listStack.pop().type}>`);
|
||||
}
|
||||
const top = listStack[listStack.length - 1];
|
||||
if (!top || indent > top.indent) {
|
||||
openList(type, num);
|
||||
listStack.push({ type, indent });
|
||||
} else {
|
||||
out.push("</li>"); // close previous sibling item
|
||||
if (top.type !== type) { out.push(`</${listStack.pop().type}>`); openList(type, num); listStack.push({ type, indent }); }
|
||||
}
|
||||
out.push(`<li>${mdInline(content)}`); // left OPEN so a nested list can sit inside it
|
||||
};
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li];
|
||||
let m;
|
||||
// GFM table: this line is a pipe row AND the next is an alignment/delimiter row.
|
||||
if (/\|/.test(line) && li + 1 < lines.length
|
||||
&& /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[li + 1])
|
||||
&& lines[li + 1].includes("|") && /-/.test(lines[li + 1])) {
|
||||
flushPara(); closeAllLists();
|
||||
const { html, consumed } = mdTable(lines, li);
|
||||
out.push(html); li += consumed - 1; continue;
|
||||
}
|
||||
if ((m = /^(#{1,6})\s+(.*)$/.exec(line))) { flushPara(); closeAllLists(); out.push(`<div class="md-h md-h${m[1].length}">${mdInline(m[2])}</div>`); }
|
||||
else if ((m = /^>\s?(.*)$/.exec(line))) { flushPara(); closeAllLists(); out.push(`<div class="md-quote">${mdInline(m[1])}</div>`); }
|
||||
else if ((m = /^(\s*)[-*+]\s+(.*)$/.exec(line))) { pushListItem("ul", m[1].length, m[2]); }
|
||||
else if ((m = /^(\s*)(\d+)\.\s+(.*)$/.exec(line))) { pushListItem("ol", m[1].length, m[3], +m[2]); }
|
||||
else if (/^\s*$/.test(line)) { flushPara(); /* blank keeps lists open (loose lists) */ }
|
||||
else { closeAllLists(); para.push(line); }
|
||||
}
|
||||
flushPara(); closeAllLists();
|
||||
let html = out.join("");
|
||||
html = html.replace(/ IC(\d+) /g, (m, i) => `<code>${ic[+i]}</code>`);
|
||||
html = html.replace(/ CB(\d+) /g, (m, i) => `<pre class="md-code">${cb[+i]}</pre>`);
|
||||
@@ -809,7 +955,19 @@ function pushAffectHistory(snap) {
|
||||
}
|
||||
}
|
||||
// ---- SVG sparklines (replace the unicode strings; adapted from the design prototype) ----
|
||||
const _clamp1 = (v) => Math.max(-1, Math.min(1, Number(v) || 0));
|
||||
// Debug-surface PAD display scale. ratatoskr is a downstream OBSERVER — this scaling is
|
||||
// PURELY cosmetic (fader fill + orbit geometry); it never touches the agent's real affect
|
||||
// (Worldtree-computed) or any write path, so relaxing it has zero agent consequence.
|
||||
// R32-1B makes Worldtree's PAD wire unbounded (z, ~±10 instead of [-1,1]); auto-scale the
|
||||
// gauges to the session's OWN max |PAD| (floor 1.0) so an unbounded value renders at FULL
|
||||
// range and never pegs, while today's [-1,1] values render exactly as before (scale==1).
|
||||
function padScale() {
|
||||
const H = AFFECT_HIST.pad; let m = 1;
|
||||
for (const ax of ["pleasure", "arousal", "dominance"])
|
||||
for (const v of (H[ax] || [])) { const a = Math.abs(v); if (a > m) m = a; }
|
||||
return m;
|
||||
}
|
||||
const _padNorm = (v) => Math.max(-1, Math.min(1, (Number(v) || 0) / padScale()));
|
||||
// horizontal sparkline for a w×h box, auto-scaled to the vals' OWN range, newest at endX.
|
||||
function sparkPointsH(vals, w, h, endX) {
|
||||
vals = (vals || []).slice(-12).filter((v) => typeof v === "number");
|
||||
@@ -822,23 +980,42 @@ function sparkPointsH(vals, w, h, endX) {
|
||||
const pts = vals.map((v, i) => (x0 + (x1 - x0) * (i / (n - 1))).toFixed(1) + "," + yOf(v).toFixed(1));
|
||||
return { pts: pts.join(" "), endY: +yOf(vals[n - 1]).toFixed(1), empty: false };
|
||||
}
|
||||
// vertical PAD strip: time down the Y axis (top=oldest y=8 ‥ bottom=newest y=124),
|
||||
// value on X (center 13, ±11 for ±1). Dot at the newest point.
|
||||
function stripPoints(vals) {
|
||||
vals = (vals || []).slice(-12).filter((v) => typeof v === "number");
|
||||
const yTop = 8, yBot = 124;
|
||||
if (!vals.length) return { pts: "", dotX: 13, empty: true };
|
||||
const n = vals.length;
|
||||
const xOf = (v) => 13 + _clamp1(v) * 11;
|
||||
const pts = vals.map((v, i) => xOf(v).toFixed(1) + "," + (n === 1 ? yBot : yTop + (yBot - yTop) * (i / (n - 1))).toFixed(1));
|
||||
return { pts: pts.join(" "), dotX: +xOf(vals[n - 1]).toFixed(1), empty: false };
|
||||
// per-turn Δ series from an absolute-value history: consecutive differences, last 12.
|
||||
function padDeltas(vals) {
|
||||
vals = (vals || []).filter((v) => typeof v === "number");
|
||||
const d = [];
|
||||
for (let i = 1; i < vals.length; i++) d.push(vals[i] - vals[i - 1]);
|
||||
return d.slice(-12);
|
||||
}
|
||||
// isometric projection of (P,A,D) ∈ [-1,1]³ into the mood-cube: P right-down, A left-down,
|
||||
// D up; center (62,66); 2:1 iso, scale 26. (Reverse-derived from the design's now-point.)
|
||||
function proj3(p, a, dd) {
|
||||
p = _clamp1(p); a = _clamp1(a); dd = _clamp1(dd);
|
||||
return [62 + 26 * p - 26 * a, 66 + 13 * p + 13 * a - 26 * dd];
|
||||
// Diverging Δ-per-turn strip (adapted from the design prototype): each turn's Δ as a bar
|
||||
// left/right of the center line (cx 13), auto-scaled to the window's largest |Δ| so rare
|
||||
// small moves stay readable; opacity fades with age; a zero-change turn is a faint center
|
||||
// dot. Returns {l,w,h,o,t} per turn (oldest→newest, rendered newest at bottom).
|
||||
function deltaStrip(deltas) {
|
||||
const half = 11, cx = 13;
|
||||
const maxD = Math.max(0.02, ...deltas.map((d) => Math.abs(d)));
|
||||
const n = deltas.length;
|
||||
return deltas.map((d, i) => {
|
||||
const age = n > 1 ? i / (n - 1) : 1; // 0 oldest → 1 newest
|
||||
const rel = i - (n - 1);
|
||||
const t = (rel === 0 ? "this turn" : rel + " turns ago") + " · Δ" + (d > 0 ? "+" : "") + d.toFixed(2);
|
||||
if (d === 0) return { l: 11.75, w: 2.5, h: 2.5, o: (0.1 + 0.35 * age).toFixed(2), t };
|
||||
const w = Math.max(2, (Math.abs(d) / maxD) * half);
|
||||
return { l: d > 0 ? cx : +(cx - w).toFixed(1), w: +w.toFixed(1), h: 3, o: (0.2 + 0.8 * age).toFixed(2), t };
|
||||
});
|
||||
}
|
||||
// Dimetric projection · azimuth 35° elevation 25° · D→right, A→left-back, P→up (from the
|
||||
// design prototype; the static open-box frame's vertices are derived from these). Four
|
||||
// projections: the point, its floor shadow (P dropped to the d/a=−1 floor), the wall point
|
||||
// (d=−1 A×P plane), and the P-axis rear edge (a=d=−1). All normalize via _padNorm
|
||||
// (÷ session scale) so an unbounded z stays in-frame at the box's session extremes.
|
||||
function orbitProj(p, a, d) {
|
||||
p = _padNorm(p); a = _padNorm(a); d = _padNorm(d);
|
||||
return [62 + 34 * (0.819 * d - 0.574 * a), 64 + 34 * (0.243 * d + 0.346 * a - 0.906 * p)];
|
||||
}
|
||||
function orbitShadowY(p, a, d) { a = _padNorm(a); d = _padNorm(d); return 64 + 34 * (0.243 * d + 0.346 * a + 0.906); }
|
||||
function orbitWallPt(p, a) { p = _padNorm(p); a = _padNorm(a); return [62 + 34 * (-0.819 - 0.574 * a), 64 + 34 * (-0.243 + 0.346 * a - 0.906 * p)]; }
|
||||
function orbitAxisPt(p) { p = _padNorm(p); return [53.7, 64 + 34 * (-0.589 - 0.906 * p)]; }
|
||||
function trendDelta(vals) {
|
||||
if (!vals || vals.length < 2) return "";
|
||||
const d = vals[vals.length - 1] - vals[vals.length - 2];
|
||||
@@ -856,7 +1033,9 @@ function stddev(vals) {
|
||||
const m = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
return Math.sqrt(vals.reduce((a, b) => a + (b - m) * (b - m), 0) / vals.length);
|
||||
}
|
||||
function clamp01(v) { return Math.max(0, Math.min(1, (Number(v) + 1) / 2)); }
|
||||
// Bipolar fader fill fraction ∈ [0,1], auto-scaled to the session PAD range (R32-1B
|
||||
// unbounded-safe): 0.5 at neutral, →1 / →0 toward the session extremes; never pegs.
|
||||
function padFillFrac(v) { return Math.max(0, Math.min(1, 0.5 + 0.5 * ((Number(v) || 0) / padScale()))); }
|
||||
|
||||
// ---- canonical affect→NL (vendored from Worldtree's d2 render canons) ------
|
||||
let PERSONA_CANON = null;
|
||||
@@ -941,42 +1120,103 @@ function setFader(axis, v) {
|
||||
const fill = bar.querySelector(".fill");
|
||||
const val = $("pad-" + axis + "-val");
|
||||
if (typeof v !== "number") { fill.style.height = "0"; val.textContent = "—"; return; }
|
||||
const frac = clamp01(v); // (v+1)/2 ∈ [0,1]
|
||||
const frac = padFillFrac(v); // auto-scaled to session range ∈ [0,1]; never pegs
|
||||
const mag = Math.abs(frac - 0.5) * 100;
|
||||
if (frac >= 0.5) { fill.style.bottom = "50%"; fill.style.top = ""; fill.style.borderRadius = "3px 3px 0 0"; }
|
||||
else { fill.style.top = "50%"; fill.style.bottom = ""; fill.style.borderRadius = "0 0 3px 3px"; }
|
||||
fill.style.height = mag + "%";
|
||||
val.textContent = (v >= 0 ? "+" : "") + v.toFixed(2);
|
||||
}
|
||||
// 3D mood cube: plot the PAD trajectory (P,A,D) in the isometric cube, with a drop line
|
||||
// from the current point to the D=−1 floor + a floor shadow for depth.
|
||||
// Dimetric open-box mood orbit: plot the live PAD trajectory + a floor shadow + the P
|
||||
// readout on the wall, ANIMATED as a replay over the session's PAD history. JS-driven
|
||||
// (no SMIL / no CSS-keyframes) per the design prototype: a singleton rAF loop reads the
|
||||
// live ORBIT_HIST each frame, glides a head from oldest→newest, then holds at "now".
|
||||
let ORBIT_HIST = [];
|
||||
// Build the SVG string for a given animation head (∈[0,n-1], fractional): the interpolated
|
||||
// current point + the committed trail/shadow behind it + the P-readout legs + a pulsing ring.
|
||||
function orbitFrame(H, head, ts) {
|
||||
const n = H.length;
|
||||
const i0 = n < 2 ? 0 : Math.min(Math.floor(head), n - 2);
|
||||
const f = n < 2 ? 0 : Math.min(head - i0, 1);
|
||||
const lerp = (c) => n < 2 ? H[0][c] : H[i0][c] + (H[i0 + 1][c] - H[i0][c]) * f;
|
||||
const p = lerp(0), a = lerp(1), d = lerp(2);
|
||||
const np = orbitProj(p, a, d), shY = orbitShadowY(p, a, d);
|
||||
const wall = orbitWallPt(p, a), axis = orbitAxisPt(p);
|
||||
const committed = n < 2 ? 0 : i0;
|
||||
const trail = [], shadow = [];
|
||||
for (let k = 0; k <= committed; k++) {
|
||||
const pr = orbitProj(H[k][0], H[k][1], H[k][2]);
|
||||
trail.push(pr[0].toFixed(1) + "," + pr[1].toFixed(1));
|
||||
shadow.push(pr[0].toFixed(1) + "," + orbitShadowY(H[k][0], H[k][1], H[k][2]).toFixed(1));
|
||||
}
|
||||
trail.push(np[0].toFixed(1) + "," + np[1].toFixed(1));
|
||||
shadow.push(np[0].toFixed(1) + "," + shY.toFixed(1));
|
||||
const nx = np[0].toFixed(1), ny = np[1].toFixed(1), sy = shY.toFixed(1);
|
||||
const wx = wall[0].toFixed(1), wy = wall[1].toFixed(1), ax = axis[0].toFixed(1), ay = axis[1].toFixed(1);
|
||||
const ringR = (6 + 2 * Math.sin(ts / 500)).toFixed(2);
|
||||
const ringOp = (0.32 + 0.16 * Math.sin(ts / 500 + 2)).toFixed(2);
|
||||
let s = "";
|
||||
// floor shadow trail + end blob + drop line (depth cue)
|
||||
s += `<polyline points="${shadow.join(" ")}" fill="none" stroke="var(--aus-cyan)" stroke-opacity="0.28" stroke-width="1"></polyline>`;
|
||||
s += `<ellipse cx="${nx}" cy="${sy}" rx="3.6" ry="1.7" fill="var(--aus-cyan)" fill-opacity="0.3"></ellipse>`;
|
||||
s += `<line x1="${nx}" y1="${ny}" x2="${nx}" y2="${sy}" stroke="var(--aus-cyan)" stroke-opacity="0.35" stroke-dasharray="2 3"></line>`;
|
||||
// P readout: point → wall → the P-axis rear edge (two green dashed legs + dots)
|
||||
s += `<line x1="${nx}" y1="${ny}" x2="${wx}" y2="${wy}" stroke="var(--aus-bright-green)" stroke-opacity="0.5" stroke-dasharray="1.5 2.5" stroke-width="1"></line>`;
|
||||
s += `<line x1="${wx}" y1="${wy}" x2="${ax}" y2="${ay}" stroke="var(--aus-bright-green)" stroke-opacity="0.35" stroke-dasharray="1.5 2.5" stroke-width="1"></line>`;
|
||||
s += `<circle cx="${wx}" cy="${wy}" r="1.6" fill="var(--aus-bright-green)" fill-opacity="0.6"></circle>`;
|
||||
s += `<circle cx="${ax}" cy="${ay}" r="1.6" fill="var(--aus-bright-green)" fill-opacity="0.9"></circle>`;
|
||||
// trajectory + committed dots (fading with age) + now marker + pulsing ring
|
||||
s += `<polyline points="${trail.join(" ")}" fill="none" stroke="var(--aus-bright-cyan)" stroke-opacity="0.4" stroke-width="1.25"></polyline>`;
|
||||
for (let k = 0; k <= committed && k < n - 1; k++) {
|
||||
const pr = orbitProj(H[k][0], H[k][1], H[k][2]);
|
||||
const op = Math.max(0.12, 0.85 - 0.11 * (committed - k)).toFixed(2);
|
||||
s += `<circle cx="${pr[0].toFixed(1)}" cy="${pr[1].toFixed(1)}" r="1.4" fill="var(--aus-bright-cyan)" fill-opacity="${op}"></circle>`;
|
||||
}
|
||||
s += `<circle cx="${nx}" cy="${ny}" r="3.2" fill="var(--aus-bright-cyan)"></circle>`;
|
||||
s += `<circle cx="${nx}" cy="${ny}" r="${ringR}" fill="none" stroke="var(--aus-bright-cyan)" stroke-opacity="${ringOp}"></circle>`;
|
||||
return s;
|
||||
}
|
||||
function renderOrbit() {
|
||||
const P = AFFECT_HIST.pad.pleasure || [], A = AFFECT_HIST.pad.arousal || [], D = AFFECT_HIST.pad.dominance || [];
|
||||
const n = Math.min(P.length, A.length, D.length);
|
||||
const g = $("orbit-dyn");
|
||||
if (!g) return;
|
||||
if (n === 0) { g.innerHTML = ""; return; }
|
||||
const pts = [];
|
||||
for (let i = 0; i < n; i++) pts.push(proj3(P[i], A[i], D[i]));
|
||||
const poly = pts.map((p) => p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" ");
|
||||
const last = pts[n - 1];
|
||||
const floor = proj3(P[n - 1], A[n - 1], -1); // straight down to the D=−1 plane (same x)
|
||||
const fx = last[0].toFixed(1);
|
||||
let s = "";
|
||||
// depth cue: drop line + floor shadow
|
||||
s += `<line x1="${fx}" y1="${last[1].toFixed(1)}" x2="${fx}" y2="${floor[1].toFixed(1)}" stroke="var(--aus-cyan)" stroke-opacity="0.3" stroke-dasharray="2 3"></line>`;
|
||||
s += `<ellipse cx="${fx}" cy="${floor[1].toFixed(1)}" rx="4" ry="2" fill="var(--aus-cyan)" fill-opacity="0.25"></ellipse>`;
|
||||
// trajectory oldest→newest, dots fading in
|
||||
s += `<polyline points="${poly}" fill="none" stroke="var(--aus-bright-cyan)" stroke-opacity="0.35" stroke-width="1.25"></polyline>`;
|
||||
pts.forEach((p, i) => {
|
||||
const op = (0.15 + 0.7 * (i / (n - 1 || 1))).toFixed(2);
|
||||
s += `<circle cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="1.5" fill="var(--aus-bright-cyan)" fill-opacity="${op}"></circle>`;
|
||||
});
|
||||
s += `<circle cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="3.4" fill="var(--aus-bright-cyan)"></circle>`;
|
||||
s += `<circle cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="7" stroke="var(--aus-bright-cyan)" fill="none" stroke-opacity="0.4">`
|
||||
+ `<animate attributeName="r" values="5;9;5" dur="3.2s" repeatCount="indefinite"></animate>`
|
||||
+ `<animate attributeName="stroke-opacity" values="0.5;0.15;0.5" dur="3.2s" repeatCount="indefinite"></animate></circle>`;
|
||||
g.innerHTML = s;
|
||||
ORBIT_HIST = [];
|
||||
for (let i = Math.max(0, n - 12); i < n; i++) ORBIT_HIST.push([P[i], A[i], D[i]]);
|
||||
if (!ORBIT_HIST.length) { g.innerHTML = ""; return; }
|
||||
// Static render = the final state (also the reduced-motion fallback); the rAF loop
|
||||
// then replays over it if motion is allowed.
|
||||
g.innerHTML = orbitFrame(ORBIT_HIST, ORBIT_HIST.length - 1, 0);
|
||||
startOrbitAnim();
|
||||
}
|
||||
// Singleton replay loop: 0.5s glide + 0.3s dwell per turn, 2.4s hold at "now", repeat.
|
||||
// Reads the live ORBIT_HIST each frame so it survives session re-renders; one loop only.
|
||||
function startOrbitAnim() {
|
||||
if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
if (window.__rkOrbitRaf) return; // already looping — it picks up the updated ORBIT_HIST
|
||||
const segMove = 0.5, seg = 0.8, hold = 2.4;
|
||||
const step = (ts) => {
|
||||
const g = $("orbit-dyn");
|
||||
if (!g || !g.isConnected) { window.__rkOrbitRaf = 0; return; } // detached → stop
|
||||
const H = ORBIT_HIST, n = H.length;
|
||||
if (n === 0) { window.__rkOrbitRaf = requestAnimationFrame(step); return; }
|
||||
let head;
|
||||
if (n < 2) head = 0;
|
||||
else {
|
||||
const travel = seg * (n - 1), cycle = travel + hold;
|
||||
const t = (ts / 1000) % cycle;
|
||||
if (t >= travel) head = n - 1;
|
||||
else {
|
||||
const i = Math.floor(t / seg);
|
||||
let m = Math.min((t - i * seg) / segMove, 1);
|
||||
m = m * m * (3 - 2 * m); // smoothstep ease
|
||||
head = i + m;
|
||||
}
|
||||
}
|
||||
g.innerHTML = orbitFrame(H, head, ts);
|
||||
window.__rkOrbitRaf = requestAnimationFrame(step);
|
||||
};
|
||||
window.__rkOrbitRaf = requestAnimationFrame(step);
|
||||
}
|
||||
// dominant-emotion centerpiece: real OCC emotion (Tier-1) OR the canonical mood
|
||||
// word Worldtree tells the agent it feels (Tier-3, dimmer) OR "—". Never a
|
||||
@@ -1012,7 +1252,6 @@ function renderDerived(snap) {
|
||||
const bp = snap.baseline_pad || {}, md = snap.mood_drift || {}, H = AFFECT_HIST;
|
||||
const vol = stddev(H.pad.pleasure);
|
||||
const dv = md.valence_delta;
|
||||
$("pad-samples").textContent = H.at.length + " sample" + (H.at.length === 1 ? "" : "s");
|
||||
g.innerHTML =
|
||||
cell("samples", String(H.at.length)) +
|
||||
cell("updated", (_at(snap) || "—").slice(11, 19) || "—") +
|
||||
@@ -1031,6 +1270,7 @@ function renderRelations(snap) {
|
||||
const hint = METRIC_HINTS[label] || "";
|
||||
const sp = sparkPointsH(hist, 56, 13, 52);
|
||||
const svg = `<svg class="m-spark" width="56" height="13" viewBox="0 0 56 13">`
|
||||
+ `<rect x="0.5" y="0.5" width="55" height="12" fill="url(#sparkGrid)" stroke="var(--border-subtle)" stroke-width="0.5" stroke-opacity="0.6"></rect>`
|
||||
+ (sp.empty ? "" : `<polyline points="${sp.pts}" fill="none" stroke="url(#sparkFade)" stroke-width="1.4" stroke-linejoin="round"></polyline><circle cx="52" cy="${sp.endY}" r="1.8" fill="var(--aus-bright-cyan)"></circle>`)
|
||||
+ `</svg>`;
|
||||
return `<div class="mrow"${hint ? ` title="${esc(hint)}"` : ""}><span class="mk">${esc(label)}</span>`
|
||||
@@ -1119,11 +1359,11 @@ function setFaderTrend(axis) {
|
||||
dEl.className = "d " + (d.startsWith("▲") ? "up" : d.startsWith("▼") ? "dn" : "");
|
||||
const strip = $("strip-" + axis);
|
||||
if (strip) {
|
||||
const { pts, dotX, empty } = stripPoints(hist);
|
||||
strip.querySelector(".strip-line").setAttribute("points", pts);
|
||||
const dot = strip.querySelector(".strip-dot");
|
||||
dot.setAttribute("cx", dotX);
|
||||
dot.style.display = empty ? "none" : "";
|
||||
const col = strip.querySelector(".strip-col");
|
||||
const bars = deltaStrip(padDeltas(hist));
|
||||
col.innerHTML = bars.map((b) =>
|
||||
`<div class="strip-cell" title="${esc(b.t)}"><i style="left:${b.l}px;width:${b.w}px;height:${b.h}px;opacity:${b.o}"></i></div>`
|
||||
).join("");
|
||||
}
|
||||
}
|
||||
// mouseover hints for each relationship metric (native title tooltips).
|
||||
@@ -1193,6 +1433,57 @@ async function loadAffect(agentId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- memory viewer (durable chunks in OUR store, #memory pane) --------------
|
||||
// GET /api/memory/chunks?agent_id=… → the server supplies end_user_id; the pane shows
|
||||
// content·scope·origin per chunk + count(matched)/total(store-wide) so the 0-chunks
|
||||
// state is legible (empty store vs scope-mismatch). Polled on session open + post-turn.
|
||||
function setMemHead(count, total) {
|
||||
const c = $("mem-count"), t = $("mem-total");
|
||||
if (count == null) { c.textContent = "—"; t.textContent = ""; return; }
|
||||
c.textContent = count;
|
||||
t.textContent = "store total " + total;
|
||||
}
|
||||
function renderMemory(data) {
|
||||
const chunks = data.chunks || [];
|
||||
const total = data.total || 0;
|
||||
setMemHead(data.count != null ? data.count : chunks.length, total);
|
||||
const pane = $("memory-body");
|
||||
if (!chunks.length) {
|
||||
pane.innerHTML = total > 0
|
||||
? `<div class="empty">no chunks for this agent / end-user — the store holds ${esc(total)} in other partitions (scope mismatch, not an empty store)</div>`
|
||||
: `<div class="empty">— no memory chunks yet —<br>promotion needs a bound memory/combined session + ~6 turns (or idle); if this stays 0/0, the bind wasn't memory-granted or closed pre-promotion</div>`;
|
||||
return;
|
||||
}
|
||||
const scopeStr = (sc) => Object.entries(sc || {}).map(([k, v]) => `${esc(k)}=${esc(v)}`).join(" · ") || "—";
|
||||
pane.innerHTML = chunks.map((c) =>
|
||||
`<div class="mem-chunk"><div class="mem-meta"><span class="mem-scope">${scopeStr(c.scope)}</span>`
|
||||
+ `<span class="mem-origin">${esc(c.origin || "—")}${c.revision ? " ·r" + esc(c.revision) : ""}</span></div>`
|
||||
+ `<div class="mem-content">${esc(c.content || "")}</div></div>`
|
||||
).join("");
|
||||
}
|
||||
async function loadMemory(agentId) {
|
||||
const pane = $("memory-body");
|
||||
try {
|
||||
const r = await fetch("/api/memory/chunks?agent_id=" + encodeURIComponent(agentId));
|
||||
if (r.status === 200) {
|
||||
renderMemory(await r.json());
|
||||
} else {
|
||||
let code = ""; try { code = (await r.json()).error_code || ""; } catch (_) {}
|
||||
let msg;
|
||||
if (code === "memory_not_configured")
|
||||
msg = "memory viewer not configured (RATATOSKR_MEMORY_READ_URL + RATATOSKR_END_USER_ID).";
|
||||
else if (r.status === 502 && code === "memory_provider_unreachable")
|
||||
msg = "memory provider unreachable (is the :8392 provider up?).";
|
||||
else msg = `memory unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
|
||||
pane.innerHTML = `<div class="empty">${msg}</div>`;
|
||||
setMemHead(null, null);
|
||||
}
|
||||
} catch (e) {
|
||||
pane.innerHTML = `<div class="empty">memory fetch failed</div>`;
|
||||
setMemHead(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tools inventory (left rail chips) ----
|
||||
function renderToolsInventory(inv) {
|
||||
const names = (arr) => (arr || []).map((t) => (typeof t === "string" ? t : (t && t.name) || "?"));
|
||||
@@ -1307,6 +1598,7 @@ async function startSession() {
|
||||
tickerAdd("", "session.created", info.session_id.slice(-8));
|
||||
await loadTranscript(state.sessionId);
|
||||
await loadPersona(agentId);
|
||||
loadMemory(agentId);
|
||||
loadSessionTools(state.sessionId);
|
||||
loadBifrostState(state.sessionId);
|
||||
openAdminEvents(state.sessionId);
|
||||
@@ -1487,6 +1779,7 @@ async function submitPrompt() {
|
||||
setTimeout(async () => {
|
||||
if (settled || state.turnId) return;
|
||||
await loadPersona(state.agentId);
|
||||
loadMemory(state.agentId); // durable chunks land post-turn (promotion is async too)
|
||||
if (state.lastAffectAt && state.lastAffectAt !== beforeAt) settled = true;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -681,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",
|
||||
@@ -1267,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
|
||||
|
||||
@@ -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.20.5"
|
||||
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" },
|
||||
|
||||
Reference in New Issue
Block a user