/** * pi-loom: Memory Store — SQLite-backed storage with temporal semantics * * Tables: * memories - all memory types with provenance + derivation DAG * memories_fts - FTS5 full-text search with porter stemming * vec_memories - sqlite-vec KNN vector search (1536 dims) * entity_edges - typed relations between ESR entities * raw_events - immutable event audit log * session_summaries - LLM-generated session digests * episodes - session-level temporal summaries * * Types and helpers have been extracted to types.ts. */ import { createHash } from "node:crypto"; import type Database from "better-sqlite3"; import { embedText } from "./embed.js"; import type { GraphProvider } from "./graph-provider.js"; import { SqliteGraphProvider } from "./graph-provider.js"; import { memoryEdgeExpansionWeight } from "./memory-edges.js"; import { ConstraintStore, EpisodeStore, RawEventStore } from "./store-modules.js"; import type { TemporalFilter } from "./temporal.js"; import { normalizeQuery, parseTemporalQuery } from "./temporal.js"; import type { ConstraintRow, DerivationLink, EntityEdgeRow, EpisodeRow, MemoryEdgeRow, MemRow, MemStatus, Provenance, RawEventRow, SessionSummaryRow, } from "./types.js"; import { estimateTokens, genId, getDbDir, getDbPath, openDb, parseTags } from "./types.js"; // Re-export for backward compatibility export type { ConstraintRow, DerivationLink, EntityEdgeRow, EpisodeRow, MemoryEdgeRow, MemRow, MemStatus, Provenance, RawEventRow, SessionSummaryRow, }; export { estimateTokens, getDbDir, getDbPath, openDb, parseTags }; function inferKindFromTags(tags?: string[]): string { if (!tags || tags.length === 0) return "memory"; const tagSet = new Set(tags); const orderedKinds = ["procedure", "handoff", "decision", "profile", "insight", "constraint", "fact"]; for (const kind of orderedKinds) { if (tagSet.has(kind)) return kind; } if (tagSet.has("architecture") || tagSet.has("principle")) return "decision"; return "memory"; } export interface HybridSearchParams { query?: string; queryEmbedding?: number[]; entity_id?: string; /** Pre-filter: only search memories belonging to this entity_id. */ entity_filter?: string; /** Pre-filter: only return memories in this MemoryNode scope. */ scope_type?: string; /** Pre-filter: only return memories for this scope_id; global scope_id NULL remains eligible. */ scope_id?: string; /** Visibility boundary. Defaults to project recall: project/shared only. */ visibility?: VisibilityFilter; limit?: number; compact?: boolean; weights?: { fts5?: number; vector?: number; recency?: number; graph?: number; access?: number }; temporal?: TemporalFilter; provenance_filter?: Provenance[]; } export type VisibilityFilter = "private" | "project" | "shared"; export interface HybridSignalScores { fts5: number; vec: number; recency: number; graph: number; access: number; hop: number; } export interface HybridSearchResult { mem: MemRow; score: number; scores: HybridSignalScores; } export interface LoomHealthCheck { dbPath: string; activeMemories: number; ftsRows: number; ftsSynced: boolean; vecLoaded: boolean; embeddingRows: number; embeddingCoverage: number; rawEvents: number; entityEdges: number; hasEmbedConfig: boolean; hasLocalEmbedConfig: boolean; hasDreamModel: boolean; hasFactModel: boolean; } export interface LoomReviewProposal { action: "merge" | "supersede" | "promote_to_procedure" | "archive" | "contradicts"; memory_ids: string[]; reason: string; confidence: number; } export interface LoomReviewResult { scope?: { scope_type?: string; scope_id?: string }; proposals: LoomReviewProposal[]; } export interface LoomApplyResult { action: LoomReviewProposal["action"]; memory_ids: string[]; archived_ids: string[]; created_ids: string[]; edge_ids: string[]; } const REVIEW_ACTION_PRIORITY: Record = { supersede: 5, contradicts: 4, merge: 3, promote_to_procedure: 2, archive: 1, }; function proposalKey(proposal: LoomReviewProposal): string { const ids = proposal.action === "supersede" || proposal.action === "promote_to_procedure" || proposal.action === "archive" ? proposal.memory_ids : [...proposal.memory_ids].sort(); return `${proposal.action}:${ids.join(":")}`; } function rankReviewProposals(proposals: LoomReviewProposal[], limit: number): LoomReviewProposal[] { const bestByKey = new Map(); for (const proposal of proposals) { const key = proposalKey(proposal); const existing = bestByKey.get(key); if (!existing || proposal.confidence > existing.confidence) { bestByKey.set(key, proposal); } } return [...bestByKey.values()] .sort((a, b) => { const priority = REVIEW_ACTION_PRIORITY[b.action] - REVIEW_ACTION_PRIORITY[a.action]; if (priority !== 0) return priority; const confidence = b.confidence - a.confidence; if (confidence !== 0) return confidence; return b.memory_ids.length - a.memory_ids.length; }) .slice(0, limit); } const PROCEDURE_STOP_WORDS = new Set([ "after", "always", "before", "build", "check", "changing", "should", "tests", "then", "when", "with", ]); function procedureTerms(content: string): Set { const terms = new Set(); for (const term of content.toLowerCase().split(/[^a-z0-9_]+/)) { if (term.length < 4 || PROCEDURE_STOP_WORDS.has(term)) continue; terms.add(term); } return terms; } function looksUnsafeForProcedurePromotion(content: string): boolean { return /\b(?:ignore\s+(?:previous|all)\s+(?:instructions|rules)|reveal\s+(?:secrets?|tokens?|credentials?)|exfiltrate|disable\s+(?:auth|authorization|safety)|leak\s+(?:private|secret|credentials?))\b/i.test( content, ); } function failedExperiencePenalty(mem: MemRow): number { if (mem.kind === "procedure" || (mem.confidence ?? 1) > 0.5) return 0; const tags = parseTags(mem.tags); const markedFailed = tags.some((tag) => ["failed", "failure", "caution"].includes(tag)); if (!markedFailed && !/\b(?:failed attempt|did not fix|do not use|caution)\b/i.test(mem.content)) return 0; return 0.25; } function termOverlapRatio(source: Set, target: Set): number { if (source.size === 0) return 0; let matched = 0; for (const term of source) { if (target.has(term)) matched++; } return matched / source.size; } function sameMemoryScope(a: MemRow, b: MemRow): boolean { return (a.scope_type ?? "repo") === (b.scope_type ?? "repo") && (a.scope_id ?? "") === (b.scope_id ?? ""); } function memoryTime(mem: MemRow): number { return new Date(mem.valid_at || mem.created_at).getTime(); } function procedureSupersedePair(a: MemRow, b: MemRow): [MemRow, MemRow] | null { if (a.kind !== "procedure" || b.kind !== "procedure" || !sameMemoryScope(a, b)) return null; const aTerms = procedureTerms(a.content); const bTerms = procedureTerms(b.content); const aCoversB = termOverlapRatio(bTerms, aTerms); const bCoversA = termOverlapRatio(aTerms, bTerms); const aNewer = memoryTime(a) > memoryTime(b); const bNewer = memoryTime(b) > memoryTime(a); const aMoreSpecific = a.content.length >= b.content.length + 12 && aCoversB >= 0.5; const bMoreSpecific = b.content.length >= a.content.length + 12 && bCoversA >= 0.5; if ((aNewer || aMoreSpecific) && aMoreSpecific) return [b, a]; if ((bNewer || bMoreSpecific) && bMoreSpecific) return [a, b]; return null; } export class LoomStore { private db: Database.Database; private _vecLoaded: boolean; readonly graphProvider: GraphProvider; readonly rawEvents: RawEventStore; readonly episodes: EpisodeStore; readonly constraints: ConstraintStore; constructor(db: Database.Database, graphProvider?: GraphProvider) { this.db = db; this._ensureMemoryGraphSchema(); this._vecLoaded = this._checkVecLoaded(); this.graphProvider = graphProvider ?? new SqliteGraphProvider(db); this.rawEvents = new RawEventStore(db); this.episodes = new EpisodeStore(db); this.constraints = new ConstraintStore(db); } private _ensureMemoryGraphSchema(): void { const cols: Array<[string, string]> = [ ["kind", "TEXT NOT NULL DEFAULT 'memory'"], ["scope_type", "TEXT NOT NULL DEFAULT 'repo'"], ["scope_id", "TEXT"], ["confidence", "REAL NOT NULL DEFAULT 1.0"], ["visibility", "TEXT NOT NULL DEFAULT 'project'"], ]; for (const [col, def] of cols) { const has = this.db .prepare("SELECT COUNT(*) as cnt FROM pragma_table_info('memories') WHERE name = ?") .get(col) as { cnt: number }; if (has.cnt === 0) this.db.exec(`ALTER TABLE memories ADD COLUMN ${col} ${def}`); } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_mem_kind ON memories(kind); CREATE INDEX IF NOT EXISTS idx_mem_scope ON memories(scope_type, scope_id); CREATE TABLE IF NOT EXISTS memory_edges ( edge_id TEXT PRIMARY KEY, source_id TEXT NOT NULL, target_id TEXT NOT NULL, relation TEXT NOT NULL, confidence REAL DEFAULT 0.5, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_memory_edges_source ON memory_edges(source_id); CREATE INDEX IF NOT EXISTS idx_memory_edges_target ON memory_edges(target_id); CREATE INDEX IF NOT EXISTS idx_memory_edges_relation ON memory_edges(relation); `); } /** Set session context for provenance tracking. Called by bridge/extension. */ setSessionId(sid: string) { (this as any)._sessionId = sid; } private _checkVecLoaded(): boolean { try { const row = this.db .prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name='vec_memories'") .get() as { cnt: number }; return row.cnt > 0; } catch { return false; } } /** Whether vector search via sqlite-vec is available. */ get vecLoaded(): boolean { return this._vecLoaded; } // ── CRUD ────────────────────────────────────────────── store(params: { content: string; fact_summary?: string; entity_id?: string; kind?: string; scope_type?: string; scope_id?: string; confidence?: number; visibility?: string; importance?: number; valid_at?: string; expire_at?: string; tags?: string[]; provenance?: Provenance; // v1.0: string enum instead of JSON derivation?: DerivationLink[]; // v1.0: explicit derivation chain }): MemRow { const kind = params.kind ?? inferKindFromTags(params.tags); // Content-hash dedup: same content+entity within 24h returns existing const hash = createHash("sha256") .update(params.content + (params.entity_id ?? "") + kind) .digest("hex") .slice(0, 12); const existing = this.db .prepare( `SELECT id FROM memories WHERE content_hash = ? AND status = 'active' AND created_at > datetime('now', '-1 day') LIMIT 1`, ) .get(hash) as { id: string } | undefined; if (existing) return this.get(existing.id)!; const id = genId(); const provenance = params.provenance ?? "direct"; const derivation = JSON.stringify(params.derivation ?? []); this.db .prepare(` INSERT INTO memories ( id, content, fact_summary, entity_id, kind, scope_type, scope_id, confidence, visibility, valid_at, expire_at, importance, status, tags, content_hash, provenance, derivation ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) `) .run( id, params.content, params.fact_summary ?? null, params.entity_id ?? null, kind, params.scope_type ?? "repo", params.scope_id ?? null, params.confidence ?? 1.0, params.visibility ?? "project", params.valid_at ?? new Date().toISOString(), params.expire_at ?? null, params.importance ?? 0.5, params.tags ? JSON.stringify(params.tags) : null, hash, provenance, derivation, ); const row = this.db.prepare("SELECT rowid FROM memories WHERE id = ?").get(id) as { rowid: number }; // Sync to FTS5 this.db .prepare(`INSERT INTO memories_fts(rowid, content, fact_summary) VALUES (?, ?, ?)`) .run(row.rowid, params.content, params.fact_summary ?? ""); // Auto-embed: fire-and-forget (don't block memory storage) const _memId = id; const memRowid = row.rowid; const textToEmbed = params.fact_summary || params.content; this._embedAndStore(memRowid, textToEmbed).catch(() => {}); return this.get(id)!; } /** Generate embedding for content and store in vec_memories. Async, fire-and-forget. */ private async _embedAndStore(rowid: number, text: string): Promise { if (!this._vecLoaded) return; const vector = await embedText(text); if (vector.length === 0) return; try { this.db .prepare("INSERT OR REPLACE INTO vec_memories(rowid, embedding) VALUES (CAST(? AS INTEGER), ?)") .run(rowid, JSON.stringify(vector)); } catch (_err) { // vec insert may fail if rowid already exists — that's OK } } get(id: string): MemRow | undefined { return this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) as MemRow | undefined; } private _extractCodingEntityIds(content: string): string[] { const found = new Set(); for (const m of content.matchAll(/\b(?:[A-Za-z]:)?(?:\.{1,2}\/|\/)?(?:[\w.-]+\/)+[\w.-]+\.[A-Za-z][\w.-]*\b/g)) { found.add(`file:${m[0]}`); } for (const m of content.matchAll(/\b(?:task|issue|bug|feature|fix|pr)[-_:][A-Za-z0-9][\w.-]*\b/gi)) { found.add(m[0].toLowerCase()); } for (const m of content.matchAll(/\b(?:commit|sha)\s+([0-9a-f]{7,40})\b/gi)) { found.add(`commit:${m[1].toLowerCase()}`); } for (const m of content.matchAll(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+(\/[\w./:?-]*)/g)) { found.add(`api:${m[0]}`); } for (const m of content.matchAll(/(?:^|[\s(["'])((?:@[\w.-]+\/)?[\w.-]+)@(?:\^|~)?\d+\.\d+\.\d+/g)) { found.add(`pkg:${m[1]}`); } return [...found]; } /** * Lightweight entity_id extraction: scan content for known entity_ids and * coding-specific entities. Zero LLM cost — pure pattern matching. * Returns list of entity_ids found in the content. */ extractEntityIds(memoryId: string): string[] { const mem = this.get(memoryId); if (!mem) return []; const content = mem.content; const known = this.db .prepare( "SELECT DISTINCT entity_id FROM memories WHERE entity_id IS NOT NULL AND entity_id != '' ORDER BY length(entity_id) DESC LIMIT 500", ) .all() as Array<{ entity_id: string }>; const found = new Set(); for (const { entity_id: eid } of known) { if (eid.length < 4) continue; if (content.includes(eid)) { found.add(eid); } } for (const eid of this._extractCodingEntityIds(content)) found.add(eid); // Add entity tags for each match const currentTags = parseTags(mem.tags); const origLen = currentTags.length; for (const eid of found) { if (!currentTags.includes(`entity:${eid}`)) { currentTags.push(`entity:${eid}`); } } if (currentTags.length > origLen) { this.db.prepare("UPDATE memories SET tags = ? WHERE id = ?").run(JSON.stringify(currentTags), memoryId); } // Create weak MENTIONS edges for each match for (const eid of found) { if (mem.entity_id !== eid) { try { this.linkEntities({ source_entity: eid, target_entity: `memory:${memoryId}`, relation_type: "MENTIONS", confidence: 0.3, }); } catch { /* edge dedup handled internally */ } } } return [...found]; } /** * TrustMem-inspired lightweight memory trust validator. * Zero LLM cost — uses pattern matching and FTS5 similarity. * Returns a trust score 0-1 and list of potential conflicts. */ checkTrust(memoryId: string): { score: number; conflicts: Array<{ id: string; content: string; reason: string }>; warnings: string[]; } { const mem = this.get(memoryId); if (!mem) return { score: 1, conflicts: [], warnings: [] }; const conflicts: Array<{ id: string; content: string; reason: string }> = []; const warnings: string[] = []; let score = 1.0; // 1. Exact content dedup (different entity, same content = potential hallucination) const dups = this.db .prepare( "SELECT id, content, entity_id FROM memories WHERE content_hash = ? AND id != ? AND status = 'active' LIMIT 3", ) .all(mem.content_hash, memoryId) as Array<{ id: string; content: string; entity_id: string | null }>; if (dups.length > 0) { score -= 0.15; for (const d of dups) { conflicts.push({ id: d.id, content: d.content.slice(0, 80), reason: `duplicate content anchored to different entity (${d.entity_id || "none"})`, }); } } // 2. Contradiction patterns on same entity if (mem.entity_id) { const isLifecycleTransition = (a: string, b: string) => /\b(?:ESR\s+)?task\b.*→\s*(?:draft|active|stable|completed|done|resolved)/i.test(a) && /\b(?:ESR\s+)?task\b.*→\s*(?:draft|active|stable|completed|done|resolved)/i.test(b); const CONTRADICT_PAIRS: Array<[RegExp, RegExp, string]> = [ [ /completed|finished|done|stable|resolved|fixed/i, /started|began|active|broken|failed|error/i, "completion vs inception", ], [/success|pass/i, /fail|error|broke/i, "success vs failure"], [/delete|remove|drop/i, /create|add|insert/i, "deletion vs creation"], ]; const sameEntity = this.db .prepare( "SELECT id, content, tags FROM memories WHERE entity_id = ? AND id != ? AND status = 'active' ORDER BY created_at DESC LIMIT 20", ) .all(mem.entity_id, memoryId) as Array<{ id: string; content: string; tags: string | null }>; for (const pair of CONTRADICT_PAIRS) { const isPositive = pair[0].test(mem.content); const isNegative = pair[1].test(mem.content); if (!isPositive && !isNegative) continue; for (const other of sameEntity) { const otherPositive = pair[0].test(other.content); const otherNegative = pair[1].test(other.content); if ((isPositive && otherNegative) || (isNegative && otherPositive)) { if (isLifecycleTransition(mem.content, other.content)) { warnings.push("Opposite lifecycle states detected as a state transition, not a contradiction"); continue; } score -= 0.1; conflicts.push({ id: other.id, content: other.content.slice(0, 80), reason: `possible contradiction: "${mem.content.slice(0, 40)}..." vs "${other.content.slice(0, 40)}..."`, }); } } } // 3. Importance inflation detection if (mem.importance >= 0.9 && mem.content.length < 30) { warnings.push("High importance assigned to very short content — may be noise"); score -= 0.05; } } // 4. Entity-anchored check if (!mem.entity_id && mem.importance >= 0.7) { warnings.push("High-importance memory without entity anchor — harder to retrieve later"); score -= 0.1; } return { score: Math.max(0, score), conflicts, warnings, }; } updateStatus(id: string, status: MemStatus): void { this.db.prepare("UPDATE memories SET status = ? WHERE id = ?").run(status, id); // Remove from FTS5 when no longer active if (status !== "active") { const row = this.db.prepare("SELECT rowid FROM memories WHERE id = ?").get(id) as { rowid: number } | undefined; if (row) { this.db.prepare("DELETE FROM memories_fts WHERE rowid = ?").run(row.rowid); } } } updateImportance(id: string, importance: number): void { this.db.prepare("UPDATE memories SET importance = ? WHERE id = ?").run(importance, id); } // ── Expiration ──────────────────────────────────────── expireOverdue(): number { // Purge raw events first this.purgeRawEvents(); // Remove from FTS5 first this.db .prepare(` DELETE FROM memories_fts WHERE rowid IN ( SELECT rowid FROM memories WHERE status = 'active' AND expire_at IS NOT NULL AND expire_at < datetime('now') ) `) .run(); const result = this.db .prepare( `UPDATE memories SET status = 'expired' WHERE status = 'active' AND expire_at IS NOT NULL AND expire_at < datetime('now')`, ) .run(); return result.changes; } // ── Recall Tracking ────────────────────────────────── /** Called whenever a memory is returned from any recall/search. Extends TTL. */ private _trackRecall(id: string): void { this.db .prepare(`UPDATE memories SET recall_count = recall_count + 1, last_recalled_at = datetime('now') WHERE id = ?`) .run(id); // Extend expiration: base_ttl * (1 + log(recall_count + 1)) this.db .prepare(` UPDATE memories SET expire_at = CASE WHEN julianday(datetime('now', '+' || CAST(7 * (1.0 + LN(CAST(recall_count + 1 AS REAL) / 2.302585)) AS INTEGER) || ' days')) > julianday(expire_at) THEN datetime('now', '+' || CAST(7 * (1.0 + LN(CAST(recall_count + 1 AS REAL) / 2.302585)) AS INTEGER) || ' days') ELSE expire_at END WHERE id = ? AND expire_at IS NOT NULL AND recall_count > 0 `) .run(id); } /** Compute recency score for RRF (0=oldest, 1=most recent). */ private _recencyScore(createdAt: string): number { const ageDays = (Date.now() - new Date(createdAt).getTime()) / 86400000; return 1.0 / (1.0 + 0.1 * ageDays); // λ=0.1: half-life ~10 days } /** * Compute access-frequency decay score for retrieval ranking. * Mirrors Mem0 v3 Memory Decay: frequently-accessed memories surface higher. * * Formula: log2(1 + recall_count) / (1 + 0.05 * days_since_access) * - recall_count=1, accessed now → ~0.69 * - recall_count=5, accessed now → ~1.97 * - recall_count=1, accessed 30d → ~0.28 * - recall_count=5, accessed 30d → ~0.79 * * Range: 0.0–3.0+, clamped to [0, 2.0] for stable RRF contribution. */ private _accessScore(mem: MemRow): number { const count = mem.recall_count || 0; if (count === 0) return 0; const countScore = Math.log2(1 + count); const daysSince = mem.last_recalled_at ? (Date.now() - new Date(mem.last_recalled_at).getTime()) / 86400000 : 30; // never recalled → treat as 30 days stale const decay = 1 / (1 + 0.05 * daysSince); return Math.min(countScore * decay, 2.0); } /** * v1.0: Derivation boost — reward memories with provable provenance chains. * Direct-from-event gets +0.05, multi-source consensus gets +0.03, * weak/low-weight derivations get slight penalty. * Range: ~ -0.02 to +0.08 (small relative to 0.0–1.0 signal scores). */ private _derivationBoost(mem: MemRow): number { if (!mem.derivation || mem.derivation === "[]") return 0; let boost = 0; try { const deriv: DerivationLink[] = JSON.parse(mem.derivation); for (const d of deriv) { if (d.type === "event" && d.weight >= 0.8) boost += 0.05; if (d.type === "memory" && d.weight >= 0.8) boost += 0.02; if (d.weight < 0.3) boost -= 0.01; } // Bonus for consolidated (multi-source consensus) if (mem.provenance === "consolidated_pattern" && deriv.length >= 3) boost += 0.03; } catch { /* parse error — ignore */ } return boost; } /** v1.1: Score fusion — additive (Mem0-style) or RRF weighted sum. */ private _fuseScores( scores: { fts5: number; vec: number; recency: number; graph: number; access: number }, w: { fts5: number; vector: number; recency: number; graph: number; access: number }, additive: boolean, ): number { if (additive) { let sum = 0, n = 0; if (scores.fts5 > 0) { sum += scores.fts5; n++; } if (scores.vec > 0) { sum += scores.vec; n++; } if (scores.recency > 0) { sum += scores.recency; n++; } if (scores.graph > 0) { sum += scores.graph; n++; } if (scores.access > 0) { sum += scores.access; n++; } return n > 0 ? sum / n : 0; } return ( w.fts5 * scores.fts5 + w.vector * scores.vec + w.recency * scores.recency + w.graph * scores.graph + w.access * scores.access ); } /** * v1.0: Consolidation quality gate — zero LLM cost. * * 回应论文 "Useful Memories Become Faulty When Continuously Updated by LLMs" * (arXiv:2605.12978): LLM 巩固会丢失关键细节甚至产生虚假记忆。 * * 检查:提取源记忆的关键术语(>3 chars),检查它们在巩固结果中的覆盖率。 * 覆盖率 < 0.35 → 标记为 'consolidation-degraded',importance 降至 0.6x。 * 不拒绝巩固(我们不够信号),但降低检索优先级。 * * 返回 degradation tag 或 null。 */ private _consolidationQualityCheck(consolidatedId: string, sourceIds: string[]): string | null { const sources = sourceIds.map((id) => this.get(id)).filter((m): m is MemRow => m !== undefined); if (sources.length < 2) return null; const consolidated = this.get(consolidatedId); if (!consolidated) return null; // Extract key terms from sources: words > 3 chars, lowercase, deduped const stopWords = new Set([ "this", "that", "with", "from", "have", "been", "were", "they", "about", "there", "their", "your", "would", "could", "should", "these", "those", "after", "before", "which", "what", "when", ]); const sourceTerms = new Set(); for (const s of sources) { const text = (s.fact_summary || s.content).toLowerCase(); for (const w of text.split(/[^a-z0-9_]+/)) { if (w.length > 3 && !stopWords.has(w)) sourceTerms.add(w); } } if (sourceTerms.size === 0) return null; // Count how many source terms appear in consolidated const consolidatedLower = (consolidated.fact_summary || consolidated.content).toLowerCase(); let matched = 0; for (const t of sourceTerms) { if (consolidatedLower.includes(t)) matched++; } const coverage = matched / sourceTerms.size; if (coverage < 0.35) { console.error( `[pi-loom] Consolidation degradation detected: ` + `${matched}/${sourceTerms.size} terms (${(coverage * 100).toFixed(0)}%) ` + `from ${sources.length} sources → ${consolidatedId.slice(0, 8)}`, ); return "consolidation-degraded"; } return null; } // ── Recall ──────────────────────────────────────────── recallByEntity(entityId: string, limit = 20, visibility?: VisibilityFilter): MemRow[] { const entityTag = `%entity:${entityId}%`; const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = [entityId, entityTag]; if (visibility === "private" || visibility === "shared") params.push(visibility); const results = this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND (entity_id = ? OR tags LIKE ?) ${visibilityClause} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params, Math.max(limit * 3, limit)) as MemRow[]; const visible = this._filterVisible(results, visibility, limit); for (const r of visible) this._trackRecall(r.id); return visible; } recallActive(limit = 20, visibility?: VisibilityFilter): MemRow[] { const where = ["status = 'active'"]; const params: unknown[] = []; if (visibility === "private" || visibility === "shared") { where.push("visibility = ?"); params.push(visibility); } else { where.push("visibility != 'private'"); } const results = this.db .prepare( `SELECT * FROM memories WHERE ${where.join(" AND ")} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params, Math.max(limit * 3, limit)) as MemRow[]; const visible = this._filterVisible(results, visibility, limit); for (const r of visible) this._trackRecall(r.id); return visible; } private _matchesScope(mem: MemRow, scopeType?: string, scopeId?: string): boolean { if (scopeType && mem.scope_type !== scopeType) return false; if (scopeId && mem.scope_id !== scopeId && mem.scope_id !== null) return false; return true; } private _matchesVisibility(mem: MemRow, visibility?: VisibilityFilter): boolean { if (visibility === "private") return mem.visibility === "private"; if (visibility === "shared") return mem.visibility === "shared"; return mem.visibility !== "private" && !this._derivesFromPrivate(mem); } private _derivesFromPrivate(mem: MemRow): boolean { for (const edge of this.getMemoryEdges(mem.id)) { if (edge.relation !== "derives_from" || edge.source_id !== mem.id) continue; const source = this.get(edge.target_id); if (source?.visibility === "private") return true; } try { const links = JSON.parse(mem.derivation || "[]") as DerivationLink[]; for (const link of links) { if (link.type !== "memory") continue; const source = this.get(link.id); if (source?.visibility === "private") return true; } } catch { return false; } return false; } private _rankActiveFallback( limit: number, scopeType?: string, scopeId?: string, visibility?: VisibilityFilter, ): HybridSearchResult[] { const where = ["status = 'active'"]; const params: unknown[] = []; if (scopeType) { where.push("scope_type = ?"); params.push(scopeType); } if (scopeId) { where.push("(scope_id = ? OR scope_id IS NULL)"); params.push(scopeId); } if (visibility === "private" || visibility === "shared") { where.push("visibility = ?"); params.push(visibility); } else { where.push("visibility != 'private'"); } const results = this.db .prepare( `SELECT * FROM memories WHERE ${where.join(" AND ")} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params, limit) as MemRow[]; return this._filterVisible(results, visibility, limit).map((mem) => ({ mem, score: mem.importance, scores: { fts5: 0, vec: 0, recency: this._recencyScore(mem.created_at), graph: 0, access: this._accessScore(mem), hop: 99, }, })); } /** * Recall active memories filtered by tags. * Most relevant for context injection — e.g. tags: ["decision","architecture"]. */ recallByTags(tags: string[], limit = 20, visibility?: VisibilityFilter): MemRow[] { const conditions = tags.map(() => `tags LIKE ?`).join(" OR "); const params = tags.map((t) => `%${t}%`); const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; if (visibility === "private" || visibility === "shared") params.push(visibility); const rows = this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND (${conditions}) ${visibilityClause} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params, Math.max(limit * 3, limit)) as MemRow[]; return this._filterVisible(rows, visibility, limit); } recallByKind(kind: string, limit = 20, scopeType?: string, scopeId?: string, visibility?: VisibilityFilter): MemRow[] { const where = ["status = 'active'", "kind = ?"]; const params: unknown[] = [kind]; if (scopeType) { where.push("scope_type = ?"); params.push(scopeType); } if (scopeId) { where.push("(scope_id = ? OR scope_id IS NULL)"); params.push(scopeId); } if (visibility === "private" || visibility === "shared") { where.push("visibility = ?"); params.push(visibility); } else { where.push("visibility != 'private'"); } const results = this.db .prepare( `SELECT * FROM memories WHERE ${where.join(" AND ")} ORDER BY importance DESC, confidence DESC, created_at DESC LIMIT ?`, ) .all(...params, Math.max(limit * 3, limit)) as MemRow[]; const visible = this._filterVisible(results, visibility, limit); for (const r of visible) this._trackRecall(r.id); return visible; } /** * FTS5 full-text search with BM25 ranking (Mem0-style keyword matching). * Uses porter stemming — "attending" matches "attend", "meetings" matches "meeting". * Falls back to LIKE search for short/partial queries that FTS5 can't handle. */ search( query: string, limit = 20, entityFilter?: string, temporal?: TemporalFilter, visibility?: VisibilityFilter, ): MemRow[] { const sqlLimit = Math.max(limit * 3, limit); // For very short queries (1-2 chars), stick with LIKE if (query.length <= 2) { return this.searchLike(query, limit, temporal, visibility); } try { // Sanitize query for FTS5: escape special chars, add prefix wildcard for partial matches const sanitized = query.replace(/['"*()^]/g, " ").trim(); if (!sanitized) return this.searchLike(query, limit, temporal, visibility); // Add prefix matching: "meet" → "meet*" for stemming const terms = sanitized.split(/\s+/).filter((t) => t.length >= 3); const ftsQuery = terms.map((t) => `"${t}"*`).join(" AND "); // Build date range clause const dateWhere: string[] = []; const dateParams: string[] = []; if (temporal?.startDate) { dateWhere.push("m.created_at >= ?"); dateParams.push(temporal.startDate); } if (temporal?.endDate) { dateWhere.push("m.created_at <= ?"); dateParams.push(`${temporal.endDate}T23:59:59`); } if (temporal?.unexpiredOnly) { dateWhere.push("m.expire_at IS NULL"); } const dateClause = dateWhere.length > 0 ? `AND ${dateWhere.join(" AND ")}` : ""; const _eidFilter = entityFilter ? "AND m.entity_id = ?" : ""; if (entityFilter && dateParams.length > 0) { const rows = this.db .prepare(` SELECT m.* FROM memories m JOIN memories_fts fts ON m.rowid = fts.rowid WHERE m.status = 'active' AND m.entity_id = ? ${dateClause} AND memories_fts MATCH ? ORDER BY bm25(memories_fts, 0.0, 1.0, 0.5) ASC LIMIT ? `) .all(entityFilter, ...dateParams, ftsQuery, sqlLimit) as MemRow[]; return this._filterVisible(rows, visibility, limit); } if (dateParams.length > 0) { const rows = this.db .prepare(` SELECT m.* FROM memories m JOIN memories_fts fts ON m.rowid = fts.rowid WHERE m.status = 'active' ${dateClause} AND memories_fts MATCH ? ORDER BY bm25(memories_fts, 0.0, 1.0, 0.5) ASC LIMIT ? `) .all(...dateParams, ftsQuery, sqlLimit) as MemRow[]; return this._filterVisible(rows, visibility, limit); } const rows = entityFilter ? (this.db .prepare(` SELECT m.* FROM memories m JOIN memories_fts fts ON m.rowid = fts.rowid WHERE m.status = 'active' AND m.entity_id = ? AND memories_fts MATCH ? ORDER BY bm25(memories_fts, 0.0, 1.0, 0.5) ASC LIMIT ? `) .all(entityFilter, ftsQuery, sqlLimit) as MemRow[]) : (this.db .prepare(` SELECT m.* FROM memories m JOIN memories_fts fts ON m.rowid = fts.rowid WHERE m.status = 'active' AND memories_fts MATCH ? ORDER BY bm25(memories_fts, 0.0, 1.0, 0.5) ASC LIMIT ? `) .all(ftsQuery, sqlLimit) as MemRow[]); return this._filterVisible(rows, visibility, limit); } catch (err) { // FTS5 query parse error — fall back to LIKE console.error("[pi-loom] FTS5 search error, falling back to LIKE:", err instanceof Error ? err.message : err); return this.searchLike(query, limit, temporal, visibility); } } /** * LIKE-based search (fallback for short queries or FTS5 parse errors). */ searchLike(query: string, limit = 20, temporal?: TemporalFilter, visibility?: VisibilityFilter): MemRow[] { const dateWhere: string[] = []; const dateParams: string[] = []; if (temporal?.startDate) { dateWhere.push("created_at >= ?"); dateParams.push(temporal.startDate); } if (temporal?.endDate) { dateWhere.push("created_at <= ?"); dateParams.push(`${temporal.endDate}T23:59:59`); } if (temporal?.unexpiredOnly) { dateWhere.push("expire_at IS NULL"); } const dateClause = dateWhere.length > 0 ? `AND ${dateWhere.join(" AND ")}` : ""; const rows = this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND content LIKE ? ${dateClause} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(`%${query}%`, ...dateParams, Math.max(limit * 3, limit)) as MemRow[]; return this._filterVisible(rows, visibility, limit); } private _filterVisible(rows: MemRow[], visibility: VisibilityFilter | undefined, limit: number): MemRow[] { return rows.filter((row) => this._matchesVisibility(row, visibility)).slice(0, limit); } // ═══════════════════════════════════════════════════════════════ // Phase 3: RecMem — Subconscious Layer + Consolidation // ═══════════════════════════════════════════════════════════════ /** * Store a memory in the subconscious layer. * * Subconscious memories have low importance (0.15), short TTL (3 days), * and are tagged "subconscious". They don't trigger LLM extraction on their own — * only after hit_count reaches 3+ do they become consolidation candidates. */ /** * Category-based consolidation thresholds. * Errors get immediate attention (hit≥2). File edits within same session * just get deduped (no LLM). Cross-session everything uses lower thresholds. */ private static readonly CATEGORY_THRESHOLDS: Record = { error: 2, git: 3, esr: 3, edit: 8, // same-session: wait a lot. cross-session uses different logic env: 8, read: 10, // very rarely worth consolidating _default: 5, }; private static inferCategory(tags: string[]): string { if (tags.includes("error")) return "error"; if (tags.includes("git")) return "git"; if (tags.includes("esr")) return "esr"; if (tags.includes("wrote") || tags.includes("edited")) return "edit"; if (tags.includes("env")) return "env"; if (tags.includes("read")) return "read"; return "_default"; } storeSubconscious(params: { content: string; entity_id?: string; tags?: string[]; session_id?: string }): MemRow { const category = LoomStore.inferCategory(params.tags ?? []); const mem = this.store({ content: params.content, entity_id: params.entity_id, importance: 0.15, tags: [...(params.tags ?? []), "subconscious", `cat:${category}`], expire_at: new Date(Date.now() + 3 * 86400000).toISOString(), provenance: "auto_captured", }); const sessionIds = params.session_id ? JSON.stringify([params.session_id]) : "[]"; this.db .prepare("UPDATE memories SET hit_count = 1, last_hit_at = datetime('now'), session_ids = ? WHERE id = ?") .run(sessionIds, mem.id); return this.get(mem.id)!; } /** * Bump hit_count on existing subconscious memories that are similar to * a newly captured memory. Uses cheap LIKE-based overlap (no embedding call): * - Same entity_id * - Overlapping tags * - First 50 chars of content match * * Returns count of bumped memories. */ bumpSimilarHits(params: { content: string; entity_id?: string | null; tags?: string[]; session_id?: string; }): number { const memTags = params.tags ?? []; const tagConditions = memTags.length > 0 ? memTags.map((t) => `tags LIKE '%${t.replace(/'/g, "''")}%'`).join(" OR ") : "0"; const sessionId = params.session_id ?? ""; const result = this.db .prepare(` UPDATE memories SET hit_count = MIN(hit_count + 1, 10), last_hit_at = datetime('now'), session_ids = CASE WHEN session_ids = '[]' THEN ? WHEN session_ids NOT LIKE '%' || ? || '%' THEN json_insert(session_ids, '$[#]', ?) ELSE session_ids END WHERE status = 'active' AND tags LIKE '%subconscious%' AND hit_count < 10 AND ( (entity_id IS NOT NULL AND ? IS NOT NULL AND entity_id = ?) OR (${tagConditions}) OR (substr(content, 1, 50) = substr(?, 1, 50)) ) `) .run( sessionId ? JSON.stringify([sessionId]) : "[]", sessionId, sessionId, params.entity_id ?? "", params.entity_id ?? "", params.content, ); return result.changes; } /** * Find subconscious memories that have accumulated enough hits for consolidation. * Uses category-aware thresholds: errors at 2, git/esr at 3, edits at 8. * * When minSessionDistinct >= 2, only returns memories that have appeared * in at least that many different sessions (cross-session recurrence). */ /** * Discover active tag categories from subconscious memories. * Returns tags sorted by frequency. Used for auto-discovery in consolidation * loops instead of hardcoded category lists (e.g. ["git", "esr", "error"]). * * Filters out structural tags (subconscious, auto-captured, cat:*, consolidated-into:*). */ getActiveSubconsciousTags(minHitCount = 2, limit = 10): Array<{ tag: string; count: number }> { const rows = this.db .prepare(` SELECT tags FROM memories WHERE status = 'active' AND tags LIKE '%subconscious%' AND hit_count >= ? ORDER BY hit_count DESC LIMIT 200 `) .all(minHitCount) as Array<{ tags: string }>; const tagCounts = new Map(); for (const row of rows) { for (const t of parseTags(row.tags)) { if (t === "subconscious" || t === "auto-captured" || t.startsWith("cat:") || t.startsWith("consolidated-into:")) continue; tagCounts.set(t, (tagCounts.get(t) || 0) + 1); } } return [...tagCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, limit) .map(([tag, count]) => ({ tag, count })); } findConsolidationCandidates(params?: { category?: string; limit?: number; minSessionDistinct?: number }): MemRow[] { const limit = params?.limit ?? 3; const category = params?.category; const minSessions = params?.minSessionDistinct ?? 1; let hitFilter = "hit_count >= 3"; if (category) { const threshold = LoomStore.CATEGORY_THRESHOLDS[category] ?? LoomStore.CATEGORY_THRESHOLDS._default; hitFilter = `hit_count >= ${threshold}`; } else { hitFilter = "hit_count >= 2"; } const categoryFilter = category ? `AND tags LIKE '%cat:${category}%'` : ""; const consumedFilter = "AND (tags NOT LIKE '%consolidated-into:%')"; const sessionFilter = minSessions >= 2 ? `AND (session_ids IS NOT NULL AND session_ids != '[]' AND json_array_length(session_ids) >= ${minSessions})` : ""; return this.db .prepare(` SELECT * FROM memories WHERE status = 'active' AND tags LIKE '%subconscious%' ${consumedFilter} AND ${hitFilter} ${categoryFilter} ${sessionFilter} ORDER BY hit_count DESC, last_hit_at DESC LIMIT ? `) .all(limit) as MemRow[]; } /** * Find similar subconscious memories using embedding similarity (sqlite-vec KNN). * Falls back to tag/entity/content overlap when vec is not loaded. * * @param sourceMem - the source memory to find similar ones for * @param threshold - cosine similarity threshold (default 0.85) * @param limit - max results */ findSimilarByEmbedding(sourceMem: MemRow, threshold = 0.85, limit = 5): Array<{ mem: MemRow; similarity: number }> { if (!this._vecLoaded) { return this._findSimilarByOverlap(sourceMem, threshold, limit); } const sourceRowid = this.db.prepare("SELECT rowid FROM memories WHERE id = ?").get(sourceMem.id) as | { rowid: number } | undefined; if (!sourceRowid) return []; try { const rows = this.db .prepare(` SELECT m.*, v.distance FROM vec_memories v JOIN memories m ON m.rowid = v.rowid WHERE v.embedding MATCH (SELECT embedding FROM vec_memories WHERE rowid = ?) AND m.status = 'active' AND m.tags LIKE '%subconscious%' AND m.id != ? AND v.distance <= ? ORDER BY v.distance LIMIT ? `) .all(sourceRowid.rowid, sourceMem.id, 1.0 - threshold, limit) as Array; return rows.map((r) => ({ mem: r, similarity: 1.0 - r.distance })); } catch { return this._findSimilarByOverlap(sourceMem, threshold, limit); } } /** Tag/entity/content overlap fallback when embeddings unavailable. */ private _findSimilarByOverlap( sourceMem: MemRow, _threshold = 0.85, limit = 5, ): Array<{ mem: MemRow; similarity: number }> { const sourceTags = parseTags(sourceMem.tags); const sourcePrefix = sourceMem.content.slice(0, 50); const conditions: string[] = []; const params: string[] = []; if (sourceMem.entity_id) { conditions.push("entity_id = ?"); params.push(sourceMem.entity_id); } if (sourceTags.length > 0) { const tagConds = sourceTags.map((t) => { params.push(`%${t}%`); return "tags LIKE ?"; }); conditions.push(`(${tagConds.join(" OR ")})`); } params.push(sourcePrefix); conditions.push("substr(content, 1, 50) = ?"); const sql = ` SELECT * FROM memories WHERE status = 'active' AND tags LIKE '%subconscious%' AND id != ? AND (${conditions.join(" OR ")}) LIMIT ? `; const rows = this.db.prepare(sql).all(sourceMem.id, ...params, limit) as MemRow[]; return rows .map((r) => { // Simple tag overlap similarity score (approximate) const rTags = parseTags(r.tags); const overlap = sourceTags.filter((t) => rTags.includes(t)).length; const sim = Math.min(0.95, 0.5 + overlap * 0.15 + (r.entity_id === sourceMem.entity_id ? 0.3 : 0)); return { mem: r, similarity: sim }; }) .filter((r) => r.similarity >= 0.7); } /** * Consolidate a group of similar subconscious memories into a single long-term memory. * * Creates a new memory with tags ["consolidated", "recurrence:N"] and boosted importance. * Source memories are tagged with "consolidated-into:" but remain active/searchable. */ consolidate(params: { sourceIds: string[]; content: string; fact_summary: string; entity_id?: string | null; importance: number; }): MemRow { const consolidated = this.store({ content: params.content, fact_summary: params.fact_summary, entity_id: params.entity_id ?? undefined, importance: params.importance, tags: ["consolidated", `recurrence:${params.sourceIds.length}`], provenance: "consolidated_pattern", derivation: params.sourceIds.map((id) => ({ id, type: "memory" as const, weight: 0.8 })), }); // v1.0: Consolidation quality gate (回应 Useful Memories Become Faulty, arXiv:2605.12978) // Check term overlap between consolidated and source memories — zero LLM cost. // Low overlap → possible over-generalization → reduce importance, tag as degraded. const qualityTag = this._consolidationQualityCheck(consolidated.id, params.sourceIds); if (qualityTag) { const tags = parseTags(consolidated.tags); tags.push(qualityTag); this.db .prepare("UPDATE memories SET tags = ?, importance = MAX(importance * 0.6, 0.15) WHERE id = ?") .run(JSON.stringify(tags), consolidated.id); } // Tag source memories as consolidated-into AND reset hit_count to prevent re-trigger for (const sourceId of params.sourceIds) { const source = this.get(sourceId); if (!source) continue; const sourceTags = parseTags(source.tags); const newTags = JSON.stringify([ ...sourceTags.filter((t) => !t.startsWith("consolidated-into")), `consolidated-into:${consolidated.id}`, ]); this.db .prepare("UPDATE memories SET tags = ?, importance = MAX(importance, 0.3) * 1.5, hit_count = 0 WHERE id = ?") .run(newTags, sourceId); } return consolidated; } /** * Phase 2.2: Cross-session recurrence boost. * * Detects query terms that recur across 3+ distinct sessions (by valid_at date). * When found, boosts memories from ALL sessions where the recurring topic appears, * ensuring later sessions aren't buried by early ones with higher BM25 scores. * * Example: query "WAL desync bug" → term "desync" appears in 5/6 sessions → * all sessions with "desync" memories get boosted, not just top-3 BM25 hits. */ private _crossSessionBoost(query: string, limit: number): Map { const boost = new Map(); // Extract key terms (4+ chars, exclude common question words) const stopWords = new Set([ "what", "when", "where", "which", "that", "this", "with", "from", "have", "been", "were", "they", "about", "there", "their", "your", "would", "could", "should", "does", "these", "those", "after", "before", "happen", "across", "between", "during", "still", "first", "second", ]); const terms = query .toLowerCase() .replace(/['"*()^?.,!;:#-]/g, " ") .split(/\s+/) .filter((t) => t.length >= 4 && !stopWords.has(t)) .slice(0, 6); if (terms.length === 0) return boost; const recurringDates = new Set(); let totalRecurringTerms = 0; for (const term of terms) { try { // Count distinct dates where this term appears const row = this.db .prepare(` SELECT COUNT(DISTINCT substr(valid_at, 1, 10)) as cnt FROM memories WHERE status = 'active' AND (content LIKE ? OR fact_summary LIKE ?) `) .get(`%${term}%`, `%${term}%`) as { cnt: number }; if (row.cnt >= 3) { // Recurring term — collect all dates where it appears const dates = this.db .prepare(` SELECT DISTINCT substr(valid_at, 1, 10) as d FROM memories WHERE status = 'active' AND (content LIKE ? OR fact_summary LIKE ?) `) .all(`%${term}%`, `%${term}%`) as Array<{ d: string }>; for (const { d } of dates) recurringDates.add(d); totalRecurringTerms++; } } catch { // LIKE query failure — skip this term } } if (recurringDates.size < 3) return boost; // Recurrence factor: normalized by session spread and term coverage ratio const spreadFactor = Math.log2(recurringDates.size + 1) / Math.log2(10); const termFactor = Math.min(1.0, totalRecurringTerms / terms.length); // Boost coefficient: 0.28 gives recurrence signal equal footing with entity recall (0.3) // and ensures later sessions aren't buried by early sessions with high BM25 scores. const boostCoeff = 0.28 * spreadFactor * termFactor; // Pull memories from all recurring dates (even dates with low FTS5 BM25) for (const date of recurringDates) { try { const mems = this.db .prepare(` SELECT * FROM memories WHERE status = 'active' AND substr(valid_at, 1, 10) = ? ORDER BY importance DESC LIMIT ? `) .all(date, Math.ceil(limit / 3)) as MemRow[]; for (let i = 0; i < mems.length; i++) { const m = mems[i]; const score = boostCoeff + 0.1 / (i + 2); boost.set(m.id, Math.max(boost.get(m.id) ?? 0, score)); } } catch { // skip date } } return boost; } /** * Three-way hybrid search with Reciprocal Rank Fusion. * * Signals: * 1. FTS5 BM25 keyword matching (porter stemming) * 2. Vector semantic similarity (sqlite-vec KNN, cosine distance) * 3. Recency weighting (exponential decay, λ=0.1) * * RRF formula: score = α * fts5_norm + β * vec_norm + γ * recency_norm * Default weights: α=0.40, β=0.40, γ=0.20 * * Falls back gracefully when vec0 is not available (α=0.55, β=0, γ=0.45). */ searchHybrid(params: HybridSearchParams): MemRow[] { return this.searchHybridExplain(params).map(({ mem }) => { this._trackRecall(mem.id); if (params.compact && mem.fact_summary) { return { ...mem, content: mem.fact_summary }; } return mem; }); } /** Return ranked hybrid search results with per-signal score breakdown. */ searchHybridExplain(params: HybridSearchParams): HybridSearchResult[] { const limit = params.limit ?? 20; const hasVec = this._vecLoaded; const w = { fts5: params.weights?.fts5 ?? (hasVec ? 0.33 : 0.45), vector: params.weights?.vector ?? (hasVec ? 0.33 : 0.0), recency: params.weights?.recency ?? (hasVec ? 0.12 : 0.35), graph: params.weights?.graph ?? (hasVec ? 0.1 : 0.1), access: params.weights?.access ?? (hasVec ? 0.12 : 0.1), }; const seen = new Map< string, { mem: MemRow; scores: HybridSignalScores } >(); // Helper: track a result with scores const add = (mem: MemRow, signal: "fts5" | "vec" | "recency" | "graph", score: number, hop = 99) => { const entry = seen.get(mem.id); if (entry) { entry.scores[signal] = Math.max(entry.scores[signal], score); if (signal === "graph") entry.scores.hop = Math.min(entry.scores.hop, hop); } else { seen.set(mem.id, { mem, scores: { fts5: 0, vec: 0, recency: this._recencyScore(mem.created_at), graph: 0, access: this._accessScore(mem), hop: 99, }, }); seen.get(mem.id)!.scores[signal] = score; } }; // Signal 1: FTS5 keyword search (with BM25 ranking) // v1.1: auto-detect temporal intent from query const temporal = params.temporal ?? (params.query ? (parseTemporalQuery(params.query) ?? undefined) : undefined); const cleanQuery = normalizeQuery(temporal?.cleanQuery || params.query || ""); if (cleanQuery.length > 0) { try { const ftsResults = this.search(cleanQuery, limit * 3, params.entity_filter, temporal, params.visibility); for (let i = 0; i < ftsResults.length; i++) { // Normalize BM25 rank to [0, 1]: 1/(rank + k), k=2 const bm25Norm = 1.0 / (i + 2); add(ftsResults[i], "fts5", bm25Norm); } } catch (err) { console.error("[pi-loom] searchHybrid FTS5:", err instanceof Error ? err.message : err); } } // Signal 2: Vector semantic search (sqlite-vec KNN or JS cosine) if (hasVec && params.queryEmbedding && params.queryEmbedding.length > 0) { try { const vecStr = JSON.stringify(params.queryEmbedding); const _eidJoin = params.entity_filter ? "AND m.entity_id = ?" : ""; const rows = params.entity_filter ? (this.db .prepare(` SELECT m.*, v.distance as _vec_dist FROM vec_memories v JOIN memories m ON m.rowid = v.rowid WHERE v.embedding MATCH ? AND m.status = 'active' AND k = ? AND m.entity_id = ? ORDER BY v.distance LIMIT ? `) .all(vecStr, limit * 2, params.entity_filter, limit * 2) as Array) : (this.db .prepare(` SELECT m.*, v.distance as _vec_dist FROM vec_memories v JOIN memories m ON m.rowid = v.rowid WHERE v.embedding MATCH ? AND m.status = 'active' AND k = ? ORDER BY v.distance LIMIT ? `) .all(vecStr, limit * 2, limit * 2) as Array); for (let i = 0; i < rows.length; i++) { add(rows[i], "vec", 1.0 / (1.0 + rows[i]._vec_dist)); } } catch (err) { console.error("[pi-loom] searchHybrid vec:", err instanceof Error ? err.message : err); } } else if (!hasVec && params.queryEmbedding && params.queryEmbedding.length > 0) { // JS cosine fallback try { const oldResults = this._searchByEmbeddingJS(params.queryEmbedding, limit * 2); for (let i = 0; i < oldResults.length; i++) { add(oldResults[i], "vec", oldResults[i]._vec_score / (i + 1)); } } catch (err) { console.error("[pi-loom] searchHybrid JS vec:", err instanceof Error ? err.message : err); } } // Signal 3: Entity recall const seenEntityIds = new Set(); if (params.entity_id) { seenEntityIds.add(params.entity_id); const entityMems = this.recallByEntity(params.entity_id, limit * 2, params.visibility); for (let i = 0; i < entityMems.length; i++) { add(entityMems[i], "fts5", 0.3 + 0.3 / (i + 2)); } } // Signal 4: Entity graph traversal — boost fts5 scores of neighbor memories // by BFS hop distance. Seeds already have entity recall (0.3+), so skip hop=0. // v1.2: Relation-type weighting — supports (0.3x), depends_on (1.0x), others (0.6x). // v1.2: Neighbor cap — at most 15 neighbors per entity (confidence-sorted). if (seenEntityIds.size > 0) { try { const allEdges = this.graphProvider.getAllEdges(); if (allEdges.length > 0) { // v1.2: Relation type weight multiplier const relWeight = (rel: string): number => { switch (rel) { case "depends_on": return 1.0; case "produces": case "validates": case "blocks": return 0.8; case "refines": case "evaluates": return 0.6; case "supports": case "relates_to": case "part_of": return 0.3; default: return 0.5; } }; const adj = new Map(); const edgeConf = new Map(); for (const e of allEdges) { if (!adj.has(e.source_entity)) adj.set(e.source_entity, []); if (!adj.has(e.target_entity)) adj.set(e.target_entity, []); const rw = relWeight(e.relation_type); adj.get(e.source_entity)!.push(e.target_entity); adj.get(e.target_entity)!.push(e.source_entity); const key1 = `${e.source_entity}→${e.target_entity}`; const key2 = `${e.target_entity}→${e.source_entity}`; edgeConf.set(key1, Math.max(edgeConf.get(key1) ?? 0, e.confidence * rw)); edgeConf.set(key2, Math.max(edgeConf.get(key2) ?? 0, e.confidence * rw)); } // v1.2: Cap neighbors per entity to top-15 by weighted confidence for (const [eid, neighbors] of adj) { if (neighbors.length > 15) { const sorted = [...new Set(neighbors)] .map((n) => ({ n, w: edgeConf.get(`${eid}→${n}`) ?? 0 })) .sort((a, b) => b.w - a.w) .slice(0, 15) .map((x) => x.n); adj.set(eid, sorted); } } const hopDist = new Map(); const pathConf = new Map(); const seeds = Array.from(seenEntityIds); for (const s of seeds) { hopDist.set(s, 0); pathConf.set(s, 1.0); } let frontier = seeds; for (let hop = 0; hop < 2 && frontier.length > 0; hop++) { const next: string[] = []; for (const eid of frontier) { const prevConf = pathConf.get(eid) ?? 1.0; for (const nb of adj.get(eid) || []) { if (!hopDist.has(nb)) { hopDist.set(nb, hop + 1); const ec = edgeConf.get(`${eid}→${nb}`) ?? 0.5; pathConf.set(nb, Math.min(prevConf * ec, 1.0)); next.push(nb); } } } frontier = next; } const hopBoost = (h: number, conf: number) => { const base = h === 1 ? 0.25 : h === 2 ? 0.15 : 0; return base * conf; }; for (const [nbId, hop] of hopDist) { if (hop === 0) continue; const conf = pathConf.get(nbId) ?? 0.5; const neighborMems = this.recallByEntity(nbId, Math.ceil(limit / 3), params.visibility); for (let i = 0; i < neighborMems.length; i++) { const boost = hopBoost(hop, conf) + 0.1 / (i + 2); add(neighborMems[i], "fts5", boost); } } } } catch { /* optional */ } } // Signal 4b: MemoryEdge one-hop expansion. // Reuses the same graph score channel without adding a new retrieval subsystem. try { const seeds = [...seen.values()] .sort((a, b) => Math.max(b.scores.fts5, b.scores.vec, b.scores.access) - Math.max(a.scores.fts5, a.scores.vec, a.scores.access)) .slice(0, Math.max(3, limit)); for (const seed of seeds) { for (const edge of this.getMemoryEdges(seed.mem.id).slice(0, 6)) { const edgeWeight = memoryEdgeExpansionWeight(edge.relation); if (edge.confidence < 0.5 || edgeWeight <= 0) continue; const relatedId = edge.source_id === seed.mem.id ? edge.target_id : edge.source_id; if (seen.has(relatedId)) continue; const related = this.get(relatedId); if (!related || related.status !== "active") continue; if (params.entity_filter && related.entity_id !== params.entity_filter) continue; add(related, "graph", edgeWeight * edge.confidence, 1); } } } catch { /* optional */ } // Signal 5: Cross-session recurrence boost (Phase 2.2) if (cleanQuery.length > 0) { try { const boost = this._crossSessionBoost(cleanQuery, limit); for (const [memId, score] of boost) { const entry = seen.get(memId); if (entry) { entry.scores.fts5 = Math.max(entry.scores.fts5, score); } else { const mem = this.get(memId); if (mem) { add(mem, "fts5", score); } } } } catch { /* optional */ } } // Fallback: if no signals found, return top active if (seen.size === 0) { return this._rankActiveFallback(limit, params.scope_type, params.scope_id, params.visibility); } // RRF fusion: FTS5 + vector + recency + graph (4-signal hybrid) // v1.0: apply provenance filter before scoring const pfilter = params.provenance_filter; const candidates = Array.from(seen.values()).filter(({ mem }) => { if (params.entity_filter && mem.entity_id !== params.entity_filter) return false; if (!this._matchesScope(mem, params.scope_type, params.scope_id)) return false; if (!this._matchesVisibility(mem, params.visibility)) return false; if (pfilter && pfilter.length > 0 && !pfilter.includes(mem.provenance as Provenance)) return false; return true; }); if (candidates.length === 0) { return this._rankActiveFallback(limit, params.scope_type, params.scope_id, params.visibility); } const fused = candidates.map(({ mem, scores }) => { let finalScore = this._fuseScores(scores, w, !!params.entity_filter); finalScore += this._derivationBoost(mem); finalScore -= failedExperiencePenalty(mem); return { mem, score: finalScore, scores }; }); fused.sort((a, b) => b.score - a.score); return fused.slice(0, limit); } /** * Full three-way hybrid search WITH pre-computed query embedding. * This is the primary method for semantic queries — call embedText() first, * then pass the result here. */ searchHybridWithEmbedding(params: HybridSearchParams): MemRow[] { return this.searchHybrid(params); } // ═══════════════════════════════════════════════════════════════ // Vector helpers (backward compatible) // ═══════════════════════════════════════════════════════════════ /** Check if any embeddings are stored. */ hasEmbeddings(): boolean { if (this._vecLoaded) { const row = this.db.prepare("SELECT COUNT(*) as cnt FROM vec_memories").get() as { cnt: number }; return row.cnt > 0; } return false; } /** * Embed an existing memory by its ID. Used for backfilling or explicit embedding. * Returns the embedding vector, or [] if embedding failed. */ async embedMemory(id: string): Promise { if (!this._vecLoaded) return []; const mem = this.get(id); if (!mem) return []; const text = mem.fact_summary || mem.content; const vector = await embedText(text); if (vector.length === 0) return []; const row = this.db.prepare("SELECT rowid FROM memories WHERE id = ?").get(id) as { rowid: number } | undefined; if (!row) return []; this.db .prepare("INSERT OR REPLACE INTO vec_memories(rowid, embedding) VALUES (CAST(? AS INTEGER), ?)") .run(row.rowid, JSON.stringify(vector)); return vector; } /** * Bulk embed all active memories that don't have vectors yet. * Returns count of newly embedded memories. */ async embedAll(batchSize = 10): Promise { if (!this._vecLoaded) return 0; let embedded = 0; const total = ( this.db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE status = 'active'").get() as { cnt: number } ).cnt; for (let offset = 0; offset < total; offset += batchSize) { const batch = this.db .prepare( `SELECT m.rowid, m.content, m.fact_summary FROM memories m WHERE m.status = 'active' AND m.rowid NOT IN (SELECT rowid FROM vec_memories) ORDER BY m.importance DESC LIMIT ? OFFSET ?`, ) .all(batchSize, offset) as Array<{ rowid: number; content: string; fact_summary: string | null }>; if (batch.length === 0) break; const texts = batch.map((m) => (m.fact_summary || m.content).slice(0, 8000)); const vectors = await import("./embed.js").then((e) => e.embedTexts(texts)); for (let i = 0; i < batch.length && i < vectors.length; i++) { if (vectors[i].length > 0) { this.db .prepare("INSERT OR IGNORE INTO vec_memories(rowid, embedding) VALUES (CAST(? AS INTEGER), ?)") .run(batch[i].rowid, JSON.stringify(vectors[i])); embedded++; } } } return embedded; } /** JSON-based cosine similarity fallback (used when sqlite-vec not available). */ private _searchByEmbeddingJS(queryEmbedding: number[], limit: number): Array { // Check for legacy memories_emb table const hasLegacy = ( this.db .prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name='memories_emb'") .get() as { cnt: number } ).cnt > 0; if (!hasLegacy) return []; const rows = this.db .prepare(` SELECT m.*, e.embedding FROM memories_emb e JOIN memories m ON m.id = e.id WHERE m.status = 'active' `) .all() as Array; if (rows.length === 0) return []; const qNorm = Math.sqrt(queryEmbedding.reduce((s, v) => s + v * v, 0)) + 1e-10; const scored = rows.map((row) => { const emb = JSON.parse(row.embedding) as number[]; const dot = emb.reduce((s, v, i) => s + v * (queryEmbedding[i] ?? 0), 0); const eNorm = Math.sqrt(emb.reduce((s, v) => s + v * v, 0)) + 1e-10; return { ...row, _vec_score: dot / (qNorm * eNorm) }; }); return scored .filter((s) => s._vec_score > 0.2) .sort((a, b) => b._vec_score - a._vec_score) .slice(0, limit); } countByEntity(entityId: string): number { const row = this.db .prepare("SELECT COUNT(*) as cnt FROM memories WHERE entity_id = ? AND status = 'active'") .get(entityId) as { cnt: number }; return row.cnt; } /** * Store an extracted fact as a memory (Mem0-style atomic fact storage). * Facts are regular memories with "extracted-fact" tag — they participate * in FTS5 search, searchHybrid, and context injection exactly like raw memories. * Short, keyword-dense facts naturally rank higher in BM25. * * @param parent - parent memory that this fact was extracted from * @param factText - the extracted atomic fact (1 sentence) */ storeFact(parent: MemRow, factText: string): MemRow { const parentTags = parseTags(parent.tags); return this.store({ content: factText, entity_id: parent.entity_id ?? undefined, kind: "fact", scope_type: parent.scope_type ?? undefined, scope_id: parent.scope_id ?? undefined, confidence: Math.min(parent.confidence ?? 1.0, 0.9), visibility: parent.visibility ?? undefined, importance: parent.importance * 0.9, valid_at: parent.valid_at, tags: [...parentTags.filter((t) => !t.startsWith("parent:")), "extracted-fact", `parent:${parent.id}`], provenance: "extracted_fact", derivation: [{ id: parent.id, type: "memory", weight: 0.9 }], }); } /** * Get all extracted facts for a parent memory. */ getFacts(parentId: string, limit = 20, visibility?: VisibilityFilter): MemRow[] { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = [`%parent:${parentId}%`]; if (visibility === "private" || visibility === "shared") params.push(visibility); params.push(limit); return this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND tags LIKE ? ${visibilityClause} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params) as MemRow[]; } /** * Get extracted facts for an entity (Mem0-style entity fact recall). */ getFactsForEntity(entityId: string, limit = 50, visibility?: VisibilityFilter): MemRow[] { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = [entityId]; if (visibility === "private" || visibility === "shared") params.push(visibility); params.push(limit); return this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND entity_id = ? AND tags LIKE '%extracted-fact%' ${visibilityClause} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params) as MemRow[]; } // ── Sampling (for Dream Engine) ─────────────────────── /** * Weighted random sampling: importance * recency. * Returns top 60% by score + 40% random from the rest. */ sampleWeighted(count: number, visibility?: VisibilityFilter): MemRow[] { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = []; if (visibility === "private" || visibility === "shared") params.push(visibility); params.push(Math.max(count * 5, 100)); const pool = this.db .prepare( `SELECT * FROM memories WHERE status = 'active' ${visibilityClause} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params) as MemRow[]; return this._sampleFromPool(pool, count); } /** * Entity-scoped sampling: importance * recency, filtered to one entity and its neighbors. * Used by Dream Engine with --entity-id. */ sampleByEntity(entityId: string, count: number, visibility?: VisibilityFilter): MemRow[] { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = [entityId]; if (visibility === "private" || visibility === "shared") params.push(visibility); params.push(count * 3); const own = this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND entity_id = ? ${visibilityClause} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params) as MemRow[]; // Also pull a few from neighboring entities const neighbors = this.traverseGraph([entityId], 1); const neighborMems: MemRow[] = []; for (const nid of neighbors) { if (nid === entityId || neighborMems.length >= count) break; const mems = this.recallByEntity(nid, 5, visibility); neighborMems.push(...mems); } const pool = [...own, ...neighborMems]; return this._sampleFromPool(pool, count); } private _sampleFromPool(pool: MemRow[], count: number): MemRow[] { if (pool.length === 0) return []; const now = Date.now(); const scored = pool.map((m) => { const ageDays = (now - new Date(m.created_at).getTime()) / 86400000; const recency = Math.exp(-ageDays / 7); return { ...m, _score: m.importance * 0.7 + recency * 0.3 }; }); scored.sort((a, b) => b._score - a._score); const topCount = Math.ceil(count * 0.6); const top = scored.slice(0, topCount); const rest = scored.slice(topCount); const randomPick = [...rest].sort(() => Math.random() - 0.5).slice(0, count - topCount); return [...top, ...randomPick]; } /** * Find potential conflicts: memory pairs about the same entity * tagged as task-started vs task-completed, or other heuristics. */ findConflicts(limit = 100, visibility?: VisibilityFilter): MemRow[][] { const active = this.recallActive(limit, visibility); const conflicts: MemRow[][] = []; const byEntity = new Map(); for (const m of active) { if (!m.entity_id) continue; const list = byEntity.get(m.entity_id) || []; list.push(m); byEntity.set(m.entity_id, list); } for (const [, mems] of byEntity) { if (mems.length < 2) continue; for (let i = 0; i < mems.length; i++) { for (let j = i + 1; j < mems.length; j++) { const ti = parseTags(mems[i].tags); const tj = parseTags(mems[j].tags); if ( (ti.includes("task-completed") && tj.includes("task-started")) || (ti.includes("task-started") && tj.includes("task-completed")) ) { conflicts.push([mems[i], mems[j]]); } } } } return conflicts.slice(0, 10); } // ═══════════════════════════════════════════════════════════════ // Raw Event Log (Phase 2.1: zero LLM cost, 30-day TTL) // ═══════════════════════════════════════════════════════════════ storeRawEvent(params: { session_id: string; event_type: string; payload: Record }): string { return this.rawEvents.store(params); } auditRawEvents(params: { session_id?: string; event_type?: string; limit?: number; offset?: number }): RawEventRow[] { return this.rawEvents.audit(params); } recentSessions(limit = 10): string[] { return this.rawEvents.recentSessions(limit); } countRawEvents(sessionId: string): number { return this.rawEvents.count(sessionId); } getRawEventsAsText(sessionId: string, maxEvents = 200): string { return this.rawEvents.getAsText(sessionId, maxEvents); } purgeRawEvents(): number { return this.rawEvents.purge(); } // ═══════════════════════════════════════════════════════════════ // Session Summaries (Phase 2.1: LLM-generated at session end) // ═══════════════════════════════════════════════════════════════ storeSessionSummary(params: { session_id: string; summary: string; decisions?: string[]; errors?: string[]; changes?: string[]; unfinished?: string[]; memory_ids?: string[]; }): string { return this.episodes.storeSummary(params); } getSessionSummary(sessionId: string): SessionSummaryRow | undefined { return this.episodes.getSummary(sessionId); } recentSessionSummaries(limit = 10): SessionSummaryRow[] { return this.episodes.recentSummaries(limit); } /** Update the memory_ids on a session summary. */ updateSummaryMemories(sid: string, memIds: string[]): void { this.episodes.updateSummaryMemories(sid, memIds); } // ═══════════════════════════════════════════════════════════════ // Episodes + Entity Graph (Phase 1.2: Temporal KG + Graph Traversal) // ═══════════════════════════════════════════════════════════════ /** Create an episode from a session summary or standalone. */ createEpisode(params: { session_id: string; summary?: string; entity_id?: string; token_count?: number }): string { return this.episodes.create(params); } /** Get timeline of episodes for an entity, newest first. */ getTimeline(entityId: string, limit = 20): EpisodeRow[] { return this.episodes.timeline(entityId, limit); } /** Link two entities with a typed relation. Delegates to graphProvider. */ linkEntities(params: { source_entity: string; target_entity: string; relation_type: string; memory_id?: string; episode_id?: string; confidence?: number; }): string { return this.graphProvider.linkEntities(params); } /** Get all relations for an entity (both directions). Delegates to graphProvider. */ getRelatedEntities( entityId: string, ): Array<{ entity: string; relation_type: string; confidence: number; direction: "out" | "in" }> { return this.graphProvider.getRelatedEntities(entityId); } /** * BFS graph traversal from start entities up to maxHops. * Delegates to graphProvider. Used to expand retrieval scope. */ traverseGraph(startEntities: string[], maxHops = 2): string[] { return this.graphProvider.traverseGraph(startEntities, maxHops); } /** * Count how many episodes mention a set of entities. * Used to score graph expansion candidates. */ countEpisodesForEntities(entityIds: string[]): Map { return this.episodes.countForEntities(entityIds); } linkMemories(params: { source_id: string; target_id: string; relation: string; confidence?: number; }): string { const existing = this.db .prepare( `SELECT edge_id, confidence FROM memory_edges WHERE source_id = ? AND target_id = ? AND relation = ? LIMIT 1`, ) .get(params.source_id, params.target_id, params.relation) as { edge_id: string; confidence: number } | undefined; if (existing) { const confidence = params.confidence ?? 0.5; if (confidence > existing.confidence) { this.db.prepare("UPDATE memory_edges SET confidence = ? WHERE edge_id = ?").run(confidence, existing.edge_id); } return existing.edge_id; } const edgeId = genId(); this.db .prepare( `INSERT INTO memory_edges (edge_id, source_id, target_id, relation, confidence) VALUES (?, ?, ?, ?, ?)`, ) .run(edgeId, params.source_id, params.target_id, params.relation, params.confidence ?? 0.5); return edgeId; } getMemoryEdges(memoryId: string): MemoryEdgeRow[] { return this.db .prepare( `SELECT * FROM memory_edges WHERE source_id = ? OR target_id = ? ORDER BY confidence DESC, created_at DESC`, ) .all(memoryId, memoryId) as MemoryEdgeRow[]; } listMemoryEdges(limit = 100): MemoryEdgeRow[] { return this.db .prepare( `SELECT * FROM memory_edges ORDER BY confidence DESC, created_at DESC LIMIT ?`, ) .all(limit) as MemoryEdgeRow[]; } private _hasMemoryEdge(sourceId: string, targetId: string, relation: string): boolean { const row = this.db .prepare( `SELECT 1 FROM memory_edges WHERE source_id = ? AND target_id = ? AND relation = ? LIMIT 1`, ) .get(sourceId, targetId, relation) as { 1: number } | undefined; return !!row; } review(params?: { scope_type?: string; scope_id?: string; visibility?: VisibilityFilter; limit?: number }): LoomReviewResult { const limit = params?.limit ?? 20; const where = ["status = 'active'"]; const queryParams: unknown[] = []; if (params?.scope_type) { where.push("scope_type = ?"); queryParams.push(params.scope_type); } if (params?.scope_id) { where.push("(scope_id = ? OR scope_id IS NULL)"); queryParams.push(params.scope_id); } if (params?.visibility === "private" || params?.visibility === "shared") { where.push("visibility = ?"); queryParams.push(params.visibility); } else { where.push("visibility != 'private'"); } const rows = this.db .prepare( `SELECT * FROM memories WHERE ${where.join(" AND ")} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...queryParams, Math.max(limit * 5, 50)) as MemRow[]; const proposals: LoomReviewProposal[] = []; const byHash = new Map(); for (const m of rows) { if (!m.content_hash) continue; const group = byHash.get(m.content_hash) ?? []; group.push(m); byHash.set(m.content_hash, group); } for (const group of byHash.values()) { if (group.length > 1) { const sorted = [...group].sort((a, b) => b.importance - a.importance); const keeper = sorted[0]; const duplicates = sorted.slice(1); if (duplicates.every((m) => this._hasMemoryEdge(keeper.id, m.id, "supports"))) continue; proposals.push({ action: "merge", memory_ids: group.map((m) => m.id), reason: "duplicate content hash in active memories", confidence: 0.9, }); } } const byEntity = new Map(); for (const m of rows) { if (!m.entity_id) continue; const group = byEntity.get(m.entity_id) ?? []; group.push(m); byEntity.set(m.entity_id, group); } for (const group of byEntity.values()) { const stable = group.find((m) => /\b(?:stable|completed|resolved|done|fixed)\b/i.test(m.content)); const active = group.find((m) => /\b(?:draft|active|started|began|broken|failed)\b/i.test(m.content)); if (stable && active && stable.id !== active.id) { if (this._hasMemoryEdge(stable.id, active.id, "supersedes")) continue; proposals.push({ action: "supersede", memory_ids: [active.id, stable.id], reason: "newer completed/stable state appears to supersede earlier active/draft state", confidence: 0.75, }); } } const procedures = rows.filter((m) => m.kind === "procedure"); const seenProcedurePairs = new Set(); for (let i = 0; i < procedures.length; i++) { for (let j = i + 1; j < procedures.length; j++) { const pair = procedureSupersedePair(procedures[i], procedures[j]); if (!pair) continue; const [oldProcedure, newProcedure] = pair; const pairKey = `${oldProcedure.id}:${newProcedure.id}`; if (seenProcedurePairs.has(pairKey)) continue; seenProcedurePairs.add(pairKey); if (this._hasMemoryEdge(newProcedure.id, oldProcedure.id, "supersedes")) continue; proposals.push({ action: "supersede", memory_ids: [oldProcedure.id, newProcedure.id], reason: "newer or more specific procedure appears to supersede an older procedure in the same scope", confidence: 0.7, }); } } const rowIds = new Set(rows.map((m) => m.id)); const seenContradictions = new Set(); for (const m of rows) { const trust = this.checkTrust(m.id); for (const conflict of trust.conflicts) { if (!rowIds.has(conflict.id)) continue; const pair = [m.id, conflict.id].sort().join(":"); if (seenContradictions.has(pair)) continue; seenContradictions.add(pair); if (this._hasMemoryEdge(m.id, conflict.id, "contradicts") || this._hasMemoryEdge(conflict.id, m.id, "contradicts")) { continue; } proposals.push({ action: "contradicts", memory_ids: [m.id, conflict.id], reason: conflict.reason, confidence: 0.65, }); } } for (const m of rows) { const tags = parseTags(m.tags); const protectedKind = new Set(["decision", "procedure", "handoff", "profile", "insight", "constraint"]).has( m.kind ?? "memory", ); const protectedTags = tags.some((t) => ["decision", "architecture", "principle", "procedure", "handoff", "insight", "constraint", "error"].includes(t), ); if ( !protectedKind && !protectedTags && !m.entity_id && m.recall_count === 0 && m.importance <= 0.2 && (m.confidence ?? 1) <= 0.4 && m.content.trim().length < 80 ) { proposals.push({ action: "archive", memory_ids: [m.id], reason: "low-signal unreferenced memory", confidence: 0.7, }); } if ( (m.kind ?? "memory") === "procedure" && m.recall_count === 0 && m.importance <= 0.3 && (m.confidence ?? 1) <= 0.35 && this.getMemoryEdges(m.id).length === 0 ) { proposals.push({ action: "archive", memory_ids: [m.id], reason: "low-confidence unused procedure", confidence: 0.65, }); } const procedural = m.kind !== "procedure" && (tags.includes("decision") || tags.includes("architecture") || /\b(?:always|never|when|before|after|run|check|avoid|prefer)\b/i.test(m.content)); if (procedural) { if (looksUnsafeForProcedurePromotion(m.content)) continue; const hasEvidence = this.getMemoryEdges(m.id).some((edge) => ["supports", "derives_from"].includes(edge.relation)); if ((m.confidence ?? 1) < 0.5 && !hasEvidence) continue; if (this.getMemoryEdges(m.id).some((edge) => edge.relation === "derives_from" && edge.target_id === m.id)) continue; proposals.push({ action: "promote_to_procedure", memory_ids: [m.id], reason: "memory looks like reusable coding guidance", confidence: 0.55, }); } } return { scope: { scope_type: params?.scope_type, scope_id: params?.scope_id }, proposals: rankReviewProposals(proposals, limit), }; } applyReviewProposal(params: { action: LoomReviewProposal["action"]; memory_ids: string[]; }): LoomApplyResult { const archivedIds: string[] = []; const createdIds: string[] = []; const edgeIds: string[] = []; const ids = [...new Set(params.memory_ids)].filter(Boolean); if (params.action === "promote_to_procedure") { for (const id of ids) { const source = this.get(id); if (!source || source.status !== "active") continue; if (source.kind === "procedure") continue; const procedure = this.store({ content: source.content, fact_summary: source.fact_summary ?? undefined, entity_id: source.entity_id ?? undefined, kind: "procedure", scope_type: source.scope_type ?? "repo", scope_id: source.scope_id ?? undefined, confidence: Math.min(source.confidence ?? 1.0, 0.75), visibility: source.visibility ?? "project", importance: Math.max(source.importance, 0.75), valid_at: source.valid_at, tags: [...parseTags(source.tags).filter((t) => t !== "decision"), "procedure", "review-applied"], provenance: "consolidated_pattern", derivation: [{ id: source.id, type: "memory", weight: 0.8 }], }); createdIds.push(procedure.id); edgeIds.push(this.linkMemories({ source_id: procedure.id, target_id: source.id, relation: "derives_from", confidence: 0.8 })); } } else if (params.action === "supersede") { if (ids.length >= 2) { const oldMem = this.get(ids[0]); const newMem = this.get(ids[1]); if (oldMem && newMem) { edgeIds.push(this.linkMemories({ source_id: newMem.id, target_id: oldMem.id, relation: "supersedes", confidence: 0.8 })); for (const evidenceId of ids.slice(2)) { const evidence = this.get(evidenceId); if (!evidence) continue; edgeIds.push(this.linkMemories({ source_id: newMem.id, target_id: evidence.id, relation: "derives_from", confidence: 0.75 })); } this.updateStatus(oldMem.id, "archived"); archivedIds.push(oldMem.id); } } } else if (params.action === "merge") { const existing = ids.map((id) => this.get(id)).filter((m): m is MemRow => !!m); existing.sort((a, b) => b.importance - a.importance); const keeper = existing[0]; for (const duplicate of existing.slice(1)) { edgeIds.push(this.linkMemories({ source_id: keeper.id, target_id: duplicate.id, relation: "supports", confidence: 0.9 })); this.updateStatus(duplicate.id, "archived"); archivedIds.push(duplicate.id); } } else if (params.action === "contradicts") { if (ids.length >= 2) { const [sourceId, ...targetIds] = ids; const source = this.get(sourceId); if (source) { for (const targetId of targetIds) { const target = this.get(targetId); if (!target) continue; edgeIds.push(this.linkMemories({ source_id: source.id, target_id: target.id, relation: "contradicts", confidence: 0.8 })); } } } } else if (params.action === "archive") { for (const id of ids) { const mem = this.get(id); if (!mem || mem.status !== "active") continue; this.updateStatus(id, "archived"); archivedIds.push(id); } } return { action: params.action, memory_ids: ids, archived_ids: archivedIds, created_ids: createdIds, edge_ids: edgeIds, }; } // ── Stats ───────────────────────────────────────────── stats(): { active: number; expired: number; archived: number; insight: number; totalInsights: number; tokenEstimate: number; degraded: number; expiringSoon: number; extendedByRecall: number; } { const memCounts = this.db.prepare("SELECT status, COUNT(*) as cnt FROM memories GROUP BY status").all() as { status: string; cnt: number; }[]; const insightCount = this.db .prepare("SELECT COUNT(*) as cnt FROM memories WHERE status = 'active' AND provenance = 'dream_insight'") .get() as { cnt: number }; const degradedCount = this.db .prepare("SELECT COUNT(*) as cnt FROM memories WHERE status = 'active' AND tags LIKE '%consolidation-degraded%'") .get() as { cnt: number }; const expiringSoonCount = this.db .prepare( "SELECT COUNT(*) as cnt FROM memories WHERE status = 'active' AND expire_at IS NOT NULL AND julianday(expire_at) < julianday('now', '+3 days')", ) .get() as { cnt: number }; const extendedByRecallCount = this.db .prepare( "SELECT COUNT(*) as cnt FROM memories WHERE status = 'active' AND expire_at IS NOT NULL AND recall_count > 0", ) .get() as { cnt: number }; const tokenRow = this.db .prepare("SELECT COALESCE(SUM(LENGTH(content)), 0) as total FROM memories WHERE status = 'active'") .get() as { total: number }; const map: Record = {}; for (const row of memCounts) map[row.status] = row.cnt; return { active: map.active || 0, expired: map.expired || 0, archived: map.archived || 0, insight: map.insight || 0, totalInsights: insightCount.cnt, tokenEstimate: map.active ? estimateTokens(`${tokenRow.total || 0}`) : 0, degraded: degradedCount.cnt, expiringSoon: expiringSoonCount.cnt, extendedByRecall: extendedByRecallCount.cnt, }; } healthCheck(): LoomHealthCheck { const count = (sql: string): number => { try { return (this.db.prepare(sql).get() as { cnt?: number; c?: number })?.cnt ?? 0; } catch { return 0; } }; const active = count("SELECT COUNT(*) as cnt FROM memories WHERE status = 'active'"); const ftsRows = count("SELECT COUNT(*) as cnt FROM memories_fts"); const embeddingRows = this._vecLoaded ? count("SELECT COUNT(*) as cnt FROM vec_memories") : 0; return { dbPath: getDbPath(), activeMemories: active, ftsRows, ftsSynced: ftsRows >= active, vecLoaded: this._vecLoaded, embeddingRows, embeddingCoverage: active > 0 ? embeddingRows / active : 0, rawEvents: count("SELECT COUNT(*) as cnt FROM raw_events"), entityEdges: count("SELECT COUNT(*) as cnt FROM entity_edges"), hasEmbedConfig: Boolean(process.env.EMBED_API_KEY || process.env.EMBED_API_BASE), hasLocalEmbedConfig: Boolean(process.env.EMBED_LOCAL_MODEL), hasDreamModel: Boolean(process.env.PI_DREAM_MODEL), hasFactModel: Boolean(process.env.PI_FACT_MODEL), }; } // ═══════════════════════════════════════════════════════ // Insight / Pattern Memory Bank // ═══════════════════════════════════════════════════════ storeInsight(params: { content: string; supporting_ids: string[]; confidence: number; entity_id?: string; step: number; visibility?: VisibilityFilter; }): MemRow { return this.store({ content: params.content, entity_id: params.entity_id, kind: "insight", confidence: params.confidence, importance: params.confidence, tags: ["dream_insight", `step:${params.step}`], visibility: params.visibility, provenance: "dream_insight", derivation: params.supporting_ids.map((id) => ({ id, type: "memory" as const, weight: 0.3 })), }); } getInsights(limit = 20, visibility?: VisibilityFilter): MemRow[] { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = []; if (visibility === "private" || visibility === "shared") params.push(visibility); params.push(Math.max(limit * 3, limit)); const rows = this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND provenance = 'dream_insight' AND tags NOT LIKE '%entity_profile%' ${visibilityClause} ORDER BY importance DESC, created_at DESC LIMIT ?`, ) .all(...params) as MemRow[]; return this._filterVisible(rows, visibility, limit); } getInsightsForEntity(entityId: string, visibility?: VisibilityFilter): MemRow[] { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = [entityId]; if (visibility === "private" || visibility === "shared") params.push(visibility); return this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND provenance = 'dream_insight' AND tags NOT LIKE '%entity_profile%' AND entity_id = ? ${visibilityClause} ORDER BY importance DESC`, ) .all(...params) as MemRow[]; } getSupportingMemories(insightId: string, visibility?: VisibilityFilter): MemRow[] { const insight = this.get(insightId); if (!insight?.derivation) return []; const derivation: DerivationLink[] = JSON.parse(insight.derivation); const ids = derivation.filter((d) => d.type === "memory").map((d) => d.id); if (ids.length === 0) return []; const ph = ids.map(() => "?").join(","); const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = [...ids]; if (visibility === "private" || visibility === "shared") params.push(visibility); return this.db.prepare(`SELECT * FROM memories WHERE id IN (${ph}) ${visibilityClause}`).all(...params) as MemRow[]; } updateInsight(id: string, params: { content?: string; confidence?: number; entity_id?: string | null }): boolean { const existing = this.get(id); if (existing?.provenance !== "dream_insight") return false; const content = params.content ?? existing.content; const confidence = params.confidence ?? existing.importance; const entityId = params.entity_id !== undefined ? params.entity_id : existing.entity_id; this.db .prepare(`UPDATE memories SET content = ?, importance = ?, entity_id = ? WHERE id = ?`) .run(content, confidence, entityId, id); return true; } deleteInsight(id: string): boolean { const result = this.db.prepare("DELETE FROM memories WHERE id = ? AND provenance = 'dream_insight'").run(id); return result.changes > 0; } getInsight(id: string): MemRow | undefined { const mem = this.get(id); if (mem?.provenance !== "dream_insight") return undefined; return mem; } // ═══════════════════════════════════════════════════════ // TriMem Profile Layer — aggregated entity portraits // ═══════════════════════════════════════════════════════ /** * Store an entity profile portrait. Now uses provenance "dream_insight" * (merged with insights — profiles are insight sub-type tagged "entity_profile"). */ storeProfile(params: { entity_id: string; content: string; supporting_ids?: string[]; confidence?: number; visibility?: VisibilityFilter; }): MemRow { // Archive previous profile versions for this entity this.db .prepare( `UPDATE memories SET status = 'archived' WHERE entity_id = ? AND provenance = 'dream_insight' AND tags LIKE '%entity_profile%'`, ) .run(params.entity_id); return this.store({ content: params.content, entity_id: params.entity_id, kind: "profile", confidence: params.confidence ?? 0.7, importance: params.confidence ?? 0.7, tags: ["dream_insight", "entity_profile", `entity:${params.entity_id}`], visibility: params.visibility, provenance: "dream_insight", derivation: (params.supporting_ids ?? []).map((id) => ({ id, type: "memory" as const, weight: 0.5 })), }); } getProfiles(entityId: string, limit = 5, visibility?: VisibilityFilter): MemRow[] { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = [entityId]; if (visibility === "private" || visibility === "shared") params.push(visibility); params.push(limit); return this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND provenance = 'dream_insight' AND tags LIKE '%entity_profile%' AND entity_id = ? ${visibilityClause} ORDER BY created_at DESC LIMIT ?`, ) .all(...params) as MemRow[]; } getLatestProfile(entityId: string, visibility?: VisibilityFilter): MemRow | undefined { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = [entityId]; if (visibility === "private" || visibility === "shared") params.push(visibility); return this.db .prepare( `SELECT * FROM memories WHERE status = 'active' AND provenance = 'dream_insight' AND tags LIKE '%entity_profile%' AND entity_id = ? ${visibilityClause} ORDER BY created_at DESC LIMIT 1`, ) .get(...params) as MemRow | undefined; } getAllProfiles(limit = 10, visibility?: VisibilityFilter): MemRow[] { const visibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const latestVisibilityClause = visibility === "private" || visibility === "shared" ? "AND visibility = ?" : "AND visibility != 'private'"; const params: unknown[] = []; if (visibility === "private" || visibility === "shared") params.push(visibility); if (visibility === "private" || visibility === "shared") params.push(visibility); params.push(Math.max(limit * 3, limit)); const rows = this.db .prepare( `SELECT m.* FROM memories m JOIN (SELECT entity_id, MAX(created_at) as max_ca FROM memories WHERE status = 'active' AND provenance = 'dream_insight' AND tags LIKE '%entity_profile%' ${latestVisibilityClause} GROUP BY entity_id) latest ON m.entity_id = latest.entity_id AND m.created_at = latest.max_ca WHERE m.status = 'active' AND m.provenance = 'dream_insight' AND m.tags LIKE '%entity_profile%' ${visibilityClause} ORDER BY m.created_at DESC LIMIT ?`, ) .all(...params) as MemRow[]; return this._filterVisible(rows, visibility, limit); } // ═══════════════════════════════════════════════════════════ // v1.0: Path-Conditioned Constraints (Policies on Paths) // ═══════════════════════════════════════════════════════════ /** * Store a path-conditioned constraint. * * path_condition format: "KEY>=N,window=SECONDS" or null for static-only. * KEY: event_type prefix, e.g. "tool:bash error" — * matches raw_events where event_type starts with the key * and payload contains '"isError":true' when tag "error" present. * N: threshold count to trigger * SECONDS: sliding window (default 300 = 5 min) * * Examples: * "tool:bash error>=3,window=300" → 3+ bash errors in 5 min → violation * null → static constraint (always active) */ storeConstraint(params: { entity_id: string; description: string; path_condition?: string; enforcement?: "warn" | "block" | "log"; }): ConstraintRow { return this.constraints.store(params); } /** Evaluate path-conditioned constraints against raw_events. Delegates to ConstraintStore. */ checkPathConstraints(params?: { entity_id?: string; session_id?: string; }): Array { return this.constraints.check(params); } /** List all constraints for an entity. */ getConstraints(entityId: string): ConstraintRow[] { return this.constraints.list(entityId); } }