@modusensus/dsh-mneme 0.14.0-beta.1 → 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 -830
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -262
  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 -404
  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 -3600
  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 -14322
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1344
  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 -945
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -2027
  54. package/lib/dream/triage.js +139 -139
  55. package/lib/dream.js +1879 -1879
  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 -119
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -852
  74. package/lib/inject.js +656 -651
  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 -595
  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 -3268
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -817
  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 -5895
  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 -267
  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 -640
  142. package/lib/wiki/render.js +261 -261
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -500
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -609
  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 +1 -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 -210
  162. package/scripts/release-notes.mjs +164 -164
  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 -3600
  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 -1344
  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 -945
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -2027
  196. package/src/dream/triage.js +139 -139
  197. package/src/dream.js +1879 -1879
  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 -119
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -852
  216. package/src/inject.js +656 -651
  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 -595
  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 -3268
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -817
  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 -5895
  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 -267
  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 -640
  284. package/src/wiki/render.js +261 -261
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -500
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -609
  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 -2998
  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 -4425
  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 -452
  318. package/test/conflict-ledger.test.js +242 -242
  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 -432
  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 -174
  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 -333
  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 -259
  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 -109
  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 -553
  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 -90
  407. package/test/panel-tokens.test.js +245 -245
  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 -156
  453. package/test/release-notes.test.js +265 -265
  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 -188
  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 -1455
  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 -734
  529. package/test/wiki-claims.test.js +194 -194
  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 -202
  535. package/test/wiki-queries.test.js +398 -398
  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 -456
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -460
  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
