@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/test/store.test.js
CHANGED
|
@@ -1,744 +1,744 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { DatabaseSync } from "node:sqlite";
|
|
4
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { createStore } from "../src/store.js";
|
|
8
|
-
|
|
9
|
-
function openMemory() {
|
|
10
|
-
return createStore(":memory:");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
test("createStore initializes schema and opens db", () => {
|
|
14
|
-
const store = openMemory();
|
|
15
|
-
const row = store.db.prepare(
|
|
16
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name='memories'"
|
|
17
|
-
).get();
|
|
18
|
-
assert.ok(row, "memories table exists");
|
|
19
|
-
const cursorTable = store.db.prepare(
|
|
20
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name='distill_cursors'"
|
|
21
|
-
).get();
|
|
22
|
-
assert.ok(cursorTable, "distill cursor table exists");
|
|
23
|
-
assert.equal(store.getDistillCursor("session-1"), undefined);
|
|
24
|
-
const saved = store.setDistillCursor("session-1", 4);
|
|
25
|
-
assert.equal(saved.last_seq, 4);
|
|
26
|
-
assert.ok(saved.updated_at);
|
|
27
|
-
assert.equal(store.setDistillCursor("session-1", 2).last_seq, 4, "cursor never moves backwards");
|
|
28
|
-
store.close();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("save inserts a memory and returns it with id/created_at", () => {
|
|
32
|
-
const store = openMemory();
|
|
33
|
-
const saved = store.save({
|
|
34
|
-
type: "preference",
|
|
35
|
-
title: "语言",
|
|
36
|
-
content: "用户用中文交流",
|
|
37
|
-
tags: ["偏好"],
|
|
38
|
-
importance: 5,
|
|
39
|
-
source: "manual"
|
|
40
|
-
});
|
|
41
|
-
assert.ok(saved.id, "has id");
|
|
42
|
-
assert.ok(saved.created_at, "has created_at");
|
|
43
|
-
assert.equal(saved.type, "preference");
|
|
44
|
-
assert.equal(store.count(), 1);
|
|
45
|
-
store.close();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("getById returns the memory", () => {
|
|
49
|
-
const store = openMemory();
|
|
50
|
-
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
51
|
-
const got = store.getById(saved.id);
|
|
52
|
-
assert.equal(got.title, "t");
|
|
53
|
-
assert.equal(got.content, "c");
|
|
54
|
-
store.close();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("update modifies fields and bumps updated_at", () => {
|
|
58
|
-
const store = openMemory();
|
|
59
|
-
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
60
|
-
const updated = store.update(saved.id, { content: "new content", importance: 4 });
|
|
61
|
-
assert.equal(updated.content, "new content");
|
|
62
|
-
assert.equal(updated.importance, 4);
|
|
63
|
-
assert.notEqual(updated.updated_at, saved.updated_at);
|
|
64
|
-
store.close();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("remove deletes the memory", () => {
|
|
68
|
-
const store = openMemory();
|
|
69
|
-
const saved = store.save({ type: "project", title: "t", content: "c" });
|
|
70
|
-
store.remove(saved.id);
|
|
71
|
-
assert.equal(store.getById(saved.id), undefined);
|
|
72
|
-
assert.equal(store.count(), 0);
|
|
73
|
-
store.close();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("list filters by type and paginates", () => {
|
|
77
|
-
const store = openMemory();
|
|
78
|
-
for (let i = 0; i < 5; i++) store.save({ type: "preference", title: `p${i}`, content: "c" });
|
|
79
|
-
for (let i = 0; i < 3; i++) store.save({ type: "project", title: `j${i}`, content: "c" });
|
|
80
|
-
assert.equal(store.list({ type: "preference" }).length, 5);
|
|
81
|
-
assert.equal(store.list({ type: "project" }).length, 3);
|
|
82
|
-
assert.equal(store.list({ limit: 2 }).length, 2);
|
|
83
|
-
assert.equal(store.list({ limit: 2, offset: 2 }).length, 2);
|
|
84
|
-
store.close();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("setForget toggles injection suppression", () => {
|
|
88
|
-
const store = openMemory();
|
|
89
|
-
const saved = store.save({ type: "project", title: "t", content: "c", importance: 5 });
|
|
90
|
-
store.setForget(saved.id, true);
|
|
91
|
-
const got = store.getById(saved.id);
|
|
92
|
-
// toRow maps SQLite 0/1 to boolean
|
|
93
|
-
assert.equal(got.forgotten, true);
|
|
94
|
-
store.close();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("count excludes forgotten by default, includeForgotten opts in", () => {
|
|
98
|
-
const store = openMemory();
|
|
99
|
-
const a = store.save({ type: "project", title: "t1", content: "c" });
|
|
100
|
-
store.save({ type: "project", title: "t2", content: "c" });
|
|
101
|
-
store.save({ type: "preference", title: "p", content: "c" });
|
|
102
|
-
store.setForget(a.id, true);
|
|
103
|
-
assert.equal(store.count(), 2, "default excludes forgotten (matches list)");
|
|
104
|
-
assert.equal(store.count("project"), 1);
|
|
105
|
-
assert.equal(store.count("preference"), 1);
|
|
106
|
-
assert.equal(store.count(undefined, { includeForgotten: true }), 3);
|
|
107
|
-
assert.equal(store.count("project", { includeForgotten: true }), 2);
|
|
108
|
-
store.close();
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("countByType: same filters, no type clause — the distribution sums to count(undefined) (#597)", () => {
|
|
112
|
-
const store = openMemory();
|
|
113
|
-
const a = store.save({ type: "project", title: "t1", content: "c" });
|
|
114
|
-
store.save({ type: "project", title: "t2", content: "c" });
|
|
115
|
-
store.save({ type: "preference", title: "p", content: "c", importance: 5 });
|
|
116
|
-
store.setForget(a.id, true);
|
|
117
|
-
const counts = store.countByType();
|
|
118
|
-
assert.equal(counts.project, 1, "forgotten rows excluded (same口径 as count)");
|
|
119
|
-
assert.equal(counts.preference, 1);
|
|
120
|
-
assert.equal(
|
|
121
|
-
Object.keys(counts).reduce((n, k) => n + counts[k], 0),
|
|
122
|
-
store.count(),
|
|
123
|
-
"distribution sums to count(undefined)"
|
|
124
|
-
);
|
|
125
|
-
// 筛选同口径:带 minImportance 的分布合计 = 带同筛选的 count
|
|
126
|
-
const filtered = store.countByType({ minImportance: 5 });
|
|
127
|
-
assert.deepEqual(filtered, { preference: 1 }, "distribution honors the same filters as count");
|
|
128
|
-
store.close();
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("search matches title, content and tags", () => {
|
|
132
|
-
const store = openMemory();
|
|
133
|
-
store.save({ type: "project", title: "记忆插件", content: "c1", tags: [] });
|
|
134
|
-
store.save({ type: "project", title: "t2", content: "用户用中文交流", tags: [] });
|
|
135
|
-
store.save({ type: "preference", title: "t3", content: "c3", tags: ["偏好"] });
|
|
136
|
-
assert.ok(store.search("记忆插件").some((m) => m.title === "记忆插件"));
|
|
137
|
-
assert.ok(store.search("中文").some((m) => m.content === "用户用中文交流"));
|
|
138
|
-
assert.ok(store.search("偏好").some((m) => m.tags.includes("偏好")));
|
|
139
|
-
store.close();
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("search with empty query returns []", () => {
|
|
143
|
-
const store = openMemory();
|
|
144
|
-
store.save({ type: "project", title: "t", content: "c" });
|
|
145
|
-
assert.deepEqual(store.search(""), []);
|
|
146
|
-
assert.deepEqual(store.search(" "), []);
|
|
147
|
-
store.close();
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("forgotten memories are excluded from list and search until un-forgotten", () => {
|
|
151
|
-
const store = openMemory();
|
|
152
|
-
const saved = store.save({ type: "project", title: "秘密", content: "c", importance: 5 });
|
|
153
|
-
assert.ok(store.list().some((m) => m.id === saved.id));
|
|
154
|
-
assert.ok(store.search("秘密").some((m) => m.id === saved.id));
|
|
155
|
-
store.setForget(saved.id, true);
|
|
156
|
-
assert.ok(!store.list().some((m) => m.id === saved.id));
|
|
157
|
-
assert.ok(!store.search("秘密").some((m) => m.id === saved.id));
|
|
158
|
-
store.setForget(saved.id, false);
|
|
159
|
-
assert.ok(store.list().some((m) => m.id === saved.id));
|
|
160
|
-
assert.ok(store.search("秘密").some((m) => m.id === saved.id));
|
|
161
|
-
store.close();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
test("search matches CJK substring", () => {
|
|
165
|
-
const store = openMemory();
|
|
166
|
-
store.save({ type: "project", title: "t", content: "用户用中文交流" });
|
|
167
|
-
assert.equal(store.search("中文").length, 1);
|
|
168
|
-
store.close();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("search respects limit", () => {
|
|
172
|
-
const store = openMemory();
|
|
173
|
-
for (let i = 0; i < 3; i++) store.save({ type: "project", title: `m${i}`, content: "匹配词" });
|
|
174
|
-
assert.equal(store.search("匹配词").length, 3);
|
|
175
|
-
assert.equal(store.search("匹配词", { limit: 2 }).length, 2);
|
|
176
|
-
store.close();
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("list sanitizes invalid limit/offset", () => {
|
|
180
|
-
const store = openMemory();
|
|
181
|
-
for (let i = 0; i < 3; i++) store.save({ type: "project", title: `t${i}`, content: "c" });
|
|
182
|
-
assert.equal(store.list({ limit: -1 }).length, 3);
|
|
183
|
-
assert.equal(store.list({ limit: 0 }).length, 3);
|
|
184
|
-
assert.equal(store.list({ limit: 1.5 }).length, 3);
|
|
185
|
-
assert.equal(store.list({ limit: 2, offset: -5 }).length, 2);
|
|
186
|
-
store.close();
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test("schema migration adds archived column to legacy database", () => {
|
|
190
|
-
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-"));
|
|
191
|
-
const dbPath = join(dir, "legacy.db");
|
|
192
|
-
try {
|
|
193
|
-
// Create a legacy db WITHOUT archived column
|
|
194
|
-
const legacy = new DatabaseSync(dbPath);
|
|
195
|
-
legacy.exec(`CREATE TABLE memories (
|
|
196
|
-
id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
|
|
197
|
-
tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3,
|
|
198
|
-
forgotten INTEGER NOT NULL DEFAULT 0, source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
199
|
-
);`);
|
|
200
|
-
legacy.close();
|
|
201
|
-
// Open with createStore → should ALTER TABLE
|
|
202
|
-
const store = createStore(dbPath);
|
|
203
|
-
const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
204
|
-
assert.ok(cols.includes("archived"), "archived column added");
|
|
205
|
-
const cursorTable = store.db.prepare(
|
|
206
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name='distill_cursors'"
|
|
207
|
-
).get();
|
|
208
|
-
assert.ok(cursorTable, "new cursor table is created for legacy databases");
|
|
209
|
-
store.close();
|
|
210
|
-
} finally {
|
|
211
|
-
rmSync(dir, { recursive: true, force: true });
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("setArchived marks entry archived; list excludes it by default", () => {
|
|
216
|
-
const store = openMemory();
|
|
217
|
-
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
218
|
-
const archived = store.setArchived(saved.id, true);
|
|
219
|
-
assert.equal(archived.archived, true);
|
|
220
|
-
assert.equal(store.list().length, 0, "excluded from default list");
|
|
221
|
-
assert.equal(store.list({ includeArchived: true }).length, 1, "included with flag");
|
|
222
|
-
assert.equal(store.count(), 0, "excluded from default count");
|
|
223
|
-
assert.equal(store.count(undefined, { includeArchived: true }), 1);
|
|
224
|
-
store.close();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("search excludes archived by default", () => {
|
|
228
|
-
const store = openMemory();
|
|
229
|
-
const saved = store.save({ type: "project", title: "secret", content: "hidden content", importance: 3 });
|
|
230
|
-
store.setArchived(saved.id, true);
|
|
231
|
-
assert.equal(store.search("secret").length, 0);
|
|
232
|
-
assert.equal(store.search("secret", { includeArchived: true }).length, 1);
|
|
233
|
-
store.close();
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
// ============================ #275 第二批:archived_at 精确归档时刻
|
|
237
|
-
|
|
238
|
-
test("schema migration adds archived_at column to legacy database", () => {
|
|
239
|
-
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-at-"));
|
|
240
|
-
const dbPath = join(dir, "legacy.db");
|
|
241
|
-
try {
|
|
242
|
-
// 存量库:有 archived 标志位,但没有 archived_at(#275 第一批时代的库)
|
|
243
|
-
const legacy = new DatabaseSync(dbPath);
|
|
244
|
-
legacy.exec(`CREATE TABLE memories (
|
|
245
|
-
id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
|
|
246
|
-
tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3,
|
|
247
|
-
forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
|
|
248
|
-
source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
249
|
-
);`);
|
|
250
|
-
legacy.exec(`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, created_at, updated_at)
|
|
251
|
-
VALUES ('old1', 'history', 't', 'c', '[]', 3, 0, 1, NULL, '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z');`);
|
|
252
|
-
legacy.close();
|
|
253
|
-
|
|
254
|
-
const store = createStore(dbPath);
|
|
255
|
-
const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
256
|
-
assert.ok(cols.includes("archived_at"), "archived_at column added");
|
|
257
|
-
// 存量归档行零重写:不回填 updated_at(那等于把被推翻的代理口径固化进列里)
|
|
258
|
-
const raw = store.db.prepare("SELECT archived_at FROM memories WHERE id = 'old1'").get();
|
|
259
|
-
assert.equal(raw.archived_at, null, "legacy archived row keeps NULL, not backfilled from updated_at");
|
|
260
|
-
assert.equal(store.getById("old1").archived_at, undefined, "NULL 读作 undefined,调用方须显式处理");
|
|
261
|
-
store.close();
|
|
262
|
-
} finally {
|
|
263
|
-
rmSync(dir, { recursive: true, force: true });
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test("archived_at migration is idempotent: reopening the upgraded database is a no-op", () => {
|
|
268
|
-
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-at2-"));
|
|
269
|
-
const dbPath = join(dir, "upgraded.db");
|
|
270
|
-
try {
|
|
271
|
-
const first = createStore(dbPath);
|
|
272
|
-
const m = first.save({ type: "history", title: "t", content: "c" });
|
|
273
|
-
first.setArchived(m.id, true);
|
|
274
|
-
const stamp = first.getById(m.id).archived_at;
|
|
275
|
-
first.close();
|
|
276
|
-
|
|
277
|
-
// 再开一次:addColumn 先 PRAGMA 后 ALTER,不该报错、不该重建、不该动已有值
|
|
278
|
-
const second = createStore(dbPath);
|
|
279
|
-
const cols = second.db.prepare("PRAGMA table_info(memories)").all().filter((c) => c.name === "archived_at");
|
|
280
|
-
assert.equal(cols.length, 1, "列只存在一份,没被重复 ALTER");
|
|
281
|
-
assert.equal(second.getById(m.id).archived_at, stamp, "重开不动已落盘的归档时刻");
|
|
282
|
-
second.close();
|
|
283
|
-
} finally {
|
|
284
|
-
rmSync(dir, { recursive: true, force: true });
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
test("setArchived stamps archived_at on archive, clears it on restore", () => {
|
|
289
|
-
const store = openMemory();
|
|
290
|
-
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
291
|
-
assert.equal(saved.archived_at, undefined, "新建行未归档 → 无时刻");
|
|
292
|
-
|
|
293
|
-
const archived = store.setArchived(saved.id, true);
|
|
294
|
-
assert.equal(archived.archived, true);
|
|
295
|
-
assert.ok(archived.archived_at, "活跃 → 归档落时刻");
|
|
296
|
-
assert.ok(
|
|
297
|
-
Math.abs(Date.parse(archived.archived_at) - Date.now()) < 5000,
|
|
298
|
-
"落的是当下时刻,不是别的行的 updated_at"
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
const restored = store.setArchived(saved.id, false);
|
|
302
|
-
assert.equal(restored.archived, false);
|
|
303
|
-
assert.equal(restored.archived_at, undefined, "取消归档清空");
|
|
304
|
-
store.close();
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test("setArchived keeps updated_at semantics (existing readers depend on it)", () => {
|
|
308
|
-
// archived_at 是新增列,不是 updated_at 的替代品:面板排序、快照哈希、CAS 都读
|
|
309
|
-
// updated_at,本函数自 v0.2 起就无条件刷它——改口径不得顺手改掉这条既有语义。
|
|
310
|
-
const store = openMemory();
|
|
311
|
-
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
312
|
-
const before = store.getById(saved.id).updated_at;
|
|
313
|
-
const archived = store.setArchived(saved.id, true);
|
|
314
|
-
assert.ok(Date.parse(archived.updated_at) >= Date.parse(before), "updated_at 照旧被刷");
|
|
315
|
-
const again = store.setArchived(saved.id, false);
|
|
316
|
-
assert.ok(Date.parse(again.updated_at) >= Date.parse(archived.updated_at), "取消归档也刷");
|
|
317
|
-
store.close();
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test("save with archived:true stamps archived_at at insert time", () => {
|
|
321
|
-
// 铸造即归档的行:archived=1 而 archived_at=NULL 会落进「存量行」语义,读侧只能
|
|
322
|
-
// 当它时刻不可追溯——明明是当下发生的归档,白丢一条记录。
|
|
323
|
-
const store = openMemory();
|
|
324
|
-
const born = store.save({ type: "history", title: "生来就归档", content: "c", archived: true });
|
|
325
|
-
assert.equal(born.archived, true);
|
|
326
|
-
assert.ok(born.archived_at, "入库即归档 → 归档时刻 = 入库时刻");
|
|
327
|
-
assert.equal(born.archived_at, born.created_at, "两列同源(同一个 nowIso)");
|
|
328
|
-
|
|
329
|
-
const live = store.save({ type: "history", title: "活跃", content: "c" });
|
|
330
|
-
assert.equal(live.archived_at, undefined);
|
|
331
|
-
store.close();
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
test("update() cannot set archived_at (archived is not an updatable field)", () => {
|
|
335
|
-
// 归档时刻只能由 setArchived 落。若 update 的 patch 能写它,就等于给精确口径开了
|
|
336
|
-
// 一个绕过 setArchived 语义(幂等 / 清空)的后门,代理口径会从缝里长回来。
|
|
337
|
-
const store = openMemory();
|
|
338
|
-
const saved = store.save({ type: "history", title: "t", content: "c" });
|
|
339
|
-
store.update(saved.id, { archived_at: "2020-01-01T00:00:00.000Z", content: "改过" });
|
|
340
|
-
assert.equal(store.getById(saved.id).archived_at, undefined, "patch 里的 archived_at 被忽略");
|
|
341
|
-
store.close();
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
// --- vector search ---
|
|
345
|
-
|
|
346
|
-
test("save/update persist embedding vector and searchVector ranks by cosine", () => {
|
|
347
|
-
const store = openMemory();
|
|
348
|
-
const a = store.save({ type: "preference", title: "猫", content: "喜欢猫", embedding: [1, 0, 0] });
|
|
349
|
-
const b = store.save({ type: "preference", title: "狗", content: "喜欢狗", embedding: [0, 1, 0] });
|
|
350
|
-
const c = store.save({ type: "preference", title: "猫狗", content: "都养", embedding: [0.9, 0.1, 0] });
|
|
351
|
-
|
|
352
|
-
const hits = store.searchVector([1, 0, 0], { limit: 3 });
|
|
353
|
-
assert.deepEqual(hits.map((m) => m.id), [a.id, c.id, b.id]);
|
|
354
|
-
assert.equal(hits[0].score, 1);
|
|
355
|
-
assert.ok(hits[1].score > hits[2].score);
|
|
356
|
-
|
|
357
|
-
// update re-stores embedding
|
|
358
|
-
store.update(b.id, { embedding: [1, 1, 0] });
|
|
359
|
-
const hits2 = store.searchVector([1, 1, 0], { limit: 3 });
|
|
360
|
-
assert.equal(hits2[0].id, b.id);
|
|
361
|
-
store.close();
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
test("searchVector only considers rows with an embedding and filters forgotten/archived", () => {
|
|
365
|
-
const store = openMemory();
|
|
366
|
-
const plain = store.save({ type: "project", title: "无向量", content: "x" });
|
|
367
|
-
const withVec = store.save({ type: "project", title: "有向量", content: "y", embedding: [1, 0, 0] });
|
|
368
|
-
store.setForget(withVec.id, true);
|
|
369
|
-
assert.deepEqual(store.searchVector([1, 0, 0]).map((m) => m.id), []);
|
|
370
|
-
store.setForget(withVec.id, false);
|
|
371
|
-
store.setArchived(withVec.id, true);
|
|
372
|
-
assert.deepEqual(store.searchVector([1, 0, 0]).map((m) => m.id), []);
|
|
373
|
-
store.setArchived(withVec.id, false);
|
|
374
|
-
assert.deepEqual(store.searchVector([1, 0, 0]).map((m) => m.id), [withVec.id]);
|
|
375
|
-
assert.equal(plain.id, plain.id, "plain row keeps id");
|
|
376
|
-
store.close();
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
test("setEmbedding, embeddedCount, needsEmbedding and threshold filtering", () => {
|
|
380
|
-
const store = openMemory();
|
|
381
|
-
const t1 = store.save({ type: "project", title: "t1", content: "c1" });
|
|
382
|
-
const m2 = store.save({ type: "project", title: "t2", content: "c2" });
|
|
383
|
-
assert.equal(store.embeddedCount(), 0);
|
|
384
|
-
store.setEmbedding(m2.id, [0, 1]);
|
|
385
|
-
assert.equal(store.embeddedCount(), 1);
|
|
386
|
-
// Status-card denominator alignment: count() defaults exclude archived and
|
|
387
|
-
// forgotten rows, so embeddedCount must drop them too.
|
|
388
|
-
store.setArchived(m2.id, true);
|
|
389
|
-
assert.equal(store.embeddedCount(), 0, "archived rows leave the embedded count");
|
|
390
|
-
store.setArchived(m2.id, false);
|
|
391
|
-
store.setForget(m2.id, true);
|
|
392
|
-
assert.equal(store.embeddedCount(), 0, "forgotten rows leave the embedded count");
|
|
393
|
-
store.setForget(m2.id, false);
|
|
394
|
-
assert.equal(store.embeddedCount(), 1);
|
|
395
|
-
const missing = store.needsEmbedding(10);
|
|
396
|
-
assert.equal(missing.length, 1);
|
|
397
|
-
assert.equal(missing[0].id, t1.id, "only the non-embedded row is listed");
|
|
398
|
-
|
|
399
|
-
const t3 = store.save({ type: "project", title: "t3", content: "c3", embedding: [1, 1] });
|
|
400
|
-
// threshold 0.99: only t3 (cos=1) survives; m2 ([0,1]) scores ~0.707.
|
|
401
|
-
const near = store.searchVector([1, 1], { limit: 5, threshold: 0.99 });
|
|
402
|
-
assert.deepEqual(near.map((m) => m.id), [t3.id]);
|
|
403
|
-
store.close();
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
test("save/update preserve archived flag", () => {
|
|
407
|
-
const store = openMemory();
|
|
408
|
-
const saved = store.save({ type: "project", title: "t", content: "c" });
|
|
409
|
-
store.setArchived(saved.id, true);
|
|
410
|
-
const updated = store.update(saved.id, { content: "new" });
|
|
411
|
-
assert.equal(updated.archived, true, "update keeps archived");
|
|
412
|
-
store.close();
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
// --- compare-and-set update (item ①/③) ------------------------------------
|
|
416
|
-
|
|
417
|
-
test("compareAndUpdate applies when the version token still matches", () => {
|
|
418
|
-
const store = openMemory();
|
|
419
|
-
const saved = store.save({ type: "project", title: "t", content: "count=0" });
|
|
420
|
-
const before = store.getById(saved.id);
|
|
421
|
-
const updated = store.compareAndUpdate(saved.id, before.updated_at, { content: "count=1" });
|
|
422
|
-
assert.ok(updated, "CAS with the current version succeeds");
|
|
423
|
-
assert.equal(updated.content, "count=1");
|
|
424
|
-
assert.notEqual(updated.updated_at, before.updated_at, "version token advances");
|
|
425
|
-
store.close();
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
test("compareAndUpdate rejects a stale version token without writing", () => {
|
|
429
|
-
const store = openMemory();
|
|
430
|
-
const saved = store.save({ type: "project", title: "t", content: "count=0" });
|
|
431
|
-
const stale = store.getById(saved.id).updated_at;
|
|
432
|
-
store.update(saved.id, { content: "count=1" }); // concurrent write wins
|
|
433
|
-
const result = store.compareAndUpdate(saved.id, stale, { content: "count=2" });
|
|
434
|
-
assert.equal(result, undefined, "stale CAS is a miss");
|
|
435
|
-
assert.equal(store.getById(saved.id).content, "count=1", "no lost update");
|
|
436
|
-
store.close();
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
test("compareAndUpdate on unknown id throws like update", () => {
|
|
440
|
-
const store = openMemory();
|
|
441
|
-
assert.throws(() => store.compareAndUpdate("ghost", "any", { content: "x" }), /not found/);
|
|
442
|
-
store.close();
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
// --- conflict freeze: pending manual review -------------------------------
|
|
446
|
-
|
|
447
|
-
test("saveConflictPending inserts and listConflictPending excludes resolved by default", () => {
|
|
448
|
-
const store = openMemory();
|
|
449
|
-
const a = store.save({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
450
|
-
const b = store.save({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
451
|
-
const pending = store.saveConflictPending({ run_id: "run-1", memory_a: a.id, memory_b: b.id, reason: "日期更新" });
|
|
452
|
-
assert.ok(pending.id, "has id");
|
|
453
|
-
assert.equal(pending.run_id, "run-1");
|
|
454
|
-
assert.equal(pending.reason, "日期更新");
|
|
455
|
-
assert.ok(pending.created_at);
|
|
456
|
-
assert.equal(pending.resolved_at, undefined);
|
|
457
|
-
|
|
458
|
-
const list = store.listConflictPending();
|
|
459
|
-
assert.equal(list.length, 1);
|
|
460
|
-
assert.ok([list[0].memory_a, list[0].memory_b].includes(a.id), "pair holds both sides");
|
|
461
|
-
assert.ok([list[0].memory_a, list[0].memory_b].includes(b.id));
|
|
462
|
-
|
|
463
|
-
const resolved = store.resolveConflictPending(pending.id, { winner: a.id });
|
|
464
|
-
assert.ok(resolved.resolved_at, "resolution stamped");
|
|
465
|
-
assert.equal(resolved.resolved_winner, a.id);
|
|
466
|
-
assert.equal(store.listConflictPending().length, 0, "resolved excluded by default");
|
|
467
|
-
const all = store.listConflictPending({ includeResolved: true });
|
|
468
|
-
assert.equal(all.length, 1, "resolved visible with includeResolved");
|
|
469
|
-
assert.equal(all[0].resolved_winner, a.id);
|
|
470
|
-
store.close();
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
test("saveConflictPending dedupes the same pair regardless of order", () => {
|
|
474
|
-
const store = openMemory();
|
|
475
|
-
const a = store.save({ type: "decision", title: "截止", content: "x" });
|
|
476
|
-
const b = store.save({ type: "decision", title: "截止2", content: "y" });
|
|
477
|
-
const p1 = store.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "r1" });
|
|
478
|
-
const p2 = store.saveConflictPending({ memory_a: b.id, memory_b: a.id, reason: "r2" });
|
|
479
|
-
assert.equal(p2.id, p1.id, "same pair re-detected returns the existing pending row");
|
|
480
|
-
assert.equal(p2.reason, "r1", "original reason preserved");
|
|
481
|
-
assert.equal(store.listConflictPending().length, 1, "never a duplicate queue entry");
|
|
482
|
-
store.close();
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
test("countConflictPending counts unresolved rows only; resolve unknown id is undefined", () => {
|
|
486
|
-
const store = openMemory();
|
|
487
|
-
const a = store.save({ type: "decision", title: "a", content: "x" });
|
|
488
|
-
const b = store.save({ type: "decision", title: "b", content: "y" });
|
|
489
|
-
const c = store.save({ type: "decision", title: "c", content: "z" });
|
|
490
|
-
store.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "ab" });
|
|
491
|
-
const p2 = store.saveConflictPending({ memory_a: b.id, memory_b: c.id, reason: "bc" });
|
|
492
|
-
assert.equal(store.countConflictPending(), 2);
|
|
493
|
-
store.resolveConflictPending(p2.id, { winner: b.id });
|
|
494
|
-
assert.equal(store.countConflictPending(), 1, "resolved no longer pending");
|
|
495
|
-
assert.equal(store.resolveConflictPending("ghost"), undefined, "unknown id resolves to undefined");
|
|
496
|
-
store.close();
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
// Issue #533:differentiate 的裁决账本。正文不再追加注记,改由这张表记住「这一对
|
|
500
|
-
// 已经判过、两条都留」,候选构造阶段读它避免同一对每轮重判。
|
|
501
|
-
test("Issue #533: differentiated_pairs survives a reopen and normalizes pair order", () => {
|
|
502
|
-
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-diffpair-"));
|
|
503
|
-
const path = join(dir, "memory.db");
|
|
504
|
-
const s1 = createStore(path);
|
|
505
|
-
// 送审顺序与存储顺序相反也要落到同一行(账本按 id 排序归一)。
|
|
506
|
-
s1.saveDifferentiatedPair({ run_id: "r1", memory_a: "mb", memory_b: "ma", hash_a: "hb", hash_b: "ha", distinctions: ["只看甲", "只看乙"] });
|
|
507
|
-
assert.equal(s1.listDifferentiatedPairs().length, 1);
|
|
508
|
-
s1.close();
|
|
509
|
-
const s2 = createStore(path);
|
|
510
|
-
const found = s2.findDifferentiatedPair("ma", "mb");
|
|
511
|
-
assert.ok(found, "lookups work in either argument order");
|
|
512
|
-
assert.equal(found.memory_a, "ma", "the row is stored sorted");
|
|
513
|
-
assert.equal(found.hash_a, "ha", "hashes travel with their side");
|
|
514
|
-
assert.deepEqual(found.distinctions, ["只看甲", "只看乙"]);
|
|
515
|
-
assert.equal(s2.listDifferentiatedPairs().length, 1, "re-saving the same pair updates instead of duplicating");
|
|
516
|
-
s2.saveDifferentiatedPair({ run_id: "r2", memory_a: "ma", memory_b: "mb", hash_a: "ha2", hash_b: "hb2" });
|
|
517
|
-
assert.equal(s2.listDifferentiatedPairs().length, 1, "still one row for one pair");
|
|
518
|
-
const updated = s2.findDifferentiatedPair("ma", "mb");
|
|
519
|
-
assert.equal(updated.hash_a, "ha2", "the refresh overwrites the hashes");
|
|
520
|
-
assert.equal(updated.distinctions, undefined, "an empty distinctions lands as NULL, not as an empty array");
|
|
521
|
-
s2.close();
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
test("conflict_pending table persists across store reopen", () => {
|
|
525
|
-
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-conflict-"));
|
|
526
|
-
const path = join(dir, "memory.db");
|
|
527
|
-
const s1 = createStore(path);
|
|
528
|
-
s1.saveConflictPending({ run_id: "run-1", memory_a: "ma", memory_b: "mb", reason: "x" });
|
|
529
|
-
s1.close();
|
|
530
|
-
const s2 = createStore(path);
|
|
531
|
-
const pending = s2.listConflictPending();
|
|
532
|
-
assert.equal(pending.length, 1);
|
|
533
|
-
assert.equal(pending[0].reason, "x");
|
|
534
|
-
s2.close();
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
// Issue #128:回填候选只取活跃行——归档/遗忘行不参与召回与聚类,不该吃掉
|
|
538
|
-
// 回填配额(报告实测 50 个 reindex 名额全部落在归档行,88 条活跃记忆永远轮不到)。
|
|
539
|
-
test("Issue #128: needsEmbedding skips archived and forgotten rows", () => {
|
|
540
|
-
const store = openMemory();
|
|
541
|
-
const active = store.save({ type: "preference", title: "活跃", content: "内容" });
|
|
542
|
-
const archived = store.save({ type: "preference", title: "归档", content: "内容" });
|
|
543
|
-
const forgotten = store.save({ type: "preference", title: "遗忘", content: "内容" });
|
|
544
|
-
store.setArchived(archived.id, true);
|
|
545
|
-
store.setForget(forgotten.id, true);
|
|
546
|
-
const ids = store.needsEmbedding(50).map((r) => r.id);
|
|
547
|
-
assert.deepEqual(ids, [active.id], "only the active row is a backfill candidate");
|
|
548
|
-
store.close();
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
// Issue #495:listDreamRuns 的 excludeRunType 是**下推**条件而不是调用方后置过滤——
|
|
552
|
-
// 后置过滤在 limit 生效之后就晚了:5 个名额已经被 organize 占掉,再筛只能得到空。
|
|
553
|
-
// 三条一起钉:默认不过滤(审计面要看全量)、指定值真的排除、白名单之外的值不生效
|
|
554
|
-
// (这个参数拼进 SQL 的 != 条件,任意外来字符串不能改变查询形状)。
|
|
555
|
-
test("Issue #495: listDreamRuns excludes a run type by pushdown, and only accepts known types", () => {
|
|
556
|
-
const store = openMemory();
|
|
557
|
-
store.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "auto", receipt: "r1" });
|
|
558
|
-
store.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r2" });
|
|
559
|
-
store.saveDreamRun({ created_at: "2026-01-03T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r3" });
|
|
560
|
-
|
|
561
|
-
assert.equal(store.listDreamRuns().length, 3, "no filter by default — the audit surface keeps every run type");
|
|
562
|
-
assert.equal(store.listDreamRuns({ limit: 1 })[0].run_type, "organize", "limit applies to the full set");
|
|
563
|
-
|
|
564
|
-
const onlyConsolidation = store.listDreamRuns({ limit: 1, excludeRunType: "organize" });
|
|
565
|
-
assert.equal(onlyConsolidation.length, 1);
|
|
566
|
-
assert.equal(onlyConsolidation[0].run_type, "auto", "the pushdown must apply before LIMIT, not after");
|
|
567
|
-
|
|
568
|
-
// 白名单之外的值按「不过滤」处理,不静默返回空集。诚实说:这条断言钉得住
|
|
569
|
-
// 「未知值 ⇒ 不过滤」这个可观测契约,钉不住白名单本身——把 includes 判断换成裸绑定,
|
|
570
|
-
// 四条断言仍然全绿(不存在的 run_type 本来也筛不掉任何行)。白名单的价值是纵深:
|
|
571
|
-
// 值来自调用方时只允许已知的三个字面量进 SQL 的 != 条件,查询形状不随外部字符串改变。
|
|
572
|
-
assert.equal(store.listDreamRuns({ excludeRunType: "auto' OR 1=1 --" }).length, 3,
|
|
573
|
-
"a hostile value is bound as a parameter and filters nothing, never widens the query");
|
|
574
|
-
assert.equal(store.listDreamRuns({ excludeRunType: "nope" }).length, 3);
|
|
575
|
-
store.close();
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
// ============================ #512 蒸馏 run 归因
|
|
579
|
-
|
|
580
|
-
test("#512 迁移:老库补上 distill_run_id 列与两张新表,存量链接留 NULL 不猜", () => {
|
|
581
|
-
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run-"));
|
|
582
|
-
const dbPath = join(dir, "legacy.db");
|
|
583
|
-
try {
|
|
584
|
-
// 存量库:#399 时代的形状——memory_provenance 没有 distill_run_id,也没有
|
|
585
|
-
// distill_runs 与 memory_mints。这一行代表「改造之前就落盘的链接」,它没有 run 可指。
|
|
586
|
-
const legacy = new DatabaseSync(dbPath);
|
|
587
|
-
legacy.exec("CREATE TABLE memories (" +
|
|
588
|
-
"id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, " +
|
|
589
|
-
"tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3, " +
|
|
590
|
-
"forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, " +
|
|
591
|
-
"source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);");
|
|
592
|
-
legacy.exec("CREATE TABLE memory_provenance (" +
|
|
593
|
-
"memory_id TEXT NOT NULL, session_id TEXT NOT NULL, " +
|
|
594
|
-
"seq_start INTEGER NOT NULL, seq_end INTEGER NOT NULL, created_at TEXT NOT NULL);");
|
|
595
|
-
legacy.exec("INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, created_at, updated_at) " +
|
|
596
|
-
"VALUES ('old1', 'history', '老记忆', '内容', '[]', 3, 0, 0, NULL, '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z');");
|
|
597
|
-
legacy.exec("INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at) " +
|
|
598
|
-
"VALUES ('old1', 's-old', 1, 5, '2026-01-02T00:00:00.000Z');");
|
|
599
|
-
legacy.close();
|
|
600
|
-
|
|
601
|
-
const store = createStore(dbPath);
|
|
602
|
-
const cols = store.db.prepare("PRAGMA table_info(memory_provenance)").all().map((c) => c.name);
|
|
603
|
-
assert.ok(cols.includes("distill_run_id"), "存量库重开即补上 distill_run_id");
|
|
604
|
-
for (const table of ["distill_runs", "memory_mints"]) {
|
|
605
|
-
const row = store.db.prepare(
|
|
606
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name=?"
|
|
607
|
-
).get(table);
|
|
608
|
-
assert.ok(row, "存量库重开即建 " + table);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const links = store.provenanceFor("old1");
|
|
612
|
-
assert.equal(links.length, 1);
|
|
613
|
-
assert.equal(
|
|
614
|
-
links[0].distill_run_id,
|
|
615
|
-
null,
|
|
616
|
-
"历史链接保持 NULL:反推一个合成 run id 比不填更坏(读数正常、实际是猜的)"
|
|
617
|
-
);
|
|
618
|
-
assert.equal(store.mintsFor("old1").length, 0, "历史记忆不补铸造记录——那时根本没有 run 概念");
|
|
619
|
-
store.close();
|
|
620
|
-
} finally {
|
|
621
|
-
rmSync(dir, { recursive: true, force: true });
|
|
622
|
-
}
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
test("#512 迁移幂等:重开已升级的库不重复 ALTER、不动已落盘的 run 归因", () => {
|
|
626
|
-
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run2-"));
|
|
627
|
-
const dbPath = join(dir, "upgraded.db");
|
|
628
|
-
try {
|
|
629
|
-
const first = createStore(dbPath);
|
|
630
|
-
const m = first.save({ type: "history", title: "t", content: "c" });
|
|
631
|
-
first.saveDistillRun({
|
|
632
|
-
id: "run-a",
|
|
633
|
-
status: "ok",
|
|
634
|
-
run_type: "autoSummarize",
|
|
635
|
-
trigger_source: "autoSummarize",
|
|
636
|
-
session_id: "s1",
|
|
637
|
-
seq_start: 1,
|
|
638
|
-
seq_end: 2,
|
|
639
|
-
memory_count: 1
|
|
640
|
-
});
|
|
641
|
-
first.saveMemoryProvenance({
|
|
642
|
-
memoryId: m.id, sessionId: "s1", seqStart: 1, seqEnd: 2,
|
|
643
|
-
createdAt: "2026-11-01T00:00:00.000Z", distillRunId: "run-a"
|
|
644
|
-
});
|
|
645
|
-
first.close();
|
|
646
|
-
|
|
647
|
-
const second = createStore(dbPath);
|
|
648
|
-
const cols = second.db.prepare("PRAGMA table_info(memory_provenance)")
|
|
649
|
-
.all().filter((c) => c.name === "distill_run_id");
|
|
650
|
-
assert.equal(cols.length, 1, "列只存在一份,没被重复 ALTER");
|
|
651
|
-
assert.equal(second.provenanceFor(m.id)[0].distill_run_id, "run-a", "重开不动已落盘的归因");
|
|
652
|
-
assert.equal(second.getDistillRun("run-a").status, "ok");
|
|
653
|
-
second.close();
|
|
654
|
-
} finally {
|
|
655
|
-
rmSync(dir, { recursive: true, force: true });
|
|
656
|
-
}
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
test("#512 distill_runs:同 id replay 覆写而不重复", () => {
|
|
660
|
-
const store = openMemory();
|
|
661
|
-
store.saveDistillRun({
|
|
662
|
-
id: "r1", status: "failed", error: "boom",
|
|
663
|
-
run_type: "autoSummarize", trigger_source: "autoSummarize",
|
|
664
|
-
session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 0,
|
|
665
|
-
created_at: "2026-11-01T00:00:00.000Z"
|
|
666
|
-
});
|
|
667
|
-
// 同一轮重放(等价于宿主重启后重试同一逻辑轮):必须覆写,不能变成两行。
|
|
668
|
-
store.saveDistillRun({
|
|
669
|
-
id: "r1", status: "ok",
|
|
670
|
-
run_type: "autoSummarize", trigger_source: "autoSummarize",
|
|
671
|
-
session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 2,
|
|
672
|
-
created_at: "2026-11-01T00:00:00.000Z"
|
|
673
|
-
});
|
|
674
|
-
const runs = store.listDistillRuns();
|
|
675
|
-
assert.equal(runs.length, 1, "一轮只有一行");
|
|
676
|
-
assert.equal(runs[0].status, "ok", "replay 覆写成最新的事实");
|
|
677
|
-
assert.equal(runs[0].error, undefined, "覆写清掉上一次的错误");
|
|
678
|
-
assert.equal(runs[0].memory_count, 2);
|
|
679
|
-
assert.equal(runs[0].audit_id, undefined, "没有 audit 行时是 undefined,不是 0");
|
|
680
|
-
store.close();
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
test("#512 distill_runs:audit_id 事后补注,补不存在的 run 静默返回 0", () => {
|
|
684
|
-
const store = openMemory();
|
|
685
|
-
store.saveDistillRun({ id: "r2", status: "ok", run_type: "autoSummarize", trigger_source: "autoSummarize" });
|
|
686
|
-
assert.equal(store.attachDistillRunAudit("r2", 42), 1);
|
|
687
|
-
assert.equal(store.getDistillRun("r2").audit_id, 42);
|
|
688
|
-
assert.equal(store.attachDistillRunAudit("missing", 42), 0, "行不存在时不动任何东西");
|
|
689
|
-
assert.equal(store.attachDistillRunAudit("r2", null), 0, "非整数 audit id 一律不写");
|
|
690
|
-
store.close();
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
test("#512 memory_mints:同一 (记忆, run) 只记一次,删记忆时显式清掉归因", () => {
|
|
694
|
-
const store = openMemory();
|
|
695
|
-
const m = store.save({ type: "pattern", title: "抽出来的规律", content: "c", source: "dream" });
|
|
696
|
-
const mint = { memoryId: m.id, runId: "run-d", runType: "dream", triggerSource: "autoDream", createdAt: "2026-11-01T00:00:00.000Z" };
|
|
697
|
-
assert.equal(store.saveMemoryMint(mint), 1);
|
|
698
|
-
assert.equal(store.saveMemoryMint(mint), 0, "INSERT OR IGNORE:重放不重复记一次铸造");
|
|
699
|
-
const rows = store.mintsFor(m.id);
|
|
700
|
-
assert.equal(rows.length, 1);
|
|
701
|
-
assert.equal(rows[0].run_type, "dream");
|
|
702
|
-
assert.equal(rows[0].trigger_source, "autoDream");
|
|
703
|
-
|
|
704
|
-
store.remove(m.id);
|
|
705
|
-
assert.equal(
|
|
706
|
-
store.db.prepare("SELECT count(*) AS c FROM memory_mints WHERE memory_id = ?").get(m.id).c,
|
|
707
|
-
0,
|
|
708
|
-
"记忆没了,归因不许悬空(CASCADE 之外仍显式清,与 memory_provenance 同一口径)"
|
|
709
|
-
);
|
|
710
|
-
store.close();
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
// ============================ #493 补充:失败计数持久化(读侧失效)
|
|
714
|
-
// 这张表只服务一道默认关的护栏(summarizeParseFailureMaxRetries),但它的语义有三处
|
|
715
|
-
// 容易写错、且错法都不可见:计数不落库(重启即清零)、失效不在读侧(几周前的失败
|
|
716
|
-
// 直接熔断当前窗口)、清零写成 0(「从没失败过」与「刚清零」变得不可区分)。
|
|
717
|
-
test("#493 补充:解析失败计数持久化、读侧带失效窗口、清零是真 DELETE", () => {
|
|
718
|
-
const store = openMemory();
|
|
719
|
-
const sid = "s-streak";
|
|
720
|
-
assert.equal(store.getDistillParseFailureStreak(sid), 0, "没有记录就是 0,不是 undefined");
|
|
721
|
-
|
|
722
|
-
assert.equal(store.bumpDistillParseFailureStreak(sid), 1);
|
|
723
|
-
assert.equal(store.bumpDistillParseFailureStreak(sid), 2, "累计加一");
|
|
724
|
-
assert.equal(store.getDistillParseFailureStreak(sid), 2);
|
|
725
|
-
|
|
726
|
-
// 读侧失效:时间戳挪到 30 天前,7 天窗口下必须当 0(不判失效时它仍是 2)。
|
|
727
|
-
const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
728
|
-
store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, sid);
|
|
729
|
-
assert.equal(store.getDistillParseFailureStreak(sid), 2, "staleAfterMs 缺省不判失效(调用方显式要才判)");
|
|
730
|
-
assert.equal(store.getDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0,
|
|
731
|
-
"过期的 streak 必须读成 0:拿几周前的失败去熔断当前窗口,护栏就成了新的误伤路径");
|
|
732
|
-
|
|
733
|
-
// 失效之后再加一,必须从 1 起(不是在旧值上 +1)——这条是本块实现的首版 bug,
|
|
734
|
-
// 被 summarize.test.js 的端到端用例抓出来:SQL 里写 streak + 1 会让失效判断失效。
|
|
735
|
-
assert.equal(store.bumpDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 1,
|
|
736
|
-
"过期的旧行不许把「新的第一次失败」加成 2");
|
|
737
|
-
|
|
738
|
-
// 清零是真删除:没有任何在途失败时不该留一行 0。
|
|
739
|
-
assert.equal(store.clearDistillParseFailureStreak(sid), 1);
|
|
740
|
-
assert.equal(store.getDistillParseFailureStreak(sid), 0);
|
|
741
|
-
assert.equal(store.db.prepare("SELECT count(*) AS c FROM distill_parse_failures").get().c, 0,
|
|
742
|
-
"清零是 DELETE,不是写 0——写 0 会让「从没失败过」与「刚清零」不可区分");
|
|
743
|
-
store.close();
|
|
744
|
-
});
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createStore } from "../src/store.js";
|
|
8
|
+
|
|
9
|
+
function openMemory() {
|
|
10
|
+
return createStore(":memory:");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("createStore initializes schema and opens db", () => {
|
|
14
|
+
const store = openMemory();
|
|
15
|
+
const row = store.db.prepare(
|
|
16
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='memories'"
|
|
17
|
+
).get();
|
|
18
|
+
assert.ok(row, "memories table exists");
|
|
19
|
+
const cursorTable = store.db.prepare(
|
|
20
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='distill_cursors'"
|
|
21
|
+
).get();
|
|
22
|
+
assert.ok(cursorTable, "distill cursor table exists");
|
|
23
|
+
assert.equal(store.getDistillCursor("session-1"), undefined);
|
|
24
|
+
const saved = store.setDistillCursor("session-1", 4);
|
|
25
|
+
assert.equal(saved.last_seq, 4);
|
|
26
|
+
assert.ok(saved.updated_at);
|
|
27
|
+
assert.equal(store.setDistillCursor("session-1", 2).last_seq, 4, "cursor never moves backwards");
|
|
28
|
+
store.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("save inserts a memory and returns it with id/created_at", () => {
|
|
32
|
+
const store = openMemory();
|
|
33
|
+
const saved = store.save({
|
|
34
|
+
type: "preference",
|
|
35
|
+
title: "语言",
|
|
36
|
+
content: "用户用中文交流",
|
|
37
|
+
tags: ["偏好"],
|
|
38
|
+
importance: 5,
|
|
39
|
+
source: "manual"
|
|
40
|
+
});
|
|
41
|
+
assert.ok(saved.id, "has id");
|
|
42
|
+
assert.ok(saved.created_at, "has created_at");
|
|
43
|
+
assert.equal(saved.type, "preference");
|
|
44
|
+
assert.equal(store.count(), 1);
|
|
45
|
+
store.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("getById returns the memory", () => {
|
|
49
|
+
const store = openMemory();
|
|
50
|
+
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
51
|
+
const got = store.getById(saved.id);
|
|
52
|
+
assert.equal(got.title, "t");
|
|
53
|
+
assert.equal(got.content, "c");
|
|
54
|
+
store.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("update modifies fields and bumps updated_at", () => {
|
|
58
|
+
const store = openMemory();
|
|
59
|
+
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
60
|
+
const updated = store.update(saved.id, { content: "new content", importance: 4 });
|
|
61
|
+
assert.equal(updated.content, "new content");
|
|
62
|
+
assert.equal(updated.importance, 4);
|
|
63
|
+
assert.notEqual(updated.updated_at, saved.updated_at);
|
|
64
|
+
store.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("remove deletes the memory", () => {
|
|
68
|
+
const store = openMemory();
|
|
69
|
+
const saved = store.save({ type: "project", title: "t", content: "c" });
|
|
70
|
+
store.remove(saved.id);
|
|
71
|
+
assert.equal(store.getById(saved.id), undefined);
|
|
72
|
+
assert.equal(store.count(), 0);
|
|
73
|
+
store.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("list filters by type and paginates", () => {
|
|
77
|
+
const store = openMemory();
|
|
78
|
+
for (let i = 0; i < 5; i++) store.save({ type: "preference", title: `p${i}`, content: "c" });
|
|
79
|
+
for (let i = 0; i < 3; i++) store.save({ type: "project", title: `j${i}`, content: "c" });
|
|
80
|
+
assert.equal(store.list({ type: "preference" }).length, 5);
|
|
81
|
+
assert.equal(store.list({ type: "project" }).length, 3);
|
|
82
|
+
assert.equal(store.list({ limit: 2 }).length, 2);
|
|
83
|
+
assert.equal(store.list({ limit: 2, offset: 2 }).length, 2);
|
|
84
|
+
store.close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("setForget toggles injection suppression", () => {
|
|
88
|
+
const store = openMemory();
|
|
89
|
+
const saved = store.save({ type: "project", title: "t", content: "c", importance: 5 });
|
|
90
|
+
store.setForget(saved.id, true);
|
|
91
|
+
const got = store.getById(saved.id);
|
|
92
|
+
// toRow maps SQLite 0/1 to boolean
|
|
93
|
+
assert.equal(got.forgotten, true);
|
|
94
|
+
store.close();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("count excludes forgotten by default, includeForgotten opts in", () => {
|
|
98
|
+
const store = openMemory();
|
|
99
|
+
const a = store.save({ type: "project", title: "t1", content: "c" });
|
|
100
|
+
store.save({ type: "project", title: "t2", content: "c" });
|
|
101
|
+
store.save({ type: "preference", title: "p", content: "c" });
|
|
102
|
+
store.setForget(a.id, true);
|
|
103
|
+
assert.equal(store.count(), 2, "default excludes forgotten (matches list)");
|
|
104
|
+
assert.equal(store.count("project"), 1);
|
|
105
|
+
assert.equal(store.count("preference"), 1);
|
|
106
|
+
assert.equal(store.count(undefined, { includeForgotten: true }), 3);
|
|
107
|
+
assert.equal(store.count("project", { includeForgotten: true }), 2);
|
|
108
|
+
store.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("countByType: same filters, no type clause — the distribution sums to count(undefined) (#597)", () => {
|
|
112
|
+
const store = openMemory();
|
|
113
|
+
const a = store.save({ type: "project", title: "t1", content: "c" });
|
|
114
|
+
store.save({ type: "project", title: "t2", content: "c" });
|
|
115
|
+
store.save({ type: "preference", title: "p", content: "c", importance: 5 });
|
|
116
|
+
store.setForget(a.id, true);
|
|
117
|
+
const counts = store.countByType();
|
|
118
|
+
assert.equal(counts.project, 1, "forgotten rows excluded (same口径 as count)");
|
|
119
|
+
assert.equal(counts.preference, 1);
|
|
120
|
+
assert.equal(
|
|
121
|
+
Object.keys(counts).reduce((n, k) => n + counts[k], 0),
|
|
122
|
+
store.count(),
|
|
123
|
+
"distribution sums to count(undefined)"
|
|
124
|
+
);
|
|
125
|
+
// 筛选同口径:带 minImportance 的分布合计 = 带同筛选的 count
|
|
126
|
+
const filtered = store.countByType({ minImportance: 5 });
|
|
127
|
+
assert.deepEqual(filtered, { preference: 1 }, "distribution honors the same filters as count");
|
|
128
|
+
store.close();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("search matches title, content and tags", () => {
|
|
132
|
+
const store = openMemory();
|
|
133
|
+
store.save({ type: "project", title: "记忆插件", content: "c1", tags: [] });
|
|
134
|
+
store.save({ type: "project", title: "t2", content: "用户用中文交流", tags: [] });
|
|
135
|
+
store.save({ type: "preference", title: "t3", content: "c3", tags: ["偏好"] });
|
|
136
|
+
assert.ok(store.search("记忆插件").some((m) => m.title === "记忆插件"));
|
|
137
|
+
assert.ok(store.search("中文").some((m) => m.content === "用户用中文交流"));
|
|
138
|
+
assert.ok(store.search("偏好").some((m) => m.tags.includes("偏好")));
|
|
139
|
+
store.close();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("search with empty query returns []", () => {
|
|
143
|
+
const store = openMemory();
|
|
144
|
+
store.save({ type: "project", title: "t", content: "c" });
|
|
145
|
+
assert.deepEqual(store.search(""), []);
|
|
146
|
+
assert.deepEqual(store.search(" "), []);
|
|
147
|
+
store.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("forgotten memories are excluded from list and search until un-forgotten", () => {
|
|
151
|
+
const store = openMemory();
|
|
152
|
+
const saved = store.save({ type: "project", title: "秘密", content: "c", importance: 5 });
|
|
153
|
+
assert.ok(store.list().some((m) => m.id === saved.id));
|
|
154
|
+
assert.ok(store.search("秘密").some((m) => m.id === saved.id));
|
|
155
|
+
store.setForget(saved.id, true);
|
|
156
|
+
assert.ok(!store.list().some((m) => m.id === saved.id));
|
|
157
|
+
assert.ok(!store.search("秘密").some((m) => m.id === saved.id));
|
|
158
|
+
store.setForget(saved.id, false);
|
|
159
|
+
assert.ok(store.list().some((m) => m.id === saved.id));
|
|
160
|
+
assert.ok(store.search("秘密").some((m) => m.id === saved.id));
|
|
161
|
+
store.close();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("search matches CJK substring", () => {
|
|
165
|
+
const store = openMemory();
|
|
166
|
+
store.save({ type: "project", title: "t", content: "用户用中文交流" });
|
|
167
|
+
assert.equal(store.search("中文").length, 1);
|
|
168
|
+
store.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("search respects limit", () => {
|
|
172
|
+
const store = openMemory();
|
|
173
|
+
for (let i = 0; i < 3; i++) store.save({ type: "project", title: `m${i}`, content: "匹配词" });
|
|
174
|
+
assert.equal(store.search("匹配词").length, 3);
|
|
175
|
+
assert.equal(store.search("匹配词", { limit: 2 }).length, 2);
|
|
176
|
+
store.close();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("list sanitizes invalid limit/offset", () => {
|
|
180
|
+
const store = openMemory();
|
|
181
|
+
for (let i = 0; i < 3; i++) store.save({ type: "project", title: `t${i}`, content: "c" });
|
|
182
|
+
assert.equal(store.list({ limit: -1 }).length, 3);
|
|
183
|
+
assert.equal(store.list({ limit: 0 }).length, 3);
|
|
184
|
+
assert.equal(store.list({ limit: 1.5 }).length, 3);
|
|
185
|
+
assert.equal(store.list({ limit: 2, offset: -5 }).length, 2);
|
|
186
|
+
store.close();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("schema migration adds archived column to legacy database", () => {
|
|
190
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-"));
|
|
191
|
+
const dbPath = join(dir, "legacy.db");
|
|
192
|
+
try {
|
|
193
|
+
// Create a legacy db WITHOUT archived column
|
|
194
|
+
const legacy = new DatabaseSync(dbPath);
|
|
195
|
+
legacy.exec(`CREATE TABLE memories (
|
|
196
|
+
id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
|
|
197
|
+
tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3,
|
|
198
|
+
forgotten INTEGER NOT NULL DEFAULT 0, source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
199
|
+
);`);
|
|
200
|
+
legacy.close();
|
|
201
|
+
// Open with createStore → should ALTER TABLE
|
|
202
|
+
const store = createStore(dbPath);
|
|
203
|
+
const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
204
|
+
assert.ok(cols.includes("archived"), "archived column added");
|
|
205
|
+
const cursorTable = store.db.prepare(
|
|
206
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='distill_cursors'"
|
|
207
|
+
).get();
|
|
208
|
+
assert.ok(cursorTable, "new cursor table is created for legacy databases");
|
|
209
|
+
store.close();
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(dir, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("setArchived marks entry archived; list excludes it by default", () => {
|
|
216
|
+
const store = openMemory();
|
|
217
|
+
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
218
|
+
const archived = store.setArchived(saved.id, true);
|
|
219
|
+
assert.equal(archived.archived, true);
|
|
220
|
+
assert.equal(store.list().length, 0, "excluded from default list");
|
|
221
|
+
assert.equal(store.list({ includeArchived: true }).length, 1, "included with flag");
|
|
222
|
+
assert.equal(store.count(), 0, "excluded from default count");
|
|
223
|
+
assert.equal(store.count(undefined, { includeArchived: true }), 1);
|
|
224
|
+
store.close();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("search excludes archived by default", () => {
|
|
228
|
+
const store = openMemory();
|
|
229
|
+
const saved = store.save({ type: "project", title: "secret", content: "hidden content", importance: 3 });
|
|
230
|
+
store.setArchived(saved.id, true);
|
|
231
|
+
assert.equal(store.search("secret").length, 0);
|
|
232
|
+
assert.equal(store.search("secret", { includeArchived: true }).length, 1);
|
|
233
|
+
store.close();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ============================ #275 第二批:archived_at 精确归档时刻
|
|
237
|
+
|
|
238
|
+
test("schema migration adds archived_at column to legacy database", () => {
|
|
239
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-at-"));
|
|
240
|
+
const dbPath = join(dir, "legacy.db");
|
|
241
|
+
try {
|
|
242
|
+
// 存量库:有 archived 标志位,但没有 archived_at(#275 第一批时代的库)
|
|
243
|
+
const legacy = new DatabaseSync(dbPath);
|
|
244
|
+
legacy.exec(`CREATE TABLE memories (
|
|
245
|
+
id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
|
|
246
|
+
tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3,
|
|
247
|
+
forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
|
|
248
|
+
source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
249
|
+
);`);
|
|
250
|
+
legacy.exec(`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, created_at, updated_at)
|
|
251
|
+
VALUES ('old1', 'history', 't', 'c', '[]', 3, 0, 1, NULL, '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z');`);
|
|
252
|
+
legacy.close();
|
|
253
|
+
|
|
254
|
+
const store = createStore(dbPath);
|
|
255
|
+
const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
256
|
+
assert.ok(cols.includes("archived_at"), "archived_at column added");
|
|
257
|
+
// 存量归档行零重写:不回填 updated_at(那等于把被推翻的代理口径固化进列里)
|
|
258
|
+
const raw = store.db.prepare("SELECT archived_at FROM memories WHERE id = 'old1'").get();
|
|
259
|
+
assert.equal(raw.archived_at, null, "legacy archived row keeps NULL, not backfilled from updated_at");
|
|
260
|
+
assert.equal(store.getById("old1").archived_at, undefined, "NULL 读作 undefined,调用方须显式处理");
|
|
261
|
+
store.close();
|
|
262
|
+
} finally {
|
|
263
|
+
rmSync(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("archived_at migration is idempotent: reopening the upgraded database is a no-op", () => {
|
|
268
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-at2-"));
|
|
269
|
+
const dbPath = join(dir, "upgraded.db");
|
|
270
|
+
try {
|
|
271
|
+
const first = createStore(dbPath);
|
|
272
|
+
const m = first.save({ type: "history", title: "t", content: "c" });
|
|
273
|
+
first.setArchived(m.id, true);
|
|
274
|
+
const stamp = first.getById(m.id).archived_at;
|
|
275
|
+
first.close();
|
|
276
|
+
|
|
277
|
+
// 再开一次:addColumn 先 PRAGMA 后 ALTER,不该报错、不该重建、不该动已有值
|
|
278
|
+
const second = createStore(dbPath);
|
|
279
|
+
const cols = second.db.prepare("PRAGMA table_info(memories)").all().filter((c) => c.name === "archived_at");
|
|
280
|
+
assert.equal(cols.length, 1, "列只存在一份,没被重复 ALTER");
|
|
281
|
+
assert.equal(second.getById(m.id).archived_at, stamp, "重开不动已落盘的归档时刻");
|
|
282
|
+
second.close();
|
|
283
|
+
} finally {
|
|
284
|
+
rmSync(dir, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("setArchived stamps archived_at on archive, clears it on restore", () => {
|
|
289
|
+
const store = openMemory();
|
|
290
|
+
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
291
|
+
assert.equal(saved.archived_at, undefined, "新建行未归档 → 无时刻");
|
|
292
|
+
|
|
293
|
+
const archived = store.setArchived(saved.id, true);
|
|
294
|
+
assert.equal(archived.archived, true);
|
|
295
|
+
assert.ok(archived.archived_at, "活跃 → 归档落时刻");
|
|
296
|
+
assert.ok(
|
|
297
|
+
Math.abs(Date.parse(archived.archived_at) - Date.now()) < 5000,
|
|
298
|
+
"落的是当下时刻,不是别的行的 updated_at"
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const restored = store.setArchived(saved.id, false);
|
|
302
|
+
assert.equal(restored.archived, false);
|
|
303
|
+
assert.equal(restored.archived_at, undefined, "取消归档清空");
|
|
304
|
+
store.close();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("setArchived keeps updated_at semantics (existing readers depend on it)", () => {
|
|
308
|
+
// archived_at 是新增列,不是 updated_at 的替代品:面板排序、快照哈希、CAS 都读
|
|
309
|
+
// updated_at,本函数自 v0.2 起就无条件刷它——改口径不得顺手改掉这条既有语义。
|
|
310
|
+
const store = openMemory();
|
|
311
|
+
const saved = store.save({ type: "project", title: "t", content: "c", importance: 3 });
|
|
312
|
+
const before = store.getById(saved.id).updated_at;
|
|
313
|
+
const archived = store.setArchived(saved.id, true);
|
|
314
|
+
assert.ok(Date.parse(archived.updated_at) >= Date.parse(before), "updated_at 照旧被刷");
|
|
315
|
+
const again = store.setArchived(saved.id, false);
|
|
316
|
+
assert.ok(Date.parse(again.updated_at) >= Date.parse(archived.updated_at), "取消归档也刷");
|
|
317
|
+
store.close();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("save with archived:true stamps archived_at at insert time", () => {
|
|
321
|
+
// 铸造即归档的行:archived=1 而 archived_at=NULL 会落进「存量行」语义,读侧只能
|
|
322
|
+
// 当它时刻不可追溯——明明是当下发生的归档,白丢一条记录。
|
|
323
|
+
const store = openMemory();
|
|
324
|
+
const born = store.save({ type: "history", title: "生来就归档", content: "c", archived: true });
|
|
325
|
+
assert.equal(born.archived, true);
|
|
326
|
+
assert.ok(born.archived_at, "入库即归档 → 归档时刻 = 入库时刻");
|
|
327
|
+
assert.equal(born.archived_at, born.created_at, "两列同源(同一个 nowIso)");
|
|
328
|
+
|
|
329
|
+
const live = store.save({ type: "history", title: "活跃", content: "c" });
|
|
330
|
+
assert.equal(live.archived_at, undefined);
|
|
331
|
+
store.close();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("update() cannot set archived_at (archived is not an updatable field)", () => {
|
|
335
|
+
// 归档时刻只能由 setArchived 落。若 update 的 patch 能写它,就等于给精确口径开了
|
|
336
|
+
// 一个绕过 setArchived 语义(幂等 / 清空)的后门,代理口径会从缝里长回来。
|
|
337
|
+
const store = openMemory();
|
|
338
|
+
const saved = store.save({ type: "history", title: "t", content: "c" });
|
|
339
|
+
store.update(saved.id, { archived_at: "2020-01-01T00:00:00.000Z", content: "改过" });
|
|
340
|
+
assert.equal(store.getById(saved.id).archived_at, undefined, "patch 里的 archived_at 被忽略");
|
|
341
|
+
store.close();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// --- vector search ---
|
|
345
|
+
|
|
346
|
+
test("save/update persist embedding vector and searchVector ranks by cosine", () => {
|
|
347
|
+
const store = openMemory();
|
|
348
|
+
const a = store.save({ type: "preference", title: "猫", content: "喜欢猫", embedding: [1, 0, 0] });
|
|
349
|
+
const b = store.save({ type: "preference", title: "狗", content: "喜欢狗", embedding: [0, 1, 0] });
|
|
350
|
+
const c = store.save({ type: "preference", title: "猫狗", content: "都养", embedding: [0.9, 0.1, 0] });
|
|
351
|
+
|
|
352
|
+
const hits = store.searchVector([1, 0, 0], { limit: 3 });
|
|
353
|
+
assert.deepEqual(hits.map((m) => m.id), [a.id, c.id, b.id]);
|
|
354
|
+
assert.equal(hits[0].score, 1);
|
|
355
|
+
assert.ok(hits[1].score > hits[2].score);
|
|
356
|
+
|
|
357
|
+
// update re-stores embedding
|
|
358
|
+
store.update(b.id, { embedding: [1, 1, 0] });
|
|
359
|
+
const hits2 = store.searchVector([1, 1, 0], { limit: 3 });
|
|
360
|
+
assert.equal(hits2[0].id, b.id);
|
|
361
|
+
store.close();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("searchVector only considers rows with an embedding and filters forgotten/archived", () => {
|
|
365
|
+
const store = openMemory();
|
|
366
|
+
const plain = store.save({ type: "project", title: "无向量", content: "x" });
|
|
367
|
+
const withVec = store.save({ type: "project", title: "有向量", content: "y", embedding: [1, 0, 0] });
|
|
368
|
+
store.setForget(withVec.id, true);
|
|
369
|
+
assert.deepEqual(store.searchVector([1, 0, 0]).map((m) => m.id), []);
|
|
370
|
+
store.setForget(withVec.id, false);
|
|
371
|
+
store.setArchived(withVec.id, true);
|
|
372
|
+
assert.deepEqual(store.searchVector([1, 0, 0]).map((m) => m.id), []);
|
|
373
|
+
store.setArchived(withVec.id, false);
|
|
374
|
+
assert.deepEqual(store.searchVector([1, 0, 0]).map((m) => m.id), [withVec.id]);
|
|
375
|
+
assert.equal(plain.id, plain.id, "plain row keeps id");
|
|
376
|
+
store.close();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("setEmbedding, embeddedCount, needsEmbedding and threshold filtering", () => {
|
|
380
|
+
const store = openMemory();
|
|
381
|
+
const t1 = store.save({ type: "project", title: "t1", content: "c1" });
|
|
382
|
+
const m2 = store.save({ type: "project", title: "t2", content: "c2" });
|
|
383
|
+
assert.equal(store.embeddedCount(), 0);
|
|
384
|
+
store.setEmbedding(m2.id, [0, 1]);
|
|
385
|
+
assert.equal(store.embeddedCount(), 1);
|
|
386
|
+
// Status-card denominator alignment: count() defaults exclude archived and
|
|
387
|
+
// forgotten rows, so embeddedCount must drop them too.
|
|
388
|
+
store.setArchived(m2.id, true);
|
|
389
|
+
assert.equal(store.embeddedCount(), 0, "archived rows leave the embedded count");
|
|
390
|
+
store.setArchived(m2.id, false);
|
|
391
|
+
store.setForget(m2.id, true);
|
|
392
|
+
assert.equal(store.embeddedCount(), 0, "forgotten rows leave the embedded count");
|
|
393
|
+
store.setForget(m2.id, false);
|
|
394
|
+
assert.equal(store.embeddedCount(), 1);
|
|
395
|
+
const missing = store.needsEmbedding(10);
|
|
396
|
+
assert.equal(missing.length, 1);
|
|
397
|
+
assert.equal(missing[0].id, t1.id, "only the non-embedded row is listed");
|
|
398
|
+
|
|
399
|
+
const t3 = store.save({ type: "project", title: "t3", content: "c3", embedding: [1, 1] });
|
|
400
|
+
// threshold 0.99: only t3 (cos=1) survives; m2 ([0,1]) scores ~0.707.
|
|
401
|
+
const near = store.searchVector([1, 1], { limit: 5, threshold: 0.99 });
|
|
402
|
+
assert.deepEqual(near.map((m) => m.id), [t3.id]);
|
|
403
|
+
store.close();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("save/update preserve archived flag", () => {
|
|
407
|
+
const store = openMemory();
|
|
408
|
+
const saved = store.save({ type: "project", title: "t", content: "c" });
|
|
409
|
+
store.setArchived(saved.id, true);
|
|
410
|
+
const updated = store.update(saved.id, { content: "new" });
|
|
411
|
+
assert.equal(updated.archived, true, "update keeps archived");
|
|
412
|
+
store.close();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// --- compare-and-set update (item ①/③) ------------------------------------
|
|
416
|
+
|
|
417
|
+
test("compareAndUpdate applies when the version token still matches", () => {
|
|
418
|
+
const store = openMemory();
|
|
419
|
+
const saved = store.save({ type: "project", title: "t", content: "count=0" });
|
|
420
|
+
const before = store.getById(saved.id);
|
|
421
|
+
const updated = store.compareAndUpdate(saved.id, before.updated_at, { content: "count=1" });
|
|
422
|
+
assert.ok(updated, "CAS with the current version succeeds");
|
|
423
|
+
assert.equal(updated.content, "count=1");
|
|
424
|
+
assert.notEqual(updated.updated_at, before.updated_at, "version token advances");
|
|
425
|
+
store.close();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("compareAndUpdate rejects a stale version token without writing", () => {
|
|
429
|
+
const store = openMemory();
|
|
430
|
+
const saved = store.save({ type: "project", title: "t", content: "count=0" });
|
|
431
|
+
const stale = store.getById(saved.id).updated_at;
|
|
432
|
+
store.update(saved.id, { content: "count=1" }); // concurrent write wins
|
|
433
|
+
const result = store.compareAndUpdate(saved.id, stale, { content: "count=2" });
|
|
434
|
+
assert.equal(result, undefined, "stale CAS is a miss");
|
|
435
|
+
assert.equal(store.getById(saved.id).content, "count=1", "no lost update");
|
|
436
|
+
store.close();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("compareAndUpdate on unknown id throws like update", () => {
|
|
440
|
+
const store = openMemory();
|
|
441
|
+
assert.throws(() => store.compareAndUpdate("ghost", "any", { content: "x" }), /not found/);
|
|
442
|
+
store.close();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// --- conflict freeze: pending manual review -------------------------------
|
|
446
|
+
|
|
447
|
+
test("saveConflictPending inserts and listConflictPending excludes resolved by default", () => {
|
|
448
|
+
const store = openMemory();
|
|
449
|
+
const a = store.save({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
450
|
+
const b = store.save({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
451
|
+
const pending = store.saveConflictPending({ run_id: "run-1", memory_a: a.id, memory_b: b.id, reason: "日期更新" });
|
|
452
|
+
assert.ok(pending.id, "has id");
|
|
453
|
+
assert.equal(pending.run_id, "run-1");
|
|
454
|
+
assert.equal(pending.reason, "日期更新");
|
|
455
|
+
assert.ok(pending.created_at);
|
|
456
|
+
assert.equal(pending.resolved_at, undefined);
|
|
457
|
+
|
|
458
|
+
const list = store.listConflictPending();
|
|
459
|
+
assert.equal(list.length, 1);
|
|
460
|
+
assert.ok([list[0].memory_a, list[0].memory_b].includes(a.id), "pair holds both sides");
|
|
461
|
+
assert.ok([list[0].memory_a, list[0].memory_b].includes(b.id));
|
|
462
|
+
|
|
463
|
+
const resolved = store.resolveConflictPending(pending.id, { winner: a.id });
|
|
464
|
+
assert.ok(resolved.resolved_at, "resolution stamped");
|
|
465
|
+
assert.equal(resolved.resolved_winner, a.id);
|
|
466
|
+
assert.equal(store.listConflictPending().length, 0, "resolved excluded by default");
|
|
467
|
+
const all = store.listConflictPending({ includeResolved: true });
|
|
468
|
+
assert.equal(all.length, 1, "resolved visible with includeResolved");
|
|
469
|
+
assert.equal(all[0].resolved_winner, a.id);
|
|
470
|
+
store.close();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("saveConflictPending dedupes the same pair regardless of order", () => {
|
|
474
|
+
const store = openMemory();
|
|
475
|
+
const a = store.save({ type: "decision", title: "截止", content: "x" });
|
|
476
|
+
const b = store.save({ type: "decision", title: "截止2", content: "y" });
|
|
477
|
+
const p1 = store.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "r1" });
|
|
478
|
+
const p2 = store.saveConflictPending({ memory_a: b.id, memory_b: a.id, reason: "r2" });
|
|
479
|
+
assert.equal(p2.id, p1.id, "same pair re-detected returns the existing pending row");
|
|
480
|
+
assert.equal(p2.reason, "r1", "original reason preserved");
|
|
481
|
+
assert.equal(store.listConflictPending().length, 1, "never a duplicate queue entry");
|
|
482
|
+
store.close();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("countConflictPending counts unresolved rows only; resolve unknown id is undefined", () => {
|
|
486
|
+
const store = openMemory();
|
|
487
|
+
const a = store.save({ type: "decision", title: "a", content: "x" });
|
|
488
|
+
const b = store.save({ type: "decision", title: "b", content: "y" });
|
|
489
|
+
const c = store.save({ type: "decision", title: "c", content: "z" });
|
|
490
|
+
store.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "ab" });
|
|
491
|
+
const p2 = store.saveConflictPending({ memory_a: b.id, memory_b: c.id, reason: "bc" });
|
|
492
|
+
assert.equal(store.countConflictPending(), 2);
|
|
493
|
+
store.resolveConflictPending(p2.id, { winner: b.id });
|
|
494
|
+
assert.equal(store.countConflictPending(), 1, "resolved no longer pending");
|
|
495
|
+
assert.equal(store.resolveConflictPending("ghost"), undefined, "unknown id resolves to undefined");
|
|
496
|
+
store.close();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Issue #533:differentiate 的裁决账本。正文不再追加注记,改由这张表记住「这一对
|
|
500
|
+
// 已经判过、两条都留」,候选构造阶段读它避免同一对每轮重判。
|
|
501
|
+
test("Issue #533: differentiated_pairs survives a reopen and normalizes pair order", () => {
|
|
502
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-diffpair-"));
|
|
503
|
+
const path = join(dir, "memory.db");
|
|
504
|
+
const s1 = createStore(path);
|
|
505
|
+
// 送审顺序与存储顺序相反也要落到同一行(账本按 id 排序归一)。
|
|
506
|
+
s1.saveDifferentiatedPair({ run_id: "r1", memory_a: "mb", memory_b: "ma", hash_a: "hb", hash_b: "ha", distinctions: ["只看甲", "只看乙"] });
|
|
507
|
+
assert.equal(s1.listDifferentiatedPairs().length, 1);
|
|
508
|
+
s1.close();
|
|
509
|
+
const s2 = createStore(path);
|
|
510
|
+
const found = s2.findDifferentiatedPair("ma", "mb");
|
|
511
|
+
assert.ok(found, "lookups work in either argument order");
|
|
512
|
+
assert.equal(found.memory_a, "ma", "the row is stored sorted");
|
|
513
|
+
assert.equal(found.hash_a, "ha", "hashes travel with their side");
|
|
514
|
+
assert.deepEqual(found.distinctions, ["只看甲", "只看乙"]);
|
|
515
|
+
assert.equal(s2.listDifferentiatedPairs().length, 1, "re-saving the same pair updates instead of duplicating");
|
|
516
|
+
s2.saveDifferentiatedPair({ run_id: "r2", memory_a: "ma", memory_b: "mb", hash_a: "ha2", hash_b: "hb2" });
|
|
517
|
+
assert.equal(s2.listDifferentiatedPairs().length, 1, "still one row for one pair");
|
|
518
|
+
const updated = s2.findDifferentiatedPair("ma", "mb");
|
|
519
|
+
assert.equal(updated.hash_a, "ha2", "the refresh overwrites the hashes");
|
|
520
|
+
assert.equal(updated.distinctions, undefined, "an empty distinctions lands as NULL, not as an empty array");
|
|
521
|
+
s2.close();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("conflict_pending table persists across store reopen", () => {
|
|
525
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-conflict-"));
|
|
526
|
+
const path = join(dir, "memory.db");
|
|
527
|
+
const s1 = createStore(path);
|
|
528
|
+
s1.saveConflictPending({ run_id: "run-1", memory_a: "ma", memory_b: "mb", reason: "x" });
|
|
529
|
+
s1.close();
|
|
530
|
+
const s2 = createStore(path);
|
|
531
|
+
const pending = s2.listConflictPending();
|
|
532
|
+
assert.equal(pending.length, 1);
|
|
533
|
+
assert.equal(pending[0].reason, "x");
|
|
534
|
+
s2.close();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// Issue #128:回填候选只取活跃行——归档/遗忘行不参与召回与聚类,不该吃掉
|
|
538
|
+
// 回填配额(报告实测 50 个 reindex 名额全部落在归档行,88 条活跃记忆永远轮不到)。
|
|
539
|
+
test("Issue #128: needsEmbedding skips archived and forgotten rows", () => {
|
|
540
|
+
const store = openMemory();
|
|
541
|
+
const active = store.save({ type: "preference", title: "活跃", content: "内容" });
|
|
542
|
+
const archived = store.save({ type: "preference", title: "归档", content: "内容" });
|
|
543
|
+
const forgotten = store.save({ type: "preference", title: "遗忘", content: "内容" });
|
|
544
|
+
store.setArchived(archived.id, true);
|
|
545
|
+
store.setForget(forgotten.id, true);
|
|
546
|
+
const ids = store.needsEmbedding(50).map((r) => r.id);
|
|
547
|
+
assert.deepEqual(ids, [active.id], "only the active row is a backfill candidate");
|
|
548
|
+
store.close();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Issue #495:listDreamRuns 的 excludeRunType 是**下推**条件而不是调用方后置过滤——
|
|
552
|
+
// 后置过滤在 limit 生效之后就晚了:5 个名额已经被 organize 占掉,再筛只能得到空。
|
|
553
|
+
// 三条一起钉:默认不过滤(审计面要看全量)、指定值真的排除、白名单之外的值不生效
|
|
554
|
+
// (这个参数拼进 SQL 的 != 条件,任意外来字符串不能改变查询形状)。
|
|
555
|
+
test("Issue #495: listDreamRuns excludes a run type by pushdown, and only accepts known types", () => {
|
|
556
|
+
const store = openMemory();
|
|
557
|
+
store.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "auto", receipt: "r1" });
|
|
558
|
+
store.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r2" });
|
|
559
|
+
store.saveDreamRun({ created_at: "2026-01-03T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r3" });
|
|
560
|
+
|
|
561
|
+
assert.equal(store.listDreamRuns().length, 3, "no filter by default — the audit surface keeps every run type");
|
|
562
|
+
assert.equal(store.listDreamRuns({ limit: 1 })[0].run_type, "organize", "limit applies to the full set");
|
|
563
|
+
|
|
564
|
+
const onlyConsolidation = store.listDreamRuns({ limit: 1, excludeRunType: "organize" });
|
|
565
|
+
assert.equal(onlyConsolidation.length, 1);
|
|
566
|
+
assert.equal(onlyConsolidation[0].run_type, "auto", "the pushdown must apply before LIMIT, not after");
|
|
567
|
+
|
|
568
|
+
// 白名单之外的值按「不过滤」处理,不静默返回空集。诚实说:这条断言钉得住
|
|
569
|
+
// 「未知值 ⇒ 不过滤」这个可观测契约,钉不住白名单本身——把 includes 判断换成裸绑定,
|
|
570
|
+
// 四条断言仍然全绿(不存在的 run_type 本来也筛不掉任何行)。白名单的价值是纵深:
|
|
571
|
+
// 值来自调用方时只允许已知的三个字面量进 SQL 的 != 条件,查询形状不随外部字符串改变。
|
|
572
|
+
assert.equal(store.listDreamRuns({ excludeRunType: "auto' OR 1=1 --" }).length, 3,
|
|
573
|
+
"a hostile value is bound as a parameter and filters nothing, never widens the query");
|
|
574
|
+
assert.equal(store.listDreamRuns({ excludeRunType: "nope" }).length, 3);
|
|
575
|
+
store.close();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// ============================ #512 蒸馏 run 归因
|
|
579
|
+
|
|
580
|
+
test("#512 迁移:老库补上 distill_run_id 列与两张新表,存量链接留 NULL 不猜", () => {
|
|
581
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run-"));
|
|
582
|
+
const dbPath = join(dir, "legacy.db");
|
|
583
|
+
try {
|
|
584
|
+
// 存量库:#399 时代的形状——memory_provenance 没有 distill_run_id,也没有
|
|
585
|
+
// distill_runs 与 memory_mints。这一行代表「改造之前就落盘的链接」,它没有 run 可指。
|
|
586
|
+
const legacy = new DatabaseSync(dbPath);
|
|
587
|
+
legacy.exec("CREATE TABLE memories (" +
|
|
588
|
+
"id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, " +
|
|
589
|
+
"tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3, " +
|
|
590
|
+
"forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, " +
|
|
591
|
+
"source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);");
|
|
592
|
+
legacy.exec("CREATE TABLE memory_provenance (" +
|
|
593
|
+
"memory_id TEXT NOT NULL, session_id TEXT NOT NULL, " +
|
|
594
|
+
"seq_start INTEGER NOT NULL, seq_end INTEGER NOT NULL, created_at TEXT NOT NULL);");
|
|
595
|
+
legacy.exec("INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, created_at, updated_at) " +
|
|
596
|
+
"VALUES ('old1', 'history', '老记忆', '内容', '[]', 3, 0, 0, NULL, '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z');");
|
|
597
|
+
legacy.exec("INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at) " +
|
|
598
|
+
"VALUES ('old1', 's-old', 1, 5, '2026-01-02T00:00:00.000Z');");
|
|
599
|
+
legacy.close();
|
|
600
|
+
|
|
601
|
+
const store = createStore(dbPath);
|
|
602
|
+
const cols = store.db.prepare("PRAGMA table_info(memory_provenance)").all().map((c) => c.name);
|
|
603
|
+
assert.ok(cols.includes("distill_run_id"), "存量库重开即补上 distill_run_id");
|
|
604
|
+
for (const table of ["distill_runs", "memory_mints"]) {
|
|
605
|
+
const row = store.db.prepare(
|
|
606
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?"
|
|
607
|
+
).get(table);
|
|
608
|
+
assert.ok(row, "存量库重开即建 " + table);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const links = store.provenanceFor("old1");
|
|
612
|
+
assert.equal(links.length, 1);
|
|
613
|
+
assert.equal(
|
|
614
|
+
links[0].distill_run_id,
|
|
615
|
+
null,
|
|
616
|
+
"历史链接保持 NULL:反推一个合成 run id 比不填更坏(读数正常、实际是猜的)"
|
|
617
|
+
);
|
|
618
|
+
assert.equal(store.mintsFor("old1").length, 0, "历史记忆不补铸造记录——那时根本没有 run 概念");
|
|
619
|
+
store.close();
|
|
620
|
+
} finally {
|
|
621
|
+
rmSync(dir, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("#512 迁移幂等:重开已升级的库不重复 ALTER、不动已落盘的 run 归因", () => {
|
|
626
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run2-"));
|
|
627
|
+
const dbPath = join(dir, "upgraded.db");
|
|
628
|
+
try {
|
|
629
|
+
const first = createStore(dbPath);
|
|
630
|
+
const m = first.save({ type: "history", title: "t", content: "c" });
|
|
631
|
+
first.saveDistillRun({
|
|
632
|
+
id: "run-a",
|
|
633
|
+
status: "ok",
|
|
634
|
+
run_type: "autoSummarize",
|
|
635
|
+
trigger_source: "autoSummarize",
|
|
636
|
+
session_id: "s1",
|
|
637
|
+
seq_start: 1,
|
|
638
|
+
seq_end: 2,
|
|
639
|
+
memory_count: 1
|
|
640
|
+
});
|
|
641
|
+
first.saveMemoryProvenance({
|
|
642
|
+
memoryId: m.id, sessionId: "s1", seqStart: 1, seqEnd: 2,
|
|
643
|
+
createdAt: "2026-11-01T00:00:00.000Z", distillRunId: "run-a"
|
|
644
|
+
});
|
|
645
|
+
first.close();
|
|
646
|
+
|
|
647
|
+
const second = createStore(dbPath);
|
|
648
|
+
const cols = second.db.prepare("PRAGMA table_info(memory_provenance)")
|
|
649
|
+
.all().filter((c) => c.name === "distill_run_id");
|
|
650
|
+
assert.equal(cols.length, 1, "列只存在一份,没被重复 ALTER");
|
|
651
|
+
assert.equal(second.provenanceFor(m.id)[0].distill_run_id, "run-a", "重开不动已落盘的归因");
|
|
652
|
+
assert.equal(second.getDistillRun("run-a").status, "ok");
|
|
653
|
+
second.close();
|
|
654
|
+
} finally {
|
|
655
|
+
rmSync(dir, { recursive: true, force: true });
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("#512 distill_runs:同 id replay 覆写而不重复", () => {
|
|
660
|
+
const store = openMemory();
|
|
661
|
+
store.saveDistillRun({
|
|
662
|
+
id: "r1", status: "failed", error: "boom",
|
|
663
|
+
run_type: "autoSummarize", trigger_source: "autoSummarize",
|
|
664
|
+
session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 0,
|
|
665
|
+
created_at: "2026-11-01T00:00:00.000Z"
|
|
666
|
+
});
|
|
667
|
+
// 同一轮重放(等价于宿主重启后重试同一逻辑轮):必须覆写,不能变成两行。
|
|
668
|
+
store.saveDistillRun({
|
|
669
|
+
id: "r1", status: "ok",
|
|
670
|
+
run_type: "autoSummarize", trigger_source: "autoSummarize",
|
|
671
|
+
session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 2,
|
|
672
|
+
created_at: "2026-11-01T00:00:00.000Z"
|
|
673
|
+
});
|
|
674
|
+
const runs = store.listDistillRuns();
|
|
675
|
+
assert.equal(runs.length, 1, "一轮只有一行");
|
|
676
|
+
assert.equal(runs[0].status, "ok", "replay 覆写成最新的事实");
|
|
677
|
+
assert.equal(runs[0].error, undefined, "覆写清掉上一次的错误");
|
|
678
|
+
assert.equal(runs[0].memory_count, 2);
|
|
679
|
+
assert.equal(runs[0].audit_id, undefined, "没有 audit 行时是 undefined,不是 0");
|
|
680
|
+
store.close();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("#512 distill_runs:audit_id 事后补注,补不存在的 run 静默返回 0", () => {
|
|
684
|
+
const store = openMemory();
|
|
685
|
+
store.saveDistillRun({ id: "r2", status: "ok", run_type: "autoSummarize", trigger_source: "autoSummarize" });
|
|
686
|
+
assert.equal(store.attachDistillRunAudit("r2", 42), 1);
|
|
687
|
+
assert.equal(store.getDistillRun("r2").audit_id, 42);
|
|
688
|
+
assert.equal(store.attachDistillRunAudit("missing", 42), 0, "行不存在时不动任何东西");
|
|
689
|
+
assert.equal(store.attachDistillRunAudit("r2", null), 0, "非整数 audit id 一律不写");
|
|
690
|
+
store.close();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("#512 memory_mints:同一 (记忆, run) 只记一次,删记忆时显式清掉归因", () => {
|
|
694
|
+
const store = openMemory();
|
|
695
|
+
const m = store.save({ type: "pattern", title: "抽出来的规律", content: "c", source: "dream" });
|
|
696
|
+
const mint = { memoryId: m.id, runId: "run-d", runType: "dream", triggerSource: "autoDream", createdAt: "2026-11-01T00:00:00.000Z" };
|
|
697
|
+
assert.equal(store.saveMemoryMint(mint), 1);
|
|
698
|
+
assert.equal(store.saveMemoryMint(mint), 0, "INSERT OR IGNORE:重放不重复记一次铸造");
|
|
699
|
+
const rows = store.mintsFor(m.id);
|
|
700
|
+
assert.equal(rows.length, 1);
|
|
701
|
+
assert.equal(rows[0].run_type, "dream");
|
|
702
|
+
assert.equal(rows[0].trigger_source, "autoDream");
|
|
703
|
+
|
|
704
|
+
store.remove(m.id);
|
|
705
|
+
assert.equal(
|
|
706
|
+
store.db.prepare("SELECT count(*) AS c FROM memory_mints WHERE memory_id = ?").get(m.id).c,
|
|
707
|
+
0,
|
|
708
|
+
"记忆没了,归因不许悬空(CASCADE 之外仍显式清,与 memory_provenance 同一口径)"
|
|
709
|
+
);
|
|
710
|
+
store.close();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// ============================ #493 补充:失败计数持久化(读侧失效)
|
|
714
|
+
// 这张表只服务一道默认关的护栏(summarizeParseFailureMaxRetries),但它的语义有三处
|
|
715
|
+
// 容易写错、且错法都不可见:计数不落库(重启即清零)、失效不在读侧(几周前的失败
|
|
716
|
+
// 直接熔断当前窗口)、清零写成 0(「从没失败过」与「刚清零」变得不可区分)。
|
|
717
|
+
test("#493 补充:解析失败计数持久化、读侧带失效窗口、清零是真 DELETE", () => {
|
|
718
|
+
const store = openMemory();
|
|
719
|
+
const sid = "s-streak";
|
|
720
|
+
assert.equal(store.getDistillParseFailureStreak(sid), 0, "没有记录就是 0,不是 undefined");
|
|
721
|
+
|
|
722
|
+
assert.equal(store.bumpDistillParseFailureStreak(sid), 1);
|
|
723
|
+
assert.equal(store.bumpDistillParseFailureStreak(sid), 2, "累计加一");
|
|
724
|
+
assert.equal(store.getDistillParseFailureStreak(sid), 2);
|
|
725
|
+
|
|
726
|
+
// 读侧失效:时间戳挪到 30 天前,7 天窗口下必须当 0(不判失效时它仍是 2)。
|
|
727
|
+
const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
728
|
+
store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, sid);
|
|
729
|
+
assert.equal(store.getDistillParseFailureStreak(sid), 2, "staleAfterMs 缺省不判失效(调用方显式要才判)");
|
|
730
|
+
assert.equal(store.getDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0,
|
|
731
|
+
"过期的 streak 必须读成 0:拿几周前的失败去熔断当前窗口,护栏就成了新的误伤路径");
|
|
732
|
+
|
|
733
|
+
// 失效之后再加一,必须从 1 起(不是在旧值上 +1)——这条是本块实现的首版 bug,
|
|
734
|
+
// 被 summarize.test.js 的端到端用例抓出来:SQL 里写 streak + 1 会让失效判断失效。
|
|
735
|
+
assert.equal(store.bumpDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 1,
|
|
736
|
+
"过期的旧行不许把「新的第一次失败」加成 2");
|
|
737
|
+
|
|
738
|
+
// 清零是真删除:没有任何在途失败时不该留一行 0。
|
|
739
|
+
assert.equal(store.clearDistillParseFailureStreak(sid), 1);
|
|
740
|
+
assert.equal(store.getDistillParseFailureStreak(sid), 0);
|
|
741
|
+
assert.equal(store.db.prepare("SELECT count(*) AS c FROM distill_parse_failures").get().c, 0,
|
|
742
|
+
"清零是 DELETE,不是写 0——写 0 会让「从没失败过」与「刚清零」不可区分");
|
|
743
|
+
store.close();
|
|
744
|
+
});
|