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