@modusensus/dsh-mneme 0.8.14 → 0.8.15

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 (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -673
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -843
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -503
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -229
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -215
  65. package/lib/service.js +2663 -2605
  66. package/lib/settings.js +517 -511
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +118 -118
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -36
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -673
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -843
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -503
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -229
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -215
  137. package/src/service.js +2663 -2605
  138. package/src/settings.js +517 -511
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1690
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -60
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -77
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -367
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -121
  246. package/test/serve.test.js +196 -196
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -751
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
@@ -0,0 +1,394 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+ import { createInjector, getInjectionSnapshot } from "../src/inject.js";
6
+ import { createSettings } from "../src/settings.js";
7
+ import { createTools } from "../src/tools.js";
8
+ import { createDreamScheduler } from "../src/dream.js";
9
+ import { createVectorIndex } from "../src/vector-index.js";
10
+ import { Config } from "../src/config.js";
11
+ import { mockCtx } from "./helpers/dream-mock.js";
12
+
13
+ // --- 状态追踪回归集(issue #218 前置)----------------------------------------
14
+ //
15
+ // 读侧形状锁:一次 supersede 之后的终态必须同时满足三条互相牵制的性质——
16
+ // (a) 检索链(service.searchMemories / store.search / memory_search 工具)
17
+ // 不得返回被取代的行、也不得返回它的旧值正文;
18
+ // (b) 注入链(src/inject.js 的 candidates 选择 + render)不得把旧值渲染进
19
+ // 系统提示;
20
+ // (c) 旧值仍可通过 memory_get 按 id 取回——supersede 是「归档」不是「删除」。
21
+ //
22
+ // 防的回归类型:读侧的归档过滤被绕过。具体有三个已知的绕过面——
23
+ // 1) 某条召回支路(keyword SQL / vector / BM25 / entity)忘了带
24
+ // `archived = 0`,旧值借那条支路回到候选池;
25
+ // 2) 注入候选池忘了过滤 archived(或只过滤 forgotten),旧值被渲染进
26
+ // 每一轮的系统提示,模型此后持续引用一个已被取代的旧状态;
27
+ // 3) 有人为了让「归档旧值可查阅」更好用,顺手把 memory_get 之外的口子
28
+ // (search/list)也放开——(a)(b) 与 (c) 是一组:只放开任意一条就会
29
+ // 在这里变红。这就是「保留旧记忆锚」策略的前置条件:保留 ≠ 可召回。
30
+ //
31
+ // 支路形状锁(第 (d)(e) 两条用例):前三 (a)(b)(c) 跑在 config:{}(无 embedder)
32
+ // 上,mode=auto 实际只落 keyword + BM25,语义支路一次都没被走到。后两条装上
33
+ // 确定性假 embedder(纯函数、零网络、无真实模型)与向量索引,把同一个回归
34
+ // 类型钉到语义支路上:
35
+ // (d) 真索引:mode=vector/hybrid/auto 经 vectorIndex → store.searchVector 时,
36
+ // SQL 的 `archived = 0` 是这条支路唯一的墙——src/service.js 的向量结果
37
+ // 没有 JS 层 archived 复查,拆掉 SQL 过滤旧值立刻借向量回到候选池;
38
+ // (e) 故障注入型假索引:刻意让「源侧失守」(直接返回带 archived 的行),压
39
+ // src/service.js:1583(向量命中池)与 :1594(lastSemanticRecall 语义
40
+ // 缓存池)两处 JS 层 `!m.archived` 兜底——源侧正常时本就滤过归档,只有
41
+ // 源侧失守才轮到这两行承重(同形状的 :1607 是 BM25 兜底,其上游
42
+ // bm25Recall 已在 src/service.js:472 按 JS 滤过 archived,属非承重保险)。
43
+ //
44
+ // 夹具刻意不手改数据库字段:走 dream 的 consolidation 决策 →
45
+ // validateDecisions → applySupersede,拿到的是仓库真实的 supersede 终态
46
+ // (loser 追加取代注记后被 setArchived(true),winner 正文一字不动)。
47
+
48
+ const OLD_TITLE = "缓存池配置 v1";
49
+ const NEW_TITLE = "缓存池配置 v2";
50
+ // 正文里放两个互不包含的唯一令牌——只用「20 条 / 200 条」这类前缀相同的
51
+ // 数字做子串断言会自己咬到自己("20 条" 是 "200 条" 的子串吗?边界上极易
52
+ // 写错),令牌化之后 includes 的语义没有歧义。
53
+ const OLD_MARK = "ALPHA-7721";
54
+ const NEW_MARK = "BETA-9930";
55
+ // 两个版本共享的查询词:keyword 支路靠它把参与 supersede 的两行都捞进候选,
56
+ // 于是「旧值不在结果里」只能是归档过滤干的事,而不是「压根没匹配上」。
57
+ const QUERY = "缓存池配置";
58
+ // 语义支路专用的探针查询:只出现在假 embedder 的映射规则里,正文与标题都不含
59
+ // 它,因此 keyword / BM25 / entity 三条词法支路对它必然空手——命中只能来自
60
+ // 向量支路,压的正是 vectorIndex → store.searchVector 这条链。
61
+ const PROBE = "SEM-PROBE-4477";
62
+
63
+ // 假 embedder:确定性纯函数替身——零网络、零真实模型、零随机。含任一语义令牌
64
+ // 的文本(两条项目记忆各含自己的令牌,探针查询即 PROBE)都映到 [1,0,0],其余
65
+ // 落 [0,1,0]:老/新两行在向量空间里完全同向,旧值若浮出只可能是归档过滤失守,
66
+ // 而不是「它压根没被嵌入」。
67
+ //
68
+ // `schedule` 把写入路径排进一个队列(真实 embedder 也是异步写回),由夹具在
69
+ // dream 之后用 `flush(store)` 显式排空——这样嵌入写入是确定的、没有悬空异步。
70
+ // 这一步不是可有可无的:service.update 改正文时会先落空向量再重排嵌入(正文
71
+ // 变了旧向量即失效),supersede 恰好 update 了 loser 的正文(追加取代注记),
72
+ // 所以 loser 的向量是「重嵌入」补回来的——排空队列后 loser 才真的带着向量躺在
73
+ // 归档态里,向量支路的 archived 过滤也才真的被压到。
74
+ function semanticEmbedder() {
75
+ const pending = [];
76
+ const lit = (text) =>
77
+ String(text).includes(PROBE) || String(text).includes(OLD_MARK) || String(text).includes(NEW_MARK);
78
+ const one = async (text) => (lit(text) ? [1, 0, 0] : [0, 1, 0]);
79
+ return {
80
+ embedSingle: one,
81
+ embed: async (texts) => texts.map((t) => (lit(t) ? [1, 0, 0] : [0, 1, 0])),
82
+ schedule: (memory) => {
83
+ pending.push(memory);
84
+ },
85
+ async flush(store) {
86
+ for (const memory of pending.splice(0)) {
87
+ const text = [memory.title, memory.content].filter(Boolean).join("\n");
88
+ const vector = await one(text);
89
+ store.setEmbedding(memory.id, vector);
90
+ }
91
+ },
92
+ modelHash: "mock#supersede-state-tracking",
93
+ dimension: 3
94
+ };
95
+ }
96
+
97
+ async function supersededFixture({ embedder = null } = {}) {
98
+ const store = createStore(":memory:");
99
+ const service = createService({ store, mirror: null, config: {} });
100
+ const vectorIndex = embedder ? createVectorIndex({ store }) : null;
101
+ if (embedder) {
102
+ service.setEmbedder(embedder);
103
+ service.setVectorIndex(vectorIndex);
104
+ }
105
+ const legacy = service.saveWithDedupe({
106
+ type: "project",
107
+ title: OLD_TITLE,
108
+ content: `缓存池上限 20 条;旧值令牌 ${OLD_MARK}`,
109
+ importance: 4
110
+ }).memory;
111
+ const current = service.saveWithDedupe({
112
+ type: "project",
113
+ title: NEW_TITLE,
114
+ content: `缓存池上限 200 条;现值令牌 ${NEW_MARK}`,
115
+ importance: 5
116
+ }).memory;
117
+
118
+ // 建索引必须在 dream 之前:store.needsEmbedding(src/store.js:1545)只认
119
+ // archived = 0 的行,归档之后再 rebuild 根本不会给 loser 补向量,那样「旧值
120
+ // 不在向量结果里」就成了「上游压根没索引它」的平凡结论,压不到 archived 过滤。
121
+ // 归档本身不清向量(只有 reclaim 会清,src/store.js:2107),所以 loser 带着
122
+ // 向量活到 supersede 之后,两条路(SQL 过滤 / JS 兜底)才有得压。
123
+ if (vectorIndex) await vectorIndex.rebuildIndex(embedder);
124
+
125
+ const ctx = mockCtx({
126
+ onConsolidation: () => JSON.stringify([
127
+ { action: "supersede", winner: current.id, loser: legacy.id, reason: "版本演进" }
128
+ ])
129
+ });
130
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
131
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
132
+ // 排空嵌入队列:supersede 的 service.update(loser, {content}) 会让 loser 的旧
133
+ // 向量失效并重排一次嵌入,走的就是 embedder.schedule 这条真实异步路径。
134
+ // 不排空的话 loser 归档后没有向量,向量支路的 archived 过滤根本没被压到。
135
+ if (embedder && typeof embedder.flush === "function") await embedder.flush(store);
136
+
137
+ // 夹具自检(不是被测性质):确认真的走到了 supersede 终态,且旧行是被归档
138
+ // 而不是被删。这两条不成立时后面的绿/红都没有意义。
139
+ assert.equal(result.status, "ok", `dream 路径未走通:${result.error ?? JSON.stringify(result)}`);
140
+ const archived = store.getById(legacy.id);
141
+ assert.ok(archived, "被取代的行必须还在库里(归档非删除)");
142
+ assert.equal(archived.archived, true, "真实路径必须把 loser 置为 archived");
143
+ assert.ok(
144
+ archived.content.includes(OLD_MARK),
145
+ "归档行的正文(连同旧值)应原样保留,只是被隐藏"
146
+ );
147
+ assert.ok(
148
+ archived.content.includes(NEW_TITLE),
149
+ "取代注记应写在 loser 上并指向 winner(issue #126 口径)"
150
+ );
151
+ assert.equal(store.getById(current.id).content.includes(OLD_MARK), false, "winner 正文不得掺入旧值");
152
+ if (vectorIndex) {
153
+ // 夹具自检:loser 必须带着向量进入归档态。否则「旧值不在向量结果里」只是
154
+ // 它没有向量,向量支路的 archived 过滤根本没被压到。
155
+ assert.ok(
156
+ store.getParsedEmbedding(legacy.id),
157
+ "loser 归档后仍须带向量,语义支路用例才有意义"
158
+ );
159
+ }
160
+
161
+ return { store, service, legacy, current, result, vectorIndex };
162
+ }
163
+
164
+ // (a) 检索链:service.searchMemories(memory_search 的同一实现)/ store.search
165
+ // / memory_search 工具,都不得召回被取代的旧值。
166
+ test("supersede 后检索链不得返回被取代的旧值(keyword/auto/store/工具)", async () => {
167
+ const { store, service, legacy, current } = await supersededFixture();
168
+
169
+ const keyword = await service.searchMemories(QUERY, { mode: "keyword", useRerank: false, recordRecall: false });
170
+ const auto = await service.searchMemories(QUERY, { mode: "auto", useRerank: false, recordRecall: false });
171
+ const direct = service.search(QUERY);
172
+
173
+ const registered = [];
174
+ createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, Config({}), null);
175
+ const searchTool = registered.find((t) => t.name === "memory_search");
176
+ const viaTool = (await searchTool.execute({ query: QUERY, rerank: false })).items;
177
+
178
+ const chains = [
179
+ ["searchMemories(mode=keyword)", keyword],
180
+ ["searchMemories(mode=auto)", auto],
181
+ ["store.search", direct],
182
+ ["memory_search 工具", viaTool]
183
+ ];
184
+
185
+ for (const [name, rows] of chains) {
186
+ // 反向对照:新值必须能被查到。否则一条「什么都查不到」的实现也能让这条
187
+ // 测试变绿——那是假绿,不是状态追踪正确。
188
+ assert.ok(
189
+ rows.some((r) => r.id === current.id),
190
+ `${name}:新值必须可检索到(否则该支路是空结果,测试无意义)`
191
+ );
192
+ assert.equal(
193
+ rows.some((r) => r.id === legacy.id),
194
+ false,
195
+ `${name}:不得返回被取代的行 ${legacy.id}`
196
+ );
197
+ assert.equal(
198
+ rows.some((r) => String(r.content ?? "").includes(OLD_MARK)),
199
+ false,
200
+ `${name}:不得返回被取代的旧值正文 ${OLD_MARK}`
201
+ );
202
+ }
203
+
204
+ // toApiList 之后(工具出口的 DTO)同样不得残留旧值。
205
+ assert.equal(
206
+ service.toApiList(direct).some((r) => String(r.content ?? "").includes(OLD_MARK)),
207
+ false,
208
+ "toApiList 出口不得残留旧值"
209
+ );
210
+
211
+ store.close();
212
+ });
213
+
214
+ // (b) 注入链:src/inject.js 的候选选择 + render 不得把旧值渲染进系统提示。
215
+ test("supersede 后注入候选不得包含被取代的旧值", async () => {
216
+ const { store, service, legacy, current } = await supersededFixture();
217
+
218
+ const contexts = [];
219
+ const ctx = {
220
+ systemPrompt: {
221
+ context(def) {
222
+ contexts.push(def);
223
+ return () => {};
224
+ }
225
+ },
226
+ // 带上一轮真实用户查询:注入走查询驱动的候选选择(而不是退化成纯重要性
227
+ // 兜底),才真正压到 recall → injectCandidates 这条链上。
228
+ agent: {
229
+ session: {
230
+ id: "supersede-state-tracking",
231
+ snapshotEvents: () => [
232
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: QUERY }] } }
233
+ ]
234
+ }
235
+ }
236
+ };
237
+ createInjector(ctx, service, createSettings(store.db), { maxInjectedItems: 5, importanceThreshold: 3 });
238
+
239
+ const memorySection = contexts.find((c) => c.name === "memory");
240
+ assert.ok(memorySection, "注入器必须注册 memory 段");
241
+ const text = memorySection.text(ctx);
242
+
243
+ assert.ok(text.includes(NEW_MARK), "新值应当被注入(否则注入是空的,断言不成立)");
244
+ assert.equal(text.includes(OLD_MARK), false, "注入正文不得包含被取代的旧值");
245
+ assert.equal(text.includes(OLD_TITLE), false, "注入正文不得包含被取代的旧标题");
246
+
247
+ // 注入预览快照(面板数据底座)是同一帧的另一视角:id 集合也不得含 loser,
248
+ // 否则「预览里看得见、正文里看不见」会掩盖同一处过滤缺失。
249
+ const snap = getInjectionSnapshot();
250
+ assert.ok(snap, "一次渲染后应留下注入快照");
251
+ assert.ok(snap.entries.some((e) => e.id === current.id), "快照应含新值条目");
252
+ assert.equal(snap.entries.some((e) => e.id === legacy.id), false, "快照不得含被取代的行");
253
+
254
+ store.close();
255
+ });
256
+
257
+ // (c) 归档非删除:memory_get 仍能按 id 取回旧值全文。
258
+ test("supersede 后 memory_get 仍能取到被取代的旧值(归档非删除)", async () => {
259
+ const { store, service, legacy, current } = await supersededFixture();
260
+
261
+ const registered = [];
262
+ createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, Config({}), null);
263
+ const getTool = registered.find((t) => t.name === "memory_get");
264
+ assert.ok(getTool, "memory_get 必须注册");
265
+
266
+ const out = await getTool.execute({ id: legacy.id });
267
+ assert.ok(out.memory.content.includes(OLD_MARK), "memory_get 必须能取回旧值正文");
268
+ assert.equal(out.memory.id, legacy.id);
269
+ // 取回的是归档行,且带着指向 winner 的取代注记——「可查阅」与「不可召回」
270
+ // 在这里同时成立。
271
+ assert.ok(out.memory.content.includes(NEW_TITLE), "取回的旧值应带取代注记");
272
+ assert.ok(out.memory.id !== current.id, "取回的是被取代方,不是新值");
273
+
274
+ // 取回归档行不得把它「复活」成可检索状态。
275
+ assert.equal(
276
+ (await service.searchMemories(QUERY, { mode: "keyword", useRerank: false, recordRecall: false }))
277
+ .some((r) => r.id === legacy.id),
278
+ false,
279
+ "memory_get 之后旧值仍不得被检索到"
280
+ );
281
+
282
+ store.close();
283
+ });
284
+
285
+ // 故障注入型假索引:刻意「源侧失守」——无视 archived 直接吐回全量行(含被取代
286
+ // 的旧值)。它本身不是被测对象,做的是把 service 推到「上游不再滤归档」的
287
+ // 位置,去压 src/service.js:1583(向量命中池)与 :1594(语义缓存池)那两处
288
+ // JS 层 `!m.archived` 兜底——源侧正常时它们不承重,源侧失守时它们是最后一道墙。
289
+ function leakyVectorIndex(store) {
290
+ return {
291
+ search: (_vector, { limit = 20 } = {}) =>
292
+ store
293
+ .list({ limit, includeForgotten: false, includeArchived: true })
294
+ .map((m) => ({ ...m, score: 1 }))
295
+ };
296
+ }
297
+
298
+ // (d) 语义检索链:装上真向量索引后 mode=vector/hybrid/auto 都走
299
+ // vectorIndex → store.searchVector。老/新两行被嵌成与查询同向的向量,旧值
300
+ // 必须缺席——而 src/service.js 的向量结果没有 JS 层 archived 复查,所以
301
+ // 唯一可能是 SQL 的 `archived = 0`(src/store.js:1598)生效。
302
+ test("supersede 后 vector/hybrid/auto 语义检索不得返回被取代的旧值", async () => {
303
+ const { store, service, legacy, current } = await supersededFixture({ embedder: semanticEmbedder() });
304
+
305
+ const chains = [
306
+ ["searchMemories(mode=vector)", await service.searchMemories(PROBE, { mode: "vector", useRerank: false, recordRecall: false })],
307
+ ["searchMemories(mode=hybrid)", await service.searchMemories(PROBE, { mode: "hybrid", useRerank: false, recordRecall: false })],
308
+ ["searchMemories(mode=auto)", await service.searchMemories(PROBE, { mode: "auto", useRerank: false, recordRecall: false })]
309
+ ];
310
+
311
+ for (const [name, rows] of chains) {
312
+ // 反向对照:新值必须经向量支路可检索到。否则「语义检索恒空」的实现也能让
313
+ // 这条测试变绿——那是假绿,不是状态追踪正确。
314
+ const hit = rows.find((r) => r.id === current.id);
315
+ assert.ok(hit, `${name}:新值必须能被语义支路检索到(否则是空结果假绿)`);
316
+ assert.equal(hit.source, "vector", `${name}:新值必须来自向量支路,而非词法兜底`);
317
+ assert.equal(rows.some((r) => r.id === legacy.id), false, `${name}:不得返回被取代的行 ${legacy.id}`);
318
+ assert.equal(
319
+ rows.some((r) => String(r.content ?? "").includes(OLD_MARK)),
320
+ false,
321
+ `${name}:不得返回被取代的旧值正文 ${OLD_MARK}`
322
+ );
323
+ }
324
+
325
+ // 词法支路对 PROBE 必然空手(PROBE 只在假 embedder 的映射规则里):这保证
326
+ // 上面的命中确实只能来自向量支路,而不是 keyword/BM25 顺手把新值捞了回来。
327
+ const keyword = await service.searchMemories(PROBE, { mode: "keyword", useRerank: false, recordRecall: false });
328
+ assert.equal(keyword.length, 0, "PROBE 不得命中词法支路(否则压到的不是向量支路)");
329
+
330
+ store.close();
331
+ });
332
+
333
+ // (e) 语义候选池的 JS 层兜底:假索引源侧失守后,注入候选池必须在 :1583
334
+ // (向量命中)与 :1594(lastSemanticRecall 语义缓存)两处各自把旧值挡下,
335
+ // 同时不得连新值一起挡掉。
336
+ test("supersede 后注入语义候选池(向量命中 / 语义缓存)不得浮出被取代的旧值", async () => {
337
+ const { store, service, legacy, current } = await supersededFixture({ embedder: semanticEmbedder() });
338
+ service.setVectorIndex(leakyVectorIndex(store));
339
+
340
+ // 先填一次语义缓存:新查询的首帧 render 没有 queryVector(src/inject.js:327
341
+ // 的预取是异步 .then),只能吃 lastSemanticRecall。真实检索不会吐归档行,
342
+ // 是上面的假索引在源侧失守。填缓存的这一刻已经是 supersede 之后,所以缓存
343
+ // 条目里 loser 的 archived 是 true。
344
+ const cached = await service.searchMemories(QUERY, { mode: "vector", useRerank: false, recordRecall: false });
345
+ const cachedOld = cached.find((r) => r.id === legacy.id);
346
+ // 夹具自检:确认缓存里确实躺着带 archived 标记的旧值,否则 (e) 是空压。
347
+ assert.ok(cachedOld, "假索引必须把归档旧值喂进语义缓存(夹具自检,否则压不到 :1594)");
348
+ assert.equal(cachedOld.archived, true, "缓存条目里的 loser 必须已带 archived 标记");
349
+
350
+ // 向量命中池(:1583):带了 queryVector 就直接问索引。
351
+ const viaVector = service.injectCandidates({
352
+ query: QUERY,
353
+ queryVector: [1, 0, 0],
354
+ maxItems: 5,
355
+ threshold: 3
356
+ });
357
+ assert.ok(viaVector.some((c) => c.id === current.id), "向量命中池:新值必须仍在候选里(反向对照)");
358
+ assert.equal(viaVector.some((c) => c.id === legacy.id), false, "向量命中池:不得含被取代的行");
359
+ assert.equal(
360
+ viaVector.some((c) => String(c.content ?? "").includes(OLD_MARK)),
361
+ false,
362
+ "向量命中池:候选正文不得携带旧值"
363
+ );
364
+
365
+ // 语义缓存池(:1594):交付端到端——渲染一次注入,首帧无 queryVector,走缓存。
366
+ const contexts = [];
367
+ const ctx = {
368
+ systemPrompt: {
369
+ context(def) {
370
+ contexts.push(def);
371
+ return () => {};
372
+ }
373
+ },
374
+ agent: {
375
+ session: {
376
+ id: "supersede-state-tracking-semantic",
377
+ snapshotEvents: () => [
378
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: QUERY }] } }
379
+ ]
380
+ }
381
+ }
382
+ };
383
+ createInjector(ctx, service, createSettings(store.db), { maxInjectedItems: 5, importanceThreshold: 3 });
384
+
385
+ const memorySection = contexts.find((c) => c.name === "memory");
386
+ assert.ok(memorySection, "注入器必须注册 memory 段");
387
+ const text = memorySection.text(ctx);
388
+
389
+ assert.ok(text.includes(NEW_MARK), "语义缓存池:新值应当被注入(否则注入是空的,断言不成立)");
390
+ assert.equal(text.includes(OLD_MARK), false, "语义缓存池:注入正文不得包含被取代的旧值");
391
+ assert.equal(text.includes(OLD_TITLE), false, "语义缓存池:注入正文不得包含被取代的旧标题");
392
+
393
+ store.close();
394
+ });
@@ -1,60 +1,60 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { readFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { applyTestCount, readTestCounts } from "../scripts/test-count-sync.mjs";
7
-
8
- const HERE = dirname(fileURLToPath(import.meta.url));
9
- const PKG_ROOT = join(HERE, "..");
10
- const REPO_ROOT = join(PKG_ROOT, "..");
11
- const READMES = [join(REPO_ROOT, "README.md"), join(PKG_ROOT, "README.md")];
12
-
13
- // 双 README 是发布门面,同一个数字在里面重复出现三到五次(一个 shields.io 徽章 +
14
- // 每条开发命令注释一处,root 是双语所以中英各一条)。它们必须永远同数——否则
15
- // 「这个项目有多少测试」取决于读者恰好看了哪一行。
16
- //
17
- // v0.8.11 实际漂移过:徽章 1437、四条注释留在 1431。本用例就是那次漂移的回归锁,
18
- // 跑在真实 README 上(不是 fixture),所以它能在发版前拦住同类漂移。
19
- test("双 README 的测试数处处一致(徽章与所有注释同数)", () => {
20
- for (const f of READMES) {
21
- const counts = readTestCounts(readFileSync(f, "utf8"));
22
- const shapes = new Set(counts.map((c) => c.shape));
23
- assert.ok(counts.length >= 2, `${f} 抽到的测试数出现处太少:${JSON.stringify(counts)}`);
24
- assert.ok(shapes.has("badge"), `${f} 没抽到徽章:${JSON.stringify(counts)}`);
25
- const values = [...new Set(counts.map((c) => c.value))];
26
- assert.equal(values.length, 1, `${f} 测试数不一致:${JSON.stringify(counts)}`);
27
- }
28
- });
29
-
30
- // v0.8.11 的真实漂移样本(徽章已 1437、注释仍 1431)。锁住「徽章 + 四种注释形状」
31
- // 全覆盖:少任何一种,就会有一处静默落后——release-prep.mjs 原先只覆盖徽章 URL,
32
- // 自动发版路径因此反倒成了漂移来源。
33
- test("applyTestCount 覆盖徽章与全部四种注释形状(真实漂移样本)", () => {
34
- const drifted = [
35
- '<img src="https://img.shields.io/badge/tests-1431%20passed-3E63DD?style=flat-square" alt="tests">',
36
- "[![tests](https://img.shields.io/badge/tests-1431%20passed-success)](https://github.com/slow-stack/mneme)",
37
- "npm test # 1431 个测试",
38
- "npm test # 运行 1431 个测试",
39
- "test/ # 1431 个 node:test 测试(审计与三轴线压测不变量)",
40
- "npm test # 1431 tests"
41
- ].join("\n");
42
- const fixed = applyTestCount(drifted, 1437);
43
- assert.ok(!/\b1431\b/.test(fixed), "仍有 1431 未被替换:\n" + fixed);
44
- const counts = readTestCounts(fixed);
45
- assert.equal(counts.length, 6, "六处出现都该被认出来:" + JSON.stringify(counts));
46
- assert.deepEqual([...new Set(counts.map((c) => c.value))], [1437]);
47
- });
48
-
49
- test("applyTestCount 幂等:同一个数重复施加不再产生变化", () => {
50
- const once = applyTestCount("tests-9%20passed-3E63DD\n# 9 个测试\n# 9 tests", 12);
51
- assert.equal(applyTestCount(once, 12), once);
52
- });
53
-
54
- // 历史版本表里的「745 测试全绿」「790 通过」是各版本当时的真实记录(sync-test-badge
55
- // 的注释也写明了刻意不改它们)。替换规则只认徽章 URL 与 `#` 前缀注释两种形状,
56
- // 这条用例锁住那个边界,免得将来放宽正则把历史改写掉。
57
- test("applyTestCount 不碰历史版本表里的「N 测试全绿」", () => {
58
- const history = "| **v0.8.0** | 745 测试全绿 |\n| **v0.7.9** | 790 通过 |\n| v0.2.7 | 263 |";
59
- assert.equal(applyTestCount(history, 1437), history);
60
- });
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { applyTestCount, readTestCounts } from "../scripts/test-count-sync.mjs";
7
+
8
+ const HERE = dirname(fileURLToPath(import.meta.url));
9
+ const PKG_ROOT = join(HERE, "..");
10
+ const REPO_ROOT = join(PKG_ROOT, "..");
11
+ const READMES = [join(REPO_ROOT, "README.md"), join(PKG_ROOT, "README.md")];
12
+
13
+ // 双 README 是发布门面,同一个数字在里面重复出现三到五次(一个 shields.io 徽章 +
14
+ // 每条开发命令注释一处,root 是双语所以中英各一条)。它们必须永远同数——否则
15
+ // 「这个项目有多少测试」取决于读者恰好看了哪一行。
16
+ //
17
+ // v0.8.11 实际漂移过:徽章 1437、四条注释留在 1431。本用例就是那次漂移的回归锁,
18
+ // 跑在真实 README 上(不是 fixture),所以它能在发版前拦住同类漂移。
19
+ test("双 README 的测试数处处一致(徽章与所有注释同数)", () => {
20
+ for (const f of READMES) {
21
+ const counts = readTestCounts(readFileSync(f, "utf8"));
22
+ const shapes = new Set(counts.map((c) => c.shape));
23
+ assert.ok(counts.length >= 2, `${f} 抽到的测试数出现处太少:${JSON.stringify(counts)}`);
24
+ assert.ok(shapes.has("badge"), `${f} 没抽到徽章:${JSON.stringify(counts)}`);
25
+ const values = [...new Set(counts.map((c) => c.value))];
26
+ assert.equal(values.length, 1, `${f} 测试数不一致:${JSON.stringify(counts)}`);
27
+ }
28
+ });
29
+
30
+ // v0.8.11 的真实漂移样本(徽章已 1437、注释仍 1431)。锁住「徽章 + 四种注释形状」
31
+ // 全覆盖:少任何一种,就会有一处静默落后——release-prep.mjs 原先只覆盖徽章 URL,
32
+ // 自动发版路径因此反倒成了漂移来源。
33
+ test("applyTestCount 覆盖徽章与全部四种注释形状(真实漂移样本)", () => {
34
+ const drifted = [
35
+ '<img src="https://img.shields.io/badge/tests-1431%20passed-3E63DD?style=flat-square" alt="tests">',
36
+ "[![tests](https://img.shields.io/badge/tests-1431%20passed-success)](https://github.com/slow-stack/mneme)",
37
+ "npm test # 1431 个测试",
38
+ "npm test # 运行 1431 个测试",
39
+ "test/ # 1431 个 node:test 测试(审计与三轴线压测不变量)",
40
+ "npm test # 1431 tests"
41
+ ].join("\n");
42
+ const fixed = applyTestCount(drifted, 1437);
43
+ assert.ok(!/\b1431\b/.test(fixed), "仍有 1431 未被替换:\n" + fixed);
44
+ const counts = readTestCounts(fixed);
45
+ assert.equal(counts.length, 6, "六处出现都该被认出来:" + JSON.stringify(counts));
46
+ assert.deepEqual([...new Set(counts.map((c) => c.value))], [1437]);
47
+ });
48
+
49
+ test("applyTestCount 幂等:同一个数重复施加不再产生变化", () => {
50
+ const once = applyTestCount("tests-9%20passed-3E63DD\n# 9 个测试\n# 9 tests", 12);
51
+ assert.equal(applyTestCount(once, 12), once);
52
+ });
53
+
54
+ // 历史版本表里的「745 测试全绿」「790 通过」是各版本当时的真实记录(sync-test-badge
55
+ // 的注释也写明了刻意不改它们)。替换规则只认徽章 URL 与 `#` 前缀注释两种形状,
56
+ // 这条用例锁住那个边界,免得将来放宽正则把历史改写掉。
57
+ test("applyTestCount 不碰历史版本表里的「N 测试全绿」", () => {
58
+ const history = "| **v0.8.0** | 745 测试全绿 |\n| **v0.7.9** | 790 通过 |\n| v0.2.7 | 263 |";
59
+ assert.equal(applyTestCount(history, 1437), history);
60
+ });