feat(sf): co-extracted memories get auto-linked with related_to

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) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-03 00:13:21 +02:00
parent 55b14c3f78
commit b9bff37623
2 changed files with 96 additions and 2 deletions

View file

@ -3,6 +3,7 @@
// Storage layer for auto-learned project memories. Follows context-store.ts patterns. // Storage layer for auto-learned project memories. Follows context-store.ts patterns.
// All functions degrade gracefully: return empty results when DB unavailable, never throw. // All functions degrade gracefully: return empty results when DB unavailable, never throw.
import { createMemoryRelation } from "./memory-relations.js";
import { import {
_getAdapter, _getAdapter,
decayMemoriesBefore, decayMemoriesBefore,
@ -526,17 +527,26 @@ export function applyMemoryActions(
try { try {
transaction(() => { 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) { for (const action of actions) {
switch (action.action) { switch (action.action) {
case "CREATE": case "CREATE": {
createMemory({ const id = createMemory({
category: action.category, category: action.category,
content: action.content, content: action.content,
confidence: action.confidence, confidence: action.confidence,
source_unit_type: unitType, source_unit_type: unitType,
source_unit_id: unitId, source_unit_id: unitId,
}); });
if (id) createdInBatch.push(id);
break; break;
}
case "UPDATE": case "UPDATE":
updateMemoryContent(action.id, action.content, action.confidence); updateMemoryContent(action.id, action.content, action.confidence);
break; break;
@ -548,6 +558,28 @@ export function applyMemoryActions(
break; 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(); enforceMemoryCap();
}); });
} catch { } catch {

View file

@ -657,6 +657,68 @@ test("supersedeMemory drops the superseded memory's embedding row", async () =>
rmSync(dir, { recursive: true, force: true }); 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 () => { test("enforceMemoryCap sweeps embeddings of newly-superseded memories", async () => {
closeDatabase(); closeDatabase();
const { mkdtempSync, rmSync } = await import("node:fs"); const { mkdtempSync, rmSync } = await import("node:fs");