@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,1455 +1,1455 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { createSleepScheduler, runSleep } from "../src/dream/sleep.js";
4
- import { Config } from "../src/config.js";
5
- import { createStore } from "../src/store.js";
6
- import { createService } from "../src/service.js";
7
- import { createVectorIndex } from "../src/vector-index.js";
8
- import { parseReceipt } from "../src/dream.js";
9
-
10
- // Mock embedder: every query maps to [1,0,0] so vectors are identical unless a
11
- // test pre-seeds a custom vector via vectorIndex.saveEmbedding.
12
- const embedder = {
13
- embedSingle: async () => [1, 0, 0],
14
- embed: async () => [1, 0, 0],
15
- schedule: () => {},
16
- modelHash: "mock#1",
17
- dimension: 3
18
- };
19
-
20
- // Deterministic LLM: onConsolidation(userText) => decisions JSON string.
21
- function mockCtx(onConsolidation, selection = { provider: "mock", model: "sleep-model" }) {
22
- return {
23
- logger: { warn: () => {}, info: () => {} },
24
- agentDefaultModel: { currentSelection: () => selection },
25
- llm: {
26
- async *stream(options) {
27
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
28
- const reply = onConsolidation ? onConsolidation(userText) : "[]";
29
- yield { type: "text-delta", index: 0, text: reply };
30
- yield { type: "finish", reason: { kind: "stop" } };
31
- }
32
- }
33
- };
34
- }
35
-
36
- function baseConfig(overrides = {}) {
37
- return {
38
- sleepModeEnabled: true,
39
- sleepIdleMinutes: 5,
40
- sleepMinIntervalHours: 8,
41
- sleepConflictStrictness: "normal",
42
- sleepArchiveDays: 30,
43
- sleepCompressDays: 90,
44
- sleepPatternMinMemories: 100,
45
- sleepMaxPatternPerRun: 3,
46
- ...overrides
47
- };
48
- }
49
-
50
- // v0.7.0 heat 双保护默认保守:默认 heatTypeDecay 下 project 记忆 40 天热值
51
- // ≈0.55、100 天 ≈0.28,始终高于 sleepHeatThreshold(0.05),demotion 不触发。
52
- // 降级语义测试把 project 的 λ 调快到 0.02(40 天热值≈0.03),复现"时间窗冷态
53
- // 即降级"的旧路径;默认保守语义由 sleep-heat.test.js 单独覆盖。
54
- function demotionConfig(overrides = {}) {
55
- return baseConfig({ heatTypeDecay: { project: 0.02 }, ...overrides });
56
- }
57
-
58
- function setup() {
59
- const store = createStore(":memory:");
60
- const service = createService({ store, mirror: null, config: {} });
61
- const vectorIndex = createVectorIndex({ store });
62
- service.setEmbedder(embedder);
63
- service.setVectorIndex(vectorIndex);
64
- return { store, service, vectorIndex };
65
- }
66
-
67
- function makeMemory(service, title, content, type = "project") {
68
- return service.saveWithDedupe({ type, title, content, importance: 3 }).memory;
69
- }
70
-
71
- // ------------------------------------------------------------ scheduler
72
-
73
- test("sleep: scheduler does not run when disabled", () => {
74
- const { service, store } = setup();
75
- const sched = createSleepScheduler({
76
- service, config: baseConfig({ sleepModeEnabled: false }), logger: { warn: () => {} },
77
- onRun: async () => ({ ok: true }),
78
- now: () => 1_000_000, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
79
- });
80
- assert.equal(sched.shouldRun(1_000_000 + 60 * 60000), false, "disabled never schedules");
81
- store.close();
82
- });
83
-
84
- test("sleep: scheduler fires once idle window elapses", () => {
85
- const { service, store } = setup();
86
- const sched = createSleepScheduler({
87
- service, config: baseConfig(), logger: { warn: () => {} },
88
- onRun: async () => ({ ok: true }),
89
- now: () => 1_000_000, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
90
- });
91
- assert.equal(sched.shouldRun(1_000_000 + 4 * 60000), false, "still within idle window");
92
- assert.equal(sched.shouldRun(1_000_000 + 6 * 60000), true, "idle window elapsed");
93
- store.close();
94
- });
95
-
96
- test("sleep: noteWrite resets the idle clock (stale-timer guard)", () => {
97
- const { service, store } = setup();
98
- let now = 1_000_000;
99
- const sched = createSleepScheduler({
100
- service, config: baseConfig(), logger: { warn: () => {} },
101
- onRun: async () => ({ ok: true }),
102
- now: () => now, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
103
- });
104
- assert.equal(sched.shouldRun(now + 6 * 60000), true, "idle after 6min");
105
- now = 1_000_000 + 6 * 60000;
106
- sched.noteWrite();
107
- assert.equal(sched.shouldRun(now), false, "write resets idle clock");
108
- assert.equal(sched.shouldRun(now + 4 * 60000), false, "still idle-pending after reset");
109
- assert.equal(sched.shouldRun(now + 6 * 60000), true, "idle again after fresh window");
110
- store.close();
111
- });
112
-
113
- test("sleep: first-ever run is not blocked by min interval (lastRunAt=0)", () => {
114
- const { service, store } = setup();
115
- const sched = createSleepScheduler({
116
- service, config: baseConfig(), logger: { warn: () => {} },
117
- onRun: async () => ({ ok: true }),
118
- now: () => 1_000_000, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
119
- });
120
- assert.equal(sched.shouldRun(1_000_000 + 6 * 60000), true, "first run allowed");
121
- store.close();
122
- });
123
-
124
- test("sleep: min interval blocks a re-run within the window, allows after", async () => {
125
- const { service, store } = setup();
126
- let now = 1_000_000;
127
- const sched = createSleepScheduler({
128
- service, config: baseConfig(), logger: { warn: () => {} },
129
- onRun: async () => ({ ok: true }),
130
- now: () => now, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
131
- });
132
- now = 1_000_000 + 6 * 60000; // idle satisfied
133
- const ok = await sched.maybeSchedule();
134
- assert.equal(ok, true, "first run executed");
135
- assert.equal(sched.shouldRun(now + 1 * 3600000), false, "1h later still inside 8h min interval");
136
- assert.equal(sched.shouldRun(now + 9 * 3600000), true, "9h later past min interval → allowed");
137
- store.close();
138
- });
139
-
140
- test("sleep: maybeSchedule enqueues via service and serializes with other work", async () => {
141
- const { service, store } = setup();
142
- const config = baseConfig();
143
- let now = 1_000_000;
144
- let order = [];
145
- const sched = createSleepScheduler({
146
- service, config, logger: { warn: () => {} },
147
- onRun: async () => { order.push("sleep"); return { ok: true, applied: 7 }; },
148
- now: () => now, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
149
- });
150
- now = 1_000_000 + 6 * 60000;
151
- const p1 = service.enqueue(async () => { order.push("a"); });
152
- const p2 = sched.maybeSchedule();
153
- await p1;
154
- const result = await p2;
155
- assert.equal(result, true, "run result propagated through enqueue");
156
- assert.deepEqual(order, ["a", "sleep"], "sleep run serialized behind queued work");
157
- store.close();
158
- });
159
-
160
- test("sleep: dispose clears the timer and prevents further runs", async () => {
161
- const { service, store } = setup();
162
- let cleared = false;
163
- const sched = createSleepScheduler({
164
- service, config: baseConfig(), logger: { warn: () => {} },
165
- onRun: async () => ({ ok: true }),
166
- now: () => 1_000_000,
167
- setTimeoutFn: () => 1,
168
- clearTimeoutFn: () => { cleared = true; }
169
- });
170
- // #187 起构造即挂表——dispose 必须能清掉这张(以及 noteWrite 重挂过的任何)表。
171
- assert.equal(cleared, false, "timer armed at construction, not yet cleared");
172
- await sched.dispose();
173
- assert.equal(cleared, true, "idle timer cleared on dispose");
174
- assert.equal(sched.shouldRun(1_000_000 + 6 * 60000), false, "disposed never runs");
175
- store.close();
176
- });
177
-
178
- // ------------------------------------------------------------ runSleep phases
179
-
180
- test("sleep: demotion shrinks cold memory to summary, keeps _full_content", async () => {
181
- const { service, store } = setup();
182
- const now = Date.now();
183
- const m = makeMemory(service, "cold", "原内容".repeat(60), "project");
184
- service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
185
- const ctx = mockCtx(() => "[]");
186
- const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
187
- const after = service.getById(m.id);
188
- assert.equal(result.status, "ok");
189
- assert.ok(after._full_content && after._full_content.length > 0, "full body preserved");
190
- assert.ok(after.content.length < "原内容".repeat(60).length, "content shrank to summary");
191
- assert.equal(after.archived, false, "not fully archived at 40 days");
192
- store.close();
193
- });
194
-
195
- test("sleep: demotion fully archives memory past sleepCompressDays", async () => {
196
- const { service, store } = setup();
197
- const now = Date.now();
198
- const m = makeMemory(service, "ancient", "很老的记忆", "project");
199
- service.touchLastAccess(m.id, new Date(now - 100 * 86400000).toISOString());
200
- const ctx = mockCtx(() => "[]");
201
- const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
202
- const after = service.getById(m.id);
203
- assert.equal(result.status, "ok");
204
- assert.equal(after.archived, true, "past compress days → archived");
205
- store.close();
206
- });
207
-
208
- test("sleep: demoteToSummary minRefTimeMs skips a memory touched after snapshot", () => {
209
- const { service, store } = setup();
210
- const now = Date.now();
211
- const m = makeMemory(service, "touched", "内容".repeat(60), "project");
212
- service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
213
- const snapshotCut = now - 1 * 86400000;
214
- service.touchLastAccess(m.id, new Date(snapshotCut + 60000).toISOString());
215
- const updated = service.demoteToSummary(m.id, "摘要", { minRefTimeMs: snapshotCut });
216
- assert.equal(updated, undefined, "touched-after-snapshot memory is not demoted");
217
- assert.equal(service.getById(m.id)._full_content ?? null, null, "no demotion happened");
218
- store.close();
219
- });
220
-
221
- test("sleep: demotion demotes a memory still cold after snapshot", () => {
222
- const { service, store } = setup();
223
- const now = Date.now();
224
- const m = makeMemory(service, "stillcold", "内容".repeat(60), "project");
225
- service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
226
- const snapshotCut = now - 1 * 86400000;
227
- const updated = service.demoteToSummary(m.id, "摘要", { minRefTimeMs: snapshotCut });
228
- assert.ok(updated && updated._full_content, "cold memory demoted to summary");
229
- store.close();
230
- });
231
-
232
- test("sleep: pattern discovery filters fabricated evidence ids", async () => {
233
- const { service, store } = setup();
234
- const a = makeMemory(service, "模式A", "反复出现的模式A", "project");
235
- const b = makeMemory(service, "模式B", "反复出现的模式B", "project");
236
- const ctx = mockCtx(() =>
237
- JSON.stringify([
238
- { action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3, evidence: [a.id, "fake-zzz", b.id] }
239
- ])
240
- );
241
- const result = await runSleep(ctx, service, baseConfig({ sleepPatternMinMemories: 10 }), ctx.logger, null, null);
242
- assert.ok(!JSON.stringify(result.phases.patterns).includes("fake-zzz"), "fabricated evidence filtered before apply");
243
- const pattern = service.all().find((x) => x.type === "pattern");
244
- assert.ok(pattern, "pattern minted");
245
- store.close();
246
- });
247
-
248
- test("sleep: relation completion links orphan entities co-occurring in a memory", async () => {
249
- const { service, store } = setup();
250
- const alpha = service.createEntity({ name: "Alpha", type: "project" });
251
- const beta = service.createEntity({ name: "Beta", type: "project" });
252
- makeMemory(service, "协作", "Alpha 与 Beta 一起干活", "project");
253
- const ctx = mockCtx(() => "[]");
254
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
255
- assert.equal(result.phases.relations.status, "ok", "relations phase ran");
256
- const rels = service.getRelations(alpha.id);
257
- assert.ok(rels.length >= 1, "orphan Alpha gained a relation");
258
- assert.ok(rels.some((r) => r.to_entity === beta.id || r.from_entity === beta.id), "relation targets Beta");
259
- store.close();
260
- });
261
-
262
- test("sleep: runSleep writes an audit receipt with run_type='sleep'", async () => {
263
- const { service, store } = setup();
264
- const now = Date.now();
265
- const m = makeMemory(service, "cold", "内容".repeat(60), "project");
266
- service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
267
- const ctx = mockCtx(() => "[]");
268
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
269
- const runs = service.listDreamRuns();
270
- const last = runs[runs.length - 1];
271
- assert.equal(last.run_type, "sleep", "audit row tagged sleep");
272
- assert.equal(result.runId, last.id, "run id matches audit row");
273
- assert.ok(typeof result.receipt === "string" && result.receipt.startsWith("dsh-mneme:run:"), "receipt is the audit string");
274
- store.close();
275
- });
276
-
277
- test("sleep: a failing phase does not block the others (fail-safe)", async () => {
278
- const { service, store } = setup();
279
- service.setEmbedder({
280
- embed: async () => { throw new Error("embed down"); },
281
- embedSingle: async () => { throw new Error("embed down"); },
282
- modelHash: "x", dimension: 3
283
- });
284
- const now = Date.now();
285
- const m = makeMemory(service, "cold", "内容".repeat(60), "project");
286
- service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
287
- const ctx = mockCtx(() => "[]");
288
- const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
289
- assert.equal(result.phases.conflicts.status, "skipped", "conflicts phase degraded gracefully (no usable vectors)");
290
- assert.equal(result.phases.demotion.status, "ok", "demotion still ran");
291
- assert.equal(result.status, "ok", "overall run still ok despite conflicts degrading");
292
- assert.ok(service.getById(m.id)._full_content, "cold memory still demoted despite conflicts failure");
293
- store.close();
294
- });
295
-
296
- test("sleep: no LLM route skips LLM phases but demotion still runs", async () => {
297
- const { service, store } = setup();
298
- const now = Date.now();
299
- const m = makeMemory(service, "cold", "内容".repeat(60), "project");
300
- service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
301
- const ctx = mockCtx(() => "[]", null); // currentSelection() → null, no route
302
- const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
303
- assert.equal(result.phases.conflicts.status, "skipped", "no llm route → conflicts skipped");
304
- assert.equal(result.phases.patterns.status, "skipped", "no llm route → patterns skipped");
305
- assert.equal(result.phases.demotion.status, "ok", "demotion is LLM-free and runs");
306
- store.close();
307
- });
308
-
309
- // ------------------------------------------------------------ conflict strictness
310
-
311
- function seedConflictPair(service, vectorIndex, sim) {
312
- const a = makeMemory(service, "主题X", "内容A 关于主题X", "project");
313
- const b = makeMemory(service, "主题X副本", "内容B 关于主题X", "project");
314
- const sin = Math.sqrt(Math.max(0, 1 - sim * sim));
315
- vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
316
- vectorIndex.saveEmbedding(b.id, [sim, sin, 0]);
317
- return { a, b };
318
- }
319
-
320
- test("sleep: conflict strictness gentle ignores sim 0.88 pairs", async () => {
321
- const { service, store, vectorIndex } = setup();
322
- seedConflictPair(service, vectorIndex, 0.88);
323
- const config = baseConfig({ sleepConflictStrictness: "gentle", conflictFreezeEnabled: true });
324
- const ctx = mockCtx(() => "[]");
325
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
326
- assert.equal(result.phases.conflicts.status, "skipped", "0.88 below gentle 0.92 → no conflicts");
327
- store.close();
328
- });
329
-
330
- test("sleep: conflict strictness normal resolves sim 0.88 pairs", async () => {
331
- const { service, store, vectorIndex } = setup();
332
- seedConflictPair(service, vectorIndex, 0.88);
333
- const config = baseConfig({ sleepConflictStrictness: "normal", conflictFreezeEnabled: true });
334
- const ctx = mockCtx(() => "[]");
335
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
336
- assert.equal(result.phases.conflicts.status, "ok", "0.88 above normal 0.85 → conflicts found");
337
- assert.ok(result.phases.conflicts.frozen >= 1, "pairs frozen for review");
338
- store.close();
339
- });
340
-
341
- test("sleep: conflict strictness aggressive adjudicates low-confidence pairs", async () => {
342
- const { service, store, vectorIndex } = setup();
343
- seedConflictPair(service, vectorIndex, 0.80);
344
- const config = baseConfig({ sleepConflictStrictness: "aggressive", conflictFreezeEnabled: true });
345
- const ctx = mockCtx(() => "[]");
346
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
347
- assert.equal(result.phases.conflicts.status, "ok", "0.80 above aggressive 0.75 → conflicts found");
348
- assert.ok(result.phases.conflicts.frozen >= 1, "pairs frozen");
349
- store.close();
350
- });
351
-
352
- test("sleep: conflict LLM arbitration applies winner/loser", async () => {
353
- const { service, store, vectorIndex } = setup();
354
- const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
355
- const ctx = mockCtx(() =>
356
- JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
357
- );
358
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
359
- assert.equal(result.phases.conflicts.status, "ok", "conflict resolved");
360
- assert.equal(service.getById(b.id).archived, true, "loser archived by arbitration");
361
- store.close();
362
- });
363
-
364
- test("sleep: runSleep is abortable via signal between phases", async () => {
365
- const { service, store } = setup();
366
- const now = Date.now();
367
- const m = makeMemory(service, "cold", "内容".repeat(60), "project");
368
- service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
369
- const ctrl = new AbortController();
370
- ctrl.abort(); // pre-aborted
371
- const ctx = mockCtx(() => "[]");
372
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, ctrl.signal);
373
- assert.equal(result.phases.conflicts, undefined, "aborted before any phase ran");
374
- store.close();
375
- });
376
-
377
- // Issue #257:冲突/模式两阶段的输出预算原硬编码 2048——sleepActionSet=full
378
- // 实测 24 对需约 6967 token,截断即 invalid decisions json 整轮失败。两阶段
379
- // 必须读取 sleepMaxTokens 配置并透传到 ctx.llm.stream 的 options。
380
- test("issue#257 conflict and pattern phases forward configured sleepMaxTokens", async () => {
381
- const { service, store, vectorIndex } = setup();
382
- const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
383
- const seen = [];
384
- const ctx = mockCtx((userText) =>
385
- JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
386
- );
387
- const origStream = ctx.llm.stream.bind(ctx.llm);
388
- ctx.llm.stream = async function* (options) {
389
- if (options?.purpose === "sleep-conflict" || options?.purpose === "sleep-pattern") {
390
- seen.push({ purpose: options.purpose, maxTokens: options.maxTokens });
391
- }
392
- if (options?.purpose === "sleep-pattern") {
393
- // pattern 阶段喂确定性空结果(no patterns found → skipped),
394
- // 不复用 conflict 决策(宽松模式下会被当 Fabricated 静默跳过)。
395
- yield { type: "text-delta", index: 0, text: "[]" };
396
- yield { type: "finish", reason: { kind: "stop" } };
397
- return;
398
- }
399
- yield* origStream(options);
400
- };
401
- const result = await runSleep(ctx, service, baseConfig({ sleepMaxTokens: 6967 }), ctx.logger, { embedder, vectorIndex }, null);
402
- assert.equal(result.status, "ok", "run completes with the configured budget");
403
- assert.ok(seen.some((s) => s.purpose === "sleep-conflict" && s.maxTokens === 6967), "conflict phase reads sleepMaxTokens");
404
- assert.ok(seen.some((s) => s.purpose === "sleep-pattern" && s.maxTokens === 6967), "pattern phase reads sleepMaxTokens");
405
- store.close();
406
- });
407
-
408
- test("issue#257 schema default for sleepMaxTokens is 8192 (was hardcoded 2048)", () => {
409
- const cfg = Config({});
410
- assert.equal(cfg.sleepMaxTokens, 8192, "schema default covers the measured full-set peak (6967)");
411
- });
412
-
413
- // ------------------------------------------- sleep LLM accounting (issue #250)
414
- // Bug8 给 dream 与 summarize 接记账时漏了 sleep:src/dream/sleep.js 有一份自己的
415
- // streamText,签名里没有 onUsage,两条 LLM 链路(conflict 裁决 / pattern 挖掘)的
416
- // token 与状态从未进 llm_audit_logs——面板因此只看到两条链路,回答不了「sleep 花了
417
- // 多少」。下面两条用例锁定「审计行落库 + token 数值来自 usage chunk」。
418
- // 两条链路共用同一处 streamText 改动,故放在同一轮 red-green 里。
419
-
420
- /** 在 base ctx 的流里、finish 之前插入一个 usage chunk(形态同 llm-audit.test.js)。 */
421
- function withUsageChunk(base, usageChunk) {
422
- return {
423
- ...base,
424
- llm: {
425
- async *stream(options) {
426
- for await (const chunk of base.llm.stream(options)) {
427
- if (chunk.type === "finish") yield usageChunk;
428
- yield chunk;
429
- }
430
- }
431
- }
432
- };
433
- }
434
-
435
- test("issue#250 sleep conflict phase writes an llm_audit row with the streamed tokens", async () => {
436
- const { service, store, vectorIndex } = setup();
437
- const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
438
- const ctx = withUsageChunk(
439
- mockCtx(() => JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])),
440
- { type: "usage", usage: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 } }
441
- );
442
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
443
- assert.equal(result.phases.conflicts.status, "ok", "conflict resolved");
444
-
445
- const conflict = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_conflict");
446
- assert.ok(conflict, "sleep_conflict audit row present");
447
- assert.equal(conflict.trigger_source, "sleep");
448
- assert.equal(conflict.status, "success");
449
- assert.equal(conflict.model_id, "mock:sleep-model", "route actually used is recorded");
450
- assert.equal(conflict.input_tokens, 1200, "input tokens come from chunk.usage");
451
- assert.equal(conflict.output_tokens, 300, "output tokens come from chunk.usage");
452
- assert.equal(conflict.total_tokens, 1500);
453
- assert.ok(typeof conflict.duration_ms === "number" && conflict.duration_ms >= 0);
454
- store.close();
455
- });
456
-
457
- test("issue#250 sleep pattern phase writes an llm_audit row with the streamed tokens", async () => {
458
- const { service, store } = setup();
459
- const a = makeMemory(service, "模式A", "反复出现的模式A", "project");
460
- const b = makeMemory(service, "模式B", "反复出现的模式B", "project");
461
- const ctx = withUsageChunk(
462
- mockCtx(() => JSON.stringify([
463
- { action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3, evidence: [a.id, b.id] }
464
- ])),
465
- { type: "usage", usage: { inputTokens: 900, outputTokens: 250, totalTokens: 1150 } }
466
- );
467
- const result = await runSleep(ctx, service, baseConfig({ sleepPatternMinMemories: 10 }), ctx.logger, null, null);
468
- assert.ok(result.phases.patterns, "pattern phase ran");
469
-
470
- const pattern = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_pattern");
471
- assert.ok(pattern, "sleep_pattern audit row present");
472
- assert.equal(pattern.trigger_source, "sleep");
473
- assert.equal(pattern.status, "success");
474
- assert.equal(pattern.input_tokens, 900, "input tokens come from chunk.usage");
475
- assert.equal(pattern.output_tokens, 250, "output tokens come from chunk.usage");
476
- assert.equal(pattern.total_tokens, 1150);
477
- store.close();
478
- });
479
-
480
- // 审计诚实性(issue #250):流式成功但输出里没有 JSON 数组时,phase 报 failed,
481
- // 审计行也必须记 error——不能一个调用在两表里自相矛盾。
482
- test("issue#250 sleep conflict parse failure is audited as status=error, not fake success", async () => {
483
- const { service, store, vectorIndex } = setup();
484
- const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
485
- const ctx = mockCtx(() => "抱歉,我无法把裁决整理成 JSON。");
486
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
487
- assert.equal(result.phases.conflicts.status, "failed", "the phase reports the failure");
488
-
489
- const row = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_conflict");
490
- assert.ok(row, "the failed call is still audited");
491
- assert.equal(row.status, "error", "a stream that returned unusable output is not a success");
492
- assert.match(row.error_message, /no json array in llm output/);
493
- store.close();
494
- });
495
-
496
- test("issue#250 sleep pattern parse failure is audited as status=error, not fake success", async () => {
497
- const { service, store } = setup();
498
- makeMemory(service, "模式A", "反复出现的模式A", "project");
499
- makeMemory(service, "模式B", "反复出现的模式B", "project");
500
- const ctx = mockCtx(() => "这不是 JSON 数组");
501
- const result = await runSleep(ctx, service, baseConfig({ sleepPatternMinMemories: 10 }), ctx.logger, null, null);
502
- // pattern 阶段对不可解析输出的既定语义是 skipped("no patterns found")——那是
503
- // 「有没有铸造出模式」的结论,不是「这次调用成没成功」的结论,此处不改上游语义。
504
- assert.equal(result.phases.patterns.status, "skipped", "the phase keeps its existing semantics");
505
-
506
- const row = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_pattern");
507
- assert.ok(row, "the call still consumed tokens and is still audited");
508
- assert.equal(row.status, "error", "the audit records the call's truth, not the phase's conclusion");
509
- assert.match(row.error_message, /no json array in llm output/);
510
- store.close();
511
- });
512
-
513
- // 回归护栏:llmAudit.enabled === false 时 auditedSleepLlm 整段早退,auditError 钩子
514
- // 不会被调用。所以两个 phase 的控制流必须各自解析输出、不能依赖那个钩子的副作用
515
- // ——dream 的 runAuditedLlm 正是那样踩了(关掉审计 flag 会让 autoDream 直接失效)。
516
- test("issue#250 sleep still adjudicates normally when llmAudit.enabled === false", async () => {
517
- const { service, store, vectorIndex } = setup();
518
- const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
519
- const ctx = mockCtx(() =>
520
- JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
521
- );
522
- const config = baseConfig({ llmAudit: { enabled: false } });
523
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
524
- assert.equal(result.phases.conflicts.status, "ok", "turning the audit off must not break sleep");
525
- assert.equal(service.getById(b.id).archived, true, "the arbitration still applied");
526
- assert.equal(service.listLlmAudits().length, 0, "no audit rows when disabled");
527
- store.close();
528
- });
529
- // ------------------------------------------------------------ issue #529: input budget
530
-
531
- // seedSimilarPairs:造 n 条同类型、两两相似度都不低于阈值的记忆,且每条只可能与
532
- // 一条配成对(相似度按 i 递减)。用来验证「池子大于单批上限」时分批是否真的分开了。
533
- // 这些向量不是正交的——cosine 只在 [1,0,0] 与 [s, sin, 0] 之间算,因此给每对单独
534
- // 一个角度,与 seedConflictPair 同一套构造。
535
- function seedSimilarPairs(service, vectorIndex, n) {
536
- const mems = [];
537
- for (let i = 0; i < n; i++) {
538
- const m = makeMemory(service, `主题${i}`, `内容${i}`, "project");
539
- mems.push(m);
540
- }
541
- // 全部推到同一个方向附近:任意两条的 cosine 都 ≈ 1(> 0.85),findPotentialConflicts
542
- // 会生成 C(n,2) 对,去重阶段按相似度留下 n/2 对(每记忆至多一对)。
543
- for (const m of mems) vectorIndex.saveEmbedding(m.id, [1, 0, 0]);
544
- return mems;
545
- }
546
-
547
- // 记录**冲突相位**每次往返送进去的候选对数(user 消息里每个候选行以 "id=" 开头)。
548
- // 必须按 purpose 过滤:同一轮里 pattern 相位也会调 LLM,把它的往返算进来会让
549
- // 「送了几批」这个断言凭空多一批。
550
- function countingCtx(responder, batchSizes = []) {
551
- let call = 0;
552
- const ctx = {
553
- logger: { warn: () => {}, info: () => {} },
554
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
555
- llm: {
556
- async *stream(options) {
557
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
558
- let reply = "[]";
559
- if (options.purpose === "sleep-conflict") {
560
- batchSizes.push((userText.match(/id=/g) ?? []).length);
561
- reply = responder(userText, ++call);
562
- }
563
- yield { type: "text-delta", index: 0, text: reply };
564
- yield { type: "finish", reason: { kind: "stop" } };
565
- }
566
- }
567
- };
568
- return { ctx, batchSizes, calls: () => call };
569
- }
570
-
571
- test("issue#529 conflict pairs are sent in bounded batches, not one giant prompt", async () => {
572
- const { service, store, vectorIndex } = setup();
573
- seedSimilarPairs(service, vectorIndex, 8);
574
- const { ctx, batchSizes } = countingCtx(() => "[]");
575
- const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 8 });
576
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
577
- assert.ok(batchSizes.length > 1, `expected several batches, got ${batchSizes.length}`);
578
- assert.ok(batchSizes.every((n) => n > 0 && n <= 4), `每批不超过 2 对(每对 2 个 id): ${JSON.stringify(batchSizes)}`);
579
- assert.deepEqual(batchSizes.slice(-2), [4, 4], "每对 2 个 id ⇒ 2 对 = 4 个 id 行");
580
- assert.equal(result.phases.conflicts.batches, batchSizes.length, "审计里的批次数与真实往返次数一致");
581
- store.close();
582
- });
583
-
584
- test("issue#529 a failing batch does not kill the whole run (degraded, not failed)", async () => {
585
- const { service, store, vectorIndex } = setup();
586
- seedSimilarPairs(service, vectorIndex, 8);
587
- const { ctx } = countingCtx((_text, n) => {
588
- // 第一批吐空(stream 正常结束但无文本 ⇒ llm failed),后面的批正常。
589
- return n === 1 ? "" : "[]";
590
- });
591
- const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 8 });
592
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
593
- assert.equal(result.phases.conflicts.status, "degraded", "坏批只记 degraded,不判死整轮");
594
- assert.ok(result.phases.conflicts.batches > 1, "后面的批照样跑完了");
595
- store.close();
596
- });
597
-
598
- test("issue#529 a judged pair is not re-sent while neither side changed (keep leaves a trace)", async () => {
599
- const { service, store, vectorIndex } = setup();
600
- const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
601
- const config = baseConfig();
602
- // 第一轮:判 keep(= 模型说这两条不冲突)。这曾经是彻底的 no-op。
603
- const first = await runSleep(mockCtx(() => "[]"), service, config, { warn: () => {} }, { embedder, vectorIndex }, null);
604
- assert.equal(first.phases.conflicts.status, "noop", "没有动作 ⇒ noop");
605
- const ledger = service.listConflictReviewed();
606
- assert.equal(ledger.length, 1, "判 keep 也要在台账里留一行");
607
- assert.equal(ledger[0].outcome, "keep");
608
- assert.deepEqual([ledger[0].memory_a, ledger[0].memory_b].sort(), [a.id, b.id].sort());
609
-
610
- // 第二轮:两侧都没动 ⇒ 这对已经在台账里,不该再送 LLM。
611
- const secondCtx = countingCtx(() => "[]");
612
- const second = await runSleep(secondCtx.ctx, service, config, secondCtx.ctx.logger, { embedder, vectorIndex }, null);
613
- assert.equal(secondCtx.calls(), 0, "同一对、两侧都没更新 ⇒ 不再送审");
614
- assert.equal(second.phases.conflicts.ledgerSkipped, 1);
615
-
616
- // 第三轮:动一下其中一侧(内容更新 ⇒ updated_at 前进),它就该重新入池。
617
- // service.update 会把该行的向量作废并异步重算;测试里的 mock embedder 只支持
618
- // 「一条一条 embed」的形式(embedSingle),批量 embed 的返回形状不是它覆盖的范围,
619
- // 所以这里按既有夹具的做法手工补一次向量——被测的是台账判据,不是回填路径。
620
- service.update(b.id, { content: "内容B 关于主题X(改)" });
621
- vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
622
- const thirdCtx = countingCtx(() => "[]");
623
- const third = await runSleep(thirdCtx.ctx, service, config, thirdCtx.ctx.logger, { embedder, vectorIndex }, null);
624
- assert.equal(thirdCtx.calls(), 1, "任一侧更新过 ⇒ 重新送审");
625
- assert.equal(third.phases.conflicts.ledgerSkipped, 0);
626
- store.close();
627
- });
628
-
629
- test("issue#529 per-run cap bounds the pairs adjudicated in one run", async () => {
630
- const { service, store, vectorIndex } = setup();
631
- seedSimilarPairs(service, vectorIndex, 12);
632
- const { ctx, calls } = countingCtx(() => "[]");
633
- const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 4 });
634
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
635
- assert.ok(calls() <= 2, `每轮最多 4 对 ÷ 每批 2 对 = 2 次往返,实际 ${calls()}`);
636
- assert.ok(result.phases.conflicts.perRunSkipped > 0, "超出本轮预算的对要被记下来");
637
- store.close();
638
- });
639
-
640
- test("issue#529 user activity between batches aborts instead of reporting noop", async () => {
641
- const { service, store, vectorIndex } = setup();
642
- seedSimilarPairs(service, vectorIndex, 12);
643
- const controller = new AbortController();
644
- // 第一批返回之后用户恢复活动(noteWrite → abort)。此时第二、三批根本没送出去,
645
- // 把它们算成「看过、没冲突」(noop)是这一相位最容易犯的谎。
646
- const { ctx } = countingCtx(() => {
647
- controller.abort();
648
- return "[]";
649
- });
650
- const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 12 });
651
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, controller.signal);
652
- assert.equal(result.phases.conflicts.status, "aborted", "中途被打断不是 noop");
653
- assert.equal(result.phases.conflicts.batches, 6, "计划批次数照实记(不是实际送出的批次数)");
654
- // 整轮层也要如实:deriveStatus 此前只认 failed/error,aborted 相位会被静默折成
655
- // noop,审计行于是把「没跑完」记成「跑过、没什么事」——与相位层同一个谎。
656
- assert.equal(result.status, "aborted", "整轮状态沿用相位的 aborted,不折回 noop");
657
- store.close();
658
- });
659
-
660
- test("issue#529 failed batches still report pairs/batches (audit used to lose them)", async () => {
661
- const { service, store, vectorIndex } = setup();
662
- seedSimilarPairs(service, vectorIndex, 4);
663
- const { ctx } = countingCtx(() => "");
664
- const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 4 });
665
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
666
- const phase = result.phases.conflicts;
667
- assert.equal(phase.status, "failed", "全部批次失败 ⇒ 整轮 failed");
668
- assert.ok(Number.isInteger(phase.pairs) && phase.pairs > 0, "失败轮也带 pairs");
669
- assert.ok(Number.isInteger(phase.batches) && phase.batches > 0, "失败轮也带 batches");
670
- store.close();
671
- });
672
-
673
-
674
- // ------------------------------------------- issue #536 review: CodeRabbit 三条
675
-
676
- // 把一条记忆的 created_at / updated_at 一起回拨。update 决策有 24h 保护期,
677
- // 台账判据比的是 updated_at——两处都要动,只改一个会让用例互相干扰。
678
- function backdate(store, id, hours) {
679
- const ts = new Date(Date.now() - hours * 3600000).toISOString();
680
- store.db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run(ts, ts, id);
681
- }
682
-
683
- // 造一对向量完全相同的记忆(similarity = 1)。
684
- function seedIdenticalPair(service, vectorIndex, dir, tag) {
685
- const a = makeMemory(service, `${tag}A`, `${tag} 内容A`, "project");
686
- const b = makeMemory(service, `${tag}B`, `${tag} 内容B`, "project");
687
- vectorIndex.saveEmbedding(a.id, dir);
688
- vectorIndex.saveEmbedding(b.id, dir);
689
- return { a, b };
690
- }
691
-
692
- test("issue#536 review ①: an already-judged pair must not eat a memory's pairing slot", async () => {
693
- const { service, store, vectorIndex } = setup();
694
- // a-b 相似度 1.0,a-c 与 b-c 都是 0.9:按相似度排序先去重的旧写法会先选中 a-b,
695
- // 再把 a-b 当"已审"丢掉,于是 a 的名额被一个注定被过滤的对占住,a-c 进不来。
696
- const a = makeMemory(service, "主题X", "内容A 关于主题X", "project");
697
- const b = makeMemory(service, "主题X副本", "内容B 关于主题X", "project");
698
- const c = makeMemory(service, "主题X变体", "内容C 关于主题X", "project");
699
- vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
700
- vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
701
- vectorIndex.saveEmbedding(c.id, [0.9, Math.sqrt(1 - 0.81), 0]);
702
- // a-b 已经判过(keep),且两侧此后都没动过 ⇒ 本轮不该再送。
703
- service.saveConflictReviewed({
704
- run_id: "seed",
705
- memory_a: a.id,
706
- memory_b: b.id,
707
- outcome: "keep",
708
- judged_at: new Date(Date.now() + 60000).toISOString()
709
- });
710
-
711
- const { ctx, calls } = countingCtx(() => "[]");
712
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
713
- assert.equal(calls(), 1, "台账预筛必须发生在去重之前,a 要能和 c 配对送出去");
714
- assert.equal(result.phases.conflicts.ledgerSkipped, 1, "被台账挡下的对数照实记");
715
- store.close();
716
- });
717
-
718
- test("issue#536 review ②: reflectionUpdateMaxPerRun stays a whole-run cap across batches", async () => {
719
- const { service, store, vectorIndex } = setup();
720
- // 两对向量正交,互不干扰 ⇒ 去重后正好两对,每批一对。
721
- const p1 = seedIdenticalPair(service, vectorIndex, [1, 0, 0], "甲");
722
- const p2 = seedIdenticalPair(service, vectorIndex, [0, 1, 0], "乙");
723
- for (const m of [p1.a, p1.b, p2.a, p2.b]) backdate(store, m.id, 48);
724
-
725
- // 每批都提一条 update:整轮上限 1,所以只有先跑的那批能落地。
726
- const { ctx, calls } = countingCtx((text) => {
727
- const id = text.includes(p1.a.id) ? p1.a.id : p2.a.id;
728
- const next = text.includes(p1.a.id) ? "甲 内容A(更新)" : "乙 内容A(更新)";
729
- return JSON.stringify([{ action: "update", ids: [id], content: next, reason: "内容有误" }]);
730
- });
731
- const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2, reflectionUpdateMaxPerRun: 1 });
732
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
733
- assert.equal(calls(), 2, "两对各自成批,都要送");
734
- const updated = [p1.a, p2.a].filter((m) => service.getById(m.id).content.endsWith("(更新)"));
735
- assert.equal(updated.length, 1, "update 是整轮上限,不是每批上限——只能落地一条");
736
- assert.equal(result.phases.conflicts.batches, 2);
737
- store.close();
738
- });
739
-
740
- test("issue#536 review ③: a decision that never landed must not be recorded as judged", async () => {
741
- const { service, store, vectorIndex } = setup();
742
- const { a } = seedConflictPair(service, vectorIndex, 1.0);
743
- // 提一条"不改任何字段"的 update:validateDecisions 会把它跳过(skipInvalid),
744
- // 该条决策没有落地。若照旧把整批送审对都写进台账,这一对就会被判成"已审"
745
- // 而永远不再送——沉默地永久压制一个真实冲突。
746
- const { ctx, calls } = countingCtx(() =>
747
- JSON.stringify([{ action: "update", ids: [a.id], content: "内容A 关于主题X" }])
748
- );
749
- const config = baseConfig({ reflectionUpdateMinAgeHours: 0 });
750
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
751
- assert.equal(calls(), 1, "这一对确实被送出去判过");
752
- assert.equal(service.listConflictReviewed().length, 0, "没落地的决策不能记成已审");
753
- assert.ok(result.phases.conflicts.skipped.length > 0, "跳过明细照常进审计");
754
- store.close();
755
- });
756
-
757
- test("issue#536 review ④: judged_at lives in the store's clock domain, not the wall clock", async () => {
758
- const { service, store, vectorIndex } = setup();
759
- const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
760
- // 造一次「库里的单调时钟比墙钟快」——src/store.js 的 nowIso 在同一毫秒内的连续
761
- // 写入上逐次 +1ms,高频写入的库完全可能领先墙钟几毫秒(CI 的 test-host-legacy
762
- // 腿抓到过这次真 flake)。这里直接把两侧推到墙钟之后,等价于那个状态。
763
- const ahead = new Date(Date.now() + 5000).toISOString();
764
- for (const id of [a.id, b.id]) {
765
- store.db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run(ahead, ahead, id);
766
- }
767
-
768
- // 第一轮判 keep ⇒ 记台账。
769
- const first = await runSleep(mockCtx(() => "[]"), service, baseConfig(), { warn: () => {} }, { embedder, vectorIndex }, null);
770
- assert.equal(first.phases.conflicts.status, "noop");
771
- const ledger = service.listConflictReviewed();
772
- assert.equal(ledger.length, 1, "判 keep 要留一行");
773
- assert.ok(
774
- ledger[0].judged_at >= ahead,
775
- "judged_at 不能早于被测行自己的 updated_at(" + ledger[0].judged_at + " vs " + ahead + ")"
776
- );
777
-
778
- // 第二轮:两侧确实没动过 ⇒ 不许重送。若 judged_at 取了墙钟,这里会读成
779
- // 「判过之后又被改过」,判 keep 的死循环原样复活。
780
- const second = countingCtx(() => "[]");
781
- await runSleep(second.ctx, service, baseConfig(), second.ctx.logger, { embedder, vectorIndex }, null);
782
- assert.equal(second.calls(), 0, "时钟领先墙钟时,已审的对仍然不许重送");
783
- store.close();
784
- });
785
-
786
- test("issue#536 review ⑤: a batch that throws does not take the rest of the run with it", async () => {
787
- const { service, store, vectorIndex } = setup();
788
- // 两对正交向量 ⇒ 两批。让**第一批**的 LLM 往返直接抛(不是返回错误:返回错误那条
789
- // 通路本来就有失败返回,早被前面的用例覆盖了)。要验的是:adjudicateBatch 内部
790
- // 任何未被兜住的异常,都只折损它自己那一批,后面的批次照跑、之前的汇总不丢。
791
- // applyDecisions 把 service.update 的异常吞进 failures(decisions.js:532-540),
792
- // 所以打桩 service 的方法测不到这条守卫,必须在 LLM 这一层抛。
793
- const p1 = seedIdenticalPair(service, vectorIndex, [1, 0, 0], "甲");
794
- const p2 = seedIdenticalPair(service, vectorIndex, [0, 1, 0], "乙");
795
- for (const m of [p1.a, p1.b, p2.a, p2.b]) backdate(store, m.id, 48);
796
- const { ctx, calls } = countingCtx((text) => {
797
- const id = text.includes(p1.a.id) ? p1.a.id : p2.a.id;
798
- const next = text.includes(p1.a.id) ? "甲 内容A(更新)" : "乙 内容A(更新)";
799
- return JSON.stringify([{ action: "update", ids: [id], content: next, reason: "内容有误" }]);
800
- });
801
- const realStream = ctx.llm.stream;
802
- let thrown = false;
803
- ctx.llm.stream = async function* (options) {
804
- if (!thrown && options.purpose === "sleep-conflict") {
805
- thrown = true;
806
- throw new Error("boom inside adjudicateBatch");
807
- }
808
- yield* realStream.call(this, options);
809
- };
810
- const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2, reflectionUpdateMaxPerRun: 2 });
811
- const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
812
- assert.equal(thrown, true, "第一批确实抛了,否则这条用例什么也没验");
813
- assert.equal(calls(), 1, "第一批抛错后第二批仍然要跑");
814
- assert.notEqual(result.phases.conflicts.status, "failed", "只要有一批活下来,就不是整轮 failed");
815
- // 抛的是第一批(哪个 pair 先送取决于排序,所以两条都问,只要求"恰好一条活下来"):
816
- // 断言落在结果上,不落在顺序上,免得排序一变这条用例就假红。
817
- const landed = [p1.a, p2.a].filter((m) => service.getById(m.id).content.endsWith("(更新)"));
818
- assert.equal(landed.length, 1, "第二批照常落地,且只落地一批");
819
- assert.equal(result.phases.conflicts.batches, 2, "抛错的批次不吞掉轮次的形状");
820
- store.close();
821
- });
822
-
823
- // ------------------------------------------------------------ affective (#619)
824
-
825
- // Only the affective phase gets a real answer; every other LLM phase says "[]"
826
- // so this file keeps testing one thing at a time.
827
- function affectiveCtx(payload) {
828
- return {
829
- logger: { warn: () => {}, info: () => {} },
830
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "affective-model" }) },
831
- llm: {
832
- async *stream(options) {
833
- const reply = options.purpose === "sleep-affective"
834
- ? JSON.stringify(payload)
835
- : "[]";
836
- yield { type: "text-delta", index: 0, text: reply };
837
- yield { type: "finish", reason: { kind: "stop" } };
838
- }
839
- }
840
- };
841
- }
842
-
843
- function affectiveConfig(overrides = {}) {
844
- return baseConfig({ sleepAffectiveEnabled: true, sleepPatternMinMemories: 10, ...overrides });
845
- }
846
-
847
- function linkSession(service, memoryId, sessionId) {
848
- service.saveMemoryProvenance({ memoryId, sessionId, seqStart: 1, seqEnd: 2, createdAt: new Date().toISOString() });
849
- }
850
-
851
- test("sleep: affective phase stays off unless explicitly enabled", async () => {
852
- const { service, store } = setup();
853
- service.createEntity({ name: "桉桉", type: "person" });
854
- service.createEntity({ name: "答辩", type: "concept" });
855
- makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
856
- const ctx = affectiveCtx([]);
857
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
858
- assert.equal(result.phases.affective.status, "skipped");
859
- assert.equal(result.phases.affective.reason, "disabled");
860
- store.close();
861
- });
862
-
863
- test("sleep: a one-session emotion lands as affective_context, and a trait claim is downgraded", async () => {
864
- // 这条是整个 #619 的主 claim:模型说这是「稳定特质」,但证据只覆盖一个会话
865
- // ⇒ 必须降级成场合边,绝不能铸出「他是焦虑的人」。
866
- const { service, store } = setup();
867
- const person = service.createEntity({ name: "桉桉", type: "person" });
868
- const target = service.createEntity({ name: "答辩", type: "concept" });
869
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
870
- linkSession(service, mem.id, "session-A");
871
- const ctx = affectiveCtx([
872
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: [mem.id], why: "提到答辩就焦虑" }
873
- ]);
874
- const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
875
- const phase = result.phases.affective;
876
- assert.equal(phase.status, "ok");
877
- assert.equal(phase.downgraded, 1, "trait claim on one session must be counted as downgraded");
878
- assert.equal(phase.traits, 0, "no trait pattern minted");
879
- assert.equal(service.all().filter((m) => m.type === "pattern").length, 0, "nothing minted as a trait");
880
- const rels = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
881
- assert.equal(rels.length, 1, "emotion landed as a relation instead");
882
- assert.equal(rels[0].to_entity, target.id);
883
- assert.equal(rels[0].source, "llm");
884
- assert.equal(rels[0].metadata.attribution, "occasion");
885
- assert.equal(rels[0].metadata.downgradedFrom, "trait");
886
- store.close();
887
- });
888
-
889
- test("sleep: an emotion backed by enough independent sessions mints a trait pattern", async () => {
890
- const { service, store } = setup();
891
- service.createEntity({ name: "桉桉", type: "person" });
892
- service.createEntity({ name: "答辩", type: "concept" });
893
- const ids = [];
894
- for (const session of ["s1", "s2", "s3"]) {
895
- const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
896
- linkSession(service, mem.id, session);
897
- ids.push(mem.id);
898
- }
899
- const ctx = affectiveCtx([
900
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次独立答辩前都这样" }
901
- ]);
902
- const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
903
- const phase = result.phases.affective;
904
- assert.equal(phase.downgraded, 0, "three independent sessions is a real trait");
905
- assert.equal(phase.traits, 1);
906
- assert.equal(phase.applied, 1, "trait went through applyDecisions");
907
- const patterns = service.all().filter((m) => m.type === "pattern");
908
- assert.equal(patterns.length, 1, "one trait pattern minted");
909
- assert.match(patterns[0].title, /稳定倾向/);
910
- assert.match(patterns[0].title, /答辩/, "标题必须带对象,否则人对什么焦虑都看不出来");
911
- assert.match(patterns[0].content, /3 个相互独立/);
912
- store.close();
913
- });
914
-
915
- test("sleep: two different targets mint two patterns instead of one swallowing the other", async () => {
916
- // #619 / Ephor 第 5 轮:标题原本不含 target,而 applyCreate 走
917
- // saveWithDedupe,按 (type, title) 找同题行。于是「对答辩长期焦虑」与
918
- // 「对面试长期焦虑」共用同一个标题,第二条被判为重复——不报错、不进
919
- // failures、applied 还如实显示写入了。一条真实结论就这样静默消失,
920
- // 正好落在本相位「降级不丢弃」这条原则的反面。
921
- const { service, store } = setup();
922
- service.createEntity({ name: "桉桉", type: "person" });
923
- service.createEntity({ name: "答辩", type: "concept" });
924
- service.createEntity({ name: "面试", type: "concept" });
925
- const ids = { 答辩: [], 面试: [] };
926
- for (const target of ["答辩", "面试"]) {
927
- for (const session of ["s1", "s2", "s3"]) {
928
- const mem = makeMemory(service, `${target}-${session}`, `第 ${session} 次${target}前都失眠`);
929
- linkSession(service, mem.id, `${target}-${session}`);
930
- ids[target].push(mem.id);
931
- }
932
- }
933
- const ctx = affectiveCtx([
934
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids.答辩, why: "三次答辩前都这样" },
935
- { person: "桉桉", target: "面试", emotion: "焦虑", attribution: "trait", evidence: ids.面试, why: "三次面试前都这样" }
936
- ]);
937
- const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 2 }), ctx.logger, { embedder, vectorIndex: null }, null);
938
- const phase = result.phases.affective;
939
- assert.equal(phase.traits, 2, "两条 trait 候选");
940
- assert.equal(phase.failures.length, 0, "没有失败");
941
- assert.equal(phase.applied, 2, "两条都真的落库了");
942
- const patterns = service.all().filter((m) => m.type === "pattern");
943
- assert.equal(patterns.length, 2, "两个对象各一条,第二条不许被静默合并掉");
944
- const titles = patterns.map((m) => m.title).sort();
945
- assert.ok(titles.some((t) => t.includes("答辩")), "答辩那条在");
946
- assert.ok(titles.some((t) => t.includes("面试")), "面试那条也在 —— 这条以前会消失");
947
- assert.notEqual(titles[0], titles[1], "标题必须不同,否则去重键仍会撞");
948
- store.close();
949
- });
950
-
951
- test("sleep: an occasion edge is marked when the same emotion is later confirmed as a trait", async () => {
952
- // #619 / Ephor 第 5 轮 C:先有 occasion 边,后来证据够了判成 trait。
953
- // 边不改(它表示「这次场合观察到过」,这句仍然是真的),但要标上
954
- // promotedToTrait——否则同一件事实在图上是「一次性」、在 pattern 里是
955
- // 「长期」,读归因覆盖率的人拿到两份互相矛盾的记录,而这不会自愈。
956
- const { service, store } = setup();
957
- service.createEntity({ name: "桉桉", type: "person" });
958
- service.createEntity({ name: "答辩", type: "concept" });
959
- const mem0 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
960
- linkSession(service, mem0.id, "s0");
961
- const ctxOccasion = affectiveCtx([
962
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem0.id] }
963
- ]);
964
- const first = await runSleep(ctxOccasion, service, affectiveConfig(), ctxOccasion.logger, { embedder, vectorIndex: null }, null);
965
- assert.equal(first.phases.affective.relations, 1, "先落一条 occasion 边");
966
- const ids = [];
967
- for (const session of ["s1", "s2", "s3"]) {
968
- const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
969
- linkSession(service, mem.id, session);
970
- ids.push(mem.id);
971
- }
972
- const ctxTrait = affectiveCtx([
973
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次独立答辩前都这样" }
974
- ]);
975
- const second = await runSleep(ctxTrait, service, affectiveConfig(), ctxTrait.logger, { embedder, vectorIndex: null }, null);
976
- assert.equal(second.phases.affective.traits, 1, "够格铸 trait");
977
- const person = service.findEntityByName("桉桉");
978
- const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
979
- assert.equal(edges.length, 1, "仍然是同一条边");
980
- assert.equal(edges[0].metadata.sessionCount, 1, "边的场合数不因后来升格而改写");
981
- assert.equal(edges[0].metadata.promotedToTrait, true, "但必须标上升格过,否则两份记录互相打脸");
982
- store.close();
983
- });
984
-
985
- test("sleep: unknown entities and fabricated evidence are reported in dropped, not silently discarded", async () => {
986
- const { service, store } = setup();
987
- service.createEntity({ name: "桉桉", type: "person" });
988
- service.createEntity({ name: "答辩", type: "concept" });
989
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
990
- linkSession(service, mem.id, "session-A");
991
- const ctx = affectiveCtx([
992
- { person: "不存在的人", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] },
993
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: ["fake-zzz"] },
994
- { person: "桉桉", target: "答辩", emotion: "", attribution: "occasion", evidence: [mem.id] }
995
- ]);
996
- const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
997
- const phase = result.phases.affective;
998
- assert.equal(phase.relations, 0, "nothing landed");
999
- assert.equal(phase.dropped.length, 3, "every rejection is accounted for");
1000
- const reasons = phase.dropped.map((d) => d.reason).sort();
1001
- assert.deepEqual(reasons, ["no emotion", "no real evidence", "unknown entity"]);
1002
- store.close();
1003
- });
1004
-
1005
- test("sleep: the affective phase never adds a related_to edge", async () => {
1006
- // 变异自证的反面:如果情感边走的是 related_to(旧兜底行为),这条会挂。
1007
- const { service, store } = setup();
1008
- service.createEntity({ name: "桉桉", type: "person" });
1009
- service.createEntity({ name: "答辩", type: "concept" });
1010
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1011
- linkSession(service, mem.id, "session-A");
1012
- const before = service.all().map((m) => m.id).length;
1013
- const ctx = affectiveCtx([
1014
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1015
- ]);
1016
- await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1017
- const person = service.findEntityByName("桉桉");
1018
- const rels = service.getRelations(person.id);
1019
- assert.equal(rels.filter((r) => r.relation_type === "related_to").length, 0, "no related_to smuggled in");
1020
- assert.equal(rels.filter((r) => r.relation_type === "affective_context").length, 1);
1021
- assert.equal(service.all().length, before, "no memory minted for a single-occasion emotion");
1022
- store.close();
1023
- });
1024
-
1025
- test("sleep: replaying the same segment merges into the existing edge instead of stacking", async () => {
1026
- // #619 / Ephor 第 2 条:entity_relations 没有 UNIQUE,saveRelation 每次新插。
1027
- // 探针拿同一批段反复回放时,重复边会把 sessionCount 抬高、主指标失真。
1028
- // 而判重不能只「丢弃」:同一场合后来又看到更多证据时,那条边上的
1029
- // sessionCount 会永远停在第一次,将来读归因覆盖率会一直读到 1。
1030
- const { service, store } = setup();
1031
- service.createEntity({ name: "桉桉", type: "person" });
1032
- service.createEntity({ name: "答辩", type: "concept" });
1033
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1034
- linkSession(service, mem.id, "session-A");
1035
- const ctx = affectiveCtx([
1036
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1037
- ]);
1038
- const first = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1039
- assert.equal(first.phases.affective.relations, 1, "第一轮写了一条");
1040
- const second = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1041
- assert.equal(second.phases.affective.relations, 0, "第二轮不再新插边");
1042
- assert.ok(second.phases.affective.merged >= 1, "第二轮认出了同一场合,走合并而不是丢弃");
1043
- const person = service.findEntityByName("桉桉");
1044
- const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1045
- assert.equal(edges.length, 1, "库里仍只有一条边");
1046
- assert.equal(edges[0].metadata.sessionCount, 1, "同一会话不会把场合数吹大");
1047
- assert.deepEqual(edges[0].metadata.evidence, [mem.id], "证据不变");
1048
- store.close();
1049
- });
1050
-
1051
- test("sleep: two memories from one session merge into one edge, not two", async () => {
1052
- // 同一会话里的两条记忆各说一句「他很焦虑」,证据天然不相交,但它们是
1053
- // 同一个场合。按 evidence 交集判会各写一条,把边数与场合数一起吹大。
1054
- const { service, store } = setup();
1055
- service.createEntity({ name: "桉桉", type: "person" });
1056
- service.createEntity({ name: "答辩", type: "concept" });
1057
- const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1058
- const m2 = makeMemory(service, "答辩前一晚", "越想越慌,睡不着");
1059
- linkSession(service, m1.id, "session-A");
1060
- linkSession(service, m2.id, "session-A");
1061
- const ctx = affectiveCtx([
1062
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id] },
1063
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m2.id] }
1064
- ]);
1065
- const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1066
- assert.equal(result.phases.affective.relations, 1, "同一会话只该有一条边");
1067
- assert.ok(result.phases.affective.merged >= 1, "第二条按同场合合并进来");
1068
- const person = service.findEntityByName("桉桉");
1069
- const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1070
- assert.equal(edges.length, 1);
1071
- assert.equal(edges[0].metadata.sessionCount, 1, "两条记忆都在同一会话,场合数仍是 1");
1072
- assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort(), "证据取并集");
1073
- store.close();
1074
- });
1075
-
1076
- test("sleep: a later occasion extends the existing edge instead of being dropped", async () => {
1077
- // 第一轮 1 个会话写边,第二轮攒到 2 个会话(含新会话)。如果判成重复丢掉,
1078
- // 边上的 sessionCount 会冻结在 1;将来用它算归因覆盖率就一直读到 1。
1079
- const { service, store } = setup();
1080
- service.createEntity({ name: "桉桉", type: "person" });
1081
- service.createEntity({ name: "答辩", type: "concept" });
1082
- const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1083
- const m2 = makeMemory(service, "第二次答辩前", "又要答辩了,还是焦虑");
1084
- linkSession(service, m1.id, "session-A");
1085
- linkSession(service, m2.id, "session-B");
1086
- const first = affectiveCtx([
1087
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id] }
1088
- ]);
1089
- const r1 = await runSleep(first, service, affectiveConfig(), first.logger, { embedder, vectorIndex: null }, null);
1090
- assert.equal(r1.phases.affective.relations, 1);
1091
- const second = affectiveCtx([
1092
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id, m2.id] }
1093
- ]);
1094
- const r2 = await runSleep(second, service, affectiveConfig(), second.logger, { embedder, vectorIndex: null }, null);
1095
- assert.equal(r2.phases.affective.relations, 0, "不是新边");
1096
- assert.ok(r2.phases.affective.merged >= 1, "是合并");
1097
- const person = service.findEntityByName("桉桉");
1098
- const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1099
- assert.equal(edges.length, 1);
1100
- assert.equal(edges[0].metadata.sessionCount, 2, "场合数跟着证据涨到 2");
1101
- assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort());
1102
- store.close();
1103
- });
1104
-
1105
- test("sleep: reordering the evidence list does not smuggle in a second edge", async () => {
1106
- // 判重键如果只看 evidence[0],模型把 [m1, m2] 写成 [m2, m1] 就会再插一条。
1107
- // LLM 给 evidence 的顺序本来就不稳定,parseJsonArray 也不排序——同一条边
1108
- // 换个顺序就重复写,sessionCount 会被抬得比真实场合数高,正好毁掉主指标。
1109
- const { service, store } = setup();
1110
- service.createEntity({ name: "桉桉", type: "person" });
1111
- service.createEntity({ name: "答辩", type: "concept" });
1112
- const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1113
- const m2 = makeMemory(service, "答辩前一晚", "越想越慌,睡不着");
1114
- linkSession(service, m1.id, "session-A");
1115
- linkSession(service, m2.id, "session-A");
1116
- const forward = affectiveCtx([
1117
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id, m2.id] }
1118
- ]);
1119
- const first = await runSleep(forward, service, affectiveConfig(), forward.logger, { embedder, vectorIndex: null }, null);
1120
- assert.equal(first.phases.affective.relations, 1, "第一轮写了一条");
1121
- const reversed = affectiveCtx([
1122
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m2.id, m1.id] }
1123
- ]);
1124
- const second = await runSleep(reversed, service, affectiveConfig(), reversed.logger, { embedder, vectorIndex: null }, null);
1125
- assert.equal(second.phases.affective.relations, 0, "只换了证据顺序,不能再写一条");
1126
- assert.ok(second.phases.affective.merged >= 1, "认成同一场合,走合并");
1127
- const person = service.findEntityByName("桉桉");
1128
- const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1129
- assert.equal(edges.length, 1, "库里仍只有一条边");
1130
- assert.equal(edges[0].metadata.sessionCount, 1, "同一会话,场合数仍是 1");
1131
- assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort(), "并集后证据不多不少");
1132
- store.close();
1133
- });
1134
-
1135
- test("sleep: two emotions in the same memory are two edges, not a duplicate", async () => {
1136
- // 判重键如果不带情绪,「答辩前又焦虑又期待」里的第二条会被当成重复丢掉,
1137
- // dropped 里还写着 duplicate edge——读起来像「本来就重复」,实际是丢了一条
1138
- // 真信息。本相位的承诺是降级不丢弃,重复和丢弃不能混成一个理由。
1139
- const { service, store } = setup();
1140
- service.createEntity({ name: "桉桉", type: "person" });
1141
- service.createEntity({ name: "答辩", type: "concept" });
1142
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他又焦虑又有点期待");
1143
- linkSession(service, mem.id, "session-A");
1144
- const ctx = affectiveCtx([
1145
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] },
1146
- { person: "桉桉", target: "答辩", emotion: "期待", attribution: "occasion", evidence: [mem.id] }
1147
- ]);
1148
- const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1149
- assert.equal(result.phases.affective.relations, 2, "两种情绪各写一条");
1150
- assert.equal(result.phases.affective.dropped.filter((d) => d.reason === "duplicate edge").length, 0, "不许把第二种情绪当重复");
1151
- const person = service.findEntityByName("桉桉");
1152
- const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1153
- assert.deepEqual(edges.map((r) => r.metadata.emotion).sort(), ["期待", "焦虑"], "两条边各带自己的情绪词");
1154
- store.close();
1155
- });
1156
-
1157
- test("sleep: an edge write shows up in the receipt instead of reading as zero", async () => {
1158
- // #619 / Ephor: receipt 的倒数第二位是 applied,而 affective 相位写边走
1159
- // saveRelation、不进 applied。不改的话,一轮明明写了边,审计行却是 `:0`——
1160
- // 「跑了但什么都没做」,探针会把有写入的轮次记成空轮。
1161
- const { service, store } = setup();
1162
- service.createEntity({ name: "桉桉", type: "person" });
1163
- service.createEntity({ name: "答辩", type: "concept" });
1164
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1165
- linkSession(service, mem.id, "session-A");
1166
- const ctx = affectiveCtx([
1167
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1168
- ]);
1169
- const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1170
- assert.equal(result.phases.affective.relations, 1, "恰好一条边落地,否则这条用例没在验东西");
1171
- assert.equal(result.phases.affective.applied, 0, "边不是 decision,applied 仍是 0");
1172
- const parts = result.receipt.split(":");
1173
- assert.equal(parts.length, 8, "receipt 仍是八段");
1174
- // 段序:dsh-mneme / run / runId / status / snapshotHash / inputCount / applied / summaryFlag
1175
- assert.equal(parts[6], "1", "写入计数必须非零,读成 0 等于谎报本轮没做过事");
1176
- store.close();
1177
- });
1178
-
1179
- test("sleep: a merge-only run also shows up in the receipt", async () => {
1180
- // #619 / Ephor 第 4 轮:只合并的那一轮 relations 与 edgeWrites 都是 0,
1181
- // 但它确实改了库(证据并集、sessionCount 重算)。receipt 若读成 `:0`,
1182
- // 就是上一处修过的同一个谎——只是换了个形状。合并也算写。
1183
- const { service, store } = setup();
1184
- service.createEntity({ name: "桉桉", type: "person" });
1185
- service.createEntity({ name: "答辩", type: "concept" });
1186
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1187
- linkSession(service, mem.id, "session-A");
1188
- const ctx = affectiveCtx([
1189
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1190
- ]);
1191
- const first = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1192
- assert.equal(first.receipt.split(":")[6], "1", "第一轮新写边,计数 1");
1193
- const second = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1194
- assert.equal(second.phases.affective.relations, 0, "第二轮没有新边");
1195
- assert.equal(second.phases.affective.edgeWrites, 0, "第二轮没有新写");
1196
- assert.equal(second.phases.affective.merged, 1, "第二轮走的是合并");
1197
- assert.equal(second.phases.affective.status, "ok", "合并过就是 ok,不是 noop");
1198
- const parts = second.receipt.split(":");
1199
- assert.equal(parts.length, 8, "receipt 仍是八段");
1200
- assert.equal(parts[6], "1", "只合并的一轮同样改了库,必须非零");
1201
- store.close();
1202
- });
1203
-
1204
- test("sleep: an aborted affective phase reports aborted, not ok", async () => {
1205
- // 中途被打断且已经写了边,既不能报 ok(没跑完),也不能报 noop(确实写了)。
1206
- const { service, store } = setup();
1207
- service.createEntity({ name: "桉桉", type: "person" });
1208
- service.createEntity({ name: "答辩", type: "concept" });
1209
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1210
- linkSession(service, mem.id, "session-A");
1211
- const controller = new AbortController();
1212
- const ctx = affectiveCtx([
1213
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1214
- ]);
1215
- const realStream = ctx.llm.stream;
1216
- ctx.llm.stream = async function* (options) {
1217
- yield* realStream.call(this, options);
1218
- // 模型答完、写边之前用户回到电脑前
1219
- if (options.purpose === "sleep-affective") controller.abort();
1220
- };
1221
- const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, controller.signal);
1222
- const phase = result.phases.affective;
1223
- assert.equal(phase.status, "aborted", "打断不是 ok");
1224
- assert.equal(phase.reason, "user activity");
1225
- assert.ok(phase.edgeWrites >= 0, "打断也把已写的条数报出来");
1226
- // 明细字段在每条返回路径上都得在。只有正常的返回带 mergedList 的话,
1227
- // 消费方在 aborted/failed 两条路径上读明细会静默拿到 undefined,不报错。
1228
- assert.ok(Array.isArray(phase.mergedList), "aborted 分支也要带 mergedList,缺了是静默失明");
1229
- store.close();
1230
- });
1231
-
1232
- test("sleep: a trait that clears the threshold mints exactly one pattern and counts it", async () => {
1233
- // 这条与上一条配对:同样的三会话证据,上一条说「够格就铸」,这条看「铸了几个、
1234
- // 计数是否如实」。Ephor 第 4 条的 `if (ok)` 缺 else 已补成 failed 分支,但那条
1235
- // 分支在合法决策下不可达(决策是我们按合法形状拼的),不写假用例假装测过它。
1236
- const { service, store } = setup();
1237
- service.createEntity({ name: "桉桉", type: "person" });
1238
- service.createEntity({ name: "答辩", type: "concept" });
1239
- const ids = [];
1240
- for (const session of ["s1", "s2", "s3"]) {
1241
- const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1242
- linkSession(service, mem.id, session);
1243
- ids.push(mem.id);
1244
- }
1245
- const ctx = affectiveCtx([
1246
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1247
- ]);
1248
- const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1249
- const phase = result.phases.affective;
1250
- assert.equal(phase.status, "ok");
1251
- assert.equal(phase.traits, 1, "识别出一个 trait");
1252
- assert.equal(phase.applied, 1, "它真的被铸出来了");
1253
- store.close();
1254
- });
1255
-
1256
- test("sleep: replaying the same trait rewrites the pattern instead of growing it", async () => {
1257
- // #619 / Ephor 第 6 轮:trait 标题由模板拼成,同一 (人, 对象, 情绪) 天然同题。
1258
- // applyCreate 走 saveWithDedupe,命中同题活跃行时默认 appendContent——把同一段
1259
- // 结论再粘一遍。pattern 是注入面消费的东西,重放三轮正文就从 161 字涨到 549 字,
1260
- // 还带一串重复的 [证据: …] 尾巴。修法:pattern 走 `_overwrite`,原地刷新。
1261
- const { service, store } = setup();
1262
- service.createEntity({ name: "桉桉", type: "person" });
1263
- service.createEntity({ name: "答辩", type: "concept" });
1264
- const ids = [];
1265
- for (const session of ["s1", "s2", "s3"]) {
1266
- const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1267
- linkSession(service, mem.id, session);
1268
- ids.push(mem.id);
1269
- }
1270
- const payload = (ctx) => ctx;
1271
- const lengths = [];
1272
- for (let round = 0; round < 3; round += 1) {
1273
- const ctx = affectiveCtx([
1274
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1275
- ]);
1276
- void payload(ctx);
1277
- await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1278
- const patterns = service.all().filter((m) => m.type === "pattern");
1279
- assert.equal(patterns.length, 1, `round ${round}: 仍然只有一行 pattern`);
1280
- lengths.push(patterns[0].content.length);
1281
- }
1282
- assert.equal(lengths[1], lengths[0], "第二轮不许把正文再粘一遍");
1283
- assert.equal(lengths[2], lengths[0], "第三轮同样");
1284
- const pattern = service.all().find((m) => m.type === "pattern");
1285
- assert.ok(!pattern.content.includes("---"), "没有 appendContent 的分隔符");
1286
- assert.equal((pattern.content.match(/\[证据/g) ?? []).length, 1, "证据尾巴只有一条");
1287
- store.close();
1288
- });
1289
-
1290
- test("sleep: a refresh-only trait run reports refreshed, not ok with zero mints", async () => {
1291
- // 同一批证据重放:第二轮没铸新行,只是把既有 pattern 原地刷新。这时若报
1292
- // status:"ok" + applied:0,读审计的人会以为「说明这一轮没东西」,而实际上是
1293
- // 一次真实写入;若报 noop,又等于说整池看过了什么都没有。给它自己的名字。
1294
- const { service, store } = setup();
1295
- service.createEntity({ name: "桉桉", type: "person" });
1296
- service.createEntity({ name: "答辩", type: "concept" });
1297
- const ids = [];
1298
- for (const session of ["s1", "s2", "s3"]) {
1299
- const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1300
- linkSession(service, mem.id, session);
1301
- ids.push(mem.id);
1302
- }
1303
- const payload = [
1304
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1305
- ];
1306
- const first = await (async () => {
1307
- const ctx = affectiveCtx(payload);
1308
- return runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1309
- })();
1310
- assert.equal(first.phases.affective.status, "ok", "第一轮真的铸了");
1311
- assert.equal(first.phases.affective.applied, 1);
1312
- const second = await (async () => {
1313
- const ctx = affectiveCtx(payload);
1314
- return runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1315
- })();
1316
- const phase = second.phases.affective;
1317
- assert.equal(phase.applied, 0, "第二轮没有新铸");
1318
- assert.equal(phase.refreshedTraits, 1, "刷新了一条");
1319
- assert.equal(phase.status, "refreshed", "刷新不是 noop,也不是一次新铸");
1320
- assert.equal(second.status, "ok", "整轮仍算有产出");
1321
- // #619 / Ephor 第七轮 ①:相位说「改了」,整轮 receipt 也必须说「改了」。此前
1322
- // totalApplied 只加 applied + edgeWrites + merged,refreshedTraits 不在表里,
1323
- // 于是这一轮 receipt 第 7 段是 0——「改了库读成 0」,正是上一轮刚修掉的形态。
1324
- const parsed = parseReceipt(second.receipt);
1325
- assert.ok(parsed, `receipt 必须可解析(实测 ${second.receipt})`);
1326
- assert.equal(parsed.applied, 1, "刷新一条要算一次真实写入,不能被读成 0");
1327
- // ③ 同一个场景:刷新不是出生,不该再往 memory_mints 添一行。
1328
- const pattern = service.all().find((m) => m.type === "pattern");
1329
- assert.equal(store.mintsFor(pattern.id).length, 1, "两轮下来只有一条出生记录");
1330
- store.close();
1331
- });
1332
-
1333
- test("sleep: the affective prompt does not corrupt JSON while filling the threshold", async () => {
1334
- // 占位符原来是一个裸的 `N`,而 prompt 正文里还写着「只输出 JSON 数组」。
1335
- // replaceAll("N", …) 于是把 JSON 换成了 JSO3——模型收到的是「只输出 JSO3 数组」,
1336
- // 而这一相位的唯一出口就是那个数组。这不是理论风险:zh 与 en 两版都中。
1337
- const { service, store } = setup();
1338
- service.createEntity({ name: "桉桉", type: "person" });
1339
- service.createEntity({ name: "答辩", type: "concept" });
1340
- makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
1341
- const seen = [];
1342
- const ctx = {
1343
- logger: { warn: () => {}, info: () => {} },
1344
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "affective-model" }) },
1345
- llm: {
1346
- async *stream(options) {
1347
- if (options.purpose === "sleep-affective") {
1348
- const system = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
1349
- seen.push(system);
1350
- }
1351
- yield { type: "text-delta", index: 0, text: "[]" };
1352
- yield { type: "finish", reason: { kind: "stop" } };
1353
- }
1354
- }
1355
- };
1356
- await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1357
- assert.equal(seen.length, 1, "情感相位确实调了一次模型");
1358
- const system = seen[0];
1359
- assert.ok(!system.includes("JSO3"), `prompt 里的 JSON 被阈值替换吃掉了:${system.slice(0, 200)}`);
1360
- assert.ok(system.includes("JSON"), "「只输出 JSON 数组」这句必须原样到达模型");
1361
- assert.ok(!system.includes("{{MIN_SESSIONS}}"), "占位符必须被填掉,不能把字面量发给模型");
1362
- assert.ok(system.includes("3"), "阈值默认 3 要出现在 prompt 里");
1363
- store.close();
1364
- });
1365
-
1366
- test("sleep: evidence without provenance still counts as the same occasion", async () => {
1367
- // memory_save 写下的记忆没有 provenance,sessionsFor 返回空集。纯会话判据在
1368
- // 这种情况下永远判不出「同一场合」,同一批证据会在每一轮 sleep 里再写一条边,
1369
- // 把 sessionCount 抬得比真实场合数高。落不着会话键时退回 evidence 交集。
1370
- const { service, store } = setup();
1371
- const person = service.createEntity({ name: "桉桉", type: "person" });
1372
- const target = service.createEntity({ name: "答辩", type: "concept" });
1373
- const mem = makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
1374
- const payload = [
1375
- { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id], why: "就这一次" }
1376
- ];
1377
- const run = () => {
1378
- const ctx = affectiveCtx(payload);
1379
- return runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1380
- };
1381
- const first = await run();
1382
- assert.equal(first.phases.affective.relations, 1, "第一轮写了一条边");
1383
- const second = await run();
1384
- assert.equal(second.phases.affective.relations, 0, "第二轮不许再写一条");
1385
- assert.equal(second.phases.affective.merged, 1, "认成同一场合,合并进既有边");
1386
- const rels = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1387
- assert.equal(rels.length, 1, "库里仍只有一条");
1388
- assert.equal(rels[0].to_entity, target.id);
1389
- store.close();
1390
- });
1391
-
1392
- test("sleep: trait candidates pushed out by the cap are recorded as dropped", async () => {
1393
- // traits.slice(0, maxTraits) 把超出的候选截掉,它们既不铸、也不降级、也不进
1394
- // dropped,而 traits 仍把它们算进候选数——读者会以为都处理过了。与边的
1395
- // `relation cap` 同一口径记一笔。
1396
- const { service, store } = setup();
1397
- service.createEntity({ name: "桉桉", type: "person" });
1398
- const payload = [];
1399
- for (const [i, target] of ["答辩", "面试", "汇报", "截止日期"].entries()) {
1400
- service.createEntity({ name: target, type: "concept" });
1401
- const ids = [];
1402
- for (const session of ["s1", "s2", "s3"]) {
1403
- const mem = makeMemory(service, `${target}-${session}-${i}`, `第 ${session} 次${target}前都失眠`);
1404
- linkSession(service, mem.id, `${target}-${session}`);
1405
- ids.push(mem.id);
1406
- }
1407
- payload.push({ person: "桉桉", target, emotion: "焦虑", attribution: "trait", evidence: ids, why: `三次${target}前都这样` });
1408
- }
1409
- const ctx = affectiveCtx(payload);
1410
- const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 2, sleepPatternMinMemories: 20 }), ctx.logger, { embedder, vectorIndex: null }, null);
1411
- const phase = result.phases.affective;
1412
- assert.equal(phase.traits, 4, "四条 trait 候选都被识别出来");
1413
- assert.equal(phase.applied, 2, "上限内两条落库");
1414
- const capped = phase.dropped.filter((d) => d.reason === "trait cap");
1415
- assert.equal(capped.length, 2, "被上限截掉的两条必须记账,不能静默消失");
1416
- assert.ok(capped.every((d) => typeof d.target === "string" && d.target), "每条都要能看出是哪条结论被截");
1417
- store.close();
1418
- });
1419
-
1420
- test("sleep: an affective stream failure keeps its cause on the phase", async () => {
1421
- // #619 可见化 A。这条与 conflict 相位那条同源:只写 "llm failed" 的话,
1422
- // 事后看 dream_runs 完全分不清是上下文超限、模型拒收、还是上游 502。
1423
- // 实测出处 2026-11-10T16:09 那一轮:outcome.affective 只有
1424
- // {"status":"failed","error":"llm failed"},真正的 502 原文只能去
1425
- // llm_audit_logs 里捞,而那张表不是给人看面板用的。
1426
- // 所以这里钉两件事:失败原因必须写进返回值的 failure;同时必须打 warn。
1427
- const { service, store } = setup();
1428
- service.createEntity({ name: "桉桉", type: "person" });
1429
- service.createEntity({ name: "答辩", type: "concept" });
1430
- const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1431
- linkSession(service, mem.id, "session-A");
1432
-
1433
- const warnings = [];
1434
- const ctx = affectiveCtx([]);
1435
- ctx.logger = { warn: (line) => warnings.push(String(line)), info: () => {} };
1436
- // 只让 affective 这一路失败,别的相位照常回 "[]"——否则测的是整轮,不是这一路。
1437
- ctx.llm.stream = async function* (options) {
1438
- if (options.purpose === "sleep-affective") {
1439
- yield { type: "finish", reason: { kind: "error", failure: { code: "SERVER", message: "502: upstream fetch failed" } } };
1440
- return;
1441
- }
1442
- yield { type: "text-delta", index: 0, text: "[]" };
1443
- yield { type: "finish", reason: { kind: "stop" } };
1444
- };
1445
-
1446
- const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1447
- const phase = result.phases.affective;
1448
- assert.equal(phase.status, "failed");
1449
- assert.equal(phase.error, "llm failed", "粗粒度分类保持不变,面板与审计都依赖它");
1450
- assert.ok(String(phase.failure ?? "").includes("502"),
1451
- `失败原因要带上游原文,实际拿到 ${JSON.stringify(phase.failure)}`);
1452
- assert.ok(warnings.some((w) => w.includes("affective stream aborted or errored")),
1453
- "同一份原因也要进日志,光靠返回值救不了已经过去的运行");
1454
- store.close();
1455
- });
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createSleepScheduler, runSleep } from "../src/dream/sleep.js";
4
+ import { Config } from "../src/config.js";
5
+ import { createStore } from "../src/store.js";
6
+ import { createService } from "../src/service.js";
7
+ import { createVectorIndex } from "../src/vector-index.js";
8
+ import { parseReceipt } from "../src/dream.js";
9
+
10
+ // Mock embedder: every query maps to [1,0,0] so vectors are identical unless a
11
+ // test pre-seeds a custom vector via vectorIndex.saveEmbedding.
12
+ const embedder = {
13
+ embedSingle: async () => [1, 0, 0],
14
+ embed: async () => [1, 0, 0],
15
+ schedule: () => {},
16
+ modelHash: "mock#1",
17
+ dimension: 3
18
+ };
19
+
20
+ // Deterministic LLM: onConsolidation(userText) => decisions JSON string.
21
+ function mockCtx(onConsolidation, selection = { provider: "mock", model: "sleep-model" }) {
22
+ return {
23
+ logger: { warn: () => {}, info: () => {} },
24
+ agentDefaultModel: { currentSelection: () => selection },
25
+ llm: {
26
+ async *stream(options) {
27
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
28
+ const reply = onConsolidation ? onConsolidation(userText) : "[]";
29
+ yield { type: "text-delta", index: 0, text: reply };
30
+ yield { type: "finish", reason: { kind: "stop" } };
31
+ }
32
+ }
33
+ };
34
+ }
35
+
36
+ function baseConfig(overrides = {}) {
37
+ return {
38
+ sleepModeEnabled: true,
39
+ sleepIdleMinutes: 5,
40
+ sleepMinIntervalHours: 8,
41
+ sleepConflictStrictness: "normal",
42
+ sleepArchiveDays: 30,
43
+ sleepCompressDays: 90,
44
+ sleepPatternMinMemories: 100,
45
+ sleepMaxPatternPerRun: 3,
46
+ ...overrides
47
+ };
48
+ }
49
+
50
+ // v0.7.0 heat 双保护默认保守:默认 heatTypeDecay 下 project 记忆 40 天热值
51
+ // ≈0.55、100 天 ≈0.28,始终高于 sleepHeatThreshold(0.05),demotion 不触发。
52
+ // 降级语义测试把 project 的 λ 调快到 0.02(40 天热值≈0.03),复现"时间窗冷态
53
+ // 即降级"的旧路径;默认保守语义由 sleep-heat.test.js 单独覆盖。
54
+ function demotionConfig(overrides = {}) {
55
+ return baseConfig({ heatTypeDecay: { project: 0.02 }, ...overrides });
56
+ }
57
+
58
+ function setup() {
59
+ const store = createStore(":memory:");
60
+ const service = createService({ store, mirror: null, config: {} });
61
+ const vectorIndex = createVectorIndex({ store });
62
+ service.setEmbedder(embedder);
63
+ service.setVectorIndex(vectorIndex);
64
+ return { store, service, vectorIndex };
65
+ }
66
+
67
+ function makeMemory(service, title, content, type = "project") {
68
+ return service.saveWithDedupe({ type, title, content, importance: 3 }).memory;
69
+ }
70
+
71
+ // ------------------------------------------------------------ scheduler
72
+
73
+ test("sleep: scheduler does not run when disabled", () => {
74
+ const { service, store } = setup();
75
+ const sched = createSleepScheduler({
76
+ service, config: baseConfig({ sleepModeEnabled: false }), logger: { warn: () => {} },
77
+ onRun: async () => ({ ok: true }),
78
+ now: () => 1_000_000, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
79
+ });
80
+ assert.equal(sched.shouldRun(1_000_000 + 60 * 60000), false, "disabled never schedules");
81
+ store.close();
82
+ });
83
+
84
+ test("sleep: scheduler fires once idle window elapses", () => {
85
+ const { service, store } = setup();
86
+ const sched = createSleepScheduler({
87
+ service, config: baseConfig(), logger: { warn: () => {} },
88
+ onRun: async () => ({ ok: true }),
89
+ now: () => 1_000_000, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
90
+ });
91
+ assert.equal(sched.shouldRun(1_000_000 + 4 * 60000), false, "still within idle window");
92
+ assert.equal(sched.shouldRun(1_000_000 + 6 * 60000), true, "idle window elapsed");
93
+ store.close();
94
+ });
95
+
96
+ test("sleep: noteWrite resets the idle clock (stale-timer guard)", () => {
97
+ const { service, store } = setup();
98
+ let now = 1_000_000;
99
+ const sched = createSleepScheduler({
100
+ service, config: baseConfig(), logger: { warn: () => {} },
101
+ onRun: async () => ({ ok: true }),
102
+ now: () => now, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
103
+ });
104
+ assert.equal(sched.shouldRun(now + 6 * 60000), true, "idle after 6min");
105
+ now = 1_000_000 + 6 * 60000;
106
+ sched.noteWrite();
107
+ assert.equal(sched.shouldRun(now), false, "write resets idle clock");
108
+ assert.equal(sched.shouldRun(now + 4 * 60000), false, "still idle-pending after reset");
109
+ assert.equal(sched.shouldRun(now + 6 * 60000), true, "idle again after fresh window");
110
+ store.close();
111
+ });
112
+
113
+ test("sleep: first-ever run is not blocked by min interval (lastRunAt=0)", () => {
114
+ const { service, store } = setup();
115
+ const sched = createSleepScheduler({
116
+ service, config: baseConfig(), logger: { warn: () => {} },
117
+ onRun: async () => ({ ok: true }),
118
+ now: () => 1_000_000, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
119
+ });
120
+ assert.equal(sched.shouldRun(1_000_000 + 6 * 60000), true, "first run allowed");
121
+ store.close();
122
+ });
123
+
124
+ test("sleep: min interval blocks a re-run within the window, allows after", async () => {
125
+ const { service, store } = setup();
126
+ let now = 1_000_000;
127
+ const sched = createSleepScheduler({
128
+ service, config: baseConfig(), logger: { warn: () => {} },
129
+ onRun: async () => ({ ok: true }),
130
+ now: () => now, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
131
+ });
132
+ now = 1_000_000 + 6 * 60000; // idle satisfied
133
+ const ok = await sched.maybeSchedule();
134
+ assert.equal(ok, true, "first run executed");
135
+ assert.equal(sched.shouldRun(now + 1 * 3600000), false, "1h later still inside 8h min interval");
136
+ assert.equal(sched.shouldRun(now + 9 * 3600000), true, "9h later past min interval → allowed");
137
+ store.close();
138
+ });
139
+
140
+ test("sleep: maybeSchedule enqueues via service and serializes with other work", async () => {
141
+ const { service, store } = setup();
142
+ const config = baseConfig();
143
+ let now = 1_000_000;
144
+ let order = [];
145
+ const sched = createSleepScheduler({
146
+ service, config, logger: { warn: () => {} },
147
+ onRun: async () => { order.push("sleep"); return { ok: true, applied: 7 }; },
148
+ now: () => now, setTimeoutFn: () => 1, clearTimeoutFn: () => {}
149
+ });
150
+ now = 1_000_000 + 6 * 60000;
151
+ const p1 = service.enqueue(async () => { order.push("a"); });
152
+ const p2 = sched.maybeSchedule();
153
+ await p1;
154
+ const result = await p2;
155
+ assert.equal(result, true, "run result propagated through enqueue");
156
+ assert.deepEqual(order, ["a", "sleep"], "sleep run serialized behind queued work");
157
+ store.close();
158
+ });
159
+
160
+ test("sleep: dispose clears the timer and prevents further runs", async () => {
161
+ const { service, store } = setup();
162
+ let cleared = false;
163
+ const sched = createSleepScheduler({
164
+ service, config: baseConfig(), logger: { warn: () => {} },
165
+ onRun: async () => ({ ok: true }),
166
+ now: () => 1_000_000,
167
+ setTimeoutFn: () => 1,
168
+ clearTimeoutFn: () => { cleared = true; }
169
+ });
170
+ // #187 起构造即挂表——dispose 必须能清掉这张(以及 noteWrite 重挂过的任何)表。
171
+ assert.equal(cleared, false, "timer armed at construction, not yet cleared");
172
+ await sched.dispose();
173
+ assert.equal(cleared, true, "idle timer cleared on dispose");
174
+ assert.equal(sched.shouldRun(1_000_000 + 6 * 60000), false, "disposed never runs");
175
+ store.close();
176
+ });
177
+
178
+ // ------------------------------------------------------------ runSleep phases
179
+
180
+ test("sleep: demotion shrinks cold memory to summary, keeps _full_content", async () => {
181
+ const { service, store } = setup();
182
+ const now = Date.now();
183
+ const m = makeMemory(service, "cold", "原内容".repeat(60), "project");
184
+ service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
185
+ const ctx = mockCtx(() => "[]");
186
+ const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
187
+ const after = service.getById(m.id);
188
+ assert.equal(result.status, "ok");
189
+ assert.ok(after._full_content && after._full_content.length > 0, "full body preserved");
190
+ assert.ok(after.content.length < "原内容".repeat(60).length, "content shrank to summary");
191
+ assert.equal(after.archived, false, "not fully archived at 40 days");
192
+ store.close();
193
+ });
194
+
195
+ test("sleep: demotion fully archives memory past sleepCompressDays", async () => {
196
+ const { service, store } = setup();
197
+ const now = Date.now();
198
+ const m = makeMemory(service, "ancient", "很老的记忆", "project");
199
+ service.touchLastAccess(m.id, new Date(now - 100 * 86400000).toISOString());
200
+ const ctx = mockCtx(() => "[]");
201
+ const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
202
+ const after = service.getById(m.id);
203
+ assert.equal(result.status, "ok");
204
+ assert.equal(after.archived, true, "past compress days → archived");
205
+ store.close();
206
+ });
207
+
208
+ test("sleep: demoteToSummary minRefTimeMs skips a memory touched after snapshot", () => {
209
+ const { service, store } = setup();
210
+ const now = Date.now();
211
+ const m = makeMemory(service, "touched", "内容".repeat(60), "project");
212
+ service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
213
+ const snapshotCut = now - 1 * 86400000;
214
+ service.touchLastAccess(m.id, new Date(snapshotCut + 60000).toISOString());
215
+ const updated = service.demoteToSummary(m.id, "摘要", { minRefTimeMs: snapshotCut });
216
+ assert.equal(updated, undefined, "touched-after-snapshot memory is not demoted");
217
+ assert.equal(service.getById(m.id)._full_content ?? null, null, "no demotion happened");
218
+ store.close();
219
+ });
220
+
221
+ test("sleep: demotion demotes a memory still cold after snapshot", () => {
222
+ const { service, store } = setup();
223
+ const now = Date.now();
224
+ const m = makeMemory(service, "stillcold", "内容".repeat(60), "project");
225
+ service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
226
+ const snapshotCut = now - 1 * 86400000;
227
+ const updated = service.demoteToSummary(m.id, "摘要", { minRefTimeMs: snapshotCut });
228
+ assert.ok(updated && updated._full_content, "cold memory demoted to summary");
229
+ store.close();
230
+ });
231
+
232
+ test("sleep: pattern discovery filters fabricated evidence ids", async () => {
233
+ const { service, store } = setup();
234
+ const a = makeMemory(service, "模式A", "反复出现的模式A", "project");
235
+ const b = makeMemory(service, "模式B", "反复出现的模式B", "project");
236
+ const ctx = mockCtx(() =>
237
+ JSON.stringify([
238
+ { action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3, evidence: [a.id, "fake-zzz", b.id] }
239
+ ])
240
+ );
241
+ const result = await runSleep(ctx, service, baseConfig({ sleepPatternMinMemories: 10 }), ctx.logger, null, null);
242
+ assert.ok(!JSON.stringify(result.phases.patterns).includes("fake-zzz"), "fabricated evidence filtered before apply");
243
+ const pattern = service.all().find((x) => x.type === "pattern");
244
+ assert.ok(pattern, "pattern minted");
245
+ store.close();
246
+ });
247
+
248
+ test("sleep: relation completion links orphan entities co-occurring in a memory", async () => {
249
+ const { service, store } = setup();
250
+ const alpha = service.createEntity({ name: "Alpha", type: "project" });
251
+ const beta = service.createEntity({ name: "Beta", type: "project" });
252
+ makeMemory(service, "协作", "Alpha 与 Beta 一起干活", "project");
253
+ const ctx = mockCtx(() => "[]");
254
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
255
+ assert.equal(result.phases.relations.status, "ok", "relations phase ran");
256
+ const rels = service.getRelations(alpha.id);
257
+ assert.ok(rels.length >= 1, "orphan Alpha gained a relation");
258
+ assert.ok(rels.some((r) => r.to_entity === beta.id || r.from_entity === beta.id), "relation targets Beta");
259
+ store.close();
260
+ });
261
+
262
+ test("sleep: runSleep writes an audit receipt with run_type='sleep'", async () => {
263
+ const { service, store } = setup();
264
+ const now = Date.now();
265
+ const m = makeMemory(service, "cold", "内容".repeat(60), "project");
266
+ service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
267
+ const ctx = mockCtx(() => "[]");
268
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
269
+ const runs = service.listDreamRuns();
270
+ const last = runs[runs.length - 1];
271
+ assert.equal(last.run_type, "sleep", "audit row tagged sleep");
272
+ assert.equal(result.runId, last.id, "run id matches audit row");
273
+ assert.ok(typeof result.receipt === "string" && result.receipt.startsWith("dsh-mneme:run:"), "receipt is the audit string");
274
+ store.close();
275
+ });
276
+
277
+ test("sleep: a failing phase does not block the others (fail-safe)", async () => {
278
+ const { service, store } = setup();
279
+ service.setEmbedder({
280
+ embed: async () => { throw new Error("embed down"); },
281
+ embedSingle: async () => { throw new Error("embed down"); },
282
+ modelHash: "x", dimension: 3
283
+ });
284
+ const now = Date.now();
285
+ const m = makeMemory(service, "cold", "内容".repeat(60), "project");
286
+ service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
287
+ const ctx = mockCtx(() => "[]");
288
+ const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
289
+ assert.equal(result.phases.conflicts.status, "skipped", "conflicts phase degraded gracefully (no usable vectors)");
290
+ assert.equal(result.phases.demotion.status, "ok", "demotion still ran");
291
+ assert.equal(result.status, "ok", "overall run still ok despite conflicts degrading");
292
+ assert.ok(service.getById(m.id)._full_content, "cold memory still demoted despite conflicts failure");
293
+ store.close();
294
+ });
295
+
296
+ test("sleep: no LLM route skips LLM phases but demotion still runs", async () => {
297
+ const { service, store } = setup();
298
+ const now = Date.now();
299
+ const m = makeMemory(service, "cold", "内容".repeat(60), "project");
300
+ service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
301
+ const ctx = mockCtx(() => "[]", null); // currentSelection() → null, no route
302
+ const result = await runSleep(ctx, service, demotionConfig(), ctx.logger, null, null);
303
+ assert.equal(result.phases.conflicts.status, "skipped", "no llm route → conflicts skipped");
304
+ assert.equal(result.phases.patterns.status, "skipped", "no llm route → patterns skipped");
305
+ assert.equal(result.phases.demotion.status, "ok", "demotion is LLM-free and runs");
306
+ store.close();
307
+ });
308
+
309
+ // ------------------------------------------------------------ conflict strictness
310
+
311
+ function seedConflictPair(service, vectorIndex, sim) {
312
+ const a = makeMemory(service, "主题X", "内容A 关于主题X", "project");
313
+ const b = makeMemory(service, "主题X副本", "内容B 关于主题X", "project");
314
+ const sin = Math.sqrt(Math.max(0, 1 - sim * sim));
315
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
316
+ vectorIndex.saveEmbedding(b.id, [sim, sin, 0]);
317
+ return { a, b };
318
+ }
319
+
320
+ test("sleep: conflict strictness gentle ignores sim 0.88 pairs", async () => {
321
+ const { service, store, vectorIndex } = setup();
322
+ seedConflictPair(service, vectorIndex, 0.88);
323
+ const config = baseConfig({ sleepConflictStrictness: "gentle", conflictFreezeEnabled: true });
324
+ const ctx = mockCtx(() => "[]");
325
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
326
+ assert.equal(result.phases.conflicts.status, "skipped", "0.88 below gentle 0.92 → no conflicts");
327
+ store.close();
328
+ });
329
+
330
+ test("sleep: conflict strictness normal resolves sim 0.88 pairs", async () => {
331
+ const { service, store, vectorIndex } = setup();
332
+ seedConflictPair(service, vectorIndex, 0.88);
333
+ const config = baseConfig({ sleepConflictStrictness: "normal", conflictFreezeEnabled: true });
334
+ const ctx = mockCtx(() => "[]");
335
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
336
+ assert.equal(result.phases.conflicts.status, "ok", "0.88 above normal 0.85 → conflicts found");
337
+ assert.ok(result.phases.conflicts.frozen >= 1, "pairs frozen for review");
338
+ store.close();
339
+ });
340
+
341
+ test("sleep: conflict strictness aggressive adjudicates low-confidence pairs", async () => {
342
+ const { service, store, vectorIndex } = setup();
343
+ seedConflictPair(service, vectorIndex, 0.80);
344
+ const config = baseConfig({ sleepConflictStrictness: "aggressive", conflictFreezeEnabled: true });
345
+ const ctx = mockCtx(() => "[]");
346
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
347
+ assert.equal(result.phases.conflicts.status, "ok", "0.80 above aggressive 0.75 → conflicts found");
348
+ assert.ok(result.phases.conflicts.frozen >= 1, "pairs frozen");
349
+ store.close();
350
+ });
351
+
352
+ test("sleep: conflict LLM arbitration applies winner/loser", async () => {
353
+ const { service, store, vectorIndex } = setup();
354
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
355
+ const ctx = mockCtx(() =>
356
+ JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
357
+ );
358
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
359
+ assert.equal(result.phases.conflicts.status, "ok", "conflict resolved");
360
+ assert.equal(service.getById(b.id).archived, true, "loser archived by arbitration");
361
+ store.close();
362
+ });
363
+
364
+ test("sleep: runSleep is abortable via signal between phases", async () => {
365
+ const { service, store } = setup();
366
+ const now = Date.now();
367
+ const m = makeMemory(service, "cold", "内容".repeat(60), "project");
368
+ service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
369
+ const ctrl = new AbortController();
370
+ ctrl.abort(); // pre-aborted
371
+ const ctx = mockCtx(() => "[]");
372
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, ctrl.signal);
373
+ assert.equal(result.phases.conflicts, undefined, "aborted before any phase ran");
374
+ store.close();
375
+ });
376
+
377
+ // Issue #257:冲突/模式两阶段的输出预算原硬编码 2048——sleepActionSet=full
378
+ // 实测 24 对需约 6967 token,截断即 invalid decisions json 整轮失败。两阶段
379
+ // 必须读取 sleepMaxTokens 配置并透传到 ctx.llm.stream 的 options。
380
+ test("issue#257 conflict and pattern phases forward configured sleepMaxTokens", async () => {
381
+ const { service, store, vectorIndex } = setup();
382
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
383
+ const seen = [];
384
+ const ctx = mockCtx((userText) =>
385
+ JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
386
+ );
387
+ const origStream = ctx.llm.stream.bind(ctx.llm);
388
+ ctx.llm.stream = async function* (options) {
389
+ if (options?.purpose === "sleep-conflict" || options?.purpose === "sleep-pattern") {
390
+ seen.push({ purpose: options.purpose, maxTokens: options.maxTokens });
391
+ }
392
+ if (options?.purpose === "sleep-pattern") {
393
+ // pattern 阶段喂确定性空结果(no patterns found → skipped),
394
+ // 不复用 conflict 决策(宽松模式下会被当 Fabricated 静默跳过)。
395
+ yield { type: "text-delta", index: 0, text: "[]" };
396
+ yield { type: "finish", reason: { kind: "stop" } };
397
+ return;
398
+ }
399
+ yield* origStream(options);
400
+ };
401
+ const result = await runSleep(ctx, service, baseConfig({ sleepMaxTokens: 6967 }), ctx.logger, { embedder, vectorIndex }, null);
402
+ assert.equal(result.status, "ok", "run completes with the configured budget");
403
+ assert.ok(seen.some((s) => s.purpose === "sleep-conflict" && s.maxTokens === 6967), "conflict phase reads sleepMaxTokens");
404
+ assert.ok(seen.some((s) => s.purpose === "sleep-pattern" && s.maxTokens === 6967), "pattern phase reads sleepMaxTokens");
405
+ store.close();
406
+ });
407
+
408
+ test("issue#257 schema default for sleepMaxTokens is 8192 (was hardcoded 2048)", () => {
409
+ const cfg = Config({});
410
+ assert.equal(cfg.sleepMaxTokens, 8192, "schema default covers the measured full-set peak (6967)");
411
+ });
412
+
413
+ // ------------------------------------------- sleep LLM accounting (issue #250)
414
+ // Bug8 给 dream 与 summarize 接记账时漏了 sleep:src/dream/sleep.js 有一份自己的
415
+ // streamText,签名里没有 onUsage,两条 LLM 链路(conflict 裁决 / pattern 挖掘)的
416
+ // token 与状态从未进 llm_audit_logs——面板因此只看到两条链路,回答不了「sleep 花了
417
+ // 多少」。下面两条用例锁定「审计行落库 + token 数值来自 usage chunk」。
418
+ // 两条链路共用同一处 streamText 改动,故放在同一轮 red-green 里。
419
+
420
+ /** 在 base ctx 的流里、finish 之前插入一个 usage chunk(形态同 llm-audit.test.js)。 */
421
+ function withUsageChunk(base, usageChunk) {
422
+ return {
423
+ ...base,
424
+ llm: {
425
+ async *stream(options) {
426
+ for await (const chunk of base.llm.stream(options)) {
427
+ if (chunk.type === "finish") yield usageChunk;
428
+ yield chunk;
429
+ }
430
+ }
431
+ }
432
+ };
433
+ }
434
+
435
+ test("issue#250 sleep conflict phase writes an llm_audit row with the streamed tokens", async () => {
436
+ const { service, store, vectorIndex } = setup();
437
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
438
+ const ctx = withUsageChunk(
439
+ mockCtx(() => JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])),
440
+ { type: "usage", usage: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 } }
441
+ );
442
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
443
+ assert.equal(result.phases.conflicts.status, "ok", "conflict resolved");
444
+
445
+ const conflict = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_conflict");
446
+ assert.ok(conflict, "sleep_conflict audit row present");
447
+ assert.equal(conflict.trigger_source, "sleep");
448
+ assert.equal(conflict.status, "success");
449
+ assert.equal(conflict.model_id, "mock:sleep-model", "route actually used is recorded");
450
+ assert.equal(conflict.input_tokens, 1200, "input tokens come from chunk.usage");
451
+ assert.equal(conflict.output_tokens, 300, "output tokens come from chunk.usage");
452
+ assert.equal(conflict.total_tokens, 1500);
453
+ assert.ok(typeof conflict.duration_ms === "number" && conflict.duration_ms >= 0);
454
+ store.close();
455
+ });
456
+
457
+ test("issue#250 sleep pattern phase writes an llm_audit row with the streamed tokens", async () => {
458
+ const { service, store } = setup();
459
+ const a = makeMemory(service, "模式A", "反复出现的模式A", "project");
460
+ const b = makeMemory(service, "模式B", "反复出现的模式B", "project");
461
+ const ctx = withUsageChunk(
462
+ mockCtx(() => JSON.stringify([
463
+ { action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3, evidence: [a.id, b.id] }
464
+ ])),
465
+ { type: "usage", usage: { inputTokens: 900, outputTokens: 250, totalTokens: 1150 } }
466
+ );
467
+ const result = await runSleep(ctx, service, baseConfig({ sleepPatternMinMemories: 10 }), ctx.logger, null, null);
468
+ assert.ok(result.phases.patterns, "pattern phase ran");
469
+
470
+ const pattern = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_pattern");
471
+ assert.ok(pattern, "sleep_pattern audit row present");
472
+ assert.equal(pattern.trigger_source, "sleep");
473
+ assert.equal(pattern.status, "success");
474
+ assert.equal(pattern.input_tokens, 900, "input tokens come from chunk.usage");
475
+ assert.equal(pattern.output_tokens, 250, "output tokens come from chunk.usage");
476
+ assert.equal(pattern.total_tokens, 1150);
477
+ store.close();
478
+ });
479
+
480
+ // 审计诚实性(issue #250):流式成功但输出里没有 JSON 数组时,phase 报 failed,
481
+ // 审计行也必须记 error——不能一个调用在两表里自相矛盾。
482
+ test("issue#250 sleep conflict parse failure is audited as status=error, not fake success", async () => {
483
+ const { service, store, vectorIndex } = setup();
484
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
485
+ const ctx = mockCtx(() => "抱歉,我无法把裁决整理成 JSON。");
486
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
487
+ assert.equal(result.phases.conflicts.status, "failed", "the phase reports the failure");
488
+
489
+ const row = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_conflict");
490
+ assert.ok(row, "the failed call is still audited");
491
+ assert.equal(row.status, "error", "a stream that returned unusable output is not a success");
492
+ assert.match(row.error_message, /no json array in llm output/);
493
+ store.close();
494
+ });
495
+
496
+ test("issue#250 sleep pattern parse failure is audited as status=error, not fake success", async () => {
497
+ const { service, store } = setup();
498
+ makeMemory(service, "模式A", "反复出现的模式A", "project");
499
+ makeMemory(service, "模式B", "反复出现的模式B", "project");
500
+ const ctx = mockCtx(() => "这不是 JSON 数组");
501
+ const result = await runSleep(ctx, service, baseConfig({ sleepPatternMinMemories: 10 }), ctx.logger, null, null);
502
+ // pattern 阶段对不可解析输出的既定语义是 skipped("no patterns found")——那是
503
+ // 「有没有铸造出模式」的结论,不是「这次调用成没成功」的结论,此处不改上游语义。
504
+ assert.equal(result.phases.patterns.status, "skipped", "the phase keeps its existing semantics");
505
+
506
+ const row = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_pattern");
507
+ assert.ok(row, "the call still consumed tokens and is still audited");
508
+ assert.equal(row.status, "error", "the audit records the call's truth, not the phase's conclusion");
509
+ assert.match(row.error_message, /no json array in llm output/);
510
+ store.close();
511
+ });
512
+
513
+ // 回归护栏:llmAudit.enabled === false 时 auditedSleepLlm 整段早退,auditError 钩子
514
+ // 不会被调用。所以两个 phase 的控制流必须各自解析输出、不能依赖那个钩子的副作用
515
+ // ——dream 的 runAuditedLlm 正是那样踩了(关掉审计 flag 会让 autoDream 直接失效)。
516
+ test("issue#250 sleep still adjudicates normally when llmAudit.enabled === false", async () => {
517
+ const { service, store, vectorIndex } = setup();
518
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
519
+ const ctx = mockCtx(() =>
520
+ JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
521
+ );
522
+ const config = baseConfig({ llmAudit: { enabled: false } });
523
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
524
+ assert.equal(result.phases.conflicts.status, "ok", "turning the audit off must not break sleep");
525
+ assert.equal(service.getById(b.id).archived, true, "the arbitration still applied");
526
+ assert.equal(service.listLlmAudits().length, 0, "no audit rows when disabled");
527
+ store.close();
528
+ });
529
+ // ------------------------------------------------------------ issue #529: input budget
530
+
531
+ // seedSimilarPairs:造 n 条同类型、两两相似度都不低于阈值的记忆,且每条只可能与
532
+ // 一条配成对(相似度按 i 递减)。用来验证「池子大于单批上限」时分批是否真的分开了。
533
+ // 这些向量不是正交的——cosine 只在 [1,0,0] 与 [s, sin, 0] 之间算,因此给每对单独
534
+ // 一个角度,与 seedConflictPair 同一套构造。
535
+ function seedSimilarPairs(service, vectorIndex, n) {
536
+ const mems = [];
537
+ for (let i = 0; i < n; i++) {
538
+ const m = makeMemory(service, `主题${i}`, `内容${i}`, "project");
539
+ mems.push(m);
540
+ }
541
+ // 全部推到同一个方向附近:任意两条的 cosine 都 ≈ 1(> 0.85),findPotentialConflicts
542
+ // 会生成 C(n,2) 对,去重阶段按相似度留下 n/2 对(每记忆至多一对)。
543
+ for (const m of mems) vectorIndex.saveEmbedding(m.id, [1, 0, 0]);
544
+ return mems;
545
+ }
546
+
547
+ // 记录**冲突相位**每次往返送进去的候选对数(user 消息里每个候选行以 "id=" 开头)。
548
+ // 必须按 purpose 过滤:同一轮里 pattern 相位也会调 LLM,把它的往返算进来会让
549
+ // 「送了几批」这个断言凭空多一批。
550
+ function countingCtx(responder, batchSizes = []) {
551
+ let call = 0;
552
+ const ctx = {
553
+ logger: { warn: () => {}, info: () => {} },
554
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
555
+ llm: {
556
+ async *stream(options) {
557
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
558
+ let reply = "[]";
559
+ if (options.purpose === "sleep-conflict") {
560
+ batchSizes.push((userText.match(/id=/g) ?? []).length);
561
+ reply = responder(userText, ++call);
562
+ }
563
+ yield { type: "text-delta", index: 0, text: reply };
564
+ yield { type: "finish", reason: { kind: "stop" } };
565
+ }
566
+ }
567
+ };
568
+ return { ctx, batchSizes, calls: () => call };
569
+ }
570
+
571
+ test("issue#529 conflict pairs are sent in bounded batches, not one giant prompt", async () => {
572
+ const { service, store, vectorIndex } = setup();
573
+ seedSimilarPairs(service, vectorIndex, 8);
574
+ const { ctx, batchSizes } = countingCtx(() => "[]");
575
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 8 });
576
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
577
+ assert.ok(batchSizes.length > 1, `expected several batches, got ${batchSizes.length}`);
578
+ assert.ok(batchSizes.every((n) => n > 0 && n <= 4), `每批不超过 2 对(每对 2 个 id): ${JSON.stringify(batchSizes)}`);
579
+ assert.deepEqual(batchSizes.slice(-2), [4, 4], "每对 2 个 id ⇒ 2 对 = 4 个 id 行");
580
+ assert.equal(result.phases.conflicts.batches, batchSizes.length, "审计里的批次数与真实往返次数一致");
581
+ store.close();
582
+ });
583
+
584
+ test("issue#529 a failing batch does not kill the whole run (degraded, not failed)", async () => {
585
+ const { service, store, vectorIndex } = setup();
586
+ seedSimilarPairs(service, vectorIndex, 8);
587
+ const { ctx } = countingCtx((_text, n) => {
588
+ // 第一批吐空(stream 正常结束但无文本 ⇒ llm failed),后面的批正常。
589
+ return n === 1 ? "" : "[]";
590
+ });
591
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 8 });
592
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
593
+ assert.equal(result.phases.conflicts.status, "degraded", "坏批只记 degraded,不判死整轮");
594
+ assert.ok(result.phases.conflicts.batches > 1, "后面的批照样跑完了");
595
+ store.close();
596
+ });
597
+
598
+ test("issue#529 a judged pair is not re-sent while neither side changed (keep leaves a trace)", async () => {
599
+ const { service, store, vectorIndex } = setup();
600
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
601
+ const config = baseConfig();
602
+ // 第一轮:判 keep(= 模型说这两条不冲突)。这曾经是彻底的 no-op。
603
+ const first = await runSleep(mockCtx(() => "[]"), service, config, { warn: () => {} }, { embedder, vectorIndex }, null);
604
+ assert.equal(first.phases.conflicts.status, "noop", "没有动作 ⇒ noop");
605
+ const ledger = service.listConflictReviewed();
606
+ assert.equal(ledger.length, 1, "判 keep 也要在台账里留一行");
607
+ assert.equal(ledger[0].outcome, "keep");
608
+ assert.deepEqual([ledger[0].memory_a, ledger[0].memory_b].sort(), [a.id, b.id].sort());
609
+
610
+ // 第二轮:两侧都没动 ⇒ 这对已经在台账里,不该再送 LLM。
611
+ const secondCtx = countingCtx(() => "[]");
612
+ const second = await runSleep(secondCtx.ctx, service, config, secondCtx.ctx.logger, { embedder, vectorIndex }, null);
613
+ assert.equal(secondCtx.calls(), 0, "同一对、两侧都没更新 ⇒ 不再送审");
614
+ assert.equal(second.phases.conflicts.ledgerSkipped, 1);
615
+
616
+ // 第三轮:动一下其中一侧(内容更新 ⇒ updated_at 前进),它就该重新入池。
617
+ // service.update 会把该行的向量作废并异步重算;测试里的 mock embedder 只支持
618
+ // 「一条一条 embed」的形式(embedSingle),批量 embed 的返回形状不是它覆盖的范围,
619
+ // 所以这里按既有夹具的做法手工补一次向量——被测的是台账判据,不是回填路径。
620
+ service.update(b.id, { content: "内容B 关于主题X(改)" });
621
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
622
+ const thirdCtx = countingCtx(() => "[]");
623
+ const third = await runSleep(thirdCtx.ctx, service, config, thirdCtx.ctx.logger, { embedder, vectorIndex }, null);
624
+ assert.equal(thirdCtx.calls(), 1, "任一侧更新过 ⇒ 重新送审");
625
+ assert.equal(third.phases.conflicts.ledgerSkipped, 0);
626
+ store.close();
627
+ });
628
+
629
+ test("issue#529 per-run cap bounds the pairs adjudicated in one run", async () => {
630
+ const { service, store, vectorIndex } = setup();
631
+ seedSimilarPairs(service, vectorIndex, 12);
632
+ const { ctx, calls } = countingCtx(() => "[]");
633
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 4 });
634
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
635
+ assert.ok(calls() <= 2, `每轮最多 4 对 ÷ 每批 2 对 = 2 次往返,实际 ${calls()}`);
636
+ assert.ok(result.phases.conflicts.perRunSkipped > 0, "超出本轮预算的对要被记下来");
637
+ store.close();
638
+ });
639
+
640
+ test("issue#529 user activity between batches aborts instead of reporting noop", async () => {
641
+ const { service, store, vectorIndex } = setup();
642
+ seedSimilarPairs(service, vectorIndex, 12);
643
+ const controller = new AbortController();
644
+ // 第一批返回之后用户恢复活动(noteWrite → abort)。此时第二、三批根本没送出去,
645
+ // 把它们算成「看过、没冲突」(noop)是这一相位最容易犯的谎。
646
+ const { ctx } = countingCtx(() => {
647
+ controller.abort();
648
+ return "[]";
649
+ });
650
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 12 });
651
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, controller.signal);
652
+ assert.equal(result.phases.conflicts.status, "aborted", "中途被打断不是 noop");
653
+ assert.equal(result.phases.conflicts.batches, 6, "计划批次数照实记(不是实际送出的批次数)");
654
+ // 整轮层也要如实:deriveStatus 此前只认 failed/error,aborted 相位会被静默折成
655
+ // noop,审计行于是把「没跑完」记成「跑过、没什么事」——与相位层同一个谎。
656
+ assert.equal(result.status, "aborted", "整轮状态沿用相位的 aborted,不折回 noop");
657
+ store.close();
658
+ });
659
+
660
+ test("issue#529 failed batches still report pairs/batches (audit used to lose them)", async () => {
661
+ const { service, store, vectorIndex } = setup();
662
+ seedSimilarPairs(service, vectorIndex, 4);
663
+ const { ctx } = countingCtx(() => "");
664
+ const config = baseConfig({ sleepConflictMaxPairs: 2, sleepConflictMaxPairsPerRun: 4 });
665
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
666
+ const phase = result.phases.conflicts;
667
+ assert.equal(phase.status, "failed", "全部批次失败 ⇒ 整轮 failed");
668
+ assert.ok(Number.isInteger(phase.pairs) && phase.pairs > 0, "失败轮也带 pairs");
669
+ assert.ok(Number.isInteger(phase.batches) && phase.batches > 0, "失败轮也带 batches");
670
+ store.close();
671
+ });
672
+
673
+
674
+ // ------------------------------------------- issue #536 review: CodeRabbit 三条
675
+
676
+ // 把一条记忆的 created_at / updated_at 一起回拨。update 决策有 24h 保护期,
677
+ // 台账判据比的是 updated_at——两处都要动,只改一个会让用例互相干扰。
678
+ function backdate(store, id, hours) {
679
+ const ts = new Date(Date.now() - hours * 3600000).toISOString();
680
+ store.db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run(ts, ts, id);
681
+ }
682
+
683
+ // 造一对向量完全相同的记忆(similarity = 1)。
684
+ function seedIdenticalPair(service, vectorIndex, dir, tag) {
685
+ const a = makeMemory(service, `${tag}A`, `${tag} 内容A`, "project");
686
+ const b = makeMemory(service, `${tag}B`, `${tag} 内容B`, "project");
687
+ vectorIndex.saveEmbedding(a.id, dir);
688
+ vectorIndex.saveEmbedding(b.id, dir);
689
+ return { a, b };
690
+ }
691
+
692
+ test("issue#536 review ①: an already-judged pair must not eat a memory's pairing slot", async () => {
693
+ const { service, store, vectorIndex } = setup();
694
+ // a-b 相似度 1.0,a-c 与 b-c 都是 0.9:按相似度排序先去重的旧写法会先选中 a-b,
695
+ // 再把 a-b 当"已审"丢掉,于是 a 的名额被一个注定被过滤的对占住,a-c 进不来。
696
+ const a = makeMemory(service, "主题X", "内容A 关于主题X", "project");
697
+ const b = makeMemory(service, "主题X副本", "内容B 关于主题X", "project");
698
+ const c = makeMemory(service, "主题X变体", "内容C 关于主题X", "project");
699
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
700
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
701
+ vectorIndex.saveEmbedding(c.id, [0.9, Math.sqrt(1 - 0.81), 0]);
702
+ // a-b 已经判过(keep),且两侧此后都没动过 ⇒ 本轮不该再送。
703
+ service.saveConflictReviewed({
704
+ run_id: "seed",
705
+ memory_a: a.id,
706
+ memory_b: b.id,
707
+ outcome: "keep",
708
+ judged_at: new Date(Date.now() + 60000).toISOString()
709
+ });
710
+
711
+ const { ctx, calls } = countingCtx(() => "[]");
712
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
713
+ assert.equal(calls(), 1, "台账预筛必须发生在去重之前,a 要能和 c 配对送出去");
714
+ assert.equal(result.phases.conflicts.ledgerSkipped, 1, "被台账挡下的对数照实记");
715
+ store.close();
716
+ });
717
+
718
+ test("issue#536 review ②: reflectionUpdateMaxPerRun stays a whole-run cap across batches", async () => {
719
+ const { service, store, vectorIndex } = setup();
720
+ // 两对向量正交,互不干扰 ⇒ 去重后正好两对,每批一对。
721
+ const p1 = seedIdenticalPair(service, vectorIndex, [1, 0, 0], "甲");
722
+ const p2 = seedIdenticalPair(service, vectorIndex, [0, 1, 0], "乙");
723
+ for (const m of [p1.a, p1.b, p2.a, p2.b]) backdate(store, m.id, 48);
724
+
725
+ // 每批都提一条 update:整轮上限 1,所以只有先跑的那批能落地。
726
+ const { ctx, calls } = countingCtx((text) => {
727
+ const id = text.includes(p1.a.id) ? p1.a.id : p2.a.id;
728
+ const next = text.includes(p1.a.id) ? "甲 内容A(更新)" : "乙 内容A(更新)";
729
+ return JSON.stringify([{ action: "update", ids: [id], content: next, reason: "内容有误" }]);
730
+ });
731
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2, reflectionUpdateMaxPerRun: 1 });
732
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
733
+ assert.equal(calls(), 2, "两对各自成批,都要送");
734
+ const updated = [p1.a, p2.a].filter((m) => service.getById(m.id).content.endsWith("(更新)"));
735
+ assert.equal(updated.length, 1, "update 是整轮上限,不是每批上限——只能落地一条");
736
+ assert.equal(result.phases.conflicts.batches, 2);
737
+ store.close();
738
+ });
739
+
740
+ test("issue#536 review ③: a decision that never landed must not be recorded as judged", async () => {
741
+ const { service, store, vectorIndex } = setup();
742
+ const { a } = seedConflictPair(service, vectorIndex, 1.0);
743
+ // 提一条"不改任何字段"的 update:validateDecisions 会把它跳过(skipInvalid),
744
+ // 该条决策没有落地。若照旧把整批送审对都写进台账,这一对就会被判成"已审"
745
+ // 而永远不再送——沉默地永久压制一个真实冲突。
746
+ const { ctx, calls } = countingCtx(() =>
747
+ JSON.stringify([{ action: "update", ids: [a.id], content: "内容A 关于主题X" }])
748
+ );
749
+ const config = baseConfig({ reflectionUpdateMinAgeHours: 0 });
750
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
751
+ assert.equal(calls(), 1, "这一对确实被送出去判过");
752
+ assert.equal(service.listConflictReviewed().length, 0, "没落地的决策不能记成已审");
753
+ assert.ok(result.phases.conflicts.skipped.length > 0, "跳过明细照常进审计");
754
+ store.close();
755
+ });
756
+
757
+ test("issue#536 review ④: judged_at lives in the store's clock domain, not the wall clock", async () => {
758
+ const { service, store, vectorIndex } = setup();
759
+ const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
760
+ // 造一次「库里的单调时钟比墙钟快」——src/store.js 的 nowIso 在同一毫秒内的连续
761
+ // 写入上逐次 +1ms,高频写入的库完全可能领先墙钟几毫秒(CI 的 test-host-legacy
762
+ // 腿抓到过这次真 flake)。这里直接把两侧推到墙钟之后,等价于那个状态。
763
+ const ahead = new Date(Date.now() + 5000).toISOString();
764
+ for (const id of [a.id, b.id]) {
765
+ store.db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run(ahead, ahead, id);
766
+ }
767
+
768
+ // 第一轮判 keep ⇒ 记台账。
769
+ const first = await runSleep(mockCtx(() => "[]"), service, baseConfig(), { warn: () => {} }, { embedder, vectorIndex }, null);
770
+ assert.equal(first.phases.conflicts.status, "noop");
771
+ const ledger = service.listConflictReviewed();
772
+ assert.equal(ledger.length, 1, "判 keep 要留一行");
773
+ assert.ok(
774
+ ledger[0].judged_at >= ahead,
775
+ "judged_at 不能早于被测行自己的 updated_at(" + ledger[0].judged_at + " vs " + ahead + ")"
776
+ );
777
+
778
+ // 第二轮:两侧确实没动过 ⇒ 不许重送。若 judged_at 取了墙钟,这里会读成
779
+ // 「判过之后又被改过」,判 keep 的死循环原样复活。
780
+ const second = countingCtx(() => "[]");
781
+ await runSleep(second.ctx, service, baseConfig(), second.ctx.logger, { embedder, vectorIndex }, null);
782
+ assert.equal(second.calls(), 0, "时钟领先墙钟时,已审的对仍然不许重送");
783
+ store.close();
784
+ });
785
+
786
+ test("issue#536 review ⑤: a batch that throws does not take the rest of the run with it", async () => {
787
+ const { service, store, vectorIndex } = setup();
788
+ // 两对正交向量 ⇒ 两批。让**第一批**的 LLM 往返直接抛(不是返回错误:返回错误那条
789
+ // 通路本来就有失败返回,早被前面的用例覆盖了)。要验的是:adjudicateBatch 内部
790
+ // 任何未被兜住的异常,都只折损它自己那一批,后面的批次照跑、之前的汇总不丢。
791
+ // applyDecisions 把 service.update 的异常吞进 failures(decisions.js:532-540),
792
+ // 所以打桩 service 的方法测不到这条守卫,必须在 LLM 这一层抛。
793
+ const p1 = seedIdenticalPair(service, vectorIndex, [1, 0, 0], "甲");
794
+ const p2 = seedIdenticalPair(service, vectorIndex, [0, 1, 0], "乙");
795
+ for (const m of [p1.a, p1.b, p2.a, p2.b]) backdate(store, m.id, 48);
796
+ const { ctx, calls } = countingCtx((text) => {
797
+ const id = text.includes(p1.a.id) ? p1.a.id : p2.a.id;
798
+ const next = text.includes(p1.a.id) ? "甲 内容A(更新)" : "乙 内容A(更新)";
799
+ return JSON.stringify([{ action: "update", ids: [id], content: next, reason: "内容有误" }]);
800
+ });
801
+ const realStream = ctx.llm.stream;
802
+ let thrown = false;
803
+ ctx.llm.stream = async function* (options) {
804
+ if (!thrown && options.purpose === "sleep-conflict") {
805
+ thrown = true;
806
+ throw new Error("boom inside adjudicateBatch");
807
+ }
808
+ yield* realStream.call(this, options);
809
+ };
810
+ const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2, reflectionUpdateMaxPerRun: 2 });
811
+ const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
812
+ assert.equal(thrown, true, "第一批确实抛了,否则这条用例什么也没验");
813
+ assert.equal(calls(), 1, "第一批抛错后第二批仍然要跑");
814
+ assert.notEqual(result.phases.conflicts.status, "failed", "只要有一批活下来,就不是整轮 failed");
815
+ // 抛的是第一批(哪个 pair 先送取决于排序,所以两条都问,只要求"恰好一条活下来"):
816
+ // 断言落在结果上,不落在顺序上,免得排序一变这条用例就假红。
817
+ const landed = [p1.a, p2.a].filter((m) => service.getById(m.id).content.endsWith("(更新)"));
818
+ assert.equal(landed.length, 1, "第二批照常落地,且只落地一批");
819
+ assert.equal(result.phases.conflicts.batches, 2, "抛错的批次不吞掉轮次的形状");
820
+ store.close();
821
+ });
822
+
823
+ // ------------------------------------------------------------ affective (#619)
824
+
825
+ // Only the affective phase gets a real answer; every other LLM phase says "[]"
826
+ // so this file keeps testing one thing at a time.
827
+ function affectiveCtx(payload) {
828
+ return {
829
+ logger: { warn: () => {}, info: () => {} },
830
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "affective-model" }) },
831
+ llm: {
832
+ async *stream(options) {
833
+ const reply = options.purpose === "sleep-affective"
834
+ ? JSON.stringify(payload)
835
+ : "[]";
836
+ yield { type: "text-delta", index: 0, text: reply };
837
+ yield { type: "finish", reason: { kind: "stop" } };
838
+ }
839
+ }
840
+ };
841
+ }
842
+
843
+ function affectiveConfig(overrides = {}) {
844
+ return baseConfig({ sleepAffectiveEnabled: true, sleepPatternMinMemories: 10, ...overrides });
845
+ }
846
+
847
+ function linkSession(service, memoryId, sessionId) {
848
+ service.saveMemoryProvenance({ memoryId, sessionId, seqStart: 1, seqEnd: 2, createdAt: new Date().toISOString() });
849
+ }
850
+
851
+ test("sleep: affective phase stays off unless explicitly enabled", async () => {
852
+ const { service, store } = setup();
853
+ service.createEntity({ name: "桉桉", type: "person" });
854
+ service.createEntity({ name: "答辩", type: "concept" });
855
+ makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
856
+ const ctx = affectiveCtx([]);
857
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
858
+ assert.equal(result.phases.affective.status, "skipped");
859
+ assert.equal(result.phases.affective.reason, "disabled");
860
+ store.close();
861
+ });
862
+
863
+ test("sleep: a one-session emotion lands as affective_context, and a trait claim is downgraded", async () => {
864
+ // 这条是整个 #619 的主 claim:模型说这是「稳定特质」,但证据只覆盖一个会话
865
+ // ⇒ 必须降级成场合边,绝不能铸出「他是焦虑的人」。
866
+ const { service, store } = setup();
867
+ const person = service.createEntity({ name: "桉桉", type: "person" });
868
+ const target = service.createEntity({ name: "答辩", type: "concept" });
869
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
870
+ linkSession(service, mem.id, "session-A");
871
+ const ctx = affectiveCtx([
872
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: [mem.id], why: "提到答辩就焦虑" }
873
+ ]);
874
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
875
+ const phase = result.phases.affective;
876
+ assert.equal(phase.status, "ok");
877
+ assert.equal(phase.downgraded, 1, "trait claim on one session must be counted as downgraded");
878
+ assert.equal(phase.traits, 0, "no trait pattern minted");
879
+ assert.equal(service.all().filter((m) => m.type === "pattern").length, 0, "nothing minted as a trait");
880
+ const rels = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
881
+ assert.equal(rels.length, 1, "emotion landed as a relation instead");
882
+ assert.equal(rels[0].to_entity, target.id);
883
+ assert.equal(rels[0].source, "llm");
884
+ assert.equal(rels[0].metadata.attribution, "occasion");
885
+ assert.equal(rels[0].metadata.downgradedFrom, "trait");
886
+ store.close();
887
+ });
888
+
889
+ test("sleep: an emotion backed by enough independent sessions mints a trait pattern", async () => {
890
+ const { service, store } = setup();
891
+ service.createEntity({ name: "桉桉", type: "person" });
892
+ service.createEntity({ name: "答辩", type: "concept" });
893
+ const ids = [];
894
+ for (const session of ["s1", "s2", "s3"]) {
895
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
896
+ linkSession(service, mem.id, session);
897
+ ids.push(mem.id);
898
+ }
899
+ const ctx = affectiveCtx([
900
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次独立答辩前都这样" }
901
+ ]);
902
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
903
+ const phase = result.phases.affective;
904
+ assert.equal(phase.downgraded, 0, "three independent sessions is a real trait");
905
+ assert.equal(phase.traits, 1);
906
+ assert.equal(phase.applied, 1, "trait went through applyDecisions");
907
+ const patterns = service.all().filter((m) => m.type === "pattern");
908
+ assert.equal(patterns.length, 1, "one trait pattern minted");
909
+ assert.match(patterns[0].title, /稳定倾向/);
910
+ assert.match(patterns[0].title, /答辩/, "标题必须带对象,否则人对什么焦虑都看不出来");
911
+ assert.match(patterns[0].content, /3 个相互独立/);
912
+ store.close();
913
+ });
914
+
915
+ test("sleep: two different targets mint two patterns instead of one swallowing the other", async () => {
916
+ // #619 / Ephor 第 5 轮:标题原本不含 target,而 applyCreate 走
917
+ // saveWithDedupe,按 (type, title) 找同题行。于是「对答辩长期焦虑」与
918
+ // 「对面试长期焦虑」共用同一个标题,第二条被判为重复——不报错、不进
919
+ // failures、applied 还如实显示写入了。一条真实结论就这样静默消失,
920
+ // 正好落在本相位「降级不丢弃」这条原则的反面。
921
+ const { service, store } = setup();
922
+ service.createEntity({ name: "桉桉", type: "person" });
923
+ service.createEntity({ name: "答辩", type: "concept" });
924
+ service.createEntity({ name: "面试", type: "concept" });
925
+ const ids = { 答辩: [], 面试: [] };
926
+ for (const target of ["答辩", "面试"]) {
927
+ for (const session of ["s1", "s2", "s3"]) {
928
+ const mem = makeMemory(service, `${target}-${session}`, `第 ${session} 次${target}前都失眠`);
929
+ linkSession(service, mem.id, `${target}-${session}`);
930
+ ids[target].push(mem.id);
931
+ }
932
+ }
933
+ const ctx = affectiveCtx([
934
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids.答辩, why: "三次答辩前都这样" },
935
+ { person: "桉桉", target: "面试", emotion: "焦虑", attribution: "trait", evidence: ids.面试, why: "三次面试前都这样" }
936
+ ]);
937
+ const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 2 }), ctx.logger, { embedder, vectorIndex: null }, null);
938
+ const phase = result.phases.affective;
939
+ assert.equal(phase.traits, 2, "两条 trait 候选");
940
+ assert.equal(phase.failures.length, 0, "没有失败");
941
+ assert.equal(phase.applied, 2, "两条都真的落库了");
942
+ const patterns = service.all().filter((m) => m.type === "pattern");
943
+ assert.equal(patterns.length, 2, "两个对象各一条,第二条不许被静默合并掉");
944
+ const titles = patterns.map((m) => m.title).sort();
945
+ assert.ok(titles.some((t) => t.includes("答辩")), "答辩那条在");
946
+ assert.ok(titles.some((t) => t.includes("面试")), "面试那条也在 —— 这条以前会消失");
947
+ assert.notEqual(titles[0], titles[1], "标题必须不同,否则去重键仍会撞");
948
+ store.close();
949
+ });
950
+
951
+ test("sleep: an occasion edge is marked when the same emotion is later confirmed as a trait", async () => {
952
+ // #619 / Ephor 第 5 轮 C:先有 occasion 边,后来证据够了判成 trait。
953
+ // 边不改(它表示「这次场合观察到过」,这句仍然是真的),但要标上
954
+ // promotedToTrait——否则同一件事实在图上是「一次性」、在 pattern 里是
955
+ // 「长期」,读归因覆盖率的人拿到两份互相矛盾的记录,而这不会自愈。
956
+ const { service, store } = setup();
957
+ service.createEntity({ name: "桉桉", type: "person" });
958
+ service.createEntity({ name: "答辩", type: "concept" });
959
+ const mem0 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
960
+ linkSession(service, mem0.id, "s0");
961
+ const ctxOccasion = affectiveCtx([
962
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem0.id] }
963
+ ]);
964
+ const first = await runSleep(ctxOccasion, service, affectiveConfig(), ctxOccasion.logger, { embedder, vectorIndex: null }, null);
965
+ assert.equal(first.phases.affective.relations, 1, "先落一条 occasion 边");
966
+ const ids = [];
967
+ for (const session of ["s1", "s2", "s3"]) {
968
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
969
+ linkSession(service, mem.id, session);
970
+ ids.push(mem.id);
971
+ }
972
+ const ctxTrait = affectiveCtx([
973
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次独立答辩前都这样" }
974
+ ]);
975
+ const second = await runSleep(ctxTrait, service, affectiveConfig(), ctxTrait.logger, { embedder, vectorIndex: null }, null);
976
+ assert.equal(second.phases.affective.traits, 1, "够格铸 trait");
977
+ const person = service.findEntityByName("桉桉");
978
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
979
+ assert.equal(edges.length, 1, "仍然是同一条边");
980
+ assert.equal(edges[0].metadata.sessionCount, 1, "边的场合数不因后来升格而改写");
981
+ assert.equal(edges[0].metadata.promotedToTrait, true, "但必须标上升格过,否则两份记录互相打脸");
982
+ store.close();
983
+ });
984
+
985
+ test("sleep: unknown entities and fabricated evidence are reported in dropped, not silently discarded", async () => {
986
+ const { service, store } = setup();
987
+ service.createEntity({ name: "桉桉", type: "person" });
988
+ service.createEntity({ name: "答辩", type: "concept" });
989
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
990
+ linkSession(service, mem.id, "session-A");
991
+ const ctx = affectiveCtx([
992
+ { person: "不存在的人", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] },
993
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: ["fake-zzz"] },
994
+ { person: "桉桉", target: "答辩", emotion: "", attribution: "occasion", evidence: [mem.id] }
995
+ ]);
996
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
997
+ const phase = result.phases.affective;
998
+ assert.equal(phase.relations, 0, "nothing landed");
999
+ assert.equal(phase.dropped.length, 3, "every rejection is accounted for");
1000
+ const reasons = phase.dropped.map((d) => d.reason).sort();
1001
+ assert.deepEqual(reasons, ["no emotion", "no real evidence", "unknown entity"]);
1002
+ store.close();
1003
+ });
1004
+
1005
+ test("sleep: the affective phase never adds a related_to edge", async () => {
1006
+ // 变异自证的反面:如果情感边走的是 related_to(旧兜底行为),这条会挂。
1007
+ const { service, store } = setup();
1008
+ service.createEntity({ name: "桉桉", type: "person" });
1009
+ service.createEntity({ name: "答辩", type: "concept" });
1010
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1011
+ linkSession(service, mem.id, "session-A");
1012
+ const before = service.all().map((m) => m.id).length;
1013
+ const ctx = affectiveCtx([
1014
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1015
+ ]);
1016
+ await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1017
+ const person = service.findEntityByName("桉桉");
1018
+ const rels = service.getRelations(person.id);
1019
+ assert.equal(rels.filter((r) => r.relation_type === "related_to").length, 0, "no related_to smuggled in");
1020
+ assert.equal(rels.filter((r) => r.relation_type === "affective_context").length, 1);
1021
+ assert.equal(service.all().length, before, "no memory minted for a single-occasion emotion");
1022
+ store.close();
1023
+ });
1024
+
1025
+ test("sleep: replaying the same segment merges into the existing edge instead of stacking", async () => {
1026
+ // #619 / Ephor 第 2 条:entity_relations 没有 UNIQUE,saveRelation 每次新插。
1027
+ // 探针拿同一批段反复回放时,重复边会把 sessionCount 抬高、主指标失真。
1028
+ // 而判重不能只「丢弃」:同一场合后来又看到更多证据时,那条边上的
1029
+ // sessionCount 会永远停在第一次,将来读归因覆盖率会一直读到 1。
1030
+ const { service, store } = setup();
1031
+ service.createEntity({ name: "桉桉", type: "person" });
1032
+ service.createEntity({ name: "答辩", type: "concept" });
1033
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1034
+ linkSession(service, mem.id, "session-A");
1035
+ const ctx = affectiveCtx([
1036
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1037
+ ]);
1038
+ const first = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1039
+ assert.equal(first.phases.affective.relations, 1, "第一轮写了一条");
1040
+ const second = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1041
+ assert.equal(second.phases.affective.relations, 0, "第二轮不再新插边");
1042
+ assert.ok(second.phases.affective.merged >= 1, "第二轮认出了同一场合,走合并而不是丢弃");
1043
+ const person = service.findEntityByName("桉桉");
1044
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1045
+ assert.equal(edges.length, 1, "库里仍只有一条边");
1046
+ assert.equal(edges[0].metadata.sessionCount, 1, "同一会话不会把场合数吹大");
1047
+ assert.deepEqual(edges[0].metadata.evidence, [mem.id], "证据不变");
1048
+ store.close();
1049
+ });
1050
+
1051
+ test("sleep: two memories from one session merge into one edge, not two", async () => {
1052
+ // 同一会话里的两条记忆各说一句「他很焦虑」,证据天然不相交,但它们是
1053
+ // 同一个场合。按 evidence 交集判会各写一条,把边数与场合数一起吹大。
1054
+ const { service, store } = setup();
1055
+ service.createEntity({ name: "桉桉", type: "person" });
1056
+ service.createEntity({ name: "答辩", type: "concept" });
1057
+ const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1058
+ const m2 = makeMemory(service, "答辩前一晚", "越想越慌,睡不着");
1059
+ linkSession(service, m1.id, "session-A");
1060
+ linkSession(service, m2.id, "session-A");
1061
+ const ctx = affectiveCtx([
1062
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id] },
1063
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m2.id] }
1064
+ ]);
1065
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1066
+ assert.equal(result.phases.affective.relations, 1, "同一会话只该有一条边");
1067
+ assert.ok(result.phases.affective.merged >= 1, "第二条按同场合合并进来");
1068
+ const person = service.findEntityByName("桉桉");
1069
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1070
+ assert.equal(edges.length, 1);
1071
+ assert.equal(edges[0].metadata.sessionCount, 1, "两条记忆都在同一会话,场合数仍是 1");
1072
+ assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort(), "证据取并集");
1073
+ store.close();
1074
+ });
1075
+
1076
+ test("sleep: a later occasion extends the existing edge instead of being dropped", async () => {
1077
+ // 第一轮 1 个会话写边,第二轮攒到 2 个会话(含新会话)。如果判成重复丢掉,
1078
+ // 边上的 sessionCount 会冻结在 1;将来用它算归因覆盖率就一直读到 1。
1079
+ const { service, store } = setup();
1080
+ service.createEntity({ name: "桉桉", type: "person" });
1081
+ service.createEntity({ name: "答辩", type: "concept" });
1082
+ const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1083
+ const m2 = makeMemory(service, "第二次答辩前", "又要答辩了,还是焦虑");
1084
+ linkSession(service, m1.id, "session-A");
1085
+ linkSession(service, m2.id, "session-B");
1086
+ const first = affectiveCtx([
1087
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id] }
1088
+ ]);
1089
+ const r1 = await runSleep(first, service, affectiveConfig(), first.logger, { embedder, vectorIndex: null }, null);
1090
+ assert.equal(r1.phases.affective.relations, 1);
1091
+ const second = affectiveCtx([
1092
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id, m2.id] }
1093
+ ]);
1094
+ const r2 = await runSleep(second, service, affectiveConfig(), second.logger, { embedder, vectorIndex: null }, null);
1095
+ assert.equal(r2.phases.affective.relations, 0, "不是新边");
1096
+ assert.ok(r2.phases.affective.merged >= 1, "是合并");
1097
+ const person = service.findEntityByName("桉桉");
1098
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1099
+ assert.equal(edges.length, 1);
1100
+ assert.equal(edges[0].metadata.sessionCount, 2, "场合数跟着证据涨到 2");
1101
+ assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort());
1102
+ store.close();
1103
+ });
1104
+
1105
+ test("sleep: reordering the evidence list does not smuggle in a second edge", async () => {
1106
+ // 判重键如果只看 evidence[0],模型把 [m1, m2] 写成 [m2, m1] 就会再插一条。
1107
+ // LLM 给 evidence 的顺序本来就不稳定,parseJsonArray 也不排序——同一条边
1108
+ // 换个顺序就重复写,sessionCount 会被抬得比真实场合数高,正好毁掉主指标。
1109
+ const { service, store } = setup();
1110
+ service.createEntity({ name: "桉桉", type: "person" });
1111
+ service.createEntity({ name: "答辩", type: "concept" });
1112
+ const m1 = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1113
+ const m2 = makeMemory(service, "答辩前一晚", "越想越慌,睡不着");
1114
+ linkSession(service, m1.id, "session-A");
1115
+ linkSession(service, m2.id, "session-A");
1116
+ const forward = affectiveCtx([
1117
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m1.id, m2.id] }
1118
+ ]);
1119
+ const first = await runSleep(forward, service, affectiveConfig(), forward.logger, { embedder, vectorIndex: null }, null);
1120
+ assert.equal(first.phases.affective.relations, 1, "第一轮写了一条");
1121
+ const reversed = affectiveCtx([
1122
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [m2.id, m1.id] }
1123
+ ]);
1124
+ const second = await runSleep(reversed, service, affectiveConfig(), reversed.logger, { embedder, vectorIndex: null }, null);
1125
+ assert.equal(second.phases.affective.relations, 0, "只换了证据顺序,不能再写一条");
1126
+ assert.ok(second.phases.affective.merged >= 1, "认成同一场合,走合并");
1127
+ const person = service.findEntityByName("桉桉");
1128
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1129
+ assert.equal(edges.length, 1, "库里仍只有一条边");
1130
+ assert.equal(edges[0].metadata.sessionCount, 1, "同一会话,场合数仍是 1");
1131
+ assert.deepEqual([...edges[0].metadata.evidence].sort(), [m1.id, m2.id].sort(), "并集后证据不多不少");
1132
+ store.close();
1133
+ });
1134
+
1135
+ test("sleep: two emotions in the same memory are two edges, not a duplicate", async () => {
1136
+ // 判重键如果不带情绪,「答辩前又焦虑又期待」里的第二条会被当成重复丢掉,
1137
+ // dropped 里还写着 duplicate edge——读起来像「本来就重复」,实际是丢了一条
1138
+ // 真信息。本相位的承诺是降级不丢弃,重复和丢弃不能混成一个理由。
1139
+ const { service, store } = setup();
1140
+ service.createEntity({ name: "桉桉", type: "person" });
1141
+ service.createEntity({ name: "答辩", type: "concept" });
1142
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他又焦虑又有点期待");
1143
+ linkSession(service, mem.id, "session-A");
1144
+ const ctx = affectiveCtx([
1145
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] },
1146
+ { person: "桉桉", target: "答辩", emotion: "期待", attribution: "occasion", evidence: [mem.id] }
1147
+ ]);
1148
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1149
+ assert.equal(result.phases.affective.relations, 2, "两种情绪各写一条");
1150
+ assert.equal(result.phases.affective.dropped.filter((d) => d.reason === "duplicate edge").length, 0, "不许把第二种情绪当重复");
1151
+ const person = service.findEntityByName("桉桉");
1152
+ const edges = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1153
+ assert.deepEqual(edges.map((r) => r.metadata.emotion).sort(), ["期待", "焦虑"], "两条边各带自己的情绪词");
1154
+ store.close();
1155
+ });
1156
+
1157
+ test("sleep: an edge write shows up in the receipt instead of reading as zero", async () => {
1158
+ // #619 / Ephor: receipt 的倒数第二位是 applied,而 affective 相位写边走
1159
+ // saveRelation、不进 applied。不改的话,一轮明明写了边,审计行却是 `:0`——
1160
+ // 「跑了但什么都没做」,探针会把有写入的轮次记成空轮。
1161
+ const { service, store } = setup();
1162
+ service.createEntity({ name: "桉桉", type: "person" });
1163
+ service.createEntity({ name: "答辩", type: "concept" });
1164
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1165
+ linkSession(service, mem.id, "session-A");
1166
+ const ctx = affectiveCtx([
1167
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1168
+ ]);
1169
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1170
+ assert.equal(result.phases.affective.relations, 1, "恰好一条边落地,否则这条用例没在验东西");
1171
+ assert.equal(result.phases.affective.applied, 0, "边不是 decision,applied 仍是 0");
1172
+ const parts = result.receipt.split(":");
1173
+ assert.equal(parts.length, 8, "receipt 仍是八段");
1174
+ // 段序:dsh-mneme / run / runId / status / snapshotHash / inputCount / applied / summaryFlag
1175
+ assert.equal(parts[6], "1", "写入计数必须非零,读成 0 等于谎报本轮没做过事");
1176
+ store.close();
1177
+ });
1178
+
1179
+ test("sleep: a merge-only run also shows up in the receipt", async () => {
1180
+ // #619 / Ephor 第 4 轮:只合并的那一轮 relations 与 edgeWrites 都是 0,
1181
+ // 但它确实改了库(证据并集、sessionCount 重算)。receipt 若读成 `:0`,
1182
+ // 就是上一处修过的同一个谎——只是换了个形状。合并也算写。
1183
+ const { service, store } = setup();
1184
+ service.createEntity({ name: "桉桉", type: "person" });
1185
+ service.createEntity({ name: "答辩", type: "concept" });
1186
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1187
+ linkSession(service, mem.id, "session-A");
1188
+ const ctx = affectiveCtx([
1189
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1190
+ ]);
1191
+ const first = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1192
+ assert.equal(first.receipt.split(":")[6], "1", "第一轮新写边,计数 1");
1193
+ const second = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1194
+ assert.equal(second.phases.affective.relations, 0, "第二轮没有新边");
1195
+ assert.equal(second.phases.affective.edgeWrites, 0, "第二轮没有新写");
1196
+ assert.equal(second.phases.affective.merged, 1, "第二轮走的是合并");
1197
+ assert.equal(second.phases.affective.status, "ok", "合并过就是 ok,不是 noop");
1198
+ const parts = second.receipt.split(":");
1199
+ assert.equal(parts.length, 8, "receipt 仍是八段");
1200
+ assert.equal(parts[6], "1", "只合并的一轮同样改了库,必须非零");
1201
+ store.close();
1202
+ });
1203
+
1204
+ test("sleep: an aborted affective phase reports aborted, not ok", async () => {
1205
+ // 中途被打断且已经写了边,既不能报 ok(没跑完),也不能报 noop(确实写了)。
1206
+ const { service, store } = setup();
1207
+ service.createEntity({ name: "桉桉", type: "person" });
1208
+ service.createEntity({ name: "答辩", type: "concept" });
1209
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1210
+ linkSession(service, mem.id, "session-A");
1211
+ const controller = new AbortController();
1212
+ const ctx = affectiveCtx([
1213
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id] }
1214
+ ]);
1215
+ const realStream = ctx.llm.stream;
1216
+ ctx.llm.stream = async function* (options) {
1217
+ yield* realStream.call(this, options);
1218
+ // 模型答完、写边之前用户回到电脑前
1219
+ if (options.purpose === "sleep-affective") controller.abort();
1220
+ };
1221
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, controller.signal);
1222
+ const phase = result.phases.affective;
1223
+ assert.equal(phase.status, "aborted", "打断不是 ok");
1224
+ assert.equal(phase.reason, "user activity");
1225
+ assert.ok(phase.edgeWrites >= 0, "打断也把已写的条数报出来");
1226
+ // 明细字段在每条返回路径上都得在。只有正常的返回带 mergedList 的话,
1227
+ // 消费方在 aborted/failed 两条路径上读明细会静默拿到 undefined,不报错。
1228
+ assert.ok(Array.isArray(phase.mergedList), "aborted 分支也要带 mergedList,缺了是静默失明");
1229
+ store.close();
1230
+ });
1231
+
1232
+ test("sleep: a trait that clears the threshold mints exactly one pattern and counts it", async () => {
1233
+ // 这条与上一条配对:同样的三会话证据,上一条说「够格就铸」,这条看「铸了几个、
1234
+ // 计数是否如实」。Ephor 第 4 条的 `if (ok)` 缺 else 已补成 failed 分支,但那条
1235
+ // 分支在合法决策下不可达(决策是我们按合法形状拼的),不写假用例假装测过它。
1236
+ const { service, store } = setup();
1237
+ service.createEntity({ name: "桉桉", type: "person" });
1238
+ service.createEntity({ name: "答辩", type: "concept" });
1239
+ const ids = [];
1240
+ for (const session of ["s1", "s2", "s3"]) {
1241
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1242
+ linkSession(service, mem.id, session);
1243
+ ids.push(mem.id);
1244
+ }
1245
+ const ctx = affectiveCtx([
1246
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1247
+ ]);
1248
+ const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1249
+ const phase = result.phases.affective;
1250
+ assert.equal(phase.status, "ok");
1251
+ assert.equal(phase.traits, 1, "识别出一个 trait");
1252
+ assert.equal(phase.applied, 1, "它真的被铸出来了");
1253
+ store.close();
1254
+ });
1255
+
1256
+ test("sleep: replaying the same trait rewrites the pattern instead of growing it", async () => {
1257
+ // #619 / Ephor 第 6 轮:trait 标题由模板拼成,同一 (人, 对象, 情绪) 天然同题。
1258
+ // applyCreate 走 saveWithDedupe,命中同题活跃行时默认 appendContent——把同一段
1259
+ // 结论再粘一遍。pattern 是注入面消费的东西,重放三轮正文就从 161 字涨到 549 字,
1260
+ // 还带一串重复的 [证据: …] 尾巴。修法:pattern 走 `_overwrite`,原地刷新。
1261
+ const { service, store } = setup();
1262
+ service.createEntity({ name: "桉桉", type: "person" });
1263
+ service.createEntity({ name: "答辩", type: "concept" });
1264
+ const ids = [];
1265
+ for (const session of ["s1", "s2", "s3"]) {
1266
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1267
+ linkSession(service, mem.id, session);
1268
+ ids.push(mem.id);
1269
+ }
1270
+ const payload = (ctx) => ctx;
1271
+ const lengths = [];
1272
+ for (let round = 0; round < 3; round += 1) {
1273
+ const ctx = affectiveCtx([
1274
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1275
+ ]);
1276
+ void payload(ctx);
1277
+ await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1278
+ const patterns = service.all().filter((m) => m.type === "pattern");
1279
+ assert.equal(patterns.length, 1, `round ${round}: 仍然只有一行 pattern`);
1280
+ lengths.push(patterns[0].content.length);
1281
+ }
1282
+ assert.equal(lengths[1], lengths[0], "第二轮不许把正文再粘一遍");
1283
+ assert.equal(lengths[2], lengths[0], "第三轮同样");
1284
+ const pattern = service.all().find((m) => m.type === "pattern");
1285
+ assert.ok(!pattern.content.includes("---"), "没有 appendContent 的分隔符");
1286
+ assert.equal((pattern.content.match(/\[证据/g) ?? []).length, 1, "证据尾巴只有一条");
1287
+ store.close();
1288
+ });
1289
+
1290
+ test("sleep: a refresh-only trait run reports refreshed, not ok with zero mints", async () => {
1291
+ // 同一批证据重放:第二轮没铸新行,只是把既有 pattern 原地刷新。这时若报
1292
+ // status:"ok" + applied:0,读审计的人会以为「说明这一轮没东西」,而实际上是
1293
+ // 一次真实写入;若报 noop,又等于说整池看过了什么都没有。给它自己的名字。
1294
+ const { service, store } = setup();
1295
+ service.createEntity({ name: "桉桉", type: "person" });
1296
+ service.createEntity({ name: "答辩", type: "concept" });
1297
+ const ids = [];
1298
+ for (const session of ["s1", "s2", "s3"]) {
1299
+ const mem = makeMemory(service, `答辩-${session}`, `第 ${session} 次答辩前都失眠`);
1300
+ linkSession(service, mem.id, session);
1301
+ ids.push(mem.id);
1302
+ }
1303
+ const payload = [
1304
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "trait", evidence: ids, why: "三次都一样" }
1305
+ ];
1306
+ const first = await (async () => {
1307
+ const ctx = affectiveCtx(payload);
1308
+ return runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1309
+ })();
1310
+ assert.equal(first.phases.affective.status, "ok", "第一轮真的铸了");
1311
+ assert.equal(first.phases.affective.applied, 1);
1312
+ const second = await (async () => {
1313
+ const ctx = affectiveCtx(payload);
1314
+ return runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 1 }), ctx.logger, { embedder, vectorIndex: null }, null);
1315
+ })();
1316
+ const phase = second.phases.affective;
1317
+ assert.equal(phase.applied, 0, "第二轮没有新铸");
1318
+ assert.equal(phase.refreshedTraits, 1, "刷新了一条");
1319
+ assert.equal(phase.status, "refreshed", "刷新不是 noop,也不是一次新铸");
1320
+ assert.equal(second.status, "ok", "整轮仍算有产出");
1321
+ // #619 / Ephor 第七轮 ①:相位说「改了」,整轮 receipt 也必须说「改了」。此前
1322
+ // totalApplied 只加 applied + edgeWrites + merged,refreshedTraits 不在表里,
1323
+ // 于是这一轮 receipt 第 7 段是 0——「改了库读成 0」,正是上一轮刚修掉的形态。
1324
+ const parsed = parseReceipt(second.receipt);
1325
+ assert.ok(parsed, `receipt 必须可解析(实测 ${second.receipt})`);
1326
+ assert.equal(parsed.applied, 1, "刷新一条要算一次真实写入,不能被读成 0");
1327
+ // ③ 同一个场景:刷新不是出生,不该再往 memory_mints 添一行。
1328
+ const pattern = service.all().find((m) => m.type === "pattern");
1329
+ assert.equal(store.mintsFor(pattern.id).length, 1, "两轮下来只有一条出生记录");
1330
+ store.close();
1331
+ });
1332
+
1333
+ test("sleep: the affective prompt does not corrupt JSON while filling the threshold", async () => {
1334
+ // 占位符原来是一个裸的 `N`,而 prompt 正文里还写着「只输出 JSON 数组」。
1335
+ // replaceAll("N", …) 于是把 JSON 换成了 JSO3——模型收到的是「只输出 JSO3 数组」,
1336
+ // 而这一相位的唯一出口就是那个数组。这不是理论风险:zh 与 en 两版都中。
1337
+ const { service, store } = setup();
1338
+ service.createEntity({ name: "桉桉", type: "person" });
1339
+ service.createEntity({ name: "答辩", type: "concept" });
1340
+ makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
1341
+ const seen = [];
1342
+ const ctx = {
1343
+ logger: { warn: () => {}, info: () => {} },
1344
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "affective-model" }) },
1345
+ llm: {
1346
+ async *stream(options) {
1347
+ if (options.purpose === "sleep-affective") {
1348
+ const system = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
1349
+ seen.push(system);
1350
+ }
1351
+ yield { type: "text-delta", index: 0, text: "[]" };
1352
+ yield { type: "finish", reason: { kind: "stop" } };
1353
+ }
1354
+ }
1355
+ };
1356
+ await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1357
+ assert.equal(seen.length, 1, "情感相位确实调了一次模型");
1358
+ const system = seen[0];
1359
+ assert.ok(!system.includes("JSO3"), `prompt 里的 JSON 被阈值替换吃掉了:${system.slice(0, 200)}`);
1360
+ assert.ok(system.includes("JSON"), "「只输出 JSON 数组」这句必须原样到达模型");
1361
+ assert.ok(!system.includes("{{MIN_SESSIONS}}"), "占位符必须被填掉,不能把字面量发给模型");
1362
+ assert.ok(system.includes("3"), "阈值默认 3 要出现在 prompt 里");
1363
+ store.close();
1364
+ });
1365
+
1366
+ test("sleep: evidence without provenance still counts as the same occasion", async () => {
1367
+ // memory_save 写下的记忆没有 provenance,sessionsFor 返回空集。纯会话判据在
1368
+ // 这种情况下永远判不出「同一场合」,同一批证据会在每一轮 sleep 里再写一条边,
1369
+ // 把 sessionCount 抬得比真实场合数高。落不着会话键时退回 evidence 交集。
1370
+ const { service, store } = setup();
1371
+ const person = service.createEntity({ name: "桉桉", type: "person" });
1372
+ const target = service.createEntity({ name: "答辩", type: "concept" });
1373
+ const mem = makeMemory(service, "答辩前一晚", "答辩前一晚他很焦虑");
1374
+ const payload = [
1375
+ { person: "桉桉", target: "答辩", emotion: "焦虑", attribution: "occasion", evidence: [mem.id], why: "就这一次" }
1376
+ ];
1377
+ const run = () => {
1378
+ const ctx = affectiveCtx(payload);
1379
+ return runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1380
+ };
1381
+ const first = await run();
1382
+ assert.equal(first.phases.affective.relations, 1, "第一轮写了一条边");
1383
+ const second = await run();
1384
+ assert.equal(second.phases.affective.relations, 0, "第二轮不许再写一条");
1385
+ assert.equal(second.phases.affective.merged, 1, "认成同一场合,合并进既有边");
1386
+ const rels = service.getRelations(person.id).filter((r) => r.relation_type === "affective_context");
1387
+ assert.equal(rels.length, 1, "库里仍只有一条");
1388
+ assert.equal(rels[0].to_entity, target.id);
1389
+ store.close();
1390
+ });
1391
+
1392
+ test("sleep: trait candidates pushed out by the cap are recorded as dropped", async () => {
1393
+ // traits.slice(0, maxTraits) 把超出的候选截掉,它们既不铸、也不降级、也不进
1394
+ // dropped,而 traits 仍把它们算进候选数——读者会以为都处理过了。与边的
1395
+ // `relation cap` 同一口径记一笔。
1396
+ const { service, store } = setup();
1397
+ service.createEntity({ name: "桉桉", type: "person" });
1398
+ const payload = [];
1399
+ for (const [i, target] of ["答辩", "面试", "汇报", "截止日期"].entries()) {
1400
+ service.createEntity({ name: target, type: "concept" });
1401
+ const ids = [];
1402
+ for (const session of ["s1", "s2", "s3"]) {
1403
+ const mem = makeMemory(service, `${target}-${session}-${i}`, `第 ${session} 次${target}前都失眠`);
1404
+ linkSession(service, mem.id, `${target}-${session}`);
1405
+ ids.push(mem.id);
1406
+ }
1407
+ payload.push({ person: "桉桉", target, emotion: "焦虑", attribution: "trait", evidence: ids, why: `三次${target}前都这样` });
1408
+ }
1409
+ const ctx = affectiveCtx(payload);
1410
+ const result = await runSleep(ctx, service, affectiveConfig({ sleepMaxPatternPerRun: 2, sleepPatternMinMemories: 20 }), ctx.logger, { embedder, vectorIndex: null }, null);
1411
+ const phase = result.phases.affective;
1412
+ assert.equal(phase.traits, 4, "四条 trait 候选都被识别出来");
1413
+ assert.equal(phase.applied, 2, "上限内两条落库");
1414
+ const capped = phase.dropped.filter((d) => d.reason === "trait cap");
1415
+ assert.equal(capped.length, 2, "被上限截掉的两条必须记账,不能静默消失");
1416
+ assert.ok(capped.every((d) => typeof d.target === "string" && d.target), "每条都要能看出是哪条结论被截");
1417
+ store.close();
1418
+ });
1419
+
1420
+ test("sleep: an affective stream failure keeps its cause on the phase", async () => {
1421
+ // #619 可见化 A。这条与 conflict 相位那条同源:只写 "llm failed" 的话,
1422
+ // 事后看 dream_runs 完全分不清是上下文超限、模型拒收、还是上游 502。
1423
+ // 实测出处 2026-11-10T16:09 那一轮:outcome.affective 只有
1424
+ // {"status":"failed","error":"llm failed"},真正的 502 原文只能去
1425
+ // llm_audit_logs 里捞,而那张表不是给人看面板用的。
1426
+ // 所以这里钉两件事:失败原因必须写进返回值的 failure;同时必须打 warn。
1427
+ const { service, store } = setup();
1428
+ service.createEntity({ name: "桉桉", type: "person" });
1429
+ service.createEntity({ name: "答辩", type: "concept" });
1430
+ const mem = makeMemory(service, "答辩前一天", "明天答辩,他很焦虑");
1431
+ linkSession(service, mem.id, "session-A");
1432
+
1433
+ const warnings = [];
1434
+ const ctx = affectiveCtx([]);
1435
+ ctx.logger = { warn: (line) => warnings.push(String(line)), info: () => {} };
1436
+ // 只让 affective 这一路失败,别的相位照常回 "[]"——否则测的是整轮,不是这一路。
1437
+ ctx.llm.stream = async function* (options) {
1438
+ if (options.purpose === "sleep-affective") {
1439
+ yield { type: "finish", reason: { kind: "error", failure: { code: "SERVER", message: "502: upstream fetch failed" } } };
1440
+ return;
1441
+ }
1442
+ yield { type: "text-delta", index: 0, text: "[]" };
1443
+ yield { type: "finish", reason: { kind: "stop" } };
1444
+ };
1445
+
1446
+ const result = await runSleep(ctx, service, affectiveConfig(), ctx.logger, { embedder, vectorIndex: null }, null);
1447
+ const phase = result.phases.affective;
1448
+ assert.equal(phase.status, "failed");
1449
+ assert.equal(phase.error, "llm failed", "粗粒度分类保持不变,面板与审计都依赖它");
1450
+ assert.ok(String(phase.failure ?? "").includes("502"),
1451
+ `失败原因要带上游原文,实际拿到 ${JSON.stringify(phase.failure)}`);
1452
+ assert.ok(warnings.some((w) => w.includes("affective stream aborted or errored")),
1453
+ "同一份原因也要进日志,光靠返回值救不了已经过去的运行");
1454
+ store.close();
1455
+ });