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 };
},
);
}