diff --git a/dist/dsh.js b/dist/dsh.js index c7e4fd9..cc0f586 100644 --- a/dist/dsh.js +++ b/dist/dsh.js @@ -7,7 +7,7 @@ */ import { createHash, randomUUID } from "node:crypto"; import { openDb } from "./src/store/db.js"; -import { allActiveNodes, findByName, getBySession, getStats, getVectorStats, getUnextracted, markExtracted, saveMessageOnce, updateNode, upsertEdge, upsertNode, } from "./src/store/store.js"; +import { allActiveNodes, findByName, getBySession, getStats, getVectorStats, getUnextracted, markExtracted, saveMessageOnce, updateNode, upsertEdge, upsertNode, replaceNodeSources, } from "./src/store/store.js"; import { Extractor } from "./src/extractor/extract.js"; import { Recaller } from "./src/recaller/recall.js"; import { assembleContext } from "./src/format/assemble.js"; @@ -230,11 +230,15 @@ export function apply(ctx, input = {}) { name: stableName, description: "Consolidated checkpoint for an older span of one DSH conversation", content: summary, - }, sid, sources); + }, sid); const node = updateNode(db, result.node.name, { description: "Consolidated checkpoint for an older span of one DSH conversation", content: summary, }) ?? result.node; + // Replace, never append: a fresh summary supersedes the previously + // shadowed span. Keeping the node permanently pinned to every old + // message grows gm_node_sources without bound for long sessions. + replaceNodeSources(db, node.id, sid, sources); void recaller.syncEmbed(node); invalidateGraphCache(); } diff --git a/dist/src/store/store.js b/dist/src/store/store.js index b1644c0..5d70bdf 100644 --- a/dist/src/store/store.js +++ b/dist/src/store/store.js @@ -83,6 +83,16 @@ export function saveNodeSources(db, nodeId, sessionId, sources) { insert.run(nodeId, sessionId, source.messageId); } } +/** + * Replace (not append) the durable message sources of one graph node. + * Used for compaction capsule nodes: a fresh summary supersedes the + * previously shadowed span, so the node must reference only the most + * recent checkpoint instead of accumulating every old span forever. + */ +export function replaceNodeSources(db, nodeId, sessionId, sources) { + db.prepare("DELETE FROM gm_node_sources WHERE node_id=?").run(nodeId); + saveNodeSources(db, nodeId, sessionId, sources); +} /** 按 name 精确更新 description / content;找不到返回 null(调用方决定报错语义) */ export function updateNode(db, name, patch) { const ex = findByName(db, name); diff --git a/dsh.ts b/dsh.ts index 8c2a2c0..cb17b62 100644 --- a/dsh.ts +++ b/dsh.ts @@ -20,6 +20,7 @@ import { updateNode, upsertEdge, upsertNode, + replaceNodeSources, } from "./src/store/store.ts"; import { Extractor } from "./src/extractor/extract.ts"; import { Recaller } from "./src/recaller/recall.ts"; @@ -312,11 +313,15 @@ export function apply(ctx: DshContext, input: Config = {}): void { name: stableName, description: "Consolidated checkpoint for an older span of one DSH conversation", content: summary, - }, sid, sources); + }, sid); const node = updateNode(db, result.node.name, { description: "Consolidated checkpoint for an older span of one DSH conversation", content: summary, }) ?? result.node; + // Replace, never append: a fresh summary supersedes the previously + // shadowed span. Keeping the node permanently pinned to every old + // message grows gm_node_sources without bound for long sessions. + replaceNodeSources(db, node.id, sid, sources); void recaller.syncEmbed(node); invalidateGraphCache(); } diff --git a/src/store/store.ts b/src/store/store.ts index 336b3df..780e3e4 100755 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -111,6 +111,22 @@ export function saveNodeSources( } } +/** + * Replace (not append) the durable message sources of one graph node. + * Used for compaction capsule nodes: a fresh summary supersedes the + * previously shadowed span, so the node must reference only the most + * recent checkpoint instead of accumulating every old span forever. + */ +export function replaceNodeSources( + db: DatabaseSyncInstance, + nodeId: string, + sessionId: string, + sources: Array<{ messageId: string; turnIndex: number }>, +): void { + db.prepare("DELETE FROM gm_node_sources WHERE node_id=?").run(nodeId); + saveNodeSources(db, nodeId, sessionId, sources); +} + /** 按 name 精确更新 description / content;找不到返回 null(调用方决定报错语义) */ export function updateNode( db: DatabaseSyncInstance, diff --git a/test/store.test.ts b/test/store.test.ts index 3fb4fdd..7045c78 100755 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -13,7 +13,7 @@ import { mergeNodes, edgesFrom, edgesTo, allActiveNodes, allEdges, searchNodes, topNodes, graphWalk, getBySession, saveMessage, saveMessageOnce, getMessages, getUnextracted, markExtracted, getEpisodicMessages, - getNodeSourceMessages, + getNodeSourceMessages, replaceNodeSources, saveSignal, pendingSignals, markSignalsDone, getStats, saveVector, vectorSearch, getAllVectors, upsertCommunitySummary, vectorSearchWithScore, @@ -73,6 +73,40 @@ describe("host event messages", () => { }); }); +describe("compaction capsule sources", () => { + it("replaceNodeSources 替换而非追加:新 span 覆盖旧 span", () => { + saveMessageOnce(db, "dsh:s1:100", "dsh:s1", 100, "user", { + role: "user", content: [{ type: "text", text: "old span" }], + }); + saveMessageOnce(db, "dsh:s1:200", "dsh:s1", 200, "user", { + role: "user", content: [{ type: "text", text: "new span" }], + }); + const { node } = upsertNode(db, { + type: "EVENT", name: "session-memory-capsule", description: "", content: "summary v1", + }, "dsh:s1", [{ messageId: "dsh:s1:100", turnIndex: 100 }]); + + // 第二次压缩:替换而非追加 + replaceNodeSources(db, node.id, "dsh:s1", [{ messageId: "dsh:s1:200", turnIndex: 200 }]); + + const texts = getNodeSourceMessages(db, node.id, 1000).map(message => message.text); + expect(texts).toEqual(["new span"]); + expect(texts).not.toContain("old span"); + }); + + it("replaceNodeSources 空 sources 清空节点溯源", () => { + saveMessageOnce(db, "dsh:s1:100", "dsh:s1", 100, "user", { + role: "user", content: [{ type: "text", text: "old span" }], + }); + const { node } = upsertNode(db, { + type: "EVENT", name: "session-memory-capsule-empty", description: "", content: "summary", + }, "dsh:s1", [{ messageId: "dsh:s1:100", turnIndex: 100 }]); + + replaceNodeSources(db, node.id, "dsh:s1", []); + + expect(getNodeSourceMessages(db, node.id, 1000)).toEqual([]); + }); +}); + // ═══════════════════════════════════════════════════════════════ // 节点 CRUD // ═══════════════════════════════════════════════════════════════