@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
@@ -1,2027 +1,2027 @@
1
- import { STR, langOf } from "../lang.js";
2
- // System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
3
- // the memory store. Four independent, fail-safe phases:
4
- // 1. conflict resolution — high-similarity same-type pairs are either parked
5
- // for review (freeze mode) or adjudicated by the LLM (winner kept / loser
6
- // archived), reusing the dream conflict machinery. Strictness-graded.
7
- // v0.8.1 (issue #170): cross-scope pairs are ALWAYS parked for human
8
- // ownership review with a dedicated reason — never auto-adjudicated.
9
- // 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
10
- // to a one-line summary with the full body moved to _full_content; past
11
- // sleepCompressDays they are archived outright.
12
- // 3. pattern discovery — the LLM scans the most recent memories and mints
13
- // type=pattern entries carrying evidence id references.
14
- // 4. relation completion — orphan entities (zero relations) get implied
15
- // relations completed from memory co-occurrence.
16
- // 5. affective attribution (v0.13.0, #619) — opt-in. Occasion-bound emotions
17
- // become affective_context relations; only emotions recurring across
18
- // enough independent sessions are minted as type=pattern traits.
19
- // Each phase is wrapped so one failure never aborts the others, and a missing
20
- // LLM route / semantic embedder only skips the phases that need it. A run is
21
- // abortable via an AbortController signal (user activity) — phases check the
22
- // signal between batches so a running cycle yields promptly.
23
- import { randomUUID, createHash } from "node:crypto";
24
- import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
25
- import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
26
- import { triagePair, recordLedgerTriage } from "./triage.js";
27
- import { supersedeInfoFirstPair } from "./decisions.js";
28
- import { scopeKeyOf } from "../scope.js";
29
- import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, withMaxTokensFallback, maintainIndexAfterDream, fenceListBody, parseFailureMeta } from "../dream.js";
30
- import { computeHeat } from "../heat.js";
31
- // 四个相位读的都是行池,而动行的写手不经汇流口:phaseDemotion 直接 setArchived /
32
- // demoteToSummary,phasePatterns 还会 mint 出新行,所以这条契约要在每处各挡一遍
33
- // (清单与静态锁见 test/share-imported-guard.test.js)。
34
- import { isImportedRow } from "../memory-source.js";
35
- // Issue #533:查账本时要拿两侧当前的正文哈希,与 applyDifferentiate 记账时的口径同源。
36
- import { contentHashOf } from "../content-hash.js";
37
-
38
- // Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
39
- // ACTIONS 去掉 #126 新增的两个。从 ACTIONS 派生而非硬编码,将来再扩动作集时自动跟随。
40
- // 只换 prompt 挡不住模型自发输出新动作,必须由校验器按档位收口。
41
- const LEGACY_ACTIONS = [...ACTIONS].filter((a) => a !== "supersede" && a !== "differentiate");
42
-
43
- const SUMMARY_MAX = 120;
44
- // Conflict similarity threshold per strictness level (v0.4.0):
45
- // gentle only high-confidence pairs (0.92) — first-time users
46
- // normal standard dream-level (0.85) — default
47
- // aggressive low-confidence pairs too (0.75) — bloated stores
48
- const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
49
-
50
- // Issue #529(C 项的替代实现):连续失败时最小间隔的封顶。基数默认 8 小时,
51
- // 2^3 就到 64 小时——不封顶的话模型持续失败会让 sleep 事实上停摆好几天,而
52
- // 用户完全看不出来。24 小时 = 「最多一天试一次」,与 #292 给 autoDream 的
53
- // 30 分钟封顶同一意图(封顶值不同是因为两个调度的基数差一个数量级)。
54
- //
55
- // 实际封顶取 max(基数, 24h),不是硬 24h:`sleepMinIntervalHours` 允许 1–168,
56
- // 用户配 48 小时就是明说"两天跑一次",退避不该把它压回 24 小时(封顶只能放宽
57
- // 间隔、不能收紧用户自己设的)。
58
- const FAILURE_BACKOFF_CAP_MS = 24 * 3600000;
59
-
60
- // Issue #529(续排):一轮内部最多连追多少轮。449 对的池子按每轮 60 对只需 8 轮,
61
- // 24 留足余量;超过就退回正常最小间隔。防的是"池子在长、每轮都 clean 但永远追不完"
62
- // ——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟一轮,
63
- // 而审计上每轮都"有产出",肉眼很难发现。
64
- const CATCHUP_STREAK_CAP = 24;
65
-
66
- function parseJsonArray(text) {
67
- if (typeof text !== "string") return undefined;
68
- const start = text.indexOf("[");
69
- const end = text.lastIndexOf("]");
70
- if (start === -1 || end <= start) return undefined;
71
- try {
72
- const parsed = JSON.parse(text.slice(start, end + 1));
73
- return Array.isArray(parsed) ? parsed : undefined;
74
- } catch {
75
- return undefined;
76
- }
77
- }
78
-
79
- /**
80
- * Same stream consumption contract as dream.js.
81
- *
82
- * `onUsage`(optional, issue #250)receives any usage chunk for token accounting.
83
- * dream.js 那份一直有它;sleep 这份副本没有,导致 conflict / pattern 两条链路的
84
- * token 从未进 llm_audit_logs(面板因此只看到 autoDream 与 autoSummarize)。
85
- */
86
- async function streamText(ctx, options, onUsage, onStreamError) {
87
- if (!ctx?.llm?.stream) return undefined;
88
- let text = "";
89
- for await (const chunk of ctx.llm.stream(options)) {
90
- if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
91
- // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
92
- // outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在
93
- // 这里,调用侧的 reporter 只需认平铺形状——与 dream.js:239 同一处理。
94
- if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk.usage ?? chunk);
95
- if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
96
- // Same rc.1 error-as-finish-chunk behavior as dream.js — surface the
97
- // cause instead of discarding it.
98
- if (typeof onStreamError === "function") {
99
- try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
100
- }
101
- return undefined;
102
- }
103
- }
104
- return text;
105
- }
106
-
107
- /**
108
- * Issue #250:给 sleep 的一条 LLM 调用记一笔 llm_audit_logs(token / 时长 / 状态 /
109
- * 触发源)。与 dream.js 的 runAuditedLlm 同口径但各自独立一份——dream 那两个
110
- * checker 的调用形状绑定着它自己的排程细节,硬抽共享抽象只会收敛回 dream 的形态。
111
- *
112
- * 记账是 best-effort:写审计行失败只 warn,绝不反噬 sleep 本体(CONTRIBUTING 的
113
- * fail-safe 硬约定)。`body(reportUsage)` 负责真实消费流,usage chunk 经
114
- * reportUsage 上报。
115
- *
116
- * 审计诚实性:流被中止/报错时 body 返回 undefined,记 status='error';流式成功但
117
- * 输出不可用时 `spec.auditError` 给出原因,同样记 error——否则同一轮调用会在
118
- * 「phase 报 failed」与「audit 报 success」之间自相矛盾(CONTRIBUTING 的审计诚实性
119
- * 硬约定)。`auditError` 必须是**纯判据函数**:调用方的控制流不能依赖它的副作用,
120
- * 因为审计关闭时这里整段早退、钩子根本不会被调用(dream 正是那样踩的,见 PR 正文)。
121
- */
122
- async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
123
- if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") {
124
- return body(() => {});
125
- }
126
- const startedAt = Date.now();
127
- const timestamp = new Date(startedAt).toISOString();
128
- let inputTokens = 0;
129
- let outputTokens = 0;
130
- let status = "success";
131
- let errorMessage = null;
132
- // 提到 try 之外:finally 里的 metadataFrom 要读这一轮的原始输出(dream.js 的
133
- // runAuditedLlm 就是这么声明的)。留在 try 里,finally 看不见它,读数会静默变空。
134
- let result;
135
- try {
136
- result = await body((usage) => {
137
- if (!usage) return;
138
- const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
139
- const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
140
- if (Number.isFinite(i)) inputTokens = i;
141
- if (Number.isFinite(o)) outputTokens = o;
142
- });
143
- if (result === undefined) {
144
- status = "error";
145
- const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
146
- errorMessage = streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored";
147
- } else if (typeof spec.auditError === "function") {
148
- // 流式成功但输出不可用,仍然是一次失败的调用——记 error,不与调用方的
149
- // failed 返回值打架。
150
- const message = spec.auditError(result);
151
- if (message) {
152
- status = "error";
153
- errorMessage = message;
154
- }
155
- }
156
- return result;
157
- } catch (error) {
158
- status = "error";
159
- errorMessage = String(error?.message ?? error);
160
- throw error;
161
- } finally {
162
- // spec.metadataFrom(result):与 dream.js 的 runAuditedLlm 同一形状(这里是一份并行
163
- // 实现,不是同一个函数——两份审计写法的字段必须一致,否则面板按 operation_type
164
- // 筛出来的读数会时有时没有)。判据函数只读 result,不读闭包,理由同 dream.js。
165
- let metadata;
166
- if (result !== undefined && typeof spec.metadataFrom === "function") {
167
- try {
168
- metadata = spec.metadataFrom(result) ?? undefined;
169
- } catch {
170
- metadata = undefined;
171
- }
172
- }
173
- try {
174
- service.saveLlmAudit({
175
- timestamp,
176
- trigger_source: spec.triggerSource,
177
- operation_type: spec.operationType,
178
- model_id: spec.modelId,
179
- input_tokens: inputTokens,
180
- output_tokens: outputTokens,
181
- total_tokens: inputTokens + outputTokens,
182
- cost_usd: 0,
183
- duration_ms: Date.now() - startedAt,
184
- status,
185
- error_message: errorMessage,
186
- related_memory_ids: spec.relatedMemoryIds ?? [],
187
- ...(metadata ? { metadata } : {})
188
- });
189
- } catch (auditError) {
190
- logger?.warn?.(`dsh-mneme sleep: llm audit write failed: ${String(auditError)}`);
191
- }
192
- }
193
- }
194
-
195
- /** LLM route (Issue #25): explicit sleepProvider/Model wins, then the dream
196
- * route as a shared explicit fallback, then the agent default model. Sleep
197
- * can pin a cheaper model for its bulk passes without disturbing the dream
198
- * route. Explicit config first — otherwise the config routes are dead code
199
- * whenever agentDefaultModel resolves (see resolveRoute in dream.js). */
200
- function resolveSleepRoute(ctx, config, logger) {
201
- if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
202
- if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
203
- try {
204
- const sel = ctx?.agentDefaultModel?.currentSelection?.();
205
- if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
206
- } catch { /* fall through to warn */ }
207
- logger?.warn?.("dsh-mneme sleep: no llm route available");
208
- return undefined;
209
- }
210
-
211
- function makeSummary(m) {
212
- const text = (m.content ?? "").trim();
213
- if (!text) return (m.title ?? "").trim();
214
- return text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX)}…`;
215
- }
216
-
217
- // issue #471 政策轴:被按下的作用域也不参与睡眠整理。与 dream.js 的 withoutPaused
218
- // 同源(那里记着「判定只许有一份实现」的理由),但这里是**单独一处**而不是复用:
219
- // 两个模块各被独立装配(runSleep / runDream 各自接 service),任何一个被换进只
220
- // 实现了一半 service 的夹具时都该各自降级,而不是因为对方模块没装而双双失效。
221
- //
222
- // 睡眠侧比日间加固侧更要紧:这几个相位不只读——phaseDemotion 会 setArchived 与
223
- // demoteToSummary(真改归档面),phasePatterns 会 mint 出 type=pattern 的新行
224
- // (凭空给被按下的作用域生出衍生记忆)。只挡日间那几处读入口的话,闸门开着而每
225
- // 6 小时一次的睡眠照样动手,那等于「按下」只是让它在白天看不见。
226
- //
227
- // service 上取不到 filterPaused 时(老装配、测试夹具)原样返回,与改动前一致。
228
- function withoutPaused(service, rows) {
229
- if (typeof service?.filterPaused !== "function") return rows;
230
- return service.filterPaused(rows);
231
- }
232
-
233
- // ---------------------------------------------------------------- phases
234
-
235
- /**
236
- * Phase 1 — conflict resolution. Needs a semantic embedder + vector index.
237
- * In freeze mode (conflictFreezeEnabled) conflicting pairs are parked in
238
- * conflict_pending for human review (no LLM). Otherwise the LLM adjudicates:
239
- * each pair → winner kept / loser archived. Returns a per-run summary.
240
- */
241
- async function phaseConflicts(ctx, service, config, logger, runId, semantic = null, signal = null) {
242
- const language = langOf(config);
243
- const embedder = semantic?.embedder;
244
- const vectorIndex = semantic?.vectorIndex;
245
- if (!embedder || !vectorIndex || typeof embedder.embed !== "function") {
246
- return { status: "skipped", reason: "no semantic embedder" };
247
- }
248
- const strictness = config.sleepConflictStrictness ?? "normal";
249
- const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
250
- const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));
251
- if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
252
- if (signal?.aborted) return { status: "aborted", reason: "user activity" };
253
-
254
- // Backfill + collect vectors for every eligible memory (best effort).
255
- const vectors = new Array(memories.length);
256
- const missing = [];
257
- for (let i = 0; i < memories.length; i++) {
258
- const cached = vectorIndex.getEmbedding?.(memories[i].id);
259
- if (cached) vectors[i] = cached;
260
- else missing.push(i);
261
- }
262
- if (missing.length) {
263
- try {
264
- const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
265
- const rows = await embedder.embed(texts);
266
- missing.forEach((mi, j) => {
267
- if (rows[j]?.length) {
268
- vectors[mi] = rows[j];
269
- vectorIndex.saveEmbedding?.(memories[mi].id, rows[j]);
270
- }
271
- });
272
- } catch (error) {
273
- logger?.warn?.(`dsh-mneme sleep: conflict vector backfill failed: ${String(error)}`);
274
- }
275
- }
276
- const usable = [];
277
- for (let i = 0; i < memories.length; i++) {
278
- if (vectors[i]?.length) usable.push(i);
279
- }
280
- if (usable.length < 2) return { status: "skipped", reason: "no usable vectors" };
281
- const usableMemories = usable.map((i) => memories[i]);
282
- const usableVectors = usable.map((i) => vectors[i]);
283
-
284
- // Issue #126:动作集档位既要决定 prompt,也要决定**校验器允许的动作集合**(见下方
285
- // allowedActions),并且在候选构造阶段就要知道——full 档需要补跨类型对。因此在这里
286
- // 解析,而不是等到拼 prompt 时。
287
- const actionSet = config.sleepActionSet ?? "conflict";
288
- const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);
289
- if (actionSet === "full") {
290
- // Issue #126 review(Copilot):findPotentialConflicts 只看同类型,而 full 档的
291
- // prompt 明确宣传 differentiate 用于互补型——互补本来就常跨类型(同一事实的项目侧
292
- // 与偏好侧)。不补这一遍,那条路径永远选不到。merge 的类型守卫不受影响:它拦在
293
- // validateDecisions 里(allowCrossTypeMerge),不在候选生成。
294
- for (let i = 0; i < usableMemories.length; i++) {
295
- for (let j = i + 1; j < usableMemories.length; j++) {
296
- if (usableMemories[i].type === usableMemories[j].type) continue;
297
- const sim = cosineSimilarity(usableVectors[i], usableVectors[j]);
298
- if (sim > threshold) pairs.push({ a: usableMemories[i], b: usableMemories[j], similarity: sim });
299
- }
300
- }
301
- }
302
- if (pairs.length === 0) return { status: "skipped", reason: "no conflicts found" };
303
-
304
- // Dedupe: each memory participates in at most one pair, highest similarity
305
- // first — overlapping pairs would violate validateDecisions' "one claim".
306
- pairs.sort((a, b) => b.similarity - a.similarity);
307
-
308
- // Issue #533:已经裁决过「互补、两条都留」的对不再重判。
309
- //
310
- // 过滤必须发生在 used 去重**之前**:若先占位再丢弃,一条已裁决的记忆会把
311
- // 名额占住,而它真正的对手又是自己人,这一轮就白跑——抑制一对不该顺带压掉
312
- // 另一对候选。判据与 applyDifferentiate 的幂等同源:账本里有这对、且两侧正文
313
- // 的 content_hash 与记账时一致(任一侧改过 = 结论可能不再成立,重判)。
314
- // 存量兼容走同一格:旧版本把注记写进正文却没账本行,两侧都还带着注记块也算
315
- // 已裁决,免得升级后第一轮把所有老对重判一遍。
316
- const alreadyDifferentiated = (p) => {
317
- const a = service.getById(p.a.id);
318
- const b = service.getById(p.b.id);
319
- if (!a || !b) return false;
320
- if (typeof service.findDifferentiatedPair === "function") {
321
- const record = service.findDifferentiatedPair(a.id, b.id);
322
- if (record) {
323
- const [ha, hb] = a.id <= b.id
324
- ? [contentHashOf(a), contentHashOf(b)]
325
- : [contentHashOf(b), contentHashOf(a)];
326
- // 账本行没记 hash(存量/异常写入)时不下结论,宁可多判一次。
327
- return record.hash_a != null && record.hash_b != null
328
- && record.hash_a === ha && record.hash_b === hb;
329
- }
330
- }
331
- return STR.differentiatedNoteRe.test(String(a.content ?? ""))
332
- && STR.differentiatedNoteRe.test(String(b.content ?? ""));
333
- };
334
- const fresh = [];
335
- let suppressed = 0;
336
- for (const p of pairs) {
337
- if (alreadyDifferentiated(p)) {
338
- suppressed += 1;
339
- continue;
340
- }
341
- fresh.push(p);
342
- }
343
- if (fresh.length === 0) {
344
- // 与 "no conflicts found" 分开报:一对都没检测到 vs 检测到但全都裁决过,
345
- // 是完全不同的两件事,混成一句会让「sleep 是不是没在干活」读不出来。
346
- return { status: "noop", reason: "all candidate pairs already differentiated", suppressed };
347
- }
348
-
349
- // Issue #529:输入侧预算。此前 samePairs 是**全部**同 scope 相似对,一次拼成一个
350
- // prompt——实测单请求 163,054 输入 token(服务端 --ctx-size 163,840),撞墙后按冷却
351
- // 重排,下一轮送进去的还是同一份输入,于是 23 轮连续失败。
352
- //
353
- // 预筛掉「已经判过且两侧都没动过」的对:判 keep 曾经是彻底的 no-op,既不留痕也不
354
- // 影响下一轮的候选池,同一对每个冷却周期都被重新送审一遍。台账(conflict_reviewed)
355
- // 记的是"这段输入被看过",两侧任一 updated_at 晚于 judged_at 就重新入池——与
356
- // conflict_pending 复核项 4 同一条宽松代理:任何字段更新都算值得再看一眼。
357
- //
358
- // Issue #536 review(CodeRabbit ①):预筛必须发生在下面的 used 去重**之前**,理由与
359
- // #533 那条一模一样——先占位再去掉,一条已审的记忆会把自己的配对名额占住,而它真正
360
- // 的对手(另一条没审过的记忆)因此进不了这一轮。三条边界:
361
- // · 只筛同 scope 对:跨 scope 对本来就要停车进人工队列,不能被台账吞掉;
362
- // · freeze 模式不筛:那一档只停车不送 LLM,台账与它无关(ledgerSkipped 记 0);
363
- // · ledgerSkipped 在这里一次数清,后面 candidates 直接取 samePairs,不再二次过滤。
364
- // ⚠️ Issue #616:这个判据把「未标注」当成「同 scope」。scopeKeyOf 把 NULL/空串
365
- // 归一成 null,于是两条都没标注时 null !== null 为 false,配对算同 scope、可以进
366
- // 自动池。但「归属未知」和「归属相同」不是一回事:按 v0.8.1 #170,前者本该是不可
367
- // 自动的范围。live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL,受影响的
368
- // 面不小。修法是把「任一未标注」显式判为不可自动,而不是继续让 null 自我相等。
369
- const isCrossScopePair = (p) =>
370
- scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
371
- || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
372
- const ledger = typeof service.loadConflictReviewed === "function"
373
- ? (service.loadConflictReviewed() ?? new Map())
374
- : new Map();
375
- const pairKey = (p) => (p.a.id < p.b.id ? `${p.a.id}\u0000${p.b.id}` : `${p.b.id}\u0000${p.a.id}`);
376
- const alreadyJudged = (p) => {
377
- const rec = ledger.get(pairKey(p));
378
- if (!rec?.judged_at) return false;
379
- return (p.a.updated_at ?? "") <= rec.judged_at && (p.b.updated_at ?? "") <= rec.judged_at;
380
- };
381
- // Issue #641(beta 第二阶段):分诊账本的档位。#616 点名的父子绑定不继承——
382
- // enforce 与 freeze 同开时 freeze 让位(本相位降级 shadow + warn):freeze 照常
383
- // 停车(shadow 语义 = 只记账零处置),绝不出现两套处置语义同时生效。宁可少处置,
384
- // 不在打架的配置下跑。
385
- let ledgerMode = config.conflictLedgerMode ?? "off";
386
- if (ledgerMode === "enforce" && config.conflictFreezeEnabled === true) {
387
- logger?.warn?.("dsh-mneme sleep: conflictLedgerMode=enforce coexists with conflictFreezeEnabled — degrading to shadow; disable one of them");
388
- ledgerMode = "shadow";
389
- }
390
- // alreadyJudged 预筛只为省 LLM:freeze 与 enforce 都不走 LLM,筛不筛不影响成本,
391
- // 不筛还能让 T2/T3/T4 的每轮观察持续刷新账本(tier 可能随内容更新而变)。
392
- const selectionPairs = config.conflictFreezeEnabled === true || ledgerMode === "enforce"
393
- ? fresh
394
- : fresh.filter((p) => isCrossScopePair(p) || !alreadyJudged(p));
395
- const ledgerSkipped = fresh.length - selectionPairs.length;
396
-
397
- const used = new Set();
398
- const selected = [];
399
- for (const p of selectionPairs) {
400
- if (used.has(p.a.id) || used.has(p.b.id)) continue;
401
- used.add(p.a.id);
402
- used.add(p.b.id);
403
- selected.push(p);
404
- }
405
-
406
- // Issue #641(beta 第一阶段,shadow 档):分诊账本。对 selected 全量做确定性
407
- // 分诊(dup / cross-scope-dup / evolution / conflict,判据唯一实现见 triage.js)
408
- // 并落 conflict_ledger。shadow = 只记账零处置:下面 freeze/LLM 路径一个字节都
409
- // 不变——shadow 的存在意义就是让「分诊分布是否复现真实库 46/20/5/27」在真实
410
- // 运行里可测,作为切 enforce 前的预注册证据。记账失败只 warn 计数,绝不反噬
411
- // 相位(与 conflict_reviewed 同一条 best-effort 纪律)。
412
- // 记账时点在 used 去重之后:被去重压掉的那半边对(同一条记忆配给了更高相似的
413
- // 对手)本来就不是这一轮的候选,观察它们只会让分母虚高。
414
- const ledgerTriage = ledgerMode !== "off"
415
- ? await recordLedgerTriage(service, runId, selected, {
416
- dupMinSimilarity: config.conflictLedgerDupMinSimilarity,
417
- logger,
418
- // 分块记账的取消检查:用户活动打断时停止记账,余下的对下一轮重见
419
- signal
420
- })
421
- : null;
422
-
423
- // Issue #641(beta 第二阶段,enforce 档):确定性处置接线。T1(dup)走
424
- // supersedeInfoFirstPair(与冻结分支自动档同一条契约,decisions.js 共享实现);
425
- // T1 走信息量优先 supersede、T3 走取代链(superseded_by 指向,不归档)、
426
- // T2/T4 记账即处置——不停车、不送 LLM。人审队列与 LLM 裁决在本档下都不再
427
- // 消费冲突候选,conflict_pending 恒空。每轮上限沿用
428
- // sleepConflictMaxPairsPerRun:处置虽不走 LLM,归档/落链仍是写动作,写预算
429
- // 与 freeze/auto 两档同口径。T4 的检索时消解(fuseRecall 矛盾感知 + 注入
430
- // 标注)是后续 PR,本档先把人审摘出去。
431
- if (ledgerMode === "enforce") {
432
- const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
433
- const pool = perRunCap > 0 ? selected.slice(0, perRunCap) : selected;
434
- let t1Resolved = 0;
435
- let t1Failed = 0;
436
- let t3Chained = 0;
437
- const t1VectorFixups = [];
438
- for (const p of pool) {
439
- const verdict = triagePair(p.a, p.b, p.similarity, { dupMinSimilarity: config.conflictLedgerDupMinSimilarity });
440
- if (verdict.tier === "evolution") {
441
- // T3 取代链(beta 第三阶段):旧行 superseded_by → 新行,检索降权,
442
- // **不归档**——演进不是输赢,旧值仍是「曾经为真」的可追溯历史(与
443
- // supersede 动作的归档语义不同)。新者胜对演进成立:间隔 ≥7 天的
444
- // 同主题对,新记载才反映现状(服务器时钟,内容声称的时间不做键)。
445
- const newer = (p.a.created_at ?? "") >= (p.b.created_at ?? "") ? p.a : p.b;
446
- const older = newer === p.a ? p.b : p.a;
447
- try {
448
- if (service.supersedeChain(older.id, newer.id)) {
449
- t3Chained += 1;
450
- // 先落观察行再推进状态(#650 评审):enforce 档下 recordLedgerTriage 只会
451
- // 把这一对写成 open,UPDATE 于此才有行可命中;顺序反了就是静默落空。
452
- service.saveConflictLedgerEntry({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, tier: verdict.tier, similarity: p.similarity, signals: verdict.signals });
453
- service.setConflictLedgerStatus(p.a.id, p.b.id, "superseded");
454
- }
455
- } catch (error) {
456
- logger?.warn?.(`dsh-mneme sleep: ledger T3 chain failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
457
- }
458
- continue;
459
- }
460
- if (verdict.tier !== "dup") continue;
461
- let res = null;
462
- try {
463
- res = supersedeInfoFirstPair(p, service, logger, config);
464
- } catch (error) {
465
- logger?.warn?.(`dsh-mneme sleep: ledger T1 supersede failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
466
- }
467
- if (res?.applied) {
468
- t1Resolved += 1;
469
- // winner/loser 用 helper 判定的真实方向(信息量优先可能翻转 p.a/p.b)
470
- t1VectorFixups.push({ action: "supersede", winner: res.winnerId, loser: res.loserId });
471
- try {
472
- // 账本状态推进 + 机器台账照记(与自动档同款 outcome 口径)——自动处置
473
- // 也要能事后回答「这一对什么时候、被谁判的」。先落观察行(#650 评审):
474
- // 这一对未必已在账本里,直接 UPDATE 会命中 0 行而静默失记。
475
- service.saveConflictLedgerEntry({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, tier: verdict.tier, similarity: p.similarity, signals: verdict.signals });
476
- service.setConflictLedgerStatus(p.a.id, p.b.id, "superseded");
477
- service.saveConflictReviewed?.({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, outcome: "supersede", judged_at: new Date().toISOString() });
478
- } catch (error) {
479
- logger?.warn?.(`dsh-mneme sleep: ledger bookkeeping after T1 supersede failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
480
- }
481
- } else {
482
- t1Failed += 1;
483
- }
484
- }
485
- // 败者向量清理:T1 的 supersede 不在下面 LLM 通路的 maintainIndexAfterDream
486
- // 清单里(enforce 已早退),必须在早退前自己清——已归档行留向量等于检索
487
- // 层「看得见摸不着」。
488
- if (t1VectorFixups.length > 0 && semantic?.embedder && semantic?.vectorIndex) {
489
- try {
490
- await maintainIndexAfterDream(t1VectorFixups, service, semantic);
491
- } catch (error) {
492
- logger?.warn?.(`dsh-mneme sleep: ledger T1 index maintenance failed: ${String(error)}`);
493
- }
494
- }
495
- if (selected.length > pool.length) {
496
- logger?.warn?.(`dsh-mneme sleep: conflict ledger enforce capped at ${pool.length}/${selected.length} pairs this run`);
497
- }
498
- return {
499
- status: t1Failed > 0 ? "degraded" : t1Resolved > 0 || t3Chained > 0 ? "ok" : "noop",
500
- clean: t1Failed === 0,
501
- frozen: 0,
502
- scopeCandidates: 0,
503
- pairs: selected.length,
504
- batches: 0,
505
- ledgerSkipped,
506
- // applied 是整轮回执的唯一写库口径(runSleep 的 totalApplied 只认这个键)。
507
- // 缺了它,本档真实发生的 T1 归档与 T3 落链在 receipt / dream_runs.applied
508
- // 里全是 0 ——「本相位说改了、整轮说没改」(#650 评审,#619 同族)。
509
- applied: t1Resolved + t3Chained,
510
- enforced: { t1Resolved, t1Failed, t3Chained },
511
- ...(ledgerTriage ? { ledgerTriage } : {})
512
- };
513
- }
514
-
515
- // v0.8.1(issue #170 第 2 步):跨 scope 相似对分流。同一内容落在两个归属下
516
- // (任一维标注键不等即算,NULL=未标注=全局也参与比较——「全局副本 + 专属副本
517
- // 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
518
- // 某个归属下的唯一副本,LLM 也无从替用户决定归属。一律停车到冲突待确认队列
519
- // (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
520
- const crossPairs = selected.filter(isCrossScopePair);
521
- const samePairs = selected.filter((p) => !isCrossScopePair(p));
522
- // 四态:fresh(真的新插了一行)/ existing(这一对已在队列里)/ suppressed
523
- // (已人工裁决且两侧未变,复核项 4)/ failed(写库抛错)。旧代码把
524
- // 「返回非 null」一律当停车成功,existing 因此每轮虚报一遍 frozen(#603)。
525
- const parkPair = (p, reason) => {
526
- try {
527
- const parked = service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason, similarity: p.similarity });
528
- if (parked == null) return "suppressed";
529
- return parked.fresh === true ? "fresh" : "existing";
530
- } catch (error) {
531
- logger?.warn?.(`dsh-mneme sleep: failed to park conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
532
- return "failed";
533
- }
534
- };
535
- const scopeReason = (p) => STR.scopeCandidateReason[language](p.similarity.toFixed(2));
536
- const simReason = (p) => STR.similarityReason[language](p.similarity.toFixed(2));
537
-
538
- // 待审对的键集合(与 pairKey 同形)。只用于 freeze 分支的「先滤已入队」,
539
- // 分页拉全量:队列规模不受 50 条默认页长限制。
540
- const loadPendingPairKeys = () => {
541
- const keys = new Set();
542
- if (typeof service.listConflictPending !== "function") return keys;
543
- const pageSize = 500;
544
- for (let offset = 0; ; offset += pageSize) {
545
- const page = service.listConflictPending({ limit: pageSize, offset, includeResolved: false }) ?? [];
546
- for (const row of page) keys.add(`${row.memory_a}\u0000${row.memory_b}`);
547
- if (page.length < pageSize) break;
548
- }
549
- return keys;
550
- };
551
-
552
- // Freeze mode: park pairs for manual review, no LLM required. Cross-scope
553
- // pairs carry the dedicated scope-candidate reason so the queue reads them
554
- // as ownership decisions rather than plain duplicates.
555
- //
556
- // Issue #603 第 1 层:这条分支此前绕过全部三个上限、直接裸跑 selected。
557
- // · 先滤已入队再截断——裸 slice 会饥饿:上限恒小于候选数时,前 N 个名额
558
- // 每轮都被同一批已在队列里的对占住,后面的对永远轮不上;
559
- // · 接预算:conflictFreezeMaxPending 决定队列还能装多少,
560
- // sleepConflictMaxPairsPerRun 决定一轮最多处理多少,取两者较小值;
561
- // · 只把 fresh 计入 frozen,已入队的对不再虚报。
562
- //
563
- // ⚠️ Issue #616:下面这段 freeze 分支同时也是「自动裁决档」的唯一宿主——
564
- // conflictAutoResolveEnabled 的全部逻辑(autoEnabled / autoPool / autoResolved)
565
- // 都在这对花括号里面。两个配置键在面板上看是平行的,实际是父子:把 freeze
566
- // 关掉,自动档会跟着静默消失,没有任何提示。拆开之前别把它们当独立开关用。
567
- if (config.conflictFreezeEnabled === true) {
568
- const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;
569
- const pendingNow = typeof service.countConflictPending === "function" ? (service.countConflictPending() ?? 0) : 0;
570
- const room = Math.max(0, maxPending - pendingNow);
571
- const pendingKeys = loadPendingPairKeys();
572
- const unparked = selected.filter((p) => !pendingKeys.has(pairKey(p)));
573
- const withinRoom = room > 0 ? unparked.slice(0, room) : [];
574
- const pendingSkipped = unparked.length - withinRoom.length;
575
- const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
576
- const pool = perRunCap > 0 ? withinRoom.slice(0, perRunCap) : withinRoom;
577
- // 只报「有位置但被每轮上限截住」的那部分:队列满时再追一轮也没用,
578
- // 报成 backlog 会让调度器空转(catchup 上限 24 轮)。
579
- const perRunSkipped = withinRoom.length - pool.length;
580
- // Issue #603 第 4 层(自动裁决档,默认关):相似度极高的**同 scope** 对不再
581
- // 进人工队列,直接保留信息量更大的那条、归档另一条。人只看模糊地带。
582
- //
583
- // 为什么阈值留在配置里而不是写死 0.95:这一档减的是峰值,
584
- // 不是把队列清零——把阈值当常量会让人以为「开了就没事了」。
585
- //
586
- // 为什么跨 scope 永不自动:归属是用户的决定(v0.8.1 #170),归档败者可能
587
- // 销毁某归属下的唯一副本。这条约束比减负重要。
588
- //
589
- // ⚠️ Issue #616:这条守卫有一个洞。isCrossScopePair 走 scopeKeyOf,NULL 归一
590
- // 成 null,null !== null 判为 false —— 「未标注」因此被当成「同 scope」混进
591
- // autoPool。live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL。改判据
592
- // 之前,别把这一档当成「归属安全」。
593
- //
594
- // 为什么用 supersede 而不是 conflict:conflict 会把「(已否决旧信息:…)」
595
- // 追加到**赢家**正文,而赢家正是留下来的那条;近重复对再叠一段互相引用的
596
- // 注记,等于用冗余污染幸存记忆。supersede 把可追溯性留在被归档的一侧,
597
- // 赢家正文一字不动(#126 的原设计)。
598
- const autoEnabled = config.conflictAutoResolveEnabled === true;
599
- const autoMin = typeof config.conflictAutoResolveMinSimilarity === "number"
600
- ? config.conflictAutoResolveMinSimilarity
601
- : 0.95;
602
- const autoPool = [];
603
- const parkPool = [];
604
- for (const p of pool) {
605
- const eligible = autoEnabled
606
- && !isCrossScopePair(p)
607
- && typeof p.similarity === "number"
608
- && p.similarity >= autoMin;
609
- (eligible ? autoPool : parkPool).push(p);
610
- }
611
- let autoResolved = 0;
612
- let autoFailed = 0;
613
- // 这一档不走 LLM,没有批次时间戳可借(judgedAt 在下面那条 LLM 通路里才定义),
614
- // 自己取一个:台账要能回答「什么时候判的」,不能留空。
615
- const autoJudgedAt = new Date().toISOString();
616
- // 保留**信息量更大**的那条,不是更新的那条。
617
- //
618
- // 「保留新的」看着合理,实测是错的:live 库 >=0.95 的 20 对里,11 对(55%)
619
- // 新的那条比旧的短,平均短 132 字;降到 0.88 的 213 对里 122 对(57%)如此。
620
- // 典型例子:「用户同时开 web profile 和 desktop profile 时出现开不了新对话/
621
- // 会话异常,用户自己…」(371 字,旧)被压成「用户同时开 web profile 和
622
- // desktop profile 的 dsh-mneme 时开不了新对话」(307 字,新)——蒸馏时压缩过
623
- // 的一版反而更新。留新条等于把更完整的那条归档掉。
624
- //
625
- // 这与 mem0 的裁决规则一致:`configs/prompts.py:217` 明写「两句话说的是同一
626
- // 件事时,留信息量最大的那条,不是留最新的」。cognee 反过来(「取更新的」),
627
- // 那是反例——照抄会把「0:00 前必须躺下」削成残缺版。
628
- //
629
- // 长度相同才算「信息量相当」,此时退回更新时间,再退回 id:三级兜底保证同一
630
- // 对每次判出同一个赢家(否则重放会来回翻,归档的可能是刚保住的那条)。
631
- const infoFirst = (p) => {
632
- const an = p.a.content?.length ?? 0;
633
- const bn = p.b.content?.length ?? 0;
634
- if (an !== bn) return an > bn ? [p.a, p.b] : [p.b, p.a];
635
- const au = p.a.updated_at ?? "";
636
- const bu = p.b.updated_at ?? "";
637
- if (au !== bu) return au > bu ? [p.a, p.b] : [p.b, p.a];
638
- return p.a.id > p.b.id ? [p.a, p.b] : [p.b, p.a];
639
- };
640
- for (const p of autoPool) {
641
- const [winner, loser] = infoFirst(p);
642
- const snapshot = new Map();
643
- for (const m of [winner, loser]) {
644
- snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
645
- }
646
- // trustEpistemicWeighting 显式关掉:它会在 applySupersede 里按 epistemic_status
647
- // 交换赢家,那会让「留信息量更大的那条」这条契约随数据漂移。自动档的承诺
648
- // 必须是确定的。
649
- const { applied, conflicts } = applyDecisions(
650
- [{ action: "supersede", winner: winner.id, loser: loser.id }],
651
- service, logger, snapshot, { ...config, trustEpistemicWeighting: false }
652
- );
653
- if (applied > 0) {
654
- autoResolved++;
655
- // 记进机器侧台账:自动动作也要能事后回答「这一对什么时候、被谁判的」。
656
- try {
657
- service.saveConflictReviewed?.({
658
- run_id: runId, memory_a: p.a.id, memory_b: p.b.id,
659
- outcome: "supersede", judged_at: autoJudgedAt
660
- });
661
- } catch (error) {
662
- logger?.warn?.(`dsh-mneme sleep: failed to record auto-resolved pair ${p.a.id}/${p.b.id}: ${String(error)}`);
663
- }
664
- continue;
665
- }
666
- // 自动档没落地 → 退回人工队列。绝不因为「本来想自动处理」就把这一对丢掉,
667
- // 那才是真正的静默丢失。两种没落地的原因都要说清楚:
668
- // · CAS 冲突(applyDecisions 记在 conflicts 里)——两侧在快照之后被改过,
669
- // 自动判的赢家可能已经不是该保的那条,人看一眼最稳;
670
- // · 其余异常(记在 failures 里)——写库失败之类,重试无意义就交给人。
671
- autoFailed++;
672
- logger?.warn?.(
673
- `dsh-mneme sleep: auto-resolve skipped ${p.a.id}/${p.b.id}`
674
- + (conflicts.length ? ` (concurrent edit)` : "")
675
- + `, falling back to manual review`
676
- );
677
- parkPool.push(p);
678
- }
679
- let frozen = 0;
680
- let parkFailed = 0;
681
- for (const p of parkPool) {
682
- const outcome = parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p));
683
- if (outcome === "fresh") frozen++;
684
- else if (outcome === "failed") parkFailed++;
685
- }
686
- if (pendingSkipped > 0) {
687
- logger?.warn?.(`dsh-mneme sleep: conflict freeze queue full (${pendingNow}/${maxPending}), skipped ${pendingSkipped} conflict(s)`);
688
- }
689
- return {
690
- status: frozen > 0 || autoResolved > 0 ? "ok" : "noop",
691
- frozen,
692
- // 自动裁决掉的不进队列,但确实是这一轮干的活——分开报,别混进 frozen
693
- // (frozen 的语义是「给人工队列新增了几行」,混进去面板就会虚高)。
694
- // Issue #616:这两个字段目前**没有消费者**。dream.js 的两处 return 只透出
695
- // frozen,面板也不读它们;conflict_reviewed 台账同样没有 API 暴露,且 schema
696
- // 不记赢家(memory_a/b 是排序过的)。也就是说自动裁决干了什么,事后只能翻
697
- // loser 正文的「(已被取代:…)」后缀反推。补可见性之前,别指望从报告里看出
698
- // 这一档跑了几对。
699
- autoResolved,
700
- autoFailed,
701
- // 写库抛错的对既没入队也不算落地:clean=false,别让调度器当「追完了」。
702
- clean: parkFailed === 0,
703
- scopeCandidates: crossPairs.length,
704
- pairs: selected.length,
705
- batches: 0,
706
- ledgerSkipped,
707
- perRunSkipped,
708
- pendingSkipped,
709
- // #641 shadow:分诊分布随相位结果透出(dream phases 与审计行都能看到),
710
- // 面板消费在 beta 第二批接。
711
- ...(ledgerTriage ? { ledgerTriage } : {})
712
- };
713
- }
714
-
715
- // 非 freeze:跨 scope 对先停车(绝不进 LLM 裁决),同 scope 对照常走 LLM。
716
- let scopeParked = 0;
717
- for (const p of crossPairs) {
718
- if (parkPair(p, scopeReason(p)) === "fresh") scopeParked++;
719
- }
720
- if (samePairs.length === 0) {
721
- // 没有可送 LLM 的同 scope 对:可能一对都没检测到,也可能全被台账挡下了
722
- // (ledgerSkipped)。两种都要如实报,否则「sleep 怎么什么都不干」读不出来。
723
- return {
724
- status: scopeParked > 0 ? "ok" : "noop",
725
- frozen: 0,
726
- scopeCandidates: scopeParked,
727
- pairs: selected.length,
728
- batches: 0,
729
- ledgerSkipped,
730
- ...(ledgerTriage ? { ledgerTriage } : {})
731
- };
732
- }
733
-
734
- // LLM adjudication(仅同 scope 对)。
735
- const route = resolveSleepRoute(ctx, config, logger);
736
- if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length, batches: 0, ledgerSkipped, ...(ledgerTriage ? { ledgerTriage } : {}) };
737
- // 每轮总上限:分批解决的是单请求撞墙,不是"一个 449 对的池子该在一轮里跑完"。
738
- // 没进这一轮的对留在下一轮(它们既没被丢弃,也不在台账里)。
739
- const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
740
- const pool = perRunCap > 0 ? samePairs.slice(0, perRunCap) : samePairs;
741
- const perRunSkipped = samePairs.length - pool.length;
742
- const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
743
- let conflictStreamFailure = "";
744
- // actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
745
- // 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
746
- const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
747
- // reflectionUpdateEnabled=false:两份 conflict prompt 都教了模型怎么用 update
748
- // (「信息部分过时 → update」那一支),只靠校验器拒会把每轮这份输出白付掉。
749
- // 后缀与 dream 主链路共用同一条文案,两个入口对用户是同一个开关、同一种说法。
750
- const conflictPromptGated = config.reflectionUpdateEnabled !== false
751
- ? conflictPrompt
752
- : conflictPrompt + STR.prompts.noUpdateSuffix[language];
753
- const runConflict = (batch, withEffort, tokens) => {
754
- conflictStreamFailure = "";
755
- const listText = batch.map((p) => STR.candidateConflicts[language](p)).join("\n\n");
756
- return auditedSleepLlm(ctx, service, config, logger, {
757
- triggerSource: "sleep",
758
- operationType: "sleep_conflict",
759
- modelId: `${route.provider}:${route.model}`,
760
- // 候选对的双方 id:面板的活动流用 related_memory_ids 解析「沉淀条数」。
761
- // 分批后必须取**这一批**的 id:整轮的 60 对一次写进每一批的审计行,面板会把
762
- // 同一个「沉淀条数」重复累加五遍。
763
- relatedMemoryIds: batch.flatMap((p) => [p.a.id, p.b.id]),
764
- streamError: () => conflictStreamFailure,
765
- // 纯判据:输出里没有 JSON 数组 ⇒ 这轮是失败的调用,审计记 error。**不要**把
766
- // 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
767
- // 这个钩子不会被调用,控制流会跟着失效(dream 的既有坑)。代价是审计开启时
768
- // 多解析一次,换来两条路径彻底解耦。
769
- auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output"),
770
- // 纯判据,与 auditError 同一条约定:重解析一次、不读闭包(审计关掉时那些闭包
771
- // 根本不会被赋值,读它会让「审计说了什么」依赖调用方走到了哪一行)。
772
- metadataFrom: (text) => (parseJsonArray(text) ? null : parseFailureMeta(text))
773
- }, (reportUsage) => streamText(ctx, {
774
- provider: route.provider,
775
- model: route.model,
776
- purpose: "sleep-conflict",
777
- maxTokens: tokens,
778
- ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
779
- messages: [
780
- { role: "system", content: [{ type: "text", text: conflictPromptGated }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
781
- { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
782
- ]
783
- }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
784
- };
785
- // 单批的完整往返:送 → 解析 → 校验 → 应用 → 记账。抽成函数是为了让失败只影响
786
- // 自己这一批(下面的循环),而不是把整轮判死。
787
- const adjudicateBatch = async (batch, updateBudget) => {
788
- // Issue #529 review(CodeRabbit ①):留痕写入失败要能被上层看见。只打 warn 的话,
789
- // 这一对既没记账、又被算成"送出去的全落地了",于是下一轮它原样回到候选池、又被
790
- // 算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而审计上每轮
791
- // 都是干净的成功轮。计数随返回值透出,由 phaseConflicts 折进 clean。
792
- let ledgerFailed = 0;
793
- // Issue #536 review(CodeRabbit ③):judged_at 是**发送时刻**,不是落地时刻。
794
- //
795
- // 取 max(墙钟, 本批两侧里最新的 updated_at):`updated_at` 用的是 store 的
796
- // **单调时钟**(src/store.js 的 nowIso:同一毫秒内的连续写入逐次 +1ms),它可能
797
- // 比墙钟快几毫秒;直接用墙钟会记出一个早于被测行自身版本的 `judged_at`,于是
798
- // 「这一对刚判过」在下一轮的 `alreadyJudged` 里读成「判过之后又被改过」,判 keep
799
- // 的死循环原样复活(CI 的 test-host-legacy 腿抓到过一次真 flake)。两列同源才可比。
800
- const judgedAt = batch.reduce(
801
- (acc, p) => (p.a.updated_at > acc ? p.a.updated_at : (p.b.updated_at > acc ? p.b.updated_at : acc)),
802
- new Date().toISOString()
803
- );
804
- const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runConflict(batch, true, tokens), () => runConflict(batch, false, tokens), () => conflictStreamFailure), () => conflictStreamFailure);
805
- if (text === undefined) {
806
- if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
807
- return { status: "failed", error: "llm failed", failure: conflictStreamFailure || undefined };
808
- }
809
- const decisions = parseJsonArray(text);
810
- if (!decisions) return { status: "failed", error: "invalid decisions json" };
811
- // 本批的 snapshot 只含本批的 id。validateDecisions 要求每个 snapshot id 恰好被
812
- // claim 一次,拿整轮的 snapshot 校验单批输出会把其余 48 对判成"未裁决"。
813
- const batchSnapshot = new Map();
814
- for (const p of batch) {
815
- batchSnapshot.set(p.a.id, p.a);
816
- batchSnapshot.set(p.b.id, p.b);
817
- }
818
- // v0.4.4 起 validateDecisions 本身就会为未覆盖的 id 自动补 keep(dreamImplicitKeep
819
- // 默认开启),这里保留显式预填作为防御性双保险——漏判读作"未裁决冲突"而非
820
- // "冲突阶段整体失败"。
821
- const covered = new Set();
822
- for (const d of decisions) {
823
- // Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
824
- // 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
825
- // supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
826
- if (d?.action === "conflict" || d?.action === "supersede") {
827
- if (typeof d?.winner === "string") covered.add(d.winner);
828
- if (typeof d?.loser === "string") covered.add(d.loser);
829
- } else if (Array.isArray(d?.ids)) {
830
- for (const id of d.ids) covered.add(id);
831
- }
832
- }
833
- for (const id of batchSnapshot.keys()) {
834
- if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
835
- }
836
- // Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
837
- // 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
838
- // 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
839
- // 模块对"什么算合法"的判据一致。
840
- const { ok, errors, skipped } = validateDecisions(decisions, batchSnapshot, {
841
- // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限,
842
- // 分批后不能每批都拿整份额度——那样 N 批就是 N 倍上限。这里用的是本轮的剩余额度。
843
- maxUpdatePerRun: updateBudget,
844
- // reflectionUpdateEnabled=false:update 单条跳过(同 dream 主链路口径)。
845
- allowUpdate: config.reflectionUpdateEnabled !== false,
846
- minAgeHours: config.reflectionUpdateMinAgeHours,
847
- skipInvalid: config.dreamSkipInvalid !== false,
848
- allowCrossTypeMerge: config.allowCrossTypeMerge === true,
849
- // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
850
- // 与 dream 主链路同一判据。
851
- mergeGuard: config.dreamMergeGuard === true,
852
- // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
853
- // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
854
- allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
855
- });
856
- if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
857
- // 同 phasePatterns——本相位的 create 也落 source=sleep(默认档的
858
- // LEGACY_ACTIONS 含 create,理论上可 mint)。
859
- const { applied, failures, conflicts } = applyDecisions(
860
- // runId 让 differentiate 的账本行能追到是哪一轮 sleep 判的(issue #533)。
861
- decisions, service, logger, batchSnapshot, { ...config, mintSource: "sleep", runId }
862
- );
863
- // Issue #536 review(CodeRabbit ②):本轮剩余的 update 额度按本批**通过校验的**
864
- // update 数扣减——validateDecisions 已把非法条目从 decisions 就地 splice 掉,
865
- // 所以数到的都是真的会落地的那些。落地时被 CAS 挡下的也算:额度宁可少发一次,
866
- // 也不能让"多批各扣一次满额"把整轮上限架空。
867
- const updateUsed = decisions.filter((d) => d?.action === "update").length;
868
- // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
869
- // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
870
- // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
871
- if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
872
- try {
873
- await maintainIndexAfterDream(decisions, service, semantic);
874
- } catch (error) {
875
- logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
876
- }
877
- }
878
- // 台账:本批**全部送审对**都记一行,不只记被判成冲突的那几对。判 keep 的那一对
879
- // 正是 #529 里"下一轮原样再送"的元凶,只记有动作的对等于没记。
880
- if (typeof service.saveConflictReviewed === "function") {
881
- // judged_at 取的是**发送之前**的时刻(见函数开头),不是落地之后:LLM 往返
882
- // 期间若一侧被外部改动,它的 updated_at 会晚于 judged_at,下一轮自然重新入池;
883
- // 模型看到的本来就是旧快照,按"已审"封盘等于用陈旧结论锁死这一对。
884
- const outcomeOf = new Map();
885
- for (const d of decisions) {
886
- const action = d?.action ?? "keep";
887
- if (action === "conflict" || action === "supersede") {
888
- if (typeof d?.winner === "string") outcomeOf.set(d.winner, action);
889
- if (typeof d?.loser === "string") outcomeOf.set(d.loser, action);
890
- } else if (Array.isArray(d?.ids)) {
891
- for (const id of d.ids) outcomeOf.set(id, action);
892
- }
893
- }
894
- // Issue #536 review(CodeRabbit ③):未落地的对不记账。skipped(非法被跳)、
895
- // failures(写入失败)、conflicts(CAS 冲突)里出现过的 id,说明那条决策没生效;
896
- // 把它记成"已审"会让下一轮的 alreadyJudged 为真,这一对从此不再送审——
897
- // 沉默地永久压制一个真实冲突,比多送一轮贵得多。
898
- const unsettled = new Set();
899
- for (const f of [...(failures ?? []), ...(conflicts ?? []), ...(skipped ?? [])]) {
900
- for (const id of f?.ids ?? []) unsettled.add(id);
901
- }
902
- for (const p of batch) {
903
- if (unsettled.has(p.a.id) || unsettled.has(p.b.id)) continue;
904
- try {
905
- service.saveConflictReviewed({
906
- run_id: runId,
907
- memory_a: p.a.id,
908
- memory_b: p.b.id,
909
- // 一对两端的动作可能不同(conflict 的胜/败、merge 的两端),取**非 keep
910
- // 的那一侧**:keep/keep 才代表"看过、不冲突",这正是我们要留痕的结论。
911
- outcome: [outcomeOf.get(p.a.id), outcomeOf.get(p.b.id)]
912
- .find((x) => x && x !== "keep") ?? "keep",
913
- judged_at: judgedAt
914
- });
915
- } catch (error) {
916
- // Issue #529 review(CodeRabbit):留痕失败必须让这一轮**不 clean**。只 warn 的
917
- // 话,这一对既没记账、又被算成"送出去的全落地了",下一轮它原样回到候选池、
918
- // 又被算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而
919
- // 审计上每一轮都是干净的成功轮。
920
- ledgerFailed += 1;
921
- logger?.warn?.(`dsh-mneme sleep: failed to record reviewed conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
922
- }
923
- }
924
- }
925
- // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
926
- // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
927
- return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", applied, failures, conflicts, skipped, updateUsed, ledgerFailed };
928
- };
929
- // 分批:每批独立往返,坏批只记 degraded 不中断。此前一次全送,任何一处理由
930
- // (撞上下文、输出截断、JSON 坏掉)都是整轮 failed——而整轮 failed 又不带 pairs,
931
- // 事后看不出它到底送了什么。
932
- const batchSize = config.sleepConflictMaxPairs ?? 12;
933
- const batches = batchSize > 0
934
- ? Array.from({ length: Math.ceil(pool.length / batchSize) }, (_, i) => pool.slice(i * batchSize, (i + 1) * batchSize))
935
- : [pool];
936
- let applied = 0;
937
- const failures = [];
938
- const conflicts = [];
939
- const skipped = [];
940
- let failedBatches = 0;
941
- // Issue #529 review(CodeRabbit ①):台账写入失败的累计数(逐批由 adjudicateBatch 报回)。
942
- let ledgerFailed = 0;
943
- // 用户活动打断时**不能**落进下面那条 noop:noop 的语义是「看过,没冲突」,而被打断
944
- // 的那几批根本没送出去。用与入口处(signal 一开始就是 aborted)同一个词汇,避免
945
- // 同一件事两个状态名。
946
- let interrupted = false;
947
- // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限。
948
- // 校验器本身不跨批记忆,每批各拿一份满额就等于 N 倍上限,所以额度由这里持有:
949
- // 每批只领剩余额度,校验通过后按**本批通过校验的** update 数扣减。额度用尽的
950
- // 那几批若还含 update,会被校验器整单拒绝(记 failed),其余批照常继续——
951
- // 与"一批坏掉不判死整轮"的既有契约一致。
952
- let updateLeft = config.reflectionUpdateMaxPerRun ?? 2;
953
- for (let i = 0; i < batches.length; i++) {
954
- if (signal?.aborted) { interrupted = true; break; }
955
- // Issue #536 review(CodeRabbit):单批抛错不能带走整轮。`adjudicateBatch` 里不只有
956
- // "把失败变成返回值"的 LLM 通路,还有 `applyDecisions` 与台账写入——它们抛出的异常
957
- // 此前会直接跳出循环,后面所有批都不跑、已汇总的 applied/failures 也一起丢掉。
958
- // 只放行 AbortError(取消意图绝不能被降级重试吞掉,与 dream.js 的 withEffort 守卫同款),
959
- // 其余一律折成这一批的 failed 结果,循环继续。
960
- let res;
961
- try {
962
- res = await adjudicateBatch(batches[i], updateLeft);
963
- } catch (error) {
964
- if (error?.name === "AbortError") { interrupted = true; break; }
965
- res = { status: "failed", error: String(error?.message ?? error), failures: [], skipped: [] };
966
- }
967
- if (res.status === "failed") {
968
- failedBatches++;
969
- failures.push(...(res.failures ?? []));
970
- skipped.push(...(res.skipped ?? []));
971
- // 失败批的原始理由(stream 层的 abort/error 原文)要进审计:只写 "llm failed"
972
- // 的话,运维分不清是上下文超限、模型拒收还是用户活动打断。
973
- logger?.warn?.(`dsh-mneme sleep: conflict batch ${i + 1}/${batches.length} failed: ${res.error}${res.failure ? ` (${res.failure})` : ""}`);
974
- continue;
975
- }
976
- applied += res.applied;
977
- ledgerFailed += res.ledgerFailed ?? 0;
978
- updateLeft = Math.max(0, updateLeft - (res.updateUsed ?? 0));
979
- failures.push(...(res.failures ?? []));
980
- conflicts.push(...(res.conflicts ?? []));
981
- skipped.push(...(res.skipped ?? []));
982
- }
983
- const degraded = skipped.length > 0 || failedBatches > 0;
984
- // 一批都没成功才算整轮失败;只要有一批落地,如实记 degraded——这跟 deriveStatus
985
- // 的契约一致(degraded 也算有产出)。
986
- const allFailed = failedBatches === batches.length;
987
- // 有一批没走通时整轮**绝不能报 noop**:noop 的语义是「看过,没冲突」,而踩了
988
- // llm failed 的那批其实没看过。这是 #529 里最误导人的一处——23 轮失败期间
989
- // applied 恒为 0,若再叠上 noop,运维只会以为库里没冲突。
990
- const status = interrupted
991
- ? "aborted"
992
- : allFailed
993
- ? "failed"
994
- : applied > 0
995
- ? (degraded ? "degraded" : "ok")
996
- : failures.length
997
- ? "failed"
998
- : failedBatches > 0
999
- ? "degraded"
1000
- : "noop";
1001
- // Issue #529(续排):「这一轮有没有产出」和「送出去的每一对是不是都落地了」是两件
1002
- // 正交的事,status 只回答前者。全判 keep 的轮次 applied 恒为 0 → status 是 noop
1003
- // (never ok),而它恰恰是 #529 里最该续排的场景;拿 status === "ok" 当判据会在
1004
- // 这里把续排堵死。所以另给一个布尔,口径与下面台账的 unsettled 完全一致:failure
1005
- // (写入失败)/ conflicts(CAS 冲突)/ skipped(非法被跳)任一出现过,就说明有对没
1006
- // 落地,它下一轮还会被重新配出来,不算 clean。
1007
- // Issue #529 review(CodeRabbit ①):台账写入失败同样算"没落地"——那一对没被记成
1008
- // 已审,下一轮会重新入池,与上面 unsettled 是同一个道理。
1009
- const clean =
1010
- !interrupted &&
1011
- failedBatches === 0 &&
1012
- skipped.length === 0 &&
1013
- failures.length === 0 &&
1014
- conflicts.length === 0 &&
1015
- ledgerFailed === 0;
1016
- return {
1017
- status,
1018
- clean,
1019
- // Issue #529 D:失败轮也要带 pairs/batches——此前只有成功路径带 pairs,85 条
1020
- // 失败审计行全是"送了个寂寞",连送了多少对都查不到。
1021
- pairs: selected.length,
1022
- // #533:这一轮被账本压掉、没送 LLM 的候选对数——不记的话「pairs 变小了」
1023
- // 分不清是没检测到还是已经裁决过。
1024
- suppressed,
1025
- batches: batches.length,
1026
- ledgerSkipped,
1027
- perRunSkipped,
1028
- applied,
1029
- // Issue #529 review(CodeRabbit ①):留痕失败的对数——clean 为 false 时要能分清是
1030
- // 哪一类没落地(写入失败 / 非法被跳 / CAS 冲突 / 台账没记上)。
1031
- ledgerFailed,
1032
- failures,
1033
- conflicts,
1034
- // Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断
1035
- // 是哪一对、因何被跳过(此前只进 logger.warn,splice 后就地消失)。
1036
- skipped,
1037
- // #641 shadow:分诊分布随相位结果透出(四个出口都要带——freeze/空池/无路由/
1038
- // LLM 全路径,缺一个出口 shadow 期就有部分轮次查不到分布)。
1039
- ...(ledgerTriage ? { ledgerTriage } : {})
1040
- };
1041
- }
1042
-
1043
- /**
1044
- * Phase 2 — archival demotion. No LLM: tiering is time-based, summaries are
1045
- * truncations, and the full body is preserved in _full_content so nothing is
1046
- * lost. Deterministic and cheap, so it runs even with no LLM route.
1047
- */
1048
- function phaseDemotion(service, config, logger, runId, signal = null) {
1049
- const archiveDays = config.sleepArchiveDays ?? 30;
1050
- const compressDays = config.sleepCompressDays ?? 90;
1051
- const archiveCut = Date.now() - archiveDays * 86400000;
1052
- const compressCut = Date.now() - compressDays * 86400000;
1053
- const demoted = [];
1054
- const archived = [];
1055
- // 被判定为「该降级」但 demoteToSummary 实际没动手的条数。单独报出来:
1056
- // 它正是「台账说降级了 N 条、实际一条没变」那个缺口,藏起来就等于没修。
1057
- let demoteSkipped = 0;
1058
- // 政策轴在这里尤其要紧:下面两行会真改归档面(setArchived / demoteToSummary)。
1059
- // 把过滤放在循环之外、而不是在循环体里 continue:一旦将来有人在循环体里加
1060
- // 副作用(现已有),漏掉那条 continue 就是静默改到被按下的行。
1061
- // 外来行也在循环之外挡掉,同一个理由:它不经本方蒸馏,时间分层无权动它的原文或
1062
- // 归档面;而下面两行动作(setArchived / demoteToSummary)不经 saveWithDedupe,
1063
- // 汇流口那道闸门到这里已经管不到了。
1064
- for (const m of withoutPaused(service, service.all().filter((m) => !isImportedRow(m)))) {
1065
- if (signal?.aborted) break;
1066
- if (m.archived || m.forgotten) continue;
1067
- // 已降级的行不必再进判定链:demoteToSummary 见到 _full_content 会直接返回,
1068
- // 但这行每轮仍会被选中、空转一次事务,再被下面无条件 push 进 demoted。
1069
- // 提前挡掉既省事务,也让「这一轮到底动了什么」从源头算得准(#603 台账)。
1070
- if (m._full_content) continue;
1071
- const ref = m.last_accessed_at ?? m.created_at;
1072
- if (!ref) continue;
1073
- const t = new Date(ref).getTime();
1074
- if (Number.isNaN(t)) continue;
1075
- // v0.7.0 热联合判定(仅 heatEnabled 时启用;默认关则退回纯时间分层,
1076
- // 与 v0.7.12 行为一致):时间窗之外再加两道保护闸——热度低于
1077
- // sleepHeatThreshold 且 importance<5 才允许降级。λ=0 的免疫类型 heat 恒
1078
- // 1.0 天然豁免(preference/pattern/summary 永不因 sleep 降级);importance
1079
- // ≥5 的紧要记忆无论多冷都保留。`冷但重要` 与 `热但低值` 均不满足条件。
1080
- const heatOn = config.heatEnabled !== false;
1081
- if (heatOn) {
1082
- const heat = computeHeat(m, Date.now(), config);
1083
- const heatProtected = heat >= (config.sleepHeatThreshold ?? 0.05);
1084
- const important = (m.importance ?? 0) >= 5;
1085
- if (heatProtected || important) continue;
1086
- }
1087
- if (t < compressCut) {
1088
- service.setArchived(m.id, true);
1089
- archived.push(m.id);
1090
- } else if (t < archiveCut) {
1091
- // minRefTimeMs re-checks freshness inside demoteToSummary's transaction:
1092
- // a recall touch landing after this snapshot must not demote the memory.
1093
- //
1094
- // 返回值必须看:demoteToSummary 在三种情况下返回 undefined(行不存在、
1095
- // 已降级、快照后被 recall 摸热),而这里原来无条件 push,于是台账把没发生
1096
- // 的降级记成发生了——live 库实测 12 轮里虚报 43%–99%(#603)。
1097
- const updated = service.demoteToSummary(m.id, makeSummary(m), { minRefTimeMs: archiveCut });
1098
- if (updated) demoted.push(m.id);
1099
- else demoteSkipped++;
1100
- }
1101
- }
1102
- return {
1103
- status: demoted.length || archived.length ? "ok" : "noop",
1104
- demoted,
1105
- archived,
1106
- demoteSkipped
1107
- };
1108
- }
1109
-
1110
- /**
1111
- * Phase 3 — pattern discovery. The LLM scans the most recent memories and
1112
- * mints type=pattern entries (create actions) with evidence references.
1113
- * The empty snapshot is intentional: create claims no existing id, so the
1114
- * "every id claimed" invariant is trivially satisfied for pure-create lists.
1115
- */
1116
- async function phasePatterns(ctx, service, config, logger, runId, signal = null) {
1117
- const language = langOf(config);
1118
- const route = resolveSleepRoute(ctx, config, logger);
1119
- if (!route) return { status: "skipped", reason: "no llm route" };
1120
- const limit = config.sleepPatternMinMemories ?? 100;
1121
- // #230:document 在查询层排除(excludeTypes)而不是截断后过滤——LIMIT 200
1122
- // 先生效的话,指针行一多就会把普通记忆挤出扫描窗,池子饿到门槛以下。
1123
- // 政策轴同理必须排在 `.slice(0, limit)` **之前**,否则被按下的行照样占掉
1124
- // sleepPatternMinMemories 的名额,而它们既不会被扫、也不该挤掉别人(#471)。
1125
- //
1126
- // archived 这一轴不在下面出现,不是漏了:list() 默认就带 `archived = 0`
1127
- // (store.js list 的 includeArchived 默认 false、onlyArchived 默认 false),
1128
- // 旧写法那句 `!m.archived` 一直是重复过滤——查询层已经把它挡在外面。
1129
- // 窗口至少等于本次要扫的名额:`sleepPatternMinMemories` 的合法上限是 1000
1130
- // (src/config.js:612)。窗口若还是死的 200,那么把这个值配到 200 以上的人
1131
- // **每一轮**都会触发下面那次无界重取,跟闸门按没按下无关——那是把这条修复
1132
- // 从「按需」变成「常付」。
1133
- const poolQuery = {
1134
- limit: Math.max(200, limit), includeForgotten: false,
1135
- // 三条政策轴全部下推到查询层:留在 LIMIT 之后过滤,等于让被丢掉的行为窗口占名额
1136
- // (模式扫描池会因此饿到 sleepPatternMinMemories 以下而静默跳过)。同一取舍见
1137
- // #230 的 document 轴与本文件 #471 的暂停轴,src/service.js:2247 那条注释记着前例。
1138
- excludeTypes: ["document", "summary", "pattern"],
1139
- excludeImported: true
1140
- };
1141
- const gather = (query) => withoutPaused(service, service.list(query));
1142
- let pool = gather(poolQuery);
1143
- // #515:暂停闸门要读 scope_state 并按「这一维有标注」匹配,下推进 SQL 就等于让判定
1144
- // 长出第二份实现(issue 里写过为什么否掉,`*` 那一行是关键的不可下推处)。所以这里
1145
- // 不动判定、只放宽取数:**只有窗口过滤后不够本次要扫的名额**才不带窗口重取一次。
1146
- // 常态仍只付一次有界查询;多付的那一次恰好就是原本会静默跳过的那一次——
1147
- // 症状从「库里没东西可扫」变成「扫了」,这才是这条闸门该有的行为。
1148
- if (pool.length < limit) pool = gather({ ...poolQuery, limit: null });
1149
- const memories = pool
1150
- .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
1151
- .slice(0, limit);
1152
- if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
1153
- if (signal?.aborted) return { status: "aborted", reason: "user activity" };
1154
- const listText = memories
1155
- .map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
1156
- .join("\n");
1157
- const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
1158
- const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
1159
- let patternStreamFailure = "";
1160
- const runPattern = (withEffort, tokens) => {
1161
- patternStreamFailure = "";
1162
- return auditedSleepLlm(ctx, service, config, logger, {
1163
- triggerSource: "sleep",
1164
- operationType: "sleep_pattern",
1165
- modelId: `${route.provider}:${route.model}`,
1166
- // 本轮扫描的记忆 id:与 dream 的 relatedMemoryIds 同义,供面板解析沉淀条数。
1167
- relatedMemoryIds: memories.map((m) => m.id),
1168
- streamError: () => patternStreamFailure,
1169
- // 纯判据,同 conflict 阶段:不把解析结果存进闭包供控制流使用。
1170
- auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output"),
1171
- // 纯判据,与 auditError 同一条约定:重解析一次、不读闭包(审计关掉时那些闭包
1172
- // 根本不会被赋值,读它会让「审计说了什么」依赖调用方走到了哪一行)。
1173
- metadataFrom: (text) => (parseJsonArray(text) ? null : parseFailureMeta(text))
1174
- }, (reportUsage) => streamText(ctx, {
1175
- provider: route.provider,
1176
- model: route.model,
1177
- purpose: "sleep-pattern",
1178
- maxTokens: tokens,
1179
- ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
1180
- messages: [
1181
- { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
1182
- { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1183
- ]
1184
- }, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
1185
- };
1186
- const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runPattern(true, tokens), () => runPattern(false, tokens), () => patternStreamFailure), () => patternStreamFailure);
1187
- if (text === undefined) {
1188
- if (patternStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: pattern stream aborted or errored (${patternStreamFailure})`);
1189
- return { status: "failed", error: "llm failed" };
1190
- }
1191
- const decisions = parseJsonArray(text);
1192
- if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
1193
- // Evidence ids are provenance refs; an LLM-fabricated id would mint a dead
1194
- // ev:tag pointing nowhere. Intersect evidence with the scanned set so only
1195
- // real memory references survive.
1196
- const scannedIds = new Set(memories.map((m) => m.id));
1197
- for (const d of decisions) {
1198
- if (d?.action === "create" && Array.isArray(d.evidence)) {
1199
- d.evidence = d.evidence.filter((id) => typeof id === "string" && scannedIds.has(id));
1200
- }
1201
- }
1202
- const snapshot = new Map();
1203
- const { ok, errors } = validateDecisions(decisions, snapshot, {
1204
- maxCreatePerRun: maxPatterns
1205
- });
1206
- if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
1207
- // 本相位的 create 落 source=sleep。来源从管道给而非从决策对象取——见
1208
- // applyDecisions 的 @param config。
1209
- // #512:runId 也必须给——这是 sleep 唯一的真实铸造路径,漏了它,铸造出来的
1210
- // pattern 就只能追到「sleep 这条管道」,追不到「哪一轮 sleep」(正是 #512 要补的口子)。
1211
- const { applied, merged, failures, conflicts } = applyDecisions(
1212
- decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId }
1213
- );
1214
- return {
1215
- // A pattern that hit an existing same-title row is refreshed in place, not
1216
- // minted. Counting it as `ok` with `applied: 0` claims a mint that did not
1217
- // happen; report the refresh separately (#619 audit round 6).
1218
- status: applied > 0 ? "ok" : (merged > 0 ? "refreshed" : "noop"),
1219
- scanned: memories.length,
1220
- applied,
1221
- merged,
1222
- failures,
1223
- conflicts
1224
- };
1225
- }
1226
-
1227
- /**
1228
- * Phase 5 — affective attribution (issue #619). The LLM reads recent memories and
1229
- * names emotions, but the shape of what it may write is fixed here, not by the
1230
- * prompt: an emotion tied to **one occasion** becomes an `affective_context`
1231
- * relation carrying the attribution on the edge; only an emotion that recurs
1232
- * across sleepAffectiveTraitMinSessions **independent sessions** may be minted as
1233
- * a standing trait (a type=pattern memory, through the same
1234
- * validateDecisions/applyDecisions channel every other mint uses).
1235
- *
1236
- * The whole point is the guardrail, so it lives in code and not in the prompt:
1237
- * - the model may *claim* trait, but the claim only survives if provenance shows
1238
- * enough distinct sessions. A single session is downgraded to occasion, never
1239
- * dropped silently — dropped claims are counted and reported.
1240
- * - person/target must resolve to entities that already exist; an emotion about
1241
- * an unknown name is not evidence, it is a hallucination.
1242
- * - evidence ids must be inside the scanned pool (same rule phasePatterns uses).
1243
- * Off by default (sleepAffectiveEnabled) and a child of sleepModeEnabled: no
1244
- * route, no entities, or the flag off all mean "skip", never "run anyway".
1245
- */
1246
- async function phaseAffective(ctx, service, config, logger, runId, signal = null) {
1247
- if (config.sleepAffectiveEnabled !== true) return { status: "skipped", reason: "disabled" };
1248
- const route = resolveSleepRoute(ctx, config, logger);
1249
- if (!route) return { status: "skipped", reason: "no llm route" };
1250
- const entities = service.listEntities({ limit: 1000 }) ?? [];
1251
- if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
1252
- const limit = config.sleepPatternMinMemories ?? 100;
1253
- const poolQuery = {
1254
- limit: Math.max(200, limit),
1255
- includeForgotten: false,
1256
- excludeTypes: ["document", "summary", "pattern"],
1257
- excludeImported: true
1258
- };
1259
- const gather = (query) => withoutPaused(service, service.list(query));
1260
- let pool = gather(poolQuery);
1261
- if (pool.length < limit) pool = gather({ ...poolQuery, limit: null });
1262
- const memories = [...pool].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0, limit);
1263
- if (memories.length === 0) return { status: "skipped", reason: "no memories" };
1264
- const scannedIds = new Set(memories.map((m) => m.id));
1265
- const byName = new Map();
1266
- for (const e of entities) byName.set(e.name.toLowerCase(), e);
1267
- const traitMinSessions = config.sleepAffectiveTraitMinSessions ?? 3;
1268
- const maxRelations = config.sleepAffectiveMaxRelationsPerRun ?? 10;
1269
- const maxTraits = config.sleepMaxPatternPerRun ?? 3;
1270
- const language = langOf(config).startsWith("zh") ? "zh" : "en";
1271
- const entityNames = entities.map((e) => e.name).slice(0, 200).join(", ");
1272
- const listText = memories.map((m) => `id=${m.id} | time=${m.updated_at ?? ""} | type=${m.type} | title=${m.title} | content=${m.content}`).join("\n");
1273
- let streamFailure;
1274
- const runAffective = (tokens) => auditedSleepLlm(
1275
- ctx, service, config, logger,
1276
- {
1277
- triggerSource: "sleep",
1278
- operationType: "sleep_affective",
1279
- modelId: `${route.provider}:${route.model}`,
1280
- relatedMemoryIds: memories.map((m) => m.id),
1281
- streamError: () => streamFailure,
1282
- auditError: (out) => (parseJsonArray(out) ? null : "no json array in llm output"),
1283
- metadataFrom: (out) => (parseJsonArray(out) ? null : parseFailureMeta(out))
1284
- },
1285
- (reportUsage) => streamText(ctx, {
1286
- provider: route.provider,
1287
- model: route.model,
1288
- purpose: "sleep-affective",
1289
- maxTokens: tokens,
1290
- messages: [
1291
- {
1292
- role: "system",
1293
- content: [{ type: "text", text: STR.prompts.affective[language].replaceAll("{{MIN_SESSIONS}}", String(traitMinSessions)) }],
1294
- source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
1295
- },
1296
- { role: "user", content: [{ type: "text", text: fenceListBody(language, `${entityNames}\n---\n${listText}`) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1297
- ]
1298
- }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); })
1299
- );
1300
- const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, runAffective, () => streamFailure);
1301
- if (text === undefined) {
1302
- // #619 可见化 A:失败原因不能只写成 "llm failed"。conflict 相位
1303
- // (src/dream/sleep.js 的 adjudicateBatch 调用处)早就把 stream 层的原文写进
1304
- // warn 并挂在返回值的 `failure` 上,理由写在它旁边的注释里:只写 "llm failed"
1305
- // 的话,运维分不清是上下文超限、模型拒收还是用户活动打断。affective 相位漏了
1306
- // 这一步,实测 2026-11-10T16:09 那一轮审计行只有 {"status":"failed",
1307
- // "error":"llm failed"},502 原文只能去 llm_audit_logs 里捞。
1308
- // 相位自身不重试 5xx(withMaxTokensFallback 只处理 max_tokens 被拒、
1309
- // withEffortFallback 只处理 effort 被拒),所以这里记的就是一次性失败的真因。
1310
- if (streamFailure) logger?.warn?.(`dsh-mneme sleep: affective stream aborted or errored (${streamFailure})`);
1311
- return { status: "failed", error: "llm failed", failure: streamFailure || undefined };
1312
- }
1313
- const items = parseJsonArray(text) ?? [];
1314
- if (items.length === 0) return { status: "skipped", reason: "no attributions found" };
1315
- const relations = [];
1316
- const merges = [];
1317
- const traits = [];
1318
- const dropped = [];
1319
- let downgradedCount = 0;
1320
- const sessionCache = new Map();
1321
- const sessionsFor = (memoryIds) => {
1322
- const out = new Set();
1323
- for (const id of memoryIds) {
1324
- if (!sessionCache.has(id)) {
1325
- let rows = [];
1326
- try {
1327
- rows = service.provenanceFor?.(id) ?? [];
1328
- } catch {
1329
- rows = [];
1330
- }
1331
- sessionCache.set(id, rows.map((r) => r.session_id).filter((s) => typeof s === "string" && s));
1332
- }
1333
- for (const s of sessionCache.get(id)) out.add(s);
1334
- }
1335
- return out;
1336
- };
1337
- for (const item of items) {
1338
- if (signal?.aborted) break;
1339
- if (!item || typeof item !== "object") continue;
1340
- const person = byName.get(String(item.person ?? "").toLowerCase());
1341
- const target = byName.get(String(item.target ?? "").toLowerCase());
1342
- if (!person || !target || person.id === target.id) {
1343
- dropped.push({ reason: "unknown entity", person: item.person, target: item.target });
1344
- continue;
1345
- }
1346
- const evidence = (Array.isArray(item.evidence) ? item.evidence : []).filter((id) => scannedIds.has(id));
1347
- if (evidence.length === 0) {
1348
- dropped.push({ reason: "no real evidence", person: item.person, target: item.target });
1349
- continue;
1350
- }
1351
- const emotion = String(item.emotion ?? "").trim();
1352
- if (!emotion) {
1353
- dropped.push({ reason: "no emotion", person: item.person, target: item.target });
1354
- continue;
1355
- }
1356
- // The guardrail: a claimed trait is checked against provenance, and a claim
1357
- // that does not survive is demoted — not discarded. Demoted claims are the
1358
- // interesting number: they are exactly the mis-attributions the probe hunts.
1359
- const claimedTrait = String(item.attribution ?? "").toLowerCase() === "trait";
1360
- const sessions = sessionsFor(evidence);
1361
- const downgraded = claimedTrait && sessions.size < traitMinSessions;
1362
- const attribution = claimedTrait && !downgraded ? "trait" : "occasion";
1363
- const record = {
1364
- person: person.name,
1365
- target: target.name,
1366
- emotion,
1367
- attribution,
1368
- evidence,
1369
- sessionCount: sessions.size,
1370
- why: String(item.why ?? "").slice(0, 200),
1371
- ...(downgraded ? { downgradedFrom: "trait" } : {})
1372
- };
1373
- if (downgraded) downgradedCount += 1;
1374
- if (attribution === "trait") {
1375
- traits.push(record);
1376
- continue;
1377
- }
1378
- // #619 / Ephor: entity_relations 没有 UNIQUE 约束,saveRelation 每次
1379
- // randomUUID() 新插一条。同一个 (person, target, 场合) 跑第二轮就会得到
1380
- // 第二条一模一样的边——而探针会拿同一批段反复回放,重复边会让 sessionCount
1381
- // 看起来比真实场合数多,主指标直接失真。
1382
- //
1383
- // 「同一场合」不能拿 evidence 有没有交集来判。两次判据都基于证据列表,
1384
- // 而证据列表既不是场合本身、也不稳定:
1385
- // ① 模型给 evidence 的顺序不定(parseJsonArray 也不排序),[m1,m2] 与
1386
- // [m2,m1] 是同一场合的两种写法;
1387
- // ② 同一会话里的两条不同记忆各说一句「他很焦虑」,证据天然不相交,可
1388
- // 它们是同一个场合——那时该合并成一条边,而不是各写一条把边数抬高;
1389
- // ③ 反过来,第一轮看到 1 条证据、第二轮攒到 2 条(含新会话),若判成重复
1390
- // 就整条丢掉,边上 sessionCount 永远停在第一次那个数,将来读归因覆盖率
1391
- // 会一直读到 1。
1392
- // 所以键落在会话层:同一 (person, target, emotion) 且两边的会话集有交集,
1393
- // 就是同一场合。命中时把 evidence 并集写回、按并集重算 sessionCount,而不是
1394
- // 丢弃——被扩充的边记进 merged,让「这一轮其实补了证据」在返回值里看得见。
1395
- // 这一步在 cap 之前:合并既有边不新增行,不该占名额。
1396
- const currentSessions = sessions;
1397
- const priorEdges = (service.getRelations(person.id) ?? []).filter((r) =>
1398
- r.relation_type === "affective_context" && r.to_entity === target.id
1399
- );
1400
- let mergedInto = null;
1401
- for (const edge of priorEdges) {
1402
- const meta = edge.metadata && typeof edge.metadata === "object" ? edge.metadata : null;
1403
- const priorEmotion = String(meta?.emotion ?? "").trim();
1404
- if (priorEmotion !== emotion) continue;
1405
- const priorEvidence = Array.isArray(meta?.evidence)
1406
- ? meta.evidence
1407
- : (edge.memory_id ? [edge.memory_id] : []);
1408
- const priorSessions = sessionsFor(priorEvidence);
1409
- // "Same occasion" is judged on session keys first. Only when either side
1410
- // has no session key at all does it fall back to an evidence intersection:
1411
- // memory_save writes memories without provenance (1671 of 8517 active rows
1412
- // in the live database have none), so sessionsFor returns an empty set and
1413
- // a session-only test would write the same edge again on every sleep pass,
1414
- // inflating sessionCount past the real number of occasions.
1415
- const sameOccasion = [...priorSessions].some((s) => currentSessions.has(s))
1416
- || ((priorSessions.size === 0 || currentSessions.size === 0)
1417
- && priorEvidence.some((id) => evidence.includes(id)));
1418
- if (!sameOccasion) continue;
1419
- mergedInto = { edge, priorEvidence, priorSessions };
1420
- break;
1421
- }
1422
- if (mergedInto) {
1423
- const { edge, priorEvidence, priorSessions } = mergedInto;
1424
- const unionEvidence = [...new Set([...priorEvidence, ...evidence])];
1425
- const unionSessions = new Set([...priorSessions, ...currentSessions]);
1426
- const mergedMetadata = {
1427
- ...(edge.metadata && typeof edge.metadata === "object" ? edge.metadata : {}),
1428
- emotion,
1429
- // 保留首个 attribution,但降级痕迹一旦有就留着——合并不能把
1430
- // 「它曾被判成 trait 又被降级」这段历史抹掉。
1431
- attribution: (edge.metadata && edge.metadata.attribution) || attribution,
1432
- evidence: unionEvidence,
1433
- sessionCount: unionSessions.size,
1434
- lastMergedRunId: runId
1435
- };
1436
- if (record.downgradedFrom && !mergedMetadata.downgradedFrom) {
1437
- mergedMetadata.downgradedFrom = record.downgradedFrom;
1438
- }
1439
- try {
1440
- service.updateRelationMetadata?.(edge.id, mergedMetadata);
1441
- merges.push({
1442
- id: edge.id,
1443
- from: person.name,
1444
- to: target.name,
1445
- emotion,
1446
- evidence: unionEvidence.length,
1447
- sessionCount: unionSessions.size
1448
- });
1449
- } catch (error) {
1450
- logger?.warn?.(`dsh-mneme sleep: affective relation merge ${person.name}/${target.name} failed: ${String(error)}`);
1451
- }
1452
- continue;
1453
- }
1454
- if (relations.length >= maxRelations) {
1455
- dropped.push({ reason: "relation cap", person: person.name, target: target.name });
1456
- continue;
1457
- }
1458
- try {
1459
- const saved = service.saveRelation({
1460
- from_entity: person.id,
1461
- to_entity: target.id,
1462
- relation_type: "affective_context",
1463
- memory_id: evidence[0],
1464
- source: "llm",
1465
- metadata: {
1466
- emotion,
1467
- attribution,
1468
- why: record.why,
1469
- evidence,
1470
- sessionCount: sessions.size,
1471
- ...(record.downgradedFrom ? { downgradedFrom: record.downgradedFrom } : {}),
1472
- model: `${route.provider}:${route.model}`,
1473
- runId
1474
- }
1475
- });
1476
- relations.push({ id: saved?.id, from: person.name, to: target.name, emotion, attribution, ...(record.downgradedFrom ? { downgradedFrom: record.downgradedFrom } : {}) });
1477
- } catch (error) {
1478
- logger?.warn?.(`dsh-mneme sleep: affective relation ${person.name}/${target.name} failed: ${String(error)}`);
1479
- }
1480
- }
1481
- // Traits ride the standard decision channel so they inherit every existing cap
1482
- // and the #512 mint ledger. No new write path is introduced for them.
1483
- let applied = 0;
1484
- let refreshedTraits = 0;
1485
- let failures = [];
1486
- let conflicts = [];
1487
- let ok = true;
1488
- // Candidates beyond the cap are otherwise neither minted, demoted, nor
1489
- // recorded: they simply vanish, while `traits` still counts them and a reader
1490
- // concludes they were handled. Record them the same way `relation cap` records
1491
- // edges, so "everything that disappears is accounted for" holds for traits.
1492
- for (const t of traits.slice(maxTraits)) {
1493
- dropped.push({ reason: "trait cap", person: t.person, target: t.target });
1494
- }
1495
- if (traits.length > 0) {
1496
- const decisions = traits.slice(0, maxTraits).map((t) => ({
1497
- action: "create",
1498
- type: "pattern",
1499
- title: STR.affectiveTrait.title[language](t),
1500
- content: STR.affectiveTrait.content[language](t),
1501
- importance: 3,
1502
- evidence: t.evidence
1503
- }));
1504
- const snapshot = new Map();
1505
- const validated = validateDecisions(decisions, snapshot, { maxCreatePerRun: maxTraits, allowedActions: ["create"] });
1506
- ok = validated.ok;
1507
- if (ok) {
1508
- const result = applyDecisions(decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId });
1509
- applied = result.applied;
1510
- failures = result.failures;
1511
- conflicts = result.conflicts;
1512
- // A trait whose pattern was refreshed in place (same title, same row) is
1513
- // not a new conclusion. Keep it out of `applied` and report it as a
1514
- // refresh, otherwise two different targets would read as two minted
1515
- // patterns while only one row exists (#619 audit round 6).
1516
- refreshedTraits = result.merged ?? 0;
1517
- // Ocasion edges observed before a disposition was confirmed do not get
1518
- // rewritten — an edge records "this occasion was observed", which stays
1519
- // true. But the edge and the pattern would then answer "how many
1520
- // occasions" differently (edge.sessionCount vs pattern evidence), and a
1521
- // coverage query reading both would have no way to tell they describe the
1522
- // same thing. Mark them instead of mutating the count: the edge still
1523
- // means one observed occasion, and `promotedToTrait` says that occasion is
1524
- // now part of a confirmed disposition.
1525
- for (const trait of traits.slice(0, maxTraits)) {
1526
- const person = byName.get(String(trait.person ?? "").toLowerCase());
1527
- const target = byName.get(String(trait.target ?? "").toLowerCase());
1528
- if (!person || !target || person.id === target.id) continue;
1529
- for (const edge of service.getRelations(person.id) ?? []) {
1530
- if (edge.relation_type !== "affective_context" || edge.to_entity !== target.id) continue;
1531
- const meta = edge.metadata && typeof edge.metadata === "object" ? edge.metadata : null;
1532
- if (String(meta?.emotion ?? "").trim() !== trait.emotion) continue;
1533
- if (meta?.promotedToTrait === true) continue;
1534
- try {
1535
- service.updateRelationMetadata?.(edge.id, {
1536
- ...meta,
1537
- promotedToTrait: true,
1538
- traitSessionCount: trait.sessionCount
1539
- });
1540
- } catch (error) {
1541
- logger?.warn?.(`dsh-mneme sleep: marking occasion ${edge.id} as promoted failed: ${String(error)}`);
1542
- }
1543
- }
1544
- }
1545
- }
1546
- }
1547
- // Aborting mid-pass is not "did nothing" and not "all done". Even when nothing
1548
- // was written yet, "noop" would claim the whole pool was examined and found
1549
- // empty — the loop broke at an arbitrary index, so it examined an unknown
1550
- // prefix. Report it as aborted either way.
1551
- if (signal?.aborted) {
1552
- return {
1553
- status: "aborted",
1554
- reason: "user activity",
1555
- scanned: memories.length,
1556
- relations: relations.length,
1557
- edgeWrites: relations.length,
1558
- merged: merges.length,
1559
- mergedList: merges,
1560
- applied,
1561
- refreshedTraits,
1562
- traits: traits.length,
1563
- downgraded: downgradedCount,
1564
- dropped
1565
- };
1566
- }
1567
- if (!ok) {
1568
- return {
1569
- status: "failed",
1570
- error: "trait decisions rejected by validation",
1571
- scanned: memories.length,
1572
- relations: relations.length,
1573
- edgeWrites: relations.length,
1574
- merged: merges.length,
1575
- mergedList: merges,
1576
- applied: 0,
1577
- refreshedTraits,
1578
- traits: traits.length,
1579
- downgraded: downgradedCount,
1580
- dropped
1581
- };
1582
- }
1583
- return {
1584
- // "refreshed" is a real write that minted nothing: the trait pattern already
1585
- // existed under the same title and was rewritten in place. Calling that
1586
- // "noop" would say the phase examined the pool and found nothing to do.
1587
- status: relations.length + merges.length + applied > 0 ? "ok" : (refreshedTraits > 0 ? "refreshed" : "noop"),
1588
- scanned: memories.length,
1589
- relations: relations.length,
1590
- merged: merges.length,
1591
- // #619: edges are real writes but not "applied decisions", so the receipt
1592
- // would read `:0` for a run that did write. Count them separately instead
1593
- // of folding them into `applied` (that would make the two numbers mean
1594
- // different things depending on which phase produced them).
1595
- edgeWrites: relations.length,
1596
- mergedList: merges,
1597
- applied,
1598
- refreshedTraits,
1599
- failures,
1600
- conflicts,
1601
- traits: traits.length,
1602
- downgraded: downgradedCount,
1603
- dropped
1604
- };
1605
- }
1606
-
1607
- /**
1608
- * Phase 4 — entity relation completion. Detects orphan entities (zero
1609
- * relations) and completes implied relations from memory co-occurrence:
1610
- * entities named in the same memory → related_to; container kinds
1611
- * (project/module) → part_of; tech-ish pairs → depends_on. Deterministic,
1612
- * no LLM — cheap, so it runs even without a route. saveRelation is
1613
- * bookkeeping (no write hook), so it never re-triggers the scheduler.
1614
- */
1615
- function inferRelationType(a, b) {
1616
- if ((a.type === "project" || a.type === "module") && a.type !== b.type) return "part_of";
1617
- if ((b.type === "project" || b.type === "module") && b.type !== a.type) return "part_of";
1618
- if (/npm|plugin|api|sdk|lib|framework|package|deps?|build/i.test(`${a.name} ${b.name}`)) return "depends_on";
1619
- return "related_to";
1620
- }
1621
-
1622
- function phaseRelations(service, config, logger, runId, signal = null) {
1623
- const entities = service.listEntities({ limit: 1000 }) ?? [];
1624
- if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
1625
- const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
1626
- if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
1627
- const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten));
1628
- const seen = new Set();
1629
- const related = [];
1630
- const MAX_RELATIONS_PER_ORPHAN = 3;
1631
- for (const o of orphans) {
1632
- if (signal?.aborted) break;
1633
- let made = 0;
1634
- for (const m of memories) {
1635
- if (signal?.aborted || made >= MAX_RELATIONS_PER_ORPHAN) break;
1636
- const text = `${m.title ?? ""} ${m.content ?? ""}`;
1637
- if (!text.includes(o.name)) continue;
1638
- for (const other of entities) {
1639
- if (other.id === o.id || other.name === o.name) continue;
1640
- const key = [o.id, other.id].sort().join("|");
1641
- if (seen.has(key)) continue;
1642
- if (!text.includes(other.name)) continue;
1643
- const relationType = inferRelationType(o, other);
1644
- try {
1645
- service.saveRelation({ from_entity: o.id, to_entity: other.id, relation_type: relationType, memory_id: m.id, metadata: { source: "sleep_relation_completion" } });
1646
- seen.add(key);
1647
- related.push({ from: o.id, to: other.id, type: relationType });
1648
- made++;
1649
- } catch (error) {
1650
- logger?.warn?.(`dsh-mneme sleep: relation ${o.id}/${other.id} failed: ${String(error)}`);
1651
- }
1652
- }
1653
- }
1654
- }
1655
- return {
1656
- status: related.length > 0 ? "ok" : "noop",
1657
- orphanCount: orphans.length,
1658
- related
1659
- };
1660
- }
1661
-
1662
- // ---------------------------------------------------------------- run
1663
-
1664
- function deriveStatus(phases) {
1665
- const list = Object.values(phases);
1666
- if (list.length === 0) return "noop";
1667
- const anyError = list.some((p) => p.status === "failed" || p.status === "error");
1668
- // Issue #126 review(Copilot):phase 自身可以是 degraded(合法子集已应用但有条目被
1669
- // 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
1670
- // #619:`refreshed` 也是一次真实写入(同题行原地刷新),只是没铸出新条目。把它
1671
- // 排除在 anyWork 之外,整轮会被记成 noop,审计行又变回「跑了、什么都没干」。
1672
- const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded" || p.status === "refreshed");
1673
- const anyDegraded = list.some((p) => p.status === "degraded");
1674
- // Issue #529:被打断(用户恢复活动)既不是失败也不是"没活干"。相位层已经用
1675
- // aborted 区分了这两种,整轮层若回落到 noop,审计行就又把"没跑完"记成"跑过、
1676
- // 没什么事"——所以这里也给它一个自己的名字。只在没有任何产出时生效:真的落了
1677
- // 东西就是 ok/degraded,真的报错就是 failed,二者都比 aborted 值得优先播报。
1678
- const anyAborted = list.some((p) => p.status === "aborted");
1679
- if (anyDegraded) return "degraded";
1680
- if (anyWork && anyError) return "degraded";
1681
- if (anyError) return "failed";
1682
- if (anyWork) return "ok";
1683
- if (anyAborted) return "aborted";
1684
- return "noop";
1685
- }
1686
-
1687
- /**
1688
- * Run one full sleep cycle. Best-effort across all phases; writes a
1689
- * run_type='sleep' audit row (same dream_runs table) so sleep activity is
1690
- * observable alongside consolidation runs.
1691
- */
1692
- export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
1693
- const runId = randomUUID();
1694
- // Issue #89:开跑时刻——审计行 created_at 的基准(= 冷却种子),run 耗时
1695
- // (LLM 各 phase)不应计入重启后的冷却窗口。
1696
- const sleepStartedAt = Date.now();
1697
- const phases = {};
1698
- const attempt = async (name, fn) => {
1699
- if (signal?.aborted) return; // user resumed activity — stop before next phase
1700
- try {
1701
- phases[name] = await fn();
1702
- } catch (error) {
1703
- phases[name] = { status: "failed", error: error?.message ?? String(error) };
1704
- logger?.warn?.(`dsh-mneme sleep: ${name} phase failed: ${error?.message ?? error}`);
1705
- }
1706
- };
1707
- await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
1708
- await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
1709
- await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
1710
- await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
1711
- // #619:情感归因排在 relations 之后——它要读实体与 provenance,先让孤立实体的
1712
- // 结构边补完,再往上挂情绪轴;两相写的是不同 relation_type,不抢同一批对。
1713
- await attempt("affective", () => phaseAffective(ctx, service, config, logger, runId, signal));
1714
-
1715
- const status = deriveStatus(phases);
1716
- const route = resolveSleepRoute(ctx, config, logger);
1717
- // #619: a phase may write rows that are not "applied decisions" — the
1718
- // affective phase inserts relation edges directly — and it may also extend an
1719
- // existing edge instead of inserting one. Both are store writes, and the
1720
- // receipt has to answer "did this run change anything", so both count. A run
1721
- // whose only effect was extending an edge would otherwise read `:0`, i.e.
1722
- // "ran and did nothing", which is exactly what a probe must never see.
1723
- //
1724
- // The per-kind numbers stay apart in `phases` (`applied` / `edgeWrites` /
1725
- // `merged` / `refreshedTraits`); the receipt is a single write count on
1726
- // purpose, because buildReceipt/parseReceipt are an eight-field contract
1727
- // shared with organize.js and four test files. Detail lives in
1728
- // dream_runs.phases JSON, not in the receipt.
1729
- //
1730
- // #619 / Ephor 第七轮 ①:`refreshedTraits` 必须计入。它是「同一标题的 pattern
1731
- // 被原地重写」的条数——库确实变了,但没有铸新条目,所以它既不在 `applied` 里
1732
- // (那会把刷新谎报成铸造),也不在 `merged` 里(affective 相位的 `merged` 是
1733
- // **边**合并数 `merges.length`,同名不同义)。漏掉它的后果实测过:第二轮
1734
- // `phase.status=refreshed`、`refreshedTraits=1`,整轮 receipt 第 7 段仍是 0,
1735
- // 于是「相位说改了、整轮说没改」,正是上一轮刚修掉的「改了库读成 0」形态。
1736
- const totalApplied = Object.values(phases).reduce(
1737
- (n, p) =>
1738
- n +
1739
- (Number.isInteger(p?.applied) ? p.applied : 0) +
1740
- (Number.isInteger(p?.edgeWrites) ? p.edgeWrites : 0) +
1741
- (Number.isInteger(p?.merged) ? p.merged : 0) +
1742
- (Number.isInteger(p?.refreshedTraits) ? p.refreshedTraits : 0),
1743
- 0
1744
- );
1745
- // Issue #126(与 #104 同向):把各 phase 的跳过明细汇总成一行审计数据,带 phase
1746
- // 名(同一次 run 里 conflicts 与 patterns 都可能产出 skipped)。全空时写 NULL,
1747
- // 不落空数组——审计行只记真实发生过的跳过。
1748
- const skippedDetail = Object.entries(phases).flatMap(([phase, p]) =>
1749
- Array.isArray(p?.skipped) ? p.skipped.map((s) => ({ phase, ...s })) : []
1750
- );
1751
- const snapshotHash = createHash("sha256").update(JSON.stringify(phases)).digest("hex");
1752
- const receipt = buildReceipt({
1753
- runId,
1754
- status,
1755
- snapshotHash,
1756
- inputCount: 0,
1757
- applied: totalApplied,
1758
- summaryStored: false
1759
- });
1760
- try {
1761
- service.saveDreamRun({
1762
- id: runId,
1763
- // Issue #89:审计行记开跑时刻而非完成时刻——run 耗时(含 LLM 各 phase)
1764
- // 不该计入重启后的 sleep 冷却种子。
1765
- created_at: new Date(sleepStartedAt).toISOString(),
1766
- status,
1767
- provider: route?.provider,
1768
- model: route?.model,
1769
- snapshot_hash: snapshotHash,
1770
- input_count: 0,
1771
- input: null,
1772
- decisions: phases,
1773
- outcome: phases,
1774
- applied: totalApplied,
1775
- summary_stored: false,
1776
- receipt,
1777
- policy_epoch: config.policyEpoch ?? 0,
1778
- run_type: "sleep",
1779
- skipped: skippedDetail.length ? skippedDetail : undefined
1780
- });
1781
- } catch (error) {
1782
- logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`);
1783
- }
1784
- // Issue #529(续排):给调度器的续排判据。两个条件缺一不可——
1785
- // · 本轮**被预算截断**(conflict 相位报告还有没进这一轮的对):说明池子没清空;
1786
- // · 本轮**clean**:送出去的每一对都落地了,不是为了追一个永远失败的批次空转。
1787
- // `clean` 缺失时(该相位被 attempt 吞成 failed、或配置让它早退)按"不干净"算,
1788
- // 宁可少追一轮,也不要在失败状态下把间隔压到静默窗口。
1789
- const conflictPhase = phases.conflicts;
1790
- const backlog = Number(conflictPhase?.perRunSkipped ?? 0) > 0;
1791
- // clean 与 catchup 分开给:前者是"这一轮的活全落地了",后者是"池子没清空 + 前者"。
1792
- // 合成一个字段会让"干净但没 backlog"看起来像出过问题,排查时反着读。
1793
- const clean = conflictPhase?.clean === true;
1794
- const catchup = backlog && clean;
1795
- return { ok: status === "ok" || status === "degraded", status, clean, catchup, backlog, runId, phases, receipt };
1796
- }
1797
-
1798
- // ---------------------------------------------------------------- scheduler
1799
-
1800
- /**
1801
- * Idle-triggered scheduler. DSH plugins have no resident cron, so a sleep run
1802
- * fires when: sleep is enabled, the store has been quiet for sleepIdleMinutes,
1803
- * and the previous run is older than sleepMinIntervalHours. noteWrite() is
1804
- * called on every store write and (re)arms an idle timer that re-checks at the
1805
- * exact moment the idle window elapses — no polling, no cron.
1806
- *
1807
- * A `now` clock can be injected for tests; it defaults to Date.now.
1808
- */
1809
- export function createSleepScheduler({
1810
- service,
1811
- config,
1812
- logger,
1813
- onRun = null,
1814
- now = () => Date.now(),
1815
- setTimeoutFn = setTimeout,
1816
- clearTimeoutFn = clearTimeout,
1817
- // Issue #89 同族:冷却时刻由调用方从 dream_runs 审计表恢复(run_type='sleep')——
1818
- // 内存变量进程重启即归零,冷却闸对新实例放行 → 重启后立即进入 sleep 冷却盲区。
1819
- // 测试注入 0(默认)即保持旧行为。
1820
- lastRunAtSeed = 0
1821
- }) {
1822
- let lastWriteAt = now();
1823
- let lastRunAt = lastRunAtSeed;
1824
- let running = false;
1825
- let disposed = false;
1826
- let idleTimer = null;
1827
- let sleepAbort = null;
1828
- // Issue #529(C 项):同一进程内的连续失败计数。失败(run 报 status="failed"
1829
- // 或抛错)+1,其余一律清零(ok/degraded/noop——noop 说明 LLM 通路是好的,
1830
- // 只是没活干,不该被当成失败拖着退避)。纯内存变量、宿主重启归零:跨重启的
1831
- // 冷却本来就由 lastRunAtSeed(#89/#291)负责,两者不重叠。
1832
- let consecutiveFailures = 0;
1833
- // Issue #529(续排):上一轮报告"还有对没进这一轮、且送出去的全落地了"——池子没清空,
1834
- // 该接着追而不是等满一个最小间隔。只由 maybeSchedule 按 onRun 的返回置位,跑完一轮就
1835
- // 重新判:池子追干净或某一轮不 clean 时自动落回 false,不需要额外的复位路径。
1836
- let catchupWanted = false;
1837
- // Issue #529(续排):进程内续排连击数。给"池子在长、每轮都 clean 但永远追不完"这类
1838
- // 库留一道刹车——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟
1839
- // 一轮。超过上限就退回正常间隔,等下一个静默窗口重新起算。
1840
- let catchupStreak = 0;
1841
-
1842
- // Issue #529:有效最小间隔 = 基数 × 2^连续失败数。关闭或尚无失败时逐字节
1843
- // 返回基数(默认关 = 行为与现状一致)。封顶取 max(基数, 24h) 而不是硬 24h:
1844
- // 指数再大也不会把用户自己配的大基数(允许到 168h)压小。
1845
- function effectiveMinIntervalMs() {
1846
- // Issue #529(续排):还有 backlog 时把冷却项按 0 处理,让表落在**静默窗口**而不是
1847
- // 最小间隔上。不新增配置键:本轮的账本/更新写入会重置 lastWriteAt,下一轮自然至少
1848
- // 等一个 sleepIdleMinutes,这本身就是节流;再开一个键要连带 settings.js 白名单与
1849
- // test/api.test.js 的旗标计数锁,不值得为一件事动三个文件。
1850
- // 与连败互斥(同 issue 的第三条):连续失败 > 0 时根本不追——两个逻辑抢同一只闹钟
1851
- // 最容易出"重排的是短的、判定的是长的"这种空转,互斥最省事。
1852
- if (catchupWanted && consecutiveFailures <= 0) return 0;
1853
- const base = (config.sleepMinIntervalHours ?? 8) * 3600000;
1854
- if (config.sleepFailureBackoff !== true || consecutiveFailures <= 0) return base;
1855
- const cap = Math.max(base, FAILURE_BACKOFF_CAP_MS);
1856
- return Math.min(base * 2 ** consecutiveFailures, cap);
1857
- }
1858
-
1859
- function armIdleTimer() {
1860
- if (disposed || idleTimer) return;
1861
- if (config.sleepModeEnabled !== true) return;
1862
- const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
1863
- const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
1864
- idleTimer = setTimeoutFn(async () => {
1865
- idleTimer = null;
1866
- // Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期"
1867
- // 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。
1868
- // Issue #529(续排):跑成了也未必收工——池子还有没进这一轮的对时要接着追,
1869
- // 而此时已经没有待触发的写入了,不在这里补挂就永远等不到下一轮。
1870
- const ran = await maybeSchedule();
1871
- if (!ran || catchupWanted) armNextWindow();
1872
- }, delay);
1873
- idleTimer.unref?.();
1874
- }
1875
-
1876
- // Issue #187:撞 CD / run 失败后的补挂表——按「剩余 CD / 剩余静默」里更晚的
1877
- // 到点时刻重排一次,回调里重复同样的判断所以不会空转。跑成了一轮就不重排:
1878
- // 安静的库没有要处理的新东西,触发权交还给下一次写入的 noteWrite。
1879
- function armNextWindow() {
1880
- if (disposed || idleTimer) return;
1881
- if (config.sleepModeEnabled !== true) return;
1882
- // lastRunAt === 0 表示从未跑过(此路径不该出现,防御性返回)。
1883
- if (lastRunAt <= 0) return;
1884
- const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
1885
- // Issue #529:这里必须和 shouldRun 用同一个有效间隔,否则退避期里重排的闹钟
1886
- // 会在基数到点就响、然后被 shouldRun 否掉再重排——空转一串没有意义的定时器。
1887
- const cdMs = effectiveMinIntervalMs();
1888
- let delay = Math.max(
1889
- lastRunAt + cdMs - now(),
1890
- lastWriteAt + idleMs - now(),
1891
- 0
1892
- ) + 1000;
1893
- // Issue #529(续排):冷却项按 0 处理之后,静默项可能已经是负的(这一轮跑得久、
1894
- // 期间没有新写入),算出来只剩那 1 秒——追 backlog 不该变成秒级连发。续排下限钉在
1895
- // 一个静默窗口上:追得动就每轮追一批,但每批之间仍然让库安静下来。
1896
- // 有写入时静默项会自己变大(账本行落库会重置 lastWriteAt),这里只是保证下限。
1897
- if (catchupWanted) delay = Math.max(delay, idleMs + 1000);
1898
- idleTimer = setTimeoutFn(async () => {
1899
- idleTimer = null;
1900
- const ran = await maybeSchedule();
1901
- // Issue #529(续排):与 armIdleTimer 的回调同一条理由——追 backlog 的轮次
1902
- // 跑完必须自己补挂,否则续排在第二张表就断了。
1903
- if (!ran || catchupWanted) armNextWindow();
1904
- }, delay);
1905
- idleTimer.unref?.();
1906
- }
1907
-
1908
- function shouldRun(at = now()) {
1909
- if (disposed || running) return false;
1910
- if (config.sleepModeEnabled !== true) return false;
1911
- if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
1912
- // lastRunAt === 0 means never ran — the min-interval check must not block
1913
- // the very first cycle (a real run stamps a nonzero timestamp).
1914
- // Issue #529:连败退避开启时改用指数放大的有效间隔——恒定失败的模型不再按
1915
- // 固定节奏连发(#529 实测连续 23 轮 llm failed),只会越打越稀。
1916
- if (lastRunAt > 0 && at - lastRunAt < effectiveMinIntervalMs()) return false;
1917
- return true;
1918
- }
1919
-
1920
- /** Called on writes: resets the idle clock and re-arms the fire timer. The
1921
- * pending timer is cleared first — a stale timer armed against the old idle
1922
- * window would otherwise fire early, fail shouldRun, and leave nothing armed
1923
- * for the next window (a missed trigger until the next write).
1924
- *
1925
- * While a sleep run is executing (running=true) the in-flight AbortController
1926
- * is NOT aborted: the run's own writes (demoteToSummary / setArchived ride
1927
- * the normal write-hook path) would otherwise self-abort the cycle. External
1928
- * activity during the run still resets the idle clock here, so no new cycle
1929
- * fires until the store is quiet again. */
1930
- function noteWrite() {
1931
- lastWriteAt = now();
1932
- if (!running && sleepAbort) {
1933
- sleepAbort.abort(); // user resumed activity — interrupt an idle run
1934
- }
1935
- if (idleTimer) {
1936
- clearTimeoutFn(idleTimer);
1937
- idleTimer = null;
1938
- }
1939
- armIdleTimer();
1940
- }
1941
-
1942
- async function maybeSchedule() {
1943
- if (!shouldRun()) return false;
1944
- running = true;
1945
- const abort = new AbortController();
1946
- sleepAbort = abort;
1947
- try {
1948
- lastRunAt = now();
1949
- const result = await service.enqueue(() =>
1950
- onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
1951
- );
1952
- // Issue #529:连败计数只在这里加减。判据优先看 result.status——runSleep 的
1953
- // ok 只在 ok/degraded 时为真,noop(LLM 通路正常、只是没活干)和用户活动
1954
- // 打断都会给出 ok:false,把它当失败会让退避在自己工作良好的库里越退越长。
1955
- // 回落到 !result.ok 只是为了兼容注入的 no-op 桩(没有 status 字段)。
1956
- // result 为空(最小测试替身)=完成且无失败,不动计数,与 #292 口径一致。
1957
- if (result) {
1958
- const failed = typeof result.status === "string" ? result.status === "failed" : !result.ok;
1959
- consecutiveFailures = failed ? consecutiveFailures + 1 : 0;
1960
- // Issue #529(续排):判据只认 runSleep 给的 catchup——它是"池子还有没进这一轮
1961
- // 的对"且"送出去的每一对都落地了"二者的合取。**不要**在这里拿 status 反推:
1962
- // 全判 keep 的轮次 applied 恒为 0、status 是 noop,恰好在最该追的场景失效。
1963
- // 连续失败时不追(与退避互斥,见 effectiveMinIntervalMs),并把连击计数清零——
1964
- // 失败说明这轮的"追"没有意义,重新从正常节奏起算。
1965
- // 连击到顶就收手,并把连击清零——不清零的话这个进程从此再也不会追(streak 只增
1966
- // 不减,之后每一轮都被同一道闸挡下),"等下一个静默窗口重新起算"就成了空话。
1967
- // 收手必须落在 catchupWanted 这个状态上,不能只写在 effectiveMinIntervalMs 里:
1968
- // 那里改成"返回基数"之后,armNextWindow 仍会按 catchupWanted 把闹钟钉在静默
1969
- // 窗口上重排,于是每 5 分钟醒一次、每次都被 shouldRun 否掉再重排——没有 LLM
1970
- // 调用,但定时器链永远不断。
1971
- const wantCatchup = result.catchup === true && consecutiveFailures <= 0;
1972
- if (!wantCatchup || catchupStreak + 1 >= CATCHUP_STREAK_CAP) {
1973
- catchupStreak = 0;
1974
- catchupWanted = false;
1975
- } else {
1976
- catchupStreak += 1;
1977
- catchupWanted = true;
1978
- }
1979
- }
1980
- return !!(result && result.ok);
1981
- } catch (error) {
1982
- logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
1983
- consecutiveFailures += 1; // Issue #529:抛错同样计入连败
1984
- // Issue #529(续排):抛错也算失败,续排意图一并清掉——否则上一轮置位的状态会跟着
1985
- // 逃出 try 块,让下一次排表仍按静默窗口的空转节奏走。
1986
- catchupWanted = false;
1987
- catchupStreak = 0;
1988
- return false;
1989
- } finally {
1990
- sleepAbort = null;
1991
- running = false;
1992
- // Issue #529 review(CodeRabbit ②):运行期间用户写入会走 noteWrite(),它把闹钟
1993
- // 按**那一次写入**重算了一遍——那可能是静默窗口的尾声。若这一轮结束时报告
1994
- // catchup=true,那只表可能几秒后就要响,下一批紧接着开跑,于是「让库安静一个
1995
- // 静默窗口再追」变成「跑完立刻再跑」。这里把已挂着的表撤掉重挂,交给
1996
- // armNextWindow 从**此刻**算出整整一个静默窗口。
1997
- // 反过来(不追的轮次)保留 noteWrite 挂的那只表:它是按用户最后一次写入算的,
1998
- // 正是「用户写完了、安静满一个窗口再说」的意思。
1999
- if (catchupWanted) {
2000
- if (idleTimer) {
2001
- clearTimeoutFn(idleTimer);
2002
- idleTimer = null;
2003
- }
2004
- armNextWindow();
2005
- }
2006
- }
2007
- }
2008
-
2009
- async function dispose() {
2010
- disposed = true;
2011
- if (idleTimer) {
2012
- clearTimeoutFn(idleTimer);
2013
- idleTimer = null;
2014
- }
2015
- if (sleepAbort) {
2016
- sleepAbort.abort();
2017
- sleepAbort = null;
2018
- }
2019
- }
2020
-
2021
- // Issue #187(同族缺陷):构造即挂表——重启后只要没发生过写入,此前永远没有
2022
- // 闹钟,安静多久都不会触发。lastWriteAt 已在构造时设为当前时刻,shouldRun 的
2023
- // 静默条件自然生效:重启后静默满 idleMinutes 才可能起跑。
2024
- armIdleTimer();
2025
-
2026
- return { noteWrite, maybeSchedule, shouldRun, dispose };
2027
- }
1
+ import { STR, langOf } from "../lang.js";
2
+ // System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
3
+ // the memory store. Four independent, fail-safe phases:
4
+ // 1. conflict resolution — high-similarity same-type pairs are either parked
5
+ // for review (freeze mode) or adjudicated by the LLM (winner kept / loser
6
+ // archived), reusing the dream conflict machinery. Strictness-graded.
7
+ // v0.8.1 (issue #170): cross-scope pairs are ALWAYS parked for human
8
+ // ownership review with a dedicated reason — never auto-adjudicated.
9
+ // 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
10
+ // to a one-line summary with the full body moved to _full_content; past
11
+ // sleepCompressDays they are archived outright.
12
+ // 3. pattern discovery — the LLM scans the most recent memories and mints
13
+ // type=pattern entries carrying evidence id references.
14
+ // 4. relation completion — orphan entities (zero relations) get implied
15
+ // relations completed from memory co-occurrence.
16
+ // 5. affective attribution (v0.13.0, #619) — opt-in. Occasion-bound emotions
17
+ // become affective_context relations; only emotions recurring across
18
+ // enough independent sessions are minted as type=pattern traits.
19
+ // Each phase is wrapped so one failure never aborts the others, and a missing
20
+ // LLM route / semantic embedder only skips the phases that need it. A run is
21
+ // abortable via an AbortController signal (user activity) — phases check the
22
+ // signal between batches so a running cycle yields promptly.
23
+ import { randomUUID, createHash } from "node:crypto";
24
+ import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
25
+ import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
26
+ import { triagePair, recordLedgerTriage } from "./triage.js";
27
+ import { supersedeInfoFirstPair } from "./decisions.js";
28
+ import { scopeKeyOf } from "../scope.js";
29
+ import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, withMaxTokensFallback, maintainIndexAfterDream, fenceListBody, parseFailureMeta } from "../dream.js";
30
+ import { computeHeat } from "../heat.js";
31
+ // 四个相位读的都是行池,而动行的写手不经汇流口:phaseDemotion 直接 setArchived /
32
+ // demoteToSummary,phasePatterns 还会 mint 出新行,所以这条契约要在每处各挡一遍
33
+ // (清单与静态锁见 test/share-imported-guard.test.js)。
34
+ import { isImportedRow } from "../memory-source.js";
35
+ // Issue #533:查账本时要拿两侧当前的正文哈希,与 applyDifferentiate 记账时的口径同源。
36
+ import { contentHashOf } from "../content-hash.js";
37
+
38
+ // Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
39
+ // ACTIONS 去掉 #126 新增的两个。从 ACTIONS 派生而非硬编码,将来再扩动作集时自动跟随。
40
+ // 只换 prompt 挡不住模型自发输出新动作,必须由校验器按档位收口。
41
+ const LEGACY_ACTIONS = [...ACTIONS].filter((a) => a !== "supersede" && a !== "differentiate");
42
+
43
+ const SUMMARY_MAX = 120;
44
+ // Conflict similarity threshold per strictness level (v0.4.0):
45
+ // gentle only high-confidence pairs (0.92) — first-time users
46
+ // normal standard dream-level (0.85) — default
47
+ // aggressive low-confidence pairs too (0.75) — bloated stores
48
+ const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
49
+
50
+ // Issue #529(C 项的替代实现):连续失败时最小间隔的封顶。基数默认 8 小时,
51
+ // 2^3 就到 64 小时——不封顶的话模型持续失败会让 sleep 事实上停摆好几天,而
52
+ // 用户完全看不出来。24 小时 = 「最多一天试一次」,与 #292 给 autoDream 的
53
+ // 30 分钟封顶同一意图(封顶值不同是因为两个调度的基数差一个数量级)。
54
+ //
55
+ // 实际封顶取 max(基数, 24h),不是硬 24h:`sleepMinIntervalHours` 允许 1–168,
56
+ // 用户配 48 小时就是明说"两天跑一次",退避不该把它压回 24 小时(封顶只能放宽
57
+ // 间隔、不能收紧用户自己设的)。
58
+ const FAILURE_BACKOFF_CAP_MS = 24 * 3600000;
59
+
60
+ // Issue #529(续排):一轮内部最多连追多少轮。449 对的池子按每轮 60 对只需 8 轮,
61
+ // 24 留足余量;超过就退回正常最小间隔。防的是"池子在长、每轮都 clean 但永远追不完"
62
+ // ——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟一轮,
63
+ // 而审计上每轮都"有产出",肉眼很难发现。
64
+ const CATCHUP_STREAK_CAP = 24;
65
+
66
+ function parseJsonArray(text) {
67
+ if (typeof text !== "string") return undefined;
68
+ const start = text.indexOf("[");
69
+ const end = text.lastIndexOf("]");
70
+ if (start === -1 || end <= start) return undefined;
71
+ try {
72
+ const parsed = JSON.parse(text.slice(start, end + 1));
73
+ return Array.isArray(parsed) ? parsed : undefined;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Same stream consumption contract as dream.js.
81
+ *
82
+ * `onUsage`(optional, issue #250)receives any usage chunk for token accounting.
83
+ * dream.js 那份一直有它;sleep 这份副本没有,导致 conflict / pattern 两条链路的
84
+ * token 从未进 llm_audit_logs(面板因此只看到 autoDream 与 autoSummarize)。
85
+ */
86
+ async function streamText(ctx, options, onUsage, onStreamError) {
87
+ if (!ctx?.llm?.stream) return undefined;
88
+ let text = "";
89
+ for await (const chunk of ctx.llm.stream(options)) {
90
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
91
+ // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
92
+ // outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在
93
+ // 这里,调用侧的 reporter 只需认平铺形状——与 dream.js:239 同一处理。
94
+ if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk.usage ?? chunk);
95
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
96
+ // Same rc.1 error-as-finish-chunk behavior as dream.js — surface the
97
+ // cause instead of discarding it.
98
+ if (typeof onStreamError === "function") {
99
+ try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
100
+ }
101
+ return undefined;
102
+ }
103
+ }
104
+ return text;
105
+ }
106
+
107
+ /**
108
+ * Issue #250:给 sleep 的一条 LLM 调用记一笔 llm_audit_logs(token / 时长 / 状态 /
109
+ * 触发源)。与 dream.js 的 runAuditedLlm 同口径但各自独立一份——dream 那两个
110
+ * checker 的调用形状绑定着它自己的排程细节,硬抽共享抽象只会收敛回 dream 的形态。
111
+ *
112
+ * 记账是 best-effort:写审计行失败只 warn,绝不反噬 sleep 本体(CONTRIBUTING 的
113
+ * fail-safe 硬约定)。`body(reportUsage)` 负责真实消费流,usage chunk 经
114
+ * reportUsage 上报。
115
+ *
116
+ * 审计诚实性:流被中止/报错时 body 返回 undefined,记 status='error';流式成功但
117
+ * 输出不可用时 `spec.auditError` 给出原因,同样记 error——否则同一轮调用会在
118
+ * 「phase 报 failed」与「audit 报 success」之间自相矛盾(CONTRIBUTING 的审计诚实性
119
+ * 硬约定)。`auditError` 必须是**纯判据函数**:调用方的控制流不能依赖它的副作用,
120
+ * 因为审计关闭时这里整段早退、钩子根本不会被调用(dream 正是那样踩的,见 PR 正文)。
121
+ */
122
+ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
123
+ if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") {
124
+ return body(() => {});
125
+ }
126
+ const startedAt = Date.now();
127
+ const timestamp = new Date(startedAt).toISOString();
128
+ let inputTokens = 0;
129
+ let outputTokens = 0;
130
+ let status = "success";
131
+ let errorMessage = null;
132
+ // 提到 try 之外:finally 里的 metadataFrom 要读这一轮的原始输出(dream.js 的
133
+ // runAuditedLlm 就是这么声明的)。留在 try 里,finally 看不见它,读数会静默变空。
134
+ let result;
135
+ try {
136
+ result = await body((usage) => {
137
+ if (!usage) return;
138
+ const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
139
+ const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
140
+ if (Number.isFinite(i)) inputTokens = i;
141
+ if (Number.isFinite(o)) outputTokens = o;
142
+ });
143
+ if (result === undefined) {
144
+ status = "error";
145
+ const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
146
+ errorMessage = streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored";
147
+ } else if (typeof spec.auditError === "function") {
148
+ // 流式成功但输出不可用,仍然是一次失败的调用——记 error,不与调用方的
149
+ // failed 返回值打架。
150
+ const message = spec.auditError(result);
151
+ if (message) {
152
+ status = "error";
153
+ errorMessage = message;
154
+ }
155
+ }
156
+ return result;
157
+ } catch (error) {
158
+ status = "error";
159
+ errorMessage = String(error?.message ?? error);
160
+ throw error;
161
+ } finally {
162
+ // spec.metadataFrom(result):与 dream.js 的 runAuditedLlm 同一形状(这里是一份并行
163
+ // 实现,不是同一个函数——两份审计写法的字段必须一致,否则面板按 operation_type
164
+ // 筛出来的读数会时有时没有)。判据函数只读 result,不读闭包,理由同 dream.js。
165
+ let metadata;
166
+ if (result !== undefined && typeof spec.metadataFrom === "function") {
167
+ try {
168
+ metadata = spec.metadataFrom(result) ?? undefined;
169
+ } catch {
170
+ metadata = undefined;
171
+ }
172
+ }
173
+ try {
174
+ service.saveLlmAudit({
175
+ timestamp,
176
+ trigger_source: spec.triggerSource,
177
+ operation_type: spec.operationType,
178
+ model_id: spec.modelId,
179
+ input_tokens: inputTokens,
180
+ output_tokens: outputTokens,
181
+ total_tokens: inputTokens + outputTokens,
182
+ cost_usd: 0,
183
+ duration_ms: Date.now() - startedAt,
184
+ status,
185
+ error_message: errorMessage,
186
+ related_memory_ids: spec.relatedMemoryIds ?? [],
187
+ ...(metadata ? { metadata } : {})
188
+ });
189
+ } catch (auditError) {
190
+ logger?.warn?.(`dsh-mneme sleep: llm audit write failed: ${String(auditError)}`);
191
+ }
192
+ }
193
+ }
194
+
195
+ /** LLM route (Issue #25): explicit sleepProvider/Model wins, then the dream
196
+ * route as a shared explicit fallback, then the agent default model. Sleep
197
+ * can pin a cheaper model for its bulk passes without disturbing the dream
198
+ * route. Explicit config first — otherwise the config routes are dead code
199
+ * whenever agentDefaultModel resolves (see resolveRoute in dream.js). */
200
+ function resolveSleepRoute(ctx, config, logger) {
201
+ if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
202
+ if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
203
+ try {
204
+ const sel = ctx?.agentDefaultModel?.currentSelection?.();
205
+ if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
206
+ } catch { /* fall through to warn */ }
207
+ logger?.warn?.("dsh-mneme sleep: no llm route available");
208
+ return undefined;
209
+ }
210
+
211
+ function makeSummary(m) {
212
+ const text = (m.content ?? "").trim();
213
+ if (!text) return (m.title ?? "").trim();
214
+ return text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX)}…`;
215
+ }
216
+
217
+ // issue #471 政策轴:被按下的作用域也不参与睡眠整理。与 dream.js 的 withoutPaused
218
+ // 同源(那里记着「判定只许有一份实现」的理由),但这里是**单独一处**而不是复用:
219
+ // 两个模块各被独立装配(runSleep / runDream 各自接 service),任何一个被换进只
220
+ // 实现了一半 service 的夹具时都该各自降级,而不是因为对方模块没装而双双失效。
221
+ //
222
+ // 睡眠侧比日间加固侧更要紧:这几个相位不只读——phaseDemotion 会 setArchived 与
223
+ // demoteToSummary(真改归档面),phasePatterns 会 mint 出 type=pattern 的新行
224
+ // (凭空给被按下的作用域生出衍生记忆)。只挡日间那几处读入口的话,闸门开着而每
225
+ // 6 小时一次的睡眠照样动手,那等于「按下」只是让它在白天看不见。
226
+ //
227
+ // service 上取不到 filterPaused 时(老装配、测试夹具)原样返回,与改动前一致。
228
+ function withoutPaused(service, rows) {
229
+ if (typeof service?.filterPaused !== "function") return rows;
230
+ return service.filterPaused(rows);
231
+ }
232
+
233
+ // ---------------------------------------------------------------- phases
234
+
235
+ /**
236
+ * Phase 1 — conflict resolution. Needs a semantic embedder + vector index.
237
+ * In freeze mode (conflictFreezeEnabled) conflicting pairs are parked in
238
+ * conflict_pending for human review (no LLM). Otherwise the LLM adjudicates:
239
+ * each pair → winner kept / loser archived. Returns a per-run summary.
240
+ */
241
+ async function phaseConflicts(ctx, service, config, logger, runId, semantic = null, signal = null) {
242
+ const language = langOf(config);
243
+ const embedder = semantic?.embedder;
244
+ const vectorIndex = semantic?.vectorIndex;
245
+ if (!embedder || !vectorIndex || typeof embedder.embed !== "function") {
246
+ return { status: "skipped", reason: "no semantic embedder" };
247
+ }
248
+ const strictness = config.sleepConflictStrictness ?? "normal";
249
+ const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
250
+ const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));
251
+ if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
252
+ if (signal?.aborted) return { status: "aborted", reason: "user activity" };
253
+
254
+ // Backfill + collect vectors for every eligible memory (best effort).
255
+ const vectors = new Array(memories.length);
256
+ const missing = [];
257
+ for (let i = 0; i < memories.length; i++) {
258
+ const cached = vectorIndex.getEmbedding?.(memories[i].id);
259
+ if (cached) vectors[i] = cached;
260
+ else missing.push(i);
261
+ }
262
+ if (missing.length) {
263
+ try {
264
+ const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
265
+ const rows = await embedder.embed(texts);
266
+ missing.forEach((mi, j) => {
267
+ if (rows[j]?.length) {
268
+ vectors[mi] = rows[j];
269
+ vectorIndex.saveEmbedding?.(memories[mi].id, rows[j]);
270
+ }
271
+ });
272
+ } catch (error) {
273
+ logger?.warn?.(`dsh-mneme sleep: conflict vector backfill failed: ${String(error)}`);
274
+ }
275
+ }
276
+ const usable = [];
277
+ for (let i = 0; i < memories.length; i++) {
278
+ if (vectors[i]?.length) usable.push(i);
279
+ }
280
+ if (usable.length < 2) return { status: "skipped", reason: "no usable vectors" };
281
+ const usableMemories = usable.map((i) => memories[i]);
282
+ const usableVectors = usable.map((i) => vectors[i]);
283
+
284
+ // Issue #126:动作集档位既要决定 prompt,也要决定**校验器允许的动作集合**(见下方
285
+ // allowedActions),并且在候选构造阶段就要知道——full 档需要补跨类型对。因此在这里
286
+ // 解析,而不是等到拼 prompt 时。
287
+ const actionSet = config.sleepActionSet ?? "conflict";
288
+ const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);
289
+ if (actionSet === "full") {
290
+ // Issue #126 review(Copilot):findPotentialConflicts 只看同类型,而 full 档的
291
+ // prompt 明确宣传 differentiate 用于互补型——互补本来就常跨类型(同一事实的项目侧
292
+ // 与偏好侧)。不补这一遍,那条路径永远选不到。merge 的类型守卫不受影响:它拦在
293
+ // validateDecisions 里(allowCrossTypeMerge),不在候选生成。
294
+ for (let i = 0; i < usableMemories.length; i++) {
295
+ for (let j = i + 1; j < usableMemories.length; j++) {
296
+ if (usableMemories[i].type === usableMemories[j].type) continue;
297
+ const sim = cosineSimilarity(usableVectors[i], usableVectors[j]);
298
+ if (sim > threshold) pairs.push({ a: usableMemories[i], b: usableMemories[j], similarity: sim });
299
+ }
300
+ }
301
+ }
302
+ if (pairs.length === 0) return { status: "skipped", reason: "no conflicts found" };
303
+
304
+ // Dedupe: each memory participates in at most one pair, highest similarity
305
+ // first — overlapping pairs would violate validateDecisions' "one claim".
306
+ pairs.sort((a, b) => b.similarity - a.similarity);
307
+
308
+ // Issue #533:已经裁决过「互补、两条都留」的对不再重判。
309
+ //
310
+ // 过滤必须发生在 used 去重**之前**:若先占位再丢弃,一条已裁决的记忆会把
311
+ // 名额占住,而它真正的对手又是自己人,这一轮就白跑——抑制一对不该顺带压掉
312
+ // 另一对候选。判据与 applyDifferentiate 的幂等同源:账本里有这对、且两侧正文
313
+ // 的 content_hash 与记账时一致(任一侧改过 = 结论可能不再成立,重判)。
314
+ // 存量兼容走同一格:旧版本把注记写进正文却没账本行,两侧都还带着注记块也算
315
+ // 已裁决,免得升级后第一轮把所有老对重判一遍。
316
+ const alreadyDifferentiated = (p) => {
317
+ const a = service.getById(p.a.id);
318
+ const b = service.getById(p.b.id);
319
+ if (!a || !b) return false;
320
+ if (typeof service.findDifferentiatedPair === "function") {
321
+ const record = service.findDifferentiatedPair(a.id, b.id);
322
+ if (record) {
323
+ const [ha, hb] = a.id <= b.id
324
+ ? [contentHashOf(a), contentHashOf(b)]
325
+ : [contentHashOf(b), contentHashOf(a)];
326
+ // 账本行没记 hash(存量/异常写入)时不下结论,宁可多判一次。
327
+ return record.hash_a != null && record.hash_b != null
328
+ && record.hash_a === ha && record.hash_b === hb;
329
+ }
330
+ }
331
+ return STR.differentiatedNoteRe.test(String(a.content ?? ""))
332
+ && STR.differentiatedNoteRe.test(String(b.content ?? ""));
333
+ };
334
+ const fresh = [];
335
+ let suppressed = 0;
336
+ for (const p of pairs) {
337
+ if (alreadyDifferentiated(p)) {
338
+ suppressed += 1;
339
+ continue;
340
+ }
341
+ fresh.push(p);
342
+ }
343
+ if (fresh.length === 0) {
344
+ // 与 "no conflicts found" 分开报:一对都没检测到 vs 检测到但全都裁决过,
345
+ // 是完全不同的两件事,混成一句会让「sleep 是不是没在干活」读不出来。
346
+ return { status: "noop", reason: "all candidate pairs already differentiated", suppressed };
347
+ }
348
+
349
+ // Issue #529:输入侧预算。此前 samePairs 是**全部**同 scope 相似对,一次拼成一个
350
+ // prompt——实测单请求 163,054 输入 token(服务端 --ctx-size 163,840),撞墙后按冷却
351
+ // 重排,下一轮送进去的还是同一份输入,于是 23 轮连续失败。
352
+ //
353
+ // 预筛掉「已经判过且两侧都没动过」的对:判 keep 曾经是彻底的 no-op,既不留痕也不
354
+ // 影响下一轮的候选池,同一对每个冷却周期都被重新送审一遍。台账(conflict_reviewed)
355
+ // 记的是"这段输入被看过",两侧任一 updated_at 晚于 judged_at 就重新入池——与
356
+ // conflict_pending 复核项 4 同一条宽松代理:任何字段更新都算值得再看一眼。
357
+ //
358
+ // Issue #536 review(CodeRabbit ①):预筛必须发生在下面的 used 去重**之前**,理由与
359
+ // #533 那条一模一样——先占位再去掉,一条已审的记忆会把自己的配对名额占住,而它真正
360
+ // 的对手(另一条没审过的记忆)因此进不了这一轮。三条边界:
361
+ // · 只筛同 scope 对:跨 scope 对本来就要停车进人工队列,不能被台账吞掉;
362
+ // · freeze 模式不筛:那一档只停车不送 LLM,台账与它无关(ledgerSkipped 记 0);
363
+ // · ledgerSkipped 在这里一次数清,后面 candidates 直接取 samePairs,不再二次过滤。
364
+ // ⚠️ Issue #616:这个判据把「未标注」当成「同 scope」。scopeKeyOf 把 NULL/空串
365
+ // 归一成 null,于是两条都没标注时 null !== null 为 false,配对算同 scope、可以进
366
+ // 自动池。但「归属未知」和「归属相同」不是一回事:按 v0.8.1 #170,前者本该是不可
367
+ // 自动的范围。live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL,受影响的
368
+ // 面不小。修法是把「任一未标注」显式判为不可自动,而不是继续让 null 自我相等。
369
+ const isCrossScopePair = (p) =>
370
+ scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
371
+ || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
372
+ const ledger = typeof service.loadConflictReviewed === "function"
373
+ ? (service.loadConflictReviewed() ?? new Map())
374
+ : new Map();
375
+ const pairKey = (p) => (p.a.id < p.b.id ? `${p.a.id}\u0000${p.b.id}` : `${p.b.id}\u0000${p.a.id}`);
376
+ const alreadyJudged = (p) => {
377
+ const rec = ledger.get(pairKey(p));
378
+ if (!rec?.judged_at) return false;
379
+ return (p.a.updated_at ?? "") <= rec.judged_at && (p.b.updated_at ?? "") <= rec.judged_at;
380
+ };
381
+ // Issue #641(beta 第二阶段):分诊账本的档位。#616 点名的父子绑定不继承——
382
+ // enforce 与 freeze 同开时 freeze 让位(本相位降级 shadow + warn):freeze 照常
383
+ // 停车(shadow 语义 = 只记账零处置),绝不出现两套处置语义同时生效。宁可少处置,
384
+ // 不在打架的配置下跑。
385
+ let ledgerMode = config.conflictLedgerMode ?? "off";
386
+ if (ledgerMode === "enforce" && config.conflictFreezeEnabled === true) {
387
+ logger?.warn?.("dsh-mneme sleep: conflictLedgerMode=enforce coexists with conflictFreezeEnabled — degrading to shadow; disable one of them");
388
+ ledgerMode = "shadow";
389
+ }
390
+ // alreadyJudged 预筛只为省 LLM:freeze 与 enforce 都不走 LLM,筛不筛不影响成本,
391
+ // 不筛还能让 T2/T3/T4 的每轮观察持续刷新账本(tier 可能随内容更新而变)。
392
+ const selectionPairs = config.conflictFreezeEnabled === true || ledgerMode === "enforce"
393
+ ? fresh
394
+ : fresh.filter((p) => isCrossScopePair(p) || !alreadyJudged(p));
395
+ const ledgerSkipped = fresh.length - selectionPairs.length;
396
+
397
+ const used = new Set();
398
+ const selected = [];
399
+ for (const p of selectionPairs) {
400
+ if (used.has(p.a.id) || used.has(p.b.id)) continue;
401
+ used.add(p.a.id);
402
+ used.add(p.b.id);
403
+ selected.push(p);
404
+ }
405
+
406
+ // Issue #641(beta 第一阶段,shadow 档):分诊账本。对 selected 全量做确定性
407
+ // 分诊(dup / cross-scope-dup / evolution / conflict,判据唯一实现见 triage.js)
408
+ // 并落 conflict_ledger。shadow = 只记账零处置:下面 freeze/LLM 路径一个字节都
409
+ // 不变——shadow 的存在意义就是让「分诊分布是否复现真实库 46/20/5/27」在真实
410
+ // 运行里可测,作为切 enforce 前的预注册证据。记账失败只 warn 计数,绝不反噬
411
+ // 相位(与 conflict_reviewed 同一条 best-effort 纪律)。
412
+ // 记账时点在 used 去重之后:被去重压掉的那半边对(同一条记忆配给了更高相似的
413
+ // 对手)本来就不是这一轮的候选,观察它们只会让分母虚高。
414
+ const ledgerTriage = ledgerMode !== "off"
415
+ ? await recordLedgerTriage(service, runId, selected, {
416
+ dupMinSimilarity: config.conflictLedgerDupMinSimilarity,
417
+ logger,
418
+ // 分块记账的取消检查:用户活动打断时停止记账,余下的对下一轮重见
419
+ signal
420
+ })
421
+ : null;
422
+
423
+ // Issue #641(beta 第二阶段,enforce 档):确定性处置接线。T1(dup)走
424
+ // supersedeInfoFirstPair(与冻结分支自动档同一条契约,decisions.js 共享实现);
425
+ // T1 走信息量优先 supersede、T3 走取代链(superseded_by 指向,不归档)、
426
+ // T2/T4 记账即处置——不停车、不送 LLM。人审队列与 LLM 裁决在本档下都不再
427
+ // 消费冲突候选,conflict_pending 恒空。每轮上限沿用
428
+ // sleepConflictMaxPairsPerRun:处置虽不走 LLM,归档/落链仍是写动作,写预算
429
+ // 与 freeze/auto 两档同口径。T4 的检索时消解(fuseRecall 矛盾感知 + 注入
430
+ // 标注)是后续 PR,本档先把人审摘出去。
431
+ if (ledgerMode === "enforce") {
432
+ const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
433
+ const pool = perRunCap > 0 ? selected.slice(0, perRunCap) : selected;
434
+ let t1Resolved = 0;
435
+ let t1Failed = 0;
436
+ let t3Chained = 0;
437
+ const t1VectorFixups = [];
438
+ for (const p of pool) {
439
+ const verdict = triagePair(p.a, p.b, p.similarity, { dupMinSimilarity: config.conflictLedgerDupMinSimilarity });
440
+ if (verdict.tier === "evolution") {
441
+ // T3 取代链(beta 第三阶段):旧行 superseded_by → 新行,检索降权,
442
+ // **不归档**——演进不是输赢,旧值仍是「曾经为真」的可追溯历史(与
443
+ // supersede 动作的归档语义不同)。新者胜对演进成立:间隔 ≥7 天的
444
+ // 同主题对,新记载才反映现状(服务器时钟,内容声称的时间不做键)。
445
+ const newer = (p.a.created_at ?? "") >= (p.b.created_at ?? "") ? p.a : p.b;
446
+ const older = newer === p.a ? p.b : p.a;
447
+ try {
448
+ if (service.supersedeChain(older.id, newer.id)) {
449
+ t3Chained += 1;
450
+ // 先落观察行再推进状态(#650 评审):enforce 档下 recordLedgerTriage 只会
451
+ // 把这一对写成 open,UPDATE 于此才有行可命中;顺序反了就是静默落空。
452
+ service.saveConflictLedgerEntry({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, tier: verdict.tier, similarity: p.similarity, signals: verdict.signals });
453
+ service.setConflictLedgerStatus(p.a.id, p.b.id, "superseded");
454
+ }
455
+ } catch (error) {
456
+ logger?.warn?.(`dsh-mneme sleep: ledger T3 chain failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
457
+ }
458
+ continue;
459
+ }
460
+ if (verdict.tier !== "dup") continue;
461
+ let res = null;
462
+ try {
463
+ res = supersedeInfoFirstPair(p, service, logger, config);
464
+ } catch (error) {
465
+ logger?.warn?.(`dsh-mneme sleep: ledger T1 supersede failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
466
+ }
467
+ if (res?.applied) {
468
+ t1Resolved += 1;
469
+ // winner/loser 用 helper 判定的真实方向(信息量优先可能翻转 p.a/p.b)
470
+ t1VectorFixups.push({ action: "supersede", winner: res.winnerId, loser: res.loserId });
471
+ try {
472
+ // 账本状态推进 + 机器台账照记(与自动档同款 outcome 口径)——自动处置
473
+ // 也要能事后回答「这一对什么时候、被谁判的」。先落观察行(#650 评审):
474
+ // 这一对未必已在账本里,直接 UPDATE 会命中 0 行而静默失记。
475
+ service.saveConflictLedgerEntry({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, tier: verdict.tier, similarity: p.similarity, signals: verdict.signals });
476
+ service.setConflictLedgerStatus(p.a.id, p.b.id, "superseded");
477
+ service.saveConflictReviewed?.({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, outcome: "supersede", judged_at: new Date().toISOString() });
478
+ } catch (error) {
479
+ logger?.warn?.(`dsh-mneme sleep: ledger bookkeeping after T1 supersede failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
480
+ }
481
+ } else {
482
+ t1Failed += 1;
483
+ }
484
+ }
485
+ // 败者向量清理:T1 的 supersede 不在下面 LLM 通路的 maintainIndexAfterDream
486
+ // 清单里(enforce 已早退),必须在早退前自己清——已归档行留向量等于检索
487
+ // 层「看得见摸不着」。
488
+ if (t1VectorFixups.length > 0 && semantic?.embedder && semantic?.vectorIndex) {
489
+ try {
490
+ await maintainIndexAfterDream(t1VectorFixups, service, semantic);
491
+ } catch (error) {
492
+ logger?.warn?.(`dsh-mneme sleep: ledger T1 index maintenance failed: ${String(error)}`);
493
+ }
494
+ }
495
+ if (selected.length > pool.length) {
496
+ logger?.warn?.(`dsh-mneme sleep: conflict ledger enforce capped at ${pool.length}/${selected.length} pairs this run`);
497
+ }
498
+ return {
499
+ status: t1Failed > 0 ? "degraded" : t1Resolved > 0 || t3Chained > 0 ? "ok" : "noop",
500
+ clean: t1Failed === 0,
501
+ frozen: 0,
502
+ scopeCandidates: 0,
503
+ pairs: selected.length,
504
+ batches: 0,
505
+ ledgerSkipped,
506
+ // applied 是整轮回执的唯一写库口径(runSleep 的 totalApplied 只认这个键)。
507
+ // 缺了它,本档真实发生的 T1 归档与 T3 落链在 receipt / dream_runs.applied
508
+ // 里全是 0 ——「本相位说改了、整轮说没改」(#650 评审,#619 同族)。
509
+ applied: t1Resolved + t3Chained,
510
+ enforced: { t1Resolved, t1Failed, t3Chained },
511
+ ...(ledgerTriage ? { ledgerTriage } : {})
512
+ };
513
+ }
514
+
515
+ // v0.8.1(issue #170 第 2 步):跨 scope 相似对分流。同一内容落在两个归属下
516
+ // (任一维标注键不等即算,NULL=未标注=全局也参与比较——「全局副本 + 专属副本
517
+ // 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
518
+ // 某个归属下的唯一副本,LLM 也无从替用户决定归属。一律停车到冲突待确认队列
519
+ // (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
520
+ const crossPairs = selected.filter(isCrossScopePair);
521
+ const samePairs = selected.filter((p) => !isCrossScopePair(p));
522
+ // 四态:fresh(真的新插了一行)/ existing(这一对已在队列里)/ suppressed
523
+ // (已人工裁决且两侧未变,复核项 4)/ failed(写库抛错)。旧代码把
524
+ // 「返回非 null」一律当停车成功,existing 因此每轮虚报一遍 frozen(#603)。
525
+ const parkPair = (p, reason) => {
526
+ try {
527
+ const parked = service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason, similarity: p.similarity });
528
+ if (parked == null) return "suppressed";
529
+ return parked.fresh === true ? "fresh" : "existing";
530
+ } catch (error) {
531
+ logger?.warn?.(`dsh-mneme sleep: failed to park conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
532
+ return "failed";
533
+ }
534
+ };
535
+ const scopeReason = (p) => STR.scopeCandidateReason[language](p.similarity.toFixed(2));
536
+ const simReason = (p) => STR.similarityReason[language](p.similarity.toFixed(2));
537
+
538
+ // 待审对的键集合(与 pairKey 同形)。只用于 freeze 分支的「先滤已入队」,
539
+ // 分页拉全量:队列规模不受 50 条默认页长限制。
540
+ const loadPendingPairKeys = () => {
541
+ const keys = new Set();
542
+ if (typeof service.listConflictPending !== "function") return keys;
543
+ const pageSize = 500;
544
+ for (let offset = 0; ; offset += pageSize) {
545
+ const page = service.listConflictPending({ limit: pageSize, offset, includeResolved: false }) ?? [];
546
+ for (const row of page) keys.add(`${row.memory_a}\u0000${row.memory_b}`);
547
+ if (page.length < pageSize) break;
548
+ }
549
+ return keys;
550
+ };
551
+
552
+ // Freeze mode: park pairs for manual review, no LLM required. Cross-scope
553
+ // pairs carry the dedicated scope-candidate reason so the queue reads them
554
+ // as ownership decisions rather than plain duplicates.
555
+ //
556
+ // Issue #603 第 1 层:这条分支此前绕过全部三个上限、直接裸跑 selected。
557
+ // · 先滤已入队再截断——裸 slice 会饥饿:上限恒小于候选数时,前 N 个名额
558
+ // 每轮都被同一批已在队列里的对占住,后面的对永远轮不上;
559
+ // · 接预算:conflictFreezeMaxPending 决定队列还能装多少,
560
+ // sleepConflictMaxPairsPerRun 决定一轮最多处理多少,取两者较小值;
561
+ // · 只把 fresh 计入 frozen,已入队的对不再虚报。
562
+ //
563
+ // ⚠️ Issue #616:下面这段 freeze 分支同时也是「自动裁决档」的唯一宿主——
564
+ // conflictAutoResolveEnabled 的全部逻辑(autoEnabled / autoPool / autoResolved)
565
+ // 都在这对花括号里面。两个配置键在面板上看是平行的,实际是父子:把 freeze
566
+ // 关掉,自动档会跟着静默消失,没有任何提示。拆开之前别把它们当独立开关用。
567
+ if (config.conflictFreezeEnabled === true) {
568
+ const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;
569
+ const pendingNow = typeof service.countConflictPending === "function" ? (service.countConflictPending() ?? 0) : 0;
570
+ const room = Math.max(0, maxPending - pendingNow);
571
+ const pendingKeys = loadPendingPairKeys();
572
+ const unparked = selected.filter((p) => !pendingKeys.has(pairKey(p)));
573
+ const withinRoom = room > 0 ? unparked.slice(0, room) : [];
574
+ const pendingSkipped = unparked.length - withinRoom.length;
575
+ const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
576
+ const pool = perRunCap > 0 ? withinRoom.slice(0, perRunCap) : withinRoom;
577
+ // 只报「有位置但被每轮上限截住」的那部分:队列满时再追一轮也没用,
578
+ // 报成 backlog 会让调度器空转(catchup 上限 24 轮)。
579
+ const perRunSkipped = withinRoom.length - pool.length;
580
+ // Issue #603 第 4 层(自动裁决档,默认关):相似度极高的**同 scope** 对不再
581
+ // 进人工队列,直接保留信息量更大的那条、归档另一条。人只看模糊地带。
582
+ //
583
+ // 为什么阈值留在配置里而不是写死 0.95:这一档减的是峰值,
584
+ // 不是把队列清零——把阈值当常量会让人以为「开了就没事了」。
585
+ //
586
+ // 为什么跨 scope 永不自动:归属是用户的决定(v0.8.1 #170),归档败者可能
587
+ // 销毁某归属下的唯一副本。这条约束比减负重要。
588
+ //
589
+ // ⚠️ Issue #616:这条守卫有一个洞。isCrossScopePair 走 scopeKeyOf,NULL 归一
590
+ // 成 null,null !== null 判为 false —— 「未标注」因此被当成「同 scope」混进
591
+ // autoPool。live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL。改判据
592
+ // 之前,别把这一档当成「归属安全」。
593
+ //
594
+ // 为什么用 supersede 而不是 conflict:conflict 会把「(已否决旧信息:…)」
595
+ // 追加到**赢家**正文,而赢家正是留下来的那条;近重复对再叠一段互相引用的
596
+ // 注记,等于用冗余污染幸存记忆。supersede 把可追溯性留在被归档的一侧,
597
+ // 赢家正文一字不动(#126 的原设计)。
598
+ const autoEnabled = config.conflictAutoResolveEnabled === true;
599
+ const autoMin = typeof config.conflictAutoResolveMinSimilarity === "number"
600
+ ? config.conflictAutoResolveMinSimilarity
601
+ : 0.95;
602
+ const autoPool = [];
603
+ const parkPool = [];
604
+ for (const p of pool) {
605
+ const eligible = autoEnabled
606
+ && !isCrossScopePair(p)
607
+ && typeof p.similarity === "number"
608
+ && p.similarity >= autoMin;
609
+ (eligible ? autoPool : parkPool).push(p);
610
+ }
611
+ let autoResolved = 0;
612
+ let autoFailed = 0;
613
+ // 这一档不走 LLM,没有批次时间戳可借(judgedAt 在下面那条 LLM 通路里才定义),
614
+ // 自己取一个:台账要能回答「什么时候判的」,不能留空。
615
+ const autoJudgedAt = new Date().toISOString();
616
+ // 保留**信息量更大**的那条,不是更新的那条。
617
+ //
618
+ // 「保留新的」看着合理,实测是错的:live 库 >=0.95 的 20 对里,11 对(55%)
619
+ // 新的那条比旧的短,平均短 132 字;降到 0.88 的 213 对里 122 对(57%)如此。
620
+ // 典型例子:「用户同时开 web profile 和 desktop profile 时出现开不了新对话/
621
+ // 会话异常,用户自己…」(371 字,旧)被压成「用户同时开 web profile 和
622
+ // desktop profile 的 dsh-mneme 时开不了新对话」(307 字,新)——蒸馏时压缩过
623
+ // 的一版反而更新。留新条等于把更完整的那条归档掉。
624
+ //
625
+ // 这与 mem0 的裁决规则一致:`configs/prompts.py:217` 明写「两句话说的是同一
626
+ // 件事时,留信息量最大的那条,不是留最新的」。cognee 反过来(「取更新的」),
627
+ // 那是反例——照抄会把「0:00 前必须躺下」削成残缺版。
628
+ //
629
+ // 长度相同才算「信息量相当」,此时退回更新时间,再退回 id:三级兜底保证同一
630
+ // 对每次判出同一个赢家(否则重放会来回翻,归档的可能是刚保住的那条)。
631
+ const infoFirst = (p) => {
632
+ const an = p.a.content?.length ?? 0;
633
+ const bn = p.b.content?.length ?? 0;
634
+ if (an !== bn) return an > bn ? [p.a, p.b] : [p.b, p.a];
635
+ const au = p.a.updated_at ?? "";
636
+ const bu = p.b.updated_at ?? "";
637
+ if (au !== bu) return au > bu ? [p.a, p.b] : [p.b, p.a];
638
+ return p.a.id > p.b.id ? [p.a, p.b] : [p.b, p.a];
639
+ };
640
+ for (const p of autoPool) {
641
+ const [winner, loser] = infoFirst(p);
642
+ const snapshot = new Map();
643
+ for (const m of [winner, loser]) {
644
+ snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
645
+ }
646
+ // trustEpistemicWeighting 显式关掉:它会在 applySupersede 里按 epistemic_status
647
+ // 交换赢家,那会让「留信息量更大的那条」这条契约随数据漂移。自动档的承诺
648
+ // 必须是确定的。
649
+ const { applied, conflicts } = applyDecisions(
650
+ [{ action: "supersede", winner: winner.id, loser: loser.id }],
651
+ service, logger, snapshot, { ...config, trustEpistemicWeighting: false }
652
+ );
653
+ if (applied > 0) {
654
+ autoResolved++;
655
+ // 记进机器侧台账:自动动作也要能事后回答「这一对什么时候、被谁判的」。
656
+ try {
657
+ service.saveConflictReviewed?.({
658
+ run_id: runId, memory_a: p.a.id, memory_b: p.b.id,
659
+ outcome: "supersede", judged_at: autoJudgedAt
660
+ });
661
+ } catch (error) {
662
+ logger?.warn?.(`dsh-mneme sleep: failed to record auto-resolved pair ${p.a.id}/${p.b.id}: ${String(error)}`);
663
+ }
664
+ continue;
665
+ }
666
+ // 自动档没落地 → 退回人工队列。绝不因为「本来想自动处理」就把这一对丢掉,
667
+ // 那才是真正的静默丢失。两种没落地的原因都要说清楚:
668
+ // · CAS 冲突(applyDecisions 记在 conflicts 里)——两侧在快照之后被改过,
669
+ // 自动判的赢家可能已经不是该保的那条,人看一眼最稳;
670
+ // · 其余异常(记在 failures 里)——写库失败之类,重试无意义就交给人。
671
+ autoFailed++;
672
+ logger?.warn?.(
673
+ `dsh-mneme sleep: auto-resolve skipped ${p.a.id}/${p.b.id}`
674
+ + (conflicts.length ? ` (concurrent edit)` : "")
675
+ + `, falling back to manual review`
676
+ );
677
+ parkPool.push(p);
678
+ }
679
+ let frozen = 0;
680
+ let parkFailed = 0;
681
+ for (const p of parkPool) {
682
+ const outcome = parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p));
683
+ if (outcome === "fresh") frozen++;
684
+ else if (outcome === "failed") parkFailed++;
685
+ }
686
+ if (pendingSkipped > 0) {
687
+ logger?.warn?.(`dsh-mneme sleep: conflict freeze queue full (${pendingNow}/${maxPending}), skipped ${pendingSkipped} conflict(s)`);
688
+ }
689
+ return {
690
+ status: frozen > 0 || autoResolved > 0 ? "ok" : "noop",
691
+ frozen,
692
+ // 自动裁决掉的不进队列,但确实是这一轮干的活——分开报,别混进 frozen
693
+ // (frozen 的语义是「给人工队列新增了几行」,混进去面板就会虚高)。
694
+ // Issue #616:这两个字段目前**没有消费者**。dream.js 的两处 return 只透出
695
+ // frozen,面板也不读它们;conflict_reviewed 台账同样没有 API 暴露,且 schema
696
+ // 不记赢家(memory_a/b 是排序过的)。也就是说自动裁决干了什么,事后只能翻
697
+ // loser 正文的「(已被取代:…)」后缀反推。补可见性之前,别指望从报告里看出
698
+ // 这一档跑了几对。
699
+ autoResolved,
700
+ autoFailed,
701
+ // 写库抛错的对既没入队也不算落地:clean=false,别让调度器当「追完了」。
702
+ clean: parkFailed === 0,
703
+ scopeCandidates: crossPairs.length,
704
+ pairs: selected.length,
705
+ batches: 0,
706
+ ledgerSkipped,
707
+ perRunSkipped,
708
+ pendingSkipped,
709
+ // #641 shadow:分诊分布随相位结果透出(dream phases 与审计行都能看到),
710
+ // 面板消费在 beta 第二批接。
711
+ ...(ledgerTriage ? { ledgerTriage } : {})
712
+ };
713
+ }
714
+
715
+ // 非 freeze:跨 scope 对先停车(绝不进 LLM 裁决),同 scope 对照常走 LLM。
716
+ let scopeParked = 0;
717
+ for (const p of crossPairs) {
718
+ if (parkPair(p, scopeReason(p)) === "fresh") scopeParked++;
719
+ }
720
+ if (samePairs.length === 0) {
721
+ // 没有可送 LLM 的同 scope 对:可能一对都没检测到,也可能全被台账挡下了
722
+ // (ledgerSkipped)。两种都要如实报,否则「sleep 怎么什么都不干」读不出来。
723
+ return {
724
+ status: scopeParked > 0 ? "ok" : "noop",
725
+ frozen: 0,
726
+ scopeCandidates: scopeParked,
727
+ pairs: selected.length,
728
+ batches: 0,
729
+ ledgerSkipped,
730
+ ...(ledgerTriage ? { ledgerTriage } : {})
731
+ };
732
+ }
733
+
734
+ // LLM adjudication(仅同 scope 对)。
735
+ const route = resolveSleepRoute(ctx, config, logger);
736
+ if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length, batches: 0, ledgerSkipped, ...(ledgerTriage ? { ledgerTriage } : {}) };
737
+ // 每轮总上限:分批解决的是单请求撞墙,不是"一个 449 对的池子该在一轮里跑完"。
738
+ // 没进这一轮的对留在下一轮(它们既没被丢弃,也不在台账里)。
739
+ const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
740
+ const pool = perRunCap > 0 ? samePairs.slice(0, perRunCap) : samePairs;
741
+ const perRunSkipped = samePairs.length - pool.length;
742
+ const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
743
+ let conflictStreamFailure = "";
744
+ // actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
745
+ // 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
746
+ const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
747
+ // reflectionUpdateEnabled=false:两份 conflict prompt 都教了模型怎么用 update
748
+ // (「信息部分过时 → update」那一支),只靠校验器拒会把每轮这份输出白付掉。
749
+ // 后缀与 dream 主链路共用同一条文案,两个入口对用户是同一个开关、同一种说法。
750
+ const conflictPromptGated = config.reflectionUpdateEnabled !== false
751
+ ? conflictPrompt
752
+ : conflictPrompt + STR.prompts.noUpdateSuffix[language];
753
+ const runConflict = (batch, withEffort, tokens) => {
754
+ conflictStreamFailure = "";
755
+ const listText = batch.map((p) => STR.candidateConflicts[language](p)).join("\n\n");
756
+ return auditedSleepLlm(ctx, service, config, logger, {
757
+ triggerSource: "sleep",
758
+ operationType: "sleep_conflict",
759
+ modelId: `${route.provider}:${route.model}`,
760
+ // 候选对的双方 id:面板的活动流用 related_memory_ids 解析「沉淀条数」。
761
+ // 分批后必须取**这一批**的 id:整轮的 60 对一次写进每一批的审计行,面板会把
762
+ // 同一个「沉淀条数」重复累加五遍。
763
+ relatedMemoryIds: batch.flatMap((p) => [p.a.id, p.b.id]),
764
+ streamError: () => conflictStreamFailure,
765
+ // 纯判据:输出里没有 JSON 数组 ⇒ 这轮是失败的调用,审计记 error。**不要**把
766
+ // 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
767
+ // 这个钩子不会被调用,控制流会跟着失效(dream 的既有坑)。代价是审计开启时
768
+ // 多解析一次,换来两条路径彻底解耦。
769
+ auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output"),
770
+ // 纯判据,与 auditError 同一条约定:重解析一次、不读闭包(审计关掉时那些闭包
771
+ // 根本不会被赋值,读它会让「审计说了什么」依赖调用方走到了哪一行)。
772
+ metadataFrom: (text) => (parseJsonArray(text) ? null : parseFailureMeta(text))
773
+ }, (reportUsage) => streamText(ctx, {
774
+ provider: route.provider,
775
+ model: route.model,
776
+ purpose: "sleep-conflict",
777
+ maxTokens: tokens,
778
+ ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
779
+ messages: [
780
+ { role: "system", content: [{ type: "text", text: conflictPromptGated }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
781
+ { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
782
+ ]
783
+ }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
784
+ };
785
+ // 单批的完整往返:送 → 解析 → 校验 → 应用 → 记账。抽成函数是为了让失败只影响
786
+ // 自己这一批(下面的循环),而不是把整轮判死。
787
+ const adjudicateBatch = async (batch, updateBudget) => {
788
+ // Issue #529 review(CodeRabbit ①):留痕写入失败要能被上层看见。只打 warn 的话,
789
+ // 这一对既没记账、又被算成"送出去的全落地了",于是下一轮它原样回到候选池、又被
790
+ // 算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而审计上每轮
791
+ // 都是干净的成功轮。计数随返回值透出,由 phaseConflicts 折进 clean。
792
+ let ledgerFailed = 0;
793
+ // Issue #536 review(CodeRabbit ③):judged_at 是**发送时刻**,不是落地时刻。
794
+ //
795
+ // 取 max(墙钟, 本批两侧里最新的 updated_at):`updated_at` 用的是 store 的
796
+ // **单调时钟**(src/store.js 的 nowIso:同一毫秒内的连续写入逐次 +1ms),它可能
797
+ // 比墙钟快几毫秒;直接用墙钟会记出一个早于被测行自身版本的 `judged_at`,于是
798
+ // 「这一对刚判过」在下一轮的 `alreadyJudged` 里读成「判过之后又被改过」,判 keep
799
+ // 的死循环原样复活(CI 的 test-host-legacy 腿抓到过一次真 flake)。两列同源才可比。
800
+ const judgedAt = batch.reduce(
801
+ (acc, p) => (p.a.updated_at > acc ? p.a.updated_at : (p.b.updated_at > acc ? p.b.updated_at : acc)),
802
+ new Date().toISOString()
803
+ );
804
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runConflict(batch, true, tokens), () => runConflict(batch, false, tokens), () => conflictStreamFailure), () => conflictStreamFailure);
805
+ if (text === undefined) {
806
+ if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
807
+ return { status: "failed", error: "llm failed", failure: conflictStreamFailure || undefined };
808
+ }
809
+ const decisions = parseJsonArray(text);
810
+ if (!decisions) return { status: "failed", error: "invalid decisions json" };
811
+ // 本批的 snapshot 只含本批的 id。validateDecisions 要求每个 snapshot id 恰好被
812
+ // claim 一次,拿整轮的 snapshot 校验单批输出会把其余 48 对判成"未裁决"。
813
+ const batchSnapshot = new Map();
814
+ for (const p of batch) {
815
+ batchSnapshot.set(p.a.id, p.a);
816
+ batchSnapshot.set(p.b.id, p.b);
817
+ }
818
+ // v0.4.4 起 validateDecisions 本身就会为未覆盖的 id 自动补 keep(dreamImplicitKeep
819
+ // 默认开启),这里保留显式预填作为防御性双保险——漏判读作"未裁决冲突"而非
820
+ // "冲突阶段整体失败"。
821
+ const covered = new Set();
822
+ for (const d of decisions) {
823
+ // Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
824
+ // 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
825
+ // supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
826
+ if (d?.action === "conflict" || d?.action === "supersede") {
827
+ if (typeof d?.winner === "string") covered.add(d.winner);
828
+ if (typeof d?.loser === "string") covered.add(d.loser);
829
+ } else if (Array.isArray(d?.ids)) {
830
+ for (const id of d.ids) covered.add(id);
831
+ }
832
+ }
833
+ for (const id of batchSnapshot.keys()) {
834
+ if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
835
+ }
836
+ // Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
837
+ // 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
838
+ // 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
839
+ // 模块对"什么算合法"的判据一致。
840
+ const { ok, errors, skipped } = validateDecisions(decisions, batchSnapshot, {
841
+ // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限,
842
+ // 分批后不能每批都拿整份额度——那样 N 批就是 N 倍上限。这里用的是本轮的剩余额度。
843
+ maxUpdatePerRun: updateBudget,
844
+ // reflectionUpdateEnabled=false:update 单条跳过(同 dream 主链路口径)。
845
+ allowUpdate: config.reflectionUpdateEnabled !== false,
846
+ minAgeHours: config.reflectionUpdateMinAgeHours,
847
+ skipInvalid: config.dreamSkipInvalid !== false,
848
+ allowCrossTypeMerge: config.allowCrossTypeMerge === true,
849
+ // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
850
+ // 与 dream 主链路同一判据。
851
+ mergeGuard: config.dreamMergeGuard === true,
852
+ // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
853
+ // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
854
+ allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
855
+ });
856
+ if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
857
+ // 同 phasePatterns——本相位的 create 也落 source=sleep(默认档的
858
+ // LEGACY_ACTIONS 含 create,理论上可 mint)。
859
+ const { applied, failures, conflicts } = applyDecisions(
860
+ // runId 让 differentiate 的账本行能追到是哪一轮 sleep 判的(issue #533)。
861
+ decisions, service, logger, batchSnapshot, { ...config, mintSource: "sleep", runId }
862
+ );
863
+ // Issue #536 review(CodeRabbit ②):本轮剩余的 update 额度按本批**通过校验的**
864
+ // update 数扣减——validateDecisions 已把非法条目从 decisions 就地 splice 掉,
865
+ // 所以数到的都是真的会落地的那些。落地时被 CAS 挡下的也算:额度宁可少发一次,
866
+ // 也不能让"多批各扣一次满额"把整轮上限架空。
867
+ const updateUsed = decisions.filter((d) => d?.action === "update").length;
868
+ // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
869
+ // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
870
+ // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
871
+ if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
872
+ try {
873
+ await maintainIndexAfterDream(decisions, service, semantic);
874
+ } catch (error) {
875
+ logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
876
+ }
877
+ }
878
+ // 台账:本批**全部送审对**都记一行,不只记被判成冲突的那几对。判 keep 的那一对
879
+ // 正是 #529 里"下一轮原样再送"的元凶,只记有动作的对等于没记。
880
+ if (typeof service.saveConflictReviewed === "function") {
881
+ // judged_at 取的是**发送之前**的时刻(见函数开头),不是落地之后:LLM 往返
882
+ // 期间若一侧被外部改动,它的 updated_at 会晚于 judged_at,下一轮自然重新入池;
883
+ // 模型看到的本来就是旧快照,按"已审"封盘等于用陈旧结论锁死这一对。
884
+ const outcomeOf = new Map();
885
+ for (const d of decisions) {
886
+ const action = d?.action ?? "keep";
887
+ if (action === "conflict" || action === "supersede") {
888
+ if (typeof d?.winner === "string") outcomeOf.set(d.winner, action);
889
+ if (typeof d?.loser === "string") outcomeOf.set(d.loser, action);
890
+ } else if (Array.isArray(d?.ids)) {
891
+ for (const id of d.ids) outcomeOf.set(id, action);
892
+ }
893
+ }
894
+ // Issue #536 review(CodeRabbit ③):未落地的对不记账。skipped(非法被跳)、
895
+ // failures(写入失败)、conflicts(CAS 冲突)里出现过的 id,说明那条决策没生效;
896
+ // 把它记成"已审"会让下一轮的 alreadyJudged 为真,这一对从此不再送审——
897
+ // 沉默地永久压制一个真实冲突,比多送一轮贵得多。
898
+ const unsettled = new Set();
899
+ for (const f of [...(failures ?? []), ...(conflicts ?? []), ...(skipped ?? [])]) {
900
+ for (const id of f?.ids ?? []) unsettled.add(id);
901
+ }
902
+ for (const p of batch) {
903
+ if (unsettled.has(p.a.id) || unsettled.has(p.b.id)) continue;
904
+ try {
905
+ service.saveConflictReviewed({
906
+ run_id: runId,
907
+ memory_a: p.a.id,
908
+ memory_b: p.b.id,
909
+ // 一对两端的动作可能不同(conflict 的胜/败、merge 的两端),取**非 keep
910
+ // 的那一侧**:keep/keep 才代表"看过、不冲突",这正是我们要留痕的结论。
911
+ outcome: [outcomeOf.get(p.a.id), outcomeOf.get(p.b.id)]
912
+ .find((x) => x && x !== "keep") ?? "keep",
913
+ judged_at: judgedAt
914
+ });
915
+ } catch (error) {
916
+ // Issue #529 review(CodeRabbit):留痕失败必须让这一轮**不 clean**。只 warn 的
917
+ // 话,这一对既没记账、又被算成"送出去的全落地了",下一轮它原样回到候选池、
918
+ // 又被算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而
919
+ // 审计上每一轮都是干净的成功轮。
920
+ ledgerFailed += 1;
921
+ logger?.warn?.(`dsh-mneme sleep: failed to record reviewed conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
922
+ }
923
+ }
924
+ }
925
+ // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
926
+ // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
927
+ return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", applied, failures, conflicts, skipped, updateUsed, ledgerFailed };
928
+ };
929
+ // 分批:每批独立往返,坏批只记 degraded 不中断。此前一次全送,任何一处理由
930
+ // (撞上下文、输出截断、JSON 坏掉)都是整轮 failed——而整轮 failed 又不带 pairs,
931
+ // 事后看不出它到底送了什么。
932
+ const batchSize = config.sleepConflictMaxPairs ?? 12;
933
+ const batches = batchSize > 0
934
+ ? Array.from({ length: Math.ceil(pool.length / batchSize) }, (_, i) => pool.slice(i * batchSize, (i + 1) * batchSize))
935
+ : [pool];
936
+ let applied = 0;
937
+ const failures = [];
938
+ const conflicts = [];
939
+ const skipped = [];
940
+ let failedBatches = 0;
941
+ // Issue #529 review(CodeRabbit ①):台账写入失败的累计数(逐批由 adjudicateBatch 报回)。
942
+ let ledgerFailed = 0;
943
+ // 用户活动打断时**不能**落进下面那条 noop:noop 的语义是「看过,没冲突」,而被打断
944
+ // 的那几批根本没送出去。用与入口处(signal 一开始就是 aborted)同一个词汇,避免
945
+ // 同一件事两个状态名。
946
+ let interrupted = false;
947
+ // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限。
948
+ // 校验器本身不跨批记忆,每批各拿一份满额就等于 N 倍上限,所以额度由这里持有:
949
+ // 每批只领剩余额度,校验通过后按**本批通过校验的** update 数扣减。额度用尽的
950
+ // 那几批若还含 update,会被校验器整单拒绝(记 failed),其余批照常继续——
951
+ // 与"一批坏掉不判死整轮"的既有契约一致。
952
+ let updateLeft = config.reflectionUpdateMaxPerRun ?? 2;
953
+ for (let i = 0; i < batches.length; i++) {
954
+ if (signal?.aborted) { interrupted = true; break; }
955
+ // Issue #536 review(CodeRabbit):单批抛错不能带走整轮。`adjudicateBatch` 里不只有
956
+ // "把失败变成返回值"的 LLM 通路,还有 `applyDecisions` 与台账写入——它们抛出的异常
957
+ // 此前会直接跳出循环,后面所有批都不跑、已汇总的 applied/failures 也一起丢掉。
958
+ // 只放行 AbortError(取消意图绝不能被降级重试吞掉,与 dream.js 的 withEffort 守卫同款),
959
+ // 其余一律折成这一批的 failed 结果,循环继续。
960
+ let res;
961
+ try {
962
+ res = await adjudicateBatch(batches[i], updateLeft);
963
+ } catch (error) {
964
+ if (error?.name === "AbortError") { interrupted = true; break; }
965
+ res = { status: "failed", error: String(error?.message ?? error), failures: [], skipped: [] };
966
+ }
967
+ if (res.status === "failed") {
968
+ failedBatches++;
969
+ failures.push(...(res.failures ?? []));
970
+ skipped.push(...(res.skipped ?? []));
971
+ // 失败批的原始理由(stream 层的 abort/error 原文)要进审计:只写 "llm failed"
972
+ // 的话,运维分不清是上下文超限、模型拒收还是用户活动打断。
973
+ logger?.warn?.(`dsh-mneme sleep: conflict batch ${i + 1}/${batches.length} failed: ${res.error}${res.failure ? ` (${res.failure})` : ""}`);
974
+ continue;
975
+ }
976
+ applied += res.applied;
977
+ ledgerFailed += res.ledgerFailed ?? 0;
978
+ updateLeft = Math.max(0, updateLeft - (res.updateUsed ?? 0));
979
+ failures.push(...(res.failures ?? []));
980
+ conflicts.push(...(res.conflicts ?? []));
981
+ skipped.push(...(res.skipped ?? []));
982
+ }
983
+ const degraded = skipped.length > 0 || failedBatches > 0;
984
+ // 一批都没成功才算整轮失败;只要有一批落地,如实记 degraded——这跟 deriveStatus
985
+ // 的契约一致(degraded 也算有产出)。
986
+ const allFailed = failedBatches === batches.length;
987
+ // 有一批没走通时整轮**绝不能报 noop**:noop 的语义是「看过,没冲突」,而踩了
988
+ // llm failed 的那批其实没看过。这是 #529 里最误导人的一处——23 轮失败期间
989
+ // applied 恒为 0,若再叠上 noop,运维只会以为库里没冲突。
990
+ const status = interrupted
991
+ ? "aborted"
992
+ : allFailed
993
+ ? "failed"
994
+ : applied > 0
995
+ ? (degraded ? "degraded" : "ok")
996
+ : failures.length
997
+ ? "failed"
998
+ : failedBatches > 0
999
+ ? "degraded"
1000
+ : "noop";
1001
+ // Issue #529(续排):「这一轮有没有产出」和「送出去的每一对是不是都落地了」是两件
1002
+ // 正交的事,status 只回答前者。全判 keep 的轮次 applied 恒为 0 → status 是 noop
1003
+ // (never ok),而它恰恰是 #529 里最该续排的场景;拿 status === "ok" 当判据会在
1004
+ // 这里把续排堵死。所以另给一个布尔,口径与下面台账的 unsettled 完全一致:failure
1005
+ // (写入失败)/ conflicts(CAS 冲突)/ skipped(非法被跳)任一出现过,就说明有对没
1006
+ // 落地,它下一轮还会被重新配出来,不算 clean。
1007
+ // Issue #529 review(CodeRabbit ①):台账写入失败同样算"没落地"——那一对没被记成
1008
+ // 已审,下一轮会重新入池,与上面 unsettled 是同一个道理。
1009
+ const clean =
1010
+ !interrupted &&
1011
+ failedBatches === 0 &&
1012
+ skipped.length === 0 &&
1013
+ failures.length === 0 &&
1014
+ conflicts.length === 0 &&
1015
+ ledgerFailed === 0;
1016
+ return {
1017
+ status,
1018
+ clean,
1019
+ // Issue #529 D:失败轮也要带 pairs/batches——此前只有成功路径带 pairs,85 条
1020
+ // 失败审计行全是"送了个寂寞",连送了多少对都查不到。
1021
+ pairs: selected.length,
1022
+ // #533:这一轮被账本压掉、没送 LLM 的候选对数——不记的话「pairs 变小了」
1023
+ // 分不清是没检测到还是已经裁决过。
1024
+ suppressed,
1025
+ batches: batches.length,
1026
+ ledgerSkipped,
1027
+ perRunSkipped,
1028
+ applied,
1029
+ // Issue #529 review(CodeRabbit ①):留痕失败的对数——clean 为 false 时要能分清是
1030
+ // 哪一类没落地(写入失败 / 非法被跳 / CAS 冲突 / 台账没记上)。
1031
+ ledgerFailed,
1032
+ failures,
1033
+ conflicts,
1034
+ // Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断
1035
+ // 是哪一对、因何被跳过(此前只进 logger.warn,splice 后就地消失)。
1036
+ skipped,
1037
+ // #641 shadow:分诊分布随相位结果透出(四个出口都要带——freeze/空池/无路由/
1038
+ // LLM 全路径,缺一个出口 shadow 期就有部分轮次查不到分布)。
1039
+ ...(ledgerTriage ? { ledgerTriage } : {})
1040
+ };
1041
+ }
1042
+
1043
+ /**
1044
+ * Phase 2 — archival demotion. No LLM: tiering is time-based, summaries are
1045
+ * truncations, and the full body is preserved in _full_content so nothing is
1046
+ * lost. Deterministic and cheap, so it runs even with no LLM route.
1047
+ */
1048
+ function phaseDemotion(service, config, logger, runId, signal = null) {
1049
+ const archiveDays = config.sleepArchiveDays ?? 30;
1050
+ const compressDays = config.sleepCompressDays ?? 90;
1051
+ const archiveCut = Date.now() - archiveDays * 86400000;
1052
+ const compressCut = Date.now() - compressDays * 86400000;
1053
+ const demoted = [];
1054
+ const archived = [];
1055
+ // 被判定为「该降级」但 demoteToSummary 实际没动手的条数。单独报出来:
1056
+ // 它正是「台账说降级了 N 条、实际一条没变」那个缺口,藏起来就等于没修。
1057
+ let demoteSkipped = 0;
1058
+ // 政策轴在这里尤其要紧:下面两行会真改归档面(setArchived / demoteToSummary)。
1059
+ // 把过滤放在循环之外、而不是在循环体里 continue:一旦将来有人在循环体里加
1060
+ // 副作用(现已有),漏掉那条 continue 就是静默改到被按下的行。
1061
+ // 外来行也在循环之外挡掉,同一个理由:它不经本方蒸馏,时间分层无权动它的原文或
1062
+ // 归档面;而下面两行动作(setArchived / demoteToSummary)不经 saveWithDedupe,
1063
+ // 汇流口那道闸门到这里已经管不到了。
1064
+ for (const m of withoutPaused(service, service.all().filter((m) => !isImportedRow(m)))) {
1065
+ if (signal?.aborted) break;
1066
+ if (m.archived || m.forgotten) continue;
1067
+ // 已降级的行不必再进判定链:demoteToSummary 见到 _full_content 会直接返回,
1068
+ // 但这行每轮仍会被选中、空转一次事务,再被下面无条件 push 进 demoted。
1069
+ // 提前挡掉既省事务,也让「这一轮到底动了什么」从源头算得准(#603 台账)。
1070
+ if (m._full_content) continue;
1071
+ const ref = m.last_accessed_at ?? m.created_at;
1072
+ if (!ref) continue;
1073
+ const t = new Date(ref).getTime();
1074
+ if (Number.isNaN(t)) continue;
1075
+ // v0.7.0 热联合判定(仅 heatEnabled 时启用;默认关则退回纯时间分层,
1076
+ // 与 v0.7.12 行为一致):时间窗之外再加两道保护闸——热度低于
1077
+ // sleepHeatThreshold 且 importance<5 才允许降级。λ=0 的免疫类型 heat 恒
1078
+ // 1.0 天然豁免(preference/pattern/summary 永不因 sleep 降级);importance
1079
+ // ≥5 的紧要记忆无论多冷都保留。`冷但重要` 与 `热但低值` 均不满足条件。
1080
+ const heatOn = config.heatEnabled !== false;
1081
+ if (heatOn) {
1082
+ const heat = computeHeat(m, Date.now(), config);
1083
+ const heatProtected = heat >= (config.sleepHeatThreshold ?? 0.05);
1084
+ const important = (m.importance ?? 0) >= 5;
1085
+ if (heatProtected || important) continue;
1086
+ }
1087
+ if (t < compressCut) {
1088
+ service.setArchived(m.id, true);
1089
+ archived.push(m.id);
1090
+ } else if (t < archiveCut) {
1091
+ // minRefTimeMs re-checks freshness inside demoteToSummary's transaction:
1092
+ // a recall touch landing after this snapshot must not demote the memory.
1093
+ //
1094
+ // 返回值必须看:demoteToSummary 在三种情况下返回 undefined(行不存在、
1095
+ // 已降级、快照后被 recall 摸热),而这里原来无条件 push,于是台账把没发生
1096
+ // 的降级记成发生了——live 库实测 12 轮里虚报 43%–99%(#603)。
1097
+ const updated = service.demoteToSummary(m.id, makeSummary(m), { minRefTimeMs: archiveCut });
1098
+ if (updated) demoted.push(m.id);
1099
+ else demoteSkipped++;
1100
+ }
1101
+ }
1102
+ return {
1103
+ status: demoted.length || archived.length ? "ok" : "noop",
1104
+ demoted,
1105
+ archived,
1106
+ demoteSkipped
1107
+ };
1108
+ }
1109
+
1110
+ /**
1111
+ * Phase 3 — pattern discovery. The LLM scans the most recent memories and
1112
+ * mints type=pattern entries (create actions) with evidence references.
1113
+ * The empty snapshot is intentional: create claims no existing id, so the
1114
+ * "every id claimed" invariant is trivially satisfied for pure-create lists.
1115
+ */
1116
+ async function phasePatterns(ctx, service, config, logger, runId, signal = null) {
1117
+ const language = langOf(config);
1118
+ const route = resolveSleepRoute(ctx, config, logger);
1119
+ if (!route) return { status: "skipped", reason: "no llm route" };
1120
+ const limit = config.sleepPatternMinMemories ?? 100;
1121
+ // #230:document 在查询层排除(excludeTypes)而不是截断后过滤——LIMIT 200
1122
+ // 先生效的话,指针行一多就会把普通记忆挤出扫描窗,池子饿到门槛以下。
1123
+ // 政策轴同理必须排在 `.slice(0, limit)` **之前**,否则被按下的行照样占掉
1124
+ // sleepPatternMinMemories 的名额,而它们既不会被扫、也不该挤掉别人(#471)。
1125
+ //
1126
+ // archived 这一轴不在下面出现,不是漏了:list() 默认就带 `archived = 0`
1127
+ // (store.js list 的 includeArchived 默认 false、onlyArchived 默认 false),
1128
+ // 旧写法那句 `!m.archived` 一直是重复过滤——查询层已经把它挡在外面。
1129
+ // 窗口至少等于本次要扫的名额:`sleepPatternMinMemories` 的合法上限是 1000
1130
+ // (src/config.js:612)。窗口若还是死的 200,那么把这个值配到 200 以上的人
1131
+ // **每一轮**都会触发下面那次无界重取,跟闸门按没按下无关——那是把这条修复
1132
+ // 从「按需」变成「常付」。
1133
+ const poolQuery = {
1134
+ limit: Math.max(200, limit), includeForgotten: false,
1135
+ // 三条政策轴全部下推到查询层:留在 LIMIT 之后过滤,等于让被丢掉的行为窗口占名额
1136
+ // (模式扫描池会因此饿到 sleepPatternMinMemories 以下而静默跳过)。同一取舍见
1137
+ // #230 的 document 轴与本文件 #471 的暂停轴,src/service.js:2247 那条注释记着前例。
1138
+ excludeTypes: ["document", "summary", "pattern"],
1139
+ excludeImported: true
1140
+ };
1141
+ const gather = (query) => withoutPaused(service, service.list(query));
1142
+ let pool = gather(poolQuery);
1143
+ // #515:暂停闸门要读 scope_state 并按「这一维有标注」匹配,下推进 SQL 就等于让判定
1144
+ // 长出第二份实现(issue 里写过为什么否掉,`*` 那一行是关键的不可下推处)。所以这里
1145
+ // 不动判定、只放宽取数:**只有窗口过滤后不够本次要扫的名额**才不带窗口重取一次。
1146
+ // 常态仍只付一次有界查询;多付的那一次恰好就是原本会静默跳过的那一次——
1147
+ // 症状从「库里没东西可扫」变成「扫了」,这才是这条闸门该有的行为。
1148
+ if (pool.length < limit) pool = gather({ ...poolQuery, limit: null });
1149
+ const memories = pool
1150
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
1151
+ .slice(0, limit);
1152
+ if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
1153
+ if (signal?.aborted) return { status: "aborted", reason: "user activity" };
1154
+ const listText = memories
1155
+ .map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
1156
+ .join("\n");
1157
+ const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
1158
+ const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
1159
+ let patternStreamFailure = "";
1160
+ const runPattern = (withEffort, tokens) => {
1161
+ patternStreamFailure = "";
1162
+ return auditedSleepLlm(ctx, service, config, logger, {
1163
+ triggerSource: "sleep",
1164
+ operationType: "sleep_pattern",
1165
+ modelId: `${route.provider}:${route.model}`,
1166
+ // 本轮扫描的记忆 id:与 dream 的 relatedMemoryIds 同义,供面板解析沉淀条数。
1167
+ relatedMemoryIds: memories.map((m) => m.id),
1168
+ streamError: () => patternStreamFailure,
1169
+ // 纯判据,同 conflict 阶段:不把解析结果存进闭包供控制流使用。
1170
+ auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output"),
1171
+ // 纯判据,与 auditError 同一条约定:重解析一次、不读闭包(审计关掉时那些闭包
1172
+ // 根本不会被赋值,读它会让「审计说了什么」依赖调用方走到了哪一行)。
1173
+ metadataFrom: (text) => (parseJsonArray(text) ? null : parseFailureMeta(text))
1174
+ }, (reportUsage) => streamText(ctx, {
1175
+ provider: route.provider,
1176
+ model: route.model,
1177
+ purpose: "sleep-pattern",
1178
+ maxTokens: tokens,
1179
+ ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
1180
+ messages: [
1181
+ { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
1182
+ { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1183
+ ]
1184
+ }, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
1185
+ };
1186
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runPattern(true, tokens), () => runPattern(false, tokens), () => patternStreamFailure), () => patternStreamFailure);
1187
+ if (text === undefined) {
1188
+ if (patternStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: pattern stream aborted or errored (${patternStreamFailure})`);
1189
+ return { status: "failed", error: "llm failed" };
1190
+ }
1191
+ const decisions = parseJsonArray(text);
1192
+ if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
1193
+ // Evidence ids are provenance refs; an LLM-fabricated id would mint a dead
1194
+ // ev:tag pointing nowhere. Intersect evidence with the scanned set so only
1195
+ // real memory references survive.
1196
+ const scannedIds = new Set(memories.map((m) => m.id));
1197
+ for (const d of decisions) {
1198
+ if (d?.action === "create" && Array.isArray(d.evidence)) {
1199
+ d.evidence = d.evidence.filter((id) => typeof id === "string" && scannedIds.has(id));
1200
+ }
1201
+ }
1202
+ const snapshot = new Map();
1203
+ const { ok, errors } = validateDecisions(decisions, snapshot, {
1204
+ maxCreatePerRun: maxPatterns
1205
+ });
1206
+ if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
1207
+ // 本相位的 create 落 source=sleep。来源从管道给而非从决策对象取——见
1208
+ // applyDecisions 的 @param config。
1209
+ // #512:runId 也必须给——这是 sleep 唯一的真实铸造路径,漏了它,铸造出来的
1210
+ // pattern 就只能追到「sleep 这条管道」,追不到「哪一轮 sleep」(正是 #512 要补的口子)。
1211
+ const { applied, merged, failures, conflicts } = applyDecisions(
1212
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId }
1213
+ );
1214
+ return {
1215
+ // A pattern that hit an existing same-title row is refreshed in place, not
1216
+ // minted. Counting it as `ok` with `applied: 0` claims a mint that did not
1217
+ // happen; report the refresh separately (#619 audit round 6).
1218
+ status: applied > 0 ? "ok" : (merged > 0 ? "refreshed" : "noop"),
1219
+ scanned: memories.length,
1220
+ applied,
1221
+ merged,
1222
+ failures,
1223
+ conflicts
1224
+ };
1225
+ }
1226
+
1227
+ /**
1228
+ * Phase 5 — affective attribution (issue #619). The LLM reads recent memories and
1229
+ * names emotions, but the shape of what it may write is fixed here, not by the
1230
+ * prompt: an emotion tied to **one occasion** becomes an `affective_context`
1231
+ * relation carrying the attribution on the edge; only an emotion that recurs
1232
+ * across sleepAffectiveTraitMinSessions **independent sessions** may be minted as
1233
+ * a standing trait (a type=pattern memory, through the same
1234
+ * validateDecisions/applyDecisions channel every other mint uses).
1235
+ *
1236
+ * The whole point is the guardrail, so it lives in code and not in the prompt:
1237
+ * - the model may *claim* trait, but the claim only survives if provenance shows
1238
+ * enough distinct sessions. A single session is downgraded to occasion, never
1239
+ * dropped silently — dropped claims are counted and reported.
1240
+ * - person/target must resolve to entities that already exist; an emotion about
1241
+ * an unknown name is not evidence, it is a hallucination.
1242
+ * - evidence ids must be inside the scanned pool (same rule phasePatterns uses).
1243
+ * Off by default (sleepAffectiveEnabled) and a child of sleepModeEnabled: no
1244
+ * route, no entities, or the flag off all mean "skip", never "run anyway".
1245
+ */
1246
+ async function phaseAffective(ctx, service, config, logger, runId, signal = null) {
1247
+ if (config.sleepAffectiveEnabled !== true) return { status: "skipped", reason: "disabled" };
1248
+ const route = resolveSleepRoute(ctx, config, logger);
1249
+ if (!route) return { status: "skipped", reason: "no llm route" };
1250
+ const entities = service.listEntities({ limit: 1000 }) ?? [];
1251
+ if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
1252
+ const limit = config.sleepPatternMinMemories ?? 100;
1253
+ const poolQuery = {
1254
+ limit: Math.max(200, limit),
1255
+ includeForgotten: false,
1256
+ excludeTypes: ["document", "summary", "pattern"],
1257
+ excludeImported: true
1258
+ };
1259
+ const gather = (query) => withoutPaused(service, service.list(query));
1260
+ let pool = gather(poolQuery);
1261
+ if (pool.length < limit) pool = gather({ ...poolQuery, limit: null });
1262
+ const memories = [...pool].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0, limit);
1263
+ if (memories.length === 0) return { status: "skipped", reason: "no memories" };
1264
+ const scannedIds = new Set(memories.map((m) => m.id));
1265
+ const byName = new Map();
1266
+ for (const e of entities) byName.set(e.name.toLowerCase(), e);
1267
+ const traitMinSessions = config.sleepAffectiveTraitMinSessions ?? 3;
1268
+ const maxRelations = config.sleepAffectiveMaxRelationsPerRun ?? 10;
1269
+ const maxTraits = config.sleepMaxPatternPerRun ?? 3;
1270
+ const language = langOf(config).startsWith("zh") ? "zh" : "en";
1271
+ const entityNames = entities.map((e) => e.name).slice(0, 200).join(", ");
1272
+ const listText = memories.map((m) => `id=${m.id} | time=${m.updated_at ?? ""} | type=${m.type} | title=${m.title} | content=${m.content}`).join("\n");
1273
+ let streamFailure;
1274
+ const runAffective = (tokens) => auditedSleepLlm(
1275
+ ctx, service, config, logger,
1276
+ {
1277
+ triggerSource: "sleep",
1278
+ operationType: "sleep_affective",
1279
+ modelId: `${route.provider}:${route.model}`,
1280
+ relatedMemoryIds: memories.map((m) => m.id),
1281
+ streamError: () => streamFailure,
1282
+ auditError: (out) => (parseJsonArray(out) ? null : "no json array in llm output"),
1283
+ metadataFrom: (out) => (parseJsonArray(out) ? null : parseFailureMeta(out))
1284
+ },
1285
+ (reportUsage) => streamText(ctx, {
1286
+ provider: route.provider,
1287
+ model: route.model,
1288
+ purpose: "sleep-affective",
1289
+ maxTokens: tokens,
1290
+ messages: [
1291
+ {
1292
+ role: "system",
1293
+ content: [{ type: "text", text: STR.prompts.affective[language].replaceAll("{{MIN_SESSIONS}}", String(traitMinSessions)) }],
1294
+ source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
1295
+ },
1296
+ { role: "user", content: [{ type: "text", text: fenceListBody(language, `${entityNames}\n---\n${listText}`) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1297
+ ]
1298
+ }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); })
1299
+ );
1300
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, runAffective, () => streamFailure);
1301
+ if (text === undefined) {
1302
+ // #619 可见化 A:失败原因不能只写成 "llm failed"。conflict 相位
1303
+ // (src/dream/sleep.js 的 adjudicateBatch 调用处)早就把 stream 层的原文写进
1304
+ // warn 并挂在返回值的 `failure` 上,理由写在它旁边的注释里:只写 "llm failed"
1305
+ // 的话,运维分不清是上下文超限、模型拒收还是用户活动打断。affective 相位漏了
1306
+ // 这一步,实测 2026-11-10T16:09 那一轮审计行只有 {"status":"failed",
1307
+ // "error":"llm failed"},502 原文只能去 llm_audit_logs 里捞。
1308
+ // 相位自身不重试 5xx(withMaxTokensFallback 只处理 max_tokens 被拒、
1309
+ // withEffortFallback 只处理 effort 被拒),所以这里记的就是一次性失败的真因。
1310
+ if (streamFailure) logger?.warn?.(`dsh-mneme sleep: affective stream aborted or errored (${streamFailure})`);
1311
+ return { status: "failed", error: "llm failed", failure: streamFailure || undefined };
1312
+ }
1313
+ const items = parseJsonArray(text) ?? [];
1314
+ if (items.length === 0) return { status: "skipped", reason: "no attributions found" };
1315
+ const relations = [];
1316
+ const merges = [];
1317
+ const traits = [];
1318
+ const dropped = [];
1319
+ let downgradedCount = 0;
1320
+ const sessionCache = new Map();
1321
+ const sessionsFor = (memoryIds) => {
1322
+ const out = new Set();
1323
+ for (const id of memoryIds) {
1324
+ if (!sessionCache.has(id)) {
1325
+ let rows = [];
1326
+ try {
1327
+ rows = service.provenanceFor?.(id) ?? [];
1328
+ } catch {
1329
+ rows = [];
1330
+ }
1331
+ sessionCache.set(id, rows.map((r) => r.session_id).filter((s) => typeof s === "string" && s));
1332
+ }
1333
+ for (const s of sessionCache.get(id)) out.add(s);
1334
+ }
1335
+ return out;
1336
+ };
1337
+ for (const item of items) {
1338
+ if (signal?.aborted) break;
1339
+ if (!item || typeof item !== "object") continue;
1340
+ const person = byName.get(String(item.person ?? "").toLowerCase());
1341
+ const target = byName.get(String(item.target ?? "").toLowerCase());
1342
+ if (!person || !target || person.id === target.id) {
1343
+ dropped.push({ reason: "unknown entity", person: item.person, target: item.target });
1344
+ continue;
1345
+ }
1346
+ const evidence = (Array.isArray(item.evidence) ? item.evidence : []).filter((id) => scannedIds.has(id));
1347
+ if (evidence.length === 0) {
1348
+ dropped.push({ reason: "no real evidence", person: item.person, target: item.target });
1349
+ continue;
1350
+ }
1351
+ const emotion = String(item.emotion ?? "").trim();
1352
+ if (!emotion) {
1353
+ dropped.push({ reason: "no emotion", person: item.person, target: item.target });
1354
+ continue;
1355
+ }
1356
+ // The guardrail: a claimed trait is checked against provenance, and a claim
1357
+ // that does not survive is demoted — not discarded. Demoted claims are the
1358
+ // interesting number: they are exactly the mis-attributions the probe hunts.
1359
+ const claimedTrait = String(item.attribution ?? "").toLowerCase() === "trait";
1360
+ const sessions = sessionsFor(evidence);
1361
+ const downgraded = claimedTrait && sessions.size < traitMinSessions;
1362
+ const attribution = claimedTrait && !downgraded ? "trait" : "occasion";
1363
+ const record = {
1364
+ person: person.name,
1365
+ target: target.name,
1366
+ emotion,
1367
+ attribution,
1368
+ evidence,
1369
+ sessionCount: sessions.size,
1370
+ why: String(item.why ?? "").slice(0, 200),
1371
+ ...(downgraded ? { downgradedFrom: "trait" } : {})
1372
+ };
1373
+ if (downgraded) downgradedCount += 1;
1374
+ if (attribution === "trait") {
1375
+ traits.push(record);
1376
+ continue;
1377
+ }
1378
+ // #619 / Ephor: entity_relations 没有 UNIQUE 约束,saveRelation 每次
1379
+ // randomUUID() 新插一条。同一个 (person, target, 场合) 跑第二轮就会得到
1380
+ // 第二条一模一样的边——而探针会拿同一批段反复回放,重复边会让 sessionCount
1381
+ // 看起来比真实场合数多,主指标直接失真。
1382
+ //
1383
+ // 「同一场合」不能拿 evidence 有没有交集来判。两次判据都基于证据列表,
1384
+ // 而证据列表既不是场合本身、也不稳定:
1385
+ // ① 模型给 evidence 的顺序不定(parseJsonArray 也不排序),[m1,m2] 与
1386
+ // [m2,m1] 是同一场合的两种写法;
1387
+ // ② 同一会话里的两条不同记忆各说一句「他很焦虑」,证据天然不相交,可
1388
+ // 它们是同一个场合——那时该合并成一条边,而不是各写一条把边数抬高;
1389
+ // ③ 反过来,第一轮看到 1 条证据、第二轮攒到 2 条(含新会话),若判成重复
1390
+ // 就整条丢掉,边上 sessionCount 永远停在第一次那个数,将来读归因覆盖率
1391
+ // 会一直读到 1。
1392
+ // 所以键落在会话层:同一 (person, target, emotion) 且两边的会话集有交集,
1393
+ // 就是同一场合。命中时把 evidence 并集写回、按并集重算 sessionCount,而不是
1394
+ // 丢弃——被扩充的边记进 merged,让「这一轮其实补了证据」在返回值里看得见。
1395
+ // 这一步在 cap 之前:合并既有边不新增行,不该占名额。
1396
+ const currentSessions = sessions;
1397
+ const priorEdges = (service.getRelations(person.id) ?? []).filter((r) =>
1398
+ r.relation_type === "affective_context" && r.to_entity === target.id
1399
+ );
1400
+ let mergedInto = null;
1401
+ for (const edge of priorEdges) {
1402
+ const meta = edge.metadata && typeof edge.metadata === "object" ? edge.metadata : null;
1403
+ const priorEmotion = String(meta?.emotion ?? "").trim();
1404
+ if (priorEmotion !== emotion) continue;
1405
+ const priorEvidence = Array.isArray(meta?.evidence)
1406
+ ? meta.evidence
1407
+ : (edge.memory_id ? [edge.memory_id] : []);
1408
+ const priorSessions = sessionsFor(priorEvidence);
1409
+ // "Same occasion" is judged on session keys first. Only when either side
1410
+ // has no session key at all does it fall back to an evidence intersection:
1411
+ // memory_save writes memories without provenance (1671 of 8517 active rows
1412
+ // in the live database have none), so sessionsFor returns an empty set and
1413
+ // a session-only test would write the same edge again on every sleep pass,
1414
+ // inflating sessionCount past the real number of occasions.
1415
+ const sameOccasion = [...priorSessions].some((s) => currentSessions.has(s))
1416
+ || ((priorSessions.size === 0 || currentSessions.size === 0)
1417
+ && priorEvidence.some((id) => evidence.includes(id)));
1418
+ if (!sameOccasion) continue;
1419
+ mergedInto = { edge, priorEvidence, priorSessions };
1420
+ break;
1421
+ }
1422
+ if (mergedInto) {
1423
+ const { edge, priorEvidence, priorSessions } = mergedInto;
1424
+ const unionEvidence = [...new Set([...priorEvidence, ...evidence])];
1425
+ const unionSessions = new Set([...priorSessions, ...currentSessions]);
1426
+ const mergedMetadata = {
1427
+ ...(edge.metadata && typeof edge.metadata === "object" ? edge.metadata : {}),
1428
+ emotion,
1429
+ // 保留首个 attribution,但降级痕迹一旦有就留着——合并不能把
1430
+ // 「它曾被判成 trait 又被降级」这段历史抹掉。
1431
+ attribution: (edge.metadata && edge.metadata.attribution) || attribution,
1432
+ evidence: unionEvidence,
1433
+ sessionCount: unionSessions.size,
1434
+ lastMergedRunId: runId
1435
+ };
1436
+ if (record.downgradedFrom && !mergedMetadata.downgradedFrom) {
1437
+ mergedMetadata.downgradedFrom = record.downgradedFrom;
1438
+ }
1439
+ try {
1440
+ service.updateRelationMetadata?.(edge.id, mergedMetadata);
1441
+ merges.push({
1442
+ id: edge.id,
1443
+ from: person.name,
1444
+ to: target.name,
1445
+ emotion,
1446
+ evidence: unionEvidence.length,
1447
+ sessionCount: unionSessions.size
1448
+ });
1449
+ } catch (error) {
1450
+ logger?.warn?.(`dsh-mneme sleep: affective relation merge ${person.name}/${target.name} failed: ${String(error)}`);
1451
+ }
1452
+ continue;
1453
+ }
1454
+ if (relations.length >= maxRelations) {
1455
+ dropped.push({ reason: "relation cap", person: person.name, target: target.name });
1456
+ continue;
1457
+ }
1458
+ try {
1459
+ const saved = service.saveRelation({
1460
+ from_entity: person.id,
1461
+ to_entity: target.id,
1462
+ relation_type: "affective_context",
1463
+ memory_id: evidence[0],
1464
+ source: "llm",
1465
+ metadata: {
1466
+ emotion,
1467
+ attribution,
1468
+ why: record.why,
1469
+ evidence,
1470
+ sessionCount: sessions.size,
1471
+ ...(record.downgradedFrom ? { downgradedFrom: record.downgradedFrom } : {}),
1472
+ model: `${route.provider}:${route.model}`,
1473
+ runId
1474
+ }
1475
+ });
1476
+ relations.push({ id: saved?.id, from: person.name, to: target.name, emotion, attribution, ...(record.downgradedFrom ? { downgradedFrom: record.downgradedFrom } : {}) });
1477
+ } catch (error) {
1478
+ logger?.warn?.(`dsh-mneme sleep: affective relation ${person.name}/${target.name} failed: ${String(error)}`);
1479
+ }
1480
+ }
1481
+ // Traits ride the standard decision channel so they inherit every existing cap
1482
+ // and the #512 mint ledger. No new write path is introduced for them.
1483
+ let applied = 0;
1484
+ let refreshedTraits = 0;
1485
+ let failures = [];
1486
+ let conflicts = [];
1487
+ let ok = true;
1488
+ // Candidates beyond the cap are otherwise neither minted, demoted, nor
1489
+ // recorded: they simply vanish, while `traits` still counts them and a reader
1490
+ // concludes they were handled. Record them the same way `relation cap` records
1491
+ // edges, so "everything that disappears is accounted for" holds for traits.
1492
+ for (const t of traits.slice(maxTraits)) {
1493
+ dropped.push({ reason: "trait cap", person: t.person, target: t.target });
1494
+ }
1495
+ if (traits.length > 0) {
1496
+ const decisions = traits.slice(0, maxTraits).map((t) => ({
1497
+ action: "create",
1498
+ type: "pattern",
1499
+ title: STR.affectiveTrait.title[language](t),
1500
+ content: STR.affectiveTrait.content[language](t),
1501
+ importance: 3,
1502
+ evidence: t.evidence
1503
+ }));
1504
+ const snapshot = new Map();
1505
+ const validated = validateDecisions(decisions, snapshot, { maxCreatePerRun: maxTraits, allowedActions: ["create"] });
1506
+ ok = validated.ok;
1507
+ if (ok) {
1508
+ const result = applyDecisions(decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId });
1509
+ applied = result.applied;
1510
+ failures = result.failures;
1511
+ conflicts = result.conflicts;
1512
+ // A trait whose pattern was refreshed in place (same title, same row) is
1513
+ // not a new conclusion. Keep it out of `applied` and report it as a
1514
+ // refresh, otherwise two different targets would read as two minted
1515
+ // patterns while only one row exists (#619 audit round 6).
1516
+ refreshedTraits = result.merged ?? 0;
1517
+ // Ocasion edges observed before a disposition was confirmed do not get
1518
+ // rewritten — an edge records "this occasion was observed", which stays
1519
+ // true. But the edge and the pattern would then answer "how many
1520
+ // occasions" differently (edge.sessionCount vs pattern evidence), and a
1521
+ // coverage query reading both would have no way to tell they describe the
1522
+ // same thing. Mark them instead of mutating the count: the edge still
1523
+ // means one observed occasion, and `promotedToTrait` says that occasion is
1524
+ // now part of a confirmed disposition.
1525
+ for (const trait of traits.slice(0, maxTraits)) {
1526
+ const person = byName.get(String(trait.person ?? "").toLowerCase());
1527
+ const target = byName.get(String(trait.target ?? "").toLowerCase());
1528
+ if (!person || !target || person.id === target.id) continue;
1529
+ for (const edge of service.getRelations(person.id) ?? []) {
1530
+ if (edge.relation_type !== "affective_context" || edge.to_entity !== target.id) continue;
1531
+ const meta = edge.metadata && typeof edge.metadata === "object" ? edge.metadata : null;
1532
+ if (String(meta?.emotion ?? "").trim() !== trait.emotion) continue;
1533
+ if (meta?.promotedToTrait === true) continue;
1534
+ try {
1535
+ service.updateRelationMetadata?.(edge.id, {
1536
+ ...meta,
1537
+ promotedToTrait: true,
1538
+ traitSessionCount: trait.sessionCount
1539
+ });
1540
+ } catch (error) {
1541
+ logger?.warn?.(`dsh-mneme sleep: marking occasion ${edge.id} as promoted failed: ${String(error)}`);
1542
+ }
1543
+ }
1544
+ }
1545
+ }
1546
+ }
1547
+ // Aborting mid-pass is not "did nothing" and not "all done". Even when nothing
1548
+ // was written yet, "noop" would claim the whole pool was examined and found
1549
+ // empty — the loop broke at an arbitrary index, so it examined an unknown
1550
+ // prefix. Report it as aborted either way.
1551
+ if (signal?.aborted) {
1552
+ return {
1553
+ status: "aborted",
1554
+ reason: "user activity",
1555
+ scanned: memories.length,
1556
+ relations: relations.length,
1557
+ edgeWrites: relations.length,
1558
+ merged: merges.length,
1559
+ mergedList: merges,
1560
+ applied,
1561
+ refreshedTraits,
1562
+ traits: traits.length,
1563
+ downgraded: downgradedCount,
1564
+ dropped
1565
+ };
1566
+ }
1567
+ if (!ok) {
1568
+ return {
1569
+ status: "failed",
1570
+ error: "trait decisions rejected by validation",
1571
+ scanned: memories.length,
1572
+ relations: relations.length,
1573
+ edgeWrites: relations.length,
1574
+ merged: merges.length,
1575
+ mergedList: merges,
1576
+ applied: 0,
1577
+ refreshedTraits,
1578
+ traits: traits.length,
1579
+ downgraded: downgradedCount,
1580
+ dropped
1581
+ };
1582
+ }
1583
+ return {
1584
+ // "refreshed" is a real write that minted nothing: the trait pattern already
1585
+ // existed under the same title and was rewritten in place. Calling that
1586
+ // "noop" would say the phase examined the pool and found nothing to do.
1587
+ status: relations.length + merges.length + applied > 0 ? "ok" : (refreshedTraits > 0 ? "refreshed" : "noop"),
1588
+ scanned: memories.length,
1589
+ relations: relations.length,
1590
+ merged: merges.length,
1591
+ // #619: edges are real writes but not "applied decisions", so the receipt
1592
+ // would read `:0` for a run that did write. Count them separately instead
1593
+ // of folding them into `applied` (that would make the two numbers mean
1594
+ // different things depending on which phase produced them).
1595
+ edgeWrites: relations.length,
1596
+ mergedList: merges,
1597
+ applied,
1598
+ refreshedTraits,
1599
+ failures,
1600
+ conflicts,
1601
+ traits: traits.length,
1602
+ downgraded: downgradedCount,
1603
+ dropped
1604
+ };
1605
+ }
1606
+
1607
+ /**
1608
+ * Phase 4 — entity relation completion. Detects orphan entities (zero
1609
+ * relations) and completes implied relations from memory co-occurrence:
1610
+ * entities named in the same memory → related_to; container kinds
1611
+ * (project/module) → part_of; tech-ish pairs → depends_on. Deterministic,
1612
+ * no LLM — cheap, so it runs even without a route. saveRelation is
1613
+ * bookkeeping (no write hook), so it never re-triggers the scheduler.
1614
+ */
1615
+ function inferRelationType(a, b) {
1616
+ if ((a.type === "project" || a.type === "module") && a.type !== b.type) return "part_of";
1617
+ if ((b.type === "project" || b.type === "module") && b.type !== a.type) return "part_of";
1618
+ if (/npm|plugin|api|sdk|lib|framework|package|deps?|build/i.test(`${a.name} ${b.name}`)) return "depends_on";
1619
+ return "related_to";
1620
+ }
1621
+
1622
+ function phaseRelations(service, config, logger, runId, signal = null) {
1623
+ const entities = service.listEntities({ limit: 1000 }) ?? [];
1624
+ if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
1625
+ const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
1626
+ if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
1627
+ const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten));
1628
+ const seen = new Set();
1629
+ const related = [];
1630
+ const MAX_RELATIONS_PER_ORPHAN = 3;
1631
+ for (const o of orphans) {
1632
+ if (signal?.aborted) break;
1633
+ let made = 0;
1634
+ for (const m of memories) {
1635
+ if (signal?.aborted || made >= MAX_RELATIONS_PER_ORPHAN) break;
1636
+ const text = `${m.title ?? ""} ${m.content ?? ""}`;
1637
+ if (!text.includes(o.name)) continue;
1638
+ for (const other of entities) {
1639
+ if (other.id === o.id || other.name === o.name) continue;
1640
+ const key = [o.id, other.id].sort().join("|");
1641
+ if (seen.has(key)) continue;
1642
+ if (!text.includes(other.name)) continue;
1643
+ const relationType = inferRelationType(o, other);
1644
+ try {
1645
+ service.saveRelation({ from_entity: o.id, to_entity: other.id, relation_type: relationType, memory_id: m.id, metadata: { source: "sleep_relation_completion" } });
1646
+ seen.add(key);
1647
+ related.push({ from: o.id, to: other.id, type: relationType });
1648
+ made++;
1649
+ } catch (error) {
1650
+ logger?.warn?.(`dsh-mneme sleep: relation ${o.id}/${other.id} failed: ${String(error)}`);
1651
+ }
1652
+ }
1653
+ }
1654
+ }
1655
+ return {
1656
+ status: related.length > 0 ? "ok" : "noop",
1657
+ orphanCount: orphans.length,
1658
+ related
1659
+ };
1660
+ }
1661
+
1662
+ // ---------------------------------------------------------------- run
1663
+
1664
+ function deriveStatus(phases) {
1665
+ const list = Object.values(phases);
1666
+ if (list.length === 0) return "noop";
1667
+ const anyError = list.some((p) => p.status === "failed" || p.status === "error");
1668
+ // Issue #126 review(Copilot):phase 自身可以是 degraded(合法子集已应用但有条目被
1669
+ // 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
1670
+ // #619:`refreshed` 也是一次真实写入(同题行原地刷新),只是没铸出新条目。把它
1671
+ // 排除在 anyWork 之外,整轮会被记成 noop,审计行又变回「跑了、什么都没干」。
1672
+ const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded" || p.status === "refreshed");
1673
+ const anyDegraded = list.some((p) => p.status === "degraded");
1674
+ // Issue #529:被打断(用户恢复活动)既不是失败也不是"没活干"。相位层已经用
1675
+ // aborted 区分了这两种,整轮层若回落到 noop,审计行就又把"没跑完"记成"跑过、
1676
+ // 没什么事"——所以这里也给它一个自己的名字。只在没有任何产出时生效:真的落了
1677
+ // 东西就是 ok/degraded,真的报错就是 failed,二者都比 aborted 值得优先播报。
1678
+ const anyAborted = list.some((p) => p.status === "aborted");
1679
+ if (anyDegraded) return "degraded";
1680
+ if (anyWork && anyError) return "degraded";
1681
+ if (anyError) return "failed";
1682
+ if (anyWork) return "ok";
1683
+ if (anyAborted) return "aborted";
1684
+ return "noop";
1685
+ }
1686
+
1687
+ /**
1688
+ * Run one full sleep cycle. Best-effort across all phases; writes a
1689
+ * run_type='sleep' audit row (same dream_runs table) so sleep activity is
1690
+ * observable alongside consolidation runs.
1691
+ */
1692
+ export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
1693
+ const runId = randomUUID();
1694
+ // Issue #89:开跑时刻——审计行 created_at 的基准(= 冷却种子),run 耗时
1695
+ // (LLM 各 phase)不应计入重启后的冷却窗口。
1696
+ const sleepStartedAt = Date.now();
1697
+ const phases = {};
1698
+ const attempt = async (name, fn) => {
1699
+ if (signal?.aborted) return; // user resumed activity — stop before next phase
1700
+ try {
1701
+ phases[name] = await fn();
1702
+ } catch (error) {
1703
+ phases[name] = { status: "failed", error: error?.message ?? String(error) };
1704
+ logger?.warn?.(`dsh-mneme sleep: ${name} phase failed: ${error?.message ?? error}`);
1705
+ }
1706
+ };
1707
+ await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
1708
+ await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
1709
+ await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
1710
+ await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
1711
+ // #619:情感归因排在 relations 之后——它要读实体与 provenance,先让孤立实体的
1712
+ // 结构边补完,再往上挂情绪轴;两相写的是不同 relation_type,不抢同一批对。
1713
+ await attempt("affective", () => phaseAffective(ctx, service, config, logger, runId, signal));
1714
+
1715
+ const status = deriveStatus(phases);
1716
+ const route = resolveSleepRoute(ctx, config, logger);
1717
+ // #619: a phase may write rows that are not "applied decisions" — the
1718
+ // affective phase inserts relation edges directly — and it may also extend an
1719
+ // existing edge instead of inserting one. Both are store writes, and the
1720
+ // receipt has to answer "did this run change anything", so both count. A run
1721
+ // whose only effect was extending an edge would otherwise read `:0`, i.e.
1722
+ // "ran and did nothing", which is exactly what a probe must never see.
1723
+ //
1724
+ // The per-kind numbers stay apart in `phases` (`applied` / `edgeWrites` /
1725
+ // `merged` / `refreshedTraits`); the receipt is a single write count on
1726
+ // purpose, because buildReceipt/parseReceipt are an eight-field contract
1727
+ // shared with organize.js and four test files. Detail lives in
1728
+ // dream_runs.phases JSON, not in the receipt.
1729
+ //
1730
+ // #619 / Ephor 第七轮 ①:`refreshedTraits` 必须计入。它是「同一标题的 pattern
1731
+ // 被原地重写」的条数——库确实变了,但没有铸新条目,所以它既不在 `applied` 里
1732
+ // (那会把刷新谎报成铸造),也不在 `merged` 里(affective 相位的 `merged` 是
1733
+ // **边**合并数 `merges.length`,同名不同义)。漏掉它的后果实测过:第二轮
1734
+ // `phase.status=refreshed`、`refreshedTraits=1`,整轮 receipt 第 7 段仍是 0,
1735
+ // 于是「相位说改了、整轮说没改」,正是上一轮刚修掉的「改了库读成 0」形态。
1736
+ const totalApplied = Object.values(phases).reduce(
1737
+ (n, p) =>
1738
+ n +
1739
+ (Number.isInteger(p?.applied) ? p.applied : 0) +
1740
+ (Number.isInteger(p?.edgeWrites) ? p.edgeWrites : 0) +
1741
+ (Number.isInteger(p?.merged) ? p.merged : 0) +
1742
+ (Number.isInteger(p?.refreshedTraits) ? p.refreshedTraits : 0),
1743
+ 0
1744
+ );
1745
+ // Issue #126(与 #104 同向):把各 phase 的跳过明细汇总成一行审计数据,带 phase
1746
+ // 名(同一次 run 里 conflicts 与 patterns 都可能产出 skipped)。全空时写 NULL,
1747
+ // 不落空数组——审计行只记真实发生过的跳过。
1748
+ const skippedDetail = Object.entries(phases).flatMap(([phase, p]) =>
1749
+ Array.isArray(p?.skipped) ? p.skipped.map((s) => ({ phase, ...s })) : []
1750
+ );
1751
+ const snapshotHash = createHash("sha256").update(JSON.stringify(phases)).digest("hex");
1752
+ const receipt = buildReceipt({
1753
+ runId,
1754
+ status,
1755
+ snapshotHash,
1756
+ inputCount: 0,
1757
+ applied: totalApplied,
1758
+ summaryStored: false
1759
+ });
1760
+ try {
1761
+ service.saveDreamRun({
1762
+ id: runId,
1763
+ // Issue #89:审计行记开跑时刻而非完成时刻——run 耗时(含 LLM 各 phase)
1764
+ // 不该计入重启后的 sleep 冷却种子。
1765
+ created_at: new Date(sleepStartedAt).toISOString(),
1766
+ status,
1767
+ provider: route?.provider,
1768
+ model: route?.model,
1769
+ snapshot_hash: snapshotHash,
1770
+ input_count: 0,
1771
+ input: null,
1772
+ decisions: phases,
1773
+ outcome: phases,
1774
+ applied: totalApplied,
1775
+ summary_stored: false,
1776
+ receipt,
1777
+ policy_epoch: config.policyEpoch ?? 0,
1778
+ run_type: "sleep",
1779
+ skipped: skippedDetail.length ? skippedDetail : undefined
1780
+ });
1781
+ } catch (error) {
1782
+ logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`);
1783
+ }
1784
+ // Issue #529(续排):给调度器的续排判据。两个条件缺一不可——
1785
+ // · 本轮**被预算截断**(conflict 相位报告还有没进这一轮的对):说明池子没清空;
1786
+ // · 本轮**clean**:送出去的每一对都落地了,不是为了追一个永远失败的批次空转。
1787
+ // `clean` 缺失时(该相位被 attempt 吞成 failed、或配置让它早退)按"不干净"算,
1788
+ // 宁可少追一轮,也不要在失败状态下把间隔压到静默窗口。
1789
+ const conflictPhase = phases.conflicts;
1790
+ const backlog = Number(conflictPhase?.perRunSkipped ?? 0) > 0;
1791
+ // clean 与 catchup 分开给:前者是"这一轮的活全落地了",后者是"池子没清空 + 前者"。
1792
+ // 合成一个字段会让"干净但没 backlog"看起来像出过问题,排查时反着读。
1793
+ const clean = conflictPhase?.clean === true;
1794
+ const catchup = backlog && clean;
1795
+ return { ok: status === "ok" || status === "degraded", status, clean, catchup, backlog, runId, phases, receipt };
1796
+ }
1797
+
1798
+ // ---------------------------------------------------------------- scheduler
1799
+
1800
+ /**
1801
+ * Idle-triggered scheduler. DSH plugins have no resident cron, so a sleep run
1802
+ * fires when: sleep is enabled, the store has been quiet for sleepIdleMinutes,
1803
+ * and the previous run is older than sleepMinIntervalHours. noteWrite() is
1804
+ * called on every store write and (re)arms an idle timer that re-checks at the
1805
+ * exact moment the idle window elapses — no polling, no cron.
1806
+ *
1807
+ * A `now` clock can be injected for tests; it defaults to Date.now.
1808
+ */
1809
+ export function createSleepScheduler({
1810
+ service,
1811
+ config,
1812
+ logger,
1813
+ onRun = null,
1814
+ now = () => Date.now(),
1815
+ setTimeoutFn = setTimeout,
1816
+ clearTimeoutFn = clearTimeout,
1817
+ // Issue #89 同族:冷却时刻由调用方从 dream_runs 审计表恢复(run_type='sleep')——
1818
+ // 内存变量进程重启即归零,冷却闸对新实例放行 → 重启后立即进入 sleep 冷却盲区。
1819
+ // 测试注入 0(默认)即保持旧行为。
1820
+ lastRunAtSeed = 0
1821
+ }) {
1822
+ let lastWriteAt = now();
1823
+ let lastRunAt = lastRunAtSeed;
1824
+ let running = false;
1825
+ let disposed = false;
1826
+ let idleTimer = null;
1827
+ let sleepAbort = null;
1828
+ // Issue #529(C 项):同一进程内的连续失败计数。失败(run 报 status="failed"
1829
+ // 或抛错)+1,其余一律清零(ok/degraded/noop——noop 说明 LLM 通路是好的,
1830
+ // 只是没活干,不该被当成失败拖着退避)。纯内存变量、宿主重启归零:跨重启的
1831
+ // 冷却本来就由 lastRunAtSeed(#89/#291)负责,两者不重叠。
1832
+ let consecutiveFailures = 0;
1833
+ // Issue #529(续排):上一轮报告"还有对没进这一轮、且送出去的全落地了"——池子没清空,
1834
+ // 该接着追而不是等满一个最小间隔。只由 maybeSchedule 按 onRun 的返回置位,跑完一轮就
1835
+ // 重新判:池子追干净或某一轮不 clean 时自动落回 false,不需要额外的复位路径。
1836
+ let catchupWanted = false;
1837
+ // Issue #529(续排):进程内续排连击数。给"池子在长、每轮都 clean 但永远追不完"这类
1838
+ // 库留一道刹车——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟
1839
+ // 一轮。超过上限就退回正常间隔,等下一个静默窗口重新起算。
1840
+ let catchupStreak = 0;
1841
+
1842
+ // Issue #529:有效最小间隔 = 基数 × 2^连续失败数。关闭或尚无失败时逐字节
1843
+ // 返回基数(默认关 = 行为与现状一致)。封顶取 max(基数, 24h) 而不是硬 24h:
1844
+ // 指数再大也不会把用户自己配的大基数(允许到 168h)压小。
1845
+ function effectiveMinIntervalMs() {
1846
+ // Issue #529(续排):还有 backlog 时把冷却项按 0 处理,让表落在**静默窗口**而不是
1847
+ // 最小间隔上。不新增配置键:本轮的账本/更新写入会重置 lastWriteAt,下一轮自然至少
1848
+ // 等一个 sleepIdleMinutes,这本身就是节流;再开一个键要连带 settings.js 白名单与
1849
+ // test/api.test.js 的旗标计数锁,不值得为一件事动三个文件。
1850
+ // 与连败互斥(同 issue 的第三条):连续失败 > 0 时根本不追——两个逻辑抢同一只闹钟
1851
+ // 最容易出"重排的是短的、判定的是长的"这种空转,互斥最省事。
1852
+ if (catchupWanted && consecutiveFailures <= 0) return 0;
1853
+ const base = (config.sleepMinIntervalHours ?? 8) * 3600000;
1854
+ if (config.sleepFailureBackoff !== true || consecutiveFailures <= 0) return base;
1855
+ const cap = Math.max(base, FAILURE_BACKOFF_CAP_MS);
1856
+ return Math.min(base * 2 ** consecutiveFailures, cap);
1857
+ }
1858
+
1859
+ function armIdleTimer() {
1860
+ if (disposed || idleTimer) return;
1861
+ if (config.sleepModeEnabled !== true) return;
1862
+ const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
1863
+ const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
1864
+ idleTimer = setTimeoutFn(async () => {
1865
+ idleTimer = null;
1866
+ // Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期"
1867
+ // 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。
1868
+ // Issue #529(续排):跑成了也未必收工——池子还有没进这一轮的对时要接着追,
1869
+ // 而此时已经没有待触发的写入了,不在这里补挂就永远等不到下一轮。
1870
+ const ran = await maybeSchedule();
1871
+ if (!ran || catchupWanted) armNextWindow();
1872
+ }, delay);
1873
+ idleTimer.unref?.();
1874
+ }
1875
+
1876
+ // Issue #187:撞 CD / run 失败后的补挂表——按「剩余 CD / 剩余静默」里更晚的
1877
+ // 到点时刻重排一次,回调里重复同样的判断所以不会空转。跑成了一轮就不重排:
1878
+ // 安静的库没有要处理的新东西,触发权交还给下一次写入的 noteWrite。
1879
+ function armNextWindow() {
1880
+ if (disposed || idleTimer) return;
1881
+ if (config.sleepModeEnabled !== true) return;
1882
+ // lastRunAt === 0 表示从未跑过(此路径不该出现,防御性返回)。
1883
+ if (lastRunAt <= 0) return;
1884
+ const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
1885
+ // Issue #529:这里必须和 shouldRun 用同一个有效间隔,否则退避期里重排的闹钟
1886
+ // 会在基数到点就响、然后被 shouldRun 否掉再重排——空转一串没有意义的定时器。
1887
+ const cdMs = effectiveMinIntervalMs();
1888
+ let delay = Math.max(
1889
+ lastRunAt + cdMs - now(),
1890
+ lastWriteAt + idleMs - now(),
1891
+ 0
1892
+ ) + 1000;
1893
+ // Issue #529(续排):冷却项按 0 处理之后,静默项可能已经是负的(这一轮跑得久、
1894
+ // 期间没有新写入),算出来只剩那 1 秒——追 backlog 不该变成秒级连发。续排下限钉在
1895
+ // 一个静默窗口上:追得动就每轮追一批,但每批之间仍然让库安静下来。
1896
+ // 有写入时静默项会自己变大(账本行落库会重置 lastWriteAt),这里只是保证下限。
1897
+ if (catchupWanted) delay = Math.max(delay, idleMs + 1000);
1898
+ idleTimer = setTimeoutFn(async () => {
1899
+ idleTimer = null;
1900
+ const ran = await maybeSchedule();
1901
+ // Issue #529(续排):与 armIdleTimer 的回调同一条理由——追 backlog 的轮次
1902
+ // 跑完必须自己补挂,否则续排在第二张表就断了。
1903
+ if (!ran || catchupWanted) armNextWindow();
1904
+ }, delay);
1905
+ idleTimer.unref?.();
1906
+ }
1907
+
1908
+ function shouldRun(at = now()) {
1909
+ if (disposed || running) return false;
1910
+ if (config.sleepModeEnabled !== true) return false;
1911
+ if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
1912
+ // lastRunAt === 0 means never ran — the min-interval check must not block
1913
+ // the very first cycle (a real run stamps a nonzero timestamp).
1914
+ // Issue #529:连败退避开启时改用指数放大的有效间隔——恒定失败的模型不再按
1915
+ // 固定节奏连发(#529 实测连续 23 轮 llm failed),只会越打越稀。
1916
+ if (lastRunAt > 0 && at - lastRunAt < effectiveMinIntervalMs()) return false;
1917
+ return true;
1918
+ }
1919
+
1920
+ /** Called on writes: resets the idle clock and re-arms the fire timer. The
1921
+ * pending timer is cleared first — a stale timer armed against the old idle
1922
+ * window would otherwise fire early, fail shouldRun, and leave nothing armed
1923
+ * for the next window (a missed trigger until the next write).
1924
+ *
1925
+ * While a sleep run is executing (running=true) the in-flight AbortController
1926
+ * is NOT aborted: the run's own writes (demoteToSummary / setArchived ride
1927
+ * the normal write-hook path) would otherwise self-abort the cycle. External
1928
+ * activity during the run still resets the idle clock here, so no new cycle
1929
+ * fires until the store is quiet again. */
1930
+ function noteWrite() {
1931
+ lastWriteAt = now();
1932
+ if (!running && sleepAbort) {
1933
+ sleepAbort.abort(); // user resumed activity — interrupt an idle run
1934
+ }
1935
+ if (idleTimer) {
1936
+ clearTimeoutFn(idleTimer);
1937
+ idleTimer = null;
1938
+ }
1939
+ armIdleTimer();
1940
+ }
1941
+
1942
+ async function maybeSchedule() {
1943
+ if (!shouldRun()) return false;
1944
+ running = true;
1945
+ const abort = new AbortController();
1946
+ sleepAbort = abort;
1947
+ try {
1948
+ lastRunAt = now();
1949
+ const result = await service.enqueue(() =>
1950
+ onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
1951
+ );
1952
+ // Issue #529:连败计数只在这里加减。判据优先看 result.status——runSleep 的
1953
+ // ok 只在 ok/degraded 时为真,noop(LLM 通路正常、只是没活干)和用户活动
1954
+ // 打断都会给出 ok:false,把它当失败会让退避在自己工作良好的库里越退越长。
1955
+ // 回落到 !result.ok 只是为了兼容注入的 no-op 桩(没有 status 字段)。
1956
+ // result 为空(最小测试替身)=完成且无失败,不动计数,与 #292 口径一致。
1957
+ if (result) {
1958
+ const failed = typeof result.status === "string" ? result.status === "failed" : !result.ok;
1959
+ consecutiveFailures = failed ? consecutiveFailures + 1 : 0;
1960
+ // Issue #529(续排):判据只认 runSleep 给的 catchup——它是"池子还有没进这一轮
1961
+ // 的对"且"送出去的每一对都落地了"二者的合取。**不要**在这里拿 status 反推:
1962
+ // 全判 keep 的轮次 applied 恒为 0、status 是 noop,恰好在最该追的场景失效。
1963
+ // 连续失败时不追(与退避互斥,见 effectiveMinIntervalMs),并把连击计数清零——
1964
+ // 失败说明这轮的"追"没有意义,重新从正常节奏起算。
1965
+ // 连击到顶就收手,并把连击清零——不清零的话这个进程从此再也不会追(streak 只增
1966
+ // 不减,之后每一轮都被同一道闸挡下),"等下一个静默窗口重新起算"就成了空话。
1967
+ // 收手必须落在 catchupWanted 这个状态上,不能只写在 effectiveMinIntervalMs 里:
1968
+ // 那里改成"返回基数"之后,armNextWindow 仍会按 catchupWanted 把闹钟钉在静默
1969
+ // 窗口上重排,于是每 5 分钟醒一次、每次都被 shouldRun 否掉再重排——没有 LLM
1970
+ // 调用,但定时器链永远不断。
1971
+ const wantCatchup = result.catchup === true && consecutiveFailures <= 0;
1972
+ if (!wantCatchup || catchupStreak + 1 >= CATCHUP_STREAK_CAP) {
1973
+ catchupStreak = 0;
1974
+ catchupWanted = false;
1975
+ } else {
1976
+ catchupStreak += 1;
1977
+ catchupWanted = true;
1978
+ }
1979
+ }
1980
+ return !!(result && result.ok);
1981
+ } catch (error) {
1982
+ logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
1983
+ consecutiveFailures += 1; // Issue #529:抛错同样计入连败
1984
+ // Issue #529(续排):抛错也算失败,续排意图一并清掉——否则上一轮置位的状态会跟着
1985
+ // 逃出 try 块,让下一次排表仍按静默窗口的空转节奏走。
1986
+ catchupWanted = false;
1987
+ catchupStreak = 0;
1988
+ return false;
1989
+ } finally {
1990
+ sleepAbort = null;
1991
+ running = false;
1992
+ // Issue #529 review(CodeRabbit ②):运行期间用户写入会走 noteWrite(),它把闹钟
1993
+ // 按**那一次写入**重算了一遍——那可能是静默窗口的尾声。若这一轮结束时报告
1994
+ // catchup=true,那只表可能几秒后就要响,下一批紧接着开跑,于是「让库安静一个
1995
+ // 静默窗口再追」变成「跑完立刻再跑」。这里把已挂着的表撤掉重挂,交给
1996
+ // armNextWindow 从**此刻**算出整整一个静默窗口。
1997
+ // 反过来(不追的轮次)保留 noteWrite 挂的那只表:它是按用户最后一次写入算的,
1998
+ // 正是「用户写完了、安静满一个窗口再说」的意思。
1999
+ if (catchupWanted) {
2000
+ if (idleTimer) {
2001
+ clearTimeoutFn(idleTimer);
2002
+ idleTimer = null;
2003
+ }
2004
+ armNextWindow();
2005
+ }
2006
+ }
2007
+ }
2008
+
2009
+ async function dispose() {
2010
+ disposed = true;
2011
+ if (idleTimer) {
2012
+ clearTimeoutFn(idleTimer);
2013
+ idleTimer = null;
2014
+ }
2015
+ if (sleepAbort) {
2016
+ sleepAbort.abort();
2017
+ sleepAbort = null;
2018
+ }
2019
+ }
2020
+
2021
+ // Issue #187(同族缺陷):构造即挂表——重启后只要没发生过写入,此前永远没有
2022
+ // 闹钟,安静多久都不会触发。lastWriteAt 已在构造时设为当前时刻,shouldRun 的
2023
+ // 静默条件自然生效:重启后静默满 idleMinutes 才可能起跑。
2024
+ armIdleTimer();
2025
+
2026
+ return { noteWrite, maybeSchedule, shouldRun, dispose };
2027
+ }