From b9bff37623b6a13900b4eb2f390b7fa97c149fe4 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 3 May 2026 00:13:21 +0200 Subject: [PATCH] feat(sf): co-extracted memories get auto-linked with related_to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit (55b14c3f7) wired memory_relations into ranking, but the table was empty — no writer added edges. applyMemoryActions now links memories created in the same batch pairwise with `related_to` edges (confidence 0.5 reflects "from same extraction context" being weaker evidence than an explicit human-authored relation). Pairwise O(n²) is fine for typical extractor batches of 1–5 memories. Combined with 55b14c3f7's relation-boost ranker, the effect is: extracting memories A, B, C from one slice transcript ⇒ when later a query hits A, B and C get a small score bump (and vice versa). The cohort surfaces together rather than fragmenting across categories. UPDATE / REINFORCE / SUPERSEDE actions don't trigger linkage — linkage is for new co-extracted context, not modifications of existing memories. Best-effort: relation creation failures don't roll back the memory batch. 14 → 16 tests in memory-store.test.ts; new tests verify the 3-memory batch yields C(3,2)=3 edges and a single-CREATE batch yields 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/extensions/sf/memory-store.ts | 36 ++++++++++- .../extensions/sf/tests/memory-store.test.ts | 62 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) 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");