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:
parent
55b14c3f78
commit
b9bff37623
2 changed files with 96 additions and 2 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue