@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.2

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 (543) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +830 -828
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -254
  6. package/bin/dsh-mneme-mcp.mjs +510 -510
  7. package/bin/dsh-mneme-serve.mjs +151 -151
  8. package/cordis.patch.yml +21 -21
  9. package/docs/CONFIGURATION.md +407 -402
  10. package/docs/CONTINUITY.md +139 -139
  11. package/docs/DAEMON.md +57 -57
  12. package/docs/DIRECTORIES.md +114 -114
  13. package/docs/DISTILL.md +397 -371
  14. package/docs/DSH-GO.md +97 -97
  15. package/docs/ENTITIES.md +247 -247
  16. package/docs/FORGET.md +77 -77
  17. package/docs/KB.md +110 -110
  18. package/docs/LOCAL_MODEL.md +222 -222
  19. package/docs/SEMANTIC.md +256 -256
  20. package/docs/SHARE.md +552 -552
  21. package/docs/SLEEP.md +202 -202
  22. package/docs/STORAGE.md +122 -122
  23. package/docs/WORKFLOWS.md +366 -366
  24. package/lib/api-standalone.js +1007 -1007
  25. package/lib/api.js +3600 -3394
  26. package/lib/attachment-text.js +122 -122
  27. package/lib/bootstrap-http.js +105 -105
  28. package/lib/bootstrap.js +215 -215
  29. package/lib/candidates.js +454 -454
  30. package/lib/capabilities.js +819 -819
  31. package/lib/choro-command.js +111 -111
  32. package/lib/client.js +14420 -13436
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1317
  36. package/lib/content-hash.js +54 -54
  37. package/lib/continuity.js +258 -258
  38. package/lib/date-hint.js +32 -0
  39. package/lib/diary/auto.js +190 -190
  40. package/lib/diary/compose.js +440 -440
  41. package/lib/diary/dir.js +59 -59
  42. package/lib/diary/export.js +97 -97
  43. package/lib/diary/index.js +26 -26
  44. package/lib/diary/library.js +189 -189
  45. package/lib/diary/qq-inbox.js +142 -142
  46. package/lib/diary/rollover.js +61 -61
  47. package/lib/diary/session-provider.js +122 -122
  48. package/lib/document-index.js +94 -94
  49. package/lib/document.js +322 -322
  50. package/lib/dream/clustering.js +123 -123
  51. package/lib/dream/decisions.js +945 -907
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -1887
  54. package/lib/dream/triage.js +139 -0
  55. package/lib/dream.js +1879 -1764
  56. package/lib/dsh-go/host.js +162 -162
  57. package/lib/dsh-go/panel.js +2091 -2091
  58. package/lib/dsh-go/text.js +342 -342
  59. package/lib/dsh-go/views/common.js +68 -68
  60. package/lib/dsh-go/views/entity.js +48 -48
  61. package/lib/dsh-go/views/memory.js +198 -198
  62. package/lib/dsh-go/views/root.js +30 -30
  63. package/lib/dsh-go/views/settings.js +398 -398
  64. package/lib/dsh-go/views/status.js +51 -51
  65. package/lib/embedding.js +342 -342
  66. package/lib/entities/extractor.js +285 -285
  67. package/lib/fence.js +24 -24
  68. package/lib/graph/anchoring.js +184 -184
  69. package/lib/guide.js +74 -74
  70. package/lib/heat.js +144 -144
  71. package/lib/hot-memory.js +119 -107
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -822
  74. package/lib/inject.js +656 -639
  75. package/lib/kb/index.js +309 -309
  76. package/lib/kb/routes.js +219 -219
  77. package/lib/kb/tools.js +148 -148
  78. package/lib/lang.js +607 -584
  79. package/lib/local-embedder.js +358 -358
  80. package/lib/maintenance.js +162 -162
  81. package/lib/memory-source.js +9 -9
  82. package/lib/mirror.js +330 -330
  83. package/lib/organize.js +456 -456
  84. package/lib/peak-hours.js +172 -172
  85. package/lib/pipeline-activities.js +178 -178
  86. package/lib/pipeline-attribution.js +165 -165
  87. package/lib/pipeline-command.js +279 -279
  88. package/lib/pipeline-confirm.js +142 -142
  89. package/lib/pipeline-exec.js +174 -174
  90. package/lib/pipeline-nudge-auto.js +129 -129
  91. package/lib/pipeline-nudge.js +1623 -1623
  92. package/lib/pipeline-observations.js +181 -181
  93. package/lib/pipeline-observe.js +133 -133
  94. package/lib/pipeline-runner.js +361 -361
  95. package/lib/pipeline-runs.js +442 -442
  96. package/lib/pipeline.js +716 -716
  97. package/lib/placement.js +13 -13
  98. package/lib/pre-inject-gate.js +354 -354
  99. package/lib/profile-extract.js +164 -164
  100. package/lib/profile-facts.js +356 -356
  101. package/lib/provenance-http.js +251 -251
  102. package/lib/quality-filter.js +162 -162
  103. package/lib/recall-stats.js +190 -190
  104. package/lib/reclaim-http.js +150 -150
  105. package/lib/reranker.js +269 -269
  106. package/lib/runtime/adopt.js +176 -176
  107. package/lib/runtime/closure.js +172 -172
  108. package/lib/runtime/download.js +247 -247
  109. package/lib/runtime/layout.js +265 -265
  110. package/lib/runtime/loader.js +252 -252
  111. package/lib/runtime/model-fetch.js +361 -361
  112. package/lib/runtime/provision.js +237 -237
  113. package/lib/runtime/tarball.js +130 -130
  114. package/lib/runtime/verify.js +223 -223
  115. package/lib/safe-slice.js +18 -18
  116. package/lib/scope-state.js +277 -277
  117. package/lib/scope.js +187 -187
  118. package/lib/search/adaptive.js +60 -60
  119. package/lib/search/bm25.js +96 -96
  120. package/lib/semantic.js +572 -572
  121. package/lib/sensitive-scan.js +209 -209
  122. package/lib/serve.js +228 -228
  123. package/lib/service.js +3268 -3196
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -802
  127. package/lib/share-http.js +281 -281
  128. package/lib/share-import.js +234 -234
  129. package/lib/share.js +335 -335
  130. package/lib/store.js +5895 -5662
  131. package/lib/summarize.js +1388 -1343
  132. package/lib/tools.js +1370 -1356
  133. package/lib/user-settings.js +230 -230
  134. package/lib/vector-index.js +180 -180
  135. package/lib/version-check.js +122 -122
  136. package/lib/wiki/claims.js +267 -0
  137. package/lib/wiki/cli-hint.js +90 -90
  138. package/lib/wiki/inject.js +164 -164
  139. package/lib/wiki/partition.js +200 -200
  140. package/lib/wiki/plan.js +214 -214
  141. package/lib/wiki/queries.js +640 -474
  142. package/lib/wiki/render.js +261 -187
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -463
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -502
  147. package/lib/write-admission.js +404 -404
  148. package/locale/en.json +6 -6
  149. package/locale/zh.json +6 -6
  150. package/package.json +15 -1
  151. package/runtime-manifest.json +526 -526
  152. package/scripts/benchmark-embed.js +201 -201
  153. package/scripts/benchmark-recall.js +249 -249
  154. package/scripts/benchmark-rerank.js +166 -166
  155. package/scripts/build-runtime-manifest.mjs +224 -224
  156. package/scripts/changelog-prep.mjs +91 -91
  157. package/scripts/check-sync.js +44 -44
  158. package/scripts/e2e-dsh.js +220 -220
  159. package/scripts/mneme-runtime.mjs +277 -277
  160. package/scripts/publish-gate.mjs +197 -197
  161. package/scripts/release-docs.mjs +210 -204
  162. package/scripts/release-notes.mjs +164 -161
  163. package/scripts/stress-dsh.js +255 -255
  164. package/scripts/sync-lib.js +47 -47
  165. package/scripts/sync-test-badge.mjs +69 -69
  166. package/scripts/test-count-sync.mjs +48 -48
  167. package/src/api-standalone.js +1007 -1007
  168. package/src/api.js +3600 -3394
  169. package/src/attachment-text.js +122 -122
  170. package/src/bootstrap-http.js +105 -105
  171. package/src/bootstrap.js +215 -215
  172. package/src/candidates.js +454 -454
  173. package/src/capabilities.js +819 -819
  174. package/src/choro-command.js +111 -111
  175. package/src/commands.js +95 -95
  176. package/src/config-keys.js +144 -144
  177. package/src/config.js +1361 -1317
  178. package/src/content-hash.js +54 -54
  179. package/src/continuity.js +258 -258
  180. package/src/date-hint.js +32 -0
  181. package/src/diary/auto.js +190 -190
  182. package/src/diary/compose.js +440 -440
  183. package/src/diary/dir.js +59 -59
  184. package/src/diary/export.js +97 -97
  185. package/src/diary/index.js +26 -26
  186. package/src/diary/library.js +189 -189
  187. package/src/diary/qq-inbox.js +142 -142
  188. package/src/diary/rollover.js +61 -61
  189. package/src/diary/session-provider.js +122 -122
  190. package/src/document-index.js +94 -94
  191. package/src/document.js +322 -322
  192. package/src/dream/clustering.js +123 -123
  193. package/src/dream/decisions.js +945 -907
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -1887
  196. package/src/dream/triage.js +139 -0
  197. package/src/dream.js +1879 -1764
  198. package/src/dsh-go/host.js +162 -162
  199. package/src/dsh-go/panel.js +2091 -2091
  200. package/src/dsh-go/text.js +342 -342
  201. package/src/dsh-go/views/common.js +68 -68
  202. package/src/dsh-go/views/entity.js +48 -48
  203. package/src/dsh-go/views/memory.js +198 -198
  204. package/src/dsh-go/views/root.js +30 -30
  205. package/src/dsh-go/views/settings.js +398 -398
  206. package/src/dsh-go/views/status.js +51 -51
  207. package/src/embedding.js +342 -342
  208. package/src/entities/extractor.js +285 -285
  209. package/src/fence.js +24 -24
  210. package/src/graph/anchoring.js +184 -184
  211. package/src/guide.js +74 -74
  212. package/src/heat.js +144 -144
  213. package/src/hot-memory.js +119 -107
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -822
  216. package/src/inject.js +656 -639
  217. package/src/kb/index.js +309 -309
  218. package/src/kb/routes.js +219 -219
  219. package/src/kb/tools.js +148 -148
  220. package/src/lang.js +607 -584
  221. package/src/local-embedder.js +358 -358
  222. package/src/maintenance.js +162 -162
  223. package/src/memory-source.js +9 -9
  224. package/src/mirror.js +330 -330
  225. package/src/organize.js +456 -456
  226. package/src/peak-hours.js +172 -172
  227. package/src/pipeline-activities.js +178 -178
  228. package/src/pipeline-attribution.js +165 -165
  229. package/src/pipeline-command.js +279 -279
  230. package/src/pipeline-confirm.js +142 -142
  231. package/src/pipeline-exec.js +174 -174
  232. package/src/pipeline-nudge-auto.js +129 -129
  233. package/src/pipeline-nudge.js +1623 -1623
  234. package/src/pipeline-observations.js +181 -181
  235. package/src/pipeline-observe.js +133 -133
  236. package/src/pipeline-runner.js +361 -361
  237. package/src/pipeline-runs.js +442 -442
  238. package/src/pipeline.js +716 -716
  239. package/src/placement.js +13 -13
  240. package/src/pre-inject-gate.js +354 -354
  241. package/src/profile-extract.js +164 -164
  242. package/src/profile-facts.js +356 -356
  243. package/src/provenance-http.js +251 -251
  244. package/src/quality-filter.js +162 -162
  245. package/src/recall-stats.js +190 -190
  246. package/src/reclaim-http.js +150 -150
  247. package/src/reranker.js +269 -269
  248. package/src/runtime/adopt.js +176 -176
  249. package/src/runtime/closure.js +172 -172
  250. package/src/runtime/download.js +247 -247
  251. package/src/runtime/layout.js +265 -265
  252. package/src/runtime/loader.js +252 -252
  253. package/src/runtime/model-fetch.js +361 -361
  254. package/src/runtime/provision.js +237 -237
  255. package/src/runtime/tarball.js +130 -130
  256. package/src/runtime/verify.js +223 -223
  257. package/src/safe-slice.js +18 -18
  258. package/src/scope-state.js +277 -277
  259. package/src/scope.js +187 -187
  260. package/src/search/adaptive.js +60 -60
  261. package/src/search/bm25.js +96 -96
  262. package/src/semantic.js +572 -572
  263. package/src/sensitive-scan.js +209 -209
  264. package/src/serve.js +228 -228
  265. package/src/service.js +3268 -3196
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -802
  269. package/src/share-http.js +281 -281
  270. package/src/share-import.js +234 -234
  271. package/src/share.js +335 -335
  272. package/src/store.js +5895 -5662
  273. package/src/summarize.js +1388 -1343
  274. package/src/tools.js +1370 -1356
  275. package/src/user-settings.js +230 -230
  276. package/src/vector-index.js +180 -180
  277. package/src/version-check.js +122 -122
  278. package/src/wiki/claims.js +267 -0
  279. package/src/wiki/cli-hint.js +90 -90
  280. package/src/wiki/inject.js +164 -164
  281. package/src/wiki/partition.js +200 -200
  282. package/src/wiki/plan.js +214 -214
  283. package/src/wiki/queries.js +640 -474
  284. package/src/wiki/render.js +261 -187
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -463
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -502
  289. package/src/write-admission.js +404 -404
  290. package/test/adaptive-inject-budget.test.js +85 -85
  291. package/test/api-hardening.test.js +173 -173
  292. package/test/api-nudge-route.test.js +259 -259
  293. package/test/api-routes-gaps.test.js +1092 -1092
  294. package/test/api.test.js +3007 -2988
  295. package/test/attachment-text.test.js +139 -139
  296. package/test/audit.test.js +448 -448
  297. package/test/baseline-persist.test.js +248 -248
  298. package/test/benchmark.test.js +94 -94
  299. package/test/bm25-corpus-cache.test.js +145 -145
  300. package/test/bootstrap.test.js +228 -228
  301. package/test/candidates.test.js +345 -345
  302. package/test/capabilities.test.js +577 -577
  303. package/test/capacity-warning.test.js +262 -262
  304. package/test/changelog-prep.test.js +125 -125
  305. package/test/choro-command.test.js +218 -218
  306. package/test/cli-enums.test.js +37 -37
  307. package/test/cli-flag-value.test.js +86 -86
  308. package/test/cli-receive.test.js +390 -390
  309. package/test/cli-share.test.js +437 -437
  310. package/test/client-share.test.js +225 -225
  311. package/test/client.test.js +4595 -4184
  312. package/test/clustering.test.js +100 -100
  313. package/test/commands.test.js +106 -106
  314. package/test/config-keys-routes.test.js +233 -233
  315. package/test/config.test.js +153 -153
  316. package/test/conflict-freeze.test.js +344 -344
  317. package/test/conflict-ledger-enforce.test.js +452 -0
  318. package/test/conflict-ledger.test.js +242 -0
  319. package/test/conflict-reviewed.test.js +79 -79
  320. package/test/content-hash.test.js +204 -204
  321. package/test/continuity.test.js +452 -452
  322. package/test/contrast.test.js +432 -320
  323. package/test/date-hint.test.js +156 -0
  324. package/test/dedupe-candidate-window.test.js +180 -180
  325. package/test/dependency-overrides.test.js +78 -78
  326. package/test/diary-library.test.js +187 -187
  327. package/test/diary.test.js +576 -576
  328. package/test/differentiate-feedback.test.js +248 -248
  329. package/test/distill-tx-embed-gap.test.js +110 -110
  330. package/test/docs-config-keys.test.js +65 -65
  331. package/test/document-dir.test.js +233 -233
  332. package/test/document.test.js +703 -703
  333. package/test/dream-archive-guardrail.test.js +116 -116
  334. package/test/dream-baseline-clamp.test.js +174 -0
  335. package/test/dream-candidate-hybrid.test.js +281 -281
  336. package/test/dream-coverage-degrade.test.js +86 -86
  337. package/test/dream-decision-evidence.test.js +314 -314
  338. package/test/dream-failure-backoff.test.js +173 -173
  339. package/test/dream-fence.test.js +207 -207
  340. package/test/dream-maxtokens-fallback.test.js +132 -132
  341. package/test/dream-merge-guardrail.test.js +219 -219
  342. package/test/dream-peak-hours.test.js +194 -194
  343. package/test/dream.test.js +1528 -1528
  344. package/test/dsh-go-host.test.js +134 -134
  345. package/test/dsh-go-panel-actions.test.js +854 -854
  346. package/test/dsh-go-panel.test.js +251 -251
  347. package/test/embed-ready-poll.test.js +94 -94
  348. package/test/embed-text-reasons.test.js +112 -112
  349. package/test/embedding-cache-f32.test.js +276 -276
  350. package/test/embedding-ready.test.js +226 -226
  351. package/test/entities.test.js +711 -711
  352. package/test/entity-recall.test.js +179 -179
  353. package/test/epistemic.test.js +325 -325
  354. package/test/failure-query.test.js +147 -147
  355. package/test/failure-retention.test.js +105 -105
  356. package/test/fnew-0112.test.js +311 -311
  357. package/test/fnew-03.test.js +422 -422
  358. package/test/forget-corrections.test.js +70 -70
  359. package/test/graph-anchoring.test.js +184 -184
  360. package/test/graph-api.test.js +333 -209
  361. package/test/graph-boosted-relations.test.js +189 -189
  362. package/test/graph-inject-budget.test.js +103 -103
  363. package/test/graph-multipath.test.js +269 -269
  364. package/test/graph-passive-confirm.test.js +91 -91
  365. package/test/graph-weight-evolution.test.js +104 -104
  366. package/test/heat.test.js +175 -175
  367. package/test/helpers/dream-mock.js +86 -86
  368. package/test/helpers/peer-worker.mjs +25 -25
  369. package/test/helpers/runtime-source.js +60 -60
  370. package/test/helpers/tar-builder.js +80 -80
  371. package/test/helpers/write-admission-samples.js +312 -312
  372. package/test/hot-memory.test.js +259 -211
  373. package/test/image-description.test.js +271 -0
  374. package/test/image-memory.test.js +170 -170
  375. package/test/inject-content-cap.test.js +91 -91
  376. package/test/inject-first-round-bm25.test.js +89 -89
  377. package/test/inject-heat.test.js +77 -77
  378. package/test/inject-parent-gate.test.js +134 -106
  379. package/test/inject-pin-explicit.test.js +300 -300
  380. package/test/inject-pin-vector-crowd.test.js +225 -225
  381. package/test/inject-pools.test.js +195 -195
  382. package/test/inject-recall.test.js +172 -172
  383. package/test/inject-rotation.test.js +161 -161
  384. package/test/inject.test.js +553 -494
  385. package/test/issue-617-step1-query-and-fusion.test.js +361 -361
  386. package/test/kb-api-wiring.test.js +128 -128
  387. package/test/kb-collections.test.js +272 -272
  388. package/test/kb-routes.test.js +379 -379
  389. package/test/lang.test.js +98 -98
  390. package/test/lastrunat-persist.test.js +101 -101
  391. package/test/lib-smoke.test.js +116 -116
  392. package/test/llm-audit.test.js +815 -815
  393. package/test/llm-message-source.test.js +207 -207
  394. package/test/local-embedder.test.js +292 -292
  395. package/test/maintenance.test.js +501 -501
  396. package/test/mcp-stdio.test.js +385 -385
  397. package/test/mint-source.test.js +311 -311
  398. package/test/mirror-dirty.test.js +424 -424
  399. package/test/mirror-edit-digest.test.js +187 -187
  400. package/test/mirror-generation.test.js +499 -499
  401. package/test/mirror-types.test.js +208 -208
  402. package/test/mirror.test.js +249 -249
  403. package/test/narrative-bars.test.js +162 -162
  404. package/test/organize-tool.test.js +232 -232
  405. package/test/organize.test.js +418 -418
  406. package/test/panel-chart-scale.test.js +90 -0
  407. package/test/panel-tokens.test.js +245 -0
  408. package/test/peak-hours-observability.test.js +70 -70
  409. package/test/peer-blockers.test.js +193 -193
  410. package/test/persona-addressing.test.js +83 -83
  411. package/test/persona-api.test.js +172 -172
  412. package/test/persona-by-session.test.js +568 -568
  413. package/test/persona-flag.test.js +36 -36
  414. package/test/persona-inject.test.js +145 -145
  415. package/test/persona-settings.test.js +198 -198
  416. package/test/pipeline-activities.test.js +155 -155
  417. package/test/pipeline-attribution.test.js +175 -175
  418. package/test/pipeline-command.test.js +243 -243
  419. package/test/pipeline-confirm.test.js +148 -148
  420. package/test/pipeline-exec.test.js +172 -172
  421. package/test/pipeline-nudge-auto.test.js +248 -248
  422. package/test/pipeline-nudge.test.js +1278 -1278
  423. package/test/pipeline-observations.test.js +157 -157
  424. package/test/pipeline-observe.test.js +127 -127
  425. package/test/pipeline-r1r2.test.js +278 -278
  426. package/test/pipeline-runner.test.js +468 -468
  427. package/test/pipeline-runs.test.js +767 -767
  428. package/test/pipeline.test.js +636 -636
  429. package/test/placement.test.js +36 -36
  430. package/test/policy-epoch.test.js +259 -259
  431. package/test/pre-inject-gate.test.js +444 -444
  432. package/test/profile-extract.test.js +269 -269
  433. package/test/profile-facts.test.js +492 -492
  434. package/test/prompt-stance.test.js +128 -128
  435. package/test/provenance-http.test.js +367 -367
  436. package/test/publish-gate.test.js +272 -272
  437. package/test/quality-backfill.test.js +290 -290
  438. package/test/quality-filter.test.js +295 -295
  439. package/test/readme-claims.test.js +113 -113
  440. package/test/reasoning-effort.test.js +752 -752
  441. package/test/recall-anti-update.test.js +82 -82
  442. package/test/recall-evals.test.js +253 -253
  443. package/test/recall-layer.test.js +326 -326
  444. package/test/recall-retention.test.js +128 -128
  445. package/test/recall-stats.test.js +274 -274
  446. package/test/receipt-chain.test.js +451 -451
  447. package/test/reclaim-http.test.js +209 -209
  448. package/test/reflection-update-gate.test.js +129 -129
  449. package/test/reflection.test.js +226 -226
  450. package/test/reindex-backfill.test.js +475 -475
  451. package/test/reindex-scheduler.test.js +369 -369
  452. package/test/release-docs.test.js +156 -136
  453. package/test/release-notes.test.js +265 -240
  454. package/test/release-workflow.test.js +93 -93
  455. package/test/reranker-single-logit.test.js +73 -73
  456. package/test/reranker.test.js +197 -197
  457. package/test/resident-status.test.js +83 -83
  458. package/test/route-policy.test.js +113 -113
  459. package/test/runtime-adopt.test.js +206 -206
  460. package/test/runtime-cli.test.js +89 -89
  461. package/test/runtime-closure.test.js +193 -193
  462. package/test/runtime-download.test.js +302 -302
  463. package/test/runtime-layout.test.js +163 -163
  464. package/test/runtime-loader.test.js +255 -255
  465. package/test/runtime-manifest.test.js +159 -159
  466. package/test/runtime-model-fetch.test.js +348 -348
  467. package/test/runtime-provision.test.js +101 -101
  468. package/test/runtime-tarball.test.js +81 -81
  469. package/test/runtime-verify.test.js +319 -319
  470. package/test/scope-candidates.test.js +491 -491
  471. package/test/scope-distill-stamp.test.js +134 -134
  472. package/test/scope-explicit.test.js +452 -452
  473. package/test/scope-inject-soft.test.js +106 -106
  474. package/test/scope-optional-service.test.js +110 -110
  475. package/test/scope-retrieval.test.js +197 -197
  476. package/test/scope-state.test.js +610 -610
  477. package/test/scope-storage.test.js +255 -255
  478. package/test/scope-strict.test.js +367 -367
  479. package/test/search-fusion.test.js +90 -90
  480. package/test/security-doc.test.js +188 -178
  481. package/test/semantic.test.js +124 -124
  482. package/test/sensitive-scan.test.js +251 -251
  483. package/test/serve-bin.test.js +121 -121
  484. package/test/serve.test.js +258 -258
  485. package/test/service-search.test.js +174 -174
  486. package/test/service.test.js +410 -410
  487. package/test/session-gate.test.js +264 -264
  488. package/test/session-usage.test.js +317 -317
  489. package/test/settings.test.js +273 -273
  490. package/test/share-foreign-rows.test.js +144 -144
  491. package/test/share-http.test.js +144 -144
  492. package/test/share-import.test.js +577 -577
  493. package/test/share-imported-guard.test.js +311 -311
  494. package/test/share-imported-identity.test.js +111 -111
  495. package/test/share-pack.test.js +511 -511
  496. package/test/share-receive-http.test.js +756 -756
  497. package/test/share-routes.test.js +337 -337
  498. package/test/share-store.test.js +133 -133
  499. package/test/share-validate.test.js +101 -101
  500. package/test/skip-invalid-restore.test.js +262 -262
  501. package/test/sleep-action-set.test.js +441 -441
  502. package/test/sleep-catchup.test.js +315 -315
  503. package/test/sleep-failure-backoff.test.js +156 -156
  504. package/test/sleep-heat.test.js +174 -174
  505. package/test/sleep-scheduler-rearm.test.js +89 -89
  506. package/test/sleep.test.js +1455 -1418
  507. package/test/standalone-api.test.js +1344 -1344
  508. package/test/store-vector-cache.test.js +98 -98
  509. package/test/store-write-path-columns.test.js +191 -191
  510. package/test/store.test.js +744 -744
  511. package/test/stress.test.js +209 -209
  512. package/test/summarize-occurred-at.test.js +127 -127
  513. package/test/summarize-parse-diagnostics.test.js +75 -75
  514. package/test/summarize-reasoning-effort.test.js +189 -189
  515. package/test/summarize.test.js +2251 -2251
  516. package/test/supersede-state-tracking.test.js +394 -394
  517. package/test/test-count-sync.test.js +71 -71
  518. package/test/tool-state-ignore.test.js +65 -65
  519. package/test/tools-dto-schema-matrix.test.js +398 -398
  520. package/test/tools-memory-get-schema.test.js +65 -65
  521. package/test/tools.test.js +585 -585
  522. package/test/updated-at-semantics.test.js +113 -113
  523. package/test/user-settings-preview.test.js +127 -127
  524. package/test/user-settings-segments.test.js +527 -527
  525. package/test/vector-index.test.js +334 -334
  526. package/test/version-check.test.js +152 -152
  527. package/test/webServer-optional.test.js +82 -82
  528. package/test/wiki-api.test.js +734 -627
  529. package/test/wiki-claims.test.js +194 -0
  530. package/test/wiki-cli-hint.test.js +118 -118
  531. package/test/wiki-inject.test.js +265 -265
  532. package/test/wiki-partition.test.js +112 -112
  533. package/test/wiki-plan.test.js +80 -80
  534. package/test/wiki-provenance.test.js +202 -0
  535. package/test/wiki-queries.test.js +398 -277
  536. package/test/wiki-scan.test.js +208 -208
  537. package/test/wiki-store.test.js +190 -190
  538. package/test/wiki-t0.test.js +456 -380
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -235
  541. package/test/wiki-tools.test.js +176 -176
  542. package/test/workflow-guards.test.js +155 -155
  543. package/test/write-admission.test.js +668 -668
