Files
waggle-os/docs/redesign-warm-hive/pr35-recon/01-frame-source-server.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

12 KiB

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

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 seedingpackages/server/src/local/routes/workspaces.ts:290-291:

    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.tscreateIFrame / 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/contextpackages/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):

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:

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):

recentMemories = frames.map(f => ({
  content: f.content.slice(0, 200),
  importance: f.importance,
  date: f.created_at?.slice(0, 10) ?? 'unknown',
}));

AFTER:

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):

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:

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):

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:

  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:

recentDecisions?: Array<{ content: string; date: string }>;
recentMemories?: Array<{ content: string; importance: string; date: string }>;

AFTER:

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.tsxrecentMemories 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 → WorkspaceContextWorkspaceBriefing), 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 CHECKpackages/hive-mind-core/src/mind/frames.ts:25:

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 overloadedframe.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 neededsource already exists on every DB (NOT NULL DEFAULT). The projection is read-only over an existing column. Zero data-backfill risk.