@modusensus/dsh-mneme 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +11 -3
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +8 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +298 -12
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1153 -106
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +28 -0
  12. package/lib/graph/anchoring.js +7 -2
  13. package/lib/index.js +50 -4
  14. package/lib/inject.js +15 -3
  15. package/lib/lang.js +54 -0
  16. package/lib/quality-filter.js +48 -12
  17. package/lib/service.js +72 -8
  18. package/lib/settings.js +8 -1
  19. package/lib/store.js +657 -0
  20. package/lib/tools.js +127 -1
  21. package/lib/wiki/inject.js +164 -0
  22. package/lib/wiki/partition.js +200 -0
  23. package/lib/wiki/plan.js +214 -0
  24. package/lib/wiki/queries.js +220 -0
  25. package/lib/wiki/render.js +187 -0
  26. package/lib/wiki/scan.js +371 -0
  27. package/lib/wiki/t0.js +463 -0
  28. package/lib/wiki/t1.js +496 -0
  29. package/lib/write-admission.js +5 -3
  30. package/package.json +4 -2
  31. package/src/api-standalone.js +17 -21
  32. package/src/api.js +298 -12
  33. package/src/bootstrap-http.js +105 -0
  34. package/src/bootstrap.js +5 -1
  35. package/src/choro-command.js +104 -0
  36. package/src/config-keys.js +144 -0
  37. package/src/config.js +28 -0
  38. package/src/graph/anchoring.js +7 -2
  39. package/src/index.js +50 -4
  40. package/src/inject.js +15 -3
  41. package/src/lang.js +54 -0
  42. package/src/quality-filter.js +48 -12
  43. package/src/service.js +72 -8
  44. package/src/settings.js +8 -1
  45. package/src/store.js +657 -0
  46. package/src/tools.js +127 -1
  47. package/src/wiki/inject.js +164 -0
  48. package/src/wiki/partition.js +200 -0
  49. package/src/wiki/plan.js +214 -0
  50. package/src/wiki/queries.js +220 -0
  51. package/src/wiki/render.js +187 -0
  52. package/src/wiki/scan.js +371 -0
  53. package/src/wiki/t0.js +463 -0
  54. package/src/wiki/t1.js +496 -0
  55. package/src/write-admission.js +5 -3
  56. package/test/api-routes-gaps.test.js +131 -1
  57. package/test/api.test.js +7 -1
  58. package/test/bootstrap.test.js +8 -0
  59. package/test/choro-command.test.js +218 -0
  60. package/test/client.test.js +820 -19
  61. package/test/config-keys-routes.test.js +233 -0
  62. package/test/contrast.test.js +14 -7
  63. package/test/graph-boosted-relations.test.js +189 -0
  64. package/test/graph-multipath.test.js +269 -0
  65. package/test/inject.test.js +51 -0
  66. package/test/llm-audit.test.js +29 -1
  67. package/test/quality-backfill.test.js +290 -0
  68. package/test/quality-filter.test.js +113 -4
  69. package/test/service.test.js +1 -1
  70. package/test/wiki-api.test.js +153 -0
  71. package/test/wiki-inject.test.js +265 -0
  72. package/test/wiki-partition.test.js +112 -0
  73. package/test/wiki-plan.test.js +80 -0
  74. package/test/wiki-queries.test.js +187 -0
  75. package/test/wiki-scan.test.js +208 -0
  76. package/test/wiki-store.test.js +190 -0
  77. package/test/wiki-t0.test.js +380 -0
  78. package/test/wiki-t1.test.js +235 -0
  79. package/test/wiki-tools.test.js +121 -0