@@ -1,2251 +1,2251 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtempSync, rmSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { tmpdir } from "node:os";
6
- import { DatabaseSync } from "node:sqlite";
7
- import { Context } from "@deepseek-ai/cordis";
8
- import * as mneme from "../src/index.js";
9
- import { createStore } from "../src/store.js";
10
- import { createService } from "../src/service.js";
11
- import { createSummarizer, parseSummaryJson, parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "../src/summarize.js";
12
- import { createSettings } from "../src/settings.js";
13
-
14
- // Issue #239 第 4 项:可注入的假时钟 + 定时器——错峰队列的测试必须能「推进时间」,
15
- // 真实 setTimeout 会让补跑路径要么测不到、要么拖慢测试。
16
- function fakeClock(start) {
17
- let current = start.getTime();
18
- const timers = [];
19
- const deps = {
20
- now: () => current,
21
- setTimeoutFn: (fn, ms) => {
22
- const entry = { fn, at: current + ms };
23
- timers.push(entry);
24
- return entry;
25
- },
26
- clearTimeoutFn: (entry) => {
27
- const i = timers.indexOf(entry);
28
- if (i !== -1) timers.splice(i, 1);
29
- }
30
- };
31
- const flush = async () => {
32
- // 补跑是异步链(enqueueDistill → stream):推完时间要给微任务与一轮宏任务机会。
33
- for (let i = 0; i < 4; i++) await new Promise((resolve) => setTimeout(resolve, 0));
34
- };
35
- return {
36
- deps,
37
- pending: () => timers.length,
38
- async advanceTo(date) {
39
- current = date.getTime();
40
- for (const entry of [...timers].sort((a, b) => a.at - b.at)) {
41
- if (entry.at > current) continue;
42
- timers.splice(timers.indexOf(entry), 1);
43
- entry.fn();
44
- }
45
- await flush();
46
- },
47
- async advance(ms) {
48
- await this.advanceTo(new Date(current + ms));
49
- }
50
- };
51
- }
52
-
53
- function setup(over = {}, opts = {}) {
54
- const store = opts.store ?? createStore(":memory:");
55
- const service = createService({ store, mirror: null, config: {} });
56
- const events = [];
57
- const calls = [];
58
- const ctx = {
59
- on(name, fn) {
60
- events.push({ name, fn });
61
- return () => {
62
- const i = events.findIndex((e) => e.name === name && e.fn === fn);
63
- if (i !== -1) events.splice(i, 1);
64
- };
65
- },
66
- llm: {
67
- stream(options) {
68
- calls.push(options);
69
- if (opts.stream) return opts.stream(options);
70
- const json = JSON.stringify([
71
- { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
72
- { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
73
- ]);
74
- return (async function* () {
75
- yield { type: "block-start", block: { type: "text" } };
76
- yield { type: "text-delta", delta: json };
77
- yield { type: "block-end", block: { type: "text" } };
78
- yield { type: "finish", kind: "ok" };
79
- })();
80
- }
81
- }
82
- };
83
- const config = { autoSummarize: true, ...over };
84
- const summarizer = createSummarizer(ctx, service, config, opts.deps ?? {});
85
- return { store, service, events, calls, summarizer };
86
- }
87
-
88
- // A realistic direct human prompt event (source.kind === "user").
89
- function userMessage(text, seq) {
90
- const event = {
91
- type: "user/message",
92
- data: { source: { kind: "user" }, content: [{ type: "text", text }] }
93
- };
94
- if (seq !== undefined) event.seq = seq;
95
- return event;
96
- }
97
-
98
- function toolResultEvent(text, isError, seq) {
99
- return {
100
- seq,
101
- type: "tool/result",
102
- data: {
103
- message: {
104
- source: { kind: "tool", callId: `call-${seq}` },
105
- content: [{
106
- type: "tool-result",
107
- toolCallId: `call-${seq}`,
108
- isError,
109
- content: [{ type: "text", text }]
110
- }]
111
- }
112
- }
113
- };
114
- }
115
-
116
- // DSH 0.2.x 内核对工具结果的实际投递形状:内核给的是 `role:"tool"` 消息本身,
117
- // content 里只有 `type:"text"` 块、isError 挂在消息上,不再有 tool-result 块。
118
- // 蒸馏曾只认旧块形状,于是真实会话里的工具输出与工具报错全部被静默丢弃。
119
- function toolRoleResultEvent(text, isError, seq) {
120
- return {
121
- seq,
122
- type: "tool/result",
123
- data: {
124
- message: {
125
- role: "tool",
126
- source: { kind: "tool", callId: `call-${seq}` },
127
- toolCallId: `call-${seq}`,
128
- content: [{ type: "text", text }],
129
- isError
130
- }
131
- }
132
- };
133
- }
134
-
135
- function codeDispatchEvent(text, isError, seq) {
136
- return {
137
- seq,
138
- type: "tool/code-dispatch",
139
- data: {
140
- isError,
141
- content: [{ type: "text", text }]
142
- }
143
- };
144
- }
145
-
146
- function inboxMessage(id, kind, text) {
147
- return {
148
- id,
149
- role: "user",
150
- source: { kind },
151
- content: [{ type: "text", text }]
152
- };
153
- }
154
-
155
- test("parseSummaryJson extracts valid entries and skips malformed ones", () => {
156
- const parsed = parseSummaryJson(`前导文字 {"a":1}
157
- [
158
- {"type":"decision","title":"t1","content":"c1","importance":4},
159
- {"type":"nonsense","title":"bad","content":"x"},
160
- "garbage",
161
- {"type":"preference","title":"t2","content":"c2","importance":2}
162
- ]`);
163
- assert.equal(parsed.length, 2);
164
- assert.equal(parsed[0].type, "decision");
165
- assert.equal(parsed[1].type, "preference");
166
- });
167
-
168
- // --- issue #339 / E8 考卷:JSON 崩溃窗口的 salvage(失败路径修复,默认开)-----
169
- // E8 实测 10-20% 的窗口输出了含约束的完整对象,只因中段一处语法错误被整窗
170
- // JSON.parse 拒收 → 生产行为 = 游标卡死 + 温度 0 重试同错 = 静默丢失。
171
-
172
- test("parseSummaryJson salvages complete objects from a malformed array (issue #339)", () => {
173
- // E8 现场同款:闭合数组、中段对象含杂散引号(stray quote)。
174
- const raw = `[
175
- {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
176
- { " "type": "history", "title": "坏对象", "content": "语法错误" },
177
- {"type":"preference","title":"硬件","content":"只要 brushed brass"}
178
- ]`;
179
- const parsed = parseSummaryJson(raw);
180
- assert.equal(parsed.length, 2, "the broken middle object is dropped, both survivors kept");
181
- assert.equal(parsed[0].content, "总额 $12,400");
182
- assert.equal(parsed[1].title, "硬件");
183
- });
184
-
185
- test("parseSummaryJson salvage finding nothing stays a failure (no fake empty success)", () => {
186
- // 只有一个语法坏掉的对象 → 救回 0 条:不得按「显式空数组」放行消费窗口。
187
- const raw = `[
188
- { " "type": "history", "title": "坏", "content": "语法错误" }
189
- ]`;
190
- const parsed = parseSummaryJson(raw);
191
- assert.deepEqual(parsed, []);
192
- });
193
-
194
- test("subscribes to session/event when autoSummarize enabled", () => {
195
- const { events } = setup();
196
- assert.ok(events.some((e) => e.name === "session/event"));
197
- });
198
-
199
- test("does not subscribe when autoSummarize disabled", () => {
200
- const { events } = setup({ autoSummarize: false });
201
- assert.ok(!events.some((e) => e.name === "session/event"));
202
- });
203
-
204
- test("turn/end event triggers summarization and stores entries", async () => {
205
- const { events, store } = setup();
206
- const handler = events.find((e) => e.name === "session/event").fn;
207
- const session = {
208
- id: "s1",
209
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
210
- events: [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
211
- };
212
- await handler(session, { seq: 2, type: "turn/end" });
213
- assert.equal(store.count(), 2);
214
- const all = store.all();
215
- assert.ok(all.some((m) => m.type === "decision"));
216
- assert.ok(all.some((m) => m.type === "preference"));
217
- });
218
-
219
- test("skips summarization for events other than turn/end", async () => {
220
- const { events, store, calls } = setup();
221
- const handler = events.find((e) => e.name === "session/event").fn;
222
- const session = { id: "s1", requestHeader: () => ({ config: {} }), events: [] };
223
- await handler(session, { seq: 1, type: "user/message" });
224
- assert.equal(store.count(), 0);
225
- assert.equal(calls.length, 0);
226
- });
227
-
228
- // DSH ≥0.1.2-rc removed Session.events; the session object only exposes
229
- // snapshotEvents(). Regression for #59: with only the old .events path the
230
- // collector saw an empty log and no LLM call ever happened.
231
- test("reads events from snapshotEvents() when Session.events is absent", async () => {
232
- const { events, store, calls } = setup();
233
- const handler = events.find((e) => e.name === "session/event").fn;
234
- const session = {
235
- id: "s1",
236
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
237
- snapshotEvents: () => [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
238
- };
239
- await handler(session, { seq: 2, type: "turn/end" });
240
- assert.equal(calls.length, 1, "an LLM call must be made");
241
- assert.equal(store.count(), 2);
242
- });
243
-
244
- // --- Issue #239:成本感知级联(零 LLM 预判)与有界检查点 ---------------------
245
- // 验收对齐 #239:日志可见 skip 原因、默认配置下行为零变化、开启后调用量下降。
246
-
247
- test("#239 预判默认关:阈值为 0 时不改变现状,照常发起调用", async () => {
248
- const { events, calls, store } = setup();
249
- const handler = events.find((e) => e.name === "session/event").fn;
250
- const session = {
251
- id: "s239-default",
252
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
253
- events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
254
- };
255
- await handler(session, { seq: 2, type: "turn/end" });
256
- assert.equal(calls.length, 1, "默认不预判:再短的窗口也照常蒸馏");
257
- assert.equal(store.count(), 2);
258
- });
259
-
260
- test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费游标", async () => {
261
- const { events, calls, service, store } = setup({ summarizeMinWindowChars: 1000 });
262
- const handler = events.find((e) => e.name === "session/event").fn;
263
- const session = {
264
- id: "s239-small",
265
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
266
- events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
267
- };
268
- await handler(session, { seq: 2, type: "turn/end" });
269
- assert.equal(calls.length, 0, "短窗口不得发起 LLM 调用");
270
- assert.equal(store.count(), 0, "被拦下就不该写入记忆");
271
- const rows = service.listLlmAudits();
272
- assert.equal(rows[0].status, "skipped");
273
- assert.equal(rows[0].error_message, "window-too-small", "skip 原因必须可观测");
274
-
275
- await handler(session, { seq: 2, type: "turn/end" });
276
- assert.equal(calls.length, 0);
277
- assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计");
278
- });
279
-
280
- // #239 定标埋点:窗口字符数必须落进审计,否则下一级预判规则的阈值无从选起。
281
- // 第二条才是本改动的目的——预判开关关着(默认档位)时行为逐字节不变,但从这一刻
282
- // 起成功行开始留数,验收 4「默认配置下调用量可量化下降」才有账可对。
283
- test("#239 计量:window-too-small 的 skip 行带 distillChars 与阈值", async () => {
284
- const { events, service } = setup({ summarizeMinWindowChars: 1000 });
285
- const handler = events.find((e) => e.name === "session/event").fn;
286
- const session = {
287
- id: "s239-metric-skip",
288
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
289
- events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
290
- };
291
- await handler(session, { seq: 2, type: "turn/end" });
292
- const row = service.listLlmAudits()[0];
293
- assert.equal(row.error_message, "window-too-small");
294
- // 计的是**真正发给模型的转录文本**长度(含角色前缀:一条 1 字的「好」实测记 4),
295
- // 与闸门比较的口径同一个数,定标才对得上。这里不锁字面量,锁语义:短于阈值才该 skip。
296
- assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0,
297
- "skip 行必须带窗口字符数");
298
- assert.equal(row.metadata.minWindowChars, 1000, "阈值随行落档,事后能复核是哪条闸门拦的");
299
- assert.ok(row.metadata.distillChars < row.metadata.minWindowChars, "skip 的前提:窗口短于阈值");
300
- });
301
-
302
- test("#239 计量:预判开关关闭时成功行仍记录 distillChars", async () => {
303
- const { events, service } = setup();
304
- const handler = events.find((e) => e.name === "session/event").fn;
305
- const session = {
306
- id: "s239-metric-success",
307
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
308
- events: [userMessage("今天决定把窗口字符数记进审计", 1), { seq: 2, type: "turn/end" }]
309
- };
310
- await handler(session, { seq: 2, type: "turn/end" });
311
- const row = service.listLlmAudits()[0];
312
- assert.equal(row.status, "success");
313
- assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0,
314
- "默认档位下也要留数,否则验收 4 无从对账");
315
- });
316
-
317
- test("#239 窗口达到阈值时预判放行(不误杀)", async () => {
318
- const { events, calls } = setup({ summarizeMinWindowChars: 5 });
319
- const handler = events.find((e) => e.name === "session/event").fn;
320
- const session = {
321
- id: "s239-pass",
322
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
323
- events: [userMessage("这是一段足够长的用户输入", 1), { seq: 2, type: "turn/end" }]
324
- };
325
- await handler(session, { seq: 2, type: "turn/end" });
326
- assert.equal(calls.length, 1);
327
- });
328
-
329
- test("#239 每会话 run 预算:额度用尽后不再调用,审计写明原因", async () => {
330
- const { events, calls, service } = setup({ summarizeMaxRunsPerSession: 1, distillRateLimitIntervalMs: 0 });
331
- const handler = events.find((e) => e.name === "session/event").fn;
332
- const session = {
333
- id: "s239-cap",
334
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
335
- events: [userMessage("第一轮", 1), { seq: 2, type: "turn/end" }]
336
- };
337
- await handler(session, { seq: 2, type: "turn/end" });
338
- assert.equal(calls.length, 1, "第一次在预算内");
339
-
340
- session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
341
- await handler(session, { seq: 4, type: "turn/end" });
342
- assert.equal(calls.length, 1, "预算用尽后不得再调用");
343
- const rows = service.listLlmAudits();
344
- assert.equal(rows[0].status, "skipped");
345
- assert.equal(rows[0].error_message, "max-runs-per-session");
346
- });
347
-
348
- test("#239 预算只计真实调用:被预判拦下的窗口不消耗额度", async () => {
349
- const { events, calls } = setup({ summarizeMaxRunsPerSession: 1, summarizeMinWindowChars: 10, distillRateLimitIntervalMs: 0 });
350
- const handler = events.find((e) => e.name === "session/event").fn;
351
- const session = {
352
- id: "s239-budget",
353
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
354
- events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
355
- };
356
- await handler(session, { seq: 2, type: "turn/end" });
357
- assert.equal(calls.length, 0, "第一轮被零 LLM 预判拦下");
358
-
359
- session.events.push(userMessage("第二轮写得足够长,应该放行并消耗唯一的一次额度", 3), { seq: 4, type: "turn/end" });
360
- await handler(session, { seq: 4, type: "turn/end" });
361
- assert.equal(calls.length, 1, "零成本窗口不该吃掉预算");
362
- });
363
-
364
- // --- Issue #239 第 4 项:错峰队列 -------------------------------------------------
365
-
366
- test("#239-4 高峰时段解析:合法 / 多段 / 跨零点 / 星期前缀 / 非法一律按未配置", () => {
367
- assert.equal(parsePeakSpec(""), null);
368
- assert.equal(parsePeakSpec(" "), null);
369
- assert.deepEqual(parsePeakSpec("09:00-18:00"), { days: null, windows: [{ start: 540, end: 1080 }] });
370
- assert.deepEqual(parsePeakSpec("09:00-12:00, 14:00-18:00"), {
371
- days: null,
372
- windows: [{ start: 540, end: 720 }, { start: 840, end: 1080 }]
373
- });
374
- assert.deepEqual(parsePeakSpec("23:00-06:00"), { days: null, windows: [{ start: 1380, end: 360 }] });
375
- // 星期前缀:ISO 1=周一…7=周日,也认 mon..sun;支持列表与跨周环绕
376
- assert.deepEqual(parsePeakSpec("mon-fri 08:00-12:00,14:00-18:00"), {
377
- days: [1, 2, 3, 4, 5],
378
- windows: [{ start: 480, end: 720 }, { start: 840, end: 1080 }]
379
- });
380
- assert.deepEqual(parsePeakSpec("1-5 08:00-12:00").days, [1, 2, 3, 4, 5]);
381
- assert.deepEqual(parsePeakSpec("sat,sun 10:00-12:00").days, [6, 7]);
382
- assert.deepEqual(parsePeakSpec("fri-mon 10:00-12:00").days, [1, 5, 6, 7], "跨周环绕");
383
- // 排程是省钱手段,绝不该因为写错格式把蒸馏停掉:任何非法写法都当作「关闭」。
384
- for (const bad of ["09:00~18:00", "24:00-06:00", "09:60-18:00", "09:00-09:00", "abc", "09:00-18:00,oops", "weekday 09:00-18:00", "8 09:00-18:00"]) {
385
- assert.equal(parsePeakSpec(bad), null, `${bad} 应视为未配置`);
386
- }
387
- });
388
-
389
- // #424:中文输入法带出来的全角等价符号曾让整串解析成 null——null 的语义是
390
- // 「未配置 = 不限制」,错峰闸门于是静默失效(用户写的全角逗号把省钱功能整个
391
- // 关掉,且没有任何提示)。本用例锁两件事:
392
- // ① 全角逗号/冒号/数字/波浪号(含 U+301C)与全角星期前缀都归一成半角照常解析,
393
- // 判定与补跑(isInPeakWindow / nextOffPeakAt)走同一份解析、自动受益;
394
- // ② 归一化只做等价映射不救错:值本身越界的写法(24:00)无论半角全角仍然非法,
395
- // 防的是「归一化顺手放宽了数值边界」这类静默语义漂移。
396
- test("#424 全角等价符号:归一后照常解析,不改变越界判定", () => {
397
- assert.deepEqual(parsePeakSpec("00:00-03:00,04:00-06:00"), {
398
- days: null,
399
- windows: [{ start: 0, end: 180 }, { start: 240, end: 360 }]
400
- }, "全角逗号切段");
401
- assert.deepEqual(parsePeakSpec("00:00-03:00").windows, [{ start: 0, end: 180 }], "全角冒号");
402
- assert.deepEqual(parsePeakSpec("00:00-03:00").windows, [{ start: 0, end: 180 }], "全角数字");
403
- assert.deepEqual(parsePeakSpec("00:00~03:00").windows, [{ start: 0, end: 180 }], "全角波浪号当连接号");
404
- assert.deepEqual(parsePeakSpec("00:00〜03:00").windows, [{ start: 0, end: 180 }], "U+301C 波浪号的另一半");
405
- assert.deepEqual(parsePeakSpec("sat,sun 23:00-06:00").days, [6, 7], "整串全角:星期前缀一起归一");
406
- // 判定与补跑与解析共用同一入口,全角串在两侧行为一致
407
- const at = (h, m) => new Date(2026, 10, 10, h, m, 0);
408
- assert.equal(isInPeakWindow(at(1, 0), "00:00~03:00"), true, "判定侧同一份解析");
409
- assert.equal(nextOffPeakAt(at(1, 0), "00:00~03:00").getHours(), 3, "补跑时刻同一份解析");
410
- // 归一化不救错:#424 的举例串本身还踩 24:00 越界,跨零点的正写是 04:00-00:00
411
- assert.equal(parsePeakSpec("00:00-03:00,04:00-24:00"), null, "24:00 越界不因归一化变合法");
412
- assert.equal(parsePeakSpec("24:00-06:00"), null, "全角形式不改变越界判定");
413
- });
414
-
415
- test("#239-4 高峰判定与补跑时刻(跨零点、右开区间)", () => {
416
- const at = (h, m) => new Date(2026, 8, 19, h, m, 30);
417
- assert.equal(isInPeakWindow(at(10, 0), "09:00-18:00"), true);
418
- assert.equal(isInPeakWindow(at(18, 0), "09:00-18:00"), false, "右开区间:18:00 已出高峰");
419
- assert.equal(isInPeakWindow(at(3, 0), "23:00-06:00"), true, "跨零点时段");
420
- assert.equal(isInPeakWindow(at(12, 0), "23:00-06:00"), false);
421
- assert.equal(isInPeakWindow(at(10, 0), ""), false, "未配置 = 永不高峰");
422
- const next = nextOffPeakAt(at(10, 30), "09:00-18:00");
423
- assert.equal(next.getHours(), 18);
424
- assert.equal(next.getMinutes(), 0);
425
- assert.equal(nextOffPeakAt(at(3, 0), "23:00-06:00").getHours(), 6, "跨零点取次日 06:00 结束点");
426
- assert.equal(nextOffPeakAt(at(20, 0), "09:00-18:00"), null, "非高峰时刻无需补跑");
427
- });
428
-
429
- // 夹具:按 ISO 星期几(1=周一…7=周日)从当月 1 号推算出日期,避免把星期写死在测试里。
430
- function dateOnIsoDay(isoDay, hour, minute) {
431
- const first = new Date(2026, 8, 1, hour, minute, 0);
432
- const firstIso = first.getDay() === 0 ? 7 : first.getDay();
433
- return new Date(2026, 8, 1 + ((isoDay - firstIso + 7) % 7), hour, minute, 0);
434
- }
435
-
436
- test("#239-4 星期过滤:工作日高峰不误伤周末(跨零点段按「开窗那天」认星期)", () => {
437
- const spec = "mon-fri 08:00-12:00,14:00-18:00";
438
- assert.equal(dateOnIsoDay(1, 9, 0).getDay(), 1, "夹具自检:周一");
439
- assert.equal(dateOnIsoDay(6, 9, 0).getDay(), 6, "夹具自检:周六");
440
- assert.equal(isInPeakWindow(dateOnIsoDay(1, 9, 0), spec), true, "工作日 09:00 在高峰");
441
- assert.equal(isInPeakWindow(dateOnIsoDay(1, 12, 30), spec), false, "工作日午休不在高峰");
442
- assert.equal(isInPeakWindow(dateOnIsoDay(6, 9, 0), spec), false, "周六同刻不在高峰(按周计费)");
443
- assert.equal(isInPeakWindow(dateOnIsoDay(7, 15, 0), spec), false, "周日下午不在高峰");
444
- assert.equal(nextOffPeakAt(dateOnIsoDay(1, 9, 0), spec).getHours(), 12, "工作日顺延到 12:00");
445
- assert.equal(nextOffPeakAt(dateOnIsoDay(6, 9, 0), spec), null, "周末无需顺延");
446
- // 跨零点段:周五 23:00 开的窗口延续到周六凌晨,仍算高峰;周六开的则不覆盖周日
447
- const overnight = "mon-fri 23:00-06:00";
448
- assert.equal(isInPeakWindow(dateOnIsoDay(6, 2, 0), overnight), true, "周六 02:00 属周五开的窗口");
449
- assert.equal(isInPeakWindow(dateOnIsoDay(7, 2, 0), overnight), false, "周日 02:00 属周六开的窗口(周六不在集合内)");
450
- });
451
-
452
- test("#239-4 默认关:未配置高峰时行为与现状一致", async () => {
453
- const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
454
- const { events, calls } = setup({}, { deps: clock.deps });
455
- const handler = events.find((e) => e.name === "session/event").fn;
456
- const session = {
457
- id: "s239-peak-off",
458
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
459
- events: [userMessage("普通一轮", 1), { seq: 2, type: "turn/end" }]
460
- };
461
- await handler(session, { seq: 2, type: "turn/end" });
462
- assert.equal(calls.length, 1, "未配置高峰 → 照常蒸馏");
463
- assert.equal(clock.pending(), 0, "不该挂补跑定时器");
464
- });
465
-
466
- test("#239-4 高峰内不调模型:skip 审计 + 不消费游标,非高峰补跑整窗", async () => {
467
- const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
468
- const { events, calls, service, store } = setup(
469
- { summarizePeakHours: "09:00-18:00", distillRateLimitIntervalMs: 0 },
470
- { deps: clock.deps }
471
- );
472
- const handler = events.find((e) => e.name === "session/event").fn;
473
- const session = {
474
- id: "s239-peak",
475
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
476
- events: [userMessage("高峰里的第一段", 1), { seq: 2, type: "turn/end" }]
477
- };
478
- await handler(session, { seq: 2, type: "turn/end" });
479
- assert.equal(calls.length, 0, "高峰期不得调模型");
480
- assert.equal(store.count(), 0, "不该写入记忆");
481
- const rows = service.listLlmAudits();
482
- assert.equal(rows[0].status, "skipped");
483
- assert.equal(rows[0].error_message, "peak-hours", "skip 原因必须可观测");
484
- assert.equal(clock.pending(), 1, "应挂一个补跑定时器");
485
-
486
- // 高峰期间又来一轮:游标未消费 → 窗口累积;仍不调模型、定时器不叠加
487
- session.events.push(userMessage("高峰里的第二段", 3), { seq: 4, type: "turn/end" });
488
- await handler(session, { seq: 4, type: "turn/end" });
489
- assert.equal(calls.length, 0);
490
- assert.equal(clock.pending(), 1, "每会话只挂一个定时器");
491
-
492
- // 推到非高峰:一次补跑,且蒸到的是累积后的整窗
493
- await clock.advanceTo(new Date(2026, 8, 19, 18, 0, 5));
494
- assert.equal(calls.length, 1, "非高峰补跑一次");
495
- const prompt = JSON.stringify(calls[0].messages);
496
- assert.ok(prompt.includes("高峰里的第一段") && prompt.includes("高峰里的第二段"),
497
- "补跑应蒸馏累积后的完整窗口");
498
- assert.ok(store.count() > 0, "补跑真的写入了记忆");
499
- });
500
-
501
- test("#239-4 顺延上限:长高峰到点照跑,不饿死蒸馏", async () => {
502
- const clock = fakeClock(new Date(2026, 8, 19, 9, 0, 0));
503
- const { events, calls } = setup(
504
- { summarizePeakHours: "09:00-18:00", summarizePeakMaxDeferMinutes: 30, distillRateLimitIntervalMs: 0 },
505
- { deps: clock.deps }
506
- );
507
- const handler = events.find((e) => e.name === "session/event").fn;
508
- const session = {
509
- id: "s239-peak-cap",
510
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
511
- events: [userMessage("长高峰里的一轮", 1), { seq: 2, type: "turn/end" }]
512
- };
513
- await handler(session, { seq: 2, type: "turn/end" });
514
- assert.equal(calls.length, 0, "高峰内先顺延");
515
- await clock.advance(31 * 60000);
516
- assert.equal(calls.length, 1, "上限到点仍处高峰也照跑(bypassPeak)");
517
- });
518
-
519
- test("does not call the LLM when no event was added after the last successful seq", async () => {
520
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
521
- const handler = events.find((e) => e.name === "session/event").fn;
522
- const transcript = [userMessage("首轮", 1), { seq: 2, type: "turn/end" }];
523
- const ranges = [];
524
- const session = {
525
- id: "s-noop",
526
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
527
- snapshotEvents(fromSeq, toSeq) {
528
- ranges.push([fromSeq, toSeq]);
529
- return transcript;
530
- }
531
- };
532
-
533
- await handler(session, { seq: 2, type: "turn/end" });
534
- await handler(session, { seq: 2, type: "turn/end" });
535
-
536
- assert.equal(calls.length, 1, "a successful window must not be distilled twice");
537
- assert.equal(ranges[1][0], 2, "the successful event seq is passed as the next snapshot lower bound");
538
- });
539
-
540
- test("resumes a persisted seq cursor after the summarizer is restarted", async () => {
541
- const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-distill-cursor-"));
542
- const dbPath = join(dir, "memory.db");
543
- let firstStore;
544
- let firstSummarizer;
545
- let secondStore;
546
- let secondSummarizer;
547
- try {
548
- firstStore = createStore(dbPath);
549
- const first = setup({ distillRateLimitIntervalMs: 0 }, { store: firstStore });
550
- firstSummarizer = first.summarizer;
551
- const session = {
552
- id: "s-restart",
553
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
554
- events: [userMessage("重启前的窗口", 1), { seq: 2, type: "turn/end" }]
555
- };
556
- const firstHandler = first.events.find((e) => e.name === "session/event").fn;
557
- await firstHandler(session, { seq: 2, type: "turn/end" });
558
- assert.equal(first.calls.length, 1);
559
- assert.equal(firstStore.getDistillCursor("s-restart").last_seq, 2);
560
-
561
- firstSummarizer.dispose();
562
- firstStore.close();
563
- firstSummarizer = undefined;
564
- firstStore = undefined;
565
-
566
- secondStore = createStore(dbPath);
567
- const second = setup({ distillRateLimitIntervalMs: 0 }, { store: secondStore });
568
- secondSummarizer = second.summarizer;
569
- const secondHandler = second.events.find((e) => e.name === "session/event").fn;
570
- await secondHandler(session, { seq: 2, type: "turn/end" });
571
-
572
- assert.equal(second.calls.length, 0, "a restarted summarizer must reuse the persisted cursor");
573
- assert.equal(secondStore.count(), 2, "the restart must not duplicate memories");
574
- assert.equal(secondStore.getDistillCursor("s-restart").last_seq, 2);
575
- } finally {
576
- secondSummarizer?.dispose();
577
- secondStore?.close();
578
- firstSummarizer?.dispose();
579
- firstStore?.close();
580
- rmSync(dir, { recursive: true, force: true });
581
- }
582
- });
583
-
584
- test("treats a valid empty summary as a successful cursor commit", async () => {
585
- const { events, store, calls } = setup(
586
- { distillRateLimitIntervalMs: 0 },
587
- { stream: streamOf([]) }
588
- );
589
- const handler = events.find((e) => e.name === "session/event").fn;
590
- const session = {
591
- id: "s-empty-summary",
592
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
593
- events: [userMessage("这一轮没有可保存的长期记忆", 1), { seq: 2, type: "turn/end" }]
594
- };
595
-
596
- await handler(session, { seq: 2, type: "turn/end" });
597
- assert.equal(calls.length, 1);
598
- assert.equal(store.count(), 0);
599
- assert.equal(store.getDistillCursor(session.id).last_seq, 2);
600
-
601
- await handler(session, { seq: 2, type: "turn/end" });
602
- assert.equal(calls.length, 1, "a valid empty summary must consume the window once");
603
- });
604
-
605
- test("advances the cursor to the window end for mixed valid and invalid entries", async () => {
606
- const valid = { type: "history", title: "有效摘要", content: "只保存这一条", importance: 3 };
607
- const { events, store, calls } = setup(
608
- { distillRateLimitIntervalMs: 0 },
609
- { stream: streamOf([valid, { type: "unknown", title: "无效摘要", content: "忽略" }, null]) }
610
- );
611
- const handler = events.find((e) => e.name === "session/event").fn;
612
- const session = {
613
- id: "s-mixed-summary",
614
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
615
- events: [userMessage("混合摘要窗口", 1), { seq: 2, type: "assistant/message" }, { seq: 3, type: "turn/end" }]
616
- };
617
-
618
- await handler(session, { seq: 3, type: "turn/end" });
619
- assert.equal(calls.length, 1);
620
- assert.equal(store.count(), 1);
621
- assert.equal(store.all()[0].title, valid.title);
622
- assert.equal(store.getDistillCursor(session.id).last_seq, 3);
623
-
624
- await handler(session, { seq: 3, type: "turn/end" });
625
- assert.equal(calls.length, 1, "a mixed summary must consume the window once");
626
- });
627
-
628
- test("distills only new seq events and keeps the recent tail when a window exceeds distillMaxChars", async () => {
629
- const { events, calls } = setup(
630
- { distillMaxChars: 80, distillRateLimitIntervalMs: 0 },
631
- { stream: streamOf([]) }
632
- );
633
- const handler = events.find((e) => e.name === "session/event").fn;
634
- const transcript = [
635
- userMessage(`OLD_BEGINNING ${"x".repeat(160)} OLD_RECENT`, 1),
636
- { seq: 2, type: "assistant/message", data: { message: { content: [{ type: "text", text: "旧回复" }] } } },
637
- { seq: 3, type: "turn/end" }
638
- ];
639
- const session = {
640
- id: "s-incremental",
641
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
642
- snapshotEvents: () => transcript
643
- };
644
-
645
- await handler(session, { seq: 3, type: "turn/end" });
646
- const firstInput = calls[0].messages.find((message) => message.role === "user").content[0].text;
647
-
648
- transcript.push(
649
- userMessage(`NEW_BEGINNING ${"y".repeat(160)} NEW_RECENT`, 4),
650
- { seq: 5, type: "assistant/message", data: { message: { content: [{ type: "text", text: "新回复" }] } } },
651
- { seq: 6, type: "turn/end" }
652
- );
653
- await handler(session, { seq: 6, type: "turn/end" });
654
- const secondInput = calls[1].messages.find((message) => message.role === "user").content[0].text;
655
-
656
- assert.notEqual(firstInput, secondInput, "successive distills must receive different windows");
657
- assert.ok(secondInput.includes("NEW_RECENT"), "the newest event content remains in the bounded window");
658
- assert.ok(!secondInput.includes("OLD_BEGINNING"), "the second window must not restart at the session beginning");
659
- assert.ok(!secondInput.includes("OLD_RECENT"), "the previous successful window must not be repeated");
660
- });
661
-
662
- test("dispose unsubscribes and stops later turn/end events from summarizing", async () => {
663
- const { events, summarizer, calls } = setup();
664
- const handler = events.find((e) => e.name === "session/event").fn;
665
- summarizer.dispose();
666
- // The ctx.on() disposer must have removed the listener.
667
- assert.ok(!events.some((e) => e.name === "session/event"));
668
- const session = {
669
- id: "s1",
670
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
671
- events: [userMessage("你好"), { seq: 2, type: "turn/end" }]
672
- };
673
- // Even a stale handler reference must not start a new LLM call.
674
- await handler(session, { seq: 2, type: "turn/end" });
675
- assert.equal(calls.length, 0);
676
- });
677
-
678
- test("excludes plugin-injected user/message events from summarization input", async () => {
679
- const { events, store, calls } = setup();
680
- const handler = events.find((e) => e.name === "session/event").fn;
681
- const session = {
682
- id: "s2",
683
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
684
- events: [
685
- {
686
- seq: 1,
687
- type: "user/message",
688
- data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 内容" }] }
689
- },
690
- userMessage("帮我看看这个报错"),
691
- { seq: 3, type: "turn/end" }
692
- ]
693
- };
694
- await handler(session, { seq: 3, type: "turn/end" });
695
- assert.equal(calls.length, 1);
696
- const userMessages = calls[0].messages.filter((m) => m.role === "user");
697
- assert.equal(userMessages.length, 1);
698
- assert.ok(!JSON.stringify(calls[0].messages).includes("AGENTS.md"));
699
- assert.equal(store.count(), 2);
700
- });
701
-
702
- test("aborted finish does not store entries and leaves the seq window retryable", async () => {
703
- let attempt = 0;
704
- const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
705
- stream() {
706
- attempt++;
707
- return (async function* () {
708
- yield { type: "block-start", block: { type: "text" } };
709
- if (attempt === 1) {
710
- yield { type: "text-delta", delta: "[]" };
711
- yield { type: "finish", kind: "aborted" };
712
- return;
713
- }
714
- yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试", content: "保留原窗口", importance: 3 }]) };
715
- yield { type: "finish", kind: "ok" };
716
- })();
717
- }
718
- });
719
- const handler = events.find((e) => e.name === "session/event").fn;
720
- const session = {
721
- id: "s3",
722
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
723
- events: [userMessage("继续", 1), { seq: 2, type: "turn/end" }]
724
- };
725
- await handler(session, { seq: 2, type: "turn/end" });
726
- assert.equal(calls.length, 1); // the stream was actually reached
727
- assert.equal(store.count(), 0);
728
- await handler(session, { seq: 2, type: "turn/end" });
729
- assert.equal(calls.length, 2, "an aborted window must be retried instead of being marked consumed");
730
- assert.equal(store.count(), 1);
731
- });
732
-
733
- test("a stream failure leaves the seq window retryable", async () => {
734
- let attempt = 0;
735
- const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
736
- stream() {
737
- attempt++;
738
- if (attempt === 1) {
739
- return (async function* () {
740
- throw new Error("temporary stream failure");
741
- })();
742
- }
743
- return streamOf([{ type: "history", title: "重试成功", content: "失败窗口未丢失", importance: 3 }])();
744
- }
745
- });
746
- const handler = events.find((e) => e.name === "session/event").fn;
747
- const session = {
748
- id: "s-stream-failure",
749
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
750
- events: [userMessage("保留失败窗口", 1), { seq: 2, type: "turn/end" }]
751
- };
752
-
753
- await handler(session, { seq: 2, type: "turn/end" });
754
- assert.equal(calls.length, 1);
755
- assert.equal(store.count(), 0);
756
- await handler(session, { seq: 2, type: "turn/end" });
757
- assert.equal(calls.length, 2, "a failed stream must be retried even without new events");
758
- assert.equal(store.count(), 1);
759
- });
760
-
761
- test("invalid summary JSON leaves the seq window retryable", async () => {
762
- let attempt = 0;
763
- const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
764
- stream() {
765
- attempt++;
766
- const output = attempt === 1 ? "not a JSON array" : "[]";
767
- return (async function* () {
768
- yield { type: "block-start", block: { type: "text" } };
769
- yield { type: "text-delta", delta: output };
770
- yield { type: "finish", kind: "ok" };
771
- })();
772
- }
773
- });
774
- const handler = events.find((e) => e.name === "session/event").fn;
775
- const session = {
776
- id: "s-parse-failure",
777
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
778
- events: [userMessage("保留解析窗口", 1), { seq: 2, type: "turn/end" }]
779
- };
780
-
781
- await handler(session, { seq: 2, type: "turn/end" });
782
- await handler(session, { seq: 2, type: "turn/end" });
783
- assert.equal(calls.length, 2, "a parse failure must not advance the seq cursor");
784
- assert.equal(store.count(), 0, "the valid empty retry remains a no-op");
785
- });
786
-
787
- test("a JSON-crashed window with salvageable objects is recovered, consumed once, and audited (issue #339)", async (t) => {
788
- const malformed = `[
789
- {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
790
- { " "type": "history", "title": "坏对象", "content": "语法错误" },
791
- {"type":"preference","title":"硬件","content":"只要 brushed brass"}
792
- ]`;
793
- const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
794
- stream() {
795
- return (async function* () {
796
- yield { type: "block-start", block: { type: "text" } };
797
- yield { type: "text-delta", delta: malformed };
798
- yield { type: "finish", kind: "ok" };
799
- })();
800
- }
801
- });
802
- t.after(() => { summarizer.dispose(); store.close(); });
803
- const handler = events.find((e) => e.name === "session/event").fn;
804
- const session = {
805
- id: "s-salvage",
806
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
807
- events: [userMessage("带语法错误的蒸馏窗口", 1), { seq: 2, type: "turn/end" }]
808
- };
809
-
810
- await handler(session, { seq: 2, type: "turn/end" });
811
- assert.equal(service.count(), 2, "the two intact objects are written, the broken one dropped");
812
- const titles = service.list().map((m) => m.title).sort();
813
- assert.deepEqual(titles, ["硬件", "预算"]);
814
- assert.equal(calls.length, 1, "salvage consumed the window: no same-window retry");
815
- const audit = service.listLlmAudits().find((r) => r.status === "success");
816
- assert.ok(audit, "salvaged run is audited as a success");
817
- assert.equal(audit.metadata?.json_salvaged, true, "salvage is observable in the audit trail");
818
-
819
- await handler(session, { seq: 2, type: "turn/end" });
820
- assert.equal(calls.length, 1, "the consumed window is not re-distilled");
821
- assert.equal(service.count(), 2, "no duplicate writes on the second turn/end");
822
- });
823
-
824
- test("a JSON-crashed window with nothing salvageable stays retryable (issue #339)", async (t) => {
825
- const hopeless = `[
826
- { " "type": "history", "title": "坏", "content": "语法错误" }
827
- ]`;
828
- let attempt = 0;
829
- const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
830
- stream() {
831
- const output = attempt++ === 0 ? hopeless : `[
832
- {"type":"history","title":"重试成功","content":"干净窗口","importance":3}
833
- ]`;
834
- return (async function* () {
835
- yield { type: "block-start", block: { type: "text" } };
836
- yield { type: "text-delta", delta: output };
837
- yield { type: "finish", kind: "ok" };
838
- })();
839
- }
840
- });
841
- t.after(() => { summarizer.dispose(); store.close(); });
842
- const handler = events.find((e) => e.name === "session/event").fn;
843
- const session = {
844
- id: "s-salvage-none",
845
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
846
- events: [userMessage("救不回的窗口", 1), { seq: 2, type: "turn/end" }]
847
- };
848
-
849
- await handler(session, { seq: 2, type: "turn/end" });
850
- assert.equal(service.count(), 0, "zero salvage is not a fake success");
851
- await handler(session, { seq: 2, type: "turn/end" });
852
- assert.equal(calls.length, 2, "the window stays retryable");
853
- assert.equal(service.count(), 1);
854
- assert.ok(service.listLlmAudits().some(
855
- (r) => r.status === "error" && r.error_message === "invalid summary JSON"
856
- ), "zero-salvage run keeps the error audit");
857
- });
858
-
859
- test("an all-invalid summary leaves the same window retryable until valid memories are saved", async (t) => {
860
- for (const invalid of [
861
- [{}],
862
- [{ type: "unknown", title: "无效类型", content: "不能消费窗口" }],
863
- [{ type: "history", title: " ", content: "不能消费窗口" }, null, "garbage"]
864
- ]) {
865
- await t.test(JSON.stringify(invalid), async (t) => {
866
- let attempt = 0;
867
- const valid = { type: "history", title: "重试成功", content: "全无效摘要后仍保留原窗口", importance: 3 };
868
- const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
869
- stream() {
870
- return streamOf(attempt++ === 0 ? invalid : [valid])();
871
- }
872
- });
873
- t.after(() => { summarizer.dispose(); store.close(); });
874
- const handler = events.find((e) => e.name === "session/event").fn;
875
- const session = {
876
- id: "s-all-invalid",
877
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
878
- events: [userMessage("保留原窗口", 1), { seq: 2, type: "turn/end" }]
879
- };
880
-
881
- await handler(session, { seq: 2, type: "turn/end" });
882
- assert.equal(service.count(), 0);
883
- await handler(session, { seq: 2, type: "turn/end" });
884
- assert.equal(calls.length, 2, "all-invalid entries must not consume the window");
885
- const firstTranscript = calls[0].messages.find((message) => message.role === "user").content[0].text;
886
- const retryTranscript = calls[1].messages.find((message) => message.role === "user").content[0].text;
887
- assert.equal(retryTranscript, firstTranscript, "retry receives the original transcript");
888
- assert.equal(service.count(), 1);
889
- assert.equal(service.list()[0].content, valid.content);
890
- // #502:这一族(JSON 合法、条目全被枚举滤掉)从此报成 filtered-empty 的专属消息,
891
- // 不再与「模型压根没写数组」共用 invalid summary JSON。状态与「窗口可重试」不变,
892
- // 所以下面三条断言一条都不放松。
893
- assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some(
894
- (audit) => audit.status === "error" && audit.error_message === `no valid summary entries (arr=${invalid.length}, valid=0)`
895
- ), "an all-invalid summary must be audited as an error, named as a filtered-empty window");
896
- // metadata 读回来可能是串也可能是对象(store 存的是 JSON 串,列表接口按形状
897
- // 解析过),两种形状都归一成串再判——否则这条锁会随下游解析行为假红。
898
- const metaText = (audit) => {
899
- const m = audit.metadata;
900
- if (typeof m === "string") return m;
901
- try { return JSON.stringify(m ?? ""); } catch { return ""; }
902
- };
903
- assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some(
904
- (audit) => audit.status === "error" && metaText(audit).includes("filtered-empty")
905
- ), "metadata 要能说出是滤空而不是没写格式(sample_types 带着模型自造的分类词)");
906
-
907
- await handler(session, { seq: 2, type: "turn/end" });
908
- assert.equal(calls.length, 2, "the successfully retried window is consumed once");
909
- assert.equal(service.count(), 1);
910
- });
911
- }
912
- });
913
-
914
- test("rolls back partial memory writes so retrying a failed window does not duplicate entries", async () => {
915
- const { events, store, service, calls } = setup(
916
- { distillRateLimitIntervalMs: 0 },
917
- {
918
- stream: streamOf([
919
- { type: "history", title: "第一条", content: "第一条内容", importance: 3 },
920
- { type: "decision", title: "第二条", content: "第二条内容", importance: 4 }
921
- ])
922
- }
923
- );
924
- const originalSave = service.saveWithDedupe.bind(service);
925
- let failOnSecondEntry = true;
926
- service.saveWithDedupe = (memory) => {
927
- const result = originalSave(memory);
928
- if (failOnSecondEntry && memory.title === "第二条") {
929
- failOnSecondEntry = false;
930
- throw new Error("simulated memory write failure");
931
- }
932
- return result;
933
- };
934
- const handler = events.find((e) => e.name === "session/event").fn;
935
- const session = {
936
- id: "s-partial-write",
937
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
938
- events: [userMessage("不要丢失这个窗口", 1), { seq: 2, type: "turn/end" }]
939
- };
940
-
941
- await handler(session, { seq: 2, type: "turn/end" });
942
- assert.equal(calls.length, 1);
943
- assert.equal(store.count(), 0, "a failed write transaction must leave no partial memory");
944
- assert.equal(store.getDistillCursor("s-partial-write"), undefined, "a failed write must not advance the cursor");
945
-
946
- await handler(session, { seq: 2, type: "turn/end" });
947
- assert.equal(calls.length, 2, "the failed seq window must be retried");
948
- assert.equal(store.count(), 2);
949
- assert.equal(store.getDistillCursor("s-partial-write").last_seq, 2);
950
- assert.equal(store.all().find((memory) => memory.title === "第一条")?.content, "第一条内容");
951
-
952
- await handler(session, { seq: 2, type: "turn/end" });
953
- assert.equal(calls.length, 2, "a successfully retried window must not be distilled again");
954
- assert.equal(store.count(), 2);
955
- });
956
-
957
- test("rolls back memory writes when persisting the cursor fails", async () => {
958
- const { events, store, service, calls } = setup(
959
- { distillRateLimitIntervalMs: 0 },
960
- { stream: streamOf([{ type: "history", title: "游标失败", content: "事务应回滚", importance: 3 }]) }
961
- );
962
- const originalSetCursor = service.setDistillCursor.bind(service);
963
- let failOnce = true;
964
- service.setDistillCursor = (sessionId, lastSeq) => {
965
- if (failOnce) {
966
- failOnce = false;
967
- throw new Error("simulated cursor write failure");
968
- }
969
- return originalSetCursor(sessionId, lastSeq);
970
- };
971
- const handler = events.find((e) => e.name === "session/event").fn;
972
- const session = {
973
- id: "s-cursor-write-failure",
974
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
975
- events: [userMessage("游标写入失败也不能丢窗口", 1), { seq: 2, type: "turn/end" }]
976
- };
977
-
978
- await handler(session, { seq: 2, type: "turn/end" });
979
- assert.equal(calls.length, 1);
980
- assert.equal(store.count(), 0, "cursor failure must roll back the memory write");
981
- assert.equal(store.getDistillCursor(session.id), undefined);
982
- const failedAudit = service.listLlmAudits({ source: "autoSummarize" }).find(
983
- (audit) => audit.error_message === "simulated cursor write failure"
984
- );
985
- assert.equal(failedAudit?.status, "error", "cursor failure must not be audited as success");
986
-
987
- await handler(session, { seq: 2, type: "turn/end" });
988
- assert.equal(calls.length, 2, "the failed cursor window must be retryable");
989
- assert.equal(store.count(), 1);
990
- assert.equal(store.getDistillCursor(session.id).last_seq, 2);
991
- });
992
-
993
- test("falls back to the in-memory cursor when service lacks setDistillCursor", async () => {
994
- // 旧版 service(#274 之前)没有持久化游标:缺方法必须降级为内存游标照常蒸馏,
995
- // 而不是抛错打断每轮蒸馏。方法存在但抛错走的是上一条回滚用例,两者不可混淆。
996
- const { events, store, service, calls } = setup(
997
- { distillRateLimitIntervalMs: 0 },
998
- { stream: streamOf([{ type: "history", title: "降级游标", content: "缺方法也能蒸馏", importance: 3 }]) }
999
- );
1000
- delete service.setDistillCursor; // 模拟旧版 service:实例属性,直接删掉
1001
- const handler = events.find((e) => e.name === "session/event").fn;
1002
- const session = {
1003
- id: "s-no-cursor-api",
1004
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1005
- events: [userMessage("旧宿主没有游标接口", 1), { seq: 2, type: "turn/end" }]
1006
- };
1007
-
1008
- await handler(session, { seq: 2, type: "turn/end" });
1009
- assert.equal(calls.length, 1, "distillation must still run without a persisted cursor");
1010
- assert.equal(store.count(), 1, "the memory itself must be saved");
1011
- assert.equal(store.getDistillCursor(session.id), undefined, "nothing may be persisted without the API");
1012
-
1013
- await handler(session, { seq: 2, type: "turn/end" });
1014
- assert.equal(calls.length, 1, "the in-memory cursor must prevent re-distillation in this process");
1015
- });
1016
-
1017
- test("uses summarizeProvider/summarizeModel config override when set", async () => {
1018
- const { events, calls } = setup({
1019
- summarizeProvider: "aliyun",
1020
- summarizeModel: "qwen3.6-plus"
1021
- });
1022
- const handler = events.find((e) => e.name === "session/event").fn;
1023
- const session = {
1024
- id: "s4",
1025
- // Session header has a different model — config override should win.
1026
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-v4-pro" } }),
1027
- events: [userMessage("测试覆盖"), { seq: 2, type: "turn/end" }]
1028
- };
1029
- await handler(session, { seq: 2, type: "turn/end" });
1030
- assert.equal(calls.length, 1);
1031
- assert.equal(calls[0].provider, "aliyun");
1032
- assert.equal(calls[0].model, "qwen3.6-plus");
1033
- });
1034
-
1035
- test("falls back to session header when summarize config is empty", async () => {
1036
- const { events, calls } = setup({
1037
- summarizeProvider: "",
1038
- summarizeModel: ""
1039
- });
1040
- const handler = events.find((e) => e.name === "session/event").fn;
1041
- const session = {
1042
- id: "s5",
1043
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1044
- events: [userMessage("回退测试"), { seq: 2, type: "turn/end" }]
1045
- };
1046
- await handler(session, { seq: 2, type: "turn/end" });
1047
- assert.equal(calls.length, 1);
1048
- assert.equal(calls[0].provider, "deepseek");
1049
- assert.equal(calls[0].model, "deepseek-chat");
1050
- });
1051
-
1052
- // ── v0.7.11:智能调速器(429 保护)+ 完整转录/原子记忆 ─────────────────────
1053
-
1054
- test("serializes distill LLM calls across sessions (global queue, no concurrency)", async () => {
1055
- const timeline = [];
1056
- const { events, store } = setup({ distillRateLimitIntervalMs: 0 }, {
1057
- stream() {
1058
- return (async function* () {
1059
- timeline.push(`start:${Date.now()}`);
1060
- await new Promise((r) => setTimeout(r, 8));
1061
- yield { type: "block-start", block: { type: "text" } };
1062
- yield { type: "text-delta", delta: "[]" };
1063
- yield { type: "finish", kind: "ok" };
1064
- timeline.push(`end:${Date.now()}`);
1065
- })();
1066
- }
1067
- });
1068
- const handler = events.find((e) => e.name === "session/event").fn;
1069
- const mkSession = (id) => ({
1070
- id,
1071
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1072
- events: [userMessage(`问题${id}`), { seq: 2, type: "turn/end" }]
1073
- });
1074
- // 两个会话几乎同时 turn/end → 必须排队,第二个请求不能与第一个并发。
1075
- await Promise.all([
1076
- handler(mkSession("a"), { seq: 2, type: "turn/end" }),
1077
- handler(mkSession("b"), { seq: 2, type: "turn/end" })
1078
- ]);
1079
- assert.equal(timeline.length, 4); // 每个流 start + end
1080
- const starts = timeline.filter((t) => t.startsWith("start")).map((t) => Number(t.slice(6)));
1081
- const ends = timeline.filter((t) => t.startsWith("end")).map((t) => Number(t.slice(4)));
1082
- assert.ok(starts[1] >= ends[0], "second distill must start only after the first finished (serial queue)");
1083
- });
1084
-
1085
- test("retries with exponential backoff on 429 and still stores entries", async () => {
1086
- let attempts = 0;
1087
- const { events, store } = setup(
1088
- { distillRateLimitRetries: 3, distillRateLimitBaseDelayMs: 5, distillRateLimitIntervalMs: 0 },
1089
- {
1090
- stream() {
1091
- return (async function* () {
1092
- attempts++;
1093
- if (attempts < 3) throw Object.assign(new Error("rate limit exceeded"), { status: 429 });
1094
- yield { type: "block-start", block: { type: "text" } };
1095
- yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试成功", content: "第三次请求成功", importance: 3 }]) };
1096
- yield { type: "finish", kind: "ok" };
1097
- })();
1098
- }
1099
- }
1100
- );
1101
- const handler = events.find((e) => e.name === "session/event").fn;
1102
- const session = {
1103
- id: "s9",
1104
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1105
- events: [userMessage("限流测试"), { seq: 2, type: "turn/end" }]
1106
- };
1107
- const startedAt = Date.now();
1108
- await handler(session, { seq: 2, type: "turn/end" });
1109
- // 429 两次 → 退避重试(5ms + 10ms),第三次成功入库。
1110
- assert.equal(attempts, 3);
1111
- assert.ok(Date.now() - startedAt >= 15, "backoff waits should be visible");
1112
- assert.equal(store.count(), 1);
1113
- assert.equal(store.all()[0].title, "重试成功");
1114
- });
1115
-
1116
- test("distills full transcript (tool calls, results, code output) with atomic-memory prompt", async () => {
1117
- const { events, calls } = setup();
1118
- const handler = events.find((e) => e.name === "session/event").fn;
1119
- const session = {
1120
- id: "s10",
1121
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1122
- events: [
1123
- userMessage("帮我修这个 bug"),
1124
- { seq: 2, type: "tool/call", data: { name: "Bash", arguments: "node test.js" } },
1125
- toolResultEvent("TypeError: x is not a function", true, 3),
1126
- codeDispatchEvent("fixed", false, 4),
1127
- { seq: 5, type: "turn/end" }
1128
- ]
1129
- };
1130
- await handler(session, { seq: 5, type: "turn/end" });
1131
- const transcript = JSON.stringify(calls[0].messages);
1132
- assert.ok(transcript.includes("修这个 bug"));
1133
- assert.ok(transcript.includes("TypeError: x is not a function"));
1134
- assert.ok(transcript.includes("代码执行"));
1135
- // 原子记忆 prompt:不再"硬压 2-3 条",而是按需多提、贴近原始细节。
1136
- assert.ok(calls[0].messages[0].content[0].text.includes("原子记忆"));
1137
- });
1138
-
1139
- test("collects real tool result and code dispatch payloads with failure status", async () => {
1140
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1141
- const handler = events.find((e) => e.name === "session/event").fn;
1142
- const session = {
1143
- id: "s-real-payloads",
1144
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1145
- events: [
1146
- userMessage("检查真实事件形状", 1),
1147
- toolResultEvent("TypeError: tool failed", true, 2),
1148
- codeDispatchEvent("代码执行结果", false, 3),
1149
- { seq: 4, type: "turn/end" }
1150
- ]
1151
- };
1152
-
1153
- await handler(session, { seq: 4, type: "turn/end" });
1154
- const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1155
- assert.match(transcript, /工具结果(失败):TypeError: tool failed/);
1156
- assert.match(transcript, /代码执行(成功):代码执行结果/);
1157
- });
1158
-
1159
- test("collects tool results delivered as role:tool messages, keeping the legacy block shape", async () => {
1160
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1161
- const handler = events.find((e) => e.name === "session/event").fn;
1162
- const session = {
1163
- id: "s-tool-role-shape",
1164
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1165
- events: [
1166
- userMessage("检查工具结果两种形状", 1),
1167
- toolResultEvent("旧形状载荷", false, 2),
1168
- toolRoleResultEvent("新形状载荷", false, 3),
1169
- toolRoleResultEvent("TypeError: tool crashed", true, 4),
1170
- { seq: 5, type: "turn/end" }
1171
- ]
1172
- };
1173
-
1174
- await handler(session, { seq: 5, type: "turn/end" });
1175
- const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1176
- assert.match(transcript, /工具结果(成功):旧形状载荷/);
1177
- assert.match(transcript, /工具结果(成功):新形状载荷/);
1178
- assert.match(transcript, /工具结果(失败):TypeError: tool crashed/);
1179
- });
1180
-
1181
- test("keeps prefix trimming for tool results delivered as role:tool messages", async () => {
1182
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1183
- const handler = events.find((e) => e.name === "session/event").fn;
1184
- const session = {
1185
- id: "s-tool-role-trim",
1186
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1187
- events: [
1188
- userMessage("检查新形状截断", 1),
1189
- toolRoleResultEvent(`OUT_START ${"c".repeat(550)} OUT_END`, false, 2),
1190
- { seq: 3, type: "turn/end" }
1191
- ]
1192
- };
1193
-
1194
- await handler(session, { seq: 3, type: "turn/end" });
1195
- const transcript = JSON.stringify(calls[0].messages);
1196
- assert.ok(transcript.includes("OUT_START"));
1197
- assert.ok(!transcript.includes("OUT_END"), "role:tool 结果同样走 500 字符前缀截断");
1198
- });
1199
-
1200
- test("collects subagent delivery once across inbox and user-message views", async () => {
1201
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1202
- const handler = events.find((e) => e.name === "session/event").fn;
1203
- const delivery = inboxMessage("delivery-1", "subagent-settled", "子会话 closing message");
1204
- const agentMessage = inboxMessage("delivery-2", "agent-message", "子代理补充报告");
1205
- const injectedInstruction = inboxMessage("instruction-1", "agent-instructions", "不要把这条注入指令沉淀为记忆");
1206
- const session = {
1207
- id: "s-agent-delivery",
1208
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1209
- events: [
1210
- userMessage("检查子会话交付", 1),
1211
- { seq: 2, type: "agent/inbox/spliced", data: { inserted: [delivery, agentMessage, injectedInstruction] } },
1212
- { seq: 3, type: "user/message", data: delivery },
1213
- { seq: 4, type: "turn/end" }
1214
- ]
1215
- };
1216
-
1217
- await handler(session, { seq: 4, type: "turn/end" });
1218
- const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1219
- assert.equal(transcript.match(/子会话 closing message/g)?.length, 1);
1220
- assert.equal(transcript.match(/子代理补充报告/g)?.length, 1);
1221
- assert.ok(!transcript.includes("不要把这条注入指令沉淀为记忆"));
1222
- });
1223
-
1224
- test("keeps prefix trimming for tool arguments and tool results", async () => {
1225
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1226
- const handler = events.find((e) => e.name === "session/event").fn;
1227
- const session = {
1228
- id: "s-trim",
1229
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1230
- events: [
1231
- userMessage("检查截断", 1),
1232
- { seq: 2, type: "tool/call", data: { name: "Bash", arguments: `ARG_START ${"a".repeat(350)} ARG_END` } },
1233
- toolResultEvent(`OUT_START ${"b".repeat(550)} OUT_END`, false, 3),
1234
- { seq: 4, type: "turn/end" }
1235
- ]
1236
- };
1237
- await handler(session, { seq: 4, type: "turn/end" });
1238
- const transcript = JSON.stringify(calls[0].messages);
1239
- assert.ok(transcript.includes("ARG_START"));
1240
- assert.ok(!transcript.includes("ARG_END"), "tool arguments keep their existing prefix trim");
1241
- assert.ok(transcript.includes("OUT_START"));
1242
- assert.ok(!transcript.includes("OUT_END"), "tool results keep their existing prefix trim");
1243
- });
1244
-
1245
- test("distill excludes private assistant reasoning blocks from the transcript", async () => {
1246
- const { events, calls } = setup();
1247
- const handler = events.find((e) => e.name === "session/event").fn;
1248
- const session = {
1249
- id: "s12",
1250
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1251
- events: [
1252
- userMessage("解释一下这段代码"),
1253
- {
1254
- type: "assistant/message",
1255
- data: {
1256
- message: {
1257
- content: [
1258
- { type: "reasoning", text: "私有推理:内部权衡过程不该进记忆" },
1259
- { type: "text", text: "这段代码有死循环,第 3 行 while 条件恒真。" }
1260
- ]
1261
- }
1262
- }
1263
- },
1264
- { seq: 3, type: "turn/end" }
1265
- ]
1266
- };
1267
- await handler(session, { seq: 3, type: "turn/end" });
1268
- const transcript = JSON.stringify(calls[0].messages);
1269
- assert.ok(transcript.includes("有死循环,第 3 行"), "public assistant text still distills");
1270
- assert.ok(!transcript.includes("私有推理"), "private reasoning must never enter the distill context");
1271
- });
1272
-
1273
- test("codingRetrospect stores coding memory types in the coding memory type set", async () => {
1274
- const { events, store, calls } = setup({ codingRetrospect: true }, {
1275
- stream() {
1276
- return (async function* () {
1277
- yield { type: "block-start", block: { type: "text" } };
1278
- yield { type: "text-delta", delta: JSON.stringify([
1279
- { type: "rejected_solution", title: "弃用方案", content: "A 方案被否决,改用 B", importance: 4 }
1280
- ]) };
1281
- yield { type: "finish", kind: "ok" };
1282
- })();
1283
- }
1284
- });
1285
- const handler = events.find((e) => e.name === "session/event").fn;
1286
- const session = {
1287
- id: "s11",
1288
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1289
- events: [userMessage("编码任务"), { seq: 2, type: "turn/end" }]
1290
- };
1291
- await handler(session, { seq: 2, type: "turn/end" });
1292
- assert.equal(store.count(), 1);
1293
- const m = store.all()[0];
1294
- assert.equal(m.type, "rejected_solution");
1295
- // 读取侧门控靠 m.type(rejected_solution/pitfall/constraint ∈ INJECT_TYPES),
1296
- // sanitizeTags 不认 `type:` 前缀,所以不给编码记忆打 tag(会清空 tags 列)。
1297
- assert.deepEqual(m.tags, []);
1298
- // 编码模式用编码 prompt(含 rejected_solution 类型说明)。
1299
- assert.ok(calls[0].messages[0].content[0].text.includes("rejected_solution"));
1300
- });
1301
-
1302
- // ── Issue #127:节流 / 条数上限 / 同会话去重 ────────────────────────────────
1303
-
1304
- /** A distill stream that always returns exactly these entries. */
1305
- function streamOf(entries) {
1306
- return () => (async function* () {
1307
- yield { type: "block-start", block: { type: "text" } };
1308
- yield { type: "text-delta", delta: JSON.stringify(entries) };
1309
- yield { type: "finish", kind: "ok" };
1310
- })();
1311
- }
1312
-
1313
- function sessionFor(id) {
1314
- return {
1315
- id,
1316
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1317
- events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
1318
- };
1319
- }
1320
-
1321
- test("issue#127: min-interval gate suppresses the second turn/end and audits it as skipped", async () => {
1322
- const { events, store, calls } = setup({ summarizeMinIntervalMinutes: 30 });
1323
- const handler = events.find((e) => e.name === "session/event").fn;
1324
- const session = sessionFor("t1");
1325
- await handler(session, { seq: 2, type: "turn/end" });
1326
- assert.equal(calls.length, 1, "the first turn/end distills");
1327
- assert.equal(store.count(), 2);
1328
- await handler(session, { seq: 3, type: "turn/end" });
1329
- assert.equal(calls.length, 1, "the second turn/end inside the window makes no LLM call");
1330
- assert.equal(store.count(), 2, "nothing new is stored");
1331
- // 可观测性:被节流不再静默——留一行 status='skipped',一条 SQL 可自查。
1332
- const audits = store.listLlmAudits({ source: "autoSummarize" });
1333
- assert.ok(
1334
- audits.some((a) => a.status === "skipped" && a.error_message === "min-interval"),
1335
- "the suppression is visible as a status='skipped' audit row"
1336
- );
1337
- });
1338
-
1339
- test("issue#127: min-interval 0 still distills every new event window", async () => {
1340
- const { events, calls } = setup({ summarizeMinIntervalMinutes: 0 });
1341
- const handler = events.find((e) => e.name === "session/event").fn;
1342
- const session = sessionFor("t2");
1343
- await handler(session, { seq: 2, type: "turn/end" });
1344
- session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
1345
- await handler(session, { seq: 4, type: "turn/end" });
1346
- assert.equal(calls.length, 2, "0 = no gate (zero behavior change)");
1347
- });
1348
-
1349
- test("issue#127: the interval gate is per-session (one session never throttles another)", async () => {
1350
- const { events, calls } = setup({ summarizeMinIntervalMinutes: 30 });
1351
- const handler = events.find((e) => e.name === "session/event").fn;
1352
- await handler(sessionFor("ta"), { seq: 2, type: "turn/end" });
1353
- await handler(sessionFor("tb"), { seq: 2, type: "turn/end" });
1354
- assert.equal(calls.length, 2, "a different session is not throttled by another's lastRunAt");
1355
- });
1356
-
1357
- test("issue#127: summarizeMaxEntriesPerRun caps the stored entries and audits parsed/capped", async () => {
1358
- const { events, store } = setup({ summarizeMaxEntriesPerRun: 1 }, {
1359
- stream: streamOf([
1360
- { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
1361
- { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
1362
- ])
1363
- });
1364
- const handler = events.find((e) => e.name === "session/event").fn;
1365
- await handler(sessionFor("t3"), { seq: 2, type: "turn/end" });
1366
- assert.equal(store.count(), 1, "only the first capped entry is stored");
1367
- const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
1368
- assert.equal(audit.metadata?.parsed, 2, "the raw parsed count is kept for tuning the cap");
1369
- assert.equal(audit.metadata?.capped, 1);
1370
- });
1371
-
1372
- test("issue#127: title dedupe absorbs a normalized-same title", async () => {
1373
- const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
1374
- stream: streamOf([{ type: "pitfall", title: "Win7 OpenSSH 失效", content: "第二次记录", importance: 4 }])
1375
- });
1376
- // 既有条目:同会话、标题归一化后全等(大小写与空白差异)
1377
- const seeded = service.saveWithDedupe({
1378
- type: "pitfall", title: "win7 openssh 失效", content: "第一次记录", source: "session:t5"
1379
- }).memory;
1380
- const handler = events.find((e) => e.name === "session/event").fn;
1381
- await handler(sessionFor("t5"), { seq: 2, type: "turn/end" });
1382
- assert.equal(store.count(), 1, "the normalized-same title merged instead of creating a row");
1383
- assert.ok(store.getById(seeded.id).content.includes("第二次记录"), "content appended to the existing row");
1384
- });
1385
-
1386
- test("issue#127: title dedupe leaves a rephrase alone (it only catches an exact same title)", async () => {
1387
- // 同一事实的另一种措辞 → title 档不命中,正常新建。这条界定了 title 档的边界,
1388
- // 避免被误当成 vector 档的替代(cos≥0.85 的簇里「归一化标题全同」为 0 个)。
1389
- const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
1390
- stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "新记录", importance: 4 }])
1391
- });
1392
- service.saveWithDedupe({
1393
- type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "旧记录", source: "session:other"
1394
- });
1395
- const handler = events.find((e) => e.name === "session/event").fn;
1396
- await handler(sessionFor("t7"), { seq: 2, type: "turn/end" });
1397
- assert.equal(store.count(), 2, "a rephrase (different title) is not merged by the title tier");
1398
- });
1399
-
1400
- test("issue#127: vector dedupe merges a same-fact rephrase and keeps content_history", async () => {
1401
- const embedder = {
1402
- ready: true,
1403
- embedSingle: async (text) => (String(text).includes("OpenSSH") ? [1, 0, 0] : [0, 1, 0])
1404
- };
1405
- const { events, store, service } = setup(
1406
- { summarizeDedupeMode: "vector", summarizeDedupeMinSim: 0.92 },
1407
- { stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "重复踩坑", importance: 4 }]) }
1408
- );
1409
- service.setEmbedder(embedder);
1410
- // 同一事实的另一种措辞,已有向量 → 余弦 1.0 ≥ 0.92
1411
- const seeded = service.saveWithDedupe({
1412
- type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "第一次记录", source: "session:t8"
1413
- }).memory;
1414
- store.setEmbedding(seeded.id, [1, 0, 0]);
1415
- const handler = events.find((e) => e.name === "session/event").fn;
1416
- await handler(sessionFor("t8"), { seq: 2, type: "turn/end" });
1417
- assert.equal(store.count(), 1, "no new row: the rephrase was absorbed by cosine");
1418
- const merged = store.getById(seeded.id);
1419
- assert.ok(merged.content.includes("重复踩坑"), "content appended into the existing row");
1420
- assert.ok((merged.content_history ?? []).length >= 1, "content_history keeps the prior version");
1421
- const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
1422
- assert.equal(audit.metadata?.deduped, 1);
1423
- assert.equal(audit.metadata?.mode, "vector");
1424
- });
1425
-
1426
- test("issue#127: no embedder means vector dedupe silently falls back to a normal write", async () => {
1427
- const { events, store } = setup(
1428
- { summarizeDedupeMode: "vector" },
1429
- { stream: streamOf([{ type: "pitfall", title: "无关的一条", content: "内容", importance: 4 }]) }
1430
- );
1431
- const handler = events.find((e) => e.name === "session/event").fn;
1432
- await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
1433
- assert.equal(store.count(), 1, "dedupe failure never blocks the write");
1434
- });
1435
-
1436
- test("issue#127: the five summarize knobs are whitelisted and range-checked", () => {
1437
- const store = createStore(":memory:");
1438
- const settings = createSettings(store.db);
1439
- const merged = settings.setFeatureFlags({
1440
- summarizeMinIntervalMinutes: 30,
1441
- summarizeMaxEntriesPerRun: 5,
1442
- summarizeDedupeMode: "vector",
1443
- summarizeDedupeMinSim: 0.9,
1444
- summarizeDedupeWindowHours: 12
1445
- });
1446
- assert.equal(merged.summarizeMinIntervalMinutes, 30);
1447
- assert.equal(merged.summarizeDedupeMode, "vector");
1448
- assert.equal(merged.summarizeDedupeMinSim, 0.9, "float thresholds round-trip (new numbers whitelist)");
1449
- // 面板/API 写入走同一套逐键校验:越界与非法枚举必须被拒。
1450
- assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMinSim: 0.3 }), /number in \[0.5, 0.99\]/);
1451
- assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMode: "semantic" }), /one of: off, title, vector/);
1452
- store.close();
1453
- });
1454
-
1455
- // ── Issue #127 补测:fail-safe 与打点回滚的边界路径 ────────────────────────
1456
-
1457
- test("issue#127: a throwing audit writer never breaks the min-interval skip path", async () => {
1458
- const { events, store, service } = setup({ summarizeMinIntervalMinutes: 30 });
1459
- const handler = events.find((e) => e.name === "session/event").fn;
1460
- const session = sessionFor("t10");
1461
- await handler(session, { seq: 2, type: "turn/end" });
1462
- assert.equal(store.count(), 2);
1463
- // 审计写入器抛错:跳过路径必须吞掉异常——审计是观测手段,绝不能反过来打断蒸馏。
1464
- service.saveLlmAudit = () => { throw new Error("audit sink down"); };
1465
- await handler(session, { seq: 3, type: "turn/end" });
1466
- assert.equal(store.count(), 2, "the throttled turn stays a no-op instead of rejecting");
1467
- });
1468
-
1469
- test("issue#127: an aborted run rolls the interval claim back so the next turn is admitted", async () => {
1470
- // 间隔取毫秒级小数值(schema 的粒度是 1 分钟,测试直接构造 config 绕开它),
1471
- // 这样"回滚到上一次打点"与"保留本次打点"才有可观察差异:
1472
- // 回滚 → 下一次落在 T1+gap 之外,应被接纳;
1473
- // 不滚 → 距本次打点才几毫秒,必被挡。
1474
- // 若沿用 gap=0,两种实现的对外行为完全相同,测试无法发现 else 分支写错。
1475
- let call = 0;
1476
- const { events } = setup({ summarizeMinIntervalMinutes: 0.01, distillRateLimitIntervalMs: 0 }, {
1477
- stream() {
1478
- call++;
1479
- if (call === 2) {
1480
- return (async function* () {
1481
- yield { type: "block-start", block: { type: "text" } };
1482
- yield { type: "text-delta", delta: "[]" };
1483
- yield { type: "finish", kind: "aborted" };
1484
- })();
1485
- }
1486
- return (async function* () {
1487
- yield { type: "block-start", block: { type: "text" } };
1488
- yield { type: "text-delta", delta: JSON.stringify([{ type: "decision", title: `第${call}条`, content: "内容", importance: 3 }]) };
1489
- yield { type: "finish", kind: "ok" };
1490
- })();
1491
- }
1492
- });
1493
- const handler = events.find((e) => e.name === "session/event").fn;
1494
- const session = sessionFor("t11");
1495
- await handler(session, { seq: 2, type: "turn/end" }); // T1:成功,占用间隔
1496
- assert.equal(call, 1, "the first turn distills");
1497
- await new Promise((r) => setTimeout(r, 700)); // 越过 600ms 间隔窗
1498
- session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
1499
- await handler(session, { seq: 4, type: "turn/end" }); // T2:aborted → 回滚到 T1
1500
- assert.equal(call, 2, "the aborted turn still reached the LLM call");
1501
- await handler(session, { seq: 4, type: "turn/end" }); // 立刻再发:只有回滚到 T1 才会被接纳
1502
- assert.equal(call, 3, "the aborted run did not consume the interval window");
1503
- });
1504
-
1505
- test("issue#127: a throwing candidate lookup degrades to undefined (service fail-safe)", async () => {
1506
- const { service } = setup({ summarizeDedupeMode: "vector" });
1507
- // 候选查询在 try 内抛错 → findSessionDuplicate 吞掉并返回 undefined(不向外抛)。
1508
- const hostile = { get type() { throw new Error("store down"); }, title: "标题", content: "内容" };
1509
- const hit = await service.findSessionDuplicate(hostile, { mode: "vector", source: "session:z" });
1510
- assert.equal(hit, undefined, "a failed lookup yields no duplicate instead of throwing");
1511
- });
1512
-
1513
- test("issue#127: a dedupe lookup that yields nothing still lands the entry (never dropped)", async () => {
1514
- // 走真实的 session/event 落库路径。只让去重判定不可用(service 层为何返回 undefined
1515
- // 由上一条覆盖)——蒸馏必须照常写入,不能因为拿不到去重结论就把这一条丢掉。
1516
- const { events, store, service } = setup(
1517
- { summarizeDedupeMode: "vector" },
1518
- { stream: streamOf([{ type: "decision", title: "必须落库", content: "内容", importance: 3 }]) }
1519
- );
1520
- service.findSessionDuplicate = async () => undefined;
1521
- const handler = events.find((e) => e.name === "session/event").fn;
1522
- await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
1523
- assert.equal(store.count(), 1, "the entry is stored through the normal write path");
1524
- assert.equal(store.all()[0].title, "必须落库");
1525
- });
1526
-
1527
- test("parseSummaryJson salvage only takes direct children of the outer array (issue #339 review)", () => {
1528
- // 嵌套子数组里的对象不捞(模型没把它当顶层记忆产出),坏对象照旧丢弃。
1529
- const nested = `[[{"type":"history","title":"nested","content":"kept"}], { " "type":"history"}]`;
1530
- assert.deepEqual(parseSummaryJson(nested), []);
1531
- });
1532
-
1533
- test("parseSummaryJson salvage does not turn string-embedded objects into memories (issue #339 review)", () => {
1534
- // 全数组解析失败(第二对象坏)+ 第一对象的 content 字符串里嵌着假记忆形状:
1535
- // 盲扫不得把属性值对象当独立记忆——外层对象整体才是直接子项。
1536
- const raw = `[
1537
- {"type":"history","title":"good","content":"note { \\"type\\": \\"history\\", \\"title\\": \\"fake\\" }"},
1538
- { " "type": "history", "title": "坏", "content": "x" }
1539
- ]`;
1540
- const parsed = parseSummaryJson(raw);
1541
- assert.equal(parsed.length, 1);
1542
- assert.equal(parsed[0].title, "good");
1543
- assert.match(parsed[0].content, /fake/);
1544
- });
1545
-
1546
- // ── Issue #399:蒸馏窗口原始记录 + 记忆级溯源 ─────────────────────────────
1547
- //
1548
- // 这五条锁的是「把蒸馏从替换降级为叠加索引」的四个承重点:重跑幂等、隐私谓词
1549
- // 在落盘路径同样成立、留存不受喂给模型的裁剪约束、裁剪不毁资产、默认关零写入。
1550
- // 锁形状而非锁实现:断言直接查表,不去读 summarize.js 的中间变量。
1551
-
1552
- /** Issue #399 的开启档 + 关掉真实节流(否则同会话第二轮会被间隔门挡下)。 */
1553
- const RECORDS_ON = { distillRecords: { enabled: true }, distillRateLimitIntervalMs: 0 };
1554
-
1555
- function countRows(store, sql) {
1556
- return store.db.prepare(sql).get().c;
1557
- }
1558
-
1559
- function recordsSession(id, events) {
1560
- return {
1561
- id,
1562
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1563
- events
1564
- };
1565
- }
1566
-
1567
- function eventHandler(events) {
1568
- return events.find((e) => e.name === "session/event").fn;
1569
- }
1570
-
1571
- test("issue#399: re-distilling the same window never duplicates records", async () => {
1572
- const first = setup(RECORDS_ON);
1573
- const session = recordsSession("s399-idem", [userMessage("同一窗口的提问", 1), { seq: 2, type: "turn/end" }]);
1574
- await eventHandler(first.events)(session, { seq: 2, type: "turn/end" });
1575
- assert.equal(countRows(first.store, "SELECT count(*) AS c FROM distill_records"), 1);
1576
-
1577
- // 模拟宿主重启后重跑同一 seq 段(代码注释里点名的路径:max-runs 耗尽额度、游标
1578
- // 写入随事务回滚后丢失):删掉持久化游标 + 换一个全新的 summarizer 实例,于是
1579
- // lastDistilledSeq 这份内存游标也一并归零,同一窗口会被完整重蒸一遍。
1580
- first.store.db.prepare("DELETE FROM distill_cursors").run();
1581
- const second = setup(RECORDS_ON, { store: first.store });
1582
- await eventHandler(second.events)(session, { seq: 2, type: "turn/end" });
1583
-
1584
- assert.equal(
1585
- countRows(first.store, "SELECT count(*) AS c FROM distill_records"),
1586
- 1,
1587
- "同一 (session_id, seq) 只留一行:重跑必须幂等,保留首次落盘时间才诚实"
1588
- );
1589
- assert.equal(
1590
- countRows(
1591
- first.store,
1592
- `SELECT count(*) AS c FROM (
1593
- SELECT memory_id, session_id, seq_start, seq_end FROM memory_provenance
1594
- GROUP BY memory_id, session_id, seq_start, seq_end HAVING count(*) > 1)`
1595
- ),
1596
- 0,
1597
- "同一窗口重跑不重复挂链接(唯一索引 + ON CONFLICT DO NOTHING)"
1598
- );
1599
- });
1600
-
1601
- test("issue#399: records keep only public transcript; reasoning and machine-injected content never land on disk", async () => {
1602
- const { events, store, calls } = setup(RECORDS_ON);
1603
- const session = recordsSession("s399-priv", [
1604
- userMessage("真实提问:这个报错怎么修", 1),
1605
- // 机器注入的三类:AGENTS.md / skill 正文 / 文件变更通知。source.kind 不是
1606
- // user / agent-message / subagent-settled,必须整个事件被挡在门外。
1607
- { seq: 2, type: "user/message", data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 注入正文不该落盘" }] } },
1608
- { seq: 3, type: "user/message", data: { source: { kind: "skill" }, content: [{ type: "text", text: "SKILL_BODY 不该落盘" }] } },
1609
- { seq: 4, type: "user/message", data: { source: { kind: "file-change" }, content: [{ type: "text", text: "FILE_CHANGE 通知不该落盘" }] } },
1610
- {
1611
- seq: 5,
1612
- type: "assistant/message",
1613
- data: { message: { content: [
1614
- { type: "reasoning", text: "PRIVATE_REASONING 不该落盘" },
1615
- { type: "text", text: "公开回答:检查第 3 行。" }
1616
- ] } }
1617
- },
1618
- toolResultEvent("TOOL_OUTPUT 只进渲染不落盘", false, 6),
1619
- { seq: 7, type: "turn/end" }
1620
- ]);
1621
- await eventHandler(events)(session, { seq: 7, type: "turn/end" });
1622
-
1623
- const texts = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all().map((r) => r.text).join("\n");
1624
- assert.ok(texts.includes("真实提问"), "用户提示落盘");
1625
- assert.ok(texts.includes("公开回答"), "助手公开正文落盘");
1626
- assert.ok(!texts.includes("AGENTS.md"), "插件注入的 AGENTS.md 绝不落盘");
1627
- assert.ok(!texts.includes("SKILL_BODY"), "skill 正文绝不落盘");
1628
- assert.ok(!texts.includes("FILE_CHANGE"), "文件变更通知绝不落盘");
1629
- assert.ok(!texts.includes("PRIVATE_REASONING"), "私有推理链绝不落盘");
1630
- assert.ok(!texts.includes("TOOL_OUTPUT"), "工具结果不落盘(#399 已知边界 3)");
1631
- // 渲染路径照旧带工具输出(codingRetrospect 靠它沉淀踩坑根因)——两条路径在
1632
- // 「工具输出」上的差异是设计的一部分,不是漂移:漂移的方向必须是「落盘的更少」。
1633
- assert.ok(JSON.stringify(calls[0].messages).includes("TOOL_OUTPUT"), "工具输出仍进 LLM 转录");
1634
- });
1635
-
1636
- test("issue#399: records keep the untruncated prompt while the LLM transcript stays capped", async () => {
1637
- // 头部标记 + 超长正文:trimTranscript 保尾部,所以「渲染里找不到头部标记」正是
1638
- // 被 distillMaxChars 截断的证据;记录里必须连头部标记都在。
1639
- const long = `HEAD_MARKER ${"x".repeat(1200)} TAIL_MARKER`;
1640
- const { events, store, calls } = setup({ ...RECORDS_ON, distillMaxChars: 200 });
1641
- const session = recordsSession("s399-long", [userMessage(long, 1), { seq: 2, type: "turn/end" }]);
1642
- await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1643
-
1644
- const rows = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all();
1645
- assert.equal(rows.length, 1);
1646
- assert.equal(rows[0].text, long, "留存的是整段原文,不截断、不加省略号");
1647
- assert.ok(!JSON.stringify(calls[0].messages).includes("HEAD_MARKER"), "喂 LLM 的转录仍受 distillMaxChars 约束");
1648
- });
1649
-
1650
- test("issue#399: expiring records keeps the memories and leaves provenance dangling-but-present", async () => {
1651
- const { events, store } = setup(RECORDS_ON);
1652
- const session = recordsSession("s399-expire", [userMessage("会被裁剪的原文", 1), { seq: 2, type: "turn/end" }]);
1653
- await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1654
- assert.equal(store.count(), 2);
1655
- assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2);
1656
-
1657
- // 到期裁剪:把 cutoff 推到未来,等价于「这条记录已经过期」。
1658
- assert.equal(store.clearDistillRecords(new Date(Date.now() + 86400000).toISOString()), 1);
1659
- assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0);
1660
- assert.equal(store.count(), 2, "裁剪原文绝不级联删记忆");
1661
- assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2, "链接保留,从此指向空区间");
1662
- const memoryId = store.all()[0].id;
1663
- assert.ok(store.provenanceFor(memoryId).length >= 1, "面板/CLI 仍能回答「这条记忆出自哪个窗口」,只是来源已过期");
1664
- });
1665
-
1666
- // ── Issue #512:蒸馏归因补 run 概念 ────────────────────────────────────────
1667
- //
1668
- // 这四条锁的是「这条记忆是哪一次蒸馏带进来的」这条问句现在回答得出来:
1669
- // ① 一轮真蒸馏落一行 distill_runs,链接上带同一个 distill_run_id;
1670
- // ② run 与它的开销读数(llm_audit_logs)靠 audit_id 直连,不靠时间戳猜;
1671
- // ③ llmAudit 关掉时 run 仍然成立(归因不该依赖计费开关),只是没有 audit_id;
1672
- // ④ 失败轮也留一行——「那一轮跑了但没成」本身就是归因要回答的事。
1673
- // 反面(历史链接保持 NULL、不回填)在 test/store.test.js 的迁移用例里锁。
1674
-
1675
- test("issue#512: a distilled window records one run, and every link points at it", async () => {
1676
- const { events, store } = setup(RECORDS_ON);
1677
- const session = recordsSession("s512-ok", [userMessage("这轮要能追到 run", 1), { seq: 2, type: "turn/end" }]);
1678
- await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1679
-
1680
- const runs = store.listDistillRuns();
1681
- assert.equal(runs.length, 1, "一轮真蒸馏就是一行 run");
1682
- const run = runs[0];
1683
- assert.equal(run.status, "ok");
1684
- assert.equal(run.run_type, "autoSummarize");
1685
- assert.equal(run.trigger_source, "autoSummarize");
1686
- assert.equal(run.session_id, "s512-ok");
1687
- assert.deepEqual([run.seq_start, run.seq_end], [1, 2], "run 记的是这一步真正蒸馏掉的窗口");
1688
- assert.equal(run.memory_count, 2, "记忆条数是实际写成功的条数");
1689
-
1690
- const links = store.db.prepare("SELECT memory_id, distill_run_id FROM memory_provenance").all();
1691
- assert.equal(links.length, 2);
1692
- for (const link of links) {
1693
- assert.equal(link.distill_run_id, run.id, "每条记忆的链接都指向这一轮,不是 NULL、也不是别的轮");
1694
- }
1695
- });
1696
-
1697
- test("issue#512: the run and its spend row are joined by audit_id, not guessed from timestamps", async () => {
1698
- const { events, store } = setup(RECORDS_ON);
1699
- const session = recordsSession("s512-audit", [userMessage("归因要连到开销行", 1), { seq: 2, type: "turn/end" }]);
1700
- await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1701
-
1702
- const run = store.listDistillRuns()[0];
1703
- const audits = store.listLlmAudits({ source: "autoSummarize" });
1704
- assert.equal(audits.length, 1);
1705
- assert.equal(run.audit_id, audits[0].id, "run 直接指着它自己那一行,不靠时间窗匹配");
1706
- // 反向也成立:从 run 拿到的 id 读回来就是那一条。
1707
- const join = store.getDistillRun(run.id);
1708
- assert.equal(join.audit_id, audits[0].id);
1709
- });
1710
-
1711
- test("issue#512: with llmAudit off the run still lands — attribution must not depend on the spend switch", async () => {
1712
- const { events, store } = setup({ ...RECORDS_ON, llmAudit: { enabled: false } });
1713
- const session = recordsSession("s512-noaudit", [userMessage("关了计费也要能归因", 1), { seq: 2, type: "turn/end" }]);
1714
- await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1715
-
1716
- assert.equal(store.listLlmAudits().length, 0, "开关关着,一行 audit 都不写");
1717
- const runs = store.listDistillRuns();
1718
- assert.equal(runs.length, 1, "但 run 照落");
1719
- assert.equal(runs[0].status, "ok");
1720
- assert.equal(runs[0].audit_id, undefined, "没有开销行时这一列是空的——不填假 id");
1721
- });
1722
-
1723
- test("issue#512: a failed round still leaves a run row saying it failed", async () => {
1724
- // 流失败:窗口保留待重试,但「跑过一轮且失败」这件事必须留痕,否则归因只记得住成的。
1725
- const { events, store } = setup(RECORDS_ON, {
1726
- stream: () => (async function* () {
1727
- yield { type: "block-start", block: { type: "text" } };
1728
- yield { type: "finish", kind: "error", error: { message: "upstream exploded" } };
1729
- })()
1730
- });
1731
- const session = recordsSession("s512-fail", [userMessage("这轮会失败", 1), { seq: 2, type: "turn/end" }]);
1732
- await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1733
-
1734
- const runs = store.listDistillRuns();
1735
- assert.equal(runs.length, 1, "失败轮也是一轮");
1736
- assert.equal(runs[0].status, "failed");
1737
- assert.ok(runs[0].error, "失败要有原因,空错误等于没说");
1738
- assert.equal(runs[0].memory_count, 0);
1739
- assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0, "失败轮不落原文");
1740
- });
1741
-
1742
- test("issue#399: enabled=false (the default) writes nothing at all", async () => {
1743
- const { events, store, service } = setup({ distillRateLimitIntervalMs: 0 });
1744
- let saverCalls = 0;
1745
- const original = service.saveDistillRecords;
1746
- service.saveDistillRecords = (...args) => {
1747
- saverCalls++;
1748
- return original(...args);
1749
- };
1750
- const session = recordsSession("s399-off", [userMessage("默认关不该留原文", 1), { seq: 2, type: "turn/end" }]);
1751
- await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1752
-
1753
- assert.equal(saverCalls, 0, "默认关:连收集都不做,一次写入调用都不发");
1754
- assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0);
1755
- assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 0);
1756
- assert.equal(store.count(), 2, "记忆写入照旧");
1757
- });
1758
- // ── 蒸馏留存:开关从「设置面」一路走到「盘上有行」 ──────────────────────────────
1759
- //
1760
- // 上面 #399 那五条锁的是 summarize.js 内部的行为,它们把 config 直接递给
1761
- // createSummarizer。真实链路上,config 不来自测试也不来自 bundle:它由 index.js
1762
- // 在**启动时**把 kv(feature_flags,面板 PUT /api/dsh-mneme/features 写的就是它)
1763
- // 合并进 cfg 得到,再交给 createSummarizer。中间两环——设置面写入与启动期
1764
- // 合并——一条测试都没覆盖过,而它们正是「distill_records 行数开始增长」这条
1765
- // 验收判据的唯一通路:开关没写进 kv,或者写进了但装配时没被读出来,盘上就永远
1766
- // 是 0 行,而 summarize.js 内部的五条锁照样全绿。
1767
- //
1768
- // 所以这两条不在 createSummarizer 层造 config,而是起一个最小宿主(真实 cordis
1769
- // Context + 六个服务位),让 ctx.plugin(mneme, …) 走完 index.js 的装配:
1770
- // ① kv 写开 → 装配 → 派发 turn/end → 原文与溯源链接都落盘;
1771
- // ② 同一个装配但 kv 一个键都不写(默认态)→ 两表 0 行,记忆写入照旧。
1772
- // 第二条守的是反面:装配默认不许把 opt-in 的开关私自打开。
1773
-
1774
- /** 最小宿主:六个服务位按 webServer-optional.test.js 的形状提供。 */
1775
- function minimalHost() {
1776
- const ctx = new Context();
1777
- ctx.provide("tools", { register() { return () => {}; } });
1778
- ctx.provide("commands", { register() { return () => {}; } });
1779
- ctx.provide("systemPrompt", { context() { return () => {}; } });
1780
- ctx.provide("agentDefaultModel", { currentSelection() { return { provider: "mock", model: "mock" }; } });
1781
- ctx.provide("llm", {
1782
- async *stream() {
1783
- yield { type: "block-start", block: { type: "text" } };
1784
- yield {
1785
- type: "text-delta",
1786
- delta: JSON.stringify([
1787
- { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
1788
- { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
1789
- ])
1790
- };
1791
- yield { type: "block-end", block: { type: "text" } };
1792
- yield { type: "finish", kind: "ok" };
1793
- }
1794
- });
1795
- ctx.provide("webServer", { register() { return () => {}; } });
1796
- return ctx;
1797
- }
1798
-
1799
- /** 盘上的三张表:装配前后各查一次,才知道行是谁写的。 */
1800
- function readTables(dbPath) {
1801
- const db = new DatabaseSync(dbPath);
1802
- const count = (table) => db.prepare(`SELECT count(*) AS c FROM ${table}`).get().c;
1803
- const out = {
1804
- records: count("distill_records"),
1805
- provenance: count("memory_provenance"),
1806
- memories: count("memories")
1807
- };
1808
- out.rows = db.prepare("SELECT session_id, seq, role, text FROM distill_records ORDER BY seq").all();
1809
- db.close();
1810
- return out;
1811
- }
1812
-
1813
- /** 装配 + 派发 turn/end + 拆掉装配(不给宿主留开着的手柄)。 */
1814
- async function bootAndDistill(config, session) {
1815
- const ctx = minimalHost();
1816
- const fiber = ctx.plugin(mneme, config);
1817
- await fiber;
1818
- // ctx.parallel 会等所有监听器跑完;summarize.js 的监听器 return 了这次写入的
1819
- // promise(summarize.js 的 session/event 订阅处明写「Return the summarization
1820
- // promise so awaiters observe the writes」),所以并行派发完就是写完了。
1821
- await ctx.parallel("session/event", session, { seq: 2, type: "turn/end" });
1822
- await fiber.dispose();
1823
- }
1824
-
1825
- test("distill records grow from the settings face all the way to disk", async () => {
1826
- const dir = mkdtempSync(join(tmpdir(), "mneme-distill-on-"));
1827
- const dbPath = join(dir, "memory.db");
1828
- try {
1829
- // 第一环:用产品自己的设置面写 kv,等价于面板 PUT /api/dsh-mneme/features。
1830
- const seed = createStore(dbPath);
1831
- const merged = createSettings(seed.db).setFeatureFlags({ "distillRecords.enabled": true });
1832
- assert.equal(merged["distillRecords.enabled"], true, "开关必须先真的落进 kv");
1833
- assert.equal(readTables(dbPath).records, 0, "开之前盘上一行都没有");
1834
- seed.close();
1835
-
1836
- // 第二环 + 第三环:装配时 bundle 说关、kv 说开,合并取 kv;然后真跑一次蒸馏。
1837
- await bootAndDistill(
1838
- { memoryDir: dir, distillRecords: { enabled: false }, distillRateLimitIntervalMs: 0 },
1839
- recordsSession("s-distill-on", [userMessage("这句话该落进 distill_records", 1), { seq: 2, type: "turn/end" }])
1840
- );
1841
-
1842
- const after = readTables(dbPath);
1843
- assert.equal(after.records, 1, "装配读过 kv 之后,原文必须真的落盘");
1844
- assert.equal(after.rows[0].session_id, "s-distill-on");
1845
- assert.equal(after.rows[0].seq, 1);
1846
- assert.equal(after.rows[0].role, "user");
1847
- assert.match(after.rows[0].text, /该落进 distill_records/, "落盘的是公开正文原文");
1848
- assert.equal(after.memories, 2, "记忆照常写入,留存是叠加不是替换");
1849
- assert.equal(after.provenance, 2, "每条新记忆各落一条回指链接");
1850
- } finally {
1851
- rmSync(dir, { recursive: true, force: true });
1852
- }
1853
- });
1854
-
1855
- test("the same assembly writes nothing when the flag was never set", async () => {
1856
- const dir = mkdtempSync(join(tmpdir(), "mneme-distill-off-"));
1857
- const dbPath = join(dir, "memory.db");
1858
- try {
1859
- // 连 createStore 都不预先起:一个全新的库就是「从没碰过设置面」的真实形态。
1860
- await bootAndDistill(
1861
- { memoryDir: dir, distillRateLimitIntervalMs: 0 },
1862
- recordsSession("s-distill-off", [userMessage("默认态:不该有原文落盘", 1), { seq: 2, type: "turn/end" }])
1863
- );
1864
-
1865
- const after = readTables(dbPath);
1866
- assert.equal(after.records, 0, "opt-in 的开关不许被装配私自打开");
1867
- assert.equal(after.provenance, 0);
1868
- assert.equal(after.memories, 2, "关的只是留存,蒸馏本身照常");
1869
- } finally {
1870
- rmSync(dir, { recursive: true, force: true });
1871
- }
1872
- });
1873
-
1874
-
1875
- // v0.9.0(M.O. 项目记忆):机器提炼是写入量的绝大多数,而它此前一个作用域字段都不带
1876
- // ——全库带 scope 的行不足 3%,且全部来自 memory_save 那条工具路径。这组用例钉住
1877
- // 「蒸馏写入也盖章」这件事:值取 session.header(agentPreset + cwd),闸门与工具路径
1878
- // 同一把(scopeEnabled),来源一律 'auto'(auto 不构成硬墙,只吃软加权,见
1879
- // test/scope-strict.test.js 的 "auto and legacy rows are NOT hard walls" 用例,
1880
- // 2026-10-13 在 :41-54)。
1881
- function scopedSession(id, header) {
1882
- const session = {
1883
- id,
1884
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1885
- events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
1886
- };
1887
- if (header !== undefined) session.header = header;
1888
- return session;
1889
- }
1890
-
1891
- test("v0.9.0: scopeEnabled 开时蒸馏写入盖 auto 章(agent=agentPreset、workspace=header.cwd)", async () => {
1892
- const { events, store } = setup({ scopeEnabled: true });
1893
- const session = scopedSession("mo-scope", { agentPreset: "amarre", cwd: "C:\\work\\dsh-mneme" });
1894
- await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1895
-
1896
- const rows = store.list({ limit: null });
1897
- assert.equal(rows.length, 2, "两个条目都落库");
1898
- for (const row of rows) {
1899
- assert.equal(row.agent_scope, "amarre", "agent 维取自 session.header.agentPreset");
1900
- assert.equal(row.agent_scope_source, "auto", "来源必须标 auto:显式声明才会构成硬过滤");
1901
- assert.equal(row.workspace_scope, "C:\\work\\dsh-mneme", "workspace 维取自 session.header.cwd");
1902
- assert.equal(row.workspace_scope_source, "auto");
1903
- }
1904
- });
1905
-
1906
- test("v0.9.0: 解析不出身份时照样标 auto、值落 NULL(与「从未标注」可区分)", async () => {
1907
- const { events, store } = setup({ scopeEnabled: true });
1908
- // 无 header、无 workspaceRegistry(mock ctx 只有 on/llm):正是老会话/测试环境的形状。
1909
- await eventHandler(events)(scopedSession("mo-scope-anon"), { seq: 2, type: "turn/end" });
1910
-
1911
- for (const row of store.list({ limit: null })) {
1912
- assert.equal(row.agent_scope, undefined);
1913
- assert.equal(row.workspace_scope, undefined);
1914
- // 关键区别:*_source='auto' 说明「自动标注跑过但没解析出值」,NULL 值才等于全局可见。
1915
- assert.equal(row.agent_scope_source, "auto");
1916
- assert.equal(row.workspace_scope_source, "auto");
1917
- }
1918
- });
1919
-
1920
- test("v0.9.0: scopeEnabled 关时蒸馏写入一个 scope 字段都不加(老行为不变)", async () => {
1921
- const { events, store } = setup({});
1922
- await eventHandler(events)(scopedSession("mo-scope-off", { agentPreset: "amarre", cwd: "C:\\work\\dsh-mneme" }), { seq: 2, type: "turn/end" });
1923
-
1924
- for (const row of store.list({ limit: null })) {
1925
- assert.equal(row.agent_scope, undefined);
1926
- assert.equal(row.agent_scope_source, undefined);
1927
- assert.equal(row.workspace_scope, undefined);
1928
- assert.equal(row.workspace_scope_source, undefined);
1929
- }
1930
- });
1931
-
1932
- // --- Issue #493:转录围栏 / 失败护栏 / 失败行可诊断性 ------------------------------
1933
- // 现场:转录裸放 user 位 → 模型当成「该我回话了」接着往下写 → 输出无 `[` →
1934
- // 解析器括号早退判失败 → 不推进游标 → 下一个 turn/end 同文同温重试 → 同一窗口
1935
- // 确定性失败、窗口越攒越大(报告者 20 条失败 input 8194→11118 单调递增)。
1936
-
1937
- test("#493 提示构造:转录包进围栏,user 侧重申「第三方提炼员」,system 不动", async () => {
1938
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1939
- await eventHandler(events)(
1940
- {
1941
- id: "s493-prompt",
1942
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1943
- events: [userMessage("把这段对话蒸馏一下", 1), { seq: 2, type: "turn/end" }]
1944
- },
1945
- { seq: 2, type: "turn/end" }
1946
- );
1947
-
1948
- const [system, user] = calls[0].messages;
1949
- assert.equal(system.role, "system");
1950
- assert.ok(system.content[0].text.includes("原子记忆"), "system 提示词保持原样(既有回归断言依赖它)");
1951
- assert.equal(user.role, "user");
1952
- const body = user.content[0].text;
1953
- // 角色句与围栏都必须落在 user 侧:模型对「这段文本是什么」的判断由它所在的
1954
- // 那条消息决定,只写进 system 救不回来(实测同段转录只加 system 约束仍 0/2 出 JSON)。
1955
- assert.ok(body.includes("第三方提炼员"), "user 侧必须重申角色,否则模型接着对话往下写");
1956
- assert.ok(body.includes("不是下面这段会话的参与者"), "角色句要明说不参与,不能只说「请输出 JSON」");
1957
- const open = body.indexOf("<<<会话转录开始>>>");
1958
- const close = body.indexOf("<<<会话转录结束>>>");
1959
- assert.ok(open > 0 && close > open, "转录必须被围栏包住");
1960
- assert.ok(body.slice(open, close).includes("把这段对话蒸馏一下"), "原文仍在围栏内");
1961
- assert.ok(body.indexOf("第三方提炼员") < open, "角色句在围栏之前");
1962
- });
1963
-
1964
- test("#493 提示构造:围栏是确定性的,重试收到的转录与首次逐字节相同", async () => {
1965
- let attempt = 0;
1966
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
1967
- stream() {
1968
- const output = attempt++ === 0 ? "不按格式的一整段中文点评" : "[]";
1969
- return (async function* () {
1970
- yield { type: "block-start", block: { type: "text" } };
1971
- yield { type: "text-delta", delta: output };
1972
- yield { type: "finish", kind: "ok" };
1973
- })();
1974
- }
1975
- });
1976
- const handler = eventHandler(events);
1977
- const session = {
1978
- id: "s493-deterministic",
1979
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1980
- events: [userMessage("同一窗口重试", 1), { seq: 2, type: "turn/end" }]
1981
- };
1982
- await handler(session, { seq: 2, type: "turn/end" });
1983
- await handler(session, { seq: 2, type: "turn/end" });
1984
- assert.equal(calls.length, 2);
1985
- const first = calls[0].messages.find((m) => m.role === "user").content[0].text;
1986
- const second = calls[1].messages.find((m) => m.role === "user").content[0].text;
1987
- // 围栏里不能有时间戳/随机数:否则「同文同温」这条前提就不成立,护栏阈值
1988
- // 会变成对着一堆各不相同的输入计数。
1989
- assert.equal(first, second, "重试必须收到逐字节相同的转录");
1990
- });
1991
-
1992
- test("#493 计量口径:distillChars 只数转录正文,不被围栏与角色句撑大", async () => {
1993
- // 这条是护栏的邻居而非护栏本身:#493 给 user 消息加了约 100 字的固定包裹,
1994
- // 若 distillChars 仍按「消息总字符数」算,4 字的窗口会凭空越过任何阈值,
1995
- // summarizeMinWindowChars 等于被自己的提示词旁路掉。
1996
- const { events, service } = setup({ summarizeMinWindowChars: 60, distillRateLimitIntervalMs: 0 });
1997
- await eventHandler(events)(
1998
- {
1999
- id: "s493-metric",
2000
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2001
- events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
2002
- },
2003
- { seq: 2, type: "turn/end" }
2004
- );
2005
- const row = service.listLlmAudits()[0];
2006
- assert.equal(row.error_message, "window-too-small", "4 字正文不得被包裹撑过 60 字阈值");
2007
- assert.equal(row.metadata.distillChars, 4, "计的是转录正文(「用户:短」= 4 字)");
2008
- });
2009
-
2010
- test("#493 失败行可诊断:metadata 带 raw_head / raw_has_bracket / streak", async () => {
2011
- const { events, service } = setup({ distillRateLimitIntervalMs: 0 }, {
2012
- stream() {
2013
- return (async function* () {
2014
- yield { type: "block-start", block: { type: "text" } };
2015
- yield { type: "text-delta", delta: "已按你的要求将值班轮单日上限改为 5,下面是具体改动…" };
2016
- yield { type: "finish", kind: "ok" };
2017
- })();
2018
- }
2019
- });
2020
- await eventHandler(events)(
2021
- {
2022
- id: "s493-diag",
2023
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2024
- events: [userMessage("帮我改一下值班轮上限", 1), { seq: 2, type: "turn/end" }]
2025
- },
2026
- { seq: 2, type: "turn/end" }
2027
- );
2028
-
2029
- const row = service.listLlmAudits()[0];
2030
- assert.equal(row.status, "error");
2031
- assert.equal(row.error_message, "invalid summary JSON");
2032
- // 原先 metadata 恒为 null,90 条历史失败一条原始输出都没留 —— 事后无法回答
2033
- // 「模型到底吐了什么形状」。raw_has_bracket 是区分「没按格式写」与「格式对但
2034
- // 内容救不回来」的唯一低成本判据,也正是 #493 现场的关键特征。
2035
- assert.equal(row.metadata?.parse_failure, true);
2036
- assert.equal(row.metadata?.raw_has_bracket, false, "这段点评里没有方括号");
2037
- assert.ok(row.metadata?.raw_len > 0);
2038
- assert.ok(row.metadata?.raw_head.startsWith("已按你的要求"), "原始输出前缀要留档");
2039
- assert.equal(row.metadata?.streak, 1);
2040
- assert.ok(Number.isInteger(row.metadata?.distillChars), "失败行同样带窗口字符数");
2041
- });
2042
-
2043
- test("#493 失败护栏默认关:连续失败也不放弃窗口(历史行为逐字节不变)", async () => {
2044
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
2045
- stream() {
2046
- return (async function* () {
2047
- yield { type: "block-start", block: { type: "text" } };
2048
- yield { type: "text-delta", delta: "永远不是 JSON" };
2049
- yield { type: "finish", kind: "ok" };
2050
- })();
2051
- }
2052
- });
2053
- const handler = eventHandler(events);
2054
- const session = {
2055
- id: "s493-guard-off",
2056
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2057
- events: [userMessage("窗口", 1), { seq: 2, type: "turn/end" }]
2058
- };
2059
- for (let i = 0; i < 5; i++) await handler(session, { seq: 2, type: "turn/end" });
2060
- // 默认 0 = 关:这是本仓新行为的既定纪律(AGENTS.md),也是既有 4 个
2061
- // 「解析失败不消费窗口」用例语义不变的前提。
2062
- assert.equal(calls.length, 5, "护栏关着时每个 turn/end 仍重试同一窗口");
2063
- });
2064
-
2065
- test("#493 失败护栏:连续 N 次失败后强制消费游标跳过该窗口", async () => {
2066
- let attempt = 0;
2067
- const { events, calls, service, store, summarizer } = setup(
2068
- { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2069
- {
2070
- stream() {
2071
- attempt++;
2072
- const output = attempt <= 2 ? "确定性失败:一段中文点评" : "[]";
2073
- return (async function* () {
2074
- yield { type: "block-start", block: { type: "text" } };
2075
- yield { type: "text-delta", delta: output };
2076
- yield { type: "finish", kind: "ok" };
2077
- })();
2078
- }
2079
- }
2080
- );
2081
- const handler = eventHandler(events);
2082
- const session = {
2083
- id: "s493-guard-on",
2084
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2085
- events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }]
2086
- };
2087
-
2088
- await handler(session, { seq: 2, type: "turn/end" });
2089
- assert.equal(calls.length, 1);
2090
- assert.equal(service.getDistillCursor("s493-guard-on"), undefined, "第一次失败仍不推进游标");
2091
-
2092
- await handler(session, { seq: 2, type: "turn/end" });
2093
- assert.equal(calls.length, 2, "阈值内仍重试");
2094
- // 达到阈值(2):窗口被放弃,游标推进到本窗末尾,下一轮从全新窗口起算。
2095
- const cursor = service.getDistillCursor("s493-guard-on");
2096
- assert.equal(cursor?.last_seq, 2, "达到阈值后强制消费游标");
2097
- const errorRows = service.listLlmAudits().filter((r) => r.status === "error");
2098
- assert.equal(errorRows.length, 2);
2099
- // 按 streak 定位而不是按下标:审计表的返回顺序是存储层的事,不该被这条断言锁住。
2100
- const abandoned = errorRows.find((r) => r.metadata?.streak === 2);
2101
- assert.equal(abandoned?.metadata?.skipped, "parse-failure-streak", "放弃必须可观测");
2102
- assert.equal(abandoned?.metadata?.maxRetries, 2);
2103
- assert.equal(errorRows.find((r) => r.metadata?.streak === 1)?.metadata?.skipped, undefined,
2104
- "阈值前的那次失败仍按「可重试」记账,不得提前宣告放弃");
2105
-
2106
- // 游标已消费 → 同一 turn/end 不再重试;新事件开新窗口并正常落库。
2107
- await handler(session, { seq: 2, type: "turn/end" });
2108
- assert.equal(calls.length, 2, "被放弃的窗口不再重试");
2109
- session.events.push(userMessage("新的窗口", 3), { seq: 4, type: "turn/end" });
2110
- await handler(session, { seq: 4, type: "turn/end" });
2111
- assert.equal(calls.length, 3, "新窗口正常蒸馏");
2112
- summarizer.dispose();
2113
- store.close();
2114
- });
2115
-
2116
- test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", async () => {
2117
- let attempt = 0;
2118
- const { events, calls, service, store, summarizer } = setup(
2119
- { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2120
- {
2121
- stream() {
2122
- // 失败 → 成功 → 失败:第 3 次是「新一轮的第一次失败」,不该被判为第 2 次。
2123
- const outputs = ["不是 JSON", "[]", "又不是 JSON"];
2124
- const output = outputs[attempt++] ?? "[]";
2125
- return (async function* () {
2126
- yield { type: "block-start", block: { type: "text" } };
2127
- yield { type: "text-delta", delta: output };
2128
- yield { type: "finish", kind: "ok" };
2129
- })();
2130
- }
2131
- }
2132
- );
2133
- const handler = eventHandler(events);
2134
- const session = {
2135
- id: "s493-streak-reset",
2136
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2137
- events: [userMessage("第一窗", 1), { seq: 2, type: "turn/end" }]
2138
- };
2139
- await handler(session, { seq: 2, type: "turn/end" }); // 失败 #1
2140
- await handler(session, { seq: 2, type: "turn/end" }); // 成功 → 清零
2141
- session.events.push(userMessage("第二窗", 3), { seq: 4, type: "turn/end" });
2142
- await handler(session, { seq: 4, type: "turn/end" }); // 失败 #1(不是 #2)
2143
-
2144
- // 两条失败行都必须是「新一轮的第一次」:只查最后一条会让「第一条其实被
2145
- // 记成 streak=2」这类回归漏网。
2146
- const errorRows = service.listLlmAudits().filter((r) => r.status === "error");
2147
- assert.equal(errorRows.length, 2);
2148
- for (const row of errorRows) {
2149
- assert.equal(row.metadata?.streak, 1, "成功必须清零,否则阈值会在跨窗处误触发");
2150
- assert.equal(row.metadata?.skipped, undefined, "计数未达阈值,不得放弃窗口");
2151
- }
2152
- assert.equal(calls.length, 3);
2153
- summarizer.dispose();
2154
- store.close();
2155
- });
2156
-
2157
- // ============================ #493 补充:失败计数落库(不再只在进程内)
2158
- // 报告者 strange2000(2026-11-02)第 ① 条:别只存裸计数。原先 streak 活在
2159
- // createSummarizer 闭包里的一个 Map,重启即清零——而这道护栏要防的恰恰是「确定性
2160
- // 失败反复重试」,重启一次就绕过去了。两条用例各自钉一半:
2161
- // 1) 换一个 summarizer 实例(= 重启)后计数仍然接得上,阈值照旧触发;
2162
- // 2) 会话停了很久之后计数失效,不会被几周前攒下的 streak 误伤(他特别点名的那个
2163
- // 反例:护栏自己变成新的误伤路径)。
2164
- test("#493 补充:失败计数落库——换一个 summarizer 实例(模拟重启)仍然接得上", async () => {
2165
- const store = createStore(":memory:");
2166
- const outputs = ["不是 JSON", "还不是 JSON", "依然不是 JSON"];
2167
- let attempt = 0;
2168
- const failStream = () => (async function* () {
2169
- yield { type: "block-start", block: { type: "text" } };
2170
- yield { type: "text-delta", delta: outputs[attempt++] ?? "[]" };
2171
- yield { type: "finish", kind: "ok" };
2172
- })();
2173
- const config = { summarizeParseFailureMaxRetries: 3, distillRateLimitIntervalMs: 0 };
2174
- const sessionOf = () => ({
2175
- id: "s493-persisted-streak",
2176
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2177
- events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }]
2178
- });
2179
-
2180
- // 第一个实例:失败两次就「重启」(dispose + 丢掉闭包),阈值 3 还没到。
2181
- const first = setup(config, { store, stream: failStream });
2182
- const handlerA = eventHandler(first.events);
2183
- const sessionA = sessionOf();
2184
- await handlerA(sessionA, { seq: 2, type: "turn/end" });
2185
- await handlerA(sessionA, { seq: 2, type: "turn/end" });
2186
- first.summarizer.dispose();
2187
- assert.equal(first.service.getDistillCursor("s493-persisted-streak"), undefined, "两次失败还没到阈值,游标不推进");
2188
- assert.equal(first.store.getDistillParseFailureStreak("s493-persisted-streak"), 2,
2189
- "计数必须已经在库里——进程内 Map 在这里会是 0(重启就丢)");
2190
-
2191
- // 第二个实例:同一个库、同一个会话。第 3 次失败必须凑满阈值并放弃窗口。
2192
- const second = setup(config, { store, stream: failStream });
2193
- const handlerB = eventHandler(second.events);
2194
- const sessionB = sessionOf();
2195
- await handlerB(sessionB, { seq: 2, type: "turn/end" });
2196
-
2197
- assert.equal(second.service.getDistillCursor("s493-persisted-streak")?.last_seq, 2,
2198
- "重启前攒下的 2 次 + 这次的 1 次 = 阈值,窗口必须被放弃(进程内 Map 到这儿是 1,不会放弃)");
2199
- const abandoned = second.service.listLlmAudits().filter((r) => r.status === "error")
2200
- .find((r) => r.metadata?.skipped === "parse-failure-streak");
2201
- assert.equal(abandoned?.metadata?.streak, 3, "审计里的 streak 是**累计**值,不是本次进程内从 1 起算");
2202
- assert.equal(second.store.getDistillParseFailureStreak("s493-persisted-streak"), 0,
2203
- "放弃后清零:窗口已消费,下一窗从全新状态起算");
2204
- second.summarizer.dispose();
2205
- store.close();
2206
- });
2207
-
2208
- test("#493 补充:停了很久的会话,旧 streak 失效,不会被几周前的失败误伤", async () => {
2209
- const store = createStore(":memory:");
2210
- let attempts = 0;
2211
- const { events, calls, service, summarizer } = setup(
2212
- { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2213
- {
2214
- store,
2215
- stream() {
2216
- return (async function* () {
2217
- yield { type: "block-start", block: { type: "text" } };
2218
- yield { type: "text-delta", delta: attempts++ === 0 ? "不是 JSON" : "[]" };
2219
- yield { type: "finish", kind: "ok" };
2220
- })();
2221
- }
2222
- }
2223
- );
2224
- const handler = eventHandler(events);
2225
- const session = {
2226
- id: "s493-stale-streak",
2227
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2228
- events: [userMessage("隔了很久才回来的窗口", 1), { seq: 2, type: "turn/end" }]
2229
- };
2230
-
2231
- // 先在库里种一条「很久以前失败过一次」的记录(阈值 2,再有一次就会熔断)。
2232
- service.bumpDistillParseFailureStreak("s493-stale-streak");
2233
- const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
2234
- store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, "s493-stale-streak");
2235
- assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 1, "不判失效时它还在");
2236
- assert.equal(store.getDistillParseFailureStreak("s493-stale-streak", { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0,
2237
- "读侧带失效窗口时必须当 0:30 天前的失败不算「连续失败」");
2238
-
2239
- // 于是这次的第一次失败应当只算 streak=1,不熔断;成功一次即清零。
2240
- await handler(session, { seq: 2, type: "turn/end" });
2241
- const firstFail = service.listLlmAudits().find((r) => r.status === "error");
2242
- assert.equal(firstFail?.metadata?.streak, 1, "旧记录失效之后,这次失败是「新的第一次」");
2243
- assert.equal(firstFail?.metadata?.skipped, undefined, "不许因为几周前的失败就直接熔断当前窗口");
2244
- assert.equal(service.getDistillCursor("s493-stale-streak"), undefined, "游标没被跳过,窗口仍可重试");
2245
-
2246
- await handler(session, { seq: 2, type: "turn/end" });
2247
- assert.equal(calls.length, 2);
2248
- assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 0, "成功即清零(DELETE,不是写 0)");
2249
- summarizer.dispose();
2250
- store.close();
2251
- });
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { Context } from "@deepseek-ai/cordis";
8
+ import * as mneme from "../src/index.js";
9
+ import { createStore } from "../src/store.js";
10
+ import { createService } from "../src/service.js";
11
+ import { createSummarizer, parseSummaryJson, parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "../src/summarize.js";
12
+ import { createSettings } from "../src/settings.js";
13
+
14
+ // Issue #239 第 4 项:可注入的假时钟 + 定时器——错峰队列的测试必须能「推进时间」,
15
+ // 真实 setTimeout 会让补跑路径要么测不到、要么拖慢测试。
16
+ function fakeClock(start) {
17
+ let current = start.getTime();
18
+ const timers = [];
19
+ const deps = {
20
+ now: () => current,
21
+ setTimeoutFn: (fn, ms) => {
22
+ const entry = { fn, at: current + ms };
23
+ timers.push(entry);
24
+ return entry;
25
+ },
26
+ clearTimeoutFn: (entry) => {
27
+ const i = timers.indexOf(entry);
28
+ if (i !== -1) timers.splice(i, 1);
29
+ }
30
+ };
31
+ const flush = async () => {
32
+ // 补跑是异步链(enqueueDistill → stream):推完时间要给微任务与一轮宏任务机会。
33
+ for (let i = 0; i < 4; i++) await new Promise((resolve) => setTimeout(resolve, 0));
34
+ };
35
+ return {
36
+ deps,
37
+ pending: () => timers.length,
38
+ async advanceTo(date) {
39
+ current = date.getTime();
40
+ for (const entry of [...timers].sort((a, b) => a.at - b.at)) {
41
+ if (entry.at > current) continue;
42
+ timers.splice(timers.indexOf(entry), 1);
43
+ entry.fn();
44
+ }
45
+ await flush();
46
+ },
47
+ async advance(ms) {
48
+ await this.advanceTo(new Date(current + ms));
49
+ }
50
+ };
51
+ }
52
+
53
+ function setup(over = {}, opts = {}) {
54
+ const store = opts.store ?? createStore(":memory:");
55
+ const service = createService({ store, mirror: null, config: {} });
56
+ const events = [];
57
+ const calls = [];
58
+ const ctx = {
59
+ on(name, fn) {
60
+ events.push({ name, fn });
61
+ return () => {
62
+ const i = events.findIndex((e) => e.name === name && e.fn === fn);
63
+ if (i !== -1) events.splice(i, 1);
64
+ };
65
+ },
66
+ llm: {
67
+ stream(options) {
68
+ calls.push(options);
69
+ if (opts.stream) return opts.stream(options);
70
+ const json = JSON.stringify([
71
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
72
+ { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
73
+ ]);
74
+ return (async function* () {
75
+ yield { type: "block-start", block: { type: "text" } };
76
+ yield { type: "text-delta", delta: json };
77
+ yield { type: "block-end", block: { type: "text" } };
78
+ yield { type: "finish", kind: "ok" };
79
+ })();
80
+ }
81
+ }
82
+ };
83
+ const config = { autoSummarize: true, ...over };
84
+ const summarizer = createSummarizer(ctx, service, config, opts.deps ?? {});
85
+ return { store, service, events, calls, summarizer };
86
+ }
87
+
88
+ // A realistic direct human prompt event (source.kind === "user").
89
+ function userMessage(text, seq) {
90
+ const event = {
91
+ type: "user/message",
92
+ data: { source: { kind: "user" }, content: [{ type: "text", text }] }
93
+ };
94
+ if (seq !== undefined) event.seq = seq;
95
+ return event;
96
+ }
97
+
98
+ function toolResultEvent(text, isError, seq) {
99
+ return {
100
+ seq,
101
+ type: "tool/result",
102
+ data: {
103
+ message: {
104
+ source: { kind: "tool", callId: `call-${seq}` },
105
+ content: [{
106
+ type: "tool-result",
107
+ toolCallId: `call-${seq}`,
108
+ isError,
109
+ content: [{ type: "text", text }]
110
+ }]
111
+ }
112
+ }
113
+ };
114
+ }
115
+
116
+ // DSH 0.2.x 内核对工具结果的实际投递形状:内核给的是 `role:"tool"` 消息本身,
117
+ // content 里只有 `type:"text"` 块、isError 挂在消息上,不再有 tool-result 块。
118
+ // 蒸馏曾只认旧块形状,于是真实会话里的工具输出与工具报错全部被静默丢弃。
119
+ function toolRoleResultEvent(text, isError, seq) {
120
+ return {
121
+ seq,
122
+ type: "tool/result",
123
+ data: {
124
+ message: {
125
+ role: "tool",
126
+ source: { kind: "tool", callId: `call-${seq}` },
127
+ toolCallId: `call-${seq}`,
128
+ content: [{ type: "text", text }],
129
+ isError
130
+ }
131
+ }
132
+ };
133
+ }
134
+
135
+ function codeDispatchEvent(text, isError, seq) {
136
+ return {
137
+ seq,
138
+ type: "tool/code-dispatch",
139
+ data: {
140
+ isError,
141
+ content: [{ type: "text", text }]
142
+ }
143
+ };
144
+ }
145
+
146
+ function inboxMessage(id, kind, text) {
147
+ return {
148
+ id,
149
+ role: "user",
150
+ source: { kind },
151
+ content: [{ type: "text", text }]
152
+ };
153
+ }
154
+
155
+ test("parseSummaryJson extracts valid entries and skips malformed ones", () => {
156
+ const parsed = parseSummaryJson(`前导文字 {"a":1}
157
+ [
158
+ {"type":"decision","title":"t1","content":"c1","importance":4},
159
+ {"type":"nonsense","title":"bad","content":"x"},
160
+ "garbage",
161
+ {"type":"preference","title":"t2","content":"c2","importance":2}
162
+ ]`);
163
+ assert.equal(parsed.length, 2);
164
+ assert.equal(parsed[0].type, "decision");
165
+ assert.equal(parsed[1].type, "preference");
166
+ });
167
+
168
+ // --- issue #339 / E8 考卷:JSON 崩溃窗口的 salvage(失败路径修复,默认开)-----
169
+ // E8 实测 10-20% 的窗口输出了含约束的完整对象,只因中段一处语法错误被整窗
170
+ // JSON.parse 拒收 → 生产行为 = 游标卡死 + 温度 0 重试同错 = 静默丢失。
171
+
172
+ test("parseSummaryJson salvages complete objects from a malformed array (issue #339)", () => {
173
+ // E8 现场同款:闭合数组、中段对象含杂散引号(stray quote)。
174
+ const raw = `[
175
+ {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
176
+ { " "type": "history", "title": "坏对象", "content": "语法错误" },
177
+ {"type":"preference","title":"硬件","content":"只要 brushed brass"}
178
+ ]`;
179
+ const parsed = parseSummaryJson(raw);
180
+ assert.equal(parsed.length, 2, "the broken middle object is dropped, both survivors kept");
181
+ assert.equal(parsed[0].content, "总额 $12,400");
182
+ assert.equal(parsed[1].title, "硬件");
183
+ });
184
+
185
+ test("parseSummaryJson salvage finding nothing stays a failure (no fake empty success)", () => {
186
+ // 只有一个语法坏掉的对象 → 救回 0 条:不得按「显式空数组」放行消费窗口。
187
+ const raw = `[
188
+ { " "type": "history", "title": "坏", "content": "语法错误" }
189
+ ]`;
190
+ const parsed = parseSummaryJson(raw);
191
+ assert.deepEqual(parsed, []);
192
+ });
193
+
194
+ test("subscribes to session/event when autoSummarize enabled", () => {
195
+ const { events } = setup();
196
+ assert.ok(events.some((e) => e.name === "session/event"));
197
+ });
198
+
199
+ test("does not subscribe when autoSummarize disabled", () => {
200
+ const { events } = setup({ autoSummarize: false });
201
+ assert.ok(!events.some((e) => e.name === "session/event"));
202
+ });
203
+
204
+ test("turn/end event triggers summarization and stores entries", async () => {
205
+ const { events, store } = setup();
206
+ const handler = events.find((e) => e.name === "session/event").fn;
207
+ const session = {
208
+ id: "s1",
209
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
210
+ events: [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
211
+ };
212
+ await handler(session, { seq: 2, type: "turn/end" });
213
+ assert.equal(store.count(), 2);
214
+ const all = store.all();
215
+ assert.ok(all.some((m) => m.type === "decision"));
216
+ assert.ok(all.some((m) => m.type === "preference"));
217
+ });
218
+
219
+ test("skips summarization for events other than turn/end", async () => {
220
+ const { events, store, calls } = setup();
221
+ const handler = events.find((e) => e.name === "session/event").fn;
222
+ const session = { id: "s1", requestHeader: () => ({ config: {} }), events: [] };
223
+ await handler(session, { seq: 1, type: "user/message" });
224
+ assert.equal(store.count(), 0);
225
+ assert.equal(calls.length, 0);
226
+ });
227
+
228
+ // DSH ≥0.1.2-rc removed Session.events; the session object only exposes
229
+ // snapshotEvents(). Regression for #59: with only the old .events path the
230
+ // collector saw an empty log and no LLM call ever happened.
231
+ test("reads events from snapshotEvents() when Session.events is absent", async () => {
232
+ const { events, store, calls } = setup();
233
+ const handler = events.find((e) => e.name === "session/event").fn;
234
+ const session = {
235
+ id: "s1",
236
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
237
+ snapshotEvents: () => [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
238
+ };
239
+ await handler(session, { seq: 2, type: "turn/end" });
240
+ assert.equal(calls.length, 1, "an LLM call must be made");
241
+ assert.equal(store.count(), 2);
242
+ });
243
+
244
+ // --- Issue #239:成本感知级联(零 LLM 预判)与有界检查点 ---------------------
245
+ // 验收对齐 #239:日志可见 skip 原因、默认配置下行为零变化、开启后调用量下降。
246
+
247
+ test("#239 预判默认关:阈值为 0 时不改变现状,照常发起调用", async () => {
248
+ const { events, calls, store } = setup();
249
+ const handler = events.find((e) => e.name === "session/event").fn;
250
+ const session = {
251
+ id: "s239-default",
252
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
253
+ events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
254
+ };
255
+ await handler(session, { seq: 2, type: "turn/end" });
256
+ assert.equal(calls.length, 1, "默认不预判:再短的窗口也照常蒸馏");
257
+ assert.equal(store.count(), 2);
258
+ });
259
+
260
+ test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费游标", async () => {
261
+ const { events, calls, service, store } = setup({ summarizeMinWindowChars: 1000 });
262
+ const handler = events.find((e) => e.name === "session/event").fn;
263
+ const session = {
264
+ id: "s239-small",
265
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
266
+ events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
267
+ };
268
+ await handler(session, { seq: 2, type: "turn/end" });
269
+ assert.equal(calls.length, 0, "短窗口不得发起 LLM 调用");
270
+ assert.equal(store.count(), 0, "被拦下就不该写入记忆");
271
+ const rows = service.listLlmAudits();
272
+ assert.equal(rows[0].status, "skipped");
273
+ assert.equal(rows[0].error_message, "window-too-small", "skip 原因必须可观测");
274
+
275
+ await handler(session, { seq: 2, type: "turn/end" });
276
+ assert.equal(calls.length, 0);
277
+ assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计");
278
+ });
279
+
280
+ // #239 定标埋点:窗口字符数必须落进审计,否则下一级预判规则的阈值无从选起。
281
+ // 第二条才是本改动的目的——预判开关关着(默认档位)时行为逐字节不变,但从这一刻
282
+ // 起成功行开始留数,验收 4「默认配置下调用量可量化下降」才有账可对。
283
+ test("#239 计量:window-too-small 的 skip 行带 distillChars 与阈值", async () => {
284
+ const { events, service } = setup({ summarizeMinWindowChars: 1000 });
285
+ const handler = events.find((e) => e.name === "session/event").fn;
286
+ const session = {
287
+ id: "s239-metric-skip",
288
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
289
+ events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
290
+ };
291
+ await handler(session, { seq: 2, type: "turn/end" });
292
+ const row = service.listLlmAudits()[0];
293
+ assert.equal(row.error_message, "window-too-small");
294
+ // 计的是**真正发给模型的转录文本**长度(含角色前缀:一条 1 字的「好」实测记 4),
295
+ // 与闸门比较的口径同一个数,定标才对得上。这里不锁字面量,锁语义:短于阈值才该 skip。
296
+ assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0,
297
+ "skip 行必须带窗口字符数");
298
+ assert.equal(row.metadata.minWindowChars, 1000, "阈值随行落档,事后能复核是哪条闸门拦的");
299
+ assert.ok(row.metadata.distillChars < row.metadata.minWindowChars, "skip 的前提:窗口短于阈值");
300
+ });
301
+
302
+ test("#239 计量:预判开关关闭时成功行仍记录 distillChars", async () => {
303
+ const { events, service } = setup();
304
+ const handler = events.find((e) => e.name === "session/event").fn;
305
+ const session = {
306
+ id: "s239-metric-success",
307
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
308
+ events: [userMessage("今天决定把窗口字符数记进审计", 1), { seq: 2, type: "turn/end" }]
309
+ };
310
+ await handler(session, { seq: 2, type: "turn/end" });
311
+ const row = service.listLlmAudits()[0];
312
+ assert.equal(row.status, "success");
313
+ assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0,
314
+ "默认档位下也要留数,否则验收 4 无从对账");
315
+ });
316
+
317
+ test("#239 窗口达到阈值时预判放行(不误杀)", async () => {
318
+ const { events, calls } = setup({ summarizeMinWindowChars: 5 });
319
+ const handler = events.find((e) => e.name === "session/event").fn;
320
+ const session = {
321
+ id: "s239-pass",
322
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
323
+ events: [userMessage("这是一段足够长的用户输入", 1), { seq: 2, type: "turn/end" }]
324
+ };
325
+ await handler(session, { seq: 2, type: "turn/end" });
326
+ assert.equal(calls.length, 1);
327
+ });
328
+
329
+ test("#239 每会话 run 预算:额度用尽后不再调用,审计写明原因", async () => {
330
+ const { events, calls, service } = setup({ summarizeMaxRunsPerSession: 1, distillRateLimitIntervalMs: 0 });
331
+ const handler = events.find((e) => e.name === "session/event").fn;
332
+ const session = {
333
+ id: "s239-cap",
334
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
335
+ events: [userMessage("第一轮", 1), { seq: 2, type: "turn/end" }]
336
+ };
337
+ await handler(session, { seq: 2, type: "turn/end" });
338
+ assert.equal(calls.length, 1, "第一次在预算内");
339
+
340
+ session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
341
+ await handler(session, { seq: 4, type: "turn/end" });
342
+ assert.equal(calls.length, 1, "预算用尽后不得再调用");
343
+ const rows = service.listLlmAudits();
344
+ assert.equal(rows[0].status, "skipped");
345
+ assert.equal(rows[0].error_message, "max-runs-per-session");
346
+ });
347
+
348
+ test("#239 预算只计真实调用:被预判拦下的窗口不消耗额度", async () => {
349
+ const { events, calls } = setup({ summarizeMaxRunsPerSession: 1, summarizeMinWindowChars: 10, distillRateLimitIntervalMs: 0 });
350
+ const handler = events.find((e) => e.name === "session/event").fn;
351
+ const session = {
352
+ id: "s239-budget",
353
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
354
+ events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
355
+ };
356
+ await handler(session, { seq: 2, type: "turn/end" });
357
+ assert.equal(calls.length, 0, "第一轮被零 LLM 预判拦下");
358
+
359
+ session.events.push(userMessage("第二轮写得足够长,应该放行并消耗唯一的一次额度", 3), { seq: 4, type: "turn/end" });
360
+ await handler(session, { seq: 4, type: "turn/end" });
361
+ assert.equal(calls.length, 1, "零成本窗口不该吃掉预算");
362
+ });
363
+
364
+ // --- Issue #239 第 4 项:错峰队列 -------------------------------------------------
365
+
366
+ test("#239-4 高峰时段解析:合法 / 多段 / 跨零点 / 星期前缀 / 非法一律按未配置", () => {
367
+ assert.equal(parsePeakSpec(""), null);
368
+ assert.equal(parsePeakSpec(" "), null);
369
+ assert.deepEqual(parsePeakSpec("09:00-18:00"), { days: null, windows: [{ start: 540, end: 1080 }] });
370
+ assert.deepEqual(parsePeakSpec("09:00-12:00, 14:00-18:00"), {
371
+ days: null,
372
+ windows: [{ start: 540, end: 720 }, { start: 840, end: 1080 }]
373
+ });
374
+ assert.deepEqual(parsePeakSpec("23:00-06:00"), { days: null, windows: [{ start: 1380, end: 360 }] });
375
+ // 星期前缀:ISO 1=周一…7=周日,也认 mon..sun;支持列表与跨周环绕
376
+ assert.deepEqual(parsePeakSpec("mon-fri 08:00-12:00,14:00-18:00"), {
377
+ days: [1, 2, 3, 4, 5],
378
+ windows: [{ start: 480, end: 720 }, { start: 840, end: 1080 }]
379
+ });
380
+ assert.deepEqual(parsePeakSpec("1-5 08:00-12:00").days, [1, 2, 3, 4, 5]);
381
+ assert.deepEqual(parsePeakSpec("sat,sun 10:00-12:00").days, [6, 7]);
382
+ assert.deepEqual(parsePeakSpec("fri-mon 10:00-12:00").days, [1, 5, 6, 7], "跨周环绕");
383
+ // 排程是省钱手段,绝不该因为写错格式把蒸馏停掉:任何非法写法都当作「关闭」。
384
+ for (const bad of ["09:00~18:00", "24:00-06:00", "09:60-18:00", "09:00-09:00", "abc", "09:00-18:00,oops", "weekday 09:00-18:00", "8 09:00-18:00"]) {
385
+ assert.equal(parsePeakSpec(bad), null, `${bad} 应视为未配置`);
386
+ }
387
+ });
388
+
389
+ // #424:中文输入法带出来的全角等价符号曾让整串解析成 null——null 的语义是
390
+ // 「未配置 = 不限制」,错峰闸门于是静默失效(用户写的全角逗号把省钱功能整个
391
+ // 关掉,且没有任何提示)。本用例锁两件事:
392
+ // ① 全角逗号/冒号/数字/波浪号(含 U+301C)与全角星期前缀都归一成半角照常解析,
393
+ // 判定与补跑(isInPeakWindow / nextOffPeakAt)走同一份解析、自动受益;
394
+ // ② 归一化只做等价映射不救错:值本身越界的写法(24:00)无论半角全角仍然非法,
395
+ // 防的是「归一化顺手放宽了数值边界」这类静默语义漂移。
396
+ test("#424 全角等价符号:归一后照常解析,不改变越界判定", () => {
397
+ assert.deepEqual(parsePeakSpec("00:00-03:00,04:00-06:00"), {
398
+ days: null,
399
+ windows: [{ start: 0, end: 180 }, { start: 240, end: 360 }]
400
+ }, "全角逗号切段");
401
+ assert.deepEqual(parsePeakSpec("00:00-03:00").windows, [{ start: 0, end: 180 }], "全角冒号");
402
+ assert.deepEqual(parsePeakSpec("00:00-03:00").windows, [{ start: 0, end: 180 }], "全角数字");
403
+ assert.deepEqual(parsePeakSpec("00:00~03:00").windows, [{ start: 0, end: 180 }], "全角波浪号当连接号");
404
+ assert.deepEqual(parsePeakSpec("00:00〜03:00").windows, [{ start: 0, end: 180 }], "U+301C 波浪号的另一半");
405
+ assert.deepEqual(parsePeakSpec("sat,sun 23:00-06:00").days, [6, 7], "整串全角:星期前缀一起归一");
406
+ // 判定与补跑与解析共用同一入口,全角串在两侧行为一致
407
+ const at = (h, m) => new Date(2026, 10, 10, h, m, 0);
408
+ assert.equal(isInPeakWindow(at(1, 0), "00:00~03:00"), true, "判定侧同一份解析");
409
+ assert.equal(nextOffPeakAt(at(1, 0), "00:00~03:00").getHours(), 3, "补跑时刻同一份解析");
410
+ // 归一化不救错:#424 的举例串本身还踩 24:00 越界,跨零点的正写是 04:00-00:00
411
+ assert.equal(parsePeakSpec("00:00-03:00,04:00-24:00"), null, "24:00 越界不因归一化变合法");
412
+ assert.equal(parsePeakSpec("24:00-06:00"), null, "全角形式不改变越界判定");
413
+ });
414
+
415
+ test("#239-4 高峰判定与补跑时刻(跨零点、右开区间)", () => {
416
+ const at = (h, m) => new Date(2026, 8, 19, h, m, 30);
417
+ assert.equal(isInPeakWindow(at(10, 0), "09:00-18:00"), true);
418
+ assert.equal(isInPeakWindow(at(18, 0), "09:00-18:00"), false, "右开区间:18:00 已出高峰");
419
+ assert.equal(isInPeakWindow(at(3, 0), "23:00-06:00"), true, "跨零点时段");
420
+ assert.equal(isInPeakWindow(at(12, 0), "23:00-06:00"), false);
421
+ assert.equal(isInPeakWindow(at(10, 0), ""), false, "未配置 = 永不高峰");
422
+ const next = nextOffPeakAt(at(10, 30), "09:00-18:00");
423
+ assert.equal(next.getHours(), 18);
424
+ assert.equal(next.getMinutes(), 0);
425
+ assert.equal(nextOffPeakAt(at(3, 0), "23:00-06:00").getHours(), 6, "跨零点取次日 06:00 结束点");
426
+ assert.equal(nextOffPeakAt(at(20, 0), "09:00-18:00"), null, "非高峰时刻无需补跑");
427
+ });
428
+
429
+ // 夹具:按 ISO 星期几(1=周一…7=周日)从当月 1 号推算出日期,避免把星期写死在测试里。
430
+ function dateOnIsoDay(isoDay, hour, minute) {
431
+ const first = new Date(2026, 8, 1, hour, minute, 0);
432
+ const firstIso = first.getDay() === 0 ? 7 : first.getDay();
433
+ return new Date(2026, 8, 1 + ((isoDay - firstIso + 7) % 7), hour, minute, 0);
434
+ }
435
+
436
+ test("#239-4 星期过滤:工作日高峰不误伤周末(跨零点段按「开窗那天」认星期)", () => {
437
+ const spec = "mon-fri 08:00-12:00,14:00-18:00";
438
+ assert.equal(dateOnIsoDay(1, 9, 0).getDay(), 1, "夹具自检:周一");
439
+ assert.equal(dateOnIsoDay(6, 9, 0).getDay(), 6, "夹具自检:周六");
440
+ assert.equal(isInPeakWindow(dateOnIsoDay(1, 9, 0), spec), true, "工作日 09:00 在高峰");
441
+ assert.equal(isInPeakWindow(dateOnIsoDay(1, 12, 30), spec), false, "工作日午休不在高峰");
442
+ assert.equal(isInPeakWindow(dateOnIsoDay(6, 9, 0), spec), false, "周六同刻不在高峰(按周计费)");
443
+ assert.equal(isInPeakWindow(dateOnIsoDay(7, 15, 0), spec), false, "周日下午不在高峰");
444
+ assert.equal(nextOffPeakAt(dateOnIsoDay(1, 9, 0), spec).getHours(), 12, "工作日顺延到 12:00");
445
+ assert.equal(nextOffPeakAt(dateOnIsoDay(6, 9, 0), spec), null, "周末无需顺延");
446
+ // 跨零点段:周五 23:00 开的窗口延续到周六凌晨,仍算高峰;周六开的则不覆盖周日
447
+ const overnight = "mon-fri 23:00-06:00";
448
+ assert.equal(isInPeakWindow(dateOnIsoDay(6, 2, 0), overnight), true, "周六 02:00 属周五开的窗口");
449
+ assert.equal(isInPeakWindow(dateOnIsoDay(7, 2, 0), overnight), false, "周日 02:00 属周六开的窗口(周六不在集合内)");
450
+ });
451
+
452
+ test("#239-4 默认关:未配置高峰时行为与现状一致", async () => {
453
+ const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
454
+ const { events, calls } = setup({}, { deps: clock.deps });
455
+ const handler = events.find((e) => e.name === "session/event").fn;
456
+ const session = {
457
+ id: "s239-peak-off",
458
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
459
+ events: [userMessage("普通一轮", 1), { seq: 2, type: "turn/end" }]
460
+ };
461
+ await handler(session, { seq: 2, type: "turn/end" });
462
+ assert.equal(calls.length, 1, "未配置高峰 → 照常蒸馏");
463
+ assert.equal(clock.pending(), 0, "不该挂补跑定时器");
464
+ });
465
+
466
+ test("#239-4 高峰内不调模型:skip 审计 + 不消费游标,非高峰补跑整窗", async () => {
467
+ const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
468
+ const { events, calls, service, store } = setup(
469
+ { summarizePeakHours: "09:00-18:00", distillRateLimitIntervalMs: 0 },
470
+ { deps: clock.deps }
471
+ );
472
+ const handler = events.find((e) => e.name === "session/event").fn;
473
+ const session = {
474
+ id: "s239-peak",
475
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
476
+ events: [userMessage("高峰里的第一段", 1), { seq: 2, type: "turn/end" }]
477
+ };
478
+ await handler(session, { seq: 2, type: "turn/end" });
479
+ assert.equal(calls.length, 0, "高峰期不得调模型");
480
+ assert.equal(store.count(), 0, "不该写入记忆");
481
+ const rows = service.listLlmAudits();
482
+ assert.equal(rows[0].status, "skipped");
483
+ assert.equal(rows[0].error_message, "peak-hours", "skip 原因必须可观测");
484
+ assert.equal(clock.pending(), 1, "应挂一个补跑定时器");
485
+
486
+ // 高峰期间又来一轮:游标未消费 → 窗口累积;仍不调模型、定时器不叠加
487
+ session.events.push(userMessage("高峰里的第二段", 3), { seq: 4, type: "turn/end" });
488
+ await handler(session, { seq: 4, type: "turn/end" });
489
+ assert.equal(calls.length, 0);
490
+ assert.equal(clock.pending(), 1, "每会话只挂一个定时器");
491
+
492
+ // 推到非高峰:一次补跑,且蒸到的是累积后的整窗
493
+ await clock.advanceTo(new Date(2026, 8, 19, 18, 0, 5));
494
+ assert.equal(calls.length, 1, "非高峰补跑一次");
495
+ const prompt = JSON.stringify(calls[0].messages);
496
+ assert.ok(prompt.includes("高峰里的第一段") && prompt.includes("高峰里的第二段"),
497
+ "补跑应蒸馏累积后的完整窗口");
498
+ assert.ok(store.count() > 0, "补跑真的写入了记忆");
499
+ });
500
+
501
+ test("#239-4 顺延上限:长高峰到点照跑,不饿死蒸馏", async () => {
502
+ const clock = fakeClock(new Date(2026, 8, 19, 9, 0, 0));
503
+ const { events, calls } = setup(
504
+ { summarizePeakHours: "09:00-18:00", summarizePeakMaxDeferMinutes: 30, distillRateLimitIntervalMs: 0 },
505
+ { deps: clock.deps }
506
+ );
507
+ const handler = events.find((e) => e.name === "session/event").fn;
508
+ const session = {
509
+ id: "s239-peak-cap",
510
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
511
+ events: [userMessage("长高峰里的一轮", 1), { seq: 2, type: "turn/end" }]
512
+ };
513
+ await handler(session, { seq: 2, type: "turn/end" });
514
+ assert.equal(calls.length, 0, "高峰内先顺延");
515
+ await clock.advance(31 * 60000);
516
+ assert.equal(calls.length, 1, "上限到点仍处高峰也照跑(bypassPeak)");
517
+ });
518
+
519
+ test("does not call the LLM when no event was added after the last successful seq", async () => {
520
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
521
+ const handler = events.find((e) => e.name === "session/event").fn;
522
+ const transcript = [userMessage("首轮", 1), { seq: 2, type: "turn/end" }];
523
+ const ranges = [];
524
+ const session = {
525
+ id: "s-noop",
526
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
527
+ snapshotEvents(fromSeq, toSeq) {
528
+ ranges.push([fromSeq, toSeq]);
529
+ return transcript;
530
+ }
531
+ };
532
+
533
+ await handler(session, { seq: 2, type: "turn/end" });
534
+ await handler(session, { seq: 2, type: "turn/end" });
535
+
536
+ assert.equal(calls.length, 1, "a successful window must not be distilled twice");
537
+ assert.equal(ranges[1][0], 2, "the successful event seq is passed as the next snapshot lower bound");
538
+ });
539
+
540
+ test("resumes a persisted seq cursor after the summarizer is restarted", async () => {
541
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-distill-cursor-"));
542
+ const dbPath = join(dir, "memory.db");
543
+ let firstStore;
544
+ let firstSummarizer;
545
+ let secondStore;
546
+ let secondSummarizer;
547
+ try {
548
+ firstStore = createStore(dbPath);
549
+ const first = setup({ distillRateLimitIntervalMs: 0 }, { store: firstStore });
550
+ firstSummarizer = first.summarizer;
551
+ const session = {
552
+ id: "s-restart",
553
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
554
+ events: [userMessage("重启前的窗口", 1), { seq: 2, type: "turn/end" }]
555
+ };
556
+ const firstHandler = first.events.find((e) => e.name === "session/event").fn;
557
+ await firstHandler(session, { seq: 2, type: "turn/end" });
558
+ assert.equal(first.calls.length, 1);
559
+ assert.equal(firstStore.getDistillCursor("s-restart").last_seq, 2);
560
+
561
+ firstSummarizer.dispose();
562
+ firstStore.close();
563
+ firstSummarizer = undefined;
564
+ firstStore = undefined;
565
+
566
+ secondStore = createStore(dbPath);
567
+ const second = setup({ distillRateLimitIntervalMs: 0 }, { store: secondStore });
568
+ secondSummarizer = second.summarizer;
569
+ const secondHandler = second.events.find((e) => e.name === "session/event").fn;
570
+ await secondHandler(session, { seq: 2, type: "turn/end" });
571
+
572
+ assert.equal(second.calls.length, 0, "a restarted summarizer must reuse the persisted cursor");
573
+ assert.equal(secondStore.count(), 2, "the restart must not duplicate memories");
574
+ assert.equal(secondStore.getDistillCursor("s-restart").last_seq, 2);
575
+ } finally {
576
+ secondSummarizer?.dispose();
577
+ secondStore?.close();
578
+ firstSummarizer?.dispose();
579
+ firstStore?.close();
580
+ rmSync(dir, { recursive: true, force: true });
581
+ }
582
+ });
583
+
584
+ test("treats a valid empty summary as a successful cursor commit", async () => {
585
+ const { events, store, calls } = setup(
586
+ { distillRateLimitIntervalMs: 0 },
587
+ { stream: streamOf([]) }
588
+ );
589
+ const handler = events.find((e) => e.name === "session/event").fn;
590
+ const session = {
591
+ id: "s-empty-summary",
592
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
593
+ events: [userMessage("这一轮没有可保存的长期记忆", 1), { seq: 2, type: "turn/end" }]
594
+ };
595
+
596
+ await handler(session, { seq: 2, type: "turn/end" });
597
+ assert.equal(calls.length, 1);
598
+ assert.equal(store.count(), 0);
599
+ assert.equal(store.getDistillCursor(session.id).last_seq, 2);
600
+
601
+ await handler(session, { seq: 2, type: "turn/end" });
602
+ assert.equal(calls.length, 1, "a valid empty summary must consume the window once");
603
+ });
604
+
605
+ test("advances the cursor to the window end for mixed valid and invalid entries", async () => {
606
+ const valid = { type: "history", title: "有效摘要", content: "只保存这一条", importance: 3 };
607
+ const { events, store, calls } = setup(
608
+ { distillRateLimitIntervalMs: 0 },
609
+ { stream: streamOf([valid, { type: "unknown", title: "无效摘要", content: "忽略" }, null]) }
610
+ );
611
+ const handler = events.find((e) => e.name === "session/event").fn;
612
+ const session = {
613
+ id: "s-mixed-summary",
614
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
615
+ events: [userMessage("混合摘要窗口", 1), { seq: 2, type: "assistant/message" }, { seq: 3, type: "turn/end" }]
616
+ };
617
+
618
+ await handler(session, { seq: 3, type: "turn/end" });
619
+ assert.equal(calls.length, 1);
620
+ assert.equal(store.count(), 1);
621
+ assert.equal(store.all()[0].title, valid.title);
622
+ assert.equal(store.getDistillCursor(session.id).last_seq, 3);
623
+
624
+ await handler(session, { seq: 3, type: "turn/end" });
625
+ assert.equal(calls.length, 1, "a mixed summary must consume the window once");
626
+ });
627
+
628
+ test("distills only new seq events and keeps the recent tail when a window exceeds distillMaxChars", async () => {
629
+ const { events, calls } = setup(
630
+ { distillMaxChars: 80, distillRateLimitIntervalMs: 0 },
631
+ { stream: streamOf([]) }
632
+ );
633
+ const handler = events.find((e) => e.name === "session/event").fn;
634
+ const transcript = [
635
+ userMessage(`OLD_BEGINNING ${"x".repeat(160)} OLD_RECENT`, 1),
636
+ { seq: 2, type: "assistant/message", data: { message: { content: [{ type: "text", text: "旧回复" }] } } },
637
+ { seq: 3, type: "turn/end" }
638
+ ];
639
+ const session = {
640
+ id: "s-incremental",
641
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
642
+ snapshotEvents: () => transcript
643
+ };
644
+
645
+ await handler(session, { seq: 3, type: "turn/end" });
646
+ const firstInput = calls[0].messages.find((message) => message.role === "user").content[0].text;
647
+
648
+ transcript.push(
649
+ userMessage(`NEW_BEGINNING ${"y".repeat(160)} NEW_RECENT`, 4),
650
+ { seq: 5, type: "assistant/message", data: { message: { content: [{ type: "text", text: "新回复" }] } } },
651
+ { seq: 6, type: "turn/end" }
652
+ );
653
+ await handler(session, { seq: 6, type: "turn/end" });
654
+ const secondInput = calls[1].messages.find((message) => message.role === "user").content[0].text;
655
+
656
+ assert.notEqual(firstInput, secondInput, "successive distills must receive different windows");
657
+ assert.ok(secondInput.includes("NEW_RECENT"), "the newest event content remains in the bounded window");
658
+ assert.ok(!secondInput.includes("OLD_BEGINNING"), "the second window must not restart at the session beginning");
659
+ assert.ok(!secondInput.includes("OLD_RECENT"), "the previous successful window must not be repeated");
660
+ });
661
+
662
+ test("dispose unsubscribes and stops later turn/end events from summarizing", async () => {
663
+ const { events, summarizer, calls } = setup();
664
+ const handler = events.find((e) => e.name === "session/event").fn;
665
+ summarizer.dispose();
666
+ // The ctx.on() disposer must have removed the listener.
667
+ assert.ok(!events.some((e) => e.name === "session/event"));
668
+ const session = {
669
+ id: "s1",
670
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
671
+ events: [userMessage("你好"), { seq: 2, type: "turn/end" }]
672
+ };
673
+ // Even a stale handler reference must not start a new LLM call.
674
+ await handler(session, { seq: 2, type: "turn/end" });
675
+ assert.equal(calls.length, 0);
676
+ });
677
+
678
+ test("excludes plugin-injected user/message events from summarization input", async () => {
679
+ const { events, store, calls } = setup();
680
+ const handler = events.find((e) => e.name === "session/event").fn;
681
+ const session = {
682
+ id: "s2",
683
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
684
+ events: [
685
+ {
686
+ seq: 1,
687
+ type: "user/message",
688
+ data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 内容" }] }
689
+ },
690
+ userMessage("帮我看看这个报错"),
691
+ { seq: 3, type: "turn/end" }
692
+ ]
693
+ };
694
+ await handler(session, { seq: 3, type: "turn/end" });
695
+ assert.equal(calls.length, 1);
696
+ const userMessages = calls[0].messages.filter((m) => m.role === "user");
697
+ assert.equal(userMessages.length, 1);
698
+ assert.ok(!JSON.stringify(calls[0].messages).includes("AGENTS.md"));
699
+ assert.equal(store.count(), 2);
700
+ });
701
+
702
+ test("aborted finish does not store entries and leaves the seq window retryable", async () => {
703
+ let attempt = 0;
704
+ const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
705
+ stream() {
706
+ attempt++;
707
+ return (async function* () {
708
+ yield { type: "block-start", block: { type: "text" } };
709
+ if (attempt === 1) {
710
+ yield { type: "text-delta", delta: "[]" };
711
+ yield { type: "finish", kind: "aborted" };
712
+ return;
713
+ }
714
+ yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试", content: "保留原窗口", importance: 3 }]) };
715
+ yield { type: "finish", kind: "ok" };
716
+ })();
717
+ }
718
+ });
719
+ const handler = events.find((e) => e.name === "session/event").fn;
720
+ const session = {
721
+ id: "s3",
722
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
723
+ events: [userMessage("继续", 1), { seq: 2, type: "turn/end" }]
724
+ };
725
+ await handler(session, { seq: 2, type: "turn/end" });
726
+ assert.equal(calls.length, 1); // the stream was actually reached
727
+ assert.equal(store.count(), 0);
728
+ await handler(session, { seq: 2, type: "turn/end" });
729
+ assert.equal(calls.length, 2, "an aborted window must be retried instead of being marked consumed");
730
+ assert.equal(store.count(), 1);
731
+ });
732
+
733
+ test("a stream failure leaves the seq window retryable", async () => {
734
+ let attempt = 0;
735
+ const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
736
+ stream() {
737
+ attempt++;
738
+ if (attempt === 1) {
739
+ return (async function* () {
740
+ throw new Error("temporary stream failure");
741
+ })();
742
+ }
743
+ return streamOf([{ type: "history", title: "重试成功", content: "失败窗口未丢失", importance: 3 }])();
744
+ }
745
+ });
746
+ const handler = events.find((e) => e.name === "session/event").fn;
747
+ const session = {
748
+ id: "s-stream-failure",
749
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
750
+ events: [userMessage("保留失败窗口", 1), { seq: 2, type: "turn/end" }]
751
+ };
752
+
753
+ await handler(session, { seq: 2, type: "turn/end" });
754
+ assert.equal(calls.length, 1);
755
+ assert.equal(store.count(), 0);
756
+ await handler(session, { seq: 2, type: "turn/end" });
757
+ assert.equal(calls.length, 2, "a failed stream must be retried even without new events");
758
+ assert.equal(store.count(), 1);
759
+ });
760
+
761
+ test("invalid summary JSON leaves the seq window retryable", async () => {
762
+ let attempt = 0;
763
+ const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
764
+ stream() {
765
+ attempt++;
766
+ const output = attempt === 1 ? "not a JSON array" : "[]";
767
+ return (async function* () {
768
+ yield { type: "block-start", block: { type: "text" } };
769
+ yield { type: "text-delta", delta: output };
770
+ yield { type: "finish", kind: "ok" };
771
+ })();
772
+ }
773
+ });
774
+ const handler = events.find((e) => e.name === "session/event").fn;
775
+ const session = {
776
+ id: "s-parse-failure",
777
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
778
+ events: [userMessage("保留解析窗口", 1), { seq: 2, type: "turn/end" }]
779
+ };
780
+
781
+ await handler(session, { seq: 2, type: "turn/end" });
782
+ await handler(session, { seq: 2, type: "turn/end" });
783
+ assert.equal(calls.length, 2, "a parse failure must not advance the seq cursor");
784
+ assert.equal(store.count(), 0, "the valid empty retry remains a no-op");
785
+ });
786
+
787
+ test("a JSON-crashed window with salvageable objects is recovered, consumed once, and audited (issue #339)", async (t) => {
788
+ const malformed = `[
789
+ {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
790
+ { " "type": "history", "title": "坏对象", "content": "语法错误" },
791
+ {"type":"preference","title":"硬件","content":"只要 brushed brass"}
792
+ ]`;
793
+ const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
794
+ stream() {
795
+ return (async function* () {
796
+ yield { type: "block-start", block: { type: "text" } };
797
+ yield { type: "text-delta", delta: malformed };
798
+ yield { type: "finish", kind: "ok" };
799
+ })();
800
+ }
801
+ });
802
+ t.after(() => { summarizer.dispose(); store.close(); });
803
+ const handler = events.find((e) => e.name === "session/event").fn;
804
+ const session = {
805
+ id: "s-salvage",
806
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
807
+ events: [userMessage("带语法错误的蒸馏窗口", 1), { seq: 2, type: "turn/end" }]
808
+ };
809
+
810
+ await handler(session, { seq: 2, type: "turn/end" });
811
+ assert.equal(service.count(), 2, "the two intact objects are written, the broken one dropped");
812
+ const titles = service.list().map((m) => m.title).sort();
813
+ assert.deepEqual(titles, ["硬件", "预算"]);
814
+ assert.equal(calls.length, 1, "salvage consumed the window: no same-window retry");
815
+ const audit = service.listLlmAudits().find((r) => r.status === "success");
816
+ assert.ok(audit, "salvaged run is audited as a success");
817
+ assert.equal(audit.metadata?.json_salvaged, true, "salvage is observable in the audit trail");
818
+
819
+ await handler(session, { seq: 2, type: "turn/end" });
820
+ assert.equal(calls.length, 1, "the consumed window is not re-distilled");
821
+ assert.equal(service.count(), 2, "no duplicate writes on the second turn/end");
822
+ });
823
+
824
+ test("a JSON-crashed window with nothing salvageable stays retryable (issue #339)", async (t) => {
825
+ const hopeless = `[
826
+ { " "type": "history", "title": "坏", "content": "语法错误" }
827
+ ]`;
828
+ let attempt = 0;
829
+ const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
830
+ stream() {
831
+ const output = attempt++ === 0 ? hopeless : `[
832
+ {"type":"history","title":"重试成功","content":"干净窗口","importance":3}
833
+ ]`;
834
+ return (async function* () {
835
+ yield { type: "block-start", block: { type: "text" } };
836
+ yield { type: "text-delta", delta: output };
837
+ yield { type: "finish", kind: "ok" };
838
+ })();
839
+ }
840
+ });
841
+ t.after(() => { summarizer.dispose(); store.close(); });
842
+ const handler = events.find((e) => e.name === "session/event").fn;
843
+ const session = {
844
+ id: "s-salvage-none",
845
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
846
+ events: [userMessage("救不回的窗口", 1), { seq: 2, type: "turn/end" }]
847
+ };
848
+
849
+ await handler(session, { seq: 2, type: "turn/end" });
850
+ assert.equal(service.count(), 0, "zero salvage is not a fake success");
851
+ await handler(session, { seq: 2, type: "turn/end" });
852
+ assert.equal(calls.length, 2, "the window stays retryable");
853
+ assert.equal(service.count(), 1);
854
+ assert.ok(service.listLlmAudits().some(
855
+ (r) => r.status === "error" && r.error_message === "invalid summary JSON"
856
+ ), "zero-salvage run keeps the error audit");
857
+ });
858
+
859
+ test("an all-invalid summary leaves the same window retryable until valid memories are saved", async (t) => {
860
+ for (const invalid of [
861
+ [{}],
862
+ [{ type: "unknown", title: "无效类型", content: "不能消费窗口" }],
863
+ [{ type: "history", title: " ", content: "不能消费窗口" }, null, "garbage"]
864
+ ]) {
865
+ await t.test(JSON.stringify(invalid), async (t) => {
866
+ let attempt = 0;
867
+ const valid = { type: "history", title: "重试成功", content: "全无效摘要后仍保留原窗口", importance: 3 };
868
+ const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
869
+ stream() {
870
+ return streamOf(attempt++ === 0 ? invalid : [valid])();
871
+ }
872
+ });
873
+ t.after(() => { summarizer.dispose(); store.close(); });
874
+ const handler = events.find((e) => e.name === "session/event").fn;
875
+ const session = {
876
+ id: "s-all-invalid",
877
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
878
+ events: [userMessage("保留原窗口", 1), { seq: 2, type: "turn/end" }]
879
+ };
880
+
881
+ await handler(session, { seq: 2, type: "turn/end" });
882
+ assert.equal(service.count(), 0);
883
+ await handler(session, { seq: 2, type: "turn/end" });
884
+ assert.equal(calls.length, 2, "all-invalid entries must not consume the window");
885
+ const firstTranscript = calls[0].messages.find((message) => message.role === "user").content[0].text;
886
+ const retryTranscript = calls[1].messages.find((message) => message.role === "user").content[0].text;
887
+ assert.equal(retryTranscript, firstTranscript, "retry receives the original transcript");
888
+ assert.equal(service.count(), 1);
889
+ assert.equal(service.list()[0].content, valid.content);
890
+ // #502:这一族(JSON 合法、条目全被枚举滤掉)从此报成 filtered-empty 的专属消息,
891
+ // 不再与「模型压根没写数组」共用 invalid summary JSON。状态与「窗口可重试」不变,
892
+ // 所以下面三条断言一条都不放松。
893
+ assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some(
894
+ (audit) => audit.status === "error" && audit.error_message === `no valid summary entries (arr=${invalid.length}, valid=0)`
895
+ ), "an all-invalid summary must be audited as an error, named as a filtered-empty window");
896
+ // metadata 读回来可能是串也可能是对象(store 存的是 JSON 串,列表接口按形状
897
+ // 解析过),两种形状都归一成串再判——否则这条锁会随下游解析行为假红。
898
+ const metaText = (audit) => {
899
+ const m = audit.metadata;
900
+ if (typeof m === "string") return m;
901
+ try { return JSON.stringify(m ?? ""); } catch { return ""; }
902
+ };
903
+ assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some(
904
+ (audit) => audit.status === "error" && metaText(audit).includes("filtered-empty")
905
+ ), "metadata 要能说出是滤空而不是没写格式(sample_types 带着模型自造的分类词)");
906
+
907
+ await handler(session, { seq: 2, type: "turn/end" });
908
+ assert.equal(calls.length, 2, "the successfully retried window is consumed once");
909
+ assert.equal(service.count(), 1);
910
+ });
911
+ }
912
+ });
913
+
914
+ test("rolls back partial memory writes so retrying a failed window does not duplicate entries", async () => {
915
+ const { events, store, service, calls } = setup(
916
+ { distillRateLimitIntervalMs: 0 },
917
+ {
918
+ stream: streamOf([
919
+ { type: "history", title: "第一条", content: "第一条内容", importance: 3 },
920
+ { type: "decision", title: "第二条", content: "第二条内容", importance: 4 }
921
+ ])
922
+ }
923
+ );
924
+ const originalSave = service.saveWithDedupe.bind(service);
925
+ let failOnSecondEntry = true;
926
+ service.saveWithDedupe = (memory) => {
927
+ const result = originalSave(memory);
928
+ if (failOnSecondEntry && memory.title === "第二条") {
929
+ failOnSecondEntry = false;
930
+ throw new Error("simulated memory write failure");
931
+ }
932
+ return result;
933
+ };
934
+ const handler = events.find((e) => e.name === "session/event").fn;
935
+ const session = {
936
+ id: "s-partial-write",
937
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
938
+ events: [userMessage("不要丢失这个窗口", 1), { seq: 2, type: "turn/end" }]
939
+ };
940
+
941
+ await handler(session, { seq: 2, type: "turn/end" });
942
+ assert.equal(calls.length, 1);
943
+ assert.equal(store.count(), 0, "a failed write transaction must leave no partial memory");
944
+ assert.equal(store.getDistillCursor("s-partial-write"), undefined, "a failed write must not advance the cursor");
945
+
946
+ await handler(session, { seq: 2, type: "turn/end" });
947
+ assert.equal(calls.length, 2, "the failed seq window must be retried");
948
+ assert.equal(store.count(), 2);
949
+ assert.equal(store.getDistillCursor("s-partial-write").last_seq, 2);
950
+ assert.equal(store.all().find((memory) => memory.title === "第一条")?.content, "第一条内容");
951
+
952
+ await handler(session, { seq: 2, type: "turn/end" });
953
+ assert.equal(calls.length, 2, "a successfully retried window must not be distilled again");
954
+ assert.equal(store.count(), 2);
955
+ });
956
+
957
+ test("rolls back memory writes when persisting the cursor fails", async () => {
958
+ const { events, store, service, calls } = setup(
959
+ { distillRateLimitIntervalMs: 0 },
960
+ { stream: streamOf([{ type: "history", title: "游标失败", content: "事务应回滚", importance: 3 }]) }
961
+ );
962
+ const originalSetCursor = service.setDistillCursor.bind(service);
963
+ let failOnce = true;
964
+ service.setDistillCursor = (sessionId, lastSeq) => {
965
+ if (failOnce) {
966
+ failOnce = false;
967
+ throw new Error("simulated cursor write failure");
968
+ }
969
+ return originalSetCursor(sessionId, lastSeq);
970
+ };
971
+ const handler = events.find((e) => e.name === "session/event").fn;
972
+ const session = {
973
+ id: "s-cursor-write-failure",
974
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
975
+ events: [userMessage("游标写入失败也不能丢窗口", 1), { seq: 2, type: "turn/end" }]
976
+ };
977
+
978
+ await handler(session, { seq: 2, type: "turn/end" });
979
+ assert.equal(calls.length, 1);
980
+ assert.equal(store.count(), 0, "cursor failure must roll back the memory write");
981
+ assert.equal(store.getDistillCursor(session.id), undefined);
982
+ const failedAudit = service.listLlmAudits({ source: "autoSummarize" }).find(
983
+ (audit) => audit.error_message === "simulated cursor write failure"
984
+ );
985
+ assert.equal(failedAudit?.status, "error", "cursor failure must not be audited as success");
986
+
987
+ await handler(session, { seq: 2, type: "turn/end" });
988
+ assert.equal(calls.length, 2, "the failed cursor window must be retryable");
989
+ assert.equal(store.count(), 1);
990
+ assert.equal(store.getDistillCursor(session.id).last_seq, 2);
991
+ });
992
+
993
+ test("falls back to the in-memory cursor when service lacks setDistillCursor", async () => {
994
+ // 旧版 service(#274 之前)没有持久化游标:缺方法必须降级为内存游标照常蒸馏,
995
+ // 而不是抛错打断每轮蒸馏。方法存在但抛错走的是上一条回滚用例,两者不可混淆。
996
+ const { events, store, service, calls } = setup(
997
+ { distillRateLimitIntervalMs: 0 },
998
+ { stream: streamOf([{ type: "history", title: "降级游标", content: "缺方法也能蒸馏", importance: 3 }]) }
999
+ );
1000
+ delete service.setDistillCursor; // 模拟旧版 service:实例属性,直接删掉
1001
+ const handler = events.find((e) => e.name === "session/event").fn;
1002
+ const session = {
1003
+ id: "s-no-cursor-api",
1004
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1005
+ events: [userMessage("旧宿主没有游标接口", 1), { seq: 2, type: "turn/end" }]
1006
+ };
1007
+
1008
+ await handler(session, { seq: 2, type: "turn/end" });
1009
+ assert.equal(calls.length, 1, "distillation must still run without a persisted cursor");
1010
+ assert.equal(store.count(), 1, "the memory itself must be saved");
1011
+ assert.equal(store.getDistillCursor(session.id), undefined, "nothing may be persisted without the API");
1012
+
1013
+ await handler(session, { seq: 2, type: "turn/end" });
1014
+ assert.equal(calls.length, 1, "the in-memory cursor must prevent re-distillation in this process");
1015
+ });
1016
+
1017
+ test("uses summarizeProvider/summarizeModel config override when set", async () => {
1018
+ const { events, calls } = setup({
1019
+ summarizeProvider: "aliyun",
1020
+ summarizeModel: "qwen3.6-plus"
1021
+ });
1022
+ const handler = events.find((e) => e.name === "session/event").fn;
1023
+ const session = {
1024
+ id: "s4",
1025
+ // Session header has a different model — config override should win.
1026
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-v4-pro" } }),
1027
+ events: [userMessage("测试覆盖"), { seq: 2, type: "turn/end" }]
1028
+ };
1029
+ await handler(session, { seq: 2, type: "turn/end" });
1030
+ assert.equal(calls.length, 1);
1031
+ assert.equal(calls[0].provider, "aliyun");
1032
+ assert.equal(calls[0].model, "qwen3.6-plus");
1033
+ });
1034
+
1035
+ test("falls back to session header when summarize config is empty", async () => {
1036
+ const { events, calls } = setup({
1037
+ summarizeProvider: "",
1038
+ summarizeModel: ""
1039
+ });
1040
+ const handler = events.find((e) => e.name === "session/event").fn;
1041
+ const session = {
1042
+ id: "s5",
1043
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1044
+ events: [userMessage("回退测试"), { seq: 2, type: "turn/end" }]
1045
+ };
1046
+ await handler(session, { seq: 2, type: "turn/end" });
1047
+ assert.equal(calls.length, 1);
1048
+ assert.equal(calls[0].provider, "deepseek");
1049
+ assert.equal(calls[0].model, "deepseek-chat");
1050
+ });
1051
+
1052
+ // ── v0.7.11:智能调速器(429 保护)+ 完整转录/原子记忆 ─────────────────────
1053
+
1054
+ test("serializes distill LLM calls across sessions (global queue, no concurrency)", async () => {
1055
+ const timeline = [];
1056
+ const { events, store } = setup({ distillRateLimitIntervalMs: 0 }, {
1057
+ stream() {
1058
+ return (async function* () {
1059
+ timeline.push(`start:${Date.now()}`);
1060
+ await new Promise((r) => setTimeout(r, 8));
1061
+ yield { type: "block-start", block: { type: "text" } };
1062
+ yield { type: "text-delta", delta: "[]" };
1063
+ yield { type: "finish", kind: "ok" };
1064
+ timeline.push(`end:${Date.now()}`);
1065
+ })();
1066
+ }
1067
+ });
1068
+ const handler = events.find((e) => e.name === "session/event").fn;
1069
+ const mkSession = (id) => ({
1070
+ id,
1071
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1072
+ events: [userMessage(`问题${id}`), { seq: 2, type: "turn/end" }]
1073
+ });
1074
+ // 两个会话几乎同时 turn/end → 必须排队,第二个请求不能与第一个并发。
1075
+ await Promise.all([
1076
+ handler(mkSession("a"), { seq: 2, type: "turn/end" }),
1077
+ handler(mkSession("b"), { seq: 2, type: "turn/end" })
1078
+ ]);
1079
+ assert.equal(timeline.length, 4); // 每个流 start + end
1080
+ const starts = timeline.filter((t) => t.startsWith("start")).map((t) => Number(t.slice(6)));
1081
+ const ends = timeline.filter((t) => t.startsWith("end")).map((t) => Number(t.slice(4)));
1082
+ assert.ok(starts[1] >= ends[0], "second distill must start only after the first finished (serial queue)");
1083
+ });
1084
+
1085
+ test("retries with exponential backoff on 429 and still stores entries", async () => {
1086
+ let attempts = 0;
1087
+ const { events, store } = setup(
1088
+ { distillRateLimitRetries: 3, distillRateLimitBaseDelayMs: 5, distillRateLimitIntervalMs: 0 },
1089
+ {
1090
+ stream() {
1091
+ return (async function* () {
1092
+ attempts++;
1093
+ if (attempts < 3) throw Object.assign(new Error("rate limit exceeded"), { status: 429 });
1094
+ yield { type: "block-start", block: { type: "text" } };
1095
+ yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试成功", content: "第三次请求成功", importance: 3 }]) };
1096
+ yield { type: "finish", kind: "ok" };
1097
+ })();
1098
+ }
1099
+ }
1100
+ );
1101
+ const handler = events.find((e) => e.name === "session/event").fn;
1102
+ const session = {
1103
+ id: "s9",
1104
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1105
+ events: [userMessage("限流测试"), { seq: 2, type: "turn/end" }]
1106
+ };
1107
+ const startedAt = Date.now();
1108
+ await handler(session, { seq: 2, type: "turn/end" });
1109
+ // 429 两次 → 退避重试(5ms + 10ms),第三次成功入库。
1110
+ assert.equal(attempts, 3);
1111
+ assert.ok(Date.now() - startedAt >= 15, "backoff waits should be visible");
1112
+ assert.equal(store.count(), 1);
1113
+ assert.equal(store.all()[0].title, "重试成功");
1114
+ });
1115
+
1116
+ test("distills full transcript (tool calls, results, code output) with atomic-memory prompt", async () => {
1117
+ const { events, calls } = setup();
1118
+ const handler = events.find((e) => e.name === "session/event").fn;
1119
+ const session = {
1120
+ id: "s10",
1121
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1122
+ events: [
1123
+ userMessage("帮我修这个 bug"),
1124
+ { seq: 2, type: "tool/call", data: { name: "Bash", arguments: "node test.js" } },
1125
+ toolResultEvent("TypeError: x is not a function", true, 3),
1126
+ codeDispatchEvent("fixed", false, 4),
1127
+ { seq: 5, type: "turn/end" }
1128
+ ]
1129
+ };
1130
+ await handler(session, { seq: 5, type: "turn/end" });
1131
+ const transcript = JSON.stringify(calls[0].messages);
1132
+ assert.ok(transcript.includes("修这个 bug"));
1133
+ assert.ok(transcript.includes("TypeError: x is not a function"));
1134
+ assert.ok(transcript.includes("代码执行"));
1135
+ // 原子记忆 prompt:不再"硬压 2-3 条",而是按需多提、贴近原始细节。
1136
+ assert.ok(calls[0].messages[0].content[0].text.includes("原子记忆"));
1137
+ });
1138
+
1139
+ test("collects real tool result and code dispatch payloads with failure status", async () => {
1140
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1141
+ const handler = events.find((e) => e.name === "session/event").fn;
1142
+ const session = {
1143
+ id: "s-real-payloads",
1144
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1145
+ events: [
1146
+ userMessage("检查真实事件形状", 1),
1147
+ toolResultEvent("TypeError: tool failed", true, 2),
1148
+ codeDispatchEvent("代码执行结果", false, 3),
1149
+ { seq: 4, type: "turn/end" }
1150
+ ]
1151
+ };
1152
+
1153
+ await handler(session, { seq: 4, type: "turn/end" });
1154
+ const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1155
+ assert.match(transcript, /工具结果(失败):TypeError: tool failed/);
1156
+ assert.match(transcript, /代码执行(成功):代码执行结果/);
1157
+ });
1158
+
1159
+ test("collects tool results delivered as role:tool messages, keeping the legacy block shape", async () => {
1160
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1161
+ const handler = events.find((e) => e.name === "session/event").fn;
1162
+ const session = {
1163
+ id: "s-tool-role-shape",
1164
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1165
+ events: [
1166
+ userMessage("检查工具结果两种形状", 1),
1167
+ toolResultEvent("旧形状载荷", false, 2),
1168
+ toolRoleResultEvent("新形状载荷", false, 3),
1169
+ toolRoleResultEvent("TypeError: tool crashed", true, 4),
1170
+ { seq: 5, type: "turn/end" }
1171
+ ]
1172
+ };
1173
+
1174
+ await handler(session, { seq: 5, type: "turn/end" });
1175
+ const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1176
+ assert.match(transcript, /工具结果(成功):旧形状载荷/);
1177
+ assert.match(transcript, /工具结果(成功):新形状载荷/);
1178
+ assert.match(transcript, /工具结果(失败):TypeError: tool crashed/);
1179
+ });
1180
+
1181
+ test("keeps prefix trimming for tool results delivered as role:tool messages", async () => {
1182
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1183
+ const handler = events.find((e) => e.name === "session/event").fn;
1184
+ const session = {
1185
+ id: "s-tool-role-trim",
1186
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1187
+ events: [
1188
+ userMessage("检查新形状截断", 1),
1189
+ toolRoleResultEvent(`OUT_START ${"c".repeat(550)} OUT_END`, false, 2),
1190
+ { seq: 3, type: "turn/end" }
1191
+ ]
1192
+ };
1193
+
1194
+ await handler(session, { seq: 3, type: "turn/end" });
1195
+ const transcript = JSON.stringify(calls[0].messages);
1196
+ assert.ok(transcript.includes("OUT_START"));
1197
+ assert.ok(!transcript.includes("OUT_END"), "role:tool 结果同样走 500 字符前缀截断");
1198
+ });
1199
+
1200
+ test("collects subagent delivery once across inbox and user-message views", async () => {
1201
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1202
+ const handler = events.find((e) => e.name === "session/event").fn;
1203
+ const delivery = inboxMessage("delivery-1", "subagent-settled", "子会话 closing message");
1204
+ const agentMessage = inboxMessage("delivery-2", "agent-message", "子代理补充报告");
1205
+ const injectedInstruction = inboxMessage("instruction-1", "agent-instructions", "不要把这条注入指令沉淀为记忆");
1206
+ const session = {
1207
+ id: "s-agent-delivery",
1208
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1209
+ events: [
1210
+ userMessage("检查子会话交付", 1),
1211
+ { seq: 2, type: "agent/inbox/spliced", data: { inserted: [delivery, agentMessage, injectedInstruction] } },
1212
+ { seq: 3, type: "user/message", data: delivery },
1213
+ { seq: 4, type: "turn/end" }
1214
+ ]
1215
+ };
1216
+
1217
+ await handler(session, { seq: 4, type: "turn/end" });
1218
+ const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1219
+ assert.equal(transcript.match(/子会话 closing message/g)?.length, 1);
1220
+ assert.equal(transcript.match(/子代理补充报告/g)?.length, 1);
1221
+ assert.ok(!transcript.includes("不要把这条注入指令沉淀为记忆"));
1222
+ });
1223
+
1224
+ test("keeps prefix trimming for tool arguments and tool results", async () => {
1225
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1226
+ const handler = events.find((e) => e.name === "session/event").fn;
1227
+ const session = {
1228
+ id: "s-trim",
1229
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1230
+ events: [
1231
+ userMessage("检查截断", 1),
1232
+ { seq: 2, type: "tool/call", data: { name: "Bash", arguments: `ARG_START ${"a".repeat(350)} ARG_END` } },
1233
+ toolResultEvent(`OUT_START ${"b".repeat(550)} OUT_END`, false, 3),
1234
+ { seq: 4, type: "turn/end" }
1235
+ ]
1236
+ };
1237
+ await handler(session, { seq: 4, type: "turn/end" });
1238
+ const transcript = JSON.stringify(calls[0].messages);
1239
+ assert.ok(transcript.includes("ARG_START"));
1240
+ assert.ok(!transcript.includes("ARG_END"), "tool arguments keep their existing prefix trim");
1241
+ assert.ok(transcript.includes("OUT_START"));
1242
+ assert.ok(!transcript.includes("OUT_END"), "tool results keep their existing prefix trim");
1243
+ });
1244
+
1245
+ test("distill excludes private assistant reasoning blocks from the transcript", async () => {
1246
+ const { events, calls } = setup();
1247
+ const handler = events.find((e) => e.name === "session/event").fn;
1248
+ const session = {
1249
+ id: "s12",
1250
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1251
+ events: [
1252
+ userMessage("解释一下这段代码"),
1253
+ {
1254
+ type: "assistant/message",
1255
+ data: {
1256
+ message: {
1257
+ content: [
1258
+ { type: "reasoning", text: "私有推理:内部权衡过程不该进记忆" },
1259
+ { type: "text", text: "这段代码有死循环,第 3 行 while 条件恒真。" }
1260
+ ]
1261
+ }
1262
+ }
1263
+ },
1264
+ { seq: 3, type: "turn/end" }
1265
+ ]
1266
+ };
1267
+ await handler(session, { seq: 3, type: "turn/end" });
1268
+ const transcript = JSON.stringify(calls[0].messages);
1269
+ assert.ok(transcript.includes("有死循环,第 3 行"), "public assistant text still distills");
1270
+ assert.ok(!transcript.includes("私有推理"), "private reasoning must never enter the distill context");
1271
+ });
1272
+
1273
+ test("codingRetrospect stores coding memory types in the coding memory type set", async () => {
1274
+ const { events, store, calls } = setup({ codingRetrospect: true }, {
1275
+ stream() {
1276
+ return (async function* () {
1277
+ yield { type: "block-start", block: { type: "text" } };
1278
+ yield { type: "text-delta", delta: JSON.stringify([
1279
+ { type: "rejected_solution", title: "弃用方案", content: "A 方案被否决,改用 B", importance: 4 }
1280
+ ]) };
1281
+ yield { type: "finish", kind: "ok" };
1282
+ })();
1283
+ }
1284
+ });
1285
+ const handler = events.find((e) => e.name === "session/event").fn;
1286
+ const session = {
1287
+ id: "s11",
1288
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1289
+ events: [userMessage("编码任务"), { seq: 2, type: "turn/end" }]
1290
+ };
1291
+ await handler(session, { seq: 2, type: "turn/end" });
1292
+ assert.equal(store.count(), 1);
1293
+ const m = store.all()[0];
1294
+ assert.equal(m.type, "rejected_solution");
1295
+ // 读取侧门控靠 m.type(rejected_solution/pitfall/constraint ∈ INJECT_TYPES),
1296
+ // sanitizeTags 不认 `type:` 前缀,所以不给编码记忆打 tag(会清空 tags 列)。
1297
+ assert.deepEqual(m.tags, []);
1298
+ // 编码模式用编码 prompt(含 rejected_solution 类型说明)。
1299
+ assert.ok(calls[0].messages[0].content[0].text.includes("rejected_solution"));
1300
+ });
1301
+
1302
+ // ── Issue #127:节流 / 条数上限 / 同会话去重 ────────────────────────────────
1303
+
1304
+ /** A distill stream that always returns exactly these entries. */
1305
+ function streamOf(entries) {
1306
+ return () => (async function* () {
1307
+ yield { type: "block-start", block: { type: "text" } };
1308
+ yield { type: "text-delta", delta: JSON.stringify(entries) };
1309
+ yield { type: "finish", kind: "ok" };
1310
+ })();
1311
+ }
1312
+
1313
+ function sessionFor(id) {
1314
+ return {
1315
+ id,
1316
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1317
+ events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
1318
+ };
1319
+ }
1320
+
1321
+ test("issue#127: min-interval gate suppresses the second turn/end and audits it as skipped", async () => {
1322
+ const { events, store, calls } = setup({ summarizeMinIntervalMinutes: 30 });
1323
+ const handler = events.find((e) => e.name === "session/event").fn;
1324
+ const session = sessionFor("t1");
1325
+ await handler(session, { seq: 2, type: "turn/end" });
1326
+ assert.equal(calls.length, 1, "the first turn/end distills");
1327
+ assert.equal(store.count(), 2);
1328
+ await handler(session, { seq: 3, type: "turn/end" });
1329
+ assert.equal(calls.length, 1, "the second turn/end inside the window makes no LLM call");
1330
+ assert.equal(store.count(), 2, "nothing new is stored");
1331
+ // 可观测性:被节流不再静默——留一行 status='skipped',一条 SQL 可自查。
1332
+ const audits = store.listLlmAudits({ source: "autoSummarize" });
1333
+ assert.ok(
1334
+ audits.some((a) => a.status === "skipped" && a.error_message === "min-interval"),
1335
+ "the suppression is visible as a status='skipped' audit row"
1336
+ );
1337
+ });
1338
+
1339
+ test("issue#127: min-interval 0 still distills every new event window", async () => {
1340
+ const { events, calls } = setup({ summarizeMinIntervalMinutes: 0 });
1341
+ const handler = events.find((e) => e.name === "session/event").fn;
1342
+ const session = sessionFor("t2");
1343
+ await handler(session, { seq: 2, type: "turn/end" });
1344
+ session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
1345
+ await handler(session, { seq: 4, type: "turn/end" });
1346
+ assert.equal(calls.length, 2, "0 = no gate (zero behavior change)");
1347
+ });
1348
+
1349
+ test("issue#127: the interval gate is per-session (one session never throttles another)", async () => {
1350
+ const { events, calls } = setup({ summarizeMinIntervalMinutes: 30 });
1351
+ const handler = events.find((e) => e.name === "session/event").fn;
1352
+ await handler(sessionFor("ta"), { seq: 2, type: "turn/end" });
1353
+ await handler(sessionFor("tb"), { seq: 2, type: "turn/end" });
1354
+ assert.equal(calls.length, 2, "a different session is not throttled by another's lastRunAt");
1355
+ });
1356
+
1357
+ test("issue#127: summarizeMaxEntriesPerRun caps the stored entries and audits parsed/capped", async () => {
1358
+ const { events, store } = setup({ summarizeMaxEntriesPerRun: 1 }, {
1359
+ stream: streamOf([
1360
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
1361
+ { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
1362
+ ])
1363
+ });
1364
+ const handler = events.find((e) => e.name === "session/event").fn;
1365
+ await handler(sessionFor("t3"), { seq: 2, type: "turn/end" });
1366
+ assert.equal(store.count(), 1, "only the first capped entry is stored");
1367
+ const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
1368
+ assert.equal(audit.metadata?.parsed, 2, "the raw parsed count is kept for tuning the cap");
1369
+ assert.equal(audit.metadata?.capped, 1);
1370
+ });
1371
+
1372
+ test("issue#127: title dedupe absorbs a normalized-same title", async () => {
1373
+ const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
1374
+ stream: streamOf([{ type: "pitfall", title: "Win7 OpenSSH 失效", content: "第二次记录", importance: 4 }])
1375
+ });
1376
+ // 既有条目:同会话、标题归一化后全等(大小写与空白差异)
1377
+ const seeded = service.saveWithDedupe({
1378
+ type: "pitfall", title: "win7 openssh 失效", content: "第一次记录", source: "session:t5"
1379
+ }).memory;
1380
+ const handler = events.find((e) => e.name === "session/event").fn;
1381
+ await handler(sessionFor("t5"), { seq: 2, type: "turn/end" });
1382
+ assert.equal(store.count(), 1, "the normalized-same title merged instead of creating a row");
1383
+ assert.ok(store.getById(seeded.id).content.includes("第二次记录"), "content appended to the existing row");
1384
+ });
1385
+
1386
+ test("issue#127: title dedupe leaves a rephrase alone (it only catches an exact same title)", async () => {
1387
+ // 同一事实的另一种措辞 → title 档不命中,正常新建。这条界定了 title 档的边界,
1388
+ // 避免被误当成 vector 档的替代(cos≥0.85 的簇里「归一化标题全同」为 0 个)。
1389
+ const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
1390
+ stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "新记录", importance: 4 }])
1391
+ });
1392
+ service.saveWithDedupe({
1393
+ type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "旧记录", source: "session:other"
1394
+ });
1395
+ const handler = events.find((e) => e.name === "session/event").fn;
1396
+ await handler(sessionFor("t7"), { seq: 2, type: "turn/end" });
1397
+ assert.equal(store.count(), 2, "a rephrase (different title) is not merged by the title tier");
1398
+ });
1399
+
1400
+ test("issue#127: vector dedupe merges a same-fact rephrase and keeps content_history", async () => {
1401
+ const embedder = {
1402
+ ready: true,
1403
+ embedSingle: async (text) => (String(text).includes("OpenSSH") ? [1, 0, 0] : [0, 1, 0])
1404
+ };
1405
+ const { events, store, service } = setup(
1406
+ { summarizeDedupeMode: "vector", summarizeDedupeMinSim: 0.92 },
1407
+ { stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "重复踩坑", importance: 4 }]) }
1408
+ );
1409
+ service.setEmbedder(embedder);
1410
+ // 同一事实的另一种措辞,已有向量 → 余弦 1.0 ≥ 0.92
1411
+ const seeded = service.saveWithDedupe({
1412
+ type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "第一次记录", source: "session:t8"
1413
+ }).memory;
1414
+ store.setEmbedding(seeded.id, [1, 0, 0]);
1415
+ const handler = events.find((e) => e.name === "session/event").fn;
1416
+ await handler(sessionFor("t8"), { seq: 2, type: "turn/end" });
1417
+ assert.equal(store.count(), 1, "no new row: the rephrase was absorbed by cosine");
1418
+ const merged = store.getById(seeded.id);
1419
+ assert.ok(merged.content.includes("重复踩坑"), "content appended into the existing row");
1420
+ assert.ok((merged.content_history ?? []).length >= 1, "content_history keeps the prior version");
1421
+ const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
1422
+ assert.equal(audit.metadata?.deduped, 1);
1423
+ assert.equal(audit.metadata?.mode, "vector");
1424
+ });
1425
+
1426
+ test("issue#127: no embedder means vector dedupe silently falls back to a normal write", async () => {
1427
+ const { events, store } = setup(
1428
+ { summarizeDedupeMode: "vector" },
1429
+ { stream: streamOf([{ type: "pitfall", title: "无关的一条", content: "内容", importance: 4 }]) }
1430
+ );
1431
+ const handler = events.find((e) => e.name === "session/event").fn;
1432
+ await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
1433
+ assert.equal(store.count(), 1, "dedupe failure never blocks the write");
1434
+ });
1435
+
1436
+ test("issue#127: the five summarize knobs are whitelisted and range-checked", () => {
1437
+ const store = createStore(":memory:");
1438
+ const settings = createSettings(store.db);
1439
+ const merged = settings.setFeatureFlags({
1440
+ summarizeMinIntervalMinutes: 30,
1441
+ summarizeMaxEntriesPerRun: 5,
1442
+ summarizeDedupeMode: "vector",
1443
+ summarizeDedupeMinSim: 0.9,
1444
+ summarizeDedupeWindowHours: 12
1445
+ });
1446
+ assert.equal(merged.summarizeMinIntervalMinutes, 30);
1447
+ assert.equal(merged.summarizeDedupeMode, "vector");
1448
+ assert.equal(merged.summarizeDedupeMinSim, 0.9, "float thresholds round-trip (new numbers whitelist)");
1449
+ // 面板/API 写入走同一套逐键校验:越界与非法枚举必须被拒。
1450
+ assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMinSim: 0.3 }), /number in \[0.5, 0.99\]/);
1451
+ assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMode: "semantic" }), /one of: off, title, vector/);
1452
+ store.close();
1453
+ });
1454
+
1455
+ // ── Issue #127 补测:fail-safe 与打点回滚的边界路径 ────────────────────────
1456
+
1457
+ test("issue#127: a throwing audit writer never breaks the min-interval skip path", async () => {
1458
+ const { events, store, service } = setup({ summarizeMinIntervalMinutes: 30 });
1459
+ const handler = events.find((e) => e.name === "session/event").fn;
1460
+ const session = sessionFor("t10");
1461
+ await handler(session, { seq: 2, type: "turn/end" });
1462
+ assert.equal(store.count(), 2);
1463
+ // 审计写入器抛错:跳过路径必须吞掉异常——审计是观测手段,绝不能反过来打断蒸馏。
1464
+ service.saveLlmAudit = () => { throw new Error("audit sink down"); };
1465
+ await handler(session, { seq: 3, type: "turn/end" });
1466
+ assert.equal(store.count(), 2, "the throttled turn stays a no-op instead of rejecting");
1467
+ });
1468
+
1469
+ test("issue#127: an aborted run rolls the interval claim back so the next turn is admitted", async () => {
1470
+ // 间隔取毫秒级小数值(schema 的粒度是 1 分钟,测试直接构造 config 绕开它),
1471
+ // 这样"回滚到上一次打点"与"保留本次打点"才有可观察差异:
1472
+ // 回滚 → 下一次落在 T1+gap 之外,应被接纳;
1473
+ // 不滚 → 距本次打点才几毫秒,必被挡。
1474
+ // 若沿用 gap=0,两种实现的对外行为完全相同,测试无法发现 else 分支写错。
1475
+ let call = 0;
1476
+ const { events } = setup({ summarizeMinIntervalMinutes: 0.01, distillRateLimitIntervalMs: 0 }, {
1477
+ stream() {
1478
+ call++;
1479
+ if (call === 2) {
1480
+ return (async function* () {
1481
+ yield { type: "block-start", block: { type: "text" } };
1482
+ yield { type: "text-delta", delta: "[]" };
1483
+ yield { type: "finish", kind: "aborted" };
1484
+ })();
1485
+ }
1486
+ return (async function* () {
1487
+ yield { type: "block-start", block: { type: "text" } };
1488
+ yield { type: "text-delta", delta: JSON.stringify([{ type: "decision", title: `第${call}条`, content: "内容", importance: 3 }]) };
1489
+ yield { type: "finish", kind: "ok" };
1490
+ })();
1491
+ }
1492
+ });
1493
+ const handler = events.find((e) => e.name === "session/event").fn;
1494
+ const session = sessionFor("t11");
1495
+ await handler(session, { seq: 2, type: "turn/end" }); // T1:成功,占用间隔
1496
+ assert.equal(call, 1, "the first turn distills");
1497
+ await new Promise((r) => setTimeout(r, 700)); // 越过 600ms 间隔窗
1498
+ session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
1499
+ await handler(session, { seq: 4, type: "turn/end" }); // T2:aborted → 回滚到 T1
1500
+ assert.equal(call, 2, "the aborted turn still reached the LLM call");
1501
+ await handler(session, { seq: 4, type: "turn/end" }); // 立刻再发:只有回滚到 T1 才会被接纳
1502
+ assert.equal(call, 3, "the aborted run did not consume the interval window");
1503
+ });
1504
+
1505
+ test("issue#127: a throwing candidate lookup degrades to undefined (service fail-safe)", async () => {
1506
+ const { service } = setup({ summarizeDedupeMode: "vector" });
1507
+ // 候选查询在 try 内抛错 → findSessionDuplicate 吞掉并返回 undefined(不向外抛)。
1508
+ const hostile = { get type() { throw new Error("store down"); }, title: "标题", content: "内容" };
1509
+ const hit = await service.findSessionDuplicate(hostile, { mode: "vector", source: "session:z" });
1510
+ assert.equal(hit, undefined, "a failed lookup yields no duplicate instead of throwing");
1511
+ });
1512
+
1513
+ test("issue#127: a dedupe lookup that yields nothing still lands the entry (never dropped)", async () => {
1514
+ // 走真实的 session/event 落库路径。只让去重判定不可用(service 层为何返回 undefined
1515
+ // 由上一条覆盖)——蒸馏必须照常写入,不能因为拿不到去重结论就把这一条丢掉。
1516
+ const { events, store, service } = setup(
1517
+ { summarizeDedupeMode: "vector" },
1518
+ { stream: streamOf([{ type: "decision", title: "必须落库", content: "内容", importance: 3 }]) }
1519
+ );
1520
+ service.findSessionDuplicate = async () => undefined;
1521
+ const handler = events.find((e) => e.name === "session/event").fn;
1522
+ await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
1523
+ assert.equal(store.count(), 1, "the entry is stored through the normal write path");
1524
+ assert.equal(store.all()[0].title, "必须落库");
1525
+ });
1526
+
1527
+ test("parseSummaryJson salvage only takes direct children of the outer array (issue #339 review)", () => {
1528
+ // 嵌套子数组里的对象不捞(模型没把它当顶层记忆产出),坏对象照旧丢弃。
1529
+ const nested = `[[{"type":"history","title":"nested","content":"kept"}], { " "type":"history"}]`;
1530
+ assert.deepEqual(parseSummaryJson(nested), []);
1531
+ });
1532
+
1533
+ test("parseSummaryJson salvage does not turn string-embedded objects into memories (issue #339 review)", () => {
1534
+ // 全数组解析失败(第二对象坏)+ 第一对象的 content 字符串里嵌着假记忆形状:
1535
+ // 盲扫不得把属性值对象当独立记忆——外层对象整体才是直接子项。
1536
+ const raw = `[
1537
+ {"type":"history","title":"good","content":"note { \\"type\\": \\"history\\", \\"title\\": \\"fake\\" }"},
1538
+ { " "type": "history", "title": "坏", "content": "x" }
1539
+ ]`;
1540
+ const parsed = parseSummaryJson(raw);
1541
+ assert.equal(parsed.length, 1);
1542
+ assert.equal(parsed[0].title, "good");
1543
+ assert.match(parsed[0].content, /fake/);
1544
+ });
1545
+
1546
+ // ── Issue #399:蒸馏窗口原始记录 + 记忆级溯源 ─────────────────────────────
1547
+ //
1548
+ // 这五条锁的是「把蒸馏从替换降级为叠加索引」的四个承重点:重跑幂等、隐私谓词
1549
+ // 在落盘路径同样成立、留存不受喂给模型的裁剪约束、裁剪不毁资产、默认关零写入。
1550
+ // 锁形状而非锁实现:断言直接查表,不去读 summarize.js 的中间变量。
1551
+
1552
+ /** Issue #399 的开启档 + 关掉真实节流(否则同会话第二轮会被间隔门挡下)。 */
1553
+ const RECORDS_ON = { distillRecords: { enabled: true }, distillRateLimitIntervalMs: 0 };
1554
+
1555
+ function countRows(store, sql) {
1556
+ return store.db.prepare(sql).get().c;
1557
+ }
1558
+
1559
+ function recordsSession(id, events) {
1560
+ return {
1561
+ id,
1562
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1563
+ events
1564
+ };
1565
+ }
1566
+
1567
+ function eventHandler(events) {
1568
+ return events.find((e) => e.name === "session/event").fn;
1569
+ }
1570
+
1571
+ test("issue#399: re-distilling the same window never duplicates records", async () => {
1572
+ const first = setup(RECORDS_ON);
1573
+ const session = recordsSession("s399-idem", [userMessage("同一窗口的提问", 1), { seq: 2, type: "turn/end" }]);
1574
+ await eventHandler(first.events)(session, { seq: 2, type: "turn/end" });
1575
+ assert.equal(countRows(first.store, "SELECT count(*) AS c FROM distill_records"), 1);
1576
+
1577
+ // 模拟宿主重启后重跑同一 seq 段(代码注释里点名的路径:max-runs 耗尽额度、游标
1578
+ // 写入随事务回滚后丢失):删掉持久化游标 + 换一个全新的 summarizer 实例,于是
1579
+ // lastDistilledSeq 这份内存游标也一并归零,同一窗口会被完整重蒸一遍。
1580
+ first.store.db.prepare("DELETE FROM distill_cursors").run();
1581
+ const second = setup(RECORDS_ON, { store: first.store });
1582
+ await eventHandler(second.events)(session, { seq: 2, type: "turn/end" });
1583
+
1584
+ assert.equal(
1585
+ countRows(first.store, "SELECT count(*) AS c FROM distill_records"),
1586
+ 1,
1587
+ "同一 (session_id, seq) 只留一行:重跑必须幂等,保留首次落盘时间才诚实"
1588
+ );
1589
+ assert.equal(
1590
+ countRows(
1591
+ first.store,
1592
+ `SELECT count(*) AS c FROM (
1593
+ SELECT memory_id, session_id, seq_start, seq_end FROM memory_provenance
1594
+ GROUP BY memory_id, session_id, seq_start, seq_end HAVING count(*) > 1)`
1595
+ ),
1596
+ 0,
1597
+ "同一窗口重跑不重复挂链接(唯一索引 + ON CONFLICT DO NOTHING)"
1598
+ );
1599
+ });
1600
+
1601
+ test("issue#399: records keep only public transcript; reasoning and machine-injected content never land on disk", async () => {
1602
+ const { events, store, calls } = setup(RECORDS_ON);
1603
+ const session = recordsSession("s399-priv", [
1604
+ userMessage("真实提问:这个报错怎么修", 1),
1605
+ // 机器注入的三类:AGENTS.md / skill 正文 / 文件变更通知。source.kind 不是
1606
+ // user / agent-message / subagent-settled,必须整个事件被挡在门外。
1607
+ { seq: 2, type: "user/message", data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 注入正文不该落盘" }] } },
1608
+ { seq: 3, type: "user/message", data: { source: { kind: "skill" }, content: [{ type: "text", text: "SKILL_BODY 不该落盘" }] } },
1609
+ { seq: 4, type: "user/message", data: { source: { kind: "file-change" }, content: [{ type: "text", text: "FILE_CHANGE 通知不该落盘" }] } },
1610
+ {
1611
+ seq: 5,
1612
+ type: "assistant/message",
1613
+ data: { message: { content: [
1614
+ { type: "reasoning", text: "PRIVATE_REASONING 不该落盘" },
1615
+ { type: "text", text: "公开回答:检查第 3 行。" }
1616
+ ] } }
1617
+ },
1618
+ toolResultEvent("TOOL_OUTPUT 只进渲染不落盘", false, 6),
1619
+ { seq: 7, type: "turn/end" }
1620
+ ]);
1621
+ await eventHandler(events)(session, { seq: 7, type: "turn/end" });
1622
+
1623
+ const texts = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all().map((r) => r.text).join("\n");
1624
+ assert.ok(texts.includes("真实提问"), "用户提示落盘");
1625
+ assert.ok(texts.includes("公开回答"), "助手公开正文落盘");
1626
+ assert.ok(!texts.includes("AGENTS.md"), "插件注入的 AGENTS.md 绝不落盘");
1627
+ assert.ok(!texts.includes("SKILL_BODY"), "skill 正文绝不落盘");
1628
+ assert.ok(!texts.includes("FILE_CHANGE"), "文件变更通知绝不落盘");
1629
+ assert.ok(!texts.includes("PRIVATE_REASONING"), "私有推理链绝不落盘");
1630
+ assert.ok(!texts.includes("TOOL_OUTPUT"), "工具结果不落盘(#399 已知边界 3)");
1631
+ // 渲染路径照旧带工具输出(codingRetrospect 靠它沉淀踩坑根因)——两条路径在
1632
+ // 「工具输出」上的差异是设计的一部分,不是漂移:漂移的方向必须是「落盘的更少」。
1633
+ assert.ok(JSON.stringify(calls[0].messages).includes("TOOL_OUTPUT"), "工具输出仍进 LLM 转录");
1634
+ });
1635
+
1636
+ test("issue#399: records keep the untruncated prompt while the LLM transcript stays capped", async () => {
1637
+ // 头部标记 + 超长正文:trimTranscript 保尾部,所以「渲染里找不到头部标记」正是
1638
+ // 被 distillMaxChars 截断的证据;记录里必须连头部标记都在。
1639
+ const long = `HEAD_MARKER ${"x".repeat(1200)} TAIL_MARKER`;
1640
+ const { events, store, calls } = setup({ ...RECORDS_ON, distillMaxChars: 200 });
1641
+ const session = recordsSession("s399-long", [userMessage(long, 1), { seq: 2, type: "turn/end" }]);
1642
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1643
+
1644
+ const rows = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all();
1645
+ assert.equal(rows.length, 1);
1646
+ assert.equal(rows[0].text, long, "留存的是整段原文,不截断、不加省略号");
1647
+ assert.ok(!JSON.stringify(calls[0].messages).includes("HEAD_MARKER"), "喂 LLM 的转录仍受 distillMaxChars 约束");
1648
+ });
1649
+
1650
+ test("issue#399: expiring records keeps the memories and leaves provenance dangling-but-present", async () => {
1651
+ const { events, store } = setup(RECORDS_ON);
1652
+ const session = recordsSession("s399-expire", [userMessage("会被裁剪的原文", 1), { seq: 2, type: "turn/end" }]);
1653
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1654
+ assert.equal(store.count(), 2);
1655
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2);
1656
+
1657
+ // 到期裁剪:把 cutoff 推到未来,等价于「这条记录已经过期」。
1658
+ assert.equal(store.clearDistillRecords(new Date(Date.now() + 86400000).toISOString()), 1);
1659
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0);
1660
+ assert.equal(store.count(), 2, "裁剪原文绝不级联删记忆");
1661
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2, "链接保留,从此指向空区间");
1662
+ const memoryId = store.all()[0].id;
1663
+ assert.ok(store.provenanceFor(memoryId).length >= 1, "面板/CLI 仍能回答「这条记忆出自哪个窗口」,只是来源已过期");
1664
+ });
1665
+
1666
+ // ── Issue #512:蒸馏归因补 run 概念 ────────────────────────────────────────
1667
+ //
1668
+ // 这四条锁的是「这条记忆是哪一次蒸馏带进来的」这条问句现在回答得出来:
1669
+ // ① 一轮真蒸馏落一行 distill_runs,链接上带同一个 distill_run_id;
1670
+ // ② run 与它的开销读数(llm_audit_logs)靠 audit_id 直连,不靠时间戳猜;
1671
+ // ③ llmAudit 关掉时 run 仍然成立(归因不该依赖计费开关),只是没有 audit_id;
1672
+ // ④ 失败轮也留一行——「那一轮跑了但没成」本身就是归因要回答的事。
1673
+ // 反面(历史链接保持 NULL、不回填)在 test/store.test.js 的迁移用例里锁。
1674
+
1675
+ test("issue#512: a distilled window records one run, and every link points at it", async () => {
1676
+ const { events, store } = setup(RECORDS_ON);
1677
+ const session = recordsSession("s512-ok", [userMessage("这轮要能追到 run", 1), { seq: 2, type: "turn/end" }]);
1678
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1679
+
1680
+ const runs = store.listDistillRuns();
1681
+ assert.equal(runs.length, 1, "一轮真蒸馏就是一行 run");
1682
+ const run = runs[0];
1683
+ assert.equal(run.status, "ok");
1684
+ assert.equal(run.run_type, "autoSummarize");
1685
+ assert.equal(run.trigger_source, "autoSummarize");
1686
+ assert.equal(run.session_id, "s512-ok");
1687
+ assert.deepEqual([run.seq_start, run.seq_end], [1, 2], "run 记的是这一步真正蒸馏掉的窗口");
1688
+ assert.equal(run.memory_count, 2, "记忆条数是实际写成功的条数");
1689
+
1690
+ const links = store.db.prepare("SELECT memory_id, distill_run_id FROM memory_provenance").all();
1691
+ assert.equal(links.length, 2);
1692
+ for (const link of links) {
1693
+ assert.equal(link.distill_run_id, run.id, "每条记忆的链接都指向这一轮,不是 NULL、也不是别的轮");
1694
+ }
1695
+ });
1696
+
1697
+ test("issue#512: the run and its spend row are joined by audit_id, not guessed from timestamps", async () => {
1698
+ const { events, store } = setup(RECORDS_ON);
1699
+ const session = recordsSession("s512-audit", [userMessage("归因要连到开销行", 1), { seq: 2, type: "turn/end" }]);
1700
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1701
+
1702
+ const run = store.listDistillRuns()[0];
1703
+ const audits = store.listLlmAudits({ source: "autoSummarize" });
1704
+ assert.equal(audits.length, 1);
1705
+ assert.equal(run.audit_id, audits[0].id, "run 直接指着它自己那一行,不靠时间窗匹配");
1706
+ // 反向也成立:从 run 拿到的 id 读回来就是那一条。
1707
+ const join = store.getDistillRun(run.id);
1708
+ assert.equal(join.audit_id, audits[0].id);
1709
+ });
1710
+
1711
+ test("issue#512: with llmAudit off the run still lands — attribution must not depend on the spend switch", async () => {
1712
+ const { events, store } = setup({ ...RECORDS_ON, llmAudit: { enabled: false } });
1713
+ const session = recordsSession("s512-noaudit", [userMessage("关了计费也要能归因", 1), { seq: 2, type: "turn/end" }]);
1714
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1715
+
1716
+ assert.equal(store.listLlmAudits().length, 0, "开关关着,一行 audit 都不写");
1717
+ const runs = store.listDistillRuns();
1718
+ assert.equal(runs.length, 1, "但 run 照落");
1719
+ assert.equal(runs[0].status, "ok");
1720
+ assert.equal(runs[0].audit_id, undefined, "没有开销行时这一列是空的——不填假 id");
1721
+ });
1722
+
1723
+ test("issue#512: a failed round still leaves a run row saying it failed", async () => {
1724
+ // 流失败:窗口保留待重试,但「跑过一轮且失败」这件事必须留痕,否则归因只记得住成的。
1725
+ const { events, store } = setup(RECORDS_ON, {
1726
+ stream: () => (async function* () {
1727
+ yield { type: "block-start", block: { type: "text" } };
1728
+ yield { type: "finish", kind: "error", error: { message: "upstream exploded" } };
1729
+ })()
1730
+ });
1731
+ const session = recordsSession("s512-fail", [userMessage("这轮会失败", 1), { seq: 2, type: "turn/end" }]);
1732
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1733
+
1734
+ const runs = store.listDistillRuns();
1735
+ assert.equal(runs.length, 1, "失败轮也是一轮");
1736
+ assert.equal(runs[0].status, "failed");
1737
+ assert.ok(runs[0].error, "失败要有原因,空错误等于没说");
1738
+ assert.equal(runs[0].memory_count, 0);
1739
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0, "失败轮不落原文");
1740
+ });
1741
+
1742
+ test("issue#399: enabled=false (the default) writes nothing at all", async () => {
1743
+ const { events, store, service } = setup({ distillRateLimitIntervalMs: 0 });
1744
+ let saverCalls = 0;
1745
+ const original = service.saveDistillRecords;
1746
+ service.saveDistillRecords = (...args) => {
1747
+ saverCalls++;
1748
+ return original(...args);
1749
+ };
1750
+ const session = recordsSession("s399-off", [userMessage("默认关不该留原文", 1), { seq: 2, type: "turn/end" }]);
1751
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1752
+
1753
+ assert.equal(saverCalls, 0, "默认关:连收集都不做,一次写入调用都不发");
1754
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0);
1755
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 0);
1756
+ assert.equal(store.count(), 2, "记忆写入照旧");
1757
+ });
1758
+ // ── 蒸馏留存:开关从「设置面」一路走到「盘上有行」 ──────────────────────────────
1759
+ //
1760
+ // 上面 #399 那五条锁的是 summarize.js 内部的行为,它们把 config 直接递给
1761
+ // createSummarizer。真实链路上,config 不来自测试也不来自 bundle:它由 index.js
1762
+ // 在**启动时**把 kv(feature_flags,面板 PUT /api/dsh-mneme/features 写的就是它)
1763
+ // 合并进 cfg 得到,再交给 createSummarizer。中间两环——设置面写入与启动期
1764
+ // 合并——一条测试都没覆盖过,而它们正是「distill_records 行数开始增长」这条
1765
+ // 验收判据的唯一通路:开关没写进 kv,或者写进了但装配时没被读出来,盘上就永远
1766
+ // 是 0 行,而 summarize.js 内部的五条锁照样全绿。
1767
+ //
1768
+ // 所以这两条不在 createSummarizer 层造 config,而是起一个最小宿主(真实 cordis
1769
+ // Context + 六个服务位),让 ctx.plugin(mneme, …) 走完 index.js 的装配:
1770
+ // ① kv 写开 → 装配 → 派发 turn/end → 原文与溯源链接都落盘;
1771
+ // ② 同一个装配但 kv 一个键都不写(默认态)→ 两表 0 行,记忆写入照旧。
1772
+ // 第二条守的是反面:装配默认不许把 opt-in 的开关私自打开。
1773
+
1774
+ /** 最小宿主:六个服务位按 webServer-optional.test.js 的形状提供。 */
1775
+ function minimalHost() {
1776
+ const ctx = new Context();
1777
+ ctx.provide("tools", { register() { return () => {}; } });
1778
+ ctx.provide("commands", { register() { return () => {}; } });
1779
+ ctx.provide("systemPrompt", { context() { return () => {}; } });
1780
+ ctx.provide("agentDefaultModel", { currentSelection() { return { provider: "mock", model: "mock" }; } });
1781
+ ctx.provide("llm", {
1782
+ async *stream() {
1783
+ yield { type: "block-start", block: { type: "text" } };
1784
+ yield {
1785
+ type: "text-delta",
1786
+ delta: JSON.stringify([
1787
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
1788
+ { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
1789
+ ])
1790
+ };
1791
+ yield { type: "block-end", block: { type: "text" } };
1792
+ yield { type: "finish", kind: "ok" };
1793
+ }
1794
+ });
1795
+ ctx.provide("webServer", { register() { return () => {}; } });
1796
+ return ctx;
1797
+ }
1798
+
1799
+ /** 盘上的三张表:装配前后各查一次,才知道行是谁写的。 */
1800
+ function readTables(dbPath) {
1801
+ const db = new DatabaseSync(dbPath);
1802
+ const count = (table) => db.prepare(`SELECT count(*) AS c FROM ${table}`).get().c;
1803
+ const out = {
1804
+ records: count("distill_records"),
1805
+ provenance: count("memory_provenance"),
1806
+ memories: count("memories")
1807
+ };
1808
+ out.rows = db.prepare("SELECT session_id, seq, role, text FROM distill_records ORDER BY seq").all();
1809
+ db.close();
1810
+ return out;
1811
+ }
1812
+
1813
+ /** 装配 + 派发 turn/end + 拆掉装配(不给宿主留开着的手柄)。 */
1814
+ async function bootAndDistill(config, session) {
1815
+ const ctx = minimalHost();
1816
+ const fiber = ctx.plugin(mneme, config);
1817
+ await fiber;
1818
+ // ctx.parallel 会等所有监听器跑完;summarize.js 的监听器 return 了这次写入的
1819
+ // promise(summarize.js 的 session/event 订阅处明写「Return the summarization
1820
+ // promise so awaiters observe the writes」),所以并行派发完就是写完了。
1821
+ await ctx.parallel("session/event", session, { seq: 2, type: "turn/end" });
1822
+ await fiber.dispose();
1823
+ }
1824
+
1825
+ test("distill records grow from the settings face all the way to disk", async () => {
1826
+ const dir = mkdtempSync(join(tmpdir(), "mneme-distill-on-"));
1827
+ const dbPath = join(dir, "memory.db");
1828
+ try {
1829
+ // 第一环:用产品自己的设置面写 kv,等价于面板 PUT /api/dsh-mneme/features。
1830
+ const seed = createStore(dbPath);
1831
+ const merged = createSettings(seed.db).setFeatureFlags({ "distillRecords.enabled": true });
1832
+ assert.equal(merged["distillRecords.enabled"], true, "开关必须先真的落进 kv");
1833
+ assert.equal(readTables(dbPath).records, 0, "开之前盘上一行都没有");
1834
+ seed.close();
1835
+
1836
+ // 第二环 + 第三环:装配时 bundle 说关、kv 说开,合并取 kv;然后真跑一次蒸馏。
1837
+ await bootAndDistill(
1838
+ { memoryDir: dir, distillRecords: { enabled: false }, distillRateLimitIntervalMs: 0 },
1839
+ recordsSession("s-distill-on", [userMessage("这句话该落进 distill_records", 1), { seq: 2, type: "turn/end" }])
1840
+ );
1841
+
1842
+ const after = readTables(dbPath);
1843
+ assert.equal(after.records, 1, "装配读过 kv 之后,原文必须真的落盘");
1844
+ assert.equal(after.rows[0].session_id, "s-distill-on");
1845
+ assert.equal(after.rows[0].seq, 1);
1846
+ assert.equal(after.rows[0].role, "user");
1847
+ assert.match(after.rows[0].text, /该落进 distill_records/, "落盘的是公开正文原文");
1848
+ assert.equal(after.memories, 2, "记忆照常写入,留存是叠加不是替换");
1849
+ assert.equal(after.provenance, 2, "每条新记忆各落一条回指链接");
1850
+ } finally {
1851
+ rmSync(dir, { recursive: true, force: true });
1852
+ }
1853
+ });
1854
+
1855
+ test("the same assembly writes nothing when the flag was never set", async () => {
1856
+ const dir = mkdtempSync(join(tmpdir(), "mneme-distill-off-"));
1857
+ const dbPath = join(dir, "memory.db");
1858
+ try {
1859
+ // 连 createStore 都不预先起:一个全新的库就是「从没碰过设置面」的真实形态。
1860
+ await bootAndDistill(
1861
+ { memoryDir: dir, distillRateLimitIntervalMs: 0 },
1862
+ recordsSession("s-distill-off", [userMessage("默认态:不该有原文落盘", 1), { seq: 2, type: "turn/end" }])
1863
+ );
1864
+
1865
+ const after = readTables(dbPath);
1866
+ assert.equal(after.records, 0, "opt-in 的开关不许被装配私自打开");
1867
+ assert.equal(after.provenance, 0);
1868
+ assert.equal(after.memories, 2, "关的只是留存,蒸馏本身照常");
1869
+ } finally {
1870
+ rmSync(dir, { recursive: true, force: true });
1871
+ }
1872
+ });
1873
+
1874
+
1875
+ // v0.9.0(M.O. 项目记忆):机器提炼是写入量的绝大多数,而它此前一个作用域字段都不带
1876
+ // ——全库带 scope 的行不足 3%,且全部来自 memory_save 那条工具路径。这组用例钉住
1877
+ // 「蒸馏写入也盖章」这件事:值取 session.header(agentPreset + cwd),闸门与工具路径
1878
+ // 同一把(scopeEnabled),来源一律 'auto'(auto 不构成硬墙,只吃软加权,见
1879
+ // test/scope-strict.test.js 的 "auto and legacy rows are NOT hard walls" 用例,
1880
+ // 2026-10-13 在 :41-54)。
1881
+ function scopedSession(id, header) {
1882
+ const session = {
1883
+ id,
1884
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1885
+ events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
1886
+ };
1887
+ if (header !== undefined) session.header = header;
1888
+ return session;
1889
+ }
1890
+
1891
+ test("v0.9.0: scopeEnabled 开时蒸馏写入盖 auto 章(agent=agentPreset、workspace=header.cwd)", async () => {
1892
+ const { events, store } = setup({ scopeEnabled: true });
1893
+ const session = scopedSession("mo-scope", { agentPreset: "amarre", cwd: "C:\\work\\dsh-mneme" });
1894
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1895
+
1896
+ const rows = store.list({ limit: null });
1897
+ assert.equal(rows.length, 2, "两个条目都落库");
1898
+ for (const row of rows) {
1899
+ assert.equal(row.agent_scope, "amarre", "agent 维取自 session.header.agentPreset");
1900
+ assert.equal(row.agent_scope_source, "auto", "来源必须标 auto:显式声明才会构成硬过滤");
1901
+ assert.equal(row.workspace_scope, "C:\\work\\dsh-mneme", "workspace 维取自 session.header.cwd");
1902
+ assert.equal(row.workspace_scope_source, "auto");
1903
+ }
1904
+ });
1905
+
1906
+ test("v0.9.0: 解析不出身份时照样标 auto、值落 NULL(与「从未标注」可区分)", async () => {
1907
+ const { events, store } = setup({ scopeEnabled: true });
1908
+ // 无 header、无 workspaceRegistry(mock ctx 只有 on/llm):正是老会话/测试环境的形状。
1909
+ await eventHandler(events)(scopedSession("mo-scope-anon"), { seq: 2, type: "turn/end" });
1910
+
1911
+ for (const row of store.list({ limit: null })) {
1912
+ assert.equal(row.agent_scope, undefined);
1913
+ assert.equal(row.workspace_scope, undefined);
1914
+ // 关键区别:*_source='auto' 说明「自动标注跑过但没解析出值」,NULL 值才等于全局可见。
1915
+ assert.equal(row.agent_scope_source, "auto");
1916
+ assert.equal(row.workspace_scope_source, "auto");
1917
+ }
1918
+ });
1919
+
1920
+ test("v0.9.0: scopeEnabled 关时蒸馏写入一个 scope 字段都不加(老行为不变)", async () => {
1921
+ const { events, store } = setup({});
1922
+ await eventHandler(events)(scopedSession("mo-scope-off", { agentPreset: "amarre", cwd: "C:\\work\\dsh-mneme" }), { seq: 2, type: "turn/end" });
1923
+
1924
+ for (const row of store.list({ limit: null })) {
1925
+ assert.equal(row.agent_scope, undefined);
1926
+ assert.equal(row.agent_scope_source, undefined);
1927
+ assert.equal(row.workspace_scope, undefined);
1928
+ assert.equal(row.workspace_scope_source, undefined);
1929
+ }
1930
+ });
1931
+
1932
+ // --- Issue #493:转录围栏 / 失败护栏 / 失败行可诊断性 ------------------------------
1933
+ // 现场:转录裸放 user 位 → 模型当成「该我回话了」接着往下写 → 输出无 `[` →
1934
+ // 解析器括号早退判失败 → 不推进游标 → 下一个 turn/end 同文同温重试 → 同一窗口
1935
+ // 确定性失败、窗口越攒越大(报告者 20 条失败 input 8194→11118 单调递增)。
1936
+
1937
+ test("#493 提示构造:转录包进围栏,user 侧重申「第三方提炼员」,system 不动", async () => {
1938
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1939
+ await eventHandler(events)(
1940
+ {
1941
+ id: "s493-prompt",
1942
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1943
+ events: [userMessage("把这段对话蒸馏一下", 1), { seq: 2, type: "turn/end" }]
1944
+ },
1945
+ { seq: 2, type: "turn/end" }
1946
+ );
1947
+
1948
+ const [system, user] = calls[0].messages;
1949
+ assert.equal(system.role, "system");
1950
+ assert.ok(system.content[0].text.includes("原子记忆"), "system 提示词保持原样(既有回归断言依赖它)");
1951
+ assert.equal(user.role, "user");
1952
+ const body = user.content[0].text;
1953
+ // 角色句与围栏都必须落在 user 侧:模型对「这段文本是什么」的判断由它所在的
1954
+ // 那条消息决定,只写进 system 救不回来(实测同段转录只加 system 约束仍 0/2 出 JSON)。
1955
+ assert.ok(body.includes("第三方提炼员"), "user 侧必须重申角色,否则模型接着对话往下写");
1956
+ assert.ok(body.includes("不是下面这段会话的参与者"), "角色句要明说不参与,不能只说「请输出 JSON」");
1957
+ const open = body.indexOf("<<<会话转录开始>>>");
1958
+ const close = body.indexOf("<<<会话转录结束>>>");
1959
+ assert.ok(open > 0 && close > open, "转录必须被围栏包住");
1960
+ assert.ok(body.slice(open, close).includes("把这段对话蒸馏一下"), "原文仍在围栏内");
1961
+ assert.ok(body.indexOf("第三方提炼员") < open, "角色句在围栏之前");
1962
+ });
1963
+
1964
+ test("#493 提示构造:围栏是确定性的,重试收到的转录与首次逐字节相同", async () => {
1965
+ let attempt = 0;
1966
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
1967
+ stream() {
1968
+ const output = attempt++ === 0 ? "不按格式的一整段中文点评" : "[]";
1969
+ return (async function* () {
1970
+ yield { type: "block-start", block: { type: "text" } };
1971
+ yield { type: "text-delta", delta: output };
1972
+ yield { type: "finish", kind: "ok" };
1973
+ })();
1974
+ }
1975
+ });
1976
+ const handler = eventHandler(events);
1977
+ const session = {
1978
+ id: "s493-deterministic",
1979
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1980
+ events: [userMessage("同一窗口重试", 1), { seq: 2, type: "turn/end" }]
1981
+ };
1982
+ await handler(session, { seq: 2, type: "turn/end" });
1983
+ await handler(session, { seq: 2, type: "turn/end" });
1984
+ assert.equal(calls.length, 2);
1985
+ const first = calls[0].messages.find((m) => m.role === "user").content[0].text;
1986
+ const second = calls[1].messages.find((m) => m.role === "user").content[0].text;
1987
+ // 围栏里不能有时间戳/随机数:否则「同文同温」这条前提就不成立,护栏阈值
1988
+ // 会变成对着一堆各不相同的输入计数。
1989
+ assert.equal(first, second, "重试必须收到逐字节相同的转录");
1990
+ });
1991
+
1992
+ test("#493 计量口径:distillChars 只数转录正文,不被围栏与角色句撑大", async () => {
1993
+ // 这条是护栏的邻居而非护栏本身:#493 给 user 消息加了约 100 字的固定包裹,
1994
+ // 若 distillChars 仍按「消息总字符数」算,4 字的窗口会凭空越过任何阈值,
1995
+ // summarizeMinWindowChars 等于被自己的提示词旁路掉。
1996
+ const { events, service } = setup({ summarizeMinWindowChars: 60, distillRateLimitIntervalMs: 0 });
1997
+ await eventHandler(events)(
1998
+ {
1999
+ id: "s493-metric",
2000
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2001
+ events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
2002
+ },
2003
+ { seq: 2, type: "turn/end" }
2004
+ );
2005
+ const row = service.listLlmAudits()[0];
2006
+ assert.equal(row.error_message, "window-too-small", "4 字正文不得被包裹撑过 60 字阈值");
2007
+ assert.equal(row.metadata.distillChars, 4, "计的是转录正文(「用户:短」= 4 字)");
2008
+ });
2009
+
2010
+ test("#493 失败行可诊断:metadata 带 raw_head / raw_has_bracket / streak", async () => {
2011
+ const { events, service } = setup({ distillRateLimitIntervalMs: 0 }, {
2012
+ stream() {
2013
+ return (async function* () {
2014
+ yield { type: "block-start", block: { type: "text" } };
2015
+ yield { type: "text-delta", delta: "已按你的要求将值班轮单日上限改为 5,下面是具体改动…" };
2016
+ yield { type: "finish", kind: "ok" };
2017
+ })();
2018
+ }
2019
+ });
2020
+ await eventHandler(events)(
2021
+ {
2022
+ id: "s493-diag",
2023
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2024
+ events: [userMessage("帮我改一下值班轮上限", 1), { seq: 2, type: "turn/end" }]
2025
+ },
2026
+ { seq: 2, type: "turn/end" }
2027
+ );
2028
+
2029
+ const row = service.listLlmAudits()[0];
2030
+ assert.equal(row.status, "error");
2031
+ assert.equal(row.error_message, "invalid summary JSON");
2032
+ // 原先 metadata 恒为 null,90 条历史失败一条原始输出都没留 —— 事后无法回答
2033
+ // 「模型到底吐了什么形状」。raw_has_bracket 是区分「没按格式写」与「格式对但
2034
+ // 内容救不回来」的唯一低成本判据,也正是 #493 现场的关键特征。
2035
+ assert.equal(row.metadata?.parse_failure, true);
2036
+ assert.equal(row.metadata?.raw_has_bracket, false, "这段点评里没有方括号");
2037
+ assert.ok(row.metadata?.raw_len > 0);
2038
+ assert.ok(row.metadata?.raw_head.startsWith("已按你的要求"), "原始输出前缀要留档");
2039
+ assert.equal(row.metadata?.streak, 1);
2040
+ assert.ok(Number.isInteger(row.metadata?.distillChars), "失败行同样带窗口字符数");
2041
+ });
2042
+
2043
+ test("#493 失败护栏默认关:连续失败也不放弃窗口(历史行为逐字节不变)", async () => {
2044
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
2045
+ stream() {
2046
+ return (async function* () {
2047
+ yield { type: "block-start", block: { type: "text" } };
2048
+ yield { type: "text-delta", delta: "永远不是 JSON" };
2049
+ yield { type: "finish", kind: "ok" };
2050
+ })();
2051
+ }
2052
+ });
2053
+ const handler = eventHandler(events);
2054
+ const session = {
2055
+ id: "s493-guard-off",
2056
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2057
+ events: [userMessage("窗口", 1), { seq: 2, type: "turn/end" }]
2058
+ };
2059
+ for (let i = 0; i < 5; i++) await handler(session, { seq: 2, type: "turn/end" });
2060
+ // 默认 0 = 关:这是本仓新行为的既定纪律(AGENTS.md),也是既有 4 个
2061
+ // 「解析失败不消费窗口」用例语义不变的前提。
2062
+ assert.equal(calls.length, 5, "护栏关着时每个 turn/end 仍重试同一窗口");
2063
+ });
2064
+
2065
+ test("#493 失败护栏:连续 N 次失败后强制消费游标跳过该窗口", async () => {
2066
+ let attempt = 0;
2067
+ const { events, calls, service, store, summarizer } = setup(
2068
+ { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2069
+ {
2070
+ stream() {
2071
+ attempt++;
2072
+ const output = attempt <= 2 ? "确定性失败:一段中文点评" : "[]";
2073
+ return (async function* () {
2074
+ yield { type: "block-start", block: { type: "text" } };
2075
+ yield { type: "text-delta", delta: output };
2076
+ yield { type: "finish", kind: "ok" };
2077
+ })();
2078
+ }
2079
+ }
2080
+ );
2081
+ const handler = eventHandler(events);
2082
+ const session = {
2083
+ id: "s493-guard-on",
2084
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2085
+ events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }]
2086
+ };
2087
+
2088
+ await handler(session, { seq: 2, type: "turn/end" });
2089
+ assert.equal(calls.length, 1);
2090
+ assert.equal(service.getDistillCursor("s493-guard-on"), undefined, "第一次失败仍不推进游标");
2091
+
2092
+ await handler(session, { seq: 2, type: "turn/end" });
2093
+ assert.equal(calls.length, 2, "阈值内仍重试");
2094
+ // 达到阈值(2):窗口被放弃,游标推进到本窗末尾,下一轮从全新窗口起算。
2095
+ const cursor = service.getDistillCursor("s493-guard-on");
2096
+ assert.equal(cursor?.last_seq, 2, "达到阈值后强制消费游标");
2097
+ const errorRows = service.listLlmAudits().filter((r) => r.status === "error");
2098
+ assert.equal(errorRows.length, 2);
2099
+ // 按 streak 定位而不是按下标:审计表的返回顺序是存储层的事,不该被这条断言锁住。
2100
+ const abandoned = errorRows.find((r) => r.metadata?.streak === 2);
2101
+ assert.equal(abandoned?.metadata?.skipped, "parse-failure-streak", "放弃必须可观测");
2102
+ assert.equal(abandoned?.metadata?.maxRetries, 2);
2103
+ assert.equal(errorRows.find((r) => r.metadata?.streak === 1)?.metadata?.skipped, undefined,
2104
+ "阈值前的那次失败仍按「可重试」记账,不得提前宣告放弃");
2105
+
2106
+ // 游标已消费 → 同一 turn/end 不再重试;新事件开新窗口并正常落库。
2107
+ await handler(session, { seq: 2, type: "turn/end" });
2108
+ assert.equal(calls.length, 2, "被放弃的窗口不再重试");
2109
+ session.events.push(userMessage("新的窗口", 3), { seq: 4, type: "turn/end" });
2110
+ await handler(session, { seq: 4, type: "turn/end" });
2111
+ assert.equal(calls.length, 3, "新窗口正常蒸馏");
2112
+ summarizer.dispose();
2113
+ store.close();
2114
+ });
2115
+
2116
+ test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", async () => {
2117
+ let attempt = 0;
2118
+ const { events, calls, service, store, summarizer } = setup(
2119
+ { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2120
+ {
2121
+ stream() {
2122
+ // 失败 → 成功 → 失败:第 3 次是「新一轮的第一次失败」,不该被判为第 2 次。
2123
+ const outputs = ["不是 JSON", "[]", "又不是 JSON"];
2124
+ const output = outputs[attempt++] ?? "[]";
2125
+ return (async function* () {
2126
+ yield { type: "block-start", block: { type: "text" } };
2127
+ yield { type: "text-delta", delta: output };
2128
+ yield { type: "finish", kind: "ok" };
2129
+ })();
2130
+ }
2131
+ }
2132
+ );
2133
+ const handler = eventHandler(events);
2134
+ const session = {
2135
+ id: "s493-streak-reset",
2136
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2137
+ events: [userMessage("第一窗", 1), { seq: 2, type: "turn/end" }]
2138
+ };
2139
+ await handler(session, { seq: 2, type: "turn/end" }); // 失败 #1
2140
+ await handler(session, { seq: 2, type: "turn/end" }); // 成功 → 清零
2141
+ session.events.push(userMessage("第二窗", 3), { seq: 4, type: "turn/end" });
2142
+ await handler(session, { seq: 4, type: "turn/end" }); // 失败 #1(不是 #2)
2143
+
2144
+ // 两条失败行都必须是「新一轮的第一次」:只查最后一条会让「第一条其实被
2145
+ // 记成 streak=2」这类回归漏网。
2146
+ const errorRows = service.listLlmAudits().filter((r) => r.status === "error");
2147
+ assert.equal(errorRows.length, 2);
2148
+ for (const row of errorRows) {
2149
+ assert.equal(row.metadata?.streak, 1, "成功必须清零,否则阈值会在跨窗处误触发");
2150
+ assert.equal(row.metadata?.skipped, undefined, "计数未达阈值,不得放弃窗口");
2151
+ }
2152
+ assert.equal(calls.length, 3);
2153
+ summarizer.dispose();
2154
+ store.close();
2155
+ });
2156
+
2157
+ // ============================ #493 补充:失败计数落库(不再只在进程内)
2158
+ // 报告者 strange2000(2026-11-02)第 ① 条:别只存裸计数。原先 streak 活在
2159
+ // createSummarizer 闭包里的一个 Map,重启即清零——而这道护栏要防的恰恰是「确定性
2160
+ // 失败反复重试」,重启一次就绕过去了。两条用例各自钉一半:
2161
+ // 1) 换一个 summarizer 实例(= 重启)后计数仍然接得上,阈值照旧触发;
2162
+ // 2) 会话停了很久之后计数失效,不会被几周前攒下的 streak 误伤(他特别点名的那个
2163
+ // 反例:护栏自己变成新的误伤路径)。
2164
+ test("#493 补充:失败计数落库——换一个 summarizer 实例(模拟重启)仍然接得上", async () => {
2165
+ const store = createStore(":memory:");
2166
+ const outputs = ["不是 JSON", "还不是 JSON", "依然不是 JSON"];
2167
+ let attempt = 0;
2168
+ const failStream = () => (async function* () {
2169
+ yield { type: "block-start", block: { type: "text" } };
2170
+ yield { type: "text-delta", delta: outputs[attempt++] ?? "[]" };
2171
+ yield { type: "finish", kind: "ok" };
2172
+ })();
2173
+ const config = { summarizeParseFailureMaxRetries: 3, distillRateLimitIntervalMs: 0 };
2174
+ const sessionOf = () => ({
2175
+ id: "s493-persisted-streak",
2176
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2177
+ events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }]
2178
+ });
2179
+
2180
+ // 第一个实例:失败两次就「重启」(dispose + 丢掉闭包),阈值 3 还没到。
2181
+ const first = setup(config, { store, stream: failStream });
2182
+ const handlerA = eventHandler(first.events);
2183
+ const sessionA = sessionOf();
2184
+ await handlerA(sessionA, { seq: 2, type: "turn/end" });
2185
+ await handlerA(sessionA, { seq: 2, type: "turn/end" });
2186
+ first.summarizer.dispose();
2187
+ assert.equal(first.service.getDistillCursor("s493-persisted-streak"), undefined, "两次失败还没到阈值,游标不推进");
2188
+ assert.equal(first.store.getDistillParseFailureStreak("s493-persisted-streak"), 2,
2189
+ "计数必须已经在库里——进程内 Map 在这里会是 0(重启就丢)");
2190
+
2191
+ // 第二个实例:同一个库、同一个会话。第 3 次失败必须凑满阈值并放弃窗口。
2192
+ const second = setup(config, { store, stream: failStream });
2193
+ const handlerB = eventHandler(second.events);
2194
+ const sessionB = sessionOf();
2195
+ await handlerB(sessionB, { seq: 2, type: "turn/end" });
2196
+
2197
+ assert.equal(second.service.getDistillCursor("s493-persisted-streak")?.last_seq, 2,
2198
+ "重启前攒下的 2 次 + 这次的 1 次 = 阈值,窗口必须被放弃(进程内 Map 到这儿是 1,不会放弃)");
2199
+ const abandoned = second.service.listLlmAudits().filter((r) => r.status === "error")
2200
+ .find((r) => r.metadata?.skipped === "parse-failure-streak");
2201
+ assert.equal(abandoned?.metadata?.streak, 3, "审计里的 streak 是**累计**值,不是本次进程内从 1 起算");
2202
+ assert.equal(second.store.getDistillParseFailureStreak("s493-persisted-streak"), 0,
2203
+ "放弃后清零:窗口已消费,下一窗从全新状态起算");
2204
+ second.summarizer.dispose();
2205
+ store.close();
2206
+ });
2207
+
2208
+ test("#493 补充:停了很久的会话,旧 streak 失效,不会被几周前的失败误伤", async () => {
2209
+ const store = createStore(":memory:");
2210
+ let attempts = 0;
2211
+ const { events, calls, service, summarizer } = setup(
2212
+ { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2213
+ {
2214
+ store,
2215
+ stream() {
2216
+ return (async function* () {
2217
+ yield { type: "block-start", block: { type: "text" } };
2218
+ yield { type: "text-delta", delta: attempts++ === 0 ? "不是 JSON" : "[]" };
2219
+ yield { type: "finish", kind: "ok" };
2220
+ })();
2221
+ }
2222
+ }
2223
+ );
2224
+ const handler = eventHandler(events);
2225
+ const session = {
2226
+ id: "s493-stale-streak",
2227
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2228
+ events: [userMessage("隔了很久才回来的窗口", 1), { seq: 2, type: "turn/end" }]
2229
+ };
2230
+
2231
+ // 先在库里种一条「很久以前失败过一次」的记录(阈值 2,再有一次就会熔断)。
2232
+ service.bumpDistillParseFailureStreak("s493-stale-streak");
2233
+ const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
2234
+ store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, "s493-stale-streak");
2235
+ assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 1, "不判失效时它还在");
2236
+ assert.equal(store.getDistillParseFailureStreak("s493-stale-streak", { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0,
2237
+ "读侧带失效窗口时必须当 0:30 天前的失败不算「连续失败」");
2238
+
2239
+ // 于是这次的第一次失败应当只算 streak=1,不熔断;成功一次即清零。
2240
+ await handler(session, { seq: 2, type: "turn/end" });
2241
+ const firstFail = service.listLlmAudits().find((r) => r.status === "error");
2242
+ assert.equal(firstFail?.metadata?.streak, 1, "旧记录失效之后,这次失败是「新的第一次」");
2243
+ assert.equal(firstFail?.metadata?.skipped, undefined, "不许因为几周前的失败就直接熔断当前窗口");
2244
+ assert.equal(service.getDistillCursor("s493-stale-streak"), undefined, "游标没被跳过,窗口仍可重试");
2245
+
2246
+ await handler(session, { seq: 2, type: "turn/end" });
2247
+ assert.equal(calls.length, 2);
2248
+ assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 0, "成功即清零(DELETE,不是写 0)");
2249
+ summarizer.dispose();
2250
+ store.close();
2251
+ });