@modusensus/dsh-mneme 0.14.0-beta.1 → 0.14.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +830 -830
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +964 -964
- package/bin/dsh-mneme-choro.mjs +262 -262
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -151
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +407 -404
- package/docs/CONTINUITY.md +139 -139
- package/docs/DAEMON.md +57 -57
- package/docs/DIRECTORIES.md +114 -114
- package/docs/DISTILL.md +397 -371
- package/docs/DSH-GO.md +97 -97
- package/docs/ENTITIES.md +247 -247
- package/docs/FORGET.md +77 -77
- package/docs/KB.md +110 -110
- package/docs/LOCAL_MODEL.md +222 -222
- package/docs/SEMANTIC.md +256 -256
- package/docs/SHARE.md +552 -552
- package/docs/SLEEP.md +202 -202
- package/docs/STORAGE.md +122 -122
- package/docs/WORKFLOWS.md +366 -366
- package/lib/api-standalone.js +1007 -1007
- package/lib/api.js +3600 -3600
- package/lib/attachment-text.js +122 -122
- package/lib/bootstrap-http.js +105 -105
- package/lib/bootstrap.js +215 -215
- package/lib/candidates.js +454 -454
- package/lib/capabilities.js +819 -819
- package/lib/choro-command.js +111 -111
- package/lib/client.js +14420 -14322
- package/lib/commands.js +95 -95
- package/lib/config-keys.js +144 -144
- package/lib/config.js +1361 -1344
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +258 -258
- package/lib/date-hint.js +32 -0
- package/lib/diary/auto.js +190 -190
- package/lib/diary/compose.js +440 -440
- package/lib/diary/dir.js +59 -59
- package/lib/diary/export.js +97 -97
- package/lib/diary/index.js +26 -26
- package/lib/diary/library.js +189 -189
- package/lib/diary/qq-inbox.js +142 -142
- package/lib/diary/rollover.js +61 -61
- package/lib/diary/session-provider.js +122 -122
- package/lib/document-index.js +94 -94
- package/lib/document.js +322 -322
- package/lib/dream/clustering.js +123 -123
- package/lib/dream/decisions.js +945 -945
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +2027 -2027
- package/lib/dream/triage.js +139 -139
- package/lib/dream.js +1879 -1879
- package/lib/dsh-go/host.js +162 -162
- package/lib/dsh-go/panel.js +2091 -2091
- package/lib/dsh-go/text.js +342 -342
- package/lib/dsh-go/views/common.js +68 -68
- package/lib/dsh-go/views/entity.js +48 -48
- package/lib/dsh-go/views/memory.js +198 -198
- package/lib/dsh-go/views/root.js +30 -30
- package/lib/dsh-go/views/settings.js +398 -398
- package/lib/dsh-go/views/status.js +51 -51
- package/lib/embedding.js +342 -342
- package/lib/entities/extractor.js +285 -285
- package/lib/fence.js +24 -24
- package/lib/graph/anchoring.js +184 -184
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +119 -119
- package/lib/image-describe.js +158 -0
- package/lib/index.js +852 -852
- package/lib/inject.js +656 -651
- package/lib/kb/index.js +309 -309
- package/lib/kb/routes.js +219 -219
- package/lib/kb/tools.js +148 -148
- package/lib/lang.js +607 -595
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +162 -162
- package/lib/memory-source.js +9 -9
- package/lib/mirror.js +330 -330
- package/lib/organize.js +456 -456
- package/lib/peak-hours.js +172 -172
- package/lib/pipeline-activities.js +178 -178
- package/lib/pipeline-attribution.js +165 -165
- package/lib/pipeline-command.js +279 -279
- package/lib/pipeline-confirm.js +142 -142
- package/lib/pipeline-exec.js +174 -174
- package/lib/pipeline-nudge-auto.js +129 -129
- package/lib/pipeline-nudge.js +1623 -1623
- package/lib/pipeline-observations.js +181 -181
- package/lib/pipeline-observe.js +133 -133
- package/lib/pipeline-runner.js +361 -361
- package/lib/pipeline-runs.js +442 -442
- package/lib/pipeline.js +716 -716
- package/lib/placement.js +13 -13
- package/lib/pre-inject-gate.js +354 -354
- package/lib/profile-extract.js +164 -164
- package/lib/profile-facts.js +356 -356
- package/lib/provenance-http.js +251 -251
- package/lib/quality-filter.js +162 -162
- package/lib/recall-stats.js +190 -190
- package/lib/reclaim-http.js +150 -150
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -265
- package/lib/runtime/loader.js +252 -252
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +223 -223
- package/lib/safe-slice.js +18 -18
- package/lib/scope-state.js +277 -277
- package/lib/scope.js +187 -187
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +572 -572
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +228 -228
- package/lib/service.js +3268 -3268
- package/lib/session-gate.js +91 -91
- package/lib/session-usage.js +350 -350
- package/lib/settings.js +827 -817
- package/lib/share-http.js +281 -281
- package/lib/share-import.js +234 -234
- package/lib/share.js +335 -335
- package/lib/store.js +5895 -5895
- package/lib/summarize.js +1388 -1343
- package/lib/tools.js +1370 -1356
- package/lib/user-settings.js +230 -230
- package/lib/vector-index.js +180 -180
- package/lib/version-check.js +122 -122
- package/lib/wiki/claims.js +267 -267
- package/lib/wiki/cli-hint.js +90 -90
- package/lib/wiki/inject.js +164 -164
- package/lib/wiki/partition.js +200 -200
- package/lib/wiki/plan.js +214 -214
- package/lib/wiki/queries.js +640 -640
- package/lib/wiki/render.js +261 -261
- package/lib/wiki/scan.js +371 -371
- package/lib/wiki/t0.js +500 -500
- package/lib/wiki/t1-jobs.js +232 -232
- package/lib/wiki/t1.js +609 -609
- package/lib/write-admission.js +404 -404
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +1 -1
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +91 -91
- package/scripts/check-sync.js +44 -44
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/publish-gate.mjs +197 -197
- package/scripts/release-docs.mjs +210 -210
- package/scripts/release-notes.mjs +164 -164
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +69 -69
- package/scripts/test-count-sync.mjs +48 -48
- package/src/api-standalone.js +1007 -1007
- package/src/api.js +3600 -3600
- package/src/attachment-text.js +122 -122
- package/src/bootstrap-http.js +105 -105
- package/src/bootstrap.js +215 -215
- package/src/candidates.js +454 -454
- package/src/capabilities.js +819 -819
- package/src/choro-command.js +111 -111
- package/src/commands.js +95 -95
- package/src/config-keys.js +144 -144
- package/src/config.js +1361 -1344
- package/src/content-hash.js +54 -54
- package/src/continuity.js +258 -258
- package/src/date-hint.js +32 -0
- package/src/diary/auto.js +190 -190
- package/src/diary/compose.js +440 -440
- package/src/diary/dir.js +59 -59
- package/src/diary/export.js +97 -97
- package/src/diary/index.js +26 -26
- package/src/diary/library.js +189 -189
- package/src/diary/qq-inbox.js +142 -142
- package/src/diary/rollover.js +61 -61
- package/src/diary/session-provider.js +122 -122
- package/src/document-index.js +94 -94
- package/src/document.js +322 -322
- package/src/dream/clustering.js +123 -123
- package/src/dream/decisions.js +945 -945
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +2027 -2027
- package/src/dream/triage.js +139 -139
- package/src/dream.js +1879 -1879
- package/src/dsh-go/host.js +162 -162
- package/src/dsh-go/panel.js +2091 -2091
- package/src/dsh-go/text.js +342 -342
- package/src/dsh-go/views/common.js +68 -68
- package/src/dsh-go/views/entity.js +48 -48
- package/src/dsh-go/views/memory.js +198 -198
- package/src/dsh-go/views/root.js +30 -30
- package/src/dsh-go/views/settings.js +398 -398
- package/src/dsh-go/views/status.js +51 -51
- package/src/embedding.js +342 -342
- package/src/entities/extractor.js +285 -285
- package/src/fence.js +24 -24
- package/src/graph/anchoring.js +184 -184
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +119 -119
- package/src/image-describe.js +158 -0
- package/src/index.js +852 -852
- package/src/inject.js +656 -651
- package/src/kb/index.js +309 -309
- package/src/kb/routes.js +219 -219
- package/src/kb/tools.js +148 -148
- package/src/lang.js +607 -595
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +162 -162
- package/src/memory-source.js +9 -9
- package/src/mirror.js +330 -330
- package/src/organize.js +456 -456
- package/src/peak-hours.js +172 -172
- package/src/pipeline-activities.js +178 -178
- package/src/pipeline-attribution.js +165 -165
- package/src/pipeline-command.js +279 -279
- package/src/pipeline-confirm.js +142 -142
- package/src/pipeline-exec.js +174 -174
- package/src/pipeline-nudge-auto.js +129 -129
- package/src/pipeline-nudge.js +1623 -1623
- package/src/pipeline-observations.js +181 -181
- package/src/pipeline-observe.js +133 -133
- package/src/pipeline-runner.js +361 -361
- package/src/pipeline-runs.js +442 -442
- package/src/pipeline.js +716 -716
- package/src/placement.js +13 -13
- package/src/pre-inject-gate.js +354 -354
- package/src/profile-extract.js +164 -164
- package/src/profile-facts.js +356 -356
- package/src/provenance-http.js +251 -251
- package/src/quality-filter.js +162 -162
- package/src/recall-stats.js +190 -190
- package/src/reclaim-http.js +150 -150
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -265
- package/src/runtime/loader.js +252 -252
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +223 -223
- package/src/safe-slice.js +18 -18
- package/src/scope-state.js +277 -277
- package/src/scope.js +187 -187
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +572 -572
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +228 -228
- package/src/service.js +3268 -3268
- package/src/session-gate.js +91 -91
- package/src/session-usage.js +350 -350
- package/src/settings.js +827 -817
- package/src/share-http.js +281 -281
- package/src/share-import.js +234 -234
- package/src/share.js +335 -335
- package/src/store.js +5895 -5895
- package/src/summarize.js +1388 -1343
- package/src/tools.js +1370 -1356
- package/src/user-settings.js +230 -230
- package/src/vector-index.js +180 -180
- package/src/version-check.js +122 -122
- package/src/wiki/claims.js +267 -267
- package/src/wiki/cli-hint.js +90 -90
- package/src/wiki/inject.js +164 -164
- package/src/wiki/partition.js +200 -200
- package/src/wiki/plan.js +214 -214
- package/src/wiki/queries.js +640 -640
- package/src/wiki/render.js +261 -261
- package/src/wiki/scan.js +371 -371
- package/src/wiki/t0.js +500 -500
- package/src/wiki/t1-jobs.js +232 -232
- package/src/wiki/t1.js +609 -609
- package/src/write-admission.js +404 -404
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-hardening.test.js +173 -173
- package/test/api-nudge-route.test.js +259 -259
- package/test/api-routes-gaps.test.js +1092 -1092
- package/test/api.test.js +3007 -2998
- package/test/attachment-text.test.js +139 -139
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bm25-corpus-cache.test.js +145 -145
- package/test/bootstrap.test.js +228 -228
- package/test/candidates.test.js +345 -345
- package/test/capabilities.test.js +577 -577
- package/test/capacity-warning.test.js +262 -262
- package/test/changelog-prep.test.js +125 -125
- package/test/choro-command.test.js +218 -218
- package/test/cli-enums.test.js +37 -37
- package/test/cli-flag-value.test.js +86 -86
- package/test/cli-receive.test.js +390 -390
- package/test/cli-share.test.js +437 -437
- package/test/client-share.test.js +225 -225
- package/test/client.test.js +4595 -4425
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config-keys-routes.test.js +233 -233
- package/test/config.test.js +153 -153
- package/test/conflict-freeze.test.js +344 -344
- package/test/conflict-ledger-enforce.test.js +452 -452
- package/test/conflict-ledger.test.js +242 -242
- package/test/conflict-reviewed.test.js +79 -79
- package/test/content-hash.test.js +204 -204
- package/test/continuity.test.js +452 -452
- package/test/contrast.test.js +432 -432
- package/test/date-hint.test.js +156 -0
- package/test/dedupe-candidate-window.test.js +180 -180
- package/test/dependency-overrides.test.js +78 -78
- package/test/diary-library.test.js +187 -187
- package/test/diary.test.js +576 -576
- package/test/differentiate-feedback.test.js +248 -248
- package/test/distill-tx-embed-gap.test.js +110 -110
- package/test/docs-config-keys.test.js +65 -65
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +703 -703
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-baseline-clamp.test.js +174 -174
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream-decision-evidence.test.js +314 -314
- package/test/dream-failure-backoff.test.js +173 -173
- package/test/dream-fence.test.js +207 -207
- package/test/dream-maxtokens-fallback.test.js +132 -132
- package/test/dream-merge-guardrail.test.js +219 -219
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1528 -1528
- package/test/dsh-go-host.test.js +134 -134
- package/test/dsh-go-panel-actions.test.js +854 -854
- package/test/dsh-go-panel.test.js +251 -251
- package/test/embed-ready-poll.test.js +94 -94
- package/test/embed-text-reasons.test.js +112 -112
- package/test/embedding-cache-f32.test.js +276 -276
- package/test/embedding-ready.test.js +226 -226
- package/test/entities.test.js +711 -711
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/failure-query.test.js +147 -147
- package/test/failure-retention.test.js +105 -105
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/forget-corrections.test.js +70 -70
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +333 -333
- package/test/graph-boosted-relations.test.js +189 -189
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-multipath.test.js +269 -269
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- package/test/helpers/dream-mock.js +86 -86
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/helpers/write-admission-samples.js +312 -312
- package/test/hot-memory.test.js +259 -259
- package/test/image-description.test.js +271 -0
- package/test/image-memory.test.js +170 -170
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -77
- package/test/inject-parent-gate.test.js +134 -109
- package/test/inject-pin-explicit.test.js +300 -300
- package/test/inject-pin-vector-crowd.test.js +225 -225
- package/test/inject-pools.test.js +195 -195
- package/test/inject-recall.test.js +172 -172
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +553 -553
- package/test/issue-617-step1-query-and-fusion.test.js +361 -361
- package/test/kb-api-wiring.test.js +128 -128
- package/test/kb-collections.test.js +272 -272
- package/test/kb-routes.test.js +379 -379
- package/test/lang.test.js +98 -98
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +116 -116
- package/test/llm-audit.test.js +815 -815
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +501 -501
- package/test/mcp-stdio.test.js +385 -385
- package/test/mint-source.test.js +311 -311
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize-tool.test.js +232 -232
- package/test/organize.test.js +418 -418
- package/test/panel-chart-scale.test.js +90 -90
- package/test/panel-tokens.test.js +245 -245
- package/test/peak-hours-observability.test.js +70 -70
- package/test/peer-blockers.test.js +193 -193
- package/test/persona-addressing.test.js +83 -83
- package/test/persona-api.test.js +172 -172
- package/test/persona-by-session.test.js +568 -568
- package/test/persona-flag.test.js +36 -36
- package/test/persona-inject.test.js +145 -145
- package/test/persona-settings.test.js +198 -198
- package/test/pipeline-activities.test.js +155 -155
- package/test/pipeline-attribution.test.js +175 -175
- package/test/pipeline-command.test.js +243 -243
- package/test/pipeline-confirm.test.js +148 -148
- package/test/pipeline-exec.test.js +172 -172
- package/test/pipeline-nudge-auto.test.js +248 -248
- package/test/pipeline-nudge.test.js +1278 -1278
- package/test/pipeline-observations.test.js +157 -157
- package/test/pipeline-observe.test.js +127 -127
- package/test/pipeline-r1r2.test.js +278 -278
- package/test/pipeline-runner.test.js +468 -468
- package/test/pipeline-runs.test.js +767 -767
- package/test/pipeline.test.js +636 -636
- package/test/placement.test.js +36 -36
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +444 -444
- package/test/profile-extract.test.js +269 -269
- package/test/profile-facts.test.js +492 -492
- package/test/prompt-stance.test.js +128 -128
- package/test/provenance-http.test.js +367 -367
- package/test/publish-gate.test.js +272 -272
- package/test/quality-backfill.test.js +290 -290
- package/test/quality-filter.test.js +295 -295
- package/test/readme-claims.test.js +113 -113
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-retention.test.js +128 -128
- package/test/recall-stats.test.js +274 -274
- package/test/receipt-chain.test.js +451 -451
- package/test/reclaim-http.test.js +209 -209
- package/test/reflection-update-gate.test.js +129 -129
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +475 -475
- package/test/reindex-scheduler.test.js +369 -369
- package/test/release-docs.test.js +156 -156
- package/test/release-notes.test.js +265 -265
- package/test/release-workflow.test.js +93 -93
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -83
- package/test/route-policy.test.js +113 -113
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -255
- package/test/runtime-manifest.test.js +159 -159
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +319 -319
- package/test/scope-candidates.test.js +491 -491
- package/test/scope-distill-stamp.test.js +134 -134
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-optional-service.test.js +110 -110
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-state.test.js +610 -610
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -367
- package/test/search-fusion.test.js +90 -90
- package/test/security-doc.test.js +188 -188
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +251 -251
- package/test/serve-bin.test.js +121 -121
- package/test/serve.test.js +258 -258
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +410 -410
- package/test/session-gate.test.js +264 -264
- package/test/session-usage.test.js +317 -317
- package/test/settings.test.js +273 -273
- package/test/share-foreign-rows.test.js +144 -144
- package/test/share-http.test.js +144 -144
- package/test/share-import.test.js +577 -577
- package/test/share-imported-guard.test.js +311 -311
- package/test/share-imported-identity.test.js +111 -111
- package/test/share-pack.test.js +511 -511
- package/test/share-receive-http.test.js +756 -756
- package/test/share-routes.test.js +337 -337
- package/test/share-store.test.js +133 -133
- package/test/share-validate.test.js +101 -101
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +441 -441
- package/test/sleep-catchup.test.js +315 -315
- package/test/sleep-failure-backoff.test.js +156 -156
- package/test/sleep-heat.test.js +174 -174
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +1455 -1455
- package/test/standalone-api.test.js +1344 -1344
- package/test/store-vector-cache.test.js +98 -98
- package/test/store-write-path-columns.test.js +191 -191
- package/test/store.test.js +744 -744
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -127
- package/test/summarize-parse-diagnostics.test.js +75 -75
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +2251 -2251
- package/test/supersede-state-tracking.test.js +394 -394
- package/test/test-count-sync.test.js +71 -71
- package/test/tool-state-ignore.test.js +65 -65
- package/test/tools-dto-schema-matrix.test.js +398 -398
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +585 -585
- package/test/updated-at-semantics.test.js +113 -113
- package/test/user-settings-preview.test.js +127 -127
- package/test/user-settings-segments.test.js +527 -527
- package/test/vector-index.test.js +334 -334
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
- package/test/wiki-api.test.js +734 -734
- package/test/wiki-claims.test.js +194 -194
- package/test/wiki-cli-hint.test.js +118 -118
- package/test/wiki-inject.test.js +265 -265
- package/test/wiki-partition.test.js +112 -112
- package/test/wiki-plan.test.js +80 -80
- package/test/wiki-provenance.test.js +202 -202
- package/test/wiki-queries.test.js +398 -398
- package/test/wiki-scan.test.js +208 -208
- package/test/wiki-store.test.js +190 -190
- package/test/wiki-t0.test.js +456 -456
- package/test/wiki-t1-jobs.test.js +125 -125
- package/test/wiki-t1.test.js +460 -460
- package/test/wiki-tools.test.js +176 -176
- package/test/workflow-guards.test.js +155 -155
- package/test/write-admission.test.js +668 -668
package/lib/api-standalone.js
CHANGED
|
@@ -1,1007 +1,1007 @@
|
|
|
1
|
-
// Standalone HTTP API: a plain node:http server for ecosystem
|
|
2
|
-
// integrations that live outside the DSH host and cannot reach the plugin's
|
|
3
|
-
// internal webServer routes (/api/dsh-mneme/*). Mirrors the JSON semantics of
|
|
4
|
-
// those routes but with mandatory Bearer-token auth on everything except
|
|
5
|
-
// GET /health, so the store can be exposed safely on loopback.
|
|
6
|
-
//
|
|
7
|
-
// #181 (MCP stdio server, bin/dsh-mneme-mcp.mjs) rides on these routes as its
|
|
8
|
-
// data plane: the six-tool surface (memory_save/search/list/get/update/delete)
|
|
9
|
-
// is fully served here — PUT /memories/:id, the save passthrough fields
|
|
10
|
-
// (sensitivity / occurred_at / explicit scope) and the list/search archived +
|
|
11
|
-
// occurred-window filters exist for that parity, with tool-identical semantics.
|
|
12
|
-
//
|
|
13
|
-
// Security: the default bind host is 127.0.0.1. Pointing externalApiHost at a
|
|
14
|
-
// non-loopback address exposes the whole memory store to the network — that is
|
|
15
|
-
// the operator's explicit responsibility (documented in README).
|
|
16
|
-
import { createServer } from "node:http";
|
|
17
|
-
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
18
|
-
import { homedir } from "node:os";
|
|
19
|
-
import { TYPES } from "./store.js";
|
|
20
|
-
import { bootstrapFromDirectory } from "./bootstrap.js";
|
|
21
|
-
import { parseBootstrapJson, readBootstrapDir, bootstrapReply } from "./bootstrap-http.js";
|
|
22
|
-
import { PACKAGE_VERSION } from "./version-check.js";
|
|
23
|
-
import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
24
|
-
import { parseShareBody, shareReply, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
|
|
25
|
-
import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
|
|
26
|
-
|
|
27
|
-
const DEFAULT_PORT = 8790;
|
|
28
|
-
const DEFAULT_HOST = "127.0.0.1";
|
|
29
|
-
const MAX_PORT_ATTEMPTS = 20;
|
|
30
|
-
// 请求体与分页上界(防御,不是功能):与宿主内 api.js 的 MAX_REQUEST_BODY_BYTES /
|
|
31
|
-
// MAX_PAGE_LIMIT / MAX_SEARCH_TOP_K 同值同义——两张数据面的口径必须一致,否则
|
|
32
|
-
// 「从哪张脸进来」会决定一次请求能吃掉多少内存。
|
|
33
|
-
const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
|
34
|
-
const MAX_PAGE_LIMIT = 1000;
|
|
35
|
-
const MAX_SEARCH_TOP_K = 200;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* /context 降级原因枚举(#383,issue #508 评审后定稿)——**日志读数**的唯一真源。
|
|
39
|
-
* 它不进响应体:响应只多 `tier` 一个键,具体原因走 daemon stderr 那行 warn。
|
|
40
|
-
*
|
|
41
|
-
* 成例是「修法不同就分开」,于是六值互不吞并:
|
|
42
|
-
* embed-unavailable embedder 对象在,上面没有 embedSingle / embed → 修适配器
|
|
43
|
-
* embed-empty 接口返回了,但向量是空数组 / 非数组 → 修接口
|
|
44
|
-
* embed-timeout 接口挂起,被 embedTimeoutMs 护栏按缺失处理 → 修端点响应
|
|
45
|
-
* embed-error 接口抛错(HTTP 4xx/5xx、网络、解析失败……)→ 查端点
|
|
46
|
-
* embed-dimension 接口成功了,但库里没有一条维度相符的活跃向量 → 重建索引
|
|
47
|
-
* no-embedder 压根没装配 embedder(daemon `--embed off` / lightMode /
|
|
48
|
-
* 宿主未装配)→ 把嵌入装上或开起来。这是**预期态,不是故障**
|
|
49
|
-
* 最后一个是 #508 Minor 的修正:此前这条路不写 diag.reason,靠日志行的兜底表达式
|
|
50
|
-
* 临时拼一个值出来,结果那个值不在任何文档声明的枚举里,按枚举解析日志的运维程序
|
|
51
|
-
* 会整条漏掉。现在它在决策点赋值,与其余五值并列同源。
|
|
52
|
-
*
|
|
53
|
-
* 不并进 embed-unavailable 是有意的:那两个读数今天共用「语义路没通」的结果,但
|
|
54
|
-
* 主体与动作都不同——一个是适配器坏了(异常),一个是语义路没开(设计内常态,
|
|
55
|
-
* src/index.js 的 lightMode 注释与 src/serve.js 的 `embedder ?? semantic?.embedder
|
|
56
|
-
* ?? null` 都明写它)。并过去会让按 embed-unavailable 告警的面板对每个轻量模式
|
|
57
|
-
* 安装报警。用词则与宿主面 /api/dsh-mneme/semantic 的 reason 对齐(src/api.js),
|
|
58
|
-
* 同一个物理条件在两个面叫同一个名字,运维 grep 时不会分裂。
|
|
59
|
-
*
|
|
60
|
-
* **两套同名枚举的分界**:src/api.js 那套是 `no-embedder / not-configured /
|
|
61
|
-
* initializing / null`,免鉴权状态面板消费,test/api.test.js 锁着。两套同名不同源,
|
|
62
|
-
* 这里只借词、不改那边;清扫文档时不要顺手统一(会打红那条测试)。
|
|
63
|
-
*
|
|
64
|
-
* 这一个常量同时是代码写入方、两份文档(README 外部 API 段 / docs/DAEMON.md §2)与
|
|
65
|
-
* 契约测试的同一来源:新增降级路径必须在这里加值并同步那两处,否则
|
|
66
|
-
* test/standalone-api.test.js 的枚举契约测试会红。
|
|
67
|
-
*/
|
|
68
|
-
export const CONTEXT_DEGRADE_REASONS = Object.freeze([
|
|
69
|
-
"embed-unavailable",
|
|
70
|
-
"embed-empty",
|
|
71
|
-
"embed-timeout",
|
|
72
|
-
"embed-error",
|
|
73
|
-
"embed-dimension",
|
|
74
|
-
"no-embedder"
|
|
75
|
-
]);
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Listen with automatic EADDRINUSE recovery. Tries the configured port, then
|
|
79
|
-
* the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the
|
|
80
|
-
* OS assigns a free port. Multiple DSH profiles/instances sharing the default
|
|
81
|
-
* port no longer leave the standalone API permanently unavailable.
|
|
82
|
-
*
|
|
83
|
-
* strictPort opts out of that recovery (dsh-mneme-serve daemon, #363): a
|
|
84
|
-
* long-lived third-party integration pins the URL, so silently hopping ports
|
|
85
|
-
* would make clients talk to nothing (or, worse, to a future different data
|
|
86
|
-
* plane). A busy configured port is a configuration error there — fail loud.
|
|
87
|
-
*/
|
|
88
|
-
function listenWithRetry(server, startPort, host, logger, strictPort = false) {
|
|
89
|
-
return new Promise((resolve, reject) => {
|
|
90
|
-
let attempt = 0;
|
|
91
|
-
const tryListen = (port) => {
|
|
92
|
-
const onListening = () => {
|
|
93
|
-
server.off("error", onError);
|
|
94
|
-
const address = server.address();
|
|
95
|
-
resolve(address && typeof address === "object" ? address.port : port);
|
|
96
|
-
};
|
|
97
|
-
const onError = (error) => {
|
|
98
|
-
server.off("listening", onListening);
|
|
99
|
-
if (error?.code === "EADDRINUSE" && strictPort) {
|
|
100
|
-
reject(error);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (error?.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS - 1) {
|
|
104
|
-
attempt++;
|
|
105
|
-
const next = startPort + attempt;
|
|
106
|
-
logger?.warn?.(`[dsh-mneme] standalone API port ${port} in use, retrying ${next}`);
|
|
107
|
-
tryListen(next);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (error?.code === "EADDRINUSE") {
|
|
111
|
-
logger?.warn?.(`[dsh-mneme] standalone API port ${port} still in use, falling back to OS-assigned port`);
|
|
112
|
-
tryListen(0);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
reject(error);
|
|
116
|
-
};
|
|
117
|
-
server.once("listening", onListening);
|
|
118
|
-
server.once("error", onError);
|
|
119
|
-
server.listen(port, host);
|
|
120
|
-
};
|
|
121
|
-
tryListen(startPort);
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function sendJson(res, status, payload) {
|
|
126
|
-
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
127
|
-
res.end(JSON.stringify(payload));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** True when the request carries the configured token. */
|
|
131
|
-
function isAuthorized(req, apiToken) {
|
|
132
|
-
const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
|
|
133
|
-
const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
|
|
134
|
-
if (token === "" || token.length !== apiToken.length) return false;
|
|
135
|
-
// Constant-time comparison: no timing oracle on the token.
|
|
136
|
-
return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Collect the request body as text, or null when it exceeds the cap.
|
|
141
|
-
*
|
|
142
|
-
* 超限时**必须回 null 而不是空串**:空串经 parseBody 变成 {},而 /profile、
|
|
143
|
-
* /rules、/persona 的 PUT 会把「字段缺失」当成「用户要清空」——一个 5 MiB 的
|
|
144
|
-
* 请求就能把画像/规则/人格静默清掉。返回 null 让调用点在碰任何 store 之前
|
|
145
|
-
* 直接 413(2026-10 评审指出的数据损坏路径)。
|
|
146
|
-
* 连接不 destroy:调用方要能收到明确的 413,而不是被掐断。
|
|
147
|
-
*/
|
|
148
|
-
function readBody(req, res) {
|
|
149
|
-
return new Promise((resolve) => {
|
|
150
|
-
let body = "";
|
|
151
|
-
let bytes = 0;
|
|
152
|
-
let overflow = false;
|
|
153
|
-
req.on("data", (chunk) => {
|
|
154
|
-
if (overflow) return;
|
|
155
|
-
bytes += chunk.length;
|
|
156
|
-
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
157
|
-
overflow = true;
|
|
158
|
-
body = "";
|
|
159
|
-
sendJson(res, 413, { error: "payload-too-large", limit: MAX_REQUEST_BODY_BYTES });
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
body += chunk;
|
|
163
|
-
});
|
|
164
|
-
req.on("end", () => resolve(overflow ? null : body));
|
|
165
|
-
// 超限后再来 transport error 也必须保留超限状态:否则调用点会拿着空串
|
|
166
|
-
// 继续往 store 写(PUT /persona 把 "" 解析成 {},覆盖既有设置)。
|
|
167
|
-
req.on("error", () => resolve(overflow ? null : ""));
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* PUT /memories/:id 的请求体处理(模块级函数便于在调用点被 try/catch 包住:
|
|
173
|
-
* service 抛错绝不能变成未处理拒绝或悬着不回的请求)。校验与内部
|
|
174
|
-
* /api/dsh-mneme/update 同风格:字段出现就必须合法,宁 400 不静默纠正;
|
|
175
|
-
* content 改写按 human_override 入档;scope 修正归一化在 service 层做
|
|
176
|
-
* (null=放宽到全局,字符串=收窄/改标),审计 actor 记 "tool"。
|
|
177
|
-
*/
|
|
178
|
-
async function handlePutBody(res, service, logger, id, text) {
|
|
179
|
-
let body;
|
|
180
|
-
try {
|
|
181
|
-
body = JSON.parse(text || "{}");
|
|
182
|
-
} catch {
|
|
183
|
-
sendJson(res, 400, { error: "invalid-json" });
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
187
|
-
sendJson(res, 400, { error: "invalid-body" });
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
const patch = {};
|
|
191
|
-
if (body.title !== undefined) {
|
|
192
|
-
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
193
|
-
sendJson(res, 400, { error: "invalid-title" });
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
patch.title = body.title.trim();
|
|
197
|
-
}
|
|
198
|
-
if (body.content !== undefined) {
|
|
199
|
-
if (typeof body.content !== "string" || !body.content.trim()) {
|
|
200
|
-
sendJson(res, 400, { error: "invalid-content" });
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
patch.content = body.content.trim();
|
|
204
|
-
}
|
|
205
|
-
if (body.type !== undefined) {
|
|
206
|
-
if (!TYPES.has(body.type)) {
|
|
207
|
-
sendJson(res, 400, { error: "invalid-type" });
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
patch.type = body.type;
|
|
211
|
-
}
|
|
212
|
-
if (body.importance !== undefined) {
|
|
213
|
-
if (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5) {
|
|
214
|
-
sendJson(res, 400, { error: "invalid-importance" });
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
patch.importance = body.importance;
|
|
218
|
-
}
|
|
219
|
-
if (body.tags !== undefined) {
|
|
220
|
-
if (!Array.isArray(body.tags) || !body.tags.every((t) => typeof t === "string")) {
|
|
221
|
-
sendJson(res, 400, { error: "invalid-tags" });
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
patch.tags = body.tags;
|
|
225
|
-
}
|
|
226
|
-
if (body.agent_scope !== undefined && body.agent_scope !== null && typeof body.agent_scope !== "string") {
|
|
227
|
-
sendJson(res, 400, { error: "invalid-agent-scope" });
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
if (body.workspace_scope !== undefined && body.workspace_scope !== null && typeof body.workspace_scope !== "string") {
|
|
231
|
-
sendJson(res, 400, { error: "invalid-workspace-scope" });
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
if (body.agent_scope !== undefined) patch.agent_scope = body.agent_scope;
|
|
235
|
-
if (body.workspace_scope !== undefined) patch.workspace_scope = body.workspace_scope;
|
|
236
|
-
if (body.reason !== undefined && (typeof body.reason !== "string" || !body.reason.trim())) {
|
|
237
|
-
sendJson(res, 400, { error: "invalid-reason" });
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
if (Object.keys(patch).length === 0) {
|
|
241
|
-
sendJson(res, 400, { error: "no-fields" });
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
const existing = service.getById(id);
|
|
245
|
-
if (!existing) {
|
|
246
|
-
sendJson(res, 404, { error: "not-found" });
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (patch.type === "document" && existing.type !== "document") {
|
|
250
|
-
// #230 写入权分离:type 不许改入 document(铸造口唯一),干净 400。
|
|
251
|
-
sendJson(res, 400, { error: "document-requires-register" });
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
// 内容被改写时旧版本先入档(human_override),与内部面板写路径一致。
|
|
255
|
-
if (patch.content !== undefined && patch.content !== existing.content) {
|
|
256
|
-
const history = Array.isArray(existing.content_history) ? existing.content_history : [];
|
|
257
|
-
patch.content_history = [
|
|
258
|
-
{ content: existing.content ?? "", source: "human_override", updated_at: new Date().toISOString() },
|
|
259
|
-
...history
|
|
260
|
-
].slice(0, 20);
|
|
261
|
-
}
|
|
262
|
-
service.update(id, patch, {
|
|
263
|
-
actor: "tool",
|
|
264
|
-
...(typeof body.reason === "string" && body.reason.trim() ? { query: body.reason.trim() } : {})
|
|
265
|
-
});
|
|
266
|
-
sendJson(res, 200, { memory: service.toApiList([service.getById(id)])[0] });
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* /context 的查询嵌入:best-effort,embedder 接口宽容与 searchMemories 同款
|
|
271
|
-
* (embedSingle 优先,兼容 embed-only 的 OpenAI 兼容客户端,issue #10)。
|
|
272
|
-
* 调用方负责 catch——嵌入失败降级规则档,绝不因此 500。
|
|
273
|
-
*
|
|
274
|
-
* `diag`(optional,issue #383):拿不到向量时把原因写进去,供 daemon 侧日志枚举
|
|
275
|
-
* 降级原因。枚举是上面那个 CONTEXT_DEGRADE_REASONS(六值,互不吞并),本函数只负
|
|
276
|
-
* 责其中两个「没拿到向量」的值:无可用接口 → embed-unavailable,空/非数组 →
|
|
277
|
-
* embed-empty。其余由超时护栏、查询嵌入 .catch 与维度判据写入。不传则行为与既往
|
|
278
|
-
* 逐字节一致。
|
|
279
|
-
*/
|
|
280
|
-
async function embedQueryVector(embedder, q, diag) {
|
|
281
|
-
const embedSingle = typeof embedder.embedSingle === "function"
|
|
282
|
-
? embedder.embedSingle.bind(embedder)
|
|
283
|
-
: typeof embedder.embed === "function"
|
|
284
|
-
? embedder.embed.bind(embedder)
|
|
285
|
-
: null;
|
|
286
|
-
if (!embedSingle) {
|
|
287
|
-
if (diag) diag.reason = "embed-unavailable";
|
|
288
|
-
return undefined;
|
|
289
|
-
}
|
|
290
|
-
const vector = await embedSingle(q);
|
|
291
|
-
if (!Array.isArray(vector) || !vector.length) {
|
|
292
|
-
if (diag) diag.reason = "embed-empty";
|
|
293
|
-
return undefined;
|
|
294
|
-
}
|
|
295
|
-
return vector;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* /context 查询嵌入的超时护栏(CodeRabbit on #371):embedder 挂起(不抛错,
|
|
300
|
-
* 典型如第三方 ollama/openai 端点无响应)时不能让 HTTP 连接被无限占住——超时
|
|
301
|
-
* 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃,
|
|
302
|
-
* 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器
|
|
303
|
-
* (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。
|
|
304
|
-
*
|
|
305
|
-
* `diag`(optional,issue #383):超时写 `embed-timeout`,与上面几个原因同一套枚举
|
|
306
|
-
* (CONTEXT_DEGRADE_REASONS)。
|
|
307
|
-
*/
|
|
308
|
-
function withEmbedTimeout(promise, timeoutMs, diag) {
|
|
309
|
-
let timer;
|
|
310
|
-
const timeout = new Promise((resolve) => {
|
|
311
|
-
timer = setTimeout(() => {
|
|
312
|
-
if (diag) diag.reason = "embed-timeout";
|
|
313
|
-
resolve(undefined);
|
|
314
|
-
}, timeoutMs);
|
|
315
|
-
});
|
|
316
|
-
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* scope 查询参数(/context 与 /search 同口径,issue #370):两参全缺 = 返回
|
|
321
|
-
* undefined(调用方不传 scope 走各自默认,行为与既往逐字节一致);任一给出则
|
|
322
|
-
* 缺的一维按「解析不到」(null)走 isVisibleInScope 的 fail-closed——身份不明
|
|
323
|
-
* 的维度只见全局,不冒认,与宿主会话 scope 解析器同构。
|
|
324
|
-
*/
|
|
325
|
-
function scopeFromSearchParams(url) {
|
|
326
|
-
const agentScope = url.searchParams.get("agent_scope");
|
|
327
|
-
const workspaceScope = url.searchParams.get("workspace_scope");
|
|
328
|
-
if (agentScope === null && workspaceScope === null) return undefined;
|
|
329
|
-
return { agent_scope: agentScope, workspace_scope: workspaceScope };
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Create (and start) the standalone API server.
|
|
334
|
-
* Accepts { service, store, config, logger, settings, port, host, embedder }:
|
|
335
|
-
* - token: persisted settings kv "external_api" wins; auto-generated
|
|
336
|
-
* (crypto.randomBytes(24).toString("base64url")) and persisted when empty.
|
|
337
|
-
* - port: explicit arg > persisted settings > config.externalApiPort > 8790.
|
|
338
|
-
* - host: explicit arg > config.externalApiHost > "127.0.0.1".
|
|
339
|
-
* - strictPort: EADDRINUSE rejects instead of hopping ports (daemon mode;
|
|
340
|
-
* default false keeps the in-host sidecar recovery described above).
|
|
341
|
-
* - embedder: query-embedding handle for GET /context (issue #370) — same
|
|
342
|
-
* instance the caller handed to service.setEmbedder; null degrades /context
|
|
343
|
-
* to the rule + BM25 tier. Best-effort: embed failures never fail the route.
|
|
344
|
-
* - embedTimeoutMs: /context query-embed guard (default 3000) — a hung
|
|
345
|
-
* embedder must not hold the HTTP connection; timeout degrades to the rule
|
|
346
|
-
* tier. Factory option (like strictPort), not a user config key.
|
|
347
|
-
* - config: 缺省 `{}` 对 /memory/provenance 意味着**功能关着**——该读出口的门读
|
|
348
|
-
* config.distillRecords.enabled(判据见 src/provenance-http.js),而它返回的是
|
|
349
|
-
* 蒸馏窗口的逐字原文,所以「不传即关」是故意的 fail-closed 方向(reclaim-http
|
|
350
|
-
* 「没接线如实报」同族)。将来新增第三个构造点忘传 config,这个端点会安静地
|
|
351
|
-
* 回 409 而不是把原文读出去——那是想要的行为,不要修成默认开。
|
|
352
|
-
* Returns { server, port, host, token, ready }: `port` is the effective bound
|
|
353
|
-
* port (updated to the OS-assigned one after `ready` resolves when asked to
|
|
354
|
-
* bind port 0), `ready` resolves once listening and rejects if the bind fails.
|
|
355
|
-
*/
|
|
356
|
-
export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance, strictPort = false, embedder = null, embedTimeoutMs = 3000 }) {
|
|
357
|
-
const persisted = settings?.getExternalApi?.() ?? {};
|
|
358
|
-
|
|
359
|
-
let token = typeof persisted.token === "string" ? persisted.token : "";
|
|
360
|
-
if (!token) {
|
|
361
|
-
token = randomBytes(24).toString("base64url");
|
|
362
|
-
// Persist only the token; enabled/port keys stay as they are (merge).
|
|
363
|
-
try {
|
|
364
|
-
settings?.setExternalApi?.({ token });
|
|
365
|
-
} catch (error) {
|
|
366
|
-
logger?.warn?.(`[dsh-mneme] standalone API token persistence failed: ${String(error)}`);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Persisted host (set from the panel) wins over the bundle config, same
|
|
371
|
-
// precedence as port; the explicit argument still wins over both.
|
|
372
|
-
const boundHost = host
|
|
373
|
-
?? (typeof persisted.host === "string" && persisted.host ? persisted.host : undefined)
|
|
374
|
-
?? config.externalApiHost
|
|
375
|
-
?? DEFAULT_HOST;
|
|
376
|
-
const persistedPort = Number(persisted.port);
|
|
377
|
-
const boundPort = port
|
|
378
|
-
?? (Number.isInteger(persistedPort) && persistedPort > 0 ? persistedPort : undefined)
|
|
379
|
-
?? config.externalApiPort
|
|
380
|
-
?? DEFAULT_PORT;
|
|
381
|
-
|
|
382
|
-
const server = createServer((req, res) => {
|
|
383
|
-
try {
|
|
384
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
385
|
-
const pathname = url.pathname;
|
|
386
|
-
|
|
387
|
-
// Health is the single unauthenticated probe (monitor checks).
|
|
388
|
-
if (req.method === "GET" && pathname === "/health") {
|
|
389
|
-
sendJson(res, 200, { ok: true });
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Everything else requires the Bearer token.
|
|
394
|
-
if (!isAuthorized(req, token)) {
|
|
395
|
-
sendJson(res, 401, { error: "unauthorized" });
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// --- GET /status: version + store shape + uptime -----------------------
|
|
400
|
-
if (req.method === "GET" && pathname === "/status") {
|
|
401
|
-
const byType = {};
|
|
402
|
-
for (const type of TYPES) byType[type] = service.count(type);
|
|
403
|
-
let entities = 0;
|
|
404
|
-
try {
|
|
405
|
-
entities = store.db.prepare("SELECT count(*) AS c FROM entities").get().c;
|
|
406
|
-
} catch { /* entities storage unavailable → 0 */ }
|
|
407
|
-
sendJson(res, 200, {
|
|
408
|
-
version: PACKAGE_VERSION,
|
|
409
|
-
memories: { total: service.count(), byType },
|
|
410
|
-
entities,
|
|
411
|
-
uptime_s: Math.floor(process.uptime())
|
|
412
|
-
});
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// --- GET /profile: user self-description -------------------------------
|
|
417
|
-
if (req.method === "GET" && pathname === "/profile") {
|
|
418
|
-
sendJson(res, 200, { profile: settings?.getProfile?.() ?? "" });
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// --- GET /persona: agent persona(人格定义页,beta;v1.2 多卡形) --------
|
|
423
|
-
if (req.method === "GET" && pathname === "/persona") {
|
|
424
|
-
sendJson(res, 200, { persona: settings?.getPersona?.() ?? { default: {}, byAgent: {} } });
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// --- PUT /persona: write default (single-object form) or full multi-card
|
|
429
|
-
// 形状判定在 setPersona 内(settings.js 双形兼容:单对象=合并 default、
|
|
430
|
-
// 多卡=全量替换);坏 JSON 回 400(与 /memories 写路径同口径)。
|
|
431
|
-
if (req.method === "PUT" && pathname === "/persona") {
|
|
432
|
-
void readBody(req, res).then((text) => {
|
|
433
|
-
if (text === null) return;
|
|
434
|
-
let body;
|
|
435
|
-
try {
|
|
436
|
-
body = JSON.parse(text || "{}");
|
|
437
|
-
} catch {
|
|
438
|
-
sendJson(res, 400, { error: "invalid-json" });
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
const persona = body && typeof body === "object" && !Array.isArray(body) && body.persona && typeof body.persona === "object"
|
|
442
|
-
? body.persona
|
|
443
|
-
: body;
|
|
444
|
-
try {
|
|
445
|
-
settings?.setPersona?.(persona && typeof persona === "object" ? persona : {});
|
|
446
|
-
sendJson(res, 200, { persona: settings?.getPersona?.() ?? { default: {}, byAgent: {} } });
|
|
447
|
-
} catch {
|
|
448
|
-
sendJson(res, 500, { error: "internal" });
|
|
449
|
-
}
|
|
450
|
-
});
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// --- GET /rules: agent behavior rules -----------------------------------
|
|
455
|
-
if (req.method === "GET" && pathname === "/rules") {
|
|
456
|
-
sendJson(res, 200, { rules: settings?.getRules?.() ?? [] });
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// --- GET /memories: paged + filtered list (same semantics as the
|
|
461
|
-
// internal /api/dsh-mneme/list) ------------------------------------
|
|
462
|
-
if (req.method === "GET" && pathname === "/memories") {
|
|
463
|
-
const type = url.searchParams.get("type") ?? undefined;
|
|
464
|
-
// 上界是防御:limit 直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
|
|
465
|
-
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
|
|
466
|
-
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
467
|
-
const order = url.searchParams.get("order") ?? undefined;
|
|
468
|
-
const minRaw = url.searchParams.get("minImportance");
|
|
469
|
-
const minImportance = minRaw !== null && minRaw !== "" && !Number.isNaN(Number(minRaw))
|
|
470
|
-
? Number(minRaw)
|
|
471
|
-
: undefined;
|
|
472
|
-
const source = url.searchParams.get("source") || undefined;
|
|
473
|
-
// include_archived + occurred 时间窗:与 memory_list 工具同口径(工具
|
|
474
|
-
// 面六件套经 8790 全量可用,#181);缺省行为与既有调用方逐字节一致。
|
|
475
|
-
const includeArchived = url.searchParams.get("include_archived") === "true";
|
|
476
|
-
const occurredFrom = url.searchParams.get("occurred_from") ?? undefined;
|
|
477
|
-
const occurredTo = url.searchParams.get("occurred_to") ?? undefined;
|
|
478
|
-
const listFilters = { includeArchived, ...(occurredFrom ? { occurredFrom } : {}), ...(occurredTo ? { occurredTo } : {}) };
|
|
479
|
-
const items = service.toApiList(service.list({ type, limit, offset, order, minImportance, source, ...listFilters }));
|
|
480
|
-
// Total honors the same filters so pager math stays correct.
|
|
481
|
-
// #597:typeCounts 与宿主内 /list 同口径(同一份筛选、不带 type 的全库分布),
|
|
482
|
-
// 两张面只许剩鉴权与路径不同。
|
|
483
|
-
const countFilters = { minImportance, source, ...listFilters };
|
|
484
|
-
sendJson(res, 200, { items, total: service.count(type, countFilters), typeCounts: service.countByType(countFilters) });
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// --- GET /memory/provenance: 记忆级溯源读出口(#399 写、#421 读)--------
|
|
489
|
-
// 与宿主内 /api/dsh-mneme/memory/provenance 同一份实现口径,就在
|
|
490
|
-
// src/provenance-http.js 里写一份(#411 的教训:两张皮整段复制必然漂移)。
|
|
491
|
-
// 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回
|
|
492
|
-
// 空 windows——「不是蒸馏来的」是有效答案,不是错误。
|
|
493
|
-
//
|
|
494
|
-
// 两个读方向共一条路径(#524):?id=<memoryId> 走方向 1(记忆 → 窗口),
|
|
495
|
-
// ?sessionId=<sid> 走方向 2(会话 → 窗口)。互斥校验在共用模块里(都缺/都给
|
|
496
|
-
// 都是 400),本壳只把三个 query 参数原样递进去——拆分与择一的判断只准写一份。
|
|
497
|
-
//
|
|
498
|
-
// 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次
|
|
499
|
-
// open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。
|
|
500
|
-
//
|
|
501
|
-
// distillRecords.enabled 门(#509 评审整改):config 原样转交给共用模块,
|
|
502
|
-
// 判据只在那边算一次(isDistillRecordsEnabled,与写侧 src/summarize.js:988
|
|
503
|
-
// 同源,本文件不重写)。config 缺省 {} → 判据为假 → 409,错误码取共用模块导出的
|
|
504
|
-
// DISTILL_RECORDS_DISABLED:「不传即关」对这个返回逐字原文的端点是正确的失败
|
|
505
|
-
// 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见
|
|
506
|
-
// provenance-http.js 文件头。
|
|
507
|
-
if (req.method === "GET" && pathname === "/memory/provenance") {
|
|
508
|
-
const parsed = parseProvenanceSelector({
|
|
509
|
-
memoryId: url.searchParams.get("id"),
|
|
510
|
-
sessionId: url.searchParams.get("sessionId"),
|
|
511
|
-
limit: url.searchParams.get("limit")
|
|
512
|
-
});
|
|
513
|
-
if (!parsed.ok) {
|
|
514
|
-
sendJson(res, parsed.status, { error: parsed.error });
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => logger?.warn?.(m) });
|
|
518
|
-
sendJson(res, status, body);
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// --- GET/PUT/DELETE /memories/:id --------------------------------------
|
|
523
|
-
const idMatch = pathname.match(/^\/memories\/([^/]+)$/);
|
|
524
|
-
if (idMatch) {
|
|
525
|
-
let id = idMatch[1];
|
|
526
|
-
try { id = decodeURIComponent(id); } catch { /* keep raw */ }
|
|
527
|
-
if (req.method === "GET") {
|
|
528
|
-
const row = service.getById(id);
|
|
529
|
-
if (!row) {
|
|
530
|
-
sendJson(res, 404, { error: "not-found" });
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
sendJson(res, 200, service.toApiList([row])[0]);
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
if (req.method === "DELETE") {
|
|
537
|
-
// store.remove deletes silently — precheck for a distinguishable 404.
|
|
538
|
-
if (!service.getById(id)) {
|
|
539
|
-
sendJson(res, 404, { error: "not-found" });
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
service.remove(id);
|
|
543
|
-
sendJson(res, 200, { ok: true });
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
// --- PUT: field patch(memory_update 工具的外部通道,#181)。字段校验
|
|
547
|
-
// 与内部 /api/dsh-mneme/update 同风格:字段出现就必须合法,宁 400 不静默
|
|
548
|
-
// 纠正;content 改写按 human_override 入档;scope 修正归一化在 service
|
|
549
|
-
// 层做(null=放宽到全局,字符串=收窄/改标),审计 actor 记 "tool"。
|
|
550
|
-
// 整个异步回调套 try/catch:service 抛错不能变成未处理拒绝(Node 默认
|
|
551
|
-
// 策略下会终止宿主进程)或悬着不回的请求(外层同步 try 捕不到这里)。
|
|
552
|
-
if (req.method === "PUT") {
|
|
553
|
-
void readBody(req, res).then(async (text) => {
|
|
554
|
-
if (text === null) return;
|
|
555
|
-
try {
|
|
556
|
-
await handlePutBody(res, service, logger, id, text);
|
|
557
|
-
} catch (error) {
|
|
558
|
-
logger?.warn?.(`[dsh-mneme] standalone API update failed: ${String(error)}`);
|
|
559
|
-
sendJson(res, 500, { error: "internal" });
|
|
560
|
-
}
|
|
561
|
-
});
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
sendJson(res, 404, { error: "not-found" });
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// --- POST /memories: save with title-dedupe (safer than raw save) ------
|
|
569
|
-
if (req.method === "POST" && pathname === "/memories") {
|
|
570
|
-
void readBody(req, res).then((text) => {
|
|
571
|
-
if (text === null) return;
|
|
572
|
-
let body;
|
|
573
|
-
try {
|
|
574
|
-
body = JSON.parse(text || "{}");
|
|
575
|
-
} catch {
|
|
576
|
-
sendJson(res, 400, { error: "invalid-json" });
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
580
|
-
sendJson(res, 400, { error: "invalid-body" });
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
// Pre-validate what store.save would throw on, so clients get a
|
|
584
|
-
// clean 400 instead of a leaked SQLite error.
|
|
585
|
-
if (!TYPES.has(body.type)) {
|
|
586
|
-
sendJson(res, 400, { error: "invalid-type" });
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
if (body.type === "document") {
|
|
590
|
-
// #230 写入权分离:document 行铸造口唯一(registerDocument)。
|
|
591
|
-
// 这里给干净 400,不让它落进 saveWithDedupe 的守卫变 500。
|
|
592
|
-
sendJson(res, 400, { error: "document-requires-register" });
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
596
|
-
sendJson(res, 400, { error: "missing-title" });
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
if (typeof body.content !== "string") {
|
|
600
|
-
sendJson(res, 400, { error: "missing-content" });
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
if (body.tags !== undefined && !Array.isArray(body.tags)) {
|
|
604
|
-
sendJson(res, 400, { error: "tags-must-be-an-array" });
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
// memory_save 工具的其余可选字段(#181):sensitivity / occurred_at /
|
|
608
|
-
// 显式 scope。scope 仅收显式声明(standalone API 无会话上下文,自动
|
|
609
|
-
// 标注无从解析,语义与工具的显式参数一致);形状不对宁 400 不静默丢。
|
|
610
|
-
for (const key of ["sensitivity", "occurred_at", "agent_scope", "workspace_scope"]) {
|
|
611
|
-
if (body[key] !== undefined && (typeof body[key] !== "string" || !body[key].trim())) {
|
|
612
|
-
sendJson(res, 400, { error: `invalid-${key.replace(/_/g, "-")}` });
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
try {
|
|
617
|
-
const result = service.saveWithDedupe({
|
|
618
|
-
type: body.type,
|
|
619
|
-
title: body.title,
|
|
620
|
-
content: body.content,
|
|
621
|
-
importance: body.importance,
|
|
622
|
-
tags: body.tags,
|
|
623
|
-
source: body.source,
|
|
624
|
-
...(body.sensitivity !== undefined ? { sensitivity: body.sensitivity } : {}),
|
|
625
|
-
...(body.occurred_at !== undefined ? { occurred_at: body.occurred_at } : {}),
|
|
626
|
-
...(body.agent_scope !== undefined ? { agent_scope: body.agent_scope, agent_scope_source: "explicit" } : {}),
|
|
627
|
-
...(body.workspace_scope !== undefined ? { workspace_scope: body.workspace_scope, workspace_scope_source: "explicit" } : {})
|
|
628
|
-
});
|
|
629
|
-
const { action } = result;
|
|
630
|
-
// #254 写入准入:这条 HTTP 路径今天不带会话身份,evaluate 在无 sessionKey
|
|
631
|
-
// 时直接早退,所以拦不到;仍然显式分支——将来真接上会话身份时,落到
|
|
632
|
-
// `toApiList([null])` 上会变成 500,把「被拒」伪装成「服务器出错」,而
|
|
633
|
-
// 这两件事对调用方的处置完全不同(改内容 vs 重试)。
|
|
634
|
-
if (action === "denied") {
|
|
635
|
-
sendJson(res, 422, { error: "write-rejected", reason: result.reason });
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
const { memory } = result;
|
|
639
|
-
// action 随行透出(created/merged):memory_save 工具语义对齐所需,
|
|
640
|
-
// 附加键对既有消费方(CLI add 等)向后兼容。
|
|
641
|
-
sendJson(res, action === "created" ? 201 : 200, { ...service.toApiList([memory])[0], action });
|
|
642
|
-
} catch (error) {
|
|
643
|
-
logger?.warn?.(`[dsh-mneme] standalone API save failed: ${String(error)}`);
|
|
644
|
-
sendJson(res, 500, { error: "internal" });
|
|
645
|
-
}
|
|
646
|
-
});
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// --- POST /maintenance/reclaim: 无损回收(#275 第一批,手动入口)-------
|
|
651
|
-
// 默认 dry-run:只有显式 confirm:true 才真改数据。这两步都是不可逆的内容丢弃
|
|
652
|
-
// (历史 run 的输入快照置空、归档行向量置空),所以不给「带 body 就执行」这种
|
|
653
|
-
// 省事路径——确认必须建立在看得见的数字上。
|
|
654
|
-
//
|
|
655
|
-
// 口径(错误码字符串、默认值、报告形状)不在这里写:与宿主内的面板路由
|
|
656
|
-
// /api/dsh-mneme/maintenance/reclaim 共用 src/reclaim-http.js,两张皮只有鉴权
|
|
657
|
-
// 与路径不同。这条曾经是整段复制的,于是面板侧修好之后这边还停在旧形态(#411)。
|
|
658
|
-
if (req.method === "POST" && pathname === "/maintenance/reclaim") {
|
|
659
|
-
void readBody(req, res).then((text) => {
|
|
660
|
-
if (text === null) return;
|
|
661
|
-
const parsed = parseReclaimJson(text);
|
|
662
|
-
if (!parsed.ok) {
|
|
663
|
-
sendJson(res, 400, { error: parsed.error });
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
// 顺序敏感:端点整个没接线时报「窗口不合法」是误导,503 必须赢过字段校验。
|
|
667
|
-
if (!maintenance) {
|
|
668
|
-
sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
const fields = readReclaimFields(parsed.body);
|
|
672
|
-
if (!fields.ok) {
|
|
673
|
-
sendJson(res, 400, { error: fields.error });
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
const { status, body } = reclaimReply({
|
|
677
|
-
maintenance,
|
|
678
|
-
dryRun: fields.dryRun,
|
|
679
|
-
olderThanDays: fields.olderThanDays,
|
|
680
|
-
vacuum: fields.vacuum,
|
|
681
|
-
confirm: fields.confirm,
|
|
682
|
-
warn: (msg) => logger?.warn?.(msg)
|
|
683
|
-
});
|
|
684
|
-
sendJson(res, status, body);
|
|
685
|
-
});
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// --- POST /share: 分享包(spec §4)--------------------------------------
|
|
690
|
-
// 判据一条都不在这里:哪些行进包、超没超限、抛错回什么码,全在 share-http.js
|
|
691
|
-
// 那份共用实现里(#411 的口径——两张皮只许差在鉴权与路径)。本侧独有的只有
|
|
692
|
-
// 鉴权(Bearer,除 GET /health 外没有例外)与「不出 Content-Disposition」:
|
|
693
|
-
// 下面的 sendJson 连 headers 参数都没有,CLI 拿 JSON 自己落盘。
|
|
694
|
-
//
|
|
695
|
-
// GET 回 405 而不是掉到末尾的 404,与面板那侧一致。这**故意**与上面的
|
|
696
|
-
// /maintenance/reclaim(GET → 404)不同:状态码差一次就是第三处差别,而两条
|
|
697
|
-
// 路由的「两张皮」约束比这份文件里邻居的写法更硬。
|
|
698
|
-
//
|
|
699
|
-
// .catch 不是装饰:shareReply 现在自己兜得住,但这条链上 readBody 之前之后的
|
|
700
|
-
// 任何一处抛错都没人接——本文件那个同步 try/catch 在 .then 的回调里已经退栈,
|
|
701
|
-
// 漏出去就是未处理拒绝、客户端一个字节都没收到(#410)。
|
|
702
|
-
if (pathname === "/share") {
|
|
703
|
-
if (req.method !== "POST") {
|
|
704
|
-
sendJson(res, 405, { error: "method-not-allowed" });
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
void readBody(req, res).then((text) => {
|
|
708
|
-
if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
|
|
709
|
-
const parsed = parseShareBody(text);
|
|
710
|
-
if (!parsed.ok) {
|
|
711
|
-
sendJson(res, 400, { error: parsed.error });
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
const out = shareReply({
|
|
715
|
-
service,
|
|
716
|
-
packageVersion: PACKAGE_VERSION,
|
|
717
|
-
homeDir: homedir(),
|
|
718
|
-
options: parsed.options,
|
|
719
|
-
warn: (msg) => logger?.warn?.(msg)
|
|
720
|
-
});
|
|
721
|
-
sendJson(res, out.status, out.body);
|
|
722
|
-
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// --- POST /receive + POST /receive/undo: 分享包入库与整包退出(车道 C)-----
|
|
727
|
-
// 判据一条都不在这里:包体合格不合格住在 share.js 的闸门(由 service 的
|
|
728
|
-
// importSharePack 调),错误码 → 状态的映射与 dry_run 那一格住在 src/share-http.js
|
|
729
|
-
// 这份共用实现里——#411 的口径是两张皮只许差在鉴权与路径(面板那半见
|
|
730
|
-
// /api/dsh-mneme/receive)。本面独有的只有两件事:鉴权是这里唯一的 Bearer 一道门
|
|
731
|
-
// (没有 policy 名可用,除 GET /health 外人人都要带),以及 sendJson 连 headers
|
|
732
|
-
// 参数都没有,所以不出 Content-Disposition。
|
|
733
|
-
//
|
|
734
|
-
// 非 POST 一律 405,与面板逐字同款:这**故意**与上面的 /maintenance/reclaim 不同
|
|
735
|
-
// (那边掉到末尾 404)。状态码差一次就是第三处差别,而每条 receive 都还有个面板
|
|
736
|
-
// 双胞胎,一致性比照抄本文件的邻居更硬。
|
|
737
|
-
//
|
|
738
|
-
// void + .catch,不留外层 try 兜底:本文件那个 try 在 .then 的回调里早已退栈,
|
|
739
|
-
// 回调里抛出去就是没人接的 rejection、客户端一个字节都收不到(#410)。共用层
|
|
740
|
-
// 从不抛(连 warn 自己坏都被守卫吞掉),所以这里能被走到的只剩面自己写答复那一步。
|
|
741
|
-
// readBody 超限时会自己答 413 并把 text 解析成 null:那句 return 是必需的,少了
|
|
742
|
-
// 它就是在已发出的响应上再写一次。
|
|
743
|
-
if (pathname === "/receive" || pathname === "/receive/undo") {
|
|
744
|
-
const isUndo = pathname === "/receive/undo";
|
|
745
|
-
if (req.method !== "POST") {
|
|
746
|
-
sendJson(res, 405, { error: "method-not-allowed" });
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
void readBody(req, res).then((text) => {
|
|
750
|
-
if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
|
|
751
|
-
const parsed = isUndo ? parseUndoBody(text) : parseReceiveBody(text);
|
|
752
|
-
// 两个解析器都回 {ok:true,…}|{ok:false,error},所以一处判断就够。别把它写成
|
|
753
|
-
// 「有 error 就 400」:那会把本面缺路径时该落的 404 与真正的坏 body 混成一种
|
|
754
|
-
// 答复,排查时看不出是哪一层没接上。
|
|
755
|
-
if (!parsed.ok) {
|
|
756
|
-
sendJson(res, 400, { error: parsed.error });
|
|
757
|
-
return;
|
|
758
|
-
}
|
|
759
|
-
const out = isUndo
|
|
760
|
-
? undoReply({ service, packId: parsed.packId, dryRun: parsed.dryRun, warn: (msg) => logger?.warn?.(msg) })
|
|
761
|
-
: receiveReply({ service, pack: parsed.pack, dryRun: parsed.dryRun, warn: (msg) => logger?.warn?.(msg) });
|
|
762
|
-
sendJson(res, out.status, out.body);
|
|
763
|
-
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// --- POST /bootstrap: cold-start memories from repo files (#220) -------
|
|
768
|
-
// 显式收 dir(插件没有工作区根目录概念,不猜路径);确定性解析零 LLM;
|
|
769
|
-
// 幂等骑 saveWithDedupe 的 (type, title, scope) 去重 + _overwrite 刷新。
|
|
770
|
-
//
|
|
771
|
-
// 解析、校验、答复形状全走 bootstrap-http.js:#386 第 6 条给宿主内路由补了
|
|
772
|
-
// 第二张脸,按 #411 的纪律,两张皮只许剩鉴权与路径不同。这里刻意**不**再
|
|
773
|
-
// 自己 JSON.parse 一遍——真要写第二份,test/reclaim-http.test.js 那类静态锁
|
|
774
|
-
// 会转红,而漂移在两边都只表现为「某个错误码不一样了」。
|
|
775
|
-
if (req.method === "POST" && pathname === "/bootstrap") {
|
|
776
|
-
void readBody(req, res).then((text) => {
|
|
777
|
-
if (text === null) return;
|
|
778
|
-
const parsed = parseBootstrapJson(text);
|
|
779
|
-
if (!parsed.ok) {
|
|
780
|
-
sendJson(res, 400, { error: parsed.error });
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
const dirResult = readBootstrapDir(parsed.body);
|
|
784
|
-
if (!dirResult.ok) {
|
|
785
|
-
sendJson(res, 400, { error: dirResult.error });
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
bootstrapReply({
|
|
789
|
-
bootstrapper: ({ dir }) => bootstrapFromDirectory({ service, dir, logger }),
|
|
790
|
-
dir: dirResult.dir,
|
|
791
|
-
warn: (msg) => logger?.warn?.(msg)
|
|
792
|
-
}).then(({ status, body }) => sendJson(res, status, body));
|
|
793
|
-
});
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// --- GET /search: unified recall pipeline (keyword + vector + BM25) ----
|
|
798
|
-
if (req.method === "GET" && pathname === "/search") {
|
|
799
|
-
const q = url.searchParams.get("q") ?? "";
|
|
800
|
-
// 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
|
|
801
|
-
const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
|
|
802
|
-
const mode = url.searchParams.get("mode") ?? "auto";
|
|
803
|
-
const rerank = url.searchParams.get("rerank") !== "false";
|
|
804
|
-
const occurredFrom = url.searchParams.get("occurred_from") ?? null;
|
|
805
|
-
const occurredTo = url.searchParams.get("occurred_to") ?? null;
|
|
806
|
-
// scope 透传(issue #370):searchMemories 本就收 scope,此前 8790 没有
|
|
807
|
-
// 入口——第三方(daemon 桥接侧)声明身份后,A2 软加权与 strictScope 硬
|
|
808
|
-
// 过滤才在检索面同样成立;与 /context 同口径(scopeFromSearchParams)。
|
|
809
|
-
const scope = scopeFromSearchParams(url);
|
|
810
|
-
const query = q.trim();
|
|
811
|
-
if (!query) {
|
|
812
|
-
sendJson(res, 200, { items: [], mode: "keyword" });
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
// Any vector/rerank failure degrades to keyword inside searchMemories.
|
|
816
|
-
void Promise.resolve(
|
|
817
|
-
service.searchMemories(query, {
|
|
818
|
-
mode,
|
|
819
|
-
topK: limit,
|
|
820
|
-
useRerank: rerank,
|
|
821
|
-
...(scope ? { scope } : {}),
|
|
822
|
-
...(occurredFrom !== null || occurredTo !== null ? { occurredFrom, occurredTo } : {})
|
|
823
|
-
})
|
|
824
|
-
).then((rows) => {
|
|
825
|
-
const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
|
|
826
|
-
sendJson(res, 200, { items: service.toApiList(rows), mode: used });
|
|
827
|
-
}).catch(() => {
|
|
828
|
-
sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
|
|
829
|
-
});
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// --- GET /context: 注入候选一站式聚合(issue #370,#363 承诺)------------
|
|
834
|
-
// 给定话题返回「宿主此刻会注入什么」的数据等价物:injectCandidates 全语义
|
|
835
|
-
// (优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置,
|
|
836
|
-
// service.js:1494)+ 画像 + 规则。结构化条目、不含渲染文本——语言 / 时间
|
|
837
|
-
// 前缀 / 热记忆是宿主会话概念,调用方(Mneme Bridge 等网页端桥接)自行渲染。
|
|
838
|
-
// 与宿主注入的两点差异都是 HTTP 面的有意为之:① 首次调用即走语义路(宿主
|
|
839
|
-
// 渲染必须同步,查询向量只能异步 prefetch 给下一轮,inject.js Bug4;这里
|
|
840
|
-
// 没有该约束);② 不做跨轮轮换(daemon 无会话状态,调用方自管)。
|
|
841
|
-
// recall_runs 记账随 injectCandidates 内建(#217:注入是曝光型访问事件,
|
|
842
|
-
// mode='inject' 与检索命中同表分账)——宿主注入同样记,/context 自动同口径。
|
|
843
|
-
if (req.method === "GET" && pathname === "/context") {
|
|
844
|
-
const q = (url.searchParams.get("q") ?? "").trim();
|
|
845
|
-
const maxItemsRaw = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 5);
|
|
846
|
-
const maxItems = Number.isInteger(maxItemsRaw) && maxItemsRaw > 0 ? Math.min(maxItemsRaw, MAX_SEARCH_TOP_K) : 5;
|
|
847
|
-
// threshold 是 importance 阈值(injectCandidates 语义),与 /search 的
|
|
848
|
-
// 相似度 threshold 同名不同义——文档已点名,勿混。
|
|
849
|
-
const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3);
|
|
850
|
-
const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3;
|
|
851
|
-
const scope = scopeFromSearchParams(url);
|
|
852
|
-
// #383:降级原因记账用。embedQueryVector / withEmbedTimeout 只负责「拿不到
|
|
853
|
-
// 向量时返回 undefined」,原因(无可用接口 / 空向量 / 超时 / 抛错 / 维度不符 /
|
|
854
|
-
// 没装配 embedder)在这里回收,供 daemon 侧日志枚举——响应体只多 tier 一个
|
|
855
|
-
// 键,不加第二个字段。
|
|
856
|
-
const diag = {};
|
|
857
|
-
// 记账的唯一决策点:q 非空但没装配 embedder。#508 Minor 的成因正是这条路不写
|
|
858
|
-
// reason,靠日志行的兜底表达式临时拼一个值(no-embedder),而那个值不在文档
|
|
859
|
-
// 声明的枚举里;同一表达式里还躺着第二个越界字面量 embed-failed。兜底表达式的
|
|
860
|
-
// 问题不只是越界:它把「默认值」编码在两处,且 embedder 存在时那个 embed-error
|
|
861
|
-
// 分支一次都走不到(10 种降级场景实测:只要 embedder 为真,embedQueryVector /
|
|
862
|
-
// withEmbedTimeout / 下面的 .catch 三者必居其一地写好原因),却会把未来任何新增
|
|
863
|
-
// 的未记账失败一律谎报成「接口抛错」,把运维引去查 HTTP 端点。现在默认值只有
|
|
864
|
-
// 这一个来源,日志行不再兜底。
|
|
865
|
-
if (q && !embedder) diag.reason = "no-embedder";
|
|
866
|
-
void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q, diag), embedTimeoutMs, diag) : undefined)
|
|
867
|
-
.catch((err) => { diag.reason = "embed-error"; return undefined; }) // 查询嵌入失败降级规则档(与 searchMemories 同口径)
|
|
868
|
-
.then((queryVector) => {
|
|
869
|
-
const pinnedStats = {};
|
|
870
|
-
// items 本身失败(injectCandidates 抛错,典型如库被关)回 500 是正当的:
|
|
871
|
-
// 没有可返回的内容。items 一旦算好,后面的档位判定与降级日志就只是诊断,
|
|
872
|
-
// 任何一步出错都不该把已经算好的 items 丢掉——所以下面每一处 logger 调用
|
|
873
|
-
// 都各自包了 try/catch(logger 来自宿主,stdout 管道断裂时 warn 自身抛错是
|
|
874
|
-
// 真实可发生的,而 `logger?.warn?.()` 只防 logger 缺失,防不了这个)。
|
|
875
|
-
const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats });
|
|
876
|
-
// #383 档位信号:嵌入降级在调用方视角原本完全静默(items 看着正常,只是
|
|
877
|
-
// 召回质量悄悄变差),外部集成方无从感知自己长期跑在降级档上。
|
|
878
|
-
// 三态 fail-closed——判不出来报 unknown,不报 full:
|
|
879
|
-
// full 语义档,查询嵌入真的成功了且与库里的向量可比
|
|
880
|
-
// degraded 规则档:没装配 embedder / 嵌入超时 / 不可用向量 / 抛错 /
|
|
881
|
-
// 维度不符(拿到了向量但语义召回整个不可用)
|
|
882
|
-
// unknown 语义路未启用(空 q),或判不出来(维度探针没跑成 / 读不出
|
|
883
|
-
// 任何向量)——那不是降级,但也不能宣称 full
|
|
884
|
-
// 比 /search 的 mode 更精确:/search 把「空查询」和「向量失败」都报
|
|
885
|
-
// keyword,这里能分开(空 q 直接可观测)。
|
|
886
|
-
//
|
|
887
|
-
// 预算:判档不是免费的——q 非空且配了 embedder 时,它真的会起一次查询
|
|
888
|
-
// 嵌入(一次网络往返 + embedTimeoutMs 的超时窗口),因为档位要回答的
|
|
889
|
-
// 正是「这次嵌入成不成」。这里**没有**复用宿主 /health 那份现成数据源:
|
|
890
|
-
// standalone 的 /health 只回 {ok:true},而 embeddingDimensionBreakdown
|
|
891
|
-
// 是 src/api.js 里的一次全表扫(每行 JSON.parse 一个向量),按请求跑会
|
|
892
|
-
// 把语义路成本翻倍,且它根本不在 standalone 的装配里。维度探针因此走
|
|
893
|
-
// service.vectorDimensionState()——存在性判据(有没有一条活跃向量与查询
|
|
894
|
-
// 同维),成本模型见 store.js 那段注释:常态一次全 id 扫描 + 解析 1 个
|
|
895
|
-
// 向量,只有要报 degraded 时才走完全表。
|
|
896
|
-
let tier = "unknown";
|
|
897
|
-
if (q && queryVector) {
|
|
898
|
-
// #508 Major:探针判据从「读一行的维度」换成存在性。LIMIT 1 拿任意一条
|
|
899
|
-
// 代表全库,在混合维度库里两个方向都错——抽到与查询不符的那条会误报
|
|
900
|
-
// degraded(明明有可比的向量),首条解析失败又会漏报成 full。现在问的是
|
|
901
|
-
// 「有没有一条活跃向量的维度等于查询维度」,与 searchVector 的行集同源。
|
|
902
|
-
//
|
|
903
|
-
// 探针失败(库被锁 / 半开 / 旧装配没有这个方法)判不出来:tier 保持
|
|
904
|
-
// unknown,不宣称 full,也不把已经算好的 items 丢掉。sentinel 不能用
|
|
905
|
-
// null / undefined——那正是「库里没有向量」的合法读数,混起来就会把一次
|
|
906
|
-
// 真实的探针故障谎报成 full(fail-open)。隔离只在调用侧:store 与 service
|
|
907
|
-
// 透传都保持抛出口,不把异常吞成某个 state。
|
|
908
|
-
let state;
|
|
909
|
-
// 判不出来时的留痕。unknown 现在有三个生产者(空 q / 探针没跑成 / 有行却
|
|
910
|
-
// 一条都读不出来),而响应体只有 tier 一个键、分不出它们,调用方对 unknown
|
|
911
|
-
// 的策略又常常是「不告警」——不留一条 warn,一次真实的判档故障就从运维面上
|
|
912
|
-
// 消失了。这条 warn **不是** diag.reason 枚举的一员(tier 不是 degraded,
|
|
913
|
-
// 没有降级原因可记),运维程序不要按降级原因枚举解析它;消息也与
|
|
914
|
-
// 「degraded to the rule tier (<reason>)」分开,因为语义路这次是真的跑了,
|
|
915
|
-
// 只是没判成,两种故障的后续动作不同。
|
|
916
|
-
let judgeFailure;
|
|
917
|
-
try {
|
|
918
|
-
state = typeof service.vectorDimensionState === "function"
|
|
919
|
-
? service.vectorDimensionState(queryVector.length)
|
|
920
|
-
: undefined;
|
|
921
|
-
} catch (error) {
|
|
922
|
-
state = undefined;
|
|
923
|
-
judgeFailure = `dimension probe failed (${String(error)})`;
|
|
924
|
-
}
|
|
925
|
-
if (state?.state === "match" || state?.state === "none") {
|
|
926
|
-
// match:有维度相符的活跃向量,语义路通(通 ≠ 有命中,四道门仍可能让
|
|
927
|
-
// 语义档贡献为 0)。none:行集本身为空——没有东西可比,不是维度不符。
|
|
928
|
-
tier = "full";
|
|
929
|
-
} else if (state?.state === "mismatch") {
|
|
930
|
-
// 维度不符:searchVector 对不等长的行直接跳过(见 store.js 的
|
|
931
|
-
// distill-tx-embed-gap 注释),不抛错、只是命中为零——于是召回静默
|
|
932
|
-
// 退回 BM25/规则档,而嵌入侧一切正常。这是 issue #383 点名的那一种
|
|
933
|
-
// 「悄悄退化」,必须有自己的名字:修法是重建索引,不是查端点。
|
|
934
|
-
diag.reason = "embed-dimension";
|
|
935
|
-
tier = "degraded";
|
|
936
|
-
} else if (state?.state === "unreadable" || state?.state === "invalid") {
|
|
937
|
-
// 有行却一条都读不出来(比维度不符更严重:语义召回 100% 死亡),或查询
|
|
938
|
-
// 维度本身不是正整数。判不出来,留 unknown。
|
|
939
|
-
judgeFailure = `no comparable vector readable (${state.state})`;
|
|
940
|
-
}
|
|
941
|
-
// undefined(探针方法缺失 / 返回垃圾 / 异步漂移)同样留 unknown。
|
|
942
|
-
if (judgeFailure) {
|
|
943
|
-
try {
|
|
944
|
-
logger?.warn?.(`dsh-mneme: /context tier left unknown: ${judgeFailure}`);
|
|
945
|
-
} catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ }
|
|
946
|
-
}
|
|
947
|
-
} else if (q) {
|
|
948
|
-
tier = "degraded";
|
|
949
|
-
}
|
|
950
|
-
if (tier === "degraded") {
|
|
951
|
-
// reason 由唯一决策点(q && !embedder)+ 三个写入方(embedQueryVector /
|
|
952
|
-
// withEmbedTimeout / 查询嵌入 .catch)+ 维度判据保证必已赋值,所以这里不再
|
|
953
|
-
// 写第二份默认值——#508 之前的那份兜底表达式(`embedder ? "embed-failed" :
|
|
954
|
-
// "no-embedder"`)两个默认值互相矛盾、且都在文档枚举之外,其中 embed-error
|
|
955
|
-
// 分支在 embedder 存在时一次都走不到,却会把未来任何新增的未记账失败一律
|
|
956
|
-
// 谎报成「接口抛错」。现在只把枚举内的值写进日志:越界或漏记账都露出
|
|
957
|
-
// (undefined),那是看得见的缺陷信号,不是一个编出来的因。
|
|
958
|
-
const reason = CONTEXT_DEGRADE_REASONS.includes(diag.reason) ? diag.reason : undefined;
|
|
959
|
-
try {
|
|
960
|
-
logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${reason})`);
|
|
961
|
-
} catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ }
|
|
962
|
-
}
|
|
963
|
-
sendJson(res, 200, {
|
|
964
|
-
profile: (settings?.getProfile?.() ?? "").trim(),
|
|
965
|
-
rules: settings?.getRules?.() ?? [],
|
|
966
|
-
items: service.toApiList(items),
|
|
967
|
-
pinnedCount: pinnedStats.shown ?? 0,
|
|
968
|
-
tier
|
|
969
|
-
});
|
|
970
|
-
})
|
|
971
|
-
.catch(() => {
|
|
972
|
-
sendJson(res, 500, { error: "internal" });
|
|
973
|
-
});
|
|
974
|
-
return;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
sendJson(res, 404, { error: "not-found" });
|
|
978
|
-
} catch {
|
|
979
|
-
sendJson(res, 500, { error: "internal" });
|
|
980
|
-
}
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
// A permanent error listener keeps an EADDRINUSE / runtime socket error from
|
|
984
|
-
// crashing the host process; `ready` still surfaces the first bind failure.
|
|
985
|
-
server.on("error", (error) => {
|
|
986
|
-
logger?.warn?.(`[dsh-mneme] standalone API error: ${String(error)}`);
|
|
987
|
-
});
|
|
988
|
-
const ready = new Promise((resolve, reject) => {
|
|
989
|
-
// listening handled by listenWithRetry
|
|
990
|
-
listenWithRetry(server, boundPort, boundHost, logger, strictPort).then(resolve, reject);
|
|
991
|
-
});
|
|
992
|
-
// server.listen is called inside listenWithRetry
|
|
993
|
-
ready.then(() => {
|
|
994
|
-
const address = server.address();
|
|
995
|
-
if (address && typeof address === "object") {
|
|
996
|
-
logger?.info?.(`[dsh-mneme] standalone API listening on http://${address.address}:${address.port}`);
|
|
997
|
-
}
|
|
998
|
-
}).catch(() => { /* already logged by the error handler above */ });
|
|
999
|
-
|
|
1000
|
-
const api = { server, port: boundPort, host: boundHost, token, ready };
|
|
1001
|
-
// After the OS assigns the real port (bind port 0), reflect it for callers.
|
|
1002
|
-
ready.then(() => {
|
|
1003
|
-
const address = server.address();
|
|
1004
|
-
if (address && typeof address === "object") api.port = address.port;
|
|
1005
|
-
}).catch(() => { /* bind failed: port stays as configured */ });
|
|
1006
|
-
return api;
|
|
1007
|
-
}
|
|
1
|
+
// Standalone HTTP API: a plain node:http server for ecosystem
|
|
2
|
+
// integrations that live outside the DSH host and cannot reach the plugin's
|
|
3
|
+
// internal webServer routes (/api/dsh-mneme/*). Mirrors the JSON semantics of
|
|
4
|
+
// those routes but with mandatory Bearer-token auth on everything except
|
|
5
|
+
// GET /health, so the store can be exposed safely on loopback.
|
|
6
|
+
//
|
|
7
|
+
// #181 (MCP stdio server, bin/dsh-mneme-mcp.mjs) rides on these routes as its
|
|
8
|
+
// data plane: the six-tool surface (memory_save/search/list/get/update/delete)
|
|
9
|
+
// is fully served here — PUT /memories/:id, the save passthrough fields
|
|
10
|
+
// (sensitivity / occurred_at / explicit scope) and the list/search archived +
|
|
11
|
+
// occurred-window filters exist for that parity, with tool-identical semantics.
|
|
12
|
+
//
|
|
13
|
+
// Security: the default bind host is 127.0.0.1. Pointing externalApiHost at a
|
|
14
|
+
// non-loopback address exposes the whole memory store to the network — that is
|
|
15
|
+
// the operator's explicit responsibility (documented in README).
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { TYPES } from "./store.js";
|
|
20
|
+
import { bootstrapFromDirectory } from "./bootstrap.js";
|
|
21
|
+
import { parseBootstrapJson, readBootstrapDir, bootstrapReply } from "./bootstrap-http.js";
|
|
22
|
+
import { PACKAGE_VERSION } from "./version-check.js";
|
|
23
|
+
import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
24
|
+
import { parseShareBody, shareReply, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
|
|
25
|
+
import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
|
|
26
|
+
|
|
27
|
+
const DEFAULT_PORT = 8790;
|
|
28
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
29
|
+
const MAX_PORT_ATTEMPTS = 20;
|
|
30
|
+
// 请求体与分页上界(防御,不是功能):与宿主内 api.js 的 MAX_REQUEST_BODY_BYTES /
|
|
31
|
+
// MAX_PAGE_LIMIT / MAX_SEARCH_TOP_K 同值同义——两张数据面的口径必须一致,否则
|
|
32
|
+
// 「从哪张脸进来」会决定一次请求能吃掉多少内存。
|
|
33
|
+
const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
|
34
|
+
const MAX_PAGE_LIMIT = 1000;
|
|
35
|
+
const MAX_SEARCH_TOP_K = 200;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* /context 降级原因枚举(#383,issue #508 评审后定稿)——**日志读数**的唯一真源。
|
|
39
|
+
* 它不进响应体:响应只多 `tier` 一个键,具体原因走 daemon stderr 那行 warn。
|
|
40
|
+
*
|
|
41
|
+
* 成例是「修法不同就分开」,于是六值互不吞并:
|
|
42
|
+
* embed-unavailable embedder 对象在,上面没有 embedSingle / embed → 修适配器
|
|
43
|
+
* embed-empty 接口返回了,但向量是空数组 / 非数组 → 修接口
|
|
44
|
+
* embed-timeout 接口挂起,被 embedTimeoutMs 护栏按缺失处理 → 修端点响应
|
|
45
|
+
* embed-error 接口抛错(HTTP 4xx/5xx、网络、解析失败……)→ 查端点
|
|
46
|
+
* embed-dimension 接口成功了,但库里没有一条维度相符的活跃向量 → 重建索引
|
|
47
|
+
* no-embedder 压根没装配 embedder(daemon `--embed off` / lightMode /
|
|
48
|
+
* 宿主未装配)→ 把嵌入装上或开起来。这是**预期态,不是故障**
|
|
49
|
+
* 最后一个是 #508 Minor 的修正:此前这条路不写 diag.reason,靠日志行的兜底表达式
|
|
50
|
+
* 临时拼一个值出来,结果那个值不在任何文档声明的枚举里,按枚举解析日志的运维程序
|
|
51
|
+
* 会整条漏掉。现在它在决策点赋值,与其余五值并列同源。
|
|
52
|
+
*
|
|
53
|
+
* 不并进 embed-unavailable 是有意的:那两个读数今天共用「语义路没通」的结果,但
|
|
54
|
+
* 主体与动作都不同——一个是适配器坏了(异常),一个是语义路没开(设计内常态,
|
|
55
|
+
* src/index.js 的 lightMode 注释与 src/serve.js 的 `embedder ?? semantic?.embedder
|
|
56
|
+
* ?? null` 都明写它)。并过去会让按 embed-unavailable 告警的面板对每个轻量模式
|
|
57
|
+
* 安装报警。用词则与宿主面 /api/dsh-mneme/semantic 的 reason 对齐(src/api.js),
|
|
58
|
+
* 同一个物理条件在两个面叫同一个名字,运维 grep 时不会分裂。
|
|
59
|
+
*
|
|
60
|
+
* **两套同名枚举的分界**:src/api.js 那套是 `no-embedder / not-configured /
|
|
61
|
+
* initializing / null`,免鉴权状态面板消费,test/api.test.js 锁着。两套同名不同源,
|
|
62
|
+
* 这里只借词、不改那边;清扫文档时不要顺手统一(会打红那条测试)。
|
|
63
|
+
*
|
|
64
|
+
* 这一个常量同时是代码写入方、两份文档(README 外部 API 段 / docs/DAEMON.md §2)与
|
|
65
|
+
* 契约测试的同一来源:新增降级路径必须在这里加值并同步那两处,否则
|
|
66
|
+
* test/standalone-api.test.js 的枚举契约测试会红。
|
|
67
|
+
*/
|
|
68
|
+
export const CONTEXT_DEGRADE_REASONS = Object.freeze([
|
|
69
|
+
"embed-unavailable",
|
|
70
|
+
"embed-empty",
|
|
71
|
+
"embed-timeout",
|
|
72
|
+
"embed-error",
|
|
73
|
+
"embed-dimension",
|
|
74
|
+
"no-embedder"
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Listen with automatic EADDRINUSE recovery. Tries the configured port, then
|
|
79
|
+
* the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the
|
|
80
|
+
* OS assigns a free port. Multiple DSH profiles/instances sharing the default
|
|
81
|
+
* port no longer leave the standalone API permanently unavailable.
|
|
82
|
+
*
|
|
83
|
+
* strictPort opts out of that recovery (dsh-mneme-serve daemon, #363): a
|
|
84
|
+
* long-lived third-party integration pins the URL, so silently hopping ports
|
|
85
|
+
* would make clients talk to nothing (or, worse, to a future different data
|
|
86
|
+
* plane). A busy configured port is a configuration error there — fail loud.
|
|
87
|
+
*/
|
|
88
|
+
function listenWithRetry(server, startPort, host, logger, strictPort = false) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let attempt = 0;
|
|
91
|
+
const tryListen = (port) => {
|
|
92
|
+
const onListening = () => {
|
|
93
|
+
server.off("error", onError);
|
|
94
|
+
const address = server.address();
|
|
95
|
+
resolve(address && typeof address === "object" ? address.port : port);
|
|
96
|
+
};
|
|
97
|
+
const onError = (error) => {
|
|
98
|
+
server.off("listening", onListening);
|
|
99
|
+
if (error?.code === "EADDRINUSE" && strictPort) {
|
|
100
|
+
reject(error);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (error?.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS - 1) {
|
|
104
|
+
attempt++;
|
|
105
|
+
const next = startPort + attempt;
|
|
106
|
+
logger?.warn?.(`[dsh-mneme] standalone API port ${port} in use, retrying ${next}`);
|
|
107
|
+
tryListen(next);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (error?.code === "EADDRINUSE") {
|
|
111
|
+
logger?.warn?.(`[dsh-mneme] standalone API port ${port} still in use, falling back to OS-assigned port`);
|
|
112
|
+
tryListen(0);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
reject(error);
|
|
116
|
+
};
|
|
117
|
+
server.once("listening", onListening);
|
|
118
|
+
server.once("error", onError);
|
|
119
|
+
server.listen(port, host);
|
|
120
|
+
};
|
|
121
|
+
tryListen(startPort);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sendJson(res, status, payload) {
|
|
126
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
127
|
+
res.end(JSON.stringify(payload));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** True when the request carries the configured token. */
|
|
131
|
+
function isAuthorized(req, apiToken) {
|
|
132
|
+
const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
|
|
133
|
+
const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
|
|
134
|
+
if (token === "" || token.length !== apiToken.length) return false;
|
|
135
|
+
// Constant-time comparison: no timing oracle on the token.
|
|
136
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Collect the request body as text, or null when it exceeds the cap.
|
|
141
|
+
*
|
|
142
|
+
* 超限时**必须回 null 而不是空串**:空串经 parseBody 变成 {},而 /profile、
|
|
143
|
+
* /rules、/persona 的 PUT 会把「字段缺失」当成「用户要清空」——一个 5 MiB 的
|
|
144
|
+
* 请求就能把画像/规则/人格静默清掉。返回 null 让调用点在碰任何 store 之前
|
|
145
|
+
* 直接 413(2026-10 评审指出的数据损坏路径)。
|
|
146
|
+
* 连接不 destroy:调用方要能收到明确的 413,而不是被掐断。
|
|
147
|
+
*/
|
|
148
|
+
function readBody(req, res) {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
let body = "";
|
|
151
|
+
let bytes = 0;
|
|
152
|
+
let overflow = false;
|
|
153
|
+
req.on("data", (chunk) => {
|
|
154
|
+
if (overflow) return;
|
|
155
|
+
bytes += chunk.length;
|
|
156
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
157
|
+
overflow = true;
|
|
158
|
+
body = "";
|
|
159
|
+
sendJson(res, 413, { error: "payload-too-large", limit: MAX_REQUEST_BODY_BYTES });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
body += chunk;
|
|
163
|
+
});
|
|
164
|
+
req.on("end", () => resolve(overflow ? null : body));
|
|
165
|
+
// 超限后再来 transport error 也必须保留超限状态:否则调用点会拿着空串
|
|
166
|
+
// 继续往 store 写(PUT /persona 把 "" 解析成 {},覆盖既有设置)。
|
|
167
|
+
req.on("error", () => resolve(overflow ? null : ""));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* PUT /memories/:id 的请求体处理(模块级函数便于在调用点被 try/catch 包住:
|
|
173
|
+
* service 抛错绝不能变成未处理拒绝或悬着不回的请求)。校验与内部
|
|
174
|
+
* /api/dsh-mneme/update 同风格:字段出现就必须合法,宁 400 不静默纠正;
|
|
175
|
+
* content 改写按 human_override 入档;scope 修正归一化在 service 层做
|
|
176
|
+
* (null=放宽到全局,字符串=收窄/改标),审计 actor 记 "tool"。
|
|
177
|
+
*/
|
|
178
|
+
async function handlePutBody(res, service, logger, id, text) {
|
|
179
|
+
let body;
|
|
180
|
+
try {
|
|
181
|
+
body = JSON.parse(text || "{}");
|
|
182
|
+
} catch {
|
|
183
|
+
sendJson(res, 400, { error: "invalid-json" });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
187
|
+
sendJson(res, 400, { error: "invalid-body" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const patch = {};
|
|
191
|
+
if (body.title !== undefined) {
|
|
192
|
+
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
193
|
+
sendJson(res, 400, { error: "invalid-title" });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
patch.title = body.title.trim();
|
|
197
|
+
}
|
|
198
|
+
if (body.content !== undefined) {
|
|
199
|
+
if (typeof body.content !== "string" || !body.content.trim()) {
|
|
200
|
+
sendJson(res, 400, { error: "invalid-content" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
patch.content = body.content.trim();
|
|
204
|
+
}
|
|
205
|
+
if (body.type !== undefined) {
|
|
206
|
+
if (!TYPES.has(body.type)) {
|
|
207
|
+
sendJson(res, 400, { error: "invalid-type" });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
patch.type = body.type;
|
|
211
|
+
}
|
|
212
|
+
if (body.importance !== undefined) {
|
|
213
|
+
if (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5) {
|
|
214
|
+
sendJson(res, 400, { error: "invalid-importance" });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
patch.importance = body.importance;
|
|
218
|
+
}
|
|
219
|
+
if (body.tags !== undefined) {
|
|
220
|
+
if (!Array.isArray(body.tags) || !body.tags.every((t) => typeof t === "string")) {
|
|
221
|
+
sendJson(res, 400, { error: "invalid-tags" });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
patch.tags = body.tags;
|
|
225
|
+
}
|
|
226
|
+
if (body.agent_scope !== undefined && body.agent_scope !== null && typeof body.agent_scope !== "string") {
|
|
227
|
+
sendJson(res, 400, { error: "invalid-agent-scope" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (body.workspace_scope !== undefined && body.workspace_scope !== null && typeof body.workspace_scope !== "string") {
|
|
231
|
+
sendJson(res, 400, { error: "invalid-workspace-scope" });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (body.agent_scope !== undefined) patch.agent_scope = body.agent_scope;
|
|
235
|
+
if (body.workspace_scope !== undefined) patch.workspace_scope = body.workspace_scope;
|
|
236
|
+
if (body.reason !== undefined && (typeof body.reason !== "string" || !body.reason.trim())) {
|
|
237
|
+
sendJson(res, 400, { error: "invalid-reason" });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (Object.keys(patch).length === 0) {
|
|
241
|
+
sendJson(res, 400, { error: "no-fields" });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const existing = service.getById(id);
|
|
245
|
+
if (!existing) {
|
|
246
|
+
sendJson(res, 404, { error: "not-found" });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (patch.type === "document" && existing.type !== "document") {
|
|
250
|
+
// #230 写入权分离:type 不许改入 document(铸造口唯一),干净 400。
|
|
251
|
+
sendJson(res, 400, { error: "document-requires-register" });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// 内容被改写时旧版本先入档(human_override),与内部面板写路径一致。
|
|
255
|
+
if (patch.content !== undefined && patch.content !== existing.content) {
|
|
256
|
+
const history = Array.isArray(existing.content_history) ? existing.content_history : [];
|
|
257
|
+
patch.content_history = [
|
|
258
|
+
{ content: existing.content ?? "", source: "human_override", updated_at: new Date().toISOString() },
|
|
259
|
+
...history
|
|
260
|
+
].slice(0, 20);
|
|
261
|
+
}
|
|
262
|
+
service.update(id, patch, {
|
|
263
|
+
actor: "tool",
|
|
264
|
+
...(typeof body.reason === "string" && body.reason.trim() ? { query: body.reason.trim() } : {})
|
|
265
|
+
});
|
|
266
|
+
sendJson(res, 200, { memory: service.toApiList([service.getById(id)])[0] });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* /context 的查询嵌入:best-effort,embedder 接口宽容与 searchMemories 同款
|
|
271
|
+
* (embedSingle 优先,兼容 embed-only 的 OpenAI 兼容客户端,issue #10)。
|
|
272
|
+
* 调用方负责 catch——嵌入失败降级规则档,绝不因此 500。
|
|
273
|
+
*
|
|
274
|
+
* `diag`(optional,issue #383):拿不到向量时把原因写进去,供 daemon 侧日志枚举
|
|
275
|
+
* 降级原因。枚举是上面那个 CONTEXT_DEGRADE_REASONS(六值,互不吞并),本函数只负
|
|
276
|
+
* 责其中两个「没拿到向量」的值:无可用接口 → embed-unavailable,空/非数组 →
|
|
277
|
+
* embed-empty。其余由超时护栏、查询嵌入 .catch 与维度判据写入。不传则行为与既往
|
|
278
|
+
* 逐字节一致。
|
|
279
|
+
*/
|
|
280
|
+
async function embedQueryVector(embedder, q, diag) {
|
|
281
|
+
const embedSingle = typeof embedder.embedSingle === "function"
|
|
282
|
+
? embedder.embedSingle.bind(embedder)
|
|
283
|
+
: typeof embedder.embed === "function"
|
|
284
|
+
? embedder.embed.bind(embedder)
|
|
285
|
+
: null;
|
|
286
|
+
if (!embedSingle) {
|
|
287
|
+
if (diag) diag.reason = "embed-unavailable";
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
const vector = await embedSingle(q);
|
|
291
|
+
if (!Array.isArray(vector) || !vector.length) {
|
|
292
|
+
if (diag) diag.reason = "embed-empty";
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
return vector;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* /context 查询嵌入的超时护栏(CodeRabbit on #371):embedder 挂起(不抛错,
|
|
300
|
+
* 典型如第三方 ollama/openai 端点无响应)时不能让 HTTP 连接被无限占住——超时
|
|
301
|
+
* 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃,
|
|
302
|
+
* 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器
|
|
303
|
+
* (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。
|
|
304
|
+
*
|
|
305
|
+
* `diag`(optional,issue #383):超时写 `embed-timeout`,与上面几个原因同一套枚举
|
|
306
|
+
* (CONTEXT_DEGRADE_REASONS)。
|
|
307
|
+
*/
|
|
308
|
+
function withEmbedTimeout(promise, timeoutMs, diag) {
|
|
309
|
+
let timer;
|
|
310
|
+
const timeout = new Promise((resolve) => {
|
|
311
|
+
timer = setTimeout(() => {
|
|
312
|
+
if (diag) diag.reason = "embed-timeout";
|
|
313
|
+
resolve(undefined);
|
|
314
|
+
}, timeoutMs);
|
|
315
|
+
});
|
|
316
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* scope 查询参数(/context 与 /search 同口径,issue #370):两参全缺 = 返回
|
|
321
|
+
* undefined(调用方不传 scope 走各自默认,行为与既往逐字节一致);任一给出则
|
|
322
|
+
* 缺的一维按「解析不到」(null)走 isVisibleInScope 的 fail-closed——身份不明
|
|
323
|
+
* 的维度只见全局,不冒认,与宿主会话 scope 解析器同构。
|
|
324
|
+
*/
|
|
325
|
+
function scopeFromSearchParams(url) {
|
|
326
|
+
const agentScope = url.searchParams.get("agent_scope");
|
|
327
|
+
const workspaceScope = url.searchParams.get("workspace_scope");
|
|
328
|
+
if (agentScope === null && workspaceScope === null) return undefined;
|
|
329
|
+
return { agent_scope: agentScope, workspace_scope: workspaceScope };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create (and start) the standalone API server.
|
|
334
|
+
* Accepts { service, store, config, logger, settings, port, host, embedder }:
|
|
335
|
+
* - token: persisted settings kv "external_api" wins; auto-generated
|
|
336
|
+
* (crypto.randomBytes(24).toString("base64url")) and persisted when empty.
|
|
337
|
+
* - port: explicit arg > persisted settings > config.externalApiPort > 8790.
|
|
338
|
+
* - host: explicit arg > config.externalApiHost > "127.0.0.1".
|
|
339
|
+
* - strictPort: EADDRINUSE rejects instead of hopping ports (daemon mode;
|
|
340
|
+
* default false keeps the in-host sidecar recovery described above).
|
|
341
|
+
* - embedder: query-embedding handle for GET /context (issue #370) — same
|
|
342
|
+
* instance the caller handed to service.setEmbedder; null degrades /context
|
|
343
|
+
* to the rule + BM25 tier. Best-effort: embed failures never fail the route.
|
|
344
|
+
* - embedTimeoutMs: /context query-embed guard (default 3000) — a hung
|
|
345
|
+
* embedder must not hold the HTTP connection; timeout degrades to the rule
|
|
346
|
+
* tier. Factory option (like strictPort), not a user config key.
|
|
347
|
+
* - config: 缺省 `{}` 对 /memory/provenance 意味着**功能关着**——该读出口的门读
|
|
348
|
+
* config.distillRecords.enabled(判据见 src/provenance-http.js),而它返回的是
|
|
349
|
+
* 蒸馏窗口的逐字原文,所以「不传即关」是故意的 fail-closed 方向(reclaim-http
|
|
350
|
+
* 「没接线如实报」同族)。将来新增第三个构造点忘传 config,这个端点会安静地
|
|
351
|
+
* 回 409 而不是把原文读出去——那是想要的行为,不要修成默认开。
|
|
352
|
+
* Returns { server, port, host, token, ready }: `port` is the effective bound
|
|
353
|
+
* port (updated to the OS-assigned one after `ready` resolves when asked to
|
|
354
|
+
* bind port 0), `ready` resolves once listening and rejects if the bind fails.
|
|
355
|
+
*/
|
|
356
|
+
export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance, strictPort = false, embedder = null, embedTimeoutMs = 3000 }) {
|
|
357
|
+
const persisted = settings?.getExternalApi?.() ?? {};
|
|
358
|
+
|
|
359
|
+
let token = typeof persisted.token === "string" ? persisted.token : "";
|
|
360
|
+
if (!token) {
|
|
361
|
+
token = randomBytes(24).toString("base64url");
|
|
362
|
+
// Persist only the token; enabled/port keys stay as they are (merge).
|
|
363
|
+
try {
|
|
364
|
+
settings?.setExternalApi?.({ token });
|
|
365
|
+
} catch (error) {
|
|
366
|
+
logger?.warn?.(`[dsh-mneme] standalone API token persistence failed: ${String(error)}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Persisted host (set from the panel) wins over the bundle config, same
|
|
371
|
+
// precedence as port; the explicit argument still wins over both.
|
|
372
|
+
const boundHost = host
|
|
373
|
+
?? (typeof persisted.host === "string" && persisted.host ? persisted.host : undefined)
|
|
374
|
+
?? config.externalApiHost
|
|
375
|
+
?? DEFAULT_HOST;
|
|
376
|
+
const persistedPort = Number(persisted.port);
|
|
377
|
+
const boundPort = port
|
|
378
|
+
?? (Number.isInteger(persistedPort) && persistedPort > 0 ? persistedPort : undefined)
|
|
379
|
+
?? config.externalApiPort
|
|
380
|
+
?? DEFAULT_PORT;
|
|
381
|
+
|
|
382
|
+
const server = createServer((req, res) => {
|
|
383
|
+
try {
|
|
384
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
385
|
+
const pathname = url.pathname;
|
|
386
|
+
|
|
387
|
+
// Health is the single unauthenticated probe (monitor checks).
|
|
388
|
+
if (req.method === "GET" && pathname === "/health") {
|
|
389
|
+
sendJson(res, 200, { ok: true });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Everything else requires the Bearer token.
|
|
394
|
+
if (!isAuthorized(req, token)) {
|
|
395
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- GET /status: version + store shape + uptime -----------------------
|
|
400
|
+
if (req.method === "GET" && pathname === "/status") {
|
|
401
|
+
const byType = {};
|
|
402
|
+
for (const type of TYPES) byType[type] = service.count(type);
|
|
403
|
+
let entities = 0;
|
|
404
|
+
try {
|
|
405
|
+
entities = store.db.prepare("SELECT count(*) AS c FROM entities").get().c;
|
|
406
|
+
} catch { /* entities storage unavailable → 0 */ }
|
|
407
|
+
sendJson(res, 200, {
|
|
408
|
+
version: PACKAGE_VERSION,
|
|
409
|
+
memories: { total: service.count(), byType },
|
|
410
|
+
entities,
|
|
411
|
+
uptime_s: Math.floor(process.uptime())
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// --- GET /profile: user self-description -------------------------------
|
|
417
|
+
if (req.method === "GET" && pathname === "/profile") {
|
|
418
|
+
sendJson(res, 200, { profile: settings?.getProfile?.() ?? "" });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- GET /persona: agent persona(人格定义页,beta;v1.2 多卡形) --------
|
|
423
|
+
if (req.method === "GET" && pathname === "/persona") {
|
|
424
|
+
sendJson(res, 200, { persona: settings?.getPersona?.() ?? { default: {}, byAgent: {} } });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// --- PUT /persona: write default (single-object form) or full multi-card
|
|
429
|
+
// 形状判定在 setPersona 内(settings.js 双形兼容:单对象=合并 default、
|
|
430
|
+
// 多卡=全量替换);坏 JSON 回 400(与 /memories 写路径同口径)。
|
|
431
|
+
if (req.method === "PUT" && pathname === "/persona") {
|
|
432
|
+
void readBody(req, res).then((text) => {
|
|
433
|
+
if (text === null) return;
|
|
434
|
+
let body;
|
|
435
|
+
try {
|
|
436
|
+
body = JSON.parse(text || "{}");
|
|
437
|
+
} catch {
|
|
438
|
+
sendJson(res, 400, { error: "invalid-json" });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const persona = body && typeof body === "object" && !Array.isArray(body) && body.persona && typeof body.persona === "object"
|
|
442
|
+
? body.persona
|
|
443
|
+
: body;
|
|
444
|
+
try {
|
|
445
|
+
settings?.setPersona?.(persona && typeof persona === "object" ? persona : {});
|
|
446
|
+
sendJson(res, 200, { persona: settings?.getPersona?.() ?? { default: {}, byAgent: {} } });
|
|
447
|
+
} catch {
|
|
448
|
+
sendJson(res, 500, { error: "internal" });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- GET /rules: agent behavior rules -----------------------------------
|
|
455
|
+
if (req.method === "GET" && pathname === "/rules") {
|
|
456
|
+
sendJson(res, 200, { rules: settings?.getRules?.() ?? [] });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// --- GET /memories: paged + filtered list (same semantics as the
|
|
461
|
+
// internal /api/dsh-mneme/list) ------------------------------------
|
|
462
|
+
if (req.method === "GET" && pathname === "/memories") {
|
|
463
|
+
const type = url.searchParams.get("type") ?? undefined;
|
|
464
|
+
// 上界是防御:limit 直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
|
|
465
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
|
|
466
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
467
|
+
const order = url.searchParams.get("order") ?? undefined;
|
|
468
|
+
const minRaw = url.searchParams.get("minImportance");
|
|
469
|
+
const minImportance = minRaw !== null && minRaw !== "" && !Number.isNaN(Number(minRaw))
|
|
470
|
+
? Number(minRaw)
|
|
471
|
+
: undefined;
|
|
472
|
+
const source = url.searchParams.get("source") || undefined;
|
|
473
|
+
// include_archived + occurred 时间窗:与 memory_list 工具同口径(工具
|
|
474
|
+
// 面六件套经 8790 全量可用,#181);缺省行为与既有调用方逐字节一致。
|
|
475
|
+
const includeArchived = url.searchParams.get("include_archived") === "true";
|
|
476
|
+
const occurredFrom = url.searchParams.get("occurred_from") ?? undefined;
|
|
477
|
+
const occurredTo = url.searchParams.get("occurred_to") ?? undefined;
|
|
478
|
+
const listFilters = { includeArchived, ...(occurredFrom ? { occurredFrom } : {}), ...(occurredTo ? { occurredTo } : {}) };
|
|
479
|
+
const items = service.toApiList(service.list({ type, limit, offset, order, minImportance, source, ...listFilters }));
|
|
480
|
+
// Total honors the same filters so pager math stays correct.
|
|
481
|
+
// #597:typeCounts 与宿主内 /list 同口径(同一份筛选、不带 type 的全库分布),
|
|
482
|
+
// 两张面只许剩鉴权与路径不同。
|
|
483
|
+
const countFilters = { minImportance, source, ...listFilters };
|
|
484
|
+
sendJson(res, 200, { items, total: service.count(type, countFilters), typeCounts: service.countByType(countFilters) });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// --- GET /memory/provenance: 记忆级溯源读出口(#399 写、#421 读)--------
|
|
489
|
+
// 与宿主内 /api/dsh-mneme/memory/provenance 同一份实现口径,就在
|
|
490
|
+
// src/provenance-http.js 里写一份(#411 的教训:两张皮整段复制必然漂移)。
|
|
491
|
+
// 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回
|
|
492
|
+
// 空 windows——「不是蒸馏来的」是有效答案,不是错误。
|
|
493
|
+
//
|
|
494
|
+
// 两个读方向共一条路径(#524):?id=<memoryId> 走方向 1(记忆 → 窗口),
|
|
495
|
+
// ?sessionId=<sid> 走方向 2(会话 → 窗口)。互斥校验在共用模块里(都缺/都给
|
|
496
|
+
// 都是 400),本壳只把三个 query 参数原样递进去——拆分与择一的判断只准写一份。
|
|
497
|
+
//
|
|
498
|
+
// 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次
|
|
499
|
+
// open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。
|
|
500
|
+
//
|
|
501
|
+
// distillRecords.enabled 门(#509 评审整改):config 原样转交给共用模块,
|
|
502
|
+
// 判据只在那边算一次(isDistillRecordsEnabled,与写侧 src/summarize.js:988
|
|
503
|
+
// 同源,本文件不重写)。config 缺省 {} → 判据为假 → 409,错误码取共用模块导出的
|
|
504
|
+
// DISTILL_RECORDS_DISABLED:「不传即关」对这个返回逐字原文的端点是正确的失败
|
|
505
|
+
// 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见
|
|
506
|
+
// provenance-http.js 文件头。
|
|
507
|
+
if (req.method === "GET" && pathname === "/memory/provenance") {
|
|
508
|
+
const parsed = parseProvenanceSelector({
|
|
509
|
+
memoryId: url.searchParams.get("id"),
|
|
510
|
+
sessionId: url.searchParams.get("sessionId"),
|
|
511
|
+
limit: url.searchParams.get("limit")
|
|
512
|
+
});
|
|
513
|
+
if (!parsed.ok) {
|
|
514
|
+
sendJson(res, parsed.status, { error: parsed.error });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => logger?.warn?.(m) });
|
|
518
|
+
sendJson(res, status, body);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// --- GET/PUT/DELETE /memories/:id --------------------------------------
|
|
523
|
+
const idMatch = pathname.match(/^\/memories\/([^/]+)$/);
|
|
524
|
+
if (idMatch) {
|
|
525
|
+
let id = idMatch[1];
|
|
526
|
+
try { id = decodeURIComponent(id); } catch { /* keep raw */ }
|
|
527
|
+
if (req.method === "GET") {
|
|
528
|
+
const row = service.getById(id);
|
|
529
|
+
if (!row) {
|
|
530
|
+
sendJson(res, 404, { error: "not-found" });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
sendJson(res, 200, service.toApiList([row])[0]);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (req.method === "DELETE") {
|
|
537
|
+
// store.remove deletes silently — precheck for a distinguishable 404.
|
|
538
|
+
if (!service.getById(id)) {
|
|
539
|
+
sendJson(res, 404, { error: "not-found" });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
service.remove(id);
|
|
543
|
+
sendJson(res, 200, { ok: true });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// --- PUT: field patch(memory_update 工具的外部通道,#181)。字段校验
|
|
547
|
+
// 与内部 /api/dsh-mneme/update 同风格:字段出现就必须合法,宁 400 不静默
|
|
548
|
+
// 纠正;content 改写按 human_override 入档;scope 修正归一化在 service
|
|
549
|
+
// 层做(null=放宽到全局,字符串=收窄/改标),审计 actor 记 "tool"。
|
|
550
|
+
// 整个异步回调套 try/catch:service 抛错不能变成未处理拒绝(Node 默认
|
|
551
|
+
// 策略下会终止宿主进程)或悬着不回的请求(外层同步 try 捕不到这里)。
|
|
552
|
+
if (req.method === "PUT") {
|
|
553
|
+
void readBody(req, res).then(async (text) => {
|
|
554
|
+
if (text === null) return;
|
|
555
|
+
try {
|
|
556
|
+
await handlePutBody(res, service, logger, id, text);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
logger?.warn?.(`[dsh-mneme] standalone API update failed: ${String(error)}`);
|
|
559
|
+
sendJson(res, 500, { error: "internal" });
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
sendJson(res, 404, { error: "not-found" });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// --- POST /memories: save with title-dedupe (safer than raw save) ------
|
|
569
|
+
if (req.method === "POST" && pathname === "/memories") {
|
|
570
|
+
void readBody(req, res).then((text) => {
|
|
571
|
+
if (text === null) return;
|
|
572
|
+
let body;
|
|
573
|
+
try {
|
|
574
|
+
body = JSON.parse(text || "{}");
|
|
575
|
+
} catch {
|
|
576
|
+
sendJson(res, 400, { error: "invalid-json" });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
580
|
+
sendJson(res, 400, { error: "invalid-body" });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// Pre-validate what store.save would throw on, so clients get a
|
|
584
|
+
// clean 400 instead of a leaked SQLite error.
|
|
585
|
+
if (!TYPES.has(body.type)) {
|
|
586
|
+
sendJson(res, 400, { error: "invalid-type" });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (body.type === "document") {
|
|
590
|
+
// #230 写入权分离:document 行铸造口唯一(registerDocument)。
|
|
591
|
+
// 这里给干净 400,不让它落进 saveWithDedupe 的守卫变 500。
|
|
592
|
+
sendJson(res, 400, { error: "document-requires-register" });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
596
|
+
sendJson(res, 400, { error: "missing-title" });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (typeof body.content !== "string") {
|
|
600
|
+
sendJson(res, 400, { error: "missing-content" });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (body.tags !== undefined && !Array.isArray(body.tags)) {
|
|
604
|
+
sendJson(res, 400, { error: "tags-must-be-an-array" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// memory_save 工具的其余可选字段(#181):sensitivity / occurred_at /
|
|
608
|
+
// 显式 scope。scope 仅收显式声明(standalone API 无会话上下文,自动
|
|
609
|
+
// 标注无从解析,语义与工具的显式参数一致);形状不对宁 400 不静默丢。
|
|
610
|
+
for (const key of ["sensitivity", "occurred_at", "agent_scope", "workspace_scope"]) {
|
|
611
|
+
if (body[key] !== undefined && (typeof body[key] !== "string" || !body[key].trim())) {
|
|
612
|
+
sendJson(res, 400, { error: `invalid-${key.replace(/_/g, "-")}` });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const result = service.saveWithDedupe({
|
|
618
|
+
type: body.type,
|
|
619
|
+
title: body.title,
|
|
620
|
+
content: body.content,
|
|
621
|
+
importance: body.importance,
|
|
622
|
+
tags: body.tags,
|
|
623
|
+
source: body.source,
|
|
624
|
+
...(body.sensitivity !== undefined ? { sensitivity: body.sensitivity } : {}),
|
|
625
|
+
...(body.occurred_at !== undefined ? { occurred_at: body.occurred_at } : {}),
|
|
626
|
+
...(body.agent_scope !== undefined ? { agent_scope: body.agent_scope, agent_scope_source: "explicit" } : {}),
|
|
627
|
+
...(body.workspace_scope !== undefined ? { workspace_scope: body.workspace_scope, workspace_scope_source: "explicit" } : {})
|
|
628
|
+
});
|
|
629
|
+
const { action } = result;
|
|
630
|
+
// #254 写入准入:这条 HTTP 路径今天不带会话身份,evaluate 在无 sessionKey
|
|
631
|
+
// 时直接早退,所以拦不到;仍然显式分支——将来真接上会话身份时,落到
|
|
632
|
+
// `toApiList([null])` 上会变成 500,把「被拒」伪装成「服务器出错」,而
|
|
633
|
+
// 这两件事对调用方的处置完全不同(改内容 vs 重试)。
|
|
634
|
+
if (action === "denied") {
|
|
635
|
+
sendJson(res, 422, { error: "write-rejected", reason: result.reason });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const { memory } = result;
|
|
639
|
+
// action 随行透出(created/merged):memory_save 工具语义对齐所需,
|
|
640
|
+
// 附加键对既有消费方(CLI add 等)向后兼容。
|
|
641
|
+
sendJson(res, action === "created" ? 201 : 200, { ...service.toApiList([memory])[0], action });
|
|
642
|
+
} catch (error) {
|
|
643
|
+
logger?.warn?.(`[dsh-mneme] standalone API save failed: ${String(error)}`);
|
|
644
|
+
sendJson(res, 500, { error: "internal" });
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// --- POST /maintenance/reclaim: 无损回收(#275 第一批,手动入口)-------
|
|
651
|
+
// 默认 dry-run:只有显式 confirm:true 才真改数据。这两步都是不可逆的内容丢弃
|
|
652
|
+
// (历史 run 的输入快照置空、归档行向量置空),所以不给「带 body 就执行」这种
|
|
653
|
+
// 省事路径——确认必须建立在看得见的数字上。
|
|
654
|
+
//
|
|
655
|
+
// 口径(错误码字符串、默认值、报告形状)不在这里写:与宿主内的面板路由
|
|
656
|
+
// /api/dsh-mneme/maintenance/reclaim 共用 src/reclaim-http.js,两张皮只有鉴权
|
|
657
|
+
// 与路径不同。这条曾经是整段复制的,于是面板侧修好之后这边还停在旧形态(#411)。
|
|
658
|
+
if (req.method === "POST" && pathname === "/maintenance/reclaim") {
|
|
659
|
+
void readBody(req, res).then((text) => {
|
|
660
|
+
if (text === null) return;
|
|
661
|
+
const parsed = parseReclaimJson(text);
|
|
662
|
+
if (!parsed.ok) {
|
|
663
|
+
sendJson(res, 400, { error: parsed.error });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
// 顺序敏感:端点整个没接线时报「窗口不合法」是误导,503 必须赢过字段校验。
|
|
667
|
+
if (!maintenance) {
|
|
668
|
+
sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const fields = readReclaimFields(parsed.body);
|
|
672
|
+
if (!fields.ok) {
|
|
673
|
+
sendJson(res, 400, { error: fields.error });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const { status, body } = reclaimReply({
|
|
677
|
+
maintenance,
|
|
678
|
+
dryRun: fields.dryRun,
|
|
679
|
+
olderThanDays: fields.olderThanDays,
|
|
680
|
+
vacuum: fields.vacuum,
|
|
681
|
+
confirm: fields.confirm,
|
|
682
|
+
warn: (msg) => logger?.warn?.(msg)
|
|
683
|
+
});
|
|
684
|
+
sendJson(res, status, body);
|
|
685
|
+
});
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// --- POST /share: 分享包(spec §4)--------------------------------------
|
|
690
|
+
// 判据一条都不在这里:哪些行进包、超没超限、抛错回什么码,全在 share-http.js
|
|
691
|
+
// 那份共用实现里(#411 的口径——两张皮只许差在鉴权与路径)。本侧独有的只有
|
|
692
|
+
// 鉴权(Bearer,除 GET /health 外没有例外)与「不出 Content-Disposition」:
|
|
693
|
+
// 下面的 sendJson 连 headers 参数都没有,CLI 拿 JSON 自己落盘。
|
|
694
|
+
//
|
|
695
|
+
// GET 回 405 而不是掉到末尾的 404,与面板那侧一致。这**故意**与上面的
|
|
696
|
+
// /maintenance/reclaim(GET → 404)不同:状态码差一次就是第三处差别,而两条
|
|
697
|
+
// 路由的「两张皮」约束比这份文件里邻居的写法更硬。
|
|
698
|
+
//
|
|
699
|
+
// .catch 不是装饰:shareReply 现在自己兜得住,但这条链上 readBody 之前之后的
|
|
700
|
+
// 任何一处抛错都没人接——本文件那个同步 try/catch 在 .then 的回调里已经退栈,
|
|
701
|
+
// 漏出去就是未处理拒绝、客户端一个字节都没收到(#410)。
|
|
702
|
+
if (pathname === "/share") {
|
|
703
|
+
if (req.method !== "POST") {
|
|
704
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
void readBody(req, res).then((text) => {
|
|
708
|
+
if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
|
|
709
|
+
const parsed = parseShareBody(text);
|
|
710
|
+
if (!parsed.ok) {
|
|
711
|
+
sendJson(res, 400, { error: parsed.error });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const out = shareReply({
|
|
715
|
+
service,
|
|
716
|
+
packageVersion: PACKAGE_VERSION,
|
|
717
|
+
homeDir: homedir(),
|
|
718
|
+
options: parsed.options,
|
|
719
|
+
warn: (msg) => logger?.warn?.(msg)
|
|
720
|
+
});
|
|
721
|
+
sendJson(res, out.status, out.body);
|
|
722
|
+
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// --- POST /receive + POST /receive/undo: 分享包入库与整包退出(车道 C)-----
|
|
727
|
+
// 判据一条都不在这里:包体合格不合格住在 share.js 的闸门(由 service 的
|
|
728
|
+
// importSharePack 调),错误码 → 状态的映射与 dry_run 那一格住在 src/share-http.js
|
|
729
|
+
// 这份共用实现里——#411 的口径是两张皮只许差在鉴权与路径(面板那半见
|
|
730
|
+
// /api/dsh-mneme/receive)。本面独有的只有两件事:鉴权是这里唯一的 Bearer 一道门
|
|
731
|
+
// (没有 policy 名可用,除 GET /health 外人人都要带),以及 sendJson 连 headers
|
|
732
|
+
// 参数都没有,所以不出 Content-Disposition。
|
|
733
|
+
//
|
|
734
|
+
// 非 POST 一律 405,与面板逐字同款:这**故意**与上面的 /maintenance/reclaim 不同
|
|
735
|
+
// (那边掉到末尾 404)。状态码差一次就是第三处差别,而每条 receive 都还有个面板
|
|
736
|
+
// 双胞胎,一致性比照抄本文件的邻居更硬。
|
|
737
|
+
//
|
|
738
|
+
// void + .catch,不留外层 try 兜底:本文件那个 try 在 .then 的回调里早已退栈,
|
|
739
|
+
// 回调里抛出去就是没人接的 rejection、客户端一个字节都收不到(#410)。共用层
|
|
740
|
+
// 从不抛(连 warn 自己坏都被守卫吞掉),所以这里能被走到的只剩面自己写答复那一步。
|
|
741
|
+
// readBody 超限时会自己答 413 并把 text 解析成 null:那句 return 是必需的,少了
|
|
742
|
+
// 它就是在已发出的响应上再写一次。
|
|
743
|
+
if (pathname === "/receive" || pathname === "/receive/undo") {
|
|
744
|
+
const isUndo = pathname === "/receive/undo";
|
|
745
|
+
if (req.method !== "POST") {
|
|
746
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
void readBody(req, res).then((text) => {
|
|
750
|
+
if (text === null) return; // readBody 已经答过 413,再写一次就是双写响应
|
|
751
|
+
const parsed = isUndo ? parseUndoBody(text) : parseReceiveBody(text);
|
|
752
|
+
// 两个解析器都回 {ok:true,…}|{ok:false,error},所以一处判断就够。别把它写成
|
|
753
|
+
// 「有 error 就 400」:那会把本面缺路径时该落的 404 与真正的坏 body 混成一种
|
|
754
|
+
// 答复,排查时看不出是哪一层没接上。
|
|
755
|
+
if (!parsed.ok) {
|
|
756
|
+
sendJson(res, 400, { error: parsed.error });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const out = isUndo
|
|
760
|
+
? undoReply({ service, packId: parsed.packId, dryRun: parsed.dryRun, warn: (msg) => logger?.warn?.(msg) })
|
|
761
|
+
: receiveReply({ service, pack: parsed.pack, dryRun: parsed.dryRun, warn: (msg) => logger?.warn?.(msg) });
|
|
762
|
+
sendJson(res, out.status, out.body);
|
|
763
|
+
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// --- POST /bootstrap: cold-start memories from repo files (#220) -------
|
|
768
|
+
// 显式收 dir(插件没有工作区根目录概念,不猜路径);确定性解析零 LLM;
|
|
769
|
+
// 幂等骑 saveWithDedupe 的 (type, title, scope) 去重 + _overwrite 刷新。
|
|
770
|
+
//
|
|
771
|
+
// 解析、校验、答复形状全走 bootstrap-http.js:#386 第 6 条给宿主内路由补了
|
|
772
|
+
// 第二张脸,按 #411 的纪律,两张皮只许剩鉴权与路径不同。这里刻意**不**再
|
|
773
|
+
// 自己 JSON.parse 一遍——真要写第二份,test/reclaim-http.test.js 那类静态锁
|
|
774
|
+
// 会转红,而漂移在两边都只表现为「某个错误码不一样了」。
|
|
775
|
+
if (req.method === "POST" && pathname === "/bootstrap") {
|
|
776
|
+
void readBody(req, res).then((text) => {
|
|
777
|
+
if (text === null) return;
|
|
778
|
+
const parsed = parseBootstrapJson(text);
|
|
779
|
+
if (!parsed.ok) {
|
|
780
|
+
sendJson(res, 400, { error: parsed.error });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const dirResult = readBootstrapDir(parsed.body);
|
|
784
|
+
if (!dirResult.ok) {
|
|
785
|
+
sendJson(res, 400, { error: dirResult.error });
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
bootstrapReply({
|
|
789
|
+
bootstrapper: ({ dir }) => bootstrapFromDirectory({ service, dir, logger }),
|
|
790
|
+
dir: dirResult.dir,
|
|
791
|
+
warn: (msg) => logger?.warn?.(msg)
|
|
792
|
+
}).then(({ status, body }) => sendJson(res, status, body));
|
|
793
|
+
});
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// --- GET /search: unified recall pipeline (keyword + vector + BM25) ----
|
|
798
|
+
if (req.method === "GET" && pathname === "/search") {
|
|
799
|
+
const q = url.searchParams.get("q") ?? "";
|
|
800
|
+
// 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
|
|
801
|
+
const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
|
|
802
|
+
const mode = url.searchParams.get("mode") ?? "auto";
|
|
803
|
+
const rerank = url.searchParams.get("rerank") !== "false";
|
|
804
|
+
const occurredFrom = url.searchParams.get("occurred_from") ?? null;
|
|
805
|
+
const occurredTo = url.searchParams.get("occurred_to") ?? null;
|
|
806
|
+
// scope 透传(issue #370):searchMemories 本就收 scope,此前 8790 没有
|
|
807
|
+
// 入口——第三方(daemon 桥接侧)声明身份后,A2 软加权与 strictScope 硬
|
|
808
|
+
// 过滤才在检索面同样成立;与 /context 同口径(scopeFromSearchParams)。
|
|
809
|
+
const scope = scopeFromSearchParams(url);
|
|
810
|
+
const query = q.trim();
|
|
811
|
+
if (!query) {
|
|
812
|
+
sendJson(res, 200, { items: [], mode: "keyword" });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
// Any vector/rerank failure degrades to keyword inside searchMemories.
|
|
816
|
+
void Promise.resolve(
|
|
817
|
+
service.searchMemories(query, {
|
|
818
|
+
mode,
|
|
819
|
+
topK: limit,
|
|
820
|
+
useRerank: rerank,
|
|
821
|
+
...(scope ? { scope } : {}),
|
|
822
|
+
...(occurredFrom !== null || occurredTo !== null ? { occurredFrom, occurredTo } : {})
|
|
823
|
+
})
|
|
824
|
+
).then((rows) => {
|
|
825
|
+
const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
|
|
826
|
+
sendJson(res, 200, { items: service.toApiList(rows), mode: used });
|
|
827
|
+
}).catch(() => {
|
|
828
|
+
sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
|
|
829
|
+
});
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// --- GET /context: 注入候选一站式聚合(issue #370,#363 承诺)------------
|
|
834
|
+
// 给定话题返回「宿主此刻会注入什么」的数据等价物:injectCandidates 全语义
|
|
835
|
+
// (优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置,
|
|
836
|
+
// service.js:1494)+ 画像 + 规则。结构化条目、不含渲染文本——语言 / 时间
|
|
837
|
+
// 前缀 / 热记忆是宿主会话概念,调用方(Mneme Bridge 等网页端桥接)自行渲染。
|
|
838
|
+
// 与宿主注入的两点差异都是 HTTP 面的有意为之:① 首次调用即走语义路(宿主
|
|
839
|
+
// 渲染必须同步,查询向量只能异步 prefetch 给下一轮,inject.js Bug4;这里
|
|
840
|
+
// 没有该约束);② 不做跨轮轮换(daemon 无会话状态,调用方自管)。
|
|
841
|
+
// recall_runs 记账随 injectCandidates 内建(#217:注入是曝光型访问事件,
|
|
842
|
+
// mode='inject' 与检索命中同表分账)——宿主注入同样记,/context 自动同口径。
|
|
843
|
+
if (req.method === "GET" && pathname === "/context") {
|
|
844
|
+
const q = (url.searchParams.get("q") ?? "").trim();
|
|
845
|
+
const maxItemsRaw = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 5);
|
|
846
|
+
const maxItems = Number.isInteger(maxItemsRaw) && maxItemsRaw > 0 ? Math.min(maxItemsRaw, MAX_SEARCH_TOP_K) : 5;
|
|
847
|
+
// threshold 是 importance 阈值(injectCandidates 语义),与 /search 的
|
|
848
|
+
// 相似度 threshold 同名不同义——文档已点名,勿混。
|
|
849
|
+
const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3);
|
|
850
|
+
const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3;
|
|
851
|
+
const scope = scopeFromSearchParams(url);
|
|
852
|
+
// #383:降级原因记账用。embedQueryVector / withEmbedTimeout 只负责「拿不到
|
|
853
|
+
// 向量时返回 undefined」,原因(无可用接口 / 空向量 / 超时 / 抛错 / 维度不符 /
|
|
854
|
+
// 没装配 embedder)在这里回收,供 daemon 侧日志枚举——响应体只多 tier 一个
|
|
855
|
+
// 键,不加第二个字段。
|
|
856
|
+
const diag = {};
|
|
857
|
+
// 记账的唯一决策点:q 非空但没装配 embedder。#508 Minor 的成因正是这条路不写
|
|
858
|
+
// reason,靠日志行的兜底表达式临时拼一个值(no-embedder),而那个值不在文档
|
|
859
|
+
// 声明的枚举里;同一表达式里还躺着第二个越界字面量 embed-failed。兜底表达式的
|
|
860
|
+
// 问题不只是越界:它把「默认值」编码在两处,且 embedder 存在时那个 embed-error
|
|
861
|
+
// 分支一次都走不到(10 种降级场景实测:只要 embedder 为真,embedQueryVector /
|
|
862
|
+
// withEmbedTimeout / 下面的 .catch 三者必居其一地写好原因),却会把未来任何新增
|
|
863
|
+
// 的未记账失败一律谎报成「接口抛错」,把运维引去查 HTTP 端点。现在默认值只有
|
|
864
|
+
// 这一个来源,日志行不再兜底。
|
|
865
|
+
if (q && !embedder) diag.reason = "no-embedder";
|
|
866
|
+
void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q, diag), embedTimeoutMs, diag) : undefined)
|
|
867
|
+
.catch((err) => { diag.reason = "embed-error"; return undefined; }) // 查询嵌入失败降级规则档(与 searchMemories 同口径)
|
|
868
|
+
.then((queryVector) => {
|
|
869
|
+
const pinnedStats = {};
|
|
870
|
+
// items 本身失败(injectCandidates 抛错,典型如库被关)回 500 是正当的:
|
|
871
|
+
// 没有可返回的内容。items 一旦算好,后面的档位判定与降级日志就只是诊断,
|
|
872
|
+
// 任何一步出错都不该把已经算好的 items 丢掉——所以下面每一处 logger 调用
|
|
873
|
+
// 都各自包了 try/catch(logger 来自宿主,stdout 管道断裂时 warn 自身抛错是
|
|
874
|
+
// 真实可发生的,而 `logger?.warn?.()` 只防 logger 缺失,防不了这个)。
|
|
875
|
+
const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats });
|
|
876
|
+
// #383 档位信号:嵌入降级在调用方视角原本完全静默(items 看着正常,只是
|
|
877
|
+
// 召回质量悄悄变差),外部集成方无从感知自己长期跑在降级档上。
|
|
878
|
+
// 三态 fail-closed——判不出来报 unknown,不报 full:
|
|
879
|
+
// full 语义档,查询嵌入真的成功了且与库里的向量可比
|
|
880
|
+
// degraded 规则档:没装配 embedder / 嵌入超时 / 不可用向量 / 抛错 /
|
|
881
|
+
// 维度不符(拿到了向量但语义召回整个不可用)
|
|
882
|
+
// unknown 语义路未启用(空 q),或判不出来(维度探针没跑成 / 读不出
|
|
883
|
+
// 任何向量)——那不是降级,但也不能宣称 full
|
|
884
|
+
// 比 /search 的 mode 更精确:/search 把「空查询」和「向量失败」都报
|
|
885
|
+
// keyword,这里能分开(空 q 直接可观测)。
|
|
886
|
+
//
|
|
887
|
+
// 预算:判档不是免费的——q 非空且配了 embedder 时,它真的会起一次查询
|
|
888
|
+
// 嵌入(一次网络往返 + embedTimeoutMs 的超时窗口),因为档位要回答的
|
|
889
|
+
// 正是「这次嵌入成不成」。这里**没有**复用宿主 /health 那份现成数据源:
|
|
890
|
+
// standalone 的 /health 只回 {ok:true},而 embeddingDimensionBreakdown
|
|
891
|
+
// 是 src/api.js 里的一次全表扫(每行 JSON.parse 一个向量),按请求跑会
|
|
892
|
+
// 把语义路成本翻倍,且它根本不在 standalone 的装配里。维度探针因此走
|
|
893
|
+
// service.vectorDimensionState()——存在性判据(有没有一条活跃向量与查询
|
|
894
|
+
// 同维),成本模型见 store.js 那段注释:常态一次全 id 扫描 + 解析 1 个
|
|
895
|
+
// 向量,只有要报 degraded 时才走完全表。
|
|
896
|
+
let tier = "unknown";
|
|
897
|
+
if (q && queryVector) {
|
|
898
|
+
// #508 Major:探针判据从「读一行的维度」换成存在性。LIMIT 1 拿任意一条
|
|
899
|
+
// 代表全库,在混合维度库里两个方向都错——抽到与查询不符的那条会误报
|
|
900
|
+
// degraded(明明有可比的向量),首条解析失败又会漏报成 full。现在问的是
|
|
901
|
+
// 「有没有一条活跃向量的维度等于查询维度」,与 searchVector 的行集同源。
|
|
902
|
+
//
|
|
903
|
+
// 探针失败(库被锁 / 半开 / 旧装配没有这个方法)判不出来:tier 保持
|
|
904
|
+
// unknown,不宣称 full,也不把已经算好的 items 丢掉。sentinel 不能用
|
|
905
|
+
// null / undefined——那正是「库里没有向量」的合法读数,混起来就会把一次
|
|
906
|
+
// 真实的探针故障谎报成 full(fail-open)。隔离只在调用侧:store 与 service
|
|
907
|
+
// 透传都保持抛出口,不把异常吞成某个 state。
|
|
908
|
+
let state;
|
|
909
|
+
// 判不出来时的留痕。unknown 现在有三个生产者(空 q / 探针没跑成 / 有行却
|
|
910
|
+
// 一条都读不出来),而响应体只有 tier 一个键、分不出它们,调用方对 unknown
|
|
911
|
+
// 的策略又常常是「不告警」——不留一条 warn,一次真实的判档故障就从运维面上
|
|
912
|
+
// 消失了。这条 warn **不是** diag.reason 枚举的一员(tier 不是 degraded,
|
|
913
|
+
// 没有降级原因可记),运维程序不要按降级原因枚举解析它;消息也与
|
|
914
|
+
// 「degraded to the rule tier (<reason>)」分开,因为语义路这次是真的跑了,
|
|
915
|
+
// 只是没判成,两种故障的后续动作不同。
|
|
916
|
+
let judgeFailure;
|
|
917
|
+
try {
|
|
918
|
+
state = typeof service.vectorDimensionState === "function"
|
|
919
|
+
? service.vectorDimensionState(queryVector.length)
|
|
920
|
+
: undefined;
|
|
921
|
+
} catch (error) {
|
|
922
|
+
state = undefined;
|
|
923
|
+
judgeFailure = `dimension probe failed (${String(error)})`;
|
|
924
|
+
}
|
|
925
|
+
if (state?.state === "match" || state?.state === "none") {
|
|
926
|
+
// match:有维度相符的活跃向量,语义路通(通 ≠ 有命中,四道门仍可能让
|
|
927
|
+
// 语义档贡献为 0)。none:行集本身为空——没有东西可比,不是维度不符。
|
|
928
|
+
tier = "full";
|
|
929
|
+
} else if (state?.state === "mismatch") {
|
|
930
|
+
// 维度不符:searchVector 对不等长的行直接跳过(见 store.js 的
|
|
931
|
+
// distill-tx-embed-gap 注释),不抛错、只是命中为零——于是召回静默
|
|
932
|
+
// 退回 BM25/规则档,而嵌入侧一切正常。这是 issue #383 点名的那一种
|
|
933
|
+
// 「悄悄退化」,必须有自己的名字:修法是重建索引,不是查端点。
|
|
934
|
+
diag.reason = "embed-dimension";
|
|
935
|
+
tier = "degraded";
|
|
936
|
+
} else if (state?.state === "unreadable" || state?.state === "invalid") {
|
|
937
|
+
// 有行却一条都读不出来(比维度不符更严重:语义召回 100% 死亡),或查询
|
|
938
|
+
// 维度本身不是正整数。判不出来,留 unknown。
|
|
939
|
+
judgeFailure = `no comparable vector readable (${state.state})`;
|
|
940
|
+
}
|
|
941
|
+
// undefined(探针方法缺失 / 返回垃圾 / 异步漂移)同样留 unknown。
|
|
942
|
+
if (judgeFailure) {
|
|
943
|
+
try {
|
|
944
|
+
logger?.warn?.(`dsh-mneme: /context tier left unknown: ${judgeFailure}`);
|
|
945
|
+
} catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ }
|
|
946
|
+
}
|
|
947
|
+
} else if (q) {
|
|
948
|
+
tier = "degraded";
|
|
949
|
+
}
|
|
950
|
+
if (tier === "degraded") {
|
|
951
|
+
// reason 由唯一决策点(q && !embedder)+ 三个写入方(embedQueryVector /
|
|
952
|
+
// withEmbedTimeout / 查询嵌入 .catch)+ 维度判据保证必已赋值,所以这里不再
|
|
953
|
+
// 写第二份默认值——#508 之前的那份兜底表达式(`embedder ? "embed-failed" :
|
|
954
|
+
// "no-embedder"`)两个默认值互相矛盾、且都在文档枚举之外,其中 embed-error
|
|
955
|
+
// 分支在 embedder 存在时一次都走不到,却会把未来任何新增的未记账失败一律
|
|
956
|
+
// 谎报成「接口抛错」。现在只把枚举内的值写进日志:越界或漏记账都露出
|
|
957
|
+
// (undefined),那是看得见的缺陷信号,不是一个编出来的因。
|
|
958
|
+
const reason = CONTEXT_DEGRADE_REASONS.includes(diag.reason) ? diag.reason : undefined;
|
|
959
|
+
try {
|
|
960
|
+
logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${reason})`);
|
|
961
|
+
} catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ }
|
|
962
|
+
}
|
|
963
|
+
sendJson(res, 200, {
|
|
964
|
+
profile: (settings?.getProfile?.() ?? "").trim(),
|
|
965
|
+
rules: settings?.getRules?.() ?? [],
|
|
966
|
+
items: service.toApiList(items),
|
|
967
|
+
pinnedCount: pinnedStats.shown ?? 0,
|
|
968
|
+
tier
|
|
969
|
+
});
|
|
970
|
+
})
|
|
971
|
+
.catch(() => {
|
|
972
|
+
sendJson(res, 500, { error: "internal" });
|
|
973
|
+
});
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
sendJson(res, 404, { error: "not-found" });
|
|
978
|
+
} catch {
|
|
979
|
+
sendJson(res, 500, { error: "internal" });
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
// A permanent error listener keeps an EADDRINUSE / runtime socket error from
|
|
984
|
+
// crashing the host process; `ready` still surfaces the first bind failure.
|
|
985
|
+
server.on("error", (error) => {
|
|
986
|
+
logger?.warn?.(`[dsh-mneme] standalone API error: ${String(error)}`);
|
|
987
|
+
});
|
|
988
|
+
const ready = new Promise((resolve, reject) => {
|
|
989
|
+
// listening handled by listenWithRetry
|
|
990
|
+
listenWithRetry(server, boundPort, boundHost, logger, strictPort).then(resolve, reject);
|
|
991
|
+
});
|
|
992
|
+
// server.listen is called inside listenWithRetry
|
|
993
|
+
ready.then(() => {
|
|
994
|
+
const address = server.address();
|
|
995
|
+
if (address && typeof address === "object") {
|
|
996
|
+
logger?.info?.(`[dsh-mneme] standalone API listening on http://${address.address}:${address.port}`);
|
|
997
|
+
}
|
|
998
|
+
}).catch(() => { /* already logged by the error handler above */ });
|
|
999
|
+
|
|
1000
|
+
const api = { server, port: boundPort, host: boundHost, token, ready };
|
|
1001
|
+
// After the OS assigns the real port (bind port 0), reflect it for callers.
|
|
1002
|
+
ready.then(() => {
|
|
1003
|
+
const address = server.address();
|
|
1004
|
+
if (address && typeof address === "object") api.port = address.port;
|
|
1005
|
+
}).catch(() => { /* bind failed: port stays as configured */ });
|
|
1006
|
+
return api;
|
|
1007
|
+
}
|