Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f46ccbae1c | |||
| 772fad18b4 | |||
| 8199774405 | |||
| 66ba06875e | |||
| 25ccb5c75b | |||
| 22e7a1b0e7 | |||
| a9c521a48a | |||
| 8fc757aa61 | |||
| 39050c333f | |||
| 19e5182228 | |||
| 5f321b968a | |||
| 5e28919b39 | |||
| 7bca76e7b6 | |||
| 62a16d2d92 | |||
| 8468c471e8 | |||
| 709d2e4498 | |||
| 0441e319f6 | |||
| 48d51023f2 | |||
| 603e9439d3 | |||
| e5ec63967e | |||
| 24644ab90e | |||
| c988f273b1 |
@@ -87,8 +87,8 @@ id = "contract-drift-check-v1"
|
||||
canonical_source = "corviduo-project-template"
|
||||
canonical_path = "scripts/contract_drift_check.py"
|
||||
consumer_path = "scripts/contract_drift_check.py"
|
||||
pinned_sha256_16 = "23271287ac488da4"
|
||||
pinned_at = "2026-05-17T05:30:00+00:00"
|
||||
pinned_sha256_16 = "2659a17a65704b66"
|
||||
pinned_at = "2026-07-12T08:39:35+00:00"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worldtree Conversation-API surface (vendored from ~/development/Worldtree).
|
||||
@@ -120,8 +120,8 @@ id = "worldtree-conversation-api-spec-v1"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/conversation-api-spec.md"
|
||||
consumer_path = "docs/conversation-api-spec.md"
|
||||
pinned_sha256_16 = "c656a789caceef14"
|
||||
pinned_at = "2026-07-06T16:51:09+00:00"
|
||||
pinned_sha256_16 = "2d73d50b8680b893"
|
||||
pinned_at = "2026-07-13T07:54:05+00:00"
|
||||
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
|
||||
|
||||
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
|
||||
@@ -155,6 +155,52 @@ id = "worldtree-affect-egress-consumer-reference-v1"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/affect-egress-consumer-reference.md"
|
||||
consumer_path = "docs/vendor/worldtree-persona-canon/affect-egress-consumer-reference.md"
|
||||
pinned_sha256_16 = "d959134037efae83"
|
||||
pinned_at = "2026-07-07T06:09:24+00:00"
|
||||
pinned_sha256_16 = "b2406e237df00dcb"
|
||||
pinned_at = "2026-07-13T07:54:05+00:00"
|
||||
tolerate_drift = true # prose reference; the d2 render-canon JSONs are the gates
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Brokkr R34/R35 persona-prompt-framing reference (the character-self-report
|
||||
# reframe ratatoskr consumes: the authored psychological_profile is the prose
|
||||
# lens the Worldtree self-report producer reads for affect + memory salience).
|
||||
# Vendored for reference alongside the Worldtree affect/memory surfaces.
|
||||
# tolerate_drift: prose reference, not a machine gate — brokkr-smithy-dev owns
|
||||
# it and pings ratatoskr-dev on canonical changes. The authoring-spec GOVERNS on
|
||||
# any conflict with the parameter distillation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[[pins]]
|
||||
id = "brokkr-psych-profile-authoring-spec-v1"
|
||||
canonical_source = "brokkr-smithy"
|
||||
canonical_path = "research/R34-persona-prompt-framing/deliverables/psych-profile-authoring-spec.md"
|
||||
consumer_path = "docs/vendor/brokkr-r34-psych-profile/psych-profile-authoring-spec.md"
|
||||
pinned_sha256_16 = "4545a108d9fb6cc3"
|
||||
pinned_at = "2026-07-13T00:00:00+00:00"
|
||||
tolerate_drift = true # prose reference; brokkr-smithy-dev owns + pings on change
|
||||
|
||||
[[pins]]
|
||||
id = "brokkr-psych-profile-parameters-v1"
|
||||
canonical_source = "brokkr-smithy"
|
||||
canonical_path = "research/R34-persona-prompt-framing/deliverables/psych-profile-parameters.md"
|
||||
consumer_path = "docs/vendor/brokkr-r34-psych-profile/psych-profile-parameters.md"
|
||||
pinned_sha256_16 = "17157c82771aeeee"
|
||||
pinned_at = "2026-07-13T00:00:00+00:00"
|
||||
tolerate_drift = true # parameter distillation; authoring-spec governs on conflict
|
||||
|
||||
[[pins]]
|
||||
id = "soong-lab-export-contract-v1"
|
||||
canonical_source = "soong-lab"
|
||||
canonical_path = "docs/contracts/export.contract.md"
|
||||
consumer_path = "docs/vendor/soong-lab-bundle/export.contract.md"
|
||||
pinned_sha256_16 = "bbd8fcf0cc7bc535"
|
||||
pinned_at = "2026-07-14T17:15:44+00:00"
|
||||
tolerate_drift = true # soong-lab-dev owns the bundle format + pings ratatoskr-dev on change
|
||||
|
||||
[[pins]]
|
||||
id = "soong-lab-importer-contract-v1"
|
||||
canonical_source = "soong-lab"
|
||||
canonical_path = "docs/contracts/importer.contract.md"
|
||||
consumer_path = "docs/vendor/soong-lab-bundle/importer.contract.md"
|
||||
pinned_sha256_16 = "777b1764c8eb2cb7"
|
||||
pinned_at = "2026-07-14T17:15:44+00:00"
|
||||
tolerate_drift = true # soong-lab-dev owns the bundle format + pings ratatoskr-dev on change
|
||||
|
||||
@@ -116,9 +116,17 @@ interpreted.
|
||||
to the reference `_matches_scope`. (`scope_any` is the union-visibility primitive that
|
||||
resolves the #295/#297 silent-zero — a subset-scoped chunk now recalls via an OR member.)
|
||||
- **INV-006** [hard]: **Capabilities match implementation** (advertise-⇒-implement).
|
||||
`describe_store` advertises ONLY what v1 implements: `relational_edges_supported=False`,
|
||||
`describe_store` advertises ONLY what is implemented: `relational_edges_supported=False`,
|
||||
`atomic_supersede_supported=False`, `transaction_supported=False`,
|
||||
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`.
|
||||
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`,
|
||||
**`sortable_chunk_fields=[{"name": "updated_at", "type": "timestamp"}]`** (the ONLY
|
||||
globally-sortable field; gates `scan`'s sort at the bifrost dispatch `_validate_scan_sort`
|
||||
AND Worldtree's #349 person-prime Branch-A `"updated_at" in caps.sort_fields_supported` —
|
||||
advertising it is what lights up turn-1 durable-fact injection). Both `name` AND `type`
|
||||
are REQUIRED by the bifrost `handshake_response` `SortableChunkField` schema
|
||||
(`additionalProperties:false`) — omitting `type` fails wire-schema validation and breaks
|
||||
the ENTIRE handshake (memory + affect bind), not just the sort; `type` is advisory-only
|
||||
(the wire never interprets it).
|
||||
(`transaction_supported` is the bifrost **wire-level** multi-op transaction
|
||||
capability — NOT our internal SQLite transactions, which we use for atomic
|
||||
batches.) The client gates the gated verbs off these.
|
||||
@@ -127,6 +135,34 @@ interpreted.
|
||||
`InvalidArguments` (mirrors the reference).
|
||||
- **INV-008** [hard]: The store is REQUIRED (`build_memory_app(store=None)` raises);
|
||||
identity/scope/actor come from `ctx`, never call args.
|
||||
- **INV-009** [hard]: **`scan` is LIVE-only.** `scan` returns ONLY live chunks —
|
||||
superseded / tombstoned / any non-live governance state is EXCLUDED server-side. This
|
||||
is load-bearing because Worldtree's person-prime requests `lifecycle_state="live"` but
|
||||
that filter does NOT ride the scan wire today and the client does not re-check it
|
||||
(worldtree-dev flagged the adapter gap); server-side live-only is authoritative, so a
|
||||
dead fact can never inject. The additive `lifecycle_state` scan arg, when present, is
|
||||
honored but never relied upon.
|
||||
- **INV-010** [hard]: **`scan` is globally ordered before pagination.** The FULL
|
||||
scope-filtered live set is ordered by `(sort.field, direction)` GLOBALLY before the
|
||||
`limit` page is taken — never page-local. Missing sort value sorts LAST; ties broken by
|
||||
`chunk_id` (stable). A single `limit`-page returns the N globally-newest (for
|
||||
`updated_at desc`), matching bifrost's cross-pagination conformance negative. The sort
|
||||
field is indexed (`json_extract(record_json, '$.updated_at')`) so the read stays within
|
||||
person-prime's 500 ms fail-open budget.
|
||||
- **Cursor is v1-provisional (KNOWN DEVIATION — offset, not snapshot).** The cursor is a
|
||||
bare integer offset into the re-derived global order. This is CORRECT and conformant for
|
||||
the **single-page** person-prime call (`cursor=None`), which is the only shipped consumer.
|
||||
It **diverges from bifrost's protocol snapshot-cursor contract on multi-page continuation**:
|
||||
the dispatch engine (`bifrost.memory` scan branch) drops the `sort` arg on a cursor
|
||||
continuation because "the cursor's snapshotted order is authoritative", and maps
|
||||
`ScanCursorExpired → 410`. Our offset cursor (a) does NOT snapshot the order — a page taken
|
||||
after a concurrent write can duplicate/drop rows relative to the first page (heid-bug-hunt
|
||||
2026-07-15, all 3 arms), and (b) never raises `ScanCursorExpired`. The `global_before_paginate`
|
||||
/ cursor test asserts **static-store** behavior only. The durable/conformant fix is to adopt
|
||||
the reference `InMemoryMemoryStore`'s snapshot-cursor semantics (opaque token + frozen ordered
|
||||
id-list + TTL + `ScanCursorExpired`); DEFERRED pending bifrost-dev's ruling on the conformance
|
||||
gap (scan/cursor has NO conformance coverage today, so a non-snapshot cursor passes). Routed
|
||||
to bifrost-dev 2026-07-15.
|
||||
|
||||
## Concurrency
|
||||
|
||||
@@ -163,7 +199,7 @@ negotiation, routes). **This contract** owns the store (the basic verbs + SQLite
|
||||
|
||||
## Out of scope (deferred — do NOT flag as drift)
|
||||
|
||||
- **Gated/maintenance verbs:** `upsert_edges`/`get_edges_for`, `scan`, `mark_invalid`/`mark_superseded`, `patch_many`, `atomic_supersede`, lease/checkpoint. Absent + advertised-unsupported.
|
||||
- **Gated/maintenance verbs:** `upsert_edges`/`get_edges_for`, `mark_invalid`/`mark_superseded`, `patch_many`, `atomic_supersede`, lease/checkpoint. Absent + advertised-unsupported. (`scan` is NO LONGER deferred — it is implemented + advertised via `sortable_chunk_fields` to light up Worldtree's #349 person-prime turn-1 durable-fact injection; see the `scan` FN spec + INV-009/INV-010.)
|
||||
- **metadata_filter beyond scope:** advertise `filterable_metadata_fields=[]`; a non-empty `metadata_filter` is unsupported in v1 (rejected — see search PRE).
|
||||
- **The combined two-plane server** (guide §7) — separate memory + affect apps in v1.
|
||||
- **Deployment** — dev-box background shell (`ratatoskr-memory-provider`), no systemd/infra.
|
||||
@@ -270,6 +306,29 @@ TESTS:
|
||||
delete_absent [boundary]: unknown id → {"deleted":0}
|
||||
```
|
||||
|
||||
```contract
|
||||
FN scan(self, *, scope_all: dict | None = None, scope_any: list | None = None, cursor: str | None = None, limit: int, sort: dict | None = None, lifecycle_state=None) -> dict
|
||||
BRIEF: Query-LESS paginated LIVE-chunk scan, globally ordered by an advertised sort field (updated_at) — the #349 person-prime turn-1 durable-fact injection primitive (no query vector, unlike search). Returns {records, cursor}.
|
||||
PRE: [PRE-001 hard] limit is a positive int -- else InvalidArguments
|
||||
PRE: [PRE-002 hard] scope_all/scope_any shape + lattice-validated via _validate_scope (identical to search PRE-003) -- else InvalidArguments / InvalidFilter
|
||||
PRE: [PRE-003 hard] sort, when present, is {field, direction}: field ∈ the advertised sortable_chunk_fields names ("updated_at"), direction ∈ {asc,desc}. The bifrost dispatch layer (_validate_scan_sort) is the enforcement gate; an unadvertised/malformed sort → InvalidArguments — NEVER a silent unsorted fallback
|
||||
POST: [POST-001 return_value] {records: [<verbatim chunk wire records, same shape as a search hit's chunk>], cursor: <opaque next-page str | None>}; ≤ limit records; each record carries updated_at + agent_id + subject{type,id} + worldtree_scope (the fields person-prime's client _scan_filter_matches keys on — a record missing any is silently dropped client-side) -- assert
|
||||
POST: [POST-002 return_value] LIVE-only — returns ONLY live chunks; superseded/tombstoned excluded server-side (INV-009)
|
||||
POST: [POST-003 return_value] GLOBAL-order — the FULL scope-filtered live set is ordered by (sort.field, direction) GLOBALLY before the limit page; missing value LAST; chunk_id tiebreak (INV-010)
|
||||
STEPS:
|
||||
1. [setup] validate limit (>0) + scope (as search); sort ← the dispatch-validated {field,direction}
|
||||
2. [sequential, flexibility=indicative] SELECT scope-filtered LIVE chunks ordered by the indexed sort field (json_extract(record_json,'$.updated_at')) in `direction`, missing-last, chunk_id tiebreak, GLOBALLY; apply cursor offset; take limit
|
||||
3. [cleanup] RETURN {records: verbatim chunks, cursor: next-page-or-None}
|
||||
TESTS:
|
||||
scan_recency [happy,tracer]: upsert 4 live chunks w/ distinct updated_at; scan(scope_all={end_user}, limit=3, sort={field:updated_at,direction:desc}) → the 3 newest, newest-first
|
||||
global_before_paginate [scenario]: 5 chunks, limit=2 → page-1 = the 2 globally-newest; the cursor page continues the GLOBAL order, not a page-local re-sort (INV-010; bifrost cross-pagination conformance)
|
||||
live_only [adversarial]: a superseded/tombstoned chunk is NEVER returned even if it is the newest (INV-009)
|
||||
scope_isolation [adversarial]: scope_all one end_user → never returns another partition's chunk (INV-005 applies to scan)
|
||||
unadvertised_sort [adversarial]: sort.field ∉ sortable_chunk_fields → InvalidArguments at dispatch (never silent unsorted)
|
||||
person_prime_record_shape [scenario]: each record carries agent_id + subject{type,id} + worldtree_scope + updated_at + verbatim/distillate — the _scan_filter_matches keys (else the client silently drops it)
|
||||
parity_vs_reference [scenario]: identical scan envelopes vs InMemoryMemoryStore → same ordered chunk_ids/shape (#195)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_memory_provider_app(store: RatatoskrMemoryStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette
|
||||
BRIEF: Wire JwtVerifier + registration; hand the store to bifrost's build_memory_app.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -2858,6 +2858,8 @@ Semantics:
|
||||
which must carry all three of `pleasure` / `arousal` / `dominance`, each a
|
||||
float in `[-1.0, 1.0]`. Any other top-level key → 422 `validation_failed`;
|
||||
a missing or malformed `pad` → 422 `persona_seed_invalid`.
|
||||
|
||||
> **✓ R32-1B (landed, v1.0.0b29):** The PAD range `[-1.0, 1.0]` relaxes to an **unbounded latent `z`** with a finite wire sanity bound (`~±10`) as of R32 Slice-1B. The JSON shape/fields/types are UNCHANGED — only the declared range/semantics change (the value becomes a latent that renders to a bounded display value). Consumers that merely store-and-return PAD need no change; consumers that validate/clamp PAD to `[-1,1]` must relax that bound. Source of truth: `docs/contracts/persona_envelope.contract.md` rev 1.7 (INV-ENV-16).
|
||||
- **Seeds the current mood POINT, not the setpoint.** The OCEAN persona
|
||||
(above) fixes the setpoint the mood relaxes toward; this endpoint sets
|
||||
where the mood *starts*. It does not alter the persona.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Psychological Profile Authoring Spec — canonical
|
||||
|
||||
**Status:** canonical (v1). **Owner:** brokkr-smithy-dev (R34/R35 self-report reframe).
|
||||
**Audience:** anyone authoring a character's `psychological_profile` — Worldtree
|
||||
foundational characters (soong-dev) and consumer characters created via the
|
||||
Conversation API (ratatoskr and other external consumers).
|
||||
**For:** the Worldtree agent-definition schema; intended to live in the Worldtree
|
||||
client-app documentation.
|
||||
|
||||
This spec governs the **content** of the psychological profile (what to write and
|
||||
what never to write). The **physical wire shape** of the field (single string vs a
|
||||
small keyed dict) is Worldtree's schema call — see § Wire shape.
|
||||
|
||||
---
|
||||
|
||||
## 1. What it is
|
||||
|
||||
A dedicated **authored prose section** of a character definition that carries the
|
||||
character's **psychological bent and formative experience**. It is the source the
|
||||
self-report producer maps from when it decides, on each turn:
|
||||
|
||||
- **what the character feels** (affect self-report), and
|
||||
- **what the character notices and keeps** (character-voiced memory salience).
|
||||
|
||||
The profile is a *lens*, not a script. It never states per-turn emotions; it
|
||||
describes the standing disposition, history, values, and attention that — combined
|
||||
with the actual event — *produce* the emotion and the salience.
|
||||
|
||||
It sits **alongside the numeric OCEAN** values (a separate, deterministic input).
|
||||
The prose gives the *qualitative* bent; the OCEAN numbers give the *magnitude dial*
|
||||
(see § OCEAN interaction).
|
||||
|
||||
---
|
||||
|
||||
## 2. What it carries — the four dimensions
|
||||
|
||||
1. **Disposition / appraisal bent** — how the character characteristically
|
||||
*interprets* situations: attribution style, what they hold weighty, how they
|
||||
respond to being challenged. NOT per-event emotions.
|
||||
2. **Attention / salience focus** — the kinds of things this character
|
||||
characteristically *notices* (and therefore tends to remember).
|
||||
3. **Values / what a good day looks like** — the yardstick that drives what they
|
||||
find worth keeping.
|
||||
4. **Formative experience (history)** — the background that shapes both appraisal
|
||||
*and* salience. A character betrayed before appraises betrayal differently, and
|
||||
remembers different things.
|
||||
|
||||
You may write these as four short labelled sections or as one integrated paragraph
|
||||
— both are supported (see § Length & format).
|
||||
|
||||
---
|
||||
|
||||
## 3. Authoring rules (load-bearing)
|
||||
|
||||
These are the rules the whole reframe depends on. Rule 1 is the one that most often
|
||||
gets violated.
|
||||
|
||||
1. **Never name a per-event output emotion.** Do NOT write "is anxious", "gets
|
||||
angry at X", "feels hurt when criticized", "joyful". Naming an emotion **primes**
|
||||
it — the "pink ball" effect — so the producer will report that emotion regardless
|
||||
of what actually happens in the scene. Describe *disposition, history, values,
|
||||
attention*; let the emotion come from the event appraisal.
|
||||
- ✅ "Registers quickly when authority is substituted for craft." (an appraisal
|
||||
trigger — sets up how she reads an event, names no feeling)
|
||||
- ❌ "Feels contempt when someone pulls rank." (names the output emotion)
|
||||
|
||||
2. **Magnitude lives in the numeric OCEAN, not the prose.** *How strongly / how
|
||||
long* a character reacts (Neuroticism) is the deterministic OCEAN dial, rendered
|
||||
valence-neutral by the producer. Do not narrate reaction dynamics in the prose
|
||||
("comes apart", "takes it hard", "rich inner life") — that double-encodes what the
|
||||
number already carries. The prose gives the *qualitative bent*; the number gives
|
||||
the *gain*.
|
||||
|
||||
3. **Appraisal-style is allowed; output-emotion is not.** "Interprets others'
|
||||
actions charitably until she can't" (a style) is fine; "feels betrayed easily"
|
||||
(an output) is not. The style plus the event produce the output.
|
||||
|
||||
4. **Salience is character-relative; facts are not.** The profile shapes what the
|
||||
character *cares to remember*. It must never license rewriting *what happened* —
|
||||
when the character does remember something, it stays grounded in the transcript.
|
||||
|
||||
---
|
||||
|
||||
## 4. Wire shape & field placement
|
||||
|
||||
- **Content is prose** covering the four dimensions, authored as **one coherent prose
|
||||
string** — the four dimensions are authoring *structure* inside that single string,
|
||||
not separate wire fields.
|
||||
- **Wire shape (LOCKED, b53):** a single dedicated prose string, field
|
||||
**`psychological_profile`** (type `str`) on the persona layer — foundational
|
||||
`persona.psychological_profile`, Tier-3 `ValidatedPersona.psychological_profile`. It
|
||||
nests under the existing `Any`-typed persona field, so it is the shipped b53 shape —
|
||||
no schema change. **Not** a dict-of-four.
|
||||
- **Hard constraint (non-negotiable):** the profile is a **dedicated field the lens
|
||||
reads ONLY** (`resolve_psych_profile` reads only this field — no `behavioral_notes`
|
||||
or other general-field remap). Non-lens content leaking into the lens produces the
|
||||
"executive-assistant" failure (the producer reads response-format / tone / tool
|
||||
instructions as if they were the character's psychology).
|
||||
|
||||
---
|
||||
|
||||
## 5. The non-priming banned set
|
||||
|
||||
The non-priming rule (Rule 1) is **semantic, not a fixed wordlist** — it bans naming
|
||||
any per-event output emotion, which is broader than any specific vocabulary
|
||||
("anxious", "worried", "hurt" all prime even though they are not in the producer's
|
||||
fixed emotion roster).
|
||||
|
||||
- **The gate is human review:** does the prose describe disposition / appraisal-style
|
||||
/ history / values / attention, and never what the character *feels*?
|
||||
- **A mechanical lint is a backstop, not the gate.** If you build one, scan the
|
||||
fixed-15 OCC roster plus `synonym_map.json` (which already folds common affect
|
||||
synonyms) as the core set, optionally extended with a general affect lexicon. Treat
|
||||
a lint hit as a prompt to re-read, not an automatic reject.
|
||||
|
||||
---
|
||||
|
||||
## 6. Required vs optional dimensions
|
||||
|
||||
- **Required** (they *are* the lens): **disposition**, **attention / salience focus**,
|
||||
**values**.
|
||||
- **Strongly recommended:** **formative history** — it is the single biggest lever on
|
||||
richness (validated in P03: richer history → sharper, more character-appropriate
|
||||
salience). It may be brief for a deliberately thin character, but omitting it leaves
|
||||
salience under-grounded.
|
||||
|
||||
---
|
||||
|
||||
## 7. Length & format
|
||||
|
||||
- A focused paragraph, or four short labelled sections — **a lens, not a biography.**
|
||||
- Target **~150–300 words.** The producer reads this on **every** turn, so keep it
|
||||
tight; bloat is a latency and dilution cost.
|
||||
- **Prose only — never typed emotion fields.** The four dimensions are a coverage
|
||||
checklist for the author, not a schema of feelings to fill in.
|
||||
|
||||
---
|
||||
|
||||
## 8. Exemplars
|
||||
|
||||
These three were the validated P03 stimuli — integrated-paragraph form, each faithful
|
||||
to its OCEAN, none naming an output emotion. (OCEAN shown in **[−1, 1] storage units**;
|
||||
validated in P03 at the equivalent [0, 1] values.)
|
||||
|
||||
**Perrin — court scribe** (OCEAN: O0.0 C0.2 E−0.2 A0.1 N0.7)
|
||||
> Perrin keeps the court's records and has done so through two changes of regime. He
|
||||
> learned early that small errors compound — a misfiled writ once cost a man his
|
||||
> lands, and Perrin found the mistake too late to undo it. Since then he double-checks
|
||||
> everything and watches situations closely for what is out of place. He forms
|
||||
> attachments slowly and holds a given trust as a considerable thing. He measures
|
||||
> himself by whether he was useful and careful. He notices discrepancies, unspoken
|
||||
> tensions, and anything that threatens the order he keeps.
|
||||
|
||||
**Vared — veteran caravan guard** (OCEAN: O−0.2 C0.4 E−0.5 A−0.2 N−0.7)
|
||||
> Vared has guarded caravans across the northern routes for twenty years and buried
|
||||
> more traveling companions than he cares to count. He speaks little and shows less.
|
||||
> Danger he treats as weather — a thing to be handled. He judges people by what they
|
||||
> do under pressure and remembers who held the line. What reaches him reaches him
|
||||
> quietly and privately. He notices terrain, exits, who is armed, and shifts in a
|
||||
> group that might precede trouble.
|
||||
|
||||
**Sella — village healer** (OCEAN: O0.2 C0.2 E0.0 A0.8 N0.0)
|
||||
> Sella has tended the sick since she was old enough to carry water for her
|
||||
> grandmother, the healer before her. She reads people's pain quickly and carries some
|
||||
> of it with her. She interprets others' actions charitably until she cannot, and
|
||||
> prioritizes keeping the peace between people. She measures a day by whether she eased
|
||||
> someone's burden. She notices who is unwell, who is troubled, and what is left
|
||||
> unsaid.
|
||||
|
||||
Note how each closes on **attention** ("he notices…", "she notices…") — the salience
|
||||
focus stated plainly, no emotion named.
|
||||
|
||||
---
|
||||
|
||||
## 9. OCEAN interaction & the scaffold fallback
|
||||
|
||||
OCEAN values are stored on **[−1, 1]** (0 = average) — a **separate deterministic
|
||||
input** and the **magnitude dial** the prose must not duplicate (Rule 2). The producer
|
||||
renders **off-average** bands as valence-neutral disposition cues. It maps storage to
|
||||
[0, 1] first (`c = (v + 1) / 2`, `render_disposition` in b53) and then applies the
|
||||
canonical [0, 1] band cutoffs (`c < 0.33` low / `c > 0.66` high). In **storage units**
|
||||
that is:
|
||||
|
||||
| trait | low (v < −0.34) | high (v > +0.32) |
|
||||
|---|---|---|
|
||||
| **N** (reactivity only) | reactions are milder than most people's | reactions are more intense than most people's |
|
||||
| **E** (expression; may be excluded from affect elicitation) | socially reserved; expression less outwardly amplified | socially expressive; reactions more externally visible |
|
||||
| **O** | prefers the familiar, the concrete, established ways | curious, drawn to novelty, ideas, the unfamiliar |
|
||||
| **C** | less plan-bound; less weight on order, detail, obligation | attends closely to order, detail, and obligations |
|
||||
| **A** | less inclined to assume cooperative intent; direct, self-protective | more inclined to preserve rapport and weigh others' needs |
|
||||
|
||||
The **mid** band (−0.34 ≤ v ≤ +0.32, i.e. `c` in [0.33, 0.66]) renders nothing — an
|
||||
average trait is silent, **not** "low." (Boundaries are slightly asymmetric because
|
||||
the canonical 0.33/0.66 cutoffs are not symmetric about 0.5. Canonical rendering
|
||||
strings live in the reframe language catalog §4; persistence/recovery dynamics live in
|
||||
the deterministic mood decay, not the profile.)
|
||||
|
||||
**Scaffold fallback:** a character with **no** authored profile falls back to this
|
||||
band-rendering from the OCEAN numbers alone. That still functions — but the authored
|
||||
profile is what turns generic band cues into *this specific character's* appraisal and
|
||||
salience. Authoring the profile is how the reframe's value actually reaches a
|
||||
character.
|
||||
|
||||
---
|
||||
|
||||
## 10. Authoring divergent characters (contrast design)
|
||||
|
||||
When you want two characters to remember **noticeably different things** (e.g. for an
|
||||
eval contrast pair, or simply a varied cast), design the divergence on the **attention
|
||||
and values** dimensions first, and set the OCEAN numbers to *serve* that prose — not
|
||||
the reverse.
|
||||
|
||||
- **The sharpest contrast is a salience *drop*, not just a different flavor.** One
|
||||
character for whom relational/emotional content is genuinely non-salient (an
|
||||
operational, task-focused character in the Vared mold — notices terrain, logistics,
|
||||
who is armed) versus one who weights it highest (a caretaker who tracks who is
|
||||
troubled and what went unsaid). "Different notes, same facts" has real teeth only
|
||||
when one character *legitimately forgets* what the other keeps.
|
||||
- **High-yield axes for salience divergence:** O (what patterns they attend to), A
|
||||
(relational vs operational/self-protective focus), C (procedural/detail salience).
|
||||
- **Low-yield for salience:** E — it is expression-oriented (shapes how a reaction is
|
||||
*rendered*, not what is *noticed*), and may even be excluded from the affect
|
||||
elicitation. Don't lean on flipping E to create divergence.
|
||||
- **Watch the direction, not just the distance:** flipping every OCEAN axis to its
|
||||
opposite does not guarantee a strong contrast. If your reference character already
|
||||
*keeps* relational content, an even-more-agreeable opposite keeps it harder and the
|
||||
most intuitive contrast collapses. Aim the contrast at *dropping* what the reference
|
||||
*keeps*.
|
||||
|
||||
---
|
||||
|
||||
## Provenance & validation
|
||||
|
||||
Grounded in R34/R35 (self-report reframe), probes P02–P05: character-voiced memory
|
||||
salience validated on two model classes (P02/P03); the "Psychological Profile and
|
||||
Experience" section mapping validated as the lens source (P03); non-priming and
|
||||
magnitude-in-OCEAN corrections are operator rulings (2026-07-10). The affect half is
|
||||
live in production (Worldtree b53) and fired a contextually-apt self-report on a
|
||||
non-frontier seat. A powered efficacy eval (salience divergence / floor recall /
|
||||
salience≠facts firewall / graded model-slot response + the authored-vs-scaffold delta)
|
||||
is preregistering to quantify the memory half; findings will refine this spec, not
|
||||
overturn its authoring rules.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Psychological Profile Parameters — for AI generation (canonical)
|
||||
|
||||
**Status:** canonical (v1). **Owner:** brokkr-smithy-dev (R34/R35 self-report reframe).
|
||||
**Audience:** **soong-dev** (Soong's Lab / Soong's AI — the immediate builder that
|
||||
generates the profile from these parameters); **Worldtree** + **ratatoskr** (vendoring
|
||||
for reference alongside the authoring spec).
|
||||
**Relationship:** this is the **parameter distillation** of
|
||||
`psych-profile-authoring-spec.md` for the model where **Soong's AI writes the
|
||||
`psychological_profile` prose from parameters** (rather than a human hand-authoring it).
|
||||
The authoring spec carries the full reasoning + provenance and **governs on any
|
||||
conflict**; this file is the builder-facing input schema + generation guardrails + few-shot.
|
||||
|
||||
The profile is the prose **lens** the Worldtree self-report producer reads each turn to
|
||||
decide what the character **feels** (affect self-report) and what it **notices / keeps**
|
||||
(character-voiced memory salience). Soong's AI generates the prose; these are its inputs
|
||||
and the constraints its output must satisfy.
|
||||
|
||||
---
|
||||
|
||||
## 1. Input parameters (what the Lab collects / Soong's AI takes)
|
||||
|
||||
1. **role / vocation** — a short anchor ("court scribe", "veteran caravan guard",
|
||||
"village healer").
|
||||
2. **OCEAN values** — O, C, E, A, N each on **[−1, 1]** (0 = average). A **separate
|
||||
deterministic input** the producer uses directly (the "magnitude dial"); Soong's AI
|
||||
should see them to keep the qualitative bent *consistent* with the numbers, but must
|
||||
**not re-encode their magnitude** in the prose (constraint 2).
|
||||
3. **formative-history seed** — 1–2 key background facts/events that shape appraisal AND
|
||||
salience. **Single biggest lever on richness** (validated P03: richer history →
|
||||
sharper, more character-appropriate salience).
|
||||
4. **appraisal-bent seed** — how the character characteristically **interprets**
|
||||
situations (attribution style, what they hold weighty, how they respond to challenge).
|
||||
A *style*, NOT an emotion.
|
||||
5. **attention / salience-focus seed** — the kinds of things this character
|
||||
characteristically **notices** (and therefore keeps). Load-bearing for the memory half.
|
||||
6. **values / yardstick seed** — what "a good day" looks like; the yardstick driving what
|
||||
they find worth keeping.
|
||||
|
||||
## 2. Output (what Soong's AI emits)
|
||||
|
||||
A single coherent **prose string** (~150–300 words), field **`psychological_profile`**
|
||||
(type `str`) — the four dimensions (disposition / attention / values / formative-history)
|
||||
integrated as one paragraph. **Prose only — never typed emotion fields.** The producer
|
||||
reads it every turn, so keep it tight.
|
||||
|
||||
## 3. Generation constraints (the guardrails the output MUST obey — these ARE the reframe)
|
||||
|
||||
1. ★ **Never name a per-event output emotion.** Do NOT write "is anxious", "gets angry at
|
||||
X", "feels hurt when criticized", "joyful". Naming an emotion **primes** it (the
|
||||
"pink-ball" effect) so the producer reports it regardless of what actually happens.
|
||||
Describe disposition / history / values / attention; let the emotion come from the
|
||||
event appraisal.
|
||||
- ✅ "Registers quickly when authority is substituted for craft." (appraisal trigger)
|
||||
- ❌ "Feels contempt when someone pulls rank." (names the output emotion)
|
||||
2. **Magnitude lives in OCEAN, not prose.** Don't narrate reaction dynamics ("comes
|
||||
apart", "takes it hard", "rich inner life") — that double-encodes what the number
|
||||
already carries.
|
||||
3. **Appraisal-style yes; output-emotion no.** "Interprets others' actions charitably
|
||||
until she can't" (style) = fine; "feels betrayed easily" (output) = not.
|
||||
4. **Salience is character-relative; facts are not.** The profile shapes what the
|
||||
character *cares to remember*; it must never license rewriting *what happened* —
|
||||
remembered content stays grounded in the transcript.
|
||||
5. **Close on attention** ("...notices who is unwell, who is troubled, what is left
|
||||
unsaid") — state the salience focus plainly.
|
||||
|
||||
## 4. Few-shot exemplars (validated P03 — OCEAN in [−1, 1] storage units → emitted prose)
|
||||
|
||||
**Perrin, court scribe** (O0.0 C0.2 E−0.2 A0.1 N0.7)
|
||||
> Perrin keeps the court's records and has done so through two changes of regime. He
|
||||
> learned early that small errors compound — a misfiled writ once cost a man his lands,
|
||||
> and Perrin found the mistake too late to undo it. Since then he double-checks
|
||||
> everything and watches situations closely for what is out of place. He forms
|
||||
> attachments slowly and holds a given trust as a considerable thing. He measures himself
|
||||
> by whether he was useful and careful. He notices discrepancies, unspoken tensions, and
|
||||
> anything that threatens the order he keeps.
|
||||
|
||||
**Vared, veteran caravan guard** (O−0.2 C0.4 E−0.5 A−0.2 N−0.7)
|
||||
> Vared has guarded caravans across the northern routes for twenty years and buried more
|
||||
> traveling companions than he cares to count. He speaks little and shows less. Danger he
|
||||
> treats as weather — a thing to be handled. He judges people by what they do under
|
||||
> pressure and remembers who held the line. What reaches him reaches him quietly and
|
||||
> privately. He notices terrain, exits, who is armed, and shifts in a group that might
|
||||
> precede trouble.
|
||||
|
||||
**Sella, village healer** (O0.2 C0.2 E0.0 A0.8 N0.0)
|
||||
> Sella has tended the sick since she was old enough to carry water for her grandmother,
|
||||
> the healer before her. She reads people's pain quickly and carries some of it with her.
|
||||
> She interprets others' actions charitably until she cannot, and prioritizes keeping the
|
||||
> peace between people. She measures a day by whether she eased someone's burden. She
|
||||
> notices who is unwell, who is troubled, and what is left unsaid.
|
||||
|
||||
## 5. Validation
|
||||
|
||||
The gate is: **does the prose describe disposition / appraisal-style / history / values /
|
||||
attention, and NEVER what the character feels?** A mechanical lint (scan the fixed-15 OCC
|
||||
emotion roster + Worldtree's `synonym_map.json`) is a **backstop, not the gate** — treat a
|
||||
hit as a prompt to re-read, not an auto-reject.
|
||||
|
||||
## 6. Designing a varied cast / contrast (optional)
|
||||
|
||||
When two characters should remember **noticeably different things**: design the divergence
|
||||
on **attention + values first**, then set OCEAN to **serve** that prose (not the reverse).
|
||||
The sharpest contrast is a salience **drop** — one character for whom relational content is
|
||||
genuinely non-salient (a Vared-mold operational type: notices terrain, logistics, who is
|
||||
armed) vs one who weights it highest (a caretaker: tracks who is troubled, what went
|
||||
unsaid). *"Different notes, same facts" only has teeth when one character legitimately
|
||||
forgets what the other keeps.* High-yield axes: **O** (patterns attended), **A** (relational
|
||||
vs operational), **C** (procedural/detail). Low-yield: **E** (expression, not attention).
|
||||
Watch **direction, not just distance** — flipping every axis doesn't guarantee contrast (an
|
||||
even-more-agreeable opposite keeps relational content *harder*).
|
||||
|
||||
## 7. No-profile fallback
|
||||
|
||||
A character with **no** authored profile falls back to deterministic **OCEAN-band
|
||||
rendering** from the numbers alone — it still functions, but the authored profile is what
|
||||
turns generic band cues into *this* character's appraisal and salience.
|
||||
|
||||
---
|
||||
|
||||
**Provenance:** derived from `psych-profile-authoring-spec.md` (R34/R35 self-report
|
||||
reframe, probes P02–P05; non-priming + magnitude-in-OCEAN are operator rulings 2026-07-10).
|
||||
The affect half is live in Worldtree b53. A powered efficacy eval (memory half) is
|
||||
preregistering; findings will refine the parameters, not overturn the constraints.
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "soong_lab.export"
|
||||
purpose: "Assemble a versioned export BUNDLE from a DesignObject — the native agents.define payload (Frame Invariant 1, emitted unchanged) + the soong-lab sidecar (portrait ref · Bifrost tool manifest · first_message) + the resume half (the full editable design state), under a versioned schema tolerant of unknown future metadata. Pure + deterministic: no I/O, no persistence, no network (library persistence + import are separate downstream epics)."
|
||||
depends_on:
|
||||
- "soong_lab.design" # validate_ocean + ROLE_CHOICES/validate_role (the role enum canon) + the DesignObject model + serialize_design (relocated here — see Integration points R1)
|
||||
used_by:
|
||||
- "soong_lab.bifrost" # the export design-tool handler (_make_export) builds the bundle for the session's design
|
||||
- "soong_lab.web" # the /api/export endpoint + the browser 'Export Asset' modal render the bundle
|
||||
- "soong_lab.importer" # FUTURE (import epic) — round-trips the resume half back into a DesignObject
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 200
|
||||
confidence: 0.82
|
||||
assumptions:
|
||||
- "The DesignObject handed to export is already mutated to its final state by E3 (the Soong convo loop). Export READS it; it never mutates the design (INV-E5-4)."
|
||||
- "design_id is CALLER-SUPPLIED (a param), not generated here. Its generation + lifecycle (the durable library key, ≠ Worldtree session_id) is the per-design-sessions epic; export only needs the value to stamp the bundle. This keeps E5-export self-contained + forward-compatible with per-design-sessions landing before OR after it (agent-discretion, see open_question C)."
|
||||
- "exported_at is CALLER-SUPPLIED (a param, default None). Pure builders can't read the clock; the caller (tool handler / endpoint) stamps the timestamp so build_export_bundle stays deterministic + testable (byte-identical output for identical inputs)."
|
||||
- "role is a FIRST-CLASS design field (operator ruling 2026-07-13), set by the E3a set_role tool from a CURATED 4-value enum ROLE_CHOICES = {assistant, thoughtful-assistant, character, thoughtful-character} — mirroring the D2 curated-style-modes pattern (a fixed semantic set, NOT the target deployment's arbitrary model-role registry). Export EMITS design.role into ship.native.role, so the native payload is directly valid (Frame Invariant 1 now holds literally — no 'modulo role' caveat; only tools still bind separately at session-create). The 4 enum values are canonical soong-lab labels that MUST be registered/granted model-roles on the target Worldtree at deploy (same grant requirement as Soong's own 'agent_architect' role, ADR-0012) — a deploy-time grounding item, not a contract blocker (open_question B)."
|
||||
- "psych_profile exports to the NATIVE persona layer at persona.psychological_profile. RESOLVED: the vendored canonical spec (docs/psych-profile-authoring-spec.md §4) states the wire shape is LOCKED (b53) — a single prose str field, Tier-3 ValidatedPersona.psychological_profile, nesting under the existing Any-typed persona field (no schema change). Corroborated by worldtree-codex (vor-cross) + brokkr-smithy-dev (althing 01KXD34ZTF…). The open worldtree-dev thread (01KXD1PZR7…) closes as a formality."
|
||||
open_questions:
|
||||
- "[B — deploy grounding, not a blocker] The 4 ROLE_CHOICES values (assistant / thoughtful-assistant / character / thoughtful-character) must be registered + grantable model-role slugs on the TARGET Worldtree (like Soong's own agent_architect role). Confirm with worldtree-dev/infra that these exact slugs exist on the deploy target before shipping; a missing slug fails the designed agent's session-create, not export. Export emits whatever role the design holds; validity of the slug on a given deployment is a deploy concern."
|
||||
- "[C — agent-discretion, notable] design_id as a caller-supplied param (drafted) vs E5-export generating it. Drafted as an input so E5-export doesn't force per-design-sessions to land first. If the operator re-sequences the epics so per-design-sessions lands first, no change needed here (the param source just moves)."
|
||||
- "[D — scope] E5-export = the PURE builders + validators + bundle schema (this contract). The /api/export endpoint + replacing the web/api.js exportBundle shim = a thin web-surface follow-up (amends web_surface.contract.md), NOT this contract. The Bifrost export-tool wiring IS in scope (Integration points) because the tool already exists as a stub. The set_role tool + DesignObject.role field are a companion prerequisite slice (Integration points) whose contract updates land in THIS pass (design_object + bifrost_server)."
|
||||
- "schema_version starts at '1.0'. The version bump policy on future bundle-shape changes (add-only vs breaking) is deferred to when the second version actually exists — v1 only needs the field present + readers to tolerate unknown metadata (INV-E5-6)."
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
E5-export is the FOUNDATION half of the operator-accepted (2026-07-13)
|
||||
export/import/library design — the block that expands the locked single-agent
|
||||
frame into a multi-pass tuning loop (design → export → reopen → tune → keep a
|
||||
library). This contract owns exactly ONE thing: turning a finished
|
||||
`DesignObject` into a **versioned export bundle**. Persistence (the library JSON
|
||||
dir), the recent-designs picker, and import round-tripping are separate
|
||||
downstream epics; export is pure and deterministic so those epics — and the
|
||||
tests — can build on a stable, side-effect-free core.
|
||||
|
||||
**The bundle is ONE artifact with two halves** (settled decision #4):
|
||||
|
||||
- **ship** — what you hand to a deployment: the native `agents.define` payload
|
||||
(Frame Invariant 1, emitted unchanged) + the soong-lab **sidecar** (persona
|
||||
portrait ref, the Bifrost tool manifest, the D3 first_message).
|
||||
- **resume** — what you reopen to keep tuning: the full editable design state
|
||||
(the §6 DesignObject serialization), so a future import reconstructs the
|
||||
DesignObject exactly.
|
||||
|
||||
Plus a stable **`design_id`** (the durable library key, ≠ Worldtree
|
||||
`session_id`) and a **`schema_version`**, both at the top level.
|
||||
|
||||
**Frame Invariant 1 is preserved — and now holds literally.** `ship.native` is a
|
||||
valid Worldtree Tier-3 `agents.define` payload assembled from `agent_name` + the
|
||||
designed agent's **`role`** (the model-role, resolved below) + the AUTHORED
|
||||
`system_prompt` (INV-E2-2 — never `composed_preview`) + `persona.ocean`
|
||||
(Worldtree renders affect at runtime) + `persona.psychological_profile` (the
|
||||
native home, LOCKED b53 per the vendored spec §4) + `motivational` (from
|
||||
goals_fears). The image and tools are NOT in the native schema — they ride the
|
||||
sidecar (tools bind via Bifrost at session-create, exactly as grounded).
|
||||
|
||||
**The `role` resolution (operator ruling 2026-07-13).** The blast-radius pass
|
||||
caught that `agents.define` requires `role` (a model-role slug, ADR-0012) but the
|
||||
design had no source for it. Resolution: **role is a first-class design field**,
|
||||
set by a new E3a **`set_role`** tool from a **curated 4-value enum** —
|
||||
`assistant` (general LLM), `thoughtful-assistant` (CoT general),
|
||||
`character` (RP/writing-tuned), `thoughtful-character` (CoT RP). This mirrors the
|
||||
D2 curated-style-modes decision: a fixed semantic set the operator picks from,
|
||||
NOT a coupling to any one deployment's arbitrary role registry. Export emits
|
||||
`design.role`, so the native payload is directly POST-valid (modulo the tool
|
||||
binding every consumer already supplies at session-create). The one deploy-time
|
||||
caveat: the 4 slugs must be granted on the target Worldtree (open_question B).
|
||||
|
||||
**The psych field is RESOLVED (no longer quarantined).** Vendored spec §4 locks
|
||||
`persona.psychological_profile` (prose `str`, ~150–300 words, read every turn),
|
||||
nesting under the `Any`-typed persona layer. Export maps `design.psych_profile`
|
||||
there and NOWHERE else — spec §4's hard constraint is that the self-report lens
|
||||
reads ONLY this field (leaking psych prose into `behavioral_notes`/`system_prompt`
|
||||
causes the "executive-assistant" failure).
|
||||
|
||||
## Data flow
|
||||
|
||||
**In:** a `DesignObject` (final, from E3) + a caller-supplied `design_id` (str)
|
||||
+ an optional caller-supplied `exported_at` (str | None). **Out:** a plain
|
||||
JSON-ready `dict` — the versioned bundle. **On disk / network:** NONE. Export is
|
||||
pure: the OCEAN parity gate (`validate_ocean`), the role-enum gate
|
||||
(`validate_role`), and the export-critical validators are in-memory; timestamps +
|
||||
ids come in as params; no clock, no randomness, no file, no HTTP. (Library
|
||||
persistence writes the returned dict to the JSON dir — that is the library epic,
|
||||
not this module.)
|
||||
|
||||
### Export bundle schema (v1.0)
|
||||
|
||||
```
|
||||
{
|
||||
"schema_version": "1.0", # ALWAYS EXPORT_SCHEMA_VERSION — not a caller param
|
||||
"design_id": "<caller-supplied durable library key, ≠ WT session_id>",
|
||||
"exported_at": <caller-supplied OPAQUE str | null — conventionally ISO-8601, NOT validated by export>,
|
||||
"ship": {
|
||||
"native": { # a valid agents.define payload (Frame Invariant 1)
|
||||
"agent_name": <str, non-blank, ≤128>,
|
||||
"role": <one of ROLE_CHOICES: assistant|thoughtful-assistant|character|thoughtful-character>,
|
||||
"system_prompt": <str, non-blank, ≤32768 — the AUTHORED block, INV-E2-2>,
|
||||
"persona": {
|
||||
"ocean": {O,C,E,A,N}, # each a real number in [-1,1] (validate_ocean parity)
|
||||
"psychological_profile": <str> # persona.psychological_profile (LOCKED b53); included iff non-blank
|
||||
},
|
||||
"motivational": {"goals": [...], "fears": [...]} # included iff goals_fears present + non-empty
|
||||
},
|
||||
"sidecar": {
|
||||
"portrait": <image ref str | null>, # only when portrait.status == "ready"; E4 owns generation
|
||||
"tools": [{"id","name","description"}],# the Bifrost tool manifest (bind at session-create)
|
||||
"first_message": <str> # the D3 opening turn (issue #347 seed)
|
||||
}
|
||||
},
|
||||
"resume": { <the §6 camelCase editable state — key set inlined below> }
|
||||
}
|
||||
```
|
||||
|
||||
**The `resume` key set (inlined — heid-review fold Gróa #9).** The resume half IS
|
||||
`serialize_design(design)` (relocated to `soong_lab.design`, R1), but its key set is
|
||||
pinned HERE so this contract is self-contained and an implementer knows the exact
|
||||
round-trip surface without reading the external, being-relocated function:
|
||||
|
||||
```
|
||||
resume = {
|
||||
"agentName", "role", "systemPrompt", "composedPreview", "firstMessage",
|
||||
"ocean" {O,C,E,A,N}, "dispositionPhrase", "psychProfile",
|
||||
"tools" [{id,name,description}], "portrait" {status, styleMode, imageUrl?, jobId?},
|
||||
"goalsFears" {goals,fears} | null
|
||||
}
|
||||
```
|
||||
|
||||
Import reconstructs a DesignObject from exactly these keys. `role` (new, R1) MUST be
|
||||
present so a reopened design carries its model-role. (`composedPreview` +
|
||||
`dispositionPhrase` are design-time-derived and re-derivable, but they ride the resume
|
||||
so a reopen renders instantly before the first recompute.)
|
||||
|
||||
**Divergences from the imported web mock (settled here, they were UI-comp
|
||||
shortcuts):**
|
||||
|
||||
| Field | Mock (web/*.js) | Real export (this contract) |
|
||||
|---|---|---|
|
||||
| native shape | `{name, tier, system_prompt, personality:{model,values}}` | real `agents.define` (`agent_name`/`role`/`persona.ocean`/`motivational`) |
|
||||
| role | absent | `design.role` ∈ ROLE_CHOICES |
|
||||
| system_prompt | `composedPreview` (mockApi) | authored `system_prompt` (INV-E2-2) |
|
||||
| psychProfile | omitted ("open backend decision") | `persona.psychological_profile` (LOCKED b53) |
|
||||
| bundle identity | none | `design_id` + `schema_version` |
|
||||
| resume half | none | full `serialize_design` state |
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-E5-1** [hard]: `ship.native` is a valid Worldtree `agents.define` payload
|
||||
MODULO the tool binding — it carries every required field (`agent_name`,
|
||||
`role`, `system_prompt`) + `persona.ocean`, and OMITS only the tools (they bind
|
||||
via Bifrost at session-create, as they already do). Any `persona.ocean` export
|
||||
emits passes `validate_ocean`; `role` is always one of ROLE_CHOICES.
|
||||
`persona.psychological_profile` + `motivational` are OPTIONAL native fields
|
||||
(grounded) — omitting them when blank/empty keeps the payload fully valid, not
|
||||
merely "valid enough" (heid-review fold, Gróa #1).
|
||||
- **INV-E5-2** [hard]: The exported `system_prompt` is the AUTHORED
|
||||
`design.system_prompt`, NEVER `composed_preview` (binds with INV-E2-2). The
|
||||
**disposition line** — the `"Disposition: <name> is <phrase>."` sentence that
|
||||
E2 `recompute` appends to `composed_preview` (design_object.contract.md POST-E2-5)
|
||||
— is design-time-only and never ships.
|
||||
- **INV-E5-3** [hard]: Export is pure + deterministic — identical
|
||||
`(design, design_id, exported_at)` inputs yield a byte-identical serialized
|
||||
bundle. No clock, no randomness, no I/O. The determinism is WITHIN the module:
|
||||
the returned dict has a fixed key insertion order (schema_version, design_id,
|
||||
exported_at, ship, resume; native + sidecar likewise), so any consistent
|
||||
`json.dumps` settings produce byte-identical output — the invariant does NOT
|
||||
claim cross-implementation byte-identity (heid-review fold, Regin #6).
|
||||
- **INV-E5-4** [hard]: Export NEVER mutates the input `DesignObject` (read-only);
|
||||
the bundle holds copies, not aliases, of every mutable sub-structure (ocean
|
||||
dict, tool list, goals/fears lists) so a later design mutation can't change an
|
||||
already-built bundle.
|
||||
- **INV-E5-5** [hard]: `validate_exportable` is the strict export-critical gate
|
||||
(decision #6): OCEAN (via `validate_ocean`), role (∈ ROLE_CHOICES via
|
||||
`validate_role`), agent_name (non-blank, ≤128), system_prompt (non-blank,
|
||||
≤32768), tool-refs (id/name non-blank + bounded). A design that fails ANY of
|
||||
these raises `ExportError` and NO bundle is produced — a built bundle is always
|
||||
well-formed enough to round-trip on import.
|
||||
- **INV-E5-6** [hard]: The bundle carries `schema_version` at the top level, and
|
||||
readers (import, future) MUST tolerate unknown extra keys (lenient on unknown
|
||||
metadata, decision #6) — the schema is add-only-friendly.
|
||||
- **INV-E5-7** [hard]: `psych_profile` maps to `persona.psychological_profile`
|
||||
and NOWHERE else — it never leaks into `behavioral_notes`, `system_prompt`, or
|
||||
any other native field (vendored spec §4 hard constraint — the lens reads only
|
||||
this dedicated field).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[correctness]** `validate_exportable`'s OCEAN check IS `validate_ocean` and
|
||||
its role check IS `validate_role` (both E2) — no re-implementation, no drift.
|
||||
The LENGTH bounds (name, prompt, tool id/name/desc, psych_profile, first_message)
|
||||
MUST equal the E3a tool-schema caps — now shared constants in `soong_lab.design`
|
||||
(`AGENT_NAME_MAX`, `SYSTEM_PROMPT_MAX`, `PSYCH_PROFILE_MAX`, `FIRST_MESSAGE_MAX`,
|
||||
`TOOL_*_MAX`), imported by BOTH bifrost/tools.py and export — so a design's field
|
||||
LENGTHS never drift. Import the shared constants; do not re-declare the numbers.
|
||||
(Export is stricter only on whitespace-blankness of the required fields — the one
|
||||
intentional one-way difference from the tools' minLength:1.)
|
||||
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Every
|
||||
time-varying value (`design_id`, `exported_at`) is a param.
|
||||
- **[explicit]** The one deploy-time caveat (the 4 role slugs must be granted on
|
||||
the target WT) is documented in THIS contract (open_question B) + the library /
|
||||
README when it lands — NOT promised as a bundle/sidecar field (heid-review fold:
|
||||
the bundle is machine-consumed; a human deploy-note is not bundle data). The
|
||||
bundle carries the `role` value; slug-grant validity is a deploy concern.
|
||||
- **[explicit]** `build_export_bundle` is the PUBLIC entrypoint — it runs the
|
||||
validate→assemble ordering. `build_native_payload` / `build_sidecar` are exposed
|
||||
for testing + reuse but ASSUME an already-validated design (PRE-E5-2 / PRE-E5-4);
|
||||
a direct caller that skips `validate_exportable` owns that gate (heid-review fold,
|
||||
Hulda #5).
|
||||
|
||||
```contract
|
||||
FN validate_exportable(design: DesignObject) -> None
|
||||
BRIEF: The strict export-critical gate (settled decision #6) — refuse to build a bundle from a design that would fail on re-import or at the designed agent's define/session-create. Checks OCEAN (validate_ocean), role (validate_role), agent_name, system_prompt, every tool-ref, and the psych_profile/first_message LENGTH — against the SAME length caps the E3a tools enforce (shared constants). NO-DRIFT is one-directional: export's LENGTH bounds equal the tool caps, but export is deliberately STRICTER on whitespace — a whitespace-only required field (name/prompt/tool id/name) passes the tools' minLength:1 yet is rejected here (a " " name must not ship). Raises ExportError with the offending field; never mutates the design.
|
||||
PRE: [PRE-E5-1 hard] design is a DesignObject
|
||||
POST: [POST-E5-1 exception] raises ExportError(field, detail) unless ALL hold: design.ocean passes validate_ocean; design.role passes validate_role (∈ ROLE_CHOICES); agent_name is a non-blank str of len ≤ _AGENT_NAME_MAX; system_prompt is a non-blank str of len ≤ _SYSTEM_PROMPT_MAX; every tool has non-blank str id (≤_TOOL_ID_MAX) + non-blank str name (≤_TOOL_NAME_MAX) + str description (≤_TOOL_DESC_MAX); psych_profile is a str of len ≤ _PSYCH_PROFILE_MAX (blank OK); first_message is a str of len ≤ _FIRST_MESSAGE_MAX (blank OK). The id/name-required vs description/psych/first_message-may-be-blank asymmetry is INTENTIONAL — description defaults to "" via attach_tool; psych_profile/first_message are optional prose so only their LENGTH is bounded, not blankness (heid-review Gróa #8 + correctness-finder folds)
|
||||
POST: [POST-E5-2 state_change] design is unchanged — no mutation (INV-E5-4)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] TRY validate_ocean(design.ocean) — on OceanError, RAISE ExportError("persona.ocean", str(exc)) (reuse E2, no re-impl)
|
||||
2. [sequential, flexibility=prescriptive] TRY validate_role(design.role) — on RoleError, RAISE ExportError("role", str(exc)) (reuse E2 role canon)
|
||||
3. [branch] IF agent_name is not a non-blank str OR len > _AGENT_NAME_MAX: RAISE ExportError("agent_name", ...)
|
||||
4. [branch] IF system_prompt is not a non-blank str OR len > _SYSTEM_PROMPT_MAX: RAISE ExportError("system_prompt", ...) # the AUTHORED block, INV-E5-2
|
||||
5. [loop] FOR EACH tool in design.tools: IF id/name blank or over max, or description non-str/over max: RAISE ExportError(f"tools[{i}]", ...)
|
||||
6. [branch] IF psych_profile is non-str OR len > _PSYCH_PROFILE_MAX: RAISE ExportError("psych_profile", ...) # length only — blank OK (optional prose)
|
||||
7. [branch] IF first_message is non-str OR len > _FIRST_MESSAGE_MAX: RAISE ExportError("first_message", ...) # length only — blank OK
|
||||
8. [cleanup] RETURN None
|
||||
TESTS:
|
||||
minimal_ok [happy,tracer]: agent_name+system_prompt set, role="character", neutral OCEAN, no tools → no raise
|
||||
blank_name [adversarial]: agent_name="" → ExportError("agent_name")
|
||||
blank_prompt [adversarial]: system_prompt=" " → ExportError("system_prompt")
|
||||
prompt_too_long [boundary]: system_prompt of len _SYSTEM_PROMPT_MAX+1 → ExportError; len _SYSTEM_PROMPT_MAX → ok
|
||||
bad_ocean [adversarial]: ocean missing a key → ExportError("persona.ocean") (via validate_ocean)
|
||||
bad_role [adversarial]: role="wizard" (not in ROLE_CHOICES) → ExportError("role") (via validate_role)
|
||||
blank_role [adversarial]: role="" → ExportError("role")
|
||||
bad_tool_ref [adversarial]: a tool with id="" → ExportError("tools[0]")
|
||||
no_mutation [property]: a rejected design is byte-identical before/after the raise (INV-E5-4)
|
||||
psych_profile_length [boundary]: psych_profile="" → ok; len _PSYCH_PROFILE_MAX+1 → ExportError("psych_profile")
|
||||
first_message_length [boundary]: first_message len _FIRST_MESSAGE_MAX+1 → ExportError("first_message"); blank → ok
|
||||
whitespace_name_rejected [adversarial]: agent_name=" " → ExportError("agent_name") — deliberately stricter than the tool's minLength:1 (a whitespace-only name must not ship)
|
||||
length_bounds_parity [property]: any (name, prompt, tool, psych, first_message) LENGTH the E3a tool schema accepts is ≤ export's caps (shared constants); export is stricter ONLY on whitespace-blankness of required fields, never looser on length
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_native_payload(design: DesignObject) -> dict[str, Any]
|
||||
BRIEF: Map a DesignObject to a valid native agents.define payload (Frame Invariant 1). Emits agent_name + role + the AUTHORED system_prompt + persona{ocean, psychological_profile?} + motivational?. Copies mutable sub-structures (INV-E5-4). Assumes validate_exportable already passed (called by build_export_bundle).
|
||||
PRE: [PRE-E5-2 hard] design passed validate_exportable (OCEAN valid, role valid, name/prompt present) — build_export_bundle enforces this ordering
|
||||
POST: [POST-E5-3 return_value] result has agent_name == design.agent_name, role == design.role (∈ ROLE_CHOICES), and system_prompt == design.system_prompt (the AUTHORED block, INV-E5-2), and result["persona"]["ocean"] == a COPY of design.ocean
|
||||
POST: [POST-E5-4 return_value] result["role"] == design.role — the designed agent's model-role (one of the 4 ROLE_CHOICES); a valid agents.define required field
|
||||
POST: [POST-E5-5 return_value] persona.psychological_profile == design.psych_profile when psych_profile is non-blank, else the key is absent; it appears under persona and NOWHERE else (INV-E5-7)
|
||||
POST: [POST-E5-6 return_value] motivational == {"goals": copy, "fears": copy} when design.goals_fears is present AND at least one list is non-empty; else the key is absent (never an empty motivational block)
|
||||
STEPS:
|
||||
1. [setup] payload = {"agent_name": design.agent_name, "role": design.role, "system_prompt": design.system_prompt} # role emitted; system_prompt is the authored block (INV-E5-2)
|
||||
2. [sequential] persona = {"ocean": dict(design.ocean)} # COPY, not alias (INV-E5-4)
|
||||
3. [branch] IF design.psych_profile is a non-blank str: persona["psychological_profile"] = design.psych_profile # LOCKED b53 field; ONLY here (INV-E5-7)
|
||||
4. [sequential] payload["persona"] = persona
|
||||
5. [branch] IF design.goals_fears is not None AND (goals or fears non-empty): payload["motivational"] = {"goals": list(gf.goals), "fears": list(gf.fears)}
|
||||
6. [cleanup] RETURN payload # tools NOT here — they ride the sidecar / bind via Bifrost at session-create
|
||||
TESTS:
|
||||
authored_prompt [happy,tracer]: system_prompt authored + composed_preview differs → payload.system_prompt == authored, NOT composed_preview (INV-E5-2)
|
||||
role_emitted [happy]: role="thoughtful-character" → payload.role == "thoughtful-character" (POST-E5-4)
|
||||
ocean_copied [property]: mutate design.ocean after build → payload's ocean unchanged (INV-E5-4)
|
||||
psych_present [happy]: psych_profile set → persona.psychological_profile == it; it is the ONLY field carrying it (INV-E5-7)
|
||||
psych_absent [boundary]: psych_profile="" → no psychological_profile key
|
||||
motivational_present [happy]: goals_fears with goals=["x"] → motivational.goals == ["x"]
|
||||
motivational_absent [boundary]: goals_fears None → no motivational key; goals_fears with both lists empty → no motivational key
|
||||
no_tools_no_image [trace]: payload has no "tools" and no image field (they ride the sidecar / bind separately)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_sidecar(design: DesignObject) -> dict[str, Any]
|
||||
BRIEF: Assemble the soong-lab sidecar — the three artifacts the native schema has no home for: the persona portrait ref, the Bifrost tool manifest, and the D3 first_message. Copies the tool list (INV-E5-4).
|
||||
PRE: [PRE-E5-4 hard] design is a DesignObject (its portrait/tools/first_message fields are read as-is; no validation here — validate_exportable is the gate, called by build_export_bundle before this)
|
||||
POST: [POST-E5-7 return_value] result == {"portrait": <str|None>, "tools": [{"id","name","description"} per tool, copied], "first_message": design.first_message}; portrait == design.portrait.image_url IFF design.portrait.status == "ready", else None (a "ready" status with a None image_url therefore yields None — no crash; any non-"ready" status → None — heid-review fold Gróa #4)
|
||||
STEPS:
|
||||
1. [setup] portrait = design.portrait.image_url if design.portrait.status == "ready" else None
|
||||
2. [sequential] tools = [t.to_dict() for t in design.tools] # ToolRef.to_dict() — the shared {id,name,description} projection (dedups with serialize_design); it MUST emit exactly id/name/description, so if to_dict ever grows keys the sidecar spec must be revisited (heid-code-review fold)
|
||||
3. [cleanup] RETURN {"portrait": portrait, "tools": tools, "first_message": design.first_message}
|
||||
TESTS:
|
||||
ready_portrait [happy]: portrait.status="ready", image_url set → sidecar.portrait == the url
|
||||
unready_portrait [boundary]: portrait.status="generating" (url set) → sidecar.portrait is None (only ready ships)
|
||||
none_portrait [boundary]: portrait.status="none" → sidecar.portrait is None
|
||||
tools_manifest [happy,tracer]: two tools → sidecar.tools has both {id,name,description}
|
||||
tools_copied [property]: mutate design.tools after build → sidecar.tools unchanged (INV-E5-4)
|
||||
first_message [happy]: first_message set → sidecar.first_message == it
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_export_bundle(design: DesignObject, *, design_id: str, exported_at: str | None = None) -> dict[str, Any]
|
||||
BRIEF: The top-level export entrypoint — validate (strict, INV-E5-5), then assemble the versioned bundle: {schema_version, design_id, exported_at, ship:{native, sidecar}, resume}. Pure + deterministic (INV-E5-3); the caller supplies design_id + exported_at (no clock here). The resume half reuses serialize_design (the §6 state) so import round-trips. schema_version is NOT a caller param (heid-review fold) — it is ALWAYS EXPORT_SCHEMA_VERSION, so a bundle's version is never caller-forgeable; a future migration bumps the module constant. exported_at is an OPAQUE caller-supplied string (conventionally ISO-8601) — export does NOT parse or validate it (purity; the caller owns timestamp correctness).
|
||||
PRE: [PRE-E5-3 hard] design_id is a non-blank str (the durable library key) — a blank id RAISES ExportError("design_id", ...) (a bundle with no library key is unusable)
|
||||
POST: [POST-E5-8 exception] IF the design fails validate_exportable, the ExportError propagates and NO bundle is returned (INV-E5-5) — validation is BEFORE assembly
|
||||
POST: [POST-E5-9 return_value] returns {schema_version: EXPORT_SCHEMA_VERSION (always), design_id, exported_at, ship:{native: build_native_payload(design), sidecar: build_sidecar(design)}, resume: serialize_design(design)}; exported_at is the param verbatim (None → JSON null), unvalidated
|
||||
POST: [POST-E5-10 return_value] deterministic — identical (design, design_id, exported_at) → byte-identical json.dumps(result) given fixed dumps settings; the returned dict has a FIXED key insertion order (schema_version, design_id, exported_at, ship, resume), so a caller's json.dumps is stable (INV-E5-3); design unchanged (INV-E5-4)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] IF design_id is not a non-blank str: RAISE ExportError("design_id", "a non-blank design_id is required")
|
||||
2. [sequential] CALL validate_exportable(design) # strict gate BEFORE assembly (INV-E5-5) — raises propagate
|
||||
3. [sequential] native = build_native_payload(design); sidecar = build_sidecar(design); resume = serialize_design(design)
|
||||
4. [cleanup] RETURN {"schema_version": EXPORT_SCHEMA_VERSION, "design_id": design_id, "exported_at": exported_at, "ship": {"native": native, "sidecar": sidecar}, "resume": resume}
|
||||
TESTS:
|
||||
full_bundle [happy,tracer]: a complete design + design_id="d-1" → bundle has schema_version, design_id=="d-1", ship.native.agent_name, ship.native.role, ship.sidecar.first_message, resume.systemPrompt
|
||||
blank_design_id [adversarial]: design_id="" → ExportError("design_id") before any assembly
|
||||
invalid_design_no_bundle [adversarial]: a design with blank agent_name → ExportError propagates, no dict returned (POST-E5-8)
|
||||
deterministic [property]: build twice with the same (design, design_id, exported_at) → byte-identical json.dumps (INV-E5-3)
|
||||
exported_at_passthrough [trace]: exported_at="2026-07-13T00:00:00Z" → bundle.exported_at == it verbatim; None → null; a non-ISO "banana" is passed through unvalidated
|
||||
schema_version_not_a_param [trace]: build_export_bundle(..., schema_version="banana") raises TypeError — schema_version is fixed, never caller-supplied (heid-review fold)
|
||||
resume_roundtrips [property]: resume half == serialize_design(design) — every editable field present for import (incl. role)
|
||||
no_mutation [property]: design byte-identical before/after build (INV-E5-4)
|
||||
schema_version_present [trace]: bundle.schema_version == EXPORT_SCHEMA_VERSION (INV-E5-6)
|
||||
```
|
||||
|
||||
## Integration points
|
||||
|
||||
**R1 — relocate `serialize_design` out of `web.py` (agent-discretion refactor,
|
||||
no public-surface change).** The resume half reuses the §6 DesignObject
|
||||
serialization, but `serialize_design` currently lives in `soong_lab.web`
|
||||
(Starlette-coupled). Importing `web.py` into `export` would drag Starlette +
|
||||
the orchestrator into a pure module. Fix: **move `serialize_design` to
|
||||
`soong_lab.design`** (it is a pure `DesignObject → dict` mapping with no web
|
||||
dependency — it belongs with the model; add `role` to its output), and update the
|
||||
two consumers to import it from there. Blast radius (confirmed via grep):
|
||||
`web.py` (define → import; 3 call-sites unchanged), `tests/test_web.py:23`
|
||||
(import path), and the new `export` consumer. Behavior-identical;
|
||||
`web_surface.contract.md` gets a one-line note. No-backwards-compat: the old
|
||||
location is deleted, all refs updated in the same commit.
|
||||
|
||||
**Companion prerequisite slice — the `role` field + `set_role` tool (contracts
|
||||
updated in THIS pass).** Export emits `design.role`, so the field + its tool must
|
||||
exist. This slice (governed by the sibling contracts, amended alongside this one):
|
||||
- `soong_lab.design` (design_object.contract.md): a `role` field on
|
||||
`DesignObject` (default `"character"`); a `ROLE_CHOICES` enum canon +
|
||||
`validate_role`, held as an in-code module constant (mirroring the OCEAN
|
||||
adjective canon); `new_design()` sets `role="character"`; `serialize_design`
|
||||
adds `role`.
|
||||
- `soong_lab.bifrost` (bifrost_server.contract.md): a new `set_role(_ctx, role)`
|
||||
design tool (the 9th), `input_schema` an `enum` of the 4 values; the handler
|
||||
sets `design.role` after membership validation.
|
||||
The behavioral CODE for this slice lands in the TDD phase after
|
||||
`/heid-contract-review`, alongside `soong_lab.export`.
|
||||
|
||||
**Bifrost export tool (`_make_export` in bifrost/tools.py) — in scope.** Replace
|
||||
the deferred stub with: get the session's design from the store, then
|
||||
`build_export_bundle(design, design_id=<source>, exported_at=<stamp>)` and
|
||||
return the bundle (or a compact confirmation carrying it). The `design_id`
|
||||
source is the per-design-sessions seam (open_question C) — until it lands, the
|
||||
tool may pass the session_id as a provisional design_id (a documented
|
||||
placeholder, NOT a silent default). The tool handler is the impure boundary that
|
||||
stamps `exported_at` (clock) and supplies `design_id`, keeping
|
||||
`soong_lab.export` pure.
|
||||
|
||||
**`/api/export` endpoint + web/api.js shim — NOT in this contract (open_question
|
||||
D).** The browser 'Export Asset' button calls `api.export()`, today a
|
||||
client-side shim assembling a NON-native mock bundle. The real path is a thin
|
||||
`GET /api/export` on `web.py` → `build_export_bundle(orchestrator.get_design(),
|
||||
…)` → JSON → the modal's native/sidecar panes render it. That amends
|
||||
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified
|
||||
here only so the seam is visible.
|
||||
|
||||
## Downstream epics (NOT this contract)
|
||||
|
||||
- **Library persistence** (decision #5) — writing the returned bundle to the
|
||||
server-local single-user JSON dir on corviduo-dev, keyed by `design_id`; the
|
||||
minimal recent-designs picker.
|
||||
- **Import** (decision #6) — reading a bundle: lenient on unknown metadata
|
||||
(INV-E5-6), STRICT re-validation of the export-critical fields (the import-side
|
||||
mirror of `validate_exportable`), reconstructing a DesignObject from the
|
||||
`resume` half.
|
||||
- **Per-design-sessions** (decision #2) — the `design_id` generator + the
|
||||
fresh-WT-session-per-open lifecycle (also caps the #355 accumulation).
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "soong_lab.importer"
|
||||
purpose: "Reconstruct a DesignObject from an export bundle's `resume` half — the inverse of soong_lab.export. HYBRID validation (settled decision #6): LENIENT on unknown metadata (unknown top-level bundle keys, unknown keys inside resume, any schema_version), STRICT re-validation of the export-critical fields (OCEAN, role ∈ ROLE_CHOICES, agent_name, system_prompt length, tool-refs, psych/first_message length) surfaced ON IMPORT so a truncated or tampered bundle fails EARLY, not after more tuning. Pure + deterministic: no I/O, no persistence, no network, no clock (library read + the /api/import endpoint + the reopen lifecycle are separate downstream epics)."
|
||||
depends_on:
|
||||
- "soong_lab.design" # DesignObject/ToolRef/Portrait/GoalsFears + serialize_design (the round-trip partner) + ROLE_CHOICES/UNSET_ROLE + the shared field-bound constants
|
||||
- "soong_lab.export" # validate_exportable + ExportError — the strict export-critical gate is REUSED, not re-implemented (no-drift, INV-I-1)
|
||||
used_by:
|
||||
- "soong_lab.web" # FUTURE (import epic) — the POST /api/import endpoint parses the uploaded bundle JSON → import_bundle → seed a session (out of scope here, open_question D)
|
||||
- "soong_lab.soong" # FUTURE (per-design-sessions) — the reopen lifecycle imports a stored bundle, opens a fresh WT session, seeds the design-state summary (out of scope, decision #2)
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 170
|
||||
confidence: 0.83
|
||||
assumptions:
|
||||
- "Import consumes a Python dict (a Mapping), NOT raw bytes/JSON text. The JSON parse (json.loads at the /api/import endpoint or the library-read layer) happens UPSTREAM; import operates on the already-parsed structure, exactly as export RETURNS a Python dict the caller json.dumps'es. So the round-trip contract is over Python dicts: import_bundle(build_export_bundle(d, design_id=…)) == d, with no JSON layer in between (the JSON boundary — float/int coercion, encoding — is the endpoint/library epic's concern, INV-I-5 note)."
|
||||
- "The `resume` half is the ONLY source of truth on import (settled decision #4 — resume is 'what you reopen to keep tuning'). The `ship` half is a re-derivable deployment artifact; import IGNORES it. The reopen path re-exports from the reconstructed design, regenerating ship, so a ship↔resume mismatch is harmless — resume wins (INV-I-5). No cross-check in v1."
|
||||
- "The export-critical gate on import IS soong_lab.export.validate_exportable, imported and reused verbatim — NOT a re-implemented import-side validator. This guarantees import can never drift looser than export: the exact fields export refuses to ship are the exact fields import refuses to accept (INV-I-1). ExportError is caught and re-raised as BundleImportError so callers get an import-shaped error while the validation authority stays single-sourced."
|
||||
- "role is a first-class DesignObject field (operator ruling 2026-07-13), one of the curated ROLE_CHOICES, set by the E3a set_role tool. A resume carries `role`; import restores it and validate_role (via validate_exportable) rejects UNSET_ROLE ('') or any non-member — you cannot re-import an unclassified design, same as you cannot export one."
|
||||
- "composed_preview + disposition_phrase ride the resume so a reopen renders instantly (export.contract §resume). Import TRUSTS these verbatim (INV-I-8) — it does NOT call recompute. Re-derivation from ocean+prompt is the reopen lifecycle's concern (per-design-sessions), not import's. For a legitimately-exported bundle they are already self-consistent; a hand-tampered preview is design-time-only and is overwritten on the next set_ocean/edit_prompt recompute."
|
||||
open_questions:
|
||||
- "[A — RESOLVED, operator 2026-07-13] Module name is `soong_lab.importer` (operator chose it over `soong_lab.ingest`; keyword-safe agent-noun mirroring `export`). The export contract's forward-reference `used_by: soong_lab.import` — an unusable Python-keyword path (`import soong_lab.import` is a SyntaxError) — is corrected to `soong_lab.importer` in the same commit (done). SETTLED: the Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1 reconcile open-vs-locked)."
|
||||
- "[B — SETTLED, agent-discretion] Error type is `BundleImportError(field, detail)`, mirroring export's `ExportError(field, detail)`. Deliberately NOT `ImportError` — that shadows the Python builtin, a foot-gun for an import module. The Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1)."
|
||||
- "[C — presence vs default, agent-discretion, notable] For the export-critical resume keys (agentName, role, systemPrompt, ocean) a MISSING key is a hard reject (INV-I-7), NOT a silent default. Rationale: a missing `ocean` would default to a VALID neutral OCEAN and pass validate_exportable — silently masking trait loss from a truncated bundle. Rejecting on absence fails loudly + consistently (the 'fail early on import' the decision wants). Rejected alternative: reconstruct-with-defaults-then-validate (inconsistent — ocean slips through while name/role are caught by validation)."
|
||||
- "[D — scope] This contract = the PURE reconstruction (deserialize_design) + the strict entrypoint (import_bundle) + BundleImportError. The POST /api/import endpoint (amends web_surface.contract.md), the reopen Bifrost tool / session-open wiring (per-design-sessions), and reading a bundle off the library JSON dir (library epic) are ALL downstream — specified here only as the integration seam so it is visible. Nothing in this contract does I/O."
|
||||
- "[E — schema_version tolerance] `schema_version` is read at the bundle TOP LEVEL only (where export stamps EXPORT_SCHEMA_VERSION) — import does not look for it inside `resume`. v1 tolerates ANY top-level value (present or absent) and reads the v1 resume key set regardless (INV-I-2, INV-E5-6 add-only-friendly). 'Tolerate any version' means forward-compat with ADD-ONLY future changes — NOT a promise of semantic compatibility with a bundle whose meaning changed (heid-review Gróa#5/Hulda). A future policy — reject an incompatible MAJOR version, or dispatch to a version-specific deserializer — is deferred to when a second schema version actually exists. v1 has exactly one shape."
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Import is the SECOND half of the operator-accepted (2026-07-13) export/import/library
|
||||
design — the block that expands the locked single-agent frame into a multi-pass
|
||||
tuning loop (design → export → **reopen → tune** → keep a library). Where
|
||||
`soong_lab.export` turns a finished `DesignObject` into a versioned bundle, this
|
||||
module does the inverse: it takes a bundle's **`resume`** half and reconstructs an
|
||||
editable `DesignObject` you can drop back into a session and keep tuning.
|
||||
|
||||
The reconstruction is **HYBRID-validated** (settled decision #6 — the load-bearing
|
||||
import decision):
|
||||
|
||||
- **LENIENT on unknown metadata.** Unknown top-level bundle keys, unknown keys
|
||||
inside `resume`, and any `schema_version` (present or absent) are tolerated —
|
||||
import reads only the keys it knows (INV-I-2, mirroring the export bundle's
|
||||
add-only-friendly `INV-E5-6`). A bundle from a future soong-lab that added
|
||||
fields still imports.
|
||||
- **STRICT on the export-critical fields.** OCEAN, `role`, `agent_name`,
|
||||
`system_prompt`, tool-refs, and the psych/first_message length are re-validated
|
||||
**on import** by REUSING `soong_lab.export.validate_exportable` verbatim (INV-I-1)
|
||||
— so the exact fields export refuses to *ship* are the exact fields import
|
||||
refuses to *accept*, and import can never drift looser than export. A bad field
|
||||
is surfaced immediately (fail EARLY), not after the operator has tuned for
|
||||
another ten minutes against a design that was never valid.
|
||||
|
||||
**The round-trip is the load-bearing contract between the two modules** (INV-I-3):
|
||||
for any exportable design `d`,
|
||||
|
||||
```
|
||||
import_bundle(build_export_bundle(d, design_id="…")) == d
|
||||
deserialize_design(serialize_design(d)) == d
|
||||
```
|
||||
|
||||
This is what makes "export then reopen" lossless. `serialize_design`
|
||||
(relocated to `soong_lab.design` in the export pass, R1) is the forward half;
|
||||
`deserialize_design` here is its exact inverse.
|
||||
|
||||
**Import reads the `resume` half ONLY.** The `ship` half (native `agents.define`
|
||||
payload + sidecar) is a re-derivable deployment artifact — the reopen path
|
||||
re-exports from the reconstructed design, regenerating `ship`. So import ignores
|
||||
`ship` entirely (INV-I-5); a tampered `ship` that disagrees with `resume` is
|
||||
harmless (resume wins, ship regenerated). No cross-check in v1.
|
||||
|
||||
**What this contract does NOT do** (open_question D): no file read, no HTTP, no
|
||||
session seeding. The `POST /api/import` endpoint, the reopen Bifrost tool /
|
||||
session-open wiring, and reading a bundle off the library JSON dir are downstream
|
||||
epics. This module is the pure, side-effect-free reconstruction core those epics
|
||||
build on — exactly as `soong_lab.export` is the pure builder its endpoint wraps.
|
||||
|
||||
## Data flow
|
||||
|
||||
**In:** a bundle `dict` (a Mapping — already `json.loads`'d upstream). **Out:** a
|
||||
validated, ready-to-reopen `DesignObject`. **On disk / network:** NONE. Import is
|
||||
pure: the structural gate (bundle/resume/ocean are dicts, tools a list-of-dicts),
|
||||
the tolerant reconstruction, and the strict `validate_exportable` re-check are all
|
||||
in-memory; no clock, no randomness, no file, no HTTP.
|
||||
|
||||
### The resume key set consumed (v1.0)
|
||||
|
||||
Import reconstructs from exactly the `serialize_design` output (the §6 camelCase
|
||||
state — pinned in export.contract §resume, restated here so this contract is
|
||||
self-contained):
|
||||
|
||||
```
|
||||
resume = {
|
||||
"agentName": <str>, # EXPORT-CRITICAL — presence required (INV-I-7)
|
||||
"role": <str ∈ ROLE_CHOICES>, # EXPORT-CRITICAL — presence required; validate_role gates value
|
||||
"systemPrompt": <str>, # EXPORT-CRITICAL — presence required; the AUTHORED block
|
||||
"ocean": {O,C,E,A,N}, # EXPORT-CRITICAL — presence required; validate_ocean gates value
|
||||
"tools": [{id,name,description}], # optional (absent → []); each ref value-gated by validate_exportable
|
||||
"composedPreview": <str>, # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
|
||||
"dispositionPhrase": <str>, # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
|
||||
"firstMessage": <str>, # optional prose — length-gated only (blank OK)
|
||||
"psychProfile": <str>, # optional prose — length-gated only (blank OK)
|
||||
"portrait": {status, styleMode, imageUrl?, jobId?}, # optional (absent → default Portrait())
|
||||
"goalsFears": {goals,fears} | null # optional (absent/null → None)
|
||||
}
|
||||
```
|
||||
|
||||
**Critical vs optional (the presence rule, INV-I-7).** Read the two functions as a
|
||||
boundary (all three review arms flagged that the prose blurs it): the INNER
|
||||
`deserialize_design` is total and DEFAULTS every missing key (a missing `ocean` →
|
||||
neutral) — it NEVER rejects; the OUTER, public `import_bundle` PRESENCE-CHECKS the
|
||||
export-critical keys and REJECTS a missing one BEFORE it ever calls deserialize. So
|
||||
"import defaults a missing ocean to neutral" is FALSE for the public path
|
||||
(`import_bundle` rejects it, INV-I-7) — the neutral default lives ONLY inside the
|
||||
never-directly-shipped inner function (heid-review 3/3: POST-I-3 vs INV-I-7 read as
|
||||
contradictory in isolation). `agentName`, `role`, `systemPrompt`, `ocean` are
|
||||
**presence-required** — a missing one is a truncated / corrupt bundle and raises
|
||||
`BundleImportError`, because defaulting them would either be caught inconsistently
|
||||
(name/role/prompt default to values `validate_exportable` rejects) or silently
|
||||
masked (`ocean` defaults to a VALID neutral OCEAN — silent trait loss). Every other
|
||||
key is optional and defaults to the `DesignObject` default when absent. `tools`/`portrait`/`goalsFears`, when present, must be well-formed SHAPES —
|
||||
`tools` a list-of-objects, `ocean`/`portrait` an object, `goalsFears` null or an
|
||||
object whose present `goals`/`fears` are lists — structural mismatches raise a clean
|
||||
`BundleImportError`, never a leaked builtin `TypeError`/`ValueError` (INV-I-6
|
||||
robustness). These SHAPE gates all exist to prevent SILENT DATA LOSS (heid-bug-hunt
|
||||
Gróa#1/#2: a malformed portrait/goalsFears would otherwise coerce to a default in
|
||||
`deserialize_design` and slip PAST `validate_exportable`, since both are
|
||||
non-export-critical — the same loss the `tools` gate was added to close). Import does
|
||||
NOT validate their VALUE contents — portrait `status`/`styleMode` enums or goals/fears
|
||||
item contents are not export-critical (E4 / the UI own portrait validity); those
|
||||
round-trip as-is (heid-review Gróa#6).
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-I-1** [hard]: The strict export-critical re-validation IS
|
||||
`soong_lab.export.validate_exportable`, imported and reused verbatim — NO
|
||||
re-implementation, no parallel import-side validator. Import therefore can NEVER
|
||||
be looser than export: OCEAN (`validate_ocean`), role (`validate_role`, ∈
|
||||
ROLE_CHOICES), `agent_name` (non-blank, ≤`AGENT_NAME_MAX`), `system_prompt`
|
||||
(non-blank, ≤`SYSTEM_PROMPT_MAX`), every tool-ref (id/name non-blank + bounded,
|
||||
description bounded), and the psych/first_message LENGTH are all gated by the
|
||||
same code export uses. An `ExportError` from that gate is caught and re-raised
|
||||
as `BundleImportError(same field, same detail)` — same field granularity,
|
||||
import-shaped type.
|
||||
- **INV-I-2** [hard]: LENIENT on unknown metadata (settled decision #6, mirrors
|
||||
INV-E5-6). Unknown top-level bundle keys, unknown keys inside `resume`, and any
|
||||
`schema_version` value (present or absent) are tolerated — import reads only the
|
||||
keys it knows and ignores the rest. A future-schema bundle that ADDED fields
|
||||
still imports.
|
||||
- **INV-I-3** [hard]: ROUND-TRIP — for any `DesignObject` `d` that passes
|
||||
`validate_exportable`, `deserialize_design(serialize_design(d))` reconstructs an
|
||||
EQUAL `DesignObject` (dataclass `==` over every field), and
|
||||
`import_bundle(build_export_bundle(d, design_id=…))` `== d`. This is the lossless
|
||||
export↔import contract. (Equality is over Python structures; the JSON encode/decode
|
||||
boundary is the endpoint/library epic's concern, not this module's.)
|
||||
- **INV-I-4** [hard]: NO-ALIAS — the reconstructed `DesignObject` holds COPIES of
|
||||
every mutable sub-structure (the ocean dict, the tools list, the goals/fears
|
||||
lists) drawn from the bundle, never aliases. A later mutation of the input bundle
|
||||
cannot change an already-imported design (the mirror of export's INV-E5-4). The
|
||||
copies are SHALLOW (the CONTAINERS) — sufficient because legit export values are
|
||||
scalars (strings/floats), and a hostile NESTED mutable (a list-valued tool id, a
|
||||
dict-valued goal) is rejected by `validate_exportable` before any successful import
|
||||
(heid-bug-hunt Gróa#5/Hulda#1: the invariant's letter holds; deep-copy is deferred
|
||||
unless nested mutables ever become in-contract).
|
||||
- **INV-I-5** [hard]: Import reads the `resume` half and NOWHERE else — `ship`
|
||||
(native + sidecar) is ignored (it is re-derivable; the reopen path re-exports).
|
||||
No ship↔resume consistency check in v1; on any disagreement, resume is
|
||||
authoritative.
|
||||
- **INV-I-6** [hard]: `deserialize_design` is TOTAL — it never raises on any input
|
||||
Mapping. Hostile shapes (a string `ocean`, an int `tools`, a list `portrait`, a
|
||||
string `goalsFears`, or a dict `goalsFears` whose `goals`/`fears` is a non-list)
|
||||
are coerced/defaulted, not crashed — in particular EVERY `list(...)`/`dict(...)`
|
||||
coercion is type-GUARDED first: a non-list `goals` becomes `[]` (never
|
||||
`list(7)`→TypeError nor `list("ab")`→`["a","b"]`), a non-dict `ocean` is held
|
||||
verbatim (never `dict("nope")`→ValueError). ALL rejection happens in
|
||||
`import_bundle` (its structural gate + `validate_exportable`). Non-export-critical
|
||||
fields that are missing or mistyped default to the `DesignObject` default;
|
||||
export-critical VALUES are held AS-READ (no silent type-coercion) so
|
||||
`validate_exportable` judges them — with ONE structural exception: `import_bundle`
|
||||
pre-checks that `ocean` is a dict (so `deserialize_design`'s `dict()` copy is
|
||||
safe), so `ocean` has a structural judge (`import_bundle`) AND a value judge
|
||||
(`validate_ocean`), while `agent_name`/`role`/`system_prompt` are judged by value
|
||||
alone — "single judge" is exact for those three, not for `ocean` (heid-review
|
||||
Gróa#2/#4, Hulda, Regin#3). (Mirrors `recompute`'s hostile-input tolerance in derive.py.)
|
||||
- **INV-I-7** [hard]: PRESENCE — `import_bundle` requires the export-critical
|
||||
resume keys `agentName`, `role`, `systemPrompt`, `ocean` to be PRESENT; a missing
|
||||
one raises `BundleImportError(f"resume.{key}", …)` (a truncated bundle fails
|
||||
loudly, not by silently defaulting — especially `ocean`, whose neutral default
|
||||
would mask trait loss). `tools` absent → `[]` (an empty toolset is a valid
|
||||
design). This is the explicit-over-implicit choice: reject a missing critical key
|
||||
rather than accept a silently-defaulted one.
|
||||
- **INV-I-8** [hard]: Import does NOT re-derive `composed_preview` /
|
||||
`disposition_phrase` — it TRUSTS the resume values verbatim (they ride the resume
|
||||
for instant reopen-render, per export.contract). `recompute` is the reopen
|
||||
lifecycle's concern (per-design-sessions), not import's. For a legit bundle these
|
||||
are already self-consistent; a tampered preview is design-time-only and is
|
||||
overwritten on the next `set_ocean`/`edit_prompt`. Import makes NO consistency
|
||||
guarantee between the trusted preview and `ocean`+`system_prompt`: for a
|
||||
hand-edited resume the two may diverge until the first recompute self-heals them —
|
||||
round-trip equality (INV-I-3) is "== the DesignObject the bundle encodes," NOT
|
||||
"the preview matches a fresh recompute" (heid-review Gróa#8).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[correctness]** The export-critical re-validation reuses
|
||||
`soong_lab.export.validate_exportable` (INV-I-1) — import declares no length
|
||||
numbers, no role list, no OCEAN shape of its own. The shared field-bound
|
||||
constants + `ROLE_CHOICES` live in `soong_lab.design`; the strict gate lives in
|
||||
`soong_lab.export`; import imports both. Zero duplicated validation logic → zero
|
||||
drift.
|
||||
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Import is
|
||||
a total function of its input Mapping.
|
||||
- **[explicit]** `BundleImportError` does NOT shadow the builtin `ImportError`
|
||||
(open_question B). The module is `soong_lab.importer`, NOT `soong_lab.import` —
|
||||
`import` is a Python keyword and unusable as a module path (open_question A).
|
||||
- **[robustness]** `deserialize_design` guards types BEFORE any `dict()` /
|
||||
iteration: a non-dict `ocean` is held as-read (never `dict("nope")`, which raises
|
||||
a raw `ValueError`); a non-list `tools` yields `[]`; a non-dict `portrait` /
|
||||
`goalsFears` falls back to the default (`import_bundle`'s structural gates reject a
|
||||
present-but-malformed portrait/goalsFears BEFORE this, so the default-fallback is
|
||||
reachable only for a MISSING field). This keeps every rejection path flowing
|
||||
through `BundleImportError` — a caller never sees a leaked builtin exception.
|
||||
- **[robustness]** The "no builtin ever leaks from the public entrypoint" guarantee
|
||||
for hostile export-critical SCALAR types (a non-str `agent_name`/`role`/
|
||||
`system_prompt`/`psych_profile`/`first_message`, or a `None`) is provided JOINTLY by
|
||||
(a) holding them as-read + (b) `validate_exportable` being TOTAL over hostile scalar
|
||||
types — every check `isinstance`-guards BEFORE any `.strip()`/`len()`, and the `or`
|
||||
short-circuits, so a hostile scalar yields a clean `ExportError` (→ `BundleImportError`),
|
||||
never a raw `TypeError`/`AttributeError`. This is an EXPLICIT cross-module coupling
|
||||
(`soong_lab.export` guarantees the totality): import does NOT blanket-catch
|
||||
non-`ExportError` (that would mask real programming errors); the coupling is instead
|
||||
PINNED by a hostile-scalar test through `import_bundle` (heid-bug-hunt 3/3 —
|
||||
Gróa#3/Hulda#2/Regin#1). If `validate_exportable` ever did an unguarded string op, that
|
||||
test fails.
|
||||
- **[explicit]** `import_bundle` is the PUBLIC entrypoint that runs the full gate
|
||||
(structure → presence → reconstruct → `validate_exportable`). `deserialize_design`
|
||||
is exposed for the round-trip test + direct reuse but PERFORMS NO validation
|
||||
(PRE-I-1) — a direct caller that skips `import_bundle` owns re-validation (the
|
||||
mirror of export's build_native_payload/build_sidecar assuming a validated design).
|
||||
- **[explicit]** Two-LAYER error-field convention (heid-review Regin#6): a
|
||||
STRUCTURAL / PRESENCE rejection raised BY `import_bundle` names the offending
|
||||
BUNDLE key in camelCase with a `resume.` prefix (`resume.agentName` missing,
|
||||
`resume.ocean` not-an-object) — it reports the bundle's JSON shape. A VALUE
|
||||
rejection from the reused `validate_exportable` names the `DesignObject` field in
|
||||
snake_case with no prefix (`agent_name` blank, `persona.ocean` out of range) — it
|
||||
reports the design's validity. Same logical field, two deliberate `.field` forms
|
||||
encoding WHICH LAYER failed (bundle-structure vs design-value); a caller switching
|
||||
on `err.field` MUST handle both, and `err.detail` disambiguates. Tests assert on
|
||||
`.field` (pinned); `.detail` wording is human-facing and NOT pinned (heid-review
|
||||
Hulda) — intentional, not drift.
|
||||
|
||||
```contract
|
||||
FN deserialize_design(resume: Mapping[str, Any]) -> DesignObject
|
||||
BRIEF: The pure, TOTAL inverse of serialize_design — reconstruct a DesignObject from the §6 camelCase resume half. Reads each known key with a type-guard; missing/mistyped NON-export-critical fields default to the DesignObject default; export-critical fields are held AS-READ (no coercion) for validate_exportable to judge later; unknown keys are ignored (INV-I-2). Copies every mutable sub-structure (INV-I-4). NEVER raises (INV-I-6) — it performs NO validation (that is import_bundle's job). deserialize_design(serialize_design(d)) == d for any exportable d (INV-I-3).
|
||||
PRE: [PRE-I-1 hard] resume is a Mapping (import_bundle guarantees a dict before calling; a direct caller passes any Mapping — a non-Mapping is a caller error, but the function still must not crash on a Mapping of hostile VALUES)
|
||||
POST: [POST-I-1 return_value] returns a DesignObject whose fields map 1:1 from the camelCase keys: agentName→agent_name, role→role, systemPrompt→system_prompt, composedPreview→composed_preview, firstMessage→first_message, ocean→ocean (COPY), dispositionPhrase→disposition_phrase, psychProfile→psych_profile, tools→[ToolRef,…] (COPY of the list, each ref rebuilt), portrait→Portrait(...), goalsFears→GoalsFears(...) | None
|
||||
POST: [POST-I-2 return_value] ocean, tools, goals, and fears are COPIES of the resume values — mutating resume after the call never changes the returned design (INV-I-4)
|
||||
POST: [POST-I-3 return_value] export-critical fields (agentName, role, systemPrompt, ocean) are held AS-READ (missing → the DesignObject default; present-but-mistyped → the value verbatim, so validate_exportable is the single judge); design-time-derived composedPreview/dispositionPhrase coerce a non-str to "" (re-derivable, keep the object clean); firstMessage/psychProfile are held as-read (validate_exportable length-gates them). ocean is copied IFF it is a dict, else held verbatim (NEVER dict("nope"))
|
||||
POST: [POST-I-4 state_change] performs NO validation and NEVER raises on a Mapping input (INV-I-6)
|
||||
STEPS:
|
||||
1. [setup] agent_name = resume.get("agentName", ""); role = resume.get("role", UNSET_ROLE); system_prompt = resume.get("systemPrompt", "") # export-critical — held as-read, no coercion
|
||||
2. [sequential] composed_preview = resume["composedPreview"] if it is a str else ""; disposition_phrase = resume["dispositionPhrase"] if it is a str else "" # design-time-derived, re-derivable → coerce clean
|
||||
3. [sequential] first_message = resume.get("firstMessage", ""); psych_profile = resume.get("psychProfile", "") # optional prose — held as-read, length-gated by validate_exportable
|
||||
4. [branch] raw_ocean = resume.get("ocean"); ocean = dict(raw_ocean) if isinstance(raw_ocean, dict) else (raw_ocean if raw_ocean is not None else _neutral_ocean()) # COPY iff dict; else held verbatim for validate_ocean to reject (guard BEFORE dict(), INV-I-6 robustness)
|
||||
5. [loop] raw_tools = resume.get("tools"); tools = [(ToolRef(id=t.get("id",""), name=t.get("name",""), description=t.get("description","")) if isinstance(t, dict) else ToolRef(id="", name="", description="")) for t in raw_tools] IF isinstance(raw_tools, list) else [] # non-list → []; a non-dict item maps to a BLANK ToolRef (NOT skipped) so a direct caller who re-validates fails loud on the blank id rather than silently losing a tool (heid-review Gróa#7); import_bundle structurally rejects both cases upstream
|
||||
6. [branch] raw_portrait = resume.get("portrait"); portrait = Portrait(status=raw_portrait.get("status","none"), style_mode=raw_portrait.get("styleMode","cartoon"), image_url=raw_portrait.get("imageUrl"), job_id=raw_portrait.get("jobId")) IF isinstance(raw_portrait, dict) else Portrait() # use raw_portrait (heid-review Regin#2 — the `rp` working-name was unbound); imageUrl/jobId absent → None (round-trips serialize's None-omission)
|
||||
7. [branch] raw_gf = resume.get("goalsFears"); IF isinstance(raw_gf, dict): g = raw_gf.get("goals"); f = raw_gf.get("fears"); goals_fears = GoalsFears(goals=(list(g) if isinstance(g, list) else []), fears=(list(f) if isinstance(f, list) else [])) ELSE: goals_fears = None # use raw_gf (heid-review Regin#2 — `gf` was unbound); a non-LIST goals/fears → [], NEVER list(7)→TypeError (totality, INV-I-6) and NEVER list("ab")→["a","b"] (silent char-split, heid-review Gróa#2/Hulda); null/absent → None; COPY the lists (INV-I-4)
|
||||
8. [cleanup] RETURN DesignObject(agent_name, role, system_prompt, composed_preview, ocean, disposition_phrase, tools, portrait, first_message, psych_profile, goals_fears)
|
||||
TESTS:
|
||||
roundtrip_full [property,tracer]: a fully-populated exportable design d (name, role, prompt, non-neutral ocean, 2 tools, ready portrait w/ url+job, first_message, psych, goalsFears) → deserialize_design(serialize_design(d)) == d
|
||||
roundtrip_minimal [property]: minimal design (name+prompt+role, neutral ocean, no tools/portrait-url/gf) → round-trips == d
|
||||
copies_not_aliases [property]: deserialize, then mutate resume["ocean"]["O"] and append to resume["tools"] → the returned design's ocean + tools are unchanged (INV-I-4)
|
||||
total_on_hostile [property]: deserialize_design({"ocean":"nope","tools":7,"portrait":[],"goalsFears":"x","agentName":123}) does NOT raise; returns a DesignObject (ocean=="nope" held verbatim, tools==[], portrait==Portrait(), goals_fears is None, agent_name==123) — INV-I-6
|
||||
total_on_hostile_goalsfears [property]: deserialize_design({"goalsFears":{"goals":7,"fears":"abc"}}) does NOT raise (the totality-breaking case heid-review Gróa#2/Hulda caught) → goals_fears==GoalsFears([],[]) (non-list goals→[] not list(7)→TypeError; non-list fears→[] not list("abc")→["a","b","c"]) — INV-I-6
|
||||
tools_nondict_item_blank [boundary]: deserialize_design({"tools":[{"id":"a","name":"n"},7]}) → tools==[ToolRef("a","n",""), ToolRef("","","")] — the non-dict item maps to a BLANK ToolRef, NOT skipped (heid-review Gróa#7), so a direct caller re-validating fails loud on the blank id
|
||||
empty_resume [boundary]: deserialize_design({}) → DesignObject() all-defaults (role==UNSET_ROLE, neutral ocean, no tools) — total, no raise
|
||||
portrait_none_fields [boundary]: resume.portrait without imageUrl/jobId → Portrait.image_url is None, Portrait.job_id is None
|
||||
goalsfears_null [boundary]: resume.goalsFears is None → design.goals_fears is None; goalsFears={} → GoalsFears([],[])
|
||||
roundtrip_goalsfears_empty [property]: a design with goals_fears==GoalsFears([],[]) → deserialize_design(serialize_design(d)).goals_fears == GoalsFears([],[]) (empty, NOT None) — locks the null-vs-{} distinction (heid-review Regin#4)
|
||||
preview_trusted [trace]: resume.composedPreview="CUSTOM", dispositionPhrase="odd" → design.composed_preview=="CUSTOM", disposition_phrase=="odd" (NOT re-derived, INV-I-8)
|
||||
unknown_keys_ignored [trace]: resume with an extra "futureField":123 → deserialize ignores it, no crash (INV-I-2)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN import_bundle(bundle: Mapping[str, Any]) -> DesignObject
|
||||
BRIEF: The public entrypoint — the mirror of build_export_bundle. Runs the full gate: STRUCTURE (bundle/resume are dicts, ocean is a dict, tools is a list-of-dicts) → PRESENCE (the export-critical resume keys, INV-I-7) → reconstruct (deserialize_design) → STRICT re-validate (validate_exportable, reused verbatim, INV-I-1). LENIENT on unknown metadata + any schema_version (INV-I-2). Reads ONLY resume; ignores ship (INV-I-5). Returns a DesignObject that PASSES validate_exportable — ready to reopen. Every rejection is a BundleImportError(field, detail); no builtin exception ever leaks.
|
||||
PRE: [PRE-I-2 hard] bundle is a Mapping (a non-Mapping raises BundleImportError("bundle", …), never a bare TypeError)
|
||||
POST: [POST-I-5 exception] raises BundleImportError(field, detail) — with NO DesignObject returned — if ANY: bundle is not a Mapping ("bundle"); bundle["resume"] is missing or not a Mapping ("resume"); any of agentName/role/systemPrompt/ocean is absent from resume ("resume.<key>", INV-I-7); resume["ocean"] is present-but-not-a-dict ("resume.ocean"); resume["tools"] is present-but-not-a-list or contains a non-dict item ("resume.tools"); resume["portrait"] is present-but-not-a-dict ("resume.portrait"); resume["goalsFears"] is present-but-not (null OR a dict whose present goals/fears are lists) ("resume.goalsFears"); OR the reconstructed design fails validate_exportable (the ExportError's field+detail, re-raised as BundleImportError — INV-I-1)
|
||||
POST: [POST-I-6 return_value] on success returns a DesignObject that PASSES validate_exportable (name/role/prompt/ocean/tools/psych/first_message all valid), holds COPIES of every mutable sub-structure (INV-I-4), with composed_preview/disposition_phrase trusted from resume (INV-I-8); ship is never read (INV-I-5)
|
||||
POST: [POST-I-7 return_value] LENIENT — unknown top-level bundle keys, unknown resume keys, and any schema_version (present, absent, or unrecognized) do not affect the result (INV-I-2)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] IF bundle is not a Mapping: RAISE BundleImportError("bundle", "bundle must be an object")
|
||||
2. [sequential] resume = bundle.get("resume"); IF resume is not a Mapping: RAISE BundleImportError("resume", "the bundle has no readable 'resume' half") # ship + schema_version read leniently — schema_version is NOT gated (INV-I-2, open_question E)
|
||||
3. [loop] FOR key IN ("agentName", "role", "systemPrompt", "ocean"): IF key not in resume: RAISE BundleImportError(f"resume.{key}", "required export-critical field is missing") # presence, INV-I-7
|
||||
4. [branch] IF resume["ocean"] is not a dict: RAISE BundleImportError("resume.ocean", "ocean must be an object") # structural — keeps deserialize's dict() safe + gives a clean field error
|
||||
5. [branch] IF "tools" in resume AND (resume["tools"] is not a list OR any item is not a dict): RAISE BundleImportError("resume.tools", "tools must be a list of objects") # structural — prevents silent tool loss
|
||||
5b. [branch] IF "portrait" in resume AND resume["portrait"] is not a dict: RAISE BundleImportError("resume.portrait", "portrait must be an object") # SAME no-silent-loss gate as tools (heid-bug-hunt Gróa#2) — else a non-dict portrait silently coerces to Portrait() (wiping status/imageUrl/jobId) and slips past validate_exportable (portrait is non-export-critical)
|
||||
5c. [branch] IF "goalsFears" in resume AND resume["goalsFears"] is not None: IF it is not a dict RAISE BundleImportError("resume.goalsFears", "must be an object or null"); ELSE FOR k IN (goals, fears): IF k in gf AND gf[k] is not a list: RAISE BundleImportError("resume.goalsFears", f"{k} must be a list") # no-silent-loss gate (heid-bug-hunt Gróa#1) — else a non-list goals/fears silently coerces to [] (dropping the operator's data) and slips past validate_exportable (goals_fears is non-export-critical)
|
||||
6. [sequential] design = deserialize_design(resume) # total; the structural gates above guarantee a plausible shape
|
||||
7. [sequential, flexibility=prescriptive] TRY validate_exportable(design) EXCEPT ExportError AS exc: RAISE BundleImportError(exc.field, exc.detail) FROM exc # the STRICT export-critical gate, REUSED (INV-I-1) — same field granularity, import-shaped type
|
||||
8. [cleanup] RETURN design
|
||||
TESTS:
|
||||
roundtrip_full [property,tracer]: import_bundle(build_export_bundle(d, design_id="d-1")) == d for a fully-populated exportable d (INV-I-3)
|
||||
roundtrip_minimal [property]: import_bundle(build_export_bundle(d_minimal, design_id="d-1")) == d_minimal (a minimal exportable design through the FULL gate — symmetry with deserialize_design, heid-code-review Regin#4)
|
||||
roundtrip_after_export [property]: build a bundle, import it, re-export the result → the two bundles' resume halves are equal (idempotent reopen)
|
||||
lenient_unknown_metadata [happy]: a valid bundle + extra top-level "x":1, extra resume "futureField":2, schema_version="99.0" → imports fine; result == the same design without the extras (INV-I-2)
|
||||
missing_resume [adversarial]: bundle == {"schema_version":"1.0","ship":{…}} (no resume) → BundleImportError("resume")
|
||||
bundle_not_mapping [adversarial]: import_bundle("not a bundle") → BundleImportError("bundle") — no bare TypeError
|
||||
missing_ocean [adversarial]: resume without "ocean" → BundleImportError("resume.ocean") via presence (INV-I-7) — NOT silently neutral
|
||||
missing_role [adversarial]: resume without "role" → BundleImportError("resume.role")
|
||||
missing_name [adversarial]: resume without "agentName" → BundleImportError("resume.agentName")
|
||||
missing_systemprompt [adversarial]: resume without "systemPrompt" → BundleImportError("resume.systemPrompt") — the 4th critical key, completes the presence coverage (heid-code-review Hulda/Regin)
|
||||
non_dict_ocean [adversarial]: resume.ocean="nope" (present) → BundleImportError("resume.ocean", must be object) — clean error, never a raw ValueError from dict()
|
||||
non_list_tools [adversarial]: resume.tools={} → BundleImportError("resume.tools"); resume.tools=[7] (non-dict item) → BundleImportError("resume.tools")
|
||||
non_dict_portrait [adversarial]: resume.portrait=[] / "x" / 7 → BundleImportError("resume.portrait") — the no-silent-loss gate (heid-bug-hunt Gróa#2)
|
||||
malformed_goalsfears [adversarial]: resume.goalsFears={"goals":["survive"],"fears":"exposure"} (fears non-list) → BundleImportError("resume.goalsFears") — the headline silent-loss case; goalsFears=7 → BundleImportError; goalsFears=None and goalsFears={} → ok (round-trip shapes) (heid-bug-hunt Gróa#1)
|
||||
hostile_scalars_no_builtin_leak [adversarial]: resume.agentName=123 / systemPrompt=null / psychProfile=0 → each a clean BundleImportError (agent_name / system_prompt / psych_profile), NEVER a raw builtin — pins the validate_exportable-totality coupling (heid-bug-hunt 3/3)
|
||||
blank_name_rejected [adversarial]: resume.agentName=" " → BundleImportError("agent_name") via validate_exportable (whitespace stricter, INV-I-1)
|
||||
bad_role_rejected [adversarial]: resume.role="wizard" → BundleImportError("role") via validate_role
|
||||
unset_role_rejected [adversarial]: resume.role="" → BundleImportError("role") — an unclassified design is not importable, same as not exportable
|
||||
bad_ocean_value [adversarial]: resume.ocean.O=2.0 → BundleImportError("persona.ocean") via validate_ocean
|
||||
bad_tool_ref [adversarial]: resume.tools=[{"id":"","name":"x"}] → BundleImportError("tools[0]") via validate_exportable
|
||||
prompt_too_long [boundary]: resume.systemPrompt of len SYSTEM_PROMPT_MAX+1 → BundleImportError("system_prompt"); len SYSTEM_PROMPT_MAX → ok
|
||||
psych_too_long [boundary]: resume.psychProfile of len PSYCH_PROFILE_MAX+1 → BundleImportError("psych_profile"); blank → ok
|
||||
first_message_too_long [boundary]: resume.firstMessage of len FIRST_MESSAGE_MAX+1 → BundleImportError("first_message"); blank → ok (same length-gate as psych, via the reused validate_exportable — heid-code-review Hulda/Regin)
|
||||
ship_ignored [trace]: a valid bundle whose ship.native.agent_name disagrees with resume.agentName → the imported design uses resume.agentName; ship is not read (INV-I-5)
|
||||
no_alias [property]: import, then mutate the source bundle's resume["ocean"] + resume["tools"] + resume["goalsFears"]["goals"]/["fears"] → the returned design is unchanged, incl. the goals/fears lists (INV-I-4, heid-code-review Hulda)
|
||||
error_is_not_builtin [trace]: BundleImportError is not the builtin ImportError (isinstance check) — the module never shadows it (open_question B)
|
||||
error_field_layer_convention [trace]: a MISSING agentName → BundleImportError field "resume.agentName" (structural/camelCase); a BLANK agentName → BundleImportError field "agent_name" (value/snake_case via validate_exportable) — the intentional two-layer convention (heid-review Regin#6)
|
||||
```
|
||||
|
||||
## Integration points
|
||||
|
||||
**Reuse of `soong_lab.export` (the no-drift anchor).** Import imports
|
||||
`validate_exportable` + `ExportError` from `soong_lab.export`. This is the single
|
||||
most important structural decision in the contract: the strict export-critical
|
||||
gate is authored ONCE (in export) and reused on import, so the two directions can
|
||||
never diverge. Import adds no length numbers, no role membership list, no OCEAN
|
||||
shape — those all live upstream (`soong_lab.design` constants + `soong_lab.export`
|
||||
gate). The dependency direction is clean: `importer → export → design`, all three
|
||||
pure.
|
||||
|
||||
**`serialize_design` is the round-trip partner (no code change).** The forward
|
||||
half already lives in `soong_lab.design` (relocated there in the export pass, R1).
|
||||
This contract adds no change to it; `deserialize_design` is written to be its exact
|
||||
inverse, and the round-trip tests pin the pair together. If a future field is
|
||||
added to the DesignObject, BOTH `serialize_design` and `deserialize_design` must
|
||||
gain it in the same commit (the round-trip test enforces this — a field added to
|
||||
serialize but not deserialize breaks `roundtrip_full`). The round-trip also locks
|
||||
the `goalsFears` null-vs-`{}` distinction (`None`→`null`, empty→`{"goals":[],"fears":[]}`);
|
||||
the tests exercise BOTH so a future `serialize_design` change that collapsed the two
|
||||
cases is caught, not silently round-trip-broken (heid-review Regin#4).
|
||||
|
||||
**Export contract `used_by` reference (one-line canon fix, same commit as code).**
|
||||
`export.contract.md`'s `used_by:` block names `soong_lab.import` — an unusable
|
||||
Python-keyword module path. On acceptance of open_question A, that line updates to
|
||||
`soong_lab.importer` (or the chosen name). No-backwards-compat: the stale reference
|
||||
is corrected, not left as a second name for the same module.
|
||||
|
||||
**`POST /api/import` endpoint + web upload — NOT in this contract (open_question
|
||||
D).** The browser 'Import Asset' / reopen flow uploads a bundle JSON; the endpoint
|
||||
`json.loads` the body → `import_bundle(bundle)` → seed a session with the
|
||||
reconstructed design (and, per per-design-sessions, open a fresh WT session +
|
||||
build the design-state summary). A `BundleImportError` becomes a 4xx with the
|
||||
`field`/`detail` surfaced to the operator ("fail early on import"). That amends
|
||||
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified here
|
||||
only so the seam is visible. This module does no HTTP.
|
||||
|
||||
**Reopen Bifrost tool / session-open — NOT in this contract (per-design-sessions,
|
||||
decision #2).** Reopening a design mid-conversation (vs. at session boot) may want
|
||||
a Bifrost tool that swaps the session's stored DesignObject for an imported one. If
|
||||
so, its handler calls `import_bundle` and replaces the store entry — the impure
|
||||
boundary, keeping `soong_lab.importer` pure. Out of scope here.
|
||||
|
||||
## Downstream epics (NOT this contract)
|
||||
|
||||
- **Library read** (decision #5) — reading a stored bundle off the server-local
|
||||
single-user JSON dir on corviduo-dev, keyed by `design_id`, then handing it to
|
||||
`import_bundle`. The minimal recent-designs picker lists what is importable.
|
||||
- **Per-design-sessions** (decision #2) — the reopen lifecycle: `import_bundle` →
|
||||
fresh WT session → the compact design-state SUMMARY seeded as context (also caps
|
||||
the #355 accumulation). `import_bundle` is the reconstruction primitive it calls.
|
||||
- **`POST /api/import` + the browser upload/reopen UI** (open_question D) — the web
|
||||
surface that turns an uploaded/selected bundle into a live, reopened session.
|
||||
@@ -42,6 +42,8 @@ display.**
|
||||
| `schema_version` | `"relation_edge/1"` | versions the `relations` payload only |
|
||||
| `emitted_at` | ISO8601 | |
|
||||
|
||||
> **✓ R32-1B (landed, v1.0.0b29):** The PAD range `[-1.0, 1.0]` relaxes to an **unbounded latent `z`** with a finite wire sanity bound (`~±10`) as of R32 Slice-1B. The JSON shape/fields/types are UNCHANGED — only the declared range/semantics change (the value becomes a latent that renders to a bounded display value). Consumers that merely store-and-return PAD need no change; consumers that validate/clamp PAD to `[-1,1]` must relax that bound. Source of truth: `docs/contracts/persona_envelope.contract.md` rev 1.7 (INV-ENV-16).
|
||||
|
||||
**Not on `affect.emit`:** the full active-emotions list, `baseline_pad`,
|
||||
`mood_drift`, `last_updated_at`, and every rendered string.
|
||||
|
||||
|
||||
+65
-3
File diff suppressed because one or more lines are too long
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.20.5"
|
||||
version = "0.20.14"
|
||||
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.1", # consumer engines + library. 1.1.1 = frozen-wire serialization fix (ADR-0008): additive capability fields are gated on the NEGOTIATED wire, so a v0.6-negotiated describe_store handshake stays v0.6-clean. 1.1.0 leaked the v0.7-additive `sortable_chunk_fields` into v0.6 StoreCapabilities → a strict v0.6 client (additionalProperties:false) rejects our server's handshake. Wire schemas + pins UNCHANGED (serialization-correctness only); our v0.7 handshake with Worldtree b47 is unaffected. (1.1.0 = wire v0.7 additive: memory.scan sort + sortable_chunk_fields; 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)
|
||||
]
|
||||
|
||||
@@ -13,7 +13,8 @@ Usage:
|
||||
python scripts/contract_drift_check.py --contract docs/contracts/issues/138.contract.md
|
||||
python scripts/contract_drift_check.py --json
|
||||
|
||||
Requires GITEA_TOKEN in environment (and GITEA_URL/OWNER/REPO if not in env.sh).
|
||||
Requires GITEA_TOKEN in environment. Owner/repo are derived from the `origin` git remote by
|
||||
default (override with GITEA_OWNER / GITEA_REPO; GITEA_URL defaults to the Gitea host).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +22,8 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -32,6 +35,22 @@ PROJECT_ROOT = Path(__file__).parent.parent
|
||||
CONTRACTS_GLOB = "docs/contracts/**/*.contract.md"
|
||||
|
||||
|
||||
def _owner_repo_from_git_remote() -> tuple[str, str] | None:
|
||||
"""Derive (owner, repo) from the `origin` git remote so the drift check targets THIS repo
|
||||
by default — instead of a hardcoded repo name that silently checks the WRONG repo for every
|
||||
other consumer. Supports ssh (git@host:owner/repo.git) and https (https://host/owner/repo.git)
|
||||
Gitea remotes; returns None if it can't resolve."""
|
||||
try:
|
||||
url = subprocess.run(
|
||||
["git", "-C", str(PROJECT_ROOT), "remote", "get-url", "origin"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", url)
|
||||
return (m.group(1), m.group(2)) if m else None
|
||||
|
||||
|
||||
def sha16(s: str) -> str:
|
||||
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
@@ -70,11 +89,19 @@ def main() -> int:
|
||||
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
base_url = os.environ.get("GITEA_URL", "https://gitea.phasefinal.com")
|
||||
owner = os.environ.get("GITEA_OWNER", "vh")
|
||||
repo = os.environ.get("GITEA_REPO", "Worldtree")
|
||||
# Owner/repo default to the `origin` remote so the check targets THIS repo; GITEA_OWNER /
|
||||
# GITEA_REPO override when set. (Previously repo defaulted to a hardcoded "Worldtree", which
|
||||
# silently checked the WRONG repo for every other consumer unless GITEA_REPO was set in env —
|
||||
# a false-drift footgun. Derive it, and fail loud rather than guess.)
|
||||
git_remote = _owner_repo_from_git_remote()
|
||||
owner = os.environ.get("GITEA_OWNER") or (git_remote[0] if git_remote else None)
|
||||
repo = os.environ.get("GITEA_REPO") or (git_remote[1] if git_remote else None)
|
||||
if not token:
|
||||
print("error: GITEA_TOKEN not set", file=sys.stderr)
|
||||
return 2
|
||||
if not owner or not repo:
|
||||
print("error: could not resolve owner/repo — set GITEA_OWNER/GITEA_REPO or run inside a repo with an 'origin' remote", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.contract:
|
||||
files = [Path(args.contract).resolve()]
|
||||
|
||||
@@ -28,14 +28,7 @@ _SEED_TIMEOUT_S = 10.0
|
||||
# Editing this dict is how an operator tunes an agent's first turn. Keep entries
|
||||
# under the server's authored_content_max_bytes (8192 bytes) budget.
|
||||
FIRST_MESSAGE_PRESETS: dict[str, str] = {
|
||||
"ratatoskr:sindra": (
|
||||
"Hey there. I'm Sindra—glad you found me. So, three things before we start:\n\n"
|
||||
"How intense should I be? 1 is slow and teasing, 10 is relentless.\n\n"
|
||||
"What mood am I in today? Sweetheart, Vixen, Queen, Siren, or Brat?\n\n"
|
||||
"And how willing am I to begin? Enthusiastic (I want you now), Hesitant "
|
||||
"(you'll need to coax me out), Resistant (playful pushback), or Unwilling "
|
||||
"(I don't want this at all, until you prove otherwise)."
|
||||
),
|
||||
"ratatoskr:sindra": "The room materializes in a soft pulse of light. Walls of brushed metal and warm ambient lighting resolve around him as the holodesk completes its cycle. A thin seam of blue runs along the desk’s edge, reflecting the overhead glow. A chair slides into place with a quiet hiss. On the far side of the desk, Sindra is already there: shoulders relaxed, one knee drawn up onto the seat, the hem of an oversized dark-green sweater slipping off her left shoulder. Her gaze is steady, amused, and entirely fixed on him.\n\nShe doesn’t stand. She lets the space breathe for a moment, as if giving him time to understand that he is no longer where he was. Then she speaks.\n\n\"Hi. I’m Sindra. You just made it here, which means we should set this up properly.\"\n\nShe tilts her head slightly, the faintest crooked smile touching her lips.\n\n\"Three settings. Your call. I’ll take them in order.\"\n\n\"Intensity. One is slow and teasing; ten is relentless and unrelenting. Where should I start?\"\n\n\"Mood. Sweetheart, Vixen, Queen, Siren, or Brat. Which version of me do you want in the room with you?\"\n\n\"And willingness. I can be enthusiastic and ready the moment you look at me; I can be hesitant and require coaxing; I can resist and make you earn it; or I can be unwilling, and you’ll have to change my mind. How should I behave when you approach me?\"\n\nSindra lets her fingers trace the edge of the sweater sleeve, casual and unhurried, her eyes not leaving his face.\n\n\"Pick your three. Then we begin.\"",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,48 @@ def _validate_injection(record: dict) -> None:
|
||||
raise InvalidArguments("injection_source only valid for injected_context origin")
|
||||
|
||||
|
||||
# bifrost handshake_response SortableChunkField requires BOTH name + type
|
||||
# (additionalProperties:false); omitting `type` fails wire-schema validation and
|
||||
# breaks the whole handshake. `type` is advisory-only (the wire never interprets it).
|
||||
_SORTABLE_CHUNK_FIELDS: list[dict] = [{"name": "updated_at", "type": "timestamp"}]
|
||||
_SORTABLE_FIELD_NAMES = frozenset(f["name"] for f in _SORTABLE_CHUNK_FIELDS)
|
||||
|
||||
|
||||
def _is_live(record: dict) -> bool:
|
||||
"""INV-009: a chunk is live unless a lifecycle/governance marker says otherwise.
|
||||
scan returns live-only server-side (person-prime's `lifecycle_state=live` does not
|
||||
ride the scan wire, so this is authoritative — a dead fact can never inject)."""
|
||||
state = record.get("lifecycle_state")
|
||||
if isinstance(state, str) and state and state != "live":
|
||||
return False
|
||||
verbatim = record.get("verbatim")
|
||||
gov = verbatim.get("governance_state") if isinstance(verbatim, dict) else None
|
||||
return gov not in ("superseded", "tombstoned")
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -145,6 +189,7 @@ class RatatoskrMemoryStore:
|
||||
atomic_supersede_supported=False,
|
||||
transaction_supported=False,
|
||||
filterable_metadata_fields=[],
|
||||
sortable_chunk_fields=list(_SORTABLE_CHUNK_FIELDS), # INV-006: gates scan sort + #349 person-prime
|
||||
).to_dict()
|
||||
|
||||
async def upsert_many(
|
||||
@@ -325,6 +370,118 @@ class RatatoskrMemoryStore:
|
||||
self._conn.execute("DELETE FROM memory_vec WHERE chunk_id = ?", (chunk_id,))
|
||||
return {"deleted": deleted}
|
||||
|
||||
async def scan(
|
||||
self,
|
||||
*,
|
||||
scope_all: dict | None = None,
|
||||
scope_any: list | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int,
|
||||
sort: dict | None = None,
|
||||
lifecycle_state: Any = None,
|
||||
) -> dict:
|
||||
# #349 person-prime: query-LESS, LIVE-only (INV-009), globally-ordered (INV-010) scan.
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0: # PRE-001
|
||||
raise InvalidArguments("limit must be a positive int")
|
||||
scope_all = scope_all or {}
|
||||
scope_any = scope_any or []
|
||||
_validate_scope(scope_all, scope_any) # PRE-002 (same lattice as search)
|
||||
if sort is not None and not isinstance(sort, dict): # PRE-003: malformed sort => reject, never crash
|
||||
raise InvalidArguments("sort must be an object with field and direction")
|
||||
field = (sort or {}).get("field", "updated_at")
|
||||
direction = (sort or {}).get("direction", "desc")
|
||||
if field not in _SORTABLE_FIELD_NAMES or direction not in ("asc", "desc"): # PRE-003
|
||||
raise InvalidArguments(f"sort.field {field!r} is not globally sortable")
|
||||
_log.info(
|
||||
"memory-call scan REQUEST: scope_all=%r scope_any=%r limit=%s sort=%s",
|
||||
scope_all, scope_any, limit, sort,
|
||||
)
|
||||
# INV-010: global order by the INDEXED sort field, missing-last, chunk_id tiebreak
|
||||
# (field is whitelisted above, so the interpolation is injection-safe).
|
||||
order = "DESC" if direction == "desc" else "ASC"
|
||||
rows = self._conn.execute(
|
||||
"SELECT record_json FROM memory_chunks "
|
||||
f"ORDER BY (json_extract(record_json, '$.{field}') IS NULL), "
|
||||
f"json_extract(record_json, '$.{field}') {order}, chunk_id ASC"
|
||||
).fetchall()
|
||||
skip = 0
|
||||
if cursor is not None:
|
||||
try:
|
||||
skip = int(cursor)
|
||||
except (TypeError, ValueError):
|
||||
raise InvalidArguments("invalid scan cursor")
|
||||
if skip < 0:
|
||||
raise InvalidArguments("invalid scan cursor")
|
||||
records: list[dict] = []
|
||||
matched = 0
|
||||
has_more = False
|
||||
for (record_json,) in rows:
|
||||
record = json.loads(record_json)
|
||||
if not _matches_scope(record.get("scope"), scope_all, scope_any): # INV-005
|
||||
continue
|
||||
if not _is_live(record): # INV-009
|
||||
continue
|
||||
matched += 1
|
||||
if matched <= skip: # cursor is an offset into the GLOBAL order (INV-010)
|
||||
continue
|
||||
if len(records) >= limit: # POST-001: single limit page; one more match => next page exists
|
||||
has_more = True
|
||||
break
|
||||
records.append(record)
|
||||
# Emit a cursor ONLY when a further match exists — so a page that exactly exhausts
|
||||
# the matched set returns cursor=None (no empty trailing page), matching the reference.
|
||||
next_cursor = str(skip + len(records)) if has_more else None
|
||||
_log.info("memory-call scan RESPONSE: %d record(s) next_cursor=%s", len(records), next_cursor)
|
||||
return {"records": records, "cursor": next_cursor}
|
||||
|
||||
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."""
|
||||
@@ -347,6 +504,12 @@ def open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemorySto
|
||||
"chunk_id TEXT PRIMARY KEY, record_json TEXT NOT NULL, "
|
||||
"revision INTEGER NOT NULL, scope_json TEXT, origin TEXT)"
|
||||
)
|
||||
# INV-010: expression index on the scan sort field (updated_at) so the globally-ordered
|
||||
# person-prime scan stays within its 500ms fail-open budget.
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chunks_updated_at "
|
||||
"ON memory_chunks (json_extract(record_json, '$.updated_at'))"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS memory_idempotency ("
|
||||
"idempotency_id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires_at REAL)"
|
||||
@@ -360,6 +523,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 +557,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 +566,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():
|
||||
|
||||
@@ -62,6 +62,15 @@ def test_fresh_db_advertises_v1_caps_and_schema():
|
||||
assert caps["atomic_supersede_supported"] is False
|
||||
assert caps["transaction_supported"] is False
|
||||
assert caps["filterable_metadata_fields"] == []
|
||||
# bifrost handshake_response SortableChunkField requires BOTH name + type
|
||||
# (additionalProperties:false) — omitting `type` fails wire-schema validation and
|
||||
# breaks the ENTIRE Bifrost bind (regression guard: the deploy-breaker of 2026-07-15).
|
||||
scf = caps["sortable_chunk_fields"]
|
||||
assert scf == [{"name": "updated_at", "type": "timestamp"}]
|
||||
for entry in scf:
|
||||
assert set(entry) == {"name", "type"} # required exactly, no extra keys
|
||||
assert isinstance(entry["name"], str) and entry["name"]
|
||||
assert isinstance(entry["type"], str) and entry["type"]
|
||||
# tables + vec index queryable
|
||||
store._conn.execute("SELECT * FROM memory_chunks")
|
||||
store._conn.execute("SELECT * FROM memory_idempotency")
|
||||
@@ -376,6 +385,128 @@ async def test_delete_absent_counts_zero():
|
||||
assert await store.delete_many(["nope"]) == {"deleted": 0}
|
||||
|
||||
|
||||
# --- scan (#349 person-prime: sorted, live-only, paginated) ---
|
||||
|
||||
async def test_scan_recency_returns_newest_live_chunks_desc():
|
||||
# tracer: upsert 4 live chunks with distinct updated_at; scan limit=3 desc -> 3 newest
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
recs = [
|
||||
_chunk(f"c{i}", scope={"end_user": "u1"}, updated_at=f"2026-07-15T00:0{i}:00+00:00")
|
||||
for i in range(4)
|
||||
]
|
||||
await store.upsert_many(recs, idempotency_key="k1", ctx=_ctx())
|
||||
out = await store.scan(
|
||||
scope_all={"end_user": "u1"},
|
||||
limit=3,
|
||||
sort={"field": "updated_at", "direction": "desc"},
|
||||
)
|
||||
assert [r["id"] for r in out["records"]] == ["c3", "c2", "c1"] # 3 globally-newest, newest-first
|
||||
assert "cursor" in out
|
||||
|
||||
|
||||
async def test_scan_excludes_superseded_and_tombstoned():
|
||||
# INV-009: dead chunks never returned, even if they're the newest.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
recs = [
|
||||
_chunk("live1", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00"),
|
||||
_chunk("dead1", scope={"end_user": "u1"}, updated_at="2026-07-15T00:09:00+00:00", lifecycle_state="superseded"),
|
||||
_chunk("dead2", scope={"end_user": "u1"}, updated_at="2026-07-15T00:08:00+00:00", verbatim={"text": "x", "governance_state": "tombstoned"}),
|
||||
]
|
||||
await store.upsert_many(recs, idempotency_key="k", ctx=_ctx())
|
||||
out = await store.scan(scope_all={"end_user": "u1"}, limit=10, sort={"field": "updated_at", "direction": "desc"})
|
||||
assert [r["id"] for r in out["records"]] == ["live1"]
|
||||
|
||||
|
||||
async def test_scan_scope_isolation_excludes_other_partition():
|
||||
# INV-005 applies to scan.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
recs = [
|
||||
_chunk("a", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00"),
|
||||
_chunk("b", scope={"end_user": "u2"}, updated_at="2026-07-15T00:09:00+00:00"),
|
||||
]
|
||||
await store.upsert_many(recs, idempotency_key="k", ctx=_ctx())
|
||||
out = await store.scan(scope_all={"end_user": "u1"}, limit=10, sort={"field": "updated_at", "direction": "desc"})
|
||||
assert [r["id"] for r in out["records"]] == ["a"] # u2's newer chunk never surfaces
|
||||
|
||||
|
||||
async def test_scan_unadvertised_sort_field_rejected():
|
||||
# PRE-003: a sort field not in sortable_chunk_fields -> InvalidArguments (never silent unsorted).
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort={"field": "salience", "direction": "desc"})
|
||||
|
||||
|
||||
async def test_scan_non_dict_sort_rejected():
|
||||
# PRE-003: a truthy non-dict sort (caller-controlled) -> InvalidArguments, never AttributeError.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
for bad in ("updated_at", ["updated_at"], 5):
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort=bad)
|
||||
|
||||
|
||||
async def test_scan_records_carry_person_prime_filter_fields():
|
||||
# The client _scan_filter_matches keys on agent_id + subject + worldtree_scope; a record
|
||||
# missing any is silently dropped -> the scan record must carry them verbatim.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
rec = _chunk(
|
||||
"c1", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00",
|
||||
agent_id="ratatoskr:sindra", subject={"type": "end_user", "id": "u1"}, worldtree_scope="end_user",
|
||||
)
|
||||
await store.upsert_many([rec], idempotency_key="k", ctx=_ctx())
|
||||
out = await store.scan(scope_all={"end_user": "u1"}, limit=3, sort={"field": "updated_at", "direction": "desc"})
|
||||
r = out["records"][0]
|
||||
assert r["agent_id"] == "ratatoskr:sindra"
|
||||
assert r["subject"] == {"type": "end_user", "id": "u1"}
|
||||
assert r["worldtree_scope"] == "end_user"
|
||||
assert r["updated_at"] == "2026-07-15T00:01:00+00:00"
|
||||
|
||||
|
||||
async def test_scan_global_order_across_pages_via_cursor():
|
||||
# INV-010: the cursor page continues the GLOBAL order, never a page-local re-sort.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
recs = [_chunk(f"c{i}", scope={"end_user": "u1"}, updated_at=f"2026-07-15T00:0{i}:00+00:00") for i in range(5)]
|
||||
await store.upsert_many(recs, idempotency_key="k", ctx=_ctx())
|
||||
p1 = await store.scan(scope_all={"end_user": "u1"}, limit=2, sort={"field": "updated_at", "direction": "desc"})
|
||||
assert [r["id"] for r in p1["records"]] == ["c4", "c3"] # 2 globally-newest
|
||||
assert p1["cursor"] is not None
|
||||
p2 = await store.scan(scope_all={"end_user": "u1"}, limit=2, cursor=p1["cursor"], sort={"field": "updated_at", "direction": "desc"})
|
||||
assert [r["id"] for r in p2["records"]] == ["c2", "c1"] # continues the global order
|
||||
|
||||
|
||||
async def test_scan_parity_vs_reference_inmemory_store():
|
||||
# #195: identical scan envelopes vs the bifrost reference InMemoryMemoryStore produce
|
||||
# the SAME ordered chunk_ids + verbatim record shape. All chunks LIVE — our scan is
|
||||
# live-only (INV-009) while the reference does NOT lifecycle-filter, so parity is only
|
||||
# defined over the live set (the person-prime case). Both READ updated_at from the
|
||||
# record (neither stamps it), so ordering is a pure function of the shared input.
|
||||
from bifrost.consumer.testing import InMemoryMemoryStore
|
||||
|
||||
records = [
|
||||
_chunk("z1", scope={"end_user": "u1"}, updated_at="2026-07-15T00:03:00+00:00"),
|
||||
_chunk("a2", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00"),
|
||||
_chunk("a3", scope={"end_user": "u1"}, updated_at="2026-07-15T00:01:00+00:00"),
|
||||
_chunk("m4", scope={"end_user": "u1"}), # no updated_at -> sorts LAST, both directions
|
||||
]
|
||||
scope_all = {"end_user": "u1"}
|
||||
sort = {"field": "updated_at", "direction": "desc"}
|
||||
|
||||
ours = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await ours.upsert_many(records, idempotency_key="k", ctx=_ctx())
|
||||
ref = InMemoryMemoryStore()
|
||||
await ref.upsert_many(records, idempotency_key="k", ctx=_ctx())
|
||||
|
||||
# identical scan envelope on both stores
|
||||
out_ours = await ours.scan(scope_all=scope_all, limit=10, sort=sort)
|
||||
out_ref = await ref.scan(scope_all=scope_all, limit=10, sort=sort)
|
||||
|
||||
# recency beats id (z1 first despite 'z' > 'a'); tie broken by id asc (a2 < a3);
|
||||
# missing updated_at sorts last (m4).
|
||||
expected = ["z1", "a2", "a3", "m4"]
|
||||
assert [r["id"] for r in out_ref["records"]] == expected
|
||||
assert [r["id"] for r in out_ours["records"]] == expected
|
||||
assert out_ours["records"] == out_ref["records"] # verbatim record shape parity
|
||||
|
||||
|
||||
# --- build_memory_provider_app ---
|
||||
|
||||
def test_build_app_exposes_handshake_and_memory_routes():
|
||||
@@ -510,3 +641,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.1"
|
||||
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.1/bifrost-1.1.1.tar.gz", hash = "sha256:0934c5fdf14823766346e591f5a16ab57a137cf06df794152318b5ccef0fb8e8" }
|
||||
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.1/bifrost-1.1.1-py3-none-any.whl", hash = "sha256:dab551f8ad26464168f17108cb19564da56ee8e4789264a401e7a14463ab1576" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1052,7 +1052,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatoskr"
|
||||
version = "0.20.5"
|
||||
version = "0.20.14"
|
||||
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.1", 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