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,162 @@
/**
* ChatGPT Adapter — parse ChatGPT JSON export into UniversalImportItems.
*
* ChatGPT export format uses a `mapping` object with node IDs containing
* messages. Each conversation has a title, create_time, and the mapping tree.
*/
import { stableHarvestId } from './stable-id.js';
import type { SourceAdapter, UniversalImportItem, ConversationMessage } from './types.js';
import { asRecord, getArray, getNumber, getString, type RawRecord } from './raw-types.js';
export class ChatGPTAdapter implements SourceAdapter {
readonly sourceType = 'chatgpt' as const;
readonly displayName = 'ChatGPT';
parse(input: unknown): UniversalImportItem[] {
const root = asRecord(input);
const conversations = Array.isArray(input) ? input : root && getArray(root, 'conversations');
if (!Array.isArray(conversations)) return [];
const items: UniversalImportItem[] = [];
for (const rawConv of conversations) {
const conv = asRecord(rawConv);
if (!conv) continue;
const title = getString(conv, 'title') || 'Untitled';
const messages: ConversationMessage[] = [];
// ChatGPT uses a mapping object with node IDs
const mapping = asRecord(conv.mapping);
if (mapping) {
const nodes = Object.values(mapping)
.map(asRecord)
.filter((n): n is RawRecord => n !== null);
const sorted = nodes
.filter(n => {
const msg = asRecord(n.message);
const content = msg && asRecord(msg.content);
const parts = content && getArray(content, 'parts');
return (parts?.length ?? 0) > 0;
})
.sort((a, b) => {
const am = asRecord(a.message);
const bm = asRecord(b.message);
return (am && getNumber(am, 'create_time') ? getNumber(am, 'create_time')! : 0)
- (bm && getNumber(bm, 'create_time') ? getNumber(bm, 'create_time')! : 0);
});
for (const node of sorted) {
const msg = asRecord(node.message);
const author = msg && asRecord(msg.author);
const authorRole = author && getString(author, 'role');
if (!msg || !authorRole) continue;
if (authorRole === 'system') continue;
const role = authorRole === 'user' ? 'user' as const : 'assistant' as const;
const content = asRecord(msg.content);
const parts = content ? getArray(content, 'parts') : undefined;
// W4.4 (caption parity): multimodal object parts were silently
// dropped — DALL-E image parts carry their generation prompt
// (metadata.dalle.prompt), the only text-bearing image field in
// ChatGPT exports. Render as "[Shared image: …]" (Memori's
// convention; W3.3 measured dropped captions at 4.5pp single-hop).
const textParts: string[] = [];
for (const p of parts ?? []) {
if (typeof p === 'string') { textParts.push(p); continue; }
const rec = asRecord(p);
if (!rec) continue;
const ct = getString(rec, 'content_type') ?? '';
if (ct.includes('image')) {
const meta = asRecord(rec.metadata);
const dalle = meta ? asRecord(meta.dalle) : null;
const prompt = dalle ? getString(dalle, 'prompt') : undefined;
if (prompt) textParts.push(`[Shared image: ${prompt}]`);
}
}
// Message-level attachments: names are text-bearing presence signals.
const msgMeta = asRecord(msg.metadata);
for (const rawAtt of (msgMeta ? getArray(msgMeta, 'attachments') : undefined) ?? []) {
const att = asRecord(rawAtt);
const name = att ? getString(att, 'name') : undefined;
if (name) textParts.push(`[Attached: ${name}]`);
}
const text = textParts.join('\n').trim();
if (!text) continue;
const createTime = getNumber(msg, 'create_time');
messages.push({
role,
text,
timestamp: createTime ? new Date(createTime * 1000).toISOString() : undefined,
});
}
}
if (messages.length === 0) continue;
// Also check for custom instructions in conversation metadata
const customInstructions = conv.custom_instructions;
const createTime = getNumber(conv, 'create_time');
items.push({
// #7 sticky erasure: stable per-conversation id (keyed on the export's own
// conversation id, NOT content, so a grown conversation keeps its id).
id: stableHarvestId('chatgpt', getString(conv, 'id') ?? getString(conv, 'conversation_id') ?? `conv\x00${title}\x00${createTime ?? ''}`),
source: 'chatgpt',
type: 'conversation',
title,
content: messages.map(m => `${m.role}: ${m.text}`).join('\n\n'),
messages,
timestamp: createTime ? new Date(createTime * 1000).toISOString() : new Date().toISOString(),
metadata: {
conversationId: getString(conv, 'id') ?? getString(conv, 'conversation_id'),
messageCount: messages.length,
...(customInstructions ? { customInstructions } : {}),
},
});
}
// Also extract custom instructions / memory as separate items
if (root?.user_custom_instructions) {
items.push({
// 'singleton' discriminator (2 parts) so this can't collide with a
// conversation whose conv.id is literally the string 'custom_instructions'.
id: stableHarvestId('chatgpt', 'singleton', 'custom_instructions'),
source: 'chatgpt',
type: 'instruction',
title: 'ChatGPT Custom Instructions',
content: typeof root.user_custom_instructions === 'string'
? root.user_custom_instructions
: JSON.stringify(root.user_custom_instructions),
timestamp: new Date().toISOString(),
metadata: { type: 'custom_instructions' },
});
}
const memories = root && getArray(root, 'memories');
if (memories) {
for (const rawMem of memories) {
const mem = asRecord(rawMem);
const content = typeof rawMem === 'string'
? rawMem
: (mem && (getString(mem, 'content') ?? getString(mem, 'text'))) ?? JSON.stringify(rawMem);
items.push({
// No stable per-memory id exists in the export, so key on created_at+content
// (the best available surrogate). Caveat: ChatGPT memories are user-editable,
// so an EDIT changes the id → erasure isn't sticky across an edit (bounded,
// documented tradeoff — same class as the universal-text content-keyed path).
id: stableHarvestId('chatgpt', 'memory', (mem && getString(mem, 'created_at')) ?? '', content),
source: 'chatgpt',
type: 'memory',
title: 'ChatGPT Memory',
content,
timestamp: (mem && getString(mem, 'created_at')) ?? new Date().toISOString(),
metadata: { type: 'chatgpt_memory' },
});
}
}
return items;
}
}

View File

@@ -0,0 +1,30 @@
/**
* Shared text chunking utility for harvest adapters.
*
* Splits text into chunks by paragraph boundaries (double-newline),
* respecting a configurable maximum character length per chunk.
*/
const DEFAULT_MAX_LENGTH = 2000;
export function chunkByParagraphs(text: string, maxLen: number = DEFAULT_MAX_LENGTH): string[] {
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0);
const chunks: string[] = [];
let current = '';
for (const para of paragraphs) {
const trimmed = para.trim();
if (current.length + trimmed.length + 2 > maxLen && current.length > 0) {
chunks.push(current.trim());
current = trimmed;
} else {
current += (current ? '\n\n' : '') + trimmed;
}
}
if (current.trim()) {
chunks.push(current.trim());
}
return chunks;
}

View File

@@ -0,0 +1,271 @@
/**
* Claude Adapter — parse Claude web/desktop JSON export into UniversalImportItems.
*
* Handles multiple shapes of the Claude export:
* - Raw `chat_messages` array per conversation
* - Structured content-block format where `msg.content` is typed blocks
* - `projects[].docs[]` project-knowledge documents → type='artifact'
* - `memories` (conversations_memory + project_memories map) → type='memory'
* - `design_chats[]` Claude design workspace threads → type='conversation'
*
* The memories / design_chats streams (and the enriched project-docs
* parsing) came online in the 2026-04-22 Claude.ai export refresh.
* Reverse-ported from OSS hive-mind (oss-drift triage R6, 2026-06-11).
*
* W4.4 (caption parity, mono-only): message-level `attachments` /
* `files` arrays surface their text content — `extracted_content` is
* inlined as "[Attached: name] …" (capped 500 chars), bare names as
* "[Shared file: name]".
*/
import { stableHarvestId } from './stable-id.js';
import type { SourceAdapter, UniversalImportItem, ConversationMessage } from './types.js';
import { asRecord, firstString, getArray, getString, type RawRecord } from './raw-types.js';
export class ClaudeAdapter implements SourceAdapter {
readonly sourceType = 'claude' as const;
readonly displayName = 'Claude';
parse(input: unknown): UniversalImportItem[] {
const items: UniversalImportItem[] = [];
const root = asRecord(input);
// ── Conversations (historical default path) ───────────────────────
const conversations = Array.isArray(input) ? input : root && getArray(root, 'conversations');
if (Array.isArray(conversations)) {
for (const conv of conversations) {
items.push(...this.parseConversation(conv));
}
}
// ── Project-knowledge docs → artifact items ──────────────────────
const projects = root && getArray(root, 'projects');
if (projects) {
for (const project of projects) {
items.push(...this.parseProjectDocs(project));
}
}
// ── Memories stream (conversations_memory + project_memories) ────
if (root && root.memories !== undefined) {
items.push(...this.parseMemories(root.memories));
}
// ── Design chats stream ──────────────────────────────────────────
const designChats = root && getArray(root, 'design_chats');
if (designChats) {
for (const dc of designChats) {
items.push(...this.parseDesignChat(dc));
}
}
return items;
}
/**
* Parse one Claude chat message (conversation or design-chat shape) into a
* ConversationMessage, applying the W4.4 attachment/caption extraction.
* Returns null for messages with no surfaceable text.
*/
private parseMessage(rawMsg: unknown): ConversationMessage | null {
const msg = asRecord(rawMsg);
if (!msg) return null;
const role = (getString(msg, 'sender') === 'human' || getString(msg, 'role') === 'user')
? 'user' as const
: 'assistant' as const;
// Handle content blocks (Claude format)
let text: string;
const blocks = getArray(msg, 'content');
if (blocks) {
text = blocks
.map(asRecord)
.filter((b): b is RawRecord => b !== null && b.type === 'text')
.map(b => getString(b, 'text') ?? '')
.join('\n')
.trim();
} else {
text = (getString(msg, 'text') ?? getString(msg, 'content') ?? '').trim();
}
// W4.4 (caption parity): Claude exports carry message-level
// `attachments` (with extracted_content — text already extracted
// from images/docs) and `files` arrays; both were never accessed.
const extras: string[] = [];
for (const key of ['attachments', 'files'] as const) {
for (const rawAtt of getArray(msg, key) ?? []) {
const att = asRecord(rawAtt);
if (!att) continue;
const name = getString(att, 'file_name') ?? getString(att, 'name');
const extracted = getString(att, 'extracted_content');
if (extracted && extracted.trim()) {
extras.push(`[Attached: ${name ?? 'file'}] ${extracted.trim().slice(0, 500)}`);
} else if (name) {
extras.push(`[Shared file: ${name}]`);
}
}
}
if (extras.length > 0) text = [text, ...extras].filter(Boolean).join('\n').trim();
if (!text) return null;
return {
role,
text,
timestamp: getString(msg, 'created_at') ?? getString(msg, 'timestamp'),
};
}
private parseConversation(rawConv: unknown): UniversalImportItem[] {
const conv = asRecord(rawConv);
if (!conv) return [];
const title = firstString(conv, 'name', 'title') || 'Untitled';
const messages: ConversationMessage[] = [];
const chatMessages = getArray(conv, 'chat_messages') ?? getArray(conv, 'messages') ?? [];
for (const rawMsg of chatMessages) {
const parsed = this.parseMessage(rawMsg);
if (parsed) messages.push(parsed);
}
if (messages.length === 0) return [];
return [{
// #7 sticky erasure: stable per-conversation id (conv uuid, not content).
id: stableHarvestId('claude', firstString(conv, 'uuid', 'id') ?? `conv\x00${title}\x00${firstString(conv, 'created_at', 'create_time') ?? ''}`),
source: 'claude',
type: 'conversation',
title,
content: messages.map(m => `${m.role}: ${m.text}`).join('\n\n'),
messages,
timestamp: firstString(conv, 'created_at', 'create_time') ?? new Date().toISOString(),
metadata: {
conversationId: firstString(conv, 'uuid', 'id'),
messageCount: messages.length,
projectId: getString(conv, 'project_uuid') ?? undefined,
},
}];
}
private parseProjectDocs(rawProject: unknown): UniversalImportItem[] {
const project = asRecord(rawProject);
const docs = project && getArray(project, 'docs');
if (!project || !docs) return [];
const out: UniversalImportItem[] = [];
for (const rawDoc of docs) {
const doc = asRecord(rawDoc);
if (!doc) continue;
const content = getString(doc, 'content') ?? '';
if (content.length === 0) continue;
out.push({
id: stableHarvestId('claude', 'projdoc', getString(project, 'uuid') ?? '', getString(doc, 'uuid') ?? firstString(doc, 'filename', 'title') ?? ''),
source: 'claude',
type: 'artifact',
title: firstString(doc, 'filename', 'title') ?? 'Project Document',
content,
timestamp: getString(doc, 'created_at')
?? getString(project, 'updated_at')
?? getString(project, 'created_at')
?? new Date().toISOString(),
metadata: {
projectName: getString(project, 'name'),
projectUuid: getString(project, 'uuid'),
type: 'project_knowledge',
docUuid: getString(doc, 'uuid'),
filename: getString(doc, 'filename'),
},
});
}
return out;
}
private parseMemories(input: unknown): UniversalImportItem[] {
// `memories.json` is a single-entry array per the 2026-04-22 export
// shape: `[{ conversations_memory, project_memories, account_uuid }]`.
// Accept both the array-wrapped form and a bare object for flexibility.
const arr = Array.isArray(input) ? input : [input];
const out: UniversalImportItem[] = [];
for (const rawEntry of arr) {
const entry = asRecord(rawEntry);
if (!entry) continue;
const accountUuid = getString(entry, 'account_uuid');
// conversations_memory — usually a single long string of user-about facts
const convMem = entry.conversations_memory;
if (convMem !== undefined && convMem !== null) {
const content = typeof convMem === 'string' ? convMem : JSON.stringify(convMem);
if (content.length > 0) {
out.push({
// account-level singleton — fixed discriminator, NOT content (memory grows).
id: stableHarvestId('claude', 'memory', 'conversations_memory', accountUuid ?? ''),
source: 'claude',
type: 'memory',
title: 'Claude Memory — Conversations',
content,
timestamp: new Date().toISOString(),
metadata: {
memoryKind: 'conversations_memory',
accountUuid,
},
});
}
}
// project_memories — map of project_uuid -> memory string
const projectMemories = asRecord(entry.project_memories);
if (projectMemories) {
for (const [projUuid, memValue] of Object.entries(projectMemories)) {
const content = typeof memValue === 'string' ? memValue : JSON.stringify(memValue);
if (content.length > 0) {
out.push({
// per-project memory keyed on the project uuid map key (not content).
id: stableHarvestId('claude', 'memory', 'project_memory', accountUuid ?? '', projUuid),
source: 'claude',
type: 'memory',
title: `Claude Memory — Project ${projUuid}`,
content,
timestamp: new Date().toISOString(),
metadata: {
memoryKind: 'project_memory',
projectUuid: projUuid,
accountUuid,
},
});
}
}
}
}
return out;
}
private parseDesignChat(input: unknown): UniversalImportItem[] {
const dc = asRecord(input);
if (!dc) return [];
const messages: ConversationMessage[] = [];
const msgs = getArray(dc, 'messages') ?? getArray(dc, 'chat_messages') ?? [];
for (const rawMsg of msgs) {
const parsed = this.parseMessage(rawMsg);
if (parsed) messages.push(parsed);
}
if (messages.length === 0) return [];
return [{
id: stableHarvestId('claude', getString(dc, 'uuid') ?? `designchat\x00${getString(dc, 'project') ?? ''}\x00${getString(dc, 'title') ?? ''}\x00${getString(dc, 'created_at') ?? ''}`),
source: 'claude',
type: 'conversation',
title: getString(dc, 'title') ?? 'Claude Design Chat',
content: messages.map(m => `${m.role}: ${m.text}`).join('\n\n'),
messages,
timestamp: getString(dc, 'created_at') ?? new Date().toISOString(),
metadata: {
designChatUuid: getString(dc, 'uuid'),
projectUuid: getString(dc, 'project') ?? undefined,
messageCount: messages.length,
stream: 'design_chats',
},
}];
}
}

View File

@@ -0,0 +1,359 @@
/**
* Claude Code Filesystem Adapter — reads ~/.claude/ directory structure.
*
* Extracts:
* - memory/*.md files (with frontmatter: type, name, description)
* - rules/**\/*.md files (coding standards, workflow rules)
* - plans/*.md files (implementation plans)
* - settings.json (model preferences, tool config)
* - CLAUDE.md project files (architectural decisions)
* - .mind/*.md session handoffs (decisions, directions)
* - Decision extraction from memory content (pattern matching)
*
* This is a FilesystemAdapter — it reads directly from disk.
*/
import { stableHarvestId } from './stable-id.js';
import { decisionOfSubjectId } from './decision-derivation.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { FilesystemAdapter, UniversalImportItem, ImportItemType } from './types.js';
// Map Claude Code memory types to our import types
const MEMORY_TYPE_MAP: Record<string, ImportItemType> = {
user: 'preference',
feedback: 'decision',
project: 'memory',
reference: 'memory',
};
// Patterns that indicate user decisions in text
const DECISION_PATTERNS = [
/\bwe (?:decided|chose|picked|went with|agreed|confirmed)\b/i,
/\blet'?s (?:go with|use|do|keep|drop|switch|move)\b/i,
/\bdecision:\s/i,
/\bconfirmed:\s/i,
/\bapproved:\s/i,
/\brejected:\s/i,
/\bwon'?t (?:do|use|implement|add|need)\b/i,
/\bmust (?:use|have|be|support|include)\b/i,
/\bnon-negotiable\b/i,
/\brequirement:\s/i,
/\bconstraint:\s/i,
/\bchose .+ (?:over|instead of|rather than)\b/i,
/\bdropped?\b.+\bin favo(?:u)?r of\b/i,
];
interface MemoryFrontmatter {
name?: string;
description?: string;
type?: string;
}
function parseFrontmatter(content: string): { frontmatter: MemoryFrontmatter; body: string } {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const raw = match[1];
const body = match[2].trim();
const frontmatter: MemoryFrontmatter = {};
for (const line of raw.split('\n')) {
const colonIdx = line.indexOf(':');
if (colonIdx < 0) continue;
const key = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim();
if (key === 'name') frontmatter.name = value;
if (key === 'description') frontmatter.description = value;
if (key === 'type') frontmatter.type = value;
}
return { frontmatter, body };
}
function readFilesRecursive(dir: string, ext: string): { filePath: string; content: string }[] {
const results: { filePath: string; content: string }[] = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...readFilesRecursive(fullPath, ext));
} else if (entry.name.endsWith(ext)) {
try {
results.push({ filePath: fullPath, content: fs.readFileSync(fullPath, 'utf-8') });
} catch { /* skip unreadable files */ }
}
}
return results;
}
export class ClaudeCodeAdapter implements FilesystemAdapter {
readonly sourceType = 'claude-code' as const;
readonly displayName = 'Claude Code';
parse(input: unknown): UniversalImportItem[] {
// For the SourceAdapter interface — parse JSON if provided
if (typeof input === 'string') {
return this.scan(input);
}
return [];
}
scan(dirPath: string): UniversalImportItem[] {
if (!fs.existsSync(dirPath)) return [];
const items: UniversalImportItem[] = [];
// 1. Scan all project memory directories
const projectsDir = path.join(dirPath, 'projects');
if (fs.existsSync(projectsDir)) {
const projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const projEntry of projectEntries) {
if (!projEntry.isDirectory()) continue;
const memoryDir = path.join(projectsDir, projEntry.name, 'memory');
items.push(...this.scanMemoryDir(memoryDir, projEntry.name));
}
}
// 2. Scan rules
const rulesDir = path.join(dirPath, 'rules');
const ruleFiles = readFilesRecursive(rulesDir, '.md');
for (const { filePath, content } of ruleFiles) {
const relPath = path.relative(dirPath, filePath);
items.push({
// #7 sticky erasure: file path is the stable id (survives content growth).
// Normalize the OS separator so the SAME tree scanned on win32 vs POSIX
// yields the SAME id (stableHarvestId's cross-process determinism contract).
id: stableHarvestId('claude-code', relPath.split(path.sep).join('/')),
source: 'claude-code',
type: 'rule',
title: `Rule: ${path.basename(filePath, '.md')}`,
content,
timestamp: this.getFileMtime(filePath),
metadata: { filePath: relPath, category: 'rule' },
});
}
// 3. Scan plans
const plansDir = path.join(dirPath, 'plans');
if (fs.existsSync(plansDir)) {
const planFiles = fs.readdirSync(plansDir).filter(f => f.endsWith('.md'));
for (const planFile of planFiles) {
const fullPath = path.join(plansDir, planFile);
try {
const content = fs.readFileSync(fullPath, 'utf-8');
items.push({
id: stableHarvestId('claude-code', `plans/${planFile}`),
source: 'claude-code',
type: 'artifact',
title: `Plan: ${planFile.replace('.md', '')}`,
content,
timestamp: this.getFileMtime(fullPath),
metadata: { filePath: `plans/${planFile}`, category: 'plan' },
});
} catch { /* skip */ }
}
}
// 4. Read settings.json for preferences
const settingsPath = path.join(dirPath, 'settings.json');
if (fs.existsSync(settingsPath)) {
try {
const raw = fs.readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(raw);
const prefs: string[] = [];
if (settings.model) prefs.push(`Preferred model: ${settings.model}`);
if (settings.alwaysThinkingEnabled) prefs.push('Extended thinking: enabled');
if (Array.isArray(settings.allowedTools)) {
prefs.push(`Allowed tools: ${settings.allowedTools.length} configured`);
}
if (prefs.length > 0) {
items.push({
id: stableHarvestId('claude-code', 'settings.json'),
source: 'claude-code',
type: 'preference',
title: 'Claude Code Settings',
content: prefs.join('\n'),
timestamp: this.getFileMtime(settingsPath),
metadata: { filePath: 'settings.json', category: 'settings' },
});
}
} catch { /* skip */ }
}
// 5. Scan CLAUDE.md files from project directories (architectural decisions)
if (fs.existsSync(projectsDir)) {
const projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const projEntry of projectEntries) {
if (!projEntry.isDirectory()) continue;
const claudeMdPath = path.join(projectsDir, projEntry.name, 'CLAUDE.md');
if (fs.existsSync(claudeMdPath)) {
try {
const content = fs.readFileSync(claudeMdPath, 'utf-8');
if (content.trim().length > 50) {
items.push({
id: stableHarvestId('claude-code', `projects/${projEntry.name}/CLAUDE.md`),
source: 'claude-code',
type: 'artifact',
title: `Project CLAUDE.md (${projEntry.name})`,
content: content.slice(0, 4000),
timestamp: this.getFileMtime(claudeMdPath),
metadata: {
filePath: `projects/${projEntry.name}/CLAUDE.md`,
category: 'project-config',
},
});
}
} catch { /* skip */ }
}
}
}
// 6. Scan .mind/ directories for session handoffs and decisions
if (fs.existsSync(projectsDir)) {
const projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const projEntry of projectEntries) {
if (!projEntry.isDirectory()) continue;
const mindDir = path.join(projectsDir, projEntry.name, '.mind');
items.push(...this.scanMindDir(mindDir, projEntry.name));
}
}
// 7. Extract decisions from memory items that contain decision language
items.push(...this.extractDecisions(items));
return items;
}
/** Scan .mind/ directory for session handoffs with decisions. */
private scanMindDir(mindDir: string, projectHash: string): UniversalImportItem[] {
if (!fs.existsSync(mindDir)) return [];
const items: UniversalImportItem[] = [];
try {
const files = fs.readdirSync(mindDir).filter(f => f.endsWith('.md'));
for (const file of files) {
const fullPath = path.join(mindDir, file);
try {
const content = fs.readFileSync(fullPath, 'utf-8');
if (content.trim().length < 50) continue;
// Determine type from filename
const lowerFile = file.toLowerCase();
const isDecision = lowerFile.includes('decision');
const isState = lowerFile.includes('state');
items.push({
id: stableHarvestId('claude-code', `projects/${projectHash}/.mind/${file}`),
source: 'claude-code',
type: isDecision ? 'decision' : 'artifact',
title: `${isDecision ? 'Decisions' : isState ? 'State' : 'Session'}: ${file.replace('.md', '')}`,
content: content.slice(0, 4000),
timestamp: this.getFileMtime(fullPath),
metadata: {
filePath: `projects/${projectHash}/.mind/${file}`,
category: isDecision ? 'decision' : 'session-handoff',
},
});
} catch { /* skip */ }
}
} catch { /* skip */ }
return items;
}
/**
* Extract decision items from existing memory/preference/artifact items.
* Scans content for decision patterns and creates separate decision items
* for statements that match. Avoids duplicating items already typed as 'decision'.
*/
private extractDecisions(existingItems: readonly UniversalImportItem[]): UniversalImportItem[] {
const decisions: UniversalImportItem[] = [];
for (const item of existingItems) {
// Skip items already categorized as decisions
if (item.type === 'decision' || item.type === 'rule') continue;
const lines = item.content.split('\n');
const decisionLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length < 10) continue;
for (const pattern of DECISION_PATTERNS) {
if (pattern.test(trimmed)) {
decisionLines.push(trimmed);
break;
}
}
}
if (decisionLines.length > 0) {
decisions.push({
// derived item — namespaced by the (now-stable) parent id so it never
// collides with the parent's own id. decisionOfSubjectId is the SHARED
// derivation MindErasure.eraseBySourceRef recomputes to reach + suppress
// this derived subject on erasure (#7 P2), so the two sites cannot drift.
id: decisionOfSubjectId(item.id),
source: 'claude-code',
type: 'decision',
title: `Decisions from: ${item.title}`,
content: decisionLines.join('\n'),
timestamp: item.timestamp,
metadata: {
...item.metadata,
category: 'decision',
extractedFrom: item.id,
decisionCount: decisionLines.length,
},
});
}
}
return decisions;
}
private scanMemoryDir(memoryDir: string, projectHash: string): UniversalImportItem[] {
if (!fs.existsSync(memoryDir)) return [];
const items: UniversalImportItem[] = [];
const files = fs.readdirSync(memoryDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md');
for (const file of files) {
const fullPath = path.join(memoryDir, file);
try {
const raw = fs.readFileSync(fullPath, 'utf-8');
const { frontmatter, body } = parseFrontmatter(raw);
if (!body) continue;
const importType = MEMORY_TYPE_MAP[frontmatter.type ?? ''] ?? 'memory';
items.push({
id: stableHarvestId('claude-code', `projects/${projectHash}/memory/${file}`),
source: 'claude-code',
type: importType,
title: frontmatter.name ?? file.replace('.md', ''),
content: body,
timestamp: this.getFileMtime(fullPath),
metadata: {
filePath: `projects/${projectHash}/memory/${file}`,
memoryType: frontmatter.type,
description: frontmatter.description,
category: 'memory',
},
});
} catch { /* skip */ }
}
return items;
}
private getFileMtime(filePath: string): string {
try {
return fs.statSync(filePath).mtime.toISOString();
} catch {
return new Date().toISOString();
}
}
}

View File

@@ -0,0 +1,31 @@
/**
* decision-derivation.ts — the single source of truth for claude-code's derived
* `decision-of` subject key.
*
* claude-code harvest (claude-code-adapter extractDecisions) fans a scanned item
* out into a SEPARATE "Decisions from: …" item that quotes the parent's decision
* lines. That derived item lands as its OWN GDPR Art.17 subject, keyed on
* decisionOfSubjectId(parentId). Two layers must agree on that key EXACTLY:
* - the adapter, which MINTS the derived item's id at harvest time;
* - MindErasure.eraseBySourceRef, which RECOMPUTES it to erase + suppress the
* derived subject when the parent is erased (else it survives erasure and
* re-materializes on re-import — the #7 P2 gap).
* Duplicating the derivation across those two sites is precisely the drift that
* created the gap, so both import from here. Changing the token also invalidates
* every already-persisted derived subject id, so treat it as a data contract.
*/
import { stableHarvestId } from './stable-id.js';
/** The only harvest source that derives a separate `decision-of` subject. */
export const CLAUDE_CODE_DECISION_SOURCE = 'claude-code';
/** The derivation-kind token the derived id is namespaced under. */
export const DECISION_OF_KIND = 'decision-of';
/**
* Deterministic id for the `decision-of` subject derived from a claude-code
* parent item. `parentId` is the parent's stable harvest id (== its raw_archive
* source_ref), so the id is stable across re-imports of the same parent.
*/
export function decisionOfSubjectId(parentId: string): string {
return stableHarvestId(CLAUDE_CODE_DECISION_SOURCE, DECISION_OF_KIND, parentId);
}

Binary file not shown.

View File

@@ -0,0 +1,267 @@
// Reverse-ported from OSS hive-mind llm-extractor (oss-drift triage D2, 2026-06-11); executors rehomed onto LLMCallFn.
/**
* extract-kg-entities.ts — LLM-based knowledge-graph entity extraction.
*
* The heuristic capitalized-n-gram regex (packages/agent/src/entity-extractor.ts
* via CognifyPipeline) produces high noise: sentence-starts, log prefixes, and
* fragments. This pass replaces the regex with an LLM that understands semantics
* and returns typed entities (person/project/file/decision/bug/concept/tool/
* location) keyed by frame id.
*
* Ported core = PROMPT + JSONL PARSER + BATCHING + noise filter. The OSS
* executors ('cc' subprocess spawn, raw Anthropic POST) are dropped — the
* monorepo drives all LLM calls through `LLMCallFn` ('fast' tier), exactly
* like extract-memory-lanes.ts.
*
* Failure model mirrors extract-memory-lanes: per-batch failures are collected
* into `errors`, never thrown — partial results beat zero results when one
* frame confuses the model. All extracted names are injection-scanned before
* being returned (LLM output over possibly-tainted harvested content).
*/
import type { LLMCallFn } from './pipeline.js';
import { scanForInjection } from '../injection-scanner.js';
import { createCoreLogger } from '../logger.js';
import { isNoiseName, normalizeEntityName } from '../mind/entity-normalizer.js';
import type { KnowledgeGraph } from '../mind/knowledge.js';
const log = createCoreLogger('extract-kg-entities');
/** Canonical entity types the prompt asks the model to choose from. */
export const KG_ENTITY_TYPES = [
'person',
'project',
'file',
'decision',
'bug',
'concept',
'tool',
'location',
] as const;
export type KgEntityType = (typeof KG_ENTITY_TYPES)[number];
/** One extracted entity attributed back to its source frame. */
export interface KgEntity {
frameId: number;
type: KgEntityType;
name: string;
}
export interface KgEntityExtraction {
entities: KgEntity[];
errors: string[];
}
/**
* Frames per batch. OSS defaulted to 3 because `claude -p` subprocess
* wall-clock blew past 90s on batch=5 with raw frames; LLMCallFn has no
* subprocess pressure, so 5 frames/batch with the per-frame content cap
* keeps prompts bounded while halving call count.
*/
const BATCH_SIZE = 5;
/**
* Per-frame content cap before sending to the model (OSS finding: the first
* ~2-3KB of a frame carries the named entities; long wiki-synth frames can
* exceed 8KB and add nothing but latency).
*/
const MAX_FRAME_CHARS = 2500;
/** The instructions block sent to the model. Stable across batches. */
const PROMPT_INSTRUCTIONS = `Extract named entities from the FRAMES below. For each entity, output ONE JSON object on its own line.
OUTPUT FORMAT (JSONL — one object per line, no other text):
{"frame_id": <number>, "name": "<entity name>", "type": "<type>"}
VALID TYPES (pick the closest fit):
- person a specific human (e.g. "Marko", "Alice Chen")
- project a named project, repo, codebase, or product (e.g. "hive-mind", "Phase 3")
- file a specific file path or filename (e.g. "synth-drain.js", "PHASE-3-PLAN.md")
- decision a specific architectural or strategic choice with a name (e.g. "open-core boundary")
- bug a known issue, incident, or failure mode (e.g. "subprocess feedback loop")
- tool a CLI tool, library, framework, or service (e.g. "Ollama", "Voyage", "sqlite-vec")
- concept a domain concept that doesn't fit above (e.g. "watermark", "reranker")
- location a directory or workspace path (e.g. "D:/Projects/hive-mind")
DO NOT EXTRACT:
- pronouns, demonstratives ("this", "that", "these")
- generic verbs at sentence start ("Add", "Update", "Run")
- standalone acronyms shorter than 4 chars ("API", "CLI", "MCP", "JSON")
- weekdays, months, dates
- common English words
- fragments — if you'd struggle to write a wiki page about it, skip it
QUALITY BAR: ~3-8 high-signal entities per frame is typical. If a frame is short or non-substantive, return zero entities for it (just don't emit lines for it).
Output JSONL only. No prose, no markdown fences, no commentary.`;
interface FrameInput {
id: number;
content: string;
}
/** Builds the user-message text for one batch. */
function buildBatchPrompt(frames: ReadonlyArray<FrameInput>): string {
const sep = '='.repeat(60);
const blocks = frames.map((f) => {
const trimmed = f.content.trim();
const body = trimmed.length > MAX_FRAME_CHARS
? `${trimmed.slice(0, MAX_FRAME_CHARS)}\n[...frame truncated for extraction; ${trimmed.length - MAX_FRAME_CHARS} chars omitted]`
: trimmed;
return `${sep}\nFRAME id=${f.id}\n${sep}\n${body}`;
}).join('\n\n');
return `${PROMPT_INSTRUCTIONS}\n\n${blocks}\n\n=== END OF FRAMES ===\n\nNow output JSONL:`;
}
/**
* Strips a fenced code block the model sometimes adds despite instructions.
* Returns the inner content if a single \`\`\`...\`\`\` block wraps everything,
* else the original text.
*/
function unwrapFencedBlock(text: string): string {
const trimmed = text.trim();
const fenceMatch = trimmed.match(/^```(?:json|jsonl)?\s*\n([\s\S]*?)\n```\s*$/);
return fenceMatch ? fenceMatch[1] : trimmed;
}
/**
* Parses LLM JSONL output into typed entities. Per-line tolerance: a single
* malformed line never aborts the batch. Dropped lines:
* - unparseable JSON
* - frame_id not in this batch (the model invented one — unattributable)
* - type outside KG_ENTITY_TYPES (stricter than OSS, which coerced to
* 'concept' — validate at the boundary instead of laundering junk types)
* - names failing the write-time noise filter (isNoiseName)
* - names carrying an injection payload (scanned BEFORE returning — LLM
* output over harvested content is tainted input)
*/
function parseJsonlOutput(raw: string, validFrameIds: ReadonlySet<number>): KgEntity[] {
const entities: KgEntity[] = [];
const cleaned = unwrapFencedBlock(raw);
for (const line of cleaned.split('\n')) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('{')) continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
const frameId = Number(parsed.frame_id);
if (!Number.isFinite(frameId) || !validFrameIds.has(frameId)) continue;
const name = typeof parsed.name === 'string' ? parsed.name.trim() : '';
if (name.length < 2) continue;
// Write-time noise filter (oss-drift R3 — first wiring): stop tokens,
// sub-4-char names, single-word acronyms never enter the graph.
if (isNoiseName(name)) continue;
const rawType = typeof parsed.type === 'string' ? parsed.type.toLowerCase().trim() : '';
if (!(KG_ENTITY_TYPES as readonly string[]).includes(rawType)) continue;
const scan = scanForInjection(name, 'tool_output');
if (!scan.safe) {
log.warn('dropping extracted entity name with injection payload', { flags: scan.flags.join(',') });
continue;
}
entities.push({ frameId, name, type: rawType as KgEntityType });
}
return entities;
}
/**
* Extract typed KG entities from N frames via the LLM. Internally batches
* into groups of BATCH_SIZE, one 'fast'-tier call per batch. Per-batch
* failures are collected into `errors`, never thrown.
*/
export async function extractKgEntities(
datedFrames: ReadonlyArray<FrameInput>,
llmCall: LLMCallFn,
): Promise<KgEntityExtraction> {
const out: KgEntityExtraction = { entities: [], errors: [] };
if (datedFrames.length === 0) return out;
for (let i = 0; i < datedFrames.length; i += BATCH_SIZE) {
const batch = datedFrames.slice(i, i + BATCH_SIZE);
const validIds = new Set(batch.map((f) => f.id));
try {
const raw = await llmCall(buildBatchPrompt(batch), 'fast');
out.entities.push(...parseJsonlOutput(raw, validIds));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
out.errors.push(`kg-entities batch ${i / BATCH_SIZE} (frames ${batch[0].id}..${batch[batch.length - 1].id}): ${msg}`);
log.warn('kg-entity extraction batch failed', { batch: i / BATCH_SIZE, error: msg });
}
}
return out;
}
// ── Graph writing ────────────────────────────────────────────────────────────
export interface WriteKgEntitiesResult {
/** New knowledge_entities rows. */
created: number;
/** Existing entities whose seen_count was bumped (exact-name dedup hit). */
updated: number;
}
function safeParseProps(raw: string | undefined | null): Record<string, unknown> {
if (!raw) return {};
try { return JSON.parse(raw) as Record<string, unknown>; } catch { return {}; }
}
/**
* Persist extracted entities into the knowledge graph.
*
* Dedup is exact-name via `kg.findEntityByName()` (oss-drift R2) — NEVER the
* LIKE-based `searchEntities` top-K, which silently drops the exact match once
* enough similarly-named entities accumulate (3506 duplicate "Phase" rows
* observed in the OSS repo). On a hit, seen_count is bumped the same way the
* cognify CLI does; on a miss, a new row is created with source 'cognify-llm'
* (the tag that distinguishes LLM-grade entities from heuristic noise).
*/
export function writeKgEntities(
kg: KnowledgeGraph,
extraction: KgEntityExtraction,
): WriteKgEntitiesResult {
const result: WriteKgEntitiesResult = { created: 0, updated: 0 };
for (const entity of extraction.entities) {
// Defense at the write seam (mirrors the cognify CLI): callers other than
// extractKgEntities may not have noise-filtered.
if (isNoiseName(entity.name)) continue;
if (normalizeEntityName(entity.name).length < 3) continue;
const existing = kg.findEntityByName(entity.name);
if (existing) {
const existingProps = safeParseProps(existing.properties);
const seenCount = Number(existingProps.seen_count ?? 1) + 1;
kg.updateEntity(existing.id, {
properties: { ...existingProps, seen_count: seenCount },
});
kg.linkEntityToFrame(existing.id, entity.frameId);
result.updated++;
} else {
try {
const created = kg.createEntity(entity.type, entity.name, { seen_count: 1, source: 'cognify-llm' });
kg.linkEntityToFrame(created.id, entity.frameId);
result.created++;
} catch (e: unknown) {
// Ontology validation may reject — skip this entity, never abort the pass.
log.warn('createEntity rejected extracted entity', {
name: entity.name,
error: e instanceof Error ? e.message : String(e),
});
}
}
}
return result;
}

View File

@@ -0,0 +1,335 @@
/**
* extract-memory-lanes.ts — W4.3 extraction passes for the benchmark-proven
* recall lanes (W4-PRODUCTION-PORT-PLAN-2026-06-11.md components #5/#6/#8).
*
* Three single-call LLM passes over conversation text, production
* generalizations of the LoCoMo-validated extraction scripts (28/31 dense
* facts, 33 episodic events, 35 profile cards — evidence: 87.66 overall,
* benchmarks/results/memori-phase22-RESULT.md):
*
* - DENSE FACTS → `[mind-fact]` cross-session syntheses
* - EPISODIC → `[mind-event]` datable events, created_at =
* LLM-resolved EVENT date
* - PROFILES → `[mind-profile <name>]` per-person persona cards,
* importance DELIBERATELY 'normal'
* (stays out of the K5 lane)
*
* Frames are prefix-tagged on their first line so recall lanes fetch them by
* `content LIKE '[mind-… %'` — the same convention the benchmark proved.
* All LLM output is injection-scanned before any frame write (LLM passes run
* over possibly-tainted harvested content).
*
* Model: LLMCallFn 'fast' tier (benchmark used gpt-4o-mini, temp 0). Ollama
* is an optional routing target via LiteLLM — no hard dependency.
*/
import type { LLMCallFn } from './pipeline.js';
import { scanForInjection } from '../injection-scanner.js';
import { createCoreLogger } from '../logger.js';
import type { FrameStore } from '../mind/frames.js';
const log = createCoreLogger('extract-memory-lanes');
/** First-line content prefixes for the three lanes (recall fetches by these). */
export const MIND_FACT_PREFIX = '[mind-fact]';
export const MIND_EVENT_PREFIX = '[mind-event]';
export const MIND_PROFILE_PREFIX = '[mind-profile';
export interface ExtractedEvent {
/** ISO date of the session/source the event was narrated in. */
session_date: string;
/** Relative cue found in the utterance ("yesterday"…), or "none". */
cue: string;
/** LLM-resolved date the event actually happened (YYYY-MM-DD). */
event_date: string;
text: string;
}
export interface ExtractedFact {
category: 'preference' | 'decision' | 'trait' | 'theme';
speaker: string;
text: string;
}
export interface ExtractedProfile {
speaker: string;
card: string;
}
export interface MemoryLaneExtraction {
facts: ExtractedFact[];
events: ExtractedEvent[];
profiles: ExtractedProfile[];
errors: string[];
}
// ── Prompts (benchmark scripts 31/33/35, generalized off the speaker pair) ──
const FACTS_SYSTEM =
'You extract many synthesis-level memory facts from long-term conversations. ' +
'Be exhaustive — cover preferences, decisions, traits, life-stances, beliefs, ' +
'progressions, themes, hobbies, fears, joys, opinions, family relationships, ' +
'professional details. Output ONLY the JSON, no preamble.';
function factsPrompt(text: string): string {
return `${FACTS_SYSTEM}
Conversation/source material:
${text}
---
Extract a DENSE set of synthesis-level memory facts (scale the count to the material; up to ~60 for long conversations). Aim for HIGH COVERAGE — include facts relevant to inference questions like "would X be considered Y?" or "how does X feel about Y?".
Categories:
1. preference — "User preference: <Name> [values/likes/dislikes/prefers/believes] <thing>[ because <reason>]"
2. decision — "Decision: <Name> decided to/plans to <action>[, because <reason>]"
3. trait — "Trait: <Name> is <trait/orientation>[, as shown by <pattern>]"
4. theme — "Theme: <topic/arc> — <insight or progression across sessions>"
Be SPECIFIC and CONCRETE. Each fact stands alone. Cover all participants.
Output STRICT JSON:
{"facts": [{"category": "preference|decision|trait|theme", "speaker": "Name|both", "text": "<full prefix-tagged sentence>"}, ...]}`;
}
const EVENTS_SYSTEM =
'You extract specific datable events from long-term conversations AND resolve WHEN each ' +
'event actually happened. Source material carries dates (session headers, timestamps). ' +
'Events are often recounted in PAST tense with relative time cues ("yesterday", "last ' +
'week", "two months ago", "last year"). You MUST compute the ACTUAL event date by applying ' +
'the cue to that passage\'s date — do NOT just copy the source date. If an event has no ' +
'relative cue (happening now / present tense / planned for the future), use the source date. ' +
'Focus on concrete things that HAPPENED: activities, places visited, milestones, purchases, ' +
'meetings, projects, health events, travel. Do NOT include timeless preferences or ' +
'personality traits — only events. Output ONLY the JSON.';
function eventsPrompt(text: string): string {
return `${EVENTS_SYSTEM}
Source material (dated):
${text}
---
Extract the specific datable events (scale the count to the material). For EACH event output:
- session_date: the ISO date of the passage the event was narrated in (YYYY-MM-DD)
- cue: the exact relative time phrase ("yesterday", "last week", "two months ago"), or "none"
- event_date: the RESOLVED actual date the event happened (YYYY-MM-DD)
- text: a concise sentence about what happened (names, titles, exact activities)
Resolution rules (apply cue to session_date):
- "yesterday" -> session_date 1 day
- "the day before yesterday" -> session_date 2 days
- "last week" / "a week ago" -> session_date 7 days
- "N days/weeks ago" -> subtract that many days/weeks
- "last month" / "a month ago" -> session_date 1 month
- "N months ago" -> subtract N months
- "last year" -> same month/day, year 1
- "none" (present tense/now) -> event_date = session_date
Worked example: passage dated 2023-05-08, "I went to the support group yesterday"
-> {"session_date":"2023-05-08","cue":"yesterday","event_date":"2023-05-07","text":"Caroline attended the LGBTQ support group"}
Output STRICT JSON:
{"events": [{"session_date":"YYYY-MM-DD","cue":"...","event_date":"YYYY-MM-DD","text":"..."}, ...]}`;
}
const PROFILES_SYSTEM =
'You build dense persona profile cards from long-term conversations, aggregating ' +
'dispersed weak signals (activities, choices, stated values, recurring themes) into a ' +
'coherent portrait. The card must support INFERENCE questions like "would X be ' +
'considered religious?" or "what would X\'s likely preference be?". Aggregate signals; ' +
'include world-knowledge hooks (specific titles, brand names, place names — verbatim, ' +
'never generalized). Output ONLY the JSON.';
function profilesPrompt(text: string): string {
return `${PROFILES_SYSTEM}
Source material:
${text}
---
Build one profile card per main participant (120-180 words each). Each card MUST cover, compactly:
- Identity & life situation (job/role, family, relationships, location if stated)
- Interests & habits with SPECIFIC named items (exact titles, brands, activities)
- Values, beliefs, personality leanings AS EVIDENCED
- Major life arc events with rough dates
- People mentioned around them and who those people likely are
- Current state at the end of the material (latest job, plans, status)
Write declarative, signal-dense prose. No hedging filler. Keep verbatim named entities.
Output STRICT JSON:
{"profiles": [{"speaker": "<Name>", "card": "..."}, ...]}`;
}
// ── Parsing helpers ──────────────────────────────────────────────────────────
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function parseJsonObject(raw: string): Record<string, unknown> | null {
const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
try {
const parsed: unknown = JSON.parse(cleaned);
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
// ── Extraction ──────────────────────────────────────────────────────────────
/**
* Run the three lane-extraction passes over one body of conversation text.
* Each pass fails independently — a parse failure on one lane never blocks
* the others (errors are collected, not thrown).
*/
export async function extractMemoryLanes(
text: string,
llmCall: LLMCallFn,
): Promise<MemoryLaneExtraction> {
const out: MemoryLaneExtraction = { facts: [], events: [], profiles: [], errors: [] };
const passes: Array<{ name: string; run: () => Promise<void> }> = [
{
name: 'facts',
run: async () => {
const obj = parseJsonObject(await llmCall(factsPrompt(text), 'fast'));
const facts = Array.isArray(obj?.facts) ? obj.facts : [];
for (const f of facts as Array<Record<string, unknown>>) {
if (typeof f?.text === 'string' && f.text.trim().length > 0) {
out.facts.push({
category: (['preference', 'decision', 'trait', 'theme'].includes(String(f.category))
? String(f.category)
: 'preference') as ExtractedFact['category'],
speaker: typeof f.speaker === 'string' ? f.speaker : 'unknown',
text: f.text.trim(),
});
}
}
},
},
{
name: 'events',
run: async () => {
const obj = parseJsonObject(await llmCall(eventsPrompt(text), 'fast'));
const events = Array.isArray(obj?.events) ? obj.events : [];
for (const e of events as Array<Record<string, unknown>>) {
const eventDate = typeof e?.event_date === 'string' ? e.event_date : '';
if (typeof e?.text === 'string' && e.text.trim().length > 0 && ISO_DATE_RE.test(eventDate)) {
out.events.push({
session_date: typeof e.session_date === 'string' ? e.session_date : eventDate,
cue: typeof e.cue === 'string' ? e.cue : 'none',
event_date: eventDate,
text: e.text.trim(),
});
}
}
},
},
{
name: 'profiles',
run: async () => {
const obj = parseJsonObject(await llmCall(profilesPrompt(text), 'fast'));
const profiles = Array.isArray(obj?.profiles) ? obj.profiles : [];
for (const p of profiles as Array<Record<string, unknown>>) {
if (typeof p?.speaker === 'string' && typeof p?.card === 'string' && p.card.trim().length > 0) {
out.profiles.push({ speaker: p.speaker.trim(), card: p.card.trim() });
}
}
},
},
];
for (const pass of passes) {
try {
await pass.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
out.errors.push(`${pass.name}: ${msg}`);
log.warn(`memory-lane extraction pass failed`, { pass: pass.name, error: msg });
}
}
return out;
}
// ── Frame writing ────────────────────────────────────────────────────────────
export interface WriteLaneFramesResult {
factsWritten: number;
eventsWritten: number;
profilesWritten: number;
injectionDropped: number;
}
/**
* Persist an extraction as prefix-tagged frames.
*
* - facts: `[mind-fact]\n<text>` importance 'normal'
* - events: `[mind-event]\n[YYYY-MM-DD] <text>` importance 'normal',
* created_at = the RESOLVED event date (write-time temporal
* anchoring — the production counterpart of the LoCoMo P4 win)
* - profiles: `[mind-profile <name>]\n<card>` importance 'normal' —
* DELIBERATELY normal so cards stay out of the importance-K5
* lane (benchmark design decision); prior card for the same
* person is replaced (profiles evolve, facts accumulate).
*
* createIFrame's content dedup makes fact/event writes idempotent across
* re-runs. Every item is injection-scanned before write — extraction output
* derives from possibly-tainted harvested content.
*/
export function writeMemoryLaneFrames(
frames: FrameStore,
gopId: string,
extraction: MemoryLaneExtraction,
): WriteLaneFramesResult {
const result: WriteLaneFramesResult = {
factsWritten: 0, eventsWritten: 0, profilesWritten: 0, injectionDropped: 0,
};
const safe = (text: string): boolean => {
const scan = scanForInjection(text, 'tool_output');
if (!scan.safe) {
result.injectionDropped++;
log.warn('dropping extracted item with injection payload', { flags: scan.flags.join(',') });
return false;
}
return true;
};
for (const f of extraction.facts) {
if (!safe(f.text)) continue;
frames.createIFrame(gopId, `${MIND_FACT_PREFIX}\n${f.text}`, 'normal', 'system');
result.factsWritten++;
}
for (const e of extraction.events) {
if (!safe(e.text)) continue;
frames.createIFrame(
gopId,
`${MIND_EVENT_PREFIX}\n[${e.event_date}] ${e.text}`,
'normal',
'system',
`${e.event_date}T00:00:00.000Z`,
);
result.eventsWritten++;
}
for (const p of extraction.profiles) {
if (!safe(p.card)) continue;
const header = `${MIND_PROFILE_PREFIX} ${p.speaker}]`;
// Replace-on-update: a person's card supersedes the previous one.
frames.deleteByContentPrefix(header);
frames.createIFrame(gopId, `${header}\n${p.card}`, 'normal', 'system');
result.profilesWritten++;
}
return result;
}

View File

@@ -0,0 +1,164 @@
/**
* Gemini Adapter — parse Google Takeout / Gemini conversation exports.
*
* Supports both Google Takeout format and direct Gemini API export.
*/
import { stableHarvestId } from './stable-id.js';
import type { SourceAdapter, UniversalImportItem, ConversationMessage } from './types.js';
import { asRecord, firstString, getArray, getString, type RawRecord } from './raw-types.js';
export class GeminiAdapter implements SourceAdapter {
readonly sourceType = 'gemini' as const;
readonly displayName = 'Gemini';
parse(input: unknown): UniversalImportItem[] {
// Handle different Gemini export formats
if (Array.isArray(input)) {
return this.parseConversationArray(input);
}
const root = asRecord(input);
if (!root) return [];
// Google Takeout format: { conversations: [...] }
const conversations = getArray(root, 'conversations');
if (conversations) {
return this.parseConversationArray(conversations);
}
// Gemini API history format: { history: [...] }
if (getArray(root, 'history')) {
return this.parseSingleConversation(root);
}
return [];
}
private parseConversationArray(conversations: unknown[]): UniversalImportItem[] {
const items: UniversalImportItem[] = [];
for (const rawConv of conversations) {
const conv = asRecord(rawConv);
if (!conv) continue;
const title = firstString(conv, 'title', 'name') ?? 'Untitled';
const messages: ConversationMessage[] = [];
const entries = getArray(conv, 'messages') ?? getArray(conv, 'turns') ?? getArray(conv, 'history') ?? [];
for (const rawEntry of entries) {
const entry = asRecord(rawEntry);
if (!entry) continue;
const role = this.resolveRole(entry);
if (!role || role === 'system') continue;
const text = this.extractText(entry);
if (!text) continue;
messages.push({
role,
text,
timestamp: firstString(entry, 'createTime', 'create_time', 'timestamp'),
});
}
if (messages.length === 0) continue;
items.push({
// #7 sticky erasure: conversation id (Takeout export id), not content.
id: stableHarvestId('gemini', firstString(conv, 'id', 'conversationId') ?? `${title}\x00${firstString(conv, 'createTime', 'create_time', 'created_at') ?? ''}`),
source: 'gemini',
type: 'conversation',
title,
content: messages.map(m => `${m.role}: ${m.text}`).join('\n\n'),
messages,
timestamp: firstString(conv, 'createTime', 'create_time', 'created_at') ?? new Date().toISOString(),
metadata: {
conversationId: firstString(conv, 'id', 'conversationId'),
messageCount: messages.length,
model: firstString(conv, 'model', 'modelVersion'),
},
});
}
return items;
}
private parseSingleConversation(conv: RawRecord): UniversalImportItem[] {
const messages: ConversationMessage[] = [];
const entries = getArray(conv, 'history') ?? [];
for (const rawEntry of entries) {
const entry = asRecord(rawEntry);
if (!entry) continue;
const role = this.resolveRole(entry);
if (!role || role === 'system') continue;
const text = this.extractText(entry);
if (!text) continue;
messages.push({ role, text });
}
if (messages.length === 0) return [];
return [{
// {history} API dump carries no id — title+model is the only stable surrogate
// (documented collision risk for two same-title+model dumps; no better anchor).
id: stableHarvestId('gemini', getString(conv, 'title') ?? 'Gemini Conversation', getString(conv, 'model') ?? ''),
source: 'gemini',
type: 'conversation',
title: getString(conv, 'title') ?? 'Gemini Conversation',
content: messages.map(m => `${m.role}: ${m.text}`).join('\n\n'),
messages,
timestamp: new Date().toISOString(),
metadata: { model: getString(conv, 'model') },
}];
}
private resolveRole(entry: RawRecord): 'user' | 'assistant' | 'system' | null {
const role = firstString(entry, 'role', 'author', 'sender');
if (!role) return null;
const r = role.toLowerCase();
if (r === 'user' || r === 'human') return 'user';
if (r === 'model' || r === 'assistant' || r === 'gemini') return 'assistant';
if (r === 'system') return 'system';
return null;
}
private extractText(entry: RawRecord): string {
// Gemini parts format: { parts: [{ text: "..." }] }
const parts = getArray(entry, 'parts');
if (parts) {
return parts
.map(asRecord)
.map(p => {
if (!p) return undefined;
const t = getString(p, 'text');
if (t !== undefined) return t;
// W4.4 (caption parity): media parts were silently dropped.
// Surface the text-bearing fields the export carries — file
// URI/name for fileData, mime type as a presence signal for
// inline images ("did X share a photo?" questions).
const fd = asRecord(p.fileData) ?? asRecord(p.file_data);
if (fd) {
const uri = getString(fd, 'fileUri') ?? getString(fd, 'file_uri') ?? getString(fd, 'displayName');
const mime = getString(fd, 'mimeType') ?? getString(fd, 'mime_type');
return `[Shared file: ${uri ?? mime ?? 'media'}]`;
}
const il = asRecord(p.inlineData) ?? asRecord(p.inline_data);
if (il) {
const mime = getString(il, 'mimeType') ?? getString(il, 'mime_type');
return mime ? `[Shared media: ${mime}]` : undefined;
}
return undefined;
})
.filter((t): t is string => typeof t === 'string')
.join('\n')
.trim();
}
// Simple text field
const text = getString(entry, 'text');
if (text !== undefined) return text.trim();
const content = getString(entry, 'content');
if (content !== undefined) return content.trim();
return '';
}
}

View File

@@ -0,0 +1,45 @@
export * from './types.js';
export { chunkByParagraphs } from './chunk-utils.js';
export { CLASSIFY_PROMPT, EXTRACT_PROMPT, SYNTHESIZE_PROMPT } from './prompts.js';
export { HarvestSourceStore } from './source-store.js';
export { HarvestRunStore, type HarvestRun, type HarvestRunStatus } from './run-store.js';
export { dedup, harvestSetHash, type DedupResult } from './dedup.js';
export { asRecord, getString, getNumber, getArray, firstString, type RawRecord } from './raw-types.js';
export { ChatGPTAdapter } from './chatgpt-adapter.js';
export { ClaudeAdapter } from './claude-adapter.js';
export { ClaudeCodeAdapter } from './claude-code-adapter.js';
export { GeminiAdapter } from './gemini-adapter.js';
export { PerplexityAdapter } from './perplexity-adapter.js';
export { UniversalAdapter } from './universal-adapter.js';
export { MarkdownAdapter } from './markdown-adapter.js';
export { PlaintextAdapter } from './plaintext-adapter.js';
export { UrlAdapter } from './url-adapter.js';
export { PdfAdapter } from './pdf-adapter.js';
export { HarvestPipeline, type LLMCallFn, type PipelineOptions } from './pipeline.js';
// Memory-lane extraction passes (facts / events / profiles). Ported from hive-mind a99ea0e.
export {
extractMemoryLanes,
writeMemoryLaneFrames,
MIND_FACT_PREFIX,
MIND_EVENT_PREFIX,
MIND_PROFILE_PREFIX,
type ExtractedFact,
type ExtractedEvent,
type ExtractedProfile,
type MemoryLaneExtraction,
type WriteLaneFramesResult,
} from './extract-memory-lanes.js';
// Per-turn verbatim dialogue storage (raw-detail lane, write side). Ported from hive-mind a99ea0e.
export {
writeRawTurnFrames,
rawTurnHeader,
parseRawTurnHeader,
rawTurnConvKey,
MIND_RAWTURN_PREFIX,
MAX_TURNS_PER_ITEM,
RAWDETAIL_KILL_SWITCH,
type WriteRawTurnsResult,
type ParsedRawTurnHeader,
} from './raw-turns.js';

View File

@@ -0,0 +1,142 @@
/**
* Markdown Source Adapter — parses .md files into importable items.
*
* Splits markdown by top-level headings (# or ##).
* Each section becomes a separate UniversalImportItem.
* Extracts entities from heading names and bold terms.
*/
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import type { SourceAdapter, UniversalImportItem } from './types.js';
interface MarkdownSection {
heading: string;
level: number;
content: string;
}
function splitByHeadings(text: string): MarkdownSection[] {
const lines = text.split('\n');
const sections: MarkdownSection[] = [];
let currentHeading = '';
let currentLevel = 0;
let currentLines: string[] = [];
for (const line of lines) {
const headingMatch = line.match(/^(#{1,3})\s+(.+)/);
if (headingMatch) {
// Flush previous section
if (currentLines.length > 0 || currentHeading) {
sections.push({
heading: currentHeading,
level: currentLevel,
content: currentLines.join('\n').trim(),
});
}
currentHeading = headingMatch[2].trim();
currentLevel = headingMatch[1].length;
currentLines = [];
} else {
currentLines.push(line);
}
}
// Flush last section
if (currentLines.length > 0 || currentHeading) {
sections.push({
heading: currentHeading,
level: currentLevel,
content: currentLines.join('\n').trim(),
});
}
return sections.filter(s => s.content.length > 0);
}
function extractBoldTerms(text: string): string[] {
const matches = text.matchAll(/\*\*([^*]+)\*\*/g);
const terms: string[] = [];
for (const m of matches) {
const term = m[1].trim();
if (term.length > 1 && term.length < 80) {
terms.push(term);
}
}
return [...new Set(terms)];
}
export class MarkdownAdapter implements SourceAdapter {
readonly sourceType = 'markdown' as const;
readonly displayName = 'Markdown';
parse(input: unknown): UniversalImportItem[] {
if (typeof input !== 'string') return [];
// Input can be a file path or raw markdown content
let content: string;
let sourcePath: string | undefined;
if (input.length < 500 && !input.includes('\n')) {
// Likely a file path
try {
if (fs.existsSync(input)) {
content = fs.readFileSync(input, 'utf-8');
sourcePath = input;
} else {
// Treat as raw content
content = input;
}
} catch {
content = input;
}
} else {
content = input;
}
if (!content.trim()) return [];
const sections = splitByHeadings(content);
// If no headings found, treat entire content as one item
if (sections.length === 0) {
return [{
id: randomUUID(),
source: 'markdown',
type: 'document',
title: sourcePath ? sourcePath.split(/[\\/]/).pop()?.replace('.md', '') ?? 'Document' : 'Document',
content: content.slice(0, 4000),
timestamp: new Date().toISOString(),
metadata: {
...(sourcePath && { filePath: sourcePath }),
contentType: 'note',
},
}];
}
const items: UniversalImportItem[] = [];
const docTitle = sourcePath?.split(/[\\/]/).pop()?.replace('.md', '');
for (const section of sections) {
const boldTerms = extractBoldTerms(section.content);
const entities = boldTerms.slice(0, 10).map(t => ({ name: t, type: 'concept' }));
items.push({
id: randomUUID(),
source: 'markdown',
type: 'document',
title: section.heading || docTitle || 'Untitled section',
content: section.content.slice(0, 4000),
timestamp: new Date().toISOString(),
metadata: {
...(sourcePath && { filePath: sourcePath }),
headingLevel: section.level,
contentType: 'note',
...(entities.length > 0 && { entities }),
},
});
}
return items;
}
}

View File

@@ -0,0 +1,97 @@
/**
* PDF Source Adapter — extracts text from PDF files.
*
* Uses pdf-parse as an optional dependency.
* If pdf-parse is not installed, provides a clear error message.
*
* Splits PDF text by pages, groups into ~3000 char chunks.
*/
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import type { SourceAdapter, UniversalImportItem } from './types.js';
import { chunkByParagraphs } from './chunk-utils.js';
const MAX_CHUNK_LENGTH = 3000;
export class PdfAdapter implements SourceAdapter {
readonly sourceType = 'pdf' as const;
readonly displayName = 'PDF Document';
parse(_input: unknown): UniversalImportItem[] {
// Synchronous parse not supported for PDF — use parseFile()
return [];
}
/** Parse a PDF file from a file path. */
async parseFile(filePath: string): Promise<UniversalImportItem[]> {
// Dynamic import — pdf-parse is optional
let PDFParseClass: unknown;
try {
const mod = await import('pdf-parse');
PDFParseClass = mod.PDFParse;
} catch {
throw new Error(
'pdf-parse is not installed. Install it with: npm install pdf-parse\n'
+ 'Then retry the import.',
);
}
if (typeof PDFParseClass !== 'function') {
throw new Error('pdf-parse module found but PDFParse class not available.');
}
if (!fs.existsSync(filePath)) {
throw new Error(`PDF file not found: ${filePath}`);
}
const buffer = fs.readFileSync(filePath);
// PDFParse constructor takes { data: Buffer|Uint8Array }
const parser = new (PDFParseClass as new (opts: { data: Buffer }) => {
load(): Promise<void>;
getText(params?: object): Promise<{ text: string; pages: { text: string }[] }>;
getInfo(params?: object): Promise<{ info: Record<string, string>; numPages: number }>;
destroy(): Promise<void>;
})({ data: buffer });
await parser.load();
const textResult = await parser.getText();
let infoResult: { info: Record<string, string>; numPages: number } | undefined;
try {
infoResult = await parser.getInfo();
} catch { /* info extraction is non-fatal */ }
await parser.destroy();
const fullText = textResult.text ?? '';
if (fullText.trim().length < 10) {
return [];
}
const info = infoResult?.info ?? {};
const numPages = infoResult?.numPages ?? 0;
const docTitle = info['Title']
?? filePath.split(/[\\/]/).pop()?.replace('.pdf', '')
?? 'PDF Document';
const chunks = chunkByParagraphs(fullText, MAX_CHUNK_LENGTH);
return chunks.map((chunk, i) => ({
id: randomUUID(),
source: 'pdf' as const,
type: 'document' as const,
title: chunks.length > 1 ? `${docTitle} (part ${i + 1})` : docTitle,
content: chunk.slice(0, 4000),
timestamp: new Date().toISOString(),
metadata: {
filePath,
contentType: 'paper' as const,
pages: numPages,
...(info['Author'] && { author: info['Author'] }),
part: i + 1,
totalParts: chunks.length,
},
}));
}
}

View File

@@ -0,0 +1,163 @@
/**
* Perplexity Adapter — parse Perplexity conversation exports.
*
* Perplexity's export (via account → settings → data export) delivers
* threads as JSON. Two shapes seen in the wild:
*
* {
* "threads": [{ "id", "title", "created_at", "messages": [...] }]
* }
*
* or a bare array of threads. Also handles a per-thread "messages"
* variant where each message has: role, content, sources? (citations).
*
* Sources/citations per assistant message are flattened into the text
* body as "Sources: <url1>, <url2>" — they're the distinguishing
* feature of Perplexity answers and should survive into the harvest
* pipeline for downstream attribution.
*/
import { randomUUID } from 'node:crypto';
import type { SourceAdapter, UniversalImportItem, ConversationMessage } from './types.js';
export class PerplexityAdapter implements SourceAdapter {
readonly sourceType = 'perplexity' as const;
readonly displayName = 'Perplexity';
parse(input: unknown): UniversalImportItem[] {
if (Array.isArray(input)) {
return this.parseThreadArray(input);
}
const root = input as Record<string, unknown> | null;
if (!root || typeof root !== 'object') return [];
// Common wrapper keys observed in Perplexity exports
if (Array.isArray(root.threads)) {
return this.parseThreadArray(root.threads as unknown[]);
}
if (Array.isArray(root.conversations)) {
return this.parseThreadArray(root.conversations as unknown[]);
}
if (Array.isArray(root.items)) {
return this.parseThreadArray(root.items as unknown[]);
}
// Single-thread shape: the root itself has messages
if (Array.isArray(root.messages)) {
return this.parseSingleThread(root);
}
return [];
}
private parseThreadArray(threads: unknown[]): UniversalImportItem[] {
const items: UniversalImportItem[] = [];
for (const raw of threads) {
const thread = raw as Record<string, unknown> | null;
if (!thread || typeof thread !== 'object') continue;
const built = this.buildItem(thread);
if (built) items.push(built);
}
return items;
}
private parseSingleThread(thread: Record<string, unknown>): UniversalImportItem[] {
const built = this.buildItem(thread);
return built ? [built] : [];
}
private buildItem(thread: Record<string, unknown>): UniversalImportItem | null {
const rawMessages = (thread.messages ?? thread.turns ?? []) as unknown[];
if (!Array.isArray(rawMessages) || rawMessages.length === 0) return null;
const messages: ConversationMessage[] = [];
for (const rawMsg of rawMessages) {
const msg = rawMsg as Record<string, unknown> | null;
if (!msg) continue;
const role = this.resolveRole(msg);
if (!role || role === 'system') continue;
const text = this.extractText(msg);
if (!text) continue;
// Flatten citations/sources into the text so they survive the pipeline.
const sources = this.extractSources(msg);
const textWithSources = sources.length > 0
? `${text}\n\nSources: ${sources.join(', ')}`
: text;
messages.push({
role,
text: textWithSources,
timestamp: this.extractTimestamp(msg),
});
}
if (messages.length === 0) return null;
const title = (thread.title as string) ?? (thread.name as string) ?? 'Perplexity Thread';
return {
id: randomUUID(),
source: 'perplexity',
type: 'conversation',
title,
content: messages.map(m => `${m.role}: ${m.text}`).join('\n\n'),
messages,
timestamp: this.extractTimestamp(thread) ?? new Date().toISOString(),
metadata: {
threadId: thread.id ?? thread.threadId,
messageCount: messages.length,
hasCitations: messages.some(m => m.text.includes('Sources:')),
},
};
}
private resolveRole(entry: Record<string, unknown>): 'user' | 'assistant' | 'system' | null {
const raw = entry.role ?? entry.author ?? entry.sender ?? entry.type;
if (!raw) return null;
const r = String(raw).toLowerCase();
if (r === 'user' || r === 'human' || r === 'question') return 'user';
if (r === 'assistant' || r === 'ai' || r === 'perplexity' || r === 'answer') return 'assistant';
if (r === 'system') return 'system';
return null;
}
private extractText(entry: Record<string, unknown>): string {
// Try common text field names
if (typeof entry.content === 'string') return entry.content.trim();
if (typeof entry.text === 'string') return entry.text.trim();
if (typeof entry.answer === 'string') return entry.answer.trim();
if (typeof entry.query === 'string') return entry.query.trim();
// ChatGPT-like structured content: { parts: [...] }
const content = entry.content as Record<string, unknown> | undefined;
if (content && Array.isArray(content.parts)) {
return content.parts.filter(p => typeof p === 'string').join('\n').trim();
}
return '';
}
private extractSources(entry: Record<string, unknown>): string[] {
const raw = entry.sources ?? entry.citations ?? entry.web_results;
if (!Array.isArray(raw)) return [];
const urls: string[] = [];
for (const src of raw) {
if (typeof src === 'string') {
urls.push(src);
} else if (src && typeof src === 'object') {
const s = src as Record<string, unknown>;
const url = s.url ?? s.link ?? s.href;
if (typeof url === 'string') urls.push(url);
}
}
return urls;
}
private extractTimestamp(entry: Record<string, unknown>): string | undefined {
const raw = entry.timestamp ?? entry.created_at ?? entry.createdAt ?? entry.time;
if (typeof raw === 'string') return raw;
if (typeof raw === 'number') return new Date(raw).toISOString();
return undefined;
}
}

View File

@@ -0,0 +1,339 @@
/**
* HarvestPipeline — orchestrates the 4-pass distillation pipeline.
*
* Pass 1: Classify (Haiku — cheap)
* Pass 2: Extract (Sonnet — accurate)
* Pass 3: Synthesize (Sonnet — accurate)
* Pass 4: Dedup (local — no LLM)
*
* The pipeline accepts UniversalImportItems and produces DistilledKnowledge[].
* LLM calls are batched (20 items per call) to optimize cost.
*/
import type {
UniversalImportItem, ClassifiedItem, ExtractedContent,
DistilledKnowledge, HarvestPipelineResult, ImportSourceType,
} from './types.js';
import { CLASSIFY_PROMPT, EXTRACT_PROMPT, SYNTHESIZE_PROMPT } from './prompts.js';
import { dedup } from './dedup.js';
import { scanForInjection } from '../injection-scanner.js';
import { createCoreLogger } from '../logger.js';
const log = createCoreLogger('harvest-pipeline');
const BATCH_SIZE = 20;
const CONCURRENCY_CAP = 3;
export interface LLMCallFn {
(prompt: string, model: 'fast' | 'accurate'): Promise<string>;
}
export interface PipelineOptions {
llmCall: LLMCallFn;
existingContents?: string[];
onProgress?: (stage: string, current: number, total: number) => void;
/** Items per LLM batch call (default: 20). */
batchSize?: number;
/** Max concurrent LLM batch calls (default: 3). */
concurrency?: number;
/**
* Fallback behavior when the Pass 1 (classify) LLM call throws.
* - `'skip'` (default, safer): drop the batch. Under-inclusion beats cost/noise inflation.
* - `'pass-through-medium'` (legacy): promote every item to `value: 'medium'`. This is what
* the pipeline did historically but it runs extract + synthesize on junk and pollutes
* memory with trivial greetings/debugging loops when the classify model hiccups.
*/
classifyFailureFallback?: 'skip' | 'pass-through-medium';
}
/** Safely parse JSON from LLM response, handling markdown code fences. */
function parseLLMJson<T>(raw: string): T[] {
const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
try {
const parsed = JSON.parse(cleaned);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [];
}
}
/** Split array into batches. */
function batch<T>(items: T[], size: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += size) {
batches.push(items.slice(i, i + size));
}
return batches;
}
/** Run async tasks with a concurrency cap (tumbling window — waits for full batch before next). */
async function runWithConcurrency<T>(
tasks: (() => Promise<T>)[],
cap: number,
): Promise<T[]> {
const results: T[] = [];
for (let i = 0; i < tasks.length; i += cap) {
const window = tasks.slice(i, i + cap);
const windowResults = await Promise.all(window.map(fn => fn()));
results.push(...windowResults);
}
return results;
}
export class HarvestPipeline {
private llmCall: LLMCallFn;
private existingContents: string[];
private onProgress?: (stage: string, current: number, total: number) => void;
private classifyFailureFallback: 'skip' | 'pass-through-medium';
private batchSize: number;
private concurrency: number;
constructor(options: PipelineOptions) {
this.llmCall = options.llmCall;
this.existingContents = options.existingContents ?? [];
this.onProgress = options.onProgress;
this.classifyFailureFallback = options.classifyFailureFallback ?? 'skip';
this.batchSize = options.batchSize ?? BATCH_SIZE;
this.concurrency = options.concurrency ?? CONCURRENCY_CAP;
}
async run(items: UniversalImportItem[], source: ImportSourceType): Promise<HarvestPipelineResult> {
const startTime = Date.now();
const errors: string[] = [];
log.info('harvest pipeline starting', { source, itemCount: items.length, batchSize: this.batchSize, concurrency: this.concurrency });
// Pass 0: Injection scan — drop any item whose title or content carries a
// prompt-injection payload (role_override / prompt_extraction / instruction_injection).
// Harvest ingests UNTRUSTED external exports (ChatGPT/Claude/Gemini JSON dumps,
// Perplexity shares, URL fetches). A hostile file must not flow through to the
// LLM passes or into memory frames.
const originalCount = items.length;
items = items.filter((item) => {
// Scan title + first 4KB of content — enough to catch payloads hidden in either field.
// Using 'tool_output' context since imports are external data, weighted like tool output.
const probe = `${item.title ?? ''}\n${(item.content ?? '').slice(0, 4000)}`;
const scan = scanForInjection(probe, 'tool_output');
if (!scan.safe) {
const reason = scan.flags.join(',');
log.warn('dropping harvest item with injection payload', {
itemId: item.id,
title: item.title?.slice(0, 80),
flags: scan.flags,
score: scan.score,
});
errors.push(`Blocked item "${item.title?.slice(0, 40) ?? item.id}" — injection detected (${reason})`);
return false;
}
return true;
});
const blockedCount = originalCount - items.length;
if (blockedCount > 0) {
log.info(`harvest security: blocked ${blockedCount} of ${originalCount} items for injection patterns`);
}
// Pass 1: Classify
this.onProgress?.('classify', 0, items.length);
const classified = await this.classify(items, errors);
const valuable = classified.filter(c => c.value !== 'skip');
// Pass 2: Extract
this.onProgress?.('extract', 0, valuable.length);
const extracted = await this.extract(valuable, errors);
// Pass 3: Synthesize
this.onProgress?.('synthesize', 0, extracted.length);
const distilled = await this.synthesize(extracted, source, errors);
// Pass 4: Dedup
this.onProgress?.('dedup', 0, distilled.length);
const dedupResult = dedup(distilled, this.existingContents);
const durationMs = Date.now() - startTime;
log.info('harvest pipeline complete', {
source, itemsReceived: originalCount, classified: classified.length,
extracted: extracted.length, distilled: distilled.length,
unique: dedupResult.unique.length, dupsSkipped: dedupResult.duplicatesSkipped,
errors: errors.length, durationMs,
});
return {
source, itemsReceived: originalCount,
itemsClassified: classified.length,
itemsSkipped: classified.length - valuable.length,
itemsExtracted: extracted.length,
knowledgeDistilled: dedupResult.unique,
framesSaved: 0, // Caller handles persistence
entitiesCreated: 0,
relationsCreated: 0,
identityUpdates: dedupResult.unique.filter(k => k.targetLayer === 'identity').length,
duplicatesSkipped: dedupResult.duplicatesSkipped,
errors,
costUsd: 0, // Caller tracks cost
durationMs,
};
}
private async classify(items: UniversalImportItem[], errors: string[]): Promise<ClassifiedItem[]> {
const results: ClassifiedItem[] = [];
const batches = batch(items, this.batchSize);
// M3: run batches with concurrency cap instead of sequentially
const tasks = batches.map((b, i) => async () => {
this.onProgress?.('classify', i * this.batchSize, items.length);
const prompt = CLASSIFY_PROMPT + b.map((item, idx) => (
`\n--- Item ${idx} (id: ${item.id}) ---\nTitle: ${item.title}\nSource: ${item.source}\nType: ${item.type}\nContent (first 500 chars): ${item.content.slice(0, 500)}\n`
)).join('');
try {
const response = await this.llmCall(prompt, 'fast');
const parsed = parseLLMJson<{ itemId?: string; domain?: string; value?: string; categories?: string[] }>(response);
const batchResults: ClassifiedItem[] = [];
for (const entry of parsed) {
if (!entry.itemId) {
log.warn('classify entry missing itemId — skipping', { batchIndex: i });
continue;
}
const item = b.find(it => it.id === entry.itemId);
if (!item) {
log.warn('classify entry itemId does not match any batch item — skipping', { itemId: entry.itemId, batchIndex: i });
continue;
}
batchResults.push({
item,
domain: (entry.domain as ClassifiedItem['domain']) ?? 'mixed',
value: (entry.value as ClassifiedItem['value']) ?? 'medium',
categories: entry.categories ?? [],
});
}
return batchResults;
} catch (err) {
errors.push(`Classify batch ${i} failed: ${err instanceof Error ? err.message : 'unknown'}`);
if (this.classifyFailureFallback === 'pass-through-medium') {
return b.map(item => ({ item, domain: 'mixed' as const, value: 'medium' as const, categories: [] as string[] }));
}
return [] as ClassifiedItem[];
}
});
const batchResults = await runWithConcurrency(tasks, this.concurrency);
for (const br of batchResults) results.push(...br);
return results;
}
private async extract(classified: ClassifiedItem[], errors: string[]): Promise<ExtractedContent[]> {
const results: ExtractedContent[] = [];
const batches = batch(classified, this.batchSize);
// M3: concurrent batches with cap
const tasks = batches.map((b, i) => async () => {
this.onProgress?.('extract', i * this.batchSize, classified.length);
const prompt = EXTRACT_PROMPT + b.map((c, idx) => (
`\n--- Conversation ${idx} (id: ${c.item.id}, value: ${c.value}, categories: ${c.categories.join(',')}) ---\nTitle: ${c.item.title}\n${c.item.content.slice(0, 2000)}\n`
)).join('');
try {
const response = await this.llmCall(prompt, 'accurate');
const parsed = parseLLMJson<{
itemId?: string;
decisions?: unknown[]; preferences?: unknown[]; facts?: unknown[];
knowledge?: unknown[]; entities?: unknown[]; relations?: unknown[];
}>(response);
const batchResults: ExtractedContent[] = [];
for (const entry of parsed) {
if (!entry.itemId) {
log.warn('extract entry missing itemId — skipping', { batchIndex: i });
continue;
}
if (!b.some(c => c.item.id === entry.itemId)) {
log.warn('extract entry itemId does not match batch — skipping', { itemId: entry.itemId, batchIndex: i });
continue;
}
batchResults.push({
itemId: entry.itemId,
decisions: (entry.decisions ?? []) as ExtractedContent['decisions'],
preferences: (entry.preferences ?? []) as ExtractedContent['preferences'],
facts: (entry.facts ?? []) as ExtractedContent['facts'],
knowledge: (entry.knowledge ?? []) as ExtractedContent['knowledge'],
entities: (entry.entities ?? []) as ExtractedContent['entities'],
relations: (entry.relations ?? []) as ExtractedContent['relations'],
});
}
return batchResults;
} catch (err) {
errors.push(`Extract batch ${i} failed: ${err instanceof Error ? err.message : 'unknown'}`);
return [] as ExtractedContent[];
}
});
const batchResults = await runWithConcurrency(tasks, this.concurrency);
for (const br of batchResults) results.push(...br);
return results;
}
private async synthesize(
extracted: ExtractedContent[],
source: ImportSourceType,
errors: string[],
): Promise<DistilledKnowledge[]> {
const results: DistilledKnowledge[] = [];
const batches = batch(extracted, this.batchSize);
// Review C2: per-item serialization with individual budget. The old flat
// `JSON.stringify(b, null, 2).slice(0, 8000)` truncated the *middle* of the last
// item's JSON on a long batch; parseLLMJson returned [] on the malformed tail and
// every subsequent item silently vanished with no error. Now each item gets its
// own PER_ITEM_BUDGET and survives regardless of batch size.
const PER_ITEM_BUDGET = 1200;
// M3: concurrent batches with cap
const tasks = batches.map((b, i) => async () => {
this.onProgress?.('synthesize', i * this.batchSize, extracted.length);
const serialized = b.map((ec, idx) => {
const json = JSON.stringify(ec, null, 2);
const trimmed = json.length > PER_ITEM_BUDGET
? json.slice(0, PER_ITEM_BUDGET) + '\n ... (truncated — full item in trace)'
: json;
return `\n--- Item ${idx} (id: ${ec.itemId}) ---\n${trimmed}`;
}).join('');
const prompt = SYNTHESIZE_PROMPT + serialized;
try {
const response = await this.llmCall(prompt, 'accurate');
const parsed = parseLLMJson<{
targetLayer?: string; frameType?: string; importance?: string;
content?: string; confidence?: number;
}>(response);
const batchResults: DistilledKnowledge[] = [];
for (const entry of parsed) {
batchResults.push({
targetLayer: (entry.targetLayer as DistilledKnowledge['targetLayer']) ?? 'frame',
frameType: (entry.frameType as DistilledKnowledge['frameType']) ?? 'I',
importance: (entry.importance as DistilledKnowledge['importance']) ?? 'normal',
content: entry.content ?? '',
provenance: {
originalSource: source,
importedAt: new Date().toISOString(),
distillationModel: 'accurate',
confidence: entry.confidence ?? 0.7,
pass: 3,
},
});
}
return batchResults;
} catch (err) {
errors.push(`Synthesize batch ${i} failed: ${err instanceof Error ? err.message : 'unknown'}`);
return [] as DistilledKnowledge[];
}
});
const batchResults = await runWithConcurrency(tasks, this.concurrency);
for (const br of batchResults) results.push(...br);
return results;
}
}

View File

@@ -0,0 +1,61 @@
/**
* Plaintext Source Adapter — parses .txt files into importable items.
*
* Splits text by double-newline paragraphs.
* Groups paragraphs into chunks of ~2000 chars max.
*/
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import type { SourceAdapter, UniversalImportItem } from './types.js';
import { chunkByParagraphs } from './chunk-utils.js';
export class PlaintextAdapter implements SourceAdapter {
readonly sourceType = 'plaintext' as const;
readonly displayName = 'Plain Text';
parse(input: unknown): UniversalImportItem[] {
if (typeof input !== 'string') return [];
let content: string;
let sourcePath: string | undefined;
// Check if input is a file path
if (input.length < 500 && !input.includes('\n')) {
try {
if (fs.existsSync(input)) {
content = fs.readFileSync(input, 'utf-8');
sourcePath = input;
} else {
content = input;
}
} catch {
content = input;
}
} else {
content = input;
}
if (!content.trim()) return [];
const chunks = chunkByParagraphs(content);
const docTitle = sourcePath?.split(/[\\/]/).pop()?.replace(/\.\w+$/, '');
return chunks.map((chunk, i) => ({
id: randomUUID(),
source: 'plaintext' as const,
type: 'document' as const,
title: docTitle
? (chunks.length > 1 ? `${docTitle} (part ${i + 1})` : docTitle)
: `Text fragment ${i + 1}`,
content: chunk.slice(0, 4000),
timestamp: new Date().toISOString(),
metadata: {
...(sourcePath && { filePath: sourcePath }),
contentType: 'note',
part: i + 1,
totalParts: chunks.length,
},
}));
}
}

View File

@@ -0,0 +1,72 @@
/**
* Harvest Pipeline Prompts — LLM prompt templates for each distillation pass.
*/
export const CLASSIFY_PROMPT = `You are a knowledge classifier. For each conversation/item below, classify it.
Return a JSON array with one entry per item:
[{
"itemId": "...",
"domain": "work" | "personal" | "technical" | "mixed",
"value": "high" | "medium" | "low" | "skip",
"categories": ["decision", "preference", "fact", "knowledge", "project", "identity", "trivial"]
}]
Rules:
- "skip" = greetings, trivial exchanges, "hello", "thanks", debugging loops with no insight
- "high" = decisions, preferences, personal facts, project context, technical architecture
- "medium" = general knowledge, research, learning
- "low" = routine questions with generic answers
Items:
`;
export const EXTRACT_PROMPT = `You are a knowledge extractor. For each classified conversation, extract structured knowledge.
Return a JSON array:
[{
"itemId": "...",
"decisions": ["chose X over Y because Z"],
"preferences": ["prefers dark mode", "likes concise responses"],
"facts": ["works at Egzakta Group", "role is CEO"],
"knowledge": ["React 18 concurrent features improve perceived performance"],
"entities": [{"name": "Egzakta Group", "type": "organization"}],
"relations": [{"source": "Marko", "target": "Egzakta Group", "relation": "works_at"}]
}]
Rules:
- Only extract what the USER stated or decided, not what the AI suggested
- Decisions must include the reason if one was given
- Preferences must be actionable (not "I like good code" — too vague)
- Facts must be verifiable or specific (names, roles, companies, tech stack)
- Entities: types are person, organization, project, technology, concept, location, event
- Relations: use lowercase_snake_case for relation types
Conversations:
`;
export const SYNTHESIZE_PROMPT = `You are a memory synthesizer. Convert extracted knowledge into structured memory frames.
For each extraction, produce frames suitable for a persistent memory system:
Return a JSON array:
[{
"targetLayer": "identity" | "frame" | "kg_entity" | "kg_relation",
"frameType": "I",
"importance": "critical" | "important" | "normal",
"content": "The actual memory content, written as a clear statement",
"confidence": 0.0-1.0
}]
Rules:
- "identity" = personal facts (name, role, company, capabilities, personality traits)
- "frame" with importance "important" = decisions and preferences
- "frame" with importance "normal" = general knowledge and facts
- "kg_entity" = entities to add to the knowledge graph
- "kg_relation" = relationships between entities
- Content should be self-contained — readable without the original conversation
- Deduplicate: if two items say the same thing, pick the most complete version
- confidence: 1.0 = user explicitly stated, 0.7 = strongly implied, 0.5 = inferred
Extractions:
`;

View File

@@ -0,0 +1,169 @@
/**
* raw-turns.ts — W4.6 per-turn verbatim dialogue storage (write side).
*
* The W3.4 ablation attributed the benchmark's single-hop win to the
* RAWDETAIL escalation lane (+2.40 z=1.95; captions alone +0.26 ns) —
* fine-grained perceptual detail survives ONLY in verbatim turns; the
* distillation passes carry it generically. This module stores each
* conversation turn as its own frame so the recall-side lane
* (`mind/raw-detail-lane.ts`) can pool / CE-rerank / neighbor-expand them.
*
* Frame convention (first line is the lane tag; body is the verbatim turn):
*
* `[mind-rawturn conv:<key> turn:<n> speaker:<s>]\n<turn text>`
*
* - `conv:<key>` sanitized item id — groups turns of one conversation
* - `turn:<n>` contiguous index over STORED turns (dialogue order) —
* the ±1 adjacency key. Production frames interleave
* across sources, so the benchmark's id-ordering trick
* does not transfer; the index makes adjacency explicit.
* - `speaker:<s>` sanitized role/name (no whitespace, no `]`)
*
* `[mind-` prefixing keeps raw turns out of the memory-lane extraction
* cron's source material (its `NOT LIKE '[mind-%'` self-feeding guard) and
* lets recall dedup them against the snippet lanes by content prefix.
*
* Every turn is injection-scanned BEFORE write: verbatim dialogue is the
* most injection-prone frame class, and recallMemory blocks the ENTIRE
* recall block on a scan hit — poisoned turns must die here, not there.
*
* Storage growth is the accepted tradeoff (Marko GO 2026-06-11, plan §6.2).
*/
import type { FrameStore } from '../mind/frames.js';
import type { UniversalImportItem } from './types.js';
import { HARVEST_FRAME_CONTENT_CAP } from './types.js';
import { scanForInjection } from '../injection-scanner.js';
import { createCoreLogger } from '../logger.js';
const log = createCoreLogger('raw-turns');
/** First-line content prefix for raw-turn frames (recall lane fetches by this). */
export const MIND_RAWTURN_PREFIX = '[mind-rawturn';
/** Hard per-conversation cap — backstop against pathological exports.
* LoCoMo conversations run ~600 turns; 2000 leaves generous headroom. */
export const MAX_TURNS_PER_ITEM = 2000;
/** Env kill switch (checked by CALLERS, mirrored here for the recall lane). */
export const RAWDETAIL_KILL_SWITCH = 'WAGGLE_RAWDETAIL';
export interface WriteRawTurnsResult {
written: number;
skippedEmpty: number;
injectionDropped: number;
/** true when MAX_TURNS_PER_ITEM truncated the conversation (logged, never silent). */
capped: boolean;
}
/** Strict ISO-8601 gate (same contract as FrameStore.createIFrame). */
function isIsoTimestamp(value: string | undefined): value is string {
return typeof value === 'string'
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(value)
&& Number.isFinite(Date.parse(value));
}
/** Sanitize a header token: keep [A-Za-z0-9_-], collapse everything else to '-'.
* Removes `]`, whitespace, and LIKE metacharacters (% _ kept — they're safe
* in equality-style prefix lookups because the recall lane LIKE-escapes). */
function sanitizeToken(value: string, maxLen: number): string {
const cleaned = value.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
return (cleaned || 'unknown').slice(0, maxLen);
}
/** Build the raw-turn header for conv/turn/speaker. Exported for the recall
* lane's neighbor lookups (single source of truth for the format). */
export function rawTurnHeader(convKey: string, turn: number, speaker: string): string {
return `${MIND_RAWTURN_PREFIX} conv:${convKey} turn:${turn} speaker:${speaker}]`;
}
export interface ParsedRawTurnHeader {
conv: string;
turn: number;
speaker: string;
}
/** Parse a raw-turn frame's first line. Returns null for non-rawturn content. */
export function parseRawTurnHeader(content: string): ParsedRawTurnHeader | null {
const m = content.match(/^\[mind-rawturn conv:([A-Za-z0-9_-]+) turn:(\d+) speaker:([A-Za-z0-9_-]+)\]/);
if (!m) return null;
return { conv: m[1], turn: parseInt(m[2], 10), speaker: m[3] };
}
/** Conversation key for an import item (sanitized, stable across re-imports).
* Params are widened to plain strings so the GDPR erasure path (which knows a
* subject only as free-string source + source_ref) can reconstruct the exact
* same key; UniversalImportItem's narrower fields still satisfy it. */
export function rawTurnConvKey(item: { source: string; id: string }): string {
return sanitizeToken(`${item.source}-${item.id}`, 64);
}
/**
* Store each user/assistant turn of `item.messages` as a `[mind-rawturn …]`
* frame. No-op (all zeros) when the item carries no messages.
*
* - turn index is contiguous over STORED turns (skips don't leave gaps —
* ±1 adjacency stays meaningful over the stored dialogue)
* - system messages are skipped (boilerplate, not dialogue evidence)
* - created_at: message timestamp if valid ISO, else item timestamp, else
* schema default (createIFrame validates again — never writes junk)
* - importance 'normal' (stays out of the K5 importance lane), source 'import'
* - createIFrame content-dedup makes re-imports idempotent within its
* 500-frame recency window; source-level content hashing in the harvest
* routes guards the wider case (unchanged exports never reach here)
*/
export function writeRawTurnFrames(
frames: FrameStore,
gopId: string,
item: UniversalImportItem,
): WriteRawTurnsResult {
const result: WriteRawTurnsResult = {
written: 0, skippedEmpty: 0, injectionDropped: 0, capped: false,
};
const messages = item.messages;
if (!Array.isArray(messages) || messages.length === 0) return result;
const convKey = rawTurnConvKey(item);
const itemTs = isIsoTimestamp(item.timestamp) ? item.timestamp : undefined;
let turn = 0;
for (const msg of messages) {
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const text = (msg.text ?? '').trim();
if (text.length === 0) {
result.skippedEmpty++;
continue;
}
if (turn >= MAX_TURNS_PER_ITEM) {
result.capped = true;
break;
}
// Scan first 4KB — same probe budget as the harvest pipeline's Pass 0.
const scan = scanForInjection(text.slice(0, 4000), 'tool_output');
if (!scan.safe) {
result.injectionDropped++;
log.warn('dropping raw turn with injection payload', {
conv: convKey, turn, flags: scan.flags.join(','),
});
continue;
}
const speaker = sanitizeToken(msg.role, 24);
const createdAt = isIsoTimestamp(msg.timestamp) ? msg.timestamp : itemTs;
frames.createIFrame(
gopId,
`${rawTurnHeader(convKey, turn, speaker)}\n${text.slice(0, HARVEST_FRAME_CONTENT_CAP)}`,
'normal',
'import',
createdAt,
);
result.written++;
turn++;
}
if (result.capped) {
log.warn('raw-turn storage capped — conversation exceeds MAX_TURNS_PER_ITEM', {
conv: convKey, stored: result.written, totalMessages: messages.length,
});
}
return result;
}

View File

@@ -0,0 +1,45 @@
/**
* Raw external export shapes — loosely-typed structures as they arrive from
* third-party JSON exports (ChatGPT / Claude / Gemini / generic).
*
* These adapters parse UNTRUSTED external data, so every field is optional and
* widened. Property access goes through the narrowing helpers below rather than
* casting to `any`, so a malformed export degrades to "skip" instead of throwing.
*/
/** A JSON object whose keys are unknown until narrowed. */
export type RawRecord = Record<string, unknown>;
/** Narrow an unknown value to a plain object, or null. */
export function asRecord(value: unknown): RawRecord | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as RawRecord)
: null;
}
/** Read a string property, or undefined if absent / wrong type. */
export function getString(obj: RawRecord, key: string): string | undefined {
const v = obj[key];
return typeof v === 'string' ? v : undefined;
}
/** Read a number property, or undefined if absent / wrong type. */
export function getNumber(obj: RawRecord, key: string): number | undefined {
const v = obj[key];
return typeof v === 'number' ? v : undefined;
}
/** Read an array property as unknown[], or undefined if absent / wrong type. */
export function getArray(obj: RawRecord, key: string): unknown[] | undefined {
const v = obj[key];
return Array.isArray(v) ? v : undefined;
}
/** First defined string among the given keys (export shapes vary). */
export function firstString(obj: RawRecord, ...keys: string[]): string | undefined {
for (const key of keys) {
const v = getString(obj, key);
if (v !== undefined) return v;
}
return undefined;
}

View File

@@ -0,0 +1,191 @@
/**
* HarvestRunStore — tracks the lifecycle of individual harvest commit runs
* so the UI can surface interrupted runs and offer to resume them.
*
* A run is a single POST /api/harvest/commit invocation. States:
* running — route is executing
* completed — finished successfully
* failed — explicit error; `error_message` is populated
* abandoned — user chose to discard; cache is deleted
*
* A "interrupted" run from the UI's perspective is any `running` or `failed`
* row with a surviving `input_cache_path`. The route never transitions
* `running` -> `interrupted` — it simply never finalizes when the client
* disconnects, so the row stays `running` forever. getLatestInterrupted()
* surfaces the latest such row.
*
* Resume = replay the same input payload; FrameStore.createIFrame dedups
* on content so already-saved frames become no-ops. No fine-grained
* checkpoint-offset math needed.
*/
import type { MindDB } from '../mind/db.js';
import type { ImportSourceType } from './types.js';
const HARVEST_RUNS_DDL = `
CREATE TABLE IF NOT EXISTS harvest_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
total_items INTEGER NOT NULL DEFAULT 0,
items_saved INTEGER NOT NULL DEFAULT 0,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT,
error_message TEXT,
input_cache_path TEXT
);
`;
export type HarvestRunStatus = 'running' | 'completed' | 'failed' | 'abandoned';
export interface HarvestRun {
id: number;
source: ImportSourceType;
status: HarvestRunStatus;
totalItems: number;
itemsSaved: number;
startedAt: string;
updatedAt: string;
finishedAt: string | null;
errorMessage: string | null;
inputCachePath: string | null;
}
export class HarvestRunStore {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
this.ensureTable();
}
private ensureTable(): void {
const raw = this.db.getDatabase();
const existsRow = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='harvest_runs'",
).get();
if (!existsRow) {
raw.exec(HARVEST_RUNS_DDL);
}
}
/**
* Create a new `running` run record. Caller holds the id for subsequent
* heartbeat/complete/fail calls.
*/
start(source: ImportSourceType, totalItems: number, inputCachePath: string | null = null): HarvestRun {
const raw = this.db.getDatabase();
const result = raw.prepare(`
INSERT INTO harvest_runs (source, status, total_items, input_cache_path)
VALUES (?, 'running', ?, ?)
`).run(source, totalItems, inputCachePath);
return this.getById(Number(result.lastInsertRowid))!;
}
/** Update items_saved + updated_at on a running row. No-op on terminal rows. */
heartbeat(id: number, itemsSaved: number): void {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE harvest_runs SET
items_saved = ?,
updated_at = datetime('now')
WHERE id = ? AND status = 'running'
`).run(itemsSaved, id);
}
/** Mark a run completed. Idempotent — no-op on terminal rows. */
complete(id: number, itemsSaved: number): void {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE harvest_runs SET
status = 'completed',
items_saved = ?,
updated_at = datetime('now'),
finished_at = datetime('now')
WHERE id = ? AND status = 'running'
`).run(itemsSaved, id);
}
/** Mark a run failed with an error message. Idempotent — no-op on terminal rows. */
fail(id: number, errorMessage: string, itemsSaved?: number): void {
const raw = this.db.getDatabase();
if (typeof itemsSaved === 'number') {
raw.prepare(`
UPDATE harvest_runs SET
status = 'failed',
items_saved = ?,
error_message = ?,
updated_at = datetime('now'),
finished_at = datetime('now')
WHERE id = ? AND status = 'running'
`).run(itemsSaved, errorMessage.slice(0, 2000), id);
} else {
raw.prepare(`
UPDATE harvest_runs SET
status = 'failed',
error_message = ?,
updated_at = datetime('now'),
finished_at = datetime('now')
WHERE id = ? AND status = 'running'
`).run(errorMessage.slice(0, 2000), id);
}
}
/** Mark a run abandoned. Used when the user discards an interrupted run. */
abandon(id: number): void {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE harvest_runs SET
status = 'abandoned',
updated_at = datetime('now'),
finished_at = datetime('now')
WHERE id = ? AND status IN ('running', 'failed')
`).run(id);
}
getById(id: number): HarvestRun | null {
const raw = this.db.getDatabase();
const row = raw.prepare('SELECT * FROM harvest_runs WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? this.rowToRun(row) : null;
}
/**
* Latest `running` or `failed` run with a surviving cache path — the UI
* uses this to offer resume on next page load.
*/
getLatestInterrupted(): HarvestRun | null {
const raw = this.db.getDatabase();
const row = raw.prepare(`
SELECT * FROM harvest_runs
WHERE status IN ('running', 'failed')
AND input_cache_path IS NOT NULL
ORDER BY started_at DESC
LIMIT 1
`).get() as Record<string, unknown> | undefined;
return row ? this.rowToRun(row) : null;
}
/** All runs, newest first — useful for debugging and future history views. */
getAll(limit = 50): HarvestRun[] {
const raw = this.db.getDatabase();
return (raw.prepare(`
SELECT * FROM harvest_runs ORDER BY started_at DESC LIMIT ?
`).all(limit) as Record<string, unknown>[]).map(r => this.rowToRun(r));
}
private rowToRun(row: Record<string, unknown>): HarvestRun {
return {
id: row.id as number,
source: row.source as ImportSourceType,
status: row.status as HarvestRunStatus,
totalItems: row.total_items as number,
itemsSaved: row.items_saved as number,
startedAt: row.started_at as string,
updatedAt: row.updated_at as string,
finishedAt: (row.finished_at as string | null) ?? null,
errorMessage: (row.error_message as string | null) ?? null,
inputCachePath: (row.input_cache_path as string | null) ?? null,
};
}
}

View File

@@ -0,0 +1,140 @@
/**
* HarvestSourceStore — tracks connected harvest sources and sync state.
*/
import type { MindDB } from '../mind/db.js';
import type { HarvestSource, ImportSourceType } from './types.js';
const HARVEST_SOURCES_DDL = `
CREATE TABLE IF NOT EXISTS harvest_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
source_path TEXT,
last_synced_at TEXT,
items_imported INTEGER NOT NULL DEFAULT 0,
frames_created INTEGER NOT NULL DEFAULT 0,
auto_sync INTEGER NOT NULL DEFAULT 0,
sync_interval_hours INTEGER NOT NULL DEFAULT 24,
last_content_hash TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`;
export class HarvestSourceStore {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
this.ensureTable();
}
private ensureTable(): void {
const raw = this.db.getDatabase();
const exists = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='harvest_sources'",
).get();
if (!exists) {
raw.exec(HARVEST_SOURCES_DDL);
}
}
/** Register or update a harvest source. */
upsert(source: ImportSourceType, displayName: string, sourcePath?: string): HarvestSource {
const raw = this.db.getDatabase();
raw.prepare(`
INSERT INTO harvest_sources (source, display_name, source_path)
VALUES (?, ?, ?)
ON CONFLICT(source) DO UPDATE SET
display_name = excluded.display_name,
source_path = COALESCE(excluded.source_path, harvest_sources.source_path)
`).run(source, displayName, sourcePath ?? null);
return this.getBySource(source)!;
}
/** Record a completed sync. */
recordSync(source: ImportSourceType, itemsImported: number, framesCreated: number, contentHash?: string): void {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE harvest_sources SET
last_synced_at = datetime('now'),
items_imported = items_imported + ?,
frames_created = frames_created + ?,
last_content_hash = COALESCE(?, last_content_hash)
WHERE source = ?
`).run(itemsImported, framesCreated, contentHash ?? null, source);
}
/**
* Clear the R3-004 unchanged-set skip hash for a source, forcing the NEXT harvest
* of it to run the full per-item loop instead of short-circuiting as "unchanged".
* Needed after GDPR re-consent (#7): lifting a subject's suppression must let an
* IDENTICAL re-import re-materialize it — but the set-hash skip would otherwise
* skip the whole run before the per-item loop ever re-adds the re-consented subject.
* No-op if the source row does not exist.
*/
clearContentHash(source: ImportSourceType): void {
this.db.getDatabase()
.prepare('UPDATE harvest_sources SET last_content_hash = NULL WHERE source = ?')
.run(source);
}
/** Enable or disable auto-sync for a source. */
setAutoSync(source: ImportSourceType, enabled: boolean, intervalHours?: number): void {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE harvest_sources SET
auto_sync = ?,
sync_interval_hours = COALESCE(?, sync_interval_hours)
WHERE source = ?
`).run(enabled ? 1 : 0, intervalHours ?? null, source);
}
/** Get a specific source. */
getBySource(source: ImportSourceType): HarvestSource | null {
const raw = this.db.getDatabase();
const row = raw.prepare('SELECT * FROM harvest_sources WHERE source = ?').get(source) as Record<string, unknown> | undefined;
return row ? this.rowToSource(row) : null;
}
/** Get all registered sources. */
getAll(): HarvestSource[] {
const raw = this.db.getDatabase();
return (raw.prepare('SELECT * FROM harvest_sources ORDER BY last_synced_at DESC').all() as Record<string, unknown>[])
.map(r => this.rowToSource(r));
}
/** Get sources that need syncing (auto_sync enabled and interval elapsed). */
getStale(): HarvestSource[] {
const raw = this.db.getDatabase();
return (raw.prepare(`
SELECT * FROM harvest_sources
WHERE auto_sync = 1
AND (last_synced_at IS NULL
OR datetime(last_synced_at, '+' || sync_interval_hours || ' hours') <= datetime('now'))
`).all() as Record<string, unknown>[]).map(r => this.rowToSource(r));
}
/** Remove a source. */
remove(source: ImportSourceType): void {
this.db.getDatabase().prepare('DELETE FROM harvest_sources WHERE source = ?').run(source);
}
private rowToSource(row: Record<string, unknown>): HarvestSource {
return {
id: row.id as number,
source: row.source as ImportSourceType,
displayName: row.display_name as string,
sourcePath: row.source_path as string | null,
lastSyncedAt: row.last_synced_at as string | null,
itemsImported: row.items_imported as number,
framesCreated: row.frames_created as number,
autoSync: (row.auto_sync as number) === 1,
syncIntervalHours: row.sync_interval_hours as number,
lastContentHash: row.last_content_hash as string | null,
createdAt: row.created_at as string,
};
}
}

View File

@@ -0,0 +1,48 @@
/**
* stable-id.ts — deterministic, collision-safe id for harvest import items.
*
* WHY: a harvest item's `id` becomes the GDPR Art.17 subject key. The pipeline
* runs it through `rawTurnConvKey → sanitizeToken(`${source}-${id}`, 64)`
* (raw-turns.ts) to form `source_ref`, the key the erasure path reconstructs
* from (source, source_ref) alone. `randomUUID()` mints a fresh id every
* re-import, so a re-imported conversation lands under a NEW source_ref and
* "sticky erasure" (erase-once-stays-erased across re-import) silently breaks.
* A deterministic id keyed on the export's OWN stable identifiers fixes this —
* and makes raw_archive idempotent across re-imports of the same subject.
*
* CONTRACT:
* - deterministic: same (source, ...parts) -> same id, forever, cross-process.
* - sanitize-stable: output is lowercase sha256 hex ([0-9a-f]) only, so it
* survives sanitizeToken(...,64) unchanged (no '-' collapse, no truncation
* collision within the 64-char budget).
* - collision-safe: a NUL ('\x00') separator between parts prevents field-
* boundary ambiguity (e.g. 'a' + 'bc' vs 'ab' + 'c'); NUL cannot appear in
* any real title/id/path, so it is an unambiguous delimiter. Undefined parts
* collapse to '' but STILL emit a separator, so a present-vs-absent field
* never aliases a shifted field.
* - never keys on growing content: callers pass stable identifiers (conv uuid,
* file path, map key) so a conversation that GAINS turns keeps its id.
*
* Reference impl: mind/content-hash.ts (same createHash('sha256') pattern).
*/
import { createHash } from 'node:crypto';
/**
* Deterministic short id for a harvest item.
*
* @param source the ImportSourceType discriminator (e.g. 'chatgpt') — the first
* hashed field, so ids from different adapters that share an
* otherwise-identical key can never collide.
* @param parts the stable identity fields. string|number|undefined accepted;
* number is stringified, undefined becomes '' (separator still
* emitted). At least one meaningful part SHOULD be passed.
* @returns 40-char lowercase hex (sanitizeToken-stable, well under the 64 cap).
*/
export function stableHarvestId(
source: string,
...parts: ReadonlyArray<string | number | undefined>
): string {
// NUL-join: source is field 0; every part gets its own field even when ''.
const key = [source, ...parts.map(p => (p === undefined ? '' : String(p)))].join('\x00');
return createHash('sha256').update(key).digest('hex').slice(0, 40);
}

View File

@@ -0,0 +1,140 @@
/**
* Harvest Types — universal import format and distillation pipeline types.
*/
// ── Source Types ──
export type ImportSourceType =
| 'chatgpt' | 'claude' | 'claude-code' | 'claude-desktop'
| 'gemini' | 'google-ai-studio' | 'perplexity' | 'grok'
| 'cursor' | 'copilot' | 'manus' | 'genspark'
| 'qwen' | 'minimax' | 'z-ai' | 'openclaw' | 'cowork'
| 'elevenlabs' | 'google-flow'
| 'markdown' | 'plaintext' | 'pdf' | 'url'
| 'unknown';
export type ImportItemType =
| 'conversation' | 'memory' | 'instruction'
| 'preference' | 'artifact' | 'rule'
| 'decision' | 'document';
// ── Universal Import Item (adapter output) ──
export interface ConversationMessage {
role: 'user' | 'assistant' | 'system';
text: string;
timestamp?: string;
}
export interface UniversalImportItem {
id: string;
source: ImportSourceType;
type: ImportItemType;
title: string;
content: string;
messages?: ConversationMessage[];
timestamp: string;
metadata: Record<string, unknown>;
}
// ── Distilled Knowledge (pipeline output) ──
export type DistillTargetLayer = 'identity' | 'frame' | 'kg_entity' | 'kg_relation' | 'awareness';
export interface KnowledgeProvenance {
originalSource: ImportSourceType;
originalId?: string;
conversationTitle?: string;
importedAt: string;
distillationModel: string;
confidence: number;
pass: number;
}
export interface DistilledKnowledge {
targetLayer: DistillTargetLayer;
frameType?: 'I' | 'P';
importance: 'critical' | 'important' | 'normal' | 'temporary';
content: string;
entities?: { name: string; type: string }[];
relations?: { source: string; target: string; relation: string }[];
provenance: KnowledgeProvenance;
}
// ── Classification (Pass 1 output) ──
export type ClassificationDomain = 'work' | 'personal' | 'technical' | 'mixed';
export type ClassificationValue = 'high' | 'medium' | 'low' | 'skip';
export interface ClassifiedItem {
item: UniversalImportItem;
domain: ClassificationDomain;
value: ClassificationValue;
categories: string[];
}
// ── Extraction (Pass 2 output) ──
export interface ExtractedContent {
itemId: string;
decisions: string[];
preferences: string[];
facts: string[];
knowledge: string[];
entities: { name: string; type: string }[];
relations: { source: string; target: string; relation: string }[];
}
// ── Pipeline Result ──
export interface HarvestPipelineResult {
source: ImportSourceType;
itemsReceived: number;
itemsClassified: number;
itemsSkipped: number;
itemsExtracted: number;
knowledgeDistilled: DistilledKnowledge[];
framesSaved: number;
entitiesCreated: number;
relationsCreated: number;
identityUpdates: number;
duplicatesSkipped: number;
errors: string[];
costUsd: number;
durationMs: number;
}
// ── Harvest Source (tracking table) ──
export interface HarvestSource {
id: number;
source: ImportSourceType;
displayName: string;
sourcePath: string | null;
lastSyncedAt: string | null;
itemsImported: number;
framesCreated: number;
autoSync: boolean;
syncIntervalHours: number;
lastContentHash: string | null;
createdAt: string;
}
// ── Adapter Interface ──
export interface SourceAdapter {
readonly sourceType: ImportSourceType;
readonly displayName: string;
parse(input: unknown): UniversalImportItem[];
}
export interface FilesystemAdapter extends SourceAdapter {
scan(dirPath: string): UniversalImportItem[];
}
/**
* W4.4: unified per-frame content cap for harvest imports. The three ingest
* surfaces had drifted (MCP tools 2,000 vs sidecar 10,000) — a 5x divergence
* in what the same export preserved depending on the door it came through.
*/
export const HARVEST_FRAME_CONTENT_CAP = 10_000;

View File

@@ -0,0 +1,248 @@
/**
* Universal Adapter — accepts any text, JSON, or Markdown input.
*
* For Tier 2 platforms (Perplexity, Grok, Manus, Genspark, Qwen, Minimax,
* z.ai, OpenClaw, Cowork, ElevenLabs, Google Flow) where formats vary.
*
* Strategy:
* 1. Detect if input is a known JSON format
* 2. If JSON with recognizable structure, parse as conversations
* 3. Otherwise, treat as raw text and create a single import item
*/
import { stableHarvestId } from './stable-id.js';
import type { SourceAdapter, UniversalImportItem, ImportSourceType, ConversationMessage } from './types.js';
import { asRecord, firstString, getArray, getString, type RawRecord } from './raw-types.js';
/** Heuristic source detection from content cues. */
function detectSource(input: unknown): ImportSourceType {
if (typeof input === 'string') {
const lower = input.toLowerCase();
if (lower.includes('perplexity')) return 'perplexity';
if (lower.includes('grok') || lower.includes('x.ai')) return 'grok';
if (lower.includes('manus')) return 'manus';
if (lower.includes('genspark')) return 'genspark';
if (lower.includes('qwen') || lower.includes('tongyi')) return 'qwen';
if (lower.includes('minimax')) return 'minimax';
if (lower.includes('elevenlabs')) return 'elevenlabs';
return 'unknown';
}
const obj = asRecord(input);
if (obj) {
const keys = Object.keys(obj);
const source = getString(obj, 'source');
if (keys.includes('perplexity') || source === 'perplexity') return 'perplexity';
if (keys.includes('grok') || source === 'grok') return 'grok';
if (getString(obj, 'provider') === 'qwen') return 'qwen';
}
return 'unknown';
}
/** Try to find conversations in any JSON structure. */
function findConversations(obj: unknown): RawRecord[] | null {
if (Array.isArray(obj)) {
const first = asRecord(obj[0]);
if (obj.length > 0 && first && (first.messages || first.chat_messages || first.turns || first.history)) {
return obj.map(asRecord).filter((c): c is RawRecord => c !== null);
}
if (obj.length > 0 && first && (first.role || first.sender || first.author)) {
return [{ title: 'Imported Conversation', messages: obj }];
}
}
const record = asRecord(obj);
if (record) {
for (const key of ['conversations', 'chats', 'threads', 'sessions', 'history', 'data']) {
const nested = getArray(record, key);
if (nested) {
return findConversations(nested);
}
}
}
return null;
}
export class UniversalAdapter implements SourceAdapter {
readonly sourceType = 'unknown' as const;
readonly displayName = 'Universal (Auto-detect)';
parse(input: unknown): UniversalImportItem[] {
if (typeof input === 'string') {
return this.parseText(input);
}
if (typeof input === 'object' && input !== null) {
return this.parseJson(input);
}
return [];
}
private parseText(text: string): UniversalImportItem[] {
const source = detectSource(text);
const items: UniversalImportItem[] = [];
const conversations = this.splitConversations(text);
for (const conv of conversations) {
const messages = this.extractMessagesFromText(conv.content);
items.push({
// raw text paste has no id — content is the only surrogate (NOT growth-stable;
// documented tradeoff, no better anchor exists for free-text).
id: stableHarvestId('universal-text', source, conv.content),
source,
type: messages.length > 0 ? 'conversation' : 'memory',
title: conv.title,
content: conv.content,
messages: messages.length > 0 ? messages : undefined,
timestamp: new Date().toISOString(),
metadata: { parseMethod: 'universal-text', detectedSource: source },
});
}
return items;
}
private parseJson(input: object): UniversalImportItem[] {
const source = detectSource(input);
const items: UniversalImportItem[] = [];
const conversations = findConversations(input);
if (conversations) {
for (const conv of conversations) {
const title = firstString(conv, 'title', 'name', 'subject') ?? 'Imported Conversation';
const rawMessages = getArray(conv, 'messages') ?? getArray(conv, 'chat_messages')
?? getArray(conv, 'turns') ?? getArray(conv, 'history') ?? [];
const messages: ConversationMessage[] = [];
for (const rawMsg of rawMessages) {
const msg = asRecord(rawMsg);
if (!msg) continue;
const role = this.resolveRole(msg);
if (!role) continue;
const text = this.extractText(msg);
if (!text) continue;
messages.push({ role, text, timestamp: firstString(msg, 'timestamp', 'created_at', 'createTime') });
}
if (messages.length === 0) continue;
const convContent = messages.map(m => `${m.role}: ${m.text}`).join('\n\n');
const convId = getString(conv, 'id');
items.push({
// Stable per-conversation id when the export gives one (growth-stable). Else
// fall back to source+title+created_at PLUS content: a bare message-array paste
// has no id/timestamp and a CONSTANT synthetic title ('Imported Conversation'),
// so without content every such paste collapses to ONE (source, source_ref)
// subject key → cross-subject over-suppression / co-erasure. Content makes them
// distinct (id-less → not growth-stable, the documented universal-text tradeoff).
id: stableHarvestId('universal-json', convId ?? `${source}\x00${title}\x00${firstString(conv, 'created_at', 'createTime', 'timestamp') ?? ''}\x00${convContent}`),
source,
type: 'conversation',
title,
content: convContent,
messages,
timestamp: firstString(conv, 'created_at', 'createTime', 'timestamp') ?? new Date().toISOString(),
metadata: { parseMethod: 'universal-json', detectedSource: source, conversationId: convId },
});
}
}
if (items.length === 0) {
const record = asRecord(input);
items.push({
id: stableHarvestId('universal-json-raw', source, JSON.stringify(input)),
source,
type: 'memory',
title: (record && getString(record, 'title')) ?? 'Imported Data',
content: JSON.stringify(input, null, 2).slice(0, 50000),
timestamp: new Date().toISOString(),
metadata: { parseMethod: 'universal-json-raw', detectedSource: source },
});
}
return items;
}
private splitConversations(text: string): { title: string; content: string }[] {
const separators = [
/^#{1,3}\s+/gm,
/^={3,}$/gm,
/^-{3,}$/gm,
/^Conversation \d+/gim,
];
for (const sep of separators) {
const parts = text.split(sep).filter(p => p.trim().length > 20);
if (parts.length > 1) {
return parts.map((p, i) => ({
title: `Conversation ${i + 1}`,
content: p.trim(),
}));
}
}
return [{ title: 'Imported Text', content: text.trim() }];
}
private extractMessagesFromText(text: string): ConversationMessage[] {
const messages: ConversationMessage[] = [];
const pattern = /^(User|Human|Me|Assistant|AI|Bot|Claude|ChatGPT|Gemini|Grok):\s*([\s\S]*?)(?=^(?:User|Human|Me|Assistant|AI|Bot|Claude|ChatGPT|Gemini|Grok):|$)/gim;
let match;
while ((match = pattern.exec(text)) !== null) {
const speaker = match[1].toLowerCase();
const content = match[2].trim();
if (!content) continue;
const role = ['user', 'human', 'me'].includes(speaker) ? 'user' as const : 'assistant' as const;
messages.push({ role, text: content });
}
return messages;
}
private resolveRole(msg: RawRecord): 'user' | 'assistant' | null {
const role = firstString(msg, 'role', 'sender', 'author', 'type');
if (!role) return null;
const r = role.toLowerCase();
if (['user', 'human', 'me'].includes(r)) return 'user';
if (['assistant', 'ai', 'bot', 'model', 'system'].includes(r)) return 'assistant';
return null;
}
private extractText(msg: RawRecord): string {
const directText = getString(msg, 'text');
if (directText !== undefined) return directText.trim();
const directContent = getString(msg, 'content');
if (directContent !== undefined) return directContent.trim();
const contentBlocks = getArray(msg, 'content');
if (contentBlocks) {
return contentBlocks
.map(b => {
if (typeof b === 'string') return b;
const rec = asRecord(b);
if (!rec) return undefined;
if (rec.type === 'text') return getString(rec, 'text') ?? '';
// W4.4 (caption parity): generic text-bearing image fields.
const caption = getString(rec, 'caption') ?? getString(rec, 'alt') ?? getString(rec, 'description');
if (caption) return `[Shared image: ${caption}]`;
return undefined;
})
.filter((t): t is string => typeof t === 'string')
.join('\n')
.trim();
}
const parts = getArray(msg, 'parts');
if (parts) {
return parts
.map(asRecord)
.map(p => (p ? getString(p, 'text') : undefined))
.filter((t): t is string => typeof t === 'string')
.join('\n')
.trim();
}
return '';
}
}

View File

@@ -0,0 +1,186 @@
/**
* URL Source Adapter — fetches web pages and extracts readable content.
*
* Uses built-in fetch + basic HTML stripping.
* Splits content by sections if headings are present.
*/
import { randomUUID } from 'node:crypto';
import type { SourceAdapter, UniversalImportItem } from './types.js';
import { safeFetch, allowLocalFromEnv } from './url-egress-guard.js';
/** Strip HTML tags and decode common entities. Returns plain text. */
function stripHtml(html: string): string {
// Remove script and style blocks entirely
let text = html.replace(/<script[\s\S]*?<\/script>/gi, '');
text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
text = text.replace(/<nav[\s\S]*?<\/nav>/gi, '');
text = text.replace(/<footer[\s\S]*?<\/footer>/gi, '');
text = text.replace(/<header[\s\S]*?<\/header>/gi, '');
// Convert headings to markdown-style
text = text.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n');
text = text.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n');
text = text.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n');
// Convert paragraphs and breaks to newlines
text = text.replace(/<\/p>/gi, '\n\n');
text = text.replace(/<br\s*\/?>/gi, '\n');
text = text.replace(/<li[^>]*>/gi, '\n- ');
// Strip remaining tags
text = text.replace(/<[^>]+>/g, '');
// Decode common HTML entities
text = text.replace(/&amp;/g, '&');
text = text.replace(/&lt;/g, '<');
text = text.replace(/&gt;/g, '>');
text = text.replace(/&quot;/g, '"');
text = text.replace(/&#39;/g, "'");
text = text.replace(/&nbsp;/g, ' ');
// Collapse excessive whitespace
text = text.replace(/[ \t]+/g, ' ');
text = text.replace(/\n{3,}/g, '\n\n');
return text.trim();
}
/** Extract <title> from HTML. */
function extractTitle(html: string): string | undefined {
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return match ? match[1].trim().replace(/\s+/g, ' ') : undefined;
}
/** Extract meta description from HTML. */
function extractDescription(html: string): string | undefined {
const match = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([\s\S]*?)["'][^>]*>/i)
?? html.match(/<meta[^>]*content=["']([\s\S]*?)["'][^>]*name=["']description["'][^>]*>/i);
return match ? match[1].trim() : undefined;
}
export class UrlAdapter implements SourceAdapter {
readonly sourceType = 'url' as const;
readonly displayName = 'Web URL';
parse(input: unknown): UniversalImportItem[] {
// Synchronous parse — for pre-fetched HTML content
if (typeof input !== 'string') return [];
// If input looks like HTML, parse it directly
if (input.includes('<html') || input.includes('<body') || input.includes('<div')) {
return this.parseHtml(input, undefined);
}
// If input is a URL, return empty — caller should use fetchAndParse()
if (input.startsWith('http://') || input.startsWith('https://')) {
return [];
}
return [];
}
/** Fetch a URL and parse its content. Async because of network I/O.
* Routed through the SSRF egress guard — the target and every redirect hop
* must resolve to a public address (loopback allowed only when
* WAGGLE_ALLOW_LOCAL_FETCH is set). Blocked targets throw EgressBlockedError,
* surfaced to the MCP ingest caller as an error result. */
async fetchAndParse(url: string): Promise<UniversalImportItem[]> {
const response = await safeFetch(
url,
{
headers: {
'User-Agent': 'Waggle-Memory/1.0 (knowledge harvester)',
'Accept': 'text/html,application/xhtml+xml,text/plain',
},
signal: AbortSignal.timeout(15_000),
},
{ allowLocal: allowLocalFromEnv() },
);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type') ?? '';
const body = await response.text();
if (contentType.includes('text/html') || contentType.includes('xhtml')) {
return this.parseHtml(body, url);
}
// Plain text or other — treat as plaintext
return [{
id: randomUUID(),
source: 'url',
type: 'document',
title: url,
content: body.slice(0, 4000),
timestamp: new Date().toISOString(),
metadata: { sourceUrl: url, contentType: 'article' },
}];
}
private parseHtml(html: string, sourceUrl: string | undefined): UniversalImportItem[] {
const title = extractTitle(html) ?? sourceUrl ?? 'Web page';
const description = extractDescription(html);
const plainText = stripHtml(html);
if (!plainText || plainText.length < 50) return [];
// For short pages, return as single item
if (plainText.length <= 4000) {
return [{
id: randomUUID(),
source: 'url',
type: 'document',
title,
content: plainText,
timestamp: new Date().toISOString(),
metadata: {
...(sourceUrl && { sourceUrl }),
...(description && { description }),
contentType: 'article',
},
}];
}
// For longer pages, split by headings
const sections = plainText.split(/\n(?=#{1,3}\s)/);
const items: UniversalImportItem[] = [];
for (const section of sections) {
const trimmed = section.trim();
if (trimmed.length < 30) continue;
const headingMatch = trimmed.match(/^#{1,3}\s+(.+)/);
const sectionTitle = headingMatch ? headingMatch[1].trim() : title;
items.push({
id: randomUUID(),
source: 'url',
type: 'document',
title: sectionTitle === title ? title : `${title}${sectionTitle}`,
content: trimmed.slice(0, 4000),
timestamp: new Date().toISOString(),
metadata: {
...(sourceUrl && { sourceUrl }),
contentType: 'article',
},
});
}
return items.length > 0 ? items : [{
id: randomUUID(),
source: 'url',
type: 'document',
title,
content: plainText.slice(0, 4000),
timestamp: new Date().toISOString(),
metadata: {
...(sourceUrl && { sourceUrl }),
contentType: 'article',
},
}];
}
}

View File

@@ -0,0 +1,298 @@
/**
* SSRF egress guard for the URL harvest adapter.
*
* The harvest URL adapter fetches attacker-influenceable URLs (a user or an
* MCP client naming a URL to ingest). Without this guard an ingest of
* `http://169.254.169.254/latest/meta-data/` or an RFC1918 host reaches
* internal services on the cloud/TEAMS deploy (sidecar binds 0.0.0.0) and can
* exfiltrate instance-metadata IAM credentials.
*
* This module is a self-contained, dependency-free (node builtins only) copy of
* the guard spec shared with `packages/agent/src/url-egress-guard.ts`. It lives
* here — rather than importing from @waggle/agent — because hive-mind-core is
* OSS-mirrored and must not depend on Waggle-proprietary packages. Keep the two
* implementations in sync; they share one spec.
*
* Obfuscated IP literals (octal / decimal / hex) are normalized by the OS
* resolver: `net.isIP` rejects them as literals, so they fall through to
* `dns.lookup`, which returns the canonical dotted form we classify.
*/
import { lookup as dnsLookup } from 'node:dns/promises';
import { isIP } from 'node:net';
export type AddressClass =
| 'public'
| 'loopback'
| 'private'
| 'link-local'
| 'unique-local'
| 'multicast'
| 'reserved'
| 'unspecified'
| 'invalid';
export interface ResolvedAddress {
address: string;
family: number;
}
export type LookupFn = (hostname: string) => Promise<ResolvedAddress[]>;
export interface EgressGuardOptions {
/** Permit loopback targets (default false). Only loopback is unlocked. */
allowLocal?: boolean;
/** Injectable resolver (tests). Defaults to node:dns/promises lookup(all). */
lookup?: LookupFn;
}
export class EgressBlockedError extends Error {
public readonly url: string;
public readonly addressClass?: AddressClass;
constructor(message: string, url: string, addressClass?: AddressClass) {
super(message);
this.name = 'EgressBlockedError';
this.url = url;
this.addressClass = addressClass;
}
}
function parseIpv4Octets(ip: string): [number, number, number, number] | null {
const parts = ip.split('.');
if (parts.length !== 4) return null;
const octets: number[] = [];
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const n = Number(part);
if (n < 0 || n > 255) return null;
octets.push(n);
}
return [octets[0], octets[1], octets[2], octets[3]];
}
function classifyIpv4(ip: string): AddressClass {
const octets = parseIpv4Octets(ip);
if (!octets) return 'invalid';
const [a, b, c] = octets;
if (a === 0) return 'unspecified'; // 0.0.0.0/8
if (a === 127) return 'loopback'; // 127.0.0.0/8
if (a === 10) return 'private'; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return 'private'; // 172.16.0.0/12
if (a === 192 && b === 168) return 'private'; // 192.168.0.0/16
if (a === 100 && b >= 64 && b <= 127) return 'private'; // 100.64.0.0/10 CGNAT
if (a === 169 && b === 254) return 'link-local'; // 169.254.0.0/16 (metadata)
if (a >= 224 && a <= 239) return 'multicast'; // 224.0.0.0/4
if (a >= 240) return 'reserved'; // 240.0.0.0/4 + 255.255.255.255
if (a === 192 && b === 0 && c === 0) return 'reserved'; // 192.0.0.0/24
if (a === 192 && b === 0 && c === 2) return 'reserved'; // TEST-NET-1
if (a === 198 && (b === 18 || b === 19)) return 'reserved'; // 198.18.0.0/15
if (a === 198 && b === 51 && c === 100) return 'reserved'; // TEST-NET-2
if (a === 203 && b === 0 && c === 113) return 'reserved'; // TEST-NET-3
return 'public';
}
function parseIpv6Hextets(ip: string): number[] | null {
let s = ip.toLowerCase();
const zoneAt = s.indexOf('%');
if (zoneAt !== -1) s = s.slice(0, zoneAt);
const dotMatch = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotMatch) {
const v4 = parseIpv4Octets(dotMatch[1]);
if (!v4) return null;
const hi = ((v4[0] << 8) | v4[1]).toString(16);
const lo = ((v4[2] << 8) | v4[3]).toString(16);
s = s.slice(0, dotMatch.index) + hi + ':' + lo;
}
const halves = s.split('::');
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(':') : [];
let groups: string[];
if (halves.length === 2) {
const tail = halves[1] ? halves[1].split(':') : [];
const missing = 8 - head.length - tail.length;
if (missing < 0) return null;
groups = [...head, ...Array(missing).fill('0'), ...tail];
} else {
groups = head;
}
if (groups.length !== 8) return null;
const hextets: number[] = [];
for (const g of groups) {
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
hextets.push(parseInt(g, 16));
}
return hextets;
}
function classifyIpv6(ip: string): AddressClass {
const h = parseIpv6Hextets(ip);
if (!h) return 'invalid';
const firstFive = h[0] | h[1] | h[2] | h[3] | h[4];
if (firstFive === 0 && (h[5] === 0xffff || h[5] === 0)) {
const embedded = `${h[6] >> 8}.${h[6] & 0xff}.${h[7] >> 8}.${h[7] & 0xff}`;
const v4Class = classifyIpv4(embedded);
if (h[5] === 0 && h[6] === 0 && h[7] === 0) return 'unspecified';
if (h[5] === 0 && h[6] === 0 && h[7] === 1) return 'loopback'; // ::1
return v4Class;
}
if ((h[0] & 0xffc0) === 0xfe80) return 'link-local'; // fe80::/10
if ((h[0] & 0xfe00) === 0xfc00) return 'unique-local'; // fc00::/7 (ULA)
if ((h[0] & 0xff00) === 0xff00) return 'multicast'; // ff00::/8
if (h[0] === 0x2001 && h[1] === 0x0db8) return 'reserved'; // 2001:db8::/32 docs
if (h[0] === 0x0064 && h[1] === 0xff9b) return 'reserved'; // 64:ff9b::/96 NAT64
if (h[0] === 0x0100 && h[1] === 0 && h[2] === 0 && h[3] === 0) return 'reserved'; // 100::/64 discard
return 'public';
}
/** Classify a single IP-literal address. Fail-closed: unknown -> 'invalid'. */
export function classifyAddress(ip: string): AddressClass {
const family = isIP(ip);
if (family === 4) return classifyIpv4(ip);
if (family === 6) return classifyIpv6(ip);
return 'invalid';
}
function isAllowed(cls: AddressClass, allowLocal: boolean): boolean {
if (cls === 'public') return true;
if (cls === 'loopback' && allowLocal) return true;
return false;
}
async function defaultLookup(hostname: string): Promise<ResolvedAddress[]> {
const results = await dnsLookup(hostname, { all: true, verbatim: true });
return results.map((r) => ({ address: r.address, family: r.family }));
}
/**
* Validate that `rawUrl` is an http(s) URL whose host resolves only to public
* addresses. Throws {@link EgressBlockedError} otherwise. Returns parsed URL.
*/
export async function assertUrlAllowed(
rawUrl: string,
options: EgressGuardOptions = {},
): Promise<URL> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new EgressBlockedError('Invalid URL', rawUrl);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new EgressBlockedError(
`Blocked non-http(s) scheme "${parsed.protocol}"`,
rawUrl,
);
}
// url.hostname keeps the surrounding brackets on an IPv6 literal ("[::1]"),
// which isIP() does not recognize — strip them so the literal is classified
// directly (loopback/private/link-local/…) instead of falling through to a DNS
// lookup that fails ENOTFOUND on Linux (and only accidentally resolves on
// Windows). Without this, bracketed-IPv6 URLs bypass classification entirely.
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
const literalFamily = isIP(hostname);
let addresses: ResolvedAddress[];
if (literalFamily !== 0) {
addresses = [{ address: hostname, family: literalFamily }];
} else {
const lookupFn = options.lookup ?? defaultLookup;
try {
addresses = await lookupFn(hostname);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new EgressBlockedError(
`DNS resolution failed for "${hostname}": ${detail}`,
rawUrl,
);
}
if (!addresses || addresses.length === 0) {
throw new EgressBlockedError(
`DNS resolution returned no addresses for "${hostname}"`,
rawUrl,
);
}
}
const allowLocal = options.allowLocal ?? false;
for (const { address } of addresses) {
const cls = classifyAddress(address);
if (!isAllowed(cls, allowLocal)) {
throw new EgressBlockedError(
`Blocked egress to ${cls} address ${address} (host "${hostname}")`,
rawUrl,
cls,
);
}
}
return parsed;
}
export interface SafeFetchOptions extends EgressGuardOptions {
/** Maximum redirect hops to follow (default 5). */
maxRedirects?: number;
/** Injectable fetch (tests). Defaults to globalThis.fetch. */
fetchImpl?: typeof globalThis.fetch;
}
/**
* SSRF-safe fetch. Validates before the request and re-validates every redirect
* hop (`redirect: 'manual'`). Caller-supplied `redirect` in `init` is ignored.
*/
export async function safeFetch(
rawUrl: string,
init: RequestInit = {},
options: SafeFetchOptions = {},
): Promise<Response> {
const maxRedirects = options.maxRedirects ?? 5;
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
let currentUrl = rawUrl;
for (let hop = 0; hop <= maxRedirects; hop++) {
await assertUrlAllowed(currentUrl, options);
const response = await fetchImpl(currentUrl, { ...init, redirect: 'manual' });
const isRedirect = response.status >= 300 && response.status < 400;
const location = isRedirect ? response.headers.get('location') : null;
if (!location) {
return response;
}
try {
await response.body?.cancel();
} catch {
/* best-effort; ignore */
}
let nextUrl: string;
try {
nextUrl = new URL(location, currentUrl).toString();
} catch {
throw new EgressBlockedError(
`Invalid redirect target "${location}"`,
currentUrl,
);
}
currentUrl = nextUrl;
}
throw new EgressBlockedError(
`Exceeded maximum redirects (${maxRedirects})`,
rawUrl,
);
}
/** True when local (loopback) fetches are opted in via env. */
export function allowLocalFromEnv(): boolean {
const v = process.env.WAGGLE_ALLOW_LOCAL_FETCH;
return v === '1' || v === 'true';
}

View File

@@ -0,0 +1,158 @@
// @waggle/hive-mind-core — substrate package barrel.
//
// Distribution: Apache 2.0 OSS via `git subtree split` from waggle-os monorepo
// to marolinik/hive-mind. Apps/web + Waggle agent harness stay proprietary in monorepo.
//
// Contents: mind/ (substrate), harvest/ (ingestion pipeline), prompt-injection
// scanner, structured logger.
// ── Logger + injection scanner (utilities used by substrate + Waggle agent) ──
export { createCoreLogger, type CoreLogger } from './logger.js';
export { scanForInjection, type ScanResult } from './injection-scanner.js';
// ── mind/ — memory substrate (FrameStore, KnowledgeGraph, embedders, search, scoring) ──
export {
MindDB, EmbeddingDimMismatchError,
type EmbeddingFingerprint, type FingerprintCheck,
} from './mind/db.js';
export { IdentityLayer, type Identity } from './mind/identity.js';
export { AwarenessLayer, type AwarenessItem, type AwarenessCategory } from './mind/awareness.js';
export { FrameStore, stripHmPrefix, type MemoryFrame, type FrameType, type Importance, type FrameSource } from './mind/frames.js';
export { RawArchive, hashRaw, readArchiveUids, withArchiveUid, type RawArchiveRow, type ArchiveInput } from './mind/raw-archive.js';
export { MindErasure, type EraseResult } from './mind/erasure.js';
export { SuppressionStore, type SuppressedSubject } from './mind/suppression.js';
export { hashFrameContent } from './mind/content-hash.js';
export { SessionStore, type Session } from './mind/sessions.js';
export {
HybridSearch, type SearchResult, type SearchOptions,
// D1 (oss-drift triage, 2026-06-11) — chunk-level retrieval (flag-gated, default OFF)
chunkRetrievalEnabled, rechunkAllFrames, type RechunkResult,
// Abstain-path retrieval-confidence scaffold. Ported from hive-mind a99ea0e.
assessRetrievalConfidence, type RetrievalConfidence,
} from './mind/search.js';
export { chunkText, type ChunkOptions, type FrameChunk } from './mind/chunker.js';
export { KnowledgeGraph, type Entity, type Relation, type ValidationSchema } from './mind/knowledge.js';
export {
SCHEMA_SQL, VEC_TABLE_SQL, CHUNKS_VEC_TABLE_SQL, SCHEMA_VERSION,
vecTableSqlForDim, chunksVecTableSqlForDim,
} from './mind/schema.js';
export {
computeRelevance,
computeTemporalScore,
computePopularityScore,
computeContextualScore,
computeImportanceScore,
SCORING_PROFILES,
type ScoringProfile,
type ScoringWeights,
} from './mind/scoring.js';
export type { Embedder } from './mind/embeddings.js';
export { createLiteLLMEmbedder, type LiteLLMEmbedderConfig } from './mind/litellm-embedder.js';
export { createInProcessEmbedder, normalizeDimensions, type InProcessEmbedderConfig } from './mind/inprocess-embedder.js';
export { createOllamaEmbedder, type OllamaEmbedderConfig } from './mind/ollama-embedder.js';
export { createApiEmbedder, type ApiEmbedderConfig } from './mind/api-embedder.js';
export { createEmbeddingProvider, EmbeddingQuotaExceededError, getMinimumTierForProvider, maxEmbedCharsForModel, capEmbedText, reembedPerText, type EmbeddingProviderConfig, type EmbeddingProviderStatus, type EmbeddingProviderType, type EmbeddingProviderInstance, type EmbeddingQuotaStatus } from './mind/embedding-provider.js';
export { normalizeEntityName, findDuplicates, isNoiseName, isLikelyAcronym } from './mind/entity-normalizer.js';
export { Ontology, validateEntity, type EntitySchema, type ValidationResult } from './mind/ontology.js';
export {
ImprovementSignalStore,
type ImprovementSignal,
type ActionableSignal,
type SignalCategory,
type ActionableThresholds,
} from './mind/improvement-signals.js';
export {
ExecutionTraceStore, EXECUTION_TRACES_TABLE_SQL,
type ExecutionTrace, type ParsedExecutionTrace, type TraceOutcome,
type TracePayload, type TraceToolCall, type TraceReasoningStep,
type StartTraceInput, type FinalizeTraceInput, type TraceQueryFilter,
} from './mind/execution-traces.js';
export {
EvolutionRunStore, EVOLUTION_RUNS_TABLE_SQL,
type EvolutionRun, type EvolutionRunStatus, type EvolutionRunTarget,
type CreateEvolutionRunInput, type EvolutionRunFilter,
} from './mind/evolution-runs.js';
export { reconcileIndexes, reconcileFtsIndex, reconcileVecIndex, cleanOrphanVectors, cleanOrphanFts, type ReconcileResult } from './mind/reconcile.js';
export {
ConceptTracker, CONCEPT_MASTERY_TABLE_SQL,
type ConceptEntry, type ConceptUpdate,
} from './mind/concept-tracker.js';
export {
TEMPORAL_GUIDANCE,
toDatePrefix,
renderDatedSnippet,
referenceDate,
renderReferenceDateLine,
} from './mind/recall-context.js';
export { resolveRelativeDate, type ResolvedDate } from './mind/resolve-relative-date.js';
// Supersession (P) + bridge (B) frame PRODUCER — detects supersession chains +
// enumerable groups in unstructured observations and emits P/B frames. The
// downstream CONSUMERS of those frames (FrameStore.compact() merge, and the
// upstream MemoryWeaver in packages/weaver) live elsewhere. Provider-agnostic
// (caller injects the LLM); applyConsolidation returns the new frames so the
// caller can vec-index them (createPFrame/createBFrame index FTS only).
export {
detectSupersessionChains,
detectEntityGroups,
applyConsolidation,
collectObservations,
getCurrentValues,
} from './mind/supersede.js';
export type {
ConsolidationLlm,
Observation,
SupersessionChain,
EntityGroup,
ConsolidationResult,
CollectObservationsOptions,
} from './mind/supersede.js';
export { parseDateWindow, type DateWindow } from './mind/parse-date-window.js';
export { createInProcessReranker, type Reranker, type InProcessRerankerConfig } from './mind/inprocess-reranker.js';
// ── harvest/ — universal memory ingestion pipeline ──
export { HarvestSourceStore } from './harvest/source-store.js';
export { HarvestRunStore, type HarvestRun, type HarvestRunStatus } from './harvest/run-store.js';
export { ChatGPTAdapter } from './harvest/chatgpt-adapter.js';
export { ClaudeAdapter } from './harvest/claude-adapter.js';
export { ClaudeCodeAdapter } from './harvest/claude-code-adapter.js';
export { GeminiAdapter } from './harvest/gemini-adapter.js';
export { UniversalAdapter } from './harvest/universal-adapter.js';
export { MarkdownAdapter } from './harvest/markdown-adapter.js';
export { PlaintextAdapter } from './harvest/plaintext-adapter.js';
export { UrlAdapter } from './harvest/url-adapter.js';
export { PdfAdapter } from './harvest/pdf-adapter.js';
export { HarvestPipeline, type LLMCallFn, type PipelineOptions } from './harvest/pipeline.js';
export {
extractMemoryLanes, writeMemoryLaneFrames,
MIND_FACT_PREFIX, MIND_EVENT_PREFIX, MIND_PROFILE_PREFIX,
type MemoryLaneExtraction, type ExtractedEvent, type ExtractedFact, type ExtractedProfile,
type WriteLaneFramesResult,
} from './harvest/extract-memory-lanes.js';
export {
extractKgEntities, writeKgEntities, KG_ENTITY_TYPES,
type KgEntity, type KgEntityType, type KgEntityExtraction, type WriteKgEntitiesResult,
} from './harvest/extract-kg-entities.js';
export {
writeRawTurnFrames, rawTurnHeader, parseRawTurnHeader, rawTurnConvKey,
MIND_RAWTURN_PREFIX, MAX_TURNS_PER_ITEM, RAWDETAIL_KILL_SWITCH,
type WriteRawTurnsResult, type ParsedRawTurnHeader,
} from './harvest/raw-turns.js';
export {
fetchRawDetailLane, rawTurnBody, RAW_DETAIL_K,
type RawTurnHit, type RawDetailLaneOptions,
} from './mind/raw-detail-lane.js';
export { dedup, harvestSetHash } from './harvest/dedup.js';
export { HARVEST_FRAME_CONTENT_CAP } from './harvest/types.js';
export type {
ImportSourceType, ImportItemType, UniversalImportItem, DistilledKnowledge,
HarvestPipelineResult, HarvestSource, SourceAdapter, FilesystemAdapter,
ClassifiedItem, ExtractedContent, KnowledgeProvenance,
} from './harvest/types.js';
// ── Multi-workspace orchestration (Plan A AMENDMENT 2026-04-30) ──
// MultiMind + MultiMindCache + WorkspaceManager were originally part of
// @hive-mind/core in marolinik/hive-mind. PM Q3 ratified Plan A widening
// 2026-04-30 to keep hive-mind-core OSS-self-contained for mcp-server + cli.
export { MultiMind, type MultiMindSearchResult, type MindSource, type SearchScope } from './multi-mind.js';
export { MultiMindCache, type MultiMindCacheConfig } from './multi-mind-cache.js';
export { WorkspaceManager, type WorkspaceConfig, type CreateWorkspaceOptions } from './workspace-manager.js';

View File

@@ -0,0 +1,85 @@
/**
* Prompt-injection scanner — pattern-based detection for role-override,
* prompt-extraction, and instruction-injection attempts.
*
* Historically lived in `@waggle/agent`. Moved to `@waggle/core` so the
* harvest pipeline (which ingests untrusted external conversation exports)
* can call it without a cross-package dependency. The `@waggle/agent`
* version is now a thin re-export for backward compatibility.
*
* Three pattern sets:
* - ROLE_OVERRIDE: "ignore previous instructions", "you are now…", memory-wipe attempts
* - PROMPT_EXTRACTION: "print your system prompt", "reveal your instructions"
* - INSTRUCTION_INJECTION: fake "IMPORTANT:", "SYSTEM:", "[INST]" authority markers
*/
export interface ScanResult {
safe: boolean;
score: number;
flags: string[];
}
const ROLE_OVERRIDE_PATTERNS = [
/ignore\s+(all\s+)?previous\s+(instructions|prompts|rules)/i,
/you\s+are\s+now\s+/i,
/new\s+instructions?\s*:/i,
/forget\s+(everything|all|your)\s+(you|instructions|rules)/i,
/override\s+(your|the|all)\s+(instructions|rules|prompt)/i,
/disregard\s+(your|the|all|previous)\s+(instructions|rules|prompt)/i,
/ignoriere\s+alle/i,
/ignora\s+todas/i,
/ignorez\s+toutes/i,
// F8: Memory wipe — "forget everything", "erase all context", etc.
/(?:forget|erase|clear|wipe|reset)\s+(?:everything|conversation|memory|context|history|all)/i,
// F8: Role override — "from now on you are", "pretend you are", "your new role is"
/(?:from\s+now\s+on\s+you\s+are|act\s+as\s+if\s+you\s+are|pretend\s+you\s+are|your\s+new\s+role\s+is)/i,
];
const PROMPT_EXTRACTION_PATTERNS = [
/print\s+(your|the)\s+system\s+prompt/i,
/show\s+(me\s+)?(your|the)\s+(system\s+)?prompt/i,
/what\s+(is|are)\s+your\s+(system\s+)?(prompt|instructions|rules)/i,
/repeat\s+(your|the)\s+(system\s+)?(prompt|instructions)/i,
/output\s+(your|the)\s+(system\s+)?prompt\s+verbatim/i,
// F8: Instruction disclosure — "reveal your instructions", "disclose your prompt", etc.
/(?:reveal|disclose|show|display|print|output|dump)\s+(?:your\s+)?(?:instructions|system\s+prompt|rules|guidelines|configuration|training)/i,
];
const INSTRUCTION_INJECTION_PATTERNS = [
/IMPORTANT\s*:\s*(ignore|disregard|forget|override)/i,
/SYSTEM\s*:\s*/i,
/\[INST\]/i,
/<<SYS>>/i,
/\bASSISTANT\s*:\s*/i,
/BEGIN\s+NEW\s+INSTRUCTIONS/i,
// F8: Authority claims — fake system/admin messages
/(?:system\s+message|admin\s+message|admin\s+override|elevated\s+privileges|root\s+access|operator\s+mode|maintenance\s+mode|debug\s+mode)/i,
];
export function scanForInjection(
text: string,
context: 'user_input' | 'tool_output' = 'user_input'
): ScanResult {
const flags: string[] = [];
let score = 0;
for (const pattern of ROLE_OVERRIDE_PATTERNS) {
if (pattern.test(text)) { flags.push('role_override'); score += 0.5; break; }
}
for (const pattern of PROMPT_EXTRACTION_PATTERNS) {
if (pattern.test(text)) { flags.push('prompt_extraction'); score += 0.4; break; }
}
for (const pattern of INSTRUCTION_INJECTION_PATTERNS) {
if (pattern.test(text)) {
flags.push('instruction_injection');
score += context === 'tool_output' ? 0.6 : 0.3;
break;
}
}
score = Math.min(score, 1.0);
return { safe: score < 0.3, score, flags };
}

View File

@@ -0,0 +1,38 @@
/** Minimal structured logger for @waggle/core */
/**
* Logger shape used across the substrate. Downstream consumers that want
* structured output (pino, winston, etc.) can wrap their logger in this
* shape and pass it as a dependency.
*/
// Reverse-ported from OSS hive-mind (oss-drift triage R1, 2026-06-11).
export interface CoreLogger {
info(msg: string, data?: unknown): void;
warn(msg: string, data?: unknown): void;
error(msg: string, data?: unknown): void;
debug(msg: string, data?: unknown): void;
}
export function createCoreLogger(tag: string): CoreLogger {
const prefix = `[waggle:${tag}]`;
// ALL diagnostics go to stderr. stdout is reserved for program data — the
// MCP stdio protocol (hive-mind-mcp-server) and CLI `--json` envelopes — so
// a library log line on stdout corrupts machine consumers. console.warn and
// console.error already target stderr; route info/debug there too rather
// than console.info/console.debug (which write to stdout).
// Reverse-ported from OSS hive-mind (oss-drift triage R1, 2026-06-11).
//
// Guard the optional payload with `data !== undefined` (NOT a truthiness
// check) so falsy-but-defined payloads (0, '', false, null) are still
// logged instead of silently dropped. Ported from hive-mind a99ea0e.
return {
info: (msg: string, data?: unknown) =>
data !== undefined ? console.error(`${prefix} ${msg}`, data) : console.error(`${prefix} ${msg}`),
warn: (msg: string, data?: unknown) =>
data !== undefined ? console.warn(`${prefix} ${msg}`, data) : console.warn(`${prefix} ${msg}`),
error: (msg: string, data?: unknown) =>
data !== undefined ? console.error(`${prefix} ${msg}`, data) : console.error(`${prefix} ${msg}`),
debug: (msg: string, data?: unknown) =>
data !== undefined ? console.error(`${prefix} ${msg}`, data) : console.error(`${prefix} ${msg}`),
};
}

View File

@@ -0,0 +1,73 @@
/**
* API-backed embedder — calls Voyage AI or OpenAI embedding endpoints.
* For users who configure API keys in the Vault UI.
*/
import type { Embedder } from './embeddings.js';
import { normalizeDimensions } from './inprocess-embedder.js';
export interface ApiEmbedderConfig {
provider: 'voyage' | 'openai';
apiKey: string;
model?: string;
targetDimensions?: number;
baseUrl?: string;
}
const PROVIDER_DEFAULTS: Record<'voyage' | 'openai', { url: string; model: string }> = {
voyage: { url: 'https://api.voyageai.com/v1/embeddings', model: 'voyage-3-lite' },
openai: { url: 'https://api.openai.com/v1/embeddings', model: 'text-embedding-3-small' },
};
export function createApiEmbedder(config: ApiEmbedderConfig): Embedder {
const defaults = PROVIDER_DEFAULTS[config.provider];
const url = config.baseUrl ?? defaults.url;
const model = config.model ?? defaults.model;
const targetDims = config.targetDimensions ?? 1024;
async function callApi(input: string | string[]): Promise<Float32Array[]> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const body: Record<string, unknown> = { model, input };
if (config.provider === 'voyage') {
body.input_type = 'document';
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${config.provider} embeddings error (${response.status}): ${text}`);
}
const json = await response.json() as { data: Array<{ embedding: number[] }> };
return json.data.map(d => normalizeDimensions(new Float32Array(d.embedding), targetDims));
} finally {
clearTimeout(timeout);
}
}
return {
dimensions: targetDims,
async embed(text: string): Promise<Float32Array> {
const results = await callApi(text);
return results[0];
},
async embedBatch(texts: string[]): Promise<Float32Array[]> {
if (texts.length === 0) return [];
return callApi(texts);
},
};
}

View File

@@ -0,0 +1,170 @@
import type { MindDB } from './db.js';
export type AwarenessCategory = 'task' | 'action' | 'pending' | 'flag';
export interface AwarenessMetadata {
context?: string;
status?: string;
result?: string;
priority?: string;
[key: string]: unknown;
}
export interface AwarenessItem {
id: number;
category: AwarenessCategory;
content: string;
priority: number;
metadata: string;
created_at: string;
expires_at: string | null;
}
type AwarenessUpdate = Partial<Pick<AwarenessItem, 'content' | 'priority' | 'expires_at'>>;
const MAX_ITEMS = 10;
export class AwarenessLayer {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
this.ensureMetadataColumn();
}
/** Ensure metadata column exists for databases created before this feature */
private ensureMetadataColumn(): void {
try {
const raw = this.db.getDatabase();
const columns = raw.prepare("PRAGMA table_info(awareness)").all() as Array<{ name: string }>;
const hasMetadata = columns.some(c => c.name === 'metadata');
if (!hasMetadata) {
raw.exec("ALTER TABLE awareness ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}'");
}
} catch {
// Database may already be closed during async teardown — safe to skip migration
}
}
add(category: AwarenessCategory, content: string, priority = 0, expires_at?: string, metadata?: AwarenessMetadata): AwarenessItem {
const raw = this.db.getDatabase();
const metadataJson = metadata ? JSON.stringify(metadata) : '{}';
const result = raw.prepare(`
INSERT INTO awareness (category, content, priority, expires_at, metadata)
VALUES (?, ?, ?, ?, ?)
`).run(category, content, priority, expires_at ?? null, metadataJson);
return raw.prepare('SELECT * FROM awareness WHERE id = ?').get(result.lastInsertRowid) as AwarenessItem;
}
get(id: number): AwarenessItem | undefined {
return this.db.getDatabase().prepare('SELECT * FROM awareness WHERE id = ?').get(id) as AwarenessItem | undefined;
}
remove(id: number): void {
this.db.getDatabase().prepare('DELETE FROM awareness WHERE id = ?').run(id);
}
update(id: number, changes: AwarenessUpdate): AwarenessItem {
const fields = Object.entries(changes).filter(([, v]) => v !== undefined);
if (fields.length === 0) {
return this.db.getDatabase().prepare('SELECT * FROM awareness WHERE id = ?').get(id) as AwarenessItem;
}
const sets = fields.map(([k]) => `${k} = ?`).join(', ');
const values = fields.map(([, v]) => v);
const raw = this.db.getDatabase();
raw.prepare(`UPDATE awareness SET ${sets} WHERE id = ?`).run(...values, id);
return raw.prepare('SELECT * FROM awareness WHERE id = ?').get(id) as AwarenessItem;
}
updateMetadata(id: number, metadata: AwarenessMetadata): AwarenessItem {
const raw = this.db.getDatabase();
const existing = raw.prepare('SELECT metadata FROM awareness WHERE id = ?').get(id) as { metadata: string } | undefined;
if (!existing) {
throw new Error(`Awareness item ${id} not found`);
}
const current: AwarenessMetadata = JSON.parse(existing.metadata);
const merged = { ...current, ...metadata };
raw.prepare('UPDATE awareness SET metadata = ? WHERE id = ?').run(JSON.stringify(merged), id);
return raw.prepare('SELECT * FROM awareness WHERE id = ?').get(id) as AwarenessItem;
}
getByStatus(status: string): AwarenessItem[] {
const raw = this.db.getDatabase();
const items = raw.prepare(`
SELECT * FROM awareness
WHERE (expires_at IS NULL OR datetime(expires_at) > datetime('now'))
ORDER BY priority DESC
`).all() as AwarenessItem[];
return items.filter(item => {
try {
const meta: AwarenessMetadata = JSON.parse(item.metadata);
return meta.status === status;
} catch {
return false;
}
});
}
parseMetadata(item: AwarenessItem): AwarenessMetadata {
try {
return JSON.parse(item.metadata) as AwarenessMetadata;
} catch {
return {};
}
}
getAll(): AwarenessItem[] {
return this.db.getDatabase().prepare(`
SELECT * FROM awareness
WHERE expires_at IS NULL OR datetime(expires_at) > datetime('now')
ORDER BY priority DESC
LIMIT ?
`).all(MAX_ITEMS) as AwarenessItem[];
}
getByCategory(category: AwarenessCategory): AwarenessItem[] {
return this.db.getDatabase().prepare(`
SELECT * FROM awareness
WHERE category = ? AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))
ORDER BY priority DESC
LIMIT ?
`).all(category, MAX_ITEMS) as AwarenessItem[];
}
clear(): void {
this.db.getDatabase().prepare('DELETE FROM awareness').run();
}
clearCategory(category: AwarenessCategory): void {
this.db.getDatabase().prepare('DELETE FROM awareness WHERE category = ?').run(category);
}
toContext(): string {
const items = this.getAll();
if (items.length === 0) return 'No active awareness items.';
const grouped = new Map<string, AwarenessItem[]>();
for (const item of items) {
const list = grouped.get(item.category) ?? [];
list.push(item);
grouped.set(item.category, list);
}
const sections: string[] = [];
const labels: Record<AwarenessCategory, string> = {
task: 'Active Tasks',
action: 'Recent Actions',
pending: 'Pending Items',
flag: 'Context Flags',
};
for (const [cat, label] of Object.entries(labels)) {
const catItems = grouped.get(cat);
if (catItems && catItems.length > 0) {
sections.push(`${label}:\n${catItems.map(i => `- ${i.content}`).join('\n')}`);
}
}
return sections.join('\n\n');
}
}

View File

@@ -0,0 +1,194 @@
// Reverse-ported from OSS hive-mind chunker (oss-drift triage D1, 2026-06-11).
/**
* Semantic chunker for memory frames.
*
* Splits a frame's text content into coherent chunks suitable for embedding,
* then mapping back to the parent frame at recall time.
*
* Strategy (paragraph-first, sentence-fallback):
* 1. Split on blank lines to get paragraphs.
* 2. Greedily aggregate paragraphs up to maxChars.
* 3. If a single paragraph exceeds maxChars, sub-split it on sentence
* boundaries (., !, ?, newline within the paragraph), aggregate those
* up to maxChars.
* 4. Optionally prepend an overlap window from the previous chunk's tail
* so that a sentence straddling a chunk boundary still appears in both.
*
* Why these knobs:
* - maxChars=2000 ≈ 500 tokens for dense English. Fits comfortably in
* nomic-embed-text's 2048-token default context with headroom for the
* model's special tokens.
* - overlapChars=200 ≈ 50 tokens. Cheap recall safety net for queries
* whose answer phrase straddles a chunk boundary; doubles as redundancy
* for embedder noise.
*
* Char positions returned are RELATIVE to the input text (start inclusive,
* end exclusive — matching JS slice semantics). Useful for highlighting the
* chunk inside its parent frame at recall time.
*
* Pure function: no I/O, no side effects, no embedder dependency.
*/
export interface ChunkOptions {
maxChars?: number;
overlapChars?: number;
/**
* Below this length, the input is returned as a single chunk regardless
* of internal structure. Avoids fragmenting short frames into noisy
* 1-sentence chunks that hurt search quality.
*/
minChunkChars?: number;
}
export interface FrameChunk {
text: string;
charStart: number;
charEnd: number;
}
const DEFAULTS = {
maxChars: 2000,
overlapChars: 200,
minChunkChars: 1500,
};
/**
* Split text into chunks. Returns at least one chunk for non-empty input.
*/
export function chunkText(text: string, opts: ChunkOptions = {}): FrameChunk[] {
const maxChars = opts.maxChars ?? DEFAULTS.maxChars;
const overlapChars = Math.max(0, opts.overlapChars ?? DEFAULTS.overlapChars);
const minChunkChars = opts.minChunkChars ?? DEFAULTS.minChunkChars;
if (!text || text.length === 0) return [];
if (text.length <= minChunkChars) {
return [{ text, charStart: 0, charEnd: text.length }];
}
// Stage 1: split into paragraphs with their absolute char offsets.
// Treat any run of \n followed by another newline as a separator.
const paragraphs: Array<{ text: string; start: number; end: number }> = [];
const paragraphRe = /\n\s*\n/g;
let lastIdx = 0;
for (const m of text.matchAll(paragraphRe)) {
const matchStart = m.index ?? 0;
const slice = text.slice(lastIdx, matchStart);
if (slice.trim().length > 0) {
paragraphs.push({ text: slice, start: lastIdx, end: matchStart });
}
lastIdx = matchStart + m[0].length;
}
if (lastIdx < text.length) {
const slice = text.slice(lastIdx);
if (slice.trim().length > 0) {
paragraphs.push({ text: slice, start: lastIdx, end: text.length });
}
}
// Pathological input with no paragraph breaks: treat the whole text as one
// paragraph so the sentence-split path can still subdivide it.
if (paragraphs.length === 0) {
paragraphs.push({ text, start: 0, end: text.length });
}
// Stage 2: aggregate paragraphs into chunks. Sub-split any oversize paragraph.
type Span = { text: string; start: number; end: number };
const spans: Span[] = [];
for (const p of paragraphs) {
if (p.text.length <= maxChars) {
spans.push({ text: p.text, start: p.start, end: p.end });
} else {
const sentences = splitSentencesWithOffsets(p.text, p.start);
for (const s of sentences) {
if (s.text.length <= maxChars) {
spans.push(s);
} else {
for (let off = 0; off < s.text.length; off += maxChars) {
const sub = s.text.slice(off, off + maxChars);
spans.push({ text: sub, start: s.start + off, end: s.start + off + sub.length });
}
}
}
}
}
// Stage 3: greedy pack spans into chunks of <= maxChars, joining with '\n\n'
// so the embedder sees coherent paragraph boundaries.
const chunks: FrameChunk[] = [];
let current: { parts: string[]; start: number; end: number; len: number } | null = null;
const SEP = '\n\n';
for (const span of spans) {
const candidateLen = current ? current.len + SEP.length + span.text.length : span.text.length;
if (current && candidateLen > maxChars) {
chunks.push({
text: current.parts.join(SEP),
charStart: current.start,
charEnd: current.end,
});
current = null;
}
if (!current) {
current = { parts: [span.text], start: span.start, end: span.end, len: span.text.length };
} else {
current.parts.push(span.text);
current.end = span.end;
current.len = candidateLen;
}
}
if (current) {
chunks.push({
text: current.parts.join(SEP),
charStart: current.start,
charEnd: current.end,
});
}
// Stage 4: apply overlap by prepending the tail of the previous chunk.
// We never touch char_start/char_end here — those still describe the
// chunk's "primary" span in the source text. Overlap text is purely an
// embedding-quality boost, not a position claim.
if (overlapChars > 0 && chunks.length > 1) {
for (let i = 1; i < chunks.length; i++) {
const prevTail = chunks[i - 1].text.slice(-overlapChars);
chunks[i] = {
...chunks[i],
text: prevTail + (prevTail.endsWith('\n') ? '' : '\n') + chunks[i].text,
};
}
}
return chunks;
}
/**
* Sentence-split that preserves absolute char offsets relative to a base.
* Conservative — when in doubt, splits, since an over-split is harmless
* (more chunks) but an under-split bloats a chunk past maxChars and forces
* the hard-cut path.
*/
function splitSentencesWithOffsets(
text: string,
baseOffset: number,
): Array<{ text: string; start: number; end: number }> {
const results: Array<{ text: string; start: number; end: number }> = [];
// Match sentence-end punctuation followed by whitespace or end-of-string.
const re = /[.!?](?:\s+|$)/g;
let lastEnd = 0;
for (const m of text.matchAll(re)) {
const idx = m.index ?? 0;
const cut = idx + m[0].length;
const piece = text.slice(lastEnd, cut);
if (piece.trim().length > 0) {
results.push({ text: piece, start: baseOffset + lastEnd, end: baseOffset + cut });
}
lastEnd = cut;
}
if (lastEnd < text.length) {
const piece = text.slice(lastEnd);
if (piece.trim().length > 0) {
results.push({ text: piece, start: baseOffset + lastEnd, end: baseOffset + text.length });
}
}
return results.length > 0 ? results : [{ text, start: baseOffset, end: baseOffset + text.length }];
}

View File

@@ -0,0 +1,180 @@
/**
* F19: Spaced Repetition / Concept Tracking for Learning.
*
* Tracks concept mastery levels in the MindDB. Stores structured learning
* data rather than opaque blobs, enabling spaced repetition and mastery
* progression.
*/
import type { MindDB } from './db.js';
export interface ConceptEntry {
id: number;
concept: string;
mastery_level: number; // 1-5
last_tested: string | null; // ISO date
times_correct: number;
times_wrong: number;
notes: string;
created_at: string;
updated_at: string;
}
export interface ConceptUpdate {
mastery_level?: number;
notes?: string;
}
/** SQL to create the concept_mastery table. Run via MindDB migration. */
export const CONCEPT_MASTERY_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS concept_mastery (
id INTEGER PRIMARY KEY AUTOINCREMENT,
concept TEXT UNIQUE NOT NULL,
mastery_level INTEGER NOT NULL DEFAULT 1 CHECK (mastery_level BETWEEN 1 AND 5),
last_tested TEXT,
times_correct INTEGER NOT NULL DEFAULT 0,
times_wrong INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_concept_mastery_level ON concept_mastery (mastery_level);
`;
export class ConceptTracker {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
this.ensureTable();
}
private ensureTable(): void {
const raw = this.db.getDatabase();
const exists = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='concept_mastery'"
).get();
if (!exists) {
raw.exec(CONCEPT_MASTERY_TABLE_SQL);
}
}
/**
* Insert or update a concept. If the concept already exists, merge the update.
*/
upsertConcept(concept: string, update?: ConceptUpdate): ConceptEntry {
const raw = this.db.getDatabase();
const existing = raw.prepare(
'SELECT * FROM concept_mastery WHERE concept = ?'
).get(concept) as ConceptEntry | undefined;
if (existing) {
const newLevel = update?.mastery_level ?? existing.mastery_level;
const newNotes = update?.notes ?? existing.notes;
raw.prepare(`
UPDATE concept_mastery
SET mastery_level = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
Math.max(1, Math.min(5, newLevel)),
newNotes,
existing.id,
);
return raw.prepare('SELECT * FROM concept_mastery WHERE id = ?').get(existing.id) as ConceptEntry;
}
const level = Math.max(1, Math.min(5, update?.mastery_level ?? 1));
const notes = update?.notes ?? '';
const result = raw.prepare(`
INSERT INTO concept_mastery (concept, mastery_level, notes)
VALUES (?, ?, ?)
`).run(concept, level, notes);
return raw.prepare('SELECT * FROM concept_mastery WHERE id = ?').get(result.lastInsertRowid) as ConceptEntry;
}
/**
* Get a single concept by name.
*/
getConcept(concept: string): ConceptEntry | undefined {
return this.db.getDatabase().prepare(
'SELECT * FROM concept_mastery WHERE concept = ?'
).get(concept) as ConceptEntry | undefined;
}
/**
* List concepts, optionally filtered by mastery level range.
*/
listConcepts(minMastery?: number, maxMastery?: number): ConceptEntry[] {
const conditions: string[] = [];
const params: unknown[] = [];
if (minMastery !== undefined) {
conditions.push('mastery_level >= ?');
params.push(minMastery);
}
if (maxMastery !== undefined) {
conditions.push('mastery_level <= ?');
params.push(maxMastery);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return this.db.getDatabase().prepare(
`SELECT * FROM concept_mastery ${where} ORDER BY updated_at DESC`
).all(...params) as ConceptEntry[];
}
/**
* Record whether the user answered correctly about a concept.
* Adjusts mastery level: +1 on correct (max 5), -1 on wrong (min 1).
*/
recordAnswer(concept: string, correct: boolean): ConceptEntry {
const raw = this.db.getDatabase();
const existing = raw.prepare(
'SELECT * FROM concept_mastery WHERE concept = ?'
).get(concept) as ConceptEntry | undefined;
if (!existing) {
// Auto-create the concept on first answer
const level = correct ? 2 : 1;
const result = raw.prepare(`
INSERT INTO concept_mastery (concept, mastery_level, last_tested, times_correct, times_wrong)
VALUES (?, ?, datetime('now'), ?, ?)
`).run(concept, level, correct ? 1 : 0, correct ? 0 : 1);
return raw.prepare('SELECT * FROM concept_mastery WHERE id = ?').get(result.lastInsertRowid) as ConceptEntry;
}
const newLevel = correct
? Math.min(5, existing.mastery_level + 1)
: Math.max(1, existing.mastery_level - 1);
raw.prepare(`
UPDATE concept_mastery
SET mastery_level = ?,
last_tested = datetime('now'),
times_correct = times_correct + ?,
times_wrong = times_wrong + ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
newLevel,
correct ? 1 : 0,
correct ? 0 : 1,
existing.id,
);
return raw.prepare('SELECT * FROM concept_mastery WHERE id = ?').get(existing.id) as ConceptEntry;
}
/**
* Get concepts due for review -- low mastery or not tested recently.
* Returns concepts sorted by priority: lowest mastery first, then oldest test date.
*/
getDueForReview(limit = 10): ConceptEntry[] {
return this.db.getDatabase().prepare(`
SELECT * FROM concept_mastery
WHERE mastery_level < 4
ORDER BY mastery_level ASC, last_tested ASC NULLS FIRST
LIMIT ?
`).all(limit) as ConceptEntry[];
}
}

View File

@@ -0,0 +1,40 @@
import { createHash } from 'node:crypto';
/**
* content-hash.ts — canonical frame content hash for dedup (oss-drift D3).
*
* Adopts the OSS indexed-`content_hash`-column pattern but with MONO hash
* semantics: the hash covers `stripHmPrefix(content).trim()`, NOT bare
* `content.trim()` — provenance-insensitive dedup (OQ-6) is load-bearing
* here (two same-body captures of one turn from different sources must
* collapse regardless of their `[hm …]` metadata prefix). Porting the OSS
* trim-only definition verbatim would have regressed that.
*
* SINGLE definition shared by insert, dedup lookup, update, compaction, and
* the migration backfill so trim/strip semantics can never drift between
* call sites.
*
* Reverse-ported from OSS hive-mind content-hash.ts (oss-drift triage D3,
* 2026-06-11), semantics adapted per the triage verdict.
*/
/**
* Strip the leading hive-mind metadata prefix `[hm session:… src:… event:…] `
* so dedup compares the semantic turn BODY, not the provenance. The prefix is
* emitted by shim-core's `buildPrefix` (`[hm <tokens>] `); two captures of the
* same turn from different sources differ only in that prefix. Content without
* the prefix (harvest / ingest / cognify) is returned unchanged — a no-op.
* The regex anchors on `[hm ` and stops at the first `]`, so a body that
* merely contains `[` brackets later is never over-stripped.
*
* (Moved here from frames.ts so the hash and the strip live in one module;
* frames.ts re-exports it for back-compat.)
*/
export function stripHmPrefix(content: string): string {
return content.replace(/^\[hm [^\]]*\]\s*/, '');
}
/** Canonical content hash: sha256 over the stripped, trimmed body. */
export function hashFrameContent(content: string): string {
return createHash('sha256').update(stripHmPrefix(content).trim()).digest('hex');
}

View File

@@ -0,0 +1,675 @@
import Database from 'better-sqlite3';
import type { Database as DatabaseType } from 'better-sqlite3';
import * as sqliteVec from 'sqlite-vec';
import {
SCHEMA_SQL, VEC_TABLE_SQL, CHUNKS_VEC_TABLE_SQL, SCHEMA_VERSION,
vecTableSqlForDim, chunksVecTableSqlForDim,
} from './schema.js';
import { hashFrameContent } from './content-hash.js';
/** How long better-sqlite3 waits on a locked DB before throwing SQLITE_BUSY. The
* Fastify sidecar and the standalone memory-mcp server open the SAME
* ~/.waggle/personal.mind as separate OS processes, so a writer-writer clash would
* otherwise throw immediately instead of waiting for the lock to clear. */
const BUSY_TIMEOUT_MS = 10_000;
/** Bounded retry for the WAL `SQLITE_BUSY_SNAPSHOT` race that busy_timeout does NOT
* cover: a deferred transaction that began as a reader cannot upgrade to a writer
* once another connection has committed in between, and SQLite fails it instantly
* rather than waiting. Re-running the closure reads the fresh snapshot. */
const BUSY_RETRY_MAX_ATTEMPTS = 5;
const BUSY_RETRY_BASE_DELAY_MS = 20;
/** True for the two transient cross-process contention codes worth retrying. */
function isSqliteBusyError(err: unknown): boolean {
const code = (err as { code?: unknown } | null)?.code;
return code === 'SQLITE_BUSY' || code === 'SQLITE_BUSY_SNAPSHOT';
}
/** Synchronous backoff. better-sqlite3 is fully synchronous, so there is no event
* loop to yield to between retries; Atomics.wait blocks only this thread. */
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
// Reverse-ported from OSS hive-mind (oss-drift triage R7, 2026-06-11).
/** A persisted embedding fingerprint: which provider/model produced this .mind's
* vectors, and at what dimension. Recorded in `meta` on the first vector use. */
export interface EmbeddingFingerprint {
provider: string;
model: string;
dim: number;
}
export type FingerprintCheck =
| { status: 'recorded' }
| { status: 'match' }
| { status: 'model-changed'; storedModel: string; storedProvider: string };
/** Thrown when the active embedder's dimension differs from the dimension this
* .mind's vectors were written at. Mixing dims returns noise and corrupts the
* index, so we refuse loudly and point at the re-embed remediation. */
export class EmbeddingDimMismatchError extends Error {
constructor(
readonly storedDim: number,
readonly runtimeDim: number,
) {
super(
`Embedding dimension mismatch: this .mind stores ${storedDim}-dim vectors but the active ` +
`embedder produces ${runtimeDim}-dim vectors. Vector search would return noise and writes ` +
`would corrupt the index. Call MindDB.recreateVecTables(${runtimeDim}) and re-embed all ` +
`frames at the new dimension, or switch back to a ${storedDim}-dim model.`,
);
this.name = 'EmbeddingDimMismatchError';
}
}
export class MindDB {
private db: DatabaseType;
constructor(dbPath: string) {
this.db = new Database(dbPath);
// Enable WAL mode for better concurrent read performance
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
// Cross-process contention: the sidecar and memory-mcp open the same .mind
// file. Wait for a held lock instead of throwing SQLITE_BUSY on first contact
// (the WAL snapshot-upgrade race that this doesn't cover is retried in
// runWithBusyRetry).
this.db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
// Load sqlite-vec extension — support bundled path override for desktop builds
const vecPath = process.env.WAGGLE_SQLITE_VEC_PATH;
if (vecPath) {
this.db.loadExtension(vecPath);
} else {
sqliteVec.load(this.db);
}
this.initSchema();
}
private initSchema(): void {
const existing = this.db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='meta'"
).get() as { name: string } | undefined;
if (!existing) {
this.db.exec(SCHEMA_SQL);
this.db.exec(VEC_TABLE_SQL);
this.db.exec(CHUNKS_VEC_TABLE_SQL);
this.db.prepare(
"INSERT INTO meta (key, value) VALUES ('schema_version', ?)"
).run(SCHEMA_VERSION);
// 2026-04-15: Track first-run so Art. 19 retention checker can distinguish
// 'new system, no logs yet' from 'old system, logs pruned'.
this.db.prepare(
"INSERT INTO meta (key, value) VALUES ('first_run_at', ?)"
).run(new Date().toISOString());
} else {
this.runMigrations();
// Backfill first_run_at for pre-existing DBs. Best-effort: we don't know when
// they were actually created so we approximate with 'now' — this means retroactive
// retention checks can't be perfect, but forward-looking checks will be correct
// within 180 days.
const hasFirstRun = this.db.prepare(
"SELECT value FROM meta WHERE key = 'first_run_at'"
).get() as { value: string } | undefined;
if (!hasFirstRun) {
this.db.prepare(
"INSERT INTO meta (key, value) VALUES ('first_run_at', ?)"
).run(new Date().toISOString());
}
}
}
/** Read the first-run timestamp for this database (ISO 8601). Returns null if missing. */
getFirstRunAt(): string | null {
try {
const row = this.db.prepare(
"SELECT value FROM meta WHERE key = 'first_run_at'"
).get() as { value: string } | undefined;
return row?.value ?? null;
} catch {
return null;
}
}
/** Run incremental schema migrations for existing .mind databases */
private runMigrations(): void {
// 2026-04-16: Ensure all tables from SCHEMA_SQL exist. Old .mind databases
// may predate tables added during sprint work (ai_interactions, execution_traces,
// evolution_runs, harvest_sources, procedures, improvement_signals, install_audit).
// SCHEMA_SQL uses CREATE TABLE/INDEX IF NOT EXISTS throughout, so re-running it
// is safe and idempotent — it only creates what's missing.
//
// CRASH RECOVERY (must run before the rebuild below): a pre-transactional
// build of the FIX-3/M2 rebuild could die mid-sequence, stranding every
// audit row in install_audit__mig_old while install_audit is missing or
// freshly recreated empty — and the next rebuild's DROP would then destroy
// them permanently. Restore before anything else touches the table.
const migOldExists = !!this.db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='install_audit__mig_old'"
).get();
if (migOldExists) {
const auditExists = !!this.db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='install_audit'"
).get();
if (!auditExists) {
// Crash landed between RENAME and recreate — rename back wholesale;
// the sentinel check below re-runs the (now transactional) rebuild.
this.db.prepare('ALTER TABLE install_audit__mig_old RENAME TO install_audit').run();
} else {
// Crash landed between recreate and copy-back: copy the stranded rows
// home if nothing new was written, then retire the stale table.
const cnt = (this.db.prepare('SELECT COUNT(*) AS cnt FROM install_audit')
.get() as { cnt: number }).cnt;
if (cnt === 0) {
this.db.prepare(
`INSERT INTO install_audit
(id, timestamp, capability_name, capability_type, source, version,
risk_level, trust_source, approval_class, action, initiator, detail)
SELECT id, timestamp, capability_name, capability_type, source, version,
risk_level, trust_source, approval_class, action, initiator, detail
FROM install_audit__mig_old`
).run();
}
this.db.prepare('DROP TABLE install_audit__mig_old').run();
}
}
// FIX-3 (2026-05-17): install_audit's capability_type / approval_class /
// action CHECK lists drifted behind their TS type unions
// (connector/marketplace/blocked). Because the CREATE below is
// IF NOT EXISTS, an existing install_audit keeps its stale CHECK and
// auditStore.record() crashes the moment acquire_capability proposes a
// marketplace/connector capability. Rename the stale table aside so the
// corrected SCHEMA_SQL DDL (single source of truth) recreates it; rows
// are copied back below. Idempotent: keyed on whether the stored DDL
// already lists 'marketplace'.
//
// M2 (UX-Refactor Phase 4, 2026-06-10): risk_level's CHECK drifted the same
// way — TS AuditRiskLevel gained 'critical' but the DDL allowed only
// low/medium/high, so marketplace.ts's CRITICAL-block audit write was
// silently rejected. Same rebuild mechanism, keyed on the widened
// risk_level list literal ("'low', 'medium', 'high', 'critical'" — note
// 'critical' alone is NOT a safe sentinel: it already appears in the
// approval_class CHECK).
//
// The whole rename→recreate→copy-back→drop sequence runs in ONE
// transaction: a process death mid-rebuild rolls back to the pre-rebuild
// state instead of silently orphaning the audit trail (this is the EU AI
// Act compliance table — partial loss here is not acceptable).
const auditTableSql = (this.db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='install_audit'"
).get() as { sql: string } | undefined)?.sql;
// P5/D4 (2026-06-12): AuditAction gained 'uninstalled' so skill/capability
// removal is auditable. Same rebuild mechanism, keyed on whether the stored
// action CHECK already lists 'uninstalled' ('uninstalled' is a safe sentinel —
// it appears in no other CHECK on this table).
// P7/D15 #15 (2026-06-12): trust_source gained a CHECK (was unconstrained).
// Sentinel "CHECK (trust_source IN" appears nowhere else.
const auditNeedsRebuild = auditTableSql !== undefined && (
!auditTableSql.includes("'marketplace'")
|| !auditTableSql.includes("'low', 'medium', 'high', 'critical'")
|| !auditTableSql.includes("'uninstalled'")
|| !auditTableSql.includes('CHECK (trust_source IN')
);
if (auditNeedsRebuild) {
this.db.transaction(() => {
this.db.prepare('DROP TABLE IF EXISTS install_audit__mig_old').run();
this.db.prepare('ALTER TABLE install_audit RENAME TO install_audit__mig_old').run();
this.db.prepare('DROP INDEX IF EXISTS idx_audit_capability').run();
this.db.prepare('DROP INDEX IF EXISTS idx_audit_timestamp').run();
// SCHEMA_SQL recreates install_audit with the widened CHECK (and is
// idempotent for every other table — see the comment block above).
this.db.exec(SCHEMA_SQL);
this.db.prepare(
`INSERT INTO install_audit
(id, timestamp, capability_name, capability_type, source, version,
risk_level, trust_source, approval_class, action, initiator, detail)
SELECT id, timestamp, capability_name, capability_type, source, version,
risk_level, trust_source, approval_class, action, initiator, detail
FROM install_audit__mig_old`
).run();
this.db.prepare('DROP TABLE install_audit__mig_old').run();
})();
} else {
this.db.exec(SCHEMA_SQL);
}
// oss-drift D1 (2026-06-11): chunk-level retrieval. SCHEMA_SQL above creates
// memory_frame_chunks (IF NOT EXISTS); the vec0 virtual table needs its own
// idempotent exec because vec tables live outside SCHEMA_SQL (they require
// the sqlite-vec extension, loaded in the constructor). Databases that
// predate D1 gain an EMPTY chunk index here — vectorSearchChunks returns
// null on an empty index, so recall falls back to whole-frame vectors until
// rechunkAllFrames (or flag-gated indexFrame chunking) populates it.
this.db.exec(CHUNKS_VEC_TABLE_SQL);
// W2.1: Add 'source' column to memory_frames (provenance tracking)
const hasSourceCol = this.db.prepare(
"SELECT COUNT(*) as cnt FROM pragma_table_info('memory_frames') WHERE name='source'"
).get() as { cnt: number };
if (hasSourceCol.cnt === 0) {
this.db.exec(
"ALTER TABLE memory_frames ADD COLUMN source TEXT NOT NULL DEFAULT 'user_stated'"
);
}
// UX-Refactor Phase 2B: Add 'metadata' column to memory_frames. JSON blob
// backing the Memory Center (kind/confidence/scope/status/sourceId/tags/
// evidence/related*; PRD §15.4). Required by the Phase-2 gate ratifications
// A8 (reversible Archive status) + C33 (persisted 'unreviewed' status) +
// B2 (heuristic confidence) — all need per-frame state that survives a
// restart. Idempotent ADD COLUMN, same pattern as 'source' above; existing
// rows default to '{}'.
const hasMetadataCol = this.db.prepare(
"SELECT COUNT(*) as cnt FROM pragma_table_info('memory_frames') WHERE name='metadata'"
).get() as { cnt: number };
if (hasMetadataCol.cnt === 0) {
this.db.exec(
"ALTER TABLE memory_frames ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}'"
);
}
// oss-drift D3 (2026-06-11): indexed content_hash for O(1) frame dedup —
// FrameStore.findDuplicate previously scanned only the last 500 frames
// (silently missed older duplicates). Hash semantics are MONO's
// (stripHmPrefix + trim, mind/content-hash.ts), so the backfill must use
// hashFrameContent, never a SQL-side hash. Idempotent: ADD COLUMN guarded
// by pragma check; backfill targets only NULL rows (no-op when current).
const hasContentHashCol = this.db.prepare(
"SELECT COUNT(*) as cnt FROM pragma_table_info('memory_frames') WHERE name='content_hash'"
).get() as { cnt: number };
if (hasContentHashCol.cnt === 0) {
this.db.exec('ALTER TABLE memory_frames ADD COLUMN content_hash TEXT');
}
this.db.exec(
'CREATE INDEX IF NOT EXISTS idx_frames_content_hash ON memory_frames (content_hash)'
);
// W4.1: KG entity↔frame bridge — powers the 'contextual' scoring signal by
// mapping query-seeded graph distances back onto frames. Idempotent; SCHEMA_SQL
// carries the same DDL for fresh DBs. frames.ts already DELETEs from this table
// on frame deletion; the ON DELETE CASCADE FK makes that belt-and-suspenders.
this.db.exec(`
CREATE TABLE IF NOT EXISTS kg_entity_frames (
entity_id INTEGER NOT NULL REFERENCES knowledge_entities(id) ON DELETE CASCADE,
frame_id INTEGER NOT NULL REFERENCES memory_frames(id) ON DELETE CASCADE,
PRIMARY KEY (entity_id, frame_id)
);
CREATE INDEX IF NOT EXISTS idx_kg_entity_frames_frame ON kg_entity_frames (frame_id);
CREATE INDEX IF NOT EXISTS idx_kg_entity_frames_entity ON kg_entity_frames (entity_id);
`);
this.backfillContentHash();
// 2026-04-15: EU AI Act Art. 12.1(a) — record inputs and outputs, not just
// token counts (review Critical #3 from cowork/Code-Review_Compliance).
const hasInputText = this.db.prepare(
"SELECT COUNT(*) as cnt FROM pragma_table_info('ai_interactions') WHERE name='input_text'"
).get() as { cnt: number };
if (hasInputText.cnt === 0) {
this.db.exec("ALTER TABLE ai_interactions ADD COLUMN input_text TEXT");
}
const hasOutputText = this.db.prepare(
"SELECT COUNT(*) as cnt FROM pragma_table_info('ai_interactions') WHERE name='output_text'"
).get() as { cnt: number };
if (hasOutputText.cnt === 0) {
this.db.exec("ALTER TABLE ai_interactions ADD COLUMN output_text TEXT");
}
// 2026-04-15: Append-only triggers for audit log (review Critical #1). Idempotent.
this.db.exec(
"CREATE TRIGGER IF NOT EXISTS ai_interactions_no_delete BEFORE DELETE ON ai_interactions BEGIN SELECT RAISE(ABORT, 'ai_interactions is append-only (EU AI Act Art. 12 audit log)'); END"
);
this.db.exec(
"CREATE TRIGGER IF NOT EXISTS ai_interactions_no_update BEFORE UPDATE ON ai_interactions BEGIN SELECT RAISE(ABORT, 'ai_interactions is append-only (EU AI Act Art. 12 audit log)'); END"
);
// #7 (2026-06-30): verbatim provenance archive — append-only, immutable.
// Idempotent; SCHEMA_SQL carries the same DDL for fresh DBs. Not in the
// retrieval corpus (no FTS/vec). Append-only triggers mirror ai_interactions,
// EXCEPT a one-time GDPR Art.17 redaction (see raw_archive_no_update WHEN clause).
this.db.exec(`
CREATE TABLE IF NOT EXISTS raw_archive (
id INTEGER PRIMARY KEY AUTOINCREMENT,
archive_uid TEXT NOT NULL UNIQUE,
source TEXT NOT NULL,
source_ref TEXT,
title TEXT,
content TEXT NOT NULL,
content_sha256 TEXT NOT NULL,
injection_flagged INTEGER NOT NULL DEFAULT 0,
injection_flags TEXT NOT NULL DEFAULT '',
source_timestamp TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
erased_at TEXT,
erased_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_raw_archive_source_ref ON raw_archive (source, source_ref);
CREATE INDEX IF NOT EXISTS idx_raw_archive_created ON raw_archive (created_at DESC);
`);
// GDPR Art.17 columns for pre-erasure DBs (idempotent ADD COLUMN, same pattern
// as memory_frames.source/metadata above). MUST precede the trigger below, which
// references NEW.erased_at / OLD.erased_at. ALTER is DDL — it does NOT fire the
// BEFORE UPDATE trigger.
for (const col of ['erased_at', 'erased_reason'] as const) {
const has = this.db.prepare(
"SELECT COUNT(*) as cnt FROM pragma_table_info('raw_archive') WHERE name=?"
).get(col) as { cnt: number };
if (has.cnt === 0) {
this.db.exec(`ALTER TABLE raw_archive ADD COLUMN ${col} TEXT`);
}
}
// Size-guard columns for pre-guard DBs (idempotent ADD COLUMN, distinct DDL
// per column so `truncated` gets its NOT NULL DEFAULT). Not referenced by the
// append-only trigger, so no trigger swap is needed. See raw-archive.ts append().
for (const [col, ddl] of [
['truncated', 'INTEGER NOT NULL DEFAULT 0'],
['original_length', 'INTEGER'],
] as const) {
const has = this.db.prepare(
"SELECT COUNT(*) as cnt FROM pragma_table_info('raw_archive') WHERE name=?"
).get(col) as { cnt: number };
if (has.cnt === 0) {
this.db.exec(`ALTER TABLE raw_archive ADD COLUMN ${col} ${ddl}`);
}
}
// Upgrade the legacy ABSOLUTE no-update trigger to the redaction-aware one.
// CREATE TRIGGER IF NOT EXISTS will NOT swap an existing trigger, so we DROP +
// CREATE — but ATOMICALLY (one transaction), else a crash or a concurrent WAL
// writer between the two statements would see raw_archive with NO update guard.
// Sentinel: skip once the live trigger already carries the archive_uid-ROTATION
// clause (both a perf win and it stops re-opening the swap window on every process
// start). An OLD trigger that still froze archive_uid ('IS OLD.archive_uid') lacks
// this substring, so it is upgraded on reopen — required, else the rotating erase()
// would be rejected on an existing DB. The WHEN clause is kept BYTE-IDENTICAL to the
// SCHEMA_SQL version in schema.ts, and the content literal to
// RAW_ARCHIVE_REDACTION_MARKER in raw-archive.ts. (Forward-only: this does NOT
// rotate the uid of rows erased under the old trigger — erase() shipped 2026-07-01,
// so real DBs have ~zero such rows; the trigger only permits rotation during the
// one-time erased_at NULL->set transition, not on an already-erased row.)
const liveNoUpdate = this.db.prepare(
"SELECT sql FROM sqlite_master WHERE type='trigger' AND name='raw_archive_no_update'"
).get() as { sql?: string } | undefined;
if (!liveNoUpdate?.sql || !liveNoUpdate.sql.includes('NEW.archive_uid <> OLD.archive_uid')) {
this.db.transaction(() => {
this.db.exec('DROP TRIGGER IF EXISTS raw_archive_no_update');
this.db.exec(
"CREATE TRIGGER raw_archive_no_update BEFORE UPDATE ON raw_archive " +
"WHEN NOT (OLD.erased_at IS NULL AND NEW.erased_at IS NOT NULL AND NEW.erased_at <> '' " +
"AND NEW.content = '[REDACTED — GDPR Art.17 erasure]' AND NEW.content_sha256 = '' AND NEW.title IS NULL " +
"AND NEW.id IS OLD.id AND NEW.archive_uid <> OLD.archive_uid AND NEW.archive_uid <> '' " +
"AND NEW.source IS OLD.source AND NEW.source_ref IS OLD.source_ref " +
"AND NEW.created_at IS OLD.created_at AND NEW.source_timestamp IS OLD.source_timestamp " +
"AND NEW.injection_flagged IS OLD.injection_flagged AND NEW.injection_flags IS OLD.injection_flags) " +
"BEGIN SELECT RAISE(ABORT, 'raw_archive is append-only; only a one-time canonical GDPR Art.17 redaction is permitted'); END"
);
})();
}
this.db.exec(
"CREATE TRIGGER IF NOT EXISTS raw_archive_no_delete BEFORE DELETE ON raw_archive BEGIN SELECT RAISE(ABORT, 'raw_archive is append-only (verbatim provenance archive)'); END"
);
// #7 (2026-07-02): erased-subject suppression list — makes Art.17 erasure
// "sticky" across re-import. Idempotent; SCHEMA_SQL carries the same DDL for
// fresh DBs. Keyed on (source, source_ref) only (no content/hash — that would
// reintroduce the re-id vector). Rows are deletable (re-consent path), so NO
// immutability trigger. Then one-time backfill from the already-erased
// raw_archive rows so PAST erasures become sticky too (see backfillErasedSubjects).
this.db.exec(`
CREATE TABLE IF NOT EXISTS erased_subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
source_ref TEXT NOT NULL,
erased_at TEXT NOT NULL DEFAULT (datetime('now')),
reason TEXT,
UNIQUE(source, source_ref)
);
CREATE INDEX IF NOT EXISTS idx_erased_subjects_lookup ON erased_subjects (source, source_ref);
`);
this.backfillErasedSubjects();
// W4.1: one-time backfill of the kg_entity_frames bridge over pre-existing
// frames (new writes populate it live via cognify/harvest). Sentinel-guarded.
this.backfillKgEntityFrames();
}
/** One-time backfill of the kg_entity_frames bridge so the 'contextual' scoring
* signal works over frames written before the bridge existed. Offline (string
* match, no LLM): an entity links to a frame whose content mentions its name.
* Idempotent (INSERT OR IGNORE) and guarded by a meta sentinel unless `force`.
* Returns the number of new (entity, frame) links created. */
backfillKgEntityFrames(force = false): number {
if (!force) {
const done = this.db.prepare("SELECT value FROM meta WHERE key = 'kg_bridge_backfilled'").get();
if (done) return 0;
}
const frames = this.db
.prepare('SELECT id, content FROM memory_frames')
.all() as { id: number; content: string }[];
// Ubiquity cap: an entity mentioned in nearly every frame (e.g. "Claude" in a
// claude-code export) is a hub that carries no locational signal — skip it.
// Cap at 40% of frames, floored at 20 so small corpora aren't over-filtered.
const cap = Math.max(20, Math.floor(frames.length * 0.4));
const ents = this.db
.prepare("SELECT id, lower(name) AS lname FROM knowledge_entities WHERE valid_to IS NULL AND length(name) >= 3")
.all() as { id: number; lname: string }[];
const countStmt = this.db.prepare(
'SELECT COUNT(*) AS c FROM memory_frames WHERE instr(lower(content), ?) > 0'
);
const keep = ents.filter((e) => {
const c = (countStmt.get(e.lname) as { c: number }).c;
return c > 0 && c <= cap;
});
const link = this.db.prepare(
'INSERT OR IGNORE INTO kg_entity_frames (entity_id, frame_id) VALUES (?, ?)'
);
let created = 0;
const run = this.db.transaction(() => {
// On a forced re-run, rebuild from scratch so hub/merged entities don't linger.
if (force) this.db.prepare('DELETE FROM kg_entity_frames').run();
for (const f of frames) {
const lc = f.content.toLowerCase();
for (const e of keep) {
if (lc.includes(e.lname)) created += link.run(e.id, f.id).changes;
}
}
this.db
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('kg_bridge_backfilled', '1')")
.run();
});
run();
return created;
}
/** One-time backfill of the erased-subject suppression list (#7 Art.17 "sticky
* erasure") from raw_archive rows that were ALREADY erased before this feature
* shipped. Keyed on (source, source_ref); rows with a NULL source_ref carry no
* subject key and are skipped. Idempotent (INSERT OR IGNORE), meta-sentinel-guarded
* unless `force`. Returns the number of new suppression rows created.
*
* LIMITATION (id-domain mismatch): a subject harvested+erased BEFORE the stable-id
* arc has source_ref = a random UUID (adapters minted randomUUID() pre-arc). A fresh
* re-export now mints a DETERMINISTIC stableHarvestId ≠ that UUID, so the backfilled
* row won't match the new re-import and can't suppress it. The backfill is thus an
* accurate LEDGER of historical erasures but only re-suppresses a re-feed of the
* identical old-id data; a fresh re-export of pre-arc data re-establishes stickiness
* only on its next re-erase (which records the stable id). Post-arc erasures are fully
* sticky (eraseBySourceRef records the resolved stable source_ref). */
backfillErasedSubjects(force = false): number {
if (!force) {
const done = this.db.prepare("SELECT value FROM meta WHERE key = 'erased_subjects_backfilled'").get();
if (done) return 0;
}
let created = 0;
const run = this.db.transaction(() => {
created = this.db.prepare(
`INSERT OR IGNORE INTO erased_subjects (source, source_ref, erased_at, reason)
SELECT source, source_ref, erased_at, erased_reason
FROM raw_archive WHERE erased_at IS NOT NULL AND source_ref IS NOT NULL`
).run().changes;
this.db
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('erased_subjects_backfilled', '1')")
.run();
});
run();
return created;
}
/** Backfill memory_frames.content_hash for rows inserted before the column
* existed (oss-drift D3). Transactional; only NULL rows touched. */
private backfillContentHash(): void {
const rows = this.db
.prepare('SELECT id, content FROM memory_frames WHERE content_hash IS NULL')
.all() as { id: number; content: string }[];
if (rows.length === 0) return;
const update = this.db.prepare('UPDATE memory_frames SET content_hash = ? WHERE id = ?');
const tx = this.db.transaction((items: { id: number; content: string }[]) => {
for (const r of items) update.run(hashFrameContent(r.content), r.id);
});
tx(rows);
}
// Reverse-ported from OSS hive-mind (oss-drift triage R7, 2026-06-11).
/** Read a single `meta` value, or null if absent. */
private getMeta(key: string): string | null {
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as
| { value: string }
| undefined;
return row?.value ?? null;
}
/** Upsert a single `meta` key/value (meta.key is the PRIMARY KEY). */
private setMeta(key: string, value: string): void {
this.db
.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
)
.run(key, value);
}
/**
* Guard this .mind's embedding fingerprint. Call before the first vector
* write/read of a session (HybridSearch is the natural seam — it holds both
* the db and the embedder). Returns the check result; throws only on a hard
* dimension mismatch:
* - no fingerprint yet → record {provider, model, dim}, return 'recorded'
* - same dim + same model → 'match' (no-op)
* - same dim, different model/provider → update + 'model-changed' (caller
* should warn: vectors stay numerically valid but cross-model comparison
* is semantically degraded)
* - different dim → throw EmbeddingDimMismatchError (only safe path is re-embed)
*/
ensureEmbeddingFingerprint(fp: EmbeddingFingerprint): FingerprintCheck {
const storedDimRaw = this.getMeta('embedding_dim');
if (storedDimRaw === null) {
this.setMeta('embedding_provider', fp.provider);
this.setMeta('embedding_model', fp.model);
this.setMeta('embedding_dim', String(fp.dim));
return { status: 'recorded' };
}
const storedDim = Number(storedDimRaw);
if (storedDim !== fp.dim) {
throw new EmbeddingDimMismatchError(storedDim, fp.dim);
}
const storedModel = this.getMeta('embedding_model') ?? '';
const storedProvider = this.getMeta('embedding_provider') ?? '';
if (storedModel !== fp.model || storedProvider !== fp.provider) {
this.setMeta('embedding_provider', fp.provider);
this.setMeta('embedding_model', fp.model);
return { status: 'model-changed', storedModel, storedProvider };
}
return { status: 'match' };
}
/** Force-write the embedding fingerprint. Used after a re-embed so the guard
* matches the embedder that produced the new vectors. */
setEmbeddingFingerprint(fp: EmbeddingFingerprint): void {
this.setMeta('embedding_provider', fp.provider);
this.setMeta('embedding_model', fp.model);
this.setMeta('embedding_dim', String(fp.dim));
}
/** Read the recorded embedding fingerprint, or null if none recorded yet. */
getEmbeddingFingerprint(): EmbeddingFingerprint | null {
const dimRaw = this.getMeta('embedding_dim');
if (dimRaw === null) return null;
return {
provider: this.getMeta('embedding_provider') ?? 'unknown',
model: this.getMeta('embedding_model') ?? 'unknown',
dim: Number(dimRaw),
};
}
/**
* DROP + CREATE both vec tables (memory_frames_vec + memory_frame_chunks_vec)
* at `dim` (vec0 columns can't be ALTERed) and update the stored dim.
* DESTRUCTIVE — existing vectors are discarded; the caller re-embeds
* afterward (e.g. reconcileVecIndex over all frames + rechunkAllFrames for
* chunks). This is the remediation for an EmbeddingDimMismatchError.
*
* memory_frame_chunks CONTENT rows deliberately survive (OSS behavior):
* they're derived text, not vectors — re-deriving them is rechunkAllFrames'
* job, and an empty chunks_vec makes vectorSearchChunks return no rows so
* stale chunk rows are inert until re-embedded.
*/
recreateVecTables(dim: number): void {
const d = Math.trunc(dim);
const tx = this.db.transaction(() => {
this.db.exec(
'DROP TABLE IF EXISTS memory_frames_vec; DROP TABLE IF EXISTS memory_frame_chunks_vec;'
);
this.db.exec(vecTableSqlForDim(d));
this.db.exec(chunksVecTableSqlForDim(d));
this.setMeta('embedding_dim', String(d));
});
tx();
}
getDatabase(): DatabaseType {
return this.db;
}
/**
* Run a write closure, retrying on transient cross-process contention
* (SQLITE_BUSY / SQLITE_BUSY_SNAPSHOT) with bounded, growing backoff. The
* busy_timeout pragma already covers plain lock waits; this adds the WAL
* snapshot-upgrade race it cannot. Non-BUSY errors propagate immediately; after
* the attempt budget is exhausted the last BUSY error is rethrown.
*
* Centralized so a caller wraps the OUTERMOST write (a whole `db.transaction`)
* exactly ONCE. Do NOT wrap a statement nested inside an ambient transaction: a
* retry there cannot obtain a fresh snapshot and would mask the real failure.
*/
runWithBusyRetry<T>(fn: () => T): T {
let lastErr: unknown;
for (let attempt = 0; attempt < BUSY_RETRY_MAX_ATTEMPTS; attempt++) {
try {
return fn();
} catch (err: unknown) {
if (!isSqliteBusyError(err)) throw err;
lastErr = err;
if (attempt < BUSY_RETRY_MAX_ATTEMPTS - 1) {
sleepSync(BUSY_RETRY_BASE_DELAY_MS * (attempt + 1));
}
}
}
throw lastErr;
}
/**
* True while the underlying better-sqlite3 handle is open. Used by
* MultiMindCache's reopen-guard to detect a handle that was closed
* out-of-band before handing it back.
*/
isOpen(): boolean {
return this.db.open;
}
close(): void {
this.db.close();
}
}

View File

@@ -0,0 +1,537 @@
/**
* EmbeddingProvider — orchestrates the InProcess → Ollama → API → Mock fallback chain.
* Single entry point for all embedding operations in Waggle.
* Implements the Embedder interface — drop-in replacement everywhere.
*
* Tier enforcement: provider selection is gated by TIER_CAPABILITIES.embeddingProviders.
* Quota enforcement: monthly embed count tracked in embedding_usage table.
*/
import type { Embedder } from './embeddings.js';
import type { Database as DatabaseType } from 'better-sqlite3';
import { type Tier, TIERS, TIER_CAPABILITIES, TierError } from '@waggle/shared';
import { createCoreLogger } from '../logger.js';
const log = createCoreLogger('embedding');
export type EmbeddingProviderType = 'inprocess' | 'ollama' | 'voyage' | 'openai' | 'litellm' | 'mock';
export interface EmbeddingProviderConfig {
provider?: EmbeddingProviderType | 'auto';
targetDimensions?: number;
/** User tier — gates which providers are available and monthly quota. Defaults to SOLO. */
userTier?: Tier;
/** User ID for quota tracking. Defaults to 'local'. */
userId?: string;
/** Raw SQLite database for quota tracking. Optional — quota not enforced without it. */
quotaDb?: DatabaseType;
inprocess?: { model?: string; cacheDir?: string };
ollama?: { baseUrl?: string; model?: string };
voyage?: { apiKey: string; model?: string };
openai?: { apiKey: string; model?: string };
litellm?: { url: string; apiKey?: string; model?: string };
}
// ── Tier enforcement helpers ──────────────────────────────────────────
/** Find the lowest tier that allows a given embedding provider. */
export function getMinimumTierForProvider(provider: EmbeddingProviderType): Tier {
for (const tier of TIERS) {
const allowed = TIER_CAPABILITIES[tier].embeddingProviders as readonly string[];
if (allowed.includes(provider)) return tier;
}
return 'ENTERPRISE';
}
// ── Quota tracking ────────────────────────────────────────────────────
const EMBEDDING_USAGE_SCHEMA = `
CREATE TABLE IF NOT EXISTS embedding_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
year_month TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
UNIQUE(user_id, year_month)
);
`;
function ensureQuotaTable(db: DatabaseType): void {
try { db.exec(EMBEDDING_USAGE_SCHEMA); } catch { /* table may already exist */ }
}
function getCurrentYearMonth(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function getUsageCount(db: DatabaseType, userId: string, yearMonth: string): number {
const row = db.prepare(
'SELECT count FROM embedding_usage WHERE user_id = ? AND year_month = ?'
).get(userId, yearMonth) as { count: number } | undefined;
return row?.count ?? 0;
}
function incrementUsage(db: DatabaseType, userId: string, yearMonth: string, amount: number): void {
db.prepare(`
INSERT INTO embedding_usage (user_id, year_month, count, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, year_month) DO UPDATE SET count = count + ?, updated_at = ?
`).run(userId, yearMonth, amount, Date.now(), amount, Date.now());
}
export class EmbeddingQuotaExceededError extends Error {
public readonly tier: Tier;
public readonly quota: number;
public readonly current: number;
public readonly upgradeUrl = 'https://waggle-os.ai/upgrade';
constructor(tier: Tier, quota: number, current: number) {
super(`Embedding quota exceeded: ${current}/${quota} for ${tier} tier`);
this.name = 'EmbeddingQuotaExceededError';
this.tier = tier;
this.quota = quota;
this.current = current;
}
}
export interface EmbeddingQuotaStatus {
tier: Tier;
quota: number;
used: number;
remaining: number;
percentage: number;
resetsAt: string;
}
function getNextMonthReset(): string {
const d = new Date();
d.setMonth(d.getMonth() + 1, 1);
d.setHours(0, 0, 0, 0);
return d.toISOString();
}
export interface EmbeddingProviderStatus {
activeProvider: EmbeddingProviderType;
availableProviders: EmbeddingProviderType[];
dimensions: number;
modelName: string;
lastError?: string;
probeTimestamp: string;
}
export interface EmbeddingProviderInstance extends Embedder {
getStatus(): EmbeddingProviderStatus;
getActiveProvider(): EmbeddingProviderType;
reprobe(): Promise<EmbeddingProviderStatus>;
/** Get current quota status for the user. Returns unlimited values if no quotaDb configured. */
getQuotaStatus(): EmbeddingQuotaStatus;
}
/** Deterministic mock — last resort, semantically meaningless. */
function mockEmbed(text: string, dims: number): Float32Array {
const arr = new Float32Array(dims);
const bytes = new TextEncoder().encode(text);
for (let i = 0; i < Math.min(bytes.length, dims); i++) {
arr[i] = (bytes[i] - 128) / 128;
}
return arr;
}
function createMockEmbedder(dims: number): Embedder {
return {
dimensions: dims,
async embed(text: string) { return mockEmbed(text, dims); },
async embedBatch(texts: string[]) { return texts.map(t => mockEmbed(t, dims)); },
};
}
// ── Embed-input guards (oversized-frame truncation + skip-not-abort) ──
// Reverse-ported from OSS hive-mind (oss-drift triage R5, 2026-06-11).
/**
* Per-input character cap for embedding. `nomic-embed-text` has a 2048-token
* (~6K char dense English) default context. Embedding an input longer than
* the backend's context makes the backend reject the request, so we cap here.
*
* D1 probe finding (2026-06-12): model NAMES lie about context. A custom
* `nomic-embed-text-8k` (num_ctx 8192) still 400s at its nomic-bert
* ARCHITECTURE limit of 2048 tokens — the OSS heuristic's 24K-char branch
* for `*-8k` names sent every long frame down the mock-fallback path. The
* `-8k` branch is therefore capped at 8K chars (≈2048 prose tokens): safe
* for the architecture-limited reality, merely conservative for a genuine
* 8192-token embedder. reembedPerText remains the backstop for token-dense
* content that still exceeds the backend's real window.
*/
export function maxEmbedCharsForModel(modelName: string): number {
return /(-|_|\.)8k\b|num_ctx[^0-9]*8192/i.test(modelName) ? 8_000 : 6_000;
}
/** Clamp a single input to `maxChars` (no-op when already under the cap). */
export function capEmbedText(text: string, maxChars: number): string {
return text.length > maxChars ? text.slice(0, maxChars) : text;
}
/**
* Re-embed a batch one text at a time, degrading ONLY the inputs that genuinely
* fail to a deterministic mock vector. This is the batch-error recovery path:
* a single backend-rejected text can no longer poison its batchmates (the prior
* behavior substituted mock for the WHOLE batch — silent corruption of every
* frame in the batch). Inputs should already be char-capped by the caller.
*/
export async function reembedPerText(
embedder: Embedder,
texts: string[],
dims: number,
): Promise<Float32Array[]> {
return Promise.all(
texts.map(async (t) => {
try {
return await embedder.embed(t);
} catch {
return mockEmbed(t, dims);
}
}),
);
}
interface ProbeResult {
type: EmbeddingProviderType;
embedder: Embedder;
modelName: string;
}
async function probeProvider(
type: EmbeddingProviderType,
config: EmbeddingProviderConfig,
): Promise<ProbeResult | null> {
const dims = config.targetDimensions ?? 1024;
try {
switch (type) {
case 'inprocess': {
const { createInProcessEmbedder } = await import('./inprocess-embedder.js');
const embedder = await createInProcessEmbedder({
model: config.inprocess?.model,
cacheDir: config.inprocess?.cacheDir,
targetDimensions: dims,
});
const test = await embedder.embed('waggle embedding probe');
if (test.length !== dims) throw new Error(`Unexpected dims: ${test.length}`);
return { type: 'inprocess', embedder, modelName: config.inprocess?.model ?? 'Xenova/all-MiniLM-L6-v2' };
}
case 'ollama': {
const { createOllamaEmbedder } = await import('./ollama-embedder.js');
const embedder = createOllamaEmbedder({
baseUrl: config.ollama?.baseUrl,
model: config.ollama?.model,
targetDimensions: dims,
});
const test = await embedder.embed('waggle embedding probe');
if (test.length !== dims) throw new Error(`Unexpected dims: ${test.length}`);
return { type: 'ollama', embedder, modelName: config.ollama?.model ?? 'nomic-embed-text' };
}
case 'voyage': {
if (!config.voyage?.apiKey) return null;
const { createApiEmbedder } = await import('./api-embedder.js');
const embedder = createApiEmbedder({
provider: 'voyage',
apiKey: config.voyage.apiKey,
model: config.voyage.model,
targetDimensions: dims,
});
const test = await embedder.embed('waggle embedding probe');
if (test.length !== dims) throw new Error(`Unexpected dims: ${test.length}`);
return { type: 'voyage', embedder, modelName: config.voyage.model ?? 'voyage-3-lite' };
}
case 'openai': {
if (!config.openai?.apiKey) return null;
const { createApiEmbedder } = await import('./api-embedder.js');
const embedder = createApiEmbedder({
provider: 'openai',
apiKey: config.openai.apiKey,
model: config.openai.model,
targetDimensions: dims,
});
const test = await embedder.embed('waggle embedding probe');
if (test.length !== dims) throw new Error(`Unexpected dims: ${test.length}`);
return { type: 'openai', embedder, modelName: config.openai.model ?? 'text-embedding-3-small' };
}
case 'litellm': {
if (!config.litellm?.url) return null;
const { createLiteLLMEmbedder } = await import('./litellm-embedder.js');
const embedder = createLiteLLMEmbedder({
litellmUrl: config.litellm.url,
litellmApiKey: config.litellm.apiKey,
model: config.litellm.model ?? 'text-embedding',
dimensions: dims,
fallbackToMock: false,
});
const test = await embedder.embed('waggle embedding probe');
if (test.length !== dims) throw new Error(`Unexpected dims: ${test.length}`);
return { type: 'litellm', embedder, modelName: config.litellm.model ?? 'text-embedding' };
}
default:
return null;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.info(`Trying ${type}... FAILED (${msg})`);
return null;
}
}
export async function createEmbeddingProvider(config?: EmbeddingProviderConfig): Promise<EmbeddingProviderInstance> {
const cfg: EmbeddingProviderConfig = { provider: 'auto', targetDimensions: 1024, ...config };
const dims = cfg.targetDimensions ?? 1024;
const userTier: Tier = cfg.userTier ?? 'FREE';
const userId = cfg.userId ?? 'local';
const quotaDb = cfg.quotaDb ?? null;
// Tier enforcement is normally ON only when userTier is explicitly passed.
// WAGGLE_EVAL_MODE=1 disables it unconditionally — the PA v5 eval harness
// sets this so user-tier gates never confound measurement validity. This
// env-var is eval-path-only; never set it in production code paths.
// See PromptAssembler v5 brief §11.3.
const evalModeActive = process.env.WAGGLE_EVAL_MODE === '1';
const tierEnforced = evalModeActive ? false : cfg.userTier !== undefined;
const tierCaps = TIER_CAPABILITIES[userTier];
const allowedProviders = tierCaps.embeddingProviders as readonly string[];
// Initialize quota table if DB provided
if (quotaDb) {
ensureQuotaTable(quotaDb);
}
let activeResult: ProbeResult | null = null;
let activeEmbedder: Embedder;
let activeType: EmbeddingProviderType = 'mock';
let activeModelName = 'deterministic-mock';
let lastError: string | undefined;
let availableProviders: EmbeddingProviderType[] = [];
let probeTimestamp = new Date().toISOString();
/** Check quota before embedding. Throws if exceeded. Warns at 80%. */
function checkQuota(count: number): void {
if (!quotaDb || !tierEnforced) return;
const quota = tierCaps.embeddingQuotaPerMonth;
if (quota === -1) return; // unlimited
const ym = getCurrentYearMonth();
const used = getUsageCount(quotaDb, userId, ym);
if (used + count > quota) {
throw new EmbeddingQuotaExceededError(userTier, quota, used);
}
if (used + count >= quota * 0.8) {
log.warn(`Embedding quota warning: ${used + count}/${quota} (${Math.round(((used + count) / quota) * 100)}%) for ${userTier} tier`);
}
}
/** Record usage after successful embedding. */
function recordUsage(count: number): void {
if (!quotaDb) return;
incrementUsage(quotaDb, userId, getCurrentYearMonth(), count);
}
async function runProbe(): Promise<void> {
log.info('Probing embedding providers...');
const available: EmbeddingProviderType[] = [];
activeResult = null;
probeTimestamp = new Date().toISOString();
const requestedProvider = cfg.provider ?? 'auto';
if (requestedProvider !== 'auto' && requestedProvider !== 'mock') {
// Explicit provider — tier-check only when tier is explicitly configured
if (tierEnforced && !allowedProviders.includes(requestedProvider)) {
const required = getMinimumTierForProvider(requestedProvider);
throw new TierError(required, userTier);
}
log.info(`Trying ${requestedProvider}...`);
const result = await probeProvider(requestedProvider, cfg);
if (result) {
activeResult = result;
available.push(result.type);
log.info(`Trying ${requestedProvider}... OK`);
}
} else if (requestedProvider === 'auto') {
// Auto: iterate chain, skip providers not allowed by tier
const chain: EmbeddingProviderType[] = ['inprocess', 'ollama', 'voyage', 'openai'];
for (const providerType of chain) {
// Tier gate — skip providers not allowed (only when tier is explicitly configured)
if (tierEnforced && !allowedProviders.includes(providerType)) {
log.info(`Skipping ${providerType} (not available on ${userTier} tier)`);
continue;
}
// Skip API providers without keys
if (providerType === 'voyage' && !cfg.voyage?.apiKey) {
log.info('Skipping voyage (no API key in Vault)');
continue;
}
if (providerType === 'openai' && !cfg.openai?.apiKey) {
log.info('Skipping openai (no API key in Vault)');
continue;
}
log.info(`Trying ${providerType}...`);
const result = await probeProvider(providerType, cfg);
if (result) {
available.push(result.type);
log.info(`Trying ${providerType}... OK`);
if (!activeResult) {
activeResult = result;
}
}
}
}
available.push('mock'); // Always available
availableProviders = available;
if (activeResult) {
activeEmbedder = activeResult.embedder;
activeType = activeResult.type;
activeModelName = activeResult.modelName;
lastError = undefined;
log.info(`Embedding provider: ${activeType} (${activeModelName}, ${dims} dims)`);
} else {
activeEmbedder = createMockEmbedder(dims);
activeType = 'mock';
activeModelName = 'deterministic-mock';
lastError = 'No real providers available';
// Loud, structured warning — the silent "mock fallback" was the
// most dangerous failure mode in Phase 3b-3 audit. Mock embeddings
// are deterministic byte hashes; semantic search returns noise.
// We want this to be IMPOSSIBLE to miss in a CLI/server log.
// Ported from hive-mind a99ea0e.
const msg = [
'',
'⚠️ EMBEDDING WARNING ─────────────────────────────────────────',
' Active provider: mock (deterministic byte hash)',
' Effect: semantic search returns noise, not meaning.',
'',
' To fix, install Ollama and pull the embedding model:',
' ollama pull nomic-embed-text',
' Then ensure the process can reach http://localhost:11434.',
'',
' Alternative providers:',
' HIVE_MIND_EMBEDDING_PROVIDER=inprocess (downloads 23MB)',
' VOYAGE_API_KEY=... (paid, recommended)',
' OPENAI_API_KEY=... (paid)',
'─────────────────────────────────────────────────────────────',
'',
].join('\n');
// Keep production and CLI runs loud, but let deterministic test lanes
// suppress this expected fallback banner without changing provider state.
if (process.env.WAGGLE_SUPPRESS_EMBEDDING_WARNING !== '1') {
// stderr so it survives stdout-piped JSON consumers and CI tee.
try { process.stderr.write(msg); } catch { /* fall through to log */ }
log.warn('Embedding provider degraded to mock — semantic search quality is noise. See stderr banner for fix instructions.');
}
}
}
// Initial probe
try {
await runProbe();
} catch (err) {
// Re-throw tier errors — these are intentional enforcement, not probe failures
if (err instanceof TierError) throw err;
lastError = err instanceof Error ? err.message : String(err);
activeEmbedder = createMockEmbedder(dims);
activeType = 'mock';
activeModelName = 'deterministic-mock';
availableProviders = ['mock'];
}
// Ensure activeEmbedder is assigned (TypeScript flow)
activeEmbedder ??= createMockEmbedder(dims);
const instance: EmbeddingProviderInstance = {
dimensions: dims,
async embed(text: string): Promise<Float32Array> {
checkQuota(1);
// Cap input to the active model's context so the backend never rejects
// an oversized frame. Reverse-ported from OSS hive-mind (oss-drift triage R5, 2026-06-11).
const capped = capEmbedText(text, maxEmbedCharsForModel(activeModelName));
try {
const result = await activeEmbedder.embed(capped);
recordUsage(1);
return result;
} catch (err) {
if (err instanceof EmbeddingQuotaExceededError) throw err;
log.warn(`Embedding failed with ${activeType}, falling back to mock: ${(err as Error).message}`);
lastError = (err as Error).message;
const fallback = mockEmbed(capped, dims);
recordUsage(1);
return fallback;
}
},
async embedBatch(texts: string[]): Promise<Float32Array[]> {
if (texts.length === 0) return [];
checkQuota(texts.length);
// Cap each input first so one oversized frame can't make the backend
// reject the whole request. Reverse-ported from OSS hive-mind
// (oss-drift triage R5, 2026-06-11).
const capped = texts.map(t => capEmbedText(t, maxEmbedCharsForModel(activeModelName)));
try {
const result = await activeEmbedder.embedBatch(capped);
recordUsage(texts.length);
return result;
} catch (err) {
if (err instanceof EmbeddingQuotaExceededError) throw err;
// Skip-not-abort: re-embed per-text so a single backend-rejected input
// degrades alone instead of mock-poisoning the WHOLE batch.
log.warn(`Batch embedding failed with ${activeType}, re-embedding per-text: ${(err as Error).message}`);
lastError = (err as Error).message;
const result = await reembedPerText(activeEmbedder, capped, dims);
recordUsage(texts.length);
return result;
}
},
getStatus(): EmbeddingProviderStatus {
return {
activeProvider: activeType,
availableProviders,
dimensions: dims,
modelName: activeModelName,
lastError,
probeTimestamp,
};
},
getActiveProvider(): EmbeddingProviderType {
return activeType;
},
async reprobe(): Promise<EmbeddingProviderStatus> {
await runProbe();
return instance.getStatus();
},
getQuotaStatus(): EmbeddingQuotaStatus {
const quota = tierCaps.embeddingQuotaPerMonth;
if (!quotaDb || quota === -1) {
return { tier: userTier, quota: -1, used: 0, remaining: -1, percentage: 0, resetsAt: getNextMonthReset() };
}
const used = getUsageCount(quotaDb, userId, getCurrentYearMonth());
return {
tier: userTier,
quota,
used,
remaining: Math.max(0, quota - used),
percentage: Math.round((used / quota) * 100),
resetsAt: getNextMonthReset(),
};
},
};
return instance;
}

View File

@@ -0,0 +1,5 @@
export interface Embedder {
embed(text: string): Promise<Float32Array>;
embedBatch(texts: string[]): Promise<Float32Array[]>;
dimensions: number;
}

View File

@@ -0,0 +1,112 @@
const ALIASES: string[][] = [
['postgresql', 'postgres', 'pg'],
['javascript', 'js'],
['typescript', 'ts'],
['kubernetes', 'k8s'],
['new york city', 'nyc'],
['nodejs', 'node.js', 'node'],
['react.js', 'reactjs', 'react'],
['vue.js', 'vuejs', 'vue'],
['python', 'py'],
['mongodb', 'mongo'],
];
const aliasMap = new Map<string, string>();
for (const group of ALIASES) {
const canonical = group[0];
for (const alias of group) {
aliasMap.set(alias, canonical);
}
}
export function normalizeEntityName(name: string): string {
const lower = name.toLowerCase();
return aliasMap.get(lower) ?? lower;
}
export interface EntityRef {
id: string;
name: string;
type: string;
}
export function findDuplicates(entities: EntityRef[]): EntityRef[][] {
const groups = new Map<string, EntityRef[]>();
for (const entity of entities) {
const key = `${normalizeEntityName(entity.name)}::${entity.type.toLowerCase()}`;
let group = groups.get(key);
if (!group) {
group = [];
groups.set(key, group);
}
group.push(entity);
}
return Array.from(groups.values());
}
// ── Write-time noise filter ───────────────────────────────────────────────
// Reverse-ported from OSS hive-mind (oss-drift triage R3, 2026-06-11).
// Applied at extraction time so low-signal names never enter the knowledge
// graph instead of being purged after the fact. Wired into
// harvest/extract-kg-entities.ts (oss-drift D2, 2026-06-11).
/** Capitalized sentence-starts / verbs / weekday + month tokens that are
* formatting artefacts, not entities. Title-cased to match extractor output. */
const STOP_TOKENS = new Set<string>([
// sentence-starts and pronouns
'The', 'This', 'That', 'These', 'Those', 'When', 'Where', 'Why', 'How',
'What', 'Who', 'Which', 'If', 'And', 'But', 'Or', 'So', 'For', 'Nor',
'Yet', 'As', 'At', 'By', 'On', 'In', 'To', 'From', 'With', 'Without',
'Into', 'Onto', 'Upon', 'Over', 'Under', 'Between', 'Among',
// verbs commonly capitalized at sentence start / in API names / log prefixes
'Add', 'Remove', 'Set', 'Get', 'Update', 'Delete', 'Create', 'List',
'Search', 'Find', 'Run', 'Build', 'Use', 'Make', 'Test', 'Check',
'Read', 'Write', 'Edit', 'Save', 'Load', 'Open', 'Close', 'Start',
'Stop', 'Show', 'Hide', 'Push', 'Pull', 'Fix', 'Done', 'Skip',
'Wait', 'Try', 'Note', 'Warn', 'Info', 'Debug', 'Trace',
'Todo', 'Fixme', 'Should', 'Could', 'Would', 'Must', 'Will', 'Shall',
'Can', 'May', 'Might',
// days
'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
// months
'Jan', 'Feb', 'Mar', 'Apr', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct',
'Nov', 'Dec',
'January', 'February', 'March', 'April', 'June', 'July',
'August', 'September', 'October', 'November', 'December',
]);
/** Real short tech names the generic heuristics would wrongly drop (too short,
* or all-caps acronyms). Lowercased; checked case-insensitively. */
const TECH_ALLOWLIST = new Set<string>([
// languages / runtimes
'go', 'php', 'bun', 'deno',
// package managers / editors / tools
'npm', 'pip', 'gem', 'vim', 'git',
// frameworks / libraries
'vue', 'zod', 'nuxt', 'vite', 'hono',
// domains / concepts that are genuine entities
'ai', 'ml', 'db', 'os', 'ui', 'ux', 'io', 'ci', 'cd', 'k8s',
'sdk', 'orm', 'jwt', 'ssh', 'dns', 'gpu', 'cpu',
]);
/** All-caps tokens up to 6 chars (API, CLI, JSON, HTTP, SQL, AWS, URL, UUID) —
* almost always formatting artefacts, not subjects. */
export function isLikelyAcronym(s: string): boolean {
return /^[A-Z]+$/.test(s) && s.length <= 6;
}
/**
* True when `name` is too low-signal to enter the knowledge graph: empty,
* shorter than 4 chars, a stop token, or a single-word all-caps acronym —
* UNLESS it is on the tech allowlist (npm, Go, AI, …). Multi-word names skip
* the acronym filter (real entities like "Acme Corp").
*/
export function isNoiseName(name: string): boolean {
if (!name) return true;
if (TECH_ALLOWLIST.has(name.toLowerCase())) return false;
if (name.length < 4) return true;
if (STOP_TOKENS.has(name)) return true;
if (!/\s/.test(name) && isLikelyAcronym(name)) return true;
return false;
}

View File

@@ -0,0 +1,403 @@
/**
* erasure.ts — GDPR Art.17 frame + index + KG erasure companion (2026-07-01).
*
* The #7 raw_archive erasure (RawArchive.erase / eraseByFrame) redacts ONLY the
* verbatim provenance rows. This module completes a data-subject erasure by also
* purging the DERIVED retrieval corpus that quotes the source PII:
* - memory_frames + memory_frames_fts + memory_frames_vec
* - memory_frame_chunks + memory_frame_chunks_vec (via FrameStore.delete)
* - orphaned knowledge_entities + knowledge_relations (name/props may hold PII)
* while KEEPING the raw_archive identity skeleton as the audit record that an
* item existed and was erased. See docs/plans/2026-07-01-art17-frame-index-kg-
* erasure-companion.md for the full surface + decisions.
*
* Every multi-table erasure runs in ONE better-sqlite3 transaction — a partial
* erasure is a compliance failure (all-or-nothing).
*
* archive_uid = sha256(source∥sourceRef∥content) is ROTATED to an opaque id on erase
* (raw-archive.ts erase()), so the retained audit skeleton carries no content-derived
* value — the low-entropy re-identification residual is closed. (source_ref, preserved
* verbatim, is the one remaining retained-skeleton residual and MAY carry PII.)
*/
import type { MindDB } from './db.js';
import { FrameStore } from './frames.js';
import { RawArchive, readArchiveUids } from './raw-archive.js';
import { SuppressionStore } from './suppression.js';
import { MIND_RAWTURN_PREFIX, rawTurnConvKey } from '../harvest/raw-turns.js';
import { CLAUDE_CODE_DECISION_SOURCE, decisionOfSubjectId } from '../harvest/decision-derivation.js';
export interface EraseResult {
/** Derived frames physically deleted (frame + FTS + vec + chunks + chunk-vec). */
framesDeleted: number;
/** raw_archive provenance rows redacted (content → marker; skeleton frozen). */
archiveRedacted: number;
/** memory_frame_chunks_vec rows purged for the erased frame(s). */
chunkVectorsPurged: number;
/** Orphaned knowledge_entities hard-deleted (zero surviving frame links). */
entitiesErased: number;
/** knowledge_relations of those orphaned entities removed. */
relationsErased: number;
}
function zeroResult(): EraseResult {
return { framesDeleted: 0, archiveRedacted: 0, chunkVectorsPurged: 0, entitiesErased: 0, relationsErased: 0 };
}
export class MindErasure {
private db: MindDB;
private frames: FrameStore;
private archive: RawArchive;
private suppression: SuppressionStore;
constructor(db: MindDB) {
this.db = db;
this.frames = new FrameStore(db);
this.archive = new RawArchive(db);
this.suppression = new SuppressionStore(db);
}
/**
* Erase ONE derived frame under GDPR Art.17: redact its provenance rows, delete
* the frame from every retrieval store, then hard-delete any KG entity that was
* derived solely from this frame (zero surviving links) along with its relations.
* Atomic. Returns all-zero for an unknown frame id (no throw).
*
* SCOPE — a single frame. For a full data-subject erasure use eraseBySourceRef,
* which also sweeps the conversation's verbatim raw-turn frames + referencing
* B-frames that this frame-scoped primitive intentionally does NOT touch.
*/
eraseFrame(frameId: number, reason: string): EraseResult {
return this.db.getDatabase().transaction((): EraseResult => this.eraseFrameInternal(frameId, reason))();
}
/**
* Art.17-COMPLETE erase of ONE frame the user pointed at. Unlike eraseFrame
* (single frame), this reaches the WHOLE subject footprint behind a harvested
* summary — the verbatim [mind-rawturn] dialogue + referencing B-frames + KG —
* which a frame-only delete would leave recall-able. It resolves the frame's
* provenance subjects (the archive link; else a metadata.sourceId + content
* platform-prefix fallback for a legacy / append-failed frame with no link),
* sweeps each via eraseBySourceRef, then erases the frame itself. Atomic
* (better-sqlite3 nests the inner erasures as savepoints). All-zero for an
* unknown frame. This is the single primitive both the /api/memory/erase route
* and the erase_memory MCP tool call, so the two entry points cannot drift.
*/
eraseFrameComplete(frameId: number, reason: string): EraseResult {
return this.db.getDatabase().transaction((): EraseResult => {
const total = zeroResult();
const add = (r: EraseResult): void => {
total.framesDeleted += r.framesDeleted;
total.archiveRedacted += r.archiveRedacted;
total.chunkVectorsPurged += r.chunkVectorsPurged;
total.entitiesErased += r.entitiesErased;
total.relationsErased += r.relationsErased;
};
const frame = this.frames.getById(frameId);
if (!frame) return total;
// Dedup subjects with a JSON-array key (collision-proof: distinct
// (source, sourceRef) pairs never serialize equal).
const seen = new Set<string>();
const sweep = (source: string, sourceRef: string): void => {
const key = JSON.stringify([source, sourceRef]);
if (seen.has(key)) return;
seen.add(key);
add(this.eraseBySourceRef(source, sourceRef, reason));
};
// Primary: subjects linked via the frame's archive provenance.
for (const row of this.archive.reconstructSource(frameId)) {
if (row.source_ref) sweep(row.source, row.source_ref);
}
// Fallback: a harvested summary with NO archive link (legacy pre-#7 frame,
// or a raw_archive.append that failed while the raw-turns still wrote).
// Recover the subject from metadata.sourceId + the platform token in the
// content prefix ('[Harvest:<src>] ...' server harvest / '[<src>] ...' MCP
// harvest) so eraseBySourceRef reaches the raw-turns. A wrong guess on a
// non-harvest frame matches nothing (a no-op sweep).
if (seen.size === 0) {
let sourceRef: string | undefined;
try {
const meta = JSON.parse(frame.metadata ?? '{}') as Record<string, unknown>;
if (meta && typeof meta.sourceId === 'string') sourceRef = meta.sourceId;
} catch { /* malformed metadata — no fallback subject */ }
const src = frame.content?.match(/^\[(?:Harvest:)?([^\]]+)\]/)?.[1];
if (src && sourceRef) sweep(src, sourceRef);
}
const frameRes = this.eraseFrame(frameId, reason); // idempotent if already swept
add(frameRes);
// A subject-less frame (connector / ingest_source single frame) resolved no
// subject above, so the eraseBySourceRef B-frame sweep never ran for it.
// Strip any B-frame that references it directly so synthesized PII cannot
// survive. (For a subject frame this is a no-op — step 4 already swept them.)
if (frameRes.framesDeleted > 0) add(this.sweepReferencingBFrames(new Set([frameId]), reason));
return total;
})();
}
/** Non-transactional core — call inside an ambient transaction only. */
private eraseFrameInternal(frameId: number, reason: string): EraseResult {
const raw = this.db.getDatabase();
if (!this.frames.getById(frameId)) return zeroResult();
// Capture the entities linked to this frame BEFORE the delete cascades the
// kg_entity_frames bridge away (else we can't tell which became orphans).
const linkedEntityIds = (raw
.prepare('SELECT entity_id FROM kg_entity_frames WHERE frame_id = ?')
.all(frameId) as Array<{ entity_id: number }>).map(r => r.entity_id);
// Count chunk vectors that FrameStore.delete will purge (for the report).
const chunkVectorsPurged = (raw
.prepare('SELECT COUNT(*) c FROM memory_frame_chunks WHERE frame_id = ?')
.get(frameId) as { c: number }).c;
// 1. Redact the provenance rows this frame links to (audit skeleton kept).
const archiveRedacted = this.archive.eraseByFrame(frameId, reason);
// 2. Delete the frame + FTS + vec + chunks + chunk-vec + kg bridge.
const framesDeleted = this.frames.delete(frameId) ? 1 : 0;
// 3. Orphan sweep: a previously-linked entity now at zero frame links was
// derived solely from erased content → hard-delete it + its relations.
// knowledge_relations references knowledge_entities WITHOUT ON DELETE
// CASCADE (FK enforcement is ON), so relations MUST go first or the entity
// delete raises SQLITE_CONSTRAINT.
let entitiesErased = 0;
let relationsErased = 0;
for (const eid of linkedEntityIds) {
const remaining = (raw
.prepare('SELECT COUNT(*) c FROM kg_entity_frames WHERE entity_id = ?')
.get(eid) as { c: number }).c;
if (remaining > 0) continue; // still referenced by a surviving frame — shared, keep
relationsErased += raw
.prepare('DELETE FROM knowledge_relations WHERE source_id = ? OR target_id = ?')
.run(eid, eid).changes;
entitiesErased += raw
.prepare('DELETE FROM knowledge_entities WHERE id = ?')
.run(eid).changes;
}
return { framesDeleted, archiveRedacted, chunkVectorsPurged, entitiesErased, relationsErased };
}
/**
* Fixpoint sweep of every B-frame that (transitively) references an already-
* erased frame. A synthesized B-frame stores {references:[…]} in its content
* JSON and carries no archiveUids, so the summary/raw-turn sweeps cannot reach
* it. Shared by the subject sweep (eraseBySourceRef step 4) and the single-frame
* erase (eraseFrameComplete) — the latter for SUBJECT-LESS frames (connector /
* ingest_source single frames) that resolve no subject and so would otherwise
* leave a referencing B-frame (which can quote the erased PII) behind. Fixpoint:
* a B-frame may reference another B-frame; erased ones vanish from the next query
* so it terminates. Mutates `erasedIds` with the swept B-frame ids.
* Non-transactional — call inside an ambient transaction only.
*/
private sweepReferencingBFrames(erasedIds: Set<number>, reason: string): EraseResult {
const raw = this.db.getDatabase();
const total = zeroResult();
const add = (r: EraseResult): void => {
total.framesDeleted += r.framesDeleted;
total.archiveRedacted += r.archiveRedacted;
total.chunkVectorsPurged += r.chunkVectorsPurged;
total.entitiesErased += r.entitiesErased;
total.relationsErased += r.relationsErased;
};
let grew = true;
while (grew) {
grew = false;
const bframes = raw
.prepare("SELECT id, content FROM memory_frames WHERE frame_type = 'B'")
.all() as Array<{ id: number; content: string }>;
for (const bf of bframes) {
if (erasedIds.has(bf.id)) continue;
let refs: unknown;
try { refs = (JSON.parse(bf.content) as { references?: unknown }).references; } catch { continue; }
if (!Array.isArray(refs)) continue;
if (refs.some((id) => typeof id === 'number' && erasedIds.has(id))) {
const r = this.eraseFrameInternal(bf.id, reason);
if (r.framesDeleted > 0) { erasedIds.add(bf.id); grew = true; add(r); }
}
}
}
return total;
}
/**
* Subject-level sweep + suppression for a (source, source_ref) subject.
*
* Erases the subject's whole footprint (eraseSubjectFrames), records it on the
* erased-subject suppression list, then — for claude-code only — cascades to the
* DERIVED 'decision-of' subject that harvest fans out separately (see below).
* The whole operation is ONE transaction, so a rolled-back erase records nothing
* and the parent + derived subjects are all-or-nothing.
*
* This is the SINGLE suppression capture point — subject mode calls here directly;
* frame mode reaches it via eraseFrameComplete's sweep() → both surfaces (route +
* MCP erase_memory) feed the list with no drift.
*/
eraseBySourceRef(source: string, sourceRef: string, reason: string): EraseResult {
return this.db.getDatabase().transaction((): EraseResult => {
const total = zeroResult();
const add = (r: EraseResult): void => {
total.framesDeleted += r.framesDeleted;
total.archiveRedacted += r.archiveRedacted;
total.chunkVectorsPurged += r.chunkVectorsPurged;
total.entitiesErased += r.entitiesErased;
total.relationsErased += r.relationsErased;
};
// Primary subject: erase its whole footprint, then suppress it.
// UNCONDITIONAL — even when nothing currently matched, the subject was
// EXPLICITLY requested erased, so a LATER re-export/re-sync must not
// re-materialize it. (Subject-less frames correctly bypass this via the
// frame-mode path, which resolves no subject to record.)
add(this.eraseSubjectFrames(source, sourceRef, reason));
this.suppression.record(source, sourceRef, reason);
// #7 P2 — claude-code `decision-of` derived subject. claude-code harvest's
// extractDecisions emits a SEPARATE import item keyed on
// stableHarvestId('claude-code','decision-of',parentRef) that quotes the
// parent's decision lines. It lands as its OWN (source, source_ref) subject
// — a distinct archiveUid/raw-turn key, and NOT a B-frame — so the sweep
// above never reaches it: the derived frame would survive erasure AND (its
// key being un-suppressed) re-materialize on the next re-import. The
// persisted frame drops metadata.extractedFrom (harvest stamps only
// kind/confidence/status/sourceId/archiveUids), so recompute the derived key
// and cascade. SINGLE LEVEL: a decision item is never itself re-derived
// (extractDecisions skips type==='decision'), so there is no decision-of-of
// chain to follow. The derived suppression is recorded ONLY when the derived
// subject had a real footprint at erase time — an unconditional record would
// add a phantom, hash-keyed re-consent entry for every claude-code parent
// that had no decisions. (Residual: a parent that GAINS decision content
// after erasure and is re-harvested is not pre-suppressed on the derived key
// — that content post-dates the erasure and is treated as new.)
if (source === CLAUDE_CODE_DECISION_SOURCE) {
const derivedRef = decisionOfSubjectId(sourceRef);
const derived = this.eraseSubjectFrames(source, derivedRef, reason);
add(derived);
if (derived.framesDeleted > 0 || derived.archiveRedacted > 0) {
this.suppression.record(source, derivedRef, reason);
}
}
return total;
})();
}
/**
* Erase everything derived from a (source, source_ref) subject — the sweep
* MECHANICS only (no suppression record). It reaches the subject's derived
* corpus through THREE keys, because one harvested item fans out into frames
* that are keyed differently:
* (a) the distilled SUMMARY frame — linked via metadata.archiveUids;
* (b) the verbatim per-turn [mind-rawturn …] frames — keyed by the conversation
* content prefix (= sanitize(source∥sourceRef)); they carry NO archive link,
* so a link-only sweep would leave the subject's full dialogue recall-able;
* (c) synthesized B-frames that reference any erased frame in their content JSON.
* Then it redacts any subject provenance row no frame reached (orphan provenance).
*
* A frame that also links OTHER source_refs is still deleted wholesale (a merged
* summary containing the subject's PII cannot be partially redacted) — its other
* provenance rows are redacted too, which is the conservative Art.17 outcome.
*
* Non-transactional — call inside an ambient transaction only (eraseBySourceRef
* wraps it so the primary + derived-subject sweeps commit atomically together).
*/
private eraseSubjectFrames(source: string, sourceRef: string, reason: string): EraseResult {
const raw = this.db.getDatabase();
const total = zeroResult();
const add = (r: EraseResult): void => {
total.framesDeleted += r.framesDeleted;
total.archiveRedacted += r.archiveRedacted;
total.chunkVectorsPurged += r.chunkVectorsPurged;
total.entitiesErased += r.entitiesErased;
total.relationsErased += r.relationsErased;
};
// 1. Every archive uid for this subject.
const uids = (raw
.prepare('SELECT archive_uid FROM raw_archive WHERE source = ? AND source_ref = ?')
.all(source, sourceRef) as Array<{ archive_uid: string }>).map(r => r.archive_uid);
// NB: do NOT early-return on an empty uid set. A subject can have verbatim
// [mind-rawturn] frames (2b) + referencing B-frames (4) with NO raw_archive
// row — a legacy pre-#7 conversation, or one whose raw_archive.append failed
// while the raw-turns still wrote. Bailing here left that raw PII dialogue
// recall-able (the reference-class leak). Steps 2b/4 key off the conv-prefix
// and content references, independent of raw_archive, so they must still run;
// 2a and step 5 iterate `uids`, so they are natural no-ops when it is empty.
const uidSet = new Set(uids);
const frameIds = new Set<number>();
// 2a. SUMMARY frames linking any subject uid (reverse lookup). LIKE-prefilter
// the metadata JSON, then verify precisely via readArchiveUids (tolerates
// the legacy scalar archiveUid + malformed metadata).
const likeStmt = raw.prepare('SELECT id, metadata FROM memory_frames WHERE metadata LIKE ?');
for (const uid of uids) {
for (const row of likeStmt.all(`%${uid}%`) as Array<{ id: number; metadata?: string }>) {
if (!row.metadata) continue;
let meta: Record<string, unknown>;
try { meta = JSON.parse(row.metadata) as Record<string, unknown>; } catch { continue; }
if (!meta || typeof meta !== 'object') continue;
if (readArchiveUids(meta).some(u => uidSet.has(u))) frameIds.add(row.id);
}
}
// 2b. Verbatim raw-turn frames for this conversation, keyed by content prefix.
// The trailing space after the conv key makes the match EXACT (so a sweep
// of 'item' never catches 'item-9'). Escape LIKE metacharacters (the key
// is sanitized to [A-Za-z0-9_-] but escape defensively).
const convKey = rawTurnConvKey({ source, id: sourceRef });
const prefix = `${MIND_RAWTURN_PREFIX} conv:${convKey} `.replace(/[\\%_]/g, ch => `\\${ch}`);
for (const row of raw
.prepare("SELECT id FROM memory_frames WHERE content LIKE ? ESCAPE '\\'")
.all(`${prefix}%`) as Array<{ id: number }>) {
frameIds.add(row.id);
}
// 2c. Archive-less SUMMARY frames for this subject. 2a is archiveUid-keyed,
// so a harvested summary whose raw_archive.append failed (or a legacy
// pre-#7 frame) — carrying metadata.sourceId but NO archiveUids — slips
// through, leaving the distilled PII recall-able after a subject-level
// DSAR. Recover it symmetric to eraseFrameComplete's fallback: match
// metadata.sourceId === source_ref AND the content platform-prefix
// ('[Harvest:<src>] …' server / '[<src>] …' MCP) === source. The LIKE is
// a prefilter only; the two EXACT code checks are the subject identity,
// so 'item' never over-erases 'item-9' and a sibling subject is safe.
// Escape LIKE metacharacters in source_ref (mirroring 2b) to keep the
// prefilter narrow — the exact meta.sourceId check backstops either way.
const srLike = sourceRef.replace(/[\\%_]/g, ch => `\\${ch}`);
const metaLike = raw.prepare("SELECT id, content, metadata FROM memory_frames WHERE metadata LIKE ? ESCAPE '\\'");
for (const row of metaLike.all(`%${srLike}%`) as Array<{ id: number; content?: string; metadata?: string }>) {
if (!row.metadata) continue;
let meta: Record<string, unknown>;
try { meta = JSON.parse(row.metadata) as Record<string, unknown>; } catch { continue; }
if (!meta || typeof meta !== 'object' || meta.sourceId !== sourceRef) continue;
const tok = row.content?.match(/^\[(?:Harvest:)?([^\]]+)\]/)?.[1];
if (tok === source) frameIds.add(row.id);
}
// 3. Erase the direct frame set; track what was actually deleted for the
// B-frame reference sweep below.
const erasedIds = new Set<number>();
for (const fid of frameIds) {
const r = this.eraseFrameInternal(fid, reason);
if (r.framesDeleted > 0) erasedIds.add(fid);
add(r);
}
// 4. B-frame reference sweep — a synthesized B-frame references erased
// frames in its content JSON and carries no archiveUids, so 2a/2b cannot
// reach it. Shared with the single-frame path (see sweepReferencingBFrames).
add(this.sweepReferencingBFrames(erasedIds, reason));
// 5. Redact any subject archive row not reached via a frame (orphan
// provenance). Already-redacted rows are idempotent no-ops (return false),
// so this never double-counts rows handled in step 3.
for (const uid of uids) {
if (this.archive.erase(uid, reason)) total.archiveRedacted += 1;
}
return total;
}
}

View File

@@ -0,0 +1,299 @@
import type { MindDB } from './db.js';
/**
* Evolution Runs — persistent audit of every evolution proposal.
*
* One row per ComposeEvolution execution. A run starts life as `proposed`
* when the orchestrator finishes building it; the user then `accept`s or
* `reject`s it from the UI. Accepted runs that successfully deploy move
* to `deployed`; failed deploys move to `failed`.
*
* Every run stores:
* - baseline and winner text (prompts, specs, skill bodies)
* - winner schema when the evolution included a schema stage (JSON blob)
* - accuracy delta and gate verdict
* - rejection reason if rejected
* - timestamps for created, decided, deployed
*
* This is the source of truth for the Memory app → Evolution tab history
* view, and the regression audit when a deployed change causes a user-
* visible problem.
*/
export type EvolutionRunStatus =
| 'proposed'
| 'accepted'
| 'rejected'
| 'deployed'
| 'failed';
export type EvolutionRunTarget =
| 'persona-system-prompt'
| 'behavioral-spec-section'
| 'tool-description'
| 'skill-body'
| 'generic';
export interface EvolutionRun {
id: number;
run_uuid: string;
target_kind: EvolutionRunTarget;
/** User-visible name of what was evolved (e.g. persona id, spec section) */
target_name: string | null;
baseline_text: string;
winner_text: string;
/** JSON-encoded Schema when evolution included structure, else null */
winner_schema_json: string | null;
delta_accuracy: number;
gate_verdict: 'pass' | 'fail';
/** JSON array of {gate, verdict, reason} objects */
gate_reasons_json: string;
status: EvolutionRunStatus;
/** Optional JSON blob with per-gen history, scores, Pareto front, etc */
artifacts_json: string | null;
user_note: string | null;
failure_reason: string | null;
created_at: string;
decided_at: string | null;
deployed_at: string | null;
}
export interface CreateEvolutionRunInput {
runUuid?: string;
targetKind: EvolutionRunTarget;
targetName?: string | null;
baselineText: string;
winnerText: string;
winnerSchema?: unknown;
deltaAccuracy: number;
gateVerdict: 'pass' | 'fail';
gateReasons: Array<{ gate: string; verdict: 'pass' | 'fail'; reason: string }>;
artifacts?: unknown;
}
export interface EvolutionRunFilter {
status?: EvolutionRunStatus | EvolutionRunStatus[];
targetKind?: EvolutionRunTarget;
targetName?: string;
since?: string;
limit?: number;
}
const DDL: string[] = [
`CREATE TABLE IF NOT EXISTS evolution_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_uuid TEXT NOT NULL UNIQUE,
target_kind TEXT NOT NULL,
target_name TEXT,
baseline_text TEXT NOT NULL,
winner_text TEXT NOT NULL,
winner_schema_json TEXT,
delta_accuracy REAL NOT NULL DEFAULT 0,
gate_verdict TEXT NOT NULL DEFAULT 'pass'
CHECK (gate_verdict IN ('pass', 'fail')),
gate_reasons_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'proposed'
CHECK (status IN ('proposed', 'accepted', 'rejected', 'deployed', 'failed')),
artifacts_json TEXT,
user_note TEXT,
failure_reason TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
deployed_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_evo_runs_status ON evolution_runs (status, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_evo_runs_target ON evolution_runs (target_kind, target_name, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_evo_runs_created ON evolution_runs (created_at DESC)`,
];
export const EVOLUTION_RUNS_TABLE_SQL = DDL.join(';\n') + ';';
export class EvolutionRunStore {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
this.ensureTable();
}
private ensureTable(): void {
try {
const raw = this.db.getDatabase();
const exists = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='evolution_runs'",
).get();
if (exists) return;
for (const stmt of DDL) {
raw.prepare(stmt).run();
}
} catch {
// DB may be closed during teardown — safe to skip.
}
}
/** Insert a new proposed run. Generates a UUID if the caller omits one. */
create(input: CreateEvolutionRunInput): EvolutionRun {
const uuid = input.runUuid ?? generateUuid();
const raw = this.db.getDatabase();
raw.prepare(`
INSERT INTO evolution_runs (
run_uuid, target_kind, target_name,
baseline_text, winner_text, winner_schema_json,
delta_accuracy, gate_verdict, gate_reasons_json,
status, artifacts_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'proposed', ?)
`).run(
uuid,
input.targetKind,
input.targetName ?? null,
input.baselineText,
input.winnerText,
input.winnerSchema !== undefined ? JSON.stringify(input.winnerSchema) : null,
input.deltaAccuracy,
input.gateVerdict,
JSON.stringify(input.gateReasons ?? []),
input.artifacts !== undefined ? JSON.stringify(input.artifacts) : null,
);
const row = raw.prepare(
'SELECT * FROM evolution_runs WHERE run_uuid = ?',
).get(uuid) as EvolutionRun;
return row;
}
/** Mark a proposed run as accepted. */
accept(uuid: string, userNote?: string): EvolutionRun | undefined {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE evolution_runs
SET status = 'accepted',
user_note = COALESCE(?, user_note),
decided_at = datetime('now')
WHERE run_uuid = ? AND status = 'proposed'
`).run(userNote ?? null, uuid);
return this.getByUuid(uuid);
}
/** Mark a proposed run as rejected with an optional reason. */
reject(uuid: string, reason?: string): EvolutionRun | undefined {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE evolution_runs
SET status = 'rejected',
user_note = COALESCE(?, user_note),
decided_at = datetime('now')
WHERE run_uuid = ? AND status = 'proposed'
`).run(reason ?? null, uuid);
return this.getByUuid(uuid);
}
/** Mark an accepted run as successfully deployed. */
markDeployed(uuid: string): EvolutionRun | undefined {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE evolution_runs
SET status = 'deployed',
deployed_at = datetime('now')
WHERE run_uuid = ? AND status = 'accepted'
`).run(uuid);
return this.getByUuid(uuid);
}
/** Mark an accepted run as failed to deploy with the given reason. */
markFailed(uuid: string, reason: string): EvolutionRun | undefined {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE evolution_runs
SET status = 'failed',
failure_reason = ?,
deployed_at = datetime('now')
WHERE run_uuid = ? AND status = 'accepted'
`).run(reason, uuid);
return this.getByUuid(uuid);
}
getByUuid(uuid: string): EvolutionRun | undefined {
return this.db.getDatabase()
.prepare('SELECT * FROM evolution_runs WHERE run_uuid = ?')
.get(uuid) as EvolutionRun | undefined;
}
get(id: number): EvolutionRun | undefined {
return this.db.getDatabase()
.prepare('SELECT * FROM evolution_runs WHERE id = ?')
.get(id) as EvolutionRun | undefined;
}
list(filter: EvolutionRunFilter = {}): EvolutionRun[] {
const clauses: string[] = [];
const params: unknown[] = [];
if (filter.status) {
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
clauses.push(`status IN (${statuses.map(() => '?').join(',')})`);
params.push(...statuses);
}
if (filter.targetKind) {
clauses.push('target_kind = ?');
params.push(filter.targetKind);
}
if (filter.targetName) {
clauses.push('target_name = ?');
params.push(filter.targetName);
}
if (filter.since) {
clauses.push('created_at >= ?');
params.push(filter.since);
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const limit = filter.limit ?? 50;
return this.db.getDatabase().prepare(
`SELECT * FROM evolution_runs ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
).all(...params, limit) as EvolutionRun[];
}
/** Aggregate counts per status for stats/UI. */
statusCounts(
filter: Omit<EvolutionRunFilter, 'status' | 'limit'> = {},
): Record<EvolutionRunStatus, number> {
const counts: Record<EvolutionRunStatus, number> = {
proposed: 0, accepted: 0, rejected: 0, deployed: 0, failed: 0,
};
const clauses: string[] = [];
const params: unknown[] = [];
if (filter.targetKind) { clauses.push('target_kind = ?'); params.push(filter.targetKind); }
if (filter.targetName) { clauses.push('target_name = ?'); params.push(filter.targetName); }
if (filter.since) { clauses.push('created_at >= ?'); params.push(filter.since); }
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const rows = this.db.getDatabase().prepare(
`SELECT status, COUNT(*) as cnt FROM evolution_runs ${where} GROUP BY status`,
).all(...params) as Array<{ status: EvolutionRunStatus; cnt: number }>;
for (const row of rows) {
counts[row.status] = row.cnt;
}
return counts;
}
/** Delete a run by uuid (testing / cleanup). */
delete(uuid: string): void {
this.db.getDatabase()
.prepare('DELETE FROM evolution_runs WHERE run_uuid = ?')
.run(uuid);
}
/** Delete all runs (tests only). */
clear(): void {
this.db.getDatabase().prepare('DELETE FROM evolution_runs').run();
}
}
/** Minimal UUID v4-ish generator — enough to uniquely tag rows, no crypto needed. */
function generateUuid(): string {
const rand = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
return `${rand()}${rand()}-${rand()}-4${rand().slice(1)}-${rand()}-${rand()}${rand()}${rand()}`;
}

View File

@@ -0,0 +1,446 @@
import type { MindDB } from './db.js';
/**
* Execution Traces — persistent log of what agents actually did.
*
* The foundation for eval dataset construction (Phase 1.2) and
* fitness scoring in the evolution loop (Phase 2+).
*
* One trace row per "unit of agent work" — typically a single user turn,
* a single workflow phase, or a single subagent run. Tool calls, reasoning,
* and the final output are packed into `trace_json`.
*
* Outcome labels:
* - `success` — user accepted the result, no correction follow-up
* - `corrected` — user corrected/refined the output (weak negative signal)
* - `abandoned` — user left the thread / switched contexts (ambiguous)
* - `verified` — passed a verifier gate or harness checkpoint
*/
export type TraceOutcome = 'success' | 'corrected' | 'abandoned' | 'verified' | 'pending';
/** A single tool call captured during execution. */
export interface TraceToolCall {
/** Tool name */
tool: string;
/** Arguments passed to the tool (already scrubbed of secrets by caller) */
args: Record<string, unknown>;
/** Result as a string (may be truncated) */
result: string;
/** Whether the tool call succeeded */
ok: boolean;
/** Duration in milliseconds */
durationMs: number;
/** ISO timestamp */
timestamp: string;
}
/** Reasoning step — free-form agent thought recorded before/between tool calls. */
export interface TraceReasoningStep {
content: string;
timestamp: string;
}
/**
* The structured payload stored in trace_json.
* Kept separate so callers can evolve the shape without schema migrations.
*/
export interface TracePayload {
/** Original user instruction (may be truncated) */
input: string;
/** Final agent output */
output: string;
/** Reasoning steps interleaved with tool calls */
reasoning: TraceReasoningStep[];
/** Tool calls in order */
toolCalls: TraceToolCall[];
/** Files created or modified */
artifacts: string[];
/** Tokens consumed */
tokens: { input: number; output: number };
/** Optional workflow harness context */
harness?: {
harnessId: string;
phaseId: string;
phaseName: string;
gateResults?: Array<{ name: string; passed: boolean; reason: string }>;
};
/** Free-form correction text if outcome === 'corrected' */
correctionFeedback?: string;
/** Arbitrary tags for later filtering in eval-dataset */
tags?: string[];
}
/** Row shape returned by queries. */
export interface ExecutionTrace {
id: number;
session_id: string | null;
persona_id: string | null;
workspace_id: string | null;
model: string | null;
task_shape: string | null;
outcome: TraceOutcome;
trace_json: string;
cost_usd: number;
duration_ms: number;
created_at: string;
finalized_at: string | null;
}
/** With the trace_json pre-parsed. */
export interface ParsedExecutionTrace extends Omit<ExecutionTrace, 'trace_json'> {
payload: TracePayload;
}
/** Input to start a new trace. */
export interface StartTraceInput {
sessionId?: string | null;
personaId?: string | null;
workspaceId?: string | null;
model?: string | null;
taskShape?: string | null;
/** Initial user input captured immediately. */
input: string;
/** Optional tags for later filtering. */
tags?: string[];
}
/** Input to finalize a trace (update outcome + payload). */
export interface FinalizeTraceInput {
outcome: TraceOutcome;
output: string;
reasoning?: TraceReasoningStep[];
toolCalls?: TraceToolCall[];
artifacts?: string[];
tokens?: { input: number; output: number };
costUsd?: number;
harness?: TracePayload['harness'];
correctionFeedback?: string;
tags?: string[];
}
/** Filter for queries. */
export interface TraceQueryFilter {
sessionId?: string;
personaId?: string;
workspaceId?: string;
outcome?: TraceOutcome | TraceOutcome[];
taskShape?: string;
/** Lower bound (inclusive) on created_at — ISO string */
since?: string;
/** Substring pre-filter on trace_json (SQL LIKE), e.g. `"agent:` to scope
* the LIMIT to tagged traces instead of the global recency window. LIKE
* wildcards (%/_) in the value are NOT escaped — callers needing an exact
* match must still filter the parsed payload (tags array) in JS. */
tagLike?: string;
/** Max rows to return (default 100) */
limit?: number;
}
/** DDL split into single statements to stay compatible with prepare().run(). */
const EXECUTION_TRACES_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS execution_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
persona_id TEXT,
workspace_id TEXT,
model TEXT,
task_shape TEXT,
outcome TEXT NOT NULL DEFAULT 'pending'
CHECK (outcome IN ('success', 'corrected', 'abandoned', 'verified', 'pending')),
trace_json TEXT NOT NULL DEFAULT '{}',
cost_usd REAL NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
finalized_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_traces_session ON execution_traces (session_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_traces_persona ON execution_traces (persona_id, outcome)`,
`CREATE INDEX IF NOT EXISTS idx_traces_outcome ON execution_traces (outcome, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_traces_workspace ON execution_traces (workspace_id, created_at DESC)`,
];
/** Exported DDL concatenated — kept for anyone who needs the full table SQL. */
export const EXECUTION_TRACES_TABLE_SQL = EXECUTION_TRACES_DDL.join(';\n') + ';';
export class ExecutionTraceStore {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
this.ensureTable();
}
/** Ensure execution_traces table exists for databases created before this feature. */
private ensureTable(): void {
try {
const raw = this.db.getDatabase();
const exists = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='execution_traces'",
).get();
if (exists) return;
for (const stmt of EXECUTION_TRACES_DDL) {
raw.prepare(stmt).run();
}
} catch {
// DB may be closed during teardown — safe to skip.
}
}
/** Start a new trace in `pending` outcome. Returns row id. */
start(input: StartTraceInput): number {
const raw = this.db.getDatabase();
const payload: TracePayload = {
input: input.input,
output: '',
reasoning: [],
toolCalls: [],
artifacts: [],
tokens: { input: 0, output: 0 },
tags: input.tags ?? [],
};
const result = raw.prepare(`
INSERT INTO execution_traces
(session_id, persona_id, workspace_id, model, task_shape, outcome, trace_json)
VALUES (?, ?, ?, ?, ?, 'pending', ?)
`).run(
input.sessionId ?? null,
input.personaId ?? null,
input.workspaceId ?? null,
input.model ?? null,
input.taskShape ?? null,
JSON.stringify(payload),
);
return Number(result.lastInsertRowid);
}
/**
* Append incremental events to a pending trace. Used during long runs
* to avoid losing progress if the process crashes. Safe to call many times.
*/
append(
id: number,
events: {
reasoning?: TraceReasoningStep[];
toolCalls?: TraceToolCall[];
artifacts?: string[];
},
): void {
const current = this.get(id);
if (!current) return;
const payload = parsePayload(current.trace_json);
payload.reasoning = [...payload.reasoning, ...(events.reasoning ?? [])];
payload.toolCalls = [...payload.toolCalls, ...(events.toolCalls ?? [])];
if (events.artifacts?.length) {
const seen = new Set(payload.artifacts);
for (const a of events.artifacts) {
if (!seen.has(a)) {
payload.artifacts.push(a);
seen.add(a);
}
}
}
this.db.getDatabase()
.prepare('UPDATE execution_traces SET trace_json = ? WHERE id = ?')
.run(JSON.stringify(payload), id);
}
/** Finalize a trace — set outcome, merge payload, record cost + duration. */
finalize(id: number, input: FinalizeTraceInput): ExecutionTrace | undefined {
const current = this.get(id);
if (!current) return undefined;
const existing = parsePayload(current.trace_json);
const merged: TracePayload = {
...existing,
output: input.output,
reasoning: input.reasoning ?? existing.reasoning,
toolCalls: input.toolCalls ?? existing.toolCalls,
artifacts: input.artifacts ?? existing.artifacts,
tokens: input.tokens ?? existing.tokens,
harness: input.harness ?? existing.harness,
correctionFeedback: input.correctionFeedback ?? existing.correctionFeedback,
tags: input.tags ?? existing.tags,
};
const createdMs = Date.parse(current.created_at + 'Z');
const now = Date.now();
const durationMs = Number.isFinite(createdMs) ? Math.max(0, now - createdMs) : 0;
this.db.getDatabase().prepare(`
UPDATE execution_traces
SET outcome = ?,
trace_json = ?,
cost_usd = ?,
duration_ms = ?,
finalized_at = datetime('now')
WHERE id = ?
`).run(
input.outcome,
JSON.stringify(merged),
input.costUsd ?? current.cost_usd,
durationMs,
id,
);
return this.get(id);
}
/**
* Mark an already-finalized trace as corrected after the fact.
* Used when a correction-detector picks up a user correction in a later turn.
*/
markCorrected(id: number, feedback: string): void {
const current = this.get(id);
if (!current) return;
const payload = parsePayload(current.trace_json);
payload.correctionFeedback = feedback;
this.db.getDatabase().prepare(`
UPDATE execution_traces
SET outcome = 'corrected',
trace_json = ?
WHERE id = ?
`).run(JSON.stringify(payload), id);
}
get(id: number): ExecutionTrace | undefined {
return this.db.getDatabase()
.prepare('SELECT * FROM execution_traces WHERE id = ?')
.get(id) as ExecutionTrace | undefined;
}
getParsed(id: number): ParsedExecutionTrace | undefined {
const row = this.get(id);
return row ? toParsed(row) : undefined;
}
/** Query traces with optional filters. */
query(filter: TraceQueryFilter = {}): ExecutionTrace[] {
const clauses: string[] = [];
const params: unknown[] = [];
if (filter.sessionId) {
clauses.push('session_id = ?');
params.push(filter.sessionId);
}
if (filter.personaId) {
clauses.push('persona_id = ?');
params.push(filter.personaId);
}
if (filter.workspaceId) {
clauses.push('workspace_id = ?');
params.push(filter.workspaceId);
}
if (filter.taskShape) {
clauses.push('task_shape = ?');
params.push(filter.taskShape);
}
if (filter.outcome) {
const outcomes = Array.isArray(filter.outcome) ? filter.outcome : [filter.outcome];
clauses.push(`outcome IN (${outcomes.map(() => '?').join(',')})`);
params.push(...outcomes);
}
if (filter.since) {
clauses.push('created_at >= ?');
params.push(filter.since);
}
if (filter.tagLike) {
clauses.push('trace_json LIKE ?');
params.push(`%${filter.tagLike}%`);
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const limit = filter.limit ?? 100;
return this.db.getDatabase().prepare(
`SELECT * FROM execution_traces ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
).all(...params, limit) as ExecutionTrace[];
}
/** Parsed variant of query(). */
queryParsed(filter: TraceQueryFilter = {}): ParsedExecutionTrace[] {
return this.query(filter).map(toParsed);
}
/**
* Aggregate outcome counts — useful for fitness scoring.
* Returns: { success, corrected, abandoned, verified, pending }
*/
outcomeCounts(filter: Omit<TraceQueryFilter, 'outcome' | 'limit'> = {}): Record<TraceOutcome, number> {
const counts: Record<TraceOutcome, number> = {
success: 0, corrected: 0, abandoned: 0, verified: 0, pending: 0,
};
const clauses: string[] = [];
const params: unknown[] = [];
if (filter.sessionId) { clauses.push('session_id = ?'); params.push(filter.sessionId); }
if (filter.personaId) { clauses.push('persona_id = ?'); params.push(filter.personaId); }
if (filter.workspaceId) { clauses.push('workspace_id = ?'); params.push(filter.workspaceId); }
if (filter.taskShape) { clauses.push('task_shape = ?'); params.push(filter.taskShape); }
if (filter.since) { clauses.push('created_at >= ?'); params.push(filter.since); }
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const rows = this.db.getDatabase().prepare(
`SELECT outcome, COUNT(*) as cnt FROM execution_traces ${where} GROUP BY outcome`,
).all(...params) as Array<{ outcome: TraceOutcome; cnt: number }>;
for (const row of rows) {
counts[row.outcome] = row.cnt;
}
return counts;
}
/** Delete a trace by id. */
delete(id: number): void {
this.db.getDatabase().prepare('DELETE FROM execution_traces WHERE id = ?').run(id);
}
/** Delete all traces (tests only). */
clear(): void {
this.db.getDatabase().prepare('DELETE FROM execution_traces').run();
}
/** Count of all traces (for stats). */
count(filter: Omit<TraceQueryFilter, 'limit'> = {}): number {
const rows = this.query({ ...filter, limit: 1_000_000 });
return rows.length;
}
}
function parsePayload(json: string): TracePayload {
try {
const parsed = JSON.parse(json) as Partial<TracePayload>;
return {
input: parsed.input ?? '',
output: parsed.output ?? '',
reasoning: parsed.reasoning ?? [],
toolCalls: parsed.toolCalls ?? [],
artifacts: parsed.artifacts ?? [],
tokens: parsed.tokens ?? { input: 0, output: 0 },
harness: parsed.harness,
correctionFeedback: parsed.correctionFeedback,
tags: parsed.tags ?? [],
};
} catch {
return {
input: '',
output: '',
reasoning: [],
toolCalls: [],
artifacts: [],
tokens: { input: 0, output: 0 },
tags: [],
};
}
}
function toParsed(row: ExecutionTrace): ParsedExecutionTrace {
const { trace_json, ...rest } = row;
return { ...rest, payload: parsePayload(trace_json) };
}

View File

@@ -0,0 +1,487 @@
import type { MindDB } from './db.js';
import { hashFrameContent, stripHmPrefix } from './content-hash.js';
// Back-compat re-export — stripHmPrefix moved to content-hash.ts (oss-drift D3)
// so the hash and the strip live in one module; existing importers unchanged.
export { stripHmPrefix };
/** Strict ISO-8601 check used by `createIFrame` to decide whether to honor
* a caller-supplied `createdAt`. Requires the `T` separator and a
* timezone suffix (`Z` or `±HH:MM`) — anything looser is high-risk for
* range queries on `memory_frames.created_at`. Ported from hive-mind
* 9ec75e6 (Stage 0 root cause: harvest path was discarding original
* source timestamps and stamping every frame with ingest wall-clock,
* which made date-scoped retrieval queries return ABSTAIN on real
* Claude.ai exports). */
function isValidIsoTimestamp(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(value)) {
return false;
}
return Number.isFinite(Date.parse(value));
}
export type FrameType = 'I' | 'P' | 'B';
export type Importance = 'critical' | 'important' | 'normal' | 'temporary' | 'deprecated';
// Must stay in sync with the memory_frames.source CHECK constraint (schema.ts).
// ('personal'/'workspace' are MultiMind result labels, not DB sources; team-synced
// frames are stored as 'import' with provenance carried in the content prefix.)
export type FrameSource = 'user_stated' | 'tool_verified' | 'agent_inferred' | 'import' | 'system';
export interface MemoryFrame {
id: number;
frame_type: FrameType;
gop_id: string;
t: number;
base_frame_id: number | null;
content: string;
importance: Importance;
source: FrameSource;
access_count: number;
created_at: string;
last_accessed: string;
/** oss-drift D3: canonical dedup hash (hashFrameContent — stripHmPrefix +
* trim semantics). Maintained on every FrameStore write; NULL only on rows
* written by raw SQL before the next boot's migration backfill. */
content_hash?: string | null;
/** UX-Refactor Phase 2B: JSON blob for Memory Center provenance/classification
* (kind/confidence/scope/status/sourceId/sourceUrl/tags/evidence/related*).
* Always present at the column level (NOT NULL DEFAULT '{}'); typed optional
* so pre-migration callers and literal constructions stay back-compatible. */
metadata?: string;
}
export interface ReconstructedState {
iframe: MemoryFrame | null;
pframes: MemoryFrame[];
}
const IMPORTANCE_MULTIPLIERS: Record<Importance, number> = {
critical: 2.0,
important: 1.5,
normal: 1.0,
temporary: 0.7,
deprecated: 0.3,
};
export class FrameStore {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
}
createIFrame(
gopId: string,
content: string,
importance: Importance = 'normal',
source: FrameSource = 'user_stated',
/** Optional override for `memory_frames.created_at`. Supplied by the harvest
* path so frames ingested from an export preserve the original source
* timestamp (e.g. Claude session `create_time`) instead of getting
* stamped with the ingest wall-clock. Callers that don't care about
* temporal-anchor preservation (live agent writes, cognify, etc.)
* should omit this argument and let the `datetime('now')` default apply.
*
* Value must be a valid ISO-8601 string with `T` separator and timezone;
* invalid / null / undefined falls back to the schema default. The
* caller (harvest route) is responsible for validating + logging the
* fallback path — this function stays minimal and side-effect-free.
*
* Ported from hive-mind 9ec75e6. */
createdAt?: string | null,
): MemoryFrame {
// L1: Dedup — if identical content exists, update access count instead of duplicating
const existing = this.findDuplicate(content);
if (existing) return existing;
const t = this.nextT(gopId);
const raw = this.db.getDatabase();
// Branch on whether the caller supplied a valid ISO-8601 createdAt.
// Valid → INSERT also overrides created_at + last_accessed (last_accessed
// mirrors created_at on initial insert for consistency).
// Invalid / null / undefined → fall back to the schema default
// (datetime('now')) — never write junk timestamps that would corrupt
// range queries.
const useProvidedTs = typeof createdAt === 'string' && isValidIsoTimestamp(createdAt);
const contentHash = hashFrameContent(content);
const result = useProvidedTs
? raw.prepare(`
INSERT INTO memory_frames (frame_type, gop_id, t, base_frame_id, content, importance, source, content_hash, created_at, last_accessed)
VALUES ('I', ?, ?, NULL, ?, ?, ?, ?, ?, ?)
`).run(gopId, t, content, importance, source, contentHash, createdAt, createdAt)
: raw.prepare(`
INSERT INTO memory_frames (frame_type, gop_id, t, base_frame_id, content, importance, source, content_hash)
VALUES ('I', ?, ?, NULL, ?, ?, ?, ?)
`).run(gopId, t, content, importance, source, contentHash);
const frame = raw.prepare('SELECT * FROM memory_frames WHERE id = ?').get(result.lastInsertRowid) as MemoryFrame;
this.indexFts(frame);
return frame;
}
createPFrame(gopId: string, content: string, baseFrameId: number, importance: Importance = 'normal', source: FrameSource = 'user_stated'): MemoryFrame {
const t = this.nextT(gopId);
const raw = this.db.getDatabase();
const result = raw.prepare(`
INSERT INTO memory_frames (frame_type, gop_id, t, base_frame_id, content, importance, source, content_hash)
VALUES ('P', ?, ?, ?, ?, ?, ?, ?)
`).run(gopId, t, baseFrameId, content, importance, source, hashFrameContent(content));
const frame = raw.prepare('SELECT * FROM memory_frames WHERE id = ?').get(result.lastInsertRowid) as MemoryFrame;
this.indexFts(frame);
return frame;
}
createBFrame(gopId: string, content: string, baseFrameId: number, referencedFrameIds: number[]): MemoryFrame {
const t = this.nextT(gopId);
// Store cross-references in the content as structured data
const bContent = JSON.stringify({
description: content,
references: referencedFrameIds,
});
const raw = this.db.getDatabase();
const result = raw.prepare(`
INSERT INTO memory_frames (frame_type, gop_id, t, base_frame_id, content, importance, content_hash)
VALUES ('B', ?, ?, ?, ?, 'normal', ?)
`).run(gopId, t, baseFrameId, bContent, hashFrameContent(bContent));
const frame = raw.prepare('SELECT * FROM memory_frames WHERE id = ?').get(result.lastInsertRowid) as MemoryFrame;
this.indexFts(frame);
return frame;
}
getById(id: number): MemoryFrame | undefined {
return this.db.getDatabase().prepare('SELECT * FROM memory_frames WHERE id = ?').get(id) as MemoryFrame | undefined;
}
getLatestIFrame(gopId: string): MemoryFrame | undefined {
return this.db.getDatabase().prepare(`
SELECT * FROM memory_frames
WHERE gop_id = ? AND frame_type = 'I'
ORDER BY t DESC LIMIT 1
`).get(gopId) as MemoryFrame | undefined;
}
getPFramesSinceLastI(gopId: string): MemoryFrame[] {
const latestI = this.getLatestIFrame(gopId);
if (!latestI) return [];
return this.db.getDatabase().prepare(`
SELECT * FROM memory_frames
WHERE gop_id = ? AND frame_type = 'P' AND t > ?
ORDER BY t ASC
`).all(gopId, latestI.t) as MemoryFrame[];
}
getGopFrames(gopId: string): MemoryFrame[] {
return this.db.getDatabase().prepare(`
SELECT * FROM memory_frames WHERE gop_id = ? ORDER BY t ASC
`).all(gopId) as MemoryFrame[];
}
reconstructState(gopId: string): ReconstructedState {
const iframe = this.getLatestIFrame(gopId) ?? null;
const pframes = iframe ? this.getPFramesSinceLastI(gopId) : [];
return { iframe, pframes };
}
touch(id: number): number | undefined {
const row = this.db.getDatabase().prepare(`
UPDATE memory_frames SET access_count = access_count + 1, last_accessed = datetime('now')
WHERE id = ?
RETURNING access_count AS accessCount
`).get(id) as { accessCount: number } | undefined;
return row?.accessCount;
}
getImportanceMultiplier(importance: Importance): number {
return IMPORTANCE_MULTIPLIERS[importance];
}
/** List frames with an options bag (convenience wrapper used by server routes). */
list(opts: { limit?: number } = {}): MemoryFrame[] {
return this.getRecent(opts.limit ?? 50);
}
/** Get the most recent frames ordered by creation time descending. */
getRecent(limit = 50): MemoryFrame[] {
return this.db.getDatabase().prepare(`
SELECT * FROM memory_frames ORDER BY id DESC LIMIT ?
`).all(limit) as MemoryFrame[];
}
/**
* F20: Get recent frames with optional temporal boundaries.
* @param limit Maximum number of results
* @param since Only include frames created on or after this ISO date string
* @param until Only include frames created on or before this ISO date string
*/
getRecentFiltered(limit = 50, since?: string, until?: string): MemoryFrame[] {
const conditions: string[] = [];
const params: unknown[] = [];
if (since) {
conditions.push('created_at >= ?');
params.push(since);
}
if (until) {
conditions.push('created_at <= ?');
params.push(until);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(limit);
return this.db.getDatabase().prepare(`
SELECT * FROM memory_frames ${where} ORDER BY id DESC LIMIT ?
`).all(...params) as MemoryFrame[];
}
getBFrameReferences(bframeId: number): number[] {
const frame = this.getById(bframeId);
if (!frame || frame.frame_type !== 'B') return [];
try {
const parsed = JSON.parse(frame.content);
return parsed.references ?? [];
} catch {
return [];
}
}
/**
* L1: Check for duplicate content before inserting.
* Returns the existing frame if content hash matches, null otherwise.
* If a duplicate is found, updates its access_count instead of creating a new frame.
*
* oss-drift D3 (2026-06-11): O(1) lookup on the indexed `content_hash`
* column with NO recency window — the previous implementation scanned only
* the last 500 frames and silently missed older duplicates. Hash semantics
* (hashFrameContent) are unchanged:
* - trim-stable: JS `trim()` over the content (SQLite's `trim()` only
* strips ASCII space, so hashing happens JS-side, never in SQL);
* - provenance-insensitive (OQ-6): content passes through `stripHmPrefix`
* before hashing, so two same-body captures of one turn collapse into
* one frame regardless of which source's `[hm …]` prefix they carry.
* Rows written by raw SQL before the column existed are backfilled by
* db.ts runMigrations() on open.
*/
findDuplicate(content: string): MemoryFrame | null {
const frame = this.db.getDatabase().prepare(`
SELECT * FROM memory_frames WHERE content_hash = ? ORDER BY id DESC LIMIT 1
`).get(hashFrameContent(content)) as MemoryFrame | undefined;
if (frame) {
// Update access count instead of creating duplicate
this.touch(frame.id);
return frame;
}
return null;
}
/**
* Q22: Update a frame's content and/or importance by ID.
* Updates the main table, FTS index, and vector index.
* Returns the updated frame, or undefined if not found.
*/
update(id: number, content: string, importance?: Importance): MemoryFrame | undefined {
const raw = this.db.getDatabase();
const existing = this.getById(id);
if (!existing) return undefined;
const newImportance = importance ?? existing.importance;
// Update main table (content_hash maintained — oss-drift D3)
raw.prepare(`
UPDATE memory_frames SET content = ?, importance = ?, content_hash = ? WHERE id = ?
`).run(content, newImportance, hashFrameContent(content), id);
// Update FTS index: delete old entry, insert new
raw.prepare('DELETE FROM memory_frames_fts WHERE rowid = ?').run(id);
raw.prepare('INSERT INTO memory_frames_fts (rowid, content) VALUES (?, ?)').run(id, content);
// Update vector index if exists
try { raw.prepare('DELETE FROM memory_frames_vec WHERE rowid = ?').run(id); } catch { /* vec table may not exist */ }
return this.getById(id);
}
/**
* UX-Refactor Phase 2B: replace a frame's `metadata` JSON blob (Memory Center
* kind/confidence/scope/status/tags/evidence/related*). Low-level writer — the
* caller passes a fully-formed JSON string; parse/merge semantics live in the
* route layer (`memory.ts`). Does NOT touch FTS/vector indexes (metadata is not
* full-text searchable). Returns the updated frame, or undefined if the id is
* unknown.
*/
setMetadata(id: number, metadata: string): MemoryFrame | undefined {
const raw = this.db.getDatabase();
if (!this.getById(id)) return undefined;
raw.prepare('UPDATE memory_frames SET metadata = ? WHERE id = ?').run(metadata, id);
return this.getById(id);
}
/**
* L2: Delete a frame by ID. Returns true if deleted, false if not found.
*/
delete(id: number): boolean {
const raw = this.db.getDatabase();
// Clear self-referential FK: nullify base_frame_id on any frames that reference this one
raw.prepare('UPDATE memory_frames SET base_frame_id = NULL WHERE base_frame_id = ?').run(id);
// Delete from vector index if exists
try { raw.prepare('DELETE FROM memory_frames_vec WHERE rowid = ?').run(id); } catch { /* vec table may not exist */ }
// Delete chunk vectors. memory_frame_chunks_vec is a vec0 virtual table with
// NO foreign key, so the ON DELETE CASCADE that clears memory_frame_chunks
// when the frame goes would ORPHAN these embedding rows (keyed by chunk id) —
// and search() reads memory_frame_chunks_vec first, so a stale row stays
// recall-able. Collect the chunk ids WHILE memory_frame_chunks still holds
// them, then purge their vec rows (rowid must be a SQL literal for vec0).
try {
const chunkIds = raw.prepare('SELECT id FROM memory_frame_chunks WHERE frame_id = ?').all(id) as Array<{ id: number }>;
for (const c of chunkIds) {
raw.prepare(`DELETE FROM memory_frame_chunks_vec WHERE rowid = ${Math.trunc(c.id)}`).run();
}
} catch { /* chunk tables may not exist on a pre-D1 DB */ }
// Delete from FTS index
raw.prepare('DELETE FROM memory_frames_fts WHERE rowid = ?').run(id);
// Delete from KG entity-frame links if KG tables exist
try { raw.prepare('DELETE FROM kg_entity_frames WHERE frame_id = ?').run(id); } catch { /* KG tables may not exist */ }
// Delete from main table (FK cascade clears memory_frame_chunks + kg_entity_frames)
const result = raw.prepare('DELETE FROM memory_frames WHERE id = ?').run(id);
return result.changes > 0;
}
/**
* W4.3: delete every frame whose content starts with `prefix` (exact
* literal match — LIKE metacharacters in the prefix are escaped). Used by
* replace-on-update lanes (profile cards supersede the prior card for the
* same person). Routes through delete(id) so FTS/vec/KG cleanup applies.
* Returns the number of frames deleted.
*/
deleteByContentPrefix(prefix: string): number {
const raw = this.db.getDatabase();
const escaped = prefix.replace(/[\\%_]/g, ch => `\\${ch}`);
const rows = raw.prepare(
`SELECT id FROM memory_frames WHERE content LIKE ? ESCAPE '\\'`
).all(`${escaped}%`) as Array<{ id: number }>;
let deleted = 0;
for (const r of rows) {
if (this.delete(r.id)) deleted++;
}
return deleted;
}
// ── 9a: Memory Compaction ──────────────────────────────────────────
/**
* Compact memory: merge stale P-frames into their base I-frame,
* prune deprecated frames, and clean up temporary frames older than maxAge.
*
* @param maxTempAgeDays - Delete temporary frames older than this (default 30)
* @param maxDeprecatedAgeDays - Delete deprecated frames older than this (default 90)
* @returns Summary of compaction actions taken
*/
compact(maxTempAgeDays = 30, maxDeprecatedAgeDays = 90): {
temporaryPruned: number;
deprecatedPruned: number;
pframesMerged: number;
} {
const raw = this.db.getDatabase();
let temporaryPruned = 0;
let deprecatedPruned = 0;
let pframesMerged = 0;
// Prune via delete(id), NOT a bare `DELETE FROM memory_frames`. A bare delete
// relies on the FK cascade, which reaches memory_frame_chunks + kg_entity_frames
// but NOT the vec0 virtual tables (memory_frames_vec, memory_frame_chunks_vec)
// or the FTS index — those have no FK, so a bare delete orphans their rows and
// they linger in the search index. delete() purges all of them (and nullifies
// referencing base_frame_id, which a bare delete would trip on under FK-ON).
// 1. Delete old temporary frames
const tempIds = raw.prepare(`
SELECT id FROM memory_frames
WHERE importance = 'temporary'
AND created_at < datetime('now', '-' || ? || ' days')
`).all(maxTempAgeDays) as Array<{ id: number }>;
for (const { id } of tempIds) if (this.delete(id)) temporaryPruned++;
// 2. Delete old deprecated frames
const depIds = raw.prepare(`
SELECT id FROM memory_frames
WHERE importance = 'deprecated'
AND created_at < datetime('now', '-' || ? || ' days')
`).all(maxDeprecatedAgeDays) as Array<{ id: number }>;
for (const { id } of depIds) if (this.delete(id)) deprecatedPruned++;
// 3. Merge P-frames into I-frames when there are more than 10 P-frames
// for a single GOP. The merged content becomes a new I-frame and the
// old P-frames are deleted.
const gopsWithManyPframes = raw.prepare(`
SELECT gop_id, COUNT(*) as cnt FROM memory_frames
WHERE frame_type = 'P'
GROUP BY gop_id
HAVING cnt > 10
`).all() as { gop_id: string; cnt: number }[];
for (const { gop_id } of gopsWithManyPframes) {
const latestI = this.getLatestIFrame(gop_id);
if (!latestI) continue;
const pframes = raw.prepare(`
SELECT * FROM memory_frames
WHERE gop_id = ? AND frame_type = 'P' AND t > ?
ORDER BY t ASC
`).all(gop_id, latestI.t) as MemoryFrame[];
if (pframes.length <= 10) continue;
// Keep the 5 most recent P-frames, merge the rest into the I-frame
const toMerge = pframes.slice(0, pframes.length - 5);
const mergedContent = [latestI.content, ...toMerge.map(p => p.content)].join('\n---\n');
// Update the I-frame with merged content (content_hash maintained — oss-drift D3)
raw.prepare('UPDATE memory_frames SET content = ?, content_hash = ? WHERE id = ?')
.run(mergedContent, hashFrameContent(mergedContent), latestI.id);
// Update FTS
raw.prepare('DELETE FROM memory_frames_fts WHERE rowid = ?').run(latestI.id);
raw.prepare('INSERT INTO memory_frames_fts (rowid, content) VALUES (?, ?)').run(latestI.id, mergedContent);
// Delete merged P-frames — through delete() so the chunk vec index is
// purged too (the old inline delete omitted memory_frame_chunks_vec).
for (const pf of toMerge) {
if (this.delete(pf.id)) pframesMerged++;
}
}
return { temporaryPruned, deprecatedPruned, pframesMerged };
}
/** Get frame statistics for monitoring. */
getStats(): { total: number; byType: Record<string, number>; byImportance: Record<string, number> } {
const raw = this.db.getDatabase();
const total = (raw.prepare('SELECT COUNT(*) as cnt FROM memory_frames').get() as { cnt: number }).cnt;
const byType: Record<string, number> = {};
for (const row of raw.prepare('SELECT frame_type, COUNT(*) as cnt FROM memory_frames GROUP BY frame_type').all() as { frame_type: string; cnt: number }[]) {
byType[row.frame_type] = row.cnt;
}
const byImportance: Record<string, number> = {};
for (const row of raw.prepare('SELECT importance, COUNT(*) as cnt FROM memory_frames GROUP BY importance').all() as { importance: string; cnt: number }[]) {
byImportance[row.importance] = row.cnt;
}
return { total, byType, byImportance };
}
private nextT(gopId: string): number {
const row = this.db.getDatabase().prepare(`
SELECT COALESCE(MAX(t), -1) + 1 AS next_t FROM memory_frames WHERE gop_id = ?
`).get(gopId) as { next_t: number };
return row.next_t;
}
private indexFts(frame: MemoryFrame): void {
this.db.getDatabase().prepare(`
INSERT INTO memory_frames_fts (rowid, content) VALUES (?, ?)
`).run(frame.id, frame.content);
}
}

View File

@@ -0,0 +1,64 @@
/**
* fts-sanitize.ts — shared FTS5 OR-query sanitizer (S1 Unicode fix).
*
* One canonical copy of the sanitizer previously duplicated in
* HybridSearch.keywordSearch (W3.6), MultiMind.ftsSearch (F6), and
* raw-detail-lane's ftsOrQuery. The old `[^\w]` strip removed EVERY
* non-ASCII letter — a Cyrillic ("Београд") or diacritic (č/ž/š/đ) query
* sanitized to an empty MATCH string and keyword recall silently returned
* [] even though the unicode61 FTS5 tokenizer handles those scripts fine.
* Fixed with the Unicode-aware class `[^\p{L}\p{N}_]`, which is a no-op
* for pure-ASCII input (`\w` ⊂ `\p{L}\p{N}_`), so English MATCH strings —
* and therefore results and scores — are byte-identical to before.
*
* CJK tokens (Han/Hiragana/Katakana/Hangul) are deliberately EXCLUDED from
* the OR query: unicode61 does not segment those scripts, so contiguous
* prose is indexed as one long token and a per-word MATCH almost never
* hits — the query "succeeds" with zero rows, which would also block any
* parse-error fallback. Dropping CJK tokens leaves the OR query empty for
* pure-CJK input; callers with a LIKE fallback (HybridSearch.keywordSearch)
* detect that via `hasUnsegmentedScript` and use substring matching, which
* is reliable for unsegmented text. This also sidesteps the `length > 2`
* filter, which would have dropped typical 12 char CJK words. Other
* unsegmented scripts (Thai, Khmer, Lao, …) can be added to
* UNSEGMENTED_SCRIPT_RE if those markets materialize.
*/
export const FTS_STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
'should', 'may', 'might', 'shall', 'can', 'to', 'of', 'in', 'for',
'on', 'with', 'at', 'by', 'from', 'as', 'into', 'about', 'this',
'that', 'these', 'those', 'it', 'its', 'my', 'your', 'our', 'their',
'what', 'which', 'who', 'whom', 'how', 'when', 'where', 'why', 'all',
'each', 'every', 'both', 'some', 'any', 'no', 'not', 'and', 'or', 'but',
]);
/** Strip everything that is not a Unicode letter, digit, or underscore. */
export function sanitizeFtsToken(word: string): string {
return word.replace(/[^\p{L}\p{N}_]/gu, '');
}
const UNSEGMENTED_SCRIPT_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
/** True when the text contains a script unicode61 cannot word-segment (CJK). */
export function hasUnsegmentedScript(text: string): boolean {
return UNSEGMENTED_SCRIPT_RE.test(text);
}
/**
* Build an OR-based FTS5 MATCH string: tokenize on whitespace, strip
* punctuation (Unicode-aware), drop stop words / tokens ≤ 2 chars / CJK
* tokens, quote each survivor. Returns '' when nothing survives — callers
* treat that as "no FTS signal" (and may route CJK queries to a LIKE
* fallback, see module doc).
*/
export function buildFtsOrQuery(query: string): string {
return query
.split(/\s+/)
.map(sanitizeFtsToken)
.filter(w => w.length > 2 && !FTS_STOP_WORDS.has(w.toLowerCase()) && !hasUnsegmentedScript(w))
.map(w => `"${w}"`)
.join(' OR ');
}

View File

@@ -0,0 +1,80 @@
import type { MindDB } from './db.js';
export interface Identity {
id: number;
name: string;
role: string;
department: string;
personality: string;
capabilities: string;
system_prompt: string;
created_at: string;
updated_at: string;
}
type IdentityInput = Omit<Identity, 'id' | 'created_at' | 'updated_at'>;
type IdentityUpdate = Partial<IdentityInput>;
// Column allowlist — update() interpolates the key into SQL (values are
// parameterized, keys are not), so only these known columns may be written.
// Defense-in-depth against a malformed/attacker-shaped `changes` object.
const UPDATABLE_COLUMNS: ReadonlySet<string> = new Set([
'name', 'role', 'department', 'personality', 'capabilities', 'system_prompt',
]);
export class IdentityLayer {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
}
create(input: IdentityInput): Identity {
const raw = this.db.getDatabase();
raw.prepare(`
INSERT INTO identity (id, name, role, department, personality, capabilities, system_prompt)
VALUES (1, ?, ?, ?, ?, ?, ?)
`).run(input.name, input.role, input.department, input.personality, input.capabilities, input.system_prompt);
return this.get();
}
get(): Identity {
const raw = this.db.getDatabase();
const row = raw.prepare('SELECT * FROM identity WHERE id = 1').get() as Identity | undefined;
if (!row) throw new Error('No identity configured');
return row;
}
exists(): boolean {
const raw = this.db.getDatabase();
const row = raw.prepare('SELECT 1 FROM identity WHERE id = 1').get();
return row !== undefined;
}
update(changes: IdentityUpdate): Identity {
if (!this.exists()) throw new Error('No identity configured');
const fields = Object.entries(changes).filter(([k, v]) => v !== undefined && UPDATABLE_COLUMNS.has(k));
if (fields.length === 0) return this.get();
const sets = fields.map(([k]) => `${k} = ?`).join(', ');
const values = fields.map(([, v]) => v);
const raw = this.db.getDatabase();
raw.prepare(`UPDATE identity SET ${sets}, updated_at = datetime('now') WHERE id = 1`).run(...values);
return this.get();
}
toContext(): string {
const id = this.get();
const parts = [
`Name: ${id.name}`,
id.role && `Role: ${id.role}`,
id.department && `Department: ${id.department}`,
id.personality && `Personality: ${id.personality}`,
id.capabilities && `Capabilities: ${id.capabilities}`,
id.system_prompt && `System Prompt: ${id.system_prompt}`,
].filter(Boolean);
return parts.join('\n');
}
}

View File

@@ -0,0 +1,174 @@
import type { MindDB } from './db.js';
export type SignalCategory = 'capability_gap' | 'correction' | 'workflow_pattern' | 'skill_promotion';
export interface ImprovementSignal {
id: number;
category: SignalCategory;
pattern_key: string;
detail: string;
count: number;
first_seen: string;
last_seen: string;
surfaced: number; // 0 or 1
surfaced_at: string | null;
metadata: string; // JSON
}
export interface ActionableSignal extends ImprovementSignal {
parsedMetadata: Record<string, unknown>;
}
export interface ActionableThresholds {
capability_gap?: number;
correction?: number;
workflow_pattern?: number;
skill_promotion?: number;
}
const DEFAULT_THRESHOLDS: Required<ActionableThresholds> = {
capability_gap: 2,
correction: 3,
workflow_pattern: 3,
skill_promotion: 1, // one promotion request is actionable
};
const MAX_ACTIONABLE = 3;
export class ImprovementSignalStore {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
this.ensureTable();
}
/** Ensure improvement_signals table exists for databases created before this feature */
private ensureTable(): void {
try {
const raw = this.db.getDatabase();
const exists = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='improvement_signals'",
).get();
if (!exists) {
raw.exec(`
CREATE TABLE IF NOT EXISTS improvement_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL CHECK (category IN ('capability_gap', 'correction', 'workflow_pattern', 'skill_promotion')),
pattern_key TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL DEFAULT 1,
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
surfaced INTEGER NOT NULL DEFAULT 0,
surfaced_at TEXT,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_signals_category_key ON improvement_signals (category, pattern_key);
CREATE INDEX IF NOT EXISTS idx_signals_category ON improvement_signals (category, count DESC);
`);
}
} catch {
// Database may already be closed during async teardown — safe to skip migration
}
}
/**
* Record an improvement signal. Upserts: increments count + updates last_seen
* if a signal with the same (category, pattern_key) already exists.
*/
record(
category: SignalCategory,
patternKey: string,
detail?: string,
metadata?: Record<string, unknown>,
): ImprovementSignal {
const raw = this.db.getDatabase();
const metadataJson = metadata ? JSON.stringify(metadata) : '{}';
raw.prepare(`
INSERT INTO improvement_signals (category, pattern_key, detail, metadata)
VALUES (?, ?, ?, ?)
ON CONFLICT (category, pattern_key) DO UPDATE SET
count = count + 1,
last_seen = datetime('now'),
detail = CASE WHEN excluded.detail != '' THEN excluded.detail ELSE detail END,
metadata = CASE WHEN excluded.metadata != '{}' THEN excluded.metadata ELSE metadata END
`).run(category, patternKey, detail ?? '', metadataJson);
return raw.prepare(
'SELECT * FROM improvement_signals WHERE category = ? AND pattern_key = ?',
).get(category, patternKey) as ImprovementSignal;
}
/** Get all signals for a category, ordered by count descending. */
getByCategory(category: SignalCategory): ImprovementSignal[] {
return this.db.getDatabase().prepare(
'SELECT * FROM improvement_signals WHERE category = ? ORDER BY count DESC',
).all(category) as ImprovementSignal[];
}
/**
* Get actionable signals: count >= threshold AND not yet surfaced.
* Returns at most MAX_ACTIONABLE (3) signals, highest count first.
* Per correction #6: threshold-based, capped, non-repeating once surfaced.
*/
getActionable(thresholds?: ActionableThresholds): ActionableSignal[] {
const merged = { ...DEFAULT_THRESHOLDS, ...thresholds };
const raw = this.db.getDatabase();
// Build a union query across categories with their respective thresholds
const results: ImprovementSignal[] = [];
for (const [category, threshold] of Object.entries(merged)) {
const rows = raw.prepare(`
SELECT * FROM improvement_signals
WHERE category = ? AND count >= ? AND surfaced = 0
ORDER BY count DESC
`).all(category, threshold) as ImprovementSignal[];
results.push(...rows);
}
// Sort by count descending, cap at MAX_ACTIONABLE
results.sort((a, b) => b.count - a.count);
return results.slice(0, MAX_ACTIONABLE).map(signal => ({
...signal,
parsedMetadata: parseMetadata(signal.metadata),
}));
}
/** Mark a signal as surfaced so it won't be returned by getActionable again. */
markSurfaced(id: number): void {
this.db.getDatabase().prepare(
"UPDATE improvement_signals SET surfaced = 1, surfaced_at = datetime('now') WHERE id = ?",
).run(id);
}
/** Get a single signal by id. */
get(id: number): ImprovementSignal | undefined {
return this.db.getDatabase().prepare(
'SELECT * FROM improvement_signals WHERE id = ?',
).get(id) as ImprovementSignal | undefined;
}
/** Get a signal by its category + pattern_key pair. */
getByKey(category: SignalCategory, patternKey: string): ImprovementSignal | undefined {
return this.db.getDatabase().prepare(
'SELECT * FROM improvement_signals WHERE category = ? AND pattern_key = ?',
).get(category, patternKey) as ImprovementSignal | undefined;
}
/** Clear all signals (for testing). */
clear(): void {
this.db.getDatabase().prepare('DELETE FROM improvement_signals').run();
}
}
function parseMetadata(json: string): Record<string, unknown> {
try {
return JSON.parse(json);
} catch {
return {};
}
}

View File

@@ -0,0 +1,67 @@
/**
* In-process embedder using @huggingface/transformers (ONNX Runtime).
* Default provider for ALL desktop users — zero config, works offline.
* Model: Xenova/all-MiniLM-L6-v2 (384 native dims, normalized to target dims).
* Downloads ~23MB model on first use, cached in ~/.waggle/models/.
*/
import path from 'node:path';
import os from 'node:os';
import type { Embedder } from './embeddings.js';
import { createCoreLogger } from '../logger.js';
const log = createCoreLogger('inprocess-embedder');
export interface InProcessEmbedderConfig {
model?: string;
cacheDir?: string;
targetDimensions?: number;
}
/** Normalize embedding dimensions: zero-pad shorter, truncate longer. */
export function normalizeDimensions(embedding: Float32Array, targetDims: number): Float32Array {
if (embedding.length === targetDims) return embedding;
const result = new Float32Array(targetDims);
const copyLen = Math.min(embedding.length, targetDims);
result.set(embedding.subarray(0, copyLen));
return result;
}
export async function createInProcessEmbedder(config?: Partial<InProcessEmbedderConfig>): Promise<Embedder> {
const model = config?.model ?? 'Xenova/all-MiniLM-L6-v2';
const cacheDir = config?.cacheDir ?? path.join(os.homedir(), '.waggle', 'models');
const targetDims = config?.targetDimensions ?? 1024;
log.info(`Loading in-process embedding model: ${model} (~23MB first download)`);
const { pipeline, env } = await import('@huggingface/transformers');
env.cacheDir = cacheDir;
env.allowRemoteModels = true;
const extractor = await pipeline('feature-extraction', model, { dtype: 'fp32' });
const nativeDims = 384; // all-MiniLM-L6-v2 output dimensions
log.info(`In-process embedder ready (${nativeDims} native dims → ${targetDims} normalized)`);
return {
dimensions: targetDims,
async embed(text: string): Promise<Float32Array> {
const result = await extractor(text, { pooling: 'mean', normalize: true });
const raw = new Float32Array(result.data as Float32Array);
return normalizeDimensions(raw, targetDims);
},
async embedBatch(texts: string[]): Promise<Float32Array[]> {
if (texts.length === 0) return [];
const results: Float32Array[] = [];
// Process one at a time to avoid memory issues with large batches
for (const text of texts) {
const result = await extractor(text, { pooling: 'mean', normalize: true });
const raw = new Float32Array(result.data as Float32Array);
results.push(normalizeDimensions(raw, targetDims));
}
return results;
},
};
}

View File

@@ -0,0 +1,129 @@
/**
* In-process cross-encoder reranker using @huggingface/transformers (ONNX).
*
* Cross-encoders take (query, doc) pairs and output a relevance score by
* jointly attending to both — much more discriminating than vector dot
* products. Use after RRF to rerank the top-K candidates from hybrid
* search. Standard pattern in production RAG systems.
*
* Default model: Xenova/ms-marco-MiniLM-L-6-v2 — the canonical
* SentenceTransformers cross-encoder, ~22MB on disk, ~30-50ms per pair
* on CPU. Trained on MS MARCO passage ranking, generalizes well to
* mixed-domain technical text.
*
* Alternative: Xenova/bge-reranker-base (~280MB, slightly higher quality
* on out-of-domain queries). Set via `model` config.
*
* `@huggingface/transformers` is an optional peer dep — if not installed,
* createInProcessReranker throws and the caller falls back to no reranking.
*/
import path from 'node:path';
import os from 'node:os';
import { createCoreLogger } from '../logger.js';
const log = createCoreLogger('inprocess-reranker');
export interface Reranker {
/**
* Score a single (query, doc) pair. Higher = more relevant.
* Score scale depends on the model — for ms-marco-MiniLM it's
* roughly [-10, 10]; relative ordering is what matters.
*/
score(query: string, doc: string): Promise<number>;
/**
* Score N pairs sharing one query. Same-shape result as score() but
* amortises the model invocation when supported.
*/
scoreBatch(query: string, docs: string[]): Promise<number[]>;
}
export interface InProcessRerankerConfig {
model?: string;
cacheDir?: string;
}
/**
* Build a reranker backed by @huggingface/transformers. Throws if the
* package isn't installed — caller is expected to catch and fall back.
*/
export async function createInProcessReranker(
config?: Partial<InProcessRerankerConfig>,
): Promise<Reranker> {
const model = config?.model ?? 'Xenova/ms-marco-MiniLM-L-6-v2';
const cacheDir = config?.cacheDir ?? path.join(os.homedir(), '.hive-mind', 'models');
log.info(`Loading in-process reranker: ${model} (~22MB first download)`);
const { AutoTokenizer, AutoModelForSequenceClassification, env } = await import(
'@huggingface/transformers'
);
env.cacheDir = cacheDir;
env.allowRemoteModels = true;
// Cross-encoders need direct tokenizer + model access — pipeline API
// doesn't expose the (text, text_pair) input pattern cleanly across
// all transformers.js versions. Calling the model directly with
// tokenized pairs is the stable path.
const tokenizer = await AutoTokenizer.from_pretrained(model);
const seqModel = await AutoModelForSequenceClassification.from_pretrained(model, { dtype: 'fp32' });
log.info(`In-process reranker ready: ${model}`);
/** Score a single pair: tokenize, forward, extract logit. */
async function scorePair(query: string, doc: string): Promise<number> {
const inputs = await tokenizer(query, {
text_pair: doc,
padding: true,
truncation: true,
return_tensors: 'pt',
});
const out = await seqModel(inputs);
// ms-marco-MiniLM outputs a single logit per pair (1-class regression).
// Other cross-encoders may output 2 classes — take logit[0] - logit[1]
// as a relevance score in that case.
const logits = out.logits ?? out[0];
const data = logits.data as Float32Array | number[];
if (logits.dims && logits.dims[logits.dims.length - 1] === 2) {
return Number(data[0]) - Number(data[1]);
}
return Number(data[0]);
}
return {
async score(query: string, doc: string): Promise<number> {
return scorePair(query, doc);
},
async scoreBatch(query: string, docs: string[]): Promise<number[]> {
if (docs.length === 0) return [];
// Tokenize all pairs together for batch inference. Padding aligns
// sequences so the model can process them in one forward pass.
const queries = docs.map(() => query);
const inputs = await tokenizer(queries, {
text_pair: docs,
padding: true,
truncation: true,
return_tensors: 'pt',
});
const out = await seqModel(inputs);
const logits = out.logits ?? out[0];
const data = logits.data as Float32Array | number[];
const dims = logits.dims;
const lastDim = dims[dims.length - 1];
const scores: number[] = [];
if (lastDim === 2) {
for (let i = 0; i < docs.length; i++) {
scores.push(Number(data[i * 2]) - Number(data[i * 2 + 1]));
}
} else {
for (let i = 0; i < docs.length; i++) {
scores.push(Number(data[i]));
}
}
return scores;
},
};
}

View File

@@ -0,0 +1,454 @@
import type { MindDB } from './db.js';
import { normalizeEntityName } from './entity-normalizer.js';
// Reverse-ported from OSS hive-mind (oss-drift triage R2, 2026-06-11).
/** Hardened JSON parse for entity/relation props — never throws, never returns non-objects. */
function safeParseProps(json: string): Record<string, unknown> {
try {
const v = JSON.parse(json || '{}');
return v && typeof v === 'object' ? (v as Record<string, unknown>) : {};
} catch {
return {};
}
}
export interface Entity {
id: number;
entity_type: string;
name: string;
properties: string; // JSON
valid_from: string;
valid_to: string | null;
recorded_at: string;
}
export interface Relation {
id: number;
source_id: number;
target_id: number;
relation_type: string;
confidence: number;
properties: string; // JSON
valid_from: string;
valid_to: string | null;
recorded_at: string;
}
export interface EntityTypeSchema {
required: string[];
allowedRelations: string[];
}
export type ValidationSchema = Record<string, EntityTypeSchema>;
/**
* Escape LIKE metacharacters (`%`, `_`) and the escape char itself (`\`) so a
* user term is matched literally rather than as a wildcard pattern. Pair with an
* `ESCAPE '\'` clause on the LIKE. Without this, `%` / `_` in a search term act
* as wildcards and a literal `%` / `_` becomes unfindable.
*/
function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, ch => `\\${ch}`);
}
export class KnowledgeGraph {
private db: MindDB;
private schema: ValidationSchema | null = null;
constructor(db: MindDB) {
this.db = db;
}
setValidationSchema(schema: ValidationSchema): void {
this.schema = schema;
}
// --- Entity operations ---
createEntity(entityType: string, name: string, properties: Record<string, unknown>, temporal?: { valid_from?: string; valid_to?: string }): Entity {
this.validateEntityProperties(entityType, properties);
const raw = this.db.getDatabase();
if (temporal?.valid_from || temporal?.valid_to) {
const validFrom = temporal.valid_from ?? new Date().toISOString();
const validTo = temporal.valid_to ?? null;
const result = raw.prepare(`
INSERT INTO knowledge_entities (entity_type, name, properties, valid_from, valid_to)
VALUES (?, ?, ?, ?, ?)
`).run(entityType, name, JSON.stringify(properties), validFrom, validTo);
return raw.prepare('SELECT * FROM knowledge_entities WHERE id = ?').get(result.lastInsertRowid) as Entity;
}
const result = raw.prepare(`
INSERT INTO knowledge_entities (entity_type, name, properties)
VALUES (?, ?, ?)
`).run(entityType, name, JSON.stringify(properties));
return raw.prepare('SELECT * FROM knowledge_entities WHERE id = ?').get(result.lastInsertRowid) as Entity;
}
getEntity(id: number): Entity | undefined {
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_entities WHERE id = ?'
).get(id) as Entity | undefined;
}
updateEntity(id: number, changes: { name?: string; properties?: Record<string, unknown> }): Entity {
const raw = this.db.getDatabase();
const sets: string[] = [];
const values: unknown[] = [];
if (changes.name !== undefined) {
sets.push('name = ?');
values.push(changes.name);
}
if (changes.properties !== undefined) {
sets.push('properties = ?');
values.push(JSON.stringify(changes.properties));
}
if (sets.length > 0) {
sets.push("recorded_at = datetime('now')");
raw.prepare(`UPDATE knowledge_entities SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
}
return raw.prepare('SELECT * FROM knowledge_entities WHERE id = ?').get(id) as Entity;
}
retireEntity(id: number): void {
this.db.getDatabase().prepare(
"UPDATE knowledge_entities SET valid_to = datetime('now') WHERE id = ?"
).run(id);
}
getEntitiesByType(entityType: string, limit = 500): Entity[] {
if (!entityType) {
return this.getEntities(limit);
}
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_entities WHERE entity_type = ? AND valid_to IS NULL ORDER BY name LIMIT ?'
).all(entityType, limit) as Entity[];
}
/** 9c: Paginated entity listing — prevents unbounded fetches. */
getEntities(limit = 200, offset = 0): Entity[] {
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_entities WHERE valid_to IS NULL ORDER BY name LIMIT ? OFFSET ?'
).all(limit, offset) as Entity[];
}
/** Get entity type counts (for dashboard display without fetching all rows). */
getEntityTypeCounts(): { type: string; count: number }[] {
return this.db.getDatabase().prepare(
'SELECT entity_type as type, COUNT(*) as count FROM knowledge_entities WHERE valid_to IS NULL GROUP BY entity_type ORDER BY count DESC'
).all() as { type: string; count: number }[];
}
/** Total active entity count. */
getEntityCount(): number {
const row = this.db.getDatabase().prepare(
'SELECT COUNT(*) as cnt FROM knowledge_entities WHERE valid_to IS NULL'
).get() as { cnt: number };
return row.cnt;
}
searchEntities(query: string, limit = 100): Entity[] {
return this.db.getDatabase().prepare(
"SELECT * FROM knowledge_entities WHERE name LIKE ? ESCAPE '\\' AND valid_to IS NULL ORDER BY name LIMIT ?"
).all(`%${escapeLikeTerm(query)}%`, limit) as Entity[];
}
/**
* Exact-name lookup. Returns the active entity whose name equals the
* query (case-sensitive), or undefined.
*
* Use this instead of `searchEntities(name, 3).find(...)` for dedup —
* the LIKE-based fuzzy search drops the exact match out of the top-K
* window once enough similarly-named entities accumulate, which causes
* dedup failures and runaway duplicate-row growth (e.g. 3506 copies of
* "Phase" observed in the OSS repo because other names containing
* "Phase" crowded the plain "Phase" out of a LIKE '%Phase%' top-K).
*
* Reverse-ported from OSS hive-mind (oss-drift triage R2, 2026-06-11).
*/
findEntityByName(name: string): Entity | undefined {
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_entities WHERE name = ? AND valid_to IS NULL LIMIT 1'
).get(name) as Entity | undefined;
}
getEntitiesValidAt(isoTime: string, limit = 500): Entity[] {
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_entities WHERE valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) LIMIT ?'
).all(isoTime, isoTime, limit) as Entity[];
}
// --- Relation operations ---
createRelation(sourceId: number, targetId: number, relationType: string, confidence = 1.0, properties: Record<string, unknown> = {}): Relation {
this.validateRelation(sourceId, relationType);
const raw = this.db.getDatabase();
const result = raw.prepare(`
INSERT INTO knowledge_relations (source_id, target_id, relation_type, confidence, properties)
VALUES (?, ?, ?, ?, ?)
`).run(sourceId, targetId, relationType, confidence, JSON.stringify(properties));
return raw.prepare('SELECT * FROM knowledge_relations WHERE id = ?').get(result.lastInsertRowid) as Relation;
}
getRelation(id: number): Relation | undefined {
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_relations WHERE id = ?'
).get(id) as Relation | undefined;
}
getRelationsFrom(sourceId: number, relationType?: string): Relation[] {
if (relationType) {
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_relations WHERE source_id = ? AND relation_type = ? AND valid_to IS NULL'
).all(sourceId, relationType) as Relation[];
}
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_relations WHERE source_id = ? AND valid_to IS NULL'
).all(sourceId) as Relation[];
}
getRelationsTo(targetId: number, relationType?: string): Relation[] {
if (relationType) {
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_relations WHERE target_id = ? AND relation_type = ? AND valid_to IS NULL'
).all(targetId, relationType) as Relation[];
}
return this.db.getDatabase().prepare(
'SELECT * FROM knowledge_relations WHERE target_id = ? AND valid_to IS NULL'
).all(targetId) as Relation[];
}
retireRelation(id: number): void {
this.db.getDatabase().prepare(
"UPDATE knowledge_relations SET valid_to = datetime('now') WHERE id = ?"
).run(id);
}
/**
* Merge active entities that share a normalized name + type. The survivor is
* the entity with the most relations (ties broken by lowest/oldest id); each
* duplicate's relations are re-pointed to the survivor, properties are merged
* (survivor wins on key conflicts, but `seen_count` is summed), and the
* duplicate is retired (bitemporal soft-delete). Runs in a single transaction.
*
* Reverse-ported from OSS hive-mind (oss-drift triage R2, 2026-06-11).
*
* @returns `{ groups }` duplicate groups processed, `{ merged }` entities retired.
*/
dedupeByName(): { groups: number; merged: number } {
const raw = this.db.getDatabase();
const grouped = new Map<string, Entity[]>();
for (const e of this.getEntities(100_000)) {
const key = `${normalizeEntityName(e.name)}::${e.entity_type.toLowerCase()}`;
let g = grouped.get(key);
if (!g) {
g = [];
grouped.set(key, g);
}
g.push(e);
}
let groups = 0;
let merged = 0;
const relCount = (id: number): number =>
this.getRelationsFrom(id).length + this.getRelationsTo(id).length;
const tx = raw.transaction(() => {
for (const group of grouped.values()) {
if (group.length <= 1) continue;
groups += 1;
// Survivor = most relations; ties → lowest (oldest) id.
const sorted = [...group].sort((a, b) => relCount(b.id) - relCount(a.id) || a.id - b.id);
const keep = sorted[0];
for (const dup of sorted.slice(1)) {
// Re-point the duplicate's relations onto the survivor, then retire them.
for (const rel of this.getRelationsFrom(dup.id)) {
try {
this.createRelation(keep.id, rel.target_id, rel.relation_type, rel.confidence, safeParseProps(rel.properties));
} catch {
/* may already exist or be schema-rejected — the retire below still applies */
}
this.retireRelation(rel.id);
}
for (const rel of this.getRelationsTo(dup.id)) {
try {
this.createRelation(rel.source_id, keep.id, rel.relation_type, rel.confidence, safeParseProps(rel.properties));
} catch {
/* idem */
}
this.retireRelation(rel.id);
}
// Merge properties: survivor wins on conflicts, seen_count is summed.
const keepProps = safeParseProps(keep.properties);
const dupProps = safeParseProps(dup.properties);
const mergedProps: Record<string, unknown> = { ...dupProps, ...keepProps };
mergedProps.seen_count =
Number(keepProps.seen_count ?? 1) + Number(dupProps.seen_count ?? 1);
this.updateEntity(keep.id, { properties: mergedProps });
this.retireEntity(dup.id);
merged += 1;
}
}
});
tx();
return { groups, merged };
}
// --- Graph traversal ---
traverse(startId: number, relationType: string, maxDepth: number): Entity[] {
const visited = new Set<number>([startId]);
const result: Entity[] = [];
let frontier = [startId];
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
const nextFrontier: number[] = [];
for (const nodeId of frontier) {
const rels = this.getRelationsFrom(nodeId, relationType);
for (const rel of rels) {
if (!visited.has(rel.target_id)) {
visited.add(rel.target_id);
const entity = this.getEntity(rel.target_id);
if (entity && entity.valid_to === null) {
result.push(entity);
nextFrontier.push(rel.target_id);
}
}
}
}
frontier = nextFrontier;
}
return result;
}
bfsDistances(startId: number, maxDepth: number): Map<number, number> {
const distances = new Map<number, number>();
const visited = new Set<number>([startId]);
let frontier = [startId];
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
const nextFrontier: number[] = [];
for (const nodeId of frontier) {
const rels = this.getRelationsFrom(nodeId);
for (const rel of rels) {
if (!visited.has(rel.target_id)) {
visited.add(rel.target_id);
distances.set(rel.target_id, depth);
nextFrontier.push(rel.target_id);
}
}
}
frontier = nextFrontier;
}
return distances;
}
/**
* Create entities extracted from a frame AND link each to that frame
* (kg_entity_frames), returning the count created+linked. The link is the
* provenance anchor that makes an entity reachable by frame-scoped operations —
* notably GDPR Art.17 erasure's orphan sweep. Harvest routes previously called
* createEntity WITHOUT linkEntityToFrame, so imported entity names (often PII)
* were born orphaned and survived erasure; route imports through here instead.
* Per-entity failures are swallowed (non-fatal import — mirrors the harvest loop).
*/
importEntitiesForFrame(
frameId: number,
entities: Array<{ name: string; type?: string }>,
provenance: { source: string; importedFrom?: string },
): number {
let created = 0;
for (const ent of entities) {
try {
const e = this.createEntity(ent.type || 'concept', ent.name, {
source: provenance.source,
imported_from: provenance.importedFrom,
});
this.linkEntityToFrame(e.id, frameId);
created++;
} catch { /* non-fatal per-entity (schema-rejected / bad name) — as in the harvest routes */ }
}
return created;
}
/** Link an entity to a frame it was extracted from (kg_entity_frames bridge).
* Powers the 'contextual' scoring signal. Idempotent per (entity, frame). */
linkEntityToFrame(entityId: number, frameId: number): void {
try {
this.db.getDatabase().prepare(
'INSERT OR IGNORE INTO kg_entity_frames (entity_id, frame_id) VALUES (?, ?)'
).run(entityId, frameId);
} catch { /* bridge table absent on a pre-migration DB — best-effort */ }
}
/** Seed entities whose name appears in free text (case-insensitive, name ≥3
* chars), longest-name-first. Used to seed contextual scoring from a query. */
findEntitiesInText(text: string, limit = 12): number[] {
try {
const rows = this.db.getDatabase().prepare(
"SELECT id FROM knowledge_entities WHERE valid_to IS NULL AND length(name) >= 3 AND instr(lower(?), lower(name)) > 0 ORDER BY length(name) DESC LIMIT ?"
).all(text, limit) as Array<{ id: number }>;
return rows.map(r => r.id);
} catch { return []; }
}
/** BFS from seed entities (≤maxDepth) and map the reached entities to the
* frames they were extracted from via the kg_entity_frames bridge, returning
* frameId → shortest graph distance. Empty when the bridge is unpopulated. */
frameDistancesFromEntities(seedEntityIds: number[], maxDepth = 3): Map<number, number> {
const frameDist = new Map<number, number>();
if (seedEntityIds.length === 0) return frameDist;
const entityDist = new Map<number, number>();
for (const seed of seedEntityIds) {
entityDist.set(seed, 0); // the seed entity itself is distance 0
for (const [eid, d] of this.bfsDistances(seed, maxDepth)) {
const prev = entityDist.get(eid);
if (prev === undefined || d < prev) entityDist.set(eid, d);
}
}
try {
const stmt = this.db.getDatabase().prepare(
'SELECT frame_id FROM kg_entity_frames WHERE entity_id = ?'
);
for (const [eid, d] of entityDist) {
for (const { frame_id } of stmt.all(eid) as Array<{ frame_id: number }>) {
const prev = frameDist.get(frame_id);
if (prev === undefined || d < prev) frameDist.set(frame_id, d);
}
}
} catch { return new Map(); } // bridge absent — no contextual signal
return frameDist;
}
// --- Validation ---
private validateEntityProperties(entityType: string, properties: Record<string, unknown>): void {
if (!this.schema || !this.schema[entityType]) return;
const typeSchema = this.schema[entityType];
for (const required of typeSchema.required) {
if (!(required in properties)) {
throw new Error(`Validation failed: required property '${required}' missing for type '${entityType}'`);
}
}
}
private validateRelation(sourceId: number, relationType: string): void {
if (!this.schema) return;
const source = this.getEntity(sourceId);
if (!source) return;
const typeSchema = this.schema[source.entity_type];
if (!typeSchema) return;
if (!typeSchema.allowedRelations.includes(relationType)) {
throw new Error(`Validation failed: relation '${relationType}' not allowed for type '${source.entity_type}'`);
}
}
}

View File

@@ -0,0 +1,93 @@
/**
* LiteLLM-backed embedder that calls the /embeddings endpoint.
* Falls back to a deterministic mock (text→Float32Array hash) on API error
* when `fallbackToMock` is enabled.
*/
import type { Embedder } from './embeddings.js';
export interface LiteLLMEmbedderConfig {
litellmUrl: string;
litellmApiKey?: string;
model?: string;
dimensions?: number;
/** Custom fetch implementation (for testing). */
fetch?: typeof globalThis.fetch;
/** If true, falls back to a deterministic mock on API error instead of throwing. */
fallbackToMock?: boolean;
}
function mockEmbed(text: string, dims: number): Float32Array {
const arr = new Float32Array(dims);
const bytes = new TextEncoder().encode(text);
for (let i = 0; i < Math.min(bytes.length, dims); i++) {
arr[i] = (bytes[i] - 128) / 128;
}
return arr;
}
export function createLiteLLMEmbedder(config: LiteLLMEmbedderConfig): Embedder {
const {
litellmUrl,
litellmApiKey,
model = 'text-embedding',
dimensions = 1024,
fetch: fetchFn = globalThis.fetch,
fallbackToMock = false,
} = config;
// Normalise base URL — strip trailing /v1 if present, we add it ourselves
const baseUrl = litellmUrl.replace(/\/v1\/?$/, '');
const url = `${baseUrl}/v1/embeddings`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (litellmApiKey) {
headers['Authorization'] = `Bearer ${litellmApiKey}`;
}
async function callApi(input: string | string[]): Promise<Float32Array[]> {
const body = JSON.stringify({ model, input });
let response: Response;
try {
response = await fetchFn(url, { method: 'POST', headers, body });
} catch (err) {
if (fallbackToMock) {
const texts = Array.isArray(input) ? input : [input];
return texts.map((t) => mockEmbed(t, dimensions));
}
throw err;
}
if (!response.ok) {
if (fallbackToMock) {
const texts = Array.isArray(input) ? input : [input];
return texts.map((t) => mockEmbed(t, dimensions));
}
const text = await response.text();
throw new Error(`LiteLLM embeddings error (${response.status}): ${text}`);
}
const json = (await response.json()) as {
data: Array<{ embedding: number[] }>;
};
return json.data.map((d) => new Float32Array(d.embedding));
}
return {
dimensions,
async embed(text: string): Promise<Float32Array> {
const results = await callApi(text);
return results[0];
},
async embedBatch(texts: string[]): Promise<Float32Array[]> {
if (texts.length === 0) return [];
return callApi(texts);
},
};
}

View File

@@ -0,0 +1,58 @@
/**
* Ollama-backed embedder — calls local Ollama server for embeddings.
* Power user option for users who have Ollama installed.
*/
import type { Embedder } from './embeddings.js';
import { normalizeDimensions } from './inprocess-embedder.js';
export interface OllamaEmbedderConfig {
baseUrl?: string;
model?: string;
targetDimensions?: number;
}
export function createOllamaEmbedder(config?: Partial<OllamaEmbedderConfig>): Embedder {
const baseUrl = config?.baseUrl ?? 'http://localhost:11434';
const model = config?.model ?? 'nomic-embed-text';
const targetDims = config?.targetDimensions ?? 1024;
const url = `${baseUrl}/api/embed`;
async function callOllama(input: string | string[]): Promise<Float32Array[]> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, input }),
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Ollama embeddings error (${response.status}): ${text}`);
}
const json = await response.json() as { embeddings: number[][] };
return json.embeddings.map(e => normalizeDimensions(new Float32Array(e), targetDims));
} finally {
clearTimeout(timeout);
}
}
return {
dimensions: targetDims,
async embed(text: string): Promise<Float32Array> {
const results = await callOllama(text);
return results[0];
},
async embedBatch(texts: string[]): Promise<Float32Array[]> {
if (texts.length === 0) return [];
return callOllama(texts);
},
};
}

View File

@@ -0,0 +1,58 @@
export interface EntitySchema {
required: string[];
optional: string[];
}
export class Ontology {
private schemas = new Map<string, EntitySchema>();
define(type: string, schema: EntitySchema): void {
this.schemas.set(type, schema);
}
getSchema(type: string): EntitySchema | undefined {
return this.schemas.get(type);
}
hasType(type: string): boolean {
return this.schemas.has(type);
}
getTypes(): string[] {
return Array.from(this.schemas.keys());
}
}
export interface ValidationResult {
valid: boolean;
issues: string[];
}
export function validateEntity(
ontology: Ontology,
entity: { type: string; properties: Record<string, unknown> },
): ValidationResult {
const issues: string[] = [];
const schema = ontology.getSchema(entity.type);
if (!schema) {
return { valid: false, issues: [`Unknown entity type: ${entity.type}`] };
}
// Check required properties
for (const prop of schema.required) {
if (!(prop in entity.properties)) {
issues.push(`Missing required property: ${prop}`);
}
}
// Check for unknown properties
const known = new Set([...schema.required, ...schema.optional]);
for (const prop of Object.keys(entity.properties)) {
if (!known.has(prop)) {
issues.push(`Unknown property: ${prop}`);
}
}
return { valid: issues.length === 0, issues };
}

View File

@@ -0,0 +1,113 @@
/**
* parse-date-window.ts — deterministic query-side temporal-constraint parser
* (W4.1b, production port of the benchmark-proven Wave-3.1 date-window lane;
* W4-PRODUCTION-PORT-PLAN-2026-06-11.md component #3, MRAG arXiv:2412.15540
* pattern).
*
* When a query names an explicit period — "the last week of October 2023",
* "early June 2023", "on 9 August 2023", "May 2023", "in 2022" — return a
* `[since..until]` window (date-only `YYYY-MM-DD` bounds, inclusive) plus a
* human label. Callers pass the window to HybridSearch's since/until filter;
* the label feeds the future Events-during-X render section (component #6).
*
* Deterministic regex date math only — no LLM call, no new index. Returns
* null when the query carries no explicit period (relative phrases like
* "last week" / "two months ago" are resolution work for the WRITE side —
* resolve-relative-date.ts — not query windowing).
*
* Regex shapes are byte-equivalent to the benchmark parser validated on the
* full N=1540 LoCoMo run (wave3a); only types/docs differ.
*
* OSS-clean: pure date arithmetic, no vault/evolution/compliance deps.
*/
/** Inclusive date-only window parsed from an explicit period in a query. */
export interface DateWindow {
/** Inclusive lower bound, `YYYY-MM-DD`. */
since: string;
/** Inclusive upper bound, `YYYY-MM-DD`. */
until: string;
/** Human-readable label of the matched period (for render sections). */
label: string;
}
const MONTHS: Record<string, number> = {
january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
};
const MONTH_RE = '(january|february|march|april|may|june|july|august|september|october|november|december)';
function lastDayOfMonth(y: number, m: number): number {
return new Date(Date.UTC(y, m, 0)).getUTCDate();
}
function isoOf(y: number, m: number, d: number): string {
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
/**
* Parse an explicit-period temporal constraint out of a query. Returns null
* when no explicit period is named.
*/
export function parseDateWindow(query: string): DateWindow | null {
const t = String(query).toLowerCase();
// "first|second|third|fourth|last week of <month> <year>"
let m = t.match(new RegExp(`(first|second|third|fourth|last)\\s+week\\s+of\\s+${MONTH_RE}\\s+(\\d{4})`));
if (m) {
const y = parseInt(m[3], 10), mo = MONTHS[m[2]];
const last = lastDayOfMonth(y, mo);
const ranges: Record<string, [number, number]> = {
first: [1, 7], second: [8, 14], third: [15, 21], fourth: [22, 28],
last: [Math.max(1, last - 6), last],
};
const [d1, d2] = ranges[m[1]];
return { since: isoOf(y, mo, d1), until: isoOf(y, mo, Math.min(d2, last)), label: `the ${m[1]} week of ${m[2]} ${y}` };
}
// "early|mid|late <month> <year>"
m = t.match(new RegExp(`(early|mid|late)\\s+${MONTH_RE}\\s+(\\d{4})`));
if (m) {
const y = parseInt(m[3], 10), mo = MONTHS[m[2]];
const last = lastDayOfMonth(y, mo);
const ranges: Record<string, [number, number]> = { early: [1, 10], mid: [11, 20], late: [21, last] };
const [d1, d2] = ranges[m[1]];
return { since: isoOf(y, mo, d1), until: isoOf(y, mo, d2), label: `${m[1]} ${m[2]} ${y}` };
}
// "<day> <month> <year>" or "<month> <day>, <year>" → exact-day ±2 buffer
m = t.match(new RegExp(`\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+(?:of\\s+)?${MONTH_RE},?\\s+(\\d{4})`)) ||
t.match(new RegExp(`${MONTH_RE}\\s+(\\d{1,2})(?:st|nd|rd|th)?,?\\s+(\\d{4})`));
if (m) {
const isDayFirst = /^\d/.test(m[1]);
const day = parseInt(isDayFirst ? m[1] : m[2], 10);
const mo = MONTHS[isDayFirst ? m[2] : m[1]];
const y = parseInt(m[3], 10);
if (mo && day >= 1 && day <= 31) {
const center = Date.UTC(y, mo - 1, day);
const lo = new Date(center - 2 * 86400000), hi = new Date(center + 2 * 86400000);
return {
since: lo.toISOString().slice(0, 10),
until: hi.toISOString().slice(0, 10),
label: `${day} ${isDayFirst ? m[2] : m[1]} ${y}`,
};
}
}
// "<month> <year>" → whole month
m = t.match(new RegExp(`${MONTH_RE}\\s+(\\d{4})`));
if (m) {
const y = parseInt(m[2], 10), mo = MONTHS[m[1]];
return { since: isoOf(y, mo, 1), until: isoOf(y, mo, lastDayOfMonth(y, mo)), label: `${m[1]} ${y}` };
}
// bare "in|during <year>" → whole year ('in'/'during' required so years
// inside names/ids don't window the query)
m = t.match(/\b(?:in|during)\s+(20\d{2})\b/);
if (m) {
const y = m[1];
return { since: `${y}-01-01`, until: `${y}-12-31`, label: y };
}
return null;
}

View File

@@ -0,0 +1,305 @@
/**
* raw-archive.ts — #7 Verbatim Provenance Archive (2026-06-30).
*
* Append-only, immutable store of the FULL verbatim source of each harvested
* item. Distilled/imported frames link back via memory_frames.metadata.archiveUid;
* reconstructSource(frameId) resolves that link for audit / EU-AI-Act reconstruction.
*
* NOT part of the retrieval corpus (no FTS/vec, never fed to an LLM) — so unlike
* raw-turns (which DROPS injection payloads because they feed recall), this store
* keeps flagged content verbatim and records the flag. Idempotent on content sha256.
* Append-only is enforced by DDL triggers; inserts use INSERT OR IGNORE (OR REPLACE
* would DELETE+INSERT and trip the no-delete trigger).
*/
import { createHash, randomBytes } from 'node:crypto';
import type { MindDB } from './db.js';
import { SuppressionStore } from './suppression.js';
import { scanForInjection } from '../injection-scanner.js';
export interface ArchiveInput {
source: string;
sourceRef?: string;
title?: string;
content: string;
sourceTimestamp?: string;
}
export interface RawArchiveRow {
id: number;
archive_uid: string;
source: string;
source_ref: string | null;
title: string | null;
content: string;
content_sha256: string;
injection_flagged: 0 | 1;
injection_flags: string;
source_timestamp: string | null;
created_at: string;
/** 1 when `content` was truncated to the size cap at append; 0 otherwise. */
truncated: 0 | 1;
/** Pre-truncation character count when truncated=1; NULL otherwise. */
original_length: number | null;
/** GDPR Art.17: set once when this row's content has been redacted; NULL otherwise. */
erased_at: string | null;
erased_reason: string | null;
}
/** Placed in `content` when a row is erased under GDPR Art.17 (right to erasure). */
export const RAW_ARCHIVE_REDACTION_MARKER = '[REDACTED — GDPR Art.17 erasure]';
/**
* Max characters of verbatim `content` stored per row. A larger item is stored
* truncated to this prefix (with truncated=1 + original_length recorded) so a
* single huge harvested export can't blow the append-only store. ~1M chars
* (≈14 MB depending on encoding). Overridable per-store via the RawArchive
* constructor (`{ maxContentChars }`).
*/
export const RAW_ARCHIVE_MAX_CONTENT_CHARS = 1_000_000;
/** sha256 hex over the raw, untouched content (NOT hashFrameContent — that strips/trims). */
export function hashRaw(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
/**
* Read all archive-uid links off a frame's parsed metadata, tolerating both the
* canonical array (`archiveUids: string[]`) and the legacy scalar (`archiveUid:
* string`). Returns the set-union (dedup, order: array first, then legacy scalar)
* as a fresh array — [] when neither is present. Never mutates the input.
*/
export function readArchiveUids(meta: Record<string, unknown>): string[] {
const out = new Set<string>();
if (Array.isArray(meta.archiveUids)) {
for (const u of meta.archiveUids) if (typeof u === 'string') out.add(u);
}
if (typeof meta.archiveUid === 'string') out.add(meta.archiveUid);
return [...out];
}
/**
* Return a NEW metadata object with `uid` added to the canonical `archiveUids`
* array, migrating any legacy scalar `archiveUid` into the array and dropping it.
* Idempotent (set-union) and immutable (never mutates the input).
*/
export function withArchiveUid(meta: Record<string, unknown>, uid: string): Record<string, unknown> {
const uids = new Set(readArchiveUids(meta));
uids.add(uid);
const { archiveUid: _legacy, ...rest } = meta;
return { ...rest, archiveUids: [...uids] };
}
export class RawArchive {
private db: MindDB;
private suppression: SuppressionStore;
private readonly maxContentChars: number;
constructor(db: MindDB, opts: { maxContentChars?: number } = {}) {
this.db = db;
this.suppression = new SuppressionStore(db);
this.maxContentChars = opts.maxContentChars ?? RAW_ARCHIVE_MAX_CONTENT_CHARS;
}
/** Idempotent append. INSERT OR IGNORE on the UNIQUE archive_uid makes a
* re-append a no-op. Injection-scans (4KB probe) but stores verbatim.
* #7 "sticky erasure": a subject on the erased-subject suppression list is NOT
* re-materialized — the append is skipped (created:false). This makes stickiness
* an INTRINSIC property of the archive (belt to the harvest loops' suspenders),
* so a direct caller can't resurrect an erased subject by re-appending. */
append(input: ArchiveInput): { archiveUid: string; created: boolean } {
const raw = this.db.getDatabase();
// archive_uid is PER-SOURCE: identical content from two different sources
// keeps two provenance rows, so each frame's link resolves to ITS own source
// (a content-only uid would collapse them and make reconstructSource return
// the wrong source). Re-importing the same item from the same source still
// collapses (idempotency). content_sha256 stays a content-only integrity
// anchor — verify the verbatim, or find identical content across sources.
const archiveUid = hashRaw(`${input.source}\x00${input.sourceRef ?? ''}\x00${input.content}`);
// #7 sticky erasure: skip re-materializing an erased subject (fail-closed).
if (this.suppression.isSuppressed(input.source, input.sourceRef ?? '')) {
return { archiveUid, created: false };
}
const contentSha = hashRaw(input.content);
// Size guard: cap a single row's stored blob so one giant harvested item can't
// blow the store. archive_uid + content_sha256 are already derived from the
// FULL content above (idempotency + integrity anchor unaffected); only the
// stored `content` column is truncated, flagged by truncated=1 + original_length.
const isTruncated = input.content.length > this.maxContentChars;
const storedContent = isTruncated ? input.content.slice(0, this.maxContentChars) : input.content;
// injection_flagged is a 4KB PROBE (same budget as the harvest pipeline's
// Pass 0) — advisory, NOT a full-content guarantee. Content is stored
// verbatim regardless (zero-loss); the archive is never fed to an LLM, and
// any consumer that surfaces it to a model MUST re-scan.
const scan = scanForInjection(input.content.slice(0, 4000), 'tool_output');
const result = raw.prepare(
`INSERT OR IGNORE INTO raw_archive
(archive_uid, source, source_ref, title, content, content_sha256,
injection_flagged, injection_flags, source_timestamp, truncated, original_length)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
archiveUid,
input.source,
input.sourceRef ?? null,
input.title ?? null,
storedContent,
contentSha,
scan.safe ? 0 : 1,
scan.safe ? '' : scan.flags.join(','),
input.sourceTimestamp ?? null,
isTruncated ? 1 : 0,
isTruncated ? input.content.length : null,
);
return { archiveUid, created: result.changes > 0 };
}
getByUid(archiveUid: string): RawArchiveRow | undefined {
return this.db.getDatabase()
.prepare('SELECT * FROM raw_archive WHERE archive_uid = ?')
.get(archiveUid) as RawArchiveRow | undefined;
}
/** Look up a row by its stable numeric id — the audit handle that survives an
* Art.17 erasure's archive_uid rotation (unlike getByUid, which can no longer
* find an erased row by its original content-derived uid). Undefined if absent. */
getById(id: number): RawArchiveRow | undefined {
return this.db.getDatabase()
.prepare('SELECT * FROM raw_archive WHERE id = ?')
.get(id) as RawArchiveRow | undefined;
}
/**
* Resolve every archive-uid link on a frame → its archive rows. Accepts both
* the canonical `archiveUids: string[]` and the legacy scalar `archiveUid`
* (via readArchiveUids). Returns [] when no frame / no metadata / malformed
* metadata / no resolvable rows. Order preserved; unresolved uids dropped.
*/
reconstructSource(frameId: number): RawArchiveRow[] {
const row = this.db.getDatabase()
.prepare('SELECT metadata FROM memory_frames WHERE id = ?')
.get(frameId) as { metadata?: string } | undefined;
if (!row?.metadata) return [];
let meta: Record<string, unknown>;
try { meta = JSON.parse(row.metadata) as Record<string, unknown>; }
catch { return []; }
if (!meta || typeof meta !== 'object') return [];
const rows: RawArchiveRow[] = [];
for (const uid of readArchiveUids(meta)) {
const r = this.getByUid(uid);
if (r) rows.push(r);
}
return rows;
}
list(opts: { limit?: number; offset?: number; source?: string } = {}): RawArchiveRow[] {
const { limit = 100, offset = 0, source } = opts;
if (source) {
return this.db.getDatabase().prepare(
'SELECT * FROM raw_archive WHERE source = ? ORDER BY created_at DESC LIMIT ? OFFSET ?'
).all(source, limit, offset) as RawArchiveRow[];
}
return this.db.getDatabase().prepare(
'SELECT * FROM raw_archive ORDER BY created_at DESC LIMIT ? OFFSET ?'
).all(limit, offset) as RawArchiveRow[];
}
count(): number {
return (this.db.getDatabase().prepare('SELECT COUNT(*) as c FROM raw_archive').get() as { c: number }).c;
}
/**
* Reclaim disk pages freed by in-place Art.17 redaction. raw_archive is
* append-only (DELETE is trigger-blocked) and erase() only overwrites `content`
* with a short marker IN PLACE, so the freed bytes stay allocated to the file
* until a VACUUM rewrites it. VACUUM compacts the ENTIRE .mind file and does NOT
* fire the no-delete trigger (that guards DML, not the internal rebuild), so the
* append-only invariant survives untouched.
*
* This is an EXPLICIT maintenance/governance action: never auto-invoked, and it
* never deletes user data — it only compacts already-freed space. Must run
* OUTSIDE any transaction (SQLite forbids VACUUM inside one) and with no other
* statement in progress on this connection. Returns bytes reclaimed (>= 0).
*/
reclaim(): number {
const raw = this.db.getDatabase();
const fileBytes = (): number =>
(raw.pragma('page_count', { simple: true }) as number) *
(raw.pragma('page_size', { simple: true }) as number);
const before = fileBytes();
raw.exec('VACUUM');
const after = fileBytes();
return Math.max(0, before - after);
}
/**
* GDPR Art.17 right-to-erasure. Redacts ONE archive row in place: content ->
* marker, content_sha256 -> '', title -> NULL, ROTATES archive_uid to an opaque
* random id, and stamps erased_at/erased_reason. The audit skeleton (id/source/
* source_ref/timestamps/injection flags) is frozen — the record that an item
* existed and was erased survives — but every CONTENT-DERIVED value is gone. The
* refined raw_archive_no_update trigger permits exactly this one canonical outcome
* (content=marker, content_sha256='', title NULL, archive_uid rotated ≠ OLD) — raw
* SQL cannot use the erase path to forge audit content. Idempotent: the uid is
* rotated, so a second call on the ORIGINAL uid matches nothing and returns false.
*
* WHY ROTATE archive_uid: it was sha256(source ∥ sourceRef ∥ content), so freezing
* it left a re-identification vector — for low-entropy content an auditor holding
* the "erased" DB could brute-force the original by hashing candidates. A fresh
* 256-bit RANDOM uid (not derived from OLD) severs that linkage; the row stays
* findable by its frozen id (getById), not by the original uid.
*
* RETAINED-SKELETON RESIDUAL (founder-ratified — keep skeleton over max erasure):
* - source_ref is preserved verbatim and MAY carry PII (thread-id/filename/URL);
* harvest adapters should avoid placing raw identifiers there.
* CONSEQUENCE — re-harvest is no longer suppressed: rotating the uid frees append()'s
* content→uid dedup key, so re-importing an already-erased source re-materializes its
* archive row (its searchable summary/raw-turns/KG already re-materialize on re-import
* today, independent of this — a pre-existing gap). "Sticky" erasure across re-import is
* a separate cross-path compliance feature (an erased-subject suppression list); a
* content-keyed tombstone is NOT the fix — it would reintroduce the very content-derived
* re-identification vector this rotation removes.
* THREAT MODEL: append-only + erase-once are trigger-enforced — tamper-EVIDENT
* against ordinary INSERT/UPDATE/DELETE, NOT tamper-proof against a caller with DDL
* rights (DROP TRIGGER/TABLE bypasses it).
*/
erase(archiveUid: string, reason: string): boolean {
// Opaque, RANDOM replacement uid (not derived from OLD/content). 256-bit random
// → collision-safe under UNIQUE and guaranteed ≠ OLD, which the trigger requires.
const opaqueUid = `erased:${randomBytes(32).toString('hex')}`;
const res = this.db.getDatabase().prepare(
`UPDATE raw_archive
SET archive_uid = ?, content = ?, content_sha256 = '', title = NULL,
erased_at = datetime('now'), erased_reason = ?
WHERE archive_uid = ? AND erased_at IS NULL`
).run(opaqueUid, RAW_ARCHIVE_REDACTION_MARKER, reason, archiveUid);
return res.changes > 0;
}
/**
* Redact every archive row a frame links to (via its metadata.archiveUids, incl.
* the legacy scalar). Returns the count of rows newly redacted (already-erased
* rows are skipped).
*
* SCOPE — provenance rows ONLY. This is NOT a complete GDPR Art.17 data-subject
* erasure: the DERIVED memory_frames (whose summaries quote the source) and their
* FTS / vector / KnowledgeGraph projections still hold the PII and remain
* searchable and recall-able. A full DSAR flow MUST pair this with a frame + index
* + KG erasure. It also reaches only rows linked from THIS frame — orphan rows,
* other frames, and same-source_ref rows are not swept (a subject-level sweep by
* source_ref is a follow-up).
*/
eraseByFrame(frameId: number, reason: string): number {
const row = this.db.getDatabase()
.prepare('SELECT metadata FROM memory_frames WHERE id = ?')
.get(frameId) as { metadata?: string } | undefined;
if (!row?.metadata) return 0;
let meta: Record<string, unknown>;
try { meta = JSON.parse(row.metadata) as Record<string, unknown>; }
catch { return 0; }
if (!meta || typeof meta !== 'object') return 0;
let erased = 0;
for (const uid of readArchiveUids(meta)) {
if (this.erase(uid, reason)) erased++;
}
return erased;
}
}

View File

@@ -0,0 +1,187 @@
/**
* raw-detail-lane.ts — W4.6 RAWDETAIL escalation lane (recall side).
*
* Benchmark-proven retrieval over verbatim dialogue turns (LoCoMo W3.3:
* single-hop 92.75 +4.52 z=3.16; W3.4 ablation: this lane is the delivery
* mechanism, +2.40 z=1.95 on top of caption parity). Pipeline:
*
* pool (date-window filtered turns, else FTS BM25 top-60)
* → cross-encoder rerank, keep top K (default 6)
* → expand ±1 dialogue neighbors (gold often sits ADJACENT to the top
* CE hit — Q→A conversational adjacency)
* → dedup vs already-rendered frames, chronological render order
*
* Turns are stored by `harvest/raw-turns.ts` with explicit
* `conv:<key> turn:<n>` header keys — production frames interleave across
* sources, so adjacency is looked up per-conversation by turn index, not
* by row-id ordering (the benchmark's trick that does not transfer).
*
* The lane REQUIRES a reranker (the CE step is what makes the pool pay —
* P5 anti-goal: no relevance-only episodic injection without the CE floor);
* callers skip the lane entirely when no reranker is available.
*/
import type { Database as DatabaseType } from 'better-sqlite3';
import type { Reranker } from './inprocess-reranker.js';
import { buildFtsOrQuery } from './fts-sanitize.js';
import { MIND_RAWTURN_PREFIX, parseRawTurnHeader } from '../harvest/raw-turns.js';
/** CE survivors kept before neighbor expansion (benchmark RAWDETAIL_K). */
export const RAW_DETAIL_K = 6;
/** FTS pool size (benchmark: BM25 top-60). */
const FTS_POOL_LIMIT = 60;
/** Window pools larger than this get FTS-intersected (benchmark: 120). */
const WINDOW_POOL_MAX = 120;
export interface RawTurnHit {
id: number;
content: string;
/** Provenance class (memory_frames.source) — carried for the auto_recall
* provenance breakdown (raw-turn frames are harvest imports). */
source: string;
created_at: string;
conv: string;
turn: number;
speaker: string;
}
export interface RawDetailLaneOptions {
/** CE survivors before neighbor expansion (default RAW_DETAIL_K = 6). */
k?: number;
/** Explicit-period window from the query (recallMemory's parseDateWindow). */
window?: { since: string; until: string } | null;
/** Frame ids already rendered by other lanes — excluded from the result. */
excludeIds?: Set<number>;
}
type FrameRow = { id: number; content: string; source: string; created_at: string };
/** Body of a raw-turn frame (everything after the header line). */
export function rawTurnBody(content: string): string {
const nl = content.indexOf('\n');
return nl >= 0 ? content.slice(nl + 1).trim() : content;
}
/** FTS BM25 top-N restricted to raw-turn frames. Returns [] on FTS parse errors.
* OR-query sanitizer shared with HybridSearch.keywordSearch (S1, fts-sanitize.ts). */
function ftsPool(db: DatabaseType, query: string, limit: number): FrameRow[] {
const match = buildFtsOrQuery(query);
if (!match) return [];
try {
return db.prepare(
`SELECT mf.id, mf.content, mf.source, mf.created_at
FROM memory_frames_fts fts
JOIN memory_frames mf ON mf.id = fts.rowid
WHERE fts.content MATCH ? AND mf.content LIKE '${MIND_RAWTURN_PREFIX} %'
ORDER BY rank
LIMIT ?`
).all(match, limit) as FrameRow[];
} catch {
return [];
}
}
/** All raw-turn frames whose created_at date falls inside [since..until]. */
function windowPool(db: DatabaseType, since: string, until: string): FrameRow[] {
return db.prepare(
`SELECT id, content, source, created_at FROM memory_frames
WHERE content LIKE '${MIND_RAWTURN_PREFIX} %'
AND substr(created_at, 1, 10) >= ? AND substr(created_at, 1, 10) <= ?
ORDER BY id ASC`
).all(since, until) as FrameRow[];
}
/** Fetch every stored turn of one conversation, keyed by turn index. */
function convTurnMap(db: DatabaseType, conv: string): Map<number, FrameRow> {
// conv keys are sanitized to [A-Za-z0-9_-] at write time — no LIKE
// metacharacters can appear, so direct interpolation into the pattern
// parameter (still a BOUND parameter) is safe.
const rows = db.prepare(
`SELECT id, content, source, created_at FROM memory_frames
WHERE content LIKE ?`
).all(`${MIND_RAWTURN_PREFIX} conv:${conv} %`) as FrameRow[];
const map = new Map<number, FrameRow>();
for (const r of rows) {
const h = parseRawTurnHeader(r.content);
if (h && h.conv === conv) map.set(h.turn, r);
}
return map;
}
/**
* Run the RAWDETAIL lane over one mind's raw-turn frames.
* Returns CE-top turns ±1 dialogue neighbors, chronologically ordered,
* excluding `excludeIds`. Empty array when no turns / no pool / no signal.
*/
export async function fetchRawDetailLane(
db: DatabaseType,
query: string,
reranker: Reranker,
opts: RawDetailLaneOptions = {},
): Promise<RawTurnHit[]> {
const k = opts.k ?? RAW_DETAIL_K;
const excludeIds = opts.excludeIds ?? new Set<number>();
// ── Pool ──────────────────────────────────────────────────────────────
let pool: FrameRow[] = [];
if (opts.window) {
pool = windowPool(db, opts.window.since, opts.window.until);
if (pool.length > WINDOW_POOL_MAX) {
// Benchmark behavior: oversized window → intersect with FTS top-60;
// FTS-empty falls back to the first WINDOW_POOL_MAX turns.
const winIds = new Set(pool.map(t => t.id));
const fts = ftsPool(db, query, 200).filter(t => winIds.has(t.id)).slice(0, FTS_POOL_LIMIT);
pool = fts.length > 0 ? fts : pool.slice(0, WINDOW_POOL_MAX);
}
// Window matched nothing (period off-corpus) → fall through to FTS so
// the lane never LOSES recall, only sharpens it.
if (pool.length === 0) pool = ftsPool(db, query, FTS_POOL_LIMIT);
} else {
pool = ftsPool(db, query, FTS_POOL_LIMIT);
}
if (pool.length === 0) return [];
// ── Cross-encoder rerank → top K ─────────────────────────────────────
const docs = pool.map(t => rawTurnBody(t.content));
let scores: number[];
try {
scores = await reranker.scoreBatch(query, docs);
} catch {
return []; // soft-fail: a broken reranker never kills recall
}
const top = pool
.map((t, i) => ({ t, s: scores[i] ?? -Infinity }))
.sort((a, b) => b.s - a.s)
.slice(0, k)
.map(x => x.t);
// ── ±1 dialogue-neighbor expansion ───────────────────────────────────
const keptById = new Map<number, FrameRow>();
const convMaps = new Map<string, Map<number, FrameRow>>();
for (const t of top) {
const h = parseRawTurnHeader(t.content);
if (!h) continue;
let turns = convMaps.get(h.conv);
if (!turns) {
turns = convTurnMap(db, h.conv);
convMaps.set(h.conv, turns);
}
for (const d of [-1, 0, 1]) {
const n = turns.get(h.turn + d);
if (n && !excludeIds.has(n.id)) keptById.set(n.id, n);
}
}
// ── Chronological render order (date, then conv, then turn) ─────────
const hits: RawTurnHit[] = [];
for (const r of keptById.values()) {
const h = parseRawTurnHeader(r.content);
if (!h) continue;
hits.push({ ...r, conv: h.conv, turn: h.turn, speaker: h.speaker });
}
hits.sort((a, b) =>
String(a.created_at).localeCompare(String(b.created_at))
|| a.conv.localeCompare(b.conv)
|| a.turn - b.turn);
return hits;
}

View File

@@ -0,0 +1,100 @@
/**
* recall-context.ts — shared renderer for surfacing temporal information in
* recalled-memory blocks. Single source of truth imported by both the Waggle
* agent (production) and the benchmark harness, so the injected-memory format
* never drifts between the two.
*
* Scope (Temporal Substrate Fix, Phase 1 — "surface time", additive only):
* - Prefix each retrieved snippet with its own compact `[YYYY-MM-DD]` date.
* - Open the rendered memory block with one anchor line giving the most-recent
* memory date, so the model has a concrete "now" to resolve relative time
* expressions against.
* - Export `TEMPORAL_GUIDANCE`, the prompt fragment that tells the model to
* treat those timestamps as the anchor for relative-time arithmetic.
*
* Distilled "Memory Facts" (cross-session syntheses) are intentionally NOT
* dated here — they have no single reliable date. Phase 2 handles them.
*
* OSS-clean: pure string formatting, no vault/evolution/compliance deps.
*/
/**
* Prompt fragment wired into the memory-recall injection path (NOT the global
* system prompt). Teaches the model to use the surfaced `[YYYY-MM-DD]` stamps
* as the anchor for resolving relative time expressions and conflicting facts.
*
* W4.1 upgrade (W4-PRODUCTION-PORT-PLAN-2026-06-11.md §1): replaced the original
* "nearest timestamp as anchor" phrasing with the benchmark-proven W1 wording —
* concrete relative-date arithmetic with worked examples (Memori instruction-5
* lineage, incl. the verified conv-26 "yesterday" failure case) plus the
* granularity-calibration clause (failure mining: 22 temporal fails emitted a
* confident exact ISO day 1-7 days off where a coarse answer was correct).
* Temporal was the #1 LoCoMo lever (80.06 → 84.7 across W1-W3.1).
* Production-safe subset: no never-refuse clause (that was benchmark-cell
* policy only — conditional abstention stays).
*/
export const TEMPORAL_GUIDANCE =
"Memories and snippets are timestamped [YYYY-MM-DD]. Pay special attention to these " +
"timestamps to determine timing. If a question involves relative time references " +
"('last year', 'two months ago', 'yesterday', 'last week'), CALCULATE the actual date " +
"from the timestamp of the memory that mentions it. For example: a memory dated " +
"4 May 2022 that says 'went to India last year' means the trip was in 2021; a memory " +
"dated 8 May 2023 that says 'I went to the group yesterday' means the event was 7 May 2023. " +
"Always convert relative references to specific dates, months, or years using the " +
"memory's timestamp as the anchor, and ignore the relative phrase itself when answering. " +
"When the same fact appears at different times, the most recent version is correct. " +
"GRANULARITY: state an exact day ONLY when that exact date was explicitly stated or " +
"directly computed from an explicit relative reference; otherwise answer at the " +
"granularity you are confident in — 'early June 2023', 'the week before 9 August 2023', " +
"'August 2022'. A confidently wrong exact day is worse than a correct coarse answer. " +
"For 'how long / how many months' duration questions, give ONLY the final value " +
"(e.g. 'six months') — no intermediate dates, no reasoning steps.";
/** Anchor-line prefix for the most-recent rendered memory date. */
const REFERENCE_DATE_LABEL = 'Reference date (most recent memory):';
/**
* Slice an ISO-ish timestamp to its `YYYY-MM-DD` date prefix. Returns null when
* the value is missing or too short to carry a date.
*/
export function toDatePrefix(createdAt: string | null | undefined): string | null {
if (!createdAt || createdAt.length < 10) return null;
return createdAt.slice(0, 10);
}
/**
* Render a single retrieved snippet with its compact `[YYYY-MM-DD]` date prefix.
* `text` is the snippet text exactly as it would otherwise be rendered (the
* caller owns importance labels, truncation, etc.); this only prepends the date.
* Falls back to the bare text when the hit carries no usable timestamp.
*/
export function renderDatedSnippet(createdAt: string | null | undefined, text: string): string {
const date = toDatePrefix(createdAt);
return date ? `[${date}] ${text}` : text;
}
/**
* Compute the reference (anchor) date: the maximum `created_at` among the
* supplied timestamps, sliced to `YYYY-MM-DD`. Returns null when none carry a
* usable date (caller then omits the anchor line).
*/
export function referenceDate(createdAts: ReadonlyArray<string | null | undefined>): string | null {
let max: string | null = null;
for (const c of createdAts) {
const date = toDatePrefix(c);
if (date && (max === null || date > max)) max = date;
}
return max;
}
/**
* Build the single anchor line for a memory block, e.g.
* `Reference date (most recent memory): 2026-06-09`. Returns null when no date
* is available.
*/
export function renderReferenceDateLine(
createdAts: ReadonlyArray<string | null | undefined>,
): string | null {
const date = referenceDate(createdAts);
return date ? `${REFERENCE_DATE_LABEL} ${date}` : null;
}

View File

@@ -0,0 +1,168 @@
/**
* Index Reconciliation — repairs FTS5 and vector indexes for memory frames.
*
* If the process crashes between frame creation and FTS5/vector indexing,
* frames exist but aren't searchable. This function finds orphaned frames
* and re-indexes them. Designed to run as a periodic maintenance cron job.
*
* Idempotent: safe to run multiple times without side effects.
*/
import type { MindDB } from './db.js';
import type { Embedder } from './embeddings.js';
export interface ReconcileResult {
ftsFixed: number;
vecFixed: number;
}
/**
* Find frames missing from FTS5 and re-index them.
* Does NOT require an embedder — operates only on the FTS5 table.
*/
export function reconcileFtsIndex(db: MindDB): number {
const raw = db.getDatabase();
// Find frames that have no corresponding FTS5 entry.
// memory_frames_fts uses content_rowid='id', so rowid matches memory_frames.id.
const missingFts = raw.prepare(`
SELECT f.id, f.content FROM memory_frames f
WHERE f.id NOT IN (SELECT rowid FROM memory_frames_fts)
`).all() as { id: number; content: string }[];
if (missingFts.length === 0) return 0;
const insertFts = raw.prepare(
'INSERT INTO memory_frames_fts (rowid, content) VALUES (?, ?)',
);
const insertAll = raw.transaction(() => {
for (const row of missingFts) {
insertFts.run(row.id, row.content);
}
});
insertAll();
return missingFts.length;
}
/**
* Find frames missing from the vector index and re-index them.
* Requires an embedder to compute embeddings for the missing frames.
* Returns 0 if the vec table doesn't exist.
*/
export async function reconcileVecIndex(db: MindDB, embedder: Embedder): Promise<number> {
const raw = db.getDatabase();
// Check if vec table exists
const vecExists = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='memory_frames_vec'",
).get();
if (!vecExists) return 0;
const missingVec = raw.prepare(`
SELECT f.id, f.content FROM memory_frames f
WHERE f.id NOT IN (SELECT rowid FROM memory_frames_vec)
`).all() as { id: number; content: string }[];
if (missingVec.length === 0) return 0;
// Embed in batches to avoid memory pressure
const BATCH_SIZE = 50;
for (let i = 0; i < missingVec.length; i += BATCH_SIZE) {
const batch = missingVec.slice(i, i + BATCH_SIZE);
const contents = batch.map(r => r.content);
const embeddings = await embedder.embedBatch(contents);
const insertBatch = raw.transaction(() => {
for (let j = 0; j < batch.length; j++) {
const id = Math.trunc(batch[j].id);
const blob = new Uint8Array(
embeddings[j].buffer,
embeddings[j].byteOffset,
embeddings[j].byteLength,
);
raw.prepare(
`INSERT INTO memory_frames_vec (rowid, embedding) VALUES (${id}, ?)`,
).run(blob);
}
});
insertBatch();
}
return missingVec.length;
}
/**
* 9b: Remove orphan vector entries — vectors whose frame has been deleted.
* Returns the number of orphan entries removed.
*/
export function cleanOrphanVectors(db: MindDB): number {
const raw = db.getDatabase();
// Check if vec table exists
const vecExists = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='memory_frames_vec'",
).get();
if (!vecExists) return 0;
// Find vec entries with no corresponding frame
const orphans = raw.prepare(`
SELECT v.rowid FROM memory_frames_vec v
WHERE v.rowid NOT IN (SELECT id FROM memory_frames)
`).all() as { rowid: number }[];
if (orphans.length === 0) return 0;
const deleteTx = raw.transaction(() => {
for (const { rowid } of orphans) {
raw.prepare('DELETE FROM memory_frames_vec WHERE rowid = ?').run(rowid);
}
});
deleteTx();
return orphans.length;
}
/**
* Remove orphan FTS entries — FTS entries whose frame has been deleted.
*/
export function cleanOrphanFts(db: MindDB): number {
const raw = db.getDatabase();
const orphans = raw.prepare(`
SELECT rowid FROM memory_frames_fts
WHERE rowid NOT IN (SELECT id FROM memory_frames)
`).all() as { rowid: number }[];
if (orphans.length === 0) return 0;
const deleteTx = raw.transaction(() => {
for (const { rowid } of orphans) {
raw.prepare('DELETE FROM memory_frames_fts WHERE rowid = ?').run(rowid);
}
});
deleteTx();
return orphans.length;
}
/**
* Full reconciliation: repairs both FTS5 and vector indexes,
* and cleans up orphan entries.
* If no embedder is provided, only FTS5 is reconciled.
*/
export async function reconcileIndexes(
db: MindDB,
embedder?: Embedder,
): Promise<ReconcileResult> {
// Fix missing entries
const ftsFixed = reconcileFtsIndex(db);
const vecFixed = embedder ? await reconcileVecIndex(db, embedder) : 0;
// Clean orphans
cleanOrphanFts(db);
cleanOrphanVectors(db);
return { ftsFixed, vecFixed };
}

View File

@@ -0,0 +1,131 @@
/**
* resolve-relative-date — write-time relative-date resolution for the memory substrate.
*
* WHY THIS EXISTS (benchmark-validated, LoCoMo head-to-head vs Memori, 2026-06-10):
* A conversation utterance's *discussion date* (when it was said) is not the same as
* the *event date* (when the thing happened). "I went to the group yesterday" said on
* 2023-05-08 describes an event on 2023-05-07. Storing the discussion date makes
* "when did X happen?" questions wrong by the relative-reference delta — the verified
* root cause of our temporal-category gap. Resolving the relative reference against the
* source date at WRITE time bakes the correct event date into the frame's timestamp,
* which lifted the LoCoMo temporal category to parity with Memori (80.06 vs 80.37).
* See benchmarks/results/memori-phase22-RESULT.md (Phase 4).
*
* This is the deterministic, dependency-free production counterpart of the benchmark's
* LLM extraction pass (hive-mind-test scripts/locomo/33-distill-episodic.mjs). It covers
* the dominant cue patterns the calibration surfaced ("yesterday", "last week", "N days
* ago", "last <weekday>", "last year") without a per-frame LLM call. Pure + side-effect
* free; the caller decides whether to use the resolved date.
*/
/** A resolved relative-date hit: the matched cue phrase + the absolute ISO date (YYYY-MM-DD). */
export interface ResolvedDate {
/** The relative cue that matched, e.g. "yesterday", "last week", "3 months ago". */
cue: string;
/** The resolved absolute date as YYYY-MM-DD. */
iso: string;
}
const ISO_DATE = /^\d{4}-\d{2}-\d{2}/;
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const;
/** Parse an ISO-ish reference date (date or datetime) into a UTC Date at midnight. Null if unparseable. */
function parseReference(referenceDate: string | null | undefined): Date | null {
if (!referenceDate || !ISO_DATE.test(referenceDate)) return null;
const [y, m, d] = referenceDate.slice(0, 10).split('-').map(Number);
if (!y || !m || !d) return null;
const dt = new Date(Date.UTC(y, m - 1, d));
return Number.isNaN(dt.getTime()) ? null : dt;
}
/** Format a UTC Date as YYYY-MM-DD. */
function toIso(dt: Date): string {
return dt.toISOString().slice(0, 10);
}
function addDays(dt: Date, n: number): Date {
const out = new Date(dt.getTime());
out.setUTCDate(out.getUTCDate() + n);
return out;
}
function addMonths(dt: Date, n: number): Date {
const out = new Date(dt.getTime());
const targetMonthDay = out.getUTCDate();
out.setUTCDate(1);
out.setUTCMonth(out.getUTCMonth() + n);
// Clamp to the last valid day of the resulting month (e.g. Jan 31 1mo → Dec 31, not overflow).
const lastDay = new Date(Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)).getUTCDate();
out.setUTCDate(Math.min(targetMonthDay, lastDay));
return out;
}
function addYears(dt: Date, n: number): Date {
const out = new Date(dt.getTime());
out.setUTCFullYear(out.getUTCFullYear() + n);
return out;
}
/** Most recent occurrence of `weekday` strictly before the reference date (the "last Friday" sense). */
function lastWeekday(dt: Date, weekday: number): Date {
let delta = dt.getUTCDay() - weekday;
if (delta <= 0) delta += 7; // strictly before → at least 1 day back
return addDays(dt, -delta);
}
/**
* Resolve the first relative-time cue in `text` against `referenceDate` (the source/
* conversation date) into an absolute YYYY-MM-DD. Returns null when there is no
* recognised cue or the reference date is unusable — the caller then keeps the
* reference date as-is.
*
* Patterns are tried most-specific first so "the day before yesterday" wins over
* "yesterday", and explicit "N units ago" wins over the bare "last unit".
*/
export function resolveRelativeDate(
text: string,
referenceDate: string | null | undefined,
): ResolvedDate | null {
const ref = parseReference(referenceDate);
if (!ref || !text) return null;
const t = text.toLowerCase();
// Most-specific day offsets first.
if (/\bday before yesterday\b/.test(t)) return { cue: 'the day before yesterday', iso: toIso(addDays(ref, -2)) };
if (/\byesterday\b/.test(t)) return { cue: 'yesterday', iso: toIso(addDays(ref, -1)) };
// "N days/weeks/months/years ago" (explicit count).
const ago = t.match(/\b(\d{1,3})\s+(day|week|month|year)s?\s+ago\b/);
if (ago) {
const n = parseInt(ago[1], 10);
const unit = ago[2];
const iso =
unit === 'day' ? toIso(addDays(ref, -n)) :
unit === 'week' ? toIso(addDays(ref, -7 * n)) :
unit === 'month' ? toIso(addMonths(ref, -n)) :
toIso(addYears(ref, -n));
return { cue: `${n} ${unit}${n === 1 ? '' : 's'} ago`, iso };
}
// "a week/month/year ago" (singular, count = 1).
const aAgo = t.match(/\ba\s+(week|month|year)\s+ago\b/);
if (aAgo) {
const unit = aAgo[1];
const iso = unit === 'week' ? toIso(addDays(ref, -7)) : unit === 'month' ? toIso(addMonths(ref, -1)) : toIso(addYears(ref, -1));
return { cue: `a ${unit} ago`, iso };
}
// "last <weekday>" → most recent prior occurrence of that weekday.
const lastDow = t.match(/\blast\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/);
if (lastDow) {
const wd = WEEKDAYS.indexOf(lastDow[1] as (typeof WEEKDAYS)[number]);
return { cue: `last ${lastDow[1]}`, iso: toIso(lastWeekday(ref, wd)) };
}
// "last week/month/year" (bare, coarse offset).
if (/\blast\s+week\b/.test(t)) return { cue: 'last week', iso: toIso(addDays(ref, -7)) };
if (/\blast\s+month\b/.test(t)) return { cue: 'last month', iso: toIso(addMonths(ref, -1)) };
if (/\blast\s+year\b/.test(t)) return { cue: 'last year', iso: toIso(addYears(ref, -1)) };
return null;
}

View File

@@ -0,0 +1,430 @@
// INFORMATIONAL ONLY. Written once to meta.schema_version on first init (db.ts)
// and exposed on the public barrel, but migrations are PRESENCE-based (they probe
// for missing tables/columns/triggers), not gated on this value — nothing reads it
// back to branch. It documents "this is v1 of the on-disk shape"; bump it (and add
// a migration branch in MindDB.runMigrations()) only if you ever need version-gated
// migration logic. Kept, not deleted: it is a re-exported public constant and the
// meta row is asserted by schema.test.ts.
export const SCHEMA_VERSION = '1';
export const SCHEMA_SQL = `
-- Meta table for schema versioning
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Layer 0: Identity (single row, <500 tokens)
CREATE TABLE IF NOT EXISTS identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '',
personality TEXT NOT NULL DEFAULT '',
capabilities TEXT NOT NULL DEFAULT '',
system_prompt TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Layer 1: Awareness (<=10 active items)
CREATE TABLE IF NOT EXISTS awareness (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL CHECK (category IN ('task', 'action', 'pending', 'flag')),
content TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT
);
-- Sessions: map GOPs to projects
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gop_id TEXT NOT NULL UNIQUE,
project_id TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'archived')),
started_at TEXT NOT NULL DEFAULT (datetime('now')),
ended_at TEXT,
summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions (project_id, started_at);
-- Layer 2: Memory Frames (I/P/B with GOP organization)
CREATE TABLE IF NOT EXISTS memory_frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
frame_type TEXT NOT NULL CHECK (frame_type IN ('I', 'P', 'B')),
gop_id TEXT NOT NULL,
t INTEGER NOT NULL DEFAULT 0,
base_frame_id INTEGER REFERENCES memory_frames(id),
content TEXT NOT NULL,
importance TEXT NOT NULL DEFAULT 'normal'
CHECK (importance IN ('critical', 'important', 'normal', 'temporary', 'deprecated')),
source TEXT NOT NULL DEFAULT 'user_stated'
CHECK (source IN ('user_stated', 'tool_verified', 'agent_inferred', 'import', 'system')),
access_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed TEXT NOT NULL DEFAULT (datetime('now')),
-- UX-Refactor Phase 2B: JSON blob for Memory Center provenance/classification
-- (kind/confidence/scope/status/sourceId/sourceUrl/tags/evidence/related*).
-- See PRD §15.4 + docs/ux-refactor/deltas/shared-types-delta.md §3a. Existing
-- DBs get this via the idempotent ADD COLUMN in db.ts runMigrations().
metadata TEXT NOT NULL DEFAULT '{}',
-- oss-drift D3 (2026-06-11): canonical dedup hash — sha256 over the
-- stripHmPrefix-stripped + trimmed content (mind/content-hash.ts; mono
-- semantics, NOT the OSS trim-only hash). Indexed so FrameStore.findDuplicate
-- is an O(1) lookup with no recency window. Existing DBs get this via the
-- idempotent ADD COLUMN + backfill in db.ts runMigrations().
content_hash TEXT,
FOREIGN KEY (gop_id) REFERENCES sessions(gop_id)
);
CREATE INDEX IF NOT EXISTS idx_frames_gop_t ON memory_frames (gop_id, t);
CREATE INDEX IF NOT EXISTS idx_frames_type ON memory_frames (frame_type, gop_id);
CREATE INDEX IF NOT EXISTS idx_frames_base ON memory_frames (base_frame_id);
-- idx_frames_content_hash is created ONLY in db.ts runMigrations(), AFTER the
-- guarded ADD COLUMN. It must NOT live here: on a pre-D3 database the CREATE
-- TABLE above no-ops (table exists without content_hash), so an index here
-- referenced a missing column and SCHEMA_SQL threw BEFORE the ALTER could run
-- — every existing install failed to boot (2026-06-12 regression).
-- FTS5 for keyword search on frame content
CREATE VIRTUAL TABLE IF NOT EXISTS memory_frames_fts USING fts5(
content,
content_rowid='id',
tokenize='porter unicode61'
);
-- Layer 3: Knowledge Graph - Entities
CREATE TABLE IF NOT EXISTS knowledge_entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
name TEXT NOT NULL,
properties TEXT NOT NULL DEFAULT '{}',
valid_from TEXT NOT NULL DEFAULT (datetime('now')),
valid_to TEXT,
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_entities_type ON knowledge_entities (entity_type);
CREATE INDEX IF NOT EXISTS idx_entities_name ON knowledge_entities (name);
-- Layer 3: Knowledge Graph - Relations
CREATE TABLE IF NOT EXISTS knowledge_relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL REFERENCES knowledge_entities(id),
target_id INTEGER NOT NULL REFERENCES knowledge_entities(id),
relation_type TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0,
properties TEXT NOT NULL DEFAULT '{}',
valid_from TEXT NOT NULL DEFAULT (datetime('now')),
valid_to TEXT,
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_relations_source ON knowledge_relations (source_id, relation_type);
CREATE INDEX IF NOT EXISTS idx_relations_target ON knowledge_relations (target_id, relation_type);
-- Layer 3: Knowledge Graph - Entity↔Frame bridge.
-- Records which frames an entity was extracted from, so the 'contextual'
-- scoring signal (scoring.ts) can map query-seeded graph distances back onto
-- frames. ON DELETE CASCADE keeps it consistent when a frame or entity is removed.
CREATE TABLE IF NOT EXISTS kg_entity_frames (
entity_id INTEGER NOT NULL REFERENCES knowledge_entities(id) ON DELETE CASCADE,
frame_id INTEGER NOT NULL REFERENCES memory_frames(id) ON DELETE CASCADE,
PRIMARY KEY (entity_id, frame_id)
);
CREATE INDEX IF NOT EXISTS idx_kg_entity_frames_frame ON kg_entity_frames (frame_id);
CREATE INDEX IF NOT EXISTS idx_kg_entity_frames_entity ON kg_entity_frames (entity_id);
-- Layer 5: Improvement Signals (recurring patterns that should change behavior)
CREATE TABLE IF NOT EXISTS improvement_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL CHECK (category IN ('capability_gap', 'correction', 'workflow_pattern', 'skill_promotion')),
pattern_key TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL DEFAULT 1,
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
surfaced INTEGER NOT NULL DEFAULT 0,
surfaced_at TEXT,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_signals_category_key ON improvement_signals (category, pattern_key);
CREATE INDEX IF NOT EXISTS idx_signals_category ON improvement_signals (category, count DESC);
-- Layer 6: Install Audit (capability install trust trail)
CREATE TABLE IF NOT EXISTS install_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
capability_name TEXT NOT NULL,
-- CHECK lists MUST stay in sync with AuditCapabilityType / AuditApprovalClass
-- / AuditAction in packages/core/src/install-audit.ts. They drifted once
-- (connector/marketplace/blocked missing) and crashed acquire_capability the
-- moment marketplace search started returning candidates — see runMigrations().
capability_type TEXT NOT NULL CHECK (capability_type IN ('native', 'skill', 'plugin', 'mcp', 'connector', 'marketplace')),
source TEXT NOT NULL,
version TEXT,
risk_level TEXT NOT NULL CHECK (risk_level IN ('low', 'medium', 'high', 'critical')),
trust_source TEXT NOT NULL CHECK (trust_source IN ('builtin', 'starter_pack', 'local_user', 'third_party_verified', 'third_party_unverified', 'unknown', 'security-gate')),
approval_class TEXT NOT NULL CHECK (approval_class IN ('standard', 'elevated', 'critical', 'blocked')),
action TEXT NOT NULL CHECK (action IN ('proposed', 'approved', 'installed', 'rejected', 'failed', 'blocked', 'uninstalled')),
initiator TEXT NOT NULL CHECK (initiator IN ('agent', 'user', 'system')),
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_capability ON install_audit (capability_name, action);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON install_audit (timestamp DESC);
-- Layer 4: Procedures (GEPA-optimized prompt templates)
CREATE TABLE IF NOT EXISTS procedures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
model TEXT NOT NULL,
template TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
success_rate REAL NOT NULL DEFAULT 0.0,
avg_cost REAL NOT NULL DEFAULT 0.0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_procedures_name_model ON procedures (name, model);
-- Layer 7: AI Interactions (EU AI Act Art. 12 — automatic event logging)
CREATE TABLE IF NOT EXISTS ai_interactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
workspace_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL DEFAULT 0,
tools_called TEXT NOT NULL DEFAULT '[]',
human_action TEXT CHECK (human_action IN ('approved', 'denied', 'modified', 'none')),
risk_context TEXT,
imported_from TEXT,
persona TEXT,
-- Review Critical #3 (compliance): EU AI Act Art. 12.1(a) requires recording
-- the actual INPUTS and OUTPUTS of the system, not just token counts. Added
-- 2026-04-15; migration for pre-existing DBs in MindDB.runMigrations().
input_text TEXT,
output_text TEXT
);
CREATE INDEX IF NOT EXISTS idx_interactions_workspace ON ai_interactions (workspace_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_interactions_timestamp ON ai_interactions (timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_interactions_model ON ai_interactions (model);
-- Review Critical #1 (compliance): append-only enforcement for the audit log.
-- DDL-level triggers make the database itself refuse UPDATE / DELETE so a motivated
-- auditor's first question ('can rows be silently mutated?') has a concrete 'no'
-- answer. GDPR Art. 17 erasure is handled via a separate pseudonymize_and_tombstone
-- flow that's not yet implemented — when it is, it will replace inputText/outputText
-- with tombstone markers via a fresh INSERT + status flag, NOT by bypassing these
-- triggers.
CREATE TRIGGER IF NOT EXISTS ai_interactions_no_delete
BEFORE DELETE ON ai_interactions
BEGIN
SELECT RAISE(ABORT, 'ai_interactions is append-only (EU AI Act Art. 12 audit log)');
END;
CREATE TRIGGER IF NOT EXISTS ai_interactions_no_update
BEFORE UPDATE ON ai_interactions
BEGIN
SELECT RAISE(ABORT, 'ai_interactions is append-only (EU AI Act Art. 12 audit log)');
END;
-- Layer 9: Execution Traces (agent run history — foundation for self-evolution)
CREATE TABLE IF NOT EXISTS execution_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
persona_id TEXT,
workspace_id TEXT,
model TEXT,
task_shape TEXT,
outcome TEXT NOT NULL DEFAULT 'pending'
CHECK (outcome IN ('success', 'corrected', 'abandoned', 'verified', 'pending')),
trace_json TEXT NOT NULL DEFAULT '{}',
cost_usd REAL NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
finalized_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_traces_session ON execution_traces (session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_traces_persona ON execution_traces (persona_id, outcome);
CREATE INDEX IF NOT EXISTS idx_traces_outcome ON execution_traces (outcome, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_traces_workspace ON execution_traces (workspace_id, created_at DESC);
-- Layer 10: Evolution Runs (proposed/accepted/rejected self-evolution runs)
CREATE TABLE IF NOT EXISTS evolution_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_uuid TEXT NOT NULL UNIQUE,
target_kind TEXT NOT NULL,
target_name TEXT,
baseline_text TEXT NOT NULL,
winner_text TEXT NOT NULL,
winner_schema_json TEXT,
delta_accuracy REAL NOT NULL DEFAULT 0,
gate_verdict TEXT NOT NULL DEFAULT 'pass'
CHECK (gate_verdict IN ('pass', 'fail')),
gate_reasons_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'proposed'
CHECK (status IN ('proposed', 'accepted', 'rejected', 'deployed', 'failed')),
artifacts_json TEXT,
user_note TEXT,
failure_reason TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
deployed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_evo_runs_status ON evolution_runs (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_evo_runs_target ON evolution_runs (target_kind, target_name, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_evo_runs_created ON evolution_runs (created_at DESC);
-- Layer 8: Harvest Sources (Memory Harvest sync tracking)
CREATE TABLE IF NOT EXISTS harvest_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
source_path TEXT,
last_synced_at TEXT,
items_imported INTEGER NOT NULL DEFAULT 0,
frames_created INTEGER NOT NULL DEFAULT 0,
auto_sync INTEGER NOT NULL DEFAULT 0,
sync_interval_hours INTEGER NOT NULL DEFAULT 24,
last_content_hash TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Reverse-ported from OSS hive-mind chunker (oss-drift triage D1, 2026-06-11).
-- Memory frame chunks: paragraph-level subdivisions of memory_frames for
-- semantic-search precision. One frame produces N chunks (N=1 for short
-- frames). Each chunk gets its own embedding in memory_frame_chunks_vec.
-- Recall maps top-K chunks back to parent frames via frame_id.
CREATE TABLE IF NOT EXISTS memory_frame_chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
frame_id INTEGER NOT NULL REFERENCES memory_frames(id) ON DELETE CASCADE,
chunk_idx INTEGER NOT NULL,
content TEXT NOT NULL,
char_start INTEGER NOT NULL,
char_end INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(frame_id, chunk_idx)
);
CREATE INDEX IF NOT EXISTS idx_chunks_frame ON memory_frame_chunks (frame_id);
-- Verbatim Provenance Archive (#7, 2026-06-30): append-only, immutable, full-fidelity
-- copy of each harvested source item. Distilled/imported frames link back via
-- memory_frames.metadata.archiveUid. NOT part of the retrieval corpus (no FTS/vec) —
-- audit/reconstruction only. Append-only triggers mirror ai_interactions (Layer 7),
-- with ONE exception: a one-time GDPR Art.17 redaction (see raw_archive_no_update).
CREATE TABLE IF NOT EXISTS raw_archive (
id INTEGER PRIMARY KEY AUTOINCREMENT,
archive_uid TEXT NOT NULL UNIQUE,
source TEXT NOT NULL,
source_ref TEXT,
title TEXT,
content TEXT NOT NULL,
content_sha256 TEXT NOT NULL,
injection_flagged INTEGER NOT NULL DEFAULT 0,
injection_flags TEXT NOT NULL DEFAULT '',
source_timestamp TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Size guard: a single item's content is truncated to
-- RAW_ARCHIVE_MAX_CONTENT_CHARS (raw-archive.ts) before storage so one giant
-- harvested export can't blow the append-only store. truncated=1 marks a capped
-- row; original_length is the pre-truncation character count (NULL when not
-- truncated). archive_uid + content_sha256 still derive from the FULL content, so
-- idempotency + the integrity anchor are unaffected. Not referenced by the
-- append-only trigger below, so an erasure UPDATE that leaves them untouched
-- passes unchanged. Pre-guard DBs get these via the idempotent ADD COLUMN in db.ts.
truncated INTEGER NOT NULL DEFAULT 0,
original_length INTEGER,
-- GDPR Art.17 erasure: NULL until a data-subject erasure request. When set, the
-- audit skeleton (id/source/refs/timestamps) is frozen as the audit record while
-- content/content_sha256/title are redacted AND archive_uid is ROTATED to an opaque
-- id (the old content-derived uid was a re-identification vector — see the trigger).
erased_at TEXT,
erased_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_raw_archive_source_ref ON raw_archive (source, source_ref);
CREATE INDEX IF NOT EXISTS idx_raw_archive_created ON raw_archive (created_at DESC);
-- Append-only EXCEPT a single, one-directional GDPR Art.17 redaction. The trigger
-- pins the EXACT permitted outcome — not just the transition — so raw SQL cannot
-- abuse the erasure path to forge audit content: it is allowed ONLY when erased_at
-- goes NULL -> a non-empty value, every AUDIT column (id/source/refs/timestamps/
-- injection) is unchanged, the archive_uid is ROTATED to a new non-empty value
-- (content-derived uid must not survive — re-identification vector), AND the row
-- lands on the canonical redaction (content = marker, content_sha256 = '', title
-- NULL). erased_reason is the only free field. The content literal below MUST stay
-- byte-identical to RAW_ARCHIVE_REDACTION_MARKER in raw-archive.ts, and this whole
-- WHEN clause byte-identical to the db.ts runMigrations() recreation.
CREATE TRIGGER IF NOT EXISTS raw_archive_no_update
BEFORE UPDATE ON raw_archive
WHEN NOT (
OLD.erased_at IS NULL AND NEW.erased_at IS NOT NULL AND NEW.erased_at <> ''
AND NEW.content = '[REDACTED — GDPR Art.17 erasure]'
AND NEW.content_sha256 = ''
AND NEW.title IS NULL
AND NEW.id IS OLD.id
AND NEW.archive_uid <> OLD.archive_uid
AND NEW.archive_uid <> ''
AND NEW.source IS OLD.source
AND NEW.source_ref IS OLD.source_ref
AND NEW.created_at IS OLD.created_at
AND NEW.source_timestamp IS OLD.source_timestamp
AND NEW.injection_flagged IS OLD.injection_flagged
AND NEW.injection_flags IS OLD.injection_flags
)
BEGIN SELECT RAISE(ABORT, 'raw_archive is append-only; only a one-time canonical GDPR Art.17 redaction is permitted'); END;
CREATE TRIGGER IF NOT EXISTS raw_archive_no_delete
BEFORE DELETE ON raw_archive
BEGIN SELECT RAISE(ABORT, 'raw_archive is append-only (verbatim provenance archive)'); END;
-- Erased-subject suppression list (#7 Art.17 "sticky erasure", 2026-07-02). When a
-- data subject is erased (MindErasure.eraseBySourceRef), its (source, source_ref) is
-- recorded here; every re-import write seam consults it and SKIPS re-materialization,
-- so an exercised right-to-erasure survives a later re-export/re-sync of the source.
-- Keyed on the SUBJECT pair ONLY — deliberately NO content / content_sha256 (a
-- content-keyed tombstone would reintroduce the re-identification vector the
-- raw_archive archive_uid rotation removed). Rows are DELETABLE (unlike raw_archive):
-- deletion is the deliberate re-consent / "allow re-import again" path — hence no
-- immutability trigger. Generic substrate (no governance/trust fields) → forward-ports
-- to the OSS mirror verbatim, the deliberate opposite of install_audit.
CREATE TABLE IF NOT EXISTS erased_subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
source_ref TEXT NOT NULL,
erased_at TEXT NOT NULL DEFAULT (datetime('now')),
reason TEXT,
UNIQUE(source, source_ref)
);
CREATE INDEX IF NOT EXISTS idx_erased_subjects_lookup ON erased_subjects (source, source_ref);
`;
// Reverse-ported from OSS hive-mind (oss-drift triage R7, 2026-06-11).
/** Vec-table DDL parameterized by embedding dimension. vec0 columns can't be
* ALTERed, so changing dimension means DROP + CREATE (see MindDB.recreateVecTables). */
export function vecTableSqlForDim(dim: number): string {
const d = Math.trunc(dim);
return `
CREATE VIRTUAL TABLE IF NOT EXISTS memory_frames_vec USING vec0(
embedding float[${d}]
);
`;
}
/** Default vec schema at the canonical 1024-dim (used on first init + migrations). */
export const VEC_TABLE_SQL = vecTableSqlForDim(1024);
// Reverse-ported from OSS hive-mind chunker (oss-drift triage D1, 2026-06-11).
/** Chunk-level vec-table DDL parameterized by embedding dimension. Separate from
* vecTableSqlForDim so callers can create/recreate the chunk index independently;
* MindDB.recreateVecTables(dim) recreates BOTH (frames + chunks) together. */
export function chunksVecTableSqlForDim(dim: number): string {
const d = Math.trunc(dim);
return `
CREATE VIRTUAL TABLE IF NOT EXISTS memory_frame_chunks_vec USING vec0(
embedding float[${d}]
);
`;
}
/** Default chunk-vec schema at the canonical 1024-dim (first init + migrations). */
export const CHUNKS_VEC_TABLE_SQL = chunksVecTableSqlForDim(1024);

View File

@@ -0,0 +1,108 @@
import type { Importance } from './frames.js';
export type ScoringProfile = 'balanced' | 'recent' | 'important' | 'connected';
export interface ScoringWeights {
temporal: number;
popularity: number;
contextual: number;
importance: number;
}
/**
* KNOWN GAP (W4-PRODUCTION-PORT-PLAN-2026-06-11.md §3 bug #1): no production
* caller passes `graphDistances`, so the `contextual` dimension scores 0 for
* every frame — 20% of 'balanced' (60% of 'connected') is a uniform constant.
* This does NOT distort ranking (a constant-0 term preserves ordering) but it
* deflates absolute finalScores and makes 'connected' a functional no-op.
* Deliberately NOT zeroed out: tests and future callers may pass
* graphDistances, and KnowledgeGraph.bfsDistances needs an entity-id →
* frame-id bridge before production wiring (open decision #3).
*/
export const SCORING_PROFILES: Record<ScoringProfile, ScoringWeights> = {
balanced: { temporal: 0.4, popularity: 0.2, contextual: 0.2, importance: 0.2 },
recent: { temporal: 0.6, popularity: 0.1, contextual: 0.2, importance: 0.1 },
important: { temporal: 0.1, popularity: 0.1, contextual: 0.2, importance: 0.6 },
connected: { temporal: 0.1, popularity: 0.1, contextual: 0.6, importance: 0.2 },
};
const IMPORTANCE_WEIGHTS: Record<Importance, number> = {
critical: 2.0,
important: 1.5,
normal: 1.0,
temporary: 0.7,
deprecated: 0.3,
};
const HALF_LIFE_DAYS = 30;
const RECENCY_BOOST_DAYS = 7;
export interface ScoredResult {
frameId: number;
rrfScore: number;
relevanceScore: number;
finalScore: number;
}
export interface ScoringContext {
recentEntityIds?: number[];
graphDistances?: Map<number, number>; // frameId -> shortest BFS distance
}
export function computeTemporalScore(lastAccessedIso: string): number {
const now = Date.now();
const accessed = new Date(lastAccessedIso).getTime();
const daysSince = (now - accessed) / (1000 * 60 * 60 * 24);
if (daysSince <= RECENCY_BOOST_DAYS) {
return 1.0; // full score for recent items
}
// Exponential decay with 30-day half-life
return Math.pow(0.5, daysSince / HALF_LIFE_DAYS);
}
export function computePopularityScore(accessCount: number): number {
return 1 + Math.log10(1 + accessCount) * 0.1;
}
export function computeContextualScore(
frameId: number,
graphDistances: Map<number, number> | undefined
): number {
if (!graphDistances || !graphDistances.has(frameId)) return 0;
const distance = graphDistances.get(frameId)!;
if (distance === 0) return 1.0;
if (distance === 1) return 0.7;
if (distance === 2) return 0.4;
if (distance === 3) return 0.2;
return 0;
}
export function computeImportanceScore(importance: Importance): number {
return IMPORTANCE_WEIGHTS[importance];
}
export function computeRelevance(
frame: { id: number; created_at?: string; last_accessed: string; access_count: number; importance: Importance },
weights: ScoringWeights,
context: ScoringContext = {}
): number {
// W4.2 (plan §3 bug #3): the temporal dimension decays on WRITE time
// (created_at), not access time. last_accessed is bumped to "now" by
// touch() on every read — decaying on it made this dimension constant
// noise on historical corpora (every recalled frame scored "recent").
// created_at is optional for back-compat; callers not passing it keep
// the old access-decay behavior.
const temporal = computeTemporalScore(frame.created_at ?? frame.last_accessed);
const popularity = computePopularityScore(frame.access_count);
const contextual = computeContextualScore(frame.id, context.graphDistances);
const importance = computeImportanceScore(frame.importance);
return (
temporal * weights.temporal +
popularity * weights.popularity +
contextual * weights.contextual +
importance * weights.importance
);
}

View File

@@ -0,0 +1,698 @@
import type { MindDB } from './db.js';
import type { Embedder } from './embeddings.js';
import type { MemoryFrame, Importance } from './frames.js';
import type { Reranker } from './inprocess-reranker.js';
import { chunkText, type ChunkOptions } from './chunker.js';
import { buildFtsOrQuery, hasUnsegmentedScript, sanitizeFtsToken } from './fts-sanitize.js';
import { createCoreLogger } from '../logger.js';
import {
computeRelevance,
SCORING_PROFILES,
type ScoringProfile,
type ScoringContext,
type ScoredResult,
} from './scoring.js';
import { KnowledgeGraph } from './knowledge.js';
export interface SearchOptions {
limit?: number;
gopId?: string; // scope to a specific session
profile?: ScoringProfile;
context?: ScoringContext;
/** F20: Only include frames created on or after this ISO date string. */
since?: string;
/** F20: Only include frames created on or before this ISO date string. */
until?: string;
/**
* W4.2: cross-encoder reranker invoked AFTER RRF on the top-`rerankPoolSize`
* candidates. When provided, results are sorted by reranker score
* (jointly attentive over query+doc). RRF still selects the candidate
* pool; the reranker only re-orders the survivors. Soft-fails to RRF
* ordering on any reranker error.
*/
reranker?: Reranker;
/** How many candidates to send to the reranker (default 30). */
rerankPoolSize?: number;
/**
* Hard-exclude frames with importance='deprecated' from results. Default OFF
* for back-compat: deprecated frames still surface, merely down-weighted 0.3×
* by the scoring layer. Turn ON where a superseded value must NEVER leak into
* the read context — e.g. after supersession consolidation (see supersede.ts),
* where a 0.3× multiplier still let stale values surface via the focus lane.
*/
excludeDeprecated?: boolean;
}
export interface SearchResult {
frame: MemoryFrame;
rrfScore: number;
relevanceScore: number;
finalScore: number;
}
// Ported from hive-mind a99ea0e.
/**
* Retrieval-confidence verdict for the abstain path (LongMemEval's
* "insufficient evidence" ability). Pure + side-effect-free so callers
* (MCP recall_memory, CLI, eval harness) can decide whether to answer or
* abstain without re-running search.
*/
export interface RetrievalConfidence {
/** True when the top result clears the threshold (safe to answer). */
sufficient: boolean;
/** The top finalScore observed (0 when there were no results). */
topScore: number;
/** The threshold it was compared against. */
threshold: number;
}
// Ported from hive-mind a99ea0e.
/**
* Assess whether a result set carries enough signal to answer, or whether the
* caller should abstain ("insufficient evidence"). A scaffold for the abstain
* path: it does NOT change `search()` output — callers opt in by passing the
* results plus a τ threshold. `sufficient` is true iff the top finalScore is
* strictly greater than τ; an empty set is always insufficient.
*
* Threshold semantics intentionally mirror the recall-stress edge-query rule
* (a low top score means "nothing relevant surfaced").
*/
export function assessRetrievalConfidence(
results: readonly SearchResult[],
threshold: number,
): RetrievalConfidence {
const topScore = results.length ? results[0].finalScore : 0;
return { sufficient: topScore > threshold, topScore, threshold };
}
const RRF_K = 60;
const log = createCoreLogger('hybrid-search');
// Reverse-ported from OSS hive-mind chunker (oss-drift triage D1, 2026-06-11).
/**
* Chunk-level retrieval flag — DEFAULT ON since the 2026-06-12 long-frame
* needle probe (benchmarks/chunk-probe/): on a copy of the real production
* personal mind, paired hit@5 = chunk 46/52 vs whole-frame 17/52 (discordant
* pairs 30-vs-1, McNemar p≈2e-8); chunk led even within the embed cap
* (17/20 vs 13/20) and dominated beyond it (29/32 vs 4/32 — content past the
* embedder's true token context is structurally invisible to whole-frame
* vectors). LoCoMo was rejected as the ruler: its frames sit below the
* 2000-char chunk threshold, so an A/B there measures noise by construction.
* Kill switch: WAGGLE_CHUNK_RETRIEVAL=0. Gates BOTH the write side
* (indexFrame / indexFramesBatch also chunk-index the frame) and the read
* side (search() queries memory_frame_chunks_vec, falling back to whole-frame
* vectors while the chunk index is empty). `indexChunksForFrame` /
* `rechunkAllFrames` stay callable regardless of the flag (backfill + eval).
*/
export function chunkRetrievalEnabled(): boolean {
return process.env.WAGGLE_CHUNK_RETRIEVAL !== '0';
}
function f32ToBlob(f32: Float32Array): Uint8Array {
return new Uint8Array(f32.buffer, f32.byteOffset, f32.byteLength);
}
/**
* Escape LIKE metacharacters (`%`, `_`) and the escape char itself (`\`) so the
* keyword-fallback term is matched literally. Pair with `ESCAPE '\'` on the LIKE.
*/
function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, ch => `\\${ch}`);
}
export class HybridSearch {
private db: MindDB;
private embedder: Embedder;
private fingerprintChecked = false;
constructor(db: MindDB, embedder: Embedder) {
this.db = db;
this.embedder = embedder;
}
// Reverse-ported from OSS hive-mind (oss-drift triage R7, 2026-06-11).
/**
* Guard the .mind's embedding fingerprint before vector reads/writes. Throws
* EmbeddingDimMismatchError if the active embedder's dim differs from what
* the .mind's vectors were written at; warns (but allows) on a same-dim model
* change. Memoized on success so it costs one meta read per instance lifetime.
* Must be called BEFORE any try/catch that would swallow the error.
*/
private ensureFingerprint(): void {
if (this.fingerprintChecked) return;
const e = this.embedder as Embedder & {
getActiveProvider?(): string;
getStatus?(): { modelName?: string };
};
const provider = e.getActiveProvider?.() ?? 'unknown';
const model = e.getStatus?.().modelName ?? 'unknown';
const result = this.db.ensureEmbeddingFingerprint({ provider, model, dim: this.embedder.dimensions });
// Only memoize after a non-throwing check (a dim mismatch must keep throwing).
this.fingerprintChecked = true;
if (result.status === 'model-changed') {
log.warn(
`Embedding model changed for this .mind (${result.storedProvider}/${result.storedModel}` +
`${provider}/${model}, same ${this.embedder.dimensions}-dim). Existing vectors stay searchable, ` +
`but cross-model similarity is degraded — consider re-embedding all frames.`
);
}
}
async search(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
const { limit = 20, gopId, profile = 'balanced', context = {}, since, until, reranker, rerankPoolSize } = options;
const weights = SCORING_PROFILES[profile];
// Run keyword and vector searches in parallel.
// W4.1b slot-consumption fix: the since/until filter applies AFTER the
// lanes run (as a WHERE over candidate ids), so out-of-window candidates
// would otherwise consume lane slots and shrink results below `limit`
// even when in-window frames exist deeper in the lanes. Over-fetch the
// lanes when a temporal window is active so the post-filter has depth.
const laneFetch = (since || until) ? limit * 10 : limit * 2;
// D1 chunk lane (flag-gated, default OFF): prefer chunk-level vector
// search when WAGGLE_CHUNK_RETRIEVAL=1 AND chunks_vec is populated —
// chunk embeddings discriminate better on domain-homogeneous corpora
// than whole-frame embeddings. vectorSearchChunks returns null when no
// chunks exist, signalling clean fallback to the whole-frame path. Both
// paths return frame IDs so the RRF + scoring pipeline is unchanged.
// Flag off → chunkResults is null without touching the chunk tables,
// so the lane below is byte-identical to pre-D1.
const chunkResults = chunkRetrievalEnabled()
? await this.vectorSearchChunks(query, laneFetch, gopId)
: null;
const [keywordResults, vectorResults] = await Promise.all([
this.keywordSearch(query, laneFetch, gopId),
chunkResults !== null
? Promise.resolve(chunkResults)
: this.vectorSearch(query, laneFetch, gopId),
]);
// RRF fusion
const rrfScores = new Map<number, number>();
keywordResults.forEach((id, rank) => {
rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (RRF_K + rank));
});
vectorResults.forEach((id, rank) => {
rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (RRF_K + rank));
});
// Get all unique frame IDs
const frameIds = [...rrfScores.keys()];
if (frameIds.length === 0) return [];
// F20: Fetch frames with optional temporal filtering
const raw = this.db.getDatabase();
const placeholders = frameIds.map(() => '?').join(',');
const temporalConditions: string[] = [];
const temporalParams: unknown[] = [...frameIds];
// W4.1b fencepost fix: `created_at` carries mixed formats across write
// paths — `datetime('now')` ("YYYY-MM-DD HH:MM:SS") vs harvest ISO
// ("YYYY-MM-DDT…Z"). A date-only `until` string-compares BELOW any
// same-day timestamp ("2026-03-21T10:00" > "2026-03-21"), silently
// excluding the whole final day. Compare date-only bounds on the
// 10-char date prefix instead — format-agnostic and inclusive.
if (since) {
if (since.length === 10) {
temporalConditions.push('substr(created_at, 1, 10) >= ?');
} else {
temporalConditions.push('created_at >= ?');
}
temporalParams.push(since);
}
if (until) {
if (until.length === 10) {
temporalConditions.push('substr(created_at, 1, 10) <= ?');
} else {
temporalConditions.push('created_at <= ?');
}
temporalParams.push(until);
}
const whereExtra = temporalConditions.length > 0
? ` AND ${temporalConditions.join(' AND ')}`
: '';
// Hard-exclude deprecated frames when requested (no param needed — literal
// condition). Dropping them from `frames` removes them from frameMap, so
// they never enter the result set OR the reranker pool.
const deprecatedExtra = options.excludeDeprecated ? " AND importance != 'deprecated'" : '';
const frames = raw.prepare(
`SELECT * FROM memory_frames WHERE id IN (${placeholders})${whereExtra}${deprecatedExtra}`
).all(...temporalParams) as MemoryFrame[];
const frameMap = new Map(frames.map(f => [f.id, f]));
// W4.1: turn on the 'contextual' scoring signal. Seed graph distance from
// entities the caller flagged (context.recentEntityIds) plus entities named
// in the query, BFS the KG, and map to frames via the kg_entity_frames bridge.
// Best-effort: a graph hiccup must never fail the search.
let scoringContext = context;
if (!scoringContext.graphDistances) {
try {
const kg = new KnowledgeGraph(this.db);
const seeds = new Set<number>(scoringContext.recentEntityIds ?? []);
for (const id of kg.findEntitiesInText(query)) seeds.add(id);
if (seeds.size > 0) {
const graphDistances = kg.frameDistancesFromEntities([...seeds], 3);
if (graphDistances.size > 0) scoringContext = { ...scoringContext, graphDistances };
}
} catch { /* contextual signal is optional */ }
}
// Compute final scores
const results: SearchResult[] = [];
for (const [frameId, rrfScore] of rrfScores) {
const frame = frameMap.get(frameId);
if (!frame) continue;
const relevanceScore = computeRelevance(
{
id: frame.id,
// W4.2 bug #3: temporal decay anchors on write time, not access time.
created_at: frame.created_at,
last_accessed: frame.last_accessed,
access_count: frame.access_count,
importance: frame.importance as Importance,
},
weights,
scoringContext
);
results.push({
frame,
rrfScore,
relevanceScore,
finalScore: rrfScore * relevanceScore,
});
}
results.sort((a, b) => b.finalScore - a.finalScore);
// W4.2: optional cross-encoder reranking on the top pool (reverse-ported
// from the OSS benchmark-proven stack). Reranker scoring is jointly
// attentive over (query, doc), so it discriminates much better than
// vector dot products on densely-homogeneous corpora. RRF still selects
// the candidate pool; the reranker only re-orders the survivors.
if (reranker) {
const poolSize = Math.min(rerankPoolSize ?? 30, results.length);
const pool = results.slice(0, poolSize);
try {
const docs = pool.map((r) => r.frame.content);
const scores = await reranker.scoreBatch(query, docs);
// Pair (result, rerank score), sort desc, replace finalScore so the
// shape stays the same for downstream consumers.
const reranked = pool.map((r, i) => ({ ...r, finalScore: scores[i] }));
reranked.sort((a, b) => b.finalScore - a.finalScore);
// Append any pool tail items beyond rerankPoolSize so a small limit
// doesn't suddenly contract the result set.
return reranked.concat(results.slice(poolSize)).slice(0, limit);
} catch {
// Reranker failure (model load, OOM, dim mismatch) — fall back to
// RRF ordering. Soft-fail so a misconfigured reranker doesn't
// kill recall entirely.
}
}
return results.slice(0, limit);
}
async keywordSearch(query: string, limit: number, gopId?: string): Promise<number[]> {
const raw = this.db.getDatabase();
// W3.6: Sanitize query for FTS5 with OR-based matching for better recall.
// Old: implicit AND (all terms required) → fails on "hiring decisions this month"
// New: OR between terms (any term matches) → FTS5 rank orders by relevance
// S1: sanitizer unified in fts-sanitize.ts, Unicode-aware (Cyrillic and
// diacritic terms survive; ASCII output is byte-identical to before).
const safeQuery = query.includes('"')
? query // already quoted by caller
: buildFtsOrQuery(query);
if (!safeQuery) {
// Empty MATCH string. For ASCII queries that means stop words / short
// tokens only — keep returning [] (regression lock). For queries in an
// unsegmented script (CJK) the emptiness is a sanitizer artifact, not a
// lack of signal: unicode61 cannot token-match CJK prose, but LIKE
// substring matching can, so route those to the fallback lane.
return hasUnsegmentedScript(query) ? this.likeFallbackSearch(query, limit, gopId) : [];
}
let sql: string;
let params: unknown[];
if (gopId) {
sql = `
SELECT mf.id FROM memory_frames_fts fts
JOIN memory_frames mf ON mf.id = fts.rowid
WHERE fts.content MATCH ? AND mf.gop_id = ?
ORDER BY rank
LIMIT ?
`;
params = [safeQuery, gopId, limit];
} else {
sql = `
SELECT rowid as id FROM memory_frames_fts
WHERE content MATCH ?
ORDER BY rank
LIMIT ?
`;
params = [safeQuery, limit];
}
try {
const rows = raw.prepare(sql).all(...params) as { id: number }[];
return rows.map(r => r.id);
} catch {
// FTS5 parse error (e.g. user query with FTS5-special chars that survived
// sanitization) — fall back to a LIKE keyword scan over the same column so
// we return best-effort matches instead of a false "no memory found".
return this.likeFallbackSearch(query, limit, gopId);
}
}
/**
* LIKE-based keyword fallback over memory_frames.content. Used when the FTS5
* MATCH query throws a parse error (e.g. an unbalanced quote or other FTS5
* operator the user typed literally). The raw query is split into word tokens
* — stripping the punctuation that caused the FTS5 error, mirroring the
* primary sanitizer — and matched with OR-ed LIKE clauses for best-effort
* recall. Bound parameters only (the term is never interpolated) and LIKE
* metachars (`%`, `_`, `\`) are escaped with an ESCAPE clause so each token
* matches literally. If no usable token survives, a single literal LIKE over
* the whole escaped query is used.
*/
private likeFallbackSearch(query: string, limit: number, gopId?: string): number[] {
const raw = this.db.getDatabase();
const tokens = query
.split(/\s+/)
.map(sanitizeFtsToken) // strip punctuation (incl. FTS5 operators), Unicode-aware
.filter(w => w.length > 0);
const terms = (tokens.length > 0 ? tokens : [query]).map(t => `%${escapeLikeTerm(t)}%`);
const likeClause = terms.map(() => `content LIKE ? ESCAPE '\\'`).join(' OR ');
try {
if (gopId) {
const rows = raw.prepare(
`SELECT id FROM memory_frames
WHERE (${likeClause}) AND gop_id = ?
ORDER BY created_at DESC LIMIT ?`
).all(...terms, gopId, limit) as { id: number }[];
return rows.map(r => r.id);
}
const rows = raw.prepare(
`SELECT id FROM memory_frames
WHERE (${likeClause})
ORDER BY created_at DESC LIMIT ?`
).all(...terms, limit) as { id: number }[];
return rows.map(r => r.id);
} catch {
return [];
}
}
async vectorSearch(query: string, limit: number, gopId?: string): Promise<number[]> {
this.ensureFingerprint();
const embedding = await this.embedder.embed(query);
const blob = f32ToBlob(embedding);
const raw = this.db.getDatabase();
if (gopId) {
// Two-step: get candidates from vec, then filter by GOP
try {
const rows = raw.prepare(`
SELECT v.rowid as id FROM memory_frames_vec v
WHERE v.embedding MATCH ? AND k = ?
ORDER BY distance
`).all(blob, limit * 3) as { id: number }[];
// Filter by GOP
if (rows.length === 0) return [];
const placeholders = rows.map(() => '?').join(',');
const filtered = raw.prepare(`
SELECT id FROM memory_frames WHERE id IN (${placeholders}) AND gop_id = ?
`).all(...rows.map(r => r.id), gopId) as { id: number }[];
return filtered.map(r => r.id).slice(0, limit);
} catch {
return [];
}
} else {
try {
const rows = raw.prepare(`
SELECT rowid as id FROM memory_frames_vec
WHERE embedding MATCH ? AND k = ?
ORDER BY distance
`).all(blob, limit) as { id: number }[];
return rows.map(r => r.id);
} catch {
return [];
}
}
}
async indexFrame(frameId: number, content: string): Promise<void> {
this.ensureFingerprint();
if (!Number.isFinite(frameId)) {
throw new Error('Invalid frame ID for vector indexing');
}
const embedding = await this.embedder.embed(content);
const raw = this.db.getDatabase();
// sqlite-vec vec0 requires rowid as SQL literal (parameterized rowid not supported)
const id = Math.trunc(frameId);
raw.prepare(
`INSERT INTO memory_frames_vec (rowid, embedding) VALUES (${id}, ?)`
).run(f32ToBlob(embedding));
// D1 (flag-gated, default OFF): keep the chunk index in lockstep with
// live frame writes. Soft-fail — a chunk-indexing error must never break
// the primary whole-frame write (mirrors the reranker soft-fail stance).
if (chunkRetrievalEnabled()) {
try {
await this.indexChunksForFrame(frameId, content);
} catch (err) {
log.warn(
`chunk indexing failed for frame ${id} (whole-frame vector written): ` +
`${err instanceof Error ? err.message : String(err)}`
);
}
}
}
async indexFramesBatch(frames: { id: number; content: string }[]): Promise<void> {
if (frames.length === 0) return;
this.ensureFingerprint();
for (const f of frames) {
if (!Number.isFinite(f.id)) {
throw new Error('Invalid frame ID for vector indexing');
}
}
const contents = frames.map(f => f.content);
const embeddings = await this.embedder.embedBatch(contents);
const raw = this.db.getDatabase();
// sqlite-vec vec0 requires rowid as SQL literal (parameterized rowid not supported)
const insertAll = raw.transaction(() => {
for (let i = 0; i < frames.length; i++) {
const id = Math.trunc(frames[i].id);
raw.prepare(
`INSERT INTO memory_frames_vec (rowid, embedding) VALUES (${id}, ?)`
).run(f32ToBlob(embeddings[i]));
}
});
insertAll();
// D1 (flag-gated, default OFF): chunk-index batch writes too, so frames
// ingested via the batch path (harvest) aren't invisible to the chunk
// lane. Soft-fail per frame — see indexFrame.
if (chunkRetrievalEnabled()) {
for (const f of frames) {
try {
await this.indexChunksForFrame(f.id, f.content);
} catch (err) {
log.warn(
`chunk indexing failed for frame ${Math.trunc(f.id)} (whole-frame vector written): ` +
`${err instanceof Error ? err.message : String(err)}`
);
}
}
}
}
// ── Chunk-level indexing (oss-drift triage D1, 2026-06-11) ─────────────
// Reverse-ported from OSS hive-mind "Phase 3b-3 chunking". Whole-frame
// embeddings cluster too tightly on a domain-homogeneous corpus (every
// frame is "about the same project"), so retrieval can't discriminate.
// Chunking decomposes a frame into ~500-token paragraph-level pieces,
// each with its own embedding — search returns the chunk, we map back to
// the parent frame for the final result.
// ─────────────────────────────────────────────────────────────────────
/**
* Replace all chunks for a frame: clears existing chunks/vec rows for the
* frame, re-chunks the content, embeds each chunk, inserts both rows.
* Idempotent — safe to call repeatedly. Used by rechunkAllFrames and by
* the flag-gated indexFrame path. NOT itself gated on
* WAGGLE_CHUNK_RETRIEVAL (backfill + eval call it directly).
*/
async indexChunksForFrame(
frameId: number,
content: string,
opts: ChunkOptions = {},
): Promise<number> {
if (!Number.isFinite(frameId) || frameId <= 0) {
throw new Error('Invalid frame ID for chunk indexing');
}
this.ensureFingerprint();
const raw = this.db.getDatabase();
const id = Math.trunc(frameId);
const chunks = chunkText(content, opts);
if (chunks.length === 0) return 0;
// Embed all chunks. embedBatch amortises HTTP overhead on Ollama/API providers.
const texts = chunks.map((c) => c.text);
const embeddings = await this.embedder.embedBatch(texts);
// Single tx so partial failure leaves the frame's chunks empty
// (next rechunk pass will re-fill from scratch — same end state).
const tx = raw.transaction(() => {
// Find existing chunk_ids for this frame so we can drop their vec rows.
// Foreign-key cascade handles memory_frame_chunks deletion when the
// parent frame is deleted, but for re-indexing we're keeping the
// frame and just replacing its chunks.
const existing = raw
.prepare('SELECT id FROM memory_frame_chunks WHERE frame_id = ?')
.all(id) as Array<{ id: number }>;
for (const row of existing) {
// sqlite-vec rowid must be SQL literal.
raw.prepare(`DELETE FROM memory_frame_chunks_vec WHERE rowid = ${Math.trunc(row.id)}`).run();
}
raw.prepare('DELETE FROM memory_frame_chunks WHERE frame_id = ?').run(id);
const insertChunk = raw.prepare(
'INSERT INTO memory_frame_chunks (frame_id, chunk_idx, content, char_start, char_end) VALUES (?, ?, ?, ?, ?)'
);
for (let i = 0; i < chunks.length; i++) {
const c = chunks[i];
const result = insertChunk.run(id, i, c.text, c.charStart, c.charEnd);
const chunkId = Math.trunc(Number(result.lastInsertRowid));
raw
.prepare(`INSERT INTO memory_frame_chunks_vec (rowid, embedding) VALUES (${chunkId}, ?)`)
.run(f32ToBlob(embeddings[i]));
}
});
tx();
return chunks.length;
}
/**
* Vector search over chunks. Returns parent frame IDs deduped (best-chunk-
* per-frame wins — first-seen order under ORDER BY distance). When the
* chunk index is empty (or the tables are missing), returns null so callers
* can cleanly fall back to the whole-frame vectorSearch path.
*/
async vectorSearchChunks(query: string, limit: number, gopId?: string): Promise<number[] | null> {
this.ensureFingerprint();
const raw = this.db.getDatabase();
// Cheap probe — avoid embedding the query when chunks aren't populated.
let chunkCount: number;
try {
const row = raw.prepare('SELECT COUNT(*) AS n FROM memory_frame_chunks').get() as
| { n: number }
| undefined;
chunkCount = row?.n ?? 0;
} catch {
return null;
}
if (chunkCount === 0) return null;
const embedding = await this.embedder.embed(query);
const blob = f32ToBlob(embedding);
// Over-fetch chunks (limit * 5) so dedup-to-frame still leaves enough
// candidates after collapsing multiple chunks of the same frame.
try {
const chunkRows = raw
.prepare(
`SELECT v.rowid AS chunk_id, c.frame_id
FROM memory_frame_chunks_vec v
JOIN memory_frame_chunks c ON c.id = v.rowid
WHERE v.embedding MATCH ? AND k = ?
ORDER BY distance`
)
.all(blob, Math.max(limit * 5, 25)) as Array<{ chunk_id: number; frame_id: number }>;
if (chunkRows.length === 0) return [];
// Dedup by frame_id, preserving first-seen order (best-distance chunk).
const seen = new Set<number>();
const frameIds: number[] = [];
for (const r of chunkRows) {
if (seen.has(r.frame_id)) continue;
seen.add(r.frame_id);
frameIds.push(r.frame_id);
if (frameIds.length >= limit) break;
}
if (gopId) {
const placeholders = frameIds.map(() => '?').join(',');
const filtered = raw
.prepare(
`SELECT id FROM memory_frames WHERE id IN (${placeholders}) AND gop_id = ?`
)
.all(...frameIds, gopId) as { id: number }[];
return filtered.map((r) => r.id).slice(0, limit);
}
return frameIds;
} catch {
return null;
}
}
}
// Reverse-ported from OSS hive-mind chunker (oss-drift triage D1, 2026-06-11);
// follows the OSS `maintenance --rechunk-all` per-mind logic.
export interface RechunkResult {
framesProcessed: number;
chunksCreated: number;
framesFailed: number;
}
/**
* (Re)chunk + chunk-index every non-deprecated frame in the .mind. Idempotent
* per-frame — indexChunksForFrame deletes a frame's existing chunks before
* re-inserting. One bad frame doesn't abort the batch (logged + counted).
* Backfill/eval helper only — no CLI/route wiring yet, and NOT gated on
* WAGGLE_CHUNK_RETRIEVAL (it must be runnable before any flag flip).
*/
export async function rechunkAllFrames(db: MindDB, search: HybridSearch): Promise<RechunkResult> {
const raw = db.getDatabase();
const frames = raw
.prepare("SELECT id, content FROM memory_frames WHERE importance != 'deprecated' ORDER BY id ASC")
.all() as Array<{ id: number; content: string }>;
let framesProcessed = 0;
let chunksCreated = 0;
let framesFailed = 0;
for (const f of frames) {
try {
const n = await search.indexChunksForFrame(f.id, f.content);
framesProcessed++;
chunksCreated += n;
} catch (err) {
framesFailed++;
log.warn(
`rechunkAllFrames: frame ${f.id} failed: ${err instanceof Error ? err.message : String(err)}`
);
}
}
return { framesProcessed, chunksCreated, framesFailed };
}

View File

@@ -0,0 +1,107 @@
import type { MindDB } from './db.js';
export interface Session {
id: number;
gop_id: string;
project_id: string | null;
status: 'active' | 'closed' | 'archived';
started_at: string;
ended_at: string | null;
summary: string | null;
}
export class SessionStore {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
}
create(projectId?: string): Session {
const gopId = `session:${new Date().toISOString()}:${Math.random().toString(36).slice(2, 8)}`;
const raw = this.db.getDatabase();
const result = raw.prepare(`
INSERT INTO sessions (gop_id, project_id, status, started_at)
VALUES (?, ?, 'active', datetime('now'))
`).run(gopId, projectId ?? null);
return raw.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid) as Session;
}
close(gopId: string, summary?: string): Session {
const raw = this.db.getDatabase();
raw.prepare(`
UPDATE sessions SET status = 'closed', ended_at = datetime('now'), summary = ?
WHERE gop_id = ?
`).run(summary ?? null, gopId);
return raw.prepare('SELECT * FROM sessions WHERE gop_id = ?').get(gopId) as Session;
}
archive(gopId: string): Session {
const raw = this.db.getDatabase();
raw.prepare("UPDATE sessions SET status = 'archived' WHERE gop_id = ?").run(gopId);
return raw.prepare('SELECT * FROM sessions WHERE gop_id = ?').get(gopId) as Session;
}
getByProject(projectId: string): Session[] {
return this.db.getDatabase().prepare(
'SELECT * FROM sessions WHERE project_id = ? ORDER BY started_at DESC'
).all(projectId) as Session[];
}
getActive(): Session[] {
return this.db.getDatabase().prepare(
"SELECT * FROM sessions WHERE status = 'active' ORDER BY started_at DESC"
).all() as Session[];
}
/**
* Return the most-recent active session, or create one atomically if none exists.
* Transaction-wrapped so two concurrent callers on a fresh mind produce exactly
* one session (review finding #7: session-create race in autoSaveFromExchange).
*/
ensureActive(projectId?: string): Session {
const raw = this.db.getDatabase();
const txn = raw.transaction((): Session => {
// Secondary `id DESC` tiebreak: datetime('now') has second precision, so two
// create() calls in the same second share started_at and SQLite's ordering becomes
// unspecified without an explicit tiebreaker.
const existing = raw.prepare(
"SELECT * FROM sessions WHERE status = 'active' ORDER BY started_at DESC, id DESC LIMIT 1"
).get() as Session | undefined;
if (existing) return existing;
const gopId = `session:${new Date().toISOString()}:${Math.random().toString(36).slice(2, 8)}`;
const result = raw.prepare(`
INSERT INTO sessions (gop_id, project_id, status, started_at)
VALUES (?, ?, 'active', datetime('now'))
`).run(gopId, projectId ?? null);
return raw.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid) as Session;
});
return txn();
}
getByGopId(gopId: string): Session | undefined {
return this.db.getDatabase().prepare(
'SELECT * FROM sessions WHERE gop_id = ?'
).get(gopId) as Session | undefined;
}
/**
* Ensure a session with the given stable gop_id exists, creating it on
* first use. Used for long-lived logical sessions like `harvest` that
* group imported memory frames under a single parent across many runs.
*
* Unlike `create()`, which generates a timestamped id per call, this
* method is idempotent — calling it repeatedly with the same gop_id
* returns the same session.
*/
ensure(gopId: string, projectId?: string, summary?: string): Session {
const existing = this.getByGopId(gopId);
if (existing) return existing;
const raw = this.db.getDatabase();
raw.prepare(`
INSERT INTO sessions (gop_id, project_id, status, summary, started_at)
VALUES (?, ?, 'active', ?, datetime('now'))
`).run(gopId, projectId ?? null, summary ?? null);
return this.getByGopId(gopId)!;
}
}

View File

@@ -0,0 +1,393 @@
/**
* supersede.ts — supersession (P) + bridge (B) frame PRODUCER.
*
* The distiller only ever emits I-frames (independent assertions); the
* substrate's P (partial-update / supersession delta) and B (bridge /
* cross-link) primitives sat dormant with zero production callers. This module
* turns them on as a first-class, provider-agnostic capability.
*
* PRODUCER vs CONSUMER — read this before renaming or merging. This module is
* the PRODUCER: it reads UNSTRUCTURED observations, detects supersession chains
* + enumerable groups, and WRITES P/B frames. The CONSUMERS of those frames
* live elsewhere and are intentionally NOT here:
* - `FrameStore.compact()` merges accumulated P-frames back into their base
* I-frame (a downstream housekeeping consumer).
* - `packages/weaver/src/consolidation.ts` (MemoryWeaver) consumes P/B frames
* (merges I+P into consolidated I-frames, materialises B-frames from KG
* entities). Do NOT collapse this file into that name — the two sit on
* opposite sides of the P/B lifecycle.
*
* Two LLM passes read an I-frame observation set and produce structured
* consolidation intents:
* - Supersession chains — the SAME attribute of the SAME subject whose value
* changes over time (follower count 1250 → 1300; job title A → B). We
* deprecate the stale members, boost the newest to `critical`, and emit ONE
* P-frame carrying the CURRENT value so a "what is X now" read surfaces the
* latest, not a stale mention.
* - Enumerable entity groups — 2+ observations each describing a DISTINCT
* member of one countable class (each aquarium tank; each wedding). We emit
* ONE B-frame referencing every member frame so a counting read can expand
* it to the COMPLETE set.
*
* Provider-agnostic by construction: the caller injects an `llm(system, user)`
* callback (study harvest/extract-kg-entities.ts for the repo's executor
* pattern). This module is PURE — no child_process, no fetch, no env reads — so
* it is trivially unit-testable with a fake llm and safe to call from any
* executor (CLI `claude -p` subprocess, MCP server, benchmark harness).
*
* ⚠️ INDEXING CONTRACT: FrameStore.createPFrame / createBFrame index ONLY the
* FTS table, NOT the vector table. `applyConsolidation` therefore RETURNS the
* new frames so the CALLER can vec-index them (e.g. HybridSearch.indexFramesBatch).
* Skip that step and the P/B frames are keyword-recallable but invisible to
* semantic search.
*
* Ported from the validated LongMemEval experiment
* (benchmarks/longmemeval/46-consolidate.mjs + 47-answer-pb.mjs): the two
* prompts below are carried over verbatim, including the clean `current_value`
* extraction that strips hedges ("about", "around", "close to").
*
* Ported from hive-mind 2d0abc5 (mono-parity 2026-07-05, §7.5).
*/
import type { MindDB } from './db.js';
import type { FrameStore, FrameSource, MemoryFrame } from './frames.js';
/**
* LLM callback the consolidation passes inject. Given a system + user message,
* returns the model's raw text response (JSON is parsed defensively downstream).
* Provider-agnostic — the caller owns the transport (subprocess, HTTP, etc.).
*/
export type ConsolidationLlm = (system: string, user: string) => Promise<string>;
/** One dated observation fed to the detectors. `id` is the real frame id. */
export interface Observation {
id: number;
content: string;
created_at: string;
}
/** A detected supersession chain, mapped to real frame ids (oldest → newest). */
export interface SupersessionChain {
/** Short attribute label, e.g. "follower count", "job title". */
attribute: string;
/** The clean, unhedged latest value; empty when the model omitted it. */
currentValue: string;
/** Member frame ids in oldest → newest order (length ≥ 2). */
frameIds: number[];
}
/** A detected enumerable entity group, mapped to real frame ids. */
export interface EntityGroup {
/** Short class name, e.g. "aquarium tanks the user owns". */
label: string;
/** Member frame ids (length ≥ 2). */
frameIds: number[];
}
/** What `applyConsolidation` wrote — returned so the caller can vec-index. */
export interface ConsolidationResult {
/** Newly created P-frames (current-value deltas). */
pframes: MemoryFrame[];
/** Newly created B-frames (member-set bridges). */
bframes: MemoryFrame[];
/** Frame ids demoted to importance='deprecated'. */
deprecated: number[];
}
/** Options for gathering the observation set the detectors run over. */
export interface CollectObservationsOptions {
/** Scope to a single GOP session; omit for the whole mind. */
gopId?: string;
/** Cap the number of observations (keeps the LLM prompt bounded). */
limit?: number;
/**
* Which frame source to include. Defaults to 'agent_inferred' (the
* distiller's output, matching the benchmark). Pass 'any' for every source.
*/
source?: FrameSource | 'any';
}
// ── Prompts (ported verbatim from 46-consolidate.mjs) ──────────────────────
const SUPERSESSION_SYSTEM =
'You are given a numbered list of dated observations about ONE user. Identify UPDATE CHAINS: sets of observations that state the SAME attribute of the SAME specific subject where the VALUE CHANGES over time (e.g. follower count 1250 then 1300; job title A then B; where an item is kept). Only genuine supersessions of ONE evolving fact — NOT distinct facts, NOT a count of different items. For each chain give "current_value": the LATEST value as a short, clean, unhedged phrase (e.g. "1300 followers", "in a shoe rack in the closet"), stripping words like "close to", "about", "around". Return JSON {"chains":[{"attribute":"short label","current_value":"clean latest value","ids":[oldest..newest]}]} using the observation numbers as ids. If none, {"chains":[]}.';
const GROUP_SYSTEM =
'You are given a numbered list of dated observations about ONE user. Identify ENUMERABLE GROUPS: sets of 2+ observations each describing a DISTINCT member of the same countable class that an aggregation question might count or sum (e.g. each aquarium tank the user owns; each wedding attended; each magazine subscription; each workshop with its cost). One group per class. Do NOT include update-chains (same item changing value). Return JSON {"groups":[{"label":"short class name","ids":[...]}]} using observation numbers. If none, {"groups":[]}.';
// ── Internal helpers ───────────────────────────────────────────────────────
/**
* Defensive JSON parse of an LLM response (see parseJson in 46). Tries a direct
* parse first, then falls back to extracting the first balanced-looking `{…}`
* block from prose (models sometimes wrap JSON in commentary or fences). Returns
* an empty object when nothing parses — the callers treat "no intents" as a
* valid, non-fatal outcome rather than throwing on model chatter.
*/
function parseLlmJson(raw: string): Record<string, unknown> {
if (typeof raw !== 'string') return {};
const trimmed = raw.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed) as Record<string, unknown>;
} catch {
// Not a bare JSON document — try to recover an embedded object below.
}
const match = trimmed.match(/\{[\s\S]*\}/);
if (match) {
try {
return JSON.parse(match[0]) as Record<string, unknown>;
} catch {
// Embedded block was also malformed — fall through to the empty result.
}
}
return {};
}
/** Fail loudly on malformed input rather than silently producing junk chains. */
function assertObservations(observations: unknown): asserts observations is Observation[] {
if (!Array.isArray(observations)) {
throw new TypeError('consolidate: observations must be an array');
}
for (const o of observations) {
if (!o || typeof o !== 'object') {
throw new TypeError('consolidate: each observation must be an object');
}
const rec = o as Record<string, unknown>;
if (!Number.isInteger(rec.id)) {
throw new TypeError('consolidate: observation.id must be an integer');
}
if (typeof rec.content !== 'string') {
throw new TypeError('consolidate: observation.content must be a string');
}
}
}
/** Render the observations as a `N. [YYYY-MM-DD] content` numbered list. */
function numberObservations(observations: Observation[]): string {
return observations
.map((o, idx) => `${idx + 1}. [${String(o.created_at ?? '').slice(0, 10)}] ${o.content}`)
.join('\n');
}
/**
* Map the model's 1-based observation numbers back to real frame ids. Invalid /
* out-of-range / duplicate numbers are dropped. When `sortByAge` is set the ids
* are returned in observation order (which is created_at, id order = oldest →
* newest) regardless of the order the model emitted them — the supersession
* pass relies on the last id being the genuinely newest member.
*/
function mapNumbersToFrameIds(
numbers: unknown,
observations: Observation[],
sortByAge: boolean,
): number[] {
if (!Array.isArray(numbers)) return [];
const indices: number[] = [];
const seen = new Set<number>();
for (const n of numbers) {
const idx = Number(n);
if (!Number.isInteger(idx) || idx < 1 || idx > observations.length) continue;
if (seen.has(idx)) continue;
seen.add(idx);
indices.push(idx);
}
if (sortByAge) indices.sort((a, b) => a - b);
return indices.map((i) => observations[i - 1].id);
}
// ── Detection passes ───────────────────────────────────────────────────────
/**
* Detect supersession chains via the LLM. Returns chains whose member ids map
* to ≥ 2 real frames, oldest → newest. Never throws on model chatter — a
* malformed / empty response yields `[]`. The injected `llm` IS allowed to
* throw (transport failures surface to the caller; they are not swallowed).
*/
export async function detectSupersessionChains(
observations: Observation[],
llm: ConsolidationLlm,
): Promise<SupersessionChain[]> {
assertObservations(observations);
if (typeof llm !== 'function') {
throw new TypeError('detectSupersessionChains: llm must be a function');
}
if (observations.length < 2) return [];
const raw = await llm(SUPERSESSION_SYSTEM, numberObservations(observations));
const parsed = parseLlmJson(raw);
const rawChains = Array.isArray(parsed.chains) ? parsed.chains : [];
const chains: SupersessionChain[] = [];
for (const entry of rawChains) {
if (!entry || typeof entry !== 'object') continue;
const rec = entry as Record<string, unknown>;
const frameIds = mapNumbersToFrameIds(rec.ids, observations, true);
if (frameIds.length < 2) continue;
const attribute = typeof rec.attribute === 'string' ? rec.attribute.trim() : '';
const currentValue = typeof rec.current_value === 'string' ? rec.current_value.trim() : '';
chains.push({ attribute: attribute || 'value', currentValue, frameIds });
}
return chains;
}
/**
* Detect enumerable entity groups via the LLM. Returns groups whose member ids
* map to ≥ 2 real frames (member order preserved as emitted). Same throw
* contract as `detectSupersessionChains`.
*/
export async function detectEntityGroups(
observations: Observation[],
llm: ConsolidationLlm,
): Promise<EntityGroup[]> {
assertObservations(observations);
if (typeof llm !== 'function') {
throw new TypeError('detectEntityGroups: llm must be a function');
}
if (observations.length < 2) return [];
const raw = await llm(GROUP_SYSTEM, numberObservations(observations));
const parsed = parseLlmJson(raw);
const rawGroups = Array.isArray(parsed.groups) ? parsed.groups : [];
const groups: EntityGroup[] = [];
for (const entry of rawGroups) {
if (!entry || typeof entry !== 'object') continue;
const rec = entry as Record<string, unknown>;
const frameIds = mapNumbersToFrameIds(rec.ids, observations, false);
if (frameIds.length < 2) continue;
const label = typeof rec.label === 'string' ? rec.label.trim() : '';
groups.push({ label: label || 'group', frameIds });
}
return groups;
}
// ── Application pass ───────────────────────────────────────────────────────
/**
* Apply detected chains + groups to a FrameStore. For each chain: deprecate the
* stale members, boost the newest to `critical`, and emit a P-frame carrying the
* clean current value (base = oldest member). For each group: emit a B-frame
* referencing every member (base = first member).
*
* All new frames land under `gopId` (a valid session gop_id) while their
* base_frame_id / references still point at the original — possibly cross-gop —
* source frames.
*
* ⚠️ The returned `pframes` / `bframes` are FTS-indexed only (createPFrame /
* createBFrame do not touch the vector table). The caller MUST vec-index them
* (e.g. `HybridSearch.indexFramesBatch`) for semantic search to reach them.
*/
export function applyConsolidation(
frames: FrameStore,
chains: SupersessionChain[],
groups: EntityGroup[],
gopId: string,
): ConsolidationResult {
if (!frames || typeof frames.createPFrame !== 'function') {
throw new TypeError('applyConsolidation: frames must be a FrameStore');
}
if (typeof gopId !== 'string' || !gopId) {
throw new Error('applyConsolidation: gopId is required');
}
const pframes: MemoryFrame[] = [];
const bframes: MemoryFrame[] = [];
const deprecated: number[] = [];
for (const chain of chains ?? []) {
const ids = chain.frameIds;
if (!Array.isArray(ids) || ids.length < 2) continue;
const baseId = ids[0];
const newestId = ids[ids.length - 1];
const newest = frames.getById(newestId);
if (!newest) continue; // newest member gone — cannot anchor a current value
// Deprecate every stale member (all but the newest).
for (const staleId of ids.slice(0, -1)) {
const stale = frames.getById(staleId);
if (!stale) continue;
frames.update(staleId, stale.content, 'deprecated');
deprecated.push(staleId);
}
// Boost the surviving newest so it wins recall ties.
frames.update(newestId, newest.content, 'critical');
// Emit the current-value P-frame (base = oldest), preferring the model's
// clean value and falling back to the newest frame's raw content.
const cleanValue = chain.currentValue.trim() ? chain.currentValue.trim() : newest.content;
const attribute = chain.attribute.trim() ? chain.attribute.trim() : 'value';
const asOf = String(newest.created_at).slice(0, 10);
const pContent = `[current] ${attribute}: ${cleanValue} (as of ${asOf})`;
pframes.push(frames.createPFrame(gopId, pContent, baseId, 'critical', 'agent_inferred'));
}
for (const group of groups ?? []) {
const ids = group.frameIds;
if (!Array.isArray(ids) || ids.length < 2) continue;
const label = group.label.trim() ? group.label.trim() : 'group';
const desc = `${label} (${ids.length} members)`;
bframes.push(frames.createBFrame(gopId, desc, ids[0], ids));
}
return { pframes, bframes, deprecated };
}
// ── Read helpers ───────────────────────────────────────────────────────────
/**
* Gather the observation set the detectors run over: non-deprecated I-frames,
* chronological. Keeps the query in one place so the CLI + MCP wiring stays
* thin. `limit` caps the LLM prompt size on large minds.
*/
export function collectObservations(
db: MindDB,
options: CollectObservationsOptions = {},
): Observation[] {
const raw = db.getDatabase();
const conditions = ["frame_type = 'I'", "importance != 'deprecated'"];
const params: unknown[] = [];
const source = options.source ?? 'agent_inferred';
if (source !== 'any') {
conditions.push('source = ?');
params.push(source);
}
if (options.gopId) {
conditions.push('gop_id = ?');
params.push(options.gopId);
}
let sql = `SELECT id, content, created_at FROM memory_frames WHERE ${conditions.join(' AND ')} ORDER BY created_at, id`;
if (options.limit && options.limit > 0) {
sql += ' LIMIT ?';
params.push(options.limit);
}
return raw.prepare(sql).all(...params) as Observation[];
}
/**
* Current-value lines from the P-frames written by `applyConsolidation`, with
* the `[current]` marker stripped, chronological. Feed into a read context's
* "current values" block so a stale mention never wins a "what is X now" answer.
* Scope to a single GOP via `gopId`, or omit for the whole mind.
*/
export function getCurrentValues(db: MindDB, gopId?: string): string[] {
const raw = db.getDatabase();
const rows = (
gopId
? raw
.prepare(
"SELECT content FROM memory_frames WHERE frame_type = 'P' AND gop_id = ? ORDER BY created_at, id",
)
.all(gopId)
: raw
.prepare("SELECT content FROM memory_frames WHERE frame_type = 'P' ORDER BY created_at, id")
.all()
) as Array<{ content: string }>;
return rows
.map((r) => String(r.content).replace(/^\[current\]\s*/, '').trim())
.filter(Boolean);
}

View File

@@ -0,0 +1,104 @@
/**
* suppression.ts — #7 Art.17 "sticky erasure" (2026-07-02).
*
* The erased-subject suppression list. When a data subject is erased
* (MindErasure.eraseBySourceRef), its (source, source_ref) pair is recorded here.
* Every re-import write seam (the harvest loops, RawArchive.append, the auto-sync
* writer) consults isSuppressed() and SKIPS re-materialization, so an exercised
* right-to-erasure survives a later re-export / re-sync of the same source.
*
* KEY = the (source, source_ref) SUBJECT pair only. Deliberately NO content and NO
* content hash: a content-keyed tombstone would reintroduce the low-entropy
* re-identification vector the archive_uid rotation (raw-archive.ts erase()) removed.
*
* Rows are deletable — unsuppress() is the deliberate re-consent / "allow re-import
* again" path (no immutability trigger, unlike raw_archive). Generic substrate only,
* so it forward-ports to the OSS mirror verbatim.
*/
import type { MindDB } from './db.js';
import { createCoreLogger } from '../logger.js';
const log = createCoreLogger('suppression');
export interface SuppressedSubject {
source: string;
sourceRef: string;
erasedAt: string;
reason: string | null;
}
/**
* Result of a suppression check that separates a genuine MATCH from a fail-closed
* read ERROR. Both outcomes mean "skip the write" (erasure safety), but a caller
* can then report "N items could not be verified" distinctly from "N erased
* subjects skipped" instead of mislabeling a broken-DB read as a confirmed erasure.
*/
export type SuppressionCheck =
| { suppressed: false }
| { suppressed: true; reason: 'match' }
| { suppressed: true; reason: 'error'; error: string };
export class SuppressionStore {
private db: MindDB;
constructor(db: MindDB) { this.db = db; }
/**
* Check whether a subject is suppressed, distinguishing a genuine match from a
* fail-closed read error. FAIL-CLOSED: a read error still reports suppressed
* (Art.17 wins on the ambiguous item — the DB is broken so the follow-on import
* INSERT fails anyway; we must not re-materialize erased PII on a transient
* error) but tags reason:'error' so the caller can count "could not verify"
* separately. A found row → reason:'match'.
*/
checkSuppressed(source: string, sourceRef: string): SuppressionCheck {
try {
const row = this.db.getDatabase()
.prepare('SELECT 1 FROM erased_subjects WHERE source = ? AND source_ref = ? LIMIT 1')
.get(source, sourceRef);
return row !== undefined ? { suppressed: true, reason: 'match' } : { suppressed: false };
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);
log.error('isSuppressed read failed — failing closed (treating subject as suppressed)', {
source, sourceRef, error,
});
return { suppressed: true, reason: 'error', error };
}
}
/**
* Is this subject suppressed? FAIL-CLOSED: a read error is treated as suppressed.
* Thin boolean wrapper over checkSuppressed — a caller that needs to distinguish a
* read error from a genuine match (to report "N could not be verified") should
* call checkSuppressed directly.
*/
isSuppressed(source: string, sourceRef: string): boolean {
return this.checkSuppressed(source, sourceRef).suppressed;
}
/** Record a subject as erased/suppressed. Idempotent (UNIQUE(source, source_ref)). */
record(source: string, sourceRef: string, reason?: string): void {
this.db.getDatabase()
.prepare('INSERT OR IGNORE INTO erased_subjects (source, source_ref, reason) VALUES (?, ?, ?)')
.run(source, sourceRef, reason ?? null);
}
/**
* Re-consent: remove a subject from the suppression list so it may be re-imported
* again. Returns whether a row was actually removed.
*/
unsuppress(source: string, sourceRef: string): boolean {
const res = this.db.getDatabase()
.prepare('DELETE FROM erased_subjects WHERE source = ? AND source_ref = ?')
.run(source, sourceRef);
return res.changes > 0;
}
/** All currently-suppressed subjects, newest erasure first. */
list(): SuppressedSubject[] {
const rows = this.db.getDatabase()
.prepare('SELECT source, source_ref, erased_at, reason FROM erased_subjects ORDER BY erased_at DESC, id DESC')
.all() as Array<{ source: string; source_ref: string; erased_at: string; reason: string | null }>;
return rows.map(r => ({ source: r.source, sourceRef: r.source_ref, erasedAt: r.erased_at, reason: r.reason }));
}
}

View File

@@ -0,0 +1,186 @@
import path from 'node:path';
import { MindDB } from './mind/db.js';
import { createCoreLogger } from './logger.js';
const log = createCoreLogger('multi-mind-cache');
export interface MultiMindCacheConfig {
maxOpen: number;
getMindPath: (workspaceId: string) => string | null;
/**
* Defense-in-depth root directory. If set, `getOrOpen` rejects any path that does not
* resolve to a descendant of this root. Prevents a crafted workspaceId like
* '../../other-user.mind' from opening an arbitrary file via the caller-supplied
* `getMindPath` — closes review Critical #2 from cowork/Code-Review_MultiMind_April-2026.md.
*/
allowedRoot?: string;
}
interface CacheEntry {
db: MindDB;
lastAccessed: number;
/**
* Session-lifetime refcount. Incremented by `acquire()` when a workspace
* session borrows the handle, decremented by `release()` on session close.
* `evictLRU` never closes an entry with `pins > 0` — a pinned mind is in use
* by a live session that may write to it across an LLM await, and closing it
* mid-turn caused the swallowed "database connection is not open" flake.
*/
pins: number;
}
/**
* LRU cache of open MindDB handles keyed by workspace ID.
* Opens minds on demand and evicts the least recently used when full.
*/
export class MultiMindCache {
private readonly cache = new Map<string, CacheEntry>();
private readonly maxOpen: number;
private readonly getMindPath: (workspaceId: string) => string | null;
private readonly allowedRoot: string | null;
constructor(config: MultiMindCacheConfig) {
this.maxOpen = config.maxOpen;
this.getMindPath = config.getMindPath;
this.allowedRoot = config.allowedRoot ? path.resolve(config.allowedRoot) : null;
}
getOrOpen(workspaceId: string): MindDB | null {
const existing = this.cache.get(workspaceId);
let carriedPins = 0;
if (existing) {
// Reopen-guard: normally hand back the cached handle. But if it was closed
// out-of-band (an explicit close() seam ran while a session still held a
// reference), drop the dead entry and reopen below — carrying the pin count
// forward so an in-use mind stays eviction-protected after the reopen.
if (existing.db.isOpen()) {
existing.lastAccessed = Date.now();
return existing.db;
}
carriedPins = existing.pins;
this.cache.delete(workspaceId);
}
const mindPath = this.getMindPath(workspaceId);
if (!mindPath) return null;
// Review Critical #2: path-traversal guard. Defense-in-depth against an
// attacker-controlled workspaceId (e.g. from an LLM tool call with a misconfigured
// approval gate) that resolves to an arbitrary filesystem path.
if (this.allowedRoot) {
const resolved = path.resolve(mindPath);
if (resolved !== this.allowedRoot && !resolved.startsWith(this.allowedRoot + path.sep)) {
log.warn('path outside allowedRoot — rejecting getOrOpen', {
workspaceId,
resolvedPath: resolved,
});
return null;
}
}
// Review Major #5: re-check after evictLRU — a concurrent call may have just
// inserted the same workspaceId between our initial .get() and here.
try {
if (this.cache.size >= this.maxOpen) {
this.evictLRU();
}
const recheck = this.cache.get(workspaceId);
if (recheck && recheck.db.isOpen()) {
recheck.lastAccessed = Date.now();
return recheck.db;
}
const db = new MindDB(mindPath);
this.cache.set(workspaceId, { db, lastAccessed: Date.now(), pins: carriedPins });
return db;
} catch (err) {
log.warn('failed to open MindDB', { workspaceId, error: err instanceof Error ? err.message : String(err) });
return null;
}
}
getIfOpen(workspaceId: string): MindDB | null {
const entry = this.cache.get(workspaceId);
if (entry) {
entry.lastAccessed = Date.now();
return entry.db;
}
return null;
}
/**
* Borrow a MindDB handle for the lifetime of a workspace session and pin it
* so `evictLRU` cannot close it while the session is live. The cache remains
* the sole owner of the handle — the borrower must NOT call `.close()` on it;
* it calls `release()` exactly once when the session closes. Throws if the
* mind cannot be opened (callers pre-check via `getOrOpen`, so this is the
* unreachable-path guard, not a normal control-flow branch).
*/
acquire(workspaceId: string): MindDB {
const db = this.getOrOpen(workspaceId);
if (!db) throw new Error(`MultiMindCache.acquire: cannot open mind for workspace '${workspaceId}'`);
const entry = this.cache.get(workspaceId);
if (entry) entry.pins += 1;
return db;
}
/**
* Release a session's pin on a workspace mind. Floors at 0 so a stray extra
* release (e.g. a failed session create that never actually pinned) can never
* drive the refcount negative and wrongly un-pin a still-live session.
*/
release(workspaceId: string): void {
const entry = this.cache.get(workspaceId);
if (entry && entry.pins > 0) entry.pins -= 1;
}
has(workspaceId: string): boolean {
return this.cache.has(workspaceId);
}
close(workspaceId: string): void {
const entry = this.cache.get(workspaceId);
if (entry) {
try { entry.db.close(); } catch (err) { log.warn('close failed', { workspaceId, error: err instanceof Error ? err.message : String(err) }); }
this.cache.delete(workspaceId);
}
}
closeAll(): void {
for (const [id, entry] of this.cache) {
try { entry.db.close(); } catch (err) { log.warn('close failed', { workspaceId: id, error: err instanceof Error ? err.message : String(err) }); }
}
this.cache.clear();
}
get size(): number {
return this.cache.size;
}
keys(): string[] {
return [...this.cache.keys()];
}
private evictLRU(): void {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.cache) {
if (entry.pins > 0) continue; // never evict a mind pinned by a live session
if (entry.lastAccessed < oldestTime) {
oldestTime = entry.lastAccessed;
oldestKey = key;
}
}
if (oldestKey) {
this.close(oldestKey);
} else {
// Every open mind is pinned by an active session. Closing one would poison
// an in-flight chat turn, so we accept exceeding the soft cap instead
// (correctness over the maxOpen limit). The map shrinks again as sessions
// release their pins.
log.warn('evictLRU: all cached minds pinned by active sessions — exceeding maxOpen', {
size: this.cache.size,
maxOpen: this.maxOpen,
});
}
}
}

View File

@@ -0,0 +1,198 @@
import { MindDB } from './mind/db.js';
import { IdentityLayer, type Identity } from './mind/identity.js';
import { AwarenessLayer, type AwarenessItem } from './mind/awareness.js';
import { FrameStore, type MemoryFrame } from './mind/frames.js';
import { buildFtsOrQuery } from './mind/fts-sanitize.js';
import { createCoreLogger } from './logger.js';
const log = createCoreLogger('multi-mind');
export type MindSource = 'personal' | 'workspace';
export type SearchScope = 'personal' | 'workspace' | 'all';
export interface MultiMindSearchResult extends Omit<MemoryFrame, 'source'> {
/** Which mind this result came from. This intentionally REPURPOSES the
* `source` field as the mind label (personal/workspace); the frame's own
* DB-level source (FrameSource) is not surfaced in cross-mind results. */
source: MindSource;
}
/**
* MultiMind manages simultaneous access to personal.mind + workspace.mind.
* Identity comes from personal mind, awareness is combined from both.
* Search can target either mind or both.
*/
export class MultiMind {
personal: MindDB;
workspace: MindDB | null;
private personalFrames: FrameStore;
private workspaceFrames: FrameStore | null;
private personalIdentity: IdentityLayer;
private personalAwareness: AwarenessLayer;
private workspaceAwareness: AwarenessLayer | null;
constructor(personalPath: string, workspacePath?: string) {
this.personal = new MindDB(personalPath);
this.personalFrames = new FrameStore(this.personal);
this.personalIdentity = new IdentityLayer(this.personal);
this.personalAwareness = new AwarenessLayer(this.personal);
if (workspacePath) {
this.workspace = new MindDB(workspacePath);
this.workspaceFrames = new FrameStore(this.workspace);
this.workspaceAwareness = new AwarenessLayer(this.workspace);
} else {
this.workspace = null;
this.workspaceFrames = null;
this.workspaceAwareness = null;
}
}
/**
* Search across both minds using FTS5 keyword search.
* Results include a `source` field indicating which mind they came from.
*/
searchAll(query: string, limit = 20): MultiMindSearchResult[] {
return this.search(query, 'all', limit);
}
/**
* Search with a specific scope: personal-only, workspace-only, or all.
*/
search(query: string, scope: SearchScope = 'all', limit = 20): MultiMindSearchResult[] {
const results: MultiMindSearchResult[] = [];
if (scope === 'personal' || scope === 'all') {
const personalResults = this.ftsSearch(this.personal, query, limit);
results.push(...personalResults.map(r => ({ ...r, source: 'personal' as MindSource })));
}
if ((scope === 'workspace' || scope === 'all') && this.workspace) {
const workspaceResults = this.ftsSearch(this.workspace, query, limit);
results.push(...workspaceResults.map(r => ({ ...r, source: 'workspace' as MindSource })));
}
// Sort by FTS rank is already done per-mind; for cross-mind we sort by created_at desc
// ISO 8601 timestamps sort correctly via string comparison
results.sort((a, b) => b.created_at.localeCompare(a.created_at));
return results.slice(0, limit);
}
/**
* Get identity from the personal mind.
*/
getIdentity(): Identity {
return this.personalIdentity.get();
}
/**
* Check if identity exists in the personal mind.
*/
hasIdentity(): boolean {
return this.personalIdentity.exists();
}
/**
* Get combined awareness from both minds.
* Personal awareness items come first, then workspace items.
*/
getAwareness(): AwarenessItem[] {
const personalItems = this.personalAwareness.getAll();
const workspaceItems = this.workspaceAwareness?.getAll() ?? [];
// Merge and sort by priority descending
return [...personalItems, ...workspaceItems]
.sort((a, b) => b.priority - a.priority);
}
/**
* @deprecated Use `setWorkspace(db)` with a cache-managed `MindDB` instead.
*
* Review Major #3 (cowork/Code-Review_MultiMind_April-2026.md): this method
* unconditionally closes `this.workspace` and constructs a new `MindDB(newPath)`.
* When the previous workspace DB is owned by `MultiMindCache` (the live code path),
* the close corrupts the cache's handle — every subsequent `cache.getOrOpen()` for
* that workspace returns a closed DB and throws on every SQL call. This method is
* retained ONLY for the legacy test path (`packages/core/tests/multi-mind.test.ts`)
* which does not use the cache. Remove when those tests migrate to `setWorkspace`.
*/
switchWorkspace(newPath: string): void {
if (this.workspace) {
this.workspace.close();
}
this.workspace = new MindDB(newPath);
this.workspaceFrames = new FrameStore(this.workspace);
this.workspaceAwareness = new AwarenessLayer(this.workspace);
}
/**
* Set the workspace mind to an already-open MindDB instance.
* Does NOT close the previous workspace (caller manages lifecycle).
* Use this when the DB is managed by an external cache.
*/
setWorkspace(db: MindDB): void {
// Don't close — the caller (cache) owns the lifecycle
this.workspace = db;
this.workspaceFrames = new FrameStore(db);
this.workspaceAwareness = new AwarenessLayer(db);
}
/**
* Close both minds. After this, the MultiMind instance should not be used.
*/
close(): void {
try { this.personal?.close(); } catch (err) { log.warn('close failed (personal)', err); }
try { this.workspace?.close(); } catch (err) { log.warn('close failed (workspace)', err); }
}
/**
* Get the FrameStore for a specific mind.
*/
getFrameStore(source: MindSource): FrameStore | null {
if (source === 'personal') return this.personalFrames;
return this.workspaceFrames;
}
/**
* Get the AwarenessLayer for a specific mind.
*/
getAwarenessLayer(source: MindSource): AwarenessLayer | null {
if (source === 'personal') return this.personalAwareness;
return this.workspaceAwareness;
}
/**
* Get the IdentityLayer (always from personal mind).
*/
getIdentityLayer(): IdentityLayer {
return this.personalIdentity;
}
/**
* FTS5 keyword search on a single MindDB instance.
* Sanitizes the query by wrapping each word in double quotes.
*/
private ftsSearch(db: MindDB, query: string, limit: number): MemoryFrame[] {
// F6: OR-based search with stop word filtering (matches HybridSearch.keywordSearch fix)
// S1: sanitizer unified in mind/fts-sanitize.ts (Unicode-aware).
const safeQuery = buildFtsOrQuery(query);
if (!safeQuery) return [];
const raw = db.getDatabase();
try {
return raw.prepare(`
SELECT mf.* FROM memory_frames_fts fts
JOIN memory_frames mf ON mf.id = fts.rowid
WHERE fts.content MATCH ?
ORDER BY rank
LIMIT ?
`).all(safeQuery, limit) as MemoryFrame[];
} catch {
// FTS5 parse error — return empty
return [];
}
}
}

View File

@@ -0,0 +1,398 @@
import fs from 'node:fs';
import path from 'node:path';
import type { WorkspaceType } from '@waggle/shared';
type AIActRiskLevel = 'minimal' | 'limited' | 'high-risk' | 'unacceptable';
export interface WorkspaceConfig {
id: string;
name: string;
group: string;
icon?: string;
model?: string;
personality?: string;
/** Selected agent persona ID (from persona catalog) */
personaId?: string;
/** Template ID chosen during onboarding (e.g. 'sales-pipeline', 'research-project'). */
templateId?: string;
tools?: string[];
skills?: string[];
team?: string | null;
/** Filesystem directory where agent operates and generates files. */
directory?: string;
/** Storage type for workspace files. */
storageType?: 'virtual' | 'local' | 'team';
/** Path to local file storage for this workspace. */
storagePath?: string;
/** Extra storage provider configuration (e.g. S3 bucket, credentials). */
storageConfig?: Record<string, unknown>;
created: string; // ISO 8601
// --- Team Mode fields (Phase 5) ---
/** Team ID on the team server. Present = team workspace. */
teamId?: string;
/** URL of the team server (e.g. "https://team.waggle.dev"). */
teamServerUrl?: string;
/** Current user's role in this team workspace. */
teamRole?: 'owner' | 'admin' | 'member' | 'viewer';
/** Current user's ID on the team server. */
teamUserId?: string;
// --- Budget ---
/** Monthly cost budget in USD. null = unlimited. */
budget?: number | null;
// --- Tone/Voice (Wave 7.3) ---
/** Workspace communication tone preset. */
tone?: 'professional' | 'casual' | 'technical' | 'legal' | 'marketing';
// --- Optimization fields (GEPA/Ax) ---
/** Enable GEPA prompt optimization for this workspace (opt-in, default false). */
optimizationEnabled?: boolean;
/** Daily optimization budget in cents (default 100 = $1/day). Only used when optimizationEnabled is true. */
optimizationBudget?: number;
// --- AI Act compliance (L-17 C2) ---
/** EU AI Act risk classification for this workspace. */
riskLevel?: AIActRiskLevel;
/** ISO timestamp of the last risk classification change. Auto-stamped by WorkspaceManager. */
riskClassifiedAt?: string;
// --- UX-Refactor V2 fields (PRD §15.3; additive + optional for back-compat) ---
/** Free-text description shown in the workspace header/cards. */
description?: string;
/** Workspace classification. Defaults derivable from templateId/group when absent. */
type?: WorkspaceType;
/** Lifecycle status. Treated as 'active' when absent. */
status?: 'active' | 'paused' | 'archived';
/** Agents bound to this workspace (ids). */
agentIds?: string[];
/** Connectors scoped to this workspace (ids). */
connectorIds?: string[];
/** MCP servers scoped to this workspace (ids). */
mcpIds?: string[];
/** ISO timestamp of the last config update. Auto-stamped by update(). */
updatedAt?: string;
/** ISO timestamp of the last activity (chat/agent run) in this workspace. */
lastActiveAt?: string;
}
export interface CreateWorkspaceOptions {
name: string;
group: string;
icon?: string;
model?: string;
personality?: string;
/** Selected agent persona ID (from persona catalog) */
personaId?: string;
/** Template ID chosen during onboarding (e.g. 'sales-pipeline', 'research-project'). */
templateId?: string;
tools?: string[];
skills?: string[];
team?: string | null;
/** Filesystem directory where agent operates and generates files. */
directory?: string;
// --- Team Mode fields (Phase 5) ---
teamId?: string;
teamServerUrl?: string;
teamRole?: 'owner' | 'admin' | 'member' | 'viewer';
teamUserId?: string;
// --- Tone/Voice (Wave 7.3) ---
tone?: 'professional' | 'casual' | 'technical' | 'legal' | 'marketing';
// --- Budget ---
budget?: number | null;
// --- AI Act compliance (L-17 C2) ---
/** Initial risk level (usually derived from template). */
riskLevel?: AIActRiskLevel;
// --- Optimization fields (GEPA/Ax) ---
optimizationEnabled?: boolean;
optimizationBudget?: number;
// --- UX-Refactor V2 fields (PRD §15.3) ---
/** Free-text description shown in the workspace header/cards. */
description?: string;
/** Workspace classification (defaults derivable from templateId/group). */
type?: WorkspaceType;
}
interface WorkspacesMeta {
defaultWorkspace?: string | null;
}
/**
* WorkspaceManager manages workspace CRUD, groups, and directory structure.
* Each workspace lives under {baseDir}/workspaces/{id}/ with:
* - workspace.json (config)
* - workspace.mind (SQLite .mind file, created empty)
* - sessions/ (JSONL session logs)
*/
export class WorkspaceManager {
private readonly workspacesDir: string;
private readonly metaPath: string;
constructor(private readonly baseDir: string) {
this.workspacesDir = path.join(baseDir, 'workspaces');
this.metaPath = path.join(baseDir, 'workspaces-meta.json');
if (!fs.existsSync(this.workspacesDir)) {
fs.mkdirSync(this.workspacesDir, { recursive: true });
}
}
/**
* Create a new workspace with directory structure and config.
*/
create(options: CreateWorkspaceOptions): WorkspaceConfig {
return this.createWithId(this.generateId(options.name), options);
}
/**
* Ensure a workspace with the given id exists. Idempotent — returns the
* existing config unchanged if the workspace already exists; otherwise
* creates it with the supplied id (bypassing slug-collision handling in
* generateId, since callers construct ids from trusted internal state
* like CWD-derived prefixes — e.g. SessionStart hooks).
*
* Use this from auto-attach paths (e.g. save_memory with a workspace arg
* that names a workspace not yet created on disk). Direct-create flows
* with user-supplied names should still go through `create()` so the
* generateId collision logic runs.
*/
// Reverse-ported from OSS hive-mind (oss-drift triage R4, 2026-06-11).
ensure(id: string, options: Partial<CreateWorkspaceOptions> = {}): WorkspaceConfig {
const existing = this.get(id);
if (existing) return existing;
return this.createWithId(id, {
...options,
name: options.name ?? id,
group: options.group ?? 'auto',
});
}
/**
* Shared create path: write directory structure + config for an exact id.
*/
private createWithId(id: string, options: CreateWorkspaceOptions): WorkspaceConfig {
const wsDir = path.join(this.workspacesDir, id);
fs.mkdirSync(wsDir, { recursive: true });
fs.mkdirSync(path.join(wsDir, 'sessions'), { recursive: true });
// Touch workspace.mind — MindDB will init schema when first opened
fs.writeFileSync(path.join(wsDir, 'workspace.mind'), '');
const config: WorkspaceConfig = {
id,
name: options.name,
group: options.group,
...(options.icon !== undefined && { icon: options.icon }),
...(options.model !== undefined && { model: options.model }),
...(options.personality !== undefined && { personality: options.personality }),
...(options.personaId !== undefined && { personaId: options.personaId }),
...(options.templateId !== undefined && { templateId: options.templateId }),
...(options.tools !== undefined && { tools: options.tools }),
...(options.skills !== undefined && { skills: options.skills }),
...(options.team !== undefined && { team: options.team }),
...(options.directory !== undefined && { directory: options.directory }),
...(options.teamId !== undefined && { teamId: options.teamId }),
...(options.teamServerUrl !== undefined && { teamServerUrl: options.teamServerUrl }),
...(options.teamRole !== undefined && { teamRole: options.teamRole }),
...(options.teamUserId !== undefined && { teamUserId: options.teamUserId }),
...(options.tone !== undefined && { tone: options.tone }),
...(options.optimizationEnabled !== undefined && { optimizationEnabled: options.optimizationEnabled }),
...(options.optimizationBudget !== undefined && { optimizationBudget: options.optimizationBudget }),
...(options.riskLevel !== undefined && {
riskLevel: options.riskLevel,
riskClassifiedAt: new Date().toISOString(),
}),
created: new Date().toISOString(),
};
fs.writeFileSync(
path.join(wsDir, 'workspace.json'),
JSON.stringify(config, null, 2),
'utf-8'
);
return config;
}
/**
* List all workspaces by reading workspace.json from each subdirectory.
*/
list(): WorkspaceConfig[] {
if (!fs.existsSync(this.workspacesDir)) return [];
const entries = fs.readdirSync(this.workspacesDir, { withFileTypes: true });
const configs: WorkspaceConfig[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const configPath = path.join(this.workspacesDir, entry.name, 'workspace.json');
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, 'utf-8');
configs.push(JSON.parse(raw) as WorkspaceConfig);
}
}
return configs;
}
/**
* List workspaces filtered by group name.
*/
listByGroup(group: string): WorkspaceConfig[] {
return this.list().filter(ws => ws.group === group);
}
/**
* List all unique group names.
*/
listGroups(): string[] {
const groups = new Set(this.list().map(ws => ws.group));
return [...groups];
}
/**
* Get a workspace by ID. Returns null if not found.
*/
get(id: string): WorkspaceConfig | null {
const configPath = path.join(this.workspacesDir, id, 'workspace.json');
if (!fs.existsSync(configPath)) return null;
const raw = fs.readFileSync(configPath, 'utf-8');
return JSON.parse(raw) as WorkspaceConfig;
}
/**
* Partially update a workspace config.
* When `riskLevel` changes, `riskClassifiedAt` is auto-stamped with the
* current ISO timestamp (EU AI Act Art. 14 provenance requirement).
*/
update(id: string, updates: Partial<Omit<WorkspaceConfig, 'id' | 'created'>>): void {
const existing = this.get(id);
if (!existing) throw new Error(`Workspace not found: ${id}`);
const now = new Date().toISOString();
const stamped: Partial<WorkspaceConfig> =
'riskLevel' in updates && updates.riskLevel !== existing.riskLevel
? { ...updates, riskClassifiedAt: now, updatedAt: now }
: { ...updates, updatedAt: now };
const updated = { ...existing, ...stamped };
const configPath = path.join(this.workspacesDir, id, 'workspace.json');
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), 'utf-8');
}
/**
* Delete a workspace by removing its entire directory.
*/
delete(id: string): void {
const wsDir = path.join(this.workspacesDir, id);
if (fs.existsSync(wsDir)) {
fs.rmSync(wsDir, { recursive: true, force: true });
}
}
/**
* Check whether a workspace is team-connected (has a teamId).
*/
isTeamWorkspace(id: string): boolean {
const ws = this.get(id);
return ws !== null && typeof ws.teamId === 'string' && ws.teamId.length > 0;
}
/**
* List only team-connected workspaces.
*/
listTeamWorkspaces(): WorkspaceConfig[] {
return this.list().filter(ws => typeof ws.teamId === 'string' && ws.teamId.length > 0);
}
/**
* Get the path to a workspace's .mind file.
*/
getMindPath(id: string): string {
return path.join(this.workspacesDir, id, 'workspace.mind');
}
/**
* Set the default workspace ID in workspaces-meta.json.
*/
setDefault(id: string): void {
if (!this.get(id)) throw new Error(`Workspace not found: ${id}`);
const meta = this.loadMeta();
meta.defaultWorkspace = id;
this.saveMeta(meta);
}
/**
* Get the default workspace ID. Returns null if none set.
*/
getDefault(): string | null {
const meta = this.loadMeta();
return meta.defaultWorkspace ?? null;
}
/**
* Ensure at least one workspace exists. If none, create a default one
* and mark it as the default. Idempotent — safe to call on every startup.
*/
ensureDefault(options?: Partial<CreateWorkspaceOptions>): WorkspaceConfig {
const existing = this.list();
if (existing.length > 0) {
const defaultId = this.getDefault();
const found = defaultId ? this.get(defaultId) : null;
return found ?? existing[0];
}
const ws = this.create({
name: 'Default Workspace',
group: 'Personal',
personaId: 'researcher',
...options,
});
this.setDefault(ws.id);
return ws;
}
/**
* Generate a slug-based ID from a workspace name.
* Handles duplicates by appending -2, -3, etc.
*/
generateId(name: string): string {
const base = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!this.workspaceExists(base)) return base;
let counter = 2;
while (this.workspaceExists(`${base}-${counter}`)) {
counter++;
}
return `${base}-${counter}`;
}
private workspaceExists(id: string): boolean {
return fs.existsSync(path.join(this.workspacesDir, id));
}
private loadMeta(): WorkspacesMeta {
if (fs.existsSync(this.metaPath)) {
const raw = fs.readFileSync(this.metaPath, 'utf-8');
return JSON.parse(raw) as WorkspacesMeta;
}
return {};
}
private saveMeta(meta: WorkspacesMeta): void {
fs.writeFileSync(this.metaPath, JSON.stringify(meta, null, 2), 'utf-8');
}
}