@@ -0,0 +1,269 @@
1
+ // issue #24 块1 三路起点的回归(tag 桥接 / 向量 / 零分种子)。
2
+ // 这三条都在钉「调用点真的接上了」:能力早就写在 graph/anchoring.js 里,
3
+ // 之前的病是 service.js 只喂了一路 exact,另两路永远等不到自己被调用。
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { createStore } from "../src/store.js";
7
+ import { createService } from "../src/service.js";
8
+ import { createVectorIndex } from "../src/vector-index.js";
9
+ import { HOP_QUOTA } from "../src/graph/anchoring.js";
10
+
11
+ function mockEmbedder(vec = [1, 0, 0, 0]) {
12
+ return {
13
+ embedSingle: async () => vec,
14
+ embed: async (texts) => texts.map(() => vec),
15
+ schedule: () => {},
16
+ modelHash: "mock#abc",
17
+ dimension: 4
18
+ };
19
+ }
20
+
21
+ function makeService(config = {}, { withEmbedder = false } = {}) {
22
+ const store = createStore(":memory:");
23
+ const service = createService({ store, mirror: null, config });
24
+ if (withEmbedder) {
25
+ const vectorIndex = createVectorIndex({ store });
26
+ service.setEmbedder(mockEmbedder());
27
+ service.setVectorIndex(vectorIndex);
28
+ return { store, service, vectorIndex };
29
+ }
30
+ return { store, service, vectorIndex: null };
31
+ }
32
+
33
+ function putMemory(store, service, { type = "project", title, content, tags = [], importance = 3 }) {
34
+ return service.saveWithDedupe({ type, title, content, tags, importance }).memory;
35
+ }
36
+
37
+ function linkAttr(store, entityName, memory, { key = "k", value = "v", type = "technology" } = {}) {
38
+ let entity = store.findEntityByName(entityName);
39
+ if (!entity) entity = store.createEntity({ name: entityName, type });
40
+ store.saveAttr({ entity_id: entity.id, attr_key: key, attr_value: value, memory_id: memory.id });
41
+ return entity;
42
+ }
43
+
44
+ function linkEntities(store, fromName, toName, { relType = "uses", type = "technology" } = {}) {
45
+ const a = store.findEntityByName(fromName) ?? store.createEntity({ name: fromName, type });
46
+ const b = store.findEntityByName(toName) ?? store.createEntity({ name: toName, type });
47
+ store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: relType });
48
+ return { a, b };
49
+ }
50
+
51
+ // ---- Tag 桥接 -------------------------------------------------------------
52
+ // 查询「数据库迁移」既不是任何实体名,也不含实体名的子串。tag 命中一条记忆后,
53
+ // 该记忆挂联的实体就是「这次在谈的东西」,沿它级联出去的邻居记忆才有机会进池。
54
+ // 判别点:farMem 只能经 PgTuner → PgCache 这条边到达;tag 路不接线时它必然缺席。
55
+ test("tag path bridges a tag hit into an entity seed (query names no entity)", async () => {
56
+ const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
57
+ // nearMem 带 tag,标题正文都不含查询词——但 store.search 的 tags LIKE 也会命中它,
58
+ // 所以它的存在不是本条用例的判别点,判别点是 farMem。
59
+ const nearMem = putMemory(store, service, {
60
+ title: "alpha", content: "beta gamma", tags: ["数据库迁移"]
61
+ });
62
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
63
+ linkAttr(store, "PgTuner", nearMem);
64
+ linkAttr(store, "PgCache", farMem);
65
+ linkEntities(store, "PgTuner", "PgCache");
66
+
67
+ const rows = await service.searchMemories("数据库迁移", { mode: "hybrid" });
68
+ const ids = rows.map((r) => r.id);
69
+ assert.ok(ids.includes(farMem.id), "1-hop memory reached only via the tag-bridged seed");
70
+ const far = rows.find((r) => r.id === farMem.id);
71
+ assert.ok(
72
+ Math.abs(far.score - 1.0 * HOP_QUOTA[1] * 0.3) < 1e-9,
73
+ `hop-quota weighting preserved through the tag path, got ${far.score}`
74
+ );
75
+ });
76
+
77
+ // 关锚定时 tag 路一次都不许查:否则「默认关=旧单跳轴逐字节不变」这条承诺就破了。
78
+ test("tag path is not consulted when anchoring is off", async () => {
79
+ const { store, service } = makeService({ entityRecallEnabled: true });
80
+ const nearMem = putMemory(store, service, {
81
+ title: "alpha", content: "beta gamma", tags: ["数据库迁移"]
82
+ });
83
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
84
+ linkAttr(store, "PgTuner", nearMem);
85
+ linkAttr(store, "PgCache", farMem);
86
+ linkEntities(store, "PgTuner", "PgCache");
87
+
88
+ const rows = await service.searchMemories("数据库迁移", { mode: "hybrid" });
89
+ assert.ok(!rows.some((r) => r.id === farMem.id), "no cascade when anchoring is off");
90
+ });
91
+
92
+ // ---- 向量起点 -------------------------------------------------------------
93
+ // 查询里没有实体名、也没有 tag 命中,唯一能给出种子的是向量。早退条件若仍以
94
+ // 「名字没命中」收场,第三路接上了也永远不被调用——这条用例正是钉那个早退。
95
+ test("vector path seeds from near memories when the query names no entity", async () => {
96
+ const { store, service, vectorIndex } = makeService(
97
+ { entityRecallEnabled: true, graphAnchoringEnabled: true },
98
+ { withEmbedder: true }
99
+ );
100
+ const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
101
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
102
+ vectorIndex.saveEmbedding(nearMem.id, [1, 0, 0, 0]); // farMem 刻意不嵌:它只能经图轴到达
103
+ linkAttr(store, "VecSeed", nearMem);
104
+ linkAttr(store, "VecNeighbor", farMem);
105
+ linkEntities(store, "VecSeed", "VecNeighbor");
106
+
107
+ const rows = await service.searchMemories("omega", { mode: "hybrid" });
108
+ const ids = rows.map((r) => r.id);
109
+ assert.ok(ids.includes(farMem.id), "1-hop memory reached through the vector seed");
110
+ const far = rows.find((r) => r.id === farMem.id);
111
+ assert.ok(
112
+ Math.abs(far.score - HOP_QUOTA[1] * 0.3) < 1e-9,
113
+ `vector seed keeps hop-quota weighting, got ${far.score}`
114
+ );
115
+ });
116
+
117
+ // ---- 向量起点也要看关系端点 -----------------------------------------------
118
+ // 与 Tag 路同一条病:一条近邻记忆若只通过关系挂到实体上(phaseRelations 不建
119
+ // attr 行),只读 entity_attrs 的反查会漏掉它,向量路就凭空少了种子。
120
+ test("vector path seeds from relation-only memories, not just attr rows", async () => {
121
+ const { store, service, vectorIndex } = makeService(
122
+ { entityRecallEnabled: true, graphAnchoringEnabled: true },
123
+ { withEmbedder: true }
124
+ );
125
+ const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
126
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
127
+ vectorIndex.saveEmbedding(nearMem.id, [1, 0, 0, 0]);
128
+ // nearMem 不写 attr,只通过一条关系挂到 RelSeed / RelNeighbor 上。
129
+ const seed = store.createEntity({ name: "RelSeed", type: "module" });
130
+ const neighbor = store.createEntity({ name: "RelNeighbor", type: "module" });
131
+ store.saveRelation({
132
+ from_entity: seed.id, to_entity: neighbor.id, relation_type: "uses",
133
+ memory_id: nearMem.id, metadata: { source: "sleep_relation_completion" }
134
+ });
135
+ linkAttr(store, "RelNeighbor", farMem, { key: "k2" });
136
+
137
+ const rows = await service.searchMemories("omega", { mode: "hybrid" });
138
+ assert.ok(rows.some((r) => r.id === farMem.id),
139
+ "a relation-only near memory must still produce vector seeds");
140
+ });
141
+
142
+ // 对照:没有 embedder 就没有向量路,三路全空时必须老实返回空,而不是凭空造种子。
143
+ test("vector path absent leaves the axis empty (no phantom seeds)", async () => { const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
144
+ const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
145
+ const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
146
+ linkAttr(store, "VecSeed", nearMem);
147
+ linkAttr(store, "VecNeighbor", farMem);
148
+ linkEntities(store, "VecSeed", "VecNeighbor");
149
+
150
+ const rows = await service.searchMemories("omega", { mode: "hybrid" });
151
+ assert.ok(!rows.some((r) => r.id === farMem.id), "no seed sources → no cascade");
152
+ });
153
+
154
+ // ---- 零分种子 -------------------------------------------------------------
155
+ // min-max 会把连续通路里**最弱的那条**压成 0。0 分种子仍会被 anchorSeeds 收下,
156
+ // 而下游给种子挂联记忆的是**固定满权 tier**(不看种子自己的分数)——于是「这一路
157
+ // 里最不像的那条」会以最强的姿态把记忆拉进池。
158
+ //
159
+ // 两条近邻记忆都必须拿到**正分**:若让第二条与查询正交(cos=0),它在进向量
160
+ // 路时就被 score > 0 挡掉了,min-max 根本轮不到把它压成 0——那样这条用例会
161
+ // 以「从没发生过」的方式通过,看着绿、其实什么都没验。
162
+ //
163
+ // zeroMem 必须**挂在 0 分种子自己身上**(VoZero 同时挂 nearB 与 zeroMem)。
164
+ // 首版把它挂在另一个孤立实体上,于是无论滤不滤 0 分种子它都到不了——变异自证
165
+ // 当场抓到那条用例是空转的。
166
+ test("a zero-score seed does not inject its linked memory at full tier", async () => {
167
+ const { store, service, vectorIndex } = makeService(
168
+ { entityRecallEnabled: true, graphAnchoringEnabled: true },
169
+ { withEmbedder: true }
170
+ );
171
+ const nearA = putMemory(store, service, { title: "alpha", content: "beta" });
172
+ const nearB = putMemory(store, service, { title: "gamma", content: "delta" });
173
+ const zeroMem = putMemory(store, service, { title: "iota", content: "kappa" });
174
+ vectorIndex.saveEmbedding(nearA.id, [1, 0, 0, 0]); // cos = 1.0
175
+ vectorIndex.saveEmbedding(nearB.id, [0.7, 0.7, 0, 0]); // cos ≈ 0.707,正分但更低 → min-max 压成 0
176
+ linkAttr(store, "VoSeed", nearA);
177
+ linkAttr(store, "VoZero", nearB);
178
+ linkAttr(store, "VoZero", zeroMem, { key: "k2" }); // 同一实体挂第二条记忆,只能经这颗 0 分种子到达
179
+ // VoSeed 与 VoZero 之间不建边:zeroMem 拿不到任何别的到达路径。
180
+
181
+ const rows = await service.searchMemories("omega", { mode: "hybrid" });
182
+ assert.ok(rows.some((r) => r.id === nearB.id), "the weak memory itself still comes back via vector");
183
+ assert.ok(
184
+ !rows.some((r) => r.id === zeroMem.id),
185
+ "a 0-score seed must not pull its linked memory in at full tier"
186
+ );
187
+ });
188
+
189
+ // ---- Tag 桥接的排序 -------------------------------------------------------
190
+ // 「一个实体命中了几条记忆」是这条路的强弱信号,必须在 SQL 里数。
191
+ // 首版拿 entityIdsForMemories 的 DISTINCT 结果反推计数,每个实体恒为 1,
192
+ // 排序静默失效、只剩名字序(评审指出)。判别点:limit=1 时返回的是宽实体。
193
+ // 命名上必须让「宽实体」在字母序里排后面——首版把宽实体叫 AWide,于是字母序
194
+ // 也选它,用例空转通过(变异自证抓到的)。
195
+ test("findEntitiesByTags ranks entities by how many tagged memories they cover", async () => {
196
+ const { store, service } = makeService();
197
+ const m1 = putMemory(store, service, { title: "m1", content: "c1", tags: ["数据库迁移"] });
198
+ const m2 = putMemory(store, service, { title: "m2", content: "c2", tags: ["数据库迁移"] });
199
+ // ZWide 挂两条命中记忆;ANarrow 只挂一条。名字序让 ANarrow 排前面,
200
+ // 所以只要计数生效,返回的必然是 ZWide。
201
+ linkAttr(store, "ZWide", m1);
202
+ linkAttr(store, "ZWide", m2, { key: "k2" });
203
+ linkAttr(store, "ANarrow", m1);
204
+
205
+ const hits = store.findEntitiesByTags("数据库迁移", { limit: 1 });
206
+ assert.equal(hits.length, 1);
207
+ assert.equal(hits[0].name, "ZWide",
208
+ "the entity covering more tagged memories must win over the alphabetical order");
209
+ });
210
+
211
+ // ---- Tag 锚点的两个来源 ---------------------------------------------------
212
+ // phaseRelations 只写 entity_relations.memory_id,不建 attr 行。所以「只通过关系
213
+ // 挂到实体上」的记忆,若锚点只读 entity_attrs,关系两端一个种子都生不出来。
214
+ // 判别点:这两个实体都没有任何 attr 行,只能从关系侧被看到。
215
+ test("findEntitiesByTags sees relation endpoints, not only attr rows", () => {
216
+ const { store, service } = makeService();
217
+ const mem = putMemory(store, service, { title: "pg 调优", content: "一次调优", tags: ["postgres"] });
218
+ const a = store.createEntity({ name: "PgTuner", type: "module" });
219
+ const b = store.createEntity({ name: "PgCache", type: "module" });
220
+ store.saveRelation({
221
+ from_entity: a.id, to_entity: b.id, relation_type: "uses",
222
+ memory_id: mem.id, metadata: { source: "sleep_relation_completion" }
223
+ });
224
+
225
+ const names = store.findEntitiesByTags("postgres", { limit: 5 }).map((h) => h.name).sort();
226
+ assert.deepEqual(names, ["PgCache", "PgTuner"],
227
+ "a memory linked only through a relation must seed both endpoints");
228
+ });
229
+
230
+ // 同一 (实体, 记忆) 被 attr 与关系同时覆盖时只算一条:UNION 去的是对,不是行。
231
+ test("findEntitiesByTags counts a memory once even when attr and relation both cover it", () => {
232
+ const { store, service } = makeService();
233
+ const m1 = putMemory(store, service, { title: "a", content: "b", tags: ["缓存"] });
234
+ const m2 = putMemory(store, service, { title: "c", content: "d", tags: ["缓存"] });
235
+ const wide = linkAttr(store, "ZBoth", m1);
236
+ store.saveRelation({ from_entity: wide.id, to_entity: wide.id, relation_type: "related_to", memory_id: m1.id });
237
+ linkAttr(store, "ABoth", m2);
238
+
239
+ const hits = store.findEntitiesByTags("缓存", { limit: 2 });
240
+ assert.deepEqual(hits.map((h) => h.name).sort(), ["ABoth", "ZBoth"]);
241
+ // 两边都是 1 条记忆时按名字序;若 m1 被数成 2 条,ZBoth 会排到前面。
242
+ assert.equal(hits[0].name, "ABoth",
243
+ "duplicate coverage of the same memory must not inflate the count");
244
+ });
245
+
246
+ // ---- 分块后计数仍然正确 ---------------------------------------------------
247
+ // TAG_ID_CHUNK = 500,这条用例造 600 条命中记忆强制走两块:ZBulk 每块各 300 条
248
+ // (合计 600),ABulk 只有 400 条(全在第一块)。判别点:正确累加时 ZBulk 胜出;
249
+ // 若第二块覆盖而不是累加,ZBulk 只剩 300 < 400,字母序靠前的 ABulk 会赢。
250
+ test("findEntitiesByTags accumulates counts across chunk boundaries", () => {
251
+ const { store } = makeService();
252
+ const zBulk = store.createEntity({ name: "ZBulk", type: "module" });
253
+ const aBulk = store.createEntity({ name: "ABulk", type: "module" });
254
+ const makeTagged = (i) => store.save({
255
+ type: "project", title: `t${i}`, content: `c${i}`, tags: ["分块测试"], importance: 3
256
+ });
257
+ // 600 条:前 300 条挂 ZBulk,后 300 条挂 ZBulk 的另一批 attr key,
258
+ // 其中前 400 条同时挂 ABulk(跨两块)。
259
+ for (let i = 0; i < 600; i++) {
260
+ const m = makeTagged(i);
261
+ store.saveAttr({ entity_id: zBulk.id, attr_key: `k${i % 2}`, attr_value: "v", memory_id: m.id });
262
+ if (i < 400) store.saveAttr({ entity_id: aBulk.id, attr_key: "k", attr_value: "v", memory_id: m.id });
263
+ }
264
+
265
+ const hits = store.findEntitiesByTags("分块测试", { limit: 1 });
266
+ assert.equal(hits.length, 1);
267
+ assert.equal(hits[0].name, "ZBulk",
268
+ "counts must be summed across chunks, not overwritten by the last batch");
269
+ });
@@ -441,3 +441,54 @@ test("throwing surface getter degrades to full-log hot memory instead of a blank
441
441
  assert.ok(text.includes("热记忆不该因此整块消失"), "轮次回复应照旧进入");
442
442
  assert.equal(getInjectionSnapshot().query, "把投影插件卸了会怎样", "语义查询不受 getter 抛错影响");
443
443
  });
