import type { ISdk } from "iii-sdk"; import type { CompressedObservation, Memory, Session, MemoryProvider, } from "../types.js"; import { KV, generateId } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { recordAudit } from "./audit.js"; const CONSOLIDATION_SYSTEM = `You are a memory consolidation engine. Given a set of related observations from coding sessions, synthesize them into a single long-term memory. Output XML: pattern|preference|architecture|bug|workflow|fact Concise memory title (max 80 chars) 2-4 sentence description of the learned insight key term relevant/file/path 1-10 how confident/important this memory is `; import { getXmlTag, getXmlChildren } from "../prompts/xml.js"; import { logger } from "../logger.js"; function parseMemoryXml( xml: string, sessionIds: string[], ): Omit | null { const type = getXmlTag(xml, "type"); const title = getXmlTag(xml, "title"); const content = getXmlTag(xml, "content"); if (!type || !title || !content) return null; const validTypes = new Set([ "pattern", "preference", "architecture", "bug", "workflow", "fact", ]); return { type: (validTypes.has(type) ? type : "fact") as Memory["type"], title, content, concepts: getXmlChildren(xml, "concepts", "concept"), files: getXmlChildren(xml, "files", "file"), sessionIds, strength: Math.max( 1, Math.min(10, parseInt(getXmlTag(xml, "strength") || "5", 10) || 5), ), version: 1, isLatest: true, }; } export function registerConsolidateFunction( sdk: ISdk, kv: StateKV, provider: MemoryProvider, ): void { sdk.registerFunction("mem::consolidate", async (data: { project?: string; minObservations?: number }) => { const minObs = data.minObservations ?? 10; const sessions = await kv.list(KV.sessions); const filtered = data.project ? sessions.filter((s) => s.project === data.project) : sessions; const allObs: Array = []; const obsPerSession: CompressedObservation[][] = []; for (let batch = 0; batch < filtered.length; batch += 10) { const chunk = filtered.slice(batch, batch + 10); const results = await Promise.all( chunk.map((s) => kv .list(KV.observations(s.id)) .catch(() => [] as CompressedObservation[]), ), ); obsPerSession.push(...results); } for (let i = 0; i < filtered.length; i++) { for (const obs of obsPerSession[i]) { if (obs.title && obs.importance >= 5) { allObs.push({ ...obs, sid: filtered[i].id }); } } } if (allObs.length < minObs) { return { consolidated: 0, reason: "insufficient_observations" }; } const conceptGroups = new Map(); for (const obs of allObs) { for (const concept of obs.concepts) { const key = concept.toLowerCase(); if (!conceptGroups.has(key)) conceptGroups.set(key, []); conceptGroups.get(key)!.push(obs); } } let consolidated = 0; const existingMemories = await kv.list(KV.memories); const existingTitles = new Set( existingMemories.map((m) => m.title.toLowerCase()), ); const MAX_LLM_CALLS = 10; let llmCallCount = 0; const sortedGroups = [...conceptGroups.entries()] .filter(([, g]) => g.length >= 3) .sort((a, b) => b[1].length - a[1].length); for (const [concept, obsGroup] of sortedGroups) { if (llmCallCount >= MAX_LLM_CALLS) break; const top = obsGroup .sort((a, b) => b.importance - a.importance) .slice(0, 8); const sessionIds = [...new Set(top.map((o) => o.sid))]; const prompt = top .map( (o) => `[${o.type}] ${o.title}\n${o.narrative}\nFiles: ${o.files.join(", ")}\nImportance: ${o.importance}`, ) .join("\n\n"); try { const response = await Promise.race([ provider.compress( CONSOLIDATION_SYSTEM, `Concept: "${concept}"\n\nObservations:\n${prompt}`, ), new Promise((_, reject) => setTimeout(() => reject(new Error("compress timeout")), 30_000), ), ]); llmCallCount++; const parsed = parseMemoryXml(response, sessionIds); if (!parsed) continue; const now = new Date().toISOString(); const obsIds = [...new Set(top.map((o) => o.id))]; const scopedProject = typeof data.project === "string" && data.project.trim().length > 0 ? data.project.trim() : undefined; // A scoped consolidation run must only evolve memories that belong // to the same project. Without this guard, two projects that happen // to consolidate observations into an identically-titled memory would // cause one project's memory to silently evolve the other's — the // exact class of cross-project corruption this fix is designed to // prevent. An unscoped run (no data.project, background cron path) // preserves the pre-existing behavior and may evolve any memory. const existingMatch = existingMemories.find( (m) => m.title.toLowerCase() === parsed.title.toLowerCase() && (!scopedProject || !m.project || m.project === scopedProject), ); if (existingMatch) { existingMatch.isLatest = false; await kv.set(KV.memories, existingMatch.id, existingMatch); await recordAudit(kv, "evolve", "mem::consolidate", [existingMatch.id], { action: "mark_non_latest", concept, }); const evolved: Memory = { id: generateId("mem"), createdAt: now, updatedAt: now, ...parsed, version: (existingMatch.version || 1) + 1, parentId: existingMatch.id, supersedes: [ existingMatch.id, ...(existingMatch.supersedes || []), ], sourceObservationIds: obsIds, isLatest: true, ...(scopedProject !== undefined && { project: scopedProject }), }; await kv.set(KV.memories, evolved.id, evolved); await recordAudit(kv, "evolve", "mem::consolidate", [evolved.id], { action: "evolve_memory", oldId: existingMatch.id, newId: evolved.id, concept, }); existingTitles.add(evolved.title.toLowerCase()); consolidated++; } else { const memory: Memory = { id: generateId("mem"), createdAt: now, updatedAt: now, ...parsed, sourceObservationIds: obsIds, version: 1, isLatest: true, ...(scopedProject !== undefined && { project: scopedProject }), }; await kv.set(KV.memories, memory.id, memory); await recordAudit(kv, "remember", "mem::consolidate", [memory.id], { action: "create_memory", concept, }); existingTitles.add(memory.title.toLowerCase()); consolidated++; } } catch (err) { logger.warn("Consolidation failed for concept", { concept, error: err instanceof Error ? err.message : String(err), }); } } logger.info("Consolidation complete", { consolidated, totalObs: allObs.length, }); return { consolidated, totalObservations: allObs.length }; }, ); }