package/lib/summarize.js CHANGED
@@ -1,1343 +1,1388 @@
1
- import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
- import { STR, langOf } from "./lang.js";
3
- // Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
4
- // 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
5
- // 三条链路同一降级语义,不另造第二份实现。
6
- import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
7
- // v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
8
- import { createScopeResolver } from "./scope.js";
9
- // #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时
10
- // 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。
11
- import { breakFenceMarkers } from "./fence.js";
12
- // Issue #611:图片块的文本口径(占位,不是空串)。三个 textOf 副本共用这一份。
13
- import { contentToText, countImages } from "./attachment-text.js";
14
- // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
15
- // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
16
- import { randomUUID } from "node:crypto";
17
-
18
- // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
19
- // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
20
- // title/content 单列结构(store 无结构化字段),信息浓缩进 content。
21
- /** 解析 LLM 输出中的 JSON 数组,并保留数组是否有效的结果。 */
22
- // 蒸馏产物的事件时间锚定(SimpleMem,arXiv 2601.02553:抽取与时间锚定在同一次
23
- // 生成里完成,消融去掉后时间类问题 F1 掉 56.7%)。归一放在解析层而不是交给
24
- // store.normalizeOccurredAt:后者只判「可解析就 UTC ISO、否则 NULL」,拦不住模型
25
- // 编造的未来时间——而未来时间的记忆会污染注入侧的时间排序与 memory_search 的
26
- // occurred_from 过滤。未知/无法换算的相对时间必须整条省略字段,不能落 null。
27
- // 纯函数:唯一外部输入是显式入参 nowMs(测试注入假时钟即可确定性复现)。
28
- const OCCURRED_AT_FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000;
29
- // #493 补充 §②:持久化的解析失败计数超过这么久没更新就按 0 算(读侧失效,见
30
- // store.getDistillParseFailureStreak)。7 天:够长,能跨一次长假/一次停机;够短,
31
- // 不至于让「停了很久的会话」拿几周前的 streak 直接熔断一个其实健康的窗口。
32
- // 刻意不做成配置键:护栏本身默认关(summarizeParseFailureMaxRetries=0),一个只为
33
- // 护栏服务的失效窗口不该再占一档用户可见配置(AGENTS.md 旗标计数锁也要跟着动)。
34
- const PARSE_FAILURE_STREAK_STALE_MS = 7 * 24 * 60 * 60 * 1000;
35
- function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) {
36
- if (typeof raw !== "string") return undefined;
37
- const text = raw.trim();
38
- if (!text) return undefined;
39
- const ms = Date.parse(text);
40
- if (!Number.isFinite(ms)) return undefined;
41
- // 容忍 1 天:模型给的绝对时间可能略晚于当下(时区/时钟偏移),超出即视为瞎填。
42
- if (ms - nowMs > OCCURRED_AT_FUTURE_TOLERANCE_MS) return undefined;
43
- try {
44
- return new Date(ms).toISOString();
45
- } catch {
46
- return undefined;
47
- }
48
- }
49
-
50
- /**
51
- * 失败形态分类(#502),只在结果不 ok 时产出:
52
- * - no-array:连 `[` 都没有——模型在说话,不是没按格式;
53
- * - syntax:有方括号、整体 parse 失败且括号扫描也没救回条目(#339 那一族);
54
- * - filtered-empty:JSON 合法、`arr` 非空,条目却被 VALID 全数滤掉。**这一种最贵**:
55
- * 读起来像「没按格式写」,实际是分类词不在枚举内,而游标不推进会让整窗反复重放。
56
- */
57
- function describeParseFailure(arr, entries, salvaged, hasBracket) {
58
- if (!Array.isArray(arr) || salvaged) return { kind: hasBracket ? "syntax" : "no-array", arrCount: 0, sampleTypes: [] };
59
- if (arr.length === 0) return { kind: "no-array", arrCount: 0, sampleTypes: [] };
60
- if (entries.length > 0) return null;
61
- const sampleTypes = [...new Set(arr.map((item) => (typeof item?.type === "string" ? item.type.trim() : "")).filter(Boolean))].slice(0, 5);
62
- return { kind: "filtered-empty", arrCount: arr.length, sampleTypes };
63
- }
64
-
65
- /**
66
- * 失败行的审计读数(纯函数:不驱动整条蒸馏管线也能测)。filtered-empty 的
67
- * errorMessage 直接写出 arr/valid 两个数,一眼可辨;metadata 补 failure_kind /
68
- * arr_count / invalid_entries / sample_types / raw_tail——尾巴是给「先写几条合法
69
- * 条目再跑偏」那种输出准备的,前 300 字看不出来。
70
- */
71
- export function summarizeParseFailureAudit(raw, failure) {
72
- const text = String(raw ?? "");
73
- const kind = failure?.kind ?? (text.includes("[") ? "syntax" : "no-array");
74
- const tail = text.length > 500 ? text.slice(-200) : "";
75
- const filtered = kind === "filtered-empty" ? failure : null;
76
- return {
77
- errorMessage: filtered ? `no valid summary entries (arr=${filtered.arrCount}, valid=0)` : "invalid summary JSON",
78
- metadata: {
79
- parse_failure: true,
80
- failure_kind: kind,
81
- raw_len: text.length,
82
- raw_head: text.slice(0, 300),
83
- ...(tail ? { raw_tail: tail } : {}),
84
- raw_has_bracket: text.includes("["),
85
- ...(filtered ? { arr_count: filtered.arrCount, invalid_entries: filtered.arrCount, sample_types: filtered.sampleTypes } : {})
86
- }
87
- };
88
- }
89
-
90
- /** 导出的原因:#502 要能锁住「哪一种失败」这条判据本身,而不是只锁它的下游读数。 */
91
- export function parseSummaryJsonResult(raw) {
92
- const text = String(raw ?? "");
93
- const start = text.indexOf("[");
94
- const end = text.lastIndexOf("]");
95
- // 连方括号都没有 = 模型在说话(#493 的 B 臂正是这一型)。早退也要带上下:否则下游
96
- // 只能靠「输出里有没有 [」再猜一次,而猜出来的东西不该写进错误消息。
97
- if (start === -1 || end === -1 || end <= start) {
98
- return { ok: false, entries: [], failure: describeParseFailure(null, [], false, text.includes("[")) };
99
- }
100
- let arr;
101
- let salvaged = false;
102
- try {
103
- arr = JSON.parse(text.slice(start, end + 1));
104
- } catch {
105
- // issue #339 / E8 实测:10-20% 的窗口输出了含记忆条目的完整对象,只因中段
106
- // 一处语法错误被整窗 JSON.parse 拒收;而失败路径的生产行为是游标不推进 +
107
- // 温度 0 重试同文同错 = 该窗口记忆静默丢失。这里做失败路径修复:括号配对
108
- // 扫描截出完整的顶层对象逐个 parse,救活多少算多少;截出 0 个不视为「模型
109
- // 显式说无内容」(那是 ok:true 专属于真实空数组的语义)。
110
- arr = salvageArrayItems(text.slice(start, end + 1));
111
- salvaged = true;
112
- }
113
- if (!Array.isArray(arr)) return { ok: false, entries: [] };
114
- const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]);
115
- const entries = arr.filter(
116
- (item) =>
117
- item &&
118
- typeof item === "object" &&
119
- VALID.has(item.type) &&
120
- typeof item.title === "string" &&
121
- item.title.trim() &&
122
- typeof item.content === "string" &&
123
- item.content.trim()
124
- ).map((item) => {
125
- const occurredAt = normalizeDistilledOccurredAt(item.occurred_at);
126
- return {
127
- type: item.type,
128
- title: item.title.trim(),
129
- content: item.content.trim(),
130
- importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3,
131
- // 不可解析 / 未来时间:省略该键(不写 null、不写空串),让「模型没给」与
132
- // 「给了但站不住」在库里同为「无时间锚」,避免下游把空值当成已锚定。
133
- ...(occurredAt !== undefined ? { occurred_at: occurredAt } : {})
134
- };
135
- });
136
- // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效,
137
- // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径
138
- // 没有「显式空数组」可言——ok 只看是否救回了条目。
139
- const ok = salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0);
140
- return {
141
- ok,
142
- entries,
143
- salvaged,
144
- // #502:失败时要能说出**是哪一种**失败。「压根没写数组」与「写了合法数组、但条目
145
- // type 全不在枚举里」过去共用一句 invalid summary JSON,而两者要修的地方完全不同
146
- // ——后者是菜单与闸门不一致(summary prompt 只列 4 个类型、VALID 吃 7 个),且失败
147
- // 路径游标不推进,同一窗口会被一遍遍重放(社区报告 #493 追加的实测形态)。
148
- ...(!ok ? { failure: describeParseFailure(arr, entries, salvaged, text.includes("[")) } : {})
149
- };
150
- }
151
-
152
- /**
153
- * Salvage scanner for malformed JSON arrays (issue #339): extracts every
154
- * complete top-level {...} span, parsing each independently. Two passes,
155
- * merged with de-duplication:
156
- * - pass 1 is string-aware (handles valid objects whose string values
157
- * legitimately contain braces);
158
- * - pass 2 ignores string state entirely — E8 现场的坏对象带奇数个引号
159
- * (stray quote),pass 1 的奇偶失配会把后续对象的收尾 } 吞进字符串里,
160
- * 盲扫按括号深度截取反而能救回它们。坏对象两种扫法都 parse 失败,自然
161
- * 被丢弃;两遍的去重靠序列化键。
162
- */
163
- function salvageArrayItems(chunk) {
164
- const out = [];
165
- const seen = new Set();
166
- const push = (obj) => {
167
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
168
- const key = JSON.stringify(obj);
169
- if (!seen.has(key)) { seen.add(key); out.push(obj); }
170
- };
171
- for (const obj of scanBraceSpans(chunk, true)) push(obj);
172
- for (const obj of scanBraceSpans(chunk, false)) push(obj);
173
- return out;
174
- }
175
-
176
- function scanBraceSpans(chunk, respectStrings) {
177
- // 修订(CodeRabbit review on #350):只接受「最外层数组的直接子对象」——
178
- // 候选 { 必须满足 arrayDepth===1 && objDepth===0 且前一非空白 token 是 [ 或
179
- // ,。否则 (a) 嵌套子数组里的对象会被误捞成顶层记忆;(b) 盲扫遇到字符串值
180
- // 里的 } 会提前断 span,把属性值对象当独立记忆落库。误拦的代价只是少救回
181
- // (安全侧),误捞的代价是写进假记忆。
182
- const items = [];
183
- let arrayDepth = 0;
184
- let objDepth = 0;
185
- let inString = false;
186
- let escape = false;
187
- let objStart = -1;
188
- let prev = "";
189
- for (let i = 0; i < chunk.length; i++) {
190
- const ch = chunk[i];
191
- if (respectStrings && inString) {
192
- if (escape) escape = false;
193
- else if (ch === "\\") escape = true;
194
- else if (ch === '"') inString = false;
195
- continue;
196
- }
197
- if (respectStrings && ch === '"') { inString = true; continue; }
198
- if (ch === "[") {
199
- arrayDepth++;
200
- prev = "[";
201
- continue;
202
- }
203
- if (ch === "]") {
204
- arrayDepth = Math.max(0, arrayDepth - 1);
205
- prev = "]";
206
- continue;
207
- }
208
- if (ch === "{") {
209
- if (arrayDepth === 1 && objDepth === 0 && (prev === "[" || prev === ",")) {
210
- objStart = i;
211
- }
212
- objDepth++;
213
- prev = "{";
214
- continue;
215
- }
216
- if (ch === "}") {
217
- objDepth = Math.max(0, objDepth - 1);
218
- if (objDepth === 0 && objStart !== -1) {
219
- try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ }
220
- objStart = -1;
221
- }
222
- prev = "}";
223
- continue;
224
- }
225
- if (ch === ",") { prev = ","; continue; }
226
- if (!/\s/.test(ch)) prev = ch;
227
- }
228
- return items;
229
- }
230
-
231
- /** Extract a JSON array from LLM output that may contain prose around it. */
232
- export function parseSummaryJson(raw) {
233
- return parseSummaryJsonResult(raw).entries;
234
- }
235
-
236
- // The dsh-llm StreamChunk protocol BlockAssembler.push() consumes:
237
- // block-start {index, blockType}, text-delta {index, text},
238
- // block-end {index, block}, finish {reason}. Some consumers observe a
239
- // looser shape ({block} / {delta} / {kind}); normalize before pushing so
240
- // both real adapter streams and shape-tolerant test doubles assemble.
241
- const STREAM_CHUNK_TYPES = new Set([
242
- "block-start",
243
- "text-delta",
244
- "reasoning-delta",
245
- "tool-call-delta",
246
- "block-end",
247
- "usage",
248
- "finish"
249
- ]);
250
-
251
- function toProtocolChunk(chunk) {
252
- switch (chunk.type) {
253
- case "block-start":
254
- return { type: "block-start", index: chunk.index ?? 0, blockType: chunk.blockType ?? chunk.block?.type ?? "text" };
255
- case "text-delta":
256
- return { type: "text-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
257
- case "reasoning-delta":
258
- return { type: "reasoning-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
259
- case "block-end":
260
- return { type: "block-end", index: chunk.index ?? 0, block: chunk.block ?? { type: "text" } };
261
- case "finish":
262
- return {
263
- type: "finish",
264
- reason: chunk.reason ?? { kind: chunk.kind === "error" ? "error" : "stop" },
265
- replayState: chunk.replayState
266
- };
267
- default:
268
- return chunk;
269
- }
270
- }
271
-
272
- // Only direct human prompts are summarized: plugin-injected context
273
- // (AGENTS.md, skill bodies, file-change notices) and other machine-originated
274
- // events must not leak into the memory store. Events without a data payload
275
- // (minimal test doubles) pass the kind check and are handled by the content
276
- // check below.
277
- //
278
- // codingRetrospect: each new distill window contains the complete available
279
- // transcript for that window — user prompts plus assistant public replies, tool
280
- // calls + results and code dispatch output — so the summarizer can see tool
281
- // errors and extract pitfall root causes, not just what the user typed. The same
282
- // filtering stays: only source.kind === "user" prompts enter (plugin/machine
283
- // content is excluded). The result is a single text transcript passed to the
284
- // LLM as one user message (SUMMARY_PROMPT already says "根据下面的会话内容").
285
- //
286
- // Privacy: assistant `reasoning` (private thought) blocks are deliberately NOT
287
- // collected — distilled memories must never sink private reasoning chains.
288
- // Only public text blocks (type "text") reach the summarizer.
289
- function eventSeq(event) {
290
- return Number.isSafeInteger(event?.seq) ? event.seq : undefined;
291
- }
292
-
293
- const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
294
-
295
- // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
296
- // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
297
- //
298
- // Issue #611:图片块曾经在这里一律变空串,于是图-only 消息整条被丢、图+文消息只剩
299
- // 文字——静默,没有任何计数。现在改由 attachment-text 统一产占位,
300
- // `[图: 屏幕截图 … 1543x983]`。抽取口径只有一份,三个调用方(本文件 /
301
- // continuity / hot-memory)共用,避免同一件事写成三份会漂移的实现。
302
- function textOf(content) {
303
- return contentToText(content);
304
- }
305
-
306
- /**
307
- * Issue #399:公开会话正文的**单一隐私谓词**。
308
- *
309
- * 渲染(喂给 LLM 的转录)与落盘(distill_records 原始记录)必须走同一份判定,
310
- * 各写一遍必然漂移——而漂移方向是「存下来的比 LLM 看过的更多」,也就是把从未
311
- * 交给模型的内容留在盘上,是最糟的那个方向。所以这里只回答一个问题:这个事件
312
- * 里有没有**可公开的会话正文**,是什么角色、什么原文;裁剪与格式化交给调用方。
313
- *
314
- * 返回 `[]` 表示不含可公开正文,包含三种情形:
315
- * - 机器注入内容(AGENTS.md、skill 正文、文件变更通知):source.kind 不是
316
- * user / agent-message / subagent-settled,整个事件被挡在门外;
317
- * - 私有推理(assistant 的 reasoning 块):textOf 只认 `type:"text"` 块,
318
- * 思考链在两条路径上都到不了;
319
- * - 工具调用与结果、code 输出:它们是**渲染专属**内容(codingRetrospect 需要
320
- * 模型看见工具报错才能沉淀踩坑根因),但 #399 明确不落盘(已知边界 3),
321
- * 因此由 collectMessages 的渲染分支单独处理,不进这个函数。
322
- *
323
- * @returns {Array<{role: "user"|"assistant"|"agent", text: string, key?: string}>}
324
- * text 是**未截断**原文;key 仅子会话消息用于跨事件去重。
325
- */
326
- function publicTranscriptParts(event) {
327
- const data = event?.data ?? {};
328
- const kind = data?.source?.kind;
329
- const parts = [];
330
- const pushAgentMessage = (message) => {
331
- const messageKind = message?.source?.kind;
332
- if (!SUBAGENT_MESSAGE_KINDS.has(messageKind)) return;
333
- const text = textOf(message?.content);
334
- if (!text.trim()) return;
335
- parts.push({
336
- role: "agent",
337
- text,
338
- // Issue #611:把产文本的那份 content 一起带出去。判定与文本同源,计量才能
339
- // 跟着同一份判定走(见 collectMessages 里的 imageCount)——另写一套「哪些块
340
- // 算公开」给计量用,就是第二份实现,迟早与这份漂移。
341
- blocks: Array.isArray(message?.content) ? message.content : [],
342
- // 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced
343
- // 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。
344
- key: typeof message?.id === "string" && message.id
345
- ? `id:${message.id}`
346
- : `content:${messageKind}:${text}`
347
- });
348
- };
349
- switch (event?.type) {
350
- case "user/message": {
351
- if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break;
352
- const text = textOf(data?.content);
353
- if (!text.trim()) break;
354
- if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data);
355
- else parts.push({ role: "user", text, blocks: Array.isArray(data?.content) ? data.content : [] });
356
- break;
357
- }
358
- case "agent/inbox/spliced": {
359
- const inserted = Array.isArray(data?.inserted) ? data.inserted : [];
360
- for (const message of inserted) pushAgentMessage(message);
361
- break;
362
- }
363
- case "assistant/message": {
364
- const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
365
- const text = textOf(blocks);
366
- // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
367
- if (text.trim()) parts.push({ role: "assistant", text, blocks });
368
- break;
369
- }
370
- default:
371
- break;
372
- }
373
- return parts;
374
- }
375
-
376
- // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events。
377
- // snapshotEvents 的范围参数按 seq 传入;再做一次事件级过滤,是为了兼容
378
- // 旧实现忽略参数、或把边界解释为闭区间的情况,同时避免用数组下标充当游标。
379
- function snapshotSessionEvents(session, afterSeq, throughSeq) {
380
- let events;
381
- if (typeof session?.snapshotEvents === "function") {
382
- events = afterSeq === undefined && throughSeq === undefined
383
- ? session.snapshotEvents()
384
- : session.snapshotEvents(afterSeq, throughSeq);
385
- } else {
386
- events = session?.events ?? [];
387
- }
388
- if (!Array.isArray(events)) events = session?.events ?? [];
389
- if (!Array.isArray(events)) return [];
390
- return events.filter((event) => {
391
- const seq = eventSeq(event);
392
- // 一旦已有 seq 游标,没有 seq 的事件就无法证明是新增事件,不能重新打开
393
- // 已经成功消费过的窗口。
394
- if (afterSeq !== undefined && (seq === undefined || seq <= afterSeq)) return false;
395
- if (throughSeq !== undefined && seq !== undefined && seq > throughSeq) return false;
396
- return true;
397
- });
398
- }
399
-
400
- function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, throughSeq) {
401
- const events = snapshotSessionEvents(session, afterSeq, throughSeq);
402
- const lines = [];
403
- const seenSubagentMessages = new Set();
404
- // Issue #611:这一窗里被折成占位的图片张数。占位本身不丢信息,但「折了几张」
405
- // 必须是个能读到的数——否则 off 档和旧版静默丢弃在观测上无法区分,下次再有人
406
- // 问「图到底进来没有」还是得翻源码。计量只数窗口内事件,不受截断影响。
407
- let imageCount = 0;
408
- const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
409
- const trimTranscript = (s, n) => {
410
- if (typeof s !== "string" || s.length <= n) return s;
411
- if (n <= 1) return "…".slice(0, n);
412
- return `…${s.slice(-(n - 1))}`;
413
- };
414
- const appendSubagentMessage = (message) => {
415
- const kind = message?.source?.kind;
416
- if (!SUBAGENT_MESSAGE_KINDS.has(kind)) return;
417
- const text = textOf(message?.content);
418
- if (!text.trim()) return;
419
- const key = typeof message?.id === "string" && message.id
420
- ? `id:${message.id}`
421
- : `content:${kind}:${text}`;
422
- if (seenSubagentMessages.has(key)) return;
423
- seenSubagentMessages.add(key);
424
- lines.push(STR.transcriptAgent[language](text));
425
- };
426
- for (const event of events) {
427
- const data = event?.data ?? {};
428
- // #399:用户提示 / 助手公开正文 / 子会话交付三种可公开内容由共享谓词产出,
429
- // 渲染与落盘因此不可能对「什么算公开」产生分歧(见 publicTranscriptParts)。
430
- for (const part of publicTranscriptParts(event)) {
431
- if (part.role === "agent") {
432
- if (part.key && seenSubagentMessages.has(part.key)) continue;
433
- if (part.key) seenSubagentMessages.add(part.key);
434
- }
435
- // Issue #611 计量:数「真正进了转录的图块」,所以按 part 数、并且在去重之后
436
- // ——同一条子会话消息会同时出现在 user/message 与 agent/inbox/spliced 里
437
- // (投递路径重叠),按事件数会把它算两遍。
438
- //
439
- // 不按事件数还有个更要紧的理由:被 publicTranscriptParts 挡掉的注入消息
440
- // (工具结果、插件注入、陌生 source.kind)里的图根本进不了转录,按事件累加
441
- // 会报「这一窗有 2 张图」而转录里一张都没有。part 是渲染与落盘共用的同一份
442
- // 判定,计量跟着它走,两处不会再各有一套口径。
443
- imageCount += countImages(part.blocks);
444
- if (part.role === "user") {
445
- lines.push(STR.transcriptUser[language](part.text));
446
- } else if (part.role === "agent") {
447
- lines.push(STR.transcriptAgent[language](part.text));
448
- } else {
449
- lines.push(STR.transcriptAssistant[language](part.text));
450
- }
451
- }
452
- // 渲染专属内容:工具调用/结果与 code 输出进 LLM 转录(codingRetrospect 靠
453
- // 它们沉淀踩坑根因),但不进 distill_records——#399 已知边界 3 明说不覆盖。
454
- switch (event.type) {
455
- case "tool/call": {
456
- const args = typeof data.arguments === "string"
457
- ? data.arguments
458
- : data.arguments ? JSON.stringify(data.arguments) : "";
459
- lines.push(STR.transcriptToolCall[language](data.name ?? "?", trim(args, 300)));
460
- break;
461
- }
462
- case "tool/result": {
463
- // 工具结果有两种形状:旧形状把载荷与 isError 装在 content 内的
464
- // `type:"tool-result"` 块上;DSH 0.2.x 起内核改为直接投递 `role:"tool"`
465
- // 消息本身,content 里只有 `type:"text"` 块、isError 挂在消息上。只认
466
- // 旧块形状时新形状落进 undefined 分支,工具输出与工具报错被静默丢弃。
467
- const message = data?.message;
468
- const blocks = Array.isArray(message?.content) ? message.content : [];
469
- const legacy = blocks.find((block) => block?.type === "tool-result");
470
- const result = legacy ?? (message?.role === "tool" ? message : undefined);
471
- const out = textOf(result?.content);
472
- const status = result?.isError === true ? STR.statusFail[language] : STR.statusOk[language];
473
- lines.push(STR.transcriptToolResult[language](status, trim(out, 500)));
474
- break;
475
- }
476
- case "tool/code-dispatch": {
477
- const out = textOf(data.content);
478
- const status = data.isError === true ? STR.statusFail[language] : STR.statusOk[language];
479
- lines.push(STR.transcriptCode[language](status, trim(out, 500)));
480
- break;
481
- }
482
- default:
483
- break;
484
- }
485
- }
486
- const lastSeq = events.reduce((max, event) => {
487
- const seq = eventSeq(event);
488
- if (seq === undefined) return max;
489
- return max === undefined ? seq : Math.max(max, seq);
490
- }, undefined);
491
- const transcriptBody = trimTranscript(lines.join("\n"), maxChars);
492
- return {
493
- messages: lines.length
494
- ? [createUserMessage({
495
- content: [{
496
- type: "text",
497
- // Issue #493:转录裸放 user 位时,模型把它当成「轮到我说了」接着对话
498
- // 往下写,输出里连 `[` 都没有(解析器在括号早退处直接判失败)。围栏 +
499
- // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是
500
- // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。
501
- // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。
502
- text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${breakFenceMarkers(transcriptBody)}\n${STR.transcriptFenceClose[language]}`
503
- }],
504
- source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
505
- })]
506
- : [],
507
- hasEvents: events.length > 0,
508
- lastSeq,
509
- // Issue #611:这一窗折成占位的图片张数。调用方把它写进审计 metadata,
510
- // 让「图进来了几张」成为可对账的事实而不是推断。
511
- imageCount,
512
- // Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的
513
- // distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少
514
- // 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值,
515
- // 闸门随之失效(实测 summarizeMinWindowChars=10 会被 4 字正文 + 包裹放行)。
516
- transcriptChars: lines.length ? transcriptBody.length : 0
517
- };
518
- }
519
-
520
- /**
521
- * Issue #399:把当前蒸馏窗口里**可公开的会话正文**收集成待落盘记录。
522
- *
523
- * 与 collectMessages 共用 publicTranscriptParts,但**不截断**:留存的意义就是
524
- * 整段原文(超长用户提示在记录里必须全文,而喂给 LLM 的转录仍受 distillMaxChars
525
- * 约束)——两条路径在裁剪上的差异是设计的一部分,测试锁 3 显式覆盖。
526
- *
527
- * 只收有 seq 的事件:distill_records 的主键是 (session_id, seq),seq 也是溯源
528
- * 区间与幂等的唯一依据;没有 seq 就无法表达「这条记录属于哪个窗口」。
529
- */
530
- function collectTranscriptRecords(session, afterSeq, throughSeq, distilledAt) {
531
- const events = snapshotSessionEvents(session, afterSeq, throughSeq);
532
- const records = [];
533
- const seenKeys = new Set();
534
- // 窗口的 seq 区间取窗口内**全部**事件的 min/max(不只是公开正文那些):这是
535
- // LLM 实际看到的那个窗口,溯源链接要回答的是「这条记忆出自哪个窗口」。
536
- let seqStart;
537
- let seqEnd;
538
- for (const event of events) {
539
- const seq = eventSeq(event);
540
- if (seq !== undefined) {
541
- if (seqStart === undefined || seq < seqStart) seqStart = seq;
542
- if (seqEnd === undefined || seq > seqEnd) seqEnd = seq;
543
- }
544
- if (seq === undefined) continue;
545
- const parts = [];
546
- for (const part of publicTranscriptParts(event)) {
547
- if (part.key) {
548
- if (seenKeys.has(part.key)) continue;
549
- seenKeys.add(part.key);
550
- }
551
- parts.push(part);
552
- }
553
- if (!parts.length) continue;
554
- // 主键是 (session_id, seq):一个事件一行。agent/inbox/spliced 一次可投递多条
555
- // 子会话消息(同 seq、同 role),各自成行会被 INSERT OR IGNORE 静默丢掉后几条;
556
- // 按事件合并成一行才是这套主键的真实语义(角色同事件内不会混:user/message 与
557
- // assistant/message 各只产出一条公开 part)。
558
- records.push({
559
- sessionId: session.id,
560
- seq,
561
- role: parts[0].role,
562
- text: parts.map((part) => part.text).join("\n"),
563
- windowId: `session:${session.id}:${seq}`,
564
- distilledAt
565
- });
566
- }
567
- return { records, seqStart, seqEnd };
568
- }
569
-
570
- // ── 智能调速器(429 保护)─────────────────────────────────────────────
571
- // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
572
- // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
573
- // distillRateLimitIntervalMs 分批放行;命中 429 时按 distillRateLimitBaseDelayMs
574
- // 指数退避(1s→2s→4s…)自动重试 distillRateLimitRetries 次,全程对用户透明,
575
- // 不把 429 错误码直接抛出去。
576
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
577
-
578
- function isRateLimited(error) {
579
- const status = error?.status ?? error?.statusCode ?? error?.response?.status;
580
- if (status === 429) return true;
581
- const msg = String(error?.message ?? error ?? "");
582
- return /429|rate.?limit|too many requests|请求过于频繁/i.test(msg);
583
- }
584
-
585
- // 全局串行链:每个蒸馏任务在前一个结束后才开始,单次失败不阻塞后续。
586
- // 相邻请求间隔按"距上一个结束不足 intervalMs 就补齐等待"实现——单次蒸馏
587
- // 零延迟(上次结束距今已超过间隔,直接放行),只有连续批量蒸馏才触发限速。
588
- let distillQueue = Promise.resolve();
589
- let lastDistillEnd = 0;
590
- function enqueueDistill(task, intervalMs = 0) {
591
- const run = distillQueue.then(async () => {
592
- if (intervalMs > 0) {
593
- const wait = Math.max(0, intervalMs - (Date.now() - lastDistillEnd));
594
- if (wait > 0) await sleep(wait);
595
- }
596
- lastDistillEnd = Date.now();
597
- return task();
598
- });
599
- distillQueue = run.catch(() => {});
600
- return run;
601
- }
602
-
603
- // Issue #239(第 4 项,错峰队列):高峰时段解析与判定已抽到独立零依赖模块
604
- // src/peak-hours.js(PR #320 review:#316 后 summarize 反向依赖 dream,dream 镜像
605
- // 错峰再 import 本文件会成真循环;时段调度本就不是蒸馏私有语义)。这里 re-export
606
- // 兼容既有调用方与测试(test/summarize.test.js 从本文件 import),零改动。
607
- // 注意 re-export 不引入本地作用域——本文件 331/387 行仍调用这两个函数,
608
- // import + re-export 两行都要。
609
- import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
610
- export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
611
- export function createSummarizer(ctx, service, config, deps = {}, sessionGate = null) {
612
- if (!config.autoSummarize) return { dispose: () => {} };
613
-
614
- // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
615
- // 覆盖「高峰顺延 → 非高峰补跑」。房型同 dream/sleep.js 的注入参数。
616
- const now = typeof deps.now === "function" ? deps.now : () => Date.now();
617
- const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
618
- const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
619
-
620
- // v0.9.0(M.O. 项目记忆):蒸馏写入也要盖作用域章。此前只有 memory_save 那条工具
621
- // 路径盖章(tools.js 的 resolveSessionScope 盖章块,2026-10-14 在 :174-181),而机器提炼是写入量的绝大多数——于是「项目记忆」
622
- // 在库里根本不存在(全库带 scope 的行不足 3%,且带 scope 的全部来自工具路径)。
623
- // 这里不另造口径:同一把解析器(scope.js 的 createScopeResolver,2026-10-13 在 :24)、同一把闸门
624
- // (config.scopeEnabled === true)、同一对列(*_scope + *_scope_source='auto'),
625
- // 与工具路径逐字对齐。解析器的入参形状是「工具 exec」(内部读 exec.agent.session),
626
- // 蒸馏路径手里直接有会话对象,所以包一层壳喂进去——不改解析器的公共契约。
627
- const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
628
-
629
- const inFlight = new Map();
630
- // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
631
- // 同生命周期,dispose 时一并清空。
632
- const lastRunAt = new Map();
633
- // Issue #239:per-session 已发起的蒸馏次数(有界检查点用)。只在实际发起 LLM
634
- // 调用时自增,因此被零 LLM 预判挡下的窗口不消耗预算。
635
- const runsUsed = new Map();
636
- // Issue #239(第 4 项):每会话最多挂一个待补跑定时器(错峰队列用);dispose 时
637
- // 一并清理,避免插件卸载后还留着一个会调模型的定时器。
638
- const deferredRuns = new Map();
639
- // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
640
- const lastDistilledSeq = new Map();
641
- // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性
642
- // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即清零。
643
- //
644
- // #493 补充 §②:**落库**,不再是进程内 Map。原先的 Map 有两个洞:重启即清零
645
- // (护栏可被重启绕过,而它防的正是「确定性失败反复重试」),以及进程内无法跨
646
- // 两个宿主共享同一个库时保持一致。库里读出来的值带读侧失效窗口(见
647
- // PARSE_FAILURE_STREAK_STALE_MS),停了很久的会话不会被旧 streak 误伤。
648
- // service 没这一步(旧装配 / 测试替身)时退回进程内 Map,行为与历史版本一致。
649
- const parseFailureStreakFallback = new Map();
650
- const persistentStreak = () => typeof service?.getDistillParseFailureStreak === "function"
651
- && typeof service?.bumpDistillParseFailureStreak === "function"
652
- && typeof service?.clearDistillParseFailureStreak === "function";
653
- function readParseFailureStreak(sessionId) {
654
- if (!persistentStreak()) return parseFailureStreakFallback.get(sessionId) ?? 0;
655
- try {
656
- return Number(service.getDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS })) || 0;
657
- } catch (error) {
658
- // 读数坏了不许把蒸馏本身拖下水:退回进程内计数(最坏是多试一次)。
659
- ctx.logger?.warn?.(`dsh-mneme: parse failure streak read failed: ${String(error)}`);
660
- return parseFailureStreakFallback.get(sessionId) ?? 0;
661
- }
662
- }
663
- function bumpParseFailureStreak(sessionId) {
664
- if (!persistentStreak()) {
665
- const next = (parseFailureStreakFallback.get(sessionId) ?? 0) + 1;
666
- parseFailureStreakFallback.set(sessionId, next);
667
- return next;
668
- }
669
- try {
670
- // 失效判断由 store 那一侧负责(同一个 staleAfterMs),这里不再自己算一遍——
671
- // 算两遍就等于有两份失效口径,迟早分叉。
672
- const written = Number(service.bumpDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS }));
673
- if (Number.isFinite(written) && written > 0) return written;
674
- } catch (error) {
675
- ctx.logger?.warn?.(`dsh-mneme: parse failure streak write failed: ${String(error)}`);
676
- }
677
- const next = readParseFailureStreak(sessionId) + 1;
678
- parseFailureStreakFallback.set(sessionId, next);
679
- return next;
680
- }
681
- function clearParseFailureStreak(sessionId) {
682
- parseFailureStreakFallback.delete(sessionId);
683
- if (!persistentStreak()) return;
684
- try {
685
- service.clearDistillParseFailureStreak(sessionId);
686
- } catch (error) {
687
- ctx.logger?.warn?.(`dsh-mneme: parse failure streak clear failed: ${String(error)}`);
688
- }
689
- }
690
- let disposed = false;
691
-
692
- /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
693
- function writeAudit(entry) {
694
- if (config?.llmAudit?.enabled === false || typeof service.saveLlmAudit !== "function") return;
695
- try {
696
- service.saveLlmAudit({
697
- trigger_source: "autoSummarize",
698
- operation_type: "summarize_compress",
699
- related_memory_ids: [],
700
- ...entry
701
- });
702
- } catch (auditError) {
703
- ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
704
- }
705
- }
706
-
707
- function persistCursor(sessionId, nextSeq) {
708
- if (!Number.isFinite(nextSeq)) return;
709
- if (typeof service.setDistillCursor !== "function") {
710
- // 第三方宿主拿旧版 service 构造时没有持久化游标能力:降级为内存游标
711
- // (#274 之前的行为),本进程内不重复蒸馏,重启后窗口重放由
712
- // saveWithDedupe 三元组兜底。方法存在但抛错仍向上传播——那是
713
- // 「写失败须回滚」的恰一次语义,不能吞(见 summarize.test.js 回滚用例)。
714
- ctx.logger?.warn?.("dsh-mneme: service.setDistillCursor unavailable, distill cursor falls back to in-memory");
715
- return;
716
- }
717
- service.setDistillCursor(sessionId, nextSeq);
718
- }
719
-
720
- function commitCursor(sessionId, nextSeq) {
721
- persistCursor(sessionId, nextSeq);
722
- if (Number.isFinite(nextSeq)) lastDistilledSeq.set(sessionId, nextSeq);
723
- }
724
-
725
- /**
726
- * Issue #239(第 4 项):把这一轮蒸馏推迟到最近的「高峰结束」时刻。每会话只挂
727
- * 一个定时器(重复触发不叠加);被 summarizePeakMaxDeferMinutes 截断时到点照跑
728
- * (bypassPeak),长高峰不会把蒸馏饿死。定时器 unref——不阻止宿主退出。
729
- */
730
- function scheduleDeferredRun(session) {
731
- if (disposed || deferredRuns.has(session.id)) return;
732
- const at = nextOffPeakAt(new Date(now()), config.summarizePeakHours ?? "");
733
- if (!at) return;
734
- const maxDeferMs = (config.summarizePeakMaxDeferMinutes ?? 0) * 60000;
735
- let delay = Math.max(0, at.getTime() - now());
736
- const capped = maxDeferMs > 0 && delay > maxDeferMs;
737
- if (capped) delay = maxDeferMs;
738
- const timer = setTimer(() => {
739
- deferredRuns.delete(session.id);
740
- if (disposed) return;
741
- // 不传触发事件:补跑要蒸的是「累积后的整窗」(从上次成功游标到最新事件),
742
- // 拿老 triggerEvent 反而会把窗口卡在它那个 seq 上。
743
- summarize(session, undefined, { bypassPeak: capped }).catch((error) => {
744
- if (disposed || error?.name === "AbortError") return;
745
- ctx.logger?.warn?.(`dsh-mneme: deferred summarization failed: ${String(error)}`);
746
- });
747
- }, delay);
748
- timer?.unref?.();
749
- deferredRuns.set(session.id, timer);
750
- }
751
-
752
- async function summarize(session, triggerEvent, opts = {}) {
753
- if (disposed || inFlight.has(session.id)) return;
754
- if (sessionGate?.isOff(session)) return;
755
-
756
- const header = session.requestHeader?.()?.config;
757
- // Config override takes priority, then session header, then nothing.
758
- const route = (config.summarizeProvider && config.summarizeModel)
759
- ? { provider: config.summarizeProvider, model: config.summarizeModel }
760
- : (header?.provider && header?.model)
761
- ? { provider: header.provider, model: header.model }
762
- : undefined;
763
-
764
- // Issue #127 间隔门:同一会话两次蒸馏之间的最小间隔(0 = 现状,不限)。进入
765
- // 即打点——失败/degraded 的 run 也占用间隔,与 dreamMinIntervalMinutes 语义
766
- // 一致,防止失败连发。被挡下的 turn/end 静默跳过,但留一行 status='skipped'
767
- // 审计,否则「这一轮为什么没蒸馏」对用户完全不可观测。
768
- const gapMs = (config.summarizeMinIntervalMinutes ?? 0) * 60000;
769
- const prevClaim = lastRunAt.get(session.id);
770
- if (gapMs > 0 && Date.now() - (prevClaim ?? 0) < gapMs) {
771
- writeAudit({
772
- timestamp: new Date().toISOString(),
773
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
774
- status: "skipped",
775
- error_message: "min-interval"
776
- });
777
- return;
778
- }
779
- // Issue #239(有界检查点):每会话 run 预算。放在间隔门之后,两者都命中时
780
- // 先报间隔——间隔是「刚刚跑过」,预算是「额度用尽」,前者信息量更大。skip
781
- // 仍写审计(沿用间隔门的 error_message 口径),否则「为什么不再蒸馏了」对
782
- // 用户不可观测。prevRuns 在这里捕获、finally 里按 aborted 回滚(同 lastRunAt)。
783
- // 游标刻意不消费:预算是「这个会话先不再蒸馏了」,把窗口留在原地,日后调高
784
- // 额度(或宿主重启)仍能蒸馏到它;这与 window-too-small 主动消费游标相反——
785
- // 后者是「这些事件不值得蒸馏」,留着只会每个 turn/end 重评一次。
786
- // Issue #239(第 4 项,错峰队列):高峰期不调模型——只登记一行 skip 审计并择时
787
- // 补跑。游标刻意不消费:窗口继续累积,留到非高峰一次性蒸馏(批量比逐轮碎蒸更
788
- // 省)。被 maxDefer 截断后到点仍处高峰时,由 opts.bypassPeak 放行。
789
- if (!opts.bypassPeak && isInPeakWindow(new Date(now()), config.summarizePeakHours ?? "")) {
790
- writeAudit({
791
- timestamp: new Date(now()).toISOString(),
792
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
793
- status: "skipped",
794
- error_message: "peak-hours"
795
- });
796
- scheduleDeferredRun(session);
797
- return;
798
- }
799
- const prevRuns = runsUsed.get(session.id);
800
- const maxRuns = config.summarizeMaxRunsPerSession ?? 0;
801
- if (maxRuns > 0 && (prevRuns ?? 0) >= maxRuns) {
802
- writeAudit({
803
- timestamp: new Date().toISOString(),
804
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
805
- status: "skipped",
806
- error_message: "max-runs-per-session"
807
- });
808
- return;
809
- }
810
- const controller = new AbortController();
811
- inFlight.set(session.id, controller);
812
- // audit state for the compression call. null = no audit for this run
813
- // (disabled, or no LLM call was actually made). The audit row is written in
814
- // the finally below — once, regardless of which exit path the call took —
815
- // so a failed/aborted stream still leaves a status='error' trail without
816
- // ever blocking the summarization itself.
817
- let audit = null;
818
- let abortedRun = false;
819
- // #512:run 的三个事实必须活在 try 之外——finally 要落 run 行,而 finally 看不见
820
- // try 块内声明的 let/const(会 ReferenceError,且那一下会把 inFlight 的清理一起跳过)。
821
- let distillRunId = null;
822
- let distillRunOpenedAt = null;
823
- let distilledMemoryCount = 0;
824
- let distilledWindow = null;
825
- let distillRunStatus = null;
826
- let distillRunError = null;
827
- try {
828
- if (!route) return;
829
- const persistedCursor = typeof service.getDistillCursor === "function"
830
- ? service.getDistillCursor(session.id)
831
- : undefined;
832
- const previousSeq = persistedCursor?.last_seq ?? lastDistilledSeq.get(session.id);
833
- const triggerSeq = eventSeq(triggerEvent);
834
- // 只读取上次成功游标之后、当前 turn/end 之前的事件。旧版 snapshotEvents
835
- // 即使忽略范围参数,collectMessages 仍会按事件 seq 二次过滤。
836
- const collected = collectMessages(
837
- session,
838
- config.distillMaxChars ?? 24000,
839
- langOf(config),
840
- previousSeq,
841
- triggerSeq
842
- );
843
- const hasNewEvents = collected.hasEvents
844
- || (triggerSeq !== undefined && (previousSeq === undefined || triggerSeq > previousSeq));
845
- if (!hasNewEvents) return;
846
-
847
- const nextSeq = Math.max(collected.lastSeq ?? Number.NEGATIVE_INFINITY, triggerSeq ?? Number.NEGATIVE_INFINITY);
848
- if (!collected.messages.length) {
849
- // 没有可蒸馏的公开文本也算成功消费当前事件窗口,避免每个 turn/end
850
- // 都重新扫描同一批无内容事件;没有 seq 时则不提交不可验证的游标。
851
- commitCursor(session.id, nextSeq);
852
- return;
853
- }
854
- const messages = collected.messages;
855
- // Issue #239(成本感知级联第一级):零 LLM 预判。窗口内可蒸馏文本不足阈值
856
- // 时直接跳过——短窗口交给模型的产出多是泛泛而谈,代价却是一次完整调用。
857
- // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个
858
- // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载
859
- // 决定下一级规则的依据。
860
- //
861
- // 字符数改为无条件计算并写进审计 metadata:它是「下一级预判该不该开、阈值
862
- // 该给多少」的唯一依据,而原先开关一关就一个数都不留(维护者本机 1240 条
863
- // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置
864
- // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策——
865
- // summarizeMinWindowChars 仍为 0 时行为逐字节不变。
866
- //
867
- // Issue #493:口径改为只数转录正文(collected.transcriptChars),不再把
868
- // user 消息整段长度当窗口大小——#493 给转录加了围栏与角色句,若继续按
869
- // 「消息总字符数」算,一条 4 字的「短」也会被包裹撑过任何阈值,闸门等于
870
- // 被自己的提示词旁路掉。
871
- const distillChars = collected.transcriptChars ?? messages.reduce(
872
- (total, message) => total + (Array.isArray(message.content) ? message.content : [])
873
- .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
874
- 0
875
- );
876
- const minWindowChars = config.summarizeMinWindowChars ?? 0;
877
- const imageCount = collected.imageCount ?? 0;
878
- if (minWindowChars > 0 && distillChars < minWindowChars) {
879
- commitCursor(session.id, nextSeq);
880
- writeAudit({
881
- timestamp: new Date().toISOString(),
882
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
883
- status: "skipped",
884
- error_message: "window-too-small",
885
- metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}) }
886
- });
887
- return;
888
- }
889
- // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按
890
- // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。
891
- lastRunAt.set(session.id, Date.now());
892
- runsUsed.set(session.id, (prevRuns ?? 0) + 1);
893
-
894
- // #512:一轮蒸馏 = 一个 run。落点选在这里而不是更早——上面所有 return(间隔门、
895
- // 高峰门、run 预算、空窗口、窗口过小)都不是一轮真蒸馏,各有自己的审计口径;
896
- // 从这里往下就是「调用确实发生过」,与 lastRunAt 那两行的边界完全重合。
897
- // run 的落盘在 finally 里独立 try/catch:归因是审计主体,写不成只该 warn,
898
- // 不该把已经落盘的记忆连坐。所以本函数在 store 没有该能力时整体降级为无归因。
899
- // 归因用的三件事都从**真正发生的事**里取,不从推断里取:区间取实际写进
900
- // memory_provenance 的那个窗口(transcript 关了就没有),条数取实际写成功的
901
- // 记忆数(含 merged——被并入的既有行确实由这段原文改动)。
902
- if (typeof service?.saveDistillRun === "function") {
903
- distillRunId = randomUUID();
904
- distillRunOpenedAt = new Date(now()).toISOString();
905
- }
906
-
907
- if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
908
- audit = {
909
- route,
910
- timestamp: new Date().toISOString(),
911
- startedAt: Date.now(),
912
- inputTokens: 0,
913
- outputTokens: 0,
914
- status: "success",
915
- errorMessage: null
916
- };
917
- }
918
-
919
- // Issue #315:蒸馏思考强度。'none'/未配置都不发送字段(服务商默认生效,
920
- // 行为与历史版本一致);off/low/medium/high 原样传递。
921
- const summarizeEffort = config.summarizeReasoningEffort;
922
- const withEffort = typeof summarizeEffort === "string" && summarizeEffort !== "none";
923
-
924
- // 时间锚点:转录行不带时间戳,prompt 要模型把「昨天/上周」换算成绝对
925
- // occurred_at,就必须给基准,否则它只能编(幻觉)。锚点取本次蒸馏触发时刻
926
- // (可注入 now(),测试用假时钟);锚点行同时是 prompt 里「按提示末尾给出的
927
- // 当前时间换算」那句的落点,改写/移动随两处一起动。
928
- const distillPrompt = config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)];
929
- const anchoredPrompt = `${distillPrompt}${STR.distillTimeAnchor[langOf(config)](new Date(now()).toISOString())}`;
930
-
931
- const options = {
932
- provider: route.provider,
933
- model: route.model,
934
- purpose: "summarization",
935
- messages: [
936
- { role: "system", content: [{ type: "text", text: anchoredPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
937
- ...messages
938
- ],
939
- signal: controller.signal
940
- };
941
- // 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
942
- // 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
943
- const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
944
- // Issue #315:流式失败原因暂存槽。蒸馏的流失败以 aborted 结果而非 throw
945
- // 返回,withEffortFallback 靠它甄别「effort 被拒收」型 aborted(dream 侧
946
- // runNarratives 的 streamFailure 同款模式)。
947
- let streamFailure = "";
948
- const runDistill = (withEffort) => {
949
- streamFailure = "";
950
- return enqueueDistill(async () => {
951
- const retries = config.distillRateLimitRetries ?? 3;
952
- const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
953
- // 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
954
- // 不带 reasoningEffort,不能复用带字段的同一 options 对象。
955
- const callOptions = withEffort && summarizeEffort
956
- ? { ...options, reasoningEffort: summarizeEffort }
957
- : options;
958
- for (let attempt = 0; ; attempt++) {
959
- // 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
960
- // error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
961
- // (否则「摘要成功但审计报失败」,污染 llm_audit_logs 统计)。
962
- if (audit) audit.status = "success";
963
- const assembler = new BlockAssembler();
964
- let text = "";
965
- let aborted = false;
966
- try {
967
- for await (const chunk of ctx.llm.stream(callOptions)) {
968
- if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
969
- if (chunk.type === "text-delta") {
970
- text += chunk.text ?? chunk.delta ?? "";
971
- }
972
- if (chunk.type === "usage" && audit) {
973
- // 同 dream.js:真实协议是 {type:"usage", usage:TokenUsage},用量不在
974
- // chunk 顶层;顶层读取只作为把用量平铺的替身兜底。
975
- const usage = chunk.usage ?? chunk;
976
- const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
977
- const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
978
- if (Number.isFinite(i)) audit.inputTokens = i;
979
- if (Number.isFinite(o)) audit.outputTokens = o;
980
- }
981
- if (chunk.type === "finish") {
982
- const reasonKind = chunk.reason?.kind ?? chunk.kind;
983
- if (reasonKind === "error" || reasonKind === "aborted") {
984
- // 429 也可能以 finish reason error 携带 rate-limit 信息,统一
985
- // 转抛错走指数退避重试。
986
- if (isRateLimited(chunk.reason ?? chunk)) {
987
- throw Object.assign(new Error("rate limited"), { status: 429 });
988
- }
989
- // Issue #315:把失败原因原样留给外层的 effort 甄别
990
- // (withEffortFallback 只认「effort 被拒收」型失败)。
991
- streamFailure = describeStreamFailure(chunk.reason ?? chunk);
992
- if (audit) {
993
- audit.status = "error";
994
- audit.errorMessage = `llm stream ${reasonKind}`;
995
- }
996
- aborted = true;
997
- break;
998
- }
999
- }
1000
- }
1001
- // Direct delta accumulation is the primary extraction path (it works
1002
- // for real protocol chunks {index,text} and looser {delta} shapes
1003
- // alike); the assembler blocks are a fallback for streams that only
1004
- // deliver text inside block-end. This dsh-llm exposes no public
1005
- // no-arg assemble() — blocks() is the message-level API.
1006
- const blocks = assembler.blocks();
1007
- const assembledText = blocks
1008
- .filter((b) => b.type === "text")
1009
- .map((b) => b.text ?? "")
1010
- .join("");
1011
- return { text, assembledText, aborted };
1012
- } catch (error) {
1013
- if (error?.name === "AbortError" || controller.signal.aborted) throw error; // dispose 中止直接放行
1014
- if (isRateLimited(error) && attempt < retries) {
1015
- const delay = baseDelayMs * 2 ** attempt;
1016
- ctx.logger?.warn?.(
1017
- `dsh-mneme: 蒸馏请求过于频繁(429),为避免限流等待 ${delay}ms 后自动重试(第 ${attempt + 1}/${retries} 次)`
1018
- );
1019
- await sleep(delay);
1020
- continue;
1021
- }
1022
- // 429 重试耗尽或非 429 错误:记 audit 后抛出,保持原失败路径。
1023
- if (audit) {
1024
- audit.status = "error";
1025
- audit.errorMessage = String(error?.message ?? error);
1026
- }
1027
- throw error;
1028
- }
1029
- }
1030
- }, intervalMs);
1031
- };
1032
- // Issue #315:effort 被拒收时自动去掉字段重试一次(dream/sleep/entity
1033
- // 同一降级策略,withEffortFallback 共享)。蒸馏的流失败以 aborted 结果
1034
- // 而非 throw/undefined 返回,因此甄别在这里做:effort 型失败折叠成
1035
- // undefined,让 withEffortFallback 走 streamFailure 甄别分支触发重试;
1036
- // 非 effort 的 aborted 原样返回,仍走既有的 abortedRun 路径。
1037
- const result = await withEffortFallback(
1038
- ctx,
1039
- summarizeEffort,
1040
- () => runDistill(withEffort).then((r) =>
1041
- (r?.aborted && EFFORT_REJECT_RE.test(streamFailure)) ? undefined : r),
1042
- () => runDistill(false),
1043
- () => streamFailure
1044
- );
1045
- const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
1046
- if (aborted) {
1047
- abortedRun = true;
1048
- // #512:aborted 不等于「没发生」——LLM 调用真的发出去了,窗口也因此没被消费。
1049
- // 只有 dispose / 会话关闭才是「这一轮被主动打断」;模型侧流失败是一次失败的轮次,
1050
- // 两者在 run 台账上必须分得开,否则「这个窗口为什么一直没记忆」查不到原因。
1051
- distillRunStatus = controller.signal.aborted ? "aborted" : "failed";
1052
- distillRunError = controller.signal.aborted
1053
- ? "disposed"
1054
- : (streamFailure || "llm stream aborted");
1055
- return;
1056
- }
1057
- const parsedResult = parseSummaryJsonResult(text || assembledText);
1058
- // 解析失败不推进 seq 游标,下一次 turn/end 仍会重试同一窗口。
1059
- if (!parsedResult.ok) {
1060
- // Issue #493:失败也要留证据。原先 metadata 只在成功路径赋值,失败行恒为
1061
- // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何
1062
- // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。
1063
- const raw = text || assembledText || "";
1064
- const streak = bumpParseFailureStreak(session.id);
1065
- // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。
1066
- // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随
1067
- // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与
1068
- // 历史版本逐字节一致(本仓新行为一律 opt-in,见 AGENTS.md)。
1069
- const maxParseRetries = config.summarizeParseFailureMaxRetries ?? 0;
1070
- // nextSeq 必须是有限数才算真的「跳过了窗口」:没有可验证的 seq 时
1071
- // commitCursor 是空操作(persistCursor 早退、内存游标也不写),此时若
1072
- // 仍按跳过记账并清零 streak,审计会宣称「已放弃」而窗口下一轮照旧重试
1073
- // —— 那是最坏的一种假象:用户以为止血了,实际还在烧。
1074
- const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries && Number.isFinite(nextSeq);
1075
- if (skipWindow) {
1076
- // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。
1077
- commitCursor(session.id, nextSeq);
1078
- clearParseFailureStreak(session.id);
1079
- }
1080
- // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
1081
- // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
1082
- const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
1083
- // #512:解析失败也是失败轮,run 要和 audit 记同一句原因(两个口径不一致
1084
- // 等于让人去猜哪一边是对的)。
1085
- distillRunStatus = "failed";
1086
- distillRunError = failureAudit.errorMessage;
1087
- if (audit) {
1088
- audit.status = "error";
1089
- audit.errorMessage = failureAudit.errorMessage;
1090
- audit.metadata = {
1091
- distillChars,
1092
- ...(imageCount > 0 ? { imageCount } : {}),
1093
- ...failureAudit.metadata,
1094
- streak,
1095
- ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}),
1096
- ...(skipWindow ? { skipped: "parse-failure-streak" } : {})
1097
- };
1098
- }
1099
- return;
1100
- }
1101
- clearParseFailureStreak(session.id);
1102
- const parsed = parsedResult.entries;
1103
- // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
1104
- // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
1105
- const cap = config.summarizeMaxEntriesPerRun ?? 0;
1106
- const entries = cap > 0 ? parsed.slice(0, cap) : parsed;
1107
- const capped = parsed.length - entries.length;
1108
- const dedupeMode = config.summarizeDedupeMode ?? "off";
1109
- let deduped = 0;
1110
- let dedupeMaxSim = 0;
1111
- // v0.9.0(M.O. 项目记忆):一次窗口解析一次——同一蒸馏窗口里所有条目同属一个
1112
- // 工作区、一个 agent 身份,逐条解析是白花钱。解析不到就落 NULL(NULL = 未标注 =
1113
- // 全局可见),且解析器绝不抛错(scope.js 的硬约束:记忆写入不该被作用域拖挂)。
1114
- // 闸门与 tools.js 的 autoStamping(2026-10-14 在 :175)同一把:关着就一个字段都不加,老行为不变。
1115
- const writeScope = config?.scopeEnabled === true
1116
- ? resolveSessionScope({ agent: { session } })
1117
- : null;
1118
- const writes = [];
1119
- for (const entry of entries) {
1120
- // Provenance: the summarizer runs on a real session (turn/end hook), so
1121
- // session.id is always available here — it rides the human-readable
1122
- // source label.
1123
- // 编码记忆类型(codingRetrospect)不带 tag:读取侧门控/加权靠 m.type
1124
- // (rejected_solution/pitfall/constraint)区分即可,tag 体系
1125
- // sanitizeTags 不认 `type:` 前缀反而会清空 tags 列(额外一次 UPDATE)。
1126
- const source = `session:${session.id}`;
1127
- // Issue #127 落库前去重(opt-in,默认 off = 现状):命中则并入既有条目
1128
- // (saveWithDedupe 的 _mergeInto 分支),不再新造一行。作用域 = 同会话 +
1129
- // 时间窗;跨会话的同类事实仍交给 autoDream / sleep 的批量裁决。
1130
- const dup = (dedupeMode !== "off" && typeof service.findSessionDuplicate === "function")
1131
- ? await service.findSessionDuplicate(
1132
- { ...entry, source },
1133
- {
1134
- mode: dedupeMode,
1135
- minSim: config.summarizeDedupeMinSim ?? 0.92,
1136
- windowHours: config.summarizeDedupeWindowHours ?? 24,
1137
- source
1138
- }
1139
- )
1140
- : undefined;
1141
- if (dup) {
1142
- deduped++;
1143
- dedupeMaxSim = Math.max(dedupeMaxSim, dup.sim ?? 0);
1144
- }
1145
- writes.push({ entry, source, dup });
1146
- }
1147
- // #399:蒸馏窗口的原始公开记录 + 记忆级溯源链接,与记忆写入同事务落盘。
1148
- // 凭证与它的派生记忆必须同生共死:记忆写了而记录没写,溯源就断在「不知道
1149
- // 出自哪段原文」;记录写了而记忆没写,盘上留一份没有索引的原文。默认关
1150
- // (enabled !== true)时连收集都不做,一次 SQL 都不发(测试锁 5)。
1151
- const distilledAt = new Date(now()).toISOString();
1152
- const transcript = config?.distillRecords?.enabled === true
1153
- && typeof service.saveDistillRecords === "function"
1154
- ? collectTranscriptRecords(session, previousSeq, triggerSeq, distilledAt)
1155
- : null;
1156
- const writeWindow = () => {
1157
- const savedIds = [];
1158
- for (const { entry, source, dup } of writes) {
1159
- const outcome = service.saveWithDedupe({
1160
- ...entry,
1161
- source,
1162
- // 合并进既有行时不盖章,但这件事由 saveWithDedupe 的合并分支自己保证
1163
- // (它只认 explicit 来源升级,从不采纳这里传的 auto 字段)——调用方不能
1164
- // 靠「省略字段」来表达这个意图,因为省略会反向把合并也挡掉:
1165
- // findSessionDuplicate 只按 type+source+时间窗找候选,不看 scope;找到
1166
- // 候选后由 saveWithDedupe 的 scopeMatches 门决定并或不并。这里一省略,
1167
- // 新条目的 scope 就是 null,与已盖过章的候选不等,门必拒 → 回落新建,
1168
- // 而那一行同样是新行、同样该带章,却没有章可带。实测两轮蒸馏正是如此:
1169
- // 第二轮命中第一轮、门拒后新建,留下同题两行一行 auto 一行 NULL——
1170
- // 作用域隔离没做成,重复倒留下了。传字段、让合并分支按自己的口径处理,
1171
- // 才是「不重贴」与「新行必贴」两个要求的唯一相容解。
1172
- ...(writeScope ? { agent_scope: writeScope.agent_scope, agent_scope_source: "auto" } : {}),
1173
- ...(writeScope ? { workspace_scope: writeScope.workspace_scope, workspace_scope_source: "auto" } : {}),
1174
- ...(dup ? { _mergeInto: dup.memory.id } : {})
1175
- });
1176
- // created 与 merged 两条成功分支都返回 memory(含 id);merged 时 id 是
1177
- // 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
1178
- if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
1179
- }
1180
- // #512:run 的条数口径是「本次写成功的记忆行数」,与 savedIds 同源——
1181
- // 另起一个计数器会在 merged 分支上与它对不上(那一路也 push 了 id)。
1182
- distilledMemoryCount = savedIds.length;
1183
- if (transcript && transcript.records.length) {
1184
- service.saveDistillRecords(transcript.records);
1185
- if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined) {
1186
- distilledWindow = { seqStart: transcript.seqStart, seqEnd: transcript.seqEnd };
1187
- }
1188
- if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
1189
- && typeof service.saveMemoryProvenance === "function") {
1190
- for (const memoryId of savedIds) {
1191
- service.saveMemoryProvenance({
1192
- memoryId,
1193
- sessionId: session.id,
1194
- seqStart: transcript.seqStart,
1195
- seqEnd: transcript.seqEnd,
1196
- createdAt: distilledAt,
1197
- // #512:链接指回产出它的那一轮。distillRecords 关着时这里本就不跑,
1198
- // 于是那一轮不会留下任何窗口级归因——这是诚实的(没存原文就没有
1199
- // 「这段原文」可指),run 行本身照落,条数与状态仍然可查。
1200
- distillRunId: distillRunId ?? undefined
1201
- });
1202
- }
1203
- }
1204
- }
1205
- persistCursor(session.id, nextSeq);
1206
- };
1207
- if (writes.length || (transcript && transcript.records.length)) {
1208
- // 查询去重目标可以异步执行,但记忆写入必须在同一个事务中完成。
1209
- // 否则第 N 条写入失败时,前 N-1 条会残留,而重试同一 seq 窗口会重复
1210
- // 追加它们。createService 始终提供 transaction;缺失时直接失败并保留
1211
- // 游标窗口,避免退回到不具备原子性的部分写入。
1212
- if (typeof service.transaction !== "function") {
1213
- throw new Error("dsh-mneme: atomic summarization writes require service.transaction");
1214
- }
1215
- service.transaction(writeWindow);
1216
- } else {
1217
- // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
1218
- persistCursor(session.id, nextSeq);
1219
- }
1220
- // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
1221
- // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
1222
- // 一条都不留——下一级预判规则的阈值就无从定标。
1223
- // #512:走到这里说明整轮真的落地了(记忆写入 + 游标提交都没抛)。状态按
1224
- // 实际产出分档,与 dream_runs 的 status 词表对齐:没有可写的记忆是 noop,
1225
- // 有记忆就是 ok。degraded 留给「写了但解析有损」那一路(下面按 salvaged 判)。
1226
- distillRunStatus = distilledMemoryCount > 0
1227
- ? (parsedResult.salvaged ? "degraded" : "ok")
1228
- : "noop";
1229
- if (audit) {
1230
- audit.metadata = {
1231
- distillChars,
1232
- ...(imageCount > 0 ? { imageCount } : {}),
1233
- parsed: parsed.length,
1234
- ...(parsedResult.salvaged ? { json_salvaged: true } : {}),
1235
- ...(capped > 0 ? { capped } : {}),
1236
- ...(deduped > 0 ? { deduped, mode: dedupeMode, maxSim: Number(dedupeMaxSim.toFixed(4)) } : {})
1237
- };
1238
- }
1239
- // 记忆写入和解析都成功后才提交窗口;流失败、中止、解析失败或写入异常
1240
- // 都会在此之前退出,从而保留窗口供下一次重试。
1241
- if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
1242
- } catch (error) {
1243
- distillRunStatus = abortedRun || controller.signal.aborted ? "aborted" : "failed";
1244
- distillRunError = String(error?.message ?? error);
1245
- if (audit) {
1246
- audit.status = "error";
1247
- audit.errorMessage = String(error?.message ?? error);
1248
- }
1249
- throw error;
1250
- } finally {
1251
- // Issue #127:aborted(会话关闭 / 插件 dispose)不占间隔,避免误伤该会话的
1252
- // 下一次蒸馏;其余情况(含失败)的打点保留,与 dreamMinIntervalMinutes 一致。
1253
- if (abortedRun || controller.signal.aborted) {
1254
- if (prevClaim === undefined) lastRunAt.delete(session.id);
1255
- else lastRunAt.set(session.id, prevClaim);
1256
- // Issue #239:aborted 同样不占 run 预算——调用没有真正发生,占了会误伤
1257
- // 该会话后续的蒸馏(与上面的间隔回滚同一口径)。
1258
- if (prevRuns === undefined) runsUsed.delete(session.id);
1259
- else runsUsed.set(session.id, prevRuns);
1260
- }
1261
- // #512:run 与它的开销读数(audit 行)分开落,且都不许把已经写好的记忆连坐——
1262
- // 归因是审计,写不成只该 warn。顺序刻意是「先 audit 后 run」:run.audit_id 要
1263
- // 引用刚插进去的那一行,而 saveLlmAudit 是按 lastInsertRowid 回读的
1264
- // (#534/#535 那条注释),拿到的就是自己那行,不会被并发写串。
1265
- let auditRow = null;
1266
- if (audit) {
1267
- try {
1268
- auditRow = service.saveLlmAudit({
1269
- timestamp: audit.timestamp,
1270
- trigger_source: "autoSummarize",
1271
- operation_type: "summarize_compress",
1272
- model_id: `${audit.route.provider}:${audit.route.model}`,
1273
- input_tokens: audit.inputTokens,
1274
- output_tokens: audit.outputTokens,
1275
- total_tokens: audit.inputTokens + audit.outputTokens,
1276
- cost_usd: 0,
1277
- duration_ms: Date.now() - audit.startedAt,
1278
- status: audit.status,
1279
- error_message: audit.errorMessage,
1280
- related_memory_ids: [],
1281
- // Issue #127:parsed / capped / deduped 的观测数据。零 schema 变更
1282
- // (llm_audit_logs.metadata 已存在),一条 SQL 即可验证节流是否生效。
1283
- metadata: audit.metadata
1284
- });
1285
- } catch (auditError) {
1286
- ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
1287
- }
1288
- }
1289
- // #512:一轮真蒸馏对应一行 distill_runs,落点必须在这里(finally)而不是
1290
- // 事务里:run 是审计主体,事务只负责「记忆 + 原文 + 链接」同生共死;把 run
1291
- // 塞进事务会让一个审计写失败回滚掉已经算出来的记忆。
1292
- // status 为 null 说明在开轮之前就退出了(间隔门/高峰门/短窗口),那不是一轮。
1293
- if (distillRunId && distillRunStatus) {
1294
- try {
1295
- service.saveDistillRun({
1296
- id: distillRunId,
1297
- created_at: distillRunOpenedAt,
1298
- status: distillRunStatus,
1299
- error: distillRunError ?? undefined,
1300
- run_type: "autoSummarize",
1301
- trigger_source: "autoSummarize",
1302
- session_id: session.id,
1303
- seq_start: distilledWindow?.seqStart,
1304
- seq_end: distilledWindow?.seqEnd,
1305
- memory_count: distilledMemoryCount,
1306
- provider: audit?.route?.provider ?? route?.provider,
1307
- model: audit?.route?.model ?? route?.model,
1308
- audit_id: auditRow?.id
1309
- });
1310
- } catch (runError) {
1311
- ctx.logger?.warn?.(`dsh-mneme: distill run write failed: ${String(runError)}`);
1312
- }
1313
- }
1314
- inFlight.delete(session.id);
1315
- }
1316
- }
1317
-
1318
- const unsubscribe = ctx.on("session/event", (session, event) => {
1319
- if (disposed || event.type !== "turn/end") return;
1320
- // Return the summarization promise so awaiters observe the writes; the
1321
- // catch keeps listener dispatch from rejecting. Dispose-initiated aborts
1322
- // and external AbortErrors are silent.
1323
- return summarize(session, event).catch((error) => {
1324
- if (disposed || error?.name === "AbortError") return;
1325
- ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
1326
- });
1327
- });
1328
-
1329
- return {
1330
- dispose() {
1331
- if (disposed) return;
1332
- disposed = true;
1333
- unsubscribe?.();
1334
- for (const timer of deferredRuns.values()) clearTimer(timer);
1335
- deferredRuns.clear();
1336
- for (const controller of inFlight.values()) controller.abort();
1337
- inFlight.clear();
1338
- lastRunAt.clear();
1339
- lastDistilledSeq.clear();
1340
- parseFailureStreakFallback.clear();
1341
- }
1342
- };
1343
- }
1
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
+ import { STR, langOf } from "./lang.js";
3
+ // Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
4
+ // 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
5
+ // 三条链路同一降级语义,不另造第二份实现。
6
+ import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
7
+ // v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
8
+ import { createScopeResolver } from "./scope.js";
9
+ // #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时
10
+ // 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。
11
+ import { breakFenceMarkers } from "./fence.js";
12
+ // Issue #611:图片块的文本口径(占位,不是空串)。三个 textOf 副本共用这一份。
13
+ import { contentToText, countImages } from "./attachment-text.js";
14
+ // 图像描述档(docs/DISTILL.md:346):占位之外再补一段「图里是什么」。本文件只
15
+ // 负责把描述并进转录行;出描述的调用、准入判断都在 image-describe.js。
16
+ import { appendDescriptions, describeImages, imageBlocks, canSeeImages } from "./image-describe.js";
17
+ // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
18
+ // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
22
+ // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
23
+ // title/content 单列结构(store 无结构化字段),信息浓缩进 content。
24
+ /** 解析 LLM 输出中的 JSON 数组,并保留数组是否有效的结果。 */
25
+ // 蒸馏产物的事件时间锚定(SimpleMem,arXiv 2601.02553:抽取与时间锚定在同一次
26
+ // 生成里完成,消融去掉后时间类问题 F1 掉 56.7%)。归一放在解析层而不是交给
27
+ // store.normalizeOccurredAt:后者只判「可解析就 UTC ISO、否则 NULL」,拦不住模型
28
+ // 编造的未来时间——而未来时间的记忆会污染注入侧的时间排序与 memory_search 的
29
+ // occurred_from 过滤。未知/无法换算的相对时间必须整条省略字段,不能落 null。
30
+ // 纯函数:唯一外部输入是显式入参 nowMs(测试注入假时钟即可确定性复现)。
31
+ const OCCURRED_AT_FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000;
32
+ // #493 补充 §②:持久化的解析失败计数超过这么久没更新就按 0 算(读侧失效,见
33
+ // store.getDistillParseFailureStreak)。7 天:够长,能跨一次长假/一次停机;够短,
34
+ // 不至于让「停了很久的会话」拿几周前的 streak 直接熔断一个其实健康的窗口。
35
+ // 刻意不做成配置键:护栏本身默认关(summarizeParseFailureMaxRetries=0),一个只为
36
+ // 护栏服务的失效窗口不该再占一档用户可见配置(AGENTS.md 旗标计数锁也要跟着动)。
37
+ const PARSE_FAILURE_STREAK_STALE_MS = 7 * 24 * 60 * 60 * 1000;
38
+ function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) {
39
+ if (typeof raw !== "string") return undefined;
40
+ const text = raw.trim();
41
+ if (!text) return undefined;
42
+ const ms = Date.parse(text);
43
+ if (!Number.isFinite(ms)) return undefined;
44
+ // 容忍 1 天:模型给的绝对时间可能略晚于当下(时区/时钟偏移),超出即视为瞎填。
45
+ if (ms - nowMs > OCCURRED_AT_FUTURE_TOLERANCE_MS) return undefined;
46
+ try {
47
+ return new Date(ms).toISOString();
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * 失败形态分类(#502),只在结果不 ok 时产出:
55
+ * - no-array:连 `[` 都没有——模型在说话,不是没按格式;
56
+ * - syntax:有方括号、整体 parse 失败且括号扫描也没救回条目(#339 那一族);
57
+ * - filtered-empty:JSON 合法、`arr` 非空,条目却被 VALID 全数滤掉。**这一种最贵**:
58
+ * 读起来像「没按格式写」,实际是分类词不在枚举内,而游标不推进会让整窗反复重放。
59
+ */
60
+ function describeParseFailure(arr, entries, salvaged, hasBracket) {
61
+ if (!Array.isArray(arr) || salvaged) return { kind: hasBracket ? "syntax" : "no-array", arrCount: 0, sampleTypes: [] };
62
+ if (arr.length === 0) return { kind: "no-array", arrCount: 0, sampleTypes: [] };
63
+ if (entries.length > 0) return null;
64
+ const sampleTypes = [...new Set(arr.map((item) => (typeof item?.type === "string" ? item.type.trim() : "")).filter(Boolean))].slice(0, 5);
65
+ return { kind: "filtered-empty", arrCount: arr.length, sampleTypes };
66
+ }
67
+
68
+ /**
69
+ * 失败行的审计读数(纯函数:不驱动整条蒸馏管线也能测)。filtered-empty 的
70
+ * errorMessage 直接写出 arr/valid 两个数,一眼可辨;metadata 补 failure_kind /
71
+ * arr_count / invalid_entries / sample_types / raw_tail——尾巴是给「先写几条合法
72
+ * 条目再跑偏」那种输出准备的,前 300 字看不出来。
73
+ */
74
+ export function summarizeParseFailureAudit(raw, failure) {
75
+ const text = String(raw ?? "");
76
+ const kind = failure?.kind ?? (text.includes("[") ? "syntax" : "no-array");
77
+ const tail = text.length > 500 ? text.slice(-200) : "";
78
+ const filtered = kind === "filtered-empty" ? failure : null;
79
+ return {
80
+ errorMessage: filtered ? `no valid summary entries (arr=${filtered.arrCount}, valid=0)` : "invalid summary JSON",
81
+ metadata: {
82
+ parse_failure: true,
83
+ failure_kind: kind,
84
+ raw_len: text.length,
85
+ raw_head: text.slice(0, 300),
86
+ ...(tail ? { raw_tail: tail } : {}),
87
+ raw_has_bracket: text.includes("["),
88
+ ...(filtered ? { arr_count: filtered.arrCount, invalid_entries: filtered.arrCount, sample_types: filtered.sampleTypes } : {})
89
+ }
90
+ };
91
+ }
92
+
93
+ /** 导出的原因:#502 要能锁住「哪一种失败」这条判据本身,而不是只锁它的下游读数。 */
94
+ export function parseSummaryJsonResult(raw) {
95
+ const text = String(raw ?? "");
96
+ const start = text.indexOf("[");
97
+ const end = text.lastIndexOf("]");
98
+ // 连方括号都没有 = 模型在说话(#493 的 B 臂正是这一型)。早退也要带上下:否则下游
99
+ // 只能靠「输出里有没有 [」再猜一次,而猜出来的东西不该写进错误消息。
100
+ if (start === -1 || end === -1 || end <= start) {
101
+ return { ok: false, entries: [], failure: describeParseFailure(null, [], false, text.includes("[")) };
102
+ }
103
+ let arr;
104
+ let salvaged = false;
105
+ try {
106
+ arr = JSON.parse(text.slice(start, end + 1));
107
+ } catch {
108
+ // issue #339 / E8 实测:10-20% 的窗口输出了含记忆条目的完整对象,只因中段
109
+ // 一处语法错误被整窗 JSON.parse 拒收;而失败路径的生产行为是游标不推进 +
110
+ // 温度 0 重试同文同错 = 该窗口记忆静默丢失。这里做失败路径修复:括号配对
111
+ // 扫描截出完整的顶层对象逐个 parse,救活多少算多少;截出 0 个不视为「模型
112
+ // 显式说无内容」(那是 ok:true 专属于真实空数组的语义)。
113
+ arr = salvageArrayItems(text.slice(start, end + 1));
114
+ salvaged = true;
115
+ }
116
+ if (!Array.isArray(arr)) return { ok: false, entries: [] };
117
+ const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]);
118
+ const entries = arr.filter(
119
+ (item) =>
120
+ item &&
121
+ typeof item === "object" &&
122
+ VALID.has(item.type) &&
123
+ typeof item.title === "string" &&
124
+ item.title.trim() &&
125
+ typeof item.content === "string" &&
126
+ item.content.trim()
127
+ ).map((item) => {
128
+ const occurredAt = normalizeDistilledOccurredAt(item.occurred_at);
129
+ return {
130
+ type: item.type,
131
+ title: item.title.trim(),
132
+ content: item.content.trim(),
133
+ importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3,
134
+ // 不可解析 / 未来时间:省略该键(不写 null、不写空串),让「模型没给」与
135
+ // 「给了但站不住」在库里同为「无时间锚」,避免下游把空值当成已锚定。
136
+ ...(occurredAt !== undefined ? { occurred_at: occurredAt } : {})
137
+ };
138
+ });
139
+ // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效,
140
+ // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径
141
+ // 没有「显式空数组」可言——ok 只看是否救回了条目。
142
+ const ok = salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0);
143
+ return {
144
+ ok,
145
+ entries,
146
+ salvaged,
147
+ // #502:失败时要能说出**是哪一种**失败。「压根没写数组」与「写了合法数组、但条目
148
+ // type 全不在枚举里」过去共用一句 invalid summary JSON,而两者要修的地方完全不同
149
+ // ——后者是菜单与闸门不一致(summary prompt 只列 4 个类型、VALID 吃 7 个),且失败
150
+ // 路径游标不推进,同一窗口会被一遍遍重放(社区报告 #493 追加的实测形态)。
151
+ ...(!ok ? { failure: describeParseFailure(arr, entries, salvaged, text.includes("[")) } : {})
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Salvage scanner for malformed JSON arrays (issue #339): extracts every
157
+ * complete top-level {...} span, parsing each independently. Two passes,
158
+ * merged with de-duplication:
159
+ * - pass 1 is string-aware (handles valid objects whose string values
160
+ * legitimately contain braces);
161
+ * - pass 2 ignores string state entirely — E8 现场的坏对象带奇数个引号
162
+ * (stray quote),pass 1 的奇偶失配会把后续对象的收尾 } 吞进字符串里,
163
+ * 盲扫按括号深度截取反而能救回它们。坏对象两种扫法都 parse 失败,自然
164
+ * 被丢弃;两遍的去重靠序列化键。
165
+ */
166
+ function salvageArrayItems(chunk) {
167
+ const out = [];
168
+ const seen = new Set();
169
+ const push = (obj) => {
170
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
171
+ const key = JSON.stringify(obj);
172
+ if (!seen.has(key)) { seen.add(key); out.push(obj); }
173
+ };
174
+ for (const obj of scanBraceSpans(chunk, true)) push(obj);
175
+ for (const obj of scanBraceSpans(chunk, false)) push(obj);
176
+ return out;
177
+ }
178
+
179
+ function scanBraceSpans(chunk, respectStrings) {
180
+ // 修订(CodeRabbit review on #350):只接受「最外层数组的直接子对象」——
181
+ // 候选 { 必须满足 arrayDepth===1 && objDepth===0 且前一非空白 token 是 [ 或
182
+ // ,。否则 (a) 嵌套子数组里的对象会被误捞成顶层记忆;(b) 盲扫遇到字符串值
183
+ // 里的 } 会提前断 span,把属性值对象当独立记忆落库。误拦的代价只是少救回
184
+ // (安全侧),误捞的代价是写进假记忆。
185
+ const items = [];
186
+ let arrayDepth = 0;
187
+ let objDepth = 0;
188
+ let inString = false;
189
+ let escape = false;
190
+ let objStart = -1;
191
+ let prev = "";
192
+ for (let i = 0; i < chunk.length; i++) {
193
+ const ch = chunk[i];
194
+ if (respectStrings && inString) {
195
+ if (escape) escape = false;
196
+ else if (ch === "\\") escape = true;
197
+ else if (ch === '"') inString = false;
198
+ continue;
199
+ }
200
+ if (respectStrings && ch === '"') { inString = true; continue; }
201
+ if (ch === "[") {
202
+ arrayDepth++;
203
+ prev = "[";
204
+ continue;
205
+ }
206
+ if (ch === "]") {
207
+ arrayDepth = Math.max(0, arrayDepth - 1);
208
+ prev = "]";
209
+ continue;
210
+ }
211
+ if (ch === "{") {
212
+ if (arrayDepth === 1 && objDepth === 0 && (prev === "[" || prev === ",")) {
213
+ objStart = i;
214
+ }
215
+ objDepth++;
216
+ prev = "{";
217
+ continue;
218
+ }
219
+ if (ch === "}") {
220
+ objDepth = Math.max(0, objDepth - 1);
221
+ if (objDepth === 0 && objStart !== -1) {
222
+ try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ }
223
+ objStart = -1;
224
+ }
225
+ prev = "}";
226
+ continue;
227
+ }
228
+ if (ch === ",") { prev = ","; continue; }
229
+ if (!/\s/.test(ch)) prev = ch;
230
+ }
231
+ return items;
232
+ }
233
+
234
+ /** Extract a JSON array from LLM output that may contain prose around it. */
235
+ export function parseSummaryJson(raw) {
236
+ return parseSummaryJsonResult(raw).entries;
237
+ }
238
+
239
+ // The dsh-llm StreamChunk protocol BlockAssembler.push() consumes:
240
+ // block-start {index, blockType}, text-delta {index, text},
241
+ // block-end {index, block}, finish {reason}. Some consumers observe a
242
+ // looser shape ({block} / {delta} / {kind}); normalize before pushing so
243
+ // both real adapter streams and shape-tolerant test doubles assemble.
244
+ const STREAM_CHUNK_TYPES = new Set([
245
+ "block-start",
246
+ "text-delta",
247
+ "reasoning-delta",
248
+ "tool-call-delta",
249
+ "block-end",
250
+ "usage",
251
+ "finish"
252
+ ]);
253
+
254
+ function toProtocolChunk(chunk) {
255
+ switch (chunk.type) {
256
+ case "block-start":
257
+ return { type: "block-start", index: chunk.index ?? 0, blockType: chunk.blockType ?? chunk.block?.type ?? "text" };
258
+ case "text-delta":
259
+ return { type: "text-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
260
+ case "reasoning-delta":
261
+ return { type: "reasoning-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
262
+ case "block-end":
263
+ return { type: "block-end", index: chunk.index ?? 0, block: chunk.block ?? { type: "text" } };
264
+ case "finish":
265
+ return {
266
+ type: "finish",
267
+ reason: chunk.reason ?? { kind: chunk.kind === "error" ? "error" : "stop" },
268
+ replayState: chunk.replayState
269
+ };
270
+ default:
271
+ return chunk;
272
+ }
273
+ }
274
+
275
+ // Only direct human prompts are summarized: plugin-injected context
276
+ // (AGENTS.md, skill bodies, file-change notices) and other machine-originated
277
+ // events must not leak into the memory store. Events without a data payload
278
+ // (minimal test doubles) pass the kind check and are handled by the content
279
+ // check below.
280
+ //
281
+ // codingRetrospect: each new distill window contains the complete available
282
+ // transcript for that window — user prompts plus assistant public replies, tool
283
+ // calls + results and code dispatch output — so the summarizer can see tool
284
+ // errors and extract pitfall root causes, not just what the user typed. The same
285
+ // filtering stays: only source.kind === "user" prompts enter (plugin/machine
286
+ // content is excluded). The result is a single text transcript passed to the
287
+ // LLM as one user message (SUMMARY_PROMPT already says "根据下面的会话内容").
288
+ //
289
+ // Privacy: assistant `reasoning` (private thought) blocks are deliberately NOT
290
+ // collected — distilled memories must never sink private reasoning chains.
291
+ // Only public text blocks (type "text") reach the summarizer.
292
+ function eventSeq(event) {
293
+ return Number.isSafeInteger(event?.seq) ? event.seq : undefined;
294
+ }
295
+
296
+ const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
297
+
298
+ // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
299
+ // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
300
+ //
301
+ // Issue #611:图片块曾经在这里一律变空串,于是图-only 消息整条被丢、图+文消息只剩
302
+ // 文字——静默,没有任何计数。现在改由 attachment-text 统一产占位,
303
+ // `[图: 屏幕截图 … 1543x983]`。抽取口径只有一份,三个调用方(本文件 /
304
+ // continuity / hot-memory)共用,避免同一件事写成三份会漂移的实现。
305
+ function textOf(content) {
306
+ return contentToText(content);
307
+ }
308
+
309
+ /**
310
+ * Issue #399:公开会话正文的**单一隐私谓词**。
311
+ *
312
+ * 渲染(喂给 LLM 的转录)与落盘(distill_records 原始记录)必须走同一份判定,
313
+ * 各写一遍必然漂移——而漂移方向是「存下来的比 LLM 看过的更多」,也就是把从未
314
+ * 交给模型的内容留在盘上,是最糟的那个方向。所以这里只回答一个问题:这个事件
315
+ * 里有没有**可公开的会话正文**,是什么角色、什么原文;裁剪与格式化交给调用方。
316
+ *
317
+ * 返回 `[]` 表示不含可公开正文,包含三种情形:
318
+ * - 机器注入内容(AGENTS.md、skill 正文、文件变更通知):source.kind 不是
319
+ * user / agent-message / subagent-settled,整个事件被挡在门外;
320
+ * - 私有推理(assistant 的 reasoning 块):textOf 只认 `type:"text"` 块,
321
+ * 思考链在两条路径上都到不了;
322
+ * - 工具调用与结果、code 输出:它们是**渲染专属**内容(codingRetrospect 需要
323
+ * 模型看见工具报错才能沉淀踩坑根因),但 #399 明确不落盘(已知边界 3),
324
+ * 因此由 collectMessages 的渲染分支单独处理,不进这个函数。
325
+ *
326
+ * @returns {Array<{role: "user"|"assistant"|"agent", text: string, key?: string}>}
327
+ * text 是**未截断**原文;key 仅子会话消息用于跨事件去重。
328
+ */
329
+ function publicTranscriptParts(event) {
330
+ const data = event?.data ?? {};
331
+ const kind = data?.source?.kind;
332
+ const parts = [];
333
+ const pushAgentMessage = (message) => {
334
+ const messageKind = message?.source?.kind;
335
+ if (!SUBAGENT_MESSAGE_KINDS.has(messageKind)) return;
336
+ const text = textOf(message?.content);
337
+ if (!text.trim()) return;
338
+ parts.push({
339
+ role: "agent",
340
+ text,
341
+ // Issue #611:把产文本的那份 content 一起带出去。判定与文本同源,计量才能
342
+ // 跟着同一份判定走(见 collectMessages 里的 imageCount)——另写一套「哪些块
343
+ // 算公开」给计量用,就是第二份实现,迟早与这份漂移。
344
+ blocks: Array.isArray(message?.content) ? message.content : [],
345
+ // 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced
346
+ // 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。
347
+ key: typeof message?.id === "string" && message.id
348
+ ? `id:${message.id}`
349
+ : `content:${messageKind}:${text}`
350
+ });
351
+ };
352
+ switch (event?.type) {
353
+ case "user/message": {
354
+ if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break;
355
+ const text = textOf(data?.content);
356
+ if (!text.trim()) break;
357
+ if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data);
358
+ else parts.push({ role: "user", text, blocks: Array.isArray(data?.content) ? data.content : [] });
359
+ break;
360
+ }
361
+ case "agent/inbox/spliced": {
362
+ const inserted = Array.isArray(data?.inserted) ? data.inserted : [];
363
+ for (const message of inserted) pushAgentMessage(message);
364
+ break;
365
+ }
366
+ case "assistant/message": {
367
+ const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
368
+ const text = textOf(blocks);
369
+ // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
370
+ if (text.trim()) parts.push({ role: "assistant", text, blocks });
371
+ break;
372
+ }
373
+ default:
374
+ break;
375
+ }
376
+ return parts;
377
+ }
378
+
379
+ // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events。
380
+ // snapshotEvents 的范围参数按 seq 传入;再做一次事件级过滤,是为了兼容
381
+ // 旧实现忽略参数、或把边界解释为闭区间的情况,同时避免用数组下标充当游标。
382
+ function snapshotSessionEvents(session, afterSeq, throughSeq) {
383
+ let events;
384
+ if (typeof session?.snapshotEvents === "function") {
385
+ events = afterSeq === undefined && throughSeq === undefined
386
+ ? session.snapshotEvents()
387
+ : session.snapshotEvents(afterSeq, throughSeq);
388
+ } else {
389
+ events = session?.events ?? [];
390
+ }
391
+ if (!Array.isArray(events)) events = session?.events ?? [];
392
+ if (!Array.isArray(events)) return [];
393
+ return events.filter((event) => {
394
+ const seq = eventSeq(event);
395
+ // 一旦已有 seq 游标,没有 seq 的事件就无法证明是新增事件,不能重新打开
396
+ // 已经成功消费过的窗口。
397
+ if (afterSeq !== undefined && (seq === undefined || seq <= afterSeq)) return false;
398
+ if (throughSeq !== undefined && seq !== undefined && seq > throughSeq) return false;
399
+ return true;
400
+ });
401
+ }
402
+
403
+ async function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, throughSeq, describePart) {
404
+ const events = snapshotSessionEvents(session, afterSeq, throughSeq);
405
+ const lines = [];
406
+ const seenSubagentMessages = new Set();
407
+ // Issue #611:这一窗里被折成占位的图片张数。占位本身不丢信息,但「折了几张」
408
+ // 必须是个能读到的数——否则 off 档和旧版静默丢弃在观测上无法区分,下次再有人
409
+ // 问「图到底进来没有」还是得翻源码。计量只数窗口内事件,不受截断影响。
410
+ let imageCount = 0;
411
+ const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
412
+ const trimTranscript = (s, n) => {
413
+ if (typeof s !== "string" || s.length <= n) return s;
414
+ if (n <= 1) return "…".slice(0, n);
415
+ return `…${s.slice(-(n - 1))}`;
416
+ };
417
+ const appendSubagentMessage = (message) => {
418
+ const kind = message?.source?.kind;
419
+ if (!SUBAGENT_MESSAGE_KINDS.has(kind)) return;
420
+ const text = textOf(message?.content);
421
+ if (!text.trim()) return;
422
+ const key = typeof message?.id === "string" && message.id
423
+ ? `id:${message.id}`
424
+ : `content:${kind}:${text}`;
425
+ if (seenSubagentMessages.has(key)) return;
426
+ seenSubagentMessages.add(key);
427
+ lines.push(STR.transcriptAgent[language](text));
428
+ };
429
+ for (const event of events) {
430
+ const data = event?.data ?? {};
431
+ // #399:用户提示 / 助手公开正文 / 子会话交付三种可公开内容由共享谓词产出,
432
+ // 渲染与落盘因此不可能对「什么算公开」产生分歧(见 publicTranscriptParts)。
433
+ for (const part of publicTranscriptParts(event)) {
434
+ if (part.role === "agent") {
435
+ if (part.key && seenSubagentMessages.has(part.key)) continue;
436
+ if (part.key) seenSubagentMessages.add(part.key);
437
+ }
438
+ // Issue #611 计量:数「真正进了转录的图块」,所以按 part 数、并且在去重之后
439
+ // ——同一条子会话消息会同时出现在 user/message 与 agent/inbox/spliced 里
440
+ // (投递路径重叠),按事件数会把它算两遍。
441
+ //
442
+ // 不按事件数还有个更要紧的理由:被 publicTranscriptParts 挡掉的注入消息
443
+ // (工具结果、插件注入、陌生 source.kind)里的图根本进不了转录,按事件累加
444
+ // 会报「这一窗有 2 张图」而转录里一张都没有。part 是渲染与落盘共用的同一份
445
+ // 判定,计量跟着它走,两处不会再各有一套口径。
446
+ imageCount += countImages(part.blocks);
447
+ // 图像描述档(docs/DISTILL.md:346):开关开着且这一 part 真的带图时,才
448
+ // 去出描述。describePart 由调用方注入(默认 undefined)——本函数不认识
449
+ // 任何路由配置,只负责「文本最后长什么样」,这样单测可以只塞一个桩进去。
450
+ const described = typeof describePart === "function" && part.blocks?.length
451
+ ? await describePart(part)
452
+ : part.text;
453
+ if (part.role === "user") {
454
+ lines.push(STR.transcriptUser[language](described));
455
+ } else if (part.role === "agent") {
456
+ lines.push(STR.transcriptAgent[language](described));
457
+ } else {
458
+ lines.push(STR.transcriptAssistant[language](described));
459
+ }
460
+ }
461
+ // 渲染专属内容:工具调用/结果与 code 输出进 LLM 转录(codingRetrospect 靠
462
+ // 它们沉淀踩坑根因),但不进 distill_records——#399 已知边界 3 明说不覆盖。
463
+ switch (event.type) {
464
+ case "tool/call": {
465
+ const args = typeof data.arguments === "string"
466
+ ? data.arguments
467
+ : data.arguments ? JSON.stringify(data.arguments) : "";
468
+ lines.push(STR.transcriptToolCall[language](data.name ?? "?", trim(args, 300)));
469
+ break;
470
+ }
471
+ case "tool/result": {
472
+ // 工具结果有两种形状:旧形状把载荷与 isError 装在 content 内的
473
+ // `type:"tool-result"` 块上;DSH 0.2.x 起内核改为直接投递 `role:"tool"`
474
+ // 消息本身,content 里只有 `type:"text"` 块、isError 挂在消息上。只认
475
+ // 旧块形状时新形状落进 undefined 分支,工具输出与工具报错被静默丢弃。
476
+ const message = data?.message;
477
+ const blocks = Array.isArray(message?.content) ? message.content : [];
478
+ const legacy = blocks.find((block) => block?.type === "tool-result");
479
+ const result = legacy ?? (message?.role === "tool" ? message : undefined);
480
+ const out = textOf(result?.content);
481
+ const status = result?.isError === true ? STR.statusFail[language] : STR.statusOk[language];
482
+ lines.push(STR.transcriptToolResult[language](status, trim(out, 500)));
483
+ break;
484
+ }
485
+ case "tool/code-dispatch": {
486
+ const out = textOf(data.content);
487
+ const status = data.isError === true ? STR.statusFail[language] : STR.statusOk[language];
488
+ lines.push(STR.transcriptCode[language](status, trim(out, 500)));
489
+ break;
490
+ }
491
+ default:
492
+ break;
493
+ }
494
+ }
495
+ const lastSeq = events.reduce((max, event) => {
496
+ const seq = eventSeq(event);
497
+ if (seq === undefined) return max;
498
+ return max === undefined ? seq : Math.max(max, seq);
499
+ }, undefined);
500
+ const transcriptBody = trimTranscript(lines.join("\n"), maxChars);
501
+ return {
502
+ messages: lines.length
503
+ ? [createUserMessage({
504
+ content: [{
505
+ type: "text",
506
+ // Issue #493:转录裸放 user 位时,模型把它当成「轮到我说了」接着对话
507
+ // 往下写,输出里连 `[` 都没有(解析器在括号早退处直接判失败)。围栏 +
508
+ // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是
509
+ // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。
510
+ // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。
511
+ text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${breakFenceMarkers(transcriptBody)}\n${STR.transcriptFenceClose[language]}`
512
+ }],
513
+ source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
514
+ })]
515
+ : [],
516
+ hasEvents: events.length > 0,
517
+ lastSeq,
518
+ // Issue #611:这一窗折成占位的图片张数。调用方把它写进审计 metadata,
519
+ // 让「图进来了几张」成为可对账的事实而不是推断。
520
+ imageCount,
521
+ // Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的
522
+ // distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少
523
+ // 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值,
524
+ // 闸门随之失效(实测 summarizeMinWindowChars=10 会被 4 字正文 + 包裹放行)。
525
+ transcriptChars: lines.length ? transcriptBody.length : 0
526
+ };
527
+ }
528
+
529
+ /**
530
+ * Issue #399:把当前蒸馏窗口里**可公开的会话正文**收集成待落盘记录。
531
+ *
532
+ * 与 collectMessages 共用 publicTranscriptParts,但**不截断**:留存的意义就是
533
+ * 整段原文(超长用户提示在记录里必须全文,而喂给 LLM 的转录仍受 distillMaxChars
534
+ * 约束)——两条路径在裁剪上的差异是设计的一部分,测试锁 3 显式覆盖。
535
+ *
536
+ * 只收有 seq 的事件:distill_records 的主键是 (session_id, seq),seq 也是溯源
537
+ * 区间与幂等的唯一依据;没有 seq 就无法表达「这条记录属于哪个窗口」。
538
+ */
539
+ function collectTranscriptRecords(session, afterSeq, throughSeq, distilledAt) {
540
+ const events = snapshotSessionEvents(session, afterSeq, throughSeq);
541
+ const records = [];
542
+ const seenKeys = new Set();
543
+ // 窗口的 seq 区间取窗口内**全部**事件的 min/max(不只是公开正文那些):这是
544
+ // LLM 实际看到的那个窗口,溯源链接要回答的是「这条记忆出自哪个窗口」。
545
+ let seqStart;
546
+ let seqEnd;
547
+ for (const event of events) {
548
+ const seq = eventSeq(event);
549
+ if (seq !== undefined) {
550
+ if (seqStart === undefined || seq < seqStart) seqStart = seq;
551
+ if (seqEnd === undefined || seq > seqEnd) seqEnd = seq;
552
+ }
553
+ if (seq === undefined) continue;
554
+ const parts = [];
555
+ for (const part of publicTranscriptParts(event)) {
556
+ if (part.key) {
557
+ if (seenKeys.has(part.key)) continue;
558
+ seenKeys.add(part.key);
559
+ }
560
+ parts.push(part);
561
+ }
562
+ if (!parts.length) continue;
563
+ // 主键是 (session_id, seq):一个事件一行。agent/inbox/spliced 一次可投递多条
564
+ // 子会话消息(同 seq、同 role),各自成行会被 INSERT OR IGNORE 静默丢掉后几条;
565
+ // 按事件合并成一行才是这套主键的真实语义(角色同事件内不会混:user/message 与
566
+ // assistant/message 各只产出一条公开 part)。
567
+ records.push({
568
+ sessionId: session.id,
569
+ seq,
570
+ role: parts[0].role,
571
+ text: parts.map((part) => part.text).join("\n"),
572
+ windowId: `session:${session.id}:${seq}`,
573
+ distilledAt
574
+ });
575
+ }
576
+ return { records, seqStart, seqEnd };
577
+ }
578
+
579
+ // ── 智能调速器(429 保护)─────────────────────────────────────────────
580
+ // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
581
+ // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
582
+ // distillRateLimitIntervalMs 分批放行;命中 429 时按 distillRateLimitBaseDelayMs
583
+ // 指数退避(1s→2s→4s…)自动重试 distillRateLimitRetries 次,全程对用户透明,
584
+ // 不把 429 错误码直接抛出去。
585
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
586
+
587
+ function isRateLimited(error) {
588
+ const status = error?.status ?? error?.statusCode ?? error?.response?.status;
589
+ if (status === 429) return true;
590
+ const msg = String(error?.message ?? error ?? "");
591
+ return /429|rate.?limit|too many requests|请求过于频繁/i.test(msg);
592
+ }
593
+
594
+ // 全局串行链:每个蒸馏任务在前一个结束后才开始,单次失败不阻塞后续。
595
+ // 相邻请求间隔按"距上一个结束不足 intervalMs 就补齐等待"实现——单次蒸馏
596
+ // 零延迟(上次结束距今已超过间隔,直接放行),只有连续批量蒸馏才触发限速。
597
+ let distillQueue = Promise.resolve();
598
+ let lastDistillEnd = 0;
599
+ function enqueueDistill(task, intervalMs = 0) {
600
+ const run = distillQueue.then(async () => {
601
+ if (intervalMs > 0) {
602
+ const wait = Math.max(0, intervalMs - (Date.now() - lastDistillEnd));
603
+ if (wait > 0) await sleep(wait);
604
+ }
605
+ lastDistillEnd = Date.now();
606
+ return task();
607
+ });
608
+ distillQueue = run.catch(() => {});
609
+ return run;
610
+ }
611
+
612
+ // Issue #239(第 4 项,错峰队列):高峰时段解析与判定已抽到独立零依赖模块
613
+ // src/peak-hours.js(PR #320 review:#316 后 summarize 反向依赖 dream,dream 镜像
614
+ // 错峰再 import 本文件会成真循环;时段调度本就不是蒸馏私有语义)。这里 re-export
615
+ // 兼容既有调用方与测试(test/summarize.test.js 从本文件 import),零改动。
616
+ // 注意 re-export 不引入本地作用域——本文件 331/387 行仍调用这两个函数,
617
+ // import + re-export 两行都要。
618
+ import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
619
+ export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
620
+ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = null) {
621
+ if (!config.autoSummarize) return { dispose: () => {} };
622
+
623
+ // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
624
+ // 覆盖「高峰顺延 → 非高峰补跑」。房型同 dream/sleep.js 的注入参数。
625
+ const now = typeof deps.now === "function" ? deps.now : () => Date.now();
626
+ const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
627
+ const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
628
+
629
+ // v0.9.0(M.O. 项目记忆):蒸馏写入也要盖作用域章。此前只有 memory_save 那条工具
630
+ // 路径盖章(tools.js 的 resolveSessionScope 盖章块,2026-10-14 在 :174-181),而机器提炼是写入量的绝大多数——于是「项目记忆」
631
+ // 在库里根本不存在(全库带 scope 的行不足 3%,且带 scope 的全部来自工具路径)。
632
+ // 这里不另造口径:同一把解析器(scope.js 的 createScopeResolver,2026-10-13 在 :24)、同一把闸门
633
+ // (config.scopeEnabled === true)、同一对列(*_scope + *_scope_source='auto'),
634
+ // 与工具路径逐字对齐。解析器的入参形状是「工具 exec」(内部读 exec.agent.session),
635
+ // 蒸馏路径手里直接有会话对象,所以包一层壳喂进去——不改解析器的公共契约。
636
+ const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
637
+
638
+ const inFlight = new Map();
639
+ // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
640
+ // 同生命周期,dispose 时一并清空。
641
+ const lastRunAt = new Map();
642
+ // Issue #239:per-session 已发起的蒸馏次数(有界检查点用)。只在实际发起 LLM
643
+ // 调用时自增,因此被零 LLM 预判挡下的窗口不消耗预算。
644
+ const runsUsed = new Map();
645
+ // Issue #239(第 4 项):每会话最多挂一个待补跑定时器(错峰队列用);dispose 时
646
+ // 一并清理,避免插件卸载后还留着一个会调模型的定时器。
647
+ const deferredRuns = new Map();
648
+ // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
649
+ const lastDistilledSeq = new Map();
650
+ // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性
651
+ // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即清零。
652
+ //
653
+ // #493 补充 §②:**落库**,不再是进程内 Map。原先的 Map 有两个洞:重启即清零
654
+ // (护栏可被重启绕过,而它防的正是「确定性失败反复重试」),以及进程内无法跨
655
+ // 两个宿主共享同一个库时保持一致。库里读出来的值带读侧失效窗口(见
656
+ // PARSE_FAILURE_STREAK_STALE_MS),停了很久的会话不会被旧 streak 误伤。
657
+ // service 没这一步(旧装配 / 测试替身)时退回进程内 Map,行为与历史版本一致。
658
+ const parseFailureStreakFallback = new Map();
659
+ const persistentStreak = () => typeof service?.getDistillParseFailureStreak === "function"
660
+ && typeof service?.bumpDistillParseFailureStreak === "function"
661
+ && typeof service?.clearDistillParseFailureStreak === "function";
662
+ function readParseFailureStreak(sessionId) {
663
+ if (!persistentStreak()) return parseFailureStreakFallback.get(sessionId) ?? 0;
664
+ try {
665
+ return Number(service.getDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS })) || 0;
666
+ } catch (error) {
667
+ // 读数坏了不许把蒸馏本身拖下水:退回进程内计数(最坏是多试一次)。
668
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak read failed: ${String(error)}`);
669
+ return parseFailureStreakFallback.get(sessionId) ?? 0;
670
+ }
671
+ }
672
+ function bumpParseFailureStreak(sessionId) {
673
+ if (!persistentStreak()) {
674
+ const next = (parseFailureStreakFallback.get(sessionId) ?? 0) + 1;
675
+ parseFailureStreakFallback.set(sessionId, next);
676
+ return next;
677
+ }
678
+ try {
679
+ // 失效判断由 store 那一侧负责(同一个 staleAfterMs),这里不再自己算一遍——
680
+ // 算两遍就等于有两份失效口径,迟早分叉。
681
+ const written = Number(service.bumpDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS }));
682
+ if (Number.isFinite(written) && written > 0) return written;
683
+ } catch (error) {
684
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak write failed: ${String(error)}`);
685
+ }
686
+ const next = readParseFailureStreak(sessionId) + 1;
687
+ parseFailureStreakFallback.set(sessionId, next);
688
+ return next;
689
+ }
690
+ function clearParseFailureStreak(sessionId) {
691
+ parseFailureStreakFallback.delete(sessionId);
692
+ if (!persistentStreak()) return;
693
+ try {
694
+ service.clearDistillParseFailureStreak(sessionId);
695
+ } catch (error) {
696
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak clear failed: ${String(error)}`);
697
+ }
698
+ }
699
+ let disposed = false;
700
+
701
+ /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
702
+ function writeAudit(entry) {
703
+ if (config?.llmAudit?.enabled === false || typeof service.saveLlmAudit !== "function") return;
704
+ try {
705
+ service.saveLlmAudit({
706
+ trigger_source: "autoSummarize",
707
+ operation_type: "summarize_compress",
708
+ related_memory_ids: [],
709
+ ...entry
710
+ });
711
+ } catch (auditError) {
712
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
713
+ }
714
+ }
715
+
716
+ function persistCursor(sessionId, nextSeq) {
717
+ if (!Number.isFinite(nextSeq)) return;
718
+ if (typeof service.setDistillCursor !== "function") {
719
+ // 第三方宿主拿旧版 service 构造时没有持久化游标能力:降级为内存游标
720
+ // (#274 之前的行为),本进程内不重复蒸馏,重启后窗口重放由
721
+ // saveWithDedupe 三元组兜底。方法存在但抛错仍向上传播——那是
722
+ // 「写失败须回滚」的恰一次语义,不能吞(见 summarize.test.js 回滚用例)。
723
+ ctx.logger?.warn?.("dsh-mneme: service.setDistillCursor unavailable, distill cursor falls back to in-memory");
724
+ return;
725
+ }
726
+ service.setDistillCursor(sessionId, nextSeq);
727
+ }
728
+
729
+ function commitCursor(sessionId, nextSeq) {
730
+ persistCursor(sessionId, nextSeq);
731
+ if (Number.isFinite(nextSeq)) lastDistilledSeq.set(sessionId, nextSeq);
732
+ }
733
+
734
+ /**
735
+ * Issue #239(第 4 项):把这一轮蒸馏推迟到最近的「高峰结束」时刻。每会话只挂
736
+ * 一个定时器(重复触发不叠加);被 summarizePeakMaxDeferMinutes 截断时到点照跑
737
+ * (bypassPeak),长高峰不会把蒸馏饿死。定时器 unref——不阻止宿主退出。
738
+ */
739
+ function scheduleDeferredRun(session) {
740
+ if (disposed || deferredRuns.has(session.id)) return;
741
+ const at = nextOffPeakAt(new Date(now()), config.summarizePeakHours ?? "");
742
+ if (!at) return;
743
+ const maxDeferMs = (config.summarizePeakMaxDeferMinutes ?? 0) * 60000;
744
+ let delay = Math.max(0, at.getTime() - now());
745
+ const capped = maxDeferMs > 0 && delay > maxDeferMs;
746
+ if (capped) delay = maxDeferMs;
747
+ const timer = setTimer(() => {
748
+ deferredRuns.delete(session.id);
749
+ if (disposed) return;
750
+ // 不传触发事件:补跑要蒸的是「累积后的整窗」(从上次成功游标到最新事件),
751
+ // 拿老 triggerEvent 反而会把窗口卡在它那个 seq 上。
752
+ summarize(session, undefined, { bypassPeak: capped }).catch((error) => {
753
+ if (disposed || error?.name === "AbortError") return;
754
+ ctx.logger?.warn?.(`dsh-mneme: deferred summarization failed: ${String(error)}`);
755
+ });
756
+ }, delay);
757
+ timer?.unref?.();
758
+ deferredRuns.set(session.id, timer);
759
+ }
760
+
761
+ async function summarize(session, triggerEvent, opts = {}) {
762
+ if (disposed || inFlight.has(session.id)) return;
763
+ if (sessionGate?.isOff(session)) return;
764
+
765
+ const header = session.requestHeader?.()?.config;
766
+ // Config override takes priority, then session header, then nothing.
767
+ const route = (config.summarizeProvider && config.summarizeModel)
768
+ ? { provider: config.summarizeProvider, model: config.summarizeModel }
769
+ : (header?.provider && header?.model)
770
+ ? { provider: header.provider, model: header.model }
771
+ : undefined;
772
+
773
+ // Issue #127 间隔门:同一会话两次蒸馏之间的最小间隔(0 = 现状,不限)。进入
774
+ // 即打点——失败/degraded 的 run 也占用间隔,与 dreamMinIntervalMinutes 语义
775
+ // 一致,防止失败连发。被挡下的 turn/end 静默跳过,但留一行 status='skipped'
776
+ // 审计,否则「这一轮为什么没蒸馏」对用户完全不可观测。
777
+ const gapMs = (config.summarizeMinIntervalMinutes ?? 0) * 60000;
778
+ const prevClaim = lastRunAt.get(session.id);
779
+ if (gapMs > 0 && Date.now() - (prevClaim ?? 0) < gapMs) {
780
+ writeAudit({
781
+ timestamp: new Date().toISOString(),
782
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
783
+ status: "skipped",
784
+ error_message: "min-interval"
785
+ });
786
+ return;
787
+ }
788
+ // Issue #239(有界检查点):每会话 run 预算。放在间隔门之后,两者都命中时
789
+ // 先报间隔——间隔是「刚刚跑过」,预算是「额度用尽」,前者信息量更大。skip
790
+ // 仍写审计(沿用间隔门的 error_message 口径),否则「为什么不再蒸馏了」对
791
+ // 用户不可观测。prevRuns 在这里捕获、finally 里按 aborted 回滚(同 lastRunAt)。
792
+ // 游标刻意不消费:预算是「这个会话先不再蒸馏了」,把窗口留在原地,日后调高
793
+ // 额度(或宿主重启)仍能蒸馏到它;这与 window-too-small 主动消费游标相反——
794
+ // 后者是「这些事件不值得蒸馏」,留着只会每个 turn/end 重评一次。
795
+ // Issue #239(第 4 项,错峰队列):高峰期不调模型——只登记一行 skip 审计并择时
796
+ // 补跑。游标刻意不消费:窗口继续累积,留到非高峰一次性蒸馏(批量比逐轮碎蒸更
797
+ // 省)。被 maxDefer 截断后到点仍处高峰时,由 opts.bypassPeak 放行。
798
+ if (!opts.bypassPeak && isInPeakWindow(new Date(now()), config.summarizePeakHours ?? "")) {
799
+ writeAudit({
800
+ timestamp: new Date(now()).toISOString(),
801
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
802
+ status: "skipped",
803
+ error_message: "peak-hours"
804
+ });
805
+ scheduleDeferredRun(session);
806
+ return;
807
+ }
808
+ const prevRuns = runsUsed.get(session.id);
809
+ const maxRuns = config.summarizeMaxRunsPerSession ?? 0;
810
+ if (maxRuns > 0 && (prevRuns ?? 0) >= maxRuns) {
811
+ writeAudit({
812
+ timestamp: new Date().toISOString(),
813
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
814
+ status: "skipped",
815
+ error_message: "max-runs-per-session"
816
+ });
817
+ return;
818
+ }
819
+ const controller = new AbortController();
820
+ inFlight.set(session.id, controller);
821
+ // audit state for the compression call. null = no audit for this run
822
+ // (disabled, or no LLM call was actually made). The audit row is written in
823
+ // the finally below — once, regardless of which exit path the call took —
824
+ // so a failed/aborted stream still leaves a status='error' trail without
825
+ // ever blocking the summarization itself.
826
+ let audit = null;
827
+ let abortedRun = false;
828
+ // #512:run 的三个事实必须活在 try 之外——finally 要落 run 行,而 finally 看不见
829
+ // try 块内声明的 let/const(会 ReferenceError,且那一下会把 inFlight 的清理一起跳过)。
830
+ let distillRunId = null;
831
+ let distillRunOpenedAt = null;
832
+ let distilledMemoryCount = 0;
833
+ let distilledWindow = null;
834
+ let distillRunStatus = null;
835
+ let distillRunError = null;
836
+ try {
837
+ if (!route) return;
838
+ const persistedCursor = typeof service.getDistillCursor === "function"
839
+ ? service.getDistillCursor(session.id)
840
+ : undefined;
841
+ const previousSeq = persistedCursor?.last_seq ?? lastDistilledSeq.get(session.id);
842
+ const triggerSeq = eventSeq(triggerEvent);
843
+ // 只读取上次成功游标之后、当前 turn/end 之前的事件。旧版 snapshotEvents
844
+ // 即使忽略范围参数,collectMessages 仍会按事件 seq 二次过滤。
845
+ // 图像描述档(docs/DISTILL.md:346):三件事按顺序判——开关、路由、模型
846
+ // 是否真能看图。任一不过就整档不跑,**并且不改变转录一个字**(describePart
847
+ // 缺席时 collectMessages 走原路径,输出逐字节等于 #611 的行为)。
848
+ //
849
+ // 判定放在这里而不是 image-describe.js 里:路由来自 config,准入是一次可能
850
+ // 很贵的 resolveModelInfo 调用,两者都该只问一次,而不是每张图问一次。
851
+ let describePart;
852
+ if (config.imageDescribeEnabled === true) {
853
+ const describeProvider = typeof config.imageDescribeProvider === "string" ? config.imageDescribeProvider.trim() : "";
854
+ const describeModel = typeof config.imageDescribeModel === "string" ? config.imageDescribeModel.trim() : "";
855
+ if (describeProvider && describeModel) {
856
+ const sight = await canSeeImages(ctx, describeProvider, describeModel);
857
+ // "unknown"(老适配器不声明能力)照发:宿主那侧对未知是不投影,照原样
858
+ // 交给适配器,它若真看不到再降级成占位——这里替它决定就是猜。
859
+ if (sight !== false) {
860
+ const describeRoute = { provider: describeProvider, model: describeModel };
861
+ // 同一张图在一次蒸馏里只描述一次:同一附件常被重复贴(追问、引用),
862
+ // 按 attachmentId 缓存,省掉重复的视觉调用。
863
+ const described = new Map();
864
+ describePart = async (part) => {
865
+ const blocks = imageBlocks(part.blocks);
866
+ if (blocks.length === 0) return part.text;
867
+ const fresh = blocks.filter((block, i) => {
868
+ const key = block?.attachment?.attachmentId ?? `#${i}`;
869
+ return !described.has(key);
870
+ });
871
+ if (fresh.length > 0) {
872
+ const got = await describeImages(ctx, describeRoute, config, fresh, { signal: controller.signal });
873
+ for (const [key, value] of got) described.set(key, value);
874
+ }
875
+ return appendDescriptions(part.text, blocks, described);
876
+ };
877
+ }
878
+ }
879
+ }
880
+ const collected = await collectMessages(
881
+ session,
882
+ config.distillMaxChars ?? 24000,
883
+ langOf(config),
884
+ previousSeq,
885
+ triggerSeq,
886
+ describePart
887
+ );
888
+ const hasNewEvents = collected.hasEvents
889
+ || (triggerSeq !== undefined && (previousSeq === undefined || triggerSeq > previousSeq));
890
+ if (!hasNewEvents) return;
891
+
892
+ const nextSeq = Math.max(collected.lastSeq ?? Number.NEGATIVE_INFINITY, triggerSeq ?? Number.NEGATIVE_INFINITY);
893
+ if (!collected.messages.length) {
894
+ // 没有可蒸馏的公开文本也算成功消费当前事件窗口,避免每个 turn/end
895
+ // 都重新扫描同一批无内容事件;没有 seq 时则不提交不可验证的游标。
896
+ commitCursor(session.id, nextSeq);
897
+ return;
898
+ }
899
+ const messages = collected.messages;
900
+ // Issue #239(成本感知级联第一级):零 LLM 预判。窗口内可蒸馏文本不足阈值
901
+ // 时直接跳过——短窗口交给模型的产出多是泛泛而谈,代价却是一次完整调用。
902
+ // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个
903
+ // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载
904
+ // 决定下一级规则的依据。
905
+ //
906
+ // 字符数改为无条件计算并写进审计 metadata:它是「下一级预判该不该开、阈值
907
+ // 该给多少」的唯一依据,而原先开关一关就一个数都不留(维护者本机 1240 条
908
+ // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置
909
+ // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策——
910
+ // summarizeMinWindowChars 仍为 0 时行为逐字节不变。
911
+ //
912
+ // Issue #493:口径改为只数转录正文(collected.transcriptChars),不再把
913
+ // user 消息整段长度当窗口大小——#493 给转录加了围栏与角色句,若继续按
914
+ // 「消息总字符数」算,一条 4 字的「短」也会被包裹撑过任何阈值,闸门等于
915
+ // 被自己的提示词旁路掉。
916
+ const distillChars = collected.transcriptChars ?? messages.reduce(
917
+ (total, message) => total + (Array.isArray(message.content) ? message.content : [])
918
+ .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
919
+ 0
920
+ );
921
+ const minWindowChars = config.summarizeMinWindowChars ?? 0;
922
+ const imageCount = collected.imageCount ?? 0;
923
+ if (minWindowChars > 0 && distillChars < minWindowChars) {
924
+ commitCursor(session.id, nextSeq);
925
+ writeAudit({
926
+ timestamp: new Date().toISOString(),
927
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
928
+ status: "skipped",
929
+ error_message: "window-too-small",
930
+ metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}) }
931
+ });
932
+ return;
933
+ }
934
+ // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按
935
+ // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。
936
+ lastRunAt.set(session.id, Date.now());
937
+ runsUsed.set(session.id, (prevRuns ?? 0) + 1);
938
+
939
+ // #512:一轮蒸馏 = 一个 run。落点选在这里而不是更早——上面所有 return(间隔门、
940
+ // 高峰门、run 预算、空窗口、窗口过小)都不是一轮真蒸馏,各有自己的审计口径;
941
+ // 从这里往下就是「调用确实发生过」,与 lastRunAt 那两行的边界完全重合。
942
+ // run 的落盘在 finally 里独立 try/catch:归因是审计主体,写不成只该 warn,
943
+ // 不该把已经落盘的记忆连坐。所以本函数在 store 没有该能力时整体降级为无归因。
944
+ // 归因用的三件事都从**真正发生的事**里取,不从推断里取:区间取实际写进
945
+ // memory_provenance 的那个窗口(transcript 关了就没有),条数取实际写成功的
946
+ // 记忆数(含 merged——被并入的既有行确实由这段原文改动)。
947
+ if (typeof service?.saveDistillRun === "function") {
948
+ distillRunId = randomUUID();
949
+ distillRunOpenedAt = new Date(now()).toISOString();
950
+ }
951
+
952
+ if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
953
+ audit = {
954
+ route,
955
+ timestamp: new Date().toISOString(),
956
+ startedAt: Date.now(),
957
+ inputTokens: 0,
958
+ outputTokens: 0,
959
+ status: "success",
960
+ errorMessage: null
961
+ };
962
+ }
963
+
964
+ // Issue #315:蒸馏思考强度。'none'/未配置都不发送字段(服务商默认生效,
965
+ // 行为与历史版本一致);off/low/medium/high 原样传递。
966
+ const summarizeEffort = config.summarizeReasoningEffort;
967
+ const withEffort = typeof summarizeEffort === "string" && summarizeEffort !== "none";
968
+
969
+ // 时间锚点:转录行不带时间戳,prompt 要模型把「昨天/上周」换算成绝对
970
+ // occurred_at,就必须给基准,否则它只能编(幻觉)。锚点取本次蒸馏触发时刻
971
+ // (可注入 now(),测试用假时钟);锚点行同时是 prompt 里「按提示末尾给出的
972
+ // 当前时间换算」那句的落点,改写/移动随两处一起动。
973
+ const distillPrompt = config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)];
974
+ const anchoredPrompt = `${distillPrompt}${STR.distillTimeAnchor[langOf(config)](new Date(now()).toISOString())}`;
975
+
976
+ const options = {
977
+ provider: route.provider,
978
+ model: route.model,
979
+ purpose: "summarization",
980
+ messages: [
981
+ { role: "system", content: [{ type: "text", text: anchoredPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
982
+ ...messages
983
+ ],
984
+ signal: controller.signal
985
+ };
986
+ // 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
987
+ // 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
988
+ const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
989
+ // Issue #315:流式失败原因暂存槽。蒸馏的流失败以 aborted 结果而非 throw
990
+ // 返回,withEffortFallback 靠它甄别「effort 被拒收」型 aborted(dream 侧
991
+ // runNarratives 的 streamFailure 同款模式)。
992
+ let streamFailure = "";
993
+ const runDistill = (withEffort) => {
994
+ streamFailure = "";
995
+ return enqueueDistill(async () => {
996
+ const retries = config.distillRateLimitRetries ?? 3;
997
+ const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
998
+ // 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
999
+ // 不带 reasoningEffort,不能复用带字段的同一 options 对象。
1000
+ const callOptions = withEffort && summarizeEffort
1001
+ ? { ...options, reasoningEffort: summarizeEffort }
1002
+ : options;
1003
+ for (let attempt = 0; ; attempt++) {
1004
+ // 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
1005
+ // error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
1006
+ // (否则「摘要成功但审计报失败」,污染 llm_audit_logs 统计)。
1007
+ if (audit) audit.status = "success";
1008
+ const assembler = new BlockAssembler();
1009
+ let text = "";
1010
+ let aborted = false;
1011
+ try {
1012
+ for await (const chunk of ctx.llm.stream(callOptions)) {
1013
+ if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
1014
+ if (chunk.type === "text-delta") {
1015
+ text += chunk.text ?? chunk.delta ?? "";
1016
+ }
1017
+ if (chunk.type === "usage" && audit) {
1018
+ // 同 dream.js:真实协议是 {type:"usage", usage:TokenUsage},用量不在
1019
+ // chunk 顶层;顶层读取只作为把用量平铺的替身兜底。
1020
+ const usage = chunk.usage ?? chunk;
1021
+ const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
1022
+ const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
1023
+ if (Number.isFinite(i)) audit.inputTokens = i;
1024
+ if (Number.isFinite(o)) audit.outputTokens = o;
1025
+ }
1026
+ if (chunk.type === "finish") {
1027
+ const reasonKind = chunk.reason?.kind ?? chunk.kind;
1028
+ if (reasonKind === "error" || reasonKind === "aborted") {
1029
+ // 429 也可能以 finish reason error 携带 rate-limit 信息,统一
1030
+ // 转抛错走指数退避重试。
1031
+ if (isRateLimited(chunk.reason ?? chunk)) {
1032
+ throw Object.assign(new Error("rate limited"), { status: 429 });
1033
+ }
1034
+ // Issue #315:把失败原因原样留给外层的 effort 甄别
1035
+ // (withEffortFallback 只认「effort 被拒收」型失败)。
1036
+ streamFailure = describeStreamFailure(chunk.reason ?? chunk);
1037
+ if (audit) {
1038
+ audit.status = "error";
1039
+ audit.errorMessage = `llm stream ${reasonKind}`;
1040
+ }
1041
+ aborted = true;
1042
+ break;
1043
+ }
1044
+ }
1045
+ }
1046
+ // Direct delta accumulation is the primary extraction path (it works
1047
+ // for real protocol chunks {index,text} and looser {delta} shapes
1048
+ // alike); the assembler blocks are a fallback for streams that only
1049
+ // deliver text inside block-end. This dsh-llm exposes no public
1050
+ // no-arg assemble() — blocks() is the message-level API.
1051
+ const blocks = assembler.blocks();
1052
+ const assembledText = blocks
1053
+ .filter((b) => b.type === "text")
1054
+ .map((b) => b.text ?? "")
1055
+ .join("");
1056
+ return { text, assembledText, aborted };
1057
+ } catch (error) {
1058
+ if (error?.name === "AbortError" || controller.signal.aborted) throw error; // dispose 中止直接放行
1059
+ if (isRateLimited(error) && attempt < retries) {
1060
+ const delay = baseDelayMs * 2 ** attempt;
1061
+ ctx.logger?.warn?.(
1062
+ `dsh-mneme: 蒸馏请求过于频繁(429),为避免限流等待 ${delay}ms 后自动重试(第 ${attempt + 1}/${retries} 次)`
1063
+ );
1064
+ await sleep(delay);
1065
+ continue;
1066
+ }
1067
+ // 429 重试耗尽或非 429 错误:记 audit 后抛出,保持原失败路径。
1068
+ if (audit) {
1069
+ audit.status = "error";
1070
+ audit.errorMessage = String(error?.message ?? error);
1071
+ }
1072
+ throw error;
1073
+ }
1074
+ }
1075
+ }, intervalMs);
1076
+ };
1077
+ // Issue #315:effort 被拒收时自动去掉字段重试一次(dream/sleep/entity
1078
+ // 同一降级策略,withEffortFallback 共享)。蒸馏的流失败以 aborted 结果
1079
+ // 而非 throw/undefined 返回,因此甄别在这里做:effort 型失败折叠成
1080
+ // undefined,让 withEffortFallback 走 streamFailure 甄别分支触发重试;
1081
+ // 非 effort 的 aborted 原样返回,仍走既有的 abortedRun 路径。
1082
+ const result = await withEffortFallback(
1083
+ ctx,
1084
+ summarizeEffort,
1085
+ () => runDistill(withEffort).then((r) =>
1086
+ (r?.aborted && EFFORT_REJECT_RE.test(streamFailure)) ? undefined : r),
1087
+ () => runDistill(false),
1088
+ () => streamFailure
1089
+ );
1090
+ const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
1091
+ if (aborted) {
1092
+ abortedRun = true;
1093
+ // #512:aborted 不等于「没发生」——LLM 调用真的发出去了,窗口也因此没被消费。
1094
+ // 只有 dispose / 会话关闭才是「这一轮被主动打断」;模型侧流失败是一次失败的轮次,
1095
+ // 两者在 run 台账上必须分得开,否则「这个窗口为什么一直没记忆」查不到原因。
1096
+ distillRunStatus = controller.signal.aborted ? "aborted" : "failed";
1097
+ distillRunError = controller.signal.aborted
1098
+ ? "disposed"
1099
+ : (streamFailure || "llm stream aborted");
1100
+ return;
1101
+ }
1102
+ const parsedResult = parseSummaryJsonResult(text || assembledText);
1103
+ // 解析失败不推进 seq 游标,下一次 turn/end 仍会重试同一窗口。
1104
+ if (!parsedResult.ok) {
1105
+ // Issue #493:失败也要留证据。原先 metadata 只在成功路径赋值,失败行恒为
1106
+ // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何
1107
+ // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。
1108
+ const raw = text || assembledText || "";
1109
+ const streak = bumpParseFailureStreak(session.id);
1110
+ // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。
1111
+ // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随
1112
+ // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与
1113
+ // 历史版本逐字节一致(本仓新行为一律 opt-in,见 AGENTS.md)。
1114
+ const maxParseRetries = config.summarizeParseFailureMaxRetries ?? 0;
1115
+ // nextSeq 必须是有限数才算真的「跳过了窗口」:没有可验证的 seq 时
1116
+ // commitCursor 是空操作(persistCursor 早退、内存游标也不写),此时若
1117
+ // 仍按跳过记账并清零 streak,审计会宣称「已放弃」而窗口下一轮照旧重试
1118
+ // —— 那是最坏的一种假象:用户以为止血了,实际还在烧。
1119
+ const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries && Number.isFinite(nextSeq);
1120
+ if (skipWindow) {
1121
+ // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。
1122
+ commitCursor(session.id, nextSeq);
1123
+ clearParseFailureStreak(session.id);
1124
+ }
1125
+ // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
1126
+ // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
1127
+ const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
1128
+ // #512:解析失败也是失败轮,run 要和 audit 记同一句原因(两个口径不一致
1129
+ // 等于让人去猜哪一边是对的)。
1130
+ distillRunStatus = "failed";
1131
+ distillRunError = failureAudit.errorMessage;
1132
+ if (audit) {
1133
+ audit.status = "error";
1134
+ audit.errorMessage = failureAudit.errorMessage;
1135
+ audit.metadata = {
1136
+ distillChars,
1137
+ ...(imageCount > 0 ? { imageCount } : {}),
1138
+ ...failureAudit.metadata,
1139
+ streak,
1140
+ ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}),
1141
+ ...(skipWindow ? { skipped: "parse-failure-streak" } : {})
1142
+ };
1143
+ }
1144
+ return;
1145
+ }
1146
+ clearParseFailureStreak(session.id);
1147
+ const parsed = parsedResult.entries;
1148
+ // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
1149
+ // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
1150
+ const cap = config.summarizeMaxEntriesPerRun ?? 0;
1151
+ const entries = cap > 0 ? parsed.slice(0, cap) : parsed;
1152
+ const capped = parsed.length - entries.length;
1153
+ const dedupeMode = config.summarizeDedupeMode ?? "off";
1154
+ let deduped = 0;
1155
+ let dedupeMaxSim = 0;
1156
+ // v0.9.0(M.O. 项目记忆):一次窗口解析一次——同一蒸馏窗口里所有条目同属一个
1157
+ // 工作区、一个 agent 身份,逐条解析是白花钱。解析不到就落 NULL(NULL = 未标注 =
1158
+ // 全局可见),且解析器绝不抛错(scope.js 的硬约束:记忆写入不该被作用域拖挂)。
1159
+ // 闸门与 tools.js 的 autoStamping(2026-10-14 在 :175)同一把:关着就一个字段都不加,老行为不变。
1160
+ const writeScope = config?.scopeEnabled === true
1161
+ ? resolveSessionScope({ agent: { session } })
1162
+ : null;
1163
+ const writes = [];
1164
+ for (const entry of entries) {
1165
+ // Provenance: the summarizer runs on a real session (turn/end hook), so
1166
+ // session.id is always available here — it rides the human-readable
1167
+ // source label.
1168
+ // 编码记忆类型(codingRetrospect)不带 tag:读取侧门控/加权靠 m.type
1169
+ // (rejected_solution/pitfall/constraint)区分即可,tag 体系
1170
+ // sanitizeTags 不认 `type:` 前缀反而会清空 tags 列(额外一次 UPDATE)。
1171
+ const source = `session:${session.id}`;
1172
+ // Issue #127 落库前去重(opt-in,默认 off = 现状):命中则并入既有条目
1173
+ // (saveWithDedupe 的 _mergeInto 分支),不再新造一行。作用域 = 同会话 +
1174
+ // 时间窗;跨会话的同类事实仍交给 autoDream / sleep 的批量裁决。
1175
+ const dup = (dedupeMode !== "off" && typeof service.findSessionDuplicate === "function")
1176
+ ? await service.findSessionDuplicate(
1177
+ { ...entry, source },
1178
+ {
1179
+ mode: dedupeMode,
1180
+ minSim: config.summarizeDedupeMinSim ?? 0.92,
1181
+ windowHours: config.summarizeDedupeWindowHours ?? 24,
1182
+ source
1183
+ }
1184
+ )
1185
+ : undefined;
1186
+ if (dup) {
1187
+ deduped++;
1188
+ dedupeMaxSim = Math.max(dedupeMaxSim, dup.sim ?? 0);
1189
+ }
1190
+ writes.push({ entry, source, dup });
1191
+ }
1192
+ // #399:蒸馏窗口的原始公开记录 + 记忆级溯源链接,与记忆写入同事务落盘。
1193
+ // 凭证与它的派生记忆必须同生共死:记忆写了而记录没写,溯源就断在「不知道
1194
+ // 出自哪段原文」;记录写了而记忆没写,盘上留一份没有索引的原文。默认关
1195
+ // (enabled !== true)时连收集都不做,一次 SQL 都不发(测试锁 5)。
1196
+ const distilledAt = new Date(now()).toISOString();
1197
+ const transcript = config?.distillRecords?.enabled === true
1198
+ && typeof service.saveDistillRecords === "function"
1199
+ ? collectTranscriptRecords(session, previousSeq, triggerSeq, distilledAt)
1200
+ : null;
1201
+ const writeWindow = () => {
1202
+ const savedIds = [];
1203
+ for (const { entry, source, dup } of writes) {
1204
+ const outcome = service.saveWithDedupe({
1205
+ ...entry,
1206
+ source,
1207
+ // 合并进既有行时不盖章,但这件事由 saveWithDedupe 的合并分支自己保证
1208
+ // (它只认 explicit 来源升级,从不采纳这里传的 auto 字段)——调用方不能
1209
+ // 靠「省略字段」来表达这个意图,因为省略会反向把合并也挡掉:
1210
+ // findSessionDuplicate 只按 type+source+时间窗找候选,不看 scope;找到
1211
+ // 候选后由 saveWithDedupe 的 scopeMatches 门决定并或不并。这里一省略,
1212
+ // 新条目的 scope 就是 null,与已盖过章的候选不等,门必拒 → 回落新建,
1213
+ // 而那一行同样是新行、同样该带章,却没有章可带。实测两轮蒸馏正是如此:
1214
+ // 第二轮命中第一轮、门拒后新建,留下同题两行一行 auto 一行 NULL——
1215
+ // 作用域隔离没做成,重复倒留下了。传字段、让合并分支按自己的口径处理,
1216
+ // 才是「不重贴」与「新行必贴」两个要求的唯一相容解。
1217
+ ...(writeScope ? { agent_scope: writeScope.agent_scope, agent_scope_source: "auto" } : {}),
1218
+ ...(writeScope ? { workspace_scope: writeScope.workspace_scope, workspace_scope_source: "auto" } : {}),
1219
+ ...(dup ? { _mergeInto: dup.memory.id } : {})
1220
+ });
1221
+ // created 与 merged 两条成功分支都返回 memory(含 id);merged 时 id 是
1222
+ // 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
1223
+ if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
1224
+ }
1225
+ // #512:run 的条数口径是「本次写成功的记忆行数」,与 savedIds 同源——
1226
+ // 另起一个计数器会在 merged 分支上与它对不上(那一路也 push 了 id)。
1227
+ distilledMemoryCount = savedIds.length;
1228
+ if (transcript && transcript.records.length) {
1229
+ service.saveDistillRecords(transcript.records);
1230
+ if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined) {
1231
+ distilledWindow = { seqStart: transcript.seqStart, seqEnd: transcript.seqEnd };
1232
+ }
1233
+ if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
1234
+ && typeof service.saveMemoryProvenance === "function") {
1235
+ for (const memoryId of savedIds) {
1236
+ service.saveMemoryProvenance({
1237
+ memoryId,
1238
+ sessionId: session.id,
1239
+ seqStart: transcript.seqStart,
1240
+ seqEnd: transcript.seqEnd,
1241
+ createdAt: distilledAt,
1242
+ // #512:链接指回产出它的那一轮。distillRecords 关着时这里本就不跑,
1243
+ // 于是那一轮不会留下任何窗口级归因——这是诚实的(没存原文就没有
1244
+ // 「这段原文」可指),run 行本身照落,条数与状态仍然可查。
1245
+ distillRunId: distillRunId ?? undefined
1246
+ });
1247
+ }
1248
+ }
1249
+ }
1250
+ persistCursor(session.id, nextSeq);
1251
+ };
1252
+ if (writes.length || (transcript && transcript.records.length)) {
1253
+ // 查询去重目标可以异步执行,但记忆写入必须在同一个事务中完成。
1254
+ // 否则第 N 条写入失败时,前 N-1 条会残留,而重试同一 seq 窗口会重复
1255
+ // 追加它们。createService 始终提供 transaction;缺失时直接失败并保留
1256
+ // 游标窗口,避免退回到不具备原子性的部分写入。
1257
+ if (typeof service.transaction !== "function") {
1258
+ throw new Error("dsh-mneme: atomic summarization writes require service.transaction");
1259
+ }
1260
+ service.transaction(writeWindow);
1261
+ } else {
1262
+ // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
1263
+ persistCursor(session.id, nextSeq);
1264
+ }
1265
+ // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
1266
+ // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
1267
+ // 一条都不留——下一级预判规则的阈值就无从定标。
1268
+ // #512:走到这里说明整轮真的落地了(记忆写入 + 游标提交都没抛)。状态按
1269
+ // 实际产出分档,与 dream_runs 的 status 词表对齐:没有可写的记忆是 noop,
1270
+ // 有记忆就是 ok。degraded 留给「写了但解析有损」那一路(下面按 salvaged 判)。
1271
+ distillRunStatus = distilledMemoryCount > 0
1272
+ ? (parsedResult.salvaged ? "degraded" : "ok")
1273
+ : "noop";
1274
+ if (audit) {
1275
+ audit.metadata = {
1276
+ distillChars,
1277
+ ...(imageCount > 0 ? { imageCount } : {}),
1278
+ parsed: parsed.length,
1279
+ ...(parsedResult.salvaged ? { json_salvaged: true } : {}),
1280
+ ...(capped > 0 ? { capped } : {}),
1281
+ ...(deduped > 0 ? { deduped, mode: dedupeMode, maxSim: Number(dedupeMaxSim.toFixed(4)) } : {})
1282
+ };
1283
+ }
1284
+ // 记忆写入和解析都成功后才提交窗口;流失败、中止、解析失败或写入异常
1285
+ // 都会在此之前退出,从而保留窗口供下一次重试。
1286
+ if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
1287
+ } catch (error) {
1288
+ distillRunStatus = abortedRun || controller.signal.aborted ? "aborted" : "failed";
1289
+ distillRunError = String(error?.message ?? error);
1290
+ if (audit) {
1291
+ audit.status = "error";
1292
+ audit.errorMessage = String(error?.message ?? error);
1293
+ }
1294
+ throw error;
1295
+ } finally {
1296
+ // Issue #127:aborted(会话关闭 / 插件 dispose)不占间隔,避免误伤该会话的
1297
+ // 下一次蒸馏;其余情况(含失败)的打点保留,与 dreamMinIntervalMinutes 一致。
1298
+ if (abortedRun || controller.signal.aborted) {
1299
+ if (prevClaim === undefined) lastRunAt.delete(session.id);
1300
+ else lastRunAt.set(session.id, prevClaim);
1301
+ // Issue #239:aborted 同样不占 run 预算——调用没有真正发生,占了会误伤
1302
+ // 该会话后续的蒸馏(与上面的间隔回滚同一口径)。
1303
+ if (prevRuns === undefined) runsUsed.delete(session.id);
1304
+ else runsUsed.set(session.id, prevRuns);
1305
+ }
1306
+ // #512:run 与它的开销读数(audit 行)分开落,且都不许把已经写好的记忆连坐——
1307
+ // 归因是审计,写不成只该 warn。顺序刻意是「先 audit 后 run」:run.audit_id 要
1308
+ // 引用刚插进去的那一行,而 saveLlmAudit 是按 lastInsertRowid 回读的
1309
+ // (#534/#535 那条注释),拿到的就是自己那行,不会被并发写串。
1310
+ let auditRow = null;
1311
+ if (audit) {
1312
+ try {
1313
+ auditRow = service.saveLlmAudit({
1314
+ timestamp: audit.timestamp,
1315
+ trigger_source: "autoSummarize",
1316
+ operation_type: "summarize_compress",
1317
+ model_id: `${audit.route.provider}:${audit.route.model}`,
1318
+ input_tokens: audit.inputTokens,
1319
+ output_tokens: audit.outputTokens,
1320
+ total_tokens: audit.inputTokens + audit.outputTokens,
1321
+ cost_usd: 0,
1322
+ duration_ms: Date.now() - audit.startedAt,
1323
+ status: audit.status,
1324
+ error_message: audit.errorMessage,
1325
+ related_memory_ids: [],
1326
+ // Issue #127:parsed / capped / deduped 的观测数据。零 schema 变更
1327
+ // (llm_audit_logs.metadata 已存在),一条 SQL 即可验证节流是否生效。
1328
+ metadata: audit.metadata
1329
+ });
1330
+ } catch (auditError) {
1331
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
1332
+ }
1333
+ }
1334
+ // #512:一轮真蒸馏对应一行 distill_runs,落点必须在这里(finally)而不是
1335
+ // 事务里:run 是审计主体,事务只负责「记忆 + 原文 + 链接」同生共死;把 run
1336
+ // 塞进事务会让一个审计写失败回滚掉已经算出来的记忆。
1337
+ // status 为 null 说明在开轮之前就退出了(间隔门/高峰门/短窗口),那不是一轮。
1338
+ if (distillRunId && distillRunStatus) {
1339
+ try {
1340
+ service.saveDistillRun({
1341
+ id: distillRunId,
1342
+ created_at: distillRunOpenedAt,
1343
+ status: distillRunStatus,
1344
+ error: distillRunError ?? undefined,
1345
+ run_type: "autoSummarize",
1346
+ trigger_source: "autoSummarize",
1347
+ session_id: session.id,
1348
+ seq_start: distilledWindow?.seqStart,
1349
+ seq_end: distilledWindow?.seqEnd,
1350
+ memory_count: distilledMemoryCount,
1351
+ provider: audit?.route?.provider ?? route?.provider,
1352
+ model: audit?.route?.model ?? route?.model,
1353
+ audit_id: auditRow?.id
1354
+ });
1355
+ } catch (runError) {
1356
+ ctx.logger?.warn?.(`dsh-mneme: distill run write failed: ${String(runError)}`);
1357
+ }
1358
+ }
1359
+ inFlight.delete(session.id);
1360
+ }
1361
+ }
1362
+
1363
+ const unsubscribe = ctx.on("session/event", (session, event) => {
1364
+ if (disposed || event.type !== "turn/end") return;
1365
+ // Return the summarization promise so awaiters observe the writes; the
1366
+ // catch keeps listener dispatch from rejecting. Dispose-initiated aborts
1367
+ // and external AbortErrors are silent.
1368
+ return summarize(session, event).catch((error) => {
1369
+ if (disposed || error?.name === "AbortError") return;
1370
+ ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
1371
+ });
1372
+ });
1373
+
1374
+ return {
1375
+ dispose() {
1376
+ if (disposed) return;
1377
+ disposed = true;
1378
+ unsubscribe?.();
1379
+ for (const timer of deferredRuns.values()) clearTimer(timer);
1380
+ deferredRuns.clear();
1381
+ for (const controller of inFlight.values()) controller.abort();
1382
+ inFlight.clear();
1383
+ lastRunAt.clear();
1384
+ lastDistilledSeq.clear();
1385
+ parseFailureStreakFallback.clear();
1386
+ }
1387
+ };
1388
+ }