444
+
445
+ // M3 仓库地志(chorography):注入块折进同一个 memory context(不新开 context),
446
+ // 次序是 时间 → 地志 → 热上下文 → 长期记忆。这三例锁的是「折进来之后仍只有一个块」
447
+ // 与「空串不占位」——后者是宿主契约(空文本 section 被 filter 掉),不是我们自己加的。
448
+ function setupWithChoro(choroText, over = {}) {
449
+ const store = createStore(":memory:");
450
+ const service = createService({ store, mirror: null, config: {} });
451
+ const settings = createSettings(store.db);
452
+ const contexts = [];
453
+ const ctx = { systemPrompt: { context(def) { contexts.push(def); return () => {}; } } };
454
+ const calls = [];
455
+ const choroInject = {
456
+ tier: "light",
457
+ text(cwd) { calls.push(cwd); return typeof choroText === "function" ? choroText(cwd) : choroText; },
458
+ dispose() {}
459
+ };
460
+ const config = { maxInjectedItems: 3, importanceThreshold: 3, ...over };
461
+ const injector = createInjector(ctx, service, settings, config, null, null, choroInject);
462
+ return { store, service, contexts, injector, calls, ctx };
463
+ }
464
+
465
+ test("M3: 地志块折进 memory context,不新开第二个 context", () => {
466
+ const { contexts } = setupWithChoro("[仓库地志]");
467
+ assert.equal(contexts.length, 2, "折进来就仍是两块;多开一块会改宿主的装配顺序");
468
+ assert.equal(contexts[0].name, "memory");
469
+ });
470
+
471
+ test("M3: 地志块排在热上下文与长期记忆之前,且 cwd 取自 scope.workspace_scope", () => {
472
+ const { contexts, service, calls } = setupWithChoro("[仓库地志]");
473
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "用户用中文交流", importance: 5 });
474
+ const events = [
475
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "这个仓库怎么组织" }] } },
476
+ { type: "assistant/message", data: { message: { content: [{ type: "text", text: "按模块分组" }] } } }
477
+ ];
478
+ // scope.workspace_scope 不是入参,是 scope.js 现场解析出来的(registry 反查,
479
+ // header.cwd 兜底);测试只能提供 header.cwd,才能真验「注入器吃的是那一份口径」。
480
+ const text = contexts[0].text({ agent: { session: { header: { cwd: "/repo" }, snapshotEvents: () => events } } });
481
+ const iChoro = text.indexOf("[仓库地志]");
482
+ const iHot = text.indexOf("[短期上下文]");
483
+ const iMem = text.indexOf("[记忆库]");
484
+ assert.ok(iChoro >= 0 && iHot > iChoro && iMem > iHot, "次序 地志 → 热上下文 → 长期记忆:" + text);
485
+ assert.ok(calls.includes("/repo"), "cwd 只能用 scope.workspace_scope 这一份口径(P27),calls=" + JSON.stringify(calls));
486
+ });
487
+
488
+ test("M3: 地志为空串时不占位,块首尾不留空行;choroChars 记实际体积", () => {
489
+ const { contexts, service } = setupWithChoro("");
490
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "用户用中文交流", importance: 5 });
491
+ const text = contexts[0].text({});
492
+ assert.equal(text.startsWith("[记忆库]"), true, "空串拼接不许在块首留下空行:" + JSON.stringify(text.slice(0, 8)));
493
+ assert.equal(getInjectionSnapshot().choroChars, 0);
494
+ });
@@ -552,7 +552,7 @@ test("entity extraction writes an llm_audit row with the streamed usage tokens",
552
552
  });
