873 lines
41 KiB
TypeScript
873 lines
41 KiB
TypeScript
import type { FastifyPluginAsync } from 'fastify';
|
|
import type { Importance, MemoryFrame } from '@waggle/core';
|
|
import {
|
|
evaluateExternalMemoryIngress,
|
|
FrameStore,
|
|
HarvestSourceStore,
|
|
MindErasure,
|
|
RawArchive,
|
|
readArchiveUids,
|
|
SessionStore,
|
|
SuppressionStore,
|
|
} from '@waggle/core';
|
|
import type { Memory, MemoryKind, MemoryStatus, Scope } from '@waggle/shared';
|
|
import { redactSkillContent } from '@waggle/agent';
|
|
import { emitAuditEvent } from './events.js';
|
|
import { sanitizeFrameContent } from './memory.js';
|
|
|
|
/**
|
|
* Redact secrets/home-paths and cap length before returning trace conversation
|
|
* content to the FE (review H-3: input/output/reasoning bypass the tool-arg
|
|
* scrubbing the trace recorder applies). Pure; safe on undefined.
|
|
*/
|
|
const TRACE_CONTENT_CAP = 2000;
|
|
function safeTraceText(s: string | undefined): string {
|
|
if (!s) return '';
|
|
return redactSkillContent(s).content.slice(0, TRACE_CONTENT_CAP);
|
|
}
|
|
|
|
/**
|
|
* UX-Refactor Phase 2B — Memory Center REST surface (S04, PRD §16.4).
|
|
*
|
|
* Distinct from `memory.ts` (the legacy frame ops: search/frames/stats and the
|
|
* `/api/memory/frames` write path). This plugin serves the NEW shared `Memory`
|
|
* entity contract (PRD §15.4) — bare `/api/memory[/:id]` routes that read/write
|
|
* the `memory_frames.metadata` JSON blob (kind/confidence/scope/status/tags/
|
|
* evidence/related*) added in Phase 2B.1, and normalise rows into the shared
|
|
* `Memory` shape. Split into its own file to keep `memory.ts` under the 800-line
|
|
* ceiling and to keep the entity contract cohesive.
|
|
*
|
|
* Gate ratifications honoured here: A8 (Archive = reversible status, Delete =
|
|
* hard delete), B2 (confidence read from metadata), B6 (`MemoryKind` canonical),
|
|
* C11 (merge = concatenate v1 + archive originals, not hard delete).
|
|
*/
|
|
|
|
const MEMORY_KINDS: readonly MemoryKind[] = [
|
|
'fact', 'decision', 'task', 'preference', 'strategy', 'learning', 'goal', 'entity',
|
|
];
|
|
const MEMORY_STATUSES: readonly MemoryStatus[] = [
|
|
'active', 'unreviewed', 'low_confidence', 'conflict', 'deprecated', 'archived',
|
|
];
|
|
const SCOPES: readonly Scope[] = ['personal', 'workspace', 'team', 'organization'];
|
|
const VALID_IMPORTANCE: readonly Importance[] = [
|
|
'critical', 'important', 'normal', 'temporary', 'deprecated',
|
|
];
|
|
|
|
// Defense-in-depth caps on free-form metadata (S04 review LOW). The 1 MiB Fastify
|
|
// body limit already bounds the request, but capping here keeps the metadata blob
|
|
// small after route-level runtime shape validation.
|
|
const MAX_TITLE_LEN = 500;
|
|
const MAX_TAGS = 30;
|
|
const MAX_TAG_LEN = 80;
|
|
const MAX_EVIDENCE_ITEMS = 20;
|
|
const MAX_EVIDENCE_ITEM_LEN = 2000;
|
|
const clampStr = (s: unknown, max: number): string => String(s ?? '').slice(0, max);
|
|
const clampStrArray = (a: unknown, maxItems: number, maxLen: number): string[] =>
|
|
Array.isArray(a) ? a.slice(0, maxItems).map((x) => clampStr(x, maxLen)) : [];
|
|
const isStringArray = (value: unknown): value is string[] =>
|
|
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
|
|
function isSafeMemoryIngress(
|
|
content: string,
|
|
title?: string,
|
|
tags?: string[],
|
|
evidence?: string[],
|
|
): boolean {
|
|
return evaluateExternalMemoryIngress({
|
|
title,
|
|
content: [content, ...(tags ?? []), ...(evidence ?? [])].join('\n'),
|
|
}).action === 'allow';
|
|
}
|
|
|
|
const asKind = (v: unknown): MemoryKind | undefined =>
|
|
MEMORY_KINDS.includes(v as MemoryKind) ? (v as MemoryKind) : undefined;
|
|
const asStatus = (v: unknown): MemoryStatus | undefined =>
|
|
MEMORY_STATUSES.includes(v as MemoryStatus) ? (v as MemoryStatus) : undefined;
|
|
const asScope = (v: unknown): Scope | undefined =>
|
|
SCOPES.includes(v as Scope) ? (v as Scope) : undefined;
|
|
const asImportance = (v: unknown): Importance | undefined =>
|
|
VALID_IMPORTANCE.includes(v as Importance) ? (v as Importance) : undefined;
|
|
|
|
/** Parse the `memory_frames.metadata` TEXT blob defensively. Never throws —
|
|
* malformed JSON or a non-object payload yields an empty object. */
|
|
function parseFrameMetadata(raw: unknown): Record<string, unknown> {
|
|
if (typeof raw !== 'string' || !raw) return {};
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Derive a one-line title from frame content when metadata has none. */
|
|
function deriveTitle(content: string): string {
|
|
const firstLine = content.split('\n').find((l) => l.trim().length > 0)?.trim() ?? content.trim();
|
|
return firstLine.length > 80 ? `${firstLine.slice(0, 79)}…` : firstLine;
|
|
}
|
|
|
|
const stringArray = (v: unknown): string[] | undefined =>
|
|
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : undefined;
|
|
|
|
/** Project a DB frame row + its metadata blob into the shared `Memory` shape.
|
|
* Exported so the federated artifact `search-related` route (2C) can reuse the
|
|
* single normalization path rather than fork a second projection. */
|
|
export function normalizeToMemory(frame: MemoryFrame, mind: string, workspaceId?: string): Memory {
|
|
const meta = parseFrameMetadata(frame.metadata);
|
|
const content = frame.content ?? '';
|
|
const importance: Memory['importance'] = frame.importance ?? 'normal';
|
|
const status: MemoryStatus =
|
|
asStatus(meta.status) ?? (
|
|
importance === 'deprecated'
|
|
? 'deprecated'
|
|
: importance === 'temporary' && frame.source === 'agent_inferred'
|
|
? 'unreviewed'
|
|
: 'active'
|
|
);
|
|
const scope: Scope = asScope(meta.scope) ?? (mind === 'workspace' ? 'workspace' : 'personal');
|
|
const title =
|
|
typeof meta.title === 'string' && meta.title.trim() ? meta.title : deriveTitle(content);
|
|
|
|
return {
|
|
id: String(frame.id),
|
|
kind: asKind(meta.kind) ?? 'fact',
|
|
title,
|
|
content,
|
|
scope,
|
|
workspaceId,
|
|
source: frame.source ?? 'user_stated',
|
|
sourceId: typeof meta.sourceId === 'string' ? meta.sourceId : null,
|
|
sourceUrl: typeof meta.sourceUrl === 'string' ? meta.sourceUrl : null,
|
|
// #7: "View original source" is offered only when a raw_archive row is actually
|
|
// linked — the SAME predicate reconstructSource uses. A sourceId-only frame
|
|
// (auto-synced summary) exposes its id but not a dead View-original button.
|
|
hasOriginalSource: readArchiveUids(meta).length > 0,
|
|
confidence: typeof meta.confidence === 'number' ? meta.confidence : undefined,
|
|
importance,
|
|
evidence: stringArray(meta.evidence),
|
|
tags: stringArray(meta.tags),
|
|
relatedMemoryIds: Array.isArray(meta.relatedMemoryIds)
|
|
? meta.relatedMemoryIds.map(String)
|
|
: undefined,
|
|
relatedArtifactIds: Array.isArray(meta.relatedArtifactIds)
|
|
? meta.relatedArtifactIds.map(String)
|
|
: undefined,
|
|
status,
|
|
createdAt: frame.created_at ?? new Date().toISOString(),
|
|
updatedAt: typeof meta.updatedAt === 'string' ? meta.updatedAt : undefined,
|
|
lastAccessedAt: frame.last_accessed,
|
|
};
|
|
}
|
|
|
|
export const memoryCenterRoutes: FastifyPluginAsync = async (server) => {
|
|
/** FrameStores to consult for a given workspace, workspace-first then personal
|
|
* — mirrors the resolution pattern in `memory.ts`.
|
|
*
|
|
* P3/D2 mind-strictness: frame ids COLLIDE across the per-mind SQLite DBs
|
|
* (separate autoincrements), so the legacy fall-through is a destructive
|
|
* cross-mind hazard for mutations — a stale workspace row whose id has since
|
|
* vanished from the workspace store would resolve to (and hard-delete/patch)
|
|
* an unrelated PERSONAL frame. When the caller declares `mind`, resolution is
|
|
* strict: exactly that store, no fallback. Omitting mind keeps the legacy
|
|
* ordered fall-through for back-compat. */
|
|
function candidateStores(
|
|
workspace?: string,
|
|
mind?: 'personal' | 'workspace',
|
|
): Array<{ store: FrameStore; mind: string }> {
|
|
const stores: Array<{ store: FrameStore; mind: string }> = [];
|
|
if (workspace && mind !== 'personal') {
|
|
const wsDb = server.agentState.getWorkspaceMindDb(workspace);
|
|
if (wsDb) stores.push({ store: new FrameStore(wsDb), mind: 'workspace' });
|
|
}
|
|
if (mind !== 'workspace') {
|
|
stores.push({ store: new FrameStore(server.multiMind.personal), mind: 'personal' });
|
|
}
|
|
return stores;
|
|
}
|
|
|
|
/** Validate an optional `mind` value shared by the routes below. Returns the
|
|
* narrowed value, or an Error message when invalid (caller 400s). A typo must
|
|
* fail loudly, not silently widen back to the fall-through. */
|
|
function parseMind(
|
|
mind: string | undefined,
|
|
workspace: string | undefined,
|
|
): { ok: true; mind?: 'personal' | 'workspace' } | { ok: false; error: string } {
|
|
if (mind === undefined) return { ok: true };
|
|
if (mind !== 'personal' && mind !== 'workspace') {
|
|
return { ok: false, error: "mind must be 'personal' or 'workspace'" };
|
|
}
|
|
if (mind === 'workspace' && !workspace) {
|
|
return { ok: false, error: 'mind=workspace requires a workspace parameter' };
|
|
}
|
|
return { ok: true, mind };
|
|
}
|
|
|
|
// GET /api/memory — list memories as the shared Memory shape, with optional
|
|
// in-memory filters (PRD §12.4: filter by kind/status/scope/confidence/text).
|
|
// P3/D2: `mind=personal|workspace` selects a single store (the two-mind split
|
|
// reads exactly one mind per view); omitting it keeps the legacy merge, where
|
|
// `workspaceId` presence on a result is the mind discriminator. The merge view
|
|
// can surface colliding frame ids across minds (separate SQLite autoincrements)
|
|
// — single-mind reads are how the new UI avoids that ambiguity.
|
|
server.get<{
|
|
Querystring: {
|
|
workspace?: string; workspaceId?: string; limit?: string; mind?: string;
|
|
kind?: string; status?: string; scope?: string; q?: string; minConfidence?: string;
|
|
};
|
|
}>('/api/memory', async (request, reply) => {
|
|
const { workspace: ws, workspaceId: wsId, limit, kind, status, scope, q, minConfidence } = request.query;
|
|
const workspace = ws ?? wsId;
|
|
const max = limit ? parseInt(limit, 10) : 100;
|
|
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
const mind = parsed.mind;
|
|
|
|
const out: Memory[] = [];
|
|
if (mind !== 'workspace') {
|
|
for (const f of new FrameStore(server.multiMind.personal).getRecent(max)) {
|
|
out.push(normalizeToMemory(f, 'personal'));
|
|
}
|
|
}
|
|
if (workspace && mind !== 'personal') {
|
|
const wsDb = server.agentState.getWorkspaceMindDb(workspace);
|
|
if (wsDb) {
|
|
for (const f of new FrameStore(wsDb).getRecent(max)) {
|
|
out.push(normalizeToMemory(f, 'workspace', workspace));
|
|
}
|
|
}
|
|
}
|
|
|
|
let results = out;
|
|
if (kind) results = results.filter((m) => m.kind === kind);
|
|
if (status) results = results.filter((m) => m.status === status);
|
|
if (scope) results = results.filter((m) => m.scope === scope);
|
|
if (q) {
|
|
const ql = q.toLowerCase();
|
|
results = results.filter(
|
|
(m) => m.content.toLowerCase().includes(ql) || m.title.toLowerCase().includes(ql),
|
|
);
|
|
}
|
|
if (minConfidence) {
|
|
const mc = parseInt(minConfidence, 10);
|
|
if (!isNaN(mc)) results = results.filter((m) => (m.confidence ?? 0) >= mc);
|
|
}
|
|
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
return { results: results.slice(0, max), count: Math.min(results.length, max) };
|
|
});
|
|
|
|
// GET /api/memory/:id — one memory in the shared shape.
|
|
server.get<{
|
|
Params: { id: string };
|
|
Querystring: { workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/:id', async (request, reply) => {
|
|
const frameId = parseInt(request.params.id, 10);
|
|
if (isNaN(frameId)) return reply.status(400).send({ error: 'Invalid memory id' });
|
|
const workspace = request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
const frame = c.store.getById(frameId);
|
|
if (frame) {
|
|
return normalizeToMemory(frame, c.mind, c.mind === 'workspace' ? workspace : undefined);
|
|
}
|
|
}
|
|
return reply.status(404).send({ error: 'Memory not found' });
|
|
});
|
|
|
|
// POST /api/memory — create a single curated memory (content + kind/scope/tags).
|
|
// Bulk/pipeline writes + entity extraction stay on POST /api/memory/frames.
|
|
server.post<{
|
|
Body: {
|
|
content?: string; title?: string; kind?: string; scope?: string;
|
|
tags?: string[]; importance?: string; confidence?: number;
|
|
workspace?: string; workspaceId?: string;
|
|
};
|
|
}>('/api/memory', async (request, reply) => {
|
|
const b = request.body ?? {};
|
|
if (typeof b.content !== 'string' || !b.content.trim()) {
|
|
return reply.status(400).send({ error: 'content is required' });
|
|
}
|
|
if (b.tags !== undefined && !isStringArray(b.tags)) {
|
|
return reply.status(400).send({ error: 'tags must be an array of strings' });
|
|
}
|
|
const content = sanitizeFrameContent(b.content.trim());
|
|
const title = b.title ? clampStr(b.title, MAX_TITLE_LEN) : undefined;
|
|
const tags = b.tags !== undefined ? clampStrArray(b.tags, MAX_TAGS, MAX_TAG_LEN) : undefined;
|
|
if (!isSafeMemoryIngress(content, title, tags)) {
|
|
return reply.status(400).send({ error: 'Memory content could not be saved.' });
|
|
}
|
|
const workspace = b.workspace ?? b.workspaceId;
|
|
|
|
let targetDb = workspace ? server.agentState.getWorkspaceMindDb(workspace) : undefined;
|
|
let mind = targetDb ? 'workspace' : 'personal';
|
|
if (!targetDb) {
|
|
targetDb = server.multiMind.personal;
|
|
mind = 'personal';
|
|
}
|
|
|
|
const frames = new FrameStore(targetDb);
|
|
const sessions = new SessionStore(targetDb);
|
|
const active = sessions.getActive();
|
|
const gopId = active.length > 0 ? active[0].gop_id : sessions.create().gop_id;
|
|
const imp = asImportance(b.importance) ?? 'normal';
|
|
|
|
const dup = frames.findDuplicate(content);
|
|
const latestI = frames.getLatestIFrame(gopId);
|
|
const frame =
|
|
dup ??
|
|
(latestI
|
|
? frames.createPFrame(gopId, content, latestI.id, imp, 'user_stated')
|
|
: frames.createIFrame(gopId, content, imp, 'user_stated'));
|
|
|
|
const meta: Record<string, unknown> = {
|
|
kind: asKind(b.kind) ?? 'fact',
|
|
scope: asScope(b.scope) ?? (mind === 'workspace' ? 'workspace' : 'personal'),
|
|
status: 'active' satisfies MemoryStatus,
|
|
...(title ? { title } : {}),
|
|
...(tags ? { tags } : {}),
|
|
...(typeof b.confidence === 'number' ? { confidence: b.confidence } : {}),
|
|
};
|
|
frames.setMetadata(frame.id, JSON.stringify(meta));
|
|
|
|
emitAuditEvent(server, {
|
|
// Attribute to the RESOLVED mind — an unknown workspace falls back to the
|
|
// personal store above, and the audit row must say so (review finding).
|
|
workspaceId: mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'memory_write',
|
|
input: JSON.stringify({ content: content.slice(0, 500), kind: meta.kind, source: 'memory-create' }),
|
|
output: JSON.stringify({ frameId: frame.id, mind }),
|
|
});
|
|
|
|
const saved = frames.getById(frame.id);
|
|
return saved
|
|
? normalizeToMemory(saved, mind, mind === 'workspace' ? workspace : undefined)
|
|
: reply.status(500).send({ error: 'Failed to read back created memory' });
|
|
});
|
|
|
|
// PATCH /api/memory/:id — edit content/importance and/or metadata classification.
|
|
server.patch<{
|
|
Params: { id: string };
|
|
Body: {
|
|
content?: string; importance?: string; kind?: string; scope?: string;
|
|
tags?: string[]; status?: string; title?: string; evidence?: string[];
|
|
};
|
|
Querystring: { workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/:id', async (request, reply) => {
|
|
const frameId = parseInt(request.params.id, 10);
|
|
if (isNaN(frameId)) return reply.status(400).send({ error: 'Invalid memory id' });
|
|
const b = request.body ?? {};
|
|
if (b.content !== undefined && typeof b.content !== 'string') {
|
|
return reply.status(400).send({ error: 'content must be a string' });
|
|
}
|
|
if (b.tags !== undefined && !isStringArray(b.tags)) {
|
|
return reply.status(400).send({ error: 'tags must be an array of strings' });
|
|
}
|
|
if (b.evidence !== undefined && !isStringArray(b.evidence)) {
|
|
return reply.status(400).send({ error: 'evidence must be an array of strings' });
|
|
}
|
|
if (b.importance !== undefined && !asImportance(b.importance)) {
|
|
return reply.status(400).send({ error: `Invalid importance "${b.importance}"` });
|
|
}
|
|
if (b.status !== undefined && !asStatus(b.status)) {
|
|
return reply.status(400).send({ error: `Invalid status "${b.status}"` });
|
|
}
|
|
if (b.kind !== undefined && !asKind(b.kind)) {
|
|
return reply.status(400).send({ error: `Invalid kind "${b.kind}"` });
|
|
}
|
|
const workspace = request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
const existing = c.store.getById(frameId);
|
|
if (!existing) continue;
|
|
|
|
const merged = parseFrameMetadata(existing.metadata);
|
|
const content = b.content !== undefined ? sanitizeFrameContent(b.content) : existing.content;
|
|
const title = b.title !== undefined
|
|
? clampStr(b.title, MAX_TITLE_LEN)
|
|
: typeof merged.title === 'string' ? merged.title : undefined;
|
|
const tags = b.tags !== undefined
|
|
? clampStrArray(b.tags, MAX_TAGS, MAX_TAG_LEN)
|
|
: stringArray(merged.tags);
|
|
const evidence = b.evidence !== undefined
|
|
? clampStrArray(b.evidence, MAX_EVIDENCE_ITEMS, MAX_EVIDENCE_ITEM_LEN)
|
|
: stringArray(merged.evidence);
|
|
if ((b.content !== undefined || b.importance !== undefined || b.title !== undefined
|
|
|| b.tags !== undefined || b.evidence !== undefined)
|
|
&& !isSafeMemoryIngress(content, title, tags, evidence)) {
|
|
return reply.status(400).send({ error: 'Memory content could not be saved.' });
|
|
}
|
|
|
|
if (b.content !== undefined || b.importance !== undefined) {
|
|
c.store.update(frameId, content, asImportance(b.importance));
|
|
}
|
|
|
|
if (b.kind !== undefined) merged.kind = b.kind;
|
|
if (b.scope !== undefined) merged.scope = b.scope;
|
|
if (b.tags !== undefined) merged.tags = tags;
|
|
if (b.status !== undefined) merged.status = b.status;
|
|
if (b.title !== undefined) merged.title = title;
|
|
if (b.evidence !== undefined) merged.evidence = evidence;
|
|
merged.updatedAt = new Date().toISOString();
|
|
c.store.setMetadata(frameId, JSON.stringify(merged));
|
|
|
|
emitAuditEvent(server, {
|
|
workspaceId: c.mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'memory_write',
|
|
input: JSON.stringify({ frameId, action: 'patch' }),
|
|
output: JSON.stringify({ frameId, mind: c.mind }),
|
|
});
|
|
|
|
const updated = c.store.getById(frameId);
|
|
return updated
|
|
? normalizeToMemory(updated, c.mind, c.mind === 'workspace' ? workspace : undefined)
|
|
: reply.status(500).send({ error: 'Failed to read back memory' });
|
|
}
|
|
return reply.status(404).send({ error: 'Memory not found' });
|
|
});
|
|
|
|
// POST /api/memory/:id/archive — reversible Archive (A8). Sets status=archived.
|
|
server.post<{
|
|
Params: { id: string };
|
|
Querystring: { workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/:id/archive', async (request, reply) => {
|
|
const frameId = parseInt(request.params.id, 10);
|
|
if (isNaN(frameId)) return reply.status(400).send({ error: 'Invalid memory id' });
|
|
const workspace = request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
const existing = c.store.getById(frameId);
|
|
if (!existing) continue;
|
|
const merged = parseFrameMetadata(existing.metadata);
|
|
merged.status = 'archived' satisfies MemoryStatus;
|
|
merged.updatedAt = new Date().toISOString();
|
|
c.store.setMetadata(frameId, JSON.stringify(merged));
|
|
|
|
emitAuditEvent(server, {
|
|
workspaceId: c.mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'memory_write',
|
|
input: JSON.stringify({ frameId, action: 'archive' }),
|
|
output: JSON.stringify({ frameId, mind: c.mind, status: 'archived' }),
|
|
});
|
|
|
|
const updated = c.store.getById(frameId);
|
|
return updated
|
|
? normalizeToMemory(updated, c.mind, c.mind === 'workspace' ? workspace : undefined)
|
|
: reply.status(500).send({ error: 'Failed to read back memory' });
|
|
}
|
|
return reply.status(404).send({ error: 'Memory not found' });
|
|
});
|
|
|
|
// POST /api/memory/:id/confirm — PR3.5 Memory-Trust: mark a memory reviewed.
|
|
// Clears the 'unreviewed' lifecycle state (set by harvest import) by moving
|
|
// status → 'active'. Mirrors /archive; the "Awaiting your confirm" queue is
|
|
// GET /api/memory?status=unreviewed.
|
|
server.post<{
|
|
Params: { id: string };
|
|
Querystring: { workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/:id/confirm', async (request, reply) => {
|
|
const frameId = parseInt(request.params.id, 10);
|
|
if (isNaN(frameId)) return reply.status(400).send({ error: 'Invalid memory id' });
|
|
const workspace = request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
const existing = c.store.getById(frameId);
|
|
if (!existing) continue;
|
|
const merged = parseFrameMetadata(existing.metadata);
|
|
merged.status = 'active' satisfies MemoryStatus;
|
|
merged.confirmedAt = new Date().toISOString();
|
|
merged.updatedAt = new Date().toISOString();
|
|
c.store.setMetadata(frameId, JSON.stringify(merged));
|
|
|
|
emitAuditEvent(server, {
|
|
workspaceId: c.mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'memory_write',
|
|
input: JSON.stringify({ frameId, action: 'confirm' }),
|
|
output: JSON.stringify({ frameId, mind: c.mind, status: 'active' }),
|
|
});
|
|
|
|
const updated = c.store.getById(frameId);
|
|
return updated
|
|
? normalizeToMemory(updated, c.mind, c.mind === 'workspace' ? workspace : undefined)
|
|
: reply.status(500).send({ error: 'Failed to read back memory' });
|
|
}
|
|
return reply.status(404).send({ error: 'Memory not found' });
|
|
});
|
|
|
|
// GET /api/memory/:id/trace — PR3.5 "Why did you do that?": resolve the
|
|
// execution trace that wrote this memory, via the metadata.trace_id backlink
|
|
// stamped at chat write-back time. Returns { trace: null } honestly when the
|
|
// frame has no linked trace (manual / harvested / pre-PR3.5 frames) — never a
|
|
// synthesized reason. Traces live in the single global server.traceStore
|
|
// (tagged by workspace_id); a workspace-mind frame is bound to its own
|
|
// workspace's traces below (mind-isolation), so no cross-workspace read.
|
|
server.get<{
|
|
Params: { id: string };
|
|
Querystring: { workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/:id/trace', async (request, reply) => {
|
|
const frameId = parseInt(request.params.id, 10);
|
|
if (isNaN(frameId)) return reply.status(400).send({ error: 'Invalid memory id' });
|
|
const workspace = request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
const existing = c.store.getById(frameId);
|
|
if (!existing) continue;
|
|
const meta = parseFrameMetadata(existing.metadata);
|
|
const traceId = typeof meta.trace_id === 'string' ? parseInt(meta.trace_id, 10) : NaN;
|
|
if (!server.traceStore || isNaN(traceId)) {
|
|
return { trace: null };
|
|
}
|
|
const t = server.traceStore.getParsed(traceId);
|
|
if (!t) return { trace: null };
|
|
// Review H-2 (mind isolation): a workspace-mind frame must only resolve a
|
|
// trace tagged for THAT workspace — the global traceStore holds every
|
|
// mind's traces, so without this a stale/colliding trace_id could surface
|
|
// another workspace's conversation. Personal-mind traces carry whatever
|
|
// workspace was active when written, so they aren't constrained here.
|
|
if (c.mind === 'workspace' && t.workspace_id !== workspace) {
|
|
return { trace: null };
|
|
}
|
|
return {
|
|
trace: {
|
|
id: t.id,
|
|
sessionId: t.session_id,
|
|
workspaceId: t.workspace_id,
|
|
model: t.model,
|
|
outcome: t.outcome,
|
|
costUsd: t.cost_usd,
|
|
durationMs: t.duration_ms,
|
|
createdAt: t.created_at,
|
|
finalizedAt: t.finalized_at,
|
|
// Review H-3: redact secrets/home-paths + cap — the trace recorder only
|
|
// scrubs tool-call args, leaving input/output/reasoning raw.
|
|
input: safeTraceText(t.payload.input),
|
|
output: safeTraceText(t.payload.output),
|
|
reasoning: t.payload.reasoning.map((r) => ({
|
|
content: safeTraceText(r.content),
|
|
timestamp: r.timestamp,
|
|
})),
|
|
toolCalls: t.payload.toolCalls.map((call) => ({
|
|
tool: call.tool,
|
|
ok: call.ok,
|
|
durationMs: call.durationMs,
|
|
timestamp: call.timestamp,
|
|
})),
|
|
tokens: t.payload.tokens,
|
|
},
|
|
};
|
|
}
|
|
return reply.status(404).send({ error: 'Memory not found' });
|
|
});
|
|
|
|
// GET /api/memory/:id/source — #7 Verbatim Provenance: resolve the immutable
|
|
// raw_archive row that holds the FULL verbatim source of a harvested/distilled
|
|
// frame, via the metadata.archiveUid backlink (RawArchive.reconstructSource).
|
|
// 404 when the frame has no archive link (manual / user-stated / pre-archive
|
|
// frames) — an honest "no original source", not a synthesized one. Mirrors the
|
|
// /trace handler's param-parse + mind-strict candidateStores resolution; the
|
|
// per-candidate MindDB is resolved the same way the /merge route resolves its
|
|
// target (FrameStore exposes no public db getter).
|
|
server.get<{
|
|
Params: { id: string };
|
|
Querystring: { workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/:id/source', async (request, reply) => {
|
|
const frameId = parseInt(request.params.id, 10);
|
|
if (isNaN(frameId)) return reply.status(400).send({ error: 'Invalid memory id' });
|
|
const workspace = request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
const db =
|
|
c.mind === 'workspace' && workspace
|
|
? server.agentState.getWorkspaceMindDb(workspace)
|
|
: server.multiMind.personal;
|
|
if (!db) continue;
|
|
const archive = new RawArchive(db);
|
|
const rows = archive.reconstructSource(frameId);
|
|
if (rows.length > 0) {
|
|
const archiveRows = rows.map((row) => ({
|
|
content: row.content,
|
|
source: row.source,
|
|
sourceRef: row.source_ref,
|
|
injectionFlagged: row.injection_flagged === 1,
|
|
injectionFlags: row.injection_flags,
|
|
}));
|
|
return reply.send({ archiveRows, archiveRow: archiveRows[0] ?? null });
|
|
}
|
|
}
|
|
return reply.status(404).send({ error: 'Memory source not found' });
|
|
});
|
|
|
|
// DELETE /api/memory/:id — hard delete (A8 — no tombstone). Alias of the
|
|
// legacy DELETE /api/memory/frames/:id, on the bare-id contract.
|
|
server.delete<{
|
|
Params: { id: string };
|
|
Querystring: { workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/:id', async (request, reply) => {
|
|
const frameId = parseInt(request.params.id, 10);
|
|
if (isNaN(frameId)) return reply.status(400).send({ error: 'Invalid memory id' });
|
|
const workspace = request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
if (c.store.delete(frameId)) {
|
|
emitAuditEvent(server, {
|
|
workspaceId: c.mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'memory_delete',
|
|
input: JSON.stringify({ frameId, mind: c.mind }),
|
|
});
|
|
return reply.status(200).send({ deleted: true, id: String(frameId) });
|
|
}
|
|
}
|
|
return reply.status(404).send({ error: 'Memory not found' });
|
|
});
|
|
|
|
// POST /api/memory/erase — #7 P1 GDPR Art.17 data-subject erasure. UNLIKE the
|
|
// A8 DELETE /api/memory/:id (a frame-only UI convenience), this runs the FULL
|
|
// Art.17 sweep via MindErasure: raw_archive provenance redaction + frame delete
|
|
// from every retrieval store (FTS/vec/chunk/chunk-vec) + orphaned-KG hard-delete
|
|
// +, in subject mode, the verbatim raw-turn + referencing B-frame reach a
|
|
// single-frame primitive cannot cover. Two mutually-exclusive request modes:
|
|
// • frame — { frameId } → MindErasure.eraseFrame (one distilled frame)
|
|
// • subject — { source, sourceRef } → eraseBySourceRef (full subject sweep;
|
|
// the correct mode for "forget everything from this source")
|
|
// Per-mind MindDB resolution mirrors the /source route (frame ids collide
|
|
// across the per-mind SQLite DBs → mind-strict when declared). Emits the
|
|
// reserved `data_erase_requested` audit event with the erasure breakdown.
|
|
server.post<{
|
|
Body: {
|
|
frameId?: number | string; source?: string; sourceRef?: string;
|
|
reason?: string; workspace?: string; workspaceId?: string; mind?: string;
|
|
};
|
|
Querystring: { workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/erase', async (request, reply) => {
|
|
const b = request.body ?? {};
|
|
const hasFrame = b.frameId !== undefined && b.frameId !== null && b.frameId !== '';
|
|
const hasSubject = b.source !== undefined || b.sourceRef !== undefined;
|
|
if (!hasFrame && !hasSubject) {
|
|
return reply.status(400).send({ error: 'provide either frameId or {source, sourceRef}' });
|
|
}
|
|
if (hasFrame && hasSubject) {
|
|
return reply.status(400).send({ error: 'provide frameId OR {source, sourceRef}, not both' });
|
|
}
|
|
// workspace/mind accepted in body OR query — the GET siblings (/source,
|
|
// /trace) key off query, the POST siblings (/, /merge) off the body; erase
|
|
// can be reached either way, so read body-first then fall back to query.
|
|
const workspace = b.workspace ?? b.workspaceId ?? request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(b.mind ?? request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
const reason = clampStr(b.reason ?? 'gdpr_art17_erasure', 200);
|
|
|
|
// Resolve a MindDB the same way /source does (FrameStore exposes no public db
|
|
// getter): the workspace mind when declared+present, else the personal mind.
|
|
const mindDbFor = (mind: string) =>
|
|
mind === 'workspace' && workspace
|
|
? server.agentState.getWorkspaceMindDb(workspace)
|
|
: server.multiMind.personal;
|
|
|
|
if (hasFrame) {
|
|
const frameId = parseInt(String(b.frameId), 10);
|
|
if (isNaN(frameId)) return reply.status(400).send({ error: 'Invalid memory id' });
|
|
// Find the mind that actually holds the frame (mind-strict when declared)
|
|
// before erasing — an unresolved id must 404, never silently no-op.
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
if (!c.store.getById(frameId)) continue;
|
|
const db = mindDbFor(c.mind);
|
|
if (!db) continue;
|
|
// Art.17-COMPLETE erase of the memory the user selected — the FULL
|
|
// subject footprint (verbatim raw-turns + B-frames + orphaned KG that a
|
|
// single-frame erase would leave recall-able), atomic. eraseFrameComplete
|
|
// is the shared primitive the erase_memory MCP tool also calls, so the two
|
|
// entry points cannot drift.
|
|
const result = new MindErasure(db).eraseFrameComplete(frameId, reason);
|
|
emitAuditEvent(server, {
|
|
workspaceId: c.mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'data_erase_requested',
|
|
input: JSON.stringify({ mode: 'frame', frameId, reason, mind: c.mind }),
|
|
output: JSON.stringify(result),
|
|
});
|
|
return reply.send({ erased: true, mind: c.mind, result });
|
|
}
|
|
return reply.status(404).send({ error: 'Memory not found' });
|
|
}
|
|
|
|
// Subject mode. A harvested subject lives in ONE mind (harvest writes to the
|
|
// personal mind by default; target a workspace subject explicitly via
|
|
// mind=workspace). eraseBySourceRef returns an all-zero breakdown for an
|
|
// unknown subject → idempotent 200 (a valid Art.17 outcome), never a 404.
|
|
// Require string types, not just truthiness: a JSON body like
|
|
// {source:{},sourceRef:{}} passes a bare truthiness check, then throws deep
|
|
// in better-sqlite3's bind (non-string) → an uncaught 500 leaking the raw DB
|
|
// error. A boundary type-check turns it into a clean 400.
|
|
if (typeof b.source !== 'string' || typeof b.sourceRef !== 'string' || !b.source || !b.sourceRef) {
|
|
return reply.status(400).send({ error: 'subject erasure requires both source and sourceRef' });
|
|
}
|
|
const mind = parsed.mind === 'workspace' ? 'workspace' : 'personal';
|
|
const db = mindDbFor(mind);
|
|
if (!db) return reply.status(500).send({ error: 'Target mind unavailable' });
|
|
const result = new MindErasure(db).eraseBySourceRef(b.source, b.sourceRef, reason);
|
|
emitAuditEvent(server, {
|
|
workspaceId: mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'data_erase_requested',
|
|
input: JSON.stringify({ mode: 'subject', source: b.source, sourceRef: b.sourceRef, reason, mind }),
|
|
output: JSON.stringify(result),
|
|
});
|
|
return reply.send({ erased: true, mind, result });
|
|
});
|
|
|
|
// GET /api/memory/suppression — the #7 "sticky erasure" re-consent surface. Lists
|
|
// the (source, source_ref) subjects an Art.17 erasure recorded on the suppression
|
|
// list; any re-import of these is skipped. Per-mind (mirrors /erase resolution).
|
|
server.get<{ Querystring: { workspace?: string; workspaceId?: string; mind?: string } }>(
|
|
'/api/memory/suppression', async (request, reply) => {
|
|
const workspace = request.query.workspace ?? request.query.workspaceId;
|
|
const parsed = parseMind(request.query.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
const mind = parsed.mind === 'workspace' ? 'workspace' : 'personal';
|
|
const db = mind === 'workspace' && workspace
|
|
? server.agentState.getWorkspaceMindDb(workspace)
|
|
: server.multiMind.personal;
|
|
if (!db) return reply.status(500).send({ error: 'Target mind unavailable' });
|
|
return reply.send({ mind, suppressed: new SuppressionStore(db).list() });
|
|
});
|
|
|
|
// POST /api/memory/suppression/allow — re-consent: lift the suppression on a
|
|
// subject so it may be re-imported again (#7 sticky erasure). Idempotent — lifting
|
|
// a subject that isn't suppressed returns removed:false, still 200. NOT audited as
|
|
// data_erase_requested (that would mislabel a re-consent as an erasure); the row
|
|
// deletion is the state change and the original erase was already audited.
|
|
server.post<{
|
|
Body: { source?: string; sourceRef?: string; workspace?: string; workspaceId?: string; mind?: string };
|
|
}>('/api/memory/suppression/allow', async (request, reply) => {
|
|
const b = request.body ?? {};
|
|
if (typeof b.source !== 'string' || typeof b.sourceRef !== 'string' || !b.source || !b.sourceRef) {
|
|
return reply.status(400).send({ error: 'allow requires both source and sourceRef' });
|
|
}
|
|
const workspace = b.workspace ?? b.workspaceId;
|
|
const parsed = parseMind(b.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
const mind = parsed.mind === 'workspace' ? 'workspace' : 'personal';
|
|
const db = mind === 'workspace' && workspace
|
|
? server.agentState.getWorkspaceMindDb(workspace)
|
|
: server.multiMind.personal;
|
|
if (!db) return reply.status(500).send({ error: 'Target mind unavailable' });
|
|
const removed = new SuppressionStore(db).unsuppress(b.source, b.sourceRef);
|
|
// Re-consent must let an IDENTICAL re-import re-materialize the subject. The
|
|
// R3-004 set-hash skip would otherwise short-circuit an unchanged re-import
|
|
// before the per-item loop re-adds it, so clear the source's skip hash.
|
|
if (removed) new HarvestSourceStore(db).clearContentHash(b.source as Parameters<HarvestSourceStore['clearContentHash']>[0]);
|
|
// Audit the reversal of an exercised right-to-erasure (distinct from the erase
|
|
// event, so the timeline shows WHO lifted suppression and WHEN — GDPR symmetry).
|
|
if (removed) {
|
|
emitAuditEvent(server, {
|
|
workspaceId: mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'data_reimport_reconsented',
|
|
input: JSON.stringify({ source: b.source, sourceRef: b.sourceRef, mind }),
|
|
output: JSON.stringify({ removed }),
|
|
});
|
|
}
|
|
return reply.send({ removed, mind });
|
|
});
|
|
|
|
// POST /api/memory/merge — merge >= 2 memories into one (C11: concatenate v1,
|
|
// archive originals rather than hard-delete; LLM-synthesis deferred). All ids
|
|
// must resolve within a single mind.
|
|
server.post<{
|
|
Body: { ids?: Array<string | number>; workspace?: string; workspaceId?: string; title?: string; mind?: string };
|
|
}>('/api/memory/merge', async (request, reply) => {
|
|
const b = request.body ?? {};
|
|
if (!Array.isArray(b.ids)) {
|
|
return reply.status(400).send({ error: 'merge requires at least 2 memory ids' });
|
|
}
|
|
const ids = b.ids.map((x) => parseInt(String(x), 10)).filter((n) => !isNaN(n));
|
|
if (ids.length < 2) {
|
|
return reply.status(400).send({ error: 'merge requires at least 2 memory ids' });
|
|
}
|
|
const workspace = b.workspace ?? b.workspaceId;
|
|
const parsed = parseMind(b.mind, workspace);
|
|
if (!parsed.ok) return reply.status(400).send({ error: parsed.error });
|
|
|
|
// Find the store that holds ALL ids (single-mind merge for v1). Mind-strict
|
|
// when declared: stale workspace ids must 404, never resolve as a complete
|
|
// set in the PERSONAL store and merge+archive unrelated personal frames.
|
|
let chosen: { store: FrameStore; mind: string } | undefined;
|
|
let frames: MemoryFrame[] = [];
|
|
for (const c of candidateStores(workspace, parsed.mind)) {
|
|
const found = ids.map((id) => c.store.getById(id)).filter((f): f is MemoryFrame => !!f);
|
|
if (found.length === ids.length) {
|
|
chosen = c;
|
|
frames = found;
|
|
break;
|
|
}
|
|
}
|
|
if (!chosen) {
|
|
return reply.status(404).send({ error: 'All memory ids must exist within a single mind to merge' });
|
|
}
|
|
|
|
const mergedContent = sanitizeFrameContent(frames.map((f) => f.content).join('\n\n---\n\n'));
|
|
const mergedTitle = b.title ? clampStr(b.title, MAX_TITLE_LEN) : undefined;
|
|
if (!isSafeMemoryIngress(mergedContent, mergedTitle)) {
|
|
return reply.status(400).send({ error: 'Memory content could not be saved.' });
|
|
}
|
|
const targetDb =
|
|
chosen.mind === 'workspace' && workspace
|
|
? server.agentState.getWorkspaceMindDb(workspace)
|
|
: server.multiMind.personal;
|
|
if (!targetDb) return reply.status(500).send({ error: 'Target mind unavailable' });
|
|
|
|
const sessions = new SessionStore(targetDb);
|
|
const active = sessions.getActive();
|
|
const gopId = active.length > 0 ? active[0].gop_id : sessions.create().gop_id;
|
|
|
|
// Highest importance among sources wins; the merged record is agent_inferred.
|
|
const order: Importance[] = ['deprecated', 'temporary', 'normal', 'important', 'critical'];
|
|
const importance = frames
|
|
.map((f) => f.importance ?? 'normal')
|
|
.reduce((a, c) => (order.indexOf(c) > order.indexOf(a) ? c : a), 'normal' as Importance);
|
|
|
|
const newFrame = chosen.store.createIFrame(gopId, mergedContent, importance, 'agent_inferred');
|
|
|
|
// Carry kind/scope from the first source; relate back to the originals.
|
|
const firstMeta = parseFrameMetadata(frames[0].metadata);
|
|
const mergedMeta: Record<string, unknown> = {
|
|
kind: asKind(firstMeta.kind) ?? 'fact',
|
|
scope: asScope(firstMeta.scope) ?? (chosen.mind === 'workspace' ? 'workspace' : 'personal'),
|
|
status: 'active' satisfies MemoryStatus,
|
|
relatedMemoryIds: ids.map(String),
|
|
...(mergedTitle ? { title: mergedTitle } : {}),
|
|
};
|
|
chosen.store.setMetadata(newFrame.id, JSON.stringify(mergedMeta));
|
|
|
|
// Archive (not delete) the originals — reversible per C11/A8.
|
|
for (const f of frames) {
|
|
const m = parseFrameMetadata(f.metadata);
|
|
m.status = 'archived' satisfies MemoryStatus;
|
|
m.updatedAt = new Date().toISOString();
|
|
m.mergedIntoId = String(newFrame.id);
|
|
chosen.store.setMetadata(f.id, JSON.stringify(m));
|
|
}
|
|
|
|
emitAuditEvent(server, {
|
|
workspaceId: chosen.mind === 'workspace' && workspace ? workspace : 'personal',
|
|
eventType: 'memory_write',
|
|
input: JSON.stringify({ action: 'merge', ids, mind: chosen.mind }),
|
|
output: JSON.stringify({ mergedFrameId: newFrame.id }),
|
|
});
|
|
|
|
const saved = chosen.store.getById(newFrame.id);
|
|
return saved
|
|
? normalizeToMemory(saved, chosen.mind, chosen.mind === 'workspace' ? workspace : undefined)
|
|
: reply.status(500).send({ error: 'Failed to read back merged memory' });
|
|
});
|
|
};
|