@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +830 -828
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +964 -964
- package/bin/dsh-mneme-choro.mjs +262 -254
- 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 -402
- 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 -3394
- 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 -13436
- package/lib/commands.js +95 -95
- package/lib/config-keys.js +144 -144
- package/lib/config.js +1361 -1317
- 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 -907
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +2027 -1887
- package/lib/dream/triage.js +139 -0
- package/lib/dream.js +1879 -1764
- 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 -107
- package/lib/image-describe.js +158 -0
- package/lib/index.js +852 -822
- package/lib/inject.js +656 -639
- 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 -584
- 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 -3196
- package/lib/session-gate.js +91 -91
- package/lib/session-usage.js +350 -350
- package/lib/settings.js +827 -802
- 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 -5662
- 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 -0
- 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 -474
- package/lib/wiki/render.js +261 -187
- package/lib/wiki/scan.js +371 -371
- package/lib/wiki/t0.js +500 -463
- package/lib/wiki/t1-jobs.js +232 -232
- package/lib/wiki/t1.js +609 -502
- package/lib/write-admission.js +404 -404
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +15 -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 -204
- package/scripts/release-notes.mjs +164 -161
- 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 -3394
- 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 -1317
- 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 -907
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +2027 -1887
- package/src/dream/triage.js +139 -0
- package/src/dream.js +1879 -1764
- 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 -107
- package/src/image-describe.js +158 -0
- package/src/index.js +852 -822
- package/src/inject.js +656 -639
- 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 -584
- 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 -3196
- package/src/session-gate.js +91 -91
- package/src/session-usage.js +350 -350
- package/src/settings.js +827 -802
- 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 -5662
- 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 -0
- 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 -474
- package/src/wiki/render.js +261 -187
- package/src/wiki/scan.js +371 -371
- package/src/wiki/t0.js +500 -463
- package/src/wiki/t1-jobs.js +232 -232
- package/src/wiki/t1.js +609 -502
- 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 -2988
- 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 -4184
- 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 -0
- package/test/conflict-ledger.test.js +242 -0
- 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 -320
- 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 -0
- 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 -209
- 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 -211
- 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 -106
- 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 -494
- 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 -0
- package/test/panel-tokens.test.js +245 -0
- 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 -136
- package/test/release-notes.test.js +265 -240
- 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 -178
- 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 -1418
- 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 -627
- package/test/wiki-claims.test.js +194 -0
- 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 -0
- package/test/wiki-queries.test.js +398 -277
- package/test/wiki-scan.test.js +208 -208
- package/test/wiki-store.test.js +190 -190
- package/test/wiki-t0.test.js +456 -380
- package/test/wiki-t1-jobs.test.js +125 -125
- package/test/wiki-t1.test.js +460 -235
- 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/pipeline-nudge.js
CHANGED
|
@@ -1,1623 +1,1623 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* M.O. 沉淀链(nudge):让工作流**自己从数据里长出来**。
|
|
3
|
-
*
|
|
4
|
-
* 这一层补的是读侧之外缺的那一头。`pipeline.js` 能把人手写的 JSON 渲染成图、能从
|
|
5
|
-
* 运行记录算状态;但「一条新工作流从哪来」在那之前一直是零代码。本文件就是那个来源:
|
|
6
|
-
*
|
|
7
|
-
* 会话/记忆里的观察 → 反复出现的同一段序列(候选) → 反事实判据(请人回答)
|
|
8
|
-
* → status:"draft" 的草稿 →(人点头)→ active
|
|
9
|
-
*
|
|
10
|
-
* **三条不许越的分界线**(这三条比功能完整度重要,写在这里免得后来人图省事):
|
|
11
|
-
*
|
|
12
|
-
* 1. **机器只写它真观察到的东西。** 步骤、次数、时间、风险下限、要问的问题,这些是
|
|
13
|
-
* 数据里读出来的;人话(`when` / `done` / 触发器那句话)一律留 `(待补)`。把
|
|
14
|
-
* 「这一步为什么存在」编圆了,比留白危险得多——它会让人以为判据已经答过了。
|
|
15
|
-
* 2. **反事实判据不自动给结论。** 判据是「没有这条流程,这次会不会漏步/失败」。
|
|
16
|
-
* 日志里能读到「这一步被跳过」这个**动作**,读不到「跳过之后出事没出事」这个
|
|
17
|
-
* **结果**;那个结果恰恰就是判据本身。所以本模块只做两件事:把能观察到的对照
|
|
18
|
-
* 摆出来(有这一步几次 / 没有这一步几次),把答不出来的那部分写成**给几个选项的
|
|
19
|
-
* 问题**。`counterfactual.verdict` 永远是 null。
|
|
20
|
-
* 3. **只产草稿,绝不产 active。** 提议自动、固化要人点头(v0.9.0 设计备忘 §4)。
|
|
21
|
-
* `writeDraftPipeline` 见到 `status !== "draft"` 直接拒写,这条没有开关。
|
|
22
|
-
*
|
|
23
|
-
* 另外四条是**边界性质**,不只靠产者自觉(专家团复核后补齐,见各函数注释):
|
|
24
|
-
* · 空回答不算「已答」(`applyAnswers` 一条都没接受就不推进状态);
|
|
25
|
-
* · 支持度**只在同一个工作区桶内累加**,且扫全部会话时不许声称候选属于某个项目;
|
|
26
|
-
* · 提议/草稿一律**原子落盘**(tmp + rename),半截文件不能把整层工作流带走;
|
|
27
|
-
* · `validateProposal` 拒绝任何非 null 的 `counterfactual.verdict`。
|
|
28
|
-
*
|
|
29
|
-
* 候选的家**分两处**,因为它们的两条命不同:
|
|
30
|
-
* · 机器产出的候选/问题 → `<memoryDir>/proposals/<项目键>/nudge-*.json`(本地,不进仓库;
|
|
31
|
-
* 一天能长十条、死九条,进仓库就是垃圾,与 `candidates.js` 的绕行候选同一套理由)
|
|
32
|
-
* · 通过校验的草稿 → `<项目>/.mneme/pipelines/<id>.json`(要能 review、能 PR)
|
|
33
|
-
*
|
|
34
|
-
* **与 `candidates.js` 的关系**(那份是基线里已有的绕行候选层,能复用的一律照它来):
|
|
35
|
-
* · 复用它的 `projectKey`(分桶键一律「目录名 + 路径哈希」,绝不含绝对路径)与
|
|
36
|
-
* `scanIrreversible`(风险兜底清单只有一份,不另立第二份);
|
|
37
|
-
* · 分桶约定一致,只是目录名取 `proposals/` 而不是 `candidates/`:那边是「图上没有的
|
|
38
|
-
* 路被走通了」,这边是「还没上过图的重复序列」,两者的状态机(`collecting → proposed
|
|
39
|
-
* → promoted` 对 `proposed → answered → drafted`)与判据都不同;混进一张表里,
|
|
40
|
-
* 「这条到底有没有被跑过」就查不清了。真正的合并要动 `candidates.js` 的 `origin`
|
|
41
|
-
* 白名单与 `validateCandidate`,那不在本文件写域内。
|
|
42
|
-
*
|
|
43
|
-
* **没有复用 `src/session-usage.js`**:它只存在于隔壁**未合并**的车道,本车道与 main
|
|
44
|
-
* 都没有这个文件,import 会当场炸。所以这里自带一份**只够 nudge 用**的读取器(会话 →
|
|
45
|
-
* 按回合排的调用序列),并把它做成可替换的形状:那个模块并进来之后,
|
|
46
|
-
* `segmentObservations` 之前那一段可以整体换掉,`mineRepeatedPatterns` 以下不用改。
|
|
47
|
-
*
|
|
48
|
-
* **草稿只写 `validatePipeline` 留得住的那 13 个键**:落盘前一律过一遍校验器,写下去的是
|
|
49
|
-
* 它归一化后的形状,所以 `assert` / `onFailure` / `retries` 这类会被静默丢弃的键
|
|
50
|
-
* 一个都不在这条链上。机器算出的风险下限与重复次数留在 memoryDir 的提议文件里
|
|
51
|
-
* ——那里没有白名单。
|
|
52
|
-
*/
|
|
53
|
-
|
|
54
|
-
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, renameSync, rmSync, statSync } from "node:fs";
|
|
55
|
-
import { createHash } from "node:crypto";
|
|
56
|
-
import { join } from "node:path";
|
|
57
|
-
import { homedir } from "node:os";
|
|
58
|
-
import zlib from "node:zlib";
|
|
59
|
-
import { validatePipeline, projectPipelineDir, projectLabel, suggestRetirements } from "./pipeline.js";
|
|
60
|
-
import { projectKey, scanIrreversible } from "./candidates.js";
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* 提议门槛:同一段序列要在**至少两个不同的会话**里出现。
|
|
64
|
-
* 这个 2 不是新发明的数——`candidates.js` 的 `PROMOTE_MIN_SEEN` 已经是同一个理由:
|
|
65
|
-
* **复用第二次才算类型,第一次只是一个实例**。同一次但因为「一个会话里重复出现」
|
|
66
|
-
* 就够格的话,一条被复制粘贴的命令就能造出一条假候选。
|
|
67
|
-
*/
|
|
68
|
-
export const NUDGE_MIN_SUPPORT = 2;
|
|
69
|
-
|
|
70
|
-
/** 太短的序列没有「步骤关系」可言(两步以内就是「用了 A 又用了 B」),太长则是整场会话。 */
|
|
71
|
-
export const NUDGE_MIN_LENGTH = 2;
|
|
72
|
-
export const NUDGE_MAX_LENGTH = 4;
|
|
73
|
-
|
|
74
|
-
/** 一次挖掘最多交回多少条候选。候选是给人看的,一次推两百条等于没推。 */
|
|
75
|
-
export const NUDGE_MAX_CANDIDATES = 20;
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* 默认挖哪些层的调用:**能力级(技能 / MCP)**,不挖内置工具级。
|
|
79
|
-
*
|
|
80
|
-
* 这不是想当然定的,是拿本机 520 份真实会话日志跑出来的:工具级序列的支持度高得离谱
|
|
81
|
-
* (`read → pwsh` 出现在 322 个会话、`write → pwsh` 253 个),但它们全是 agent 的
|
|
82
|
-
* 通用动作习惯,不是项目工作流——按那个口径挖,推给人的前八条全是这类噪音。
|
|
83
|
-
* 能力级稀疏得多(真实数据里同一段能力序列只有两三个会话支持),但每一条都是
|
|
84
|
-
* 「有人特意选了这个技能」的选择,那才可能是工作流。
|
|
85
|
-
*
|
|
86
|
-
* 想挖工具级就显式传 `kinds: ["tool", ...]` 或 `kinds: null`(不过滤)——**别改默认值**,
|
|
87
|
-
* 默认值一改,面板第一屏就变回噪音。
|
|
88
|
-
*/
|
|
89
|
-
export const DEFAULT_NUDGE_KINDS = ["skill", "mcp"];
|
|
90
|
-
|
|
91
|
-
/** 单次扫描最多读多少会话文件(目录膨胀时宁可报截断,也不要把一次刷新变成全盘扫描)。 */
|
|
92
|
-
const MAX_SESSION_FILES = 2000;
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* 自动沉淀的**最小间隔**(同一个项目两次自动跑之间)。
|
|
96
|
-
*
|
|
97
|
-
* 为什么需要它:解压会话日志是**同步** I/O,本机 118 份日志实测 16.2s。挂到
|
|
98
|
-
* `session/event` 上如果每轮都跑,长会话就是把宿主主线程反复按住。十分钟这个数是
|
|
99
|
-
* 取的「够久到不会在同一个工作段里反复触发,够短到当天的新习惯还来得及被看见」——
|
|
100
|
-
* 它不是性能参数,是**观测新鲜度**参数;真卡的话先调 budgetMs(那一条保证不卡)。
|
|
101
|
-
*/
|
|
102
|
-
export const NUDGE_AUTO_MIN_INTERVAL_MS = 10 * 60 * 1000;
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* 自动沉淀的单次时间预算。超了就**带着已读到的部分收工**并如实标 `budgetExceeded`,
|
|
106
|
-
* 而不是把这一跑作废——半份观察好过零份,只要调用方知道它只看了后半截。
|
|
107
|
-
*/
|
|
108
|
-
export const NUDGE_AUTO_BUDGET_MS = 4000;
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* 沉淀链的**边界**,写进每一份 return 的契约里。抽成一份常量是防「两条出口说法不一样」:
|
|
112
|
-
* 走完一整跑和提前收工给出的边界必须是同一句话,否则读契约的人得先猜自己拿到的是哪一种。
|
|
113
|
-
*/
|
|
114
|
-
export const NUDGE_BOUNDARIES = Object.freeze({
|
|
115
|
-
autoActivate: false,
|
|
116
|
-
autoAnswerCounterfactual: false,
|
|
117
|
-
writesOnlyDrafts: true,
|
|
118
|
-
// 机器给的风险只是下限;R3 永远只能由人给。
|
|
119
|
-
riskIsFloor: true,
|
|
120
|
-
// 支持度只在同一个工作区桶内累加;跨桶不并账。
|
|
121
|
-
crossProjectSupport: false
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
/** 会话日志文件名前缀。**不写死版本号**:本机同一棵树下并存 v4 / v3 / 无版本号三种。 */
|
|
125
|
-
const SESSION_FILE_PREFIX = "session";
|
|
126
|
-
|
|
127
|
-
/** zstd 帧头 magic:多帧文件靠它切帧,见 forEachZstdFrame 的注释。 */
|
|
128
|
-
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
129
|
-
|
|
130
|
-
/** 候选(提议)自己的状态机。与 `candidates.js` 的候选状态刻意不同名,免得两套账混起来。 */
|
|
131
|
-
export const PROPOSAL_STATUSES = ["proposed", "answered", "drafted", "dismissed"];
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* 反事实的**答案选项**。为什么是固定选项而不是自由文本:
|
|
135
|
-
* 这是准入判据,将来要参与「这条草稿能不能升成 active」的清点;自由文本没法清点,
|
|
136
|
-
* 而清点不了的判据最后一定会退化成人情。`unknown` 必须留一个——**逼人二选一
|
|
137
|
-
* 会造出假答案**,而假答案比空答案更贵。
|
|
138
|
-
*/
|
|
139
|
-
export const COUNTERFACTUAL_CHOICES = [
|
|
140
|
-
{ id: "would-skip", label: "会漏掉这一步" },
|
|
141
|
-
{ id: "would-fail", label: "会失败(或做返工)" },
|
|
142
|
-
{ id: "no-harm", label: "那只是省事,不会出事" },
|
|
143
|
-
{ id: "unknown", label: "答不上来" }
|
|
144
|
-
];
|
|
145
|
-
|
|
146
|
-
const CHOICE_IDS = COUNTERFACTUAL_CHOICES.map((c) => c.id);
|
|
147
|
-
const CHOICE_LABELS = new Map(COUNTERFACTUAL_CHOICES.map((c) => [c.id, c.label]));
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* 会改本机文件/状态的内置工具 → 风险下限 R1。
|
|
151
|
-
* 这是一份**机械清单**,与 `candidates.js` 的 `IRREVERSIBLE_PATTERNS` 同一个思路:
|
|
152
|
-
* 能审计、能 diff,不看模型自报。
|
|
153
|
-
*/
|
|
154
|
-
const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "notebook_edit"]);
|
|
155
|
-
|
|
156
|
-
/** 反事实那一条问题的原文。它出现在每一张问题上,所以在这里只写一次。 */
|
|
157
|
-
const COUNTERFACTUAL_QUESTION = "没有这一步,那次会不会漏步、会不会失败?";
|
|
158
|
-
|
|
159
|
-
/** 展开 `~`。候选层与 `memoryDir` 一样是本地状态,不是仓库内容。 */
|
|
160
|
-
function expandHome(p) {
|
|
161
|
-
const s = String(p || "");
|
|
162
|
-
if (s === "~") return homedir();
|
|
163
|
-
if (s.startsWith("~/") || s.startsWith("~\\")) return join(homedir(), s.slice(2));
|
|
164
|
-
return s;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** 会话根目录。默认由 `DSH_HOME` 推导,**不写死本机路径**——仓库里出现盘符是红线。 */
|
|
168
|
-
export function resolveSessionDir({ dshHome = process.env.DSH_HOME || join(homedir(), ".dsh") } = {}) {
|
|
169
|
-
return join(String(dshHome), "sessions");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* 路径规范化:只用来**比相等**,不用来读写文件(读写永远用原串)。
|
|
174
|
-
*
|
|
175
|
-
* 为什么要折:同一份工作目录在会话头里可能是 `C:\\Users\\x\\proj`、`c:/users/x/proj`、
|
|
176
|
-
* 末尾带不带分隔符三种写法之一,直接 `===` 会让归属解析**随机**命中或落空,而落空
|
|
177
|
-
* 的症状和「这个项目确实没会话」完全同形。
|
|
178
|
-
* 只在 win32 上折小写:POSIX 的文件名大小写敏感,在那里折小写会把
|
|
179
|
-
* `/a/Proj` 与 `/a/proj` 两个不同项目并成一个——那正是本仓最防的一类「合并两个项目的账」。
|
|
180
|
-
*/
|
|
181
|
-
export function normalizeProjectPath(p) {
|
|
182
|
-
if (typeof p !== "string") return null;
|
|
183
|
-
const trimmed = p.trim();
|
|
184
|
-
if (trimmed === "") return null;
|
|
185
|
-
let s = trimmed.replace(/\\/g, "/");
|
|
186
|
-
// 去掉末尾分隔符,但根("/" 或 "C:/")要留着——去掉就不是一个路径了。
|
|
187
|
-
s = s.replace(/\/+$/, "");
|
|
188
|
-
if (/^[A-Za-z]:$/.test(s)) s += "/";
|
|
189
|
-
if (s === "") s = "/";
|
|
190
|
-
return process.platform === "win32" ? s.toLowerCase() : s;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* 一个会话文件里记的 `cwd`(会话日志首行的 `{"type":"session",...,"cwd":"…"}`)。
|
|
195
|
-
*
|
|
196
|
-
* 为什么读内容而不是反解桶名:桶名是工作区路径的**有损**变形(盘符后的 `-`、
|
|
197
|
-
* 空格被吃掉、中文按 UTF-16 码元转义),本机 19 个桶里反解实测多处对不上——
|
|
198
|
-
* 反解错的时候**不会报错**,只会把别的项目的账归到这个项目名下。会话头里的
|
|
199
|
-
* `cwd` 是宿主自己写的原文,没有歧义。
|
|
200
|
-
*
|
|
201
|
-
* 只解第一帧是性能选择(一份会话日志能到 1.5 MB、上百帧),但第一帧解不出时
|
|
202
|
-
* **继续扫后面几帧**:宁可多解几帧,也不要把「拿不准」报成「这个桶没有项目」。
|
|
203
|
-
*/
|
|
204
|
-
export function readSessionCwd(file) {
|
|
205
|
-
let buf;
|
|
206
|
-
try {
|
|
207
|
-
buf = readFileSync(file);
|
|
208
|
-
} catch {
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
let cwd = null;
|
|
212
|
-
const take = (text) => {
|
|
213
|
-
if (cwd) return;
|
|
214
|
-
for (const line of text.split("\n")) {
|
|
215
|
-
const t = line.trim();
|
|
216
|
-
// 快判:没有 "cwd" 的行连 JSON.parse 都不用做(一帧里绝大多数行是消息)。
|
|
217
|
-
if (!t || t[0] !== "{" || !t.includes('"cwd"')) continue;
|
|
218
|
-
try {
|
|
219
|
-
const obj = JSON.parse(t);
|
|
220
|
-
if (obj && typeof obj.cwd === "string" && obj.cwd.trim() !== "") {
|
|
221
|
-
cwd = obj.cwd;
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
} catch {
|
|
225
|
-
// 半行/坏行:单行脏数据不该决定归属。
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
const start = buf.indexOf(ZSTD_MAGIC, 0);
|
|
230
|
-
if (start !== -1) {
|
|
231
|
-
let end = buf.indexOf(ZSTD_MAGIC, start + 4);
|
|
232
|
-
if (end === -1) end = buf.length;
|
|
233
|
-
try {
|
|
234
|
-
take(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8"));
|
|
235
|
-
} catch {
|
|
236
|
-
// 第一帧坏了:落到下面的整文件扫描。
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
if (!cwd) forEachZstdFrame(buf, take);
|
|
240
|
-
buf.fill(0);
|
|
241
|
-
return cwd;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* 这个项目**自己的**会话桶目录(`<DSH_HOME>/sessions/<工作区桶>`)。
|
|
246
|
-
*
|
|
247
|
-
* 为什么非要它:`nudgeFromSessions` 只有在拿到「本项目自己的 sessionDir」时才敢给候选
|
|
248
|
-
* 贴项目名、写提议桶、落草稿;给不出就会降级成「无归属普查」(见那里的 `refused`)。
|
|
249
|
-
* 而桶名 → 项目这条路,除了会话头里的 `cwd` 没有可信来源。
|
|
250
|
-
*
|
|
251
|
-
* 返回里 `reason` 是给人看的那句话:
|
|
252
|
-
* matched 找到了
|
|
253
|
-
* bad-project 调用方没给项目根 / 给的不是路径
|
|
254
|
-
* no-sessions-dir 会话根不存在(新机器、DSH 还没写过东西)
|
|
255
|
-
* no-bucket 扫完了,没有任何桶的会话头指向这个项目(这个项目确实还没跑过会话)
|
|
256
|
-
* unreadable 桶在,但所有会话头的 cwd 都读不出来——**这一条必须与 no-bucket 分开**:
|
|
257
|
-
* 前者是「读不到」,后者是「确实没有」,混起来就等于把故障报成空结果。
|
|
258
|
-
* budget-exceeded 预算用光还没扫完——同样**不是**关于这个项目的答案(见 deadline 参数)。
|
|
259
|
-
* bucket-limit 桶数超过 maxBuckets 被截断,且截断范围内没找到这个项目——**也不是**
|
|
260
|
-
* 「确实没有」:目标可能就在被截掉的那部分里(同日评审抓出)。
|
|
261
|
-
*
|
|
262
|
-
* `deadline` 是这次解析的绝对截止时刻(毫秒)。不给就扫到底(测试与离线批处理要完整
|
|
263
|
-
* 结果)。为什么要它:这一层是**同步**的,最多 500 个桶 × 3 个文件,全都走同步 I/O;
|
|
264
|
-
* 调用方手里的 `budgetMs` 原先只约束后面的挖掘,约束不到这里——一条「真卡就调
|
|
265
|
-
* budgetMs」的建议对这段路径是空话(v0.9.3 评审抓出)。
|
|
266
|
-
*/
|
|
267
|
-
export function resolveProjectSessionDir({
|
|
268
|
-
dshHome = process.env.DSH_HOME || join(homedir(), ".dsh"),
|
|
269
|
-
projectRoot = null,
|
|
270
|
-
maxBuckets = 500,
|
|
271
|
-
filesPerBucket = 3,
|
|
272
|
-
deadline = null
|
|
273
|
-
} = {}) {
|
|
274
|
-
const target = normalizeProjectPath(projectRoot);
|
|
275
|
-
if (!target) {
|
|
276
|
-
return { sessionDir: null, bucket: null, reason: "bad-project", bucketsScanned: 0, filesRead: 0 };
|
|
277
|
-
}
|
|
278
|
-
const root = resolveSessionDir({ dshHome });
|
|
279
|
-
let entries;
|
|
280
|
-
try {
|
|
281
|
-
entries = readdirSync(root, { withFileTypes: true });
|
|
282
|
-
} catch {
|
|
283
|
-
return { sessionDir: null, bucket: null, reason: "no-sessions-dir", sessionsRoot: root, bucketsScanned: 0, filesRead: 0 };
|
|
284
|
-
}
|
|
285
|
-
const allBuckets = [];
|
|
286
|
-
for (const entry of entries) {
|
|
287
|
-
if (!entry.isDirectory()) continue;
|
|
288
|
-
allBuckets.push(entry.name);
|
|
289
|
-
}
|
|
290
|
-
// 先收全、再排序、最后截断——与 findSessionFiles 同一条规矩。早前是「边收边截」:
|
|
291
|
-
// 到 500 个就 break,然后才 sort(),于是**留下哪 500 个由文件系统遍历顺序决定**。
|
|
292
|
-
// 目标项目的桶一旦落在被截掉的那部分,函数就返回 no-bucket——把一次截断报成
|
|
293
|
-
// 「这个项目确实没有会话」,正好是本文件反复在防的「读不到 vs 确实没有」
|
|
294
|
-
// (v0.9.3 评审抓出)。
|
|
295
|
-
allBuckets.sort();
|
|
296
|
-
const bucketsTruncated = allBuckets.length > maxBuckets;
|
|
297
|
-
const buckets = bucketsTruncated ? allBuckets.slice(0, maxBuckets) : allBuckets;
|
|
298
|
-
let filesRead = 0;
|
|
299
|
-
let bucketsWithCwd = 0;
|
|
300
|
-
const outOfBudget = () => deadline !== null && Number.isFinite(deadline) && Date.now() > deadline;
|
|
301
|
-
for (const bucket of buckets) {
|
|
302
|
-
// 桶循环里查预算:每个桶都可能要 readdir + 读 3 个文件,全同步,
|
|
303
|
-
// 一次解析跑满 500 个桶就是几百毫秒到几秒不等。
|
|
304
|
-
if (outOfBudget()) {
|
|
305
|
-
return {
|
|
306
|
-
sessionDir: null,
|
|
307
|
-
bucket: null,
|
|
308
|
-
reason: "budget-exceeded",
|
|
309
|
-
sessionsRoot: root,
|
|
310
|
-
bucketsScanned: buckets.length,
|
|
311
|
-
bucketsTotal: allBuckets.length,
|
|
312
|
-
bucketsTruncated,
|
|
313
|
-
filesRead
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
const dir = join(root, bucket);
|
|
317
|
-
// 每个桶只读前几个文件:同一个桶里所有会话的 cwd 必然相同(桶是按工作区分的),
|
|
318
|
-
// 读三个只是给「第一个文件正好是截断的」留余量,不是要把整个桶扫一遍。
|
|
319
|
-
const found = findSessionFiles(dir, { limit: filesPerBucket });
|
|
320
|
-
for (const file of found.files.slice(0, filesPerBucket)) {
|
|
321
|
-
// 文件循环里也查:filesPerBucket 可被调用方调大,3 是最小值不是上限。
|
|
322
|
-
if (outOfBudget()) {
|
|
323
|
-
return {
|
|
324
|
-
sessionDir: null,
|
|
325
|
-
bucket: null,
|
|
326
|
-
reason: "budget-exceeded",
|
|
327
|
-
sessionsRoot: root,
|
|
328
|
-
bucketsScanned: buckets.length,
|
|
329
|
-
bucketsTotal: allBuckets.length,
|
|
330
|
-
bucketsTruncated,
|
|
331
|
-
filesRead
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
const cwd = readSessionCwd(file);
|
|
335
|
-
filesRead += 1;
|
|
336
|
-
if (!cwd) continue;
|
|
337
|
-
bucketsWithCwd += 1;
|
|
338
|
-
if (normalizeProjectPath(cwd) === target) {
|
|
339
|
-
return {
|
|
340
|
-
sessionDir: dir,
|
|
341
|
-
bucket,
|
|
342
|
-
reason: "matched",
|
|
343
|
-
matchedCwd: cwd,
|
|
344
|
-
sessionsRoot: root,
|
|
345
|
-
bucketsScanned: buckets.length,
|
|
346
|
-
bucketsTotal: allBuckets.length,
|
|
347
|
-
bucketsTruncated,
|
|
348
|
-
filesRead
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
return {
|
|
354
|
-
sessionDir: null,
|
|
355
|
-
bucket: null,
|
|
356
|
-
// 桶里一个 cwd 都读不出来 → 是读不了,不是没有。
|
|
357
|
-
// 截断且没命中 → 也不是「确实没有」:目标可能就在被截掉的那部分里。
|
|
358
|
-
reason: buckets.length === 0
|
|
359
|
-
? "no-sessions-dir"
|
|
360
|
-
: bucketsWithCwd === 0
|
|
361
|
-
? "unreadable"
|
|
362
|
-
: bucketsTruncated
|
|
363
|
-
? "bucket-limit"
|
|
364
|
-
: "no-bucket",
|
|
365
|
-
sessionsRoot: root,
|
|
366
|
-
bucketsScanned: buckets.length,
|
|
367
|
-
bucketsTotal: allBuckets.length,
|
|
368
|
-
bucketsTruncated,
|
|
369
|
-
filesRead
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* 逐帧解一个**多帧** zstd buffer,边解边回调。
|
|
375
|
-
*
|
|
376
|
-
* 为什么不能直接 `zstdDecompressSync(buf)`:它只返回**第一帧**,不报错、不警告。
|
|
377
|
-
* 实测一个 1.5 MB 的会话文件只解出 291 字节,99% 的内容没被扫到,于是得出「零命中」
|
|
378
|
-
* 的假结论——这类错在本仓历史上出现过,所以这里自己按 magic 切帧。
|
|
379
|
-
*
|
|
380
|
-
* 为什么用回调而不是返回整串:一次读上百个会话文件会 OOM,逐帧交给回调,内存只留一帧。
|
|
381
|
-
*
|
|
382
|
-
* 关于**截断帧**:zstd 是流式格式,尾部被截断的帧会解出「部分文本」而不抛错。这里
|
|
383
|
-
* **不猜帧的完整性**,改在行这一层兜底——残缺帧的尾巴一定是半行,而 jsonl 本来就要求
|
|
384
|
-
* 整行是合法 JSON,半行会解析失败被丢掉。取舍是**宁可少一条,不要错一条**。
|
|
385
|
-
*/
|
|
386
|
-
export function forEachZstdFrame(buf, onText) {
|
|
387
|
-
let offset = 0;
|
|
388
|
-
let frames = 0;
|
|
389
|
-
let failed = 0;
|
|
390
|
-
while (offset < buf.length) {
|
|
391
|
-
const start = buf.indexOf(ZSTD_MAGIC, offset);
|
|
392
|
-
if (start === -1) break;
|
|
393
|
-
let end = buf.indexOf(ZSTD_MAGIC, start + 4);
|
|
394
|
-
if (end === -1) end = buf.length;
|
|
395
|
-
try {
|
|
396
|
-
onText(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8"));
|
|
397
|
-
frames += 1;
|
|
398
|
-
} catch {
|
|
399
|
-
// 帧头坏了:计入 failed 继续后面的帧——「解不出来」要被报出来,不能被当成「没有内容」。
|
|
400
|
-
failed += 1;
|
|
401
|
-
}
|
|
402
|
-
offset = end;
|
|
403
|
-
}
|
|
404
|
-
return { frames, failed };
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* 递归找出会话日志文件。
|
|
409
|
-
*
|
|
410
|
-
* **先收全、再排序、最后截断**(早前是先截断再排序,于是「留下哪几个」由遍历顺序决定,
|
|
411
|
-
* 而遍历顺序里带着工作区目录名——截断量会报,**选择偏差不会报**,实测这正是评分表上
|
|
412
|
-
* 「2000 文件上限按遍历序截断」那一条)。路径本身是短字符串,收全的代价可以忽略;
|
|
413
|
-
* 真正的内存护栏在读取层(`readSessionObservations` 逐帧解、读完即 `buf.fill(0)`)。
|
|
414
|
-
*
|
|
415
|
-
* `totalFiles` 是真总数、`files` 是截断后的结果、`truncated` 说明两者不同——
|
|
416
|
-
* 三个数一起给,「只扫了一部分」才看得见。
|
|
417
|
-
*/
|
|
418
|
-
export function findSessionFiles(dir, { limit = MAX_SESSION_FILES } = {}) {
|
|
419
|
-
const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : MAX_SESSION_FILES;
|
|
420
|
-
const result = { files: [], unreadable: [], present: false, truncated: false, totalFiles: 0 };
|
|
421
|
-
if (!existsSync(dir)) return result;
|
|
422
|
-
result.present = true;
|
|
423
|
-
const all = [];
|
|
424
|
-
const walk = (current) => {
|
|
425
|
-
let entries;
|
|
426
|
-
try {
|
|
427
|
-
entries = readdirSync(current, { withFileTypes: true });
|
|
428
|
-
} catch (error) {
|
|
429
|
-
// 「目录不可读」与「目录是空的」必须能分辨,否则读数上同形——那正是本仓反复防的那类错。
|
|
430
|
-
result.unreadable.push({ dir: current, detail: `目录读失败:${error.message}` });
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
for (const entry of entries) {
|
|
434
|
-
const full = join(current, entry.name);
|
|
435
|
-
// 符号链接要单独认:Dirent 对 symlink 的 isDirectory() 返回 false。
|
|
436
|
-
let isDir = entry.isDirectory();
|
|
437
|
-
if (entry.isSymbolicLink()) {
|
|
438
|
-
try {
|
|
439
|
-
isDir = statSync(full).isDirectory();
|
|
440
|
-
} catch {
|
|
441
|
-
result.unreadable.push({ dir: full, detail: "符号链接目标不可达" });
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
if (isDir) walk(full);
|
|
446
|
-
else if (entry.name.startsWith(SESSION_FILE_PREFIX)) all.push(full);
|
|
447
|
-
}
|
|
448
|
-
};
|
|
449
|
-
walk(dir);
|
|
450
|
-
all.sort();
|
|
451
|
-
result.totalFiles = all.length;
|
|
452
|
-
result.truncated = all.length > cap;
|
|
453
|
-
result.files = all.slice(0, cap);
|
|
454
|
-
return result;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* 一次工具调用 → 一次观察。三类调用**判定方式故意不同**(照宿主各自契约):
|
|
459
|
-
* · `skill` → 参数里的 `name` 是技能名;
|
|
460
|
-
* · `mcp__<server>__<tool>` → 中间那段是 server;
|
|
461
|
-
* · 其余内置工具(read / pwsh / write …)→ 工具名本身就是它的身份。
|
|
462
|
-
*
|
|
463
|
-
* `command` 只为**风险下限**与**措辞**服务,原文带本机路径,所以下游一律先过
|
|
464
|
-
* `safeCommandTokens` 的清洗,绝不整条进草稿文件。
|
|
465
|
-
*
|
|
466
|
-
* `bucket` 是**工作区桶**(会话文件往上数第三层目录名)。为什么非要带上它:
|
|
467
|
-
* 会话目录在 DSH 侧按工作区分桶,`sessionId` 只是桶内那个会话目录名——两条不同工作区
|
|
468
|
-
* 里的会话会拿到两个不同的 `sessionId`,于是一段序列在两个项目里各出现一次,就会被
|
|
469
|
-
* 并成「支持度 2 的同一工作流」。那是把两个项目的账合成一本,实测过(复核报告 §2.6)。
|
|
470
|
-
* 带上 bucket 之后,支持度只在同一个桶内累加。
|
|
471
|
-
*/
|
|
472
|
-
export function observationsFromCalls(calls, { sessionId = null, bucket = null } = {}) {
|
|
473
|
-
const out = [];
|
|
474
|
-
for (const call of Array.isArray(calls) ? calls : []) {
|
|
475
|
-
if (!call || typeof call.name !== "string" || call.name === "") continue;
|
|
476
|
-
const args = call.args && typeof call.args === "object" ? call.args : {};
|
|
477
|
-
let kind = "tool";
|
|
478
|
-
let name = call.name;
|
|
479
|
-
if (call.name === "skill") {
|
|
480
|
-
const skill = typeof args.name === "string" ? args.name.trim() : "";
|
|
481
|
-
if (skill === "") continue;
|
|
482
|
-
kind = "skill";
|
|
483
|
-
name = skill;
|
|
484
|
-
} else if (call.name.startsWith("mcp__")) {
|
|
485
|
-
const parts = call.name.split("__");
|
|
486
|
-
if (!parts[1]) continue;
|
|
487
|
-
kind = "mcp";
|
|
488
|
-
name = parts[1];
|
|
489
|
-
}
|
|
490
|
-
const command =
|
|
491
|
-
typeof args.command === "string" ? args.command
|
|
492
|
-
: typeof args.cmd === "string" ? args.cmd
|
|
493
|
-
: typeof args.file_path === "string" ? args.file_path
|
|
494
|
-
: "";
|
|
495
|
-
const at = Number.isFinite(call.time) ? new Date(call.time).toISOString() : null;
|
|
496
|
-
out.push({ sessionId, bucket, turn: call.turn ?? null, at, tool: call.name, kind, name, command });
|
|
497
|
-
}
|
|
498
|
-
return out;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/** 一行 jsonl 里的 tool-call 全部抽出来(形状见 session 日志:`data.message.content[]`)。 */
|
|
502
|
-
export function toolCallsInLine(line) {
|
|
503
|
-
if (typeof line !== "string" || !line.includes('"tool-call"')) return [];
|
|
504
|
-
let obj;
|
|
505
|
-
try {
|
|
506
|
-
obj = JSON.parse(line);
|
|
507
|
-
} catch {
|
|
508
|
-
return [];
|
|
509
|
-
}
|
|
510
|
-
const content = obj?.data?.message?.content;
|
|
511
|
-
if (!Array.isArray(content)) return [];
|
|
512
|
-
const out = [];
|
|
513
|
-
for (const item of content) {
|
|
514
|
-
if (!item || item.type !== "tool-call" || typeof item.name !== "string") continue;
|
|
515
|
-
let args = null;
|
|
516
|
-
// 参数是 **JSON 字符串**(不是对象,也不叫 parameters)——取错字段会得到 null,
|
|
517
|
-
// 看着像「调用了但没记参数」。
|
|
518
|
-
if (typeof item.arguments === "string") {
|
|
519
|
-
try {
|
|
520
|
-
args = JSON.parse(item.arguments);
|
|
521
|
-
} catch {
|
|
522
|
-
args = null;
|
|
523
|
-
}
|
|
524
|
-
} else if (item.arguments && typeof item.arguments === "object") {
|
|
525
|
-
args = item.arguments;
|
|
526
|
-
}
|
|
527
|
-
out.push({ name: item.name, args, turn: obj?.data?.turn ?? null, time: Number.isFinite(obj?.time) ? obj.time : null });
|
|
528
|
-
}
|
|
529
|
-
return out;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* 扫会话日志,产出**按会话排好的观察串**(这是候选的唯一原始材料)。
|
|
534
|
-
*
|
|
535
|
-
* 会话 id 取文件名上面那一层目录名(DSH 侧一个会话一个目录),工作区桶取再上面那一层;
|
|
536
|
-
* 取不到就落 null——落 null 的观察只会让支持度算不出来,不会假装成「同一个会话」
|
|
537
|
-
* 或「同一个项目」。
|
|
538
|
-
*/
|
|
539
|
-
export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES, deadline = null, sessionGate = null } = {}) {
|
|
540
|
-
const filterSessions = sessionGate?.isEnabled?.() === true;
|
|
541
|
-
const found = findSessionFiles(sessionDir, { limit });
|
|
542
|
-
const files = found.files.slice(0, limit);
|
|
543
|
-
const totalFiles = Number.isFinite(found.totalFiles) ? found.totalFiles : found.files.length;
|
|
544
|
-
const result = {
|
|
545
|
-
sessionDir,
|
|
546
|
-
present: found.present,
|
|
547
|
-
// 「目录在」与「目录真能列出来」分开报:否则「不可读」与「空目录」同形。
|
|
548
|
-
readable: found.present && found.unreadable.length === 0,
|
|
549
|
-
truncated: found.truncated === true || totalFiles > files.length,
|
|
550
|
-
// 时间预算用光而提前收工:与「文件数上限」是两种截断,必须分开报——否则
|
|
551
|
-
// 「扫了一部分」会伪装成「扫完了但没挖到」。面板与调用方据此说「只看了最近这些」。
|
|
552
|
-
budgetExceeded: false,
|
|
553
|
-
totalFiles,
|
|
554
|
-
scannedFiles: 0,
|
|
555
|
-
frames: 0,
|
|
556
|
-
failedFrames: 0,
|
|
557
|
-
lines: 0,
|
|
558
|
-
calls: 0,
|
|
559
|
-
observations: [],
|
|
560
|
-
unreadable: [...found.unreadable]
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
for (const file of files) {
|
|
564
|
-
// 预算到点就停:解压是**同步**的,一次全量扫描会把宿主主线程按住十几秒
|
|
565
|
-
// (本机 118 份日志实测 16.2s)。宁可少看几份并如实报出来,也不能让一次
|
|
566
|
-
// 面板点击变成整窗卡死——那会被当成插件坏了。
|
|
567
|
-
if (deadline !== null && Date.now() > deadline) {
|
|
568
|
-
result.budgetExceeded = true;
|
|
569
|
-
result.truncated = true;
|
|
570
|
-
break;
|
|
571
|
-
}
|
|
572
|
-
const parts = file.split(/[\\/]/);
|
|
573
|
-
const sessionId = parts.slice(-2, -1)[0] || null;
|
|
574
|
-
const bucket = parts.slice(-3, -2)[0] || null;
|
|
575
|
-
if (filterSessions && sessionGate.isOff(sessionId)) continue;
|
|
576
|
-
let buf;
|
|
577
|
-
try {
|
|
578
|
-
buf = readFileSync(file);
|
|
579
|
-
} catch (error) {
|
|
580
|
-
result.unreadable.push({ file, detail: `读失败:${error.message}` });
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
result.scannedFiles += 1;
|
|
584
|
-
let headerChecked = false;
|
|
585
|
-
let excluded = false;
|
|
586
|
-
const stat = forEachZstdFrame(buf, (text) => {
|
|
587
|
-
for (const line of text.split("\n")) {
|
|
588
|
-
if (excluded) break;
|
|
589
|
-
if (!headerChecked && line.trim()) {
|
|
590
|
-
headerChecked = true;
|
|
591
|
-
if (filterSessions) {
|
|
592
|
-
try {
|
|
593
|
-
const row = JSON.parse(line);
|
|
594
|
-
// 会话日志首行是 {"type":"session", version, id, createdAt, cwd,
|
|
595
|
-
// isSeeded, delegationDepth, origin?, parentSession?}——**头字段就在
|
|
596
|
-
// 这一行的顶层**,没有 header/config 包一层(本机 641 个真实会话首行
|
|
597
|
-
// 全部如此)。所以先认 type,再退到 header/config 两种带壳形状。
|
|
598
|
-
// 旧写法末尾还有 `?? row?.data ?? row` 两级兜底,等于把**任何**首行
|
|
599
|
-
// 都当作头:一条 {"type":"permission/preset", data:{preset}} 会被读成
|
|
600
|
-
// 「没有 origin、没有 delegationDepth 的主会话」而放行,闸门静默失效;
|
|
601
|
-
// 认不出来时宁可返回 null(isOff 收到 null 只会按 id 判),也不假装
|
|
602
|
-
// 那是一条头。
|
|
603
|
-
const header = row?.type === "session" ? row : (row?.header ?? row?.config ?? null);
|
|
604
|
-
if (sessionGate.isOff({ id: sessionId, header })) {
|
|
605
|
-
excluded = true;
|
|
606
|
-
break;
|
|
607
|
-
}
|
|
608
|
-
} catch {
|
|
609
|
-
// 坏头无法可靠判定会话归属;后续坏行仍由既有解析路径跳过。
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
if (!line.trim() || !line.includes('"tool-call"')) continue;
|
|
614
|
-
result.lines += 1;
|
|
615
|
-
const calls = toolCallsInLine(line);
|
|
616
|
-
if (!calls.length) continue;
|
|
617
|
-
result.calls += calls.length;
|
|
618
|
-
result.observations.push(...observationsFromCalls(calls, { sessionId, bucket }));
|
|
619
|
-
}
|
|
620
|
-
});
|
|
621
|
-
result.frames += stat.frames;
|
|
622
|
-
result.failedFrames += stat.failed;
|
|
623
|
-
buf.fill(0);
|
|
624
|
-
if (excluded) continue;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
return result;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* 观察串 → 段。两件事:
|
|
632
|
-
* · **按回合切段**:同一回合里的连续调用才是「一次怎么做事」,跨回合是两次事件;
|
|
633
|
-
* · **折叠连续同一步**:同一能力连调四次不是「四步序列」,是这一步很重。折叠但把
|
|
634
|
-
* `repeats` 记下来——次数是事实,丢掉就少一个信号。
|
|
635
|
-
*/
|
|
636
|
-
export function segmentObservations(observations) {
|
|
637
|
-
const segments = [];
|
|
638
|
-
let current = null;
|
|
639
|
-
for (const obs of Array.isArray(observations) ? observations : []) {
|
|
640
|
-
// 桶也进切段键:两个工作区里的同名会话目录绝不能被当成同一段。
|
|
641
|
-
const sameSegment =
|
|
642
|
-
current && current.sessionId === obs.sessionId && current.turn === obs.turn && current.bucket === obs.bucket;
|
|
643
|
-
if (!sameSegment) {
|
|
644
|
-
current = { sessionId: obs.sessionId, bucket: obs.bucket ?? null, turn: obs.turn, steps: [] };
|
|
645
|
-
segments.push(current);
|
|
646
|
-
}
|
|
647
|
-
const key = `${obs.kind}:${obs.name}`;
|
|
648
|
-
const last = current.steps[current.steps.length - 1];
|
|
649
|
-
if (last && last.key === key) {
|
|
650
|
-
last.repeats += 1;
|
|
651
|
-
last.at = obs.at || last.at;
|
|
652
|
-
continue;
|
|
653
|
-
}
|
|
654
|
-
current.steps.push({ key, kind: obs.kind, name: obs.name, tool: obs.tool, command: obs.command, at: obs.at, repeats: 1 });
|
|
655
|
-
}
|
|
656
|
-
return segments;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* 一段里某个位置的观察样本怎么并进模式:命令**取并**——风险下限要看过全部命令才敢说
|
|
661
|
-
* 「没命中兜底清单」,只看第一条会把第三条里的 `git push` 漏掉。
|
|
662
|
-
* 上限 12 条是内存护栏:同一个位置出现几百种不同命令时,它是噪音不是工作流。
|
|
663
|
-
*/
|
|
664
|
-
const MAX_COMMANDS_PER_POSITION = 12;
|
|
665
|
-
|
|
666
|
-
/** 省略痕迹里的工具名最多记几个:只为了让人看懂「夹的是什么」,不是完整清单。 */
|
|
667
|
-
const MAX_ELIDED_TOOLS = 4;
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* 把一批被抽掉的步骤并成一条**省略痕迹**。
|
|
671
|
-
* `head` 表示这些步骤在保留序列的第一个步骤之前——那一步不是这一段的入口,
|
|
672
|
-
* 「这一段入口」这句话本身也要跟着改(见 planStep)。
|
|
673
|
-
*/
|
|
674
|
-
function elidedFrom(pending) {
|
|
675
|
-
if (!pending.length) return null;
|
|
676
|
-
const count = pending.reduce((acc, s) => acc + (s.repeats || 1), 0);
|
|
677
|
-
const tools = [];
|
|
678
|
-
for (const s of pending) {
|
|
679
|
-
const label = `${s.kind}:${s.name}`;
|
|
680
|
-
if (!tools.includes(label) && tools.length < MAX_ELIDED_TOOLS) tools.push(label);
|
|
681
|
-
}
|
|
682
|
-
return { count, tools, head: false };
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/** 同一次省略在多次出现里取「最保守」的读法:次数取最大、工具取并。 */
|
|
686
|
-
function mergeElided(a, b) {
|
|
687
|
-
if (!a) return b;
|
|
688
|
-
if (!b) return a;
|
|
689
|
-
return {
|
|
690
|
-
count: Math.max(a.count, b.count),
|
|
691
|
-
tools: [...new Set([...a.tools, ...b.tools])].slice(0, MAX_ELIDED_TOOLS),
|
|
692
|
-
head: a.head && b.head
|
|
693
|
-
};
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function mergePosition(target, step) {
|
|
697
|
-
target.repeats = Math.max(target.repeats, step.repeats);
|
|
698
|
-
if (target.tool === null) target.tool = step.tool;
|
|
699
|
-
if (step.command && !target.commands.includes(step.command) && target.commands.length < MAX_COMMANDS_PER_POSITION) {
|
|
700
|
-
target.commands.push(step.command);
|
|
701
|
-
}
|
|
702
|
-
// 同一个位置在有的会话里夹着别的调用、有的没有 → 按「夹着」记(保守:宁可多说一句
|
|
703
|
-
// 「中间还有东西」,也不要断言一个其实不成立的相邻关系)。
|
|
704
|
-
target.elided = mergeElided(target.elided, step.elided || null);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* 从步名里取出可进仓库的短词。**这不是美化,是清洗**:命令原文里有本机路径与用户名,
|
|
709
|
-
* 而草稿文件是要提交、要 review 的。白名单式的取词能机械地保证这一点:
|
|
710
|
-
* · 只收 `[A-Za-z][A-Za-z0-9._-]*` —— 盘符(带冒号)、路径(带分隔符)、非 ASCII
|
|
711
|
-
* (本机用户名常含非 ASCII)全被这条挡在外面;
|
|
712
|
-
* · 再挡一次家目录名本身,以及 `users` / `home` 这类路径段;
|
|
713
|
-
* · 取不到就返回空数组,**宁可这一步没词,也不猜**。
|
|
714
|
-
*/
|
|
715
|
-
export function safeCommandTokens(command, { maxTokens = 2 } = {}) {
|
|
716
|
-
const homeParts = homedir().split(/[\\/]/).filter(Boolean).map((s) => s.toLowerCase());
|
|
717
|
-
const banned = new Set([...homeParts, "users", "home", "desktop", "documents", "appdata"]);
|
|
718
|
-
const out = [];
|
|
719
|
-
for (const raw of String(command || "").split(/\s+/)) {
|
|
720
|
-
const token = raw.replace(/^["']+|["']+$/g, "");
|
|
721
|
-
if (!/^[A-Za-z][A-Za-z0-9._-]{0,23}$/.test(token)) continue;
|
|
722
|
-
if (banned.has(token.toLowerCase())) continue;
|
|
723
|
-
out.push(token);
|
|
724
|
-
if (out.length >= maxTokens) break;
|
|
725
|
-
}
|
|
726
|
-
return out;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/**
|
|
730
|
-
* 这一步的**风险下限**——刻意叫「下限」不叫「风险档」:它只由机械清单算出,
|
|
731
|
-
* 不假装看懂了这个动作的全部含义(一个叫 `ef-broadcast` 的技能能对外发布,名字里
|
|
732
|
-
* 却没有任何危险词)。
|
|
733
|
-
*
|
|
734
|
-
* 判法两条,都可以按字节审计:
|
|
735
|
-
* 1. 观察到的命令原文命中 `IRREVERSIBLE_PATTERNS` → R2(与绕行候选同一份清单);
|
|
736
|
-
* 2. 会改本机文件的内置工具 → R1。
|
|
737
|
-
* 其余落 R0。**R3 永远不由机器给**——那一档的定义就是「得人来定」,机器给出来就是假的。
|
|
738
|
-
*/
|
|
739
|
-
export function riskFloorForStep({ tool = "", commands = [] } = {}) {
|
|
740
|
-
const hits = [];
|
|
741
|
-
for (const command of Array.isArray(commands) ? commands : []) {
|
|
742
|
-
const scan = scanIrreversible(command);
|
|
743
|
-
if (scan.hit) hits.push(...scan.matches.map((m) => m.id));
|
|
744
|
-
}
|
|
745
|
-
if (hits.length) return { risk: "R2", source: "blacklist", hits: [...new Set(hits)].sort() };
|
|
746
|
-
if (WRITE_TOOLS.has(tool)) return { risk: "R1", source: "write-tool", hits: [] };
|
|
747
|
-
return { risk: "R0", source: "default", hits: [] };
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/** 一步的措辞:观察到的部分照实写,人话一律留 `(待补)`(见文件头第 1 条分界线)。 */
|
|
751
|
-
function planStep(position, index, prevTitle) {
|
|
752
|
-
const words = safeCommandTokens(position.commands[0] || "");
|
|
753
|
-
const id = `S${index + 1}`;
|
|
754
|
-
let title;
|
|
755
|
-
let doText;
|
|
756
|
-
if (position.kind === "skill") {
|
|
757
|
-
title = `技能:${position.name}`;
|
|
758
|
-
doText = `调用技能 ${position.name}`;
|
|
759
|
-
} else if (position.kind === "mcp") {
|
|
760
|
-
title = `MCP:${position.name}`;
|
|
761
|
-
doText = `调用 ${position.name} 服务器上的能力`;
|
|
762
|
-
} else {
|
|
763
|
-
title = words.length ? `${position.tool}:${words.join(" ")}` : `工具:${position.tool}`;
|
|
764
|
-
doText = words.length ? `调用 ${position.tool},形如「${words.join(" ")}」` : `调用 ${position.tool}`;
|
|
765
|
-
}
|
|
766
|
-
const risk = riskFloorForStep({ tool: position.tool, commands: position.commands });
|
|
767
|
-
return {
|
|
768
|
-
id,
|
|
769
|
-
phase: "观察段",
|
|
770
|
-
title,
|
|
771
|
-
// **相邻关系只在真的相邻时才断言**:按层过滤(见 filterSegmentsByKinds)会把夹在中间的
|
|
772
|
-
// 调用抽掉,而过滤后的相邻不等于观察到的相邻——原始观察里中间可能隔着 4 次工具调用。
|
|
773
|
-
// 机器只写观察到的:夹着东西就把夹着的东西一起写出来(`elided`),不写就说明真没夹。
|
|
774
|
-
when: adjacencyWording(position, prevTitle),
|
|
775
|
-
do: doText,
|
|
776
|
-
who: "main",
|
|
777
|
-
tool: position.tool,
|
|
778
|
-
// 完成判据是准入判据的一半,机器给不了。
|
|
779
|
-
done: "(待补)这一步怎么算完成",
|
|
780
|
-
risk: risk.risk,
|
|
781
|
-
riskSource: risk.source,
|
|
782
|
-
riskHits: risk.hits,
|
|
783
|
-
repeats: position.repeats,
|
|
784
|
-
// 省略痕迹原样带进提议文件:草稿那边会被 validatePipeline 的白名单丢掉,所以
|
|
785
|
-
// 「这一步在过滤前长什么样」只有提议文件里查得到。
|
|
786
|
-
elided: position.elided || null,
|
|
787
|
-
needs: index === 0 ? [] : [`S${index}`],
|
|
788
|
-
// 反事实对照**故意留空**:它是准入判据,不是备注;没人回答之前宁可是空的。
|
|
789
|
-
// 面板会把空对照节点的边画得虚一点(见 pipeline.js 的 counterfactual 投影)。
|
|
790
|
-
why: "(待补)为什么必须有这一步——由 nudge 从重复观察里提出,还没人回答",
|
|
791
|
-
evidence: [],
|
|
792
|
-
gate: false
|
|
793
|
-
};
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
/** 相邻关系的措辞。三档:真相邻 / 过滤后相邻(要说清夹着什么)/ 同一步。 */
|
|
797
|
-
function adjacencyWording(position, prevTitle) {
|
|
798
|
-
const elided = position.elided || null;
|
|
799
|
-
const tail = elided ? `(中间还有 ${elided.count} 次其他调用:${elided.tools.join("、")})` : "";
|
|
800
|
-
if (!prevTitle) {
|
|
801
|
-
return elided
|
|
802
|
-
? `(待补)它在过滤后的序列里是第一个保留的步骤,它之前还有 ${elided.count} 次其他调用:${elided.tools.join("、")}`
|
|
803
|
-
: "(待补)这一段的入口";
|
|
804
|
-
}
|
|
805
|
-
return elided
|
|
806
|
-
? `(待补)观察中它在「${prevTitle}」之后${tail},相邻关系是过滤后的`
|
|
807
|
-
: `(待补)观察中它紧跟在「${prevTitle}」之后`;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* 按层过滤段,**并重新折叠**:抽掉中间的内置工具调用之后,原本被它隔开的两个同能力
|
|
812
|
-
* 调用会变成相邻——那是一次「很重的同一步」,不是两步。不重折叠就会把 `A → pwsh → A`
|
|
813
|
-
* 记成两处步骤,支持度与形状全歪。
|
|
814
|
-
*
|
|
815
|
-
* **抽掉的东西要留痕**(`elided`):过滤后的相邻不是观察到的相邻。痕迹挂在后一个保留
|
|
816
|
-
* 步骤上——`{ count, tools, head }`,`head: true` 表示被抽掉的都在它之前(那么它也不是
|
|
817
|
-
* 「这一段的入口」)。不留痕就会生成「紧跟在 A 之后」这种机器其实没观察到的断言。
|
|
818
|
-
*/
|
|
819
|
-
export function filterSegmentsByKinds(segments, kinds) {
|
|
820
|
-
if (!Array.isArray(kinds)) return segments;
|
|
821
|
-
const wanted = new Set(kinds);
|
|
822
|
-
const out = [];
|
|
823
|
-
for (const segment of Array.isArray(segments) ? segments : []) {
|
|
824
|
-
const steps = [];
|
|
825
|
-
let pending = [];
|
|
826
|
-
for (const step of segment.steps || []) {
|
|
827
|
-
if (!wanted.has(step.kind)) {
|
|
828
|
-
pending.push(step);
|
|
829
|
-
continue;
|
|
830
|
-
}
|
|
831
|
-
const last = steps[steps.length - 1];
|
|
832
|
-
if (last && last.key === step.key) {
|
|
833
|
-
last.repeats += step.repeats;
|
|
834
|
-
last.at = step.at || last.at;
|
|
835
|
-
// 同一步被夹开也算省略:`A → pwsh → A` 折成一步时,中间那次 pwsh 一样是「夹着的」。
|
|
836
|
-
last.elided = mergeElided(last.elided, elidedFrom(pending));
|
|
837
|
-
pending = [];
|
|
838
|
-
continue;
|
|
839
|
-
}
|
|
840
|
-
const elided = elidedFrom(pending);
|
|
841
|
-
if (elided && steps.length === 0) elided.head = true;
|
|
842
|
-
steps.push({ ...step, elided: elided || null });
|
|
843
|
-
pending = [];
|
|
844
|
-
}
|
|
845
|
-
if (steps.length) out.push({ sessionId: segment.sessionId, bucket: segment.bucket ?? null, turn: segment.turn, steps });
|
|
846
|
-
}
|
|
847
|
-
return out;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
/**
|
|
851
|
-
* 挖「同一段序列反复出现」。返回的每一条都已经带好步骤草稿(`steps`)与它自己那份
|
|
852
|
-
* **极大模式**的交代。
|
|
853
|
-
*
|
|
854
|
-
* 极大模式(maximal pattern):`A → B` 与 `A → B → C` 支持度相同时,只留长的那条。
|
|
855
|
-
* 理由:`A → B` 是 `A → B → C` 的一段前缀,两条都推给人就是把同一条工作流说两遍。
|
|
856
|
-
* 判据是**包含 + 会话集是子集**——只按字符串包含会把「支持度更低的短模式」也吞掉。
|
|
857
|
-
*
|
|
858
|
-
* **支持度只在同一个工作区桶内累加**(`key` 前缀带 bucket):两个不同项目里各见过一次
|
|
859
|
-
* 的同一段序列,不是「支持度 2 的同一工作流」,而是两条各自只有一次的观察。混桶会凭空
|
|
860
|
-
* 造出候选,并把别人的工作流算到这个项目头上(复核报告 §2.6 实测过)。
|
|
861
|
-
*/
|
|
862
|
-
export function mineRepeatedPatterns(segments, { minSupport = NUDGE_MIN_SUPPORT, minLength = NUDGE_MIN_LENGTH, maxLength = NUDGE_MAX_LENGTH, maxResults = NUDGE_MAX_CANDIDATES, kinds = DEFAULT_NUDGE_KINDS } = {}) {
|
|
863
|
-
const scoped = filterSegmentsByKinds(segments, kinds);
|
|
864
|
-
const byKey = new Map();
|
|
865
|
-
for (const segment of scoped) {
|
|
866
|
-
const steps = segment.steps || [];
|
|
867
|
-
const bucket = segment.bucket ?? null;
|
|
868
|
-
for (let len = minLength; len <= maxLength; len += 1) {
|
|
869
|
-
for (let i = 0; i + len <= steps.length; i += 1) {
|
|
870
|
-
const window = steps.slice(i, i + len);
|
|
871
|
-
const names = window.map((s) => `${s.kind}:${s.name}`);
|
|
872
|
-
const key = `${bucket ?? ""}\u0000${names.join(" → ")}`;
|
|
873
|
-
let entry = byKey.get(key);
|
|
874
|
-
if (!entry) {
|
|
875
|
-
entry = {
|
|
876
|
-
key,
|
|
877
|
-
bucket,
|
|
878
|
-
names,
|
|
879
|
-
sessions: new Set(),
|
|
880
|
-
occurrences: 0,
|
|
881
|
-
turns: new Set(),
|
|
882
|
-
firstSeenAt: null,
|
|
883
|
-
lastSeenAt: null,
|
|
884
|
-
positions: window.map((s) => ({ kind: s.kind, name: s.name, tool: s.tool, commands: [], repeats: 0, elided: null }))
|
|
885
|
-
};
|
|
886
|
-
byKey.set(key, entry);
|
|
887
|
-
}
|
|
888
|
-
entry.occurrences += 1;
|
|
889
|
-
if (segment.sessionId) entry.sessions.add(segment.sessionId);
|
|
890
|
-
if (segment.turn !== null && segment.turn !== undefined) entry.turns.add(`${segment.sessionId ?? ""}#${segment.turn}`);
|
|
891
|
-
for (let k = 0; k < window.length; k += 1) mergePosition(entry.positions[k], window[k]);
|
|
892
|
-
for (const at of names.map((_, k) => window[k].at).filter(Boolean)) {
|
|
893
|
-
if (!entry.firstSeenAt || at < entry.firstSeenAt) entry.firstSeenAt = at;
|
|
894
|
-
if (!entry.lastSeenAt || at > entry.lastSeenAt) entry.lastSeenAt = at;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
const candidates = [...byKey.values()]
|
|
901
|
-
.filter((e) => e.sessions.size >= minSupport)
|
|
902
|
-
// 长的、支持度高的先看:极大模式筛选取这个顺序,输出也按这个顺序。
|
|
903
|
-
.sort((a, b) => b.names.length - a.names.length || b.sessions.size - a.sessions.size || a.key.localeCompare(b.key));
|
|
904
|
-
|
|
905
|
-
const kept = [];
|
|
906
|
-
for (const entry of candidates) {
|
|
907
|
-
// 极大模式只在同一个桶内比较:跨桶的「包含」不是包含,是两回事。
|
|
908
|
-
const subsumed = kept.some(
|
|
909
|
-
(k) =>
|
|
910
|
-
k.bucket === entry.bucket &&
|
|
911
|
-
k.names.length > entry.names.length &&
|
|
912
|
-
containsSequence(k.names, entry.names) &&
|
|
913
|
-
isSubset(entry.sessions, k.sessions)
|
|
914
|
-
);
|
|
915
|
-
if (!subsumed) kept.push(entry);
|
|
916
|
-
}
|
|
917
|
-
kept.sort((a, b) => b.sessions.size - a.sessions.size || b.names.length - a.names.length || a.key.localeCompare(b.key));
|
|
918
|
-
|
|
919
|
-
return kept.slice(0, maxResults).map((entry) => toCandidate(entry));
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
function containsSequence(haystack, needle) {
|
|
923
|
-
for (let i = 0; i + needle.length <= haystack.length; i += 1) {
|
|
924
|
-
let ok = true;
|
|
925
|
-
for (let k = 0; k < needle.length; k += 1) {
|
|
926
|
-
if (haystack[i + k] !== needle[k]) {
|
|
927
|
-
ok = false;
|
|
928
|
-
break;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
if (ok) return true;
|
|
932
|
-
}
|
|
933
|
-
return false;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
function isSubset(a, b) {
|
|
937
|
-
for (const v of a) if (!b.has(v)) return false;
|
|
938
|
-
return true;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
/** 模式 → 候选(含步骤草稿)。id 由「工作区桶 + 步骤形状」定,所以同一段路再挖到会落到同一条上。 */
|
|
942
|
-
function toCandidate(entry, { now = Date.now() } = {}) {
|
|
943
|
-
const steps = [];
|
|
944
|
-
entry.positions.forEach((position, index) => {
|
|
945
|
-
steps.push(planStep(position, index, index === 0 ? null : steps[index - 1].title));
|
|
946
|
-
});
|
|
947
|
-
const shape = `${entry.bucket ?? ""}|${entry.names.join("|")}`;
|
|
948
|
-
return {
|
|
949
|
-
id: `nudge-${createHash("sha256").update(shape).digest("hex").slice(0, 12)}`,
|
|
950
|
-
origin: "nudge",
|
|
951
|
-
status: "proposed",
|
|
952
|
-
// 候选来自哪个工作区桶:**桶名是路径的有损变形,所以它是本机私有数据**,
|
|
953
|
-
// 只进 memoryDir 的提议文件(那本来就不进仓库),不进草稿文件。
|
|
954
|
-
bucket: entry.bucket ?? null,
|
|
955
|
-
names: entry.names.slice(),
|
|
956
|
-
steps,
|
|
957
|
-
support: entry.sessions.size,
|
|
958
|
-
occurrences: entry.occurrences,
|
|
959
|
-
sessions: [...entry.sessions].sort(),
|
|
960
|
-
turns: entry.turns.size,
|
|
961
|
-
firstSeenAt: entry.firstSeenAt,
|
|
962
|
-
lastSeenAt: entry.lastSeenAt,
|
|
963
|
-
createdAt: new Date(now).toISOString(),
|
|
964
|
-
counterfactual: null
|
|
965
|
-
};
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
/**
|
|
969
|
-
* 每一步的「有它 / 没它」对照——**这是本模块唯一能自动算出来的反事实材料**。
|
|
970
|
-
*
|
|
971
|
-
* 做法:同一段序列,有的会话走全了,有的会话中间少了某一步(`A→B→C` 与 `A→C` 都
|
|
972
|
-
* 出现过)。这两种都**真发生过**,所以「少走一步的次数」是观察,不是推测。
|
|
973
|
-
*
|
|
974
|
-
* 边界要写清:
|
|
975
|
-
* · **只有中间的步骤算得出这种对照**。首尾两步的「省略」等价于「这段序列没发生过」,
|
|
976
|
-
* 那是另一件事,硬算会造出一堆假对照。所以首尾不计——这个留白是有意的。
|
|
977
|
-
* · 统计范围是**所有段**,不是「候选出现过的那些会话」:少走了中间这一步的会话,
|
|
978
|
-
* 按定义就不在候选的会话集里。只用后者会永远数出 `withoutStep = 0`,
|
|
979
|
-
* 也就是把一个真观察说成「从来没有过反例」——那正是判据变假的样子。
|
|
980
|
-
* · 只看**候选自己用到的那几层**(候选是能力级,就只数能力级):否则中间夹着的
|
|
981
|
-
* 一次内置工具调用会把相邻两步拆开,`A→B→C` 在一次真实走过它的会话里也会被
|
|
982
|
-
* 数成「没走全」。口径必须与挖掘时一致,这里用候选自己的语汇来定,省得调用方
|
|
983
|
-
* 还要记得把过滤后的段传进来。
|
|
984
|
-
* · 只看**候选自己那个工作区桶**:别的项目里的 `A→C` 不是这条候选的反例,
|
|
985
|
-
* 它是另一个项目的事。混进来说明「少走这一步」的次数,等于拿别人的账本记账。
|
|
986
|
-
*
|
|
987
|
-
* **长度为 2 的序列这里一定是空的**,而且不是「没有反例」——是这种对照对它不适用。
|
|
988
|
-
* 早前那种「静默变空」在面板上看起来跟「反例为零」一模一样,所以现在显式带
|
|
989
|
-
* `empty` 与 `note` 两个字段,把「留白」和「零」分开(复核报告 §3 那一行)。
|
|
990
|
-
*/
|
|
991
|
-
export function omissionContrast(candidate, segments) {
|
|
992
|
-
const names = candidate.names || [];
|
|
993
|
-
const out = [];
|
|
994
|
-
const all = (Array.isArray(segments) ? segments : []).filter(
|
|
995
|
-
(s) => candidate.bucket === undefined || candidate.bucket === null || (s.bucket ?? null) === candidate.bucket
|
|
996
|
-
);
|
|
997
|
-
const kindsInScope = new Set(names.map((n) => String(n).split(":")[0]));
|
|
998
|
-
for (let i = 1; i < names.length - 1; i += 1) {
|
|
999
|
-
const reduced = names.filter((_, k) => k !== i);
|
|
1000
|
-
let withStep = 0;
|
|
1001
|
-
let withoutStep = 0;
|
|
1002
|
-
for (const segment of all) {
|
|
1003
|
-
const seq = (segment.steps || [])
|
|
1004
|
-
.filter((s) => kindsInScope.has(s.kind))
|
|
1005
|
-
.map((s) => `${s.kind}:${s.name}`);
|
|
1006
|
-
if (containsSequence(seq, names)) withStep += 1;
|
|
1007
|
-
else if (containsSequence(seq, reduced)) withoutStep += 1;
|
|
1008
|
-
}
|
|
1009
|
-
out.push({
|
|
1010
|
-
stepId: candidate.steps[i] ? candidate.steps[i].id : `S${i + 1}`,
|
|
1011
|
-
title: candidate.steps[i] ? candidate.steps[i].title : names[i],
|
|
1012
|
-
withStep,
|
|
1013
|
-
withoutStep
|
|
1014
|
-
});
|
|
1015
|
-
}
|
|
1016
|
-
const empty = out.length === 0;
|
|
1017
|
-
return {
|
|
1018
|
-
steps: out,
|
|
1019
|
-
interiorOnly: true,
|
|
1020
|
-
empty,
|
|
1021
|
-
// 「空」的两种含义必须分开:这段序列没有中间步骤(长度 2),还是中间步骤暂时没有对照数据。
|
|
1022
|
-
// 首尾不计这条规矩写在这里,免得看的人把留白读成「反例为零」。
|
|
1023
|
-
note: empty
|
|
1024
|
-
? "这段序列没有可算缺席对照的中间步骤(长度为 2 时首尾的省略等价于整段没发生)——留白,不是「反例为零」"
|
|
1025
|
-
: ""
|
|
1026
|
-
};
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
/**
|
|
1030
|
-
* 给候选配上运行记录里的三态计数。匹配靠**标题或步骤 id**:已生效工作流的 runs 里记的是
|
|
1031
|
-
* 它自己的 id(`A1` 之类),而候选的步骤是 `S1…`,两者不同源,所以对不上就返回 null
|
|
1032
|
-
* ——**宁可报「没对上」,也不要把别人的 run 算到这条候选头上**。
|
|
1033
|
-
*/
|
|
1034
|
-
function runsForCandidate(candidate, runs) {
|
|
1035
|
-
const wanted = new Set((candidate.steps || []).map((s) => s.title));
|
|
1036
|
-
const totals = { uses: 0, skipped: 0, detoured: 0, refuted: 0, matched: 0 };
|
|
1037
|
-
for (const run of Array.isArray(runs) ? runs : []) {
|
|
1038
|
-
for (const step of run?.steps || []) {
|
|
1039
|
-
if (!wanted.has(step.title) && !wanted.has(step.id)) continue;
|
|
1040
|
-
totals.matched += 1;
|
|
1041
|
-
const detoured = step.detoured === true || step.kind === "detour";
|
|
1042
|
-
const skipped = step.skipped === true;
|
|
1043
|
-
if (detoured) totals.detoured += 1;
|
|
1044
|
-
if (skipped) totals.skipped += 1;
|
|
1045
|
-
if (!detoured && !skipped) totals.uses += 1;
|
|
1046
|
-
if (step.refuted) totals.refuted += 1;
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
return totals.matched ? totals : null;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
/**
|
|
1053
|
-
* 反事实判据的**全部内容**:能观察的摆出来,不能观察的写成问题。
|
|
1054
|
-
*
|
|
1055
|
-
* `verdict` 永远是 null,这不是没做完,是设计(见文件头第 2 条)。谁要是把这里改成
|
|
1056
|
-
* 自动给一个「会漏步」,那一定是错的:日志里没有「跳过之后出事没出事」的标签。
|
|
1057
|
-
*
|
|
1058
|
-
* 能给的证据三类:
|
|
1059
|
-
* · 复现:同一段序列在几个会话、几次回合、什么时间跨度里出现过(硬事实);
|
|
1060
|
-
* · 缺席对照:中间那一步「走过几次 / 少走几次」(硬事实,见 omissionContrast);
|
|
1061
|
-
* · 运行记录与失败记录:如果这条工作流已经有 runs,或者人喂了记忆侧的失败行,一并报数。
|
|
1062
|
-
*
|
|
1063
|
-
* `failures` 传 `null`(默认)表示**记忆侧的失败记录还没接进来**,与「接进来了、
|
|
1064
|
-
* 确实一条都没有」是两件事,所以这里不给 `null`,而是给 `{ available, reason }`
|
|
1065
|
-
* ——会话源那侧早就是这么做的(`present` 与 `readable` 分开报),同一套纪律。
|
|
1066
|
-
*/
|
|
1067
|
-
export function buildCounterfactual(candidate, { segments = [], runs = [], failures = null, now = Date.now() } = {}) {
|
|
1068
|
-
const omission = omissionContrast(candidate, segments);
|
|
1069
|
-
const runTotals = runsForCandidate(candidate, runs);
|
|
1070
|
-
const failuresWired = Array.isArray(failures);
|
|
1071
|
-
const failureRows = failuresWired ? failures : [];
|
|
1072
|
-
const ask = (candidate.steps || []).map((step) => ({
|
|
1073
|
-
proposalId: candidate.id,
|
|
1074
|
-
stepId: step.id,
|
|
1075
|
-
title: step.title,
|
|
1076
|
-
question: COUNTERFACTUAL_QUESTION,
|
|
1077
|
-
choices: COUNTERFACTUAL_CHOICES.map((c) => c.id),
|
|
1078
|
-
choiceLabels: Object.fromEntries(CHOICE_LABELS),
|
|
1079
|
-
// 答案写到哪:草稿里这一步的 evidence[]。写在那里是有讲究的——`toPipelineV1` 会把
|
|
1080
|
-
// 它投影成 `counterfactual.contrast`,答不出来的步骤在图上就比答得出来的虚。
|
|
1081
|
-
writeTo: "steps[].evidence[]"
|
|
1082
|
-
}));
|
|
1083
|
-
|
|
1084
|
-
return {
|
|
1085
|
-
verdict: null,
|
|
1086
|
-
verdictNote:
|
|
1087
|
-
"判据不自动判:日志里读得到「这一步被跳过」这个动作,读不到「跳过之后出事没出事」这个结果——而那个结果正是判据本身。所以这里只摆证据、只问问题。",
|
|
1088
|
-
generatedAt: new Date(now).toISOString(),
|
|
1089
|
-
observable: {
|
|
1090
|
-
recurrence: {
|
|
1091
|
-
support: candidate.support,
|
|
1092
|
-
occurrences: candidate.occurrences,
|
|
1093
|
-
turns: candidate.turns ?? null,
|
|
1094
|
-
sessions: candidate.sessions ? candidate.sessions.slice(0, 10) : [],
|
|
1095
|
-
firstSeenAt: candidate.firstSeenAt || null,
|
|
1096
|
-
lastSeenAt: candidate.lastSeenAt || null
|
|
1097
|
-
},
|
|
1098
|
-
omission,
|
|
1099
|
-
runs: runTotals,
|
|
1100
|
-
// 「没接线」与「确实一条都没有」必须能分辨(同模块会话源那套 present/readable)。
|
|
1101
|
-
failures: failuresWired
|
|
1102
|
-
? { available: true, count: failureRows.length, kinds: [...new Set(failureRows.map((f) => f && f.failure_type).filter(Boolean))].sort() }
|
|
1103
|
-
: { available: false, reason: "not-wired", note: "记忆侧的失败记录还没接进来——这不是「没有失败记录」" },
|
|
1104
|
-
bucket: candidate.bucket ?? null
|
|
1105
|
-
},
|
|
1106
|
-
unobservable: [
|
|
1107
|
-
"没有这一步「这次会不会漏步」——需要人回答",
|
|
1108
|
-
"没有这一步「这次会不会失败/返工」——需要人回答",
|
|
1109
|
-
"这一步的完成判据与触发语——需要人补"
|
|
1110
|
-
],
|
|
1111
|
-
ask
|
|
1112
|
-
};
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
/**
|
|
1116
|
-
* 人的回答并进候选:写进那一步的 `evidence[]`(准入判据的载体),并留痕。
|
|
1117
|
-
*
|
|
1118
|
-
* 两条硬规矩:
|
|
1119
|
-
* · 非法选项**直接拒**,不静默当 `unknown`——静默降级会把一个打错的答案变成一个人的判断;
|
|
1120
|
-
* · **一条回答都没接受就什么都不推进**:`applyAnswers(candidate, {})` 早前返回
|
|
1121
|
-
* `{ok:true, status:"answered", applied:0}`——无人回答却「已答」。今天没有下游消费者,
|
|
1122
|
-
* 但这条通路的名字就叫「人点头」,将来只要有人写出「answered 才准升格」,那个假状态
|
|
1123
|
-
* 立刻生效。所以空回答按失败处理,`status` 一步都不许动。
|
|
1124
|
-
*/
|
|
1125
|
-
export function applyAnswers(candidate, answers, { now = Date.now() } = {}) {
|
|
1126
|
-
const problems = [];
|
|
1127
|
-
const answeredAt = new Date(now).toISOString();
|
|
1128
|
-
const steps = (candidate.steps || []).map((s) => ({ ...s, evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [] }));
|
|
1129
|
-
const applied = [];
|
|
1130
|
-
for (const [stepId, raw] of Object.entries(answers || {})) {
|
|
1131
|
-
const step = steps.find((s) => s.id === stepId);
|
|
1132
|
-
if (!step) {
|
|
1133
|
-
problems.push(`回答指向了不存在的步骤 ${stepId}`);
|
|
1134
|
-
continue;
|
|
1135
|
-
}
|
|
1136
|
-
const choice = typeof raw === "string" ? raw : raw && raw.choice;
|
|
1137
|
-
const note = typeof raw === "object" && raw ? String(raw.note || "").trim() : "";
|
|
1138
|
-
if (!CHOICE_IDS.includes(choice)) {
|
|
1139
|
-
problems.push(`${stepId} 的答案「${String(choice)}」不在选项里(${CHOICE_IDS.join(" / ")})`);
|
|
1140
|
-
continue;
|
|
1141
|
-
}
|
|
1142
|
-
const text = `人答(${answeredAt.slice(0, 10)}):没有它,${CHOICE_LABELS.get(choice)}${note ? `——${note}` : ""}`;
|
|
1143
|
-
step.evidence = [text];
|
|
1144
|
-
applied.push({ stepId, choice, text });
|
|
1145
|
-
}
|
|
1146
|
-
if (problems.length) return { ok: false, candidate: null, applied: [], unanswered: [], problems };
|
|
1147
|
-
if (applied.length === 0) {
|
|
1148
|
-
return {
|
|
1149
|
-
ok: false,
|
|
1150
|
-
candidate: null,
|
|
1151
|
-
applied: [],
|
|
1152
|
-
unanswered: steps.filter((s) => s.evidence.length === 0).map((s) => s.id),
|
|
1153
|
-
problems: ["没有任何一条回答被接受——空回答不算「已答」,状态不推进"]
|
|
1154
|
-
};
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
const next = { ...candidate, steps, status: "answered", answeredAt };
|
|
1158
|
-
const unanswered = steps.filter((s) => s.evidence.length === 0).map((s) => s.id);
|
|
1159
|
-
return { ok: true, candidate: next, applied, unanswered, problems: [] };
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
/** 草稿里的占位符只在这里出现一次,方便一眼看出「哪些还是机器留的白」。 */
|
|
1163
|
-
const TODO = "(待补)";
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* 候选 → `status:"draft"` 的草稿,**过 validatePipeline**。
|
|
1167
|
-
*
|
|
1168
|
-
* 为什么 status 写死 draft:提议自动、固化要人点头。这个函数没有 active 分支,
|
|
1169
|
-
* 也不接 `status` 参数——想升格就得有人动手,那条路不在这个文件里。
|
|
1170
|
-
*
|
|
1171
|
-
* 三个必填字段(`done` / `trigger` / 步骤的人话)机器给不了,所以填成显眼的 `(待补)`:
|
|
1172
|
-
* **一个看起来像成品、其实没人回答过判据的草稿,比一个明摆着是半成品的草稿危险。**
|
|
1173
|
-
*/
|
|
1174
|
-
export function draftFromCandidate(candidate, { project = "", name = "", done = "", trigger = null, version = 0 } = {}) {
|
|
1175
|
-
if (!candidate || !Array.isArray(candidate.steps) || candidate.steps.length === 0) {
|
|
1176
|
-
return { ok: false, pipeline: null, problems: ["候选没有步骤,不能成草稿"], unanswered: [] };
|
|
1177
|
-
}
|
|
1178
|
-
const raw = {
|
|
1179
|
-
id: String(candidate.id || ""),
|
|
1180
|
-
name: name || `(待补)由重复观察提出的工作流:${(candidate.names || []).join(" → ")}`,
|
|
1181
|
-
project: project || TODO,
|
|
1182
|
-
version,
|
|
1183
|
-
status: "draft",
|
|
1184
|
-
done: done || `${TODO}整条怎么算完成`,
|
|
1185
|
-
trigger: Array.isArray(trigger) && trigger.length ? trigger : [{ kind: "phrase", value: `${TODO}什么话该触发这条工作流` }],
|
|
1186
|
-
steps: candidate.steps.map((s, index) => ({
|
|
1187
|
-
id: s.id || `S${index + 1}`,
|
|
1188
|
-
phase: s.phase || "观察段",
|
|
1189
|
-
title: s.title,
|
|
1190
|
-
when: s.when,
|
|
1191
|
-
do: s.do,
|
|
1192
|
-
who: s.who || "main",
|
|
1193
|
-
tool: s.tool || "",
|
|
1194
|
-
done: s.done,
|
|
1195
|
-
risk: s.risk || "R0",
|
|
1196
|
-
needs: Array.isArray(s.needs) ? s.needs : index === 0 ? [] : [`S${index}`],
|
|
1197
|
-
why: s.why,
|
|
1198
|
-
evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [],
|
|
1199
|
-
gate: s.gate === true
|
|
1200
|
-
}))
|
|
1201
|
-
};
|
|
1202
|
-
try {
|
|
1203
|
-
const pipeline = validatePipeline(raw, `${candidate.id}.json`);
|
|
1204
|
-
return {
|
|
1205
|
-
ok: true,
|
|
1206
|
-
pipeline,
|
|
1207
|
-
problems: [],
|
|
1208
|
-
unanswered: pipeline.steps.filter((s) => s.evidence.length === 0).map((s) => s.id)
|
|
1209
|
-
};
|
|
1210
|
-
} catch (error) {
|
|
1211
|
-
return { ok: false, pipeline: null, problems: error.problems || [String(error && error.message)], unanswered: [] };
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
/**
|
|
1216
|
-
* **原子整文件写**:先写同目录的临时文件,再 `rename` 覆盖目标。
|
|
1217
|
-
*
|
|
1218
|
-
* 为什么非这样不可:读侧(`loadPipelines`)是「整目录校验,任一条坏就整层抛」,所以
|
|
1219
|
-
* 一个写到一半的文件会让**整个项目层的工作流一起从面板消失**——合法的那几条也一起没,
|
|
1220
|
-
* 而且因为「已存在就跳过」的规矩还不会自愈(复核报告 §2.9 实测过这个组合)。
|
|
1221
|
-
* `rename` 在同一文件系统内是原子的:读者要么看到旧的完整内容,要么看到新的完整内容。
|
|
1222
|
-
*
|
|
1223
|
-
* 临时文件必须**同目录**(跨盘 rename 会退化成拷贝,就不再原子了),并且带 pid 与随机后缀,
|
|
1224
|
-
* 免得两个进程同时写同一个目标时互相吃掉临时文件。
|
|
1225
|
-
*
|
|
1226
|
-
* `beforeRename` 是**给测试用的注入口**(生产路径不传):它让「rename 之前崩掉」这个窗口
|
|
1227
|
-
* 可以被真的模拟一次,从而证明「失败不会留下半个目标文件」。没有它,测试只能断言
|
|
1228
|
-
* 「代码里出现了 tmp 字样」,那证明不了这条修复要证明的事。
|
|
1229
|
-
*/
|
|
1230
|
-
export function writeFileAtomic(file, text, { beforeRename = null } = {}) {
|
|
1231
|
-
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1232
|
-
try {
|
|
1233
|
-
writeFileSync(tmp, text, "utf8");
|
|
1234
|
-
if (typeof beforeRename === "function") beforeRename(tmp);
|
|
1235
|
-
renameSync(tmp, file);
|
|
1236
|
-
} catch (error) {
|
|
1237
|
-
// 崩在中途:把临时文件收掉(目标文件一个字节都没动过,读者看到的还是旧内容)。
|
|
1238
|
-
try {
|
|
1239
|
-
rmSync(tmp, { force: true });
|
|
1240
|
-
} catch {
|
|
1241
|
-
// 收不掉也不该改判原错误——真正要报的是上面那个。
|
|
1242
|
-
}
|
|
1243
|
-
throw error;
|
|
1244
|
-
}
|
|
1245
|
-
return file;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
/**
|
|
1249
|
-
* 把草稿写进 `<项目>/.mneme/pipelines/<id>.json`。
|
|
1250
|
-
*
|
|
1251
|
-
* 三条硬规矩:
|
|
1252
|
-
* · `status` 不是 `draft` 就拒写——沉淀链只产草稿,固化是人的动作;
|
|
1253
|
-
* · 目标文件已存在就拒写(`force` 才覆盖)——**已经在人手里的文件不能被机器改写**,
|
|
1254
|
-
* 否则人改过的措辞会被下一次挖掘悄悄冲掉;
|
|
1255
|
-
* · 落盘走 `writeFileAtomic`——写一半就崩不能把整层工作流带走。
|
|
1256
|
-
*/
|
|
1257
|
-
export function writeDraftPipeline({ projectRoot, pipeline, force = false } = {}) {
|
|
1258
|
-
if (!projectRoot || String(projectRoot).trim() === "") return { ok: false, reason: "no-project-root", file: null };
|
|
1259
|
-
if (!pipeline || pipeline.status !== "draft") return { ok: false, reason: "not-draft", file: null, detail: "沉淀链只产 draft,升格要人点头" };
|
|
1260
|
-
let validated;
|
|
1261
|
-
try {
|
|
1262
|
-
validated = validatePipeline(pipeline, "<nudge-draft>");
|
|
1263
|
-
} catch (error) {
|
|
1264
|
-
return { ok: false, reason: "invalid", file: null, problems: error.problems || [String(error && error.message)] };
|
|
1265
|
-
}
|
|
1266
|
-
const dir = projectPipelineDir(projectRoot);
|
|
1267
|
-
const file = join(dir, `${validated.id}.json`);
|
|
1268
|
-
if (existsSync(file) && force !== true) return { ok: false, reason: "exists", file, id: validated.id };
|
|
1269
|
-
mkdirSync(dir, { recursive: true });
|
|
1270
|
-
writeFileAtomic(file, `${JSON.stringify(validated, null, 2)}\n`);
|
|
1271
|
-
return { ok: true, reason: "written", file, id: validated.id, pipeline: validated };
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
/** 提议(候选)的家:`<memoryDir>/proposals/<项目键>/`——与绕行候选同一套分桶约定。 */
|
|
1275
|
-
export function proposalDir({ memoryDir, projectRoot } = {}) {
|
|
1276
|
-
const key = projectKey(projectRoot);
|
|
1277
|
-
if (!key) return null;
|
|
1278
|
-
const base = expandHome(memoryDir && String(memoryDir).trim() ? memoryDir : "~/.dsh/memory");
|
|
1279
|
-
return join(base, "proposals", key);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
/**
|
|
1283
|
-
* 提议的形状校验。比定稿松(它还不是工作流),但**判据那一栏的形状一个都不能少**。
|
|
1284
|
-
*
|
|
1285
|
-
* `counterfactual.verdict` 在这里**必须恰好是 `null`**(键也必须在,缺键 = `undefined` 也拒)。
|
|
1286
|
-
* 为什么这条要落在校验层、而不只靠产者自觉:`summarizeProposal` 会把 `verdict` 投影给面板,
|
|
1287
|
-
* 于是一份被人手改过、或被别的写入方塞进来的提议就能让面板显示一个机器给的判据结论——
|
|
1288
|
-
* 那正是本模块的头号红线。产者守规矩只是「产者的性质」,校验层拒绝才是「边界的性质」。
|
|
1289
|
-
*/
|
|
1290
|
-
export function validateProposal(raw) {
|
|
1291
|
-
const problems = [];
|
|
1292
|
-
const bad = (msg) => problems.push(msg);
|
|
1293
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, problems: ["顶层必须是一个对象"] };
|
|
1294
|
-
if (typeof raw.id !== "string" || !/^nudge-[a-z0-9-]{4,64}$/.test(raw.id)) bad("id 必须是 nudge- 开头的小写 kebab");
|
|
1295
|
-
if (!PROPOSAL_STATUSES.includes(raw.status)) bad(`status 必须是 ${PROPOSAL_STATUSES.join(" / ")}`);
|
|
1296
|
-
if (!Array.isArray(raw.names) || raw.names.length === 0) bad("names 是序列本身,不能为空");
|
|
1297
|
-
if (!Array.isArray(raw.steps) || raw.steps.length === 0) bad("steps 至少要有一步");
|
|
1298
|
-
if (!Number.isInteger(raw.support) || raw.support < 1) bad("support 必须是正整数");
|
|
1299
|
-
// 反事实那一栏**必须存在**:可以是 null(还没算),但不能没有——没有它的候选不是候选。
|
|
1300
|
-
if (raw.counterfactual !== null) {
|
|
1301
|
-
if (typeof raw.counterfactual !== "object" || Array.isArray(raw.counterfactual)) {
|
|
1302
|
-
bad("counterfactual 要么是 null,要么是对象");
|
|
1303
|
-
} else if (raw.counterfactual.verdict !== null) {
|
|
1304
|
-
bad("counterfactual.verdict 必须是 null:判据只能由人回答,机器不许给出结论");
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
return { ok: problems.length === 0, problems };
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
/** 写一条提议(同 id 覆盖:同一段路再挖到应当并进同一条,而不是长出第二份)。落盘同样是原子的。 */
|
|
1311
|
-
export function saveProposal(dir, proposal) {
|
|
1312
|
-
if (!dir) throw new Error("proposalDir 为空:拿不到项目根或 memoryDir");
|
|
1313
|
-
const v = validateProposal(proposal);
|
|
1314
|
-
if (!v.ok) throw new Error(v.problems.join("; "));
|
|
1315
|
-
mkdirSync(dir, { recursive: true });
|
|
1316
|
-
writeFileAtomic(join(dir, `${proposal.id}.json`), `${JSON.stringify(proposal, null, 2)}\n`);
|
|
1317
|
-
return proposal;
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
/** 读一个项目下的全部提议;坏文件不静默跳过(与定稿同一纪律,静默少一条最难查)。 */
|
|
1321
|
-
export function loadProposals(dir) {
|
|
1322
|
-
if (!dir || !existsSync(dir)) return [];
|
|
1323
|
-
const out = [];
|
|
1324
|
-
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
|
1325
|
-
let raw;
|
|
1326
|
-
try {
|
|
1327
|
-
raw = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
|
1328
|
-
} catch (error) {
|
|
1329
|
-
throw new Error(`${file}: JSON 解析失败:${error.message}`);
|
|
1330
|
-
}
|
|
1331
|
-
const v = validateProposal(raw);
|
|
1332
|
-
if (!v.ok) throw new Error(`${file}: ${v.problems.join("; ")}`);
|
|
1333
|
-
out.push(raw);
|
|
1334
|
-
}
|
|
1335
|
-
return out;
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
/** 清单行:面板与 agent 共用(与 `pipeline.js` 的 available[]、`candidates.js` 的清单对齐)。 */
|
|
1339
|
-
export function summarizeProposal(proposal) {
|
|
1340
|
-
const cf = proposal.counterfactual || {};
|
|
1341
|
-
// 整条的风险取下限里的最高档:一段路的最坏情况才是它该被看待的档位。
|
|
1342
|
-
const rank = { R0: 0, R1: 1, R2: 2, R3: 3 };
|
|
1343
|
-
const riskFloor = (proposal.steps || []).reduce(
|
|
1344
|
-
(acc, s) => ((rank[s.risk] ?? 0) > rank[acc] ? s.risk : acc),
|
|
1345
|
-
"R0"
|
|
1346
|
-
);
|
|
1347
|
-
return {
|
|
1348
|
-
id: proposal.id,
|
|
1349
|
-
origin: proposal.origin || "nudge",
|
|
1350
|
-
status: proposal.status,
|
|
1351
|
-
sequence: (proposal.names || []).slice(),
|
|
1352
|
-
steps: (proposal.steps || []).length,
|
|
1353
|
-
support: proposal.support,
|
|
1354
|
-
occurrences: proposal.occurrences ?? null,
|
|
1355
|
-
firstSeenAt: proposal.firstSeenAt || null,
|
|
1356
|
-
lastSeenAt: proposal.lastSeenAt || null,
|
|
1357
|
-
// 反事实覆盖率:答过几步 / 一共几步。19/19 与 6/19 是两条不同的工作流,清单上就该看得出来。
|
|
1358
|
-
contrast: {
|
|
1359
|
-
answered: (proposal.steps || []).filter((s) => Array.isArray(s.evidence) && s.evidence.length > 0).length,
|
|
1360
|
-
total: (proposal.steps || []).length
|
|
1361
|
-
},
|
|
1362
|
-
// 判据**永远**是 null,这里照实投影,免得有人以为它只是还没算。
|
|
1363
|
-
verdict: cf.verdict ?? null,
|
|
1364
|
-
riskFloor
|
|
1365
|
-
};
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
/**
|
|
1369
|
-
* 已经在跑的工作流的自精简提议:跳过 ≥3 次 → 提议删掉这一步;绕开 ≥3 次 → 提议重画路径。
|
|
1370
|
-
* 计数与阈值全部来自 `pipeline.js`(那份是权威),这里只做两件事:把提议包成
|
|
1371
|
-
* **要人点头的形状**(带一个封闭问题),并把它和「新工作流候选」放进同一条通路。
|
|
1372
|
-
*/
|
|
1373
|
-
export function retirementProposals(pipeline, { runs = [], now = Date.now(), observed = null } = {}) {
|
|
1374
|
-
// 四类提议各自的封闭问题与选项。判据都不能自动算(每类都是),把答案空间
|
|
1375
|
-
// 写死成封闭选择——人点一下就行,不用自由发挥。新类不配问法就会落进
|
|
1376
|
-
// 「岔路」那条不相关的问法里,所以这张表是**穷举**而不是默认分支。
|
|
1377
|
-
const ASK = {
|
|
1378
|
-
"drop-step": {
|
|
1379
|
-
question: "这一步从没被执行、且总被跳过:是「它对这条流程已经没用了」还是「这几次正好不需要」?",
|
|
1380
|
-
choices: ["这一步没用了", "这次不需要", "不知道"],
|
|
1381
|
-
writeTo: "steps[].removedBy(人批后改 status/version)"
|
|
1382
|
-
},
|
|
1383
|
-
"rework-path": {
|
|
1384
|
-
// 同一 kind 有两种成因(pipeline.js 的 cause 字段):detoured 问重画路径,
|
|
1385
|
-
// skipped 问删步/改 optional——共用一句问法会让跳过场景答非所问
|
|
1386
|
-
// (CodeRabbit #458 F3)。cause 缺失(旧提议/外部构造)退回 detoured 问法。
|
|
1387
|
-
detoured: {
|
|
1388
|
-
question: "从这一步岔出去已经成了常态,是这条路径过时了,还是岔路该并进图里?",
|
|
1389
|
-
choices: ["路径过时了", "岔路该并进来", "不知道"],
|
|
1390
|
-
writeTo: "新版本的 steps[]"
|
|
1391
|
-
},
|
|
1392
|
-
skipped: {
|
|
1393
|
-
question: "这一必经步骤总被跳过:是这步该改成 optional(或删掉),还是流程本身写错了?",
|
|
1394
|
-
choices: ["该改成 optional", "该删掉", "流程写错了", "不知道"],
|
|
1395
|
-
writeTo: "steps[].optional(人批后改 version)"
|
|
1396
|
-
}
|
|
1397
|
-
},
|
|
1398
|
-
"violation-precedence": {
|
|
1399
|
-
question: "做了后继却没做它的前置:是顺序写错了,还是这个前置根本不必要?",
|
|
1400
|
-
choices: ["顺序写错了", "前置不必要", "不知道"],
|
|
1401
|
-
writeTo: "steps[].needs(人批后改 version)"
|
|
1402
|
-
},
|
|
1403
|
-
"fix-step-tool": {
|
|
1404
|
-
question: "你说做过这步,但声明的命令一次都没出现:是 tool 写过时了,还是这一步本来就不必敲它?",
|
|
1405
|
-
choices: ["tool 过时了", "本来不必敲", "我记错了", "不知道"],
|
|
1406
|
-
writeTo: "steps[].tool(人批后改 version)"
|
|
1407
|
-
},
|
|
1408
|
-
"optional-skip-ok": {
|
|
1409
|
-
question: "这个 optional 步骤一直被跳:声明留着就好,还是干脆显式从流程里拿掉?",
|
|
1410
|
-
choices: ["留着", "拿掉", "不知道"],
|
|
1411
|
-
writeTo: "无(信息项;人若要拿掉,走删步流程)"
|
|
1412
|
-
}
|
|
1413
|
-
};
|
|
1414
|
-
const items = suggestRetirements(pipeline, { runs, now, observed }).map((s) => {
|
|
1415
|
-
// rework-path 按 cause 二选一问法;其余 kind 直接查表。缺表项的兜底保持原样。
|
|
1416
|
-
const askTable = ASK[s.kind];
|
|
1417
|
-
const ask = askTable
|
|
1418
|
-
? (s.cause && askTable[s.cause]) || (typeof askTable.question === "string" ? askTable : askTable.detoured)
|
|
1419
|
-
: { question: "这一步要怎么处理?", choices: ["不知道"], writeTo: "新版本的 steps[]" };
|
|
1420
|
-
return {
|
|
1421
|
-
kind: s.kind,
|
|
1422
|
-
pipelineId: pipeline.id,
|
|
1423
|
-
stepId: s.stepId,
|
|
1424
|
-
title: s.title,
|
|
1425
|
-
because: s.because,
|
|
1426
|
-
effect: s.effect,
|
|
1427
|
-
// cause 只有 rework-path 有;其余 kind 不带 undefined 键(消费方 deepEqual 会看到多余键)
|
|
1428
|
-
...(s.cause ? { cause: s.cause } : {}),
|
|
1429
|
-
...ask
|
|
1430
|
-
};
|
|
1431
|
-
});
|
|
1432
|
-
return { contract: "mneme.nudge/v1", pipelineId: pipeline.id, generatedAt: new Date(now).toISOString(), items };
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
/**
|
|
1436
|
-
* 一个项目的**自动沉淀入口**:先把项目解析到它自己的会话桶,再跑一次 `nudgeFromSessions`。
|
|
1437
|
-
*
|
|
1438
|
-
* 为什么这一层要单独存在:`nudgeFromSessions` 要求调用方**已经知道**这个项目的 `sessionDir`
|
|
1439
|
-
* (给了 projectRoot 却不给 sessionDir 会被降级成无归属普查)。而真实调用方手里只有一个
|
|
1440
|
-
* cwd。把「项目根 → 桶」和「跑一轮」焊在一起,是为了让调用方**没有机会**漏掉那一步而
|
|
1441
|
-
* 静默退化成普查——退化的输出看上去一切正常,只是候选全都不属于任何项目。
|
|
1442
|
-
*
|
|
1443
|
-
* 解析不到桶时**不跑**,直接回一份空结果并带上 `attribution.reason`:不是省事,是因为
|
|
1444
|
-
* 跑了也只能得到无归属普查,而它对「这个项目的工作流」这个问题的答案永远是错的。
|
|
1445
|
-
* `skipped: true` 是关键——空列表必须能区分「没跑」与「跑了但没挖到」,否则调用方会把
|
|
1446
|
-
* 一次归属失败当成「这个项目很干净」。
|
|
1447
|
-
*
|
|
1448
|
-
* `attribution` 参数允许调用方把**已经算好**的桶解析结果传进来:`/nudge` 路由要先做一遍
|
|
1449
|
-
* 解析才能决定要不要回 400,再传进来就跑两遍(一遍扫桶、一遍真挖)。
|
|
1450
|
-
*/
|
|
1451
|
-
export function nudgeForProject({
|
|
1452
|
-
projectRoot = null,
|
|
1453
|
-
dshHome = undefined,
|
|
1454
|
-
memoryDir = null,
|
|
1455
|
-
project = "",
|
|
1456
|
-
now = Date.now(),
|
|
1457
|
-
budgetMs = NUDGE_AUTO_BUDGET_MS,
|
|
1458
|
-
materialize = false,
|
|
1459
|
-
persistProposals = true,
|
|
1460
|
-
maxFiles = MAX_SESSION_FILES,
|
|
1461
|
-
kinds = DEFAULT_NUDGE_KINDS,
|
|
1462
|
-
attribution = null,
|
|
1463
|
-
sessionGate = null
|
|
1464
|
-
} = {}) {
|
|
1465
|
-
// 归属解析自己也可能很贵(同步扫桶 500 个),所以它也要吃同一个预算。调用方给了现成的
|
|
1466
|
-
// attribution 就不再解析(`/nudge` 路由就是这么做的:它先解析一次才能决定要不要回 400)。
|
|
1467
|
-
// 这里**自己算**截止点而不是多接一个 deadline 参数:两处各算一次会出现「解析用 A 截止、
|
|
1468
|
-
// 挖掘用 B 截止」的双预算,那种不一致以后没人查得出来。
|
|
1469
|
-
const budgetDeadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null;
|
|
1470
|
-
const where = attribution || resolveProjectSessionDir({ dshHome, projectRoot, deadline: budgetDeadline });
|
|
1471
|
-
if (!where || !where.sessionDir) {
|
|
1472
|
-
return {
|
|
1473
|
-
contract: "mneme.nudge/v1",
|
|
1474
|
-
generatedAt: new Date(now).toISOString(),
|
|
1475
|
-
attribution: where || { sessionDir: null, bucket: null, reason: "bad-project" },
|
|
1476
|
-
skipped: true,
|
|
1477
|
-
skippedReason: "no-session-attribution",
|
|
1478
|
-
refused: null,
|
|
1479
|
-
source: null,
|
|
1480
|
-
segments: 0,
|
|
1481
|
-
kinds: Array.isArray(kinds) ? kinds.slice() : null,
|
|
1482
|
-
project: null,
|
|
1483
|
-
proposalsDir: null,
|
|
1484
|
-
counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 },
|
|
1485
|
-
available: [],
|
|
1486
|
-
drafts: [],
|
|
1487
|
-
boundaries: NUDGE_BOUNDARIES
|
|
1488
|
-
};
|
|
1489
|
-
}
|
|
1490
|
-
const doc = nudgeFromSessions({
|
|
1491
|
-
sessionDir: where.sessionDir,
|
|
1492
|
-
projectRoot,
|
|
1493
|
-
memoryDir,
|
|
1494
|
-
project,
|
|
1495
|
-
now,
|
|
1496
|
-
maxFiles,
|
|
1497
|
-
kinds,
|
|
1498
|
-
materialize,
|
|
1499
|
-
persistProposals,
|
|
1500
|
-
budgetMs,
|
|
1501
|
-
sessionGate
|
|
1502
|
-
});
|
|
1503
|
-
return { ...doc, attribution: where, skipped: false };
|
|
1504
|
-
}
|
|
1505
|
-
/**
|
|
1506
|
-
* 一次完整的沉淀跑:读真实会话日志 → 挖候选 → 算能算的对照 → 把提议落进 memoryDir →
|
|
1507
|
-
* (可选)把草稿落进项目。
|
|
1508
|
-
*
|
|
1509
|
-
* 为什么默认不落草稿:落进仓库是会被 review、被提交的东西,一次自动跑就往里塞文件,
|
|
1510
|
-
* 会把「提议」变成「既成事实」。所以 `materialize` 默认 false——要落就得有人开口。
|
|
1511
|
-
*
|
|
1512
|
-
* **会话范围与项目归属是一件事,不是两件**:会话目录在 DSH 侧按工作区分桶,桶名是
|
|
1513
|
-
* 工作区路径的有损变形、规则不在本仓。所以「这个项目的工作流」只有在调用方给出
|
|
1514
|
-
* **这个项目自己的 `sessionDir`** 时才成立。给了 `projectRoot` 却没给 `sessionDir`
|
|
1515
|
-
* 时,这一跑会被**降级成「无归属的普查」**:候选不带项目名、不写任何项目的提议桶、
|
|
1516
|
-
* 不落草稿——绝不允许拿全机会话去冒充某个项目的账(复核报告 §2.6)。
|
|
1517
|
-
*/
|
|
1518
|
-
export function nudgeFromSessions({
|
|
1519
|
-
sessionDir = null,
|
|
1520
|
-
dshHome = undefined,
|
|
1521
|
-
projectRoot = null,
|
|
1522
|
-
memoryDir = null,
|
|
1523
|
-
project = "",
|
|
1524
|
-
now = Date.now(),
|
|
1525
|
-
minSupport = NUDGE_MIN_SUPPORT,
|
|
1526
|
-
minLength = NUDGE_MIN_LENGTH,
|
|
1527
|
-
maxLength = NUDGE_MAX_LENGTH,
|
|
1528
|
-
maxResults = NUDGE_MAX_CANDIDATES,
|
|
1529
|
-
maxFiles = MAX_SESSION_FILES,
|
|
1530
|
-
failures = null,
|
|
1531
|
-
kinds = DEFAULT_NUDGE_KINDS,
|
|
1532
|
-
materialize = false,
|
|
1533
|
-
budgetMs = 0,
|
|
1534
|
-
// 提议落盘是**写**。预览(面板刷新一次就调一次)不能顺手往 memoryDir 里塞文件:
|
|
1535
|
-
// 落盘要从这里显式关掉,不能靠调用方「记得别调」。默认 true 保持既有语义。
|
|
1536
|
-
persistProposals = true,
|
|
1537
|
-
sessionGate = null
|
|
1538
|
-
} = {}) {
|
|
1539
|
-
const scoped = typeof sessionDir === "string" && sessionDir.trim() !== "";
|
|
1540
|
-
const dir = scoped ? sessionDir : resolveSessionDir({ dshHome });
|
|
1541
|
-
// 扫描范围不在一个工作区内 → 不许有任何项目归属(连调用方自报的 project 也不认:
|
|
1542
|
-
// 那同样是「拿一批跨项目的观察去贴一个项目名」)。
|
|
1543
|
-
const attributionRefused = !scoped && Boolean(projectRoot || project);
|
|
1544
|
-
const allowedProjectRoot = attributionRefused ? null : projectRoot;
|
|
1545
|
-
// budgetMs > 0 才设截止点:测试与离线批处理要完整结果,面板要「不卡」。
|
|
1546
|
-
const deadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null;
|
|
1547
|
-
const read = readSessionObservations({ sessionDir: dir, limit: maxFiles, deadline, sessionGate });
|
|
1548
|
-
const segments = segmentObservations(read.observations);
|
|
1549
|
-
const candidates = mineRepeatedPatterns(segments, { minSupport, minLength, maxLength, maxResults, kinds });
|
|
1550
|
-
const projectName = attributionRefused ? "" : project || (allowedProjectRoot ? projectLabel(allowedProjectRoot) : "");
|
|
1551
|
-
const failureSource = Array.isArray(failures) ? "caller" : "unwired";
|
|
1552
|
-
const enriched = candidates.map((c) => ({
|
|
1553
|
-
...c,
|
|
1554
|
-
project: projectName,
|
|
1555
|
-
counterfactual: buildCounterfactual(c, { segments, failures, now })
|
|
1556
|
-
}));
|
|
1557
|
-
|
|
1558
|
-
const propDir = attributionRefused ? null : proposalDir({ memoryDir, projectRoot: allowedProjectRoot });
|
|
1559
|
-
const saved = [];
|
|
1560
|
-
if (propDir && persistProposals !== false) {
|
|
1561
|
-
for (const c of enriched) saved.push(saveProposal(propDir, c).id);
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
const drafts = [];
|
|
1565
|
-
const limit = materialize === true ? 1 : Number.isFinite(materialize) ? Math.max(0, Math.floor(materialize)) : 0;
|
|
1566
|
-
if (allowedProjectRoot && limit > 0) {
|
|
1567
|
-
for (const c of enriched.slice(0, limit)) {
|
|
1568
|
-
const made = draftFromCandidate(c, { project: projectName });
|
|
1569
|
-
if (!made.ok) {
|
|
1570
|
-
drafts.push({ id: c.id, ok: false, reason: "invalid", problems: made.problems, unanswered: made.unanswered });
|
|
1571
|
-
continue;
|
|
1572
|
-
}
|
|
1573
|
-
// `unanswered` 是草稿出口的一部分:调用方与面板据此知道「还有哪几步没人回答判据」。
|
|
1574
|
-
// 早前它被算出来又在出口丢掉,等于这条判据没有消费者。
|
|
1575
|
-
drafts.push({ id: c.id, unanswered: made.unanswered, ...writeDraftPipeline({ projectRoot: allowedProjectRoot, pipeline: made.pipeline }) });
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
return {
|
|
1580
|
-
contract: "mneme.nudge/v1",
|
|
1581
|
-
generatedAt: new Date(now).toISOString(),
|
|
1582
|
-
// 归属被降级时把原因写在最外层:调用方不该错过这件事(它决定这批候选能不能信)。
|
|
1583
|
-
refused: attributionRefused
|
|
1584
|
-
? {
|
|
1585
|
-
reason: "unscoped-sessions",
|
|
1586
|
-
detail: "给了 projectRoot/project 却没给 sessionDir:会话目录是按工作区分桶的,扫全部拿到的观察不属于任何单个项目。已按「无归属普查」处理——候选不带项目名、不写项目桶、不落草稿。要项目归属就传该项目自己的 sessionDir。"
|
|
1587
|
-
}
|
|
1588
|
-
: null,
|
|
1589
|
-
// 会话源的读数**原样带出来**:扫了几个文件、几帧解不出来、有没有被截断。
|
|
1590
|
-
source: {
|
|
1591
|
-
sessionDir: dir,
|
|
1592
|
-
scoped,
|
|
1593
|
-
present: read.present,
|
|
1594
|
-
readable: read.readable,
|
|
1595
|
-
truncated: read.truncated,
|
|
1596
|
-
budgetExceeded: read.budgetExceeded === true,
|
|
1597
|
-
totalFiles: read.totalFiles,
|
|
1598
|
-
scannedFiles: read.scannedFiles,
|
|
1599
|
-
frames: read.frames,
|
|
1600
|
-
failedFrames: read.failedFrames,
|
|
1601
|
-
observations: read.observations.length,
|
|
1602
|
-
unreadable: read.unreadable,
|
|
1603
|
-
// 失败记录是**没接线**还是**接进来了但一条都没有**,必须分开报(同 present/readable 那套)。
|
|
1604
|
-
failureSource
|
|
1605
|
-
},
|
|
1606
|
-
segments: segments.length,
|
|
1607
|
-
// 挖的是哪一层要跟着结果一起报出来:默认只有能力级,工具级要显式开。
|
|
1608
|
-
kinds: Array.isArray(kinds) ? kinds.slice() : null,
|
|
1609
|
-
project: projectName || null,
|
|
1610
|
-
proposalsDir: propDir,
|
|
1611
|
-
counts: {
|
|
1612
|
-
candidates: enriched.length,
|
|
1613
|
-
buckets: new Set(enriched.map((c) => c.bucket).filter(Boolean)).size,
|
|
1614
|
-
sessionsWithCandidates: new Set(enriched.flatMap((c) => c.sessions)).size,
|
|
1615
|
-
savedProposals: saved.length,
|
|
1616
|
-
draftsWritten: drafts.filter((d) => d.ok).length
|
|
1617
|
-
},
|
|
1618
|
-
available: enriched.map(summarizeProposal),
|
|
1619
|
-
drafts,
|
|
1620
|
-
// 「这一刀不做的事」写进契约里,免得以后有人以为忘了做。
|
|
1621
|
-
boundaries: NUDGE_BOUNDARIES
|
|
1622
|
-
};
|
|
1623
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* M.O. 沉淀链(nudge):让工作流**自己从数据里长出来**。
|
|
3
|
+
*
|
|
4
|
+
* 这一层补的是读侧之外缺的那一头。`pipeline.js` 能把人手写的 JSON 渲染成图、能从
|
|
5
|
+
* 运行记录算状态;但「一条新工作流从哪来」在那之前一直是零代码。本文件就是那个来源:
|
|
6
|
+
*
|
|
7
|
+
* 会话/记忆里的观察 → 反复出现的同一段序列(候选) → 反事实判据(请人回答)
|
|
8
|
+
* → status:"draft" 的草稿 →(人点头)→ active
|
|
9
|
+
*
|
|
10
|
+
* **三条不许越的分界线**(这三条比功能完整度重要,写在这里免得后来人图省事):
|
|
11
|
+
*
|
|
12
|
+
* 1. **机器只写它真观察到的东西。** 步骤、次数、时间、风险下限、要问的问题,这些是
|
|
13
|
+
* 数据里读出来的;人话(`when` / `done` / 触发器那句话)一律留 `(待补)`。把
|
|
14
|
+
* 「这一步为什么存在」编圆了,比留白危险得多——它会让人以为判据已经答过了。
|
|
15
|
+
* 2. **反事实判据不自动给结论。** 判据是「没有这条流程,这次会不会漏步/失败」。
|
|
16
|
+
* 日志里能读到「这一步被跳过」这个**动作**,读不到「跳过之后出事没出事」这个
|
|
17
|
+
* **结果**;那个结果恰恰就是判据本身。所以本模块只做两件事:把能观察到的对照
|
|
18
|
+
* 摆出来(有这一步几次 / 没有这一步几次),把答不出来的那部分写成**给几个选项的
|
|
19
|
+
* 问题**。`counterfactual.verdict` 永远是 null。
|
|
20
|
+
* 3. **只产草稿,绝不产 active。** 提议自动、固化要人点头(v0.9.0 设计备忘 §4)。
|
|
21
|
+
* `writeDraftPipeline` 见到 `status !== "draft"` 直接拒写,这条没有开关。
|
|
22
|
+
*
|
|
23
|
+
* 另外四条是**边界性质**,不只靠产者自觉(专家团复核后补齐,见各函数注释):
|
|
24
|
+
* · 空回答不算「已答」(`applyAnswers` 一条都没接受就不推进状态);
|
|
25
|
+
* · 支持度**只在同一个工作区桶内累加**,且扫全部会话时不许声称候选属于某个项目;
|
|
26
|
+
* · 提议/草稿一律**原子落盘**(tmp + rename),半截文件不能把整层工作流带走;
|
|
27
|
+
* · `validateProposal` 拒绝任何非 null 的 `counterfactual.verdict`。
|
|
28
|
+
*
|
|
29
|
+
* 候选的家**分两处**,因为它们的两条命不同:
|
|
30
|
+
* · 机器产出的候选/问题 → `<memoryDir>/proposals/<项目键>/nudge-*.json`(本地,不进仓库;
|
|
31
|
+
* 一天能长十条、死九条,进仓库就是垃圾,与 `candidates.js` 的绕行候选同一套理由)
|
|
32
|
+
* · 通过校验的草稿 → `<项目>/.mneme/pipelines/<id>.json`(要能 review、能 PR)
|
|
33
|
+
*
|
|
34
|
+
* **与 `candidates.js` 的关系**(那份是基线里已有的绕行候选层,能复用的一律照它来):
|
|
35
|
+
* · 复用它的 `projectKey`(分桶键一律「目录名 + 路径哈希」,绝不含绝对路径)与
|
|
36
|
+
* `scanIrreversible`(风险兜底清单只有一份,不另立第二份);
|
|
37
|
+
* · 分桶约定一致,只是目录名取 `proposals/` 而不是 `candidates/`:那边是「图上没有的
|
|
38
|
+
* 路被走通了」,这边是「还没上过图的重复序列」,两者的状态机(`collecting → proposed
|
|
39
|
+
* → promoted` 对 `proposed → answered → drafted`)与判据都不同;混进一张表里,
|
|
40
|
+
* 「这条到底有没有被跑过」就查不清了。真正的合并要动 `candidates.js` 的 `origin`
|
|
41
|
+
* 白名单与 `validateCandidate`,那不在本文件写域内。
|
|
42
|
+
*
|
|
43
|
+
* **没有复用 `src/session-usage.js`**:它只存在于隔壁**未合并**的车道,本车道与 main
|
|
44
|
+
* 都没有这个文件,import 会当场炸。所以这里自带一份**只够 nudge 用**的读取器(会话 →
|
|
45
|
+
* 按回合排的调用序列),并把它做成可替换的形状:那个模块并进来之后,
|
|
46
|
+
* `segmentObservations` 之前那一段可以整体换掉,`mineRepeatedPatterns` 以下不用改。
|
|
47
|
+
*
|
|
48
|
+
* **草稿只写 `validatePipeline` 留得住的那 13 个键**:落盘前一律过一遍校验器,写下去的是
|
|
49
|
+
* 它归一化后的形状,所以 `assert` / `onFailure` / `retries` 这类会被静默丢弃的键
|
|
50
|
+
* 一个都不在这条链上。机器算出的风险下限与重复次数留在 memoryDir 的提议文件里
|
|
51
|
+
* ——那里没有白名单。
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, renameSync, rmSync, statSync } from "node:fs";
|
|
55
|
+
import { createHash } from "node:crypto";
|
|
56
|
+
import { join } from "node:path";
|
|
57
|
+
import { homedir } from "node:os";
|
|
58
|
+
import zlib from "node:zlib";
|
|
59
|
+
import { validatePipeline, projectPipelineDir, projectLabel, suggestRetirements } from "./pipeline.js";
|
|
60
|
+
import { projectKey, scanIrreversible } from "./candidates.js";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 提议门槛:同一段序列要在**至少两个不同的会话**里出现。
|
|
64
|
+
* 这个 2 不是新发明的数——`candidates.js` 的 `PROMOTE_MIN_SEEN` 已经是同一个理由:
|
|
65
|
+
* **复用第二次才算类型,第一次只是一个实例**。同一次但因为「一个会话里重复出现」
|
|
66
|
+
* 就够格的话,一条被复制粘贴的命令就能造出一条假候选。
|
|
67
|
+
*/
|
|
68
|
+
export const NUDGE_MIN_SUPPORT = 2;
|
|
69
|
+
|
|
70
|
+
/** 太短的序列没有「步骤关系」可言(两步以内就是「用了 A 又用了 B」),太长则是整场会话。 */
|
|
71
|
+
export const NUDGE_MIN_LENGTH = 2;
|
|
72
|
+
export const NUDGE_MAX_LENGTH = 4;
|
|
73
|
+
|
|
74
|
+
/** 一次挖掘最多交回多少条候选。候选是给人看的,一次推两百条等于没推。 */
|
|
75
|
+
export const NUDGE_MAX_CANDIDATES = 20;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 默认挖哪些层的调用:**能力级(技能 / MCP)**,不挖内置工具级。
|
|
79
|
+
*
|
|
80
|
+
* 这不是想当然定的,是拿本机 520 份真实会话日志跑出来的:工具级序列的支持度高得离谱
|
|
81
|
+
* (`read → pwsh` 出现在 322 个会话、`write → pwsh` 253 个),但它们全是 agent 的
|
|
82
|
+
* 通用动作习惯,不是项目工作流——按那个口径挖,推给人的前八条全是这类噪音。
|
|
83
|
+
* 能力级稀疏得多(真实数据里同一段能力序列只有两三个会话支持),但每一条都是
|
|
84
|
+
* 「有人特意选了这个技能」的选择,那才可能是工作流。
|
|
85
|
+
*
|
|
86
|
+
* 想挖工具级就显式传 `kinds: ["tool", ...]` 或 `kinds: null`(不过滤)——**别改默认值**,
|
|
87
|
+
* 默认值一改,面板第一屏就变回噪音。
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_NUDGE_KINDS = ["skill", "mcp"];
|
|
90
|
+
|
|
91
|
+
/** 单次扫描最多读多少会话文件(目录膨胀时宁可报截断,也不要把一次刷新变成全盘扫描)。 */
|
|
92
|
+
const MAX_SESSION_FILES = 2000;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 自动沉淀的**最小间隔**(同一个项目两次自动跑之间)。
|
|
96
|
+
*
|
|
97
|
+
* 为什么需要它:解压会话日志是**同步** I/O,本机 118 份日志实测 16.2s。挂到
|
|
98
|
+
* `session/event` 上如果每轮都跑,长会话就是把宿主主线程反复按住。十分钟这个数是
|
|
99
|
+
* 取的「够久到不会在同一个工作段里反复触发,够短到当天的新习惯还来得及被看见」——
|
|
100
|
+
* 它不是性能参数,是**观测新鲜度**参数;真卡的话先调 budgetMs(那一条保证不卡)。
|
|
101
|
+
*/
|
|
102
|
+
export const NUDGE_AUTO_MIN_INTERVAL_MS = 10 * 60 * 1000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 自动沉淀的单次时间预算。超了就**带着已读到的部分收工**并如实标 `budgetExceeded`,
|
|
106
|
+
* 而不是把这一跑作废——半份观察好过零份,只要调用方知道它只看了后半截。
|
|
107
|
+
*/
|
|
108
|
+
export const NUDGE_AUTO_BUDGET_MS = 4000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 沉淀链的**边界**,写进每一份 return 的契约里。抽成一份常量是防「两条出口说法不一样」:
|
|
112
|
+
* 走完一整跑和提前收工给出的边界必须是同一句话,否则读契约的人得先猜自己拿到的是哪一种。
|
|
113
|
+
*/
|
|
114
|
+
export const NUDGE_BOUNDARIES = Object.freeze({
|
|
115
|
+
autoActivate: false,
|
|
116
|
+
autoAnswerCounterfactual: false,
|
|
117
|
+
writesOnlyDrafts: true,
|
|
118
|
+
// 机器给的风险只是下限;R3 永远只能由人给。
|
|
119
|
+
riskIsFloor: true,
|
|
120
|
+
// 支持度只在同一个工作区桶内累加;跨桶不并账。
|
|
121
|
+
crossProjectSupport: false
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** 会话日志文件名前缀。**不写死版本号**:本机同一棵树下并存 v4 / v3 / 无版本号三种。 */
|
|
125
|
+
const SESSION_FILE_PREFIX = "session";
|
|
126
|
+
|
|
127
|
+
/** zstd 帧头 magic:多帧文件靠它切帧,见 forEachZstdFrame 的注释。 */
|
|
128
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
129
|
+
|
|
130
|
+
/** 候选(提议)自己的状态机。与 `candidates.js` 的候选状态刻意不同名,免得两套账混起来。 */
|
|
131
|
+
export const PROPOSAL_STATUSES = ["proposed", "answered", "drafted", "dismissed"];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 反事实的**答案选项**。为什么是固定选项而不是自由文本:
|
|
135
|
+
* 这是准入判据,将来要参与「这条草稿能不能升成 active」的清点;自由文本没法清点,
|
|
136
|
+
* 而清点不了的判据最后一定会退化成人情。`unknown` 必须留一个——**逼人二选一
|
|
137
|
+
* 会造出假答案**,而假答案比空答案更贵。
|
|
138
|
+
*/
|
|
139
|
+
export const COUNTERFACTUAL_CHOICES = [
|
|
140
|
+
{ id: "would-skip", label: "会漏掉这一步" },
|
|
141
|
+
{ id: "would-fail", label: "会失败(或做返工)" },
|
|
142
|
+
{ id: "no-harm", label: "那只是省事,不会出事" },
|
|
143
|
+
{ id: "unknown", label: "答不上来" }
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const CHOICE_IDS = COUNTERFACTUAL_CHOICES.map((c) => c.id);
|
|
147
|
+
const CHOICE_LABELS = new Map(COUNTERFACTUAL_CHOICES.map((c) => [c.id, c.label]));
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 会改本机文件/状态的内置工具 → 风险下限 R1。
|
|
151
|
+
* 这是一份**机械清单**,与 `candidates.js` 的 `IRREVERSIBLE_PATTERNS` 同一个思路:
|
|
152
|
+
* 能审计、能 diff,不看模型自报。
|
|
153
|
+
*/
|
|
154
|
+
const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "notebook_edit"]);
|
|
155
|
+
|
|
156
|
+
/** 反事实那一条问题的原文。它出现在每一张问题上,所以在这里只写一次。 */
|
|
157
|
+
const COUNTERFACTUAL_QUESTION = "没有这一步,那次会不会漏步、会不会失败?";
|
|
158
|
+
|
|
159
|
+
/** 展开 `~`。候选层与 `memoryDir` 一样是本地状态,不是仓库内容。 */
|
|
160
|
+
function expandHome(p) {
|
|
161
|
+
const s = String(p || "");
|
|
162
|
+
if (s === "~") return homedir();
|
|
163
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) return join(homedir(), s.slice(2));
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 会话根目录。默认由 `DSH_HOME` 推导,**不写死本机路径**——仓库里出现盘符是红线。 */
|
|
168
|
+
export function resolveSessionDir({ dshHome = process.env.DSH_HOME || join(homedir(), ".dsh") } = {}) {
|
|
169
|
+
return join(String(dshHome), "sessions");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 路径规范化:只用来**比相等**,不用来读写文件(读写永远用原串)。
|
|
174
|
+
*
|
|
175
|
+
* 为什么要折:同一份工作目录在会话头里可能是 `C:\\Users\\x\\proj`、`c:/users/x/proj`、
|
|
176
|
+
* 末尾带不带分隔符三种写法之一,直接 `===` 会让归属解析**随机**命中或落空,而落空
|
|
177
|
+
* 的症状和「这个项目确实没会话」完全同形。
|
|
178
|
+
* 只在 win32 上折小写:POSIX 的文件名大小写敏感,在那里折小写会把
|
|
179
|
+
* `/a/Proj` 与 `/a/proj` 两个不同项目并成一个——那正是本仓最防的一类「合并两个项目的账」。
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeProjectPath(p) {
|
|
182
|
+
if (typeof p !== "string") return null;
|
|
183
|
+
const trimmed = p.trim();
|
|
184
|
+
if (trimmed === "") return null;
|
|
185
|
+
let s = trimmed.replace(/\\/g, "/");
|
|
186
|
+
// 去掉末尾分隔符,但根("/" 或 "C:/")要留着——去掉就不是一个路径了。
|
|
187
|
+
s = s.replace(/\/+$/, "");
|
|
188
|
+
if (/^[A-Za-z]:$/.test(s)) s += "/";
|
|
189
|
+
if (s === "") s = "/";
|
|
190
|
+
return process.platform === "win32" ? s.toLowerCase() : s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 一个会话文件里记的 `cwd`(会话日志首行的 `{"type":"session",...,"cwd":"…"}`)。
|
|
195
|
+
*
|
|
196
|
+
* 为什么读内容而不是反解桶名:桶名是工作区路径的**有损**变形(盘符后的 `-`、
|
|
197
|
+
* 空格被吃掉、中文按 UTF-16 码元转义),本机 19 个桶里反解实测多处对不上——
|
|
198
|
+
* 反解错的时候**不会报错**,只会把别的项目的账归到这个项目名下。会话头里的
|
|
199
|
+
* `cwd` 是宿主自己写的原文,没有歧义。
|
|
200
|
+
*
|
|
201
|
+
* 只解第一帧是性能选择(一份会话日志能到 1.5 MB、上百帧),但第一帧解不出时
|
|
202
|
+
* **继续扫后面几帧**:宁可多解几帧,也不要把「拿不准」报成「这个桶没有项目」。
|
|
203
|
+
*/
|
|
204
|
+
export function readSessionCwd(file) {
|
|
205
|
+
let buf;
|
|
206
|
+
try {
|
|
207
|
+
buf = readFileSync(file);
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
let cwd = null;
|
|
212
|
+
const take = (text) => {
|
|
213
|
+
if (cwd) return;
|
|
214
|
+
for (const line of text.split("\n")) {
|
|
215
|
+
const t = line.trim();
|
|
216
|
+
// 快判:没有 "cwd" 的行连 JSON.parse 都不用做(一帧里绝大多数行是消息)。
|
|
217
|
+
if (!t || t[0] !== "{" || !t.includes('"cwd"')) continue;
|
|
218
|
+
try {
|
|
219
|
+
const obj = JSON.parse(t);
|
|
220
|
+
if (obj && typeof obj.cwd === "string" && obj.cwd.trim() !== "") {
|
|
221
|
+
cwd = obj.cwd;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// 半行/坏行:单行脏数据不该决定归属。
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const start = buf.indexOf(ZSTD_MAGIC, 0);
|
|
230
|
+
if (start !== -1) {
|
|
231
|
+
let end = buf.indexOf(ZSTD_MAGIC, start + 4);
|
|
232
|
+
if (end === -1) end = buf.length;
|
|
233
|
+
try {
|
|
234
|
+
take(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8"));
|
|
235
|
+
} catch {
|
|
236
|
+
// 第一帧坏了:落到下面的整文件扫描。
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!cwd) forEachZstdFrame(buf, take);
|
|
240
|
+
buf.fill(0);
|
|
241
|
+
return cwd;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 这个项目**自己的**会话桶目录(`<DSH_HOME>/sessions/<工作区桶>`)。
|
|
246
|
+
*
|
|
247
|
+
* 为什么非要它:`nudgeFromSessions` 只有在拿到「本项目自己的 sessionDir」时才敢给候选
|
|
248
|
+
* 贴项目名、写提议桶、落草稿;给不出就会降级成「无归属普查」(见那里的 `refused`)。
|
|
249
|
+
* 而桶名 → 项目这条路,除了会话头里的 `cwd` 没有可信来源。
|
|
250
|
+
*
|
|
251
|
+
* 返回里 `reason` 是给人看的那句话:
|
|
252
|
+
* matched 找到了
|
|
253
|
+
* bad-project 调用方没给项目根 / 给的不是路径
|
|
254
|
+
* no-sessions-dir 会话根不存在(新机器、DSH 还没写过东西)
|
|
255
|
+
* no-bucket 扫完了,没有任何桶的会话头指向这个项目(这个项目确实还没跑过会话)
|
|
256
|
+
* unreadable 桶在,但所有会话头的 cwd 都读不出来——**这一条必须与 no-bucket 分开**:
|
|
257
|
+
* 前者是「读不到」,后者是「确实没有」,混起来就等于把故障报成空结果。
|
|
258
|
+
* budget-exceeded 预算用光还没扫完——同样**不是**关于这个项目的答案(见 deadline 参数)。
|
|
259
|
+
* bucket-limit 桶数超过 maxBuckets 被截断,且截断范围内没找到这个项目——**也不是**
|
|
260
|
+
* 「确实没有」:目标可能就在被截掉的那部分里(同日评审抓出)。
|
|
261
|
+
*
|
|
262
|
+
* `deadline` 是这次解析的绝对截止时刻(毫秒)。不给就扫到底(测试与离线批处理要完整
|
|
263
|
+
* 结果)。为什么要它:这一层是**同步**的,最多 500 个桶 × 3 个文件,全都走同步 I/O;
|
|
264
|
+
* 调用方手里的 `budgetMs` 原先只约束后面的挖掘,约束不到这里——一条「真卡就调
|
|
265
|
+
* budgetMs」的建议对这段路径是空话(v0.9.3 评审抓出)。
|
|
266
|
+
*/
|
|
267
|
+
export function resolveProjectSessionDir({
|
|
268
|
+
dshHome = process.env.DSH_HOME || join(homedir(), ".dsh"),
|
|
269
|
+
projectRoot = null,
|
|
270
|
+
maxBuckets = 500,
|
|
271
|
+
filesPerBucket = 3,
|
|
272
|
+
deadline = null
|
|
273
|
+
} = {}) {
|
|
274
|
+
const target = normalizeProjectPath(projectRoot);
|
|
275
|
+
if (!target) {
|
|
276
|
+
return { sessionDir: null, bucket: null, reason: "bad-project", bucketsScanned: 0, filesRead: 0 };
|
|
277
|
+
}
|
|
278
|
+
const root = resolveSessionDir({ dshHome });
|
|
279
|
+
let entries;
|
|
280
|
+
try {
|
|
281
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
282
|
+
} catch {
|
|
283
|
+
return { sessionDir: null, bucket: null, reason: "no-sessions-dir", sessionsRoot: root, bucketsScanned: 0, filesRead: 0 };
|
|
284
|
+
}
|
|
285
|
+
const allBuckets = [];
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (!entry.isDirectory()) continue;
|
|
288
|
+
allBuckets.push(entry.name);
|
|
289
|
+
}
|
|
290
|
+
// 先收全、再排序、最后截断——与 findSessionFiles 同一条规矩。早前是「边收边截」:
|
|
291
|
+
// 到 500 个就 break,然后才 sort(),于是**留下哪 500 个由文件系统遍历顺序决定**。
|
|
292
|
+
// 目标项目的桶一旦落在被截掉的那部分,函数就返回 no-bucket——把一次截断报成
|
|
293
|
+
// 「这个项目确实没有会话」,正好是本文件反复在防的「读不到 vs 确实没有」
|
|
294
|
+
// (v0.9.3 评审抓出)。
|
|
295
|
+
allBuckets.sort();
|
|
296
|
+
const bucketsTruncated = allBuckets.length > maxBuckets;
|
|
297
|
+
const buckets = bucketsTruncated ? allBuckets.slice(0, maxBuckets) : allBuckets;
|
|
298
|
+
let filesRead = 0;
|
|
299
|
+
let bucketsWithCwd = 0;
|
|
300
|
+
const outOfBudget = () => deadline !== null && Number.isFinite(deadline) && Date.now() > deadline;
|
|
301
|
+
for (const bucket of buckets) {
|
|
302
|
+
// 桶循环里查预算:每个桶都可能要 readdir + 读 3 个文件,全同步,
|
|
303
|
+
// 一次解析跑满 500 个桶就是几百毫秒到几秒不等。
|
|
304
|
+
if (outOfBudget()) {
|
|
305
|
+
return {
|
|
306
|
+
sessionDir: null,
|
|
307
|
+
bucket: null,
|
|
308
|
+
reason: "budget-exceeded",
|
|
309
|
+
sessionsRoot: root,
|
|
310
|
+
bucketsScanned: buckets.length,
|
|
311
|
+
bucketsTotal: allBuckets.length,
|
|
312
|
+
bucketsTruncated,
|
|
313
|
+
filesRead
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const dir = join(root, bucket);
|
|
317
|
+
// 每个桶只读前几个文件:同一个桶里所有会话的 cwd 必然相同(桶是按工作区分的),
|
|
318
|
+
// 读三个只是给「第一个文件正好是截断的」留余量,不是要把整个桶扫一遍。
|
|
319
|
+
const found = findSessionFiles(dir, { limit: filesPerBucket });
|
|
320
|
+
for (const file of found.files.slice(0, filesPerBucket)) {
|
|
321
|
+
// 文件循环里也查:filesPerBucket 可被调用方调大,3 是最小值不是上限。
|
|
322
|
+
if (outOfBudget()) {
|
|
323
|
+
return {
|
|
324
|
+
sessionDir: null,
|
|
325
|
+
bucket: null,
|
|
326
|
+
reason: "budget-exceeded",
|
|
327
|
+
sessionsRoot: root,
|
|
328
|
+
bucketsScanned: buckets.length,
|
|
329
|
+
bucketsTotal: allBuckets.length,
|
|
330
|
+
bucketsTruncated,
|
|
331
|
+
filesRead
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const cwd = readSessionCwd(file);
|
|
335
|
+
filesRead += 1;
|
|
336
|
+
if (!cwd) continue;
|
|
337
|
+
bucketsWithCwd += 1;
|
|
338
|
+
if (normalizeProjectPath(cwd) === target) {
|
|
339
|
+
return {
|
|
340
|
+
sessionDir: dir,
|
|
341
|
+
bucket,
|
|
342
|
+
reason: "matched",
|
|
343
|
+
matchedCwd: cwd,
|
|
344
|
+
sessionsRoot: root,
|
|
345
|
+
bucketsScanned: buckets.length,
|
|
346
|
+
bucketsTotal: allBuckets.length,
|
|
347
|
+
bucketsTruncated,
|
|
348
|
+
filesRead
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
sessionDir: null,
|
|
355
|
+
bucket: null,
|
|
356
|
+
// 桶里一个 cwd 都读不出来 → 是读不了,不是没有。
|
|
357
|
+
// 截断且没命中 → 也不是「确实没有」:目标可能就在被截掉的那部分里。
|
|
358
|
+
reason: buckets.length === 0
|
|
359
|
+
? "no-sessions-dir"
|
|
360
|
+
: bucketsWithCwd === 0
|
|
361
|
+
? "unreadable"
|
|
362
|
+
: bucketsTruncated
|
|
363
|
+
? "bucket-limit"
|
|
364
|
+
: "no-bucket",
|
|
365
|
+
sessionsRoot: root,
|
|
366
|
+
bucketsScanned: buckets.length,
|
|
367
|
+
bucketsTotal: allBuckets.length,
|
|
368
|
+
bucketsTruncated,
|
|
369
|
+
filesRead
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 逐帧解一个**多帧** zstd buffer,边解边回调。
|
|
375
|
+
*
|
|
376
|
+
* 为什么不能直接 `zstdDecompressSync(buf)`:它只返回**第一帧**,不报错、不警告。
|
|
377
|
+
* 实测一个 1.5 MB 的会话文件只解出 291 字节,99% 的内容没被扫到,于是得出「零命中」
|
|
378
|
+
* 的假结论——这类错在本仓历史上出现过,所以这里自己按 magic 切帧。
|
|
379
|
+
*
|
|
380
|
+
* 为什么用回调而不是返回整串:一次读上百个会话文件会 OOM,逐帧交给回调,内存只留一帧。
|
|
381
|
+
*
|
|
382
|
+
* 关于**截断帧**:zstd 是流式格式,尾部被截断的帧会解出「部分文本」而不抛错。这里
|
|
383
|
+
* **不猜帧的完整性**,改在行这一层兜底——残缺帧的尾巴一定是半行,而 jsonl 本来就要求
|
|
384
|
+
* 整行是合法 JSON,半行会解析失败被丢掉。取舍是**宁可少一条,不要错一条**。
|
|
385
|
+
*/
|
|
386
|
+
export function forEachZstdFrame(buf, onText) {
|
|
387
|
+
let offset = 0;
|
|
388
|
+
let frames = 0;
|
|
389
|
+
let failed = 0;
|
|
390
|
+
while (offset < buf.length) {
|
|
391
|
+
const start = buf.indexOf(ZSTD_MAGIC, offset);
|
|
392
|
+
if (start === -1) break;
|
|
393
|
+
let end = buf.indexOf(ZSTD_MAGIC, start + 4);
|
|
394
|
+
if (end === -1) end = buf.length;
|
|
395
|
+
try {
|
|
396
|
+
onText(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8"));
|
|
397
|
+
frames += 1;
|
|
398
|
+
} catch {
|
|
399
|
+
// 帧头坏了:计入 failed 继续后面的帧——「解不出来」要被报出来,不能被当成「没有内容」。
|
|
400
|
+
failed += 1;
|
|
401
|
+
}
|
|
402
|
+
offset = end;
|
|
403
|
+
}
|
|
404
|
+
return { frames, failed };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 递归找出会话日志文件。
|
|
409
|
+
*
|
|
410
|
+
* **先收全、再排序、最后截断**(早前是先截断再排序,于是「留下哪几个」由遍历顺序决定,
|
|
411
|
+
* 而遍历顺序里带着工作区目录名——截断量会报,**选择偏差不会报**,实测这正是评分表上
|
|
412
|
+
* 「2000 文件上限按遍历序截断」那一条)。路径本身是短字符串,收全的代价可以忽略;
|
|
413
|
+
* 真正的内存护栏在读取层(`readSessionObservations` 逐帧解、读完即 `buf.fill(0)`)。
|
|
414
|
+
*
|
|
415
|
+
* `totalFiles` 是真总数、`files` 是截断后的结果、`truncated` 说明两者不同——
|
|
416
|
+
* 三个数一起给,「只扫了一部分」才看得见。
|
|
417
|
+
*/
|
|
418
|
+
export function findSessionFiles(dir, { limit = MAX_SESSION_FILES } = {}) {
|
|
419
|
+
const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : MAX_SESSION_FILES;
|
|
420
|
+
const result = { files: [], unreadable: [], present: false, truncated: false, totalFiles: 0 };
|
|
421
|
+
if (!existsSync(dir)) return result;
|
|
422
|
+
result.present = true;
|
|
423
|
+
const all = [];
|
|
424
|
+
const walk = (current) => {
|
|
425
|
+
let entries;
|
|
426
|
+
try {
|
|
427
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
428
|
+
} catch (error) {
|
|
429
|
+
// 「目录不可读」与「目录是空的」必须能分辨,否则读数上同形——那正是本仓反复防的那类错。
|
|
430
|
+
result.unreadable.push({ dir: current, detail: `目录读失败:${error.message}` });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
const full = join(current, entry.name);
|
|
435
|
+
// 符号链接要单独认:Dirent 对 symlink 的 isDirectory() 返回 false。
|
|
436
|
+
let isDir = entry.isDirectory();
|
|
437
|
+
if (entry.isSymbolicLink()) {
|
|
438
|
+
try {
|
|
439
|
+
isDir = statSync(full).isDirectory();
|
|
440
|
+
} catch {
|
|
441
|
+
result.unreadable.push({ dir: full, detail: "符号链接目标不可达" });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (isDir) walk(full);
|
|
446
|
+
else if (entry.name.startsWith(SESSION_FILE_PREFIX)) all.push(full);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
walk(dir);
|
|
450
|
+
all.sort();
|
|
451
|
+
result.totalFiles = all.length;
|
|
452
|
+
result.truncated = all.length > cap;
|
|
453
|
+
result.files = all.slice(0, cap);
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* 一次工具调用 → 一次观察。三类调用**判定方式故意不同**(照宿主各自契约):
|
|
459
|
+
* · `skill` → 参数里的 `name` 是技能名;
|
|
460
|
+
* · `mcp__<server>__<tool>` → 中间那段是 server;
|
|
461
|
+
* · 其余内置工具(read / pwsh / write …)→ 工具名本身就是它的身份。
|
|
462
|
+
*
|
|
463
|
+
* `command` 只为**风险下限**与**措辞**服务,原文带本机路径,所以下游一律先过
|
|
464
|
+
* `safeCommandTokens` 的清洗,绝不整条进草稿文件。
|
|
465
|
+
*
|
|
466
|
+
* `bucket` 是**工作区桶**(会话文件往上数第三层目录名)。为什么非要带上它:
|
|
467
|
+
* 会话目录在 DSH 侧按工作区分桶,`sessionId` 只是桶内那个会话目录名——两条不同工作区
|
|
468
|
+
* 里的会话会拿到两个不同的 `sessionId`,于是一段序列在两个项目里各出现一次,就会被
|
|
469
|
+
* 并成「支持度 2 的同一工作流」。那是把两个项目的账合成一本,实测过(复核报告 §2.6)。
|
|
470
|
+
* 带上 bucket 之后,支持度只在同一个桶内累加。
|
|
471
|
+
*/
|
|
472
|
+
export function observationsFromCalls(calls, { sessionId = null, bucket = null } = {}) {
|
|
473
|
+
const out = [];
|
|
474
|
+
for (const call of Array.isArray(calls) ? calls : []) {
|
|
475
|
+
if (!call || typeof call.name !== "string" || call.name === "") continue;
|
|
476
|
+
const args = call.args && typeof call.args === "object" ? call.args : {};
|
|
477
|
+
let kind = "tool";
|
|
478
|
+
let name = call.name;
|
|
479
|
+
if (call.name === "skill") {
|
|
480
|
+
const skill = typeof args.name === "string" ? args.name.trim() : "";
|
|
481
|
+
if (skill === "") continue;
|
|
482
|
+
kind = "skill";
|
|
483
|
+
name = skill;
|
|
484
|
+
} else if (call.name.startsWith("mcp__")) {
|
|
485
|
+
const parts = call.name.split("__");
|
|
486
|
+
if (!parts[1]) continue;
|
|
487
|
+
kind = "mcp";
|
|
488
|
+
name = parts[1];
|
|
489
|
+
}
|
|
490
|
+
const command =
|
|
491
|
+
typeof args.command === "string" ? args.command
|
|
492
|
+
: typeof args.cmd === "string" ? args.cmd
|
|
493
|
+
: typeof args.file_path === "string" ? args.file_path
|
|
494
|
+
: "";
|
|
495
|
+
const at = Number.isFinite(call.time) ? new Date(call.time).toISOString() : null;
|
|
496
|
+
out.push({ sessionId, bucket, turn: call.turn ?? null, at, tool: call.name, kind, name, command });
|
|
497
|
+
}
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** 一行 jsonl 里的 tool-call 全部抽出来(形状见 session 日志:`data.message.content[]`)。 */
|
|
502
|
+
export function toolCallsInLine(line) {
|
|
503
|
+
if (typeof line !== "string" || !line.includes('"tool-call"')) return [];
|
|
504
|
+
let obj;
|
|
505
|
+
try {
|
|
506
|
+
obj = JSON.parse(line);
|
|
507
|
+
} catch {
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
const content = obj?.data?.message?.content;
|
|
511
|
+
if (!Array.isArray(content)) return [];
|
|
512
|
+
const out = [];
|
|
513
|
+
for (const item of content) {
|
|
514
|
+
if (!item || item.type !== "tool-call" || typeof item.name !== "string") continue;
|
|
515
|
+
let args = null;
|
|
516
|
+
// 参数是 **JSON 字符串**(不是对象,也不叫 parameters)——取错字段会得到 null,
|
|
517
|
+
// 看着像「调用了但没记参数」。
|
|
518
|
+
if (typeof item.arguments === "string") {
|
|
519
|
+
try {
|
|
520
|
+
args = JSON.parse(item.arguments);
|
|
521
|
+
} catch {
|
|
522
|
+
args = null;
|
|
523
|
+
}
|
|
524
|
+
} else if (item.arguments && typeof item.arguments === "object") {
|
|
525
|
+
args = item.arguments;
|
|
526
|
+
}
|
|
527
|
+
out.push({ name: item.name, args, turn: obj?.data?.turn ?? null, time: Number.isFinite(obj?.time) ? obj.time : null });
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 扫会话日志,产出**按会话排好的观察串**(这是候选的唯一原始材料)。
|
|
534
|
+
*
|
|
535
|
+
* 会话 id 取文件名上面那一层目录名(DSH 侧一个会话一个目录),工作区桶取再上面那一层;
|
|
536
|
+
* 取不到就落 null——落 null 的观察只会让支持度算不出来,不会假装成「同一个会话」
|
|
537
|
+
* 或「同一个项目」。
|
|
538
|
+
*/
|
|
539
|
+
export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES, deadline = null, sessionGate = null } = {}) {
|
|
540
|
+
const filterSessions = sessionGate?.isEnabled?.() === true;
|
|
541
|
+
const found = findSessionFiles(sessionDir, { limit });
|
|
542
|
+
const files = found.files.slice(0, limit);
|
|
543
|
+
const totalFiles = Number.isFinite(found.totalFiles) ? found.totalFiles : found.files.length;
|
|
544
|
+
const result = {
|
|
545
|
+
sessionDir,
|
|
546
|
+
present: found.present,
|
|
547
|
+
// 「目录在」与「目录真能列出来」分开报:否则「不可读」与「空目录」同形。
|
|
548
|
+
readable: found.present && found.unreadable.length === 0,
|
|
549
|
+
truncated: found.truncated === true || totalFiles > files.length,
|
|
550
|
+
// 时间预算用光而提前收工:与「文件数上限」是两种截断,必须分开报——否则
|
|
551
|
+
// 「扫了一部分」会伪装成「扫完了但没挖到」。面板与调用方据此说「只看了最近这些」。
|
|
552
|
+
budgetExceeded: false,
|
|
553
|
+
totalFiles,
|
|
554
|
+
scannedFiles: 0,
|
|
555
|
+
frames: 0,
|
|
556
|
+
failedFrames: 0,
|
|
557
|
+
lines: 0,
|
|
558
|
+
calls: 0,
|
|
559
|
+
observations: [],
|
|
560
|
+
unreadable: [...found.unreadable]
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
for (const file of files) {
|
|
564
|
+
// 预算到点就停:解压是**同步**的,一次全量扫描会把宿主主线程按住十几秒
|
|
565
|
+
// (本机 118 份日志实测 16.2s)。宁可少看几份并如实报出来,也不能让一次
|
|
566
|
+
// 面板点击变成整窗卡死——那会被当成插件坏了。
|
|
567
|
+
if (deadline !== null && Date.now() > deadline) {
|
|
568
|
+
result.budgetExceeded = true;
|
|
569
|
+
result.truncated = true;
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
const parts = file.split(/[\\/]/);
|
|
573
|
+
const sessionId = parts.slice(-2, -1)[0] || null;
|
|
574
|
+
const bucket = parts.slice(-3, -2)[0] || null;
|
|
575
|
+
if (filterSessions && sessionGate.isOff(sessionId)) continue;
|
|
576
|
+
let buf;
|
|
577
|
+
try {
|
|
578
|
+
buf = readFileSync(file);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
result.unreadable.push({ file, detail: `读失败:${error.message}` });
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
result.scannedFiles += 1;
|
|
584
|
+
let headerChecked = false;
|
|
585
|
+
let excluded = false;
|
|
586
|
+
const stat = forEachZstdFrame(buf, (text) => {
|
|
587
|
+
for (const line of text.split("\n")) {
|
|
588
|
+
if (excluded) break;
|
|
589
|
+
if (!headerChecked && line.trim()) {
|
|
590
|
+
headerChecked = true;
|
|
591
|
+
if (filterSessions) {
|
|
592
|
+
try {
|
|
593
|
+
const row = JSON.parse(line);
|
|
594
|
+
// 会话日志首行是 {"type":"session", version, id, createdAt, cwd,
|
|
595
|
+
// isSeeded, delegationDepth, origin?, parentSession?}——**头字段就在
|
|
596
|
+
// 这一行的顶层**,没有 header/config 包一层(本机 641 个真实会话首行
|
|
597
|
+
// 全部如此)。所以先认 type,再退到 header/config 两种带壳形状。
|
|
598
|
+
// 旧写法末尾还有 `?? row?.data ?? row` 两级兜底,等于把**任何**首行
|
|
599
|
+
// 都当作头:一条 {"type":"permission/preset", data:{preset}} 会被读成
|
|
600
|
+
// 「没有 origin、没有 delegationDepth 的主会话」而放行,闸门静默失效;
|
|
601
|
+
// 认不出来时宁可返回 null(isOff 收到 null 只会按 id 判),也不假装
|
|
602
|
+
// 那是一条头。
|
|
603
|
+
const header = row?.type === "session" ? row : (row?.header ?? row?.config ?? null);
|
|
604
|
+
if (sessionGate.isOff({ id: sessionId, header })) {
|
|
605
|
+
excluded = true;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
609
|
+
// 坏头无法可靠判定会话归属;后续坏行仍由既有解析路径跳过。
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (!line.trim() || !line.includes('"tool-call"')) continue;
|
|
614
|
+
result.lines += 1;
|
|
615
|
+
const calls = toolCallsInLine(line);
|
|
616
|
+
if (!calls.length) continue;
|
|
617
|
+
result.calls += calls.length;
|
|
618
|
+
result.observations.push(...observationsFromCalls(calls, { sessionId, bucket }));
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
result.frames += stat.frames;
|
|
622
|
+
result.failedFrames += stat.failed;
|
|
623
|
+
buf.fill(0);
|
|
624
|
+
if (excluded) continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* 观察串 → 段。两件事:
|
|
632
|
+
* · **按回合切段**:同一回合里的连续调用才是「一次怎么做事」,跨回合是两次事件;
|
|
633
|
+
* · **折叠连续同一步**:同一能力连调四次不是「四步序列」,是这一步很重。折叠但把
|
|
634
|
+
* `repeats` 记下来——次数是事实,丢掉就少一个信号。
|
|
635
|
+
*/
|
|
636
|
+
export function segmentObservations(observations) {
|
|
637
|
+
const segments = [];
|
|
638
|
+
let current = null;
|
|
639
|
+
for (const obs of Array.isArray(observations) ? observations : []) {
|
|
640
|
+
// 桶也进切段键:两个工作区里的同名会话目录绝不能被当成同一段。
|
|
641
|
+
const sameSegment =
|
|
642
|
+
current && current.sessionId === obs.sessionId && current.turn === obs.turn && current.bucket === obs.bucket;
|
|
643
|
+
if (!sameSegment) {
|
|
644
|
+
current = { sessionId: obs.sessionId, bucket: obs.bucket ?? null, turn: obs.turn, steps: [] };
|
|
645
|
+
segments.push(current);
|
|
646
|
+
}
|
|
647
|
+
const key = `${obs.kind}:${obs.name}`;
|
|
648
|
+
const last = current.steps[current.steps.length - 1];
|
|
649
|
+
if (last && last.key === key) {
|
|
650
|
+
last.repeats += 1;
|
|
651
|
+
last.at = obs.at || last.at;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
current.steps.push({ key, kind: obs.kind, name: obs.name, tool: obs.tool, command: obs.command, at: obs.at, repeats: 1 });
|
|
655
|
+
}
|
|
656
|
+
return segments;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* 一段里某个位置的观察样本怎么并进模式:命令**取并**——风险下限要看过全部命令才敢说
|
|
661
|
+
* 「没命中兜底清单」,只看第一条会把第三条里的 `git push` 漏掉。
|
|
662
|
+
* 上限 12 条是内存护栏:同一个位置出现几百种不同命令时,它是噪音不是工作流。
|
|
663
|
+
*/
|
|
664
|
+
const MAX_COMMANDS_PER_POSITION = 12;
|
|
665
|
+
|
|
666
|
+
/** 省略痕迹里的工具名最多记几个:只为了让人看懂「夹的是什么」,不是完整清单。 */
|
|
667
|
+
const MAX_ELIDED_TOOLS = 4;
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* 把一批被抽掉的步骤并成一条**省略痕迹**。
|
|
671
|
+
* `head` 表示这些步骤在保留序列的第一个步骤之前——那一步不是这一段的入口,
|
|
672
|
+
* 「这一段入口」这句话本身也要跟着改(见 planStep)。
|
|
673
|
+
*/
|
|
674
|
+
function elidedFrom(pending) {
|
|
675
|
+
if (!pending.length) return null;
|
|
676
|
+
const count = pending.reduce((acc, s) => acc + (s.repeats || 1), 0);
|
|
677
|
+
const tools = [];
|
|
678
|
+
for (const s of pending) {
|
|
679
|
+
const label = `${s.kind}:${s.name}`;
|
|
680
|
+
if (!tools.includes(label) && tools.length < MAX_ELIDED_TOOLS) tools.push(label);
|
|
681
|
+
}
|
|
682
|
+
return { count, tools, head: false };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** 同一次省略在多次出现里取「最保守」的读法:次数取最大、工具取并。 */
|
|
686
|
+
function mergeElided(a, b) {
|
|
687
|
+
if (!a) return b;
|
|
688
|
+
if (!b) return a;
|
|
689
|
+
return {
|
|
690
|
+
count: Math.max(a.count, b.count),
|
|
691
|
+
tools: [...new Set([...a.tools, ...b.tools])].slice(0, MAX_ELIDED_TOOLS),
|
|
692
|
+
head: a.head && b.head
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function mergePosition(target, step) {
|
|
697
|
+
target.repeats = Math.max(target.repeats, step.repeats);
|
|
698
|
+
if (target.tool === null) target.tool = step.tool;
|
|
699
|
+
if (step.command && !target.commands.includes(step.command) && target.commands.length < MAX_COMMANDS_PER_POSITION) {
|
|
700
|
+
target.commands.push(step.command);
|
|
701
|
+
}
|
|
702
|
+
// 同一个位置在有的会话里夹着别的调用、有的没有 → 按「夹着」记(保守:宁可多说一句
|
|
703
|
+
// 「中间还有东西」,也不要断言一个其实不成立的相邻关系)。
|
|
704
|
+
target.elided = mergeElided(target.elided, step.elided || null);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* 从步名里取出可进仓库的短词。**这不是美化,是清洗**:命令原文里有本机路径与用户名,
|
|
709
|
+
* 而草稿文件是要提交、要 review 的。白名单式的取词能机械地保证这一点:
|
|
710
|
+
* · 只收 `[A-Za-z][A-Za-z0-9._-]*` —— 盘符(带冒号)、路径(带分隔符)、非 ASCII
|
|
711
|
+
* (本机用户名常含非 ASCII)全被这条挡在外面;
|
|
712
|
+
* · 再挡一次家目录名本身,以及 `users` / `home` 这类路径段;
|
|
713
|
+
* · 取不到就返回空数组,**宁可这一步没词,也不猜**。
|
|
714
|
+
*/
|
|
715
|
+
export function safeCommandTokens(command, { maxTokens = 2 } = {}) {
|
|
716
|
+
const homeParts = homedir().split(/[\\/]/).filter(Boolean).map((s) => s.toLowerCase());
|
|
717
|
+
const banned = new Set([...homeParts, "users", "home", "desktop", "documents", "appdata"]);
|
|
718
|
+
const out = [];
|
|
719
|
+
for (const raw of String(command || "").split(/\s+/)) {
|
|
720
|
+
const token = raw.replace(/^["']+|["']+$/g, "");
|
|
721
|
+
if (!/^[A-Za-z][A-Za-z0-9._-]{0,23}$/.test(token)) continue;
|
|
722
|
+
if (banned.has(token.toLowerCase())) continue;
|
|
723
|
+
out.push(token);
|
|
724
|
+
if (out.length >= maxTokens) break;
|
|
725
|
+
}
|
|
726
|
+
return out;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* 这一步的**风险下限**——刻意叫「下限」不叫「风险档」:它只由机械清单算出,
|
|
731
|
+
* 不假装看懂了这个动作的全部含义(一个叫 `ef-broadcast` 的技能能对外发布,名字里
|
|
732
|
+
* 却没有任何危险词)。
|
|
733
|
+
*
|
|
734
|
+
* 判法两条,都可以按字节审计:
|
|
735
|
+
* 1. 观察到的命令原文命中 `IRREVERSIBLE_PATTERNS` → R2(与绕行候选同一份清单);
|
|
736
|
+
* 2. 会改本机文件的内置工具 → R1。
|
|
737
|
+
* 其余落 R0。**R3 永远不由机器给**——那一档的定义就是「得人来定」,机器给出来就是假的。
|
|
738
|
+
*/
|
|
739
|
+
export function riskFloorForStep({ tool = "", commands = [] } = {}) {
|
|
740
|
+
const hits = [];
|
|
741
|
+
for (const command of Array.isArray(commands) ? commands : []) {
|
|
742
|
+
const scan = scanIrreversible(command);
|
|
743
|
+
if (scan.hit) hits.push(...scan.matches.map((m) => m.id));
|
|
744
|
+
}
|
|
745
|
+
if (hits.length) return { risk: "R2", source: "blacklist", hits: [...new Set(hits)].sort() };
|
|
746
|
+
if (WRITE_TOOLS.has(tool)) return { risk: "R1", source: "write-tool", hits: [] };
|
|
747
|
+
return { risk: "R0", source: "default", hits: [] };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** 一步的措辞:观察到的部分照实写,人话一律留 `(待补)`(见文件头第 1 条分界线)。 */
|
|
751
|
+
function planStep(position, index, prevTitle) {
|
|
752
|
+
const words = safeCommandTokens(position.commands[0] || "");
|
|
753
|
+
const id = `S${index + 1}`;
|
|
754
|
+
let title;
|
|
755
|
+
let doText;
|
|
756
|
+
if (position.kind === "skill") {
|
|
757
|
+
title = `技能:${position.name}`;
|
|
758
|
+
doText = `调用技能 ${position.name}`;
|
|
759
|
+
} else if (position.kind === "mcp") {
|
|
760
|
+
title = `MCP:${position.name}`;
|
|
761
|
+
doText = `调用 ${position.name} 服务器上的能力`;
|
|
762
|
+
} else {
|
|
763
|
+
title = words.length ? `${position.tool}:${words.join(" ")}` : `工具:${position.tool}`;
|
|
764
|
+
doText = words.length ? `调用 ${position.tool},形如「${words.join(" ")}」` : `调用 ${position.tool}`;
|
|
765
|
+
}
|
|
766
|
+
const risk = riskFloorForStep({ tool: position.tool, commands: position.commands });
|
|
767
|
+
return {
|
|
768
|
+
id,
|
|
769
|
+
phase: "观察段",
|
|
770
|
+
title,
|
|
771
|
+
// **相邻关系只在真的相邻时才断言**:按层过滤(见 filterSegmentsByKinds)会把夹在中间的
|
|
772
|
+
// 调用抽掉,而过滤后的相邻不等于观察到的相邻——原始观察里中间可能隔着 4 次工具调用。
|
|
773
|
+
// 机器只写观察到的:夹着东西就把夹着的东西一起写出来(`elided`),不写就说明真没夹。
|
|
774
|
+
when: adjacencyWording(position, prevTitle),
|
|
775
|
+
do: doText,
|
|
776
|
+
who: "main",
|
|
777
|
+
tool: position.tool,
|
|
778
|
+
// 完成判据是准入判据的一半,机器给不了。
|
|
779
|
+
done: "(待补)这一步怎么算完成",
|
|
780
|
+
risk: risk.risk,
|
|
781
|
+
riskSource: risk.source,
|
|
782
|
+
riskHits: risk.hits,
|
|
783
|
+
repeats: position.repeats,
|
|
784
|
+
// 省略痕迹原样带进提议文件:草稿那边会被 validatePipeline 的白名单丢掉,所以
|
|
785
|
+
// 「这一步在过滤前长什么样」只有提议文件里查得到。
|
|
786
|
+
elided: position.elided || null,
|
|
787
|
+
needs: index === 0 ? [] : [`S${index}`],
|
|
788
|
+
// 反事实对照**故意留空**:它是准入判据,不是备注;没人回答之前宁可是空的。
|
|
789
|
+
// 面板会把空对照节点的边画得虚一点(见 pipeline.js 的 counterfactual 投影)。
|
|
790
|
+
why: "(待补)为什么必须有这一步——由 nudge 从重复观察里提出,还没人回答",
|
|
791
|
+
evidence: [],
|
|
792
|
+
gate: false
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** 相邻关系的措辞。三档:真相邻 / 过滤后相邻(要说清夹着什么)/ 同一步。 */
|
|
797
|
+
function adjacencyWording(position, prevTitle) {
|
|
798
|
+
const elided = position.elided || null;
|
|
799
|
+
const tail = elided ? `(中间还有 ${elided.count} 次其他调用:${elided.tools.join("、")})` : "";
|
|
800
|
+
if (!prevTitle) {
|
|
801
|
+
return elided
|
|
802
|
+
? `(待补)它在过滤后的序列里是第一个保留的步骤,它之前还有 ${elided.count} 次其他调用:${elided.tools.join("、")}`
|
|
803
|
+
: "(待补)这一段的入口";
|
|
804
|
+
}
|
|
805
|
+
return elided
|
|
806
|
+
? `(待补)观察中它在「${prevTitle}」之后${tail},相邻关系是过滤后的`
|
|
807
|
+
: `(待补)观察中它紧跟在「${prevTitle}」之后`;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* 按层过滤段,**并重新折叠**:抽掉中间的内置工具调用之后,原本被它隔开的两个同能力
|
|
812
|
+
* 调用会变成相邻——那是一次「很重的同一步」,不是两步。不重折叠就会把 `A → pwsh → A`
|
|
813
|
+
* 记成两处步骤,支持度与形状全歪。
|
|
814
|
+
*
|
|
815
|
+
* **抽掉的东西要留痕**(`elided`):过滤后的相邻不是观察到的相邻。痕迹挂在后一个保留
|
|
816
|
+
* 步骤上——`{ count, tools, head }`,`head: true` 表示被抽掉的都在它之前(那么它也不是
|
|
817
|
+
* 「这一段的入口」)。不留痕就会生成「紧跟在 A 之后」这种机器其实没观察到的断言。
|
|
818
|
+
*/
|
|
819
|
+
export function filterSegmentsByKinds(segments, kinds) {
|
|
820
|
+
if (!Array.isArray(kinds)) return segments;
|
|
821
|
+
const wanted = new Set(kinds);
|
|
822
|
+
const out = [];
|
|
823
|
+
for (const segment of Array.isArray(segments) ? segments : []) {
|
|
824
|
+
const steps = [];
|
|
825
|
+
let pending = [];
|
|
826
|
+
for (const step of segment.steps || []) {
|
|
827
|
+
if (!wanted.has(step.kind)) {
|
|
828
|
+
pending.push(step);
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
const last = steps[steps.length - 1];
|
|
832
|
+
if (last && last.key === step.key) {
|
|
833
|
+
last.repeats += step.repeats;
|
|
834
|
+
last.at = step.at || last.at;
|
|
835
|
+
// 同一步被夹开也算省略:`A → pwsh → A` 折成一步时,中间那次 pwsh 一样是「夹着的」。
|
|
836
|
+
last.elided = mergeElided(last.elided, elidedFrom(pending));
|
|
837
|
+
pending = [];
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
const elided = elidedFrom(pending);
|
|
841
|
+
if (elided && steps.length === 0) elided.head = true;
|
|
842
|
+
steps.push({ ...step, elided: elided || null });
|
|
843
|
+
pending = [];
|
|
844
|
+
}
|
|
845
|
+
if (steps.length) out.push({ sessionId: segment.sessionId, bucket: segment.bucket ?? null, turn: segment.turn, steps });
|
|
846
|
+
}
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 挖「同一段序列反复出现」。返回的每一条都已经带好步骤草稿(`steps`)与它自己那份
|
|
852
|
+
* **极大模式**的交代。
|
|
853
|
+
*
|
|
854
|
+
* 极大模式(maximal pattern):`A → B` 与 `A → B → C` 支持度相同时,只留长的那条。
|
|
855
|
+
* 理由:`A → B` 是 `A → B → C` 的一段前缀,两条都推给人就是把同一条工作流说两遍。
|
|
856
|
+
* 判据是**包含 + 会话集是子集**——只按字符串包含会把「支持度更低的短模式」也吞掉。
|
|
857
|
+
*
|
|
858
|
+
* **支持度只在同一个工作区桶内累加**(`key` 前缀带 bucket):两个不同项目里各见过一次
|
|
859
|
+
* 的同一段序列,不是「支持度 2 的同一工作流」,而是两条各自只有一次的观察。混桶会凭空
|
|
860
|
+
* 造出候选,并把别人的工作流算到这个项目头上(复核报告 §2.6 实测过)。
|
|
861
|
+
*/
|
|
862
|
+
export function mineRepeatedPatterns(segments, { minSupport = NUDGE_MIN_SUPPORT, minLength = NUDGE_MIN_LENGTH, maxLength = NUDGE_MAX_LENGTH, maxResults = NUDGE_MAX_CANDIDATES, kinds = DEFAULT_NUDGE_KINDS } = {}) {
|
|
863
|
+
const scoped = filterSegmentsByKinds(segments, kinds);
|
|
864
|
+
const byKey = new Map();
|
|
865
|
+
for (const segment of scoped) {
|
|
866
|
+
const steps = segment.steps || [];
|
|
867
|
+
const bucket = segment.bucket ?? null;
|
|
868
|
+
for (let len = minLength; len <= maxLength; len += 1) {
|
|
869
|
+
for (let i = 0; i + len <= steps.length; i += 1) {
|
|
870
|
+
const window = steps.slice(i, i + len);
|
|
871
|
+
const names = window.map((s) => `${s.kind}:${s.name}`);
|
|
872
|
+
const key = `${bucket ?? ""}\u0000${names.join(" → ")}`;
|
|
873
|
+
let entry = byKey.get(key);
|
|
874
|
+
if (!entry) {
|
|
875
|
+
entry = {
|
|
876
|
+
key,
|
|
877
|
+
bucket,
|
|
878
|
+
names,
|
|
879
|
+
sessions: new Set(),
|
|
880
|
+
occurrences: 0,
|
|
881
|
+
turns: new Set(),
|
|
882
|
+
firstSeenAt: null,
|
|
883
|
+
lastSeenAt: null,
|
|
884
|
+
positions: window.map((s) => ({ kind: s.kind, name: s.name, tool: s.tool, commands: [], repeats: 0, elided: null }))
|
|
885
|
+
};
|
|
886
|
+
byKey.set(key, entry);
|
|
887
|
+
}
|
|
888
|
+
entry.occurrences += 1;
|
|
889
|
+
if (segment.sessionId) entry.sessions.add(segment.sessionId);
|
|
890
|
+
if (segment.turn !== null && segment.turn !== undefined) entry.turns.add(`${segment.sessionId ?? ""}#${segment.turn}`);
|
|
891
|
+
for (let k = 0; k < window.length; k += 1) mergePosition(entry.positions[k], window[k]);
|
|
892
|
+
for (const at of names.map((_, k) => window[k].at).filter(Boolean)) {
|
|
893
|
+
if (!entry.firstSeenAt || at < entry.firstSeenAt) entry.firstSeenAt = at;
|
|
894
|
+
if (!entry.lastSeenAt || at > entry.lastSeenAt) entry.lastSeenAt = at;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const candidates = [...byKey.values()]
|
|
901
|
+
.filter((e) => e.sessions.size >= minSupport)
|
|
902
|
+
// 长的、支持度高的先看:极大模式筛选取这个顺序,输出也按这个顺序。
|
|
903
|
+
.sort((a, b) => b.names.length - a.names.length || b.sessions.size - a.sessions.size || a.key.localeCompare(b.key));
|
|
904
|
+
|
|
905
|
+
const kept = [];
|
|
906
|
+
for (const entry of candidates) {
|
|
907
|
+
// 极大模式只在同一个桶内比较:跨桶的「包含」不是包含,是两回事。
|
|
908
|
+
const subsumed = kept.some(
|
|
909
|
+
(k) =>
|
|
910
|
+
k.bucket === entry.bucket &&
|
|
911
|
+
k.names.length > entry.names.length &&
|
|
912
|
+
containsSequence(k.names, entry.names) &&
|
|
913
|
+
isSubset(entry.sessions, k.sessions)
|
|
914
|
+
);
|
|
915
|
+
if (!subsumed) kept.push(entry);
|
|
916
|
+
}
|
|
917
|
+
kept.sort((a, b) => b.sessions.size - a.sessions.size || b.names.length - a.names.length || a.key.localeCompare(b.key));
|
|
918
|
+
|
|
919
|
+
return kept.slice(0, maxResults).map((entry) => toCandidate(entry));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function containsSequence(haystack, needle) {
|
|
923
|
+
for (let i = 0; i + needle.length <= haystack.length; i += 1) {
|
|
924
|
+
let ok = true;
|
|
925
|
+
for (let k = 0; k < needle.length; k += 1) {
|
|
926
|
+
if (haystack[i + k] !== needle[k]) {
|
|
927
|
+
ok = false;
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (ok) return true;
|
|
932
|
+
}
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function isSubset(a, b) {
|
|
937
|
+
for (const v of a) if (!b.has(v)) return false;
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** 模式 → 候选(含步骤草稿)。id 由「工作区桶 + 步骤形状」定,所以同一段路再挖到会落到同一条上。 */
|
|
942
|
+
function toCandidate(entry, { now = Date.now() } = {}) {
|
|
943
|
+
const steps = [];
|
|
944
|
+
entry.positions.forEach((position, index) => {
|
|
945
|
+
steps.push(planStep(position, index, index === 0 ? null : steps[index - 1].title));
|
|
946
|
+
});
|
|
947
|
+
const shape = `${entry.bucket ?? ""}|${entry.names.join("|")}`;
|
|
948
|
+
return {
|
|
949
|
+
id: `nudge-${createHash("sha256").update(shape).digest("hex").slice(0, 12)}`,
|
|
950
|
+
origin: "nudge",
|
|
951
|
+
status: "proposed",
|
|
952
|
+
// 候选来自哪个工作区桶:**桶名是路径的有损变形,所以它是本机私有数据**,
|
|
953
|
+
// 只进 memoryDir 的提议文件(那本来就不进仓库),不进草稿文件。
|
|
954
|
+
bucket: entry.bucket ?? null,
|
|
955
|
+
names: entry.names.slice(),
|
|
956
|
+
steps,
|
|
957
|
+
support: entry.sessions.size,
|
|
958
|
+
occurrences: entry.occurrences,
|
|
959
|
+
sessions: [...entry.sessions].sort(),
|
|
960
|
+
turns: entry.turns.size,
|
|
961
|
+
firstSeenAt: entry.firstSeenAt,
|
|
962
|
+
lastSeenAt: entry.lastSeenAt,
|
|
963
|
+
createdAt: new Date(now).toISOString(),
|
|
964
|
+
counterfactual: null
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* 每一步的「有它 / 没它」对照——**这是本模块唯一能自动算出来的反事实材料**。
|
|
970
|
+
*
|
|
971
|
+
* 做法:同一段序列,有的会话走全了,有的会话中间少了某一步(`A→B→C` 与 `A→C` 都
|
|
972
|
+
* 出现过)。这两种都**真发生过**,所以「少走一步的次数」是观察,不是推测。
|
|
973
|
+
*
|
|
974
|
+
* 边界要写清:
|
|
975
|
+
* · **只有中间的步骤算得出这种对照**。首尾两步的「省略」等价于「这段序列没发生过」,
|
|
976
|
+
* 那是另一件事,硬算会造出一堆假对照。所以首尾不计——这个留白是有意的。
|
|
977
|
+
* · 统计范围是**所有段**,不是「候选出现过的那些会话」:少走了中间这一步的会话,
|
|
978
|
+
* 按定义就不在候选的会话集里。只用后者会永远数出 `withoutStep = 0`,
|
|
979
|
+
* 也就是把一个真观察说成「从来没有过反例」——那正是判据变假的样子。
|
|
980
|
+
* · 只看**候选自己用到的那几层**(候选是能力级,就只数能力级):否则中间夹着的
|
|
981
|
+
* 一次内置工具调用会把相邻两步拆开,`A→B→C` 在一次真实走过它的会话里也会被
|
|
982
|
+
* 数成「没走全」。口径必须与挖掘时一致,这里用候选自己的语汇来定,省得调用方
|
|
983
|
+
* 还要记得把过滤后的段传进来。
|
|
984
|
+
* · 只看**候选自己那个工作区桶**:别的项目里的 `A→C` 不是这条候选的反例,
|
|
985
|
+
* 它是另一个项目的事。混进来说明「少走这一步」的次数,等于拿别人的账本记账。
|
|
986
|
+
*
|
|
987
|
+
* **长度为 2 的序列这里一定是空的**,而且不是「没有反例」——是这种对照对它不适用。
|
|
988
|
+
* 早前那种「静默变空」在面板上看起来跟「反例为零」一模一样,所以现在显式带
|
|
989
|
+
* `empty` 与 `note` 两个字段,把「留白」和「零」分开(复核报告 §3 那一行)。
|
|
990
|
+
*/
|
|
991
|
+
export function omissionContrast(candidate, segments) {
|
|
992
|
+
const names = candidate.names || [];
|
|
993
|
+
const out = [];
|
|
994
|
+
const all = (Array.isArray(segments) ? segments : []).filter(
|
|
995
|
+
(s) => candidate.bucket === undefined || candidate.bucket === null || (s.bucket ?? null) === candidate.bucket
|
|
996
|
+
);
|
|
997
|
+
const kindsInScope = new Set(names.map((n) => String(n).split(":")[0]));
|
|
998
|
+
for (let i = 1; i < names.length - 1; i += 1) {
|
|
999
|
+
const reduced = names.filter((_, k) => k !== i);
|
|
1000
|
+
let withStep = 0;
|
|
1001
|
+
let withoutStep = 0;
|
|
1002
|
+
for (const segment of all) {
|
|
1003
|
+
const seq = (segment.steps || [])
|
|
1004
|
+
.filter((s) => kindsInScope.has(s.kind))
|
|
1005
|
+
.map((s) => `${s.kind}:${s.name}`);
|
|
1006
|
+
if (containsSequence(seq, names)) withStep += 1;
|
|
1007
|
+
else if (containsSequence(seq, reduced)) withoutStep += 1;
|
|
1008
|
+
}
|
|
1009
|
+
out.push({
|
|
1010
|
+
stepId: candidate.steps[i] ? candidate.steps[i].id : `S${i + 1}`,
|
|
1011
|
+
title: candidate.steps[i] ? candidate.steps[i].title : names[i],
|
|
1012
|
+
withStep,
|
|
1013
|
+
withoutStep
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
const empty = out.length === 0;
|
|
1017
|
+
return {
|
|
1018
|
+
steps: out,
|
|
1019
|
+
interiorOnly: true,
|
|
1020
|
+
empty,
|
|
1021
|
+
// 「空」的两种含义必须分开:这段序列没有中间步骤(长度 2),还是中间步骤暂时没有对照数据。
|
|
1022
|
+
// 首尾不计这条规矩写在这里,免得看的人把留白读成「反例为零」。
|
|
1023
|
+
note: empty
|
|
1024
|
+
? "这段序列没有可算缺席对照的中间步骤(长度为 2 时首尾的省略等价于整段没发生)——留白,不是「反例为零」"
|
|
1025
|
+
: ""
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* 给候选配上运行记录里的三态计数。匹配靠**标题或步骤 id**:已生效工作流的 runs 里记的是
|
|
1031
|
+
* 它自己的 id(`A1` 之类),而候选的步骤是 `S1…`,两者不同源,所以对不上就返回 null
|
|
1032
|
+
* ——**宁可报「没对上」,也不要把别人的 run 算到这条候选头上**。
|
|
1033
|
+
*/
|
|
1034
|
+
function runsForCandidate(candidate, runs) {
|
|
1035
|
+
const wanted = new Set((candidate.steps || []).map((s) => s.title));
|
|
1036
|
+
const totals = { uses: 0, skipped: 0, detoured: 0, refuted: 0, matched: 0 };
|
|
1037
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
1038
|
+
for (const step of run?.steps || []) {
|
|
1039
|
+
if (!wanted.has(step.title) && !wanted.has(step.id)) continue;
|
|
1040
|
+
totals.matched += 1;
|
|
1041
|
+
const detoured = step.detoured === true || step.kind === "detour";
|
|
1042
|
+
const skipped = step.skipped === true;
|
|
1043
|
+
if (detoured) totals.detoured += 1;
|
|
1044
|
+
if (skipped) totals.skipped += 1;
|
|
1045
|
+
if (!detoured && !skipped) totals.uses += 1;
|
|
1046
|
+
if (step.refuted) totals.refuted += 1;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return totals.matched ? totals : null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* 反事实判据的**全部内容**:能观察的摆出来,不能观察的写成问题。
|
|
1054
|
+
*
|
|
1055
|
+
* `verdict` 永远是 null,这不是没做完,是设计(见文件头第 2 条)。谁要是把这里改成
|
|
1056
|
+
* 自动给一个「会漏步」,那一定是错的:日志里没有「跳过之后出事没出事」的标签。
|
|
1057
|
+
*
|
|
1058
|
+
* 能给的证据三类:
|
|
1059
|
+
* · 复现:同一段序列在几个会话、几次回合、什么时间跨度里出现过(硬事实);
|
|
1060
|
+
* · 缺席对照:中间那一步「走过几次 / 少走几次」(硬事实,见 omissionContrast);
|
|
1061
|
+
* · 运行记录与失败记录:如果这条工作流已经有 runs,或者人喂了记忆侧的失败行,一并报数。
|
|
1062
|
+
*
|
|
1063
|
+
* `failures` 传 `null`(默认)表示**记忆侧的失败记录还没接进来**,与「接进来了、
|
|
1064
|
+
* 确实一条都没有」是两件事,所以这里不给 `null`,而是给 `{ available, reason }`
|
|
1065
|
+
* ——会话源那侧早就是这么做的(`present` 与 `readable` 分开报),同一套纪律。
|
|
1066
|
+
*/
|
|
1067
|
+
export function buildCounterfactual(candidate, { segments = [], runs = [], failures = null, now = Date.now() } = {}) {
|
|
1068
|
+
const omission = omissionContrast(candidate, segments);
|
|
1069
|
+
const runTotals = runsForCandidate(candidate, runs);
|
|
1070
|
+
const failuresWired = Array.isArray(failures);
|
|
1071
|
+
const failureRows = failuresWired ? failures : [];
|
|
1072
|
+
const ask = (candidate.steps || []).map((step) => ({
|
|
1073
|
+
proposalId: candidate.id,
|
|
1074
|
+
stepId: step.id,
|
|
1075
|
+
title: step.title,
|
|
1076
|
+
question: COUNTERFACTUAL_QUESTION,
|
|
1077
|
+
choices: COUNTERFACTUAL_CHOICES.map((c) => c.id),
|
|
1078
|
+
choiceLabels: Object.fromEntries(CHOICE_LABELS),
|
|
1079
|
+
// 答案写到哪:草稿里这一步的 evidence[]。写在那里是有讲究的——`toPipelineV1` 会把
|
|
1080
|
+
// 它投影成 `counterfactual.contrast`,答不出来的步骤在图上就比答得出来的虚。
|
|
1081
|
+
writeTo: "steps[].evidence[]"
|
|
1082
|
+
}));
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
verdict: null,
|
|
1086
|
+
verdictNote:
|
|
1087
|
+
"判据不自动判:日志里读得到「这一步被跳过」这个动作,读不到「跳过之后出事没出事」这个结果——而那个结果正是判据本身。所以这里只摆证据、只问问题。",
|
|
1088
|
+
generatedAt: new Date(now).toISOString(),
|
|
1089
|
+
observable: {
|
|
1090
|
+
recurrence: {
|
|
1091
|
+
support: candidate.support,
|
|
1092
|
+
occurrences: candidate.occurrences,
|
|
1093
|
+
turns: candidate.turns ?? null,
|
|
1094
|
+
sessions: candidate.sessions ? candidate.sessions.slice(0, 10) : [],
|
|
1095
|
+
firstSeenAt: candidate.firstSeenAt || null,
|
|
1096
|
+
lastSeenAt: candidate.lastSeenAt || null
|
|
1097
|
+
},
|
|
1098
|
+
omission,
|
|
1099
|
+
runs: runTotals,
|
|
1100
|
+
// 「没接线」与「确实一条都没有」必须能分辨(同模块会话源那套 present/readable)。
|
|
1101
|
+
failures: failuresWired
|
|
1102
|
+
? { available: true, count: failureRows.length, kinds: [...new Set(failureRows.map((f) => f && f.failure_type).filter(Boolean))].sort() }
|
|
1103
|
+
: { available: false, reason: "not-wired", note: "记忆侧的失败记录还没接进来——这不是「没有失败记录」" },
|
|
1104
|
+
bucket: candidate.bucket ?? null
|
|
1105
|
+
},
|
|
1106
|
+
unobservable: [
|
|
1107
|
+
"没有这一步「这次会不会漏步」——需要人回答",
|
|
1108
|
+
"没有这一步「这次会不会失败/返工」——需要人回答",
|
|
1109
|
+
"这一步的完成判据与触发语——需要人补"
|
|
1110
|
+
],
|
|
1111
|
+
ask
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* 人的回答并进候选:写进那一步的 `evidence[]`(准入判据的载体),并留痕。
|
|
1117
|
+
*
|
|
1118
|
+
* 两条硬规矩:
|
|
1119
|
+
* · 非法选项**直接拒**,不静默当 `unknown`——静默降级会把一个打错的答案变成一个人的判断;
|
|
1120
|
+
* · **一条回答都没接受就什么都不推进**:`applyAnswers(candidate, {})` 早前返回
|
|
1121
|
+
* `{ok:true, status:"answered", applied:0}`——无人回答却「已答」。今天没有下游消费者,
|
|
1122
|
+
* 但这条通路的名字就叫「人点头」,将来只要有人写出「answered 才准升格」,那个假状态
|
|
1123
|
+
* 立刻生效。所以空回答按失败处理,`status` 一步都不许动。
|
|
1124
|
+
*/
|
|
1125
|
+
export function applyAnswers(candidate, answers, { now = Date.now() } = {}) {
|
|
1126
|
+
const problems = [];
|
|
1127
|
+
const answeredAt = new Date(now).toISOString();
|
|
1128
|
+
const steps = (candidate.steps || []).map((s) => ({ ...s, evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [] }));
|
|
1129
|
+
const applied = [];
|
|
1130
|
+
for (const [stepId, raw] of Object.entries(answers || {})) {
|
|
1131
|
+
const step = steps.find((s) => s.id === stepId);
|
|
1132
|
+
if (!step) {
|
|
1133
|
+
problems.push(`回答指向了不存在的步骤 ${stepId}`);
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
const choice = typeof raw === "string" ? raw : raw && raw.choice;
|
|
1137
|
+
const note = typeof raw === "object" && raw ? String(raw.note || "").trim() : "";
|
|
1138
|
+
if (!CHOICE_IDS.includes(choice)) {
|
|
1139
|
+
problems.push(`${stepId} 的答案「${String(choice)}」不在选项里(${CHOICE_IDS.join(" / ")})`);
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
const text = `人答(${answeredAt.slice(0, 10)}):没有它,${CHOICE_LABELS.get(choice)}${note ? `——${note}` : ""}`;
|
|
1143
|
+
step.evidence = [text];
|
|
1144
|
+
applied.push({ stepId, choice, text });
|
|
1145
|
+
}
|
|
1146
|
+
if (problems.length) return { ok: false, candidate: null, applied: [], unanswered: [], problems };
|
|
1147
|
+
if (applied.length === 0) {
|
|
1148
|
+
return {
|
|
1149
|
+
ok: false,
|
|
1150
|
+
candidate: null,
|
|
1151
|
+
applied: [],
|
|
1152
|
+
unanswered: steps.filter((s) => s.evidence.length === 0).map((s) => s.id),
|
|
1153
|
+
problems: ["没有任何一条回答被接受——空回答不算「已答」,状态不推进"]
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const next = { ...candidate, steps, status: "answered", answeredAt };
|
|
1158
|
+
const unanswered = steps.filter((s) => s.evidence.length === 0).map((s) => s.id);
|
|
1159
|
+
return { ok: true, candidate: next, applied, unanswered, problems: [] };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** 草稿里的占位符只在这里出现一次,方便一眼看出「哪些还是机器留的白」。 */
|
|
1163
|
+
const TODO = "(待补)";
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* 候选 → `status:"draft"` 的草稿,**过 validatePipeline**。
|
|
1167
|
+
*
|
|
1168
|
+
* 为什么 status 写死 draft:提议自动、固化要人点头。这个函数没有 active 分支,
|
|
1169
|
+
* 也不接 `status` 参数——想升格就得有人动手,那条路不在这个文件里。
|
|
1170
|
+
*
|
|
1171
|
+
* 三个必填字段(`done` / `trigger` / 步骤的人话)机器给不了,所以填成显眼的 `(待补)`:
|
|
1172
|
+
* **一个看起来像成品、其实没人回答过判据的草稿,比一个明摆着是半成品的草稿危险。**
|
|
1173
|
+
*/
|
|
1174
|
+
export function draftFromCandidate(candidate, { project = "", name = "", done = "", trigger = null, version = 0 } = {}) {
|
|
1175
|
+
if (!candidate || !Array.isArray(candidate.steps) || candidate.steps.length === 0) {
|
|
1176
|
+
return { ok: false, pipeline: null, problems: ["候选没有步骤,不能成草稿"], unanswered: [] };
|
|
1177
|
+
}
|
|
1178
|
+
const raw = {
|
|
1179
|
+
id: String(candidate.id || ""),
|
|
1180
|
+
name: name || `(待补)由重复观察提出的工作流:${(candidate.names || []).join(" → ")}`,
|
|
1181
|
+
project: project || TODO,
|
|
1182
|
+
version,
|
|
1183
|
+
status: "draft",
|
|
1184
|
+
done: done || `${TODO}整条怎么算完成`,
|
|
1185
|
+
trigger: Array.isArray(trigger) && trigger.length ? trigger : [{ kind: "phrase", value: `${TODO}什么话该触发这条工作流` }],
|
|
1186
|
+
steps: candidate.steps.map((s, index) => ({
|
|
1187
|
+
id: s.id || `S${index + 1}`,
|
|
1188
|
+
phase: s.phase || "观察段",
|
|
1189
|
+
title: s.title,
|
|
1190
|
+
when: s.when,
|
|
1191
|
+
do: s.do,
|
|
1192
|
+
who: s.who || "main",
|
|
1193
|
+
tool: s.tool || "",
|
|
1194
|
+
done: s.done,
|
|
1195
|
+
risk: s.risk || "R0",
|
|
1196
|
+
needs: Array.isArray(s.needs) ? s.needs : index === 0 ? [] : [`S${index}`],
|
|
1197
|
+
why: s.why,
|
|
1198
|
+
evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [],
|
|
1199
|
+
gate: s.gate === true
|
|
1200
|
+
}))
|
|
1201
|
+
};
|
|
1202
|
+
try {
|
|
1203
|
+
const pipeline = validatePipeline(raw, `${candidate.id}.json`);
|
|
1204
|
+
return {
|
|
1205
|
+
ok: true,
|
|
1206
|
+
pipeline,
|
|
1207
|
+
problems: [],
|
|
1208
|
+
unanswered: pipeline.steps.filter((s) => s.evidence.length === 0).map((s) => s.id)
|
|
1209
|
+
};
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
return { ok: false, pipeline: null, problems: error.problems || [String(error && error.message)], unanswered: [] };
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* **原子整文件写**:先写同目录的临时文件,再 `rename` 覆盖目标。
|
|
1217
|
+
*
|
|
1218
|
+
* 为什么非这样不可:读侧(`loadPipelines`)是「整目录校验,任一条坏就整层抛」,所以
|
|
1219
|
+
* 一个写到一半的文件会让**整个项目层的工作流一起从面板消失**——合法的那几条也一起没,
|
|
1220
|
+
* 而且因为「已存在就跳过」的规矩还不会自愈(复核报告 §2.9 实测过这个组合)。
|
|
1221
|
+
* `rename` 在同一文件系统内是原子的:读者要么看到旧的完整内容,要么看到新的完整内容。
|
|
1222
|
+
*
|
|
1223
|
+
* 临时文件必须**同目录**(跨盘 rename 会退化成拷贝,就不再原子了),并且带 pid 与随机后缀,
|
|
1224
|
+
* 免得两个进程同时写同一个目标时互相吃掉临时文件。
|
|
1225
|
+
*
|
|
1226
|
+
* `beforeRename` 是**给测试用的注入口**(生产路径不传):它让「rename 之前崩掉」这个窗口
|
|
1227
|
+
* 可以被真的模拟一次,从而证明「失败不会留下半个目标文件」。没有它,测试只能断言
|
|
1228
|
+
* 「代码里出现了 tmp 字样」,那证明不了这条修复要证明的事。
|
|
1229
|
+
*/
|
|
1230
|
+
export function writeFileAtomic(file, text, { beforeRename = null } = {}) {
|
|
1231
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1232
|
+
try {
|
|
1233
|
+
writeFileSync(tmp, text, "utf8");
|
|
1234
|
+
if (typeof beforeRename === "function") beforeRename(tmp);
|
|
1235
|
+
renameSync(tmp, file);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
// 崩在中途:把临时文件收掉(目标文件一个字节都没动过,读者看到的还是旧内容)。
|
|
1238
|
+
try {
|
|
1239
|
+
rmSync(tmp, { force: true });
|
|
1240
|
+
} catch {
|
|
1241
|
+
// 收不掉也不该改判原错误——真正要报的是上面那个。
|
|
1242
|
+
}
|
|
1243
|
+
throw error;
|
|
1244
|
+
}
|
|
1245
|
+
return file;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* 把草稿写进 `<项目>/.mneme/pipelines/<id>.json`。
|
|
1250
|
+
*
|
|
1251
|
+
* 三条硬规矩:
|
|
1252
|
+
* · `status` 不是 `draft` 就拒写——沉淀链只产草稿,固化是人的动作;
|
|
1253
|
+
* · 目标文件已存在就拒写(`force` 才覆盖)——**已经在人手里的文件不能被机器改写**,
|
|
1254
|
+
* 否则人改过的措辞会被下一次挖掘悄悄冲掉;
|
|
1255
|
+
* · 落盘走 `writeFileAtomic`——写一半就崩不能把整层工作流带走。
|
|
1256
|
+
*/
|
|
1257
|
+
export function writeDraftPipeline({ projectRoot, pipeline, force = false } = {}) {
|
|
1258
|
+
if (!projectRoot || String(projectRoot).trim() === "") return { ok: false, reason: "no-project-root", file: null };
|
|
1259
|
+
if (!pipeline || pipeline.status !== "draft") return { ok: false, reason: "not-draft", file: null, detail: "沉淀链只产 draft,升格要人点头" };
|
|
1260
|
+
let validated;
|
|
1261
|
+
try {
|
|
1262
|
+
validated = validatePipeline(pipeline, "<nudge-draft>");
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
return { ok: false, reason: "invalid", file: null, problems: error.problems || [String(error && error.message)] };
|
|
1265
|
+
}
|
|
1266
|
+
const dir = projectPipelineDir(projectRoot);
|
|
1267
|
+
const file = join(dir, `${validated.id}.json`);
|
|
1268
|
+
if (existsSync(file) && force !== true) return { ok: false, reason: "exists", file, id: validated.id };
|
|
1269
|
+
mkdirSync(dir, { recursive: true });
|
|
1270
|
+
writeFileAtomic(file, `${JSON.stringify(validated, null, 2)}\n`);
|
|
1271
|
+
return { ok: true, reason: "written", file, id: validated.id, pipeline: validated };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** 提议(候选)的家:`<memoryDir>/proposals/<项目键>/`——与绕行候选同一套分桶约定。 */
|
|
1275
|
+
export function proposalDir({ memoryDir, projectRoot } = {}) {
|
|
1276
|
+
const key = projectKey(projectRoot);
|
|
1277
|
+
if (!key) return null;
|
|
1278
|
+
const base = expandHome(memoryDir && String(memoryDir).trim() ? memoryDir : "~/.dsh/memory");
|
|
1279
|
+
return join(base, "proposals", key);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* 提议的形状校验。比定稿松(它还不是工作流),但**判据那一栏的形状一个都不能少**。
|
|
1284
|
+
*
|
|
1285
|
+
* `counterfactual.verdict` 在这里**必须恰好是 `null`**(键也必须在,缺键 = `undefined` 也拒)。
|
|
1286
|
+
* 为什么这条要落在校验层、而不只靠产者自觉:`summarizeProposal` 会把 `verdict` 投影给面板,
|
|
1287
|
+
* 于是一份被人手改过、或被别的写入方塞进来的提议就能让面板显示一个机器给的判据结论——
|
|
1288
|
+
* 那正是本模块的头号红线。产者守规矩只是「产者的性质」,校验层拒绝才是「边界的性质」。
|
|
1289
|
+
*/
|
|
1290
|
+
export function validateProposal(raw) {
|
|
1291
|
+
const problems = [];
|
|
1292
|
+
const bad = (msg) => problems.push(msg);
|
|
1293
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, problems: ["顶层必须是一个对象"] };
|
|
1294
|
+
if (typeof raw.id !== "string" || !/^nudge-[a-z0-9-]{4,64}$/.test(raw.id)) bad("id 必须是 nudge- 开头的小写 kebab");
|
|
1295
|
+
if (!PROPOSAL_STATUSES.includes(raw.status)) bad(`status 必须是 ${PROPOSAL_STATUSES.join(" / ")}`);
|
|
1296
|
+
if (!Array.isArray(raw.names) || raw.names.length === 0) bad("names 是序列本身,不能为空");
|
|
1297
|
+
if (!Array.isArray(raw.steps) || raw.steps.length === 0) bad("steps 至少要有一步");
|
|
1298
|
+
if (!Number.isInteger(raw.support) || raw.support < 1) bad("support 必须是正整数");
|
|
1299
|
+
// 反事实那一栏**必须存在**:可以是 null(还没算),但不能没有——没有它的候选不是候选。
|
|
1300
|
+
if (raw.counterfactual !== null) {
|
|
1301
|
+
if (typeof raw.counterfactual !== "object" || Array.isArray(raw.counterfactual)) {
|
|
1302
|
+
bad("counterfactual 要么是 null,要么是对象");
|
|
1303
|
+
} else if (raw.counterfactual.verdict !== null) {
|
|
1304
|
+
bad("counterfactual.verdict 必须是 null:判据只能由人回答,机器不许给出结论");
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return { ok: problems.length === 0, problems };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** 写一条提议(同 id 覆盖:同一段路再挖到应当并进同一条,而不是长出第二份)。落盘同样是原子的。 */
|
|
1311
|
+
export function saveProposal(dir, proposal) {
|
|
1312
|
+
if (!dir) throw new Error("proposalDir 为空:拿不到项目根或 memoryDir");
|
|
1313
|
+
const v = validateProposal(proposal);
|
|
1314
|
+
if (!v.ok) throw new Error(v.problems.join("; "));
|
|
1315
|
+
mkdirSync(dir, { recursive: true });
|
|
1316
|
+
writeFileAtomic(join(dir, `${proposal.id}.json`), `${JSON.stringify(proposal, null, 2)}\n`);
|
|
1317
|
+
return proposal;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** 读一个项目下的全部提议;坏文件不静默跳过(与定稿同一纪律,静默少一条最难查)。 */
|
|
1321
|
+
export function loadProposals(dir) {
|
|
1322
|
+
if (!dir || !existsSync(dir)) return [];
|
|
1323
|
+
const out = [];
|
|
1324
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
|
1325
|
+
let raw;
|
|
1326
|
+
try {
|
|
1327
|
+
raw = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
throw new Error(`${file}: JSON 解析失败:${error.message}`);
|
|
1330
|
+
}
|
|
1331
|
+
const v = validateProposal(raw);
|
|
1332
|
+
if (!v.ok) throw new Error(`${file}: ${v.problems.join("; ")}`);
|
|
1333
|
+
out.push(raw);
|
|
1334
|
+
}
|
|
1335
|
+
return out;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** 清单行:面板与 agent 共用(与 `pipeline.js` 的 available[]、`candidates.js` 的清单对齐)。 */
|
|
1339
|
+
export function summarizeProposal(proposal) {
|
|
1340
|
+
const cf = proposal.counterfactual || {};
|
|
1341
|
+
// 整条的风险取下限里的最高档:一段路的最坏情况才是它该被看待的档位。
|
|
1342
|
+
const rank = { R0: 0, R1: 1, R2: 2, R3: 3 };
|
|
1343
|
+
const riskFloor = (proposal.steps || []).reduce(
|
|
1344
|
+
(acc, s) => ((rank[s.risk] ?? 0) > rank[acc] ? s.risk : acc),
|
|
1345
|
+
"R0"
|
|
1346
|
+
);
|
|
1347
|
+
return {
|
|
1348
|
+
id: proposal.id,
|
|
1349
|
+
origin: proposal.origin || "nudge",
|
|
1350
|
+
status: proposal.status,
|
|
1351
|
+
sequence: (proposal.names || []).slice(),
|
|
1352
|
+
steps: (proposal.steps || []).length,
|
|
1353
|
+
support: proposal.support,
|
|
1354
|
+
occurrences: proposal.occurrences ?? null,
|
|
1355
|
+
firstSeenAt: proposal.firstSeenAt || null,
|
|
1356
|
+
lastSeenAt: proposal.lastSeenAt || null,
|
|
1357
|
+
// 反事实覆盖率:答过几步 / 一共几步。19/19 与 6/19 是两条不同的工作流,清单上就该看得出来。
|
|
1358
|
+
contrast: {
|
|
1359
|
+
answered: (proposal.steps || []).filter((s) => Array.isArray(s.evidence) && s.evidence.length > 0).length,
|
|
1360
|
+
total: (proposal.steps || []).length
|
|
1361
|
+
},
|
|
1362
|
+
// 判据**永远**是 null,这里照实投影,免得有人以为它只是还没算。
|
|
1363
|
+
verdict: cf.verdict ?? null,
|
|
1364
|
+
riskFloor
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* 已经在跑的工作流的自精简提议:跳过 ≥3 次 → 提议删掉这一步;绕开 ≥3 次 → 提议重画路径。
|
|
1370
|
+
* 计数与阈值全部来自 `pipeline.js`(那份是权威),这里只做两件事:把提议包成
|
|
1371
|
+
* **要人点头的形状**(带一个封闭问题),并把它和「新工作流候选」放进同一条通路。
|
|
1372
|
+
*/
|
|
1373
|
+
export function retirementProposals(pipeline, { runs = [], now = Date.now(), observed = null } = {}) {
|
|
1374
|
+
// 四类提议各自的封闭问题与选项。判据都不能自动算(每类都是),把答案空间
|
|
1375
|
+
// 写死成封闭选择——人点一下就行,不用自由发挥。新类不配问法就会落进
|
|
1376
|
+
// 「岔路」那条不相关的问法里,所以这张表是**穷举**而不是默认分支。
|
|
1377
|
+
const ASK = {
|
|
1378
|
+
"drop-step": {
|
|
1379
|
+
question: "这一步从没被执行、且总被跳过:是「它对这条流程已经没用了」还是「这几次正好不需要」?",
|
|
1380
|
+
choices: ["这一步没用了", "这次不需要", "不知道"],
|
|
1381
|
+
writeTo: "steps[].removedBy(人批后改 status/version)"
|
|
1382
|
+
},
|
|
1383
|
+
"rework-path": {
|
|
1384
|
+
// 同一 kind 有两种成因(pipeline.js 的 cause 字段):detoured 问重画路径,
|
|
1385
|
+
// skipped 问删步/改 optional——共用一句问法会让跳过场景答非所问
|
|
1386
|
+
// (CodeRabbit #458 F3)。cause 缺失(旧提议/外部构造)退回 detoured 问法。
|
|
1387
|
+
detoured: {
|
|
1388
|
+
question: "从这一步岔出去已经成了常态,是这条路径过时了,还是岔路该并进图里?",
|
|
1389
|
+
choices: ["路径过时了", "岔路该并进来", "不知道"],
|
|
1390
|
+
writeTo: "新版本的 steps[]"
|
|
1391
|
+
},
|
|
1392
|
+
skipped: {
|
|
1393
|
+
question: "这一必经步骤总被跳过:是这步该改成 optional(或删掉),还是流程本身写错了?",
|
|
1394
|
+
choices: ["该改成 optional", "该删掉", "流程写错了", "不知道"],
|
|
1395
|
+
writeTo: "steps[].optional(人批后改 version)"
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
"violation-precedence": {
|
|
1399
|
+
question: "做了后继却没做它的前置:是顺序写错了,还是这个前置根本不必要?",
|
|
1400
|
+
choices: ["顺序写错了", "前置不必要", "不知道"],
|
|
1401
|
+
writeTo: "steps[].needs(人批后改 version)"
|
|
1402
|
+
},
|
|
1403
|
+
"fix-step-tool": {
|
|
1404
|
+
question: "你说做过这步,但声明的命令一次都没出现:是 tool 写过时了,还是这一步本来就不必敲它?",
|
|
1405
|
+
choices: ["tool 过时了", "本来不必敲", "我记错了", "不知道"],
|
|
1406
|
+
writeTo: "steps[].tool(人批后改 version)"
|
|
1407
|
+
},
|
|
1408
|
+
"optional-skip-ok": {
|
|
1409
|
+
question: "这个 optional 步骤一直被跳:声明留着就好,还是干脆显式从流程里拿掉?",
|
|
1410
|
+
choices: ["留着", "拿掉", "不知道"],
|
|
1411
|
+
writeTo: "无(信息项;人若要拿掉,走删步流程)"
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
const items = suggestRetirements(pipeline, { runs, now, observed }).map((s) => {
|
|
1415
|
+
// rework-path 按 cause 二选一问法;其余 kind 直接查表。缺表项的兜底保持原样。
|
|
1416
|
+
const askTable = ASK[s.kind];
|
|
1417
|
+
const ask = askTable
|
|
1418
|
+
? (s.cause && askTable[s.cause]) || (typeof askTable.question === "string" ? askTable : askTable.detoured)
|
|
1419
|
+
: { question: "这一步要怎么处理?", choices: ["不知道"], writeTo: "新版本的 steps[]" };
|
|
1420
|
+
return {
|
|
1421
|
+
kind: s.kind,
|
|
1422
|
+
pipelineId: pipeline.id,
|
|
1423
|
+
stepId: s.stepId,
|
|
1424
|
+
title: s.title,
|
|
1425
|
+
because: s.because,
|
|
1426
|
+
effect: s.effect,
|
|
1427
|
+
// cause 只有 rework-path 有;其余 kind 不带 undefined 键(消费方 deepEqual 会看到多余键)
|
|
1428
|
+
...(s.cause ? { cause: s.cause } : {}),
|
|
1429
|
+
...ask
|
|
1430
|
+
};
|
|
1431
|
+
});
|
|
1432
|
+
return { contract: "mneme.nudge/v1", pipelineId: pipeline.id, generatedAt: new Date(now).toISOString(), items };
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* 一个项目的**自动沉淀入口**:先把项目解析到它自己的会话桶,再跑一次 `nudgeFromSessions`。
|
|
1437
|
+
*
|
|
1438
|
+
* 为什么这一层要单独存在:`nudgeFromSessions` 要求调用方**已经知道**这个项目的 `sessionDir`
|
|
1439
|
+
* (给了 projectRoot 却不给 sessionDir 会被降级成无归属普查)。而真实调用方手里只有一个
|
|
1440
|
+
* cwd。把「项目根 → 桶」和「跑一轮」焊在一起,是为了让调用方**没有机会**漏掉那一步而
|
|
1441
|
+
* 静默退化成普查——退化的输出看上去一切正常,只是候选全都不属于任何项目。
|
|
1442
|
+
*
|
|
1443
|
+
* 解析不到桶时**不跑**,直接回一份空结果并带上 `attribution.reason`:不是省事,是因为
|
|
1444
|
+
* 跑了也只能得到无归属普查,而它对「这个项目的工作流」这个问题的答案永远是错的。
|
|
1445
|
+
* `skipped: true` 是关键——空列表必须能区分「没跑」与「跑了但没挖到」,否则调用方会把
|
|
1446
|
+
* 一次归属失败当成「这个项目很干净」。
|
|
1447
|
+
*
|
|
1448
|
+
* `attribution` 参数允许调用方把**已经算好**的桶解析结果传进来:`/nudge` 路由要先做一遍
|
|
1449
|
+
* 解析才能决定要不要回 400,再传进来就跑两遍(一遍扫桶、一遍真挖)。
|
|
1450
|
+
*/
|
|
1451
|
+
export function nudgeForProject({
|
|
1452
|
+
projectRoot = null,
|
|
1453
|
+
dshHome = undefined,
|
|
1454
|
+
memoryDir = null,
|
|
1455
|
+
project = "",
|
|
1456
|
+
now = Date.now(),
|
|
1457
|
+
budgetMs = NUDGE_AUTO_BUDGET_MS,
|
|
1458
|
+
materialize = false,
|
|
1459
|
+
persistProposals = true,
|
|
1460
|
+
maxFiles = MAX_SESSION_FILES,
|
|
1461
|
+
kinds = DEFAULT_NUDGE_KINDS,
|
|
1462
|
+
attribution = null,
|
|
1463
|
+
sessionGate = null
|
|
1464
|
+
} = {}) {
|
|
1465
|
+
// 归属解析自己也可能很贵(同步扫桶 500 个),所以它也要吃同一个预算。调用方给了现成的
|
|
1466
|
+
// attribution 就不再解析(`/nudge` 路由就是这么做的:它先解析一次才能决定要不要回 400)。
|
|
1467
|
+
// 这里**自己算**截止点而不是多接一个 deadline 参数:两处各算一次会出现「解析用 A 截止、
|
|
1468
|
+
// 挖掘用 B 截止」的双预算,那种不一致以后没人查得出来。
|
|
1469
|
+
const budgetDeadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null;
|
|
1470
|
+
const where = attribution || resolveProjectSessionDir({ dshHome, projectRoot, deadline: budgetDeadline });
|
|
1471
|
+
if (!where || !where.sessionDir) {
|
|
1472
|
+
return {
|
|
1473
|
+
contract: "mneme.nudge/v1",
|
|
1474
|
+
generatedAt: new Date(now).toISOString(),
|
|
1475
|
+
attribution: where || { sessionDir: null, bucket: null, reason: "bad-project" },
|
|
1476
|
+
skipped: true,
|
|
1477
|
+
skippedReason: "no-session-attribution",
|
|
1478
|
+
refused: null,
|
|
1479
|
+
source: null,
|
|
1480
|
+
segments: 0,
|
|
1481
|
+
kinds: Array.isArray(kinds) ? kinds.slice() : null,
|
|
1482
|
+
project: null,
|
|
1483
|
+
proposalsDir: null,
|
|
1484
|
+
counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 },
|
|
1485
|
+
available: [],
|
|
1486
|
+
drafts: [],
|
|
1487
|
+
boundaries: NUDGE_BOUNDARIES
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
const doc = nudgeFromSessions({
|
|
1491
|
+
sessionDir: where.sessionDir,
|
|
1492
|
+
projectRoot,
|
|
1493
|
+
memoryDir,
|
|
1494
|
+
project,
|
|
1495
|
+
now,
|
|
1496
|
+
maxFiles,
|
|
1497
|
+
kinds,
|
|
1498
|
+
materialize,
|
|
1499
|
+
persistProposals,
|
|
1500
|
+
budgetMs,
|
|
1501
|
+
sessionGate
|
|
1502
|
+
});
|
|
1503
|
+
return { ...doc, attribution: where, skipped: false };
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* 一次完整的沉淀跑:读真实会话日志 → 挖候选 → 算能算的对照 → 把提议落进 memoryDir →
|
|
1507
|
+
* (可选)把草稿落进项目。
|
|
1508
|
+
*
|
|
1509
|
+
* 为什么默认不落草稿:落进仓库是会被 review、被提交的东西,一次自动跑就往里塞文件,
|
|
1510
|
+
* 会把「提议」变成「既成事实」。所以 `materialize` 默认 false——要落就得有人开口。
|
|
1511
|
+
*
|
|
1512
|
+
* **会话范围与项目归属是一件事,不是两件**:会话目录在 DSH 侧按工作区分桶,桶名是
|
|
1513
|
+
* 工作区路径的有损变形、规则不在本仓。所以「这个项目的工作流」只有在调用方给出
|
|
1514
|
+
* **这个项目自己的 `sessionDir`** 时才成立。给了 `projectRoot` 却没给 `sessionDir`
|
|
1515
|
+
* 时,这一跑会被**降级成「无归属的普查」**:候选不带项目名、不写任何项目的提议桶、
|
|
1516
|
+
* 不落草稿——绝不允许拿全机会话去冒充某个项目的账(复核报告 §2.6)。
|
|
1517
|
+
*/
|
|
1518
|
+
export function nudgeFromSessions({
|
|
1519
|
+
sessionDir = null,
|
|
1520
|
+
dshHome = undefined,
|
|
1521
|
+
projectRoot = null,
|
|
1522
|
+
memoryDir = null,
|
|
1523
|
+
project = "",
|
|
1524
|
+
now = Date.now(),
|
|
1525
|
+
minSupport = NUDGE_MIN_SUPPORT,
|
|
1526
|
+
minLength = NUDGE_MIN_LENGTH,
|
|
1527
|
+
maxLength = NUDGE_MAX_LENGTH,
|
|
1528
|
+
maxResults = NUDGE_MAX_CANDIDATES,
|
|
1529
|
+
maxFiles = MAX_SESSION_FILES,
|
|
1530
|
+
failures = null,
|
|
1531
|
+
kinds = DEFAULT_NUDGE_KINDS,
|
|
1532
|
+
materialize = false,
|
|
1533
|
+
budgetMs = 0,
|
|
1534
|
+
// 提议落盘是**写**。预览(面板刷新一次就调一次)不能顺手往 memoryDir 里塞文件:
|
|
1535
|
+
// 落盘要从这里显式关掉,不能靠调用方「记得别调」。默认 true 保持既有语义。
|
|
1536
|
+
persistProposals = true,
|
|
1537
|
+
sessionGate = null
|
|
1538
|
+
} = {}) {
|
|
1539
|
+
const scoped = typeof sessionDir === "string" && sessionDir.trim() !== "";
|
|
1540
|
+
const dir = scoped ? sessionDir : resolveSessionDir({ dshHome });
|
|
1541
|
+
// 扫描范围不在一个工作区内 → 不许有任何项目归属(连调用方自报的 project 也不认:
|
|
1542
|
+
// 那同样是「拿一批跨项目的观察去贴一个项目名」)。
|
|
1543
|
+
const attributionRefused = !scoped && Boolean(projectRoot || project);
|
|
1544
|
+
const allowedProjectRoot = attributionRefused ? null : projectRoot;
|
|
1545
|
+
// budgetMs > 0 才设截止点:测试与离线批处理要完整结果,面板要「不卡」。
|
|
1546
|
+
const deadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null;
|
|
1547
|
+
const read = readSessionObservations({ sessionDir: dir, limit: maxFiles, deadline, sessionGate });
|
|
1548
|
+
const segments = segmentObservations(read.observations);
|
|
1549
|
+
const candidates = mineRepeatedPatterns(segments, { minSupport, minLength, maxLength, maxResults, kinds });
|
|
1550
|
+
const projectName = attributionRefused ? "" : project || (allowedProjectRoot ? projectLabel(allowedProjectRoot) : "");
|
|
1551
|
+
const failureSource = Array.isArray(failures) ? "caller" : "unwired";
|
|
1552
|
+
const enriched = candidates.map((c) => ({
|
|
1553
|
+
...c,
|
|
1554
|
+
project: projectName,
|
|
1555
|
+
counterfactual: buildCounterfactual(c, { segments, failures, now })
|
|
1556
|
+
}));
|
|
1557
|
+
|
|
1558
|
+
const propDir = attributionRefused ? null : proposalDir({ memoryDir, projectRoot: allowedProjectRoot });
|
|
1559
|
+
const saved = [];
|
|
1560
|
+
if (propDir && persistProposals !== false) {
|
|
1561
|
+
for (const c of enriched) saved.push(saveProposal(propDir, c).id);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const drafts = [];
|
|
1565
|
+
const limit = materialize === true ? 1 : Number.isFinite(materialize) ? Math.max(0, Math.floor(materialize)) : 0;
|
|
1566
|
+
if (allowedProjectRoot && limit > 0) {
|
|
1567
|
+
for (const c of enriched.slice(0, limit)) {
|
|
1568
|
+
const made = draftFromCandidate(c, { project: projectName });
|
|
1569
|
+
if (!made.ok) {
|
|
1570
|
+
drafts.push({ id: c.id, ok: false, reason: "invalid", problems: made.problems, unanswered: made.unanswered });
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
// `unanswered` 是草稿出口的一部分:调用方与面板据此知道「还有哪几步没人回答判据」。
|
|
1574
|
+
// 早前它被算出来又在出口丢掉,等于这条判据没有消费者。
|
|
1575
|
+
drafts.push({ id: c.id, unanswered: made.unanswered, ...writeDraftPipeline({ projectRoot: allowedProjectRoot, pipeline: made.pipeline }) });
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
return {
|
|
1580
|
+
contract: "mneme.nudge/v1",
|
|
1581
|
+
generatedAt: new Date(now).toISOString(),
|
|
1582
|
+
// 归属被降级时把原因写在最外层:调用方不该错过这件事(它决定这批候选能不能信)。
|
|
1583
|
+
refused: attributionRefused
|
|
1584
|
+
? {
|
|
1585
|
+
reason: "unscoped-sessions",
|
|
1586
|
+
detail: "给了 projectRoot/project 却没给 sessionDir:会话目录是按工作区分桶的,扫全部拿到的观察不属于任何单个项目。已按「无归属普查」处理——候选不带项目名、不写项目桶、不落草稿。要项目归属就传该项目自己的 sessionDir。"
|
|
1587
|
+
}
|
|
1588
|
+
: null,
|
|
1589
|
+
// 会话源的读数**原样带出来**:扫了几个文件、几帧解不出来、有没有被截断。
|
|
1590
|
+
source: {
|
|
1591
|
+
sessionDir: dir,
|
|
1592
|
+
scoped,
|
|
1593
|
+
present: read.present,
|
|
1594
|
+
readable: read.readable,
|
|
1595
|
+
truncated: read.truncated,
|
|
1596
|
+
budgetExceeded: read.budgetExceeded === true,
|
|
1597
|
+
totalFiles: read.totalFiles,
|
|
1598
|
+
scannedFiles: read.scannedFiles,
|
|
1599
|
+
frames: read.frames,
|
|
1600
|
+
failedFrames: read.failedFrames,
|
|
1601
|
+
observations: read.observations.length,
|
|
1602
|
+
unreadable: read.unreadable,
|
|
1603
|
+
// 失败记录是**没接线**还是**接进来了但一条都没有**,必须分开报(同 present/readable 那套)。
|
|
1604
|
+
failureSource
|
|
1605
|
+
},
|
|
1606
|
+
segments: segments.length,
|
|
1607
|
+
// 挖的是哪一层要跟着结果一起报出来:默认只有能力级,工具级要显式开。
|
|
1608
|
+
kinds: Array.isArray(kinds) ? kinds.slice() : null,
|
|
1609
|
+
project: projectName || null,
|
|
1610
|
+
proposalsDir: propDir,
|
|
1611
|
+
counts: {
|
|
1612
|
+
candidates: enriched.length,
|
|
1613
|
+
buckets: new Set(enriched.map((c) => c.bucket).filter(Boolean)).size,
|
|
1614
|
+
sessionsWithCandidates: new Set(enriched.flatMap((c) => c.sessions)).size,
|
|
1615
|
+
savedProposals: saved.length,
|
|
1616
|
+
draftsWritten: drafts.filter((d) => d.ok).length
|
|
1617
|
+
},
|
|
1618
|
+
available: enriched.map(summarizeProposal),
|
|
1619
|
+
drafts,
|
|
1620
|
+
// 「这一刀不做的事」写进契约里,免得以后有人以为忘了做。
|
|
1621
|
+
boundaries: NUDGE_BOUNDARIES
|
|
1622
|
+
};
|
|
1623
|
+
}
|