553
553
  const text = await streamEntityText(
554
554
  [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
555
- {}
555
+ { memoryId: "mem-ok-1" }
556
556
  );
557
557
  assert.ok(text.includes("entities"), "the extracted text still reaches the extractor");
558
558
 
@@ -565,6 +565,34 @@ test("entity extraction writes an llm_audit row with the streamed usage tokens",
565
565
  assert.equal(row.input_tokens, 700, "input tokens come from chunk.usage");
566
566
  assert.equal(row.output_tokens, 180, "output tokens come from chunk.usage");
567
567
  assert.equal(row.total_tokens, 880);
568
+ // success 行同样必须可归因。此前这里不传 memoryId、也不断言 id,于是
569
+ // writeAudit("success", null) 省略第三参的缺陷在绿测试下活了很久——活库
570
+ // 6256 条 success 行 related_memory_ids 100% 空数组,成功的那些抽取反而
571
+ // 一条都对不上记忆,#405 只修了 error 半边。
572
+ assert.deepEqual(row.related_memory_ids, ["mem-ok-1"],
573
+ "a successful extraction is attributable to the memory it read");
574
+ store.close();
575
+ });
576
+
577
+ test("entity extraction success row still leaves the id empty when there is none to attribute", async () => {
578
+ const store = createStore(":memory:");
579
+ const service = createService({ store, mirror: null, config: {} });
580
+ const streamEntityText = createEntityStreamAdapter({
581
+ llm: {
582
+ async *stream() {
583
+ yield { type: "text-delta", index: 0, text: '{"entities":[],"relations":[]}' };
584
+ yield { type: "finish", reason: { kind: "stop" } };
585
+ }
586
+ },
587
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
588
+ logger: { warn: () => {} },
589
+ service
590
+ });
591
+ await streamEntityText([{ role: "user", content: [{ type: "text", text: "记忆内容" }] }], {});
592
+ const row = service.listLlmAudits({ source: "entityExtract" })[0];
593
+ assert.equal(row.status, "success");
594
+ assert.deepEqual(row.related_memory_ids, [],
595
+ "no memoryId → empty, never a fabricated id");
568
596
  store.close();
569
597
  });
570
598