diff --git a/docs/contracts/web_debug_surface.contract.md b/docs/contracts/web_debug_surface.contract.md index d212c7e..7735639 100644 --- a/docs/contracts/web_debug_surface.contract.md +++ b/docs/contracts/web_debug_surface.contract.md @@ -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 `` 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 (`` + a bg `` 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 `` svg in the console." - "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 (`|---|:--:|`) → 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
    /
      with each child list INSIDE the open parent
    1. (valid nested HTML); same-level items are siblings; ul↔ol switches close+reopen. Ordered lists honor the first item's number (
        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." diff --git a/persistent-memory.md b/persistent-memory.md index 286549c..d4d6a92 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -41,16 +41,13 @@ upstream API key stays server-side (INV-003). _As of 2026-07-07 (session cont.):_ -**⏭ QUEUED FOR FRESH-CONTEXT IMPLEMENTATION (operator: "/snapshot and implement that and memory viewer on fresh context").** Three work items, all pre-scoped: +**✅ 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 — re-pull + adapt** (`DesignSync get_file` projectId `bc0b65d1-a33e-422a-8bc1-3635c9112775`, file `Ratatoskr Console.dc.html`; design scopes already granted — no /design-login). RE-PULL for exact coords; three changes vs the shipped v0.20.4: - 1. **Sparkline background** — the relation-row 56×13 spark SVG gets a bg `` BEHIND the polyline. New def: `` (subtle grid cell). Add pattern to the hidden `` + the rect to the metric-row spark (and consider the same bg on the PAD area). - 2. **PAD strips → per-turn Δ bars ("read as vertical shifts")** — REPLACE the current SVG polyline strip (`stripPoints`) with a column of 12 HTML rows (newest at BOTTOM), each 8px tall holding a horizontal bar offset L/R from the center line (`left:12.5px`) by that turn's Δ: `
        `. Container `data-hook=strip-{p,a,d}` 26×132 `position:relative` with a center line + an inset flex column `justify-content:space-between padding:4px 0`. Eyebrow → "Δ/turn · last 12 · newest ↓". Compute `stripP/A/D` = arrays of `{l,w,h,o,t}` from per-turn deltas of AFFECT_HIST.pad[axis] (delta sign → left/right of center; magnitude → width; age → opacity fade). - 3. **Mood orbit → DIMETRIC OPEN BOX + JS-animated replay** — REPLACE the v0.20.4 isometric cube. New frame: viewBox **124×140**; "dimetric open box · **azimuth 35° elevation 25° · D→right, A→left-back, P→up**" (NEW projection — different axes from the current `proj3`). Two faces: a **ghost WALL** (A×P plane at d=−1, `fill=var(--aus-green) op=0.05`, grid lines, P-axis = the wall's rear edge, carries the **P readout**) + a **FLOOR** (D×A plane, positive halves shaded `--aus-cyan op=0.07` + `--aus-blue op=0.07`, grid lines). Axis labels colored: **+D cyan** (112,100) · **+A blue** (14,108) · **+P green** (54,10.5). The dynamic part is a template hole `{{ orbitDynamics }}` commented "**animated trajectory + shadow + P readout (JS-DRIVEN replay)**" → implement in JS: derive the dimetric projection (az35/el25, D-right/A-left-back/P-up), plot the PAD-history trail + a floor-shadow + the P readout on the wall, ANIMATED as a replay over time (no SMIL/CSS-keyframes in the prototype — it's explicitly JS-driven). RE-PULL the static frame polygon/line coords (they're in the prototype; too many to bank here). Existing `sparkFade`/`stripFade{P,A,D}` gradient defs unchanged. +**(A) Design prototype iteration-3 adapted into `index.html`** (re-pulled `Ratatoskr Console.dc.html`, project `bc0b65d1-…`): (1) sparkline **grid bg** — `` in the hidden defs + a bg `` 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 (step ②, operator picked the CONSOLE PANE).** New debug read route on the combined provider `GET /memory/chunks?agent_id=&end_user_id=` (reads `memory.db`; MIRRORS the #18-D2 `GET /affect/state/{agent_id}` provider-read pattern — bifrost's memory protocol has NO list-all verb, so this is explicitly OUR debug read on OUR store per [[feedback_debug_surface_uses_canonical_surface_only]]) → web proxy `GET /api/memory/chunks` (server-supplied end_user, like `/api/affect`) → a live-polling "MEMORY" pane in the right console showing content·scope·origin per chunk. **Bundle a controlled bound 6-turn session** (+ a salient fact, via `--bifrost-url http://10.100.10.50:8392`) to PROVE the round-trip + settle why memory stayed 0/0 (memory-watch timed out 13min at 0 chunks / 0 idempotency → suspect the operator's session bind wasn't memory-granted OR closed pre-promotion; the viewer makes bind+promotion state visible). Contract-worthy (amend `web_debug_surface.contract.md`, mirror the D2 provider-read + proxy + pane shape). +**(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 to optionally clean:** `ratatoskr:memprobe` agent on personal WT + its 4 test chunks (scope `{end_user:ratatoskr-tui}`) now sit in the live `memory.db` — harmless (they make the pane show real data) but they're test cruft; `scripts/reset-sindra-stores.sh` or a `DELETE /agents/ratatoskr:memprobe` clears them. **To fix sindra's memory for real: DELETE+redefine her WITH `memory:{}`** (memory is immutable post-define) — operator's call on her agent. -**(C) Markdown pass-2** — tables, nested lists, ordered-list numbering, streaming robustness (pass-1 RP speech/action coloring + paragraph reflow shipped `v0.20.6`). +**(C) Markdown pass-2 SHIPPED** — `markdownSafe` extended: GFM pipe tables (`mdTable`, alignment colons), indentation-nested lists (stack of `
          `/`
            `, child list inside the open `
          1. ` = 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 (``/``/``/`{{}}`/`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. @@ -180,6 +177,7 @@ 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-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 ``, 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-`
            `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`. diff --git a/pyproject.toml b/pyproject.toml index 995200f..43d29ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.20.6" +version = "0.20.7" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/provider/combined.py b/src/ratatoskr/provider/combined.py index 298e2d1..53bc25b 100644 --- a/src/ratatoskr/provider/combined.py +++ b/src/ratatoskr/provider/combined.py @@ -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 diff --git a/src/ratatoskr/provider/memory_store.py b/src/ratatoskr/provider/memory_store.py index 51c04bc..0807566 100644 --- a/src/ratatoskr/provider/memory_store.py +++ b/src/ratatoskr/provider/memory_store.py @@ -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 diff --git a/src/ratatoskr/web/entrypoint.py b/src/ratatoskr/web/entrypoint.py index 604533b..5b3e9ac 100644 --- a/src/ratatoskr/web/entrypoint.py +++ b/src/ratatoskr/web/entrypoint.py @@ -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, ) diff --git a/src/ratatoskr/web/server.py b/src/ratatoskr/web/server.py index c3d747f..3c3ea4d 100644 --- a/src/ratatoskr/web/server.py +++ b/src/ratatoskr/web/server.py @@ -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. diff --git a/src/ratatoskr/web/static/index.html b/src/ratatoskr/web/static/index.html index 2d4cfb0..f593919 100644 --- a/src/ratatoskr/web/static/index.html +++ b/src/ratatoskr/web/static/index.html @@ -311,7 +311,13 @@ body[data-theme="light"] #theme-toggle .i-moon { display: inline; } .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); } @@ -394,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; } @@ -455,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; } @@ -574,6 +595,7 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
"; + header.forEach((c, k) => { html += `${mdInline(c)}`; }); + html += ""; + for (const row of rows) { + html += ""; + for (let k = 0; k < header.length; k++) html += `${mdInline(row[k] || "")}`; + html += ""; + } + return { html: html + "
", consumed: j - i }; +} function markdownSafe(raw) { - let s = esc(raw); // escape < > & FIRST + 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 = [], list = null; + let para = []; + const listStack = []; // [{type:'ul'|'ol', indent}], each open
  • left dangling for nesting // CommonMark soft-break: a single newline inside a paragraph is a SPACE, not a line - // break (the old join("
    ") hard-broke every soft-wrapped line). A hard break needs - // the line to end in 2+ spaces or a backslash. + // break. A hard break needs the line to end in 2+ spaces or a backslash. const flushPara = () => { if (!para.length) return; const seg = []; @@ -750,17 +833,46 @@ function markdownSafe(raw) { out.push(`
    ${seg.join("")}
    `); para = []; }; - const closeList = () => { if (list) { out.push(``); list = null; } }; - for (const line of s.split("\n")) { + const closeAllLists = () => { while (listStack.length) out.push(`
  • `); }; + const openList = (type, num) => { + out.push(type === "ol" && num != null && num !== 1 ? `
      ` : `<${type}>`); + }; + // Indentation-nested list items: deeper indent → a child list inside the open
    1. ; + // 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(`
    2. `); + } + const top = listStack[listStack.length - 1]; + if (!top || indent > top.indent) { + openList(type, num); + listStack.push({ type, indent }); + } else { + out.push(""); // close previous sibling item + if (top.type !== type) { out.push(``); openList(type, num); listStack.push({ type, indent }); } + } + out.push(`
    3. ${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; - if ((m = /^(#{1,6})\s+(.*)$/.exec(line))) { flushPara(); closeList(); out.push(`
      ${mdInline(m[2])}
      `); } - else if ((m = /^>\s?(.*)$/.exec(line))) { flushPara(); closeList(); out.push(`
      ${mdInline(m[1])}
      `); } - else if ((m = /^\s*[-*+]\s+(.*)$/.exec(line))) { flushPara(); if (list !== "ul") { closeList(); out.push("
        "); list = "ul"; } out.push(`
      • ${mdInline(m[1])}
      • `); } - else if ((m = /^\s*\d+\.\s+(.*)$/.exec(line))) { flushPara(); if (list !== "ol") { closeList(); out.push("
          "); list = "ol"; } out.push(`
        1. ${mdInline(m[1])}
        2. `); } - else if (/^\s*$/.test(line)) { flushPara(); closeList(); } - else { closeList(); para.push(line); } + // 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(`
          ${mdInline(m[2])}
          `); } + else if ((m = /^>\s?(.*)$/.exec(line))) { flushPara(); closeAllLists(); out.push(`
          ${mdInline(m[1])}
          `); } + 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(); closeList(); + flushPara(); closeAllLists(); let html = out.join(""); html = html.replace(/ IC(\d+) /g, (m, i) => `${ic[+i]}`); html = html.replace(/ CB(\d+) /g, (m, i) => `
          ${cb[+i]}
          `); @@ -856,23 +968,41 @@ 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 clamp to [-1,1] for safety. +function orbitProj(p, a, d) { + p = _clamp1(p); a = _clamp1(a); d = _clamp1(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 = _clamp1(a); d = _clamp1(d); return 64 + 34 * (0.243 * d + 0.346 * a + 0.906); } +function orbitWallPt(p, a) { p = _clamp1(p); a = _clamp1(a); return [62 + 34 * (-0.819 - 0.574 * a), 64 + 34 * (-0.243 + 0.346 * a - 0.906 * p)]; } +function orbitAxisPt(p) { p = _clamp1(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]; @@ -982,35 +1112,96 @@ function setFader(axis, v) { 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 += ``; + s += ``; + s += ``; + // P readout: point → wall → the P-axis rear edge (two green dashed legs + dots) + s += ``; + s += ``; + s += ``; + s += ``; + // trajectory + committed dots (fading with age) + now marker + pulsing ring + s += ``; + 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 += ``; + } + s += ``; + s += ``; + 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 += ``; - s += ``; - // trajectory oldest→newest, dots fading in - s += ``; - pts.forEach((p, i) => { - const op = (0.15 + 0.7 * (i / (n - 1 || 1))).toFixed(2); - s += ``; - }); - s += ``; - s += `` - + `` - + ``; - 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 @@ -1046,7 +1237,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) || "—") + @@ -1065,6 +1255,7 @@ function renderRelations(snap) { const hint = METRIC_HINTS[label] || ""; const sp = sparkPointsH(hist, 56, 13, 52); const svg = `` + + `` + (sp.empty ? "" : ``) + ``; return `
          ${esc(label)}` @@ -1153,11 +1344,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) => + `
          ` + ).join(""); } } // mouseover hints for each relationship metric (native title tooltips). @@ -1227,6 +1418,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 + ? `
          no chunks for this agent / end-user — the store holds ${esc(total)} in other partitions (scope mismatch, not an empty store)
          ` + : `
          — no memory chunks yet —
          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
          `; + return; + } + const scopeStr = (sc) => Object.entries(sc || {}).map(([k, v]) => `${esc(k)}=${esc(v)}`).join(" · ") || "—"; + pane.innerHTML = chunks.map((c) => + `
          ${scopeStr(c.scope)}` + + `${esc(c.origin || "—")}${c.revision ? " ·r" + esc(c.revision) : ""}
          ` + + `
          ${esc(c.content || "")}
          ` + ).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 = `
          ${msg}
          `; + setMemHead(null, null); + } + } catch (e) { + pane.innerHTML = `
          memory fetch failed
          `; + setMemHead(null, null); + } +} + // ---- tools inventory (left rail chips) ---- function renderToolsInventory(inv) { const names = (arr) => (arr || []).map((t) => (typeof t === "string" ? t : (t && t.name) || "?")); @@ -1341,6 +1583,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); @@ -1521,6 +1764,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); } diff --git a/tests/test_provider_combined.py b/tests/test_provider_combined.py index 0cd0e5a..9c66c3a 100644 --- a/tests/test_provider_combined.py +++ b/tests/test_provider_combined.py @@ -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(): diff --git a/tests/test_provider_memory.py b/tests/test_provider_memory.py index 9f7292f..33dd7c5 100644 --- a/tests/test_provider_memory.py +++ b/tests/test_provider_memory.py @@ -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 diff --git a/tests/test_web_server.py b/tests/test_web_server.py index c160192..e9a0f01 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -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 diff --git a/uv.lock b/uv.lock index 0351cf9..1b512fc 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.20.6" +version = "0.20.7" source = { editable = "." } dependencies = [ { name = "httpx" },