/** * pi-loom: Memory Consolidation Engine (Phase 3 — RecMem) * * When subconscious memories hit the recurrence threshold (hit_count >= 3, * similarity >= 0.85), the consolidation engine synthesizes them into a * single long-term memory via LLM. * * Unlike extractFacts (which atomizes memories into individual facts), * consolidation synthesizes a pattern across similar memories. */ import { complete } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { hasLLM, resolveMcpAuth, resolveModelForLLM } from "./model.js"; import type { LoomStore, MemRow } from "./store.js"; const CONSOLIDATE_SYSTEM = `You are a memory consolidation engine. Given multiple related observations about the same recurring topic, produce a single consolidated memory. Rules: - Synthesize the COMMON PATTERN across all observations (what keeps recurring) - Preserve specific details (names, counts, dates, file paths) when they appear in multiple sources - Output ONE consolidated sentence capturing the recurrence - If sources contradict, note the conflict - If sources are identical, output the common fact once Output JSON: { "consolidated": "...", "fact_summary": "...", "entity_label": "..." } - consolidated: 1-2 sentences synthesizing the recurring pattern - fact_summary: short version (8-15 words) suitable for compact recall - entity_label: the conceptual entity this recurrence is about (e.g. "auth-debugging", "file-edit-pattern", "esr-task-lifecycle")`; export interface ConsolidateResult { memory: MemRow; source_count: number; entity_label: string; } /** * Consolidate a group of similar subconscious memories into a single long-term memory. */ export async function consolidateMemories( store: LoomStore, sources: MemRow[], ctx: ExtensionContext | null, ): Promise { if (sources.length < 3) return null; // ── Zero-dependency fallback: embedding-only consolidation ── if (!hasLLM(ctx)) { // Concatenate key points without LLM synthesis const points = sources.map((s) => (s.fact_summary || s.content).replace(/\n/g, " ").slice(0, 120)).filter(Boolean); if (points.length < 2) return null; const content = `Recurring pattern (${sources.length}x): ${points.join(" | ")}`; const factSummary = points.slice(0, 2).join("; ").slice(0, 80); // Determine best entity_id from sources const entityCounts = new Map(); for (const s of sources) { if (s.entity_id) entityCounts.set(s.entity_id, (entityCounts.get(s.entity_id) ?? 0) + 1); } let bestEntity: string | undefined; let bestCount = 0; for (const [eid, cnt] of entityCounts) { if (cnt > bestCount) { bestCount = cnt; bestEntity = eid; } } const avgImportance = sources.reduce((s, m) => s + m.importance, 0) / sources.length; const importance = Math.min(1.0, avgImportance * 1.2 + 0.1); const consolidated = store.consolidate({ sourceIds: sources.map((s) => s.id), content, fact_summary: factSummary, entity_id: bestEntity, importance, }); console.error( `[pi-loom] Consolidate (embedding-only fallback): ${sources.length} sources → 1 consolidated (no LLM).`, ); return { memory: consolidated, source_count: sources.length, entity_label: bestEntity ?? "unknown" }; } const sourceText = sources.map((s, i) => `[S${i + 1}] ${s.content.slice(0, 300)}`).join("\n"); const model = resolveModelForLLM( ctx, { modelProvider: undefined, modelId: process.env.PI_CONSOLIDATE_MODEL }, "PI_CONSOLIDATE_MODEL", "deepseek/deepseek-v3.1", ); if (!model) { console.error("[pi-loom] No model available for consolidation"); return null; } try { // Resolve auth: prefer ctx.modelRegistry (session-configured key), fall back to env vars const auth = ctx ? await ctx.modelRegistry.getApiKeyAndHeaders(model) : resolveMcpAuth(model); if (!auth.ok || !auth.apiKey) { console.error("[pi-loom] No API key for consolidation"); return null; } const prompt = [ CONSOLIDATE_SYSTEM, "", `## Consolidate these ${sources.length} observations:`, sourceText, "", 'Output as JSON: {"consolidated": "...", "fact_summary": "...", "entity_label": "..."}', ].join("\n"); const response = await complete( model, { messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }] }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 256 }, ); const text = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n"); const jsonMatch = text.match(/\{[\s\S]*\}/); const result = jsonMatch ? (JSON.parse(jsonMatch[0]) as { consolidated: string; fact_summary: string; entity_label: string }) : null; if (!result?.consolidated) return null; // Determine best entity_id from sources const entityCounts = new Map(); for (const s of sources) { if (s.entity_id) { entityCounts.set(s.entity_id, (entityCounts.get(s.entity_id) ?? 0) + 1); } } let bestEntity: string | undefined; let bestCount = 0; for (const [eid, cnt] of entityCounts) { if (cnt > bestCount) { bestCount = cnt; bestEntity = eid; } } // Compute importance: avg of source importances, boosted const avgImportance = sources.reduce((s, m) => s + m.importance, 0) / sources.length; const importance = Math.min(1.0, avgImportance * 1.3 + 0.1); // Store consolidated memory const consolidated = store.consolidate({ sourceIds: sources.map((s) => s.id), content: result.consolidated, fact_summary: result.fact_summary, entity_id: bestEntity, importance, }); return { memory: consolidated, source_count: sources.length, entity_label: result.entity_label ?? "unknown", }; } catch (err) { console.error("[pi-loom] Consolidation failed:", err instanceof Error ? err.message : err); return null; } }