moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,301 @@
# PR3.5 Recon — `frame.source` server read/write path (1-field projection)
**Goal:** add `source` to the `recentMemories` + `recentDecisions` items returned by the
workspace-state / workspace-context API so the FE can render a ⬡ provenance pill on Chat +
Workspace. This doc maps the exact write site, the exact read/SELECT/projection sites, the
type-contract change, and the column's value vocabulary.
Repo root: `D:/Projects/waggle-os`. All line numbers verified 2026-06-16.
---
## 0. TL;DR — what to change
The ⬡ provenance pill on the **Workspace Briefing** (Chat + Workspace Overview both render
`<WorkspaceBriefing>` fed by `GET /api/workspaces/:id/context`) is unlocked by **one route +
one type change**:
1. `packages/server/src/local/routes/workspaces.ts` — add `source` to the two SELECTs (lines
**393-400** and **409-417**) and to their two `.map()` projections (lines **402-406** and
**419-429**).
2. `apps/web/src/lib/types.ts` — widen `WorkspaceContext.recentMemories` / `recentDecisions`
item shapes (lines **248-249**) with an optional `source?: string`.
That is the minimum. There is a SECOND, parallel context builder in
`packages/server/src/local/routes/workspace-context.ts` (the `WorkspaceNowBlock` /
`buildWorkspaceState` path) that feeds the **system prompt**, NOT the FE pill — see §4 for why
it is out of scope, and the one ambiguity it raises.
---
## 1. WRITE path — where `frame.source` is set
### 1a. Schema DDL (the column itself)
`packages/hive-mind-core/src/mind/schema.ts:56-57`
```sql
source TEXT NOT NULL DEFAULT 'user_stated'
CHECK (source IN ('user_stated', 'tool_verified', 'agent_inferred', 'import', 'system')),
```
Column lives on `memory_frames` (DDL `CREATE TABLE ... memory_frames` at `schema.ts:47`).
Confirmed: the column name **is `source`** (not `frame_source` / `provenance`).
### 1b. Where rows are written with an explicit `source`
- **Template starter seeding** — `packages/server/src/local/routes/workspaces.ts:290-291`:
```sql
INSERT INTO memory_frames (frame_type, gop_id, t, content, importance, source)
VALUES ('I', ?, ?, ?, 'normal', 'system')
```
i.e. workspace template starter memories are written with `source = 'system'`.
- **General frame writes** go through `FrameStore` in
`packages/hive-mind-core/src/mind/frames.ts` — `createIFrame` /
`createPFrame` (`frames.ts:74,119`) default `source: FrameSource = 'user_stated'`.
- **MCP `save_memory`** maps an incoming `source` arg, defaulting to `'agent_inferred'`
(`packages/memory-mcp/src/tools/memory.ts:35`,
`packages/hive-mind-mcp-server/src/tools/memory.ts:34`).
So in practice persisted rows carry one of the five DB-CHECK values (see §3).
---
## 2. READ path — the SELECT + projection that feeds the FE (THE CHANGE SITE)
**Route:** `GET /api/workspaces/:id/context` —
`packages/server/src/local/routes/workspaces.ts:364` (handler).
This is the response consumed by `<WorkspaceBriefing>` (apps/web), which renders both the
"Key memories" list and the "Recent decisions" list on Chat + Workspace.
### 2a. `recentMemories` — SELECT at `workspaces.ts:393-400`, projection at `402-406`
**BEFORE** (SELECT, lines 393-400):
```ts
const frames = raw.prepare(
`SELECT content, importance, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
ORDER BY CASE importance
WHEN 'critical' THEN 1 WHEN 'important' THEN 2
WHEN 'normal' THEN 3 ELSE 4 END,
id DESC LIMIT 8`
).all() as Array<{ content: string; importance: string; created_at: string }>;
```
**AFTER**:
```ts
const frames = raw.prepare(
`SELECT content, importance, source, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
ORDER BY CASE importance
WHEN 'critical' THEN 1 WHEN 'important' THEN 2
WHEN 'normal' THEN 3 ELSE 4 END,
id DESC LIMIT 8`
).all() as Array<{ content: string; importance: string; source: string; created_at: string }>;
```
**BEFORE** (projection, lines 402-406):
```ts
recentMemories = frames.map(f => ({
content: f.content.slice(0, 200),
importance: f.importance,
date: f.created_at?.slice(0, 10) ?? 'unknown',
}));
```
**AFTER**:
```ts
recentMemories = frames.map(f => ({
content: f.content.slice(0, 200),
importance: f.importance,
source: f.source,
date: f.created_at?.slice(0, 10) ?? 'unknown',
}));
```
> The local `let recentMemories` is declared at `workspaces.ts:376` as
> `Array<{ content: string; importance: string; date: string }>` — add `source: string;`
> there too (or the `.map()` widening will type-error against the narrower local).
### 2b. `recentDecisions` — SELECT at `workspaces.ts:409-417`, projection at `419-429`
**BEFORE** (SELECT, lines 409-417):
```ts
const decisionFrames = raw.prepare(
`SELECT content, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
AND (content LIKE 'Decision%' OR content LIKE '%decided%'
OR content LIKE '%decision made%' OR content LIKE '%chose %'
OR content LIKE '%selected %' OR content LIKE '%agreed %'
OR importance = 'critical')
ORDER BY id DESC LIMIT 5`
).all() as Array<{ content: string; created_at: string }>;
```
**AFTER**:
```ts
const decisionFrames = raw.prepare(
`SELECT content, source, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
AND (content LIKE 'Decision%' OR content LIKE '%decided%'
OR content LIKE '%decision made%' OR content LIKE '%chose %'
OR content LIKE '%selected %' OR content LIKE '%agreed %'
OR importance = 'critical')
ORDER BY id DESC LIMIT 5`
).all() as Array<{ content: string; source: string; created_at: string }>;
```
**BEFORE** (projection, lines 419-429):
```ts
recentDecisions = decisionFrames.map(f => {
const firstLine = f.content.split('\n')[0];
const sentenceMatch = firstLine.match(/^(.+?\.\s)(?=[A-Z])/);
const text = sentenceMatch
? sentenceMatch[1].trim()
: (firstLine.length > 150 ? firstLine.slice(0, 147) + '...' : firstLine);
return {
content: text.replace(/\.\s*$/, ''),
date: f.created_at?.slice(0, 10) ?? 'unknown',
};
});
```
**AFTER** — add `source: f.source,` to the returned object:
```ts
return {
content: text.replace(/\.\s*$/, ''),
source: f.source,
date: f.created_at?.slice(0, 10) ?? 'unknown',
};
```
> Same as above: the local `let recentDecisions` declared at `workspaces.ts:377` as
> `Array<{ content: string; date: string }>` must gain `source: string;`.
### Response shape (unchanged structurally)
The handler returns an object whose `recentMemories` / `recentDecisions` fields are these two
arrays (assembled further down in the same handler, returned as part of the workspace-context
body). Adding `source` is purely additive — no existing consumer breaks.
---
## 3. TYPE-CONTRACT change
**File:** `apps/web/src/lib/types.ts` — interface `WorkspaceContext`, lines **248-249**:
**BEFORE**:
```ts
recentDecisions?: Array<{ content: string; date: string }>;
recentMemories?: Array<{ content: string; importance: string; date: string }>;
```
**AFTER**:
```ts
recentDecisions?: Array<{ content: string; source?: string; date: string }>;
recentMemories?: Array<{ content: string; importance: string; source?: string; date: string }>;
```
- Keep `source` **optional** (`source?:`) so older sidecars (pre-PR3.5) that don't emit the
field still typecheck and the FE degrades gracefully (no pill when absent).
- This is the ONLY type file that needs to change for the pill. `WorkspaceContext` is the FE
mirror; there is no separate `@waggle/shared` interface for these inline item shapes (they're
declared anonymously inline in both `workspaces.ts` and `types.ts`).
- The FE consumer is `apps/web/src/components/os/WorkspaceBriefing.tsx` — `recentMemories`
rendered at lines **186-197** (importance badge at 189-193, add the ⬡ pill alongside it),
`recentDecisions` rendered at lines **164-178**. No type change needed in the component; it
reads `m.content` / `m.importance` today and would add `m.source`.
> Note: the richer `@waggle/shared` `Memory` interface (`packages/shared/src/types.ts:517`,
> field `source: string` at `:526`, doc-comment "maps from `memory_frames.source`") already
> models provenance — but that's the Memory Center view-model, NOT the workspace-context item
> shape. Do not route the pill through `Memory`; the context items are their own inline type.
---
## 4. The PARALLEL builder (workspace-context.ts) — out of scope, but flagged
`packages/server/src/local/routes/workspace-context.ts` builds `WorkspaceNowBlock`
(`recentDecisions: string[]`, type at `:14-21`) for **system-prompt injection**, via
`buildWorkspaceState()` in `packages/server/src/local/workspace-state.ts:234`. Its decision
SELECT is `workspace-state.ts:86-94` (`SELECT id, content, created_at`) and the legacy inline
one is `workspace-context.ts:362` (`SELECT content`). Its items are typed `StateItem`
(`workspace-state.ts:30-36`) whose `source` field is a `StateSource =
'memory'|'session'|'awareness'` — that is a DIFFERENT `source` axis (where in the substrate the
item came from), **not** the `frame.source` provenance class.
**Decision:** the FE pill is fed by §2 (`workspaces.ts` `/context` route → `WorkspaceContext`
→ `WorkspaceBriefing`), so PR3.5's 1-field projection only needs §2 + §3. The
`workspace-state.ts` / `WorkspaceNowBlock` path does not surface to the pill and can be left
untouched. If a future task wants frame-provenance in the system prompt too, that's a separate,
larger change (it would collide with the existing `StateItem.source` name).
`home.ts` (`GET /api/home/briefing`) consumes `buildWorkspaceState().recentDecisions[0].content`
(`home.ts:314-315`) only — it reads `.content`, never `.source`, so it is unaffected.
---
## 5. SOURCE-column value vocabulary
**DB CHECK constraint (authoritative for persisted rows)** —
`schema.ts:57`:
| value | meaning |
|---|---|
| `user_stated` | user said it directly (FrameStore default) |
| `tool_verified` | confirmed by a tool execution |
| `agent_inferred` | agent inferred it (MCP `save_memory` default) |
| `import` | brought in via harvest/import |
| `system` | system-seeded (e.g. workspace template starter memory — `workspaces.ts:291`) |
**TS `FrameSource` union is WIDER than the DB CHECK** —
`packages/hive-mind-core/src/mind/frames.ts:25`:
```ts
export type FrameSource = 'user_stated' | 'tool_verified' | 'agent_inferred'
| 'import' | 'system' | 'personal' | 'workspace' | 'team_sync';
```
The extra three (`personal` / `workspace` / `team_sync`) are application-level labels that the
DB CHECK does **not** allow, so a constrained INSERT with one of them would fail — **persisted
rows can only ever hold the 5 CHECK values** (documented drift:
`docs/backend-map/sections/02a-data-model-memory.md:109` and `.../05b-subsystem-memory.md:318`).
**FE pill mapping guidance:** the pill should map the 5 real values to friendly labels/icons
(e.g. `user_stated`→"you", `tool_verified`→"verified", `agent_inferred`→"agent",
`import`→"imported", `system`→"system"). Treat anything else as a graceful fallback. Because
the projection returns the raw string, the FE owns the label map (do not hardcode it in the
route).
---
## 6. Honest ambiguities / risks
1. **Two SELECTs, one new column each** — straightforward, additive. The only typecheck trap is
the narrower `let recentMemories` / `let recentDecisions` declarations at `workspaces.ts:376-377`;
widen those too or `tsc` fails. (Reminder per CLAUDE.md §2: `npm run build` typechecks
`apps/web` only — run `npx tsc --noEmit --project packages/server/tsconfig.json` to catch a
server-route type error, since the sidecar runs via `tsx` transpile-only.)
2. **The "source" name is overloaded** — `frame.source` (provenance: 5 CHECK values) vs
`StateItem.source` (`memory|session|awareness`, where-it-came-from) vs `WorkspaceContext`
inline items (currently no `source`). Make sure the pill consumes the PROVENANCE one from §2,
not the `StateItem` one.
3. **Decision frames may carry any of the 5 sources** — the decision SELECT (`workspaces.ts:409`)
filters by `content LIKE` / `importance='critical'`, not by `source`, so a "decision" can be
`agent_inferred` or `user_stated` etc. That's fine for a pill; just don't assume decisions
are always `system`.
4. **No migration / no DDL change needed** — `source` already exists on every DB (NOT NULL
DEFAULT). The projection is read-only over an existing column. Zero data-backfill risk.

