diff --git a/src/resources/extensions/sf/memory-store.ts b/src/resources/extensions/sf/memory-store.ts index 1e7cfe057..139fbb6ab 100644 --- a/src/resources/extensions/sf/memory-store.ts +++ b/src/resources/extensions/sf/memory-store.ts @@ -3,6 +3,7 @@ // Storage layer for auto-learned project memories. Follows context-store.ts patterns. // All functions degrade gracefully: return empty results when DB unavailable, never throw. +import { createMemoryRelation } from "./memory-relations.js"; import { _getAdapter, decayMemoriesBefore, @@ -526,17 +527,26 @@ export function applyMemoryActions( try { transaction(() => { + // Track IDs of memories created in THIS batch so we can link them + // pairwise with `related_to` after the loop. Memories extracted + // from the same slice transcript share narrative context — a + // downstream query that hits any one of them benefits from + // surfacing the cohort via the relation-boost pass in + // getRelevantMemoriesRanked. + const createdInBatch: string[] = []; for (const action of actions) { switch (action.action) { - case "CREATE": - createMemory({ + case "CREATE": { + const id = createMemory({ category: action.category, content: action.content, confidence: action.confidence, source_unit_type: unitType, source_unit_id: unitId, }); + if (id) createdInBatch.push(id); break; + } case "UPDATE": updateMemoryContent(action.id, action.content, action.confidence); break; @@ -548,6 +558,28 @@ export function applyMemoryActions( break; } } + // Link co-extracted memories. Confidence 0.5 reflects that + // "from the same extraction batch" is weaker evidence than + // an explicit human-authored relation. Pairwise O(n²) is fine + // for typical extractor batches of 1-5 memories. Best-effort — + // individual relation failures shouldn't roll back the batch. + if (createdInBatch.length > 1) { + try { + for (let i = 0; i < createdInBatch.length; i++) { + for (let j = i + 1; j < createdInBatch.length; j++) { + createMemoryRelation( + createdInBatch[i]!, + createdInBatch[j]!, + "related_to", + 0.5, + ); + } + } + } catch { + // Relation linkage is additive; skip on any failure so the + // memory batch still commits. + } + } enforceMemoryCap(); }); } catch { diff --git a/src/resources/extensions/sf/tests/memory-store.test.ts b/src/resources/extensions/sf/tests/memory-store.test.ts index c5822079c..77fcb8719 100644 --- a/src/resources/extensions/sf/tests/memory-store.test.ts +++ b/src/resources/extensions/sf/tests/memory-store.test.ts @@ -657,6 +657,68 @@ test("supersedeMemory drops the superseded memory's embedding row", async () => rmSync(dir, { recursive: true, force: true }); }); +test("applyMemoryActions links co-extracted memories with related_to", async () => { + closeDatabase(); + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { applyMemoryActions } = await import("../memory-store.ts"); + + const dir = mkdtempSync(join(tmpdir(), "sf-coextract-link-")); + openDatabase(join(dir, "sf.db")); + + const actions: MemoryAction[] = [ + { action: "CREATE", category: "convention", content: "alpha" }, + { action: "CREATE", category: "gotcha", content: "beta" }, + { action: "CREATE", category: "pattern", content: "gamma" }, + ]; + applyMemoryActions(actions, "execute-task", "M001/S01/T01"); + + const adapter = _getAdapter()!; + const relCount = adapter + .prepare("SELECT count(*) as c FROM memory_relations") + .get(); + // 3 memories, fully connected pairs = C(3,2) = 3 edges + assert.equal(relCount?.["c"], 3, "all pairs from same batch should be linked"); + + const sample = adapter + .prepare( + "SELECT rel, confidence FROM memory_relations LIMIT 1", + ) + .get(); + assert.equal(sample?.["rel"], "related_to"); + assert.equal(sample?.["confidence"], 0.5); + + closeDatabase(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("applyMemoryActions doesn't link a single-CREATE batch", async () => { + closeDatabase(); + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { applyMemoryActions } = await import("../memory-store.ts"); + + const dir = mkdtempSync(join(tmpdir(), "sf-single-link-")); + openDatabase(join(dir, "sf.db")); + + applyMemoryActions( + [{ action: "CREATE", category: "convention", content: "lone" }], + "execute-task", + "M001/S01/T01", + ); + + const adapter = _getAdapter()!; + const relCount = adapter + .prepare("SELECT count(*) as c FROM memory_relations") + .get(); + assert.equal(relCount?.["c"], 0, "single-create batch should add no relations"); + + closeDatabase(); + rmSync(dir, { recursive: true, force: true }); +}); + test("enforceMemoryCap sweeps embeddings of newly-superseded memories", async () => { closeDatabase(); const { mkdtempSync, rmSync } = await import("node:fs");