View File

@@ -0,0 +1,215 @@
# PR3.5 Recon — SSE/Chat "step" event path & provenance attach point
**Question:** Can a `source`/provenance field attach to streamed agent "step" events
(to power the `⬡ source · when` ProvenanceLine pill that PR3 wired date-only)?
**Verdict: NEEDS-WIRING.** A streamed `step` event today carries *only* `{ content: string }`
no frame id, no `source`, no provenance of any kind. The frame `source` *does* exist in the
substrate (`MemoryFrame.source: FrameSource`), but it is flattened to formatted text strings
before it ever reaches the SSE layer. Attaching `source` to a step is a real (but small &
well-scoped) plumbing job, not a trivial projection. The cleanest path is **not** to enrich the
generic `step` event — it's to enrich the `tool_result` event for the `auto_recall` (memory) tool,
which is the only step type that has a real provenance signal.
---
## 1. Server — where steps are produced & streamed
**Route:** `packages/server/src/local/routes/chat.ts``POST /api/chat`, SSE via `reply.hijack()`.
**SSE writer (the only emit helper):** `chat.ts:475`
```ts
const sendEvent = (event: string, data: unknown) => {
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
```
### The `step` payload shape (server)
Every `step` event the server emits has the **same minimal shape**: `{ content: string }`. There is
no other field. Representative emit sites:
- `chat.ts:761``sendEvent('step', { content: 'Recalling relevant memories...' })`
- `chat.ts:780``sendEvent('step', { content: 'Recalled N relevant memories.' })`
- `chat.ts:1170``sendEvent('step', { content: stepText })` where `stepText = describeToolUse(name, input)`
(this is the **per-tool-call** step, fired from the agent-loop `onToolUse` callback — the main step source)
- plus budget/approval/compression/GEPA steps at `:740 :754 :771 :904 :921 :929 :1023 :1027 :1108 :1352 :1382 :1389 :1439 :1458` — all `{ content }` only.
### The agent-loop emission (where tool steps originate)
**`packages/agent/src/agent-loop.ts`** does **not** emit `step` events itself. It exposes typed
callbacks (`AgentLoopConfig`, `agent-loop.ts:37-39`):
```ts
onToken?: (token: string) => void;
onToolUse?: (name: string, input: Record<string, unknown>) => void;
onToolResult?: (name: string, input: Record<string, unknown>, result: string) => void;
```
The route wires these to SSE in the runner config (`chat.ts:1167-1185` / `:1186-1220`):
```ts
onToolUse: (name, input) => {
const stepText = describeToolUse(name, input);
sendEvent('step', { content: stepText }); // ← the step
sendEvent('tool', { name, input }); // ← raw tool event (has name+input)
...
},
onToolResult: (name, input, result) => {
...
sendEvent('tool_result', { name, result, duration, isError }); // ← name+result+duration
...
},
```
**Key fact:** the loop's callbacks expose `name`, `input`, `result` — but **no frame, no `source`,
no provenance**. The agent loop has no concept of which memory frame a step touched.
### The related `tool_result` shape (server)
`chat.ts:1200``{ name, result, duration, isError }`. For the memory tool specifically:
`chat.ts:781``sendEvent('tool_result', { name: 'auto_recall', result: resultText, duration, isError })`
where `resultText` is **already-rendered text** (`chat.ts:777-779`):
```ts
const snippets = (recall.recalled ?? []).slice(0, 3);
const snippetText = snippets.map(s => ` - ${s}`).join('\n');
const resultText = `${recall.count} memories recalled:\n${snippetText}`;
```
### Why the provenance is lost: `recallMemory()` returns text, not frames
`packages/agent/src/orchestrator.ts:453-457`:
```ts
async recallMemory(...): Promise<{ text: string; count: number; recalled?: string[] }>
```
`recalled` is a `string[]` of **formatted snippets** — the frame objects (which *do* carry
`source`) are collapsed to display strings inside `recallMemory` before returning. The real
provenance lives one layer deeper: `packages/hive-mind-core/src/mind/frames.ts:35`
`MemoryFrame.source: FrameSource` (e.g. `user_stated`, harvest adapters: chatgpt/claude/etc.),
plus `sourceUrl`/`sourceId` extras. So **the data exists in the DB, it is just not projected up
through `recallMemory` → SSE.**
---
## 2. Frontend — SSE consumer → activity stream blocks
### SSE parse + event-type mapping
**`apps/web/src/lib/adapter.ts:692-738`** (`streamChat` async generator) parses the raw SSE
text and maps event names to `StreamEvent.type`:
- `adapter.ts:729``else if (type === 'step') type = 'step';`
- yields `{ type, data } as StreamEvent` (`adapter.ts:732`) — `data` is the parsed `{ content }`.
### `StreamEvent` type
**`apps/web/src/lib/types.ts:616-619`**:
```ts
export interface StreamEvent {
type: 'token' | 'step' | 'tool_start' | 'tool_end' | 'done' | 'error' | 'approval_request' | 'approval_required' | 'model_switch' | 'notification';
data: unknown;
}
```
### Step → block reduction
**`apps/web/src/hooks/useChat.ts:152-165`** turns a `step` event into a `StepContentBlock`:
```ts
case 'step': {
const description = typeof data === 'string' ? data : (data?.content as string ?? '');
if (description) {
// mark prior running steps done
blocks.push({ type: 'step', blockId: nextBlockId('step'), description, status: 'running' });
}
break;
}
```
### `StepContentBlock` type (client) — **the field set to extend**
**`apps/web/src/lib/types.ts:474-479`**:
```ts
export interface StepContentBlock {
type: 'step';
blockId: string;
description: string;
status: 'running' | 'done';
}
```
No `source`/`provenance` field. This is the type that would gain an optional
`provenance?: { source: string; when?: string }`.
### Where provenance is (not yet) shown — PR3's date-only affordance
**`apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:28-43`** groups a consecutive run
of step blocks into one `ActivityStream` card. PR3 already left the hook here and an honest comment
(`BlockRenderer.tsx:24-26`):
```ts
// Provenance pills are intentionally omitted: the SSE
// `step` payload carries no structured source field today (recon chat.md §4) —
// we render the affordance, never fabricated provenance.
```
And the map drops provenance (`BlockRenderer.tsx:30-33`):
```ts
const activitySteps: ActivityStep[] = steps.map(s => ({
tone: s.status === 'running' ? 'honey' : 'intel',
text: s.description, // ← no provenance projected
}));
```
The rendering target already exists and is wired:
- **`ActivityStream`** (`components/os/warm/ActivityStream.tsx:8-13`) — `ActivityStep.provenance?: { source; when?; onClick? }`, rendered at `:67-71` **only when present**.
- **`ProvenanceLine`** (`components/os/warm/ProvenanceLine.tsx:19-32`) — the `⬡ source · when` pill.
So the **client consumer is provenance-ready**; it is waiting for the server to deliver a `source`.
---
## 3. Honest verdict — trivial vs needs-wiring
**NEEDS-WIRING (small, scoped). NOT trivially projectable.**
- A generic `step` (e.g. "Drafting the document", "Budget limit reached", a `bash` tool call) has
**no provenance at all** and never will — those steps are not memory reads. Stamping them with a
`source` would be fabrication (exactly what PR3's comment refuses). So a blanket "add source to
every step" is wrong on the merits.
- The **one** step with a genuine provenance signal is the **memory recall** (`auto_recall`). Its
source is real (`MemoryFrame.source`) but is destroyed by `recallMemory()` returning `string[]`
text instead of frame metadata.
### The precise new wiring needed
1. **Substrate → orchestrator (the load-bearing change):** widen `recallMemory()`'s return in
`packages/agent/src/orchestrator.ts:453-457` to carry per-snippet provenance, e.g. add
`recalledFrames?: Array<{ text: string; source: FrameSource; sourceUrl?: string; when?: string }>`
alongside the existing `recalled: string[]`. The frame objects with `.source` are already in hand
inside `recallMemory` — this is a "stop flattening it" change, not a new query. (This is the
*same* 1-field server projection the S2 handoff named: *"the `frame.source` 1-field server
projection (unlocks the ⬡ provenance pill on Chat+Workspace)"*.)
2. **Server SSE:** in `chat.ts` around `:780-781`, emit the source on the memory step/tool_result —
either add `source`/`provenance` to the existing `tool_result` (`{ name:'auto_recall', ... }`) or
to the `step` payload for that one event: `sendEvent('step', { content, provenance: { source, when } })`.
(`{ content }``{ content, provenance? }` is additive and backward-compatible.)
3. **Client types:** add optional `provenance?: { source: string; when?: string }` to
`StepContentBlock` (`types.ts:474`) and to the `StreamEvent` `data` handling.
4. **Client reducer:** in `useChat.ts:152` carry `data.provenance` onto the pushed step block.
5. **Client render:** in `BlockRenderer.tsx:30-33` project `s.provenance` into `ActivityStep` (the
`ActivityStream`/`ProvenanceLine` rendering path already exists and gates on presence).
**Effort estimate:** ~5 small edits across 4 files (orchestrator return-shape widen is the only
non-trivial one; everything else is a 1-field pass-through). No new DB columns, no new query — the
`source` already exists at `frames.ts:35`. Lowest-risk slice = wire it on `auto_recall` only
(memory steps), leave all other steps provenance-less by design.
### Scope call for PR3.5
**In-scope and the right size for PR3.5** *iff* paired with the `recallMemory` return-shape widen.
If PR3.5 wants to stay frontend-only, then provenance on steps is **out-of-scope** (the data is not
on the wire) — and the honest move is to keep PR3's "render the affordance, never fabricated
provenance" stance until the server projection lands.
---
## Appendix — file:line index
| Concern | File:line |
|---|---|
| SSE writer | `packages/server/src/local/routes/chat.ts:475` |
| step payload (memory) | `chat.ts:761`, `:780` |
| step payload (per tool call) | `chat.ts:1170` (via `describeToolUse`) |
| tool_result payload | `chat.ts:1200`; memory variant `chat.ts:781` |
| recall snippet build (text flatten) | `chat.ts:777-779` |
| agent-loop callbacks (no source) | `packages/agent/src/agent-loop.ts:37-39`, wired `chat.ts:1167-1220` |
| recallMemory return shape | `packages/agent/src/orchestrator.ts:453-457` |
| frame `source` field (real provenance) | `packages/hive-mind-core/src/mind/frames.ts:35` |
| SSE parse + map | `apps/web/src/lib/adapter.ts:717-732` |
| StreamEvent type | `apps/web/src/lib/types.ts:616-619` |
| step → block reducer | `apps/web/src/hooks/useChat.ts:152-165` |
| StepContentBlock type | `apps/web/src/lib/types.ts:474-479` |
| activity grouping + omitted-provenance comment | `apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:24-43` |
| ActivityStream (provenance-ready) | `apps/web/src/components/os/warm/ActivityStream.tsx:8-13, 67-71` |
| ProvenanceLine pill | `apps/web/src/components/os/warm/ProvenanceLine.tsx:19-32` |

View File

@@ -0,0 +1,155 @@
# PR3.5 Recon — What PR3 actually left behind (hooks + primitives)
> Audit, not trust. Every row below was verified against source on branch
> `feature/warm-hive-pr3` @ `dac7b696`. Where the handoff was imprecise it is
> called out. Repo root: `D:/Projects/waggle-os`.
---
## TL;DR — claimed primitives: exist vs must-build
**The three "Memory-Trust primitives" the handoff named — `ConfidenceBadge`,
`EvidenceChip`, `DetailDrawer` — ALL EXIST and are already in production use.**
The handoff's only error was the *location*: they are NOT in `os/warm/`, they live
in `components/ui/` and predate PR3 (created in the earlier UX-refactor arc, commit
`afe96355` "Phase 2B-FE.1 — Memory Center DS foundation"). PR3 added the warm
provenance layer (`ProvenanceLine`, `ActivityStream`) on top.
So PR3.5 **reuses**, does not build-from-scratch, the trust primitives. What is
genuinely **missing / static** is the *wiring*: the Workspace "What Waggle knows"
fact rows and the Chat activity step rows are visual-only (no `onClick`, no
provenance plumbed), and the chat `StepContentBlock` type has no `source` field —
that is the real PR3.5 work (the "frame.source 1-field server projection" keystone).
---
## TRUTH TABLE — claim → real state → file:line
| # | Handoff claim | Real state | Evidence (file:line) |
|---|---|---|---|
| 1 | "ConfidenceBadge / EvidenceChip / DetailDrawer primitives **ready** [implied: in `warm/`]" | **TRUE but mislocated.** All three exist in `components/ui/`, NOT `os/warm/`. Pre-date PR3 (commit `afe96355`). Already consumed by Memory Center. Do NOT rebuild. | `apps/web/src/components/ui/confidence-badge.tsx:22` (export `ConfidenceBadge`)<br>`apps/web/src/components/ui/evidence-chip.tsx:16` (export `EvidenceChip`)<br>`apps/web/src/components/ui/detail-drawer.tsx:22` (export `DetailDrawer`)<br>Bonus: `apps/web/src/components/ui/evidence-panel.tsx` (`EvidencePanel`) also exists |
| 1b | (warm/ dir listing) | The PR3 `warm/` set is 14 atoms; the trust primitives are NOT among them. warm/ has the provenance *renderer* (`ProvenanceLine`) + the chat *container* (`ActivityStream`). | `apps/web/src/components/os/warm/index.ts:7-20` |
| 2a | "clickable fact rows" (Workspace "What Waggle knows", PR3 Phase C2) | **FALSE — STATIC.** The `<li>` rows render `HexCheckTile + text + when`. No `onClick`, no anchor, no role=button, no cursor affordance. Pure display. | `apps/web/src/components/os/apps/WorkspaceDesktopApp.tsx:160-168` (the `facts.map` `<li>` has no handler). Header comment at `:146-149` explicitly flags `frame.source` is not yet projected onto these rows ("the PR3.5 keystone"). |
| 2b | "clickable step rows" (Chat activity steps) | **FALSE — STATIC.** `ActivityStream` renders each step as a `<li>` (`DotLive + text + optional ProvenanceLine`). The step `<li>` itself has no `onClick`. Provenance is only rendered `if (s.provenance)` — and the Chat caller never supplies it. | `apps/web/src/components/os/warm/ActivityStream.tsx:62-74` (step `<li>` static; provenance gated on `s.provenance`)<br>`apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:30-33` (maps only `tone`+`text`**omits `provenance` entirely**) |
| 2c | (why chat steps can't show provenance yet) | **Root blocker.** `StepContentBlock` has no `source`/`provenance` field. So even if BlockRenderer wanted to pass provenance, the data isn't on the block. Same shape as the Workspace keystone — both need `frame.source` server projection. | `apps/web/src/lib/types.ts:474-479` (`StepContentBlock` = `{type,blockId,description,status}` — no source) |
| 3 | "intact J08 banner" | **TRUE.** Renders on Home Cockpit; gated `needsReviewCount > 0`; `role="alert"`; deep-links to Memory Center via `waggle:open-app` with `filter:'unreviewed'`. Wiring intact end-to-end (route re-stash in `MemoryRoute.tsx`, consumed by `MemoryCenterTab`). | Render: `apps/web/src/components/os/apps/HomeCockpit.tsx:496-515`<br>Handler `openMemoryReview`: `:477-481`<br>Count source: `briefing.needsReviewCount` (`lib/types.ts:303`)<br>Consumer: `MemoryCenterTab.tsx:84` |
| 4 | "ProvenanceLine.tsx" | **EXISTS** (PR3 Phase 0, commit `0b15b91f`). Thin recolor of `EvidenceChip` to the intel/violet semantic. Full API below. | `apps/web/src/components/os/warm/ProvenanceLine.tsx:19` |
| 5 | "warm/index.ts + tones.ts" | **EXIST.** Full exports + tone vocab below. | `apps/web/src/components/os/warm/index.ts`, `tones.ts` |
---
## ProvenanceLine — full API (verified, `ProvenanceLine.tsx:4-32`)
```ts
interface ProvenanceLineProps {
source: string; // REQUIRED. e.g. "web · mem0.ai" or "Claude Code"
when?: string; // optional relative time, e.g. "2h ago"
onClick?: () => void; // PR3.5 trace hook — makes the pill clickable into memory detail
className?: string;
}
export function ProvenanceLine({ source, when, onClick, className }): JSX.Element
```
**Render:** builds `label = \`⬡ ${source}${when ? \` · ${when}\` : ''}\``, then
delegates to `<EvidenceChip label title={label} onClick className=...>`. The chip is
recolored via `font-mono text-[var(--intel)] border-[var(--intel-wash)] bg-transparent`.
- **Clickable behavior:** when `onClick` is supplied, `EvidenceChip` renders a real
`<button type="button">` with `hover:bg-muted hover:text-foreground` (`evidence-chip.tsx:19-25`);
without it, a static `<span>` (`:26-30`). So `ProvenanceLine` is click-ready **iff the
caller passes `onClick`** — the primitive supports it; the call sites don't use it yet.
- **`source` absent → date-only:** `source` is a **required** prop, so the pill cannot
be rendered "date-only" via ProvenanceLine. The actual date-only fallback today is done
by **not rendering ProvenanceLine at all** and showing a plain mono date instead — see
the Workspace fact rows (`WorkspaceDesktopApp.tsx:165`, a bare
`<div class="font-mono text-[10.5px]">{f.when}</div>`). The deliberate design note: PR3
shows the REAL date only and **never fabricates a `source`** until `frame.source` is
projected (`WorkspaceDesktopApp.tsx:146-149`). **PR3.5 implication:** to light up the
⬡ pill, project `frame.source` server-side, then swap the bare date `<div>` for
`<ProvenanceLine source={...} when={f.when} onClick={...} />`.
---
## Tone / token vocabulary (for new Memory-Trust primitives)
### `os/warm/tones.ts` — `WarmTone` union + maps (`tones.ts:7-36`)
```ts
type WarmTone = 'work' | 'intel' | 'healthy' | 'attention' | 'risk' | 'honey' | 'neutral';
TONE_COLOR: Record<WarmTone, string> // foreground/dot color
work→var(--work) intel→var(--intel) healthy→var(--healthy)
attention→var(--attention) risk→var(--risk) honey→var(--honey)
neutral→var(--text-muted)
TONE_WASH: Record<WarmTone, string> // tinted background
work→var(--work-wash) intel→var(--intel-wash) healthy→var(--healthy-wash)
attention→var(--honey-wash) risk→var(--risk-wash) honey→var(--honey-wash)
neutral→var(--surface-3)
```
> Note the asymmetry: `attention` foreground = `--attention`, but its wash = `--honey-wash`
> (not `--attention-wash`). Match this when building a trust primitive on the attention tone.
### CSS tokens — defined in BOTH themes (`apps/web/src/index.css`)
| Token | Dark (`:162-168`) | Light (`:298-304`) | Semantic |
|---|---|---|---|
| `--intel` | `#b196dd` | `#7d57b8` | **violet — intelligence / memory / provenance** (the trust color) |
| `--intel-wash` | `rgba(177,150,221,.12)` | `rgba(125,87,184,.10)` | violet wash |
| `--work` / `--work-wash` | desaturated blue | — | task/work |
| `--healthy` / `--healthy-wash` | desaturated green | — | OK/done |
| `--attention` | (status block `:102+`) | — | needs-attention (uses `--honey-wash`) |
| `--risk` / `--risk-wash` | — | — | blocked/conflict |
| `--honey` / `--honey-wash` | — | — | brand accent |
The `confidence-badge` uses a *different* token family — Hive DS `--sem-*`
(`--sem-healthy` / `--sem-attention` / `--sem-risk`, `confidence-badge.tsx:17-19`) — not
the warm `--intel/--work/...`. **PR3.5 consistency call:** decide whether new
Memory-Trust atoms align to the warm `--intel` family (provenance) or the `--sem-*`
family (confidence bands). They currently coexist.
---
## warm/index.ts — full export list (`index.ts:7-20`)
```
HexAvatar · SectionLabel · DotLive · ProvenanceLine · RunChip (+RunChipProps)
IconTile · HexCheckTile · StreakChip · ModelPill · OvernightHero · AskBar
ActivityStream (+ActivityStep) · InlineApprovalCard · TONE_COLOR · TONE_WASH (+WarmTone)
```
14 atoms. **Not present (and not the warm dir's job per its header doc, `index.ts:3-6`):**
ConfidenceBadge / EvidenceChip / DetailDrawer — "Generic, always-labeled primitives
stay in `components/ui/`."
---
## Where the Memory surfaces live today (screen 19 target map)
| Surface | File | Notes for PR3.5 |
|---|---|---|
| **Memory Center (standalone screen, S04)** | `apps/web/src/components/os/apps/MemoryCenterApp.tsx` | The full screen. **Two-mind split** ("About you" = personal, "About this work" = workspace) + 7 views: `memories · timeline · graph · harvest · weaver · wiki · evolution` (`MEMORY_VIEWS`, `:36-38`). Controlled component (route owns mind+view via URL). This is what **screen 19 extends or replaces.** |
| **Per-mind memory list (the reusable core)** | `apps/web/src/components/os/apps/memory/MemoryCenterTab.tsx` | Parameterized by `mind` + `workspaceId`. **Already consumes the trust primitives:** `DetailDrawer` (`:337`), `ConfidenceBadge` (`:342`), `EvidencePanel`+source/evidence (`:406`), `MemoryCard` grid (`:323-331`, each card `onClick={() => openDetail(m)}`). This is the proven pattern PR3.5 should match. |
| **Workspace Memory tab** | `apps/web/src/components/os/apps/WorkspaceDesktopApp.tsx:701-705` | Tab `id:'memory'` (`TABS`, `:73`) embeds the SAME `<MemoryCenterTab mind="workspace" workspaceId consumeDeepLinks={false} />`. So the Workspace memory tab and the Memory Center already share one component — change once, both update. |
| **Route wrapper** | `apps/web/src/routes/MemoryRoute.tsx` | `/memory` ≡ `/memory/personal`; J08 banner target; re-stashes `?filter=` deep link. |
| **Memory card row** | `apps/web/src/components/os/apps/memory/MemoryCard.tsx` | Individual memory row component (grep-confirmed it references ConfidenceBadge family). |
| **J08 needs-review banner** | `apps/web/src/components/os/apps/HomeCockpit.tsx:496-515` | Already wired; deep-links here. |
| **Import reminder banner** | `apps/web/src/components/os/apps/memory/ImportReminderBanner.tsx` | Secondary banner on the Memory Center. |
---
## PR3.5 build implications (derived from the truth table)
1. **Reuse, don't rebuild** the trust primitives (`ui/confidence-badge`,
`ui/evidence-chip`, `ui/detail-drawer`, `ui/evidence-panel`) — they exist and ship today.
2. **The keystone is the data, not the UI:** project `frame.source` (+ `when`) server-side
so the Workspace fact rows and Chat steps can carry real provenance.
3. **Make the rows clickable** by (a) adding `onClick` to the Workspace fact `<li>`
(`WorkspaceDesktopApp.tsx:160`) → open the memory detail drawer, (b) extending
`StepContentBlock` (`types.ts:474`) with an optional `source` and threading it through
`BlockRenderer.tsx:30-33` into `ActivityStep.provenance`.
4. **`ProvenanceLine` already supports `onClick`** (→ `EvidenceChip` renders a `<button>`);
the only gap is call sites passing the handler + a real `source`.
5. **Token consistency decision** pending: warm `--intel` family (provenance) vs Hive DS
`--sem-*` family (confidence). Both live; pick one story for screen 19.

View File

@@ -0,0 +1,317 @@
# Screen 19 — Memory Trust · Build-Ready Design Contract
> Source of truth: `docs/design_handoff_waggle_app/design-files/screens/memory-trust.html`
> (authoritative mock, read in full), `screenshots/19-memory-trust.png` (rendered),
> `DESIGN_POV.md` #1, `SCREENS.md` (no 0118 entry — this screen is POV-driven, #19).
> All copy below is **verbatim** from the mock. All class names / colors / spacing are
> quoted from the HTML `<style>` block.
**DESIGN_POV #1 mandate (verbatim, `DESIGN_POV.md:12-34`):** "A persistent-memory
product's #1 churn driver isn't *forgetting* — it's **remembering the wrong thing**…"
The designed response is a *Memory Trust* layer with four primitives: **confidence +
freshness** on every memory; **Forget** (real removal from recall) and **Correct**
(inline; dependents re-checked); **Stale review** prompts; **"Why did you do that?"
trace**. It is "also a moat… auditability, right-to-correct, EU AI Act alignment."
POV §How-to-use: "wire it to the real memory store (confidence/freshness/forget/correct/
trace are all backed by data the substrate already has or can derive)."
---
## 1. Header / Views (the segmented control)
The page is a single full-height column (`body { height:100vh; overflow:hidden;
display:flex; flex-direction:column }`). A sticky control bar (`.controls`, blurred
`backdrop-filter:blur(10px)`, `border-bottom:1px solid var(--line-soft)`) holds a
**2-button segmented switch** that toggles between two full views.
| Element | Verbatim copy / spec |
|---|---|
| Eyebrow label (`.lab`, mono 10.5px, `--text-dim`, uppercase) | `Memory Trust · view` |
| Segment button 1 (`.seg button.on`**active by default**) | `Manage memory` |
| Segment button 2 (`.seg button`) | `Why did you do that?` |
| Active-view label (`.vlabel`, swaps on toggle) — Manage | `<b>Manage</b> — forget, correct, confirm; see confidence & freshness` |
| Active-view label — Why | `<b>Why-trace</b> — every action explains itself` |
| Right control (`.tbtn`) | Theme toggle `☾`/`☀` |
**Segment active state:** `.seg button.on { background:var(--honey); color:#1a1407; }`
(honey fill + dark-ink text — the canonical "selected" treatment). Inactive buttons are
`background:transparent; color:var(--text-muted)`. Segment container `.seg` is a
`var(--surface-2)` pill with `border:1px solid var(--line-soft)`, `border-radius:10px`,
3px inner padding.
**Two VIEW modes:**
- **A — "Manage memory"** (`.view[data-view="manage"]`, on by default — this is what the
screenshot shows): editorial hero → 4 stat cards → search+filters → memory rows → trust
principle footnote.
- **B — "Why did you do that?"** (`.view[data-view="why"]`): editorial hero → a single
**trace card** (goal→recall→checks→action chain) → trust principle footnote. (See §6.)
Toggle JS: clicking a segment button sets `.on`, shows the matching `.view`, swaps the
`.vlabel` HTML, and resets `.stage` scroll to 0. Stage content is centered in
`.wrap { max-width:920px; margin:0 auto; padding:30px 32px 70px }`.
---
## 2. Editorial Hero
### View A — Manage (`.head`, margin-bottom 22px)
- **Eyebrow** (`.eyebrow`, mono 11px, `letter-spacing:.14em`, uppercase, `color:var(--honey)`,
with a 20px honey rule `::before`): `Trust · the thing that makes you stay`
- **H1** (`.head h1`, 28px / weight 650 / `letter-spacing:-0.02em`; `<em>` is **honey,
non-italic** — `h1 em { font-style:normal; color:var(--honey) }`):
`Memory you can ` + **`correct, age, and forget.`** ← the phrase **"correct, age, and
forget."** gets the honey accent (everything after "Memory you can " is in `<em>`).
- **Body** (`.head p`, 15px, `--text-muted`, `line-height:1.55`, `max-width:64ch`; `<b>` =
`--text-2`): verbatim —
> A memory that only grows is a liability. Waggle shows you **how sure it is**, **how
> fresh it is**, and **where it came from** — and lets you fix or forget anything.
> You're always in control of what the hive believes.
(Honey-emphasis `<b>` spans: "how sure it is", "how fresh it is", "where it came from".)
### View B — Why
- **Eyebrow:** `Provenance · accountability`
- **H1** (honey `<em>` = the quoted question):
`Ask the agent ` + **`"why did you do that?"`**
- **Body** (`<b>` spans bolded): verbatim —
> Any action an agent takes can be traced back to the exact memories and sources behind
> it — so a wrong move is **diagnosable, not mysterious**. If a bad memory caused it,
> fix the memory right from the trace.
---
## 3. Stat Cards — "trust summary bar" (`.tsum`, View A only)
4-up grid: `grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:24px`. Collapses
to `1fr 1fr` under `max-width:820px`. Each card `.ts`: `padding:16px`, `border-radius:
var(--r-lg)` (18px), `border:1px solid var(--line-soft)`, `background:var(--surface)`.
Value `.ts .v` = 24px / weight 750 / `letter-spacing:-0.02em`; label `.ts .l` = 11.5px,
`--text-muted`, `margin-top:5px`. **Warn variant** `.ts.warn`: `border-color:
color-mix(in srgb,var(--attention) 35%,transparent)` + value tinted `--attention`.
| # | Value (verbatim) | Label (verbatim) | Color of value | Card variant |
|---|---|---|---|---|
| 1 | `142` | `Memories in this hive` | default `--text` (white/ink) | `.ts` |
| 2 | `128` | `High confidence & fresh` | **green**`style="color:var(--healthy)"` (`#6cb78c`) | `.ts` |
| 3 | `9` | `Stale · worth a review` | **honey**`--attention` (`#e9a52c`) | `.ts.warn` |
| 4 | `3` | `Awaiting your confirm` | **honey**`--attention` (`#e9a52c`) | `.ts.warn` |
Color order = **white / green / honey / honey** (matches the brief). Cards 3 & 4 also get
the honey-tinted warn border.
---
## 4. Search + Filters (`.toolbar`, View A only)
`.toolbar { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap }`.
**Search bar** (`.search` — flex:1, min-width:200px, `padding:9px 14px`, `border-radius:
11px`, `border:1px solid var(--line)`, `background:var(--surface)`):
- Leading magnifier `<svg>` (circle + handle), 16px, `stroke:var(--text-dim)`,
`stroke-width:1.9`, no fill.
- Input placeholder (verbatim): `Search what Waggle knows… or ask it to forget something`
— 14px, transparent bg, `color:var(--text)`.
**Filter chips** (`.filt`, 12.5px / weight 600, `padding:8px 13px`, `border-radius:9px`,
`border:1px solid var(--line-soft)`, `background:var(--surface)`):
| Chip (verbatim) | State |
|---|---|
| `All` | **active**`.filt.on` |
| `Stale` | default |
| `Needs confirm` | default |
| `Forgotten` | default |
**Active chip** `.filt.on`: `color:var(--text); border-color:var(--honey-line);
background:var(--honey-wash)` (honey-wash fill + honey-line border). Hover `.filt:hover`
just lifts `color` to `--text`. (Note: the warm primitive set already has chip-like
patterns; the active = honey-wash + honey-line treatment must be reused.)
---
## 5. Memory ROW anatomy (`.mem`, the core component — repeated in `.mems` grid)
`.mems { display:grid; gap:10px }`. Each row `.mem`: `border:1px solid var(--line-soft)`,
`background:var(--surface)`, `border-radius:var(--r-lg)` (18px), `padding:16px 18px`,
`transition:.15s`. **State border variants:**
- `.mem.stale``border-color:color-mix(in srgb,var(--attention) 30%,var(--line-soft))`
- `.mem.disputed` → `border-color:color-mix(in srgb,var(--risk) 30%,var(--line-soft));
opacity:.92`
Layout `.mem .top { display:flex; align-items:flex-start; gap:13px }` — three columns:
**[confidence ring] [body] [actions]**.
### 5a. Confidence ring (`.conf`, width 42px, flex:none, centered)
- Ring `.ring`: `width/height 38px`, `border-radius:999px`, `display:grid; place-items:
center`, mono 11px / weight 600. **Both the number color AND the 2px ring border come
from `confColor(c)`** (inline `style="color:${confColor(c)};border:2px solid
${confColor(c)}"`).
- **Ring color logic** (`confColor(c)` JS, verbatim):
- `c >= 85` → `var(--healthy)` (sage green)
- `c >= 60` → `var(--attention)` (honey)
- `else` (`< 60`) → `var(--risk)` (terracotta)
- Caption `.cl` below ring: literal text `conf` (rendered uppercase via
`text-transform:uppercase`), 9px mono, `--text-dim`, `margin-top:4px`,
`letter-spacing:.06em`. → reads **`CONF`**.
### 5b. Body (`.mbody`, flex:1)
- **Fact text** `.mtext`: 14.5px, `line-height:1.5`, `color:var(--text)`. **`<b>` inside
gets weight 650** — this is the honey-free *bold* emphasis on the key phrase (NOT
honey-colored; it's bold weight only). Disputed rows strike through: `.mem.disputed
.mtext { text-decoration:line-through; text-decoration-color:color-mix(in srgb,
var(--risk) 60%,transparent); color:var(--text-muted) }`.
- **Provenance line** `.prov` (`margin-top:8px`, flex-wrap, `gap:7px 14px`, mono 10.5px,
`color:var(--text-dim)`). Format = three segments:
`⬡ <id>` · `source: <src>` · `● <freshness>`
- ID segment: `<span class="src">⬡ ${m.id}</span>` — `.prov .src { color:var(--intel) }`
(muted violet) — e.g. `⬡ M-204`.
- Source segment: plain `source: ${m.src}` in `--text-dim` — e.g. `source: chat · Tue`.
- Freshness segment: `` + label, colored by `.fresh.ok`→`var(--healthy)` (green) or
`.fresh.old`→`var(--attention)` (honey). Fresh label = `fresh`; old label =
`aging — last seen 6w ago`.
### 5c. Action buttons (`.acts`, flex:none, gap:6px)
Two square icon buttons `.mact` (30×30px, `border-radius:8px`, `border:1px solid
var(--line-soft)`, `background:var(--surface-2)`, `color:var(--text-muted)`; icon svg 15px
`stroke:currentColor` `stroke-width:1.8`):
1. **Edit / correct** — `title="Edit / correct"`, `data-act="edit"`; pencil icon. Hover
`.mact:hover { border-color:var(--honey-line); color:var(--honey) }`. Click → toast
`Correcting <id> — opens an inline editor`.
2. **Forget / delete** — `.mact.danger`, `title="Forget this"`, `data-act="forget"`; trash
icon. Danger hover `.mact.danger:hover { border-color:color-mix(in srgb,var(--risk)
45%,transparent); color:var(--risk) }`. Click → row animates out (`opacity:0;
translateX(-12px)`, 300ms, then removed) + toast `Forgotten <id> — removed from recall`.
### 5d. Inline sub-banners (conditional, render inside `.mbody`)
- **Corrected banner** (`.corrected`, disputed rows that carry `m.corrected`): healthy-wash
pill — `background:var(--healthy-wash); border:1px solid color-mix(in srgb,var(--healthy)
30%,transparent)`, 12.5px `--text-2`, green check svg (`stroke:var(--healthy)`). Renders
`<b>{before "→"}</b> → {after "→"}`.
- **Stale review banner** (`.stalebanner`, on `.mem.stale`): honey-wash pill —
`background:var(--honey-wash); border:1px solid var(--honey-line)`, clock svg
(`stroke:var(--honey)`). Text (verbatim): `This is 6 weeks old — still true?` Right-aligned
button pair `.sp`:
- `.sbtn.go` (`background:var(--honey); color:#1a1407`) — verbatim `Still true`
(`data-act="confirm"`). Click → banner morphs into a `.corrected` "Confirmed still true ·
freshness reset" pill + toast `Confirmed <id> — freshness reset`.
- `.sbtn.ghost` (`background:var(--surface); color:var(--text-2); border:var(--line-strong)`)
— verbatim `Forget` (`data-act="forget"`).
### 5e. Seed dataset (the 5 demo rows — verbatim `mems[]`)
| id | fact text (`<b>` = bold phrase) | conf | ring color | src | fresh | state |
|---|---|---|---|---|---|---|
| `M-204` | Mara wants market work to **lead with the regulated-industries angle**. | 94 | green | `chat · Tue` | fresh | ok |
| `M-198` | **Mem0** is cloud-only and raised prices ~15% in March. | 88 | green | `web · mem0.ai` | fresh | ok |
| `M-141` | The Q3 launch date is **September 12**. | 61 | honey | `chat · 6 weeks ago` | old | **stale** (shows stale banner) |
| `M-088` | Mara prefers **Slack over email** for updates. | 47 | terracotta | `inferred · once` | old | **disputed** (strikethrough + corrected banner: `Corrected by you → prefers a daily digest, not Slack pings.`) |
| `M-052` | Primary competitor is **Letta**. | 90 | green | `teardown.md` | fresh | ok |
---
## 6. "Why did you do that?" Trace view (View B, `.trace`)
A single card: `border:1px solid var(--line)`, `border-radius:var(--r-xl)` (26px),
`background:var(--bg-2)`, `overflow:hidden`. Three regions: **header → vertical chain →
action footer**, then the trust principle footnote below.
### 6a. Trace header (`.th`, `padding:18px 22px`, bottom border)
- **Hex avatar** `.ti.hex` (34×38px, honey gradient `linear-gradient(150deg,
var(--honey-bright),var(--honey-deep))`, dark-ink `paper-plane`/send svg). → reuse warm
`HexAvatar` primitive.
- Title `<b>` 15.5px/650 (verbatim): `Drafted the board brief around "regulated industries"`
- Subtitle `.sub` 12px `--text-muted` (verbatim): `Deck-builder · 18m ago · Q2 Board Deck`
- `.when` (mono 11px `--text-dim`, margin-left:auto): `trace #a1f9`
### 6b. Trace chain (`.tchain`, `padding:8px 22px 18px`) — `.tnode` steps
Each node is a 2-col grid `24px 1fr` with a **vertical connector line** drawn via
`.tnode::before { position:absolute; left:11px; top:34px; bottom:-14px; width:1.5px;
background:var(--line) }` (suppressed on `:last-child`). Each `.tdot` (24px circle,
colored per step, dark-ink svg `stroke:#1a1407 stroke-width:2.4`):
| # | dot color | title `<b>` (13.5px/600) | body `.tc p` (13px `--text-muted`) — verbatim |
|---|---|---|---|
| 1 | `var(--intel)` (violet, arrow icon) | `Goal received` | You asked: `"tighten the board narrative."` (the quote in `.mono`) |
| 2 | `var(--honey)` (check icon) | `Recalled 3 memories` | Strongest was: `⬡ mem #M-204` (`.src` violet) — "Mara wants market work to lead with the regulated-industries angle." + **evidence chip** `.ev`: `confidence 94% · source: chat · Tue · still fresh` |
| 3 | `var(--honey)` (check icon) | `Cross-checked the teardown` | Confirmed 2 of 3 proof points cite customer quotes `⬡ teardown.md` (`.src`) |
| 4 | `var(--healthy)` (green, arrow icon) | `Acted` | Wrote slide 6 around the regulated angle and flagged it for your review. |
`.ev` evidence chip: `font-size:12px; color:var(--text-2); padding:8px 12px; border-radius:
8px; background:var(--surface); border:1px solid var(--line-soft)`. `.src` inline refs =
mono 11px `--intel`. Inline `.mono` quote = mono 11.5px `--text-dim`.
### 6c. Trace action footer (`.traceact`, top border, `background:var(--surface)`,
`padding:16px 22px`, gap:9px) — three buttons `.tbtn2` (12.5px/650, `padding:9px 15px`,
`border-radius:9px`):
1. `.tbtn2.go` (honey fill, `#1a1407` ink) — verbatim `Looks right`
2. `.tbtn2.ghost` (`--surface-2`, `--line-strong` border) — verbatim
`That memory is wrong → correct it`
3. `.tbtn2.danger` (transparent, risk text + risk-tint border `color-mix(in srgb,
var(--risk) 35%,transparent)`) — verbatim `Forget #M-204 & redo`
---
## 7. Trust principle footnote (`.principle`, both views)
Shared component below the content: `display:flex; gap:13px; align-items:flex-start;
padding:18px 20px; border-radius:var(--r-lg); background:var(--bg-2); border:1px solid
var(--line-soft)`. Leading 20px svg `stroke:var(--healthy)` (shield/check on View A;
info-circle on View B). Text 13.5px `--text-muted` `line-height:1.6`, `<b>`=`--text`.
- **View A (verbatim):** **Nothing is remembered behind your back.** Every memory is
inspectable, editable, and forgettable — and forgetting is real: it's removed from recall
and from anything Waggle says next. Confidence and freshness are shown so the agent (and
you) can discount what's old or shaky instead of acting on it blindly.
- **View B (verbatim):** **Every agent action keeps its trace.** The chain from goal →
recalled memories → checks → action is stored with the result, so "why did you do that?"
always has an answer — and the fix (correct or forget the offending memory) is one click
from the explanation.
**Toast** (shared, `#toast`): `.toast` bottom-center pill, `background:var(--surface);
border:1px solid var(--healthy); box-shadow:var(--shadow-lg)`, 8px green dot `.td2`
(`background:var(--healthy)`), slides up on `.show` (2400ms auto-dismiss).
---
## 8. Tokens — mock → PR3 warm tokens (1:1, already aligned)
The mock's `waggle.css` tokens are **identical hex** to PR3's warm tokens in
`apps/web/src/index.css` (same names, same dark + light values). **No remapping needed** —
build directly against the CSS vars below.
| Mock var | Hex (dark / light) | PR3 warm token | Used in screen 19 for |
|---|---|---|---|
| `--honey` | `#e9a52c` / `#b57d12` | `--honey` ✓ | segment-on, accent `<em>`, eyebrow, conf 6084, active filter, go-buttons |
| `--honey-bright` | `#f6c45a` / `#cf932a` | `--honey-bright` ✓ | hex-avatar gradient top |
| `--honey-deep` | `#c07e16` / `#92620a` | `--honey-deep` ✓ | hex-avatar gradient bottom |
| `--honey-wash` | `rgba(233,165,44,.10)` | `--honey-wash` ✓ | active filter bg, stale banner bg |
| `--honey-line` | `rgba(233,165,44,.28)` | `--honey-line` ✓ | active filter border, stale border, hover states |
| `--healthy` | `#6cb78c` / `#3c8a5f` | `--healthy` ✓ | stat #2 green, conf ≥85 ring, fresh●, "Acted" dot, principle icon, toast |
| `--healthy-wash` | `rgba(108,183,140,.12)` | `--healthy-wash` ✓ | corrected banner bg |
| `--attention` | `#e9a52c` / `#b57d12` | `--attention` ✓ | stat #3/#4 honey, conf 6084 ring, stale border, aging● |
| `--risk` | `#db8068` / `#c0573c` | `--risk` ✓ | conf <60 ring, disputed border/strikethrough, danger buttons |
| `--intel` | `#b196dd` / `#7d57b8` | `--intel` ✓ | `` provenance id, trace `.src` refs, "Goal received" dot |
| Dark-ink on honey | `#1a1407` (mock uses `#1a1407`) | warm light `--primary-foreground: #1a1407` ✓ | text on every honey fill |
| `--surface` / `--surface-2` / `--bg-2` | warm graphite / paper | `--surface` / `--surface-2` / `--bg-2` ✓ | cards, rows, action btns, trace bg |
| `--line` / `--line-soft` / `--line-strong` | warm graphite lines | same ✓ | borders |
| `--text` / `--text-2` / `--text-muted` / `--text-dim` | warm text scale | same ✓ | type hierarchy |
| `--r-lg` 18px / `--r-xl` 26px | radii | same ✓ | cards/rows 18px, trace 26px |
| `--mono` JetBrains Mono / `--sans` Hanken Grotesk | fonts | same ✓ | provenance/conf mono; body sans |
**Reuse existing warm primitives** (`apps/web/src/components/os/warm/`):
- `ProvenanceLine.tsx` — already renders `⬡ source · when` in mono `--intel` (the §5b
provenance pattern). Extend with a freshness `` segment + `source:` prefix to match.
- `HexAvatar.tsx` — the §6a honey-gradient hex with dark-ink icon.
- `SectionLabel.tsx` — eyebrow/mono-uppercase labels.
- `DotLive.tsx` — the freshness `` dot.
- `ModelPill` / `RunChip` / chip patterns — the filter-chip + go-button shapes.
---
## Component inventory (6-line summary)
1. **Header/segmented control** — `Memory Trust · view` eyebrow + 2-tab switch (`Manage memory` default-on honey-fill | `Why did you do that?`) + swapping `.vlabel` + theme toggle, toggling between two full views.
2. **Editorial hero (per view)** — honey eyebrow, 28px H1 with honey `<em>` accent ("correct, age, and forget." / "why did you do that?"), 64ch body with bold spans.
3. **Stat bar (View A)** — 4 cards: 142 white / 128 green / 9 honey-warn / 3 honey-warn, repeat(4,1fr)→2-col @820px.
4. **Toolbar (View A)** — search input ("Search what Waggle knows… or ask it to forget something") + 4 filter chips (All on / Stale / Needs confirm / Forgotten).
5. **Memory row** — 3-col [conf ring (color by ≥85 green / ≥60 honey / <60 terracotta) + CONF] · [bold fact + `⬡ id · source · ● freshness` provenance + optional corrected/stale banner] · [edit + danger-forget icon buttons]; 5 seed rows incl. 1 stale + 1 disputed/strikethrough.
6. **Why-trace (View B)** — hex-avatar header (`trace #a1f9`) + 4-node connector chain (Goal→Recalled→Cross-checked→Acted, dots intel/honey/honey/healthy, evidence chip) + 3-button footer (Looks right / correct it / Forget #M-204 & redo); shared trust-principle footnote + green toast on both views.

View File

@@ -0,0 +1,249 @@
# PR3.5 Memory-Trust — Real Memory-Store API Surface
> Recon for the warm-Hive PR3.5 Memory-Trust arc. Question: what can the
> Memory-Trust UI wire to **as it exists today**, vs what must be added —
> and what it must **never fabricate**.
>
> Repo: `D:/Projects/waggle-os`. Substrate: `packages/hive-mind-core/src/mind/`.
> Server surface: `packages/server/src/local/routes/`.
> All claims below are quoted file:line. **Brutally honest split: PR3.5 must not invent data the store doesn't hold.**
---
## TL;DR Capability Matrix
| Feature | Verdict | Where the truth lives |
|---|---|---|
| **source** (provenance class) | ✅ **REAL** (stored column) | `memory_frames.source` |
| **forget** (delete) | ✅ **REAL** (store + 2 routes) | `FrameStore.delete()` + `DELETE /api/memory/:id` |
| **correct** (edit content) | ✅ **REAL** (store + 2 routes) | `FrameStore.update()` + `PATCH /api/memory/:id` |
| **freshness / staleness** | 🟡 **DERIVABLE** (compute fn exists; not surfaced) | `computeTemporalScore()` over `created_at` / `last_accessed` |
| **confidence** | 🟡 **DERIVABLE / PARTIAL** (metadata blob; only set at harvest) | `memory_frames.metadata.confidence` (B2 heuristic) |
| **trace** ("why did you do that?") | 🟡 **DERIVABLE** (rich store + 1 route; not per-frame) | `execution_traces` + `GET /api/agents/:id/traces` |
| **confirm / verified status** | 🔴 **MUST-BUILD** (no per-frame confirm concept) | absent — see §4 |
**One-line split:** `source`, `forget`, `correct` are **REAL and route-exposed today**. `freshness`, `confidence`, and `trace` are **DERIVABLE** (the signals exist in the substrate but are not projected onto the Chat/Workspace surfaces, and confidence is only populated on the harvest path). A per-memory **"confirm / needs-confirm / verified"** status is **MUST-BUILD** — it does not exist.
---
## 1. The FRAME shape
Canonical row type — `packages/hive-mind-core/src/mind/frames.ts:27-48`:
```ts
export interface MemoryFrame {
id: number;
frame_type: FrameType; // 'I' | 'P' | 'B'
gop_id: string;
t: number;
base_frame_id: number | null;
content: string;
importance: Importance; // critical|important|normal|temporary|deprecated
source: FrameSource; // see below
access_count: number;
created_at: string;
last_accessed: string;
content_hash?: string | null; // dedup
metadata?: string; // JSON blob (Phase 2B) — provenance/classification
}
```
DDL backing it — `schema.ts:47-73` (`memory_frames`). Every field above is a real
stored column; `metadata` is `TEXT NOT NULL DEFAULT '{}'` (`schema.ts:65`).
Per-field verdict against the requested set:
- **id** — REAL (`frames.ts:28`, PK `schema.ts:48`).
- **content / text** — REAL (`frames.ts:33`, `schema.ts:53`). The field is `content`, NOT `text`.
- **source** — ✅ **REAL stored column.** `frames.ts:25` defines `FrameSource = 'user_stated' | 'tool_verified' | 'agent_inferred' | 'import' | 'system' | 'personal' | 'workspace' | 'team_sync'`. The DB CHECK is narrower — `schema.ts:56-57` only allows `('user_stated','tool_verified','agent_inferred','import','system')` (the `personal/workspace/team_sync` members are search-time mind labels, not persisted provenance — see `memory.ts:41-43`).
- **confidence / score** — 🟡 **NOT a frame column.** No `confidence` or `score` column on `memory_frames`. Two distinct things wear the name:
- `score` is a **search-time, computed** ranking field added by HybridSearch (returned in `normalizeFrame` at `memory.ts:58`), never stored.
- `confidence` (0-100) is a **derived metadata field** that rides `memory_frames.metadata` JSON, projected by `normalizeToMemory` at `memory-center.ts:100` (`typeof meta.confidence === 'number' ? meta.confidence : undefined`). It is only **populated on the harvest path** (`harvestConfidence`, see §"confidence" below). Curated/agent/quick-capture writes leave it `undefined`.
- (`confidence REAL` does exist — but on `knowledge_relations`, `schema.ts:109`, not on frames.)
- **created_at** — REAL (`frames.ts:37`, `schema.ts:59`, default `datetime('now')`).
- **updated_at** — 🔴 **NOT on frames.** The frame table has no `updated_at` column. `identity`/`procedures` tables have one (`schema.ts:20,166`); frames do not. An `updatedAt` is faked into the `metadata` blob on PATCH (`memory-center.ts:336`). The shared `Memory.updatedAt` reads it from metadata (`memory-center.ts:112`).
- **accessed_at / last_used** — REAL: `last_accessed` (`frames.ts:38`, `schema.ts:60`), bumped by `touch()` (`frames.ts:184-191`). `access_count` (`frames.ts:36`) is the use counter.
- **decay / staleness** — 🟡 **NOT stored; DERIVABLE.** No decay column. Decay is a pure compute over timestamps in `scoring.ts` (see §5).
- **type / kind** — split brain:
- `frame_type` (`'I' | 'P' | 'B'`) is the substrate-internal kind (incremental/patch/branch), REAL (`frames.ts:29`).
- `importance` (`critical|important|normal|temporary|deprecated`) is the closest stored "salience" axis, REAL (`frames.ts:35`).
- The product-facing `MemoryKind` (`fact|decision|task|preference|strategy|learning|goal|entity`, `shared/types.ts:354-356`) is **NOT a column** — it rides `metadata.kind`, projected at `memory-center.ts:91`, defaulting to `'fact'` when absent.
**Verdict — {confidence, freshness/recency, source}:**
- **source** → REAL stored field.
- **freshness/recency** → DERIVED (no column; computed from `created_at`/`last_accessed`).
- **confidence** → metadata-blob field, REAL-but-sparse (only harvest sets it); treat as DERIVABLE/PARTIAL for any non-harvested frame.
---
## 2. FORGET (delete) — ✅ REAL
Store: `FrameStore.delete(id): boolean``frames.ts:321-334`. Hard delete; cleans up
FTS, vec, KG entity links, and nullifies self-referential FKs. There is **no
tombstone** — it's a real row removal. Also `deleteByContentPrefix` (`frames.ts:343-354`).
Routes that expose it:
- `DELETE /api/memory/frames/:id``memory.ts:571-607` (legacy frame-id contract; workspace-first then personal fallback).
- `DELETE /api/memory/:id``memory-center.ts:390-411` (bare-id contract, hard delete, `mind`-strict).
Both emit an audit event (`eventType: 'memory_delete'`, `memory.ts:601`, `memory-center.ts:402`).
**Suggested approach:** wire the Memory-Trust "Forget" action straight to `DELETE /api/memory/:id?mind=…`. No new backend.
---
## 3. CORRECT (edit content) — ✅ REAL
Store: `FrameStore.update(id, content, importance?)``frames.ts:281-301`. Updates the
row, FTS index, vec index, and maintains `content_hash`. Returns the updated frame.
Routes:
- `PUT /api/memory/frames/:id``memory.ts:466-533` (content + importance; XSS-sanitized `memory.ts:483`).
- `PATCH /api/memory/:id``memory-center.ts:296-352` (content/importance **and** metadata classification: kind/scope/tags/status/title/evidence; stamps `metadata.updatedAt`).
**Suggested approach:** "Correct this memory" → `PATCH /api/memory/:id`. It already supports
editing content and reclassifying. No new backend.
---
## 4. CONFIRM / "needs confirm" / verified status — 🔴 MUST-BUILD (mostly)
**There is NO per-frame "confirmed / needs-confirmation / verified" concept.** Honest accounting of the near-misses:
- `FrameSource` has a `tool_verified` member (`frames.ts:25`, `schema.ts:57`) — but that's a **provenance class set at write time** (this fact came from a verified tool call), not a user-confirmation lifecycle. It's never toggled after creation.
- `execution_traces.outcome` has a `'verified'` value (`schema.ts:223`, `execution-traces.ts:20`) — but that's about an **agent run** passing a verifier gate, not a memory being confirmed.
- The shared `MemoryStatus` union (`shared/types.ts:514-515`) has `'unreviewed' | 'low_confidence' | 'conflict'` — the **vocabulary for a review lifecycle exists** and is already projected (`memory-center.ts:84-85`) and filterable (`memory-center.ts:198`). But: nothing **writes** `unreviewed` today (the create path stamps `'active'`, `memory-center.ts:273`; harvest commit is the only intended `unreviewed` producer per `types.ts:506-507` but that write was not confirmed in this recon), and `low_confidence`/`conflict` are explicitly noted as *"may be derived at recall-time rather than persisted"* (`types.ts:510-511`) — i.e. not implemented.
**Verdict:** A "Confirm" / "Needs your confirmation" affordance is **MUST-BUILD**, but cheaply:
the `metadata.status` field + the `MemoryStatus` union are the rails. Add a
`POST /api/memory/:id/confirm` that sets `metadata.status='active'` (clearing `unreviewed`),
mirroring the existing `/archive` route (`memory-center.ts:354-386`). The "needs confirm"
queue = `GET /api/memory?status=unreviewed` (already works — `memory-center.ts:198`).
**Do NOT show a "verified ✓" badge unless the frame is genuinely `source==='tool_verified'` or `status` was explicitly set** — anything else is fabrication.
---
## 5. STALE / freshness / decay — 🟡 DERIVABLE (not surfaced)
Real signal exists as **pure compute**, not a stored flag — `scoring.ts`:
- `computeTemporalScore(iso)``scoring.ts:52-63`. Returns `1.0` if within **7 days**
(`RECENCY_BOOST_DAYS`, `scoring.ts:38`), else exponential decay with a **30-day half-life**
(`HALF_LIFE_DAYS`, `scoring.ts:37`): `Math.pow(0.5, daysSince / 30)`.
- Decay anchors on **`created_at`** (write time), NOT `last_accessed` — deliberate
(`scoring.ts:91-97`): `last_accessed` is bumped by `touch()` on every read, so decaying on
it made the dimension constant noise. Use `created_at` for "age".
- The substrate also auto-prunes by age in `FrameStore.compact()` — temporary frames > 30d,
deprecated > 90d (`frames.ts:366-389`) — a real staleness policy, but a background sweep, not a per-frame badge.
**Verdict:** "Stale · worth a review" is **DERIVABLE today** with zero new storage:
compute `daysSince(created_at)` (or call `computeTemporalScore`) FE-side or in a thin route.
A reasonable "stale" threshold: temporal score below ~0.5 ≈ older than one half-life (~30d),
or simply `created_at` older than N days for `importance ∈ {normal, temporary}`.
**Constraint:** `created_at` IS projected on the legacy `/api/memory/frames` and `/search`
responses (`memory.ts:56`), so the FE already has the input. **Do NOT invent a "freshness %"
that implies stored decay** — present it as "last touched / age", computed honestly.
---
## 6. "WHY DID YOU DO THAT?" trace — 🟡 DERIVABLE (rich; not per-frame)
A real, rich execution-trace store exists — `execution-traces.ts` + `execution_traces`
DDL (`schema.ts:215-233`). Per "unit of agent work" it records:
- `outcome` (`success|corrected|abandoned|verified|pending`, `execution-traces.ts:20`),
`cost_usd`, `duration_ms`, `created_at`/`finalized_at`, `session_id`/`persona_id`/
`workspace_id`/`model` (`execution-traces.ts:75-88`).
- A structured `trace_json` payload (`TracePayload`, `execution-traces.ts:48-72`):
**`input`, `output`, `reasoning[]` (step text + ts), `toolCalls[]` (tool, args, result, ok,
durationMs, ts — `execution-traces.ts:22-36`), `artifacts[]`, `tokens`, optional `harness`
gate results, and `correctionFeedback`.** This is exactly the data a "why did you do that?"
panel needs.
Production wiring (it IS populated for chat):
- The chat loop creates a `TraceRecorder` and `start()`s a trace per turn
(`chat.ts:1277-1286`), records reasoning + tool calls through it, and `finalize()`s with
outcome+output+tokens (`chat.ts:1413-1424`); aborted/errored turns finalize as
`'abandoned'` (`chat.ts:1648-1649`, `1713`).
- Corrections downgrade a prior trace via `markCorrected()` (`execution-traces.ts:297-310`,
feedback noted at `chat.ts:1651`).
Route exposure:
- `GET /api/agents/:id/traces``agents.ts:469-505`. Returns id/ts/session/workspace/model/
**outcome/cost/durationMs/tools[]** per trace. **It filters by the `agent:{id}` tag**
(`agents.ts:486-487`).
**Two honest gaps for PR3.5:**
1. **The chat trace `start()` does NOT pass a `tags:['agent:…']`** (`chat.ts:1279-1285`),
so the agent-traces route's tag filter will **not** surface conversational traces. The
richest traces (chat reasoning + tool calls) are written but not addressable by that route.
2. **No frame↔trace backlink.** Nothing links a saved `memory_frame` to the
`execution_trace` that produced it (no `trace_id` column, no metadata field; grep of
chat/home/workspace routes for any `traceId`/`trace_id` link returned nothing). So
"why is THIS specific memory here?" can't be answered from the trace store today —
you can only show "what the agent did in this turn/session", not "the decision that wrote this frame".
**Verdict:** Trace data is **DERIVABLE and rich** for the session/turn granularity, but a
**per-frame "why" requires MUST-BUILD plumbing** (a `trace_id` on the frame metadata at
write time, plus a `GET /api/memory/:id/trace` resolver). For PR3.5, the **cheap honest win**
is a session/turn-scoped trace view (reasoning steps + tool calls) — and either (a) add the
`agent:` tag to chat `start()`, or (b) add a thin `GET /api/sessions/:id/traces` reading
`traceStore.queryParsed({ sessionId })` (the store already supports it, `execution-traces.ts:328`).
**Do NOT synthesize a "reason" string for a frame that has no linked trace.**
---
## 7. SERVER ROUTES exposing memory to apps/web
Two plugins. **Legacy frame ops** (`memory.ts`) + **shared-`Memory`-entity contract**
(`memory-center.ts`). Quoted with method + path + file:line:
### `packages/server/src/local/routes/memory.ts`
| Method | Path | Line | Notes |
|---|---|---|---|
| GET | `/api/memory/search?q&scope&limit&workspace&since&until` | `memory.ts:126` | HybridSearch; returns normalized frames incl. computed `score`, `source` |
| GET | `/api/memory/frames?workspace&limit&since&until` | `memory.ts:194` | recent frames, no query needed (Memory tab initial load) |
| POST | `/api/memory/frames` | `memory.ts:245` | direct write; optional entity extraction; XSS-sanitized; dedup |
| GET | `/api/memory/stats?workspace&scope` | `memory.ts:397` | counts only (mind-isolation: `?scope=all-minds` opt-in, `memory.ts:425-438`) |
| PUT | `/api/memory/frames/:id` | `memory.ts:467` | **CORRECT** — edit content/importance |
| PATCH | `/api/memory/frames/:id/access` | `memory.ts:539` | atomic `access_count++` (touch) |
| DELETE | `/api/memory/frames/:id` | `memory.ts:571` | **FORGET** — hard delete |
| POST | `/api/quick-capture` | `memory.ts:614` | Home quick-capture → frame (+ awareness task row) |
### `packages/server/src/local/routes/memory-center.ts` (shared `Memory` shape)
| Method | Path | Line | Notes |
|---|---|---|---|
| GET | `/api/memory?mind&kind&status&scope&q&minConfidence&limit` | `memory-center.ts:167` | list as `Memory`; **status/confidence filters live here** |
| GET | `/api/memory/:id` | `memory-center.ts:215` | one memory, normalized |
| POST | `/api/memory` | `memory-center.ts:235` | curated create (sets `metadata.kind/scope/status='active'/confidence`) |
| PATCH | `/api/memory/:id` | `memory-center.ts:296` | **CORRECT** — content + reclassify; stamps `updatedAt` |
| POST | `/api/memory/:id/archive` | `memory-center.ts:355` | reversible Archive (`status='archived'`) — template for a `/confirm` route |
| DELETE | `/api/memory/:id` | `memory-center.ts:390` | **FORGET** — hard delete, mind-strict |
| POST | `/api/memory/merge` | `memory-center.ts:416` | merge ≥2 → concat + archive originals (C11) |
**Projection note (load-bearing for the warm-Hive provenance pill):** the legacy
`memory.ts` `normalizeFrame` DOES carry `source` provenance (`memory.ts:48-49`), and
`memory-center.ts` `normalizeToMemory` carries `source` + `sourceUrl`/`sourceId`/`confidence`
(`memory-center.ts:97-100`). **BUT** the Chat and Workspace *context* surfaces do not read
those routes — `workspace-context.ts:283-284` selects only `content, importance, created_at`
from `memory_frames`, omitting `source`. **This is the "`frame.source` 1-field server
projection" gap flagged in the S2 handoff** — adding `source` (and `created_at`, already there)
to that projection is the MUST-BUILD that unlocks the ⬡ provenance pill on Chat + Workspace.
---
## PR3.5 build guidance (do-not-fabricate checklist)
| UI affordance | Wire to | New work |
|---|---|---|
| ⬡ provenance pill (source) | `Memory.source` from `/api/memory` ✅; for Chat/Workspace context add `source` to `workspace-context.ts:283` SELECT | 1-field projection (MUST-BUILD, tiny) |
| Forget button | `DELETE /api/memory/:id` ✅ | none |
| Correct / edit | `PATCH /api/memory/:id` ✅ | none |
| "Stale · review?" | compute from `created_at` (already projected) via `computeTemporalScore` | FE compute / thin helper (DERIVABLE) |
| Confidence chip | `Memory.confidence` (only present on harvested frames) | show **only when present**; never default a number (PARTIAL) |
| Confirm / needs-confirm | `POST /api/memory/:id/confirm` (set `metadata.status`) + `GET /api/memory?status=unreviewed` | new route mirroring `/archive` (MUST-BUILD, cheap) |
| "Why did you do that?" | `GET /api/agents/:id/traces` (session-level) | per-frame "why" needs a `trace_id` backlink (MUST-BUILD); session/turn view is DERIVABLE |
**Hard rule:** confidence, freshness, and trace-reason are the three places PR3.5 could
silently fabricate. Confidence is sparse (harvest-only) → hide when absent. Freshness has no
stored decay → present as honest age, not a stored %. Per-frame "why" has no backlink → only
show a reason when a real linked trace exists.