From 8b0735e89f226fe06db7776b83e8942bfa7ced2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 03:05:47 -0700 Subject: [PATCH] refactor(memory)!: remove the QMD backend; builtin is the only memory engine (#120936) * refactor(memory): remove qmd backend Make builtin the sole memory-core engine, rename the retained session helper barrel, retire QMD config with doctor migrations, and remove QMD runtime/UI/policy surfaces. * docs(memory): remove qmd backend guidance Delete the QMD concept page, rewrite memory documentation for builtin retrieval, and remove QMD from navigation and taxonomy source. * refactor(memory): remove qmd-only leftovers * refactor(memory): finish qmd integration cleanup * build(deps): align root string-width types * build(deps): model root string-width tooling * refactor(memory): align qmd removal ui and docs * fix(memory): preserve qmd external paths in doctor * test(memory): remove obsolete backend probe case * test(plugin-sdk): refresh private type baseline --- ...mory-runtime-boundary-critical-quality.yml | 1 - ...rocess-exec-boundary-critical-security.yml | 1 - .github/workflows/codeql-critical-quality.yml | 3 +- .github/workflows/codeql.yml | 1 - config/knip.config.ts | 2 + config/max-lines-baseline.txt | 3 - .../.generated/plugin-sdk-api-baseline.sha256 | 228 +- docs/.i18n/glossary.zh-CN.json | 4 + docs/.i18n/zh-Hans-navigation.json | 1 - docs/cli/memory.md | 14 +- docs/cli/policy.md | 12 +- docs/concepts/active-memory.md | 4 +- docs/concepts/memory-architecture.md | 16 +- docs/concepts/memory-builtin.md | 5 +- docs/concepts/memory-honcho.md | 22 +- docs/concepts/memory-qmd.md | 310 +- docs/concepts/memory-search.md | 5 - docs/concepts/memory.md | 7 +- docs/concepts/multi-agent.md | 43 +- docs/docs.json | 1 - docs/gateway/config-agents.md | 1 - docs/gateway/configuration-reference.md | 6 +- docs/gateway/doctor.md | 5 +- docs/plugins/memory-wiki.md | 25 +- docs/plugins/sdk-subpaths.md | 2 +- docs/reference/database-schemas.md | 2 +- docs/reference/memory-config.md | 171 +- extensions/active-memory/config.ts | 41 - .../active-memory/doctor-contract-api.test.ts | 30 +- .../active-memory/doctor-contract-api.ts | 36 + extensions/active-memory/index.test.ts | 59 +- extensions/active-memory/openclaw.plugin.json | 17 +- extensions/active-memory/recall-run.ts | 7 +- .../active-memory/trigger-recall.test.ts | 4 +- extensions/active-memory/trigger-recall.ts | 1 - extensions/active-memory/types.ts | 11 - .../memory-core/doctor-contract-api.test.ts | 65 + extensions/memory-core/doctor-contract-api.ts | 2 + extensions/memory-core/runtime-api.ts | 1 - .../src/cli-index-search.runtime.ts | 48 +- .../memory-core/src/cli-runtime-common.ts | 2 +- .../memory-core/src/cli-status.runtime.ts | 27 +- extensions/memory-core/src/cli.test.ts | 183 +- .../src/concept-vocabulary.test.ts | 2 +- .../memory-core/src/concept-vocabulary.ts | 1 - .../memory-core/src/dreaming-phases.test.ts | 8 +- extensions/memory-core/src/dreaming-phases.ts | 4 +- extensions/memory-core/src/dreaming-state.ts | 2 +- .../src/memory-tool-manager.test-mocks.ts | 27 +- .../src/memory/manager-session-sync-ops.ts | 2 +- .../src/memory/manager-source-sync-ops.ts | 2 +- .../manager-sync-ops.startup-catchup.test.ts | 2 +- .../src/memory/manager-sync-yield.test.ts | 6 +- .../memory/manager.reindex-recovery.test.ts | 1 - extensions/memory-core/src/memory/manager.ts | 2 +- .../src/memory/qmd-collection-controller.ts | 560 -- .../src/memory/qmd-collection-metadata.ts | 298 - .../src/memory/qmd-command-client.ts | 422 -- .../src/memory/qmd-command-errors.ts | 116 - .../memory-core/src/memory/qmd-compat.test.ts | 14 - .../memory-core/src/memory/qmd-compat.ts | 8 - .../src/memory/qmd-document-resolver.ts | 465 -- .../src/memory/qmd-manager-base.ts | 493 -- .../src/memory/qmd-manager-helpers.ts | 156 - .../memory-core/src/memory/qmd-manager-io.ts | 285 - .../src/memory/qmd-manager-lifecycle.ts | 174 - .../src/memory/qmd-manager-search-support.ts | 477 -- .../src/memory/qmd-manager-search.ts | 287 - .../src/memory/qmd-manager-sync.retry.test.ts | 116 - .../src/memory/qmd-manager-sync.ts | 443 -- .../qmd-manager.slugified-paths.test.ts | 436 -- .../src/memory/qmd-manager.test.ts | 6471 ----------------- .../src/memory/qmd-manager.timer-leak.test.ts | 33 - .../memory-core/src/memory/qmd-manager.ts | 24 - .../src/memory/qmd-runtime-cache.test.ts | 308 - .../src/memory/qmd-runtime-cache.ts | 381 - .../src/memory/qmd-runtime-debug.ts | 14 - .../qmd-session-exporter.archive.test.ts | 79 - .../src/memory/qmd-session-exporter.test.ts | 141 - .../src/memory/qmd-session-exporter.ts | 297 - .../memory-core/src/memory/runtime-host.ts | 28 - .../src/memory/search-deadline.test.ts | 28 +- .../memory-core/src/memory/search-deadline.ts | 61 +- .../src/memory/search-manager.test.ts | 1589 +--- .../memory-core/src/memory/search-manager.ts | 1075 +-- .../src/migration/doctor-memory-sidecar.ts | 47 + .../doctor-vector-index-provider.test.ts | 6 - .../migration/doctor-vector-index-provider.ts | 3 - .../src/qmd-session-artifacts.test.ts | 87 - .../memory-core/src/qmd-session-artifacts.ts | 314 - extensions/memory-core/src/rem-evidence.ts | 2 +- .../memory-core/src/runtime-provider.test.ts | 4 +- .../memory-core/src/session-backfill.ts | 2 +- .../memory-core/src/session-ingestion.ts | 2 +- .../src/session-search-visibility.qmd.test.ts | 555 -- .../src/session-search-visibility.test.ts | 86 +- .../src/session-search-visibility.ts | 65 +- .../src/short-term-promotion-artifacts.ts | 49 - .../src/short-term-promotion-types.ts | 12 +- .../src/short-term-promotion.test.ts | 6 +- .../memory-core/src/tools.citations.test.ts | 37 - extensions/memory-core/src/tools.citations.ts | 27 - .../src/tools.recall-tracking.test.ts | 65 +- extensions/memory-core/src/tools.shared.ts | 3 +- extensions/memory-core/src/tools.test.ts | 637 +- extensions/memory-core/src/tools.ts | 154 +- extensions/memory-wiki/src/query.test.ts | 350 +- extensions/memory-wiki/src/query.ts | 8 +- .../policy/src/doctor/data-auth-findings.ts | 3 - ...r.gateway-data-and-approvals.test-utils.ts | 7 +- extensions/policy/src/policy-state-data.ts | 13 - .../shared/live-gateway.runtime.test.ts | 4 +- extensions/qa-lab/src/qa-gateway-config.ts | 1 - .../tsconfig.package-boundary.paths.json | 4 +- extensions/xai/tsconfig.json | 4 +- package.json | 7 +- packages/memory-host-sdk/package.json | 2 +- .../memory-host-sdk/src/engine-foundation.ts | 9 +- .../src/{engine-qmd.ts => engine-sessions.ts} | 17 +- .../memory-host-sdk/src/engine-storage.ts | 6 +- .../src/host/backend-config.test.ts | 886 +-- .../src/host/backend-config.ts | 483 +- .../src/host/config-utils.test.ts | 13 +- .../memory-host-sdk/src/host/config-utils.ts | 73 +- .../memory-host-sdk/src/host/multimodal.ts | 2 +- .../src/host/openclaw-runtime-config.ts | 5 - .../src/host/openclaw-runtime-io.ts | 2 - .../src/host/openclaw-runtime.ts | 13 +- .../src/host/qmd-process.real.test.ts | 179 - .../src/host/qmd-process.test.ts | 640 -- .../memory-host-sdk/src/host/qmd-process.ts | 506 -- .../src/host/qmd-query-parser.test.ts | 150 - .../src/host/qmd-query-parser.ts | 190 - .../src/host/qmd-scope.test.ts | 55 - .../memory-host-sdk/src/host/qmd-scope.ts | 123 - .../src/host/session-transcript-corpus.ts | 4 +- packages/memory-host-sdk/src/host/types.ts | 37 +- .../memory-host-sdk/src/host/windows-spawn.ts | 6 - pnpm-lock.yaml | 3 + scripts/lib/plugin-sdk-entrypoints.json | 2 +- ...lugin-sdk-private-local-only-subpaths.json | 2 +- scripts/lib/policy-config-coverage.jsonc | 8 - src/commands/doctor-memory-search.test.ts | 178 +- src/commands/doctor-memory-search.ts | 104 +- src/commands/doctor-state-integrity.test.ts | 41 - src/commands/doctor-state-integrity.ts | 11 +- .../shared/codex-route-warnings.test.ts | 10 +- ...g-migrations.runtime.retired-memory-qmd.ts | 169 + ...rations.runtime.retired.memory-qmd.test.ts | 133 + ...egacy-config-migrations.runtime.retired.ts | 2 + ...acy-config-migrations.runtime.tier-eval.ts | 4 +- src/commands/status.scan.shared.test.ts | 36 - .../schema.help.quality.test.ts.snap | 2 - src/config/config.schema-regressions.test.ts | 42 - src/config/dead-config-keys.test.ts | 6 +- src/config/schema.help.models.ts | 48 +- .../schema.help.quality.test-fixtures.ts | 25 - src/config/schema.help.quality.test.ts | 4 - src/config/schema.labels.ts | 23 - src/config/schema.test.ts | 13 - src/config/schema.tiers.ts | 3 +- src/config/types.memory.ts | 49 +- src/config/zod-schema.agent-runtime.ts | 16 - src/config/zod-schema.root-support.ts | 34 - src/config/zod-schema.session.ts | 2 +- ...r-health-contribution-runners.workspace.ts | 3 - src/flows/doctor-health-contributions.test.ts | 1 - src/gateway/config-diff.ts | 2 +- src/gateway/config-reload.test.ts | 32 +- src/gateway/server-startup-memory.test.ts | 60 - src/gateway/server-startup-memory.ts | 152 - .../server-startup-outcomes.test-support.ts | 12 +- src/gateway/server-startup-outcomes.ts | 19 +- .../server-startup-post-attach.test.ts | 29 - src/gateway/server-startup-post-attach.ts | 45 - .../heartbeat-runner.ghost-reminder.test.ts | 18 +- ...beat-runner.isolated-key-stability.test.ts | 8 +- ...tbeat-runner.returns-default-unset.test.ts | 4 +- src/memory-host-sdk/engine-qmd.ts | 8 - src/memory-host-sdk/host/backend-config.ts | 5 - src/plugin-sdk/memory-core-bundled-runtime.ts | 14 +- src/plugin-sdk/memory-core-host-engine-qmd.ts | 32 - .../memory-core-host-engine-sessions.ts | 31 + .../memory-core-host-engine-storage.ts | 2 - src/plugin-sdk/session-transcript-hit.test.ts | 280 +- src/plugin-sdk/session-transcript-hit.ts | 76 +- ...tension-package-project-boundaries.test.ts | 3 +- src/plugins/loader.registration.test-utils.ts | 2 +- src/plugins/provider-runtime.test.ts | 4 +- src/plugins/registry-contribution-types.ts | 20 +- src/utils/utils-misc.test.ts | 4 +- taxonomy.yaml | 3 +- test/vitest/vitest.unit-fast-paths.mjs | 1 - ui/src/app-navigation.test.ts | 2 +- .../e2e/memory-settings-defaults.e2e.test.ts | 15 +- ui/src/i18n/locales/en.ts | 14 +- ui/src/pages/config/memory-defaults.test.ts | 8 +- ui/src/pages/config/memory-defaults.ts | 8 - ui/src/pages/config/memory-memories.test.ts | 7 +- ui/src/pages/config/memory-memories.ts | 2 +- .../pages/config/memory-page.backend.test.ts | 104 - ui/src/pages/config/memory-page.test.ts | 1 - ui/src/pages/config/memory-page.ts | 15 +- ui/src/pages/config/memory-schema.ts | 73 +- ui/src/pages/config/memory.test.ts | 142 +- ui/src/pages/config/memory.ts | 76 +- ui/src/pages/config/settings-search.test.ts | 101 - ui/src/pages/config/settings-search.ts | 76 +- 208 files changed, 1079 insertions(+), 24137 deletions(-) delete mode 100644 extensions/memory-core/src/memory/qmd-collection-controller.ts delete mode 100644 extensions/memory-core/src/memory/qmd-collection-metadata.ts delete mode 100644 extensions/memory-core/src/memory/qmd-command-client.ts delete mode 100644 extensions/memory-core/src/memory/qmd-command-errors.ts delete mode 100644 extensions/memory-core/src/memory/qmd-compat.test.ts delete mode 100644 extensions/memory-core/src/memory/qmd-compat.ts delete mode 100644 extensions/memory-core/src/memory/qmd-document-resolver.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager-base.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager-helpers.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager-io.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager-lifecycle.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager-search-support.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager-search.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager-sync.retry.test.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager-sync.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager.test.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager.timer-leak.test.ts delete mode 100644 extensions/memory-core/src/memory/qmd-manager.ts delete mode 100644 extensions/memory-core/src/memory/qmd-runtime-cache.test.ts delete mode 100644 extensions/memory-core/src/memory/qmd-runtime-cache.ts delete mode 100644 extensions/memory-core/src/memory/qmd-runtime-debug.ts delete mode 100644 extensions/memory-core/src/memory/qmd-session-exporter.archive.test.ts delete mode 100644 extensions/memory-core/src/memory/qmd-session-exporter.test.ts delete mode 100644 extensions/memory-core/src/memory/qmd-session-exporter.ts delete mode 100644 extensions/memory-core/src/qmd-session-artifacts.test.ts delete mode 100644 extensions/memory-core/src/qmd-session-artifacts.ts delete mode 100644 extensions/memory-core/src/session-search-visibility.qmd.test.ts rename packages/memory-host-sdk/src/{engine-qmd.ts => engine-sessions.ts} (66%) delete mode 100644 packages/memory-host-sdk/src/host/qmd-process.real.test.ts delete mode 100644 packages/memory-host-sdk/src/host/qmd-process.test.ts delete mode 100644 packages/memory-host-sdk/src/host/qmd-process.ts delete mode 100644 packages/memory-host-sdk/src/host/qmd-query-parser.test.ts delete mode 100644 packages/memory-host-sdk/src/host/qmd-query-parser.ts delete mode 100644 packages/memory-host-sdk/src/host/qmd-scope.test.ts delete mode 100644 packages/memory-host-sdk/src/host/qmd-scope.ts delete mode 100644 packages/memory-host-sdk/src/host/windows-spawn.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.retired-memory-qmd.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.retired.memory-qmd.test.ts delete mode 100644 src/gateway/server-startup-memory.test.ts delete mode 100644 src/gateway/server-startup-memory.ts delete mode 100644 src/memory-host-sdk/engine-qmd.ts delete mode 100644 src/memory-host-sdk/host/backend-config.ts delete mode 100644 src/plugin-sdk/memory-core-host-engine-qmd.ts create mode 100644 src/plugin-sdk/memory-core-host-engine-sessions.ts delete mode 100644 ui/src/pages/config/memory-page.backend.test.ts diff --git a/.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml b/.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml index c2787b3c7697..5722a1aeeab8 100644 --- a/.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml +++ b/.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml @@ -20,7 +20,6 @@ paths: - src/plugin-sdk/memory-*.ts - src/plugin-sdk/memory-core-host-*.ts - src/plugins/memory-*.ts - - src/gateway/server-startup-memory.ts - src/commands/doctor-memory-search.ts - src/commands/doctor/cron/dreaming-payload-migration.ts diff --git a/.github/codeql/codeql-process-exec-boundary-critical-security.yml b/.github/codeql/codeql-process-exec-boundary-critical-security.yml index 3041aa3d6e0a..577c6f531d9f 100644 --- a/.github/codeql/codeql-process-exec-boundary-critical-security.yml +++ b/.github/codeql/codeql-process-exec-boundary-critical-security.yml @@ -31,7 +31,6 @@ paths: - extensions/file-transfer/src - extensions/google-meet/src - extensions/imessage/src - - extensions/memory-core/src/memory/qmd-manager.ts - extensions/memory-wiki/src/obsidian.ts - extensions/microsoft-foundry/cli.ts - extensions/ollama/src/wsl2-crash-loop-check.ts diff --git a/.github/workflows/codeql-critical-quality.yml b/.github/workflows/codeql-critical-quality.yml index 035428430d4f..e18d434c8f08 100644 --- a/.github/workflows/codeql-critical-quality.yml +++ b/.github/workflows/codeql-critical-quality.yml @@ -111,7 +111,6 @@ on: - "src/gateway/resolve-configured-secret-input-string*.ts" - "src/gateway/security-path*.ts" - "src/gateway/server-methods/secrets*.ts" - - "src/gateway/server-startup-memory.ts" - "src/gateway/method-scopes.ts" - "packages/gateway-protocol/src/**" - "src/gateway/server-methods/**" @@ -257,7 +256,7 @@ jobs: packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts) gateway=true ;; - packages/memory-host-sdk/*|src/commands/doctor/cron/dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*) + packages/memory-host-sdk/*|src/commands/doctor/cron/dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/memory/*|src/memory-host-sdk/*) memory=true ;; src/infra/outbound/base-session-key.ts|src/infra/outbound/delivery-queue*.ts|src/infra/outbound/outbound-session.ts|src/infra/outbound/session-binding*.ts|src/infra/outbound/session-context.ts|src/infra/outbound/targets-session.ts) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 23149e977398..dfb845d82f6d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,6 @@ on: - "extensions/file-transfer/src/**" - "extensions/google-meet/src/**" - "extensions/imessage/src/**" - - "extensions/memory-core/src/memory/qmd-manager.ts" - "extensions/memory-wiki/src/obsidian.ts" - "extensions/microsoft-foundry/cli.ts" - "extensions/ollama/src/wsl2-crash-loop-check.ts" diff --git a/config/knip.config.ts b/config/knip.config.ts index 139cc1f1adad..2705bdee70cf 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -277,6 +277,8 @@ const rootToolingAndWorkspaceDependencies = [ "oxlint", "oxlint-tsgolint", "signal-utils", + // Root declaration builds compile terminal-core source and resolve this package from root. + "string-width", ] as const; function bundledPluginWorkspace(extraEntries: readonly string[] = []) { diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 209c045d8e11..e5011f5f0a61 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -154,9 +154,6 @@ extensions/memory-core/src/memory/manager-embedding-ops.ts extensions/memory-core/src/memory/manager-search.test.ts extensions/memory-core/src/memory/manager-search.ts extensions/memory-core/src/memory/manager.ts -extensions/memory-core/src/memory/qmd-manager.test.ts -extensions/memory-core/src/memory/search-manager.test.ts -extensions/memory-core/src/memory/search-manager.ts extensions/memory-core/src/rem-evidence.ts extensions/memory-core/src/short-term-promotion.test.ts extensions/memory-core/src/tools.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 01c3d39e9134..3b061ea9c1e8 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,151 +1,151 @@ -c520390ad8eaae8ba5c2f926100a833988cbe9cc4f04f8511a44edf36bb35db2 module/account-core -1b9d38050a1de968515048d4ada89d5b9d686bd51be0f0c0cb8987a124bb59a7 module/account-helpers +e43068a431ed4e50b6425b32952155bc46e114a049a6530c4619af6005050c08 module/account-core +eaafcd4e780e6a9b170d61fa7c4825c1460b12eb6ae288c5860d175a62cd8380 module/account-helpers 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id -1fe4f05f95e7b29cd81daf65c76fce5db229158c4ffcb8341b390c8ea2e63696 module/account-resolution -87714a883fa2d8c39db921b25992546ab07e23bb5c6bfe739c340d65a989db47 module/agent-config-primitives -699cbf02c6a575564693e6f608a44ed536d354c6ae501657c36fbf6c1a1ec580 module/agent-harness -b4ed6b0824e6b693b5f9d6b64a73feba66d253f1404065377a03437d5e8c8bd7 module/agent-harness-runtime -b39e78226063156e97da6189b34f1bf92dc07f4bc49a7f67e8dcc521dd878dac module/agent-media-payload -a8adf1cb3f44fca407adf41933096f63322cd3c77d9fa88bb68ea16f2aadc7a9 module/agent-runtime -68040b4e24969a5d209097b982e1382c7d8bed48d1d1fd5ef5f20754064421e0 module/agent-scope-runtime +59bffa200925645831e54c6c980af6b35734a85de60c59a081a80d8f6bbfd6c8 module/account-resolution +3fe118210b885af40088457ed81ffa5ede18c8e695295731a2ee059af46843cc module/agent-config-primitives +ae75ba974b2aac7fb064cd79103c032f1eb6b5fbfb8496e0b97072aa3396f8e0 module/agent-harness +aee9f4ba8bcdde23811f6c9af3a4423c0665114cf04e4b40be3975969a4ec167 module/agent-harness-runtime +773943f0f5cc26d4bfd1dc6cfdac5effc2bec14f1bde927e9e407ab4c9701ba0 module/agent-media-payload +ff7f81e2bec4f9e92b29b72f099f559d8abffbb94ca1c1c9eb216ffa5f71cce2 module/agent-runtime +6a906064d0300a8239c371297ad92bc78baa9ea1324c77fa6d20ac724d90681d module/agent-scope-runtime 8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from -74f099da550f5c949541a529d716a7c39a0af7f183d51e433a8b433c98be59f1 module/allowlist-config-edit -eaf10594d0b67aaa51dd633278cca395bc30e26c334a3c2ef1a0b3404c46e839 module/approval-auth-runtime -41d34eb3b55e8c14eaf791e3e95ab22fae0423b5c24a5b02cc36c14d377cf802 module/approval-client-runtime -b74733ee07372d72752985a798e247752675fb35f44ed553092ad2223cc8be2e module/approval-delivery-runtime -cc74dadd03fa6cff9514efa44a56a6febcfb4a13e41f14d14d051c5bafa53601 module/approval-gateway-runtime -c5a5a0736d0a999e1b7c8fa967f2d220b03c0c5d8ac30ec43cd0e5bb9b1e2962 module/approval-handler-adapter-runtime -b0d4604617be567422fcdceb8766b2db938690561991465085440c3b6e42d811 module/approval-handler-runtime -ba3f0a2de13b5884770c84e337e3ccf0e582dd10fe7c7291f06be547d732c537 module/approval-native-runtime -fe40c01e9b168c9b00426eb4febdc557d69d38082e092ca994589acfbb1ae99e module/approval-reply-runtime -3141aebd499c8575b1c4f6337b435e5a3de4a7059186873ded8bb43eb30e3c37 module/approval-runtime +2781cee0cfe13689707b8142f8c6f22bdf2d5989bca20f457ce866138a1560e7 module/allowlist-config-edit +0aba048d696aca5919a9c55d169e53a25987a403cb429385b2cfed01124fc843 module/approval-auth-runtime +02dfd018916fbd6a009a5ac1dc235f0ee7822e8ae312f9af9c43647727809711 module/approval-client-runtime +f24ad082a4a871f1abd2dd9bb1417fbc113a34fe2352ae925e6a7c6f4a440018 module/approval-delivery-runtime +6a99a30303f058b1b8534c83674825402723be4f2eef4217f789892f8d63f22c module/approval-gateway-runtime +5da9a30393531c72d5df3ea256eaec71ca7ad323ffe9539d27a7bfc4c75d279f module/approval-handler-adapter-runtime +c5f8ded4cf2710f330b77cebad6defc12b98ce7b489e90037e90274e1ec07d3d module/approval-handler-runtime +23fde333e68efd1b33df7682b077128453f3155168743d8969361fa910c373bc module/approval-native-runtime +a0d8b698944ed91bb099636807d5d6fbf0f17e154d9a3f10c0efd446d3af4d4e module/approval-reply-runtime +a54f1d2773fc1a99d2d8cd43f5636c98c623417c142012140142ccd1e13b2ba8 module/approval-runtime 01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param -b234c18152ce7c204483f12b56432eaf628487a58985b45fad5a748efbe7102b module/channel-actions -5b1c747a84962082207d88d629dc8e32a1c71e5579fc50899dc9d0af25347415 module/channel-config-helpers +39769190fc9d790ae5e2c4ebd5de0a78b45b3d529aaf264c84eba4e7f86a3066 module/channel-actions +b51d041b69bf2c00cc5efebafe25ef5be2b3435f3a6e3eab06cf8736d764b54f module/channel-config-helpers c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives -45af2c37b55888cd81115aa6837c3cc2ae5a73dd0173e982fff608bed8c3d607 module/channel-config-schema -a38cb34f477cdd6a1a0a664ddcfa8f0f2e1d5f1c30f7fb8bcff0aa3b5fb81a1c module/channel-contract -258d1d3f54f24722a1f839bd922e690a197e438fd550a25dfcf43f1b80d74441 module/channel-core -b0441683ed12ad0d1e3bd81124f9bfc492bfc8e3307bf1dc85068674e49419a6 module/channel-dm-policy -8e57800c267af0a7d7deda601012662e8f926251d3bb075f1461783c13c1746a module/channel-entry-contract -e348cf7e5d26ee4dc7f8de827834f7db5379a08aedc498b5ca899b3fdff497dc module/channel-feedback -49cf568cb291de3d5cfb5785692bbe040a0562d87fd4c162569918882ebf656c module/channel-inbound -3cd9fdf44a03bd23098a5106ada7b41dd0b4e69039051cbee5fc05965060f8e6 module/channel-inbound-debounce -a1269e30c077d543af196e28a63524f03146268b7f4630f440233b2f08762f11 module/channel-ingress-runtime -4ef3dd0ac32f99659e17df55ce4dc1aeda9711ddca0df5508d1907fcb4d43f16 module/channel-lifecycle +76ad615d374431580ea1755594e2ce3ce047ac1de9fd4621053dca0fbc3afc4d module/channel-config-schema +7dc736e23800c61ea8f93fd62d94ec599f61ee26ab24b0f705a02796894b87e7 module/channel-contract +832f2e471d755133d9447da9c1e52b53f04789c7881aa1cb8435ba957bece9c3 module/channel-core +807c4fedab7465a427cf88388d2784b436279474e97e8f5350a6c09cae39efbe module/channel-dm-policy +1786f72e15dccd78148eada8289a006afe18686ecc6a49efc3605641d9c4bcd2 module/channel-entry-contract +2c55b3f3d1d275f760a7e1c78764e4030e7a06c4273a4d51d8f3c82b55ea818d module/channel-feedback +482e09de1b69ef05bc85af7ee3707b8b3b98f1b42af9ee3f7127f2ec1729d5bf module/channel-inbound +d7e21bb831ad5125e6498e88fcc27e820ed296784eb2296c22795c08d55ade06 module/channel-inbound-debounce +79a8f244b0627ce4b601231bf71f0ee539f6ac7692e490847ec8e7ace7d29f1c module/channel-ingress-runtime +f167330b4af8d686055d3a32710056614eb0b6ae29d726667350e83898ef66d3 module/channel-lifecycle 0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging -63efff2edc8356db5c8ca1f9a9be43ee8441b4522f8e69d3178c404571790059 module/channel-message -909c88b892041807d65da73ad18062353331cb5c2de3da5a442ce37dd8348a9f module/channel-outbound -35b5dec6d52ce5ec372f930ca4546815236e2eef0e96ac15072e91692e620565 module/channel-pairing -762262620488e88a6e006f0c8b13a70387e7e0198cb6d74c3eedea03343df4db module/channel-plugin-common -43a4a56defd68e433dc5a30e25eefccf598513d1bd721b6861574c6fe9783bd5 module/channel-policy -d92a99cfcbc320be570bd9ab1aa2aa402fd6d136dad6fbd5f8acf28326052639 module/channel-reply-pipeline +f3d5c471aba15b0644ea152d5f2372fba9b7a45f9527f6cca7010039dff2da5b module/channel-message +aeabed6473ebf2a2ee63f3120d437c0a4ce1b1275f52df0de39add533052b4b7 module/channel-outbound +a15258d841373582b4363528e9b38d9d99eae24c9f93cc9d1686def77967264e module/channel-pairing +b5083fe6635e6ebe71b19a52aaa566b46eeb99516358b872a3ffa5e445620b65 module/channel-plugin-common +81fca70aeb29d23b5905cbbecb67c0e6e52793e5fad57d2f96e426b309a38165 module/channel-policy +879ffdc04a2098fb0a9f53c8774fc2291d3cd259a49ca423ba479211f4a58109 module/channel-reply-pipeline 482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context -e5bab77cf56d4a549b4ba1ba16c15f86394266ecd07b31f96abd5adbfa9bd94b module/channel-secret-basic-runtime -53677d02f5cc8744bd9d335bcb4172a7eda083872f90ffc65c3e474ee149ec0e module/channel-secret-runtime -337b8b5ee6e4bd478aaac99edb20a029daff9e3b31f567a5dcb2d4e678587d64 module/channel-send-result -e2cec53758720feeb0cf0dc25f9b5ea25c71b483a7f8e455a6a0fd7a9d62490c module/channel-setup -528ab5be35a3ad564e74699c6c4aec289a2c424e19f14b7e74fcaf41b0acbe7f module/channel-status -91b43898e843e9497ff1c3aad3d5de5357f0f44e3987d28e5ea70d980b7d418a module/channel-streaming +04948928b3cf310c4c401bdb89e94ac2ff2066b5b464a9bb501b36dbe7711c07 module/channel-secret-basic-runtime +e99d6f57a89503e67035da553b0ae62b893722e564031c050f3097cf5fa3bf5f module/channel-secret-runtime +872683c4181f3bbfabc69d48ea3b3b448b05851bd8f951cc4fe84dbf44441efd module/channel-send-result +a1d5d1fa6caf391698b2c322fbd39b54fc674890e0372790f33f1aa4c6df9a62 module/channel-setup +4dcbb281ccd76d4ffc4b26a1aa6130d12214771fa9455e9bd87217b15b56a83f module/channel-status +95dc206f832a0f563238dcea72d41554f409a3a9df081f41c52a8241c7dc5161 module/channel-streaming 67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config 1303df5cc58539c6941e2cd159c93259804c925795219f1630f4d740896a77c1 module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -fdf8da3db72f5d747adc1d91b269d66c2668fa6449e8225b97073e23323ab1c5 module/command-auth -c1f9d2773799af049ab6906636a6684206ec27af8a0b7b4acfa29a1972bdf9b3 module/command-auth-native -1c725b558e450d7961fc6fe7c0843838860946cb78c1ec82f68305489d534524 module/command-detection -ebaccf95249d1037448f58c543b448ccccff493c4b2086f10093faff87668a61 module/command-primitives-runtime -e85a6cc9ac9972d142cde8ff6e5b8a84b643868b7b1c402b0eede321262f2cd4 module/command-status -3bd594bf7f101450275b8e3593e6420ce25e90093489f2ebe1375ea0f3bb7c23 module/config-contracts -73221bab9ad02d0197b0cbef593aff5707bb19e0e2fdbaeb2d93ae387a206c8f module/config-mutation -5089d197ae955d1617ba4dc9d4ba3850fe7007f7c8bfeb790d75ae5718d5a724 module/config-runtime -834069efe69c5cda2747b2415baad705767276d5d3ddbe99c2ce83d2b11527eb module/conversation-runtime -9012eac78f5db5f34ab61a7861baec4bcbb64586af761c6eea268beda6427010 module/core -e5c61cf4d54cc9f82ab757712acaa1f2d7d0c39eef2bb5c80631dd1441c2c737 module/dedupe-runtime +db81e9d14930c3ea36d1a51c4feed0e3cd9a3fb3acd4587e1104ebca797bc993 module/command-auth +bfd24d32cadcbc0a50a9abd496a4cf7ac1d75da271178247099a223b07db21de module/command-auth-native +a41d9effc1656cbc131611099bdeb81423ab48adce8c1b1ba07100dff8d32818 module/command-detection +31044216c6495728a36dbe60da31f7c1ede2b069cd0d0685d887ebca31814d5f module/command-primitives-runtime +e42cc234ace96a64cec0bdb8051ddded77618e384ae2bbce8b633bf35dbd0288 module/command-status +9406e5858c1fa02c85012e21b22d1faa878030edf866d4d11efab1537eca0b6f module/config-contracts +5e866c4f8dea30045a8a94e037f0e202d597afb1ca221d9854b15bc416503643 module/config-mutation +1c1bfaad09b7d07c7865fdf8d157dc873f69180c42ec025dda9142e5d74436b6 module/config-runtime +9294298f5447890f0e798c9b14c774170541a7056b8eb8196df5dde902e9ec7e module/conversation-runtime +165dd41a6e1fff9d272fee5261ba45fc822c38c71e90e63af4eff2904f299b37 module/core +e172e27747a2e0ff24b5054c4420028c2f1acb40e489dc3cc4fe6f40158b3a8f module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap -c944f438e9108f3e023149c3d22d152e1c59867151668b9e61bba4da0c09c2cb module/diagnostic-runtime -3519f2584015d6817eefd7803cca7089d66d8569c297367a10aa2114cf6d24d2 module/directory-runtime -6297773a7852270d371686e1eed84ca054273a546b20c1a7b6121d6490ff46e4 module/discord -12f4ff032680218e63370698e2ec8b0c46a5a4dc86ce05462dd1284a06ed2eef module/error-runtime -8c80daf459dc80caf9b343e52cbef678f514e34d175b93c2986e71d54b43f782 module/extension-shared +fd7c489415aa272af724ac15e95d297eac0edeb229e30d02c4061d5276f874b2 module/diagnostic-runtime +5935d91e6e300063055ed1331fc10f229cf4a34e9d67f23cfce73474aba8246a module/directory-runtime +0d2dd013a18dc0625502cfb8c63344c7b21cae859cacf7d22c3ee6f984b72718 module/discord +c468c0ca5e5fc093ef5bd0cd15c57e19059bbe5c453d68f2664e9aaa35661cab module/error-runtime +6d9b6396888d7cddded108e211053559b10d79397d20098e0616767be4a4bfb3 module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -0311c99dafaeda7e7239b7126fd864fd2e9a12e0b8dcba41d17c9bd120cd398d module/gateway-runtime +a8fa2ed68a457c589516435e190da88433afc4e17e6f382a79799e45b64cc196 module/gateway-runtime 575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access -b77c88bbac32be4aa847c20a93566f9ea7d36b801564aa7c16abb52a44e45002 module/health -54301207a429f30f78aeadb99d6b61591c81b6ea4f467daf3d49efe9106bde81 module/hook-runtime -73c55fa5541efc308d1c99e012f651e45ca84d9049bb8125b6dea687f435862b module/inbound-envelope +7e4fe055bd9094cedc6964c1d5c14f01b2fd33906146304298d66a75beee9c37 module/health +37d96b6b331825e23fdc70bfd5c21909d4c1334c0558caa6741d3b986376261f module/hook-runtime +185a5acedbd7f1a73e5cc773e22bf494b124a09bcd68b5a756ff0f881abf431b module/inbound-envelope 4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery -ca97301f667633874ac20d0d68a1e84090aa2668cac4562ad64db688515d243f module/inbound-reply-dispatch -d4103ee20cc1d864fdcfda7da32c7350e96ba1596eb678846bcd7a9fa63a6e47 module/infra-runtime +00f9af6ac5328fadf7b25e4ffcddc64c7af238cba843cd5992658be5962e4356 module/inbound-reply-dispatch +49cd3bd187ccdb4b34c8c2c238b6ecca7c2e7f466cab4bbfd0a23a5499c5b796 module/infra-runtime ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once -6d0ef8e970d63f711298747344f3c6757fbad6d920b270be06e7611130d846cf module/interactive-runtime +d520aaf761cc662f7ef3cd720ffbc16d0b749cfb421647a914708608b73310e0 module/interactive-runtime 408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store e907fd3a98185f2c261f2aafcaa5a19ee1d7b459d519a498397d629f84c68312 module/lazy-runtime -259373ff0a62fe30ddc4e36260bdba6bf206f98b71a953948bf4dad17c137fff module/logging-core +3a6d4cd20932d21e5ae665320ff594046c656eb84d95008a318ee1e9793edf2b module/logging-core f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix -3955a8dd748418fa830a9b9a28cd3fdea844212db164b75828aa61ff3848eb5e module/media-local-roots +7869c8dcea3b96ab00a33fcbd21a6ca171131b0c7dc6a527bf1da178c092621e module/media-local-roots f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime -8eaae9c4b92d7dadf521f3b8c4ea4274b6b17043b8c2f55734502865920c9674 module/media-runtime +dfbff9f01bbbad834c252f51676713b8627a26b9b88ca29dcc25170b989639fb module/media-runtime 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store -bb9f787cd2362268894393f5cf8d16ea582609cb1bb27fbdfc8ea97a76cea214 module/media-understanding -7fc60001aee2f0f95caa7eebe4e8dc74d3a4dcac71f16ce63447d73226f9c683 module/media-understanding-runtime -83aed3a868c3765a6acd079d2f1ca81746dc26d182ad431781648e6ecd6f56d7 module/meeting-runtime -99b5eb9d24fc2f1cf0cbd086c4693718380dc2dd9ca5ddd66546c03016d55ebe module/memory-core-host-engine-foundation -9989c67ccac0600c8727d8137f2a64df3d04af855cd515e22930c96b025be108 module/memory-host-core +7dd4a69b33196e946b03a3579e3b569176ff32db22fa668d5eacc06227574cd1 module/media-understanding +064c63ff4bc6e5583d23aa54111ff25b64eb81b4eb2a60c5063381b2f57ec3d2 module/media-understanding-runtime +41bc80e91f110019ddf4a44c8e7838d39abefce11972aeb1581eae865545586f module/meeting-runtime +d16cbced4f2e6672ac9a032ac41691fe7ff4994e328d44ed6ac8a46dbbec834f module/memory-core-host-engine-foundation +6ebfad9b6c9751fb176c4985df50585bca7cc1a2f7569a0da97d3f61d42cc9ee module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets -3b84166ce93a8f5107183aeb46efb8a1be13e729cbc759a584884409072cc83e module/model-session-runtime -12ef243996027d41039354e682b45e7fa05fd89597035312eca86b0d15f399c6 module/models-provider-runtime -bba9f68844a60e9bd9b03c694684f62bd7cc11542d6f600bf65b561cadbca8ea module/native-command-config-runtime -62b6329334d16090db4af2a45f4ab5d43842b62db92f458df73af3d9323e681a module/native-command-registry -e1230968f3a3587679a4fa57b67ab36af9bcc81b2bce730c9e724eb66c4418e9 module/param-readers +86877af4a5fd8d9c31787b4848b4a917e79ba16084d333f19924069f928f11cf module/model-session-runtime +92a177d6e5165d74e2df21b98946ff0e27663325fd872d255468b4de82756506 module/models-provider-runtime +7cf0d33d38e8a445950dcbfd28f70768f7dd74b6c17c3b23424adaf5f8b96fb1 module/native-command-config-runtime +faaa22538f3459cef412c52088cb40dc732aa560fba2e70655938460022287d0 module/native-command-registry +5b968ecbef95fda927d0944409994e355ec878608dd084358970078d2ac545a9 module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe -e3984a76820581c5281412b2449b2bb431110547c2cd833e63d29991e18f7176 module/plugin-config-runtime -03a39fb83f32c8aefb9d06ff092d21ff40f195f87e6cd08340f6573e0a6d1c5a module/plugin-entry -cd1fab1de064baa56180938a314c9518bfb90bf34138b26f0ba02d956ce12e52 module/plugin-runtime -2aa4b53f69087c4103c39f5c5e9daea63eda47bb12454b7c0f0a907fb99e6e1e module/provider-auth -56a116276bc40e8718055c5ad8df86a380f7c371cbf14d12c2c0fee04f97302f module/provider-catalog-runtime +1bf4d4dfe5a4b264cf6fb8fbd0c7bc76f520ff9845cffad6da4b3a3c2bc3f6f6 module/plugin-config-runtime +ab53e0e4d56945396200bf0b80959fcfaf66e17df4e9c829abe1363fe55e9359 module/plugin-entry +b747fadc5fb68ee407c70cbab1db4c32379d9795ce35b80fda760a76815bea48 module/plugin-runtime +6c8726c1a7a900ca493da4b6a0a711913130a53d0a407546a240a1a0db9b5fe0 module/provider-auth +ba84cdb8462edb633ccbc028797d7c6ed64916d7aa1c26bce88e8e4c00abda9b module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture -d4542e5452cebc9982524720d46fe4704ab8d2f2b8ad263998441a4ad29adde6 module/question-gateway-runtime -deebc97abbbae14b005ca599d9708fc4ca71964406f6cbb38af7339cf2cdd0df module/reply-chunking -1d01de2598fb9a5f5ecbd2ac599e05676ef701c7d0c6646329a9cc0769ecb8ab module/reply-dispatch-runtime +f59277aa90ca0b553cb60c7bc69083bdeeec63a8960a7db2ac3331463facb1cb module/question-gateway-runtime +1f178909e749c85abcd8b5b28d3d35f935302ead90ccf3d7fc1be7b0f094079f module/reply-chunking +0a0d2e57a1b8e55edd5fbef67b5f18ec3dde80eb3e8743b165c281c85b9f0fc2 module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history -0e32d365a83854a04b27522abf9a331a5170ebd345157e11bace539af6a70d88 module/reply-payload -46530f1296bf52358b93a932bef8bce84e7319548ef183eea58039a29130e487 module/reply-runtime +7a6cc4866090b3f71aa8f9e5eb089c4c28529501c9f6a8f371683a29b7edfd57 module/reply-payload +8e422ee6f72f7f34a99a177f2a53739b64ef68a6f4ee1e226eebe7ab3f18902e module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk -3941a8508e73944bf0d920a7334e525c617fc24105ca984fcdc69930ae5cbb7b module/routing +e26cc92679c768fa1474f15828f545aec87f32718a6b35f7907c4f56f65542fc module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command -9a119658075d586a41dcbd2b4e431c5da1ec2c3d6d21317a18aeeef06359c8ac module/runtime -a9b9ac28b62304d457b0cd2e330225be6b7c763200701a03bf548048a9862e5e module/runtime-config-snapshot -45bede83ee89886eeda9155f9d4356ce7fa7611c4be744443c1c439173d666ce module/runtime-env +c83779c80c9e7b196b31a39ec4b986098bd701b089bf6eb2f53c368fb982ea77 module/runtime +159b563aad773cef67f18bf9bd2653420bec94b599e052f2a1d5a238bf341e16 module/runtime-config-snapshot +9fe5bcb52b462010214eda1c01f60b3a018837d9f95dc864f6d457cb3da001cf module/runtime-env 7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy -c10f69e70606506ab616b42bf99317eb8d92c6ec531d7c21b82bef3f33d8373c module/runtime-store +4d687d13f04d54086cb747c328f502714692513b684424cd04cebc58db7d7135 module/runtime-store d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file 8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input -ccd790bc28ce44ca8a2df9a643103555dc650e9778a969c2f6fdeb1f0e1fba1b module/secret-input-runtime -f473090754cb12a5c4a2d39d693315700b25cb053c825c30b3a889a555f10f16 module/secret-ref-runtime -3ac69236c14ac1861aff09f267c523b8f34a63b9bbca085d326c21056604ede0 module/security-runtime -8f825a9fb90beeb81e1e9b6081441ad736b00406ad646139979bea580deefd42 module/session-catalog -f264d6b9cb193b3e7244adbb94c62fdfce3d42969db0e827283f1d35c4f3e1f6 module/session-discussion -b43997ba4064ef3577a9d8efa178fff4dadb6645a0b6a52a79616360d66840c2 module/session-store-runtime -5af106014a62ef2802236dbc5484d55bfffc2cf7105fd184a4d3b80ac0ab7727 module/setup -b64a0abce77e276b739f8b3979ba60b9b6407955aaedd7803c7d7b9936250574 module/setup-runtime +e7672788f052a1249839c8fa24d6a9a45967c4e3fd94e890b0f90296026cd1ca module/secret-input-runtime +026631cbf010d0325be2c4fa4ccd8bbdf8e1008f0904c742c270811f578b07b8 module/secret-ref-runtime +c45158ccb5eb317434f7a80b6c97d7fde6a8b7d50e19a9f8b2b5f24b506b4568 module/security-runtime +1fa2b9ca50703a962d830c812d6538fc0bcc4166f17dc220071a28284dc96cee module/session-catalog +e8eb056cee163fe43c6ad53fd9a0bcf919234ec7dba499f3fc06b914a719e22c module/session-discussion +25c0a1d7ee7881758e015f3547e458fe1d3145bf740cc5c31a2a704395342b15 module/session-store-runtime +0bb015c64058424efb4ef9dac0e40bd9fa8ea49000544ae11f68448590fb5780 module/setup +20b9065285cc45e7bb1570a19d5946de863655bab4defee3d53e60ebfebe3b53 module/setup-runtime 44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools -f25f9cea5d406f959e11f59a236f48da59d805b8d8cac24df7973d625e77f132 module/skill-commands-runtime -c3309590231fc1576f4cb746a1f8e7d3209d1c4d2205730d03a8e0fcb0361095 module/speech-settings -3650198b8d9b0ec76560ebc30bd09fb9f99b22366749cd870918160dd87a2f33 module/ssrf-policy -e6ec1baaa72323bfd0e6e0f2bb1550896d8c95ae4a5d588bd1faeeacd651ed16 module/ssrf-runtime -c1f4358865191005afa6885abf8ed891e0d6fbdc82a04bb7473a5108a0e21516 module/state-paths -80eca61f8d0fc88798a7fb62bc09fee8ffeec7b2329abaaecf3c41c16fa1ba83 module/status-helpers +f67ce559c0377890205574f59daa172e68cc6c64ace4c04738b661f206d51c08 module/skill-commands-runtime +fe9fd3202f853b85cc464bdf54cd8df88106df4518b61d88936ea622ef8c968d module/speech-settings +ecd8c0ab209858490d993553882a519a82b52275033466415532ddcac34c3bf8 module/ssrf-policy +17b7e257f91565ecff806f4b3a7d2b25a0f2c4ccc71a5ef1e97e367883f75d48 module/ssrf-runtime +5501c65f90feec38049ce100cb320b2a629ebf1ad04b21f015a0d44aa1e4c448 module/state-paths +b47ba2bb74e6859fe4b0a21c690f6fe6038bd43e372bd02b5dfbe1accf35d65b module/status-helpers f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-coerce-runtime -799a08a3be07df057bb62c01f51b1a35bdfa360ebc3f4c5f9fcccedd19d3aea5 module/telegram-account +5fd070cabf4cbb1884ff41c25fe55283077d93e16de079f6be0a95fec0f85cfa module/telegram-account aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path 87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking -87f5a0d7948934e403f4e15cb4d2fb54dd97a16a35dffec3cf5ee66ddbd46169 module/text-runtime -13b06b24683c94edffe5247c3ace0572e7b37ba96ae1d0eeaa79c523c106c322 module/tool-plugin +434804a2166f6bf2e872bff0f03f3af850e44dea04b49b1d13b41df872bb71d1 module/text-runtime +f25191b4cf9f557b9cb130a94a379dbf391ee92e8ae306fe15b0135ca9225165 module/tool-plugin dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results -9a2f1691d747799e833f19ce8b31b844421a660354221d42299ade9cfefcbeda module/tool-send +89d5f5db653c6786338d7f65554d6f595bbae5fc6293577e73ae618c2aace44b module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -211f58b9cba3ae900fe15df0b53de685708e8b20036f0130f63f20b2ae16e67c module/webhook-ingress -3d7a4d6c0e769a78a47d6a67393c72b3a5df9c272058f1f072a28ac9d6b2cfb8 module/webhook-request-guards +82ef69cdd3ebf8cc3a39bc9173838ab2d4c738d32681de58fe02a71526c941a9 module/webhook-ingress +a107b97d3c1bb7494e516760d613950d30dbf57ccaea4b037e2e832ca6115839 module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index fe5e4b81fc26..ea66385f2eb5 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -675,6 +675,10 @@ "source": "Memory overview", "target": "记忆概览" }, + { + "source": "QMD memory backend removal", + "target": "QMD 记忆后端移除" + }, { "source": "Active memory", "target": "主动记忆" diff --git a/docs/.i18n/zh-Hans-navigation.json b/docs/.i18n/zh-Hans-navigation.json index d3f09de8e5e4..b2ec78750294 100644 --- a/docs/.i18n/zh-Hans-navigation.json +++ b/docs/.i18n/zh-Hans-navigation.json @@ -142,7 +142,6 @@ "pages": [ "zh-CN/concepts/memory", "zh-CN/concepts/memory-builtin", - "zh-CN/concepts/memory-qmd", "zh-CN/concepts/memory-honcho", "zh-CN/concepts/memory-search", "zh-CN/concepts/active-memory", diff --git a/docs/cli/memory.md b/docs/cli/memory.md index e0b1b2171398..60642b9e2d08 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -27,13 +27,13 @@ openclaw memory status [--agent ] [--deep] [--index] [--fix] [--json] [--ver Without `--agent`, runs for every agent in `agents.entries`; if no agent list is configured, falls back to the default agent. -| Flag | Effect | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--deep` | Probe vector-store, embedding-provider, and semantic-search readiness (implies extra provider calls). Plain `memory status` stays fast and skips this; a complete persisted index is shown as `indexed (unprobed)`, while unknown vector/semantic state means it was not probed. QMD lexical `searchMode: "search"` always skips semantic vector probes, even with `--deep`. | -| `--index` | Reindex if the store is dirty. Implies `--deep`. | -| `--fix` | Repair stale recall locks and normalize promotion metadata. | -| `--json` | Print JSON. | -| `--verbose` | Emit detailed per-phase logs. | +| Flag | Effect | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--deep` | Probe vector-store, embedding-provider, and semantic-search readiness (implies extra provider calls). Plain `memory status` stays fast and skips this; a complete persisted index is shown as `indexed (unprobed)`, while unknown vector/semantic state means it was not probed. | +| `--index` | Reindex if the store is dirty. Implies `--deep`. | +| `--fix` | Repair stale recall locks and normalize promotion metadata. | +| `--json` | Print JSON. | +| `--verbose` | Emit detailed per-phase logs. | If the `Dreaming` line stays `off` even with `dreaming.enabled: true`, or scheduled sweeps never seem to run, the managed dreaming cron depends on the diff --git a/docs/cli/policy.md b/docs/cli/policy.md index a02d6290ff02..354d11d2bd70 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -418,12 +418,12 @@ allowlist such as `["all"]`. #### Data Handling -| Policy field | Observed state | Use when | -| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| `dataHandling.sensitiveLogging.requireRedaction` | Runtime invariant `oc://openclaw.invariant/logging/redaction` | Set to `true` to record the requirement; OpenClaw always satisfies it. | -| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. | -| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. | -| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.qmd.sessions.enabled`, `memory.search.experimental.sessionMemory`, and per-agent overrides | Set to `true` to reject session transcript indexing into memory. | +| Policy field | Observed state | Use when | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `dataHandling.sensitiveLogging.requireRedaction` | Runtime invariant `oc://openclaw.invariant/logging/redaction` | Set to `true` to record the requirement; OpenClaw always satisfies it. | +| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. | +| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. | +| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.search.experimental.sessionMemory`, `memory.search.rememberAcrossConversations`, and per-agent overrides | Set to `true` to reject session transcript indexing into memory. | #### Secrets diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index f68273bab4f4..ee748248b53f 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -63,8 +63,7 @@ workspace memory (`MEMORY.md` and `memory/*.md`) keeps its existing behavior. Active Memory must remain enabled. Retrieval adds a bounded blocking step to eligible replies; timeout, unavailable search, and empty results all continue the reply without recalled transcript context. OpenClaw's built-in memory -provider supports this protected transcript-recall path with both the builtin -and QMD backends. Other memory providers keep their own recall behavior but do +provider supports this protected transcript-recall path. Other memory providers keep their own recall behavior but do not automatically receive private transcript authorization. `openclaw doctor` reports an unsupported provider or missing `memory_search` tool. @@ -662,7 +661,6 @@ All active memory configuration lives under `plugins.entries.active-memory`. | `config.persistTranscripts` | `boolean` | Exports blocking sub-agent transcripts as JSONL artifacts before removing their temporary SQLite session rows | | `config.transcriptDir` | `string` | Relative transcript-artifact directory under the agent sessions folder (default `"active-memory"`) | | `config.modelFallback` | `string` | Optional model used only as the last step in the [model fallback chain](#model-fallback-policy) | -| `config.qmd.searchMode` | `"inherit" \| "search" \| "vsearch" \| "query"` | Overrides the QMD search mode used by the blocking sub-agent; default `"search"` (fast lexical search) — use `"inherit"` to match the main memory backend setting | Useful tuning fields: diff --git a/docs/concepts/memory-architecture.md b/docs/concepts/memory-architecture.md index ec9eab725405..2a24811b5555 100644 --- a/docs/concepts/memory-architecture.md +++ b/docs/concepts/memory-architecture.md @@ -405,14 +405,14 @@ authority in a future session. Memory architecture is mostly convention over configuration; these are the knobs that exist: -| Concern | Where | Reference | -| ------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------- | -| Dreaming enable, cadence, model | `plugins.entries.memory-core.config.dreaming` | [Dreaming](/concepts/dreaming) | -| Search providers, hybrid tuning | `memory.search` | [Memory config](/reference/memory-config) | -| Escalation lane mode, scope | `plugins.entries.active-memory` | [Active memory](/concepts/active-memory) | -| Cross-conversation recall | `agents.entries..memory.search.rememberAcrossConversations` | [Active memory](/concepts/active-memory) | -| Flush behavior | `agents.defaults.compaction.memoryFlush` | [Memory overview](/concepts/memory) | -| Backend selection | plugin slots | [Builtin](/concepts/memory-builtin), [QMD](/concepts/memory-qmd) | +| Concern | Where | Reference | +| ------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------- | +| Dreaming enable, cadence, model | `plugins.entries.memory-core.config.dreaming` | [Dreaming](/concepts/dreaming) | +| Search providers, hybrid tuning | `memory.search` | [Memory config](/reference/memory-config) | +| Escalation lane mode, scope | `plugins.entries.active-memory` | [Active memory](/concepts/active-memory) | +| Cross-conversation recall | `agents.entries..memory.search.rememberAcrossConversations` | [Active memory](/concepts/active-memory) | +| Flush behavior | `agents.defaults.compaction.memoryFlush` | [Memory overview](/concepts/memory) | +| Memory plugin selection | `plugins.slots.memory` | [Builtin](/concepts/memory-builtin), [Plugins](/plugins) | ## Related diff --git a/docs/concepts/memory-builtin.md b/docs/concepts/memory-builtin.md index d8ee0c04d9a4..3c42025f0125 100644 --- a/docs/concepts/memory-builtin.md +++ b/docs/concepts/memory-builtin.md @@ -120,8 +120,9 @@ The builtin engine is the right choice for most users: - Supports all embedding providers. - Hybrid search combines the best of both retrieval approaches. -Consider switching to [QMD](/concepts/memory-qmd) if you need reranking, query -expansion, or want to index directories outside the workspace. +The builtin engine can index directories outside the workspace with +`memory.search.extraPaths`, but it does not provide query expansion or a +separate reranking stage. Consider [Honcho](/concepts/memory-honcho) if you want cross-session memory with automatic user modeling. diff --git a/docs/concepts/memory-honcho.md b/docs/concepts/memory-honcho.md index 519013226f65..7ce1307352db 100644 --- a/docs/concepts/memory-honcho.md +++ b/docs/concepts/memory-honcho.md @@ -107,18 +107,17 @@ sees the prompt. ## Honcho vs builtin memory -| | Builtin / QMD | Honcho | -| ----------------- | ---------------------------- | ----------------------------------- | -| **Storage** | Workspace Markdown files | Dedicated service (local or hosted) | -| **Cross-session** | Via memory files | Automatic, built-in | -| **User modeling** | Manual (write to MEMORY.md) | Automatic profiles | -| **Search** | Vector + keyword (hybrid) | Semantic over observations | -| **Multi-agent** | Not tracked | Parent/child awareness | -| **Dependencies** | None (builtin) or QMD binary | Plugin install | +| | Builtin memory | Honcho | +| ----------------- | --------------------------- | ----------------------------------- | +| **Storage** | Workspace Markdown files | Dedicated service (local or hosted) | +| **Cross-session** | Via memory files | Automatic, built-in | +| **User modeling** | Manual (write to MEMORY.md) | Automatic profiles | +| **Search** | Vector + keyword (hybrid) | Semantic over observations | +| **Multi-agent** | Not tracked | Parent/child awareness | +| **Dependencies** | None | Plugin install | -Honcho and the builtin memory system can work together. When QMD is -configured, additional tools become available for searching local Markdown -files alongside Honcho's cross-session memory. +Honcho and the builtin memory system can work together. Builtin search keeps +local Markdown available alongside Honcho's cross-session memory. ## CLI commands @@ -139,5 +138,4 @@ openclaw honcho search [-k N] [-d D] # Semantic search over memory - [Memory overview](/concepts/memory) - [Builtin memory engine](/concepts/memory-builtin) -- [QMD memory engine](/concepts/memory-qmd) - [Context Engines](/concepts/context-engine) diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index 07a18d481694..4f1fe3f461dc 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -1,304 +1,20 @@ --- -summary: "Local-first search sidecar with BM25, vectors, reranking, and query expansion" -title: "QMD memory engine" +summary: "QMD memory backend removal and migration" read_when: - - You want to set up QMD as your memory backend - - You want advanced memory features like reranking or extra indexed paths + - Migrating an installation that used the QMD memory backend +title: "QMD memory backend removal" --- -[QMD](https://github.com/tobi/qmd) is a local-first search sidecar that runs -alongside OpenClaw. It combines BM25, vector search, and reranking in a single -binary, and can index content beyond your workspace memory files. +# QMD memory backend removal -## What it adds over builtin +The optional QMD memory backend has been removed. Builtin memory is now the only memory engine. -- **Reranking and query expansion** for better recall. -- **Index extra directories** - project docs, team notes, anything on disk. -- **Index session transcripts** - recall earlier conversations. -- **Fully local** - runs with the official llama.cpp provider plugin and - auto-downloads GGUF models. -- **Automatic fallback** - if QMD is unavailable, OpenClaw falls back to the - builtin engine seamlessly. +Run `openclaw doctor --fix` to remove retired `memory.backend`, `memory.qmd.*`, and +`memory.search.qmd.*` settings, including agent-scoped variants. Your Markdown memory sources are +indexed by the builtin engine on its next sync. Doctor preserves configured QMD paths and extra +collections in the corresponding `memory.search.extraPaths` setting. Custom QMD glob patterns are +removed; builtin memory indexes its supported files under each preserved path and reports that +change during migration. QMD indexes, exported session Markdown, downloaded models, and collection +metadata are derived state and do not require migration. -## Getting started - -### Prerequisites - -- Install QMD: `npm install -g @tobilu/qmd` or `bun install -g @tobilu/qmd` -- SQLite build that allows extensions (`brew install sqlite` on macOS). -- QMD must be on the gateway's `PATH`. -- macOS and Linux work out of the box. Windows is best supported via WSL2. - -### Enable - -```json5 -{ - memory: { - backend: "qmd", - }, -} -``` - -OpenClaw creates a self-contained QMD home under -`~/.openclaw/agents//qmd/` and manages the sidecar lifecycle -automatically - collections, updates, and embedding runs are handled for you. -It prefers current QMD collection and MCP query shapes, but falls back to -alternate collection-pattern flags and older MCP tool names when needed. -Startup reconciliation also recreates stale managed collections back to their -canonical patterns when an older QMD collection with the same name is still -present. - -## How the sidecar works - -- OpenClaw creates collections from workspace memory files and configured - `memory.qmd.paths`. The QMD adapter owns update, embedding, debounce, and - timeout heuristics; these are not user configuration. -- QMD continues to own its `index.sqlite`, YAML collection config, and model - downloads under the per-agent QMD home; these are external-tool artifacts, - not OpenClaw state tables. OpenClaw-owned coordination lives only in SQLite: - one shared lease limits embedding work across agents, while one lease in each - agent database serializes that agent's collection, update, and embed writes. - Runtime no longer creates QMD file-lock sidecars. `openclaw doctor --fix` - removes retired sidecars only after proving their old process owner is stale. - Upgrades are a clean cutover: stop and restart every OpenClaw process that - shares the state directory before using the new version. Mixed old/new QMD - writers are unsupported; runtime intentionally does not dual-lock the retired - sidecars. -- The default workspace collection tracks `MEMORY.md` plus the `memory/` - tree. Lowercase `memory.md` is not indexed as a root memory file. -- QMD's own scanner ignores hidden paths and common dependency/build - directories such as `.git`, `.cache`, `node_modules`, `vendor`, `dist`, and - `build`. Gateway startup keeps QMD lazy; the manager initializes when memory - is first used. -- Searches use the configured `searchMode` (default: `search`; also supports - `vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic - vector readiness probes and embedding maintenance in that mode. If a mode - fails, OpenClaw retries with `qmd query`. -- When `searchMode` is `query`, set `memory.qmd.rerank` to `false` to use - QMD's hybrid query path without the reranker (requires QMD 2.1 or newer). - OpenClaw passes `--no-rerank` to the direct QMD CLI path and - `rerank: false` to QMD's MCP query tool. -- With QMD releases that advertise multi-collection filters, OpenClaw groups - same-source collections into one QMD search invocation. Older QMD releases - keep the compatible per-collection fallback. -- If QMD fails entirely, OpenClaw falls back to the builtin SQLite engine. - Repeated chat-turn attempts back off briefly after an open failure so a - missing binary or broken sidecar dependency does not create a retry storm; - `openclaw memory status` and one-shot CLI probes still recheck QMD - directly. - - -The first search may be slow - QMD auto-downloads GGUF models (~2 GB) for -reranking and query expansion on the first `qmd query` run. - - -## Search performance and compatibility - -OpenClaw keeps the QMD search path compatible with both current and older QMD -installs. - -On startup, OpenClaw checks the installed QMD help text once per manager. If -the binary advertises support for multiple collection filters, OpenClaw -searches all same-source collections with one command: - -```bash -qmd search "router notes" --json -n 10 -c memory-root-main -c memory-dir-main -``` - -This avoids starting one QMD subprocess per durable-memory collection. -Session transcript collections stay in their own source group, so mixed -`memory` + `sessions` searches still give the result diversifier input from -both sources. - -Older QMD builds only accept one collection filter. When OpenClaw detects one -of those builds, it keeps the compatibility path and searches each collection -separately before merging and deduplicating results. - -To inspect the installed contract manually, run: - -```bash -qmd --help | grep -i collection -``` - -Current QMD help mentions targeting one or more collections. Older help -usually describes a single collection. - -## Model overrides - -QMD model environment variables pass through unchanged from the gateway -process, so you can tune QMD globally without adding new OpenClaw config: - -```bash -export QMD_EMBED_MODEL="hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf" -export QMD_RERANK_MODEL="/absolute/path/to/reranker.gguf" -export QMD_GENERATE_MODEL="/absolute/path/to/generator.gguf" -``` - -After changing the embedding model, rerun embeddings so the index matches the -new vector space. - -## Indexing extra paths - -Point QMD at additional directories to make them searchable: - -```json5 -{ - memory: { - backend: "qmd", - qmd: { - paths: [{ name: "docs", path: "~/notes", pattern: "**/*.md" }], - }, - }, -} -``` - -Snippets from extra paths appear as `qmd//` in -search results. `memory_get` understands this prefix and reads from the -correct collection root. - -## Indexing session transcripts - -Enable session indexing to recall earlier conversations. QMD needs both the -general `memory.search` session source and the QMD transcript exporter: - -```json5 -{ - memory: { - backend: "qmd", - search: { - experimental: { sessionMemory: true }, - sources: ["memory", "sessions"], - }, - qmd: { - sessions: { enabled: true }, - }, - }, -} -``` - -Transcripts export as sanitized User/Assistant turns into a dedicated QMD -collection under `~/.openclaw/agents//qmd/sessions/`. Setting only -`sources: ["sessions"]` does not export transcripts into QMD; also enable -`rememberAcrossConversations` or explicit QMD session export. - -Session hits are still filtered by -[`tools.sessions.visibility`](/gateway/config-tools#toolssessions). The -default `tree` visibility includes the current session, its spawned sessions, -and same-agent group sessions watched through ambient group awareness. With -`session.dmScope: "main"`, users in a multi-user DM setup share the main -session and can recall content from its watched groups. Use a per-peer -`dmScope` for DM isolation, or set visibility to `"self"` to opt out of ambient -watched-session reads. Other unrelated same-agent sessions still require -`"agent"` visibility. - -## Search scope - -By default, QMD search results are surfaced only in direct sessions (not -group or channel chats). Configure `memory.qmd.scope` to change this: - -```json5 -{ - memory: { - qmd: { - scope: { - default: "deny", - rules: [{ action: "allow", match: { chatType: "direct" } }], - }, - }, - }, -} -``` - -The snippet above is the actual default rule. When scope denies a search, -OpenClaw logs a warning with the derived channel and chat type so empty -results are easier to debug. - -## Citations - -When `memory.citations` is `auto` or `on`, search snippets get a -`Source: #L` (or `#L-L`) footer appended. In `auto` -mode the footer is added only for direct-chat sessions. Set -`memory.citations = "off"` to omit the footer while still passing the path to -the agent internally. - -## When to use - -Choose QMD when you need: - -- Reranking for higher-quality results. -- To search project docs or notes outside the workspace. -- To recall past session conversations. -- Fully local search with no API keys. - -For simpler setups, the [builtin engine](/concepts/memory-builtin) works well -with no extra dependencies. - -## Troubleshooting - -**QMD not found?** Ensure the binary is on the gateway's `PATH`. If OpenClaw -runs as a service, create a symlink: -`sudo ln -s ~/.bun/bin/qmd /usr/local/bin/qmd`. - -If `qmd --version` works in your shell but OpenClaw still reports -`spawn qmd ENOENT`, the gateway process likely has a different `PATH` than -your interactive shell. Pin the binary explicitly: - -```json5 -{ - memory: { - backend: "qmd", - qmd: { - command: "/absolute/path/to/qmd", - }, - }, -} -``` - -Use `command -v qmd` in the environment where QMD is installed, then recheck -with `openclaw memory status --deep`. - -**First search very slow?** QMD downloads GGUF models on first use. Pre-warm -with `qmd query "test"` using the same XDG dirs OpenClaw uses. - -**Many QMD subprocesses during search?** Update QMD if possible. OpenClaw -uses one process for same-source multi-collection searches only when the -installed QMD advertises support for multiple `-c` filters; otherwise it -keeps the older per-collection fallback for correctness. - -**BM25-only QMD still trying to build llama.cpp?** Set -`memory.qmd.searchMode = "search"`. OpenClaw treats that mode as -lexical-only, skips QMD vector status probes and embedding maintenance, and -leaves semantic readiness checks to `vsearch` or `query` setups. - -**Search times out?** Increase `memory.qmd.limits.timeoutMs` (default: 4000ms). -Set it higher, for example `120000`, for slower hardware. This limit applies to -QMD's own search commands during agent `memory_search` calls; setup, sync, -builtin fallback, and supplemental corpus work keep their own shorter deadlines. - -**Empty results in group or channel chats?** This is expected with the -default `memory.qmd.scope`, which allows only direct sessions. Add an -`allow` rule for `group` or `channel` chat types if you want QMD results -there. - -**Root memory search suddenly got too broad?** Restart the gateway or wait -for the next startup reconciliation. OpenClaw recreates stale managed -collections back to canonical `MEMORY.md` and `memory/` patterns when it -detects a same-name conflict. - -**Workspace-visible temp repos causing `ENAMETOOLONG` or broken indexing?** -QMD traversal follows the underlying QMD scanner rather than OpenClaw's -builtin symlink rules. Keep temporary monorepo checkouts under hidden -directories like `.tmp/` or outside indexed QMD roots until QMD exposes -cycle-safe traversal or explicit exclusion controls. - -## Configuration - -For the full config surface (`memory.qmd.*`), search modes, update intervals, -scope rules, and all other knobs, see the -[Memory configuration reference](/reference/memory-config). - -## Related - -- [Memory overview](/concepts/memory) -- [Builtin memory engine](/concepts/memory-builtin) -- [Honcho memory](/concepts/memory-honcho) +See [Memory](/concepts/memory) for the current architecture and configuration. diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index d1d1ec0fa9af..19ef22072221 100644 --- a/docs/concepts/memory-search.md +++ b/docs/concepts/memory-search.md @@ -178,11 +178,6 @@ from its watched groups. Use a per-peer `dmScope` for DM isolation, or set visibility to `"self"` to opt out of ambient watched-session reads. Other unrelated same-agent sessions still require `"agent"` visibility. -When using the QMD backend, also set `memory.qmd.sessions.enabled: true` so -transcripts get exported into the QMD collection; `experimental.sessionMemory` -and `sources` alone do not export transcripts into QMD. See -[configuration reference](/reference/memory-config#session-memory-search-experimental). - ## Troubleshooting **No results?** Run `openclaw memory status` to check the index. If empty, run diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 6916b6cf80e0..c2ccbc50541e 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -184,17 +184,13 @@ a generic OpenAI-compatible endpoint. See [Memory search](/concepts/memory-search) for how search works, tuning options, and provider setup. -## Memory backends +## Memory engines SQLite-based. Works out of the box with keyword search, vector similarity, and hybrid search. No extra dependencies. - -Local-first sidecar with reranking, query expansion, and the ability to index -directories outside the workspace. - AI-native cross-session memory with user modeling, semantic search, and multi-agent awareness. Plugin install. @@ -330,7 +326,6 @@ openclaw memory index --force # Rebuild the index - [Memory search](/concepts/memory-search): search pipeline, providers, and tuning. - [Builtin memory engine](/concepts/memory-builtin): default SQLite backend. -- [QMD memory engine](/concepts/memory-qmd): advanced local-first sidecar. - [Honcho memory](/concepts/memory-honcho): AI-native cross-session memory. - [Memory LanceDB](/plugins/memory-lancedb): LanceDB-backed plugin with OpenAI-compatible embeddings. - [Memory Wiki](/plugins/memory-wiki): compiled knowledge vault and wiki-native tools. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 79cab002552f..4c69abc2f670 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -161,44 +161,13 @@ an explicit agent when multiple agents are configured. See [Memory Wiki per-agent vaults](/plugins/memory-wiki#per-agent-vaults) for bridge filtering, migration, and trust-boundary details. -## Cross-agent QMD memory search +## Cross-agent memory search -To let one agent search another agent's QMD session transcripts, add extra collections under `agents.entries.*.memory.search.qmd.extraCollections`. Use `memory.search.qmd.extraCollections` when every agent should share the same collections. - -```json5 -{ - agents: { - defaults: { - workspace: "~/workspaces/main", - }, - entries: { - main: { - default: true, - workspace: "~/workspaces/main", - memory: { - search: { - qmd: { - extraCollections: [{ path: "notes" }], // resolves inside workspace -> collection named "notes-main" - }, - }, - }, - }, - family: { workspace: "~/workspaces/family" }, - }, - }, - memory: { - backend: "qmd", - search: { - qmd: { - extraCollections: [{ path: "~/agents/family/sessions", name: "family-sessions" }], - }, - }, - qmd: { includeDefaultMemory: false }, - }, -} -``` - -An extra-collection path can be shared across agents, but its `name` stays explicit when the path is outside the agent workspace. Paths inside the workspace stay agent-scoped so each agent keeps its own transcript search set. +The QMD cross-agent search path was removed. Builtin memory does not search +another agent's transcript corpus; each agent searches only its own configured +memory and eligible same-agent session sources. Put intentionally shared +Markdown in an explicit shared `memory.search.extraPaths` directory when the +same reference material should be indexed by multiple agents. ## One WhatsApp number, multiple people (DM split) diff --git a/docs/docs.json b/docs/docs.json index 97ef04edca20..3ce54afb83a4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1260,7 +1260,6 @@ "concepts/user-model", "concepts/standing-intents", "concepts/memory-builtin", - "concepts/memory-qmd", "concepts/memory-honcho", "concepts/memory-search", "concepts/active-memory", diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 0c80f542fa69..ed4cd48e1f54 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -184,7 +184,6 @@ knob. | `agents.defaults.startupContext.*` | One-shot reset/startup model-run prelude, including recent daily `memory/*.md` files. Bare chat `/new` and `/reset` are acknowledged without invoking the model | | `skills.limits.*` | The compact skills list injected into the system prompt | | `agents.defaults.contextLimits.*` | Bounded runtime excerpts and injected runtime-owned blocks | -| `memory.qmd.limits.*` | Indexed memory-search snippet and injection sizing | Matching per-agent overrides: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 41f50c93c108..dd8d6cbcd1af 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -7,7 +7,7 @@ read_when: doc-schema-version: 1 --- -Field-level reference for `~/.openclaw/openclaw.json`: keys, defaults, and links to deeper subsystem pages. For task-oriented setup guidance, see [Configuration](/gateway/configuration). Channel- and plugin-owned command catalogs and deep memory/QMD knobs live on their own pages, not here. +Field-level reference for `~/.openclaw/openclaw.json`: keys, defaults, and links to deeper subsystem pages. For task-oriented setup guidance, see [Configuration](/gateway/configuration). Channel- and plugin-owned command catalogs and deep memory knobs live on their own pages, not here. Config format is **JSON5** (comments + trailing commas allowed). All fields are optional; OpenClaw uses safe defaults when omitted. @@ -25,7 +25,7 @@ ancestor declaration. A path with no declared ancestor is advanced by default. Dedicated deep references: -- [Memory configuration reference](/reference/memory-config) for `memory.search.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming`. +- [Memory configuration reference](/reference/memory-config) for `memory.search.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming`. - [Slash commands](/tools/slash-commands) for the current built-in + bundled command catalog. - Owning channel/plugin pages for channel-specific command surfaces. @@ -399,9 +399,7 @@ cannot be read, account-wide exposure fails closed. - Full memory config lives in [Memory configuration reference](/reference/memory-config): - `memory.search.*` - `agents.entries.*.memory.search.*` for per-agent overrides - - `memory.backend` - `memory.citations` - - `memory.qmd.*` - `plugins.entries.memory-core.config.dreaming` - Enabled Claude bundle plugins can also contribute embedded OpenClaw defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches. - `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 6a894b74dbfe..2ff0a326a1cd 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -203,7 +203,7 @@ Flags: - Workspace bootstrap file size check (truncation/near-limit warnings for context files). - Skills readiness check for the default agent; reports allowed skills with missing bins, env, config, or OS requirements, and `--fix` can disable unavailable skills in `skills.entries`. - Shell completion status check and auto-install/upgrade. - - Memory search embedding provider readiness check (local model, remote API key, or QMD binary). + - Memory search embedding provider readiness check (local model or remote API key). - Source install checks (pnpm workspace mismatch, missing UI assets, missing tsx binary). - Writes updated config + wizard metadata. @@ -561,9 +561,8 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor runs a health check and offers to restart the gateway when it looks unhealthy. - Doctor checks whether the configured memory search embedding provider is ready for the default agent. The behavior depends on the configured backend and provider: + Doctor checks whether the configured memory search embedding provider is ready for the default agent. The behavior depends on the configured provider: - - **QMD backend**: probes whether the `qmd` binary is available and startable. If not, prints fix guidance including `npm install -g @tobilu/qmd` (or the Bun equivalent) and a manual binary path option. - **Explicit local provider**: checks for a local model file or a recognized remote/downloadable model URL. If missing, suggests switching to a remote provider. - **Explicit remote provider** (`openai`, `voyage`, etc.): verifies an API key is present in the environment or auth store. Prints actionable fix hints if missing. - **Legacy auto provider**: treats `memorySearch.provider: "auto"` as OpenAI, checks OpenAI readiness, and `doctor --fix` rewrites it to `provider: "openai"`. diff --git a/docs/plugins/memory-wiki.md b/docs/plugins/memory-wiki.md index d28204e05ef4..d6a4084e6e58 100644 --- a/docs/plugins/memory-wiki.md +++ b/docs/plugins/memory-wiki.md @@ -13,8 +13,8 @@ navigable wiki: deterministic pages, structured claims with evidence, provenance, dashboards, and machine-readable digests. It does not replace the active memory plugin. Recall, promotion, indexing, and -dreaming stay owned by whichever memory backend is configured -(`memory-core`, QMD, Honcho, etc.). `memory-wiki` sits beside it and compiles +dreaming stay owned by the configured memory plugin (`memory-core`, Honcho, +and others). `memory-wiki` sits beside it and compiles knowledge into a maintained wiki layer. Enable the plugin before using its CLI, tools, or runtime integration: @@ -35,9 +35,9 @@ Practical rule: - `wiki_search` / `wiki_get` when you want wiki-specific ranking, provenance, or page-level belief structure - `memory_search corpus=all` to span both layers in one call, when the active memory plugin supports corpus selection -A common local-first setup: QMD as the active memory backend for recall, and -`memory-wiki` in `bridge` mode for durable synthesized pages. See the -QMD + bridge mode example under [Configuration](#configuration). +A common local-first setup uses builtin memory for recall and `memory-wiki` in +`bridge` mode for durable synthesized pages. See the bridge-mode example under +[Configuration](#configuration). If bridge mode reports zero exported artifacts, the active memory plugin is not currently exposing public bridge inputs. Run `openclaw wiki doctor` first, @@ -460,18 +460,15 @@ still read another agent's directory. Use [sandboxing](/gateway/sandboxing) or each other. -### Example: QMD + bridge mode +### Example: builtin memory + bridge mode -Use this when you want QMD for recall and `memory-wiki` for a maintained -knowledge layer. Each layer stays focused: QMD keeps raw notes, session -exports, and extra collections searchable, while `memory-wiki` compiles -stable entities, claims, dashboards, and source pages. +Use this when you want builtin memory for recall and `memory-wiki` for a +maintained knowledge layer. Each layer stays focused: `memory-core` searches +memory notes and eligible session sources, while `memory-wiki` compiles stable +entities, claims, dashboards, and source pages. ```json5 { - memory: { - backend: "qmd", - }, plugins: { entries: { "memory-wiki": { @@ -500,7 +497,7 @@ stable entities, claims, dashboards, and source pages. } ``` -This keeps QMD in charge of active memory recall, `memory-wiki` focused on +This keeps builtin memory in charge of active recall, `memory-wiki` focused on compiled pages and dashboards, and prompt shape unchanged until you intentionally enable compiled digest prompts. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 9e5b69270147..fc84589ebf9d 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -378,7 +378,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports | | `plugin-sdk/memory-core-host-engine-fs` | Private-local focused filesystem and user-path helpers for doctor migrations | | `plugin-sdk/memory-core-host-engine-embeddings` | Private-local after July 2026; Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers. `registerMemoryEmbeddingProvider` on this surface is deprecated; use the generic embedding provider API for new providers. | - | `plugin-sdk/memory-core-host-engine-qmd` | Private-local after July 2026; Memory host QMD engine exports | + | `plugin-sdk/memory-core-host-engine-sessions` | Private-local after July 2026; Memory session transcript and query helpers | | `plugin-sdk/memory-core-host-engine-schema` | Private-local focused memory index schema and sqlite-vec helpers for doctor migrations | | `plugin-sdk/memory-core-host-engine-storage` | Private-local after July 2026; Memory host storage engine exports | | `plugin-sdk/memory-core-host-secret` | Private-local after July 2026; Memory host secret helpers | diff --git a/docs/reference/database-schemas.md b/docs/reference/database-schemas.md index 6061e4201233..d9d6112fb8e5 100644 --- a/docs/reference/database-schemas.md +++ b/docs/reference/database-schemas.md @@ -107,7 +107,7 @@ The general procedure is: ### Example: agent schema 11 to 9 -Schema 10 added the active transcript projection. Schema 11 added leases, durable delivery, conversation-address state, and heartbeat outcomes. QMD coordination uses rows in `state_leases`; there is no separate QMD table to preserve. +Schema 10 added the active transcript projection. Schema 11 added leases, durable delivery, conversation-address state, and heartbeat outcomes. Run equivalent SQL against each affected per-agent database after inspecting the exact schema that wrote it: diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index 74570d44ee91..ead4d5c8e73b 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -1,10 +1,9 @@ --- -summary: "Memory search providers, retrieval modes, QMD, and multimodal indexing" +summary: "Built-in memory search providers, retrieval modes, and multimodal indexing" title: "Memory configuration reference" sidebarTitle: "Memory config" read_when: - You want to configure memory search providers or embedding models - - You want to set up the QMD backend - You want to enable hybrid search, MMR, or temporal decay - You want to enable multimodal memory indexing --- @@ -18,9 +17,6 @@ This page lists every configuration knob for OpenClaw memory search. For concept Default SQLite backend. - - Local-first sidecar. - Search pipeline and tuning. @@ -71,13 +67,10 @@ The value follows normal `memory.search` inheritance with a per-agent override. When unset, it defaults on only if global `session.dmScope` is unset or `"main"` and no binding has a `session.dmScope` override. Any configured DM isolation defaults it off. An explicit `true` or -`false` always wins. Enabling it implies session transcript indexing and -adds `sessions` to the agent's resolved memory sources. With QMD, it also -enables that agent's session export; no separate -`memory.qmd.sessions.enabled` setting is required for this mode. +`false` always wins. Enabling it implies session transcript indexing and adds +`sessions` to the agent's resolved memory sources. -OpenClaw's built-in memory provider supports this protected path with both the -builtin and QMD backends. Alternate memory providers can keep using their own +OpenClaw's built-in memory provider supports this protected path. Alternate memory providers can keep using their own recall hooks and advanced Active Memory tools, but this setting is skipped unless the current provider supports protected private transcript recall. `openclaw doctor` reports an unsupported provider or an explicit Active Memory @@ -405,9 +398,7 @@ auto-injected. } ``` -Paths can be absolute or workspace-relative. Directories are scanned recursively for `.md` files. Symlink handling depends on the active backend: the builtin engine skips symlinks, while QMD follows the underlying QMD scanner behavior. - -For agent-scoped cross-agent transcript search, use `agents.entries.*.memory.search.qmd.extraCollections` instead of `memory.qmd.paths`. Those extra collections follow the same `{ path, name, pattern? }` shape, but they are merged per agent and can preserve explicit shared names when the path points outside the current workspace. If the same resolved path appears in both `memory.qmd.paths` and `memory.search.qmd.extraCollections`, QMD keeps the first entry and skips the duplicate. +Paths can be absolute or workspace-relative. Directories are scanned recursively for `.md` files. The builtin engine skips symlinks. --- @@ -494,53 +485,19 @@ agent should index and search session transcripts. For same-agent gateway-to-DM recall: - - - ```json5 - { - memory: { - search: { - experimental: { sessionMemory: true }, - sources: ["memory", "sessions"], - }, - }, - tools: { - sessions: { visibility: "agent" }, - }, - } - ``` - - - ```json5 - { - memory: { - backend: "qmd", - search: { - experimental: { sessionMemory: true }, - sources: ["memory", "sessions"], - }, - qmd: { - sessions: { enabled: true }, - }, - }, - tools: { - sessions: { visibility: "agent" }, - }, - } - ``` - - - -When using QMD, `sources: ["sessions"]` does not by itself export transcripts into QMD. Set -`memory.qmd.sessions.enabled: true` as well. The higher-level -`rememberAcrossConversations: true` setting is the exception: it implies the -required QMD session export for that agent. Implied exports stay private: -they always use the default internal export location (a configured -`sessions.exportDir` applies only to explicit exports), they are searched only -during that agent's cross-conversation recall, and ordinary `memory_get` -cannot read them. Explicit -`memory.qmd.sessions.enabled: true` keeps its existing behavior and makes -exported transcripts part of the ordinary memory corpus. +```json5 +{ + memory: { + search: { + experimental: { sessionMemory: true }, + sources: ["memory", "sessions"], + }, + }, + tools: { + sessions: { visibility: "agent" }, + }, +} +``` --- @@ -566,93 +523,15 @@ Built-in memory indexes live in each agent's OpenClaw SQLite database at --- -## QMD backend config +## Citations -Set `memory.backend = "qmd"` to enable. All QMD settings live under `memory.qmd`: +`memory.citations` controls citation visibility for built-in memory results: -| Key | Type | Default | Description | -| ------------------------ | --------- | -------- | ------------------------------------------------------------------------------------- | -| `command` | `string` | `qmd` | QMD executable path; set an absolute path when service `PATH` differs from your shell | -| `searchMode` | `string` | `search` | Search command: `search`, `vsearch`, `query` | -| `rerank` | `boolean` | -- | Set to `false` with `searchMode: "query"` and QMD 2.1+ to skip QMD reranking | -| `includeDefaultMemory` | `boolean` | `true` | Auto-index `MEMORY.md` + `memory/**/*.md` | -| `paths[]` | `array` | -- | Extra paths: `{ name, path, pattern? }` | -| `sessions.enabled` | `boolean` | `false` | Export session transcripts into QMD | -| `sessions.retentionDays` | `number` | -- | Transcript retention | -| `sessions.exportDir` | `string` | -- | Export directory | - -`searchMode: "search"` is lexical/BM25-only. OpenClaw does not run semantic vector readiness probes or QMD embedding maintenance for that mode, including during `memory status --deep`; `vsearch` and `query` continue to require QMD vector readiness and embeddings. - -`rerank: false` only changes QMD `query` mode and requires QMD 2.1 or newer. In direct CLI mode OpenClaw passes `--no-rerank`; in mcporter-backed MCP mode it passes `rerank: false` to QMD's unified query tool. Leave it unset to use QMD's default query reranking behavior. - -OpenClaw prefers current QMD collection and MCP query shapes, but keeps older QMD releases working by trying compatible collection pattern flags and older MCP tool names when needed. When QMD advertises support for multiple collection filters, same-source collections are searched with one QMD process; older QMD builds keep the per-collection compatibility path. Same-source means durable memory collections (default memory files plus custom paths) are grouped together, while session transcript collections remain a separate group so source diversification still has both inputs. - - -QMD model overrides stay on the QMD side, not OpenClaw config. If you need to override QMD's models globally, set environment variables such as `QMD_EMBED_MODEL`, `QMD_RERANK_MODEL`, and `QMD_GENERATE_MODEL` in the gateway runtime environment. - - - - - | Key | Type | Default | Description | - | --------------------------- | -------- | ------- | ------------------------------ | - | `limits.maxResults` | `number` | `4` | Max search results | - | `limits.maxSnippetChars` | `number` | `450` | Clamp snippet length | - | `limits.maxInjectedChars` | `number` | `2200` | Clamp total injected chars | - | `limits.timeoutMs` | `number` | `4000` | QMD command timeout during QMD-backed search, including `memory_search`; setup, sync, builtin fallback, and supplemental work keep the default tool deadline | - - - Controls which sessions can receive QMD search results. Same schema as [`session.sendPolicy`](/gateway/config-agents#session): - - ```json5 - { - memory: { - qmd: { - scope: { - default: "deny", - rules: [{ action: "allow", match: { chatType: "direct" } }], - }, - }, - }, - } - ``` - - The shipped default is DM/direct-only, denying groups and other channel types. `match.keyPrefix` matches the normalized session key; `match.rawKeyPrefix` matches the raw key including `agent::`. - - - - `memory.citations` applies to all backends: - - | Value | Behavior | - | ------------------ | ------------------------------------------------------ | - | `auto` (default) | Include `Source: ` footer in snippets | - | `on` | Always include footer | - | `off` | Omit footer (path still passed to agent internally) | - - - - -QMD initializes lazily when memory is first used; its adapter owns refresh and embedding schedules. - -### Full QMD example - -```json5 -{ - memory: { - backend: "qmd", - citations: "auto", - qmd: { - includeDefaultMemory: true, - update: { interval: "5m", debounceMs: 15000 }, - limits: { maxResults: 4, timeoutMs: 4000 }, - scope: { - default: "deny", - rules: [{ action: "allow", match: { chatType: "direct" } }], - }, - paths: [{ name: "docs", path: "~/notes", pattern: "**/*.md" }], - }, - }, -} -``` +| Value | Behavior | +| ---------------- | ------------------------------------------------------ | +| `auto` (default) | Include `Source: ` when useful | +| `on` | Always include the source footer | +| `off` | Omit the footer; the path remains available internally | --- diff --git a/extensions/active-memory/config.ts b/extensions/active-memory/config.ts index ba15928770f0..da6c3aa1ced6 100644 --- a/extensions/active-memory/config.ts +++ b/extensions/active-memory/config.ts @@ -18,7 +18,6 @@ import { DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS, DEFAULT_MAX_SUMMARY_CHARS, DEFAULT_MIN_TIMEOUT_MS, - DEFAULT_QMD_SEARCH_MODE, DEFAULT_QUERY_MODE, DEFAULT_RECENT_ASSISTANT_CHARS, DEFAULT_RECENT_ASSISTANT_TURNS, @@ -34,7 +33,6 @@ import { type ActiveMemoryChatType, type ActiveMemoryFastMode, type ActiveMemoryPromptStyle, - type ActiveMemoryQmdSearchMode, type ActiveMemoryThinkingLevel, type ActiveRecallPluginConfig, type ResolvedActiveRecallPluginConfig, @@ -139,13 +137,6 @@ function normalizePromptConfigText(value: unknown): string | undefined { return text ? text : undefined; } -function resolveQmdSearchMode(value: unknown): ActiveMemoryQmdSearchMode { - if (value === "inherit" || value === "search" || value === "vsearch" || value === "query") { - return value; - } - return DEFAULT_QMD_SEARCH_MODE; -} - function hasDeprecatedModelFallbackPolicy(pluginConfig: unknown): boolean { const raw = asRecord(pluginConfig); return raw ? Object.hasOwn(raw, "modelFallbackPolicy") : false; @@ -221,7 +212,6 @@ function normalizePluginConfig( const raw = ( pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {} ) as ActiveRecallPluginConfig; - const qmd = asRecord(raw.qmd); const allowedChatTypes = Array.isArray(raw.allowedChatTypes) ? raw.allowedChatTypes.filter( (value): value is ActiveMemoryChatType => @@ -294,36 +284,6 @@ function normalizePluginConfig( ), persistTranscripts: raw.persistTranscripts === true, transcriptDir: normalizeTranscriptDir(raw.transcriptDir), - qmd: { - searchMode: resolveQmdSearchMode(qmd?.searchMode), - }, - }; -} - -function applyActiveMemoryRuntimeConfigSnapshot( - cfg: OpenClawConfig, - pluginConfig: ResolvedActiveRecallPluginConfig, -): OpenClawConfig { - const existingEntry = asRecord(cfg.plugins?.entries?.["active-memory"]); - const existingPluginConfig = asRecord(existingEntry?.config); - return { - ...cfg, - plugins: { - ...cfg.plugins, - entries: { - ...cfg.plugins?.entries, - "active-memory": { - ...existingEntry, - config: { - ...existingPluginConfig, - qmd: { - ...asRecord(existingPluginConfig?.qmd), - searchMode: pluginConfig.qmd.searchMode, - }, - }, - }, - }, - }, }; } @@ -414,7 +374,6 @@ function applyCliRuntimeRecallTimeoutDefault( } export { - applyActiveMemoryRuntimeConfigSnapshot, applyCliRuntimeRecallTimeoutDefault, clampInt, hasDeprecatedModelFallbackPolicy, diff --git a/extensions/active-memory/doctor-contract-api.test.ts b/extensions/active-memory/doctor-contract-api.test.ts index c125d37ed5e9..b0716b7ef474 100644 --- a/extensions/active-memory/doctor-contract-api.test.ts +++ b/extensions/active-memory/doctor-contract-api.test.ts @@ -12,7 +12,35 @@ import type { PluginDoctorStateMigrationContext, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { stateMigrations } from "./doctor-contract-api.js"; +import { + legacyConfigRules, + normalizeCompatibilityConfig, + stateMigrations, +} from "./doctor-contract-api.js"; + +it("removes the retired QMD override while preserving Active Memory siblings", () => { + expect(legacyConfigRules).toEqual([ + expect.objectContaining({ + path: ["plugins", "entries", "active-memory", "config", "qmd"], + message: expect.stringContaining("doctor --fix"), + }), + ]); + const cfg = { + plugins: { + entries: { + "active-memory": { + config: { enabled: true, qmd: { searchMode: "search" } }, + }, + }, + }, + }; + + const result = normalizeCompatibilityConfig({ cfg }); + + expect(result.config).toHaveProperty("plugins.entries.active-memory.config.enabled", true); + expect(result.config).not.toHaveProperty("plugins.entries.active-memory.config.qmd"); + expect(result.changes).toEqual(["Removed retired Active Memory QMD search-mode configuration."]); +}); function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext { return { diff --git a/extensions/active-memory/doctor-contract-api.ts b/extensions/active-memory/doctor-contract-api.ts index a08ff047dab3..d1799aba9a5b 100644 --- a/extensions/active-memory/doctor-contract-api.ts +++ b/extensions/active-memory/doctor-contract-api.ts @@ -4,7 +4,9 @@ */ import crypto from "node:crypto"; import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { + asObjectRecord, defineLegacyJsonStateMigration, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; @@ -18,6 +20,40 @@ type ActiveMemoryToggleEntry = { const TOGGLE_STATE_FILE = "session-toggles.json"; const SESSION_TOGGLES_NAMESPACE = "session-toggles"; const MAX_TOGGLE_ENTRIES = 10_000; +const RETIRED_QMD_CONFIG_PATH = ["plugins", "entries", "active-memory", "config", "qmd"]; + +/** Retired Active Memory QMD override detected before strict manifest validation. */ +export const legacyConfigRules = [ + { + path: RETIRED_QMD_CONFIG_PATH, + message: + 'plugins.entries.active-memory.config.qmd is retired because the QMD memory backend was removed. Run "openclaw doctor --fix".', + }, +]; + +/** Removes the retired plugin-owned QMD override. */ +export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): { + config: OpenClawConfig; + changes: string[]; +} { + const entry = asObjectRecord(cfg.plugins?.entries?.["active-memory"]); + const pluginConfig = asObjectRecord(entry?.config); + if (!pluginConfig || !Object.hasOwn(pluginConfig, "qmd")) { + return { config: cfg, changes: [] }; + } + + const nextConfig = structuredClone(cfg); + const nextEntry = asObjectRecord(nextConfig.plugins?.entries?.["active-memory"]); + const nextPluginConfig = asObjectRecord(nextEntry?.config); + if (!nextPluginConfig) { + return { config: cfg, changes: [] }; + } + delete nextPluginConfig.qmd; + return { + config: nextConfig, + changes: ["Removed retired Active Memory QMD search-mode configuration."], + }; +} function resolveToggleStatePath(stateDir: string): string { return path.join(stateDir, "plugins", "active-memory", TOGGLE_STATE_FILE); diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index e657035bd932..f2a47c16ec76 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -2109,7 +2109,6 @@ describe("active-memory plugin", () => { expect(params.model).toBe("gpt-5.4-mini"); expect(params.messageProvider).toBe("webchat"); expect(params.sessionKey).toMatch(/^agent:main:main:active-memory:[a-f0-9]{12}$/); - expect(activeMemoryConfigFrom(embeddedRunConfig()).qmd).toEqual({ searchMode: "search" }); expect(params.cleanupBundleMcpOnRunEnd).toBe(true); }); @@ -2185,42 +2184,6 @@ describe("active-memory plugin", () => { expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); - it("lets active memory inherit the main QMD search mode when configured", async () => { - api.config = { - agents: { - defaults: { - model: { - primary: "github-copilot/gpt-5.4-mini", - }, - }, - }, - memory: { - backend: "qmd", - qmd: { - searchMode: "query", - }, - }, - }; - registerPluginConfig({ - qmd: { - searchMode: "inherit", - }, - }); - - await runPromptBuild({ - prompt: "what wings should i order? inherit-qmd-mode-check", - }); - - const config = embeddedRunConfig(); - expect(config.memory).toEqual({ - backend: "qmd", - qmd: { - searchMode: "query", - }, - }); - expect(activeMemoryConfigFrom(config).qmd).toEqual({ searchMode: "inherit" }); - }); - it("frames the blocking memory subagent as a memory search agent for another model", async () => { await runPromptBuild({ prompt: "What is my favorite food? strict-style-check", @@ -2670,7 +2633,7 @@ describe("active-memory plugin", () => { return { meta: { activeMemorySearchDebug: { - backend: "qmd", + backend: "builtin", configuredMode: "search", effectiveMode: "query", fallback: "unsupported-search-flags", @@ -2706,7 +2669,7 @@ describe("active-memory plugin", () => { expectLinesToContain(entries?.[0]?.lines ?? [], "🧩 Active Memory: status=ok"); expectLinesToContain( entries?.[0]?.lines ?? [], - "🔎 Active Memory Debug: backend=qmd configuredMode=search effectiveMode=query fallback=unsupported-search-flags searchMs=2590 hits=3 | User prefers lemon pepper wings, and blue cheese still wins.", + "🔎 Active Memory Debug: backend=builtin configuredMode=search effectiveMode=query fallback=unsupported-search-flags searchMs=2590 hits=3 | User prefers lemon pepper wings, and blue cheese still wins.", ); }); @@ -2721,7 +2684,7 @@ describe("active-memory plugin", () => { message: { role: "toolResult", toolName: "memory_search", - details: { debug: { backend: "qmd", hits: 3 } }, + details: { debug: { backend: "builtin", hits: 3 } }, }, }), JSON.stringify({ @@ -2751,7 +2714,7 @@ describe("active-memory plugin", () => { line.startsWith("🔎 Active Memory Debug:"), ); const line = requireNonEmptyString(debugLine, "active memory debug line missing"); - expect(line).toContain("backend=qmd"); + expect(line).toContain("backend=builtin"); expect(line).toContain("hits=3"); }); @@ -3559,7 +3522,7 @@ describe("active-memory plugin", () => { role: "toolResult", toolName: "memory_search", details: { - debug: { backend: "qmd", effectiveMode: "search", hits: 1 }, + debug: { backend: "builtin", effectiveMode: "search", hits: 1 }, }, }, }, @@ -3579,7 +3542,7 @@ describe("active-memory plugin", () => { const debug = await testing.readActiveMemorySearchDebug(sessionFile, { maxLines: 4, }); - expect(debug?.backend).toBe("qmd"); + expect(debug?.backend).toBe("builtin"); expect(debug?.hits).toBe(1); }); @@ -3917,7 +3880,7 @@ describe("active-memory plugin", () => { message: { role: "toolResult", toolName: "memory_search", - details: { results: [], debug: { backend: "qmd", hits: 0, searchMs: 8 } }, + details: { results: [], debug: { backend: "builtin", hits: 0, searchMs: 8 } }, }, }, ]); @@ -3939,7 +3902,7 @@ describe("active-memory plugin", () => { const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(2); expectLinesToContain(lines, "🧩 Active Memory: status=timeout"); - expectLinesToContain(lines, "🔎 Active Memory Debug: backend=qmd searchMs=8 hits=0"); + expectLinesToContain(lines, "🔎 Active Memory Debug: backend=builtin searchMs=8 hits=0"); }); it("does not fast-fail memory_search results solely because debug hits is zero", async () => { @@ -3956,7 +3919,7 @@ describe("active-memory plugin", () => { toolName: "memory_search", details: { results: [{ path: "memory/food.md", text: "User usually orders ramen." }], - debug: { backend: "qmd", hits: 0, searchMs: 8 }, + debug: { backend: "builtin", hits: 0, searchMs: 8 }, }, }, }, @@ -3976,7 +3939,7 @@ describe("active-memory plugin", () => { const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(2); expectLinesToContain(lines, "🧩 Active Memory: status=ok"); - expectLinesToContain(lines, "🔎 Active Memory Debug: backend=qmd searchMs=8 hits=0"); + expectLinesToContain(lines, "🔎 Active Memory Debug: backend=builtin searchMs=8 hits=0"); }); it("uses a late verbose summary after a successful result and later unavailable trace", async () => { @@ -4012,7 +3975,7 @@ describe("active-memory plugin", () => { results: [ { path: "memory/food.md", text: "User usually orders tonkotsu ramen." }, ], - debug: { backend: "qmd", hits: 1, searchMs: 8 }, + debug: { backend: "builtin", hits: 1, searchMs: 8 }, }, null, 2, diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index 10d5369349f8..47ced0838d23 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -1,6 +1,7 @@ { "id": "active-memory", "doctorContract": { + "configRepair": true, "stateMigrations": true }, "activation": { @@ -89,17 +90,7 @@ "transcriptDir": { "type": "string" }, "cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }, "circuitBreakerMaxTimeouts": { "type": "integer", "minimum": 1, "maximum": 20 }, - "circuitBreakerCooldownMs": { "type": "integer", "minimum": 5000, "maximum": 600000 }, - "qmd": { - "type": "object", - "additionalProperties": false, - "properties": { - "searchMode": { - "type": "string", - "enum": ["inherit", "search", "vsearch", "query"] - } - } - } + "circuitBreakerCooldownMs": { "type": "integer", "minimum": 5000, "maximum": 600000 } } }, "uiHints": { @@ -198,10 +189,6 @@ "transcriptDir": { "label": "Transcript Directory", "help": "Relative directory under the agent sessions folder used when transcript persistence is enabled." - }, - "qmd.searchMode": { - "label": "QMD Search Mode", - "help": "Override the QMD search mode used by the blocking memory sub-agent. Defaults to fast lexical search; use inherit to match the main memory backend setting." } } } diff --git a/extensions/active-memory/recall-run.ts b/extensions/active-memory/recall-run.ts index 4849fc54bc05..0452cab1c2a6 100644 --- a/extensions/active-memory/recall-run.ts +++ b/extensions/active-memory/recall-run.ts @@ -14,7 +14,6 @@ import { import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { - applyActiveMemoryRuntimeConfigSnapshot, isMissingRegisteredMemoryToolsError, requireTransientWorkspaceDir, resolvePersistentTranscriptBaseDir, @@ -262,10 +261,6 @@ async function runRecallSubagent(params: { messageProvider: params.messageProvider, channelId: params.channelId, }); - const embeddedConfig = applyActiveMemoryRuntimeConfigSnapshot( - params.runtimeConfig, - params.config, - ); const embeddedTimeoutMs = params.config.timeoutMs + params.config.setupGraceTimeoutMs; const result = await params.api.runtime.agent.runEmbeddedAgent({ sessionId: subagentSessionId, @@ -282,7 +277,7 @@ async function runRecallSubagent(params: { sessionFile: runtimeSessionFile, workspaceDir, agentDir, - config: embeddedConfig, + config: params.runtimeConfig, prompt, provider: modelRef.provider, model: modelRef.model, diff --git a/extensions/active-memory/trigger-recall.test.ts b/extensions/active-memory/trigger-recall.test.ts index 232721647630..b9bc723ebc22 100644 --- a/extensions/active-memory/trigger-recall.test.ts +++ b/extensions/active-memory/trigger-recall.test.ts @@ -233,7 +233,7 @@ describe("active-memory trigger recall", () => { }); expect(hoisted.search).toHaveBeenCalledWith( "flight booking", - expect.objectContaining({ lexicalOnly: true, qmdSearchModeOverride: "search" }), + expect.objectContaining({ lexicalOnly: true }), ); expect(hoisted.listTriggerCandidates).toHaveBeenCalledWith({ activeProjectKeys: ["github.com/openclaw/openclaw"], @@ -253,7 +253,7 @@ describe("active-memory trigger recall", () => { expect(hoisted.getManager).toHaveBeenCalledWith({ cfg: {}, agentId: "main" }); expect(hoisted.search).toHaveBeenCalledWith( "flight booking", - expect.objectContaining({ lexicalOnly: true, qmdSearchModeOverride: "search" }), + expect.objectContaining({ lexicalOnly: true }), ); expect(hoisted.listTriggerCandidates).toHaveBeenCalledWith({ activeProjectKeys: [] }); }); diff --git a/extensions/active-memory/trigger-recall.ts b/extensions/active-memory/trigger-recall.ts index 3d989fcdd59c..abb64985176b 100644 --- a/extensions/active-memory/trigger-recall.ts +++ b/extensions/active-memory/trigger-recall.ts @@ -172,7 +172,6 @@ async function loadTriggerRecallCandidates(params: TriggerLookupParams) { // Lane-1 runs on every eligible inbound message; it must stay // deterministic and local, so query embedding is disabled. lexicalOnly: true, - qmdSearchModeOverride: "search", activeProjectKeys: [...activeProjectKeys], }) .catch(() => []), diff --git a/extensions/active-memory/types.ts b/extensions/active-memory/types.ts index 74feb3d501b6..b65dbab05697 100644 --- a/extensions/active-memory/types.ts +++ b/extensions/active-memory/types.ts @@ -22,7 +22,6 @@ const MAX_TIMEOUT_MS = 120_000; const MAX_SETUP_GRACE_TIMEOUT_MS = 30_000; const DEFAULT_QUERY_MODE = "recent" as const; const DEFAULT_ACTIVE_MEMORY_MODE = "escalate" as const; -const DEFAULT_QMD_SEARCH_MODE = "search" as const; const DEFAULT_TRANSCRIPT_DIR = "active-memory"; const ACTIVE_MEMORY_RECALL_LANE = "active-memory"; const ACTIVE_MEMORY_CLEANUP_RETRY_DELAYS_MS = [0, 50, 250] as const; @@ -165,13 +164,8 @@ type ActiveRecallPluginConfig = { circuitBreakerCooldownMs?: number; persistTranscripts?: boolean; transcriptDir?: string; - qmd?: { - searchMode?: ActiveMemoryQmdSearchMode; - }; }; -type ActiveMemoryQmdSearchMode = "inherit" | "search" | "vsearch" | "query"; - type ResolvedActiveRecallPluginConfig = { enabled: boolean; mode: ActiveMemoryMode; @@ -210,9 +204,6 @@ type ResolvedActiveRecallPluginConfig = { circuitBreakerCooldownMs: number; persistTranscripts: boolean; transcriptDir: string; - qmd: { - searchMode: ActiveMemoryQmdSearchMode; - }; }; type ActiveRecallRecentTurn = { @@ -365,7 +356,6 @@ export { DEFAULT_MAX_SUMMARY_CHARS, DEFAULT_MIN_TIMEOUT_MS, DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS, - DEFAULT_QMD_SEARCH_MODE, DEFAULT_QUERY_MODE, DEFAULT_RECENT_ASSISTANT_CHARS, DEFAULT_RECENT_ASSISTANT_TURNS, @@ -399,7 +389,6 @@ export type { ActiveMemoryFastMode, ActiveMemoryPartialTimeoutError, ActiveMemoryPromptStyle, - ActiveMemoryQmdSearchMode, ActiveMemorySearchDebug, ActiveMemoryThinkingLevel, ActiveMemoryToggleEntry, diff --git a/extensions/memory-core/doctor-contract-api.test.ts b/extensions/memory-core/doctor-contract-api.test.ts index 8814c224ae77..90d08a55a737 100644 --- a/extensions/memory-core/doctor-contract-api.test.ts +++ b/extensions/memory-core/doctor-contract-api.test.ts @@ -95,6 +95,16 @@ function qmdFileLockMigration() { return migration; } +function qmdWorkspaceMigration() { + const migration = stateMigrations.find( + (entry) => entry.id === "memory-core-qmd-workspace-retired", + ); + if (!migration) { + throw new Error("expected memory-core retired QMD workspace migration"); + } + return migration; +} + function vectorToBlob(embedding: number[]): Buffer { return Buffer.from(new Float32Array(embedding).buffer); } @@ -2556,6 +2566,61 @@ describe("memory-core doctor dreaming migration", () => { await expect(fs.access(`${legacyPath}.migrated`)).resolves.toBeUndefined(); }); + it("removes retired QMD workspace homes without following model or home symlinks", async () => { + const stateDir = path.join(rootDir, "state"); + const qmdHome = path.join(stateDir, "agents", "main", "qmd"); + const canonicalAgentFile = path.join( + stateDir, + "agents", + "main", + "agent", + "openclaw-agent.sqlite", + ); + const invalidAgentQmdHome = path.join(stateDir, "agents", "main!", "qmd"); + const externalModels = path.join(rootDir, "shared-qmd-models"); + const symlinkHomeTarget = path.join(rootDir, "symlink-qmd-home-target"); + const symlinkHome = path.join(stateDir, "agents", "other", "qmd"); + for (const filePath of [ + path.join(qmdHome, "xdg-cache", "qmd", "index.sqlite"), + path.join(qmdHome, "xdg-config", "qmd", "index.yml"), + path.join(qmdHome, "sessions", "session.md"), + canonicalAgentFile, + path.join(invalidAgentQmdHome, "index.sqlite"), + path.join(externalModels, "model.bin"), + path.join(symlinkHomeTarget, "index.sqlite"), + ]) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, "derived", "utf8"); + } + await fs.symlink(externalModels, path.join(qmdHome, "xdg-cache", "qmd", "models")); + await fs.mkdir(path.dirname(symlinkHome), { recursive: true }); + await fs.symlink(symlinkHomeTarget, symlinkHome); + + const migration = qmdWorkspaceMigration(); + expect(migration.doctorOnly).toBe(true); + await expect(migration.detectLegacyState(migrationParams())).resolves.toEqual({ + preview: [ + `- Retired Memory Core QMD workspace: ${qmdHome} -> remove derived index, config, cache, and session-export artifacts`, + ], + }); + await expect(migration.migrateLegacyState(migrationParams())).resolves.toEqual({ + changes: [`Removed retired Memory Core QMD workspace: ${qmdHome}`], + warnings: [], + }); + + await expect(fs.access(qmdHome)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.access(canonicalAgentFile)).resolves.toBeUndefined(); + await expect(fs.access(invalidAgentQmdHome)).resolves.toBeUndefined(); + await expect(fs.access(path.join(externalModels, "model.bin"))).resolves.toBeUndefined(); + expect((await fs.lstat(symlinkHome)).isSymbolicLink()).toBe(true); + await expect(fs.access(path.join(symlinkHomeTarget, "index.sqlite"))).resolves.toBeUndefined(); + await expect(migration.detectLegacyState(migrationParams())).resolves.toBeNull(); + await expect(migration.migrateLegacyState(migrationParams())).resolves.toEqual({ + changes: [], + warnings: [], + }); + }); + it("removes only exact stale QMD lock sidecars and is idempotent", async () => { const stateDir = path.join(rootDir, "state"); const globalLockPath = path.join(stateDir, "qmd", "embed.lock.lock"); diff --git a/extensions/memory-core/doctor-contract-api.ts b/extensions/memory-core/doctor-contract-api.ts index 773dae1ba36a..e2e080e58099 100644 --- a/extensions/memory-core/doctor-contract-api.ts +++ b/extensions/memory-core/doctor-contract-api.ts @@ -4,6 +4,7 @@ import { hostEventsStateMigration } from "./src/migration/doctor-host-events.js" import { memorySidecarStateMigration, qmdLocksStateMigration, + qmdWorkspaceStateMigration, } from "./src/migration/doctor-memory-sidecar.js"; import { vectorIndexProviderDiagnostic } from "./src/migration/doctor-vector-index-provider.js"; @@ -11,6 +12,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ hostEventsStateMigration, dreamingStateMigration, memorySidecarStateMigration, + qmdWorkspaceStateMigration, qmdLocksStateMigration, vectorIndexProviderDiagnostic, ]; diff --git a/extensions/memory-core/runtime-api.ts b/extensions/memory-core/runtime-api.ts index eb2961847832..c3fe81dc7afb 100644 --- a/extensions/memory-core/runtime-api.ts +++ b/extensions/memory-core/runtime-api.ts @@ -13,7 +13,6 @@ export { resolveMemoryVectorState, type Tone, } from "openclaw/plugin-sdk/memory-core-host-status"; -export { checkQmdBinaryAvailability } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; export { hasConfiguredMemorySecretInput } from "openclaw/plugin-sdk/memory-core-host-secret"; export { auditDreamingArtifacts, repairDreamingArtifacts } from "./src/dreaming-repair.js"; export { configureMemoryCoreDreamingState } from "./src/dreaming-state.js"; diff --git a/extensions/memory-core/src/cli-index-search.runtime.ts b/extensions/memory-core/src/cli-index-search.runtime.ts index abc2d84f1fc6..1a196d672d87 100644 --- a/extensions/memory-core/src/cli-index-search.runtime.ts +++ b/extensions/memory-core/src/cli-index-search.runtime.ts @@ -1,5 +1,3 @@ -import fsSync from "node:fs"; -import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveMemorySearchStaleness } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; @@ -10,7 +8,6 @@ import { formatExtraPaths, resolveMemoryPluginConfig, withMemoryCommand, - type MemoryManager, } from "./cli-runtime-common.js"; import { defaultRuntime, @@ -28,7 +25,6 @@ import type { MemoryPromoteExplainOptions, MemorySearchCommandOptions, } from "./cli.types.js"; -import { asRecord } from "./dreaming-shared.js"; import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js"; import { formatMemoryVectorDegradedWriteReason } from "./memory/manager-vector-warning.js"; import type { MemoryCoreRuntimeHost } from "./memory/runtime-host.js"; @@ -55,33 +51,6 @@ function formatSourceLabel(source: string, workspaceDir: string, agentId: string } return source; } -async function summarizeQmdIndexArtifact(manager: MemoryManager): Promise { - const status = manager.status?.(); - if (!status || status.backend !== "qmd") { - return null; - } - const dbPath = status.dbPath?.trim(); - if (!dbPath) { - return null; - } - let stat: fsSync.Stats; - try { - stat = await fs.stat(dbPath); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`QMD index file not found: ${shortenHomePath(dbPath)}`, { cause: err }); - } - throw new Error( - `QMD index file check failed: ${shortenHomePath(dbPath)} (${code ?? "error"})`, - { cause: err }, - ); - } - if (!stat.isFile() || stat.size <= 0) { - throw new Error(`QMD index file is empty: ${shortenHomePath(dbPath)}`); - } - return `QMD index: ${shortenHomePath(dbPath)} (${stat.size} bytes)`; -} export async function runMemoryIndex( opts: MemoryCommandOptions, hostOptions?: MemoryCoreRuntimeHost, @@ -179,10 +148,6 @@ export async function runMemoryIndex( } }, ); - const qmdIndexSummary = await summarizeQmdIndexArtifact(manager); - if (qmdIndexSummary) { - defaultRuntime.log(qmdIndexSummary); - } let postIndexStatus = manager.status(); let semanticVectorAvailable = postIndexStatus.vector?.semanticAvailable; const vectorStoreAvailable = @@ -381,18 +346,7 @@ export async function runMemoryPromote( } const storePath = resolveShortTermRecallStorePath(workspaceDir); const lockPath = resolveShortTermRecallLockPath(workspaceDir); - const customQmd = asRecord(asRecord(status.custom)?.qmd); - const audit = await auditShortTermPromotionArtifacts({ - workspaceDir, - qmd: - status.backend === "qmd" - ? { - dbPath: status.dbPath, - collections: - typeof customQmd?.collections === "number" ? customQmd.collections : undefined, - } - : undefined, - }); + const audit = await auditShortTermPromotionArtifacts({ workspaceDir }); if (opts.json) { defaultRuntime.writeJson({ workspaceDir, diff --git a/extensions/memory-core/src/cli-runtime-common.ts b/extensions/memory-core/src/cli-runtime-common.ts index a992c7cabb21..e6552c2d6237 100644 --- a/extensions/memory-core/src/cli-runtime-common.ts +++ b/extensions/memory-core/src/cli-runtime-common.ts @@ -2,7 +2,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { listAgentIds } from "openclaw/plugin-sdk/agent-runtime"; -import { isUsageCountedSessionTranscriptFileName } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +import { isUsageCountedSessionTranscriptFileName } from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import type { PluginStateLeaseRunner } from "openclaw/plugin-sdk/plugin-state-runtime"; import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { diff --git a/extensions/memory-core/src/cli-status.runtime.ts b/extensions/memory-core/src/cli-status.runtime.ts index 8080c3c61064..6cbf101f6b58 100644 --- a/extensions/memory-core/src/cli-status.runtime.ts +++ b/extensions/memory-core/src/cli-status.runtime.ts @@ -280,18 +280,7 @@ export async function runMemoryStatus( if (opts.fix) { repair = await repairShortTermPromotionArtifacts({ workspaceDir }); } - const customQmd = asRecord(asRecord(status.custom)?.qmd); - audit = await auditShortTermPromotionArtifacts({ - workspaceDir, - qmd: - status.backend === "qmd" - ? { - dbPath: status.dbPath, - collections: - typeof customQmd?.collections === "number" ? customQmd.collections : undefined, - } - : undefined, - }); + audit = await auditShortTermPromotionArtifacts({ workspaceDir }); } allResults.push({ agentId, @@ -509,14 +498,6 @@ export async function runMemoryStatus( if (audit.updatedAt) { lines.push(`${label("Recall updated")} ${info(audit.updatedAt)}`); } - if (status.backend === "qmd" && audit.qmd) { - const qmdBits = [ - audit.qmd.dbPath ? shortenHomePath(audit.qmd.dbPath) : "", - typeof audit.qmd.dbBytes === "number" ? `${audit.qmd.dbBytes} bytes` : null, - typeof audit.qmd.collections === "number" ? `${audit.qmd.collections} collections` : null, - ].filter(Boolean); - lines.push(`${label("QMD audit")} ${info(qmdBits.join(" · "))}`); - } } if (dreamingAudit) { lines.push( @@ -561,15 +542,9 @@ export async function runMemoryStatus( lines.push(` ${issue.severity === "error" ? warn(issue.message) : muted(issue.message)}`); } if (!opts.fix) { - // Only a subset of audit issues are repaired by `--fix`; a missing qmd - // index needs a reindex instead, so each hint is gated on the matching - // issue actually being present. if (audit.issues.some((issue) => issue.fixable)) { lines.push(` ${muted(`Fix: openclaw memory status --fix --agent ${agentId}`)}`); } - if (audit.issues.some((issue) => issue.code === "qmd-index-missing")) { - lines.push(` ${muted(`Fix: openclaw memory index --agent ${agentId}`)}`); - } } } if (dreamingAudit?.issues.length) { diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index f116b43ae1c2..e3c5c99a3225 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -114,9 +114,7 @@ let isVerbose: typeof import("openclaw/plugin-sdk/memory-core-host-runtime-cli") let setVerbose: typeof import("openclaw/plugin-sdk/memory-core-host-runtime-cli").setVerbose; let fixtureRoot = ""; let workspaceFixtureRoot = ""; -let qmdFixtureRoot = ""; let workspaceCaseId = 0; -let qmdCaseId = 0; beforeAll(async () => { await configureMemoryCoreDreamingStateForTests(); @@ -131,9 +129,7 @@ beforeAll(async () => { setVerbose = loadedSetVerbose; fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-fixtures-")); workspaceFixtureRoot = path.join(fixtureRoot, "workspace"); - qmdFixtureRoot = path.join(fixtureRoot, "qmd"); await fs.mkdir(workspaceFixtureRoot, { recursive: true }); - await fs.mkdir(qmdFixtureRoot, { recursive: true }); }); beforeEach(() => { @@ -437,12 +433,6 @@ describe("memory cli", () => { return captureHelpOutput(memoryCommand); } - async function withQmdIndexDb(content: string, run: (dbPath: string) => Promise) { - const dbPath = path.join(qmdFixtureRoot, `case-${qmdCaseId++}.sqlite`); - await fs.writeFile(dbPath, content, "utf-8"); - await run(dbPath); - } - async function withTempWorkspace(run: (workspaceDir: string) => Promise) { const workspaceDir = path.join(workspaceFixtureRoot, `case-${workspaceCaseId++}`); await fs.mkdir(path.join(workspaceDir, "memory", ".dreams"), { recursive: true }); @@ -923,77 +913,6 @@ describe("memory cli", () => { expect(close).toHaveBeenCalled(); }); - it("keeps non-builtin deep status on the semantic vector probe", async () => { - const close = vi.fn(async () => {}); - const probeVectorStoreAvailability = vi.fn(async () => true); - const probeVectorAvailability = vi.fn(async () => true); - const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true })); - mockManager({ - probeVectorStoreAvailability, - probeVectorAvailability, - probeEmbeddingAvailability, - status: () => - makeMemoryStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - vector: { - enabled: true, - semanticAvailable: true, - available: true, - }, - }), - close, - }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status", "--deep"]); - - expect(probeVectorStoreAvailability).not.toHaveBeenCalled(); - expect(probeVectorAvailability).toHaveBeenCalled(); - expect(probeEmbeddingAvailability).toHaveBeenCalled(); - expectLogged(log, "Vector: ready"); - expectNotLogged(log, "Vector store:"); - expect(close).toHaveBeenCalled(); - }); - - it("does not report qmd lexical search mode as embedding unavailable", async () => { - const close = vi.fn(async () => {}); - const probeVectorStoreAvailability = vi.fn(async () => true); - const probeVectorAvailability = vi.fn(async () => false); - const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true, checked: false })); - mockManager({ - probeVectorStoreAvailability, - probeVectorAvailability, - probeEmbeddingAvailability, - status: () => - makeMemoryStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - vector: { - enabled: false, - semanticAvailable: false, - available: false, - }, - }), - close, - }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status", "--deep"]); - - expect(probeVectorStoreAvailability).not.toHaveBeenCalled(); - expect(probeVectorAvailability).toHaveBeenCalled(); - expect(probeEmbeddingAvailability).toHaveBeenCalled(); - expectLogged(log, "Vector: disabled"); - expectLogged(log, "Embeddings: skipped"); - expectNotLogged(log, "Embeddings error:"); - expect(close).toHaveBeenCalled(); - }); - it("prints recall-store audit details during status", async () => { await withTempWorkspace(async (workspaceDir) => { await recordShortTermRecalls({ @@ -1233,7 +1152,7 @@ describe("memory cli", () => { await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); await fs.writeFile( path.join(workspaceDir, "memory", "2026-04-03.md"), - "QMD router cache note\n", + "Vector router cache note\n", "utf-8", ); await shortTermTesting.writeRawRecallStore(workspaceDir, { @@ -1246,7 +1165,7 @@ describe("memory cli", () => { startLine: 1, endLine: 2, source: "memory", - snippet: "QMD router cache note", + snippet: "Vector router cache note", recallCount: 1, totalScore: 0.8, maxScore: 0.8, @@ -1493,98 +1412,6 @@ describe("memory cli", () => { expect(process.exitCode).toBeUndefined(); }); - it("logs qmd index file path and size after index", async () => { - const close = vi.fn(async () => {}); - const sync = vi.fn(async () => {}); - await withQmdIndexDb("sqlite-bytes", async (dbPath) => { - mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["index"]); - - expectCliSync(sync); - expectLogged(log, "QMD index: "); - expect(log).toHaveBeenCalledWith("Memory index updated (main)."); - expect(close).toHaveBeenCalled(); - }); - }); - - it("surfaces qmd audit details in status output", async () => { - const close = vi.fn(async () => {}); - await withQmdIndexDb("sqlite-bytes", async (dbPath) => { - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => - makeMemoryStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - dbPath, - custom: { - qmd: { - collections: 2, - }, - }, - }), - close, - }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status"]); - - expectLogged(log, "QMD audit:"); - expectLogged(log, "2 collections"); - expect(close).toHaveBeenCalled(); - }); - }); - - it("suggests reindexing instead of --fix when the qmd index is missing", async () => { - await withTempWorkspace(async (workspaceDir) => { - const close = vi.fn(async () => {}); - const missingDbPath = path.join(qmdFixtureRoot, `missing-${qmdCaseId++}.sqlite`); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => - makeMemoryStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - workspaceDir, - dbPath: missingDbPath, - }), - close, - }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status"]); - - expectLogged(log, "QMD index file is missing."); - expectLogged(log, "Fix: openclaw memory index --agent main"); - expectNotLogged(log, "Fix: openclaw memory status --fix --agent main"); - expect(close).toHaveBeenCalled(); - }); - }); - - it("fails index when qmd db file is empty", async () => { - const close = vi.fn(async () => {}); - const sync = vi.fn(async () => {}); - await withQmdIndexDb("", async (dbPath) => { - mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); - - const error = spyRuntimeErrors(defaultRuntime); - await runMemoryCli(["index"]); - - expectCliSync(sync); - expect(error).toHaveBeenCalledWith( - `Memory index failed (main): QMD index file is empty: ${dbPath}`, - ); - expect(close).toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - }); - }); - it("logs close failures without failing the command", async () => { const sync = vi.fn(async () => {}); await expectCloseFailureAfterCommand({ @@ -2607,7 +2434,7 @@ describe("memory cli", () => { startLine: 4, endLine: 8, score: 0.9, - snippet: "Configured router VLAN 10 and Glacier backup notes for QMD.", + snippet: "Configured router VLAN 10 and Glacier backup notes for vectors.", source: "memory", }, ], @@ -2622,7 +2449,7 @@ describe("memory cli", () => { startLine: 4, endLine: 8, score: 0.88, - snippet: "Configured router VLAN 10 and Glacier backup notes for QMD.", + snippet: "Configured router VLAN 10 and Glacier backup notes for vectors.", source: "memory", }, ], @@ -2646,7 +2473,7 @@ describe("memory cli", () => { ]); expectLogged(log, "recalls=2 avg=0.890 queries=2 age=1.0d consolidate=0.30 conceptual=1.00"); - expectLogged(log, "concepts=backup, glacier, qmd, router, vlan, configured"); + expectLogged(log, "concepts=backup, glacier, router, vlan, configured, vectors"); expect(close).toHaveBeenCalled(); }); }); diff --git a/extensions/memory-core/src/concept-vocabulary.test.ts b/extensions/memory-core/src/concept-vocabulary.test.ts index daaf2cf138b1..a14b44fdebbf 100644 --- a/extensions/memory-core/src/concept-vocabulary.test.ts +++ b/extensions/memory-core/src/concept-vocabulary.test.ts @@ -106,7 +106,7 @@ describe("concept vocabulary", () => { summarizeConceptTagScriptCoverage([ ["routeur", "sauvegarde"], ["路由器", "备份"], - ["qmd", "路由器"], + ["vectors", "路由器"], ["сервер"], ]), ).toEqual({ diff --git a/extensions/memory-core/src/concept-vocabulary.ts b/extensions/memory-core/src/concept-vocabulary.ts index da8c061224f1..4517ad30af61 100644 --- a/extensions/memory-core/src/concept-vocabulary.ts +++ b/extensions/memory-core/src/concept-vocabulary.ts @@ -247,7 +247,6 @@ const PROTECTED_GLOSSARY = [ "kv", "network", "openai", - "qmd", "router", "s3", "vlan", diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index 6fe57c980846..d1e82c41e109 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -1597,7 +1597,7 @@ describe("memory-core dreaming phases", () => { role: "user", timestamp: "2026-04-05T18:01:00.000Z", content: - "[cron:job-1 Codex Sessions Sync] Run Codex sessions sync: 1. Convert sessions 2. Update qmd", + "[cron:job-1 Codex Sessions Sync] Run Codex sessions sync: 1. Convert sessions 2. Update index", }, { role: "assistant", @@ -1828,12 +1828,12 @@ describe("memory-core dreaming phases", () => { { role: "user", timestamp: "2026-04-16T18:06:00.000Z", - content: "[cron:job-2 Example] Run the qmd sync", + content: "[cron:job-2 Example] Run the memory sync", }, { role: "assistant", timestamp: "2026-04-16T18:07:00.000Z", - content: "Running the qmd sync now.", + content: "Running the memory sync now.", }, { role: "user", @@ -1902,7 +1902,7 @@ describe("memory-core dreaming phases", () => { expect(corpus).not.toContain("Checkpoint chatter should stay out."); expect(corpus).not.toContain("Read HEARTBEAT.md"); expect(corpus).not.toContain("HEARTBEAT_OK"); - expect(corpus).not.toContain("Run the qmd sync"); + expect(corpus).not.toContain("Run the memory sync"); }); it("ignores chat scaffolding tags when building rem reflections", () => { diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 9a138d4f9655..63230363dd50 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -4,7 +4,7 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { listSessionTranscriptCorpusEntriesForAgent } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +import { listSessionTranscriptCorpusEntriesForAgent } from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { formatMemoryDreamingDay, @@ -623,7 +623,7 @@ async function collectSessionIngestionBatches(params: { } if ( // Dreaming learns only from the live corpus. Retained reset/delete - // archives stay in the shared corpus for QMD and memory_search. + // archives stay in the shared corpus for memory_search. entry.artifactKind !== "active-session" || isCheckpointSessionTranscriptPath(entry.sessionFile) ) { diff --git a/extensions/memory-core/src/dreaming-state.ts b/extensions/memory-core/src/dreaming-state.ts index e23d766aa990..55a4c734a037 100644 --- a/extensions/memory-core/src/dreaming-state.ts +++ b/extensions/memory-core/src/dreaming-state.ts @@ -72,7 +72,7 @@ export function memoryCoreWorkspaceStateKey(workspaceDir: string): string { return createHash("sha256").update(normalizeMemoryCoreWorkspaceKey(workspaceDir)).digest("hex"); } -export function memoryCoreWorkspaceEntryKey(workspaceDir: string, logicalKey: string): string { +function memoryCoreWorkspaceEntryKey(workspaceDir: string, logicalKey: string): string { const workspaceKey = memoryCoreWorkspaceStateKey(workspaceDir); const itemKey = createHash("sha256").update(logicalKey).digest("hex"); return `${workspaceKey}:${itemKey}`; diff --git a/extensions/memory-core/src/memory-tool-manager.test-mocks.ts b/extensions/memory-core/src/memory-tool-manager.test-mocks.ts index 5bd220d8eb1b..9d1aa9d80773 100644 --- a/extensions/memory-core/src/memory-tool-manager.test-mocks.ts +++ b/extensions/memory-core/src/memory-tool-manager.test-mocks.ts @@ -10,11 +10,9 @@ type SearchImpl = (opts?: { minScore?: number; sessionKey?: string; activeProjectKeys?: string[]; - qmdSearchModeOverride?: "query" | "search" | "vsearch"; onDebug?: (debug: MemorySearchRuntimeDebug) => void; signal?: AbortSignal; sources?: MemorySource[]; - [key: symbol]: ((action: "pause" | "resume" | "handoff") => void) | undefined; }) => Promise; export type MemoryReadParams = { relPath: string; from?: number; lines?: number }; type MemoryReadResult = { @@ -25,7 +23,6 @@ type MemoryReadResult = { lines?: number; nextFrom?: number; }; -type MemoryBackend = "builtin" | "qmd"; type MemoryManagerDebug = Awaited>["debug"]; type MemoryManagerParams = { cfg?: unknown; @@ -35,8 +32,6 @@ type MemoryManagerParams = { withLease?: PluginStateLeaseRunner; }; -let backend: MemoryBackend = "builtin"; -let resolvedBackend: MemoryBackend | undefined; let workspaceDir = "/workspace"; let statusDirty = false; let customStatus: Record | undefined; @@ -60,7 +55,7 @@ const stubManager = { search: vi.fn(async (_query: string, opts?: Parameters[0]) => await searchImpl(opts)), readFile: vi.fn(async (params: MemoryReadParams) => await readFileImpl(params)), status: () => ({ - backend, + backend: "builtin" as const, files: 1, chunks: 1, dirty: statusDirty, @@ -86,26 +81,11 @@ const readAgentMemoryFileMock = vi.fn( ); vi.mock("./tools.runtime.js", () => ({ - resolveMemoryBackendConfig: ({ - cfg, - }: { - cfg?: { memory?: { backend?: string; qmd?: unknown } }; - }) => ({ - backend: resolvedBackend ?? backend, - qmd: cfg?.memory?.qmd, - }), + resolveMemoryBackendConfig: () => ({ backend: "builtin" as const }), getMemorySearchManager: getMemorySearchManagerMock, readAgentMemoryFile: readAgentMemoryFileMock, })); -export function setMemoryBackend(next: MemoryBackend): void { - backend = next; -} - -export function setResolvedMemoryBackend(next: MemoryBackend | undefined): void { - resolvedBackend = next; -} - export function setMemoryWorkspaceDir(next: string): void { workspaceDir = next; } @@ -143,12 +123,9 @@ export function setMemoryReadFileImpl( } export function resetMemoryToolMockState(overrides?: { - backend?: MemoryBackend; searchImpl?: SearchImpl; readFileImpl?: (params: MemoryReadParams) => Promise; }): void { - backend = overrides?.backend ?? "builtin"; - resolvedBackend = undefined; workspaceDir = "/workspace"; statusDirty = false; customStatus = undefined; diff --git a/extensions/memory-core/src/memory/manager-session-sync-ops.ts b/extensions/memory-core/src/memory/manager-session-sync-ops.ts index 52778e5d4b89..f65f63ebc765 100644 --- a/extensions/memory-core/src/memory/manager-session-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-session-sync-ops.ts @@ -11,7 +11,7 @@ import { sessionPathForSessionIdentity, statSessionEntrySync, type SessionTranscriptCorpusEntry, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +} from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import { isFileMissingError, runWithConcurrency, diff --git a/extensions/memory-core/src/memory/manager-source-sync-ops.ts b/extensions/memory-core/src/memory/manager-source-sync-ops.ts index 299b99b44640..b11ff515b1ef 100644 --- a/extensions/memory-core/src/memory/manager-source-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-source-sync-ops.ts @@ -4,7 +4,7 @@ import { buildSessionEntry, sessionPathForSessionIdentity, type SessionTranscriptCorpusEntry, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +} from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import { buildFileEntry, listMemoryFiles, diff --git a/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts b/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts index 968c2d8891d5..0d55ca475144 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts @@ -11,7 +11,7 @@ import { import { buildSessionEntry, statSessionEntrySync, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +} from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import { MEMORY_CHUNKING_VERSION, type MemorySource, diff --git a/extensions/memory-core/src/memory/manager-sync-yield.test.ts b/extensions/memory-core/src/memory/manager-sync-yield.test.ts index d7d7a5097e93..74293e7a84e5 100644 --- a/extensions/memory-core/src/memory/manager-sync-yield.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-yield.test.ts @@ -7,7 +7,7 @@ import { type OpenClawConfig, type ResolvedMemorySearchConfig, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import type { SessionTranscriptCorpusEntry } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +import type { SessionTranscriptCorpusEntry } from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import { ensureMemoryIndexSchema, requireNodeSqlite, @@ -49,9 +49,9 @@ vi.mock("undici", async () => { }; }); -vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", async (importOriginal) => { +vi.mock("openclaw/plugin-sdk/memory-core-host-engine-sessions", async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); const basename = (filePath: string) => filePath.split(/[\\/]/).pop() ?? filePath; return { ...actual, diff --git a/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts b/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts index 7cf537aed027..300bebde50b2 100644 --- a/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts +++ b/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts @@ -61,7 +61,6 @@ describe("memory manager reindex recovery", () => { }): OpenClawConfig { return { memory: { - backend: "builtin", search: { provider: params.provider ?? "openai", model: "mock-embed", diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 4287bdc7c6d1..ee266cbbc037 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -18,7 +18,7 @@ import { type OpenClawConfig, type ResolvedMemorySearchConfig, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { extractKeywords } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +import { extractKeywords } from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import { readCuratedProjectMemoryCandidates, readMemoryFile, diff --git a/extensions/memory-core/src/memory/qmd-collection-controller.ts b/extensions/memory-core/src/memory/qmd-collection-controller.ts deleted file mode 100644 index 8ea1d52ee9fc..000000000000 --- a/extensions/memory-core/src/memory/qmd-collection-controller.ts +++ /dev/null @@ -1,560 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { createSubsystemLogger } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import type { ResolvedQmdConfig } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import type { PluginStateLeaseContext } from "openclaw/plugin-sdk/plugin-state-runtime"; -import { - canMigrateLegacyQmdCollection, - deriveLegacyQmdCollectionName, - findQmdCollectionByPathPattern, - isQmdCollectionAlreadyExistsError, - isQmdCollectionMissingError, - isSameNameQmdCollectionAlreadyExistsError, - parseConflictingQmdCollectionName, - parseListedQmdCollections, - parseShownQmdCollection, - renderQmdCollectionIndexConfig, - shouldRepairDuplicateQmdDocumentConstraint, - shouldRepairNullByteQmdCollectionError, - shouldRebindQmdCollection, - type ListedQmdCollection, - type ManagedQmdCollection, -} from "./qmd-collection-metadata.js"; -import { isUnsupportedQmdOptionError } from "./qmd-command-errors.js"; -import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js"; -import { - readQmdCollectionValidationCache, - writeQmdCollectionValidationCache, - type QmdRuntimeCollectionValidationCacheContext, -} from "./qmd-runtime-cache.js"; -import type { - QmdCollectionValidationDebug, - QmdSearchRuntimeDebugContext, -} from "./qmd-runtime-debug.js"; - -const log = createSubsystemLogger("memory"); -const QMD_INDEX_CONFIG_FILE = "index.yml"; - -function throwIfAborted(signal?: AbortSignal): void { - signal?.throwIfAborted(); -} - -function assertLeaseActive(lease: PluginStateLeaseContext): void { - throwIfAborted(lease.signal); - lease.assertOwned(); -} - -export type { ManagedQmdCollection } from "./qmd-collection-metadata.js"; -export type { QmdSearchRuntimeDebugContext } from "./qmd-runtime-debug.js"; - -type RunQmd = ( - args: string[], - opts?: { timeoutMs?: number; discardOutput?: boolean; signal?: AbortSignal }, -) => Promise<{ stdout: string; stderr: string }>; - -export class QmdCollectionController { - private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask"; - private attemptedNullByteCollectionRepair = false; - private attemptedDuplicateDocumentRepair = false; - private pendingValidationDebug: QmdCollectionValidationDebug | undefined; - - constructor( - private readonly qmd: ResolvedQmdConfig, - private readonly agentId: string, - private readonly workspaceDir: string, - private readonly xdgConfigHome: string, - private readonly runQmd: RunQmd, - private readonly buildValidationCacheContext: ( - signal?: AbortSignal, - ) => Promise, - ) {} - - consumePendingValidationDebug(): QmdCollectionValidationDebug | undefined { - const debug = this.pendingValidationDebug; - this.pendingValidationDebug = undefined; - return debug; - } - - async ensureCollections(options: { - force?: boolean; - debugContext?: QmdSearchRuntimeDebugContext; - lease: PluginStateLeaseContext; - }): Promise { - const { lease } = options; - const { signal } = lease; - throwIfAborted(signal); - const startedAt = Date.now(); - const cacheContext = await this.buildValidationCacheContext(signal); - throwIfAborted(signal); - if (!options.force) { - const cached = await readQmdCollectionValidationCache(cacheContext); - throwIfAborted(signal); - if (cached.state === "hit") { - await this.ensureCollectionPathsBestEffort(lease); - const debug = { - cacheState: "hit", - elapsedMs: Math.max(0, Date.now() - startedAt), - collectionCount: cached.value.validation.collectionCount, - listCalls: 0, - showCalls: 0, - } satisfies QmdCollectionValidationDebug; - lease.assertOwned(); - this.recordValidationDebug(debug, options.debugContext); - return; - } - } - - const stats = { listCalls: 0, showCalls: 0 }; - let validationComplete = true; - const existing = await this.listCollectionsBestEffort(stats, signal); - await this.migrateLegacyUnscopedCollections(existing, lease); - - for (const collection of this.qmd.collections) { - const listed = existing.get(collection.name); - if ( - listed && - !shouldRebindQmdCollection({ collection, listed, workspaceDir: this.workspaceDir }) - ) { - continue; - } - if (listed) { - try { - await this.removeCollection(collection.name, lease); - } catch (err) { - assertLeaseActive(lease); - const message = formatErrorMessage(err); - if (!isQmdCollectionMissingError(message)) { - validationComplete = false; - log.warn(`qmd collection remove failed for ${collection.name}: ${message}`); - } - } - } - try { - await this.ensureCollectionPath(collection, lease); - await this.addCollection(collection.path, collection.name, collection.pattern, lease); - existing.set(collection.name, { - path: collection.path, - pattern: collection.pattern, - }); - } catch (err) { - assertLeaseActive(lease); - const message = formatErrorMessage(err); - if (isQmdCollectionAlreadyExistsError(message)) { - const rebound = - (await this.tryRebindSameNameCollection({ - collection, - addErrorMessage: message, - lease, - })) || - (await this.tryRebindConflictingCollection({ - collection, - existing, - addErrorMessage: message, - lease, - })); - if (rebound) { - existing.set(collection.name, { - path: collection.path, - pattern: collection.pattern, - }); - } else { - validationComplete = false; - log.warn(`qmd collection add skipped for ${collection.name}: ${message}`); - } - continue; - } - validationComplete = false; - log.warn(`qmd collection add failed for ${collection.name}: ${message}`); - } - } - throwIfAborted(signal); - assertLeaseActive(lease); - const wroteCache = validationComplete - ? await writeQmdCollectionValidationCache(cacheContext) - : false; - throwIfAborted(signal); - assertLeaseActive(lease); - this.recordValidationDebug( - { - cacheState: validationComplete - ? options.force - ? "bypass-force" - : wroteCache - ? "write" - : "error" - : "error", - elapsedMs: Math.max(0, Date.now() - startedAt), - collectionCount: this.qmd.collections.length, - listCalls: stats.listCalls, - showCalls: stats.showCalls, - }, - options.debugContext, - ); - } - - async tryRepairNullByteCollections( - err: unknown, - reason: string, - lease: PluginStateLeaseContext, - ): Promise { - if (this.attemptedNullByteCollectionRepair || !shouldRepairNullByteQmdCollectionError(err)) { - return false; - } - log.warn( - `qmd update failed with suspected null-byte collection metadata (${reason}); rebuilding managed collections and retrying once`, - ); - await this.rebuildManagedCollectionsForRepair(`null-byte metadata (${reason})`, lease); - assertLeaseActive(lease); - this.attemptedNullByteCollectionRepair = true; - return true; - } - - async tryRepairDuplicateDocumentConstraint( - err: unknown, - reason: string, - lease: PluginStateLeaseContext, - ): Promise { - if (this.attemptedDuplicateDocumentRepair || !shouldRepairDuplicateQmdDocumentConstraint(err)) { - return false; - } - log.warn( - `qmd update failed with duplicate document constraint (${reason}); rebuilding managed collections and retrying once`, - ); - await this.rebuildManagedCollectionsForRepair( - `duplicate-document constraint (${reason})`, - lease, - ); - assertLeaseActive(lease); - this.attemptedDuplicateDocumentRepair = true; - return true; - } - - private recordValidationDebug( - debug: QmdCollectionValidationDebug, - debugContext?: QmdSearchRuntimeDebugContext, - ): void { - if (debugContext) { - debugContext.collectionValidation = debug; - } else { - this.pendingValidationDebug = debug; - } - } - - private async ensureCollectionPathsBestEffort(lease: PluginStateLeaseContext): Promise { - const { signal } = lease; - for (const collection of this.qmd.collections) { - try { - throwIfAborted(signal); - await this.ensureCollectionPath(collection, lease); - } catch (err) { - assertLeaseActive(lease); - log.warn( - `qmd collection path prepare failed for ${collection.name}: ${formatErrorMessage(err)}`, - ); - } - } - } - - private async tryRebindSameNameCollection(params: { - collection: ManagedQmdCollection; - addErrorMessage: string; - lease: PluginStateLeaseContext; - }): Promise { - const { collection, addErrorMessage } = params; - if (!isSameNameQmdCollectionAlreadyExistsError(collection.name, addErrorMessage)) { - return false; - } - log.warn( - `qmd collection add conflict for ${collection.name}: collection name already exists; recreating managed collection`, - ); - try { - await this.removeCollection(collection.name, params.lease); - } catch (removeErr) { - assertLeaseActive(params.lease); - const removeMessage = formatErrorMessage(removeErr); - if (!isQmdCollectionMissingError(removeMessage)) { - log.warn(`qmd collection remove failed for ${collection.name}: ${removeMessage}`); - return false; - } - } - try { - await this.ensureCollectionPath(collection, params.lease); - await this.addCollection(collection.path, collection.name, collection.pattern, params.lease); - return true; - } catch (retryErr) { - assertLeaseActive(params.lease); - const retryMessage = formatErrorMessage(retryErr); - log.warn( - `qmd collection add failed for ${collection.name} after recreating same-name collection: ${retryMessage} (initial: ${addErrorMessage})`, - ); - return false; - } - } - - private async listCollectionsBestEffort( - stats?: { - listCalls: number; - showCalls: number; - }, - signal?: AbortSignal, - ): Promise> { - const existing = new Map(); - try { - if (stats) { - stats.listCalls += 1; - } - const result = await this.runQmd(["collection", "list", "--json"], { - timeoutMs: this.qmd.update.commandTimeoutMs, - signal, - }); - for (const [name, details] of parseListedQmdCollections(result.stdout)) { - existing.set(name, details); - } - } catch { - throwIfAborted(signal); - // Older qmd versions might not support list --json. - } - - for (const collection of this.qmd.collections) { - const entry = existing.get(collection.name); - if (!entry || entry.path) { - continue; - } - try { - if (stats) { - stats.showCalls += 1; - } - const showResult = await this.runQmd(["collection", "show", collection.name], { - timeoutMs: this.qmd.update.commandTimeoutMs, - signal, - }); - const shown = parseShownQmdCollection(showResult.stdout); - if (shown.path) { - entry.path = shown.path; - } - if (shown.pattern && !entry.pattern) { - entry.pattern = shown.pattern; - } - } catch { - throwIfAborted(signal); - // Incomplete metadata preserves the non-destructive reconciliation path. - } - } - return existing; - } - - private async tryRebindConflictingCollection(params: { - collection: ManagedQmdCollection; - existing: Map; - addErrorMessage: string; - lease: PluginStateLeaseContext; - }): Promise { - const { collection, existing, addErrorMessage } = params; - const { signal } = params.lease; - let conflictName = findQmdCollectionByPathPattern({ - collection, - listed: existing, - workspaceDir: this.workspaceDir, - }); - if (!conflictName) { - const refreshed = await this.listCollectionsBestEffort(undefined, signal); - existing.clear(); - for (const [name, details] of refreshed) { - existing.set(name, details); - } - conflictName = findQmdCollectionByPathPattern({ - collection, - listed: existing, - workspaceDir: this.workspaceDir, - }); - } - if (!conflictName) { - const parsedConflictName = parseConflictingQmdCollectionName(addErrorMessage); - if (parsedConflictName) { - log.warn( - `qmd collection add conflict for ${collection.name}: qmd reported existing collection ${parsedConflictName}, but list output did not include verifiable path/pattern metadata; refusing automatic rebind. If ${parsedConflictName} is stale, remove it manually with 'qmd collection remove ${parsedConflictName}'`, - ); - } - return false; - } - if (conflictName === collection.name) { - existing.set(collection.name, { - path: collection.path, - pattern: collection.pattern, - }); - return true; - } - log.warn( - `qmd collection add conflict for ${collection.name}: path+pattern already bound by ${conflictName}; rebinding`, - ); - try { - await this.removeCollection(conflictName, params.lease); - existing.delete(conflictName); - } catch (removeErr) { - assertLeaseActive(params.lease); - const removeMessage = formatErrorMessage(removeErr); - if (!isQmdCollectionMissingError(removeMessage)) { - log.warn(`qmd collection remove failed for ${conflictName}: ${removeMessage}`); - } - return false; - } - try { - await this.addCollection(collection.path, collection.name, collection.pattern, params.lease); - existing.set(collection.name, { - path: collection.path, - pattern: collection.pattern, - }); - return true; - } catch (retryErr) { - assertLeaseActive(params.lease); - const retryMessage = formatErrorMessage(retryErr); - log.warn( - `qmd collection add failed for ${collection.name} after rebinding ${conflictName}: ${retryMessage} (initial: ${addErrorMessage})`, - ); - return false; - } - } - - private async migrateLegacyUnscopedCollections( - existing: Map, - lease: PluginStateLeaseContext, - ): Promise { - for (const collection of this.qmd.collections) { - if (existing.has(collection.name)) { - continue; - } - const legacyName = deriveLegacyQmdCollectionName(collection.name, this.agentId); - if (!legacyName) { - continue; - } - const listedLegacy = existing.get(legacyName); - if (!listedLegacy) { - continue; - } - if ( - !canMigrateLegacyQmdCollection({ - collection, - listed: listedLegacy, - workspaceDir: this.workspaceDir, - }) - ) { - log.debug( - `qmd legacy collection migration skipped for ${legacyName} (path/pattern mismatch)`, - ); - continue; - } - try { - await this.removeCollection(legacyName, lease); - existing.delete(legacyName); - } catch (err) { - assertLeaseActive(lease); - const message = formatErrorMessage(err); - if (!isQmdCollectionMissingError(message)) { - log.warn(`qmd collection remove failed for ${legacyName}: ${message}`); - } - } - } - } - - private async ensureCollectionPath( - collection: ManagedQmdCollection, - lease: PluginStateLeaseContext, - ): Promise { - if ( - collection.pattern.includes("*") || - collection.pattern.includes("?") || - collection.pattern.includes("[") - ) { - assertLeaseActive(lease); - await fs.mkdir(collection.path, { recursive: true }); - throwIfAborted(lease.signal); - } - } - - private async addCollection( - pathArg: string, - name: string, - pattern: string, - lease: PluginStateLeaseContext, - ): Promise { - const { signal } = lease; - const candidateFlags = resolveQmdCollectionPatternFlags(this.collectionPatternFlag); - let lastError: unknown; - for (const flag of candidateFlags) { - try { - assertLeaseActive(lease); - await this.runQmd(["collection", "add", pathArg, "--name", name, flag, pattern], { - timeoutMs: this.qmd.update.commandTimeoutMs, - signal, - }); - this.collectionPatternFlag = flag; - return; - } catch (err) { - assertLeaseActive(lease); - lastError = err; - if (!isUnsupportedQmdOptionError(err) || candidateFlags.at(-1) === flag) { - throw err; - } - log.warn(`qmd collection add rejected ${flag}; retrying with legacy compatibility flag`); - } - } - throw lastError instanceof Error ? lastError : new Error(String(lastError)); - } - - private async removeCollection(name: string, lease: PluginStateLeaseContext): Promise { - assertLeaseActive(lease); - await this.runQmd(["collection", "remove", name], { - timeoutMs: this.qmd.update.commandTimeoutMs, - signal: lease.signal, - }); - throwIfAborted(lease.signal); - } - - private async refreshManagedCollectionIndexConfig(lease: PluginStateLeaseContext): Promise { - const configPath = path.join(this.xdgConfigHome, "qmd", QMD_INDEX_CONFIG_FILE); - await fs.mkdir(path.dirname(configPath), { recursive: true }); - assertLeaseActive(lease); - await fs.writeFile(configPath, renderQmdCollectionIndexConfig(this.qmd.collections), "utf8"); - throwIfAborted(lease.signal); - } - - private async rebuildManagedCollectionsForRepair( - reason: string, - lease: PluginStateLeaseContext, - ): Promise { - const { signal } = lease; - throwIfAborted(signal); - try { - await this.refreshManagedCollectionIndexConfig(lease); - } catch (configErr) { - assertLeaseActive(lease); - log.warn( - `qmd managed collection index refresh failed for update repair (${reason}): ${formatErrorMessage(configErr)}`, - ); - } - for (const collection of this.qmd.collections) { - try { - await this.removeCollection(collection.name, lease); - } catch (removeErr) { - assertLeaseActive(lease); - const removeMessage = formatErrorMessage(removeErr); - if (!isQmdCollectionMissingError(removeMessage)) { - log.warn(`qmd collection remove failed for ${collection.name}: ${removeMessage}`); - } - } - try { - await this.addCollection(collection.path, collection.name, collection.pattern, lease); - } catch (addErr) { - assertLeaseActive(lease); - const addMessage = formatErrorMessage(addErr); - if (!isQmdCollectionAlreadyExistsError(addMessage)) { - log.warn(`qmd collection add failed for ${collection.name}: ${addMessage}`); - } - } - } - throwIfAborted(signal); - assertLeaseActive(lease); - log.warn(`qmd managed collections rebuilt for update repair (${reason})`); - } -} diff --git a/extensions/memory-core/src/memory/qmd-collection-metadata.ts b/extensions/memory-core/src/memory/qmd-collection-metadata.ts deleted file mode 100644 index 36112019cf21..000000000000 --- a/extensions/memory-core/src/memory/qmd-collection-metadata.ts +++ /dev/null @@ -1,298 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; - -const NUL_MARKER_RE = /(?:\^@|\\0|\\x00|\\u0000|null\s*byte|nul\s*byte)/i; - -export type ManagedQmdCollection = { - name: string; - path: string; - pattern: string; - kind: "memory" | "custom" | "sessions"; -}; - -export type ListedQmdCollection = { - path?: string; - pattern?: string; -}; - -export function parseListedQmdCollections(output: string): Map { - const listed = new Map(); - const trimmed = output.trim(); - if (!trimmed) { - return listed; - } - try { - const parsed = JSON.parse(trimmed) as unknown; - if (Array.isArray(parsed)) { - for (const entry of parsed) { - if (typeof entry === "string") { - listed.set(entry, {}); - continue; - } - if (!entry || typeof entry !== "object") { - continue; - } - const name = (entry as { name?: unknown }).name; - if (typeof name !== "string") { - continue; - } - const listedPath = (entry as { path?: unknown }).path; - const listedPattern = (entry as { pattern?: unknown }).pattern; - const listedMask = (entry as { mask?: unknown }).mask; - listed.set(name, { - path: typeof listedPath === "string" ? listedPath : undefined, - pattern: - typeof listedPattern === "string" - ? listedPattern - : typeof listedMask === "string" - ? listedMask - : undefined, - }); - } - return listed; - } - } catch { - // Some qmd builds ignore `--json` and still print table output. - } - - let currentName: string | null = null; - for (const rawLine of output.split(/\r?\n/)) { - const line = rawLine.trimEnd(); - if (!line.trim()) { - currentName = null; - continue; - } - const collectionLine = /^\s*([a-z0-9._-]+)\s+\(qmd:\/\/[^)]+\)\s*$/i.exec(line); - if (collectionLine) { - currentName = collectionLine[1] ?? null; - if (currentName && !listed.has(currentName)) { - listed.set(currentName, {}); - } - continue; - } - if (/^\s*collections\b/i.test(line)) { - continue; - } - const bareNameLine = /^\s*([a-z0-9._-]+)\s*$/i.exec(line); - if (bareNameLine && !line.includes(":")) { - currentName = bareNameLine[1] ?? null; - if (currentName && !listed.has(currentName)) { - listed.set(currentName, {}); - } - continue; - } - if (!currentName) { - continue; - } - const patternLine = /^\s*(?:pattern|mask)\s*:\s*(.+?)\s*$/i.exec(line); - if (patternLine?.[1] !== undefined) { - const existing = listed.get(currentName) ?? {}; - existing.pattern = patternLine[1].trim(); - listed.set(currentName, existing); - continue; - } - const pathLine = /^\s*path\s*:\s*(.+?)\s*$/i.exec(line); - if (pathLine?.[1] !== undefined) { - const existing = listed.get(currentName) ?? {}; - existing.path = pathLine[1].trim(); - listed.set(currentName, existing); - } - } - return listed; -} - -export function parseShownQmdCollection(output: string): { path?: string; pattern?: string } { - const result: { path?: string; pattern?: string } = {}; - for (const rawLine of output.split(/\r?\n/)) { - const pathMatch = /^\s*Path\s*:\s*(.+?)\s*$/.exec(rawLine); - if (pathMatch?.[1] !== undefined) { - result.path = pathMatch[1].trim(); - continue; - } - const patternMatch = /^\s*Pattern\s*:\s*(.+?)\s*$/.exec(rawLine); - if (patternMatch?.[1] !== undefined) { - result.pattern = patternMatch[1].trim(); - } - } - return result; -} - -export function findQmdCollectionByPathPattern(params: { - collection: ManagedQmdCollection; - listed: Map; - workspaceDir: string; -}): string | null { - for (const [name, details] of params.listed) { - if (!details.path || typeof details.pattern !== "string") { - continue; - } - if ( - qmdCollectionPathsMatch(details.path, params.collection.path, params.workspaceDir) && - qmdCollectionPatternsMatch(params.collection.path, details.pattern, params.collection.pattern) - ) { - return name; - } - } - return null; -} - -export function parseConflictingQmdCollectionName(message: string): string | null { - if ( - !normalizeLowercaseStringOrEmpty(message).includes( - "a collection already exists for this path and pattern", - ) - ) { - return null; - } - const match = /^\s*Name:\s*([a-z0-9._-]+)\s*\(qmd:\/\/[^)\s]+\/?\)\s*$/im.exec(message); - return match?.[1] ?? null; -} - -export function deriveLegacyQmdCollectionName(scopedName: string, agentId: string): string | null { - const agentSuffix = `-${sanitizeQmdCollectionNameSegment(agentId)}`; - if (!scopedName.endsWith(agentSuffix)) { - return null; - } - const legacyName = scopedName.slice(0, -agentSuffix.length).trim(); - return legacyName || null; -} - -export function canMigrateLegacyQmdCollection(params: { - collection: ManagedQmdCollection; - listed: ListedQmdCollection; - workspaceDir: string; -}): boolean { - if ( - params.listed.path && - !qmdCollectionPathsMatch(params.listed.path, params.collection.path, params.workspaceDir) - ) { - return false; - } - return !( - typeof params.listed.pattern === "string" && - !qmdCollectionPatternsMatch( - params.collection.path, - params.listed.pattern, - params.collection.pattern, - ) - ); -} - -export function shouldRebindQmdCollection(params: { - collection: ManagedQmdCollection; - listed: ListedQmdCollection; - workspaceDir: string; -}): boolean { - if (!params.listed.path) { - return ( - typeof params.listed.pattern === "string" && - params.listed.pattern !== params.collection.pattern - ); - } - if (!qmdCollectionPathsMatch(params.listed.path, params.collection.path, params.workspaceDir)) { - return true; - } - return ( - typeof params.listed.pattern === "string" && - !qmdCollectionPatternsMatch( - params.collection.path, - params.listed.pattern, - params.collection.pattern, - ) - ); -} - -export function renderQmdCollectionIndexConfig(collections: ManagedQmdCollection[]): string { - if (collections.length === 0) { - return "collections: {}\n"; - } - const lines = ["collections:"]; - for (const collection of collections) { - lines.push( - ` ${JSON.stringify(collection.name)}:`, - ` path: ${JSON.stringify(collection.path)}`, - ` pattern: ${JSON.stringify(collection.pattern)}`, - ); - } - return `${lines.join("\n")}\n`; -} - -export function sanitizeQmdCollectionNameSegment(input: string): string { - const lower = normalizeLowercaseStringOrEmpty(input).replace(/[^a-z0-9-]+/g, "-"); - const trimmed = lower.replace(/^-+|-+$/g, ""); - return trimmed || "agent"; -} - -export function isQmdCollectionAlreadyExistsError(message: string): boolean { - return normalizeLowercaseStringOrEmpty(message).includes("exists"); -} - -export function isQmdCollectionMissingError(message: string): boolean { - const lower = normalizeLowercaseStringOrEmpty(message); - return ( - lower.includes("not found") || lower.includes("does not exist") || lower.includes("missing") - ); -} - -export function isSameNameQmdCollectionAlreadyExistsError(name: string, message: string): boolean { - const lowerName = normalizeLowercaseStringOrEmpty(name); - const lowerMessage = normalizeLowercaseStringOrEmpty(message); - return ( - lowerMessage.includes(`collection '${lowerName}' already exists`) || - lowerMessage.includes(`collection "${lowerName}" already exists`) - ); -} - -export function shouldRepairNullByteQmdCollectionError(err: unknown): boolean { - const message = formatErrorMessage(err); - const lower = normalizeLowercaseStringOrEmpty(message); - return ( - (lower.includes("enotdir") || - lower.includes("not a directory") || - lower.includes("enoent") || - lower.includes("no such file")) && - NUL_MARKER_RE.test(message) - ); -} - -export function shouldRepairDuplicateQmdDocumentConstraint(err: unknown): boolean { - const lower = normalizeLowercaseStringOrEmpty(formatErrorMessage(err)); - return ( - lower.includes("unique constraint failed") && - lower.includes("documents.collection") && - lower.includes("documents.path") - ); -} - -function qmdCollectionPatternsMatch( - collectionPath: string, - leftPattern: string, - rightPattern: string, -): boolean { - if (leftPattern === rightPattern) { - return true; - } - if (leftPattern !== "MEMORY.md" || rightPattern !== "MEMORY.md") { - return false; - } - try { - return fs - .readdirSync(collectionPath, { withFileTypes: true }) - .some((entry) => !entry.isSymbolicLink() && entry.isFile() && entry.name === "MEMORY.md"); - } catch { - return false; - } -} - -function qmdCollectionPathsMatch(left: string, right: string, workspaceDir: string): boolean { - const normalize = (value: string): string => { - const resolved = path.isAbsolute(value) - ? path.resolve(value) - : path.resolve(workspaceDir, value); - const normalized = path.normalize(resolved); - return process.platform === "win32" ? normalizeLowercaseStringOrEmpty(normalized) : normalized; - }; - return normalize(left) === normalize(right); -} diff --git a/extensions/memory-core/src/memory/qmd-command-client.ts b/extensions/memory-core/src/memory/qmd-command-client.ts deleted file mode 100644 index eda02e08c558..000000000000 --- a/extensions/memory-core/src/memory/qmd-command-client.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - createSubsystemLogger, - resolveGlobalSingleton, -} from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { - parseQmdQueryJson, - resolveCliSpawnInvocation, - runCliCommand, - type QmdQueryResult, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; -import type { - ResolvedQmdConfig, - ResolvedQmdMcporterConfig, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { addTimerTimeoutGraceMs } from "openclaw/plugin-sdk/number-runtime"; -import { asRecord } from "../dreaming-shared.js"; -import { asQmdAbortError, parseFailedQmdSearchJson } from "./qmd-command-errors.js"; -import type { MemorySearchDeadlineAction } from "./search-deadline.js"; - -const log = createSubsystemLogger("memory"); -const MCPORTER_STATE_KEY = Symbol.for("openclaw.mcporterState"); - -type McporterState = { - coldStartWarned: boolean; - daemonStart: Promise | null; -}; - -type BuiltinQmdMcpTool = "query" | "search" | "vector_search" | "deep_search"; - -export type QmdCommandPhaseReporter = (action: MemorySearchDeadlineAction) => void; - -type QmdMcporterToolSelection = - | { tool: string; explicitToolOverride: true } - | { tool: BuiltinQmdMcpTool; explicitToolOverride: false }; - -type QmdMcporterSearchParams = QmdMcporterToolSelection & { - mcporter: ResolvedQmdMcporterConfig; - searchCommand?: string; - query: string; - limit: number; - minScore: number; - collection?: string; - timeoutMs: number; - signal?: AbortSignal; - reportCommandPhase?: QmdCommandPhaseReporter; -}; - -type QmdMcporterAcrossCollectionsParams = QmdMcporterToolSelection & { - searchCommand?: string; - query: string; - limit: number; - minScore: number; - collectionNames: string[]; - signal?: AbortSignal; - reportCommandPhase?: QmdCommandPhaseReporter; -}; - -export function resolveQmdMcporterSearchProcessTimeoutMs(timeoutMs: number): number { - return Math.max(addTimerTimeoutGraceMs(timeoutMs, 2_000) ?? 1, 5_000); -} - -function getMcporterState(): McporterState { - return resolveGlobalSingleton(MCPORTER_STATE_KEY, () => ({ - coldStartWarned: false, - daemonStart: null, - })); -} - -async function runInQmdCommandPhase( - report: QmdCommandPhaseReporter | undefined, - task: () => Promise, -): Promise { - report?.("pause"); - try { - return await task(); - } finally { - report?.("resume"); - } -} - -export class QmdCommandClient { - private qmdMcpToolVersion: "v2" | "v1" | null = null; - - constructor( - private readonly qmd: ResolvedQmdConfig, - private readonly env: NodeJS.ProcessEnv, - private readonly workspaceDir: string, - private readonly maxOutputChars: number, - ) {} - - async run( - args: string[], - opts?: { timeoutMs?: number; discardOutput?: boolean; signal?: AbortSignal }, - ): Promise<{ stdout: string; stderr: string }> { - return await runCliCommand({ - commandSummary: `qmd ${args.join(" ")}`, - spawnInvocation: resolveCliSpawnInvocation({ - command: this.qmd.command, - args, - env: this.env, - packageName: "qmd", - }), - env: this.env, - cwd: this.workspaceDir, - timeoutMs: opts?.timeoutMs, - maxOutputChars: this.maxOutputChars, - // Large `qmd update` runs can easily exceed the output cap; keep only stderr. - discardStdout: opts?.discardOutput, - signal: opts?.signal, - }); - } - - async search( - args: string[], - command: "query" | "search" | "vsearch", - signal?: AbortSignal, - reportCommandPhase?: QmdCommandPhaseReporter, - ): Promise { - try { - const result = await runInQmdCommandPhase(reportCommandPhase, async () => - this.run(args, { timeoutMs: this.qmd.limits.timeoutMs, signal }), - ); - return parseQmdQueryJson(result.stdout, result.stderr); - } catch (err) { - const recovered = parseFailedQmdSearchJson(err, command); - if (recovered) { - return recovered; - } - throw err instanceof Error ? err : new Error(String(err)); - } - } - - resolveMcpTool(searchCommand: string): BuiltinQmdMcpTool { - if (this.qmdMcpToolVersion === "v2") { - return "query"; - } - if (this.qmdMcpToolVersion === "v1") { - return searchCommand === "search" - ? "search" - : searchCommand === "vsearch" - ? "vector_search" - : "deep_search"; - } - return "query"; - } - - async searchViaMcporter(params: QmdMcporterSearchParams): Promise { - if (params.signal?.aborted) { - throw asQmdAbortError(params.signal); - } - await this.ensureMcporterDaemonStarted(params.mcporter); - - const effectiveTool = - params.tool === "query" && this.qmdMcpToolVersion === "v1" - ? this.resolveMcpTool(params.searchCommand ?? "query") - : params.tool; - const selector = `${params.mcporter.serverName}.${effectiveTool}`; - const useUnifiedQueryTool = effectiveTool === "query"; - const callArgs: Record = useUnifiedQueryTool - ? { - searches: this.buildV2Searches(params.query, params.searchCommand), - limit: params.limit, - ...(params.searchCommand === "search" || params.searchCommand === "vsearch" - ? { rerank: false } - : {}), - } - : { - query: params.query, - limit: params.limit, - minScore: params.minScore, - }; - if (params.collection) { - if (useUnifiedQueryTool) { - callArgs.collections = [params.collection]; - } else { - callArgs.collection = params.collection; - } - } - if ( - useUnifiedQueryTool && - params.searchCommand === "query" && - this.qmd.searchMode === "query" && - this.qmd.rerank === false - ) { - callArgs.rerank = false; - } - - let result: { stdout: string }; - try { - result = await runInQmdCommandPhase(params.reportCommandPhase, async () => - this.runMcporter( - [ - "call", - selector, - "--args", - JSON.stringify(callArgs), - "--output", - "json", - "--timeout", - String(Math.max(0, params.timeoutMs)), - ], - { - timeoutMs: resolveQmdMcporterSearchProcessTimeoutMs(params.timeoutMs), - signal: params.signal, - }, - ), - ); - if (useUnifiedQueryTool && this.qmdMcpToolVersion === null) { - this.qmdMcpToolVersion = "v2"; - } - } catch (err) { - if (useUnifiedQueryTool && this.isQueryToolNotFoundError(err)) { - this.markQmdV1Fallback(); - const v1Tool = this.resolveMcpTool(params.searchCommand ?? "query"); - return this.searchViaMcporter({ - mcporter: params.mcporter, - tool: v1Tool, - searchCommand: params.searchCommand, - explicitToolOverride: false, - query: params.query, - limit: params.limit, - minScore: params.minScore, - collection: params.collection, - timeoutMs: params.timeoutMs, - signal: params.signal, - reportCommandPhase: params.reportCommandPhase, - }); - } - throw err; - } - - return this.parseMcporterResults(result.stdout); - } - - async searchAcrossCollections( - params: QmdMcporterAcrossCollectionsParams, - ): Promise { - const bestByDocId = new Map(); - for (const collectionName of params.collectionNames) { - const parsed = params.explicitToolOverride - ? await this.searchViaMcporter({ - mcporter: this.qmd.mcporter, - tool: params.tool, - searchCommand: params.searchCommand, - explicitToolOverride: true, - query: params.query, - limit: params.limit, - minScore: params.minScore, - collection: collectionName, - timeoutMs: this.qmd.limits.timeoutMs, - signal: params.signal, - reportCommandPhase: params.reportCommandPhase, - }) - : await this.searchViaMcporter({ - mcporter: this.qmd.mcporter, - tool: params.tool, - searchCommand: params.searchCommand, - explicitToolOverride: false, - query: params.query, - limit: params.limit, - minScore: params.minScore, - collection: collectionName, - timeoutMs: this.qmd.limits.timeoutMs, - signal: params.signal, - reportCommandPhase: params.reportCommandPhase, - }); - for (const entry of parsed) { - if (typeof entry.docid !== "string" || !entry.docid.trim()) { - continue; - } - const prev = bestByDocId.get(entry.docid); - const prevScore = typeof prev?.score === "number" ? prev.score : Number.NEGATIVE_INFINITY; - const nextScore = typeof entry.score === "number" ? entry.score : Number.NEGATIVE_INFINITY; - if (!prev || nextScore > prevScore) { - bestByDocId.set(entry.docid, entry); - } - } - } - return [...bestByDocId.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0)); - } - - private buildV2Searches( - query: string, - searchCommand?: string, - ): Array<{ type: string; query: string }> { - const semanticQuery = normalizeQmdSemanticQuery(query); - switch (searchCommand) { - case "search": - return [{ type: "lex", query }]; - case "vsearch": - return [{ type: "vec", query: semanticQuery }]; - default: - return [ - { type: "lex", query }, - { type: "vec", query: semanticQuery }, - { type: "hyde", query: semanticQuery }, - ]; - } - } - - private isQueryToolNotFoundError(err: unknown): boolean { - const message = formatErrorMessage(err); - const detail = message.match(/ failed \(code \d+\): ([\s\S]*)$/)?.[1]; - if (!detail) { - return false; - } - return /(?:^|\n|:\s)(?:MCP error [^:\n]+:\s*)?Tool ['"]?query['"]? not found\b/i.test(detail); - } - - private markQmdV1Fallback(): void { - if (this.qmdMcpToolVersion !== "v1") { - this.qmdMcpToolVersion = "v1"; - log.warn( - "QMD MCP server does not expose the v2 'query' tool; falling back to v1 tool names (search/vector_search/deep_search).", - ); - } - } - - private async ensureMcporterDaemonStarted(mcporter: ResolvedQmdMcporterConfig): Promise { - if (!mcporter.enabled) { - return; - } - const state = getMcporterState(); - if (!mcporter.startDaemon) { - if (!state.coldStartWarned) { - state.coldStartWarned = true; - log.warn( - "mcporter qmd bridge enabled but startDaemon=false; each query may cold-start QMD MCP. Consider setting memory.qmd.mcporter.startDaemon=true to keep it warm.", - ); - } - return; - } - if (!state.daemonStart) { - state.daemonStart = (async () => { - try { - await this.runMcporter(["daemon", "start"], { timeoutMs: 10_000 }); - } catch (err) { - log.warn(`mcporter daemon start failed: ${String(err)}`); - state.daemonStart = null; - } - })(); - } - await state.daemonStart; - } - - private async runMcporter( - args: string[], - opts?: { timeoutMs?: number; signal?: AbortSignal }, - ): Promise<{ stdout: string; stderr: string }> { - const spawnInvocation = resolveCliSpawnInvocation({ - command: "mcporter", - args, - env: this.env, - packageName: "mcporter", - }); - return await runCliCommand({ - commandSummary: `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`, - spawnInvocation, - env: this.env, - cwd: this.workspaceDir, - timeoutMs: opts?.timeoutMs, - maxOutputChars: this.maxOutputChars, - signal: opts?.signal, - }); - } - - private parseMcporterResults(stdout: string): QmdQueryResult[] { - let parsedUnknown: unknown; - try { - parsedUnknown = JSON.parse(stdout); - } catch { - throw new Error("qmd mcporter returned non-JSON stdout", { - cause: new Error("mcporter stdout was not valid JSON"), - }); - } - const parsedRecord = asRecord(parsedUnknown); - const structuredContent = parsedRecord ? asRecord(parsedRecord.structuredContent) : null; - const structured: unknown = structuredContent ?? parsedUnknown; - const structuredRecord = asRecord(structured); - const results: unknown[] = - structuredRecord && Array.isArray(structuredRecord.results) - ? (structuredRecord.results as unknown[]) - : Array.isArray(structured) - ? structured - : []; - - const out: QmdQueryResult[] = []; - for (const item of results) { - const itemRecord = asRecord(item); - if (!itemRecord) { - continue; - } - const docidRaw = itemRecord.docid; - const docid = typeof docidRaw === "string" ? docidRaw.replace(/^#/, "").trim() : ""; - if (!docid) { - continue; - } - const scoreRaw = itemRecord.score; - const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw); - out.push({ - docid, - score: Number.isFinite(score) ? score : 0, - snippet: typeof itemRecord.snippet === "string" ? itemRecord.snippet : "", - collection: typeof itemRecord.collection === "string" ? itemRecord.collection : undefined, - file: typeof itemRecord.file === "string" ? itemRecord.file : undefined, - body: typeof itemRecord.body === "string" ? itemRecord.body : undefined, - startLine: normalizeSnippetLine(itemRecord.start_line ?? itemRecord.startLine), - endLine: normalizeSnippetLine(itemRecord.end_line ?? itemRecord.endLine), - }); - } - return out; - } -} - -function normalizeSnippetLine(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - -function normalizeQmdSemanticQuery(query: string): string { - return query.replace(/(\w)-(?=\w)/g, "$1 "); -} diff --git a/extensions/memory-core/src/memory/qmd-command-errors.ts b/extensions/memory-core/src/memory/qmd-command-errors.ts deleted file mode 100644 index 33d5fcbe4d32..000000000000 --- a/extensions/memory-core/src/memory/qmd-command-errors.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { createSubsystemLogger } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { - parseQmdQueryJson, - type QmdQueryResult, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; - -const log = createSubsystemLogger("memory"); - -export function asQmdAbortError(signal: AbortSignal): Error { - const reason = signal.reason; - if (reason instanceof Error) { - return reason; - } - if (typeof reason === "string" && reason.length > 0) { - return new Error(reason); - } - return new Error("qmd search aborted"); -} - -export function parseFailedQmdSearchJson( - err: unknown, - command: "query" | "search" | "vsearch", -): QmdQueryResult[] | null { - if ( - !isQmdCliCommandError(err) || - isMissingCollectionSearchError(err) || - isUnsupportedQmdOptionError(err) || - isSqliteBusyError(err) || - !isQmdNativeAbortAfterOutput(err) - ) { - return null; - } - try { - const parsed = parseQmdQueryJson(err.stdout, err.stderr); - log.warn( - `qmd ${command} exited non-zero after producing valid JSON; using captured search results (${formatQmdSearchExit(err)})`, - ); - return parsed; - } catch { - return null; - } -} - -export function isMissingCollectionSearchError(err: unknown): boolean { - const normalized = normalizeLowercaseStringOrEmpty(formatErrorMessage(err)); - return ( - normalized.includes("collection") && - (normalized.includes("not found") || - normalized.includes("does not exist") || - normalized.includes("missing")) - ); -} - -export function isUnsupportedQmdOptionError(err: unknown): boolean { - const normalized = normalizeLowercaseStringOrEmpty(formatErrorMessage(err)); - return ( - normalized.includes("unknown flag") || - normalized.includes("unknown option") || - normalized.includes("unrecognized option") || - normalized.includes("flag provided but not defined") || - normalized.includes("unexpected argument") - ); -} - -export function isSqliteBusyError(err: unknown): boolean { - const normalized = normalizeLowercaseStringOrEmpty(formatErrorMessage(err)); - return normalized.includes("sqlite_busy") || normalized.includes("database is locked"); -} - -function formatQmdSearchExit(err: { code: number | null; signal: NodeJS.Signals | null }): string { - return err.code === null ? `signal ${err.signal ?? "unknown"}` : `code ${err.code}`; -} - -function isQmdCliCommandError(err: unknown): err is { - code: number | null; - signal: NodeJS.Signals | null; - stdout: string; - stderr: string; -} { - if (!(err instanceof Error)) { - return false; - } - const candidate = err as { - code?: unknown; - signal?: unknown; - stdout?: unknown; - stderr?: unknown; - }; - return ( - (typeof candidate.code === "number" || candidate.code === null) && - (typeof candidate.signal === "string" || candidate.signal === null) && - typeof candidate.stdout === "string" && - typeof candidate.stderr === "string" - ); -} - -function isQmdNativeAbortAfterOutput(err: { - code: number | null; - signal: NodeJS.Signals | null; - stderr: string; -}): boolean { - const aborted = err.code === 134 || err.signal === "SIGABRT"; - if (!aborted) { - return false; - } - const stderr = normalizeLowercaseStringOrEmpty(err.stderr); - return ( - stderr.includes("ggml-metal") || - stderr.includes("node-llama-cpp") || - stderr.includes("llama.cpp") || - stderr.includes("abort trap") || - stderr.includes("assertion failed") - ); -} diff --git a/extensions/memory-core/src/memory/qmd-compat.test.ts b/extensions/memory-core/src/memory/qmd-compat.test.ts deleted file mode 100644 index 9d10b4918f16..000000000000 --- a/extensions/memory-core/src/memory/qmd-compat.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Memory Core tests cover qmd compat plugin behavior. -import { describe, expect, it } from "vitest"; -import { resolveQmdCollectionPatternFlags } from "./qmd-compat.js"; - -describe("resolveQmdCollectionPatternFlags", () => { - it("prefers --mask by default and falls back to --glob", () => { - expect(resolveQmdCollectionPatternFlags(null)).toEqual(["--mask", "--glob"]); - expect(resolveQmdCollectionPatternFlags("--mask")).toEqual(["--mask", "--glob"]); - }); - - it("keeps preferring --glob after a glob-only qmd succeeds", () => { - expect(resolveQmdCollectionPatternFlags("--glob")).toEqual(["--glob", "--mask"]); - }); -}); diff --git a/extensions/memory-core/src/memory/qmd-compat.ts b/extensions/memory-core/src/memory/qmd-compat.ts deleted file mode 100644 index a7fc2ca1860d..000000000000 --- a/extensions/memory-core/src/memory/qmd-compat.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Memory Core plugin module implements qmd compat behavior. -export type QmdCollectionPatternFlag = "--glob" | "--mask"; - -export function resolveQmdCollectionPatternFlags( - preferredFlag: QmdCollectionPatternFlag | null, -): QmdCollectionPatternFlag[] { - return preferredFlag === "--glob" ? ["--glob", "--mask"] : ["--mask", "--glob"]; -} diff --git a/extensions/memory-core/src/memory/qmd-document-resolver.ts b/extensions/memory-core/src/memory/qmd-document-resolver.ts deleted file mode 100644 index beaf579f1916..000000000000 --- a/extensions/memory-core/src/memory/qmd-document-resolver.ts +++ /dev/null @@ -1,465 +0,0 @@ -import path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import { - createSubsystemLogger, - isPathInside, -} from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import type { MemorySource } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { - localeLowercasePreservingWhitespace, - normalizeLowercaseStringOrEmpty, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { isSqliteBusyError } from "./qmd-command-errors.js"; - -type SqliteDatabase = import("node:sqlite").DatabaseSync; - -export type QmdCollectionRoot = { - path: string; - kind: MemorySource; -}; - -export type QmdDocLocation = { - abs: string; - collection: string; - collectionRelativePath: string; - observedAt: number; - rel: string; - source: MemorySource; -}; - -type QmdDocumentRow = { - collection: string; - modified_at?: unknown; - path: string; -}; - -type QmdDocHints = { - preferredCollection?: string; - preferredFile?: string; -}; - -const log = createSubsystemLogger("memory"); - -export class QmdDocumentResolver { - private readonly docPathCache = new Map(); - - constructor( - private readonly workspaceDir: string, - private readonly collectionRoots: ReadonlyMap, - private readonly ensureDb: () => SqliteDatabase, - private readonly sessionCollectionsReadable: boolean, - ) {} - - clearCache(): void { - this.docPathCache.clear(); - } - - async resolveDocLocation(docid?: string, hints?: QmdDocHints): Promise { - const normalizedHints = this.normalizeDocHints(hints); - if (!docid) { - return this.resolveDocLocationFromHints(normalizedHints); - } - const normalized = docid.startsWith("#") ? docid.slice(1) : docid; - if (!normalized) { - return null; - } - const cacheKey = `${normalizedHints.preferredCollection ?? "*"}:${normalized}`; - const cached = this.docPathCache.get(cacheKey); - if (cached) { - return cached; - } - let rows: QmdDocumentRow[]; - try { - const db = this.ensureDb(); - rows = db - .prepare( - "SELECT collection, path, modified_at FROM documents WHERE hash = ? AND active = 1", - ) - .all(normalized) as QmdDocumentRow[]; - if (rows.length === 0) { - rows = db - .prepare( - "SELECT collection, path, modified_at FROM documents WHERE hash LIKE ? AND active = 1", - ) - .all(`${normalized}%`) as QmdDocumentRow[]; - } - } catch (err) { - if (isSqliteBusyError(err)) { - log.debug(`qmd index is busy while resolving doc path: ${String(err)}`); - throw createQmdBusyError(err); - } - throw err; - } - if (rows.length > 0) { - const dbLocation = this.pickDocLocation(rows, normalizedHints); - if (dbLocation) { - this.docPathCache.set(cacheKey, dbLocation); - return dbLocation; - } - } - // Keep hint-only locations out of the docid cache so a recovered index row - // can become authoritative on the next lookup. - return this.resolveDocLocationFromHints(normalizedHints); - } - - normalizeDocHints(hints?: QmdDocHints): QmdDocHints { - const preferredCollection = hints?.preferredCollection?.trim(); - const preferredFile = hints?.preferredFile?.trim(); - if (!preferredFile) { - return preferredCollection ? { preferredCollection } : {}; - } - const parsedQmdFile = parseQmdFileUri(preferredFile); - return { - preferredCollection: parsedQmdFile?.collection ?? preferredCollection, - preferredFile: parsedQmdFile?.collectionRelativePath ?? preferredFile, - }; - } - - toCollectionRelativePath(collection: string, filePath: string): string | null { - const rootItem = this.collectionRoots.get(collection); - if (!rootItem) { - return null; - } - const trimmedFilePath = filePath.trim(); - if (!trimmedFilePath) { - return null; - } - const normalizedInput = path.normalize(trimmedFilePath); - const absolutePath = path.isAbsolute(normalizedInput) - ? normalizedInput - : path.resolve(rootItem.path, normalizedInput); - if (!isPathInside(rootItem.path, absolutePath)) { - return null; - } - const relative = path.relative(rootItem.path, absolutePath); - if (!relative || relative === ".") { - return null; - } - return relative.replace(/\\/g, "/"); - } - - buildSearchPath( - collection: string, - collectionRelativePath: string, - relativeToWorkspace: string, - absPath: string, - ): string { - const sanitized = collectionRelativePath.replace(/^\/+/, ""); - if (isInsideRoot(relativeToWorkspace)) { - const normalized = relativeToWorkspace.replace(/\\/g, "/"); - if (!normalized) { - return path.basename(absPath); - } - // `qmd//...` is the virtual read namespace. Preserve it when - // a real workspace file also lives under qmd/ so search -> read is unambiguous. - if (normalized === "qmd" || normalized.startsWith("qmd/")) { - return `qmd/${collection}/${sanitized}`; - } - return normalized; - } - return `qmd/${collection}/${sanitized}`; - } - - resolveReadPath(relPath: string): string { - if (relPath.startsWith("qmd/")) { - const [, collection, ...rest] = relPath.split("/"); - if (!collection || rest.length === 0) { - throw new Error("invalid qmd path"); - } - const rootResult = this.collectionRoots.get(collection); - if (!rootResult) { - throw new Error(`unknown qmd collection: ${collection}`); - } - // Remember-only session exports are search-only for trusted recall; - // memory_get may read them only when the operator explicitly enabled - // memory.qmd.sessions. - if (rootResult.kind === "sessions" && !this.sessionCollectionsReadable) { - throw new Error("path required"); - } - const resolved = path.resolve(rootResult.path, rest.join("/")); - if (!isPathInside(rootResult.path, resolved)) { - throw new Error("qmd path escapes collection"); - } - return resolved; - } - const absPath = path.resolve(this.workspaceDir, relPath); - if (!isPathInside(this.workspaceDir, absPath)) { - throw new Error("path escapes workspace"); - } - const workspaceRel = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/"); - if (!isDefaultQmdMemoryPath(workspaceRel) && !this.isIndexedWorkspaceReadPath(absPath)) { - throw new Error("path required"); - } - return absPath; - } - - private resolveDocLocationFromHints(hints: QmdDocHints): QmdDocLocation | null { - if (!hints.preferredCollection || !hints.preferredFile) { - return null; - } - const indexedLocation = this.resolveIndexedDocLocationFromHint( - hints.preferredCollection, - hints.preferredFile, - ); - if (indexedLocation) { - return indexedLocation; - } - const collectionRelativePath = this.toCollectionRelativePath( - hints.preferredCollection, - hints.preferredFile, - ); - return collectionRelativePath - ? this.toDocLocation(hints.preferredCollection, collectionRelativePath) - : null; - } - - private resolveIndexedDocLocationFromHint( - collection: string, - preferredFile: string, - ): QmdDocLocation | null { - const trimmedCollection = collection.trim(); - const trimmedFile = preferredFile.trim(); - if (!trimmedCollection || !trimmedFile) { - return null; - } - const exactPath = path.normalize(trimmedFile).replace(/\\/g, "/"); - let rows: Array<{ modified_at?: unknown; path: string }>; - try { - const db = this.ensureDb(); - const exactRows = db - .prepare( - "SELECT path, modified_at FROM documents WHERE collection = ? AND path = ? AND active = 1", - ) - .all(trimmedCollection, exactPath) as Array<{ modified_at?: unknown; path: string }>; - if (exactRows.length > 0) { - const exactRow = expectDefined(exactRows.at(0), "single exact QMD document row"); - return this.toDocLocation(trimmedCollection, exactRow.path, exactRow.modified_at); - } - rows = db - .prepare("SELECT path, modified_at FROM documents WHERE collection = ? AND active = 1") - .all(trimmedCollection) as Array<{ modified_at?: unknown; path: string }>; - } catch (err) { - if (isSqliteBusyError(err)) { - log.debug(`qmd index is busy while resolving hinted path: ${String(err)}`); - throw createQmdBusyError(err); - } - log.debug(`qmd index hint lookup skipped: ${String(err)}`); - return null; - } - const matches = rows.filter((row) => this.matchesPreferredFileHint(row.path, trimmedFile)); - if (matches.length !== 1) { - return null; - } - const match = expectDefined(matches.at(0), "single preferred QMD document match"); - return this.toDocLocation(trimmedCollection, match.path, match.modified_at); - } - - private pickDocLocation(rows: QmdDocumentRow[], hints?: QmdDocHints): QmdDocLocation | null { - if (hints?.preferredCollection) { - for (const row of rows) { - if (row.collection === hints.preferredCollection) { - const location = this.toDocLocation(row.collection, row.path, row.modified_at); - if (location) { - return location; - } - } - } - } - if (hints?.preferredFile) { - for (const row of rows) { - if (this.matchesPreferredFileHint(row.path, hints.preferredFile)) { - const location = this.toDocLocation(row.collection, row.path, row.modified_at); - if (location) { - return location; - } - } - } - } - for (const row of rows) { - const location = this.toDocLocation(row.collection, row.path, row.modified_at); - if (location) { - return location; - } - } - return null; - } - - private matchesPreferredFileHint(rowPath: string, preferredFile: string): boolean { - const preferred = path.normalize(preferredFile).replace(/\\/g, "/"); - const normalizedRowPath = path.normalize(rowPath).replace(/\\/g, "/"); - if (normalizedRowPath === preferred || normalizedRowPath.endsWith(`/${preferred}`)) { - return true; - } - const normalizedPreferredLookup = normalizeQmdLookupPath(preferredFile); - if (!normalizedPreferredLookup) { - return false; - } - const normalizedRowLookup = normalizeQmdLookupPath(rowPath); - return ( - normalizedRowLookup === normalizedPreferredLookup || - normalizedRowLookup.endsWith(`/${normalizedPreferredLookup}`) - ); - } - - private toDocLocation( - collection: string, - collectionRelativePath: string, - modifiedAt?: unknown, - ): QmdDocLocation | null { - const rootEntry = this.collectionRoots.get(collection); - if (!rootEntry) { - return null; - } - const normalizedRelative = collectionRelativePath.replace(/\\/g, "/"); - const absPath = path.normalize(path.resolve(rootEntry.path, collectionRelativePath)); - const relativeToWorkspace = path.relative(this.workspaceDir, absPath); - return { - rel: this.buildSearchPath(collection, normalizedRelative, relativeToWorkspace, absPath), - abs: absPath, - collection, - collectionRelativePath: normalizedRelative, - observedAt: parseQmdModifiedAt(modifiedAt), - source: rootEntry.kind, - }; - } - - private isIndexedWorkspaceReadPath(absPath: string): boolean { - const normalizedAbsPath = path.normalize(absPath); - for (const [collection, rootValue] of this.collectionRoots.entries()) { - // Apply the same read gate to workspace-relative indexed-path resolution. - if (rootValue.kind === "sessions" && !this.sessionCollectionsReadable) { - continue; - } - if (!isPathInside(rootValue.path, normalizedAbsPath)) { - continue; - } - const collectionRelativePath = path - .relative(rootValue.path, normalizedAbsPath) - .replace(/\\/g, "/"); - if (!collectionRelativePath || collectionRelativePath.startsWith("..")) { - continue; - } - try { - const exactRow = this.ensureDb() - .prepare("SELECT path FROM documents WHERE collection = ? AND active = 1 AND path = ?") - .get(collection, collectionRelativePath) as { path: string } | undefined; - if ( - exactRow && - path.normalize(path.resolve(rootValue.path, exactRow.path)) === normalizedAbsPath - ) { - return true; - } - const rows = this.ensureDb() - .prepare("SELECT path FROM documents WHERE collection = ? AND active = 1") - .all(collection) as Array<{ path: string }>; - const match = rows.find((row) => - this.matchesPreferredFileHint(row.path, collectionRelativePath), - ); - if ( - match && - path.normalize(path.resolve(rootValue.path, match.path)) === normalizedAbsPath - ) { - return true; - } - } catch (err) { - if (isSqliteBusyError(err)) { - log.debug(`qmd index is busy while checking read path: ${String(err)}`); - throw createQmdBusyError(err); - } - log.debug(`qmd indexed read-path lookup skipped: ${String(err)}`); - } - } - return false; - } -} - -function parseQmdModifiedAt(value: unknown): number { - const timestamp = - typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN; - return Number.isFinite(timestamp) && timestamp >= 0 ? Math.floor(timestamp) : 0; -} - -export function isDefaultQmdMemoryPath(relPath: string): boolean { - const normalized = relPath.trim().replace(/^\.\//, "").replace(/\\/g, "/"); - if (!normalized) { - return false; - } - return ( - normalized === "MEMORY.md" || - normalized === "DREAMS.md" || - normalized === "dreams.md" || - normalized.startsWith("memory/") - ); -} - -function parseQmdFileUri(fileRef: string): { - collection?: string; - collectionRelativePath?: string; -} | null { - if (!normalizeLowercaseStringOrEmpty(fileRef).startsWith("qmd://")) { - return null; - } - try { - const parsed = new URL(fileRef); - const collection = decodeURIComponent(parsed.hostname).trim(); - const pathname = decodeURIComponent(parsed.pathname).replace(/^\/+/, "").trim(); - if (!collection && !pathname) { - return null; - } - return { - collection: collection || undefined, - collectionRelativePath: pathname || undefined, - }; - } catch { - return null; - } -} - -function normalizeQmdLookupPath(filePath: string): string { - return filePath - .replace(/\\/g, "/") - .split("/") - .filter((segment) => segment.length > 0 && segment !== ".") - .map((segment) => normalizeQmdLookupSegment(segment)) - .filter(Boolean) - .join("/"); -} - -function normalizeQmdLookupSegment(segment: string): string { - const trimmed = segment.trim(); - if (!trimmed || trimmed === "." || trimmed === "..") { - return trimmed; - } - const parsed = path.posix.parse(trimmed); - const normalizePart = (value: string): string => - localeLowercasePreservingWhitespace(value.normalize("NFKD")) - .replace(/[^\p{Letter}\p{Number}]+/gu, "-") - .replace(/-{2,}/g, "-") - .replace(/^-+|-+$/g, ""); - const normalizedName = normalizePart(parsed.name); - const normalizedExt = localeLowercasePreservingWhitespace(parsed.ext.normalize("NFKD")).replace( - /[^\p{Letter}\p{Number}.]+/gu, - "", - ); - const fallbackName = normalizeLowercaseStringOrEmpty(parsed.name.normalize("NFKD")).replace( - /\s+/g, - "-", - ); - return `${normalizedName || fallbackName || "file"}${normalizedExt}`; -} - -function isInsideRoot(relativePath: string): boolean { - if (!relativePath) { - return true; - } - return ( - !relativePath.startsWith("..") && - !relativePath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativePath) - ); -} - -function createQmdBusyError(err: unknown): Error { - return new Error(`qmd index busy while reading results: ${formatErrorMessage(err)}`); -} diff --git a/extensions/memory-core/src/memory/qmd-manager-base.ts b/extensions/memory-core/src/memory/qmd-manager-base.ts deleted file mode 100644 index d61e80acdd65..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager-base.ts +++ /dev/null @@ -1,493 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { FSWatcher } from "chokidar"; -import { - createSubsystemLogger, - resolveAgentContextLimits, - resolveMemorySearchSyncConfig, - resolveAgentWorkspaceDir, - resolveStateDir, - type OpenClawConfig, -} from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import type { - MemorySource, - ResolvedMemoryBackendConfig, - ResolvedQmdConfig, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import type { - PluginStateLeaseContext, - PluginStateLeaseRunner, -} from "openclaw/plugin-sdk/plugin-state-runtime"; -import { - QmdCollectionController, - type ManagedQmdCollection as ManagedCollection, - type QmdSearchRuntimeDebugContext, -} from "./qmd-collection-controller.js"; -import { QmdCommandClient } from "./qmd-command-client.js"; -import { asQmdAbortError } from "./qmd-command-errors.js"; -import { - QmdDocumentResolver, - type QmdCollectionRoot as CollectionRoot, -} from "./qmd-document-resolver.js"; -import { buildQmdProcessPath, MAX_QMD_OUTPUT_CHARS } from "./qmd-manager-helpers.js"; -import type { - QmdRuntimeCollectionValidationCacheContext, - QmdRuntimeManagedCollection, - QmdRuntimeMultiCollectionProbeCacheContext, -} from "./qmd-runtime-cache.js"; -import { QmdSessionExporter, resolveQmdSessionExporterConfig } from "./qmd-session-exporter.js"; -import type { MemoryWatchPressureWarningState } from "./watch-pressure.js"; -import type { MemoryWatchSettleQueue } from "./watch-settle.js"; - -export const qmdManagerLog = createSubsystemLogger("memory"); - -type QmdManagerMode = "full" | "status" | "cli"; -type QmdManagerRuntimeConfig = { - workspaceDir: string; - syncSettings: ReturnType; - contextLimits: ReturnType; -}; - -type SqliteDatabase = import("node:sqlite").DatabaseSync; - -export abstract class QmdManagerBase { - protected readonly agentId: string; - protected readonly qmd: ResolvedQmdConfig; - protected readonly workspaceDir: string; - protected readonly contextLimits: ReturnType; - protected readonly stateDir: string; - protected readonly agentStateDir: string; - protected readonly qmdDir: string; - protected readonly xdgConfigHome: string; - protected readonly xdgCacheHome: string; - protected readonly indexPath: string; - protected readonly env: NodeJS.ProcessEnv; - protected readonly commands: QmdCommandClient; - protected readonly withLease: PluginStateLeaseRunner; - protected readonly collectionController: QmdCollectionController; - protected readonly documentResolver: QmdDocumentResolver; - protected readonly syncSettings: ReturnType; - protected readonly managedCollectionNames: string[]; - protected readonly collectionRoots = new Map(); - protected readonly sources = new Set(); - protected readonly maxQmdOutputChars = MAX_QMD_OUTPUT_CHARS; - protected readonly sessionExporter: QmdSessionExporter | null; - protected updateTimer: NodeJS.Timeout | null = null; - protected embedTimer: NodeJS.Timeout | null = null; - protected watcher: FSWatcher | null = null; - protected watchTimer: NodeJS.Timeout | null = null; - protected readonly pendingWatchPaths: MemoryWatchSettleQueue = new Map(); - protected readonly watchPressureWarning: MemoryWatchPressureWarningState = { shown: false }; - protected pendingUpdate: Promise | null = null; - protected queuedForcedUpdate: Promise | null = null; - protected queuedForcedRuns = 0; - protected dirty = false; - protected closed = false; - protected mode: QmdManagerMode = "full"; - protected readonly closeSignal: Promise; - protected resolveCloseSignal!: () => void; - protected readonly closeAbortController = new AbortController(); - protected qmdRuntimeIdentityPromise: Promise | null = null; - protected db: SqliteDatabase | null = null; - protected lastUpdateAt: number | null = null; - protected lastEmbedAt: number | null = null; - protected embedLeaseRetryPending = false; - protected embedBackoffUntil: number | null = null; - protected embedFailureCount = 0; - protected vectorAvailable: boolean | null = null; - protected vectorStatusDetail: string | null = null; - protected readonly sessionWarm = new Set(); - protected multiCollectionFilterSupported: boolean | null = null; - - protected constructor(params: { - agentId: string; - resolved: ResolvedQmdConfig; - runtimeConfig: QmdManagerRuntimeConfig; - withLease: PluginStateLeaseRunner; - }) { - this.agentId = params.agentId; - this.qmd = params.resolved; - this.workspaceDir = params.runtimeConfig.workspaceDir; - this.contextLimits = params.runtimeConfig.contextLimits; - this.withLease = params.withLease; - this.stateDir = resolveStateDir(process.env, os.homedir); - this.agentStateDir = path.join(this.stateDir, "agents", this.agentId); - this.qmdDir = path.join(this.agentStateDir, "qmd"); - this.syncSettings = params.runtimeConfig.syncSettings; - // QMD manages collections in its index DB while XDG config and cache dirs - // isolate contexts and index.sqlite per agent. - this.xdgConfigHome = path.join(this.qmdDir, "xdg-config"); - this.xdgCacheHome = path.join(this.qmdDir, "xdg-cache"); - this.indexPath = path.join(this.xdgCacheHome, "qmd", "index.sqlite"); - - this.env = { - ...process.env, - PATH: buildQmdProcessPath(process.env.PATH), - XDG_CONFIG_HOME: this.xdgConfigHome, - // QMD resolves index.yml relative to QMD_CONFIG_DIR rather than XDG_CONFIG_HOME. - // Point it at the nested qmd config directory so per-agent collections are visible. - QMD_CONFIG_DIR: path.join(this.xdgConfigHome, "qmd"), - XDG_CACHE_HOME: this.xdgCacheHome, - NO_COLOR: "1", - }; - this.commands = new QmdCommandClient( - this.qmd, - this.env, - this.workspaceDir, - this.maxQmdOutputChars, - ); - this.collectionController = new QmdCollectionController( - this.qmd, - this.agentId, - this.workspaceDir, - this.xdgConfigHome, - async (args, opts) => await this.commands.run(args, opts), - async (signal) => await this.buildQmdCollectionValidationCacheContext(signal), - ); - this.documentResolver = new QmdDocumentResolver( - this.workspaceDir, - this.collectionRoots, - () => this.ensureDb(), - this.qmd.sessions.readable, - ); - this.closeSignal = new Promise((resolve) => { - this.resolveCloseSignal = resolve; - }); - const sessionExporterConfig = resolveQmdSessionExporterConfig({ - qmd: this.qmd, - agentId: this.agentId, - qmdDir: this.qmdDir, - }); - this.sessionExporter = sessionExporterConfig - ? new QmdSessionExporter( - sessionExporterConfig, - this.agentId, - this.workspaceDir, - this.indexPath, - (collection, collectionRelativePath, workspaceRelativePath, absolutePath) => - this.documentResolver.buildSearchPath( - collection, - collectionRelativePath, - workspaceRelativePath, - absolutePath, - ), - ) - : null; - if (sessionExporterConfig) { - this.qmd.collections = [ - ...this.qmd.collections, - { - name: sessionExporterConfig.collectionName, - path: sessionExporterConfig.dir, - pattern: "**/*.md", - kind: "sessions", - }, - ]; - } - this.managedCollectionNames = this.computeManagedCollectionNames(); - } - - protected async initialize(mode: QmdManagerMode): Promise { - this.mode = mode; - const startTime = Date.now(); - this.bootstrapCollections(); - if (mode === "status") { - return; - } - - await fs.mkdir(this.xdgConfigHome, { recursive: true }); - await fs.mkdir(this.xdgCacheHome, { recursive: true }); - await fs.mkdir(path.dirname(this.indexPath), { recursive: true }); - if (this.sessionExporter) { - await fs.mkdir(this.sessionExporter.config.dir, { recursive: true }); - } - - // Reuse default-cache ML models despite the per-agent XDG cache override - // so qmd does not download them separately for every agent. - await this.symlinkSharedModels(); - - await this.ensureCollections(); - if (mode === "cli") { - if (this.qmd.update.onBoot && this.qmd.update.waitForBootSync) { - await this.runUpdate("boot:cli", true).catch((err: unknown) => { - qmdManagerLog.warn(`qmd cli boot update failed: ${String(err)}`); - }); - } - qmdManagerLog.info( - `qmd manager initialized for agent "${this.agentId}" mode=cli collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`, - ); - return; - } - - this.ensureWatcher(); - qmdManagerLog.info( - `qmd manager initialized for agent "${this.agentId}" mode=full collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`, - ); - - if (this.qmd.update.onBoot) { - const bootRun = this.runUpdate("boot", true); - if (this.qmd.update.waitForBootSync) { - await bootRun.catch((err: unknown) => { - qmdManagerLog.warn(`qmd boot update failed: ${String(err)}`); - }); - } else { - void bootRun.catch((err: unknown) => { - qmdManagerLog.warn(`qmd boot update failed: ${String(err)}`); - }); - } - } - if (this.qmd.update.intervalMs > 0) { - this.updateTimer = setInterval(() => { - void this.runUpdate("interval").catch((err: unknown) => { - qmdManagerLog.warn(`qmd update failed (${String(err)})`); - }); - }, this.qmd.update.intervalMs); - } - if (this.shouldScheduleEmbedTimer()) { - const startPeriodicEmbedTimer = () => { - this.embedTimer = setInterval(() => { - void this.runUpdate("embed-interval").catch((err: unknown) => { - qmdManagerLog.warn(`qmd embed interval update failed (${String(err)})`); - }); - }, this.qmd.update.embedIntervalMs); - }; - const initialDelayMs = this.resolveEmbedStartupJitterMs(); - if (initialDelayMs > 0) { - this.embedTimer = setTimeout(() => { - this.embedTimer = null; - if (this.closed) { - return; - } - void this.runUpdate("embed-interval") - .catch((err: unknown) => { - qmdManagerLog.warn(`qmd embed interval update failed (${String(err)})`); - }) - .finally(() => { - if (!this.closed) { - startPeriodicEmbedTimer(); - } - }); - }, initialDelayMs); - } else { - startPeriodicEmbedTimer(); - } - } - } - - protected bootstrapCollections(): void { - this.collectionRoots.clear(); - this.sources.clear(); - for (const collection of this.qmd.collections) { - const kind: MemorySource = collection.kind === "sessions" ? "sessions" : "memory"; - this.collectionRoots.set(collection.name, { path: collection.path, kind }); - this.sources.add(kind); - } - } - - protected qmdRuntimeCacheSources(): string[] { - return [...this.sources].toSorted(); - } - - protected qmdRuntimeCacheCollections(): QmdRuntimeManagedCollection[] { - return this.qmd.collections.map((collection) => ({ - name: collection.name, - kind: collection.kind, - path: collection.path, - pattern: collection.pattern, - })); - } - - protected buildQmdRuntimeEnvironmentHash(): string { - const relevantEnv = Object.fromEntries( - Object.keys(this.env) - .filter( - (key) => - key === "PATH" || - key === "HOME" || - key === "LOCALAPPDATA" || - key === "XDG_CONFIG_HOME" || - key === "XDG_CACHE_HOME" || - key === "QMD_CONFIG_DIR" || - key.startsWith("QMD_"), - ) - .toSorted() - .map((key) => [key, this.env[key] ?? ""]), - ); - return crypto.createHash("sha256").update(JSON.stringify(relevantEnv)).digest("hex"); - } - - protected async buildQmdCollectionValidationCacheContext( - signal?: AbortSignal, - ): Promise { - return { - workspaceDir: this.workspaceDir, - agentId: this.agentId, - qmdCommand: this.qmd.command, - qmdVersion: await this.resolveQmdRuntimeIdentity(signal), - qmdEnvironmentHash: this.buildQmdRuntimeEnvironmentHash(), - qmdIndexPath: this.indexPath, - searchMode: this.qmd.searchMode, - collections: this.qmdRuntimeCacheCollections(), - sources: this.qmdRuntimeCacheSources(), - }; - } - - protected async buildQmdMultiCollectionProbeCacheContext(): Promise { - return { - workspaceDir: this.workspaceDir, - agentId: this.agentId, - qmdCommand: this.qmd.command, - qmdVersion: await this.resolveQmdRuntimeIdentity(), - qmdEnvironmentHash: this.buildQmdRuntimeEnvironmentHash(), - qmdIndexPath: this.indexPath, - searchMode: this.qmd.searchMode, - sources: this.qmdRuntimeCacheSources(), - }; - } - - protected resolveQmdRuntimeIdentity(signal?: AbortSignal): Promise { - if (signal) { - return this.readQmdRuntimeIdentity(signal); - } - this.qmdRuntimeIdentityPromise ??= this.readQmdRuntimeIdentity(); - return this.qmdRuntimeIdentityPromise; - } - - protected async readQmdRuntimeIdentity(signal?: AbortSignal): Promise { - const commandIdentity = `command:${this.qmd.command}`; - try { - const result = await this.runQmd(["--version"], { - timeoutMs: Math.min(this.qmd.limits.timeoutMs, 2_000), - signal, - }); - const versionText = `${result.stdout}\n${result.stderr}`.trim(); - return versionText ? `${commandIdentity};version:${versionText}` : commandIdentity; - } catch { - if (signal?.aborted) { - throw asQmdAbortError(signal); - } - return commandIdentity; - } - } - - protected async ensureCollections(options?: { - force?: boolean; - debugContext?: QmdSearchRuntimeDebugContext; - parentSignal?: AbortSignal; - }): Promise { - await this.withQmdStoreWriteLease(async (lease) => { - await this.collectionController.ensureCollections({ ...options, lease }); - }, options?.parentSignal); - } - - protected async tryRepairNullByteCollections( - err: unknown, - reason: string, - lease: PluginStateLeaseContext, - ): Promise { - return await this.collectionController.tryRepairNullByteCollections(err, reason, lease); - } - - protected async tryRepairDuplicateDocumentConstraint( - err: unknown, - reason: string, - lease: PluginStateLeaseContext, - ): Promise { - return await this.collectionController.tryRepairDuplicateDocumentConstraint(err, reason, lease); - } - - protected computeManagedCollectionNames(): string[] { - const seen = new Set(); - const names: string[] = []; - for (const collection of this.qmd.collections) { - const name = collection.name?.trim(); - if (!name || seen.has(name)) { - continue; - } - seen.add(name); - names.push(name); - } - return names; - } - - protected async symlinkSharedModels(): Promise { - const defaultCacheHome = - process.env.XDG_CACHE_HOME || - (process.platform === "win32" ? process.env.LOCALAPPDATA : undefined) || - path.join(os.homedir(), ".cache"); - const defaultModelsDir = path.join(defaultCacheHome, "qmd", "models"); - const targetModelsDir = path.join(this.xdgCacheHome, "qmd", "models"); - try { - const stat = await fs.stat(defaultModelsDir).catch((err: unknown) => { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw err; - }); - if (!stat?.isDirectory()) { - return; - } - try { - await fs.lstat(targetModelsDir); - return; - } catch { - // Does not exist – proceed to create symlink. - } - try { - await fs.symlink(defaultModelsDir, targetModelsDir, "dir"); - } catch (symlinkErr: unknown) { - const code = (symlinkErr as NodeJS.ErrnoException).code; - if (process.platform === "win32" && (code === "EPERM" || code === "ENOTSUP")) { - await fs.symlink(defaultModelsDir, targetModelsDir, "junction"); - } else { - throw symlinkErr; - } - } - qmdManagerLog.debug(`symlinked qmd models: ${defaultModelsDir} → ${targetModelsDir}`); - } catch (err) { - // Non-fatal: if we can't symlink, qmd will fall back to downloading. - qmdManagerLog.warn(`failed to symlink qmd models directory: ${String(err)}`); - } - } - - protected async runQmd( - args: string[], - opts?: { timeoutMs?: number; discardOutput?: boolean; signal?: AbortSignal }, - ): Promise<{ stdout: string; stderr: string }> { - return await this.commands.run(args, opts); - } - - protected abstract runUpdate(reason: string, force?: boolean): Promise; - protected abstract ensureWatcher(): void; - protected abstract shouldScheduleEmbedTimer(): boolean; - protected abstract resolveEmbedStartupJitterMs(): number; - protected abstract withQmdStoreWriteLease( - task: (lease: PluginStateLeaseContext) => Promise, - parentSignal?: AbortSignal, - ): Promise; - protected abstract ensureDb(): SqliteDatabase; -} - -export function resolveQmdManagerRuntimeConfig( - cfg: OpenClawConfig, - agentId: string, -): QmdManagerRuntimeConfig { - return { - workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), - syncSettings: resolveMemorySearchSyncConfig(cfg, agentId), - contextLimits: resolveAgentContextLimits(cfg, agentId), - }; -} - -export type QmdManagerCreateParams = { - cfg: OpenClawConfig; - agentId: string; - resolved: ResolvedMemoryBackendConfig; - withLease: PluginStateLeaseRunner; - mode?: QmdManagerMode; - runtimeConfig?: QmdManagerRuntimeConfig; -}; - -export type { ManagedCollection }; diff --git a/extensions/memory-core/src/memory/qmd-manager-helpers.ts b/extensions/memory-core/src/memory/qmd-manager-helpers.ts deleted file mode 100644 index 53fd1801b432..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager-helpers.ts +++ /dev/null @@ -1,156 +0,0 @@ -import crypto from "node:crypto"; -import path from "node:path"; -import { resolveGlobalSingleton } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import type { ResolvedQmdConfig } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { isFutureDateTimestampMs, MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; - -export const SEARCH_PENDING_UPDATE_WAIT_MS = 500; -export const MAX_QMD_OUTPUT_CHARS = 200_000; -export const QMD_EMBED_BACKOFF_BASE_MS = 60_000; -export const QMD_EMBED_BACKOFF_MAX_MS = 60 * 60 * 1000; - -const QMD_EMBED_LEASE_MIN_WAIT_MS = 15 * 60 * 1000; -const QMD_WRITE_LEASE_MIN_WAIT_MS = 5 * 60 * 1000; -const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail"); -const QMD_UPDATE_QUEUE_KEY = Symbol.for("openclaw.qmdUpdateQueueState"); -const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([ - ".git", - ".cache", - "node_modules", - "vendor", - "dist", - "build", - ".pnpm-store", - ".venv", - "venv", - ".tox", - "__pycache__", -]); - -type QmdEmbedQueueState = { - tail: Promise; -}; - -type QmdUpdateQueueState = { - tails: Map>; -}; - -export function qmdUsesVectors(searchMode: ResolvedQmdConfig["searchMode"]): boolean { - return searchMode !== "search"; -} - -export function buildQmdProcessPath(rawPath: string | undefined): string { - const nodeBinDir = path.dirname(process.execPath); - const entries = rawPath?.split(path.delimiter).filter(Boolean) ?? []; - if (entries.includes(nodeBinDir)) { - return rawPath ?? nodeBinDir; - } - return [...entries, nodeBinDir].join(path.delimiter); -} - -export function normalizePositiveInteger(value: number | undefined, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) - ? Math.max(1, Math.floor(value)) - : fallback; -} - -export function getQmdEmbedQueueState(): QmdEmbedQueueState { - return resolveGlobalSingleton(QMD_EMBED_QUEUE_KEY, () => ({ - tail: Promise.resolve(), - })); -} - -export function getQmdUpdateQueueState(): QmdUpdateQueueState { - return resolveGlobalSingleton(QMD_UPDATE_QUEUE_KEY, () => ({ - tails: new Map>(), - })); -} - -export function normalizeHanBm25Query(query: string): string { - const trimmed = query.trim(); - // Keep Han/CJK BM25 queries intact so OpenClaw search semantics match direct qmd search. - return trimmed; -} - -export function parseQmdStatusVectorCount(raw: string): number | null { - for (const line of raw.split(/\r?\n/)) { - const match = line.match(/^\s*Vectors(?:\s*[:=]\s*|\s+)(\d+)\b/i); - if (match?.[1]) { - const count = Number.parseInt(match[1], 10); - if (Number.isFinite(count)) { - return count; - } - } - } - return null; -} - -export function resolveStableJitterMs(params: { seed: string; windowMs: number }): number { - if (params.windowMs <= 0) { - return 0; - } - const hash = crypto.createHash("sha256").update(params.seed).digest(); - const bucket = hash.readUInt32BE(0); - return bucket % (Math.floor(params.windowMs) + 1); -} - -function resolveQmdWriteLeaseOptions(expectedMs: number, minWaitMs: number) { - const expected = Math.max(1, expectedMs); - return { - leaseMs: Math.min(MAX_TIMER_TIMEOUT_MS, Math.max(minWaitMs, expected * 2)), - waitMs: Math.min(MAX_TIMER_TIMEOUT_MS, Math.max(minWaitMs, expected * 6)), - }; -} - -// Cross-process serialization for qmd embeds (heavy ML work, serialized globally). -export function resolveQmdEmbedLeaseOptions(embedTimeoutMs: number) { - return resolveQmdWriteLeaseOptions(embedTimeoutMs, QMD_EMBED_LEASE_MIN_WAIT_MS); -} - -// One per-agent write lease shared by the update and embed phases (both write the -// same qmd index.sqlite), so a foreground `memory search` dirty-sync and a -// background gateway update/embed never write the same store at once -// (writer-vs-writer SQLITE_BUSY, #66339). Sized to the slower of the two writes -// so a contending caller waits for the in-flight write instead of erroring. -export function resolveQmdStoreWriteLeaseOptions(updateTimeoutMs: number, embedTimeoutMs: number) { - return resolveQmdWriteLeaseOptions( - Math.max(updateTimeoutMs, embedTimeoutMs), - QMD_WRITE_LEASE_MIN_WAIT_MS, - ); -} - -function hasIgnoredMemoryWatchSegment(relativePath: string): boolean { - const parts = relativePath - .split(path.sep) - .map((segment) => normalizeLowercaseStringOrEmpty(segment)) - .filter(Boolean); - return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment)); -} - -export function shouldIgnoreMemoryWatchPath(watchPath: string, roots: readonly string[]): boolean { - const normalized = path.normalize(watchPath); - let matchedRelative: string | null = null; - let matchedRootLength = -1; - for (const watchRoot of roots) { - const normalizedRoot = path.normalize(watchRoot); - const relative = path.relative(normalizedRoot, normalized); - if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) { - if (normalizedRoot.length > matchedRootLength) { - matchedRelative = relative; - matchedRootLength = normalizedRoot.length; - } - } - } - if (matchedRelative !== null) { - if (matchedRelative === "") { - return false; - } - return hasIgnoredMemoryWatchSegment(matchedRelative); - } - return hasIgnoredMemoryWatchSegment(normalized); -} - -export function isEmbedBackoffActive(embedBackoffUntil: number | null): boolean { - return embedBackoffUntil !== null && isFutureDateTimestampMs(embedBackoffUntil); -} diff --git a/extensions/memory-core/src/memory/qmd-manager-io.ts b/extensions/memory-core/src/memory/qmd-manager-io.ts deleted file mode 100644 index c9f71321289a..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager-io.ts +++ /dev/null @@ -1,285 +0,0 @@ -import fs from "node:fs/promises"; -import readline from "node:readline"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - buildMemoryReadResult, - buildMemoryReadResultFromSlice, - DEFAULT_MEMORY_READ_LINES, - isFileMissingError, - type MemoryEmbeddingProbeResult, - type MemoryProviderStatus, - type MemoryReadResult, - type MemorySource, - statRegularFile, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/sqlite-runtime"; -import { isDefaultQmdMemoryPath as isDefaultMemoryPath } from "./qmd-document-resolver.js"; -import { qmdManagerLog } from "./qmd-manager-base.js"; -import { - normalizePositiveInteger, - parseQmdStatusVectorCount, - qmdUsesVectors, -} from "./qmd-manager-helpers.js"; -import { QmdManagerSearch } from "./qmd-manager-search.js"; - -type SqliteDatabase = import("node:sqlite").DatabaseSync; - -export abstract class QmdManagerIo extends QmdManagerSearch { - async readFile(params: { - relPath: string; - from?: number; - lines?: number; - }): Promise { - const relPath = params.relPath?.trim(); - if (!relPath) { - throw new Error("path required"); - } - const absPath = this.resolveReadPath(relPath); - if (!absPath.endsWith(".md")) { - throw new Error("path required"); - } - let statResult: Awaited>; - try { - statResult = await statRegularFile(absPath); - } catch (err) { - if (err instanceof Error && err.message === "path must be a regular file") { - throw new Error("path required", { cause: err }); - } - throw err; - } - if (statResult.missing) { - return { text: "", path: relPath }; - } - if (params.from !== undefined || params.lines !== undefined) { - const startLine = normalizePositiveInteger(params.from, 1); - const requestedCount = normalizePositiveInteger( - params.lines ?? DEFAULT_MEMORY_READ_LINES, - DEFAULT_MEMORY_READ_LINES, - ); - const partial = await this.readPartialText(absPath, startLine, requestedCount); - if (partial.missing) { - return { text: "", path: relPath }; - } - return buildMemoryReadResultFromSlice({ - selectedLines: partial.selectedLines, - relPath, - startLine, - moreSourceLinesRemain: partial.moreSourceLinesRemain, - maxChars: this.contextLimits?.memoryGetMaxChars, - suggestReadFallback: isDefaultMemoryPath(relPath), - }); - } - const full = await this.readFullText(absPath); - if (full.missing) { - return { text: "", path: relPath }; - } - return buildMemoryReadResult({ - content: full.text, - relPath, - from: params.from, - lines: params.lines, - defaultLines: DEFAULT_MEMORY_READ_LINES, - maxChars: this.contextLimits?.memoryGetMaxChars, - suggestReadFallback: isDefaultMemoryPath(relPath), - }); - } - - status(): MemoryProviderStatus { - const counts = this.readCounts(); - return { - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - files: counts.totalDocuments, - chunks: counts.totalDocuments, - dirty: this.dirty, - workspaceDir: this.workspaceDir, - dbPath: this.indexPath, - sources: Array.from(this.sources), - sourceCounts: counts.sourceCounts, - vector: { - enabled: qmdUsesVectors(this.qmd.searchMode), - available: this.vectorAvailable ?? undefined, - semanticAvailable: this.vectorAvailable ?? undefined, - loadError: this.vectorStatusDetail ?? undefined, - }, - batch: { - enabled: false, - failures: 0, - limit: 0, - wait: false, - concurrency: 0, - pollIntervalMs: 0, - timeoutMs: 0, - }, - custom: { - qmd: { - collections: this.qmd.collections.length, - lastUpdateAt: this.lastUpdateAt, - embedFailures: this.embedFailureCount, - embedBackoffUntil: this.embedBackoffUntil, - }, - }, - }; - } - - async probeEmbeddingAvailability(): Promise { - if (!qmdUsesVectors(this.qmd.searchMode)) { - return { ok: true, checked: false }; - } - const ok = await this.probeVectorAvailability(); - return { - ok, - error: ok ? undefined : (this.vectorStatusDetail ?? "QMD semantic vectors are unavailable"), - }; - } - - async probeVectorAvailability(): Promise { - if (!qmdUsesVectors(this.qmd.searchMode)) { - this.vectorAvailable = false; - this.vectorStatusDetail = null; - return false; - } - try { - const result = await this.runQmd(["status"], { - timeoutMs: this.qmd.limits.timeoutMs, - }); - const vectorCount = parseQmdStatusVectorCount(`${result.stdout}\n${result.stderr}`); - if (vectorCount === null) { - this.vectorAvailable = false; - this.vectorStatusDetail = "Could not determine QMD vector status from `qmd status`"; - return false; - } - this.vectorAvailable = vectorCount > 0; - this.vectorStatusDetail = - vectorCount > 0 - ? null - : "QMD index has 0 vectors; semantic search is unavailable until embeddings finish"; - return this.vectorAvailable; - } catch (err) { - const message = formatErrorMessage(err); - this.vectorAvailable = false; - this.vectorStatusDetail = `QMD status probe failed: ${message}`; - return false; - } - } - - protected async readPartialText( - absPath: string, - from?: number, - lines?: number, - ): Promise< - { missing: true } | { missing: false; selectedLines: string[]; moreSourceLinesRemain: boolean } - > { - const start = normalizePositiveInteger(from, 1); - const count = normalizePositiveInteger(lines, Number.MAX_SAFE_INTEGER); - let handle; - try { - handle = await fs.open(absPath); - } catch (err) { - if (isFileMissingError(err)) { - return { missing: true }; - } - throw err; - } - const stream = handle.createReadStream({ encoding: "utf-8" }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - const selected: string[] = []; - let index = 0; - let moreSourceLinesRemain = false; - try { - for await (const line of rl) { - index += 1; - if (index < start) { - continue; - } - if (selected.length >= count) { - moreSourceLinesRemain = true; - break; - } - selected.push(line); - } - } finally { - rl.close(); - await handle.close(); - } - return { - missing: false, - selectedLines: selected.slice(0, count), - moreSourceLinesRemain, - }; - } - - protected async readFullText( - absPath: string, - ): Promise<{ missing: true } | { missing: false; text: string }> { - try { - return { missing: false, text: await fs.readFile(absPath, "utf-8") }; - } catch (err) { - if (isFileMissingError(err)) { - return { missing: true }; - } - throw err; - } - } - - protected ensureDb(): SqliteDatabase { - if (this.db) { - return this.db; - } - this.db = openNodeSqliteDatabase(this.indexPath, { readOnly: true }); - // busy_timeout is per-connection; keep it below the write path because this - // synchronous read runs on the main thread and WAL readers rarely block. - this.db.exec("PRAGMA busy_timeout = 1000"); - return this.db; - } - - protected resolveReadPath(relPath: string): string { - return this.documentResolver.resolveReadPath(relPath); - } - - protected readCounts(): { - totalDocuments: number; - sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>; - } { - try { - const rows = this.ensureDb() - .prepare( - "SELECT collection, COUNT(*) as c FROM documents WHERE active = 1 GROUP BY collection", - ) - .all() as Array<{ collection: string; c: number }>; - const bySource = new Map(); - for (const source of this.sources) { - bySource.set(source, { files: 0, chunks: 0 }); - } - let total = 0; - for (const row of rows) { - const source = this.collectionRoots.get(row.collection)?.kind ?? "memory"; - const entry = bySource.get(source) ?? { files: 0, chunks: 0 }; - entry.files += row.c ?? 0; - entry.chunks += row.c ?? 0; - bySource.set(source, entry); - total += row.c ?? 0; - } - return { - totalDocuments: total, - sourceCounts: Array.from(bySource.entries()).map(([source, value]) => ({ - source, - files: value.files, - chunks: value.chunks, - })), - }; - } catch (err) { - qmdManagerLog.warn(`failed to read qmd index stats: ${String(err)}`); - return { - totalDocuments: 0, - sourceCounts: Array.from(this.sources).map((source) => ({ - source, - files: 0, - chunks: 0, - })), - }; - } - } -} diff --git a/extensions/memory-core/src/memory/qmd-manager-lifecycle.ts b/extensions/memory-core/src/memory/qmd-manager-lifecycle.ts deleted file mode 100644 index 74b1ac8e3720..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager-lifecycle.ts +++ /dev/null @@ -1,174 +0,0 @@ -import path from "node:path"; -import chokidar from "chokidar"; -import { qmdManagerLog, type ManagedCollection } from "./qmd-manager-base.js"; -import { shouldIgnoreMemoryWatchPath } from "./qmd-manager-helpers.js"; -import { QmdManagerSync } from "./qmd-manager-sync.js"; -import { countChokidarWatchedEntries, warnIfMemoryWatchPressureHigh } from "./watch-pressure.js"; -import { - recordMemoryWatchEventPath, - settleMemoryWatchEventPaths, - type MemoryWatchEventStats, -} from "./watch-settle.js"; - -export abstract class QmdManagerLifecycle extends QmdManagerSync { - private closePromise: Promise | null = null; - - protected ensureWatcher(): void { - if (!this.syncSettings?.watch || this.watcher || this.closed) { - return; - } - const watchPaths = new Set(); - const watchRoots = new Set(); - for (const collection of this.qmd.collections) { - if (collection.kind === "sessions") { - continue; - } - watchRoots.add(path.normalize(collection.path)); - watchPaths.add(this.resolveCollectionWatchPath(collection)); - } - if (watchPaths.size === 0) { - return; - } - const watchPathList = Array.from(watchPaths); - const startTime = Date.now(); - qmdManagerLog.info( - `qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`, - ); - const watchRootList = Array.from(watchRoots); - const watcher = chokidar.watch(watchPathList, { - ignoreInitial: true, - ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath, watchRootList), - }); - this.watcher = watcher; - const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => { - recordMemoryWatchEventPath(this.pendingWatchPaths, watchPath, stats); - this.dirty = true; - this.scheduleWatchSync(); - }; - watcher.on("add", markDirty); - watcher.on("change", markDirty); - watcher.on("unlink", markDirty); - watcher.on("error", (err) => { - const message = err instanceof Error ? err.message : String(err); - qmdManagerLog.warn(`qmd watcher error: ${message}`); - }); - watcher.once("ready", () => { - this.warnIfWatchPressure(countChokidarWatchedEntries(watcher)); - qmdManagerLog.info( - `qmd watcher ready for agent "${this.agentId}" paths=${watchPathList.length} durationMs=${Date.now() - startTime}`, - ); - }); - } - - protected warnIfWatchPressure(count: number): void { - warnIfMemoryWatchPressureHigh( - this.watchPressureWarning, - count, - "paths", - "Large QMD collections can make OpenClaw run out of file watchers or open files.", - "Remove large collections, or set memory.search.sync.watch to false and refresh memory manually.", - (message) => qmdManagerLog.warn(message), - ); - } - - protected resolveCollectionWatchPath(collection: ManagedCollection): string { - return path.join(path.normalize(collection.path), collection.pattern); - } - - protected scheduleWatchSync(): void { - if (!this.syncSettings?.watch) { - return; - } - if (this.watchTimer) { - clearTimeout(this.watchTimer); - } - this.watchTimer = setTimeout(() => { - this.watchTimer = null; - void (async () => { - if (this.closed) { - return; - } - if (!(await settleMemoryWatchEventPaths(this.pendingWatchPaths))) { - if (!this.closed) { - this.scheduleWatchSync(); - } - return; - } - if (this.closed) { - return; - } - await this.sync({ reason: "watch" }); - })().catch((err: unknown) => { - qmdManagerLog.warn(`qmd watch sync failed: ${String(err)}`); - }); - }, this.syncSettings.watchDebounceMs); - } - - protected async maybeWarmSession(sessionKey?: string): Promise { - if (this.mode === "cli" || !this.syncSettings?.onSessionStart) { - return; - } - const key = sessionKey?.trim() || ""; - if (!key || this.sessionWarm.has(key)) { - return; - } - this.sessionWarm.add(key); - void this.sync({ reason: "session-start" }).catch((err: unknown) => { - qmdManagerLog.warn(`qmd session-start sync failed: ${String(err)}`); - }); - } - - protected async maybeSyncDirtySearchState(): Promise { - if (this.mode === "cli" || !this.syncSettings?.onSearch || !this.dirty) { - return; - } - await this.sync({ reason: "search" }); - } - - async close(): Promise { - const existingClose = this.closePromise; - if (existingClose) { - await existingClose; - return; - } - const closeOperation = this.closeOnce(); - this.closePromise = closeOperation; - try { - await closeOperation; - } catch (err) { - if (this.closePromise === closeOperation) { - this.closePromise = null; - } - throw err; - } - } - - private async closeOnce(): Promise { - this.closed = true; - this.resolveCloseSignal(); - this.closeAbortController.abort(new Error("qmd manager closed")); - if (this.updateTimer) { - clearInterval(this.updateTimer); - this.updateTimer = null; - } - if (this.embedTimer) { - clearTimeout(this.embedTimer); - this.embedTimer = null; - } - if (this.watchTimer) { - clearTimeout(this.watchTimer); - this.watchTimer = null; - } - if (this.watcher) { - await this.watcher.close().catch(() => undefined); - this.watcher = null; - } - this.queuedForcedRuns = 0; - await this.pendingUpdate?.catch(() => undefined); - await this.queuedForcedUpdate?.catch(() => undefined); - if (this.db) { - this.db.close(); - this.db = null; - } - } -} diff --git a/extensions/memory-core/src/memory/qmd-manager-search-support.ts b/extensions/memory-core/src/memory/qmd-manager-search-support.ts deleted file mode 100644 index 744c529087b7..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager-search-support.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { truncateUtf16Safe } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { - deriveQmdScopeChannel, - deriveQmdScopeChatType, - isQmdScopeAllowed, - type QmdQueryResult, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; -import type { - MemorySearchResult, - MemorySearchRuntimeDebug, - MemorySource, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { copyQmdSessionArtifactHit } from "../qmd-session-artifacts.js"; -import type { QmdSearchRuntimeDebugContext } from "./qmd-collection-controller.js"; -import type { QmdCommandPhaseReporter } from "./qmd-command-client.js"; -import { - asQmdAbortError, - isMissingCollectionSearchError, - isUnsupportedQmdOptionError, -} from "./qmd-command-errors.js"; -import type { QmdDocLocation as DocLocation } from "./qmd-document-resolver.js"; -import { qmdManagerLog } from "./qmd-manager-base.js"; -import { normalizeHanBm25Query, SEARCH_PENDING_UPDATE_WAIT_MS } from "./qmd-manager-helpers.js"; -import { QmdManagerLifecycle } from "./qmd-manager-lifecycle.js"; -import { - clearQmdMultiCollectionProbeCache, - readQmdMultiCollectionProbeCache, - writeQmdMultiCollectionProbeCache, -} from "./qmd-runtime-cache.js"; - -const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/; - -export abstract class QmdManagerSearchSupport extends QmdManagerLifecycle { - protected recordSearchPlanDebug(params: { - debugContext: QmdSearchRuntimeDebugContext; - command: "query" | "search" | "vsearch"; - collectionNames: string[]; - collectionGroups: string[][]; - }): void { - const sources = uniqueValues( - params.collectionNames - .map((collectionName) => this.collectionRoots.get(collectionName)?.kind) - .filter((source): source is MemorySource => Boolean(source)), - ); - params.debugContext.searchPlan = { - command: params.command, - collectionCount: params.collectionNames.length, - groupCount: params.collectionGroups.length, - sources, - }; - } - - protected beginQmdSearchRuntimeDebug(): QmdSearchRuntimeDebugContext { - const debugContext: QmdSearchRuntimeDebugContext = {}; - const collectionValidation = this.collectionController.consumePendingValidationDebug(); - if (collectionValidation) { - debugContext.collectionValidation = collectionValidation; - } - return debugContext; - } - - protected consumeQmdRuntimeDebug( - debugContext: QmdSearchRuntimeDebugContext, - ): MemorySearchRuntimeDebug["qmd"] | undefined { - const debug: NonNullable = {}; - if (debugContext.collectionValidation) { - debug.collectionValidation = debugContext.collectionValidation; - } - if (debugContext.multiCollectionProbe) { - debug.multiCollectionProbe = debugContext.multiCollectionProbe; - } - if (debugContext.searchPlan) { - debug.searchPlan = debugContext.searchPlan; - } - return Object.keys(debug).length > 0 ? debug : undefined; - } - - protected async tryRepairMissingCollectionSearch( - err: unknown, - debugContext: QmdSearchRuntimeDebugContext, - parentSignal?: AbortSignal, - ): Promise { - if (!isMissingCollectionSearchError(err)) { - return false; - } - qmdManagerLog.warn( - "qmd search failed because a managed collection is missing; repairing collections and retrying once", - ); - await this.ensureCollections({ force: true, debugContext, parentSignal }); - return true; - } - - protected async runQmdSearch( - args: string[], - command: "query" | "search" | "vsearch", - signal?: AbortSignal, - reportCommandPhase?: QmdCommandPhaseReporter, - ): Promise { - return await this.commands.search(args, command, signal, reportCommandPhase); - } - - protected async resolveDocLocation( - docid?: string, - hints?: { preferredCollection?: string; preferredFile?: string }, - ): Promise { - return await this.documentResolver.resolveDocLocation(docid, hints); - } - - protected normalizeDocHints(hints?: { preferredCollection?: string; preferredFile?: string }): { - preferredCollection?: string; - preferredFile?: string; - } { - return this.documentResolver.normalizeDocHints(hints); - } - - protected toCollectionRelativePath(collection: string, filePath: string): string | null { - return this.documentResolver.toCollectionRelativePath(collection, filePath); - } - - protected resolveSnippetLines( - entry: QmdQueryResult, - snippet: string, - ): { startLine: number; endLine: number } { - const explicitStart = this.normalizeSnippetLine(entry.startLine); - const explicitEnd = this.normalizeSnippetLine(entry.endLine); - const headerLines = this.parseSnippetHeaderLines(snippet); - if (explicitStart !== undefined && explicitEnd !== undefined) { - return explicitStart <= explicitEnd - ? { startLine: explicitStart, endLine: explicitEnd } - : { startLine: explicitEnd, endLine: explicitStart }; - } - if (explicitStart !== undefined) { - if (headerLines) { - const width = headerLines.endLine - headerLines.startLine; - return { - startLine: explicitStart, - endLine: explicitStart + Math.max(0, width), - }; - } - return { startLine: explicitStart, endLine: explicitStart }; - } - if (explicitEnd !== undefined) { - if (headerLines) { - const width = headerLines.endLine - headerLines.startLine; - return { - startLine: Math.max(1, explicitEnd - Math.max(0, width)), - endLine: explicitEnd, - }; - } - return { startLine: explicitEnd, endLine: explicitEnd }; - } - if (headerLines) { - return headerLines; - } - return { startLine: 1, endLine: snippet.split("\n").length }; - } - - protected normalizeSnippetLine(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; - } - - protected parseSnippetHeaderLines( - snippet: string, - ): { startLine: number; endLine: number } | null { - const match = SNIPPET_HEADER_RE.exec(snippet); - if (!match) { - return null; - } - const start = Number(match[1]); - const count = Number(match[2]); - if (Number.isFinite(start) && Number.isFinite(count)) { - return { startLine: start, endLine: start + count - 1 }; - } - return null; - } - - protected logScopeDenied(sessionKey?: string): void { - const channel = deriveQmdScopeChannel(sessionKey) ?? "unknown"; - const chatType = deriveQmdScopeChatType(sessionKey) ?? "unknown"; - const key = sessionKey?.trim() || ""; - qmdManagerLog.warn( - `qmd search denied by scope (channel=${channel}, chatType=${chatType}, session=${key})`, - ); - } - - protected isScopeAllowed(sessionKey?: string): boolean { - return isQmdScopeAllowed(this.qmd.scope, sessionKey); - } - - protected clampResultsByInjectedChars(results: MemorySearchResult[]): MemorySearchResult[] { - const budget = this.qmd.limits.maxInjectedChars; - if (!budget || budget <= 0) { - return results; - } - let remaining = budget; - const clamped: MemorySearchResult[] = []; - for (const entry of results) { - if (remaining <= 0) { - break; - } - const snippet = entry.snippet ?? ""; - if (snippet.length <= remaining) { - clamped.push(entry); - remaining -= snippet.length; - } else { - const trimmed = truncateUtf16Safe(snippet, remaining); - clamped.push(copyQmdSessionArtifactHit(entry, { ...entry, snippet: trimmed })); - break; - } - } - return clamped; - } - - protected diversifyResultsBySource( - results: MemorySearchResult[], - limit: number, - ): MemorySearchResult[] { - const target = Math.max(0, limit); - if (target <= 0) { - return []; - } - if (results.length <= 1) { - return results.slice(0, target); - } - const bySource = new Map(); - for (const entry of results) { - const list = bySource.get(entry.source) ?? []; - list.push(entry); - bySource.set(entry.source, list); - } - if (!bySource.has("sessions") || !bySource.has("memory")) { - return results.slice(0, target); - } - const sourceOrder = Array.from(bySource.entries()) - .toSorted((a, b) => (b[1][0]?.score ?? 0) - (a[1][0]?.score ?? 0)) - .map(([source]) => source); - const diversified: MemorySearchResult[] = []; - while (diversified.length < target) { - let emitted = false; - for (const source of sourceOrder) { - const next = bySource.get(source)?.shift(); - if (!next) { - continue; - } - diversified.push(next); - emitted = true; - if (diversified.length >= target) { - break; - } - } - if (!emitted) { - break; - } - } - return diversified; - } - - protected async waitForPendingUpdateBeforeSearch(): Promise { - const pending = this.pendingUpdate; - if (!pending) { - return; - } - // Release the losing timer when the pending update settles first. - let timeout: ReturnType | undefined; - const wait = new Promise((resolve) => { - timeout = setTimeout(resolve, SEARCH_PENDING_UPDATE_WAIT_MS); - }); - await Promise.race([pending.catch(() => undefined), wait]).finally(() => clearTimeout(timeout)); - } - - protected async resolveCollectionSearchGroups( - collectionNames: string[], - signal?: AbortSignal, - debugContext?: QmdSearchRuntimeDebugContext, - ): Promise { - if (collectionNames.length <= 1) { - return [collectionNames]; - } - if (!(await this.supportsQmdMultiCollectionFilters(signal, debugContext))) { - return collectionNames.map((collectionName) => [collectionName]); - } - return this.groupCollectionNamesBySource(collectionNames); - } - - protected async supportsQmdMultiCollectionFilters( - signal?: AbortSignal, - debugContext?: QmdSearchRuntimeDebugContext, - ): Promise { - if (signal?.aborted) { - throw asQmdAbortError(signal); - } - if (this.multiCollectionFilterSupported !== null) { - return this.multiCollectionFilterSupported; - } - const startedAt = Date.now(); - const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext(); - const cached = await readQmdMultiCollectionProbeCache(cacheContext); - if (cached.state === "hit") { - this.multiCollectionFilterSupported = cached.value.multiCollectionProbe.supported; - if (debugContext) { - debugContext.multiCollectionProbe = { - cacheState: "hit", - elapsedMs: Math.max(0, Date.now() - startedAt), - supported: this.multiCollectionFilterSupported, - }; - } - return this.multiCollectionFilterSupported; - } - try { - const result = await this.runQmd(["--help"], { - timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000), - signal, - }); - const helpText = `${result.stdout}\n${result.stderr}`; - this.multiCollectionFilterSupported = - /\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText); - const wroteCache = await writeQmdMultiCollectionProbeCache( - cacheContext, - this.multiCollectionFilterSupported, - ); - if (debugContext) { - debugContext.multiCollectionProbe = { - cacheState: wroteCache ? "write" : "error", - elapsedMs: Math.max(0, Date.now() - startedAt), - supported: this.multiCollectionFilterSupported, - }; - } - } catch (err) { - // Cancellation says nothing about QMD capabilities; leave the probe uncached. - if (signal?.aborted) { - throw asQmdAbortError(signal); - } - this.multiCollectionFilterSupported = false; - if (debugContext) { - debugContext.multiCollectionProbe = { - cacheState: "error", - elapsedMs: Math.max(0, Date.now() - startedAt), - supported: false, - }; - } - qmdManagerLog.debug(`qmd multi-collection filter probe failed: ${String(err)}`); - } - return this.multiCollectionFilterSupported; - } - - protected async markQmdMultiCollectionFiltersUnsupported( - debugContext: QmdSearchRuntimeDebugContext, - ): Promise { - const startedAt = Date.now(); - const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext(); - this.multiCollectionFilterSupported = false; - await clearQmdMultiCollectionProbeCache(cacheContext); - const wroteCache = await writeQmdMultiCollectionProbeCache(cacheContext, false); - debugContext.multiCollectionProbe = { - cacheState: wroteCache ? "write" : "error", - elapsedMs: Math.max(0, Date.now() - startedAt), - supported: false, - }; - } - - protected async runQueryAcrossCollectionGroups( - query: string, - limit: number, - collectionGroups: string[][], - command: "query" | "search" | "vsearch", - signal?: AbortSignal, - reportCommandPhase?: QmdCommandPhaseReporter, - ): Promise { - qmdManagerLog.debug( - `qmd ${command} multi-source collection grouping active (${collectionGroups.length} groups)`, - ); - const bestByResultKey = new Map(); - for (const collectionNames of collectionGroups) { - const args = this.buildSearchArgs(command, query, limit); - args.push(...this.buildCollectionFilterArgs(collectionNames)); - const parsed = await this.runQmdSearch(args, command, signal, reportCommandPhase); - for (const entry of parsed) { - const defaultCollection = collectionNames.length === 1 ? collectionNames[0] : undefined; - const normalizedHints = this.normalizeDocHints({ - preferredCollection: entry.collection ?? defaultCollection, - preferredFile: entry.file, - }); - const normalizedDocId = - typeof entry.docid === "string" && entry.docid.trim().length > 0 - ? entry.docid - : undefined; - const withCollection = { - ...entry, - docid: normalizedDocId, - collection: normalizedHints.preferredCollection ?? entry.collection ?? defaultCollection, - file: normalizedHints.preferredFile ?? entry.file, - } satisfies QmdQueryResult; - const resultKey = this.buildQmdResultKey(withCollection); - if (!resultKey) { - continue; - } - const prev = bestByResultKey.get(resultKey); - const prevScore = typeof prev?.score === "number" ? prev.score : Number.NEGATIVE_INFINITY; - const nextScore = - typeof withCollection.score === "number" - ? withCollection.score - : Number.NEGATIVE_INFINITY; - if (!prev || nextScore > prevScore) { - bestByResultKey.set(resultKey, withCollection); - } - } - } - return [...bestByResultKey.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0)); - } - - protected groupCollectionNamesBySource(collectionNames: string[]): string[][] { - const groups = new Map(); - for (const collectionName of collectionNames) { - const source = this.collectionRoots.get(collectionName)?.kind ?? collectionName; - const group = groups.get(source) ?? []; - group.push(collectionName); - groups.set(source, group); - } - return [...groups.values()]; - } - - protected buildQmdResultKey(entry: QmdQueryResult): string | null { - if (typeof entry.docid === "string" && entry.docid.trim().length > 0) { - return `docid:${entry.docid}`; - } - const hints = this.normalizeDocHints({ - preferredCollection: entry.collection, - preferredFile: entry.file, - }); - if (!hints.preferredCollection || !hints.preferredFile) { - return null; - } - const collectionRelativePath = this.toCollectionRelativePath( - hints.preferredCollection, - hints.preferredFile, - ); - return collectionRelativePath - ? `file:${hints.preferredCollection}:${collectionRelativePath}` - : null; - } - - protected listManagedCollectionNames(sources?: MemorySource[]): string[] { - if (!sources?.length) { - return this.managedCollectionNames; - } - const allowed = new Set(sources); - return this.managedCollectionNames.filter((name) => { - const source = this.collectionRoots.get(name)?.kind; - return source ? allowed.has(source) : false; - }); - } - - protected buildCollectionFilterArgs(collectionNames: string[]): string[] { - return collectionNames.filter(Boolean).flatMap((name) => ["-c", name]); - } - - protected buildSearchArgs( - command: "query" | "search" | "vsearch", - query: string, - limit: number, - ): string[] { - const normalizedQuery = command === "search" ? normalizeHanBm25Query(query) : query; - if (command === "query") { - const args = ["query", normalizedQuery, "--json", "-n", String(limit)]; - if (this.qmd.searchMode === "query" && this.qmd.rerank === false) { - args.push("--no-rerank"); - } - return args; - } - return [command, normalizedQuery, "--json", "-n", String(limit)]; - } - - protected isUnsupportedQmdOptionError(err: unknown): boolean { - return isUnsupportedQmdOptionError(err); - } -} diff --git a/extensions/memory-core/src/memory/qmd-manager-search.ts b/extensions/memory-core/src/memory/qmd-manager-search.ts deleted file mode 100644 index d910d086d350..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager-search.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { truncateUtf16Safe } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import type { QmdQueryResult } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; -import type { - MemoryEntryProvenance, - MemorySearchResult, - MemorySearchRuntimeDebug, - MemorySource, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { - attachQmdSessionArtifactHit, - resolveQmdSessionArtifactIdentity, -} from "../qmd-session-artifacts.js"; -import { asQmdAbortError, isMissingCollectionSearchError } from "./qmd-command-errors.js"; -import { qmdManagerLog } from "./qmd-manager-base.js"; -import { QmdManagerSearchSupport } from "./qmd-manager-search-support.js"; -import { - MEMORY_SEARCH_DEADLINE_CONTROL, - type MemorySearchDeadlineControlOptions, -} from "./search-deadline.js"; - -function resolveQmdSearchProvenance( - resultPath: string, - source: MemorySource, - observedAt: number, -): MemoryEntryProvenance { - const normalizedPath = resultPath.replaceAll("\\", "/").toLowerCase(); - const isSystemArtifact = - normalizedPath === "dreams.md" || - normalizedPath.startsWith("memory/dreaming/") || - normalizedPath.startsWith("memory/.dreams/"); - const isConsolidatedMemory = normalizedPath === "memory.md"; - return { - // QMD does not carry flush-recorded per-line provenance. Keep daily notes - // and extra paths untrusted until that metadata is available on results. - originClass: - source === "sessions" - ? "untrusted" - : isSystemArtifact - ? "system" - : isConsolidatedMemory - ? "agent" - : "untrusted", - sessionKind: "unknown", - observedAt, - }; -} - -export abstract class QmdManagerSearch extends QmdManagerSearchSupport { - async search( - query: string, - opts?: { - maxResults?: number; - minScore?: number; - sessionKey?: string; - qmdSearchModeOverride?: "query" | "search" | "vsearch"; - onDebug?: (debug: MemorySearchRuntimeDebug) => void; - sources?: MemorySource[]; - /** - * Caller-owned cancellation. When the caller stops waiting, abort kills - * the in-flight qmd subprocess instead of leaving it orphaned. - */ - signal?: AbortSignal; - } & MemorySearchDeadlineControlOptions, - ): Promise { - if (!this.isScopeAllowed(opts?.sessionKey)) { - this.logScopeDenied(opts?.sessionKey); - return []; - } - const searchSignal = opts?.signal; - const reportCommandPhase = opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]; - if (searchSignal?.aborted) { - throw asQmdAbortError(searchSignal); - } - const debugContext = this.beginQmdSearchRuntimeDebug(); - const trimmed = query.trim(); - if (!trimmed) { - return []; - } - await this.maybeWarmSession(opts?.sessionKey); - await this.maybeSyncDirtySearchState(); - await this.waitForPendingUpdateBeforeSearch(); - const resultLimit = Math.min( - this.qmd.limits.maxResults, - opts?.maxResults ?? this.qmd.limits.maxResults, - ); - // Remember-only session exports are indexed for trusted recall but are not - // part of ordinary manager searches. Explicit export keeps its existing - // ordinary-access behavior; trusted recall always passes sources=sessions. - const requestedSources = opts?.sources?.length - ? uniqueValues(opts.sources) - : this.qmd.sessions.readable - ? undefined - : (["memory"] satisfies MemorySource[]); - const collectionNames = this.listManagedCollectionNames(requestedSources); - const limit = resultLimit; - if (collectionNames.length === 0) { - qmdManagerLog.warn("qmd query skipped: no managed collections configured"); - return []; - } - const qmdSearchCommand = opts?.qmdSearchModeOverride ?? this.qmd.searchMode; - let effectiveSearchMode: "query" | "search" | "vsearch" = qmdSearchCommand; - let searchFallbackReason: string | undefined; - const explicitSearchTool = this.qmd.searchTool; - const mcporterEnabled = this.qmd.mcporter.enabled; - const runSearchAttempt = async ( - allowMissingCollectionRepair: boolean, - ): Promise => { - let attemptedCombinedCollectionFilter = false; - try { - if (mcporterEnabled) { - const minScore = opts?.minScore ?? 0; - const toolSelection = explicitSearchTool - ? { tool: explicitSearchTool, explicitToolOverride: true as const } - : { - tool: this.commands.resolveMcpTool(qmdSearchCommand), - explicitToolOverride: false as const, - }; - const searchParams = { - ...toolSelection, - searchCommand: qmdSearchCommand, - query: trimmed, - limit, - minScore, - signal: searchSignal, - reportCommandPhase, - }; - if (collectionNames.length > 1) { - return await this.commands.searchAcrossCollections({ - ...searchParams, - collectionNames, - }); - } - return await this.commands.searchViaMcporter({ - ...searchParams, - mcporter: this.qmd.mcporter, - collection: collectionNames[0], - timeoutMs: this.qmd.limits.timeoutMs, - }); - } - const collectionGroups = await this.resolveCollectionSearchGroups( - collectionNames, - searchSignal, - debugContext, - ); - this.recordSearchPlanDebug({ - debugContext, - command: qmdSearchCommand, - collectionNames, - collectionGroups, - }); - attemptedCombinedCollectionFilter = collectionGroups.some((group) => group.length > 1); - if (collectionGroups.length > 1) { - return await this.runQueryAcrossCollectionGroups( - trimmed, - limit, - collectionGroups, - qmdSearchCommand, - searchSignal, - reportCommandPhase, - ); - } - const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit); - args.push(...this.buildCollectionFilterArgs(collectionGroups[0] ?? collectionNames)); - return await this.runQmdSearch(args, qmdSearchCommand, searchSignal, reportCommandPhase); - } catch (err) { - if (allowMissingCollectionRepair && isMissingCollectionSearchError(err)) { - throw err; - } - if ( - !mcporterEnabled && - qmdSearchCommand !== "query" && - this.isUnsupportedQmdOptionError(err) - ) { - if (attemptedCombinedCollectionFilter) { - await this.markQmdMultiCollectionFiltersUnsupported(debugContext); - } - effectiveSearchMode = "query"; - searchFallbackReason = "unsupported-search-flags"; - qmdManagerLog.warn( - `qmd ${qmdSearchCommand} does not support configured flags; retrying search with qmd query`, - ); - try { - const collectionGroups = await this.resolveCollectionSearchGroups( - collectionNames, - searchSignal, - debugContext, - ); - this.recordSearchPlanDebug({ - debugContext, - command: "query", - collectionNames, - collectionGroups, - }); - if (collectionGroups.length > 1) { - return await this.runQueryAcrossCollectionGroups( - trimmed, - limit, - collectionGroups, - "query", - searchSignal, - reportCommandPhase, - ); - } - const fallbackArgs = this.buildSearchArgs("query", trimmed, limit); - fallbackArgs.push( - ...this.buildCollectionFilterArgs(collectionGroups[0] ?? collectionNames), - ); - return await this.runQmdSearch(fallbackArgs, "query", searchSignal, reportCommandPhase); - } catch (fallbackErr) { - qmdManagerLog.warn(`qmd query fallback failed: ${String(fallbackErr)}`); - throw fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr)); - } - } - const label = mcporterEnabled ? "mcporter/qmd" : `qmd ${qmdSearchCommand}`; - qmdManagerLog.warn(`${label} failed: ${String(err)}`); - throw err instanceof Error ? err : new Error(String(err)); - } - }; - - let parsed: QmdQueryResult[]; - try { - parsed = await runSearchAttempt(true); - } catch (err) { - if (!(await this.tryRepairMissingCollectionSearch(err, debugContext, searchSignal))) { - throw err instanceof Error ? err : new Error(String(err)); - } - parsed = await runSearchAttempt(false); - } - const results: MemorySearchResult[] = []; - for (const entry of parsed) { - const docHints = this.normalizeDocHints({ - preferredCollection: entry.collection, - preferredFile: entry.file, - }); - const doc = await this.resolveDocLocation(entry.docid, docHints); - if (!doc) { - continue; - } - const snippet = truncateUtf16Safe(entry.snippet ?? "", this.qmd.limits.maxSnippetChars); - const lines = this.resolveSnippetLines(entry, snippet); - const score = typeof entry.score === "number" ? entry.score : 0; - const minScore = opts?.minScore ?? 0; - if (score < minScore) { - continue; - } - const result: MemorySearchResult = { - path: doc.rel, - startLine: lines.startLine, - endLine: lines.endLine, - score, - snippet, - source: doc.source, - provenance: resolveQmdSearchProvenance(doc.rel, doc.source, doc.observedAt), - }; - // QMD snippets are lossy presentation excerpts, not authoritative entries. - // Leave project identity neutral until QMD can return real indexed metadata; - // inferring from nearby comment text can attribute an adjacent entry. - const artifactIdentity = - doc.source === "sessions" - ? resolveQmdSessionArtifactIdentity({ - artifactPath: doc.collectionRelativePath, - collection: doc.collection, - docid: entry.docid?.trim() || undefined, - indexPath: this.indexPath, - searchPath: doc.rel, - }) - : null; - results.push( - artifactIdentity ? attachQmdSessionArtifactHit(result, artifactIdentity) : result, - ); - } - opts?.onDebug?.({ - backend: "qmd", - configuredMode: qmdSearchCommand, - effectiveMode: effectiveSearchMode, - fallback: searchFallbackReason, - qmd: this.consumeQmdRuntimeDebug(debugContext), - }); - let ranked = results; - if (opts?.sources?.length) { - const allow = new Set(opts.sources); - ranked = results.filter((result) => allow.has(result.source)); - } - return this.clampResultsByInjectedChars(this.diversifyResultsBySource(ranked, resultLimit)); - } -} diff --git a/extensions/memory-core/src/memory/qmd-manager-sync.retry.test.ts b/extensions/memory-core/src/memory/qmd-manager-sync.retry.test.ts deleted file mode 100644 index f98a5b2438e5..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager-sync.retry.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { PluginStateLeaseContext } from "openclaw/plugin-sdk/plugin-state-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { QmdMemoryManager } from "./qmd-manager.js"; - -type QmdRetryHarness = { - runQmdUpdateOnce: (reason: string, lease: PluginStateLeaseContext) => Promise; - runQmdUpdateWithRetry: (reason: string, lease: PluginStateLeaseContext) => Promise; -}; - -function createQmdRetryHarness( - runQmdUpdateOnce: QmdRetryHarness["runQmdUpdateOnce"], -): QmdRetryHarness { - // The real inherited retry path needs only its update operation and lease; - // creating a full manager would start an unrelated external qmd process. - const manager = Object.create(QmdMemoryManager.prototype) as QmdRetryHarness; - manager.runQmdUpdateOnce = runQmdUpdateOnce; - return manager; -} - -function createQmdRetryLease(signal: AbortSignal): PluginStateLeaseContext { - return { signal, assertOwned: vi.fn() }; -} - -describe("QMD update retry policy", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("retries boot-time SQLite lock failures on the canonical exponential schedule", async () => { - vi.useFakeTimers(); - const runQmdUpdateOnce = vi - .fn() - .mockRejectedValueOnce(new Error("SQLITE_BUSY: database is locked")) - .mockRejectedValueOnce(new Error("SQLITE_BUSY: database is locked")) - .mockResolvedValueOnce(undefined); - const controller = new AbortController(); - const lease = createQmdRetryLease(controller.signal); - const manager = createQmdRetryHarness(runQmdUpdateOnce); - - const pending = manager.runQmdUpdateWithRetry("boot", lease); - await vi.advanceTimersByTimeAsync(0); - expect(runQmdUpdateOnce).toHaveBeenCalledOnce(); - - await vi.advanceTimersByTimeAsync(499); - expect(runQmdUpdateOnce).toHaveBeenCalledOnce(); - - await vi.advanceTimersByTimeAsync(1); - expect(runQmdUpdateOnce).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(999); - expect(runQmdUpdateOnce).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(1); - await expect(pending).resolves.toBeUndefined(); - expect(runQmdUpdateOnce).toHaveBeenCalledTimes(3); - expect(vi.getTimerCount()).toBe(0); - }); - - it("preserves the lease abort reason and clears a pending retry timer", async () => { - vi.useFakeTimers(); - const runQmdUpdateOnce = vi - .fn() - .mockRejectedValue(new Error("SQLITE_BUSY: database is locked")); - const controller = new AbortController(); - const leaseLost = new Error("qmd write lease was lost"); - const manager = createQmdRetryHarness(runQmdUpdateOnce); - - const pending = manager.runQmdUpdateWithRetry( - "boot:startup", - createQmdRetryLease(controller.signal), - ); - await vi.advanceTimersByTimeAsync(0); - expect(vi.getTimerCount()).toBe(1); - - controller.abort(leaseLost); - - await expect(pending).rejects.toBe(leaseLost); - expect(runQmdUpdateOnce).toHaveBeenCalledOnce(); - expect(vi.getTimerCount()).toBe(0); - }); - - it("preserves lease cancellation when it races with a retryable update failure", async () => { - const controller = new AbortController(); - const leaseLost = new Error("qmd write lease was lost"); - const runQmdUpdateOnce = vi.fn(async () => { - controller.abort(leaseLost); - throw new Error("SQLITE_BUSY: database is locked"); - }); - const manager = createQmdRetryHarness(runQmdUpdateOnce); - - await expect( - manager.runQmdUpdateWithRetry("boot", createQmdRetryLease(controller.signal)), - ).rejects.toBe(leaseLost); - - expect(runQmdUpdateOnce).toHaveBeenCalledOnce(); - }); - - it("does not replay failed manual updates or permanent boot failures", async () => { - const scenarios = [ - { reason: "manual", error: new Error("SQLITE_BUSY: database is locked") }, - { reason: "boot", error: new Error("qmd collection is invalid") }, - ]; - - for (const { reason, error } of scenarios) { - const runQmdUpdateOnce = vi - .fn() - .mockRejectedValue(error); - const manager = createQmdRetryHarness(runQmdUpdateOnce); - - await expect( - manager.runQmdUpdateWithRetry(reason, createQmdRetryLease(new AbortController().signal)), - ).rejects.toBe(error); - expect(runQmdUpdateOnce).toHaveBeenCalledOnce(); - } - }); -}); diff --git a/extensions/memory-core/src/memory/qmd-manager-sync.ts b/extensions/memory-core/src/memory/qmd-manager-sync.ts deleted file mode 100644 index 1f37edb0ea4d..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager-sync.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import type { MemorySyncParams } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime"; -import { - PluginStateLeaseError, - type PluginStateLeaseContext, -} from "openclaw/plugin-sdk/plugin-state-runtime"; -import { retryAsync } from "openclaw/plugin-sdk/retry-runtime"; -import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { asQmdAbortError, isSqliteBusyError } from "./qmd-command-errors.js"; -import { QmdManagerBase, qmdManagerLog } from "./qmd-manager-base.js"; -import { - getQmdEmbedQueueState, - getQmdUpdateQueueState, - isEmbedBackoffActive, - qmdUsesVectors, - QMD_EMBED_BACKOFF_BASE_MS, - QMD_EMBED_BACKOFF_MAX_MS, - resolveQmdEmbedLeaseOptions, - resolveQmdStoreWriteLeaseOptions, - resolveStableJitterMs, -} from "./qmd-manager-helpers.js"; - -export abstract class QmdManagerSync extends QmdManagerBase { - async sync(params?: MemorySyncParams): Promise { - if ( - params?.sessions?.some((session) => session.sessionId.trim().length > 0) || - params?.archiveFiles?.some((sessionFile) => sessionFile.trim().length > 0) - ) { - qmdManagerLog.debug("qmd sync ignoring targeted session hint; running regular update"); - } - if (params?.progress) { - params.progress({ completed: 0, total: 1, label: "Updating QMD index…" }); - } - await this.runUpdate(params?.reason ?? "manual", params?.force); - if (params?.progress) { - params.progress({ completed: 1, total: 1, label: "QMD index updated" }); - } - } - - protected async runUpdate( - reason: string, - force?: boolean, - opts?: { fromForcedQueue?: boolean }, - ): Promise { - if (this.closed) { - return; - } - if (this.pendingUpdate) { - if (force) { - return this.enqueueForcedUpdate(reason); - } - return this.pendingUpdate; - } - if (this.queuedForcedUpdate && !opts?.fromForcedQueue) { - if (force) { - return this.enqueueForcedUpdate(reason); - } - return this.queuedForcedUpdate; - } - if (this.shouldSkipUpdate(force)) { - return; - } - const run = async () => { - const startTime = Date.now(); - let updatePublished = false; - qmdManagerLog.debug( - `qmd sync started for agent "${this.agentId}" reason=${reason} force=${force === true}`, - ); - try { - await this.withQmdUpdateQueue(async (lease) => { - const { signal } = lease; - if (this.closed) { - return; - } - if (this.sessionExporter) { - await this.exportSessions(lease); - this.throwIfAborted(signal); - } - await this.runQmdUpdateWithRetry(reason, lease); - updatePublished = true; - if (this.sessionExporter) { - this.throwIfAborted(signal); - this.refreshSessionArtifactDocIds(lease); - } - }); - } catch (err) { - if (err instanceof PluginStateLeaseError && this.shouldPreserveLeaseRetry(err)) { - this.dirty = true; - if (updatePublished && qmdUsesVectors(this.qmd.searchMode)) { - this.embedLeaseRetryPending = true; - } - } - throw err; - } - if (this.closed) { - return; - } - this.dirty = false; - if (this.shouldRunEmbed(force)) { - try { - // Wait for embed capacity before taking the per-agent write lease. The - // lease should protect active qmd writes only, not time spent queued - // behind unrelated agents' embeds. - const embedded = await this.withQmdEmbedQueue(async () => { - await this.withQmdGlobalEmbedLease((globalLease) => - this.withQmdStoreWriteLease(async (lease) => { - globalLease.assertOwned(); - lease.assertOwned(); - await this.runQmd(["embed"], { - timeoutMs: this.qmd.update.embedTimeoutMs, - discardOutput: true, - signal: lease.signal, - }); - }, globalLease.signal), - ); - }); - if (!embedded) { - return; - } - this.lastEmbedAt = Date.now(); - this.embedLeaseRetryPending = false; - this.embedBackoffUntil = null; - this.embedFailureCount = 0; - } catch (err) { - if (err instanceof PluginStateLeaseError) { - if (this.shouldPreserveLeaseRetry(err)) { - // The update already published documents. Keep both the dirty-sync - // trigger and embed intent so contention cannot strand them unembedded. - this.dirty = true; - this.embedLeaseRetryPending = true; - } - throw err; - } - this.noteEmbedFailure(reason, err); - } - } - if (this.closed) { - return; - } - this.lastUpdateAt = Date.now(); - this.documentResolver.clearCache(); - qmdManagerLog.info( - `qmd sync completed for agent "${this.agentId}" reason=${reason} durationMs=${Date.now() - startTime}`, - ); - }; - this.pendingUpdate = run().finally(() => { - this.pendingUpdate = null; - }); - await this.pendingUpdate; - } - - protected async runQmdUpdateWithRetry( - reason: string, - lease: PluginStateLeaseContext, - ): Promise { - const { signal } = lease; - const isBootRun = reason === "boot" || reason.startsWith("boot:"); - await retryAsync(() => this.runQmdUpdateOnce(reason, lease), { - attempts: isBootRun ? 3 : 1, - minDelayMs: 500, - maxDelayMs: 1_000, - shouldRetry: (err) => { - if (!this.isRetryableUpdateError(err)) { - return false; - } - // A lease can be revoked while its update fails. Preserve the owning - // lease's abort reason instead of leaking the stale update failure. - this.throwIfAborted(signal); - return true; - }, - onRetry: ({ attempt, maxAttempts, err }) => { - qmdManagerLog.warn( - `qmd update retry ${attempt}/${maxAttempts - 1} after failure (${reason}): ${String(err)}`, - ); - }, - sleep: async (delayMs) => { - try { - await sleepWithAbort(delayMs, signal); - } catch (err) { - // Lease owners need their original abort reason, not the retry - // scheduler's generic abort wrapper. - if (signal.aborted) { - throw asQmdAbortError(signal); - } - throw err; - } - }, - }); - } - - protected async runQmdUpdateOnce(reason: string, lease: PluginStateLeaseContext): Promise { - const { signal } = lease; - try { - lease.assertOwned(); - await this.runQmd(["update"], { - timeoutMs: this.qmd.update.updateTimeoutMs, - discardOutput: true, - signal, - }); - } catch (err) { - if ( - !(await this.tryRepairNullByteCollections(err, reason, lease)) && - !(await this.tryRepairDuplicateDocumentConstraint(err, reason, lease)) - ) { - throw err; - } - lease.assertOwned(); - await this.runQmd(["update"], { - timeoutMs: this.qmd.update.updateTimeoutMs, - discardOutput: true, - signal, - }); - } - } - - protected isRetryableUpdateError(err: unknown): boolean { - if (isSqliteBusyError(err)) { - return true; - } - const message = formatErrorMessage(err); - const normalized = normalizeLowercaseStringOrEmpty(message); - return normalized.includes("timed out"); - } - - protected throwIfAborted(signal: AbortSignal): void { - if (signal.aborted) { - throw asQmdAbortError(signal); - } - } - - protected shouldRunEmbed(force?: boolean): boolean { - if (!qmdUsesVectors(this.qmd.searchMode)) { - return false; - } - const now = Date.now(); - if (isEmbedBackoffActive(this.embedBackoffUntil)) { - return false; - } - const embedIntervalMs = this.qmd.update.embedIntervalMs; - return ( - this.embedLeaseRetryPending || - Boolean(force) || - this.lastEmbedAt === null || - (embedIntervalMs > 0 && now - this.lastEmbedAt > embedIntervalMs) - ); - } - - protected shouldPreserveLeaseRetry(err: PluginStateLeaseError): boolean { - return ( - !this.closed && - err.code !== "PLUGIN_STATE_LEASE_ABORTED" && - err.code !== "PLUGIN_STATE_LEASE_INVALID_INPUT" - ); - } - - protected shouldScheduleEmbedTimer(): boolean { - if (!qmdUsesVectors(this.qmd.searchMode)) { - return false; - } - const embedIntervalMs = this.qmd.update.embedIntervalMs; - if (embedIntervalMs <= 0) { - return false; - } - const updateIntervalMs = this.qmd.update.intervalMs; - return updateIntervalMs <= 0 || updateIntervalMs > embedIntervalMs; - } - - protected resolveEmbedStartupJitterMs(): number { - const windowMs = this.qmd.update.embedIntervalMs; - if (windowMs <= 0) { - return 0; - } - const customCollections = this.qmd.collections - .filter((collection) => collection.kind === "custom") - .map((collection) => `${collection.path}\u0000${collection.pattern}`) - .toSorted() - .join("\u0001"); - if (!customCollections) { - return 0; - } - return resolveStableJitterMs({ - seed: `${this.agentId}:${customCollections}`, - windowMs, - }); - } - - protected async withQmdEmbedQueue(task: () => Promise): Promise { - const queue = getQmdEmbedQueueState(); - const previous = queue.tail; - let releaseCurrent!: () => void; - const current = new Promise((resolve) => { - releaseCurrent = resolve; - }); - queue.tail = previous.then( - () => current, - () => current, - ); - try { - const waitResult = await Promise.race([ - previous.then( - () => "ready" as const, - () => "ready" as const, - ), - this.closeSignal.then(() => "closed" as const), - ]); - if (waitResult === "closed") { - return false; - } - await task(); - return true; - } finally { - releaseCurrent(); - } - } - - protected async withQmdGlobalEmbedLease( - task: (lease: PluginStateLeaseContext) => Promise, - ): Promise { - return await this.withLease( - { - namespace: "qmd", - key: "embed", - database: { scope: "shared" }, - ...resolveQmdEmbedLeaseOptions(this.qmd.update.embedTimeoutMs), - signal: this.closeAbortController.signal, - }, - async (lease) => await task(lease), - ); - } - - protected async withQmdStoreWriteLease( - task: (lease: PluginStateLeaseContext) => Promise, - parentSignal?: AbortSignal, - ): Promise { - // SQLite is the sole coordinator; never dual-lock sidecars. One per-agent - // lease guards every write to index.sqlite while agents remain parallel. - return await this.withLease( - { - namespace: "qmd", - key: "write", - database: { scope: "agent", agentId: this.agentId }, - ...resolveQmdStoreWriteLeaseOptions( - this.qmd.update.updateTimeoutMs, - this.qmd.update.embedTimeoutMs, - ), - signal: parentSignal - ? AbortSignal.any([this.closeAbortController.signal, parentSignal]) - : this.closeAbortController.signal, - }, - async (lease) => await task(lease), - ); - } - - protected async withQmdUpdateQueue( - task: (lease: PluginStateLeaseContext) => Promise, - ): Promise { - const queue = getQmdUpdateQueueState(); - const key = this.qmdDir; - const previous = queue.tails.get(key) ?? Promise.resolve(); - let releaseCurrent!: () => void; - const current = new Promise((resolve) => { - releaseCurrent = resolve; - }); - const next = previous.then( - () => current, - () => current, - ); - queue.tails.set(key, next); - try { - const waitResult = await Promise.race([ - previous.then( - () => "ready" as const, - () => "ready" as const, - ), - this.closeSignal.then(() => "closed" as const), - ]); - if (waitResult === "closed") { - return undefined as T; - } - // The in-process queue is keyed per store; the per-agent write lease also - // serializes separate processes and embed writes targeting this index. - return await this.withQmdStoreWriteLease(task); - } finally { - releaseCurrent(); - void next.finally(() => { - if (queue.tails.get(key) === next) { - queue.tails.delete(key); - } - }); - } - } - - protected noteEmbedFailure(reason: string, err: unknown): void { - this.embedFailureCount += 1; - const delayMs = Math.min( - QMD_EMBED_BACKOFF_MAX_MS, - QMD_EMBED_BACKOFF_BASE_MS * 2 ** Math.max(0, this.embedFailureCount - 1), - ); - this.embedBackoffUntil = resolveExpiresAtMsFromDurationMs(delayMs) ?? null; - qmdManagerLog.warn( - `qmd embed failed (${reason}): ${String(err)}; backing off for ${Math.ceil(delayMs / 1000)}s`, - ); - } - - protected enqueueForcedUpdate(reason: string): Promise { - this.queuedForcedRuns += 1; - if (!this.queuedForcedUpdate) { - this.queuedForcedUpdate = this.drainForcedUpdates(reason).finally(() => { - this.queuedForcedUpdate = null; - }); - } - return this.queuedForcedUpdate; - } - - protected async drainForcedUpdates(reason: string): Promise { - await this.pendingUpdate?.catch(() => undefined); - while (!this.closed && this.queuedForcedRuns > 0) { - this.queuedForcedRuns -= 1; - await this.runUpdate(`${reason}:queued`, true, { fromForcedQueue: true }); - } - } - - protected shouldSkipUpdate(force?: boolean): boolean { - if (force) { - return false; - } - const debounceMs = this.qmd.update.debounceMs; - if (debounceMs <= 0 || !this.lastUpdateAt) { - return false; - } - return Date.now() - this.lastUpdateAt < debounceMs; - } - - protected async exportSessions(lease: PluginStateLeaseContext): Promise { - await this.sessionExporter?.exportSessions(lease); - } - - protected refreshSessionArtifactDocIds(lease: PluginStateLeaseContext): void { - this.sessionExporter?.refreshArtifactDocIds(lease); - } -} diff --git a/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts b/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts deleted file mode 100644 index ca74d1d18768..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts +++ /dev/null @@ -1,436 +0,0 @@ -// Memory Core tests cover qmd manager.slugified paths plugin behavior. -import { EventEmitter } from "node:events"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; -import type { Mock } from "vitest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({ - logWarnMock: vi.fn(), - logDebugMock: vi.fn(), - logInfoMock: vi.fn(), -})); - -type MockStream = EventEmitter & { setEncoding: ReturnType }; - -interface MockChild extends EventEmitter { - stdout: MockStream; - stderr: MockStream; - kill: (signal?: NodeJS.Signals) => void; - closeWith: (code?: number | null) => void; -} - -function createMockChild(params?: { autoClose?: boolean }): MockChild { - const stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - const stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - const child = new EventEmitter() as unknown as MockChild; - child.stdout = stdout; - child.stderr = stderr; - child.closeWith = (code = 0) => { - child.emit("close", code); - }; - child.kill = () => {}; - if (params?.autoClose !== false) { - queueMicrotask(() => { - child.emit("close", 0); - }); - } - return child; -} - -function emitAndClose(child: MockChild, stream: "stdout" | "stderr", data: string, code = 0) { - queueMicrotask(() => { - child[stream].emit("data", data); - child.closeWith(code); - }); -} - -vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async () => { - const actual = await vi.importActual< - typeof import("openclaw/plugin-sdk/memory-core-host-engine-foundation") - >("openclaw/plugin-sdk/memory-core-host-engine-foundation"); - return { - ...actual, - createSubsystemLogger: () => { - const logger = { - warn: logWarnMock, - debug: logDebugMock, - info: logInfoMock, - child: () => logger, - }; - return logger; - }, - }; -}); - -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: vi.fn(), - }; -}); - -import { spawn as mockedSpawn } from "node:child_process"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { resolveMemoryBackendConfig } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { QmdMemoryManager } from "./qmd-manager.js"; - -const spawnMock = mockedSpawn as unknown as Mock; -const originalQmdStateDir = process.env.OPENCLAW_STATE_DIR; -const TEST_OBSERVED_AT = "2026-07-01T10:00:00.000Z"; -const withLease = async ( - options: { signal?: AbortSignal }, - run: (lease: { signal: AbortSignal; assertOwned: () => void }) => Promise, -) => - await run({ - signal: options.signal ?? new AbortController().signal, - assertOwned: vi.fn(), - }); - -function setQmdStateDir(stateDir: string): void { - Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); -} - -function restoreQmdStateDir(): void { - if (originalQmdStateDir === undefined) { - Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); - } else { - Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalQmdStateDir); - } -} - -describe("QmdMemoryManager slugified path resolution", () => { - let tmpRoot: string; - let workspaceDir: string; - let stateDir: string; - let cfg: OpenClawConfig; - const agentId = "main"; - const openManagers = new Set(); - - function trackManager(manager: T): T { - if (manager) { - openManagers.add(manager); - } - return manager; - } - - async function createManager(params?: { cfg?: OpenClawConfig }) { - const cfgToUse = params?.cfg ?? cfg; - const resolved = resolveMemoryBackendConfig({ cfg: cfgToUse, agentId }); - const manager = trackManager( - await QmdMemoryManager.create({ - cfg: cfgToUse, - agentId, - resolved, - withLease, - mode: "status", - }), - ); - if (!manager) { - throw new Error("manager missing"); - } - return { manager, resolved }; - } - - function installIndexedPathStub(params: { - manager: QmdMemoryManager; - collection: string; - normalizedPath: string; - actualPath?: string; - exactPaths?: string[]; - allPaths?: string[]; - }) { - const inner = params.manager as unknown as { - db: { - prepare: (query: string) => { - get: (...args: unknown[]) => unknown; - all: (...args: unknown[]) => unknown; - }; - close: () => void; - }; - }; - inner.db = { - prepare: (query: string) => ({ - get: (...args: unknown[]) => { - if (query.includes("collection = ? AND active = 1 AND path = ?")) { - expect(args[0]).toBe(params.collection); - const requestedPath = args[1]; - expect(typeof requestedPath).toBe("string"); - const exactCandidates = new Set([ - ...(params.exactPaths ?? []), - ...(params.actualPath ? [params.actualPath] : []), - ]); - return typeof requestedPath === "string" && exactCandidates.has(requestedPath) - ? { path: requestedPath } - : undefined; - } - throw new Error(`unexpected sqlite query: ${query}`); - }, - all: (...args: unknown[]) => { - if (query.includes("collection = ? AND path = ? AND active = 1")) { - expect(args).toEqual([params.collection, params.normalizedPath]); - return (params.exactPaths ?? []).map((pathValue) => ({ - path: pathValue, - modified_at: TEST_OBSERVED_AT, - })); - } - if (query.includes("collection = ? AND active = 1")) { - expect(args).toEqual([params.collection]); - return (params.allPaths ?? [params.actualPath]).map((pathValue) => ({ - path: pathValue, - modified_at: TEST_OBSERVED_AT, - })); - } - throw new Error(`unexpected sqlite query: ${query}`); - }, - }), - close: () => {}, - }; - } - - beforeEach(async () => { - spawnMock.mockReset(); - spawnMock.mockImplementation(() => createMockChild()); - logWarnMock.mockClear(); - logDebugMock.mockClear(); - logInfoMock.mockClear(); - - tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-slugified-")); - workspaceDir = path.join(tmpRoot, "workspace"); - stateDir = path.join(tmpRoot, "state"); - await fs.mkdir(workspaceDir, { recursive: true }); - setQmdStateDir(stateDir); - - cfg = { - agents: { - list: [{ id: agentId, default: true, workspace: workspaceDir }], - }, - memory: { - backend: "qmd", - qmd: { - includeDefaultMemory: false, - update: { interval: "0s", debounceMs: 60_000, onBoot: false }, - paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], - }, - }, - } as OpenClawConfig; - }); - - afterEach(async () => { - await Promise.all( - Array.from(openManagers, async (manager) => { - await manager.close(); - }), - ); - openManagers.clear(); - await fs.rm(tmpRoot, { recursive: true, force: true }); - restoreQmdStateDir(); - }); - - it("maps slugified workspace qmd URIs back to the indexed filesystem path", async () => { - const actualRelative = "extra-docs/Category/Sub Category/Topic Name/Topic Name.md"; - const actualFile = path.join(workspaceDir, actualRelative); - await fs.mkdir(path.dirname(actualFile), { recursive: true }); - await fs.writeFile(actualFile, "line-1\nline-2\nline-3", "utf-8"); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - const child = createMockChild({ autoClose: false }); - emitAndClose( - child, - "stdout", - JSON.stringify([ - { - file: "qmd://workspace-main/extra-docs/category/sub-category/topic-name/topic-name.md", - score: 0.73, - snippet: "@@ -2,1\nline-2", - }, - ]), - ); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - installIndexedPathStub({ - manager, - collection: "workspace-main", - normalizedPath: "extra-docs/category/sub-category/topic-name/topic-name.md", - actualPath: actualRelative, - }); - - const results = await manager.search("line-2", { - sessionKey: "agent:main:slack:dm:u123", - }); - expect(results).toEqual([ - { - path: actualRelative, - startLine: 2, - endLine: 2, - score: 0.73, - snippet: "@@ -2,1\nline-2", - source: "memory", - provenance: { - originClass: "untrusted", - sessionKind: "unknown", - observedAt: Date.parse(TEST_OBSERVED_AT), - }, - }, - ]); - - const result = expectDefined(results[0], "slugified QMD search result"); - await expect(manager.readFile({ relPath: result.path })).resolves.toEqual({ - path: actualRelative, - text: "line-1\nline-2\nline-3", - from: 1, - lines: 3, - }); - }); - - it("maps slugified extra collection qmd URIs back to qmd// paths", async () => { - const extraRoot = path.join(tmpRoot, "vault"); - await fs.mkdir(extraRoot, { recursive: true }); - cfg = { - ...cfg, - memory: { - backend: "qmd", - qmd: { - includeDefaultMemory: false, - update: { interval: "0s", debounceMs: 60_000, onBoot: false }, - paths: [{ path: extraRoot, pattern: "**/*.md", name: "vault" }], - }, - }, - } as OpenClawConfig; - - const actualRelative = "Topics/Sub Category/Topic Name.md"; - const actualFile = path.join(extraRoot, actualRelative); - await fs.mkdir(path.dirname(actualFile), { recursive: true }); - await fs.writeFile(actualFile, "vault memory", "utf-8"); - - const { manager, resolved } = await createManager({ cfg }); - const collectionName = - resolved.qmd?.collections.find((collection) => collection.path === extraRoot)?.name ?? - "vault"; - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - const child = createMockChild({ autoClose: false }); - emitAndClose( - child, - "stdout", - JSON.stringify([ - { - file: `qmd://${collectionName}/topics/sub-category/topic-name.md`, - score: 0.81, - snippet: "@@ -1,1\nvault memory", - }, - ]), - ); - return child; - } - return createMockChild(); - }); - installIndexedPathStub({ - manager, - collection: collectionName, - normalizedPath: "topics/sub-category/topic-name.md", - actualPath: actualRelative, - }); - - const results = await manager.search("vault memory", { - sessionKey: "agent:main:slack:dm:u123", - }); - expect(results).toEqual([ - { - path: `qmd/${collectionName}/${actualRelative}`, - startLine: 1, - endLine: 1, - score: 0.81, - snippet: "@@ -1,1\nvault memory", - source: "memory", - provenance: { - originClass: "untrusted", - sessionKind: "unknown", - observedAt: Date.parse(TEST_OBSERVED_AT), - }, - }, - ]); - - const result = expectDefined(results[0], "vault QMD search result"); - await expect(manager.readFile({ relPath: result.path })).resolves.toEqual({ - path: `qmd/${collectionName}/${actualRelative}`, - text: "vault memory", - from: 1, - lines: 1, - }); - }); - - it("prefers an exact indexed path over normalized slug recovery", async () => { - const exactRelative = "notes/topic-name.md"; - const slugCollisionRelative = "notes/Topic Name.md"; - const exactFile = path.join(workspaceDir, exactRelative); - const collisionFile = path.join(workspaceDir, slugCollisionRelative); - await fs.mkdir(path.dirname(exactFile), { recursive: true }); - await fs.writeFile(exactFile, "exact slugified path", "utf-8"); - await fs.writeFile(collisionFile, "mixed case path", "utf-8"); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - const child = createMockChild({ autoClose: false }); - emitAndClose( - child, - "stdout", - JSON.stringify([ - { - file: "qmd://workspace-main/notes/topic-name.md", - score: 0.79, - snippet: "@@ -1,1\nexact slugified path", - }, - ]), - ); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - installIndexedPathStub({ - manager, - collection: "workspace-main", - normalizedPath: exactRelative, - exactPaths: [exactRelative], - allPaths: [exactRelative, slugCollisionRelative], - }); - - const results = await manager.search("exact slugified path", { - sessionKey: "agent:main:slack:dm:u123", - }); - expect(results).toEqual([ - { - path: exactRelative, - startLine: 1, - endLine: 1, - score: 0.79, - snippet: "@@ -1,1\nexact slugified path", - source: "memory", - provenance: { - originClass: "untrusted", - sessionKind: "unknown", - observedAt: Date.parse(TEST_OBSERVED_AT), - }, - }, - ]); - - const result = expectDefined(results[0], "exact QMD search result"); - await expect(manager.readFile({ relPath: result.path })).resolves.toEqual({ - path: exactRelative, - text: "exact slugified path", - from: 1, - lines: 1, - }); - }); -}); diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts deleted file mode 100644 index e08fd159ca34..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ /dev/null @@ -1,6471 +0,0 @@ -// Memory Core tests cover qmd manager plugin behavior. -import { EventEmitter } from "node:events"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { DatabaseSync } from "node:sqlite"; -import { setTimeout as scheduleNativeTimeout } from "node:timers"; -import { expectDefined } from "@openclaw/normalization-core"; -import type { - PluginStateLeaseContext, - PluginStateLeaseOptions, - PluginStateLeaseRunner, -} from "openclaw/plugin-sdk/plugin-state-runtime"; -import { withMockedWindowsPlatform } from "openclaw/plugin-sdk/test-node-mocks"; -import type { Mock } from "vitest"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({ - logWarnMock: vi.fn(), - logDebugMock: vi.fn(), - logInfoMock: vi.fn(), -})); -const { watchMock } = vi.hoisted(() => ({ - watchMock: vi.fn(() => { - const watcher = new EventEmitter() as EventEmitter & { - watchedEntries: Record; - }; - watcher.watchedEntries = {}; - return Object.assign(watcher, { - close: vi.fn(async () => undefined), - getWatched: vi.fn(() => watcher.watchedEntries), - }); - }), -})); -const { withLeaseMock } = vi.hoisted(() => { - const implementation: PluginStateLeaseRunner = async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => - await run({ - signal: options.signal ?? new AbortController().signal, - assertOwned: vi.fn(), - }); - return { - withLeaseMock: vi.fn(implementation) as Mock & PluginStateLeaseRunner, - }; -}); -const MEMORY_EMBEDDING_PROVIDERS_KEY = Symbol.for("openclaw.memoryEmbeddingProviders"); -const MCPORTER_STATE_KEY = Symbol.for("openclaw.mcporterState"); -const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail"); -const QMD_UPDATE_QUEUE_KEY = Symbol.for("openclaw.qmdUpdateQueueState"); -const BUILT_IN_WATCH_DEBOUNCE_MS = 1_500; - -type WatchOptions = { - ignored?: (watchPath: string) => boolean; -}; - -type LeaseCall = Parameters; - -type QmdTestConfig = NonNullable["qmd"]> & { - mcporter?: { enabled?: boolean; serverName?: string; startDaemon?: boolean }; - update?: { - commandTimeoutMs?: number; - debounceMs?: number; - embedInterval?: string; - embedTimeoutMs?: number; - interval?: string; - onBoot?: boolean; - startup?: "off" | "idle" | "blocking"; - startupDelayMs?: number; - updateTimeoutMs?: number; - waitForBootSync?: boolean; - }; -}; - -type QmdConfigOverrides = { - agents?: OpenClawConfig["agents"]; - search?: NonNullable["search"]> & { - sync?: { watch?: boolean; onSessionStart?: boolean; onSearch?: boolean }; - }; -}; - -type MockStream = EventEmitter & { setEncoding: ReturnType }; - -interface MockChild extends EventEmitter { - exitCode: number | null; - signalCode: NodeJS.Signals | null; - stdout: MockStream; - stderr: MockStream; - kill: (signal?: NodeJS.Signals) => boolean; - closeWith: (code?: number | null) => void; -} - -function createMockChild(params?: { autoClose?: boolean; closeDelayMs?: number }): MockChild { - const stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - const stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - const child: MockChild = Object.assign(new EventEmitter(), { - exitCode: null, - signalCode: null, - stdout, - stderr, - closeWith: (code: number | null = 0) => { - child.exitCode = code; - child.emit("close", code, child.signalCode); - }, - kill: (signal: NodeJS.Signals = "SIGTERM") => { - child.signalCode = signal; - // Let timeout rejection win in tests that simulate hung QMD commands. - return true; - }, - }); - if (params?.autoClose !== false) { - const delayMs = params?.closeDelayMs ?? 0; - if (delayMs <= 0) { - queueMicrotask(() => { - child.emit("close", 0); - }); - } else { - scheduleNativeTimeout(() => { - child.emit("close", 0); - }, delayMs); - } - } - return child; -} - -function emitAndClose(child: MockChild, stream: "stdout" | "stderr", data: string, code = 0) { - queueMicrotask(() => { - child[stream].emit("data", data); - child.closeWith(code); - }); -} - -function makeQmdChild( - overrides: { - stream?: "stdout" | "stderr"; - data?: string; - code?: number; - } = {}, -): MockChild { - const output = { stream: "stdout" as const, data: "[]", code: 0, ...overrides }; - const child = createMockChild({ autoClose: false }); - emitAndClose(child, output.stream, output.data, output.code); - return child; -} - -function makeMcporterChild(results: unknown[] = []): MockChild { - return makeQmdChild({ data: JSON.stringify({ results }) }); -} - -function makeQmdResults(...results: unknown[]): MockChild { - return makeQmdChild({ data: JSON.stringify(results) }); -} - -async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise { - const startedAt = Date.now(); - while (!predicate()) { - if (Date.now() - startedAt > timeoutMs) { - throw new Error("Timed out waiting for condition"); - } - await new Promise((resolve) => { - scheduleNativeTimeout(resolve, 10); - }); - } -} - -function isMcporterCommand(cmd: unknown): boolean { - if (typeof cmd !== "string") { - return false; - } - return /(^|[\\/])mcporter(?:\.cmd)?$/i.test(cmd); -} - -function firstWatchOptions(): WatchOptions { - const call = watchMock.mock.calls[0] as unknown as [string[], WatchOptions] | undefined; - if (!call) { - throw new Error("Expected watch call"); - } - return call[1]; -} - -function firstWatchPaths(): string[] { - const call = watchMock.mock.calls[0] as unknown as [string[], WatchOptions] | undefined; - if (!call) { - throw new Error("Expected watch call"); - } - return call[0]; -} - -function firstEmbedLeaseCall(): LeaseCall { - const call = withLeaseMock.mock.calls.find( - (entry) => entry[0].database.scope === "shared" && entry[0].key === "embed", - ) as LeaseCall | undefined; - if (!call) { - throw new Error("Expected qmd embed lease call"); - } - return call; -} - -function writeLeaseCalls(): LeaseCall[] { - return withLeaseMock.mock.calls.filter( - (entry) => entry[0].database.scope === "agent" && entry[0].key === "write", - ) as LeaseCall[]; -} - -function firstWriteLeaseCall(): LeaseCall { - const call = writeLeaseCalls()[0]; - if (!call) { - throw new Error("Expected qmd store write lease call"); - } - return call; -} - -vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async () => { - const actual = await vi.importActual< - typeof import("openclaw/plugin-sdk/memory-core-host-engine-foundation") - >("openclaw/plugin-sdk/memory-core-host-engine-foundation"); - return { - ...actual, - createSubsystemLogger: () => { - const logger = { - warn: logWarnMock, - debug: logDebugMock, - info: logInfoMock, - child: () => logger, - }; - return logger; - }, - }; -}); - -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: vi.fn(), - }; -}); - -vi.mock("chokidar", () => ({ - default: { watch: watchMock }, - watch: watchMock, -})); - -import { spawn as mockedSpawn } from "node:child_process"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { - type MemorySearchRuntimeDebug, - requireNodeSqlite, - resolveMemoryBackendConfig, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; -import { PluginStateLeaseError } from "openclaw/plugin-sdk/plugin-state-runtime"; -import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; -import { formatSessionTranscriptMemoryHitKey } from "openclaw/plugin-sdk/session-transcript-hit"; -import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; -import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; -import { configureMemoryCoreDreamingState } from "../dreaming-state.js"; -import { resolveQmdSessionArtifactIdentity } from "../qmd-session-artifacts.js"; -import { - configureMemoryCoreDreamingStateForTests, - resetMemoryCoreDreamingStateForTests, -} from "../test-helpers.js"; -import { parseListedQmdCollections, parseShownQmdCollection } from "./qmd-collection-metadata.js"; -import { QmdMemoryManager, resolveQmdMcporterSearchProcessTimeoutMs } from "./qmd-manager.js"; -import { MEMORY_SEARCH_DEADLINE_CONTROL } from "./search-deadline.js"; - -const spawnMock = mockedSpawn as unknown as Mock; -const originalPath = process.env.PATH; -const originalPathExt = process.env.PATHEXT; -const originalWindowsPath = process.env.Path; -const originalQmdStateDir = process.env.OPENCLAW_STATE_DIR; - -function expectedQmdProvenance(originClass: "agent" | "untrusted") { - return { - originClass, - sessionKind: "unknown", - observedAt: expect.any(Number), - }; -} - -function setQmdStateDir(stateDir: string): void { - Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); -} - -async function seedQmdSessionTranscript(params: { - agentId: string; - content: string; - sessionId: string; - stateDir: string; - sessionKey?: string; - timestamp?: number | string; -}): Promise { - const sessionsDir = path.join(params.stateDir, "agents", params.agentId, "sessions"); - const storePath = path.join(sessionsDir, "sessions.json"); - const sessionKey = params.sessionKey ?? `agent:${params.agentId}:qmd:${params.sessionId}`; - const timestamp = - typeof params.timestamp === "number" - ? params.timestamp - : params.timestamp - ? Date.parse(params.timestamp) - : Date.now(); - await fs.mkdir(sessionsDir, { recursive: true }); - await upsertSessionEntry({ - agentId: params.agentId, - sessionKey, - storePath, - entry: { - sessionId: params.sessionId, - updatedAt: timestamp, - }, - }); - await appendSessionTranscriptMessageByIdentity({ - agentId: params.agentId, - sessionId: params.sessionId, - sessionKey, - storePath, - message: { - role: "user", - content: params.content, - timestamp, - }, - }); -} - -function restoreQmdStateDir(): void { - if (originalQmdStateDir === undefined) { - Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); - } else { - Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalQmdStateDir); - } -} - -describe("QmdMemoryManager", () => { - let fixtureRoot: string; - let fixtureCount = 0; - let tmpRoot: string; - let workspaceDir: string; - let stateDir: string; - let cfg: OpenClawConfig; - const agentId = "main"; - const openManagers = new Set(); - let embedStartupJitterSpy: { mockRestore: () => void } | null = null; - - function seedMemoryEmbeddingProviders(): void { - (globalThis as Record)[MEMORY_EMBEDDING_PROVIDERS_KEY] = new Map([ - [ - "openai", - { - adapter: { - id: "openai", - defaultModel: "text-embedding-3-small", - transport: "remote", - create: async () => ({ provider: null }), - }, - }, - ], - ]); - } - - function trackManager(manager: T): T { - if (manager) { - openManagers.add(manager); - } - return manager; - } - - function requireValue(value: T | null | undefined, message: string): T { - if (value == null) { - throw new Error(message); - } - return value; - } - - function requireArgAfter(args: readonly string[], flag: string): string { - const index = args.indexOf(flag); - if (index < 0) { - throw new Error(`expected ${flag} argument`); - } - return expectDefined(args[index + 1], `${flag} argument value`); - } - - function mockMessages(mock: Mock): string[] { - return mock.mock.calls.map((call: unknown[]) => String(call[0])); - } - - function qmdCommandCalls(): string[][] { - return spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]); - } - - function countQmdCommand(predicate: (args: string[]) => boolean): number { - return qmdCommandCalls().filter(predicate).length; - } - - function expectMockMessageContains(mock: Mock, text: string): void { - expect(mockMessages(mock).join("\n")).toContain(text); - } - - function expectMockMessageNotContains(mock: Mock, text: string): void { - expect(mockMessages(mock).join("\n")).not.toContain(text); - } - - function configureQmd(qmd: QmdTestConfig = {}, overrides: QmdConfigOverrides = {}): void { - cfg = { - ...cfg, - ...(overrides.agents ? { agents: overrides.agents } : {}), - memory: { - backend: "qmd", - qmd: { - includeDefaultMemory: false, - update: { interval: "0s", debounceMs: 60_000, onBoot: false }, - paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], - ...qmd, - }, - ...(overrides.search ? { search: overrides.search } : {}), - }, - } as OpenClawConfig; - } - - it("caps mcporter search process timeout grace", () => { - expect(resolveQmdMcporterSearchProcessTimeoutMs(1_000)).toBe(5_000); - expect(resolveQmdMcporterSearchProcessTimeoutMs(10_000)).toBe(12_000); - expect(resolveQmdMcporterSearchProcessTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe( - MAX_TIMER_TIMEOUT_MS, - ); - expect(resolveQmdMcporterSearchProcessTimeoutMs(Number.MAX_VALUE)).toBe(MAX_TIMER_TIMEOUT_MS); - expect(resolveQmdMcporterSearchProcessTimeoutMs(MAX_TIMER_TIMEOUT_MS - 100)).toBe( - MAX_TIMER_TIMEOUT_MS, - ); - }); - - it("reuses persisted collection validation across transient cli managers", async () => { - await configureMemoryCoreDreamingStateForTests(); - const first = await createManager({ mode: "cli" }); - await first.manager.close(); - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); - - spawnMock.mockClear(); - const second = await createManager({ mode: "cli" }); - await second.manager.close(); - - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(0); - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "show")).toBe(0); - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(0); - }); - - it("serializes same-agent initialization through cache publication", async () => { - await configureMemoryCoreDreamingStateForTests(); - let tail = Promise.resolve(); - let active = 0; - let maxActive = 0; - withLeaseMock.mockImplementation( - async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => { - const context = { - signal: options.signal ?? new AbortController().signal, - assertOwned: vi.fn(), - }; - if (options.database.scope !== "agent" || options.key !== "write") { - return (await run(context)) as T; - } - const execute = async () => { - active += 1; - maxActive = Math.max(maxActive, active); - try { - return (await run(context)) as T; - } finally { - active -= 1; - } - }; - const result = tail.then(execute, execute); - tail = result.then( - () => undefined, - () => undefined, - ); - return await result; - }, - ); - - const [first, second] = await Promise.all([ - createManager({ mode: "cli" }), - createManager({ mode: "cli" }), - ]); - - expect(maxActive).toBe(1); - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); - await first.manager.close(); - await second.manager.close(); - }); - - it("does not cache incomplete collection validation", async () => { - await configureMemoryCoreDreamingStateForTests(); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "add") { - return makeQmdChild({ stream: "stderr", data: "permission denied", code: 1 }); - } - return createMockChild(); - }); - - const first = await createManager({ mode: "cli" }); - await first.manager.close(); - - spawnMock.mockClear(); - spawnMock.mockImplementation(() => createMockChild()); - const second = await createManager({ mode: "cli" }); - await second.manager.close(); - - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1); - }); - - it("runs collection validation when the runtime cache store is unavailable", async () => { - configureMemoryCoreDreamingState(() => { - throw new Error("state store unavailable"); - }); - try { - const manager = await createManager({ mode: "cli" }); - await manager.manager.close(); - } finally { - await configureMemoryCoreDreamingStateForTests(); - } - - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1); - }); - - it("reports collection validation debug only once per validation run", async () => { - await configureMemoryCoreDreamingStateForTests(); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") { - return makeQmdChild(); - } - return createMockChild(); - }); - const { manager } = await createManager({ mode: "cli" }); - const firstDebug: MemorySearchRuntimeDebug[] = []; - const secondDebug: MemorySearchRuntimeDebug[] = []; - - await manager.search("fact", { - sessionKey: "agent:main:slack:dm:u123", - onDebug: (entry) => { - firstDebug.push(entry); - }, - }); - await manager.search("fact again", { - sessionKey: "agent:main:slack:dm:u123", - onDebug: (entry) => { - secondDebug.push(entry); - }, - }); - - expect(firstDebug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("write"); - expect(secondDebug.at(-1)?.qmd?.collectionValidation).toBeUndefined(); - }); - - it("misses collection validation cache when managed collection config changes", async () => { - await configureMemoryCoreDreamingStateForTests(); - const first = await createManager({ mode: "cli" }); - await first.manager.close(); - - const otherWorkspaceDir = path.join(tmpRoot, "other-workspace"); - await fs.mkdir(otherWorkspaceDir, { recursive: true }); - const changedCfg = { - ...cfg, - memory: { - backend: "qmd", - qmd: { - ...cfg.memory?.qmd, - paths: [{ path: otherWorkspaceDir, pattern: "**/*.md", name: "workspace" }], - }, - }, - } as OpenClawConfig; - - spawnMock.mockClear(); - const second = await createManager({ mode: "cli", cfg: changedCfg }); - await second.manager.close(); - - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); - }); - - it("bypasses validation cache for missing-collection search repair", async () => { - await configureMemoryCoreDreamingStateForTests(); - const { manager } = await createManager(); - spawnMock.mockClear(); - let searchAttempts = 0; - const events: string[] = []; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - events.push(`command:${args[0]}${args[1] ? `:${args[1]}` : ""}`); - if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") { - const child = createMockChild({ autoClose: false }); - searchAttempts += 1; - if (searchAttempts === 1) { - emitAndClose(child, "stderr", "collection workspace-main not found", 1); - } else { - emitAndClose(child, "stdout", "[]"); - } - return child; - } - return createMockChild(); - }); - const debug: MemorySearchRuntimeDebug[] = []; - - await manager.search("fact", { - sessionKey: "agent:main:slack:dm:u123", - onDebug: (entry) => { - debug.push(entry); - }, - [MEMORY_SEARCH_DEADLINE_CONTROL]: (action) => { - events.push(`phase:${action}`); - }, - }); - - expect(searchAttempts).toBe(2); - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); - expect(debug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("bypass-force"); - expect(events.filter((event) => event.startsWith("phase:"))).toEqual([ - "phase:pause", - "phase:resume", - "phase:pause", - "phase:resume", - ]); - const isSearchCommand = (event: string) => - ["command:query:", "command:search:", "command:vsearch:"].some((prefix) => - event.startsWith(prefix), - ); - const firstSearch = events.findIndex(isSearchCommand); - const firstSearchEnd = events.indexOf("phase:resume"); - const collectionRepair = events.findIndex( - (event, index) => index > firstSearchEnd && event.startsWith("command:collection:"), - ); - const retryStart = events.indexOf("phase:pause", firstSearchEnd + 1); - const retrySearch = events.findIndex( - (event, index) => index > firstSearch && isSearchCommand(event), - ); - expect(events.indexOf("phase:pause")).toBeLessThan(firstSearch); - expect(firstSearch).toBeLessThan(firstSearchEnd); - expect(firstSearchEnd).toBeLessThan(collectionRepair); - expect(collectionRepair).toBeLessThan(retryStart); - expect(retryStart).toBeLessThan(retrySearch); - }); - - it("reuses persisted qmd multi-collection support probe across managers", async () => { - await configureMemoryCoreDreamingStateForTests(); - configureQmd({ sessions: { enabled: true } }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "--help") { - return makeQmdChild({ data: "Usage: qmd search -c one or more collections" }); - } - if (args[0] === "search") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const first = await createManager({ mode: "cli" }); - await first.manager.search("fact", { - sessionKey: "agent:main:slack:dm:u123", - }); - await first.manager.close(); - expect(countQmdCommand((args) => args[0] === "--help")).toBe(1); - - spawnMock.mockClear(); - const second = await createManager({ mode: "cli" }); - const debug: MemorySearchRuntimeDebug[] = []; - await second.manager.search("fact", { - sessionKey: "agent:main:slack:dm:u123", - onDebug: (entry) => { - debug.push(entry); - }, - }); - await second.manager.close(); - - expect(countQmdCommand((args) => args[0] === "--help")).toBe(0); - expect(debug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("hit"); - expect(debug.at(-1)?.qmd?.searchPlan?.groupCount).toBe(2); - }); - - it("reports multi-collection probe debug only when the probe runs", async () => { - await configureMemoryCoreDreamingStateForTests(); - configureQmd({ sessions: { enabled: true } }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "--help") { - return makeQmdChild({ data: "Usage: qmd search -c one or more collections" }); - } - if (args[0] === "search") { - return makeQmdChild(); - } - return createMockChild(); - }); - const { manager } = await createManager({ mode: "cli" }); - const firstDebug: MemorySearchRuntimeDebug[] = []; - const secondDebug: MemorySearchRuntimeDebug[] = []; - - await manager.search("fact", { - sessionKey: "agent:main:slack:dm:u123", - onDebug: (entry) => { - firstDebug.push(entry); - }, - }); - await manager.search("fact again", { - sessionKey: "agent:main:slack:dm:u123", - onDebug: (entry) => { - secondDebug.push(entry); - }, - }); - - expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("write"); - expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toBeUndefined(); - }); - - it("keeps concurrent search debug isolated on a shared qmd manager", async () => { - await configureMemoryCoreDreamingStateForTests(); - configureQmd({ sessions: { enabled: true } }); - let firstSearchChild: MockChild | undefined; - let searchCalls = 0; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - searchCalls += 1; - const child = createMockChild({ autoClose: false }); - if (searchCalls === 1) { - firstSearchChild = child; - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - } - if (args[0] === "--version") { - return makeQmdChild({ data: "qmd 1.0.0" }); - } - return createMockChild(); - }); - const { manager } = await createManager({ mode: "full" }); - const firstDebug: MemorySearchRuntimeDebug[] = []; - const secondDebug: MemorySearchRuntimeDebug[] = []; - - const firstSearch = manager.search("memory fact", { - sessionKey: "agent:main:slack:dm:u123", - sources: ["memory"], - onDebug: (entry) => { - firstDebug.push(entry); - }, - }); - await waitUntil(() => searchCalls === 1); - const secondSearch = manager.search("session fact", { - sessionKey: "agent:main:slack:dm:u123", - sources: ["sessions"], - onDebug: (entry) => { - secondDebug.push(entry); - }, - }); - await waitUntil(() => searchCalls === 2); - emitAndClose(requireValue(firstSearchChild, "first search child missing"), "stdout", "[]"); - - await Promise.all([firstSearch, secondSearch]); - - expect(firstDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["memory"]); - expect(secondDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["sessions"]); - }); - - it("keeps remember-only session exports out of ordinary manager searches", async () => { - configureQmd( - {}, - { - agents: { - ...cfg.agents, - list: [{ id: "main", memory: { search: { rememberAcrossConversations: true } } }], - }, - }, - ); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild(); - } - return createMockChild(); - }); - const { manager } = await createManager({ mode: "cli" }); - - await manager.search("remember-only", { - sessionKey: "agent:main:cli:direct:memory-search", - }); - - const searchCalls = spawnMock.mock.calls - .filter(([, args]) => args[0] === "search") - .map(([, args]) => args); - expect(searchCalls.some((args) => args.includes("workspace-main"))).toBe(true); - expect(searchCalls.some((args) => args.includes("sessions-main"))).toBe(false); - await manager.close(); - }); - - it("rewrites stale multi-collection probe cache when combined filters are rejected", async () => { - await configureMemoryCoreDreamingStateForTests(); - const otherWorkspaceDir = path.join(tmpRoot, "other-workspace"); - await fs.mkdir(otherWorkspaceDir, { recursive: true }); - configureQmd({ - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: otherWorkspaceDir, pattern: "**/*.md", name: "other" }, - ], - }); - const isCombinedSearch = (args: string[]) => - (args[0] === "search" || args[0] === "query") && - args.filter((token) => token === "-c").length > 1; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "--version") { - return makeQmdChild({ data: "qmd 1.0.0" }); - } - if (args[0] === "--help") { - return makeQmdChild({ data: "Usage: qmd search -c one or more collections" }); - } - if (isCombinedSearch(args)) { - return makeQmdChild({ stream: "stderr", data: "unknown flag: -c", code: 1 }); - } - if (args[0] === "search" || args[0] === "query" || args[0] === "vsearch") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const first = await createManager({ mode: "cli" }); - const firstDebug: MemorySearchRuntimeDebug[] = []; - await first.manager.search("fact", { - sessionKey: "agent:main:slack:dm:u123", - onDebug: (entry) => { - firstDebug.push(entry); - }, - }); - await first.manager.close(); - - expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({ - cacheState: "write", - supported: false, - }); - - spawnMock.mockClear(); - const second = await createManager({ mode: "cli" }); - const secondDebug: MemorySearchRuntimeDebug[] = []; - await second.manager.search("fact", { - sessionKey: "agent:main:slack:dm:u123", - onDebug: (entry) => { - secondDebug.push(entry); - }, - }); - await second.manager.close(); - - expect(countQmdCommand((args) => args[0] === "--help")).toBe(0); - expect(countQmdCommand(isCombinedSearch)).toBe(0); - expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({ - cacheState: "hit", - supported: false, - }); - }); - - async function expectPathMissing(targetPath: string): Promise { - try { - await fs.lstat(targetPath); - } catch (error) { - expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); - return; - } - throw new Error(`expected missing path ${targetPath}`); - } - - function qmdIndexConfigPath(selectedAgentId = agentId): string { - return path.join(stateDir, "agents", selectedAgentId, "qmd", "xdg-config", "qmd", "index.yml"); - } - - function resolveMemoryBackendConfigForTest(sourceCfg: OpenClawConfig, selectedAgentId: string) { - const resolved = resolveMemoryBackendConfig({ cfg: sourceCfg, agentId: selectedAgentId }); - const qmdTestConfig = sourceCfg.memory?.qmd as - | { - mcporter?: { enabled?: boolean; serverName?: string; startDaemon?: boolean }; - update?: { - commandTimeoutMs?: number; - debounceMs?: number; - embedInterval?: string; - embedTimeoutMs?: number; - interval?: string; - onBoot?: boolean; - startup?: "off" | "idle" | "blocking"; - startupDelayMs?: number; - updateTimeoutMs?: number; - waitForBootSync?: boolean; - }; - } - | undefined; - if (!resolved.qmd) { - return resolved; - } - - // Removed config knobs still drive focused manager mechanics in this test file only. - Object.assign(resolved.qmd.mcporter, qmdTestConfig?.mcporter); - const update = qmdTestConfig?.update; - if (!update) { - return resolved; - } - const parseInterval = (value: string | undefined, defaultUnitMs: number) => { - if (!value) { - return undefined; - } - const match = /^(\d+)(ms|s|m|h)?$/.exec(value.trim()); - if (!match) { - return undefined; - } - const amount = Number(match[1]); - const unitMsBySuffix: Record = { - ms: 1, - s: 1_000, - m: 60_000, - h: 3_600_000, - }; - return amount * (unitMsBySuffix[match[2] ?? ""] ?? defaultUnitMs); - }; - Object.assign(resolved.qmd.update, { - ...(update.interval !== undefined - ? { intervalMs: parseInterval(update.interval, 60_000) } - : {}), - ...(update.debounceMs !== undefined ? { debounceMs: update.debounceMs } : {}), - ...(update.onBoot !== undefined ? { onBoot: update.onBoot } : {}), - ...(update.startup !== undefined ? { startup: update.startup } : {}), - ...(update.startupDelayMs !== undefined ? { startupDelayMs: update.startupDelayMs } : {}), - ...(update.waitForBootSync !== undefined ? { waitForBootSync: update.waitForBootSync } : {}), - ...(update.embedInterval !== undefined - ? { embedIntervalMs: parseInterval(update.embedInterval, 60_000) } - : {}), - ...(update.commandTimeoutMs !== undefined - ? { commandTimeoutMs: update.commandTimeoutMs } - : {}), - ...(update.updateTimeoutMs !== undefined ? { updateTimeoutMs: update.updateTimeoutMs } : {}), - ...(update.embedTimeoutMs !== undefined ? { embedTimeoutMs: update.embedTimeoutMs } : {}), - }); - return resolved; - } - - async function createManager(params?: { - mode?: "full" | "status" | "cli"; - cfg?: OpenClawConfig; - agentId?: string; - }) { - const sourceCfg = params?.cfg ?? cfg; - const cfgToUse: OpenClawConfig = { - ...sourceCfg, - memory: { - ...sourceCfg.memory, - search: { - rememberAcrossConversations: false, - ...sourceCfg.memory?.search, - }, - }, - - agents: { - ...sourceCfg.agents, - defaults: { - ...sourceCfg.agents?.defaults, - }, - }, - }; - const selectedAgentId = params?.agentId ?? agentId; - const resolved = resolveMemoryBackendConfigForTest(cfgToUse, selectedAgentId); - const manager = trackManager( - await QmdMemoryManager.create({ - cfg: cfgToUse, - agentId: selectedAgentId, - resolved, - withLease: withLeaseMock, - mode: params?.mode ?? "status", - }), - ); - return { manager: requireValue(manager, "manager missing"), resolved }; - } - - function createAbortChildHarness() { - let child: MockChild | undefined; - let kill: ReturnType | undefined; - - return { - createChild(): MockChild { - const current = createMockChild({ autoClose: false }); - const currentKill = vi.fn(() => { - // Closing only after SIGKILL proves the caller abort reached this child. - queueMicrotask(() => current.emit("close", null)); - return true; - }); - Object.assign(current, { kill: currentKill }); - child ??= current; - kill ??= currentKill; - return current; - }, - async waitForSpawn(): Promise { - await waitUntil(() => kill !== undefined); - }, - expectKilled(): void { - expect(child).toBeDefined(); - expect(kill).toHaveBeenCalledWith("SIGKILL"); - }, - }; - } - - type AbortSearchScenario = { - name: string; - qmd: QmdTestConfig | (() => QmdTestConfig); - isTarget: (cmd: string, args: string[]) => boolean; - preAborted?: boolean; - outputFor?: ( - cmd: string, - args: string[], - ) => { stream: "stdout" | "stderr"; data: string; code?: number } | undefined; - neverSpawns?: (cmd: string, args: string[]) => boolean; - }; - - async function runAbortSearchScenario(scenario: AbortSearchScenario): Promise { - configureQmd(typeof scenario.qmd === "function" ? scenario.qmd() : scenario.qmd); - const abortChild = createAbortChildHarness(); - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (scenario.isTarget(cmd, args)) { - return abortChild.createChild(); - } - const output = scenario.outputFor?.(cmd, args); - if (output) { - return makeQmdChild(output); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - const controller = new AbortController(); - if (scenario.preAborted) { - controller.abort(new Error("memory_search timed out after 15s")); - } - const targetCallsBefore = spawnMock.mock.calls.filter((call: unknown[]) => - scenario.isTarget(String(call[0]), call[1] as string[]), - ).length; - const searchPromise = manager.search("test", { - sessionKey: "agent:main:slack:dm:u123", - signal: controller.signal, - }); - searchPromise.catch(() => undefined); - - if (scenario.preAborted) { - await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s"); - const targetCallsAfter = spawnMock.mock.calls.filter((call: unknown[]) => - scenario.isTarget(String(call[0]), call[1] as string[]), - ).length; - expect(targetCallsAfter).toBe(targetCallsBefore); - await manager.close(); - return; - } - - await abortChild.waitForSpawn(); - controller.abort(new Error("memory_search timed out after 15s")); - - await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s"); - abortChild.expectKilled(); - if (scenario.neverSpawns) { - expect( - spawnMock.mock.calls.some((call: unknown[]) => - scenario.neverSpawns?.(String(call[0]), call[1] as string[]), - ), - ).toBe(false); - } - await manager.close(); - } - - type QmdCommandMatrixScenario = { - name: string; - configure: () => void | Promise; - supportsMultiCollection?: boolean; - rejectSearchFlags?: boolean; - expectedCommands: (maxResults: number) => string[][]; - }; - - function qmdQueryArgs( - command: "query" | "search", - maxResults: number, - ...collections: string[] - ): string[] { - return [ - command, - "test", - "--json", - "-n", - String(maxResults), - ...collections.flatMap((collection) => ["-c", collection]), - ]; - } - - async function runQmdCommandMatrixScenario(scenario: QmdCommandMatrixScenario): Promise { - await scenario.configure(); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "--help" && scenario.supportsMultiCollection) { - return makeQmdChild({ - data: "-c, --collection Filter by one or more collections", - }); - } - if (args[0] === "search" && scenario.rejectSearchFlags) { - return makeQmdChild({ stream: "stderr", data: "unknown flag: --json", code: 2 }); - } - if (args[0] === "search" || args[0] === "query") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager, resolved } = await createManager(); - const maxResults = requireValue(resolved.qmd?.limits.maxResults, "qmd maxResults missing"); - await expect( - manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - const commandCalls = qmdCommandCalls().filter( - (args) => args[0] === "search" || args[0] === "query", - ); - expect(commandCalls).toEqual(scenario.expectedCommands(maxResults)); - await manager.close(); - } - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-manager-test-fixtures-")); - }); - - afterAll(async () => { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - }); - - beforeEach(async () => { - vi.useRealTimers(); - vi.restoreAllMocks(); - delete (globalThis as Record)[MCPORTER_STATE_KEY]; - delete (globalThis as Record)[QMD_EMBED_QUEUE_KEY]; - delete (globalThis as Record)[QMD_UPDATE_QUEUE_KEY]; - delete (globalThis as Record)[MEMORY_EMBEDDING_PROVIDERS_KEY]; - spawnMock.mockClear(); - spawnMock.mockImplementation(() => createMockChild()); - watchMock.mockClear(); - withLeaseMock.mockReset(); - withLeaseMock.mockImplementation( - async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => - await run({ - signal: options.signal ?? new AbortController().signal, - assertOwned: vi.fn(), - }), - ); - logWarnMock.mockClear(); - logDebugMock.mockClear(); - logInfoMock.mockClear(); - tmpRoot = path.join(fixtureRoot, `case-${fixtureCount++}`); - workspaceDir = path.join(tmpRoot, "workspace"); - stateDir = path.join(tmpRoot, "state"); - // Only workspace must exist for configured collection paths; state paths are - // created lazily by manager code when needed. - await fs.mkdir(workspaceDir, { recursive: true }); - setQmdStateDir(stateDir); - // Keep the default Windows path unresolved for most tests so spawn mocks can - // match the logical package command. Tests that verify wrapper resolution - // install explicit shim fixtures inline. - configureQmd( - {}, - { - agents: { - defaults: { workspace: workspaceDir }, - list: [{ id: agentId, default: true, workspace: workspaceDir }], - }, - search: { - provider: "openai", - model: "mock-embed", - rememberAcrossConversations: false, - store: { vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - }, - }, - ); - seedMemoryEmbeddingProviders(); - embedStartupJitterSpy = vi - .spyOn( - QmdMemoryManager.prototype as unknown as { - resolveEmbedStartupJitterMs: () => number; - }, - "resolveEmbedStartupJitterMs", - ) - .mockReturnValue(0); - }); - - afterEach(async () => { - await Promise.all( - Array.from(openManagers, async (manager) => { - await manager.close(); - }), - ); - openManagers.clear(); - embedStartupJitterSpy?.mockRestore(); - embedStartupJitterSpy = null; - vi.useRealTimers(); - restoreQmdStateDir(); - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalWindowsPath === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalWindowsPath; - } - delete (globalThis as Record)[MCPORTER_STATE_KEY]; - delete (globalThis as Record)[QMD_EMBED_QUEUE_KEY]; - delete (globalThis as Record)[MEMORY_EMBEDDING_PROVIDERS_KEY]; - resetMemoryCoreDreamingStateForTests(); - closeOpenClawAgentDatabasesForTest(); - }); - - it("debounces back-to-back sync calls", async () => { - const { manager, resolved } = await createManager(); - - const baselineCalls = spawnMock.mock.calls.length; - - await manager.sync({ reason: "manual" }); - expect(spawnMock.mock.calls.length).toBe(baselineCalls + 1); - - await manager.sync({ reason: "manual-again" }); - expect(spawnMock.mock.calls.length).toBe(baselineCalls + 1); - - (manager as unknown as { lastUpdateAt: number | null }).lastUpdateAt = - Date.now() - (resolved.qmd?.update.debounceMs ?? 0) - 10; - - await manager.sync({ reason: "after-wait" }); - expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2); - - await manager.close(); - }); - - it("runs a qmd sync once for the first search in a fresh session", async () => { - configureQmd( - { update: { interval: "0s", debounceMs: 0, onBoot: false } }, - { - search: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: false, onSessionStart: true, onSearch: false }, - }, - }, - ); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (args[0] === "search" || args[0] === "query" || args[0] === "vsearch") { - emitAndClose(child, "stdout", "[]"); - return child; - } - queueMicrotask(() => child.closeWith(0)); - return child; - }); - - const { manager } = await createManager({ mode: "full" }); - - await manager.search("hello", { sessionKey: "session-a" }); - await manager.search("hello again", { sessionKey: "session-a" }); - - const updateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update"); - expect(updateCalls).toHaveLength(1); - }); - - it("does not block first search on session-start sync completion", async () => { - vi.useFakeTimers(); - configureQmd( - { update: { interval: "0s", debounceMs: 0, onBoot: false } }, - { - search: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: false, onSessionStart: true, onSearch: false }, - }, - }, - ); - - let releaseUpdate: (() => void) | null = null; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - const child = createMockChild({ autoClose: false }); - releaseUpdate = () => child.closeWith(0); - return child; - } - if (args[0] === "search" || args[0] === "query" || args[0] === "vsearch") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - const searchPromise = manager.search("hello", { sessionKey: "session-b" }); - - await vi.advanceTimersByTimeAsync(500); - await expect(searchPromise).resolves.toStrictEqual([]); - - ( - releaseUpdate ?? - (() => { - throw new Error("expected qmd update process to start"); - }) - )(); - await manager.close(); - }); - - it("logs qmd watcher errors instead of throwing", async () => { - configureQmd( - { update: { interval: "0s", debounceMs: 0, onBoot: false } }, - { - search: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, - }, - ); - - const { manager } = await createManager({ mode: "full" }); - expect(watchMock).toHaveBeenCalledTimes(1); - const watcher = watchMock.mock.results[0]?.value as EventEmitter; - - expect(() => { - watcher.emit("error", new Error("ENOSPC: watcher limit reached")); - }).not.toThrow(); - expectMockMessageContains(logWarnMock, "qmd watcher error: ENOSPC: watcher limit reached"); - - await manager.close(); - }); - - it("runs qmd sync when watched collection files change", async () => { - vi.useFakeTimers(); - configureQmd( - { update: { interval: "0s", debounceMs: 0, onBoot: false } }, - { - search: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, - }, - ); - - const { manager } = await createManager({ mode: "full" }); - expect(watchMock).toHaveBeenCalledTimes(1); - const watcher = watchMock.mock.results[0]?.value as EventEmitter & { - watchedEntries: Record; - }; - const initialUpdateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update"); - expect(initialUpdateCalls).toHaveLength(0); - const watchOptions = firstWatchOptions(); - expect(watchOptions).not.toHaveProperty("awaitWriteFinish"); - expect(watchOptions.ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe( - true, - ); - expect(watchOptions.ignored?.(path.join(workspaceDir, ".cache", "qmd", "note.md"))).toBe(true); - expect(watchOptions.ignored?.(path.join(workspaceDir, "vendor", "pkg", "note.md"))).toBe(true); - expect(watchOptions.ignored?.(path.join(workspaceDir, "dist", "note.md"))).toBe(true); - expect(watchOptions.ignored?.(path.join(workspaceDir, "build", "note.md"))).toBe(true); - expect(watchOptions.ignored?.(path.join(workspaceDir, "notes.md"))).toBe(false); - watcher.watchedEntries = { - [workspaceDir]: Array.from({ length: 2_001 }, (_value, index) => `${index}.md`), - }; - watcher.emit("ready"); - expectMockMessageContains(logWarnMock, "Memory file watching is tracking 2002 paths."); - - const notesPath = path.join(workspaceDir, "notes.md"); - await fs.writeFile(notesPath, "hello"); - const initialStats = await fs.stat(notesPath); - watcher.emit("change", notesPath, { - size: initialStats.size, - mtimeMs: initialStats.mtimeMs, - isDirectory: () => false, - }); - expect(manager.status().dirty).toBe(true); - - await vi.advanceTimersByTimeAsync(BUILT_IN_WATCH_DEBOUNCE_MS); - - const updateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update"); - expect(updateCalls).toHaveLength(1); - expect(manager.status().dirty).toBe(false); - - await manager.close(); - }); - - it("keeps explicit qmd collection roots watchable when their directory name is ignored", async () => { - const rootNames = ["build", "dist", "vendor", ".cache"]; - const roots = rootNames.map((name) => path.join(workspaceDir, name)); - configureQmd( - { - update: { interval: "0s", debounceMs: 0, onBoot: false }, - paths: roots.map((root) => ({ - path: root, - pattern: "**/*.md", - name: path.basename(root), - })), - }, - { - search: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, - }, - ); - - const { manager } = await createManager({ mode: "full" }); - expect(watchMock).toHaveBeenCalledTimes(1); - expect(firstWatchPaths().toSorted()).toEqual( - roots.map((root) => path.join(root, "**/*.md")).toSorted(), - ); - const ignored = firstWatchOptions().ignored; - for (const root of roots) { - expect(ignored?.(root)).toBe(false); - expect(ignored?.(path.join(root, "note.md"))).toBe(false); - expect(ignored?.(path.join(root, "..notes", "daily.md"))).toBe(false); - expect(ignored?.(path.join(root, "notes", "daily.md"))).toBe(false); - expect(ignored?.(path.join(root, "node_modules", "pkg", "note.md"))).toBe(true); - expect(ignored?.(path.join(root, "build", "artifact.md"))).toBe(true); - } - - await manager.close(); - }); - - it("prefers a nested explicit qmd collection root over a broader watched root", async () => { - const nestedRoot = path.join(workspaceDir, "build"); - configureQmd( - { - update: { interval: "0s", debounceMs: 0, onBoot: false }, - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: nestedRoot, pattern: "**/*.md", name: "build" }, - ], - }, - { - search: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, - }, - ); - - const { manager } = await createManager({ mode: "full" }); - const ignored = firstWatchOptions().ignored; - expect(ignored?.(path.join(nestedRoot, "note.md"))).toBe(false); - expect(ignored?.(path.join(nestedRoot, "..notes", "daily.md"))).toBe(false); - expect(ignored?.(path.join(nestedRoot, "node_modules", "pkg", "note.md"))).toBe(true); - expect(ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(true); - - await manager.close(); - }); - - it("delays qmd watch sync until changed file stats settle", async () => { - vi.useFakeTimers(); - configureQmd( - { update: { interval: "0s", debounceMs: 0, onBoot: false } }, - { - search: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, - }, - ); - - const notesPath = path.join(workspaceDir, "notes.md"); - await fs.writeFile(notesPath, "hello"); - const initialStats = await fs.stat(notesPath); - const { manager } = await createManager({ mode: "full" }); - const watcher = watchMock.mock.results[0]?.value as { - emit: (event: string, ...args: unknown[]) => boolean; - }; - - watcher.emit("change", notesPath, { - size: initialStats.size, - mtimeMs: initialStats.mtimeMs, - isDirectory: () => false, - }); - await fs.writeFile(notesPath, "hello updated"); - - await vi.advanceTimersByTimeAsync(BUILT_IN_WATCH_DEBOUNCE_MS); - expect(spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update")).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(BUILT_IN_WATCH_DEBOUNCE_MS); - expect(spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update")).toHaveLength(1); - - await manager.close(); - }); - - it("runs boot update in background by default", async () => { - configureQmd({ update: { interval: "0s", debounceMs: 60_000, onBoot: true } }); - - let releaseUpdate: (() => void) | null = null; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - const child = createMockChild({ autoClose: false }); - releaseUpdate = () => child.closeWith(0); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - ( - releaseUpdate ?? - (() => { - throw new Error("Expected qmd update release callback"); - }) - )(); - await manager?.close(); - }); - - it("skips qmd command side effects in status mode initialization", async () => { - configureQmd({ - update: { interval: "5m", debounceMs: 60_000, onBoot: true }, - }); - - const { manager } = await createManager({ mode: "status" }); - expect(spawnMock).not.toHaveBeenCalled(); - await manager?.close(); - }); - - it("initializes one-shot CLI mode without watchers or background updates", async () => { - configureQmd({ - update: { interval: "5m", debounceMs: 60_000, onBoot: true }, - }); - - const { manager } = await createManager({ mode: "cli" }); - - expect(watchMock).not.toHaveBeenCalled(); - const updateCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(updateCalls).toStrictEqual([]); - - await manager?.close(); - }); - - it("preserves blocking boot update freshness for one-shot CLI mode", async () => { - configureQmd({ - update: { interval: "5m", debounceMs: 60_000, onBoot: true, waitForBootSync: true }, - }); - - const updateSpawned = createDeferred(); - let releaseUpdate: (() => void) | null = null; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - const child = createMockChild({ autoClose: false }); - releaseUpdate = () => child.closeWith(0); - updateSpawned.resolve(); - return child; - } - return createMockChild(); - }); - - const createPromise = createManager({ mode: "cli" }); - await updateSpawned.promise; - let created = false; - void createPromise.then(() => { - created = true; - }); - await new Promise((resolve) => { - setImmediate(resolve); - }); - expect(created).toBe(false); - expect(watchMock).not.toHaveBeenCalled(); - - (releaseUpdate as (() => void) | null)?.(); - const { manager } = await createPromise; - const updateCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(updateCalls).toStrictEqual([["update"]]); - expect(watchMock).not.toHaveBeenCalled(); - - await manager?.close(); - }); - - it("keeps one-shot CLI searches from scheduling session-start updates", async () => { - configureQmd( - { searchMode: "search" }, - { - agents: { - ...cfg.agents, - defaults: { ...cfg.agents?.defaults, workspace: workspaceDir }, - }, - search: { - ...cfg.memory?.search, - sync: { watch: false, onSessionStart: true, onSearch: true }, - }, - }, - ); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "cli" }); - - await expect( - manager.search("glacier", { sessionKey: "agent:main:cli:memory-search" }), - ).resolves.toStrictEqual([]); - await manager.close(); - - const updateCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(updateCalls).toStrictEqual([]); - expect( - spawnMock.mock.calls.some((call: unknown[]) => (call[1] as string[])?.[0] === "search"), - ).toBe(true); - }); - - it("can be configured to block startup on boot update", async () => { - configureQmd({ - update: { interval: "0s", debounceMs: 60_000, onBoot: true, waitForBootSync: true }, - }); - - const updateSpawned = createDeferred(); - let releaseUpdate: (() => void) | null = null; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - const child = createMockChild({ autoClose: false }); - releaseUpdate = () => child.closeWith(0); - updateSpawned.resolve(); - return child; - } - return createMockChild(); - }); - - const resolved = resolveMemoryBackendConfigForTest(cfg, agentId); - const createPromise = QmdMemoryManager.create({ - cfg, - agentId, - resolved, - withLease: withLeaseMock, - mode: "full", - }); - await updateSpawned.promise; - let created = false; - void createPromise.then(() => { - created = true; - }); - await new Promise((resolve) => { - setImmediate(resolve); - }); - expect(created).toBe(false); - (releaseUpdate as (() => void) | null)?.(); - const manager = await createPromise; - await manager?.close(); - }); - - it("times out collection bootstrap commands", async () => { - vi.useFakeTimers(); - configureQmd({ - update: { interval: "0s", debounceMs: 60_000, onBoot: false, commandTimeoutMs: 15 }, - }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return createMockChild({ autoClose: false }); - } - return createMockChild(); - }); - - const managerPromise = createManager({ mode: "full" }); - await waitUntil(() => - spawnMock.mock.calls.some((call: unknown[]) => { - const args = call[1] as string[]; - return args[0] === "collection" && args[1] === "list"; - }), - ); - await vi.advanceTimersByTimeAsync(15); - const { manager } = await managerPromise; - const status = manager.status(); - expect(status.backend).toBe("qmd"); - expect(status.requestedProvider).toBe("qmd"); - await manager?.close(); - }); - - it("rebinds sessions collection when existing collection path targets another agent", async () => { - const devAgentId = "dev"; - const devWorkspaceDir = path.join(tmpRoot, "workspace-dev"); - await fs.mkdir(devWorkspaceDir); - configureQmd( - { - paths: [{ path: devWorkspaceDir, pattern: "**/*.md", name: "workspace" }], - sessions: { enabled: true }, - }, - { - agents: { - list: [ - { id: agentId, default: true, workspace: workspaceDir }, - { id: devAgentId, workspace: devWorkspaceDir }, - ], - }, - }, - ); - - const sessionCollectionName = `sessions-${devAgentId}`; - const wrongSessionsPath = path.join(stateDir, "agents", agentId, "qmd", "sessions"); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: JSON.stringify([ - { name: sessionCollectionName, path: wrongSessionsPath, mask: "**/*.md" }, - ]), - }); - } - return createMockChild(); - }); - - const resolved = resolveMemoryBackendConfigForTest(cfg, devAgentId); - const manager = trackManager( - await QmdMemoryManager.create({ - cfg, - agentId: devAgentId, - resolved, - withLease: withLeaseMock, - mode: "full", - }), - ); - await requireValue(manager, "manager missing").close(); - - const commands = spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]); - const removeSessions = commands.find( - (args) => - args[0] === "collection" && args[1] === "remove" && args[2] === sessionCollectionName, - ); - requireValue(removeSessions, "sessions collection remove command missing"); - - const addSessions = commands.find((args) => { - if (args[0] !== "collection" || args[1] !== "add") { - return false; - } - const nameIdx = args.indexOf("--name"); - return nameIdx >= 0 && args[nameIdx + 1] === sessionCollectionName; - }); - expect(requireValue(addSessions, "sessions collection add command missing")[2]).toBe( - path.join(stateDir, "agents", devAgentId, "qmd", "sessions"), - ); - }); - - it("avoids destructive rebind when qmd only reports collection names", async () => { - configureQmd({ sessions: { enabled: true } }); - - const sessionCollectionName = `sessions-${agentId}`; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: JSON.stringify([`workspace-${agentId}`, sessionCollectionName]), - }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - const commands = spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]); - const removeCalls = commands.filter((args) => args[0] === "collection" && args[1] === "remove"); - expect(removeCalls).toHaveLength(0); - - const addCalls = commands.filter((args) => args[0] === "collection" && args[1] === "add"); - expect(addCalls).toHaveLength(0); - }); - - it("rebinds collection when qmd text output exposes a changed pattern without a path", async () => { - configureQmd(); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: [ - "workspace-main (qmd://workspace-main/)", - " Pattern: *.txt", - " Files: 17", - ].join("\n"), - }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - const commands = spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]); - const removeCalls = commands.filter( - (args) => args[0] === "collection" && args[1] === "remove" && args[2] === "workspace-main", - ); - expect(removeCalls).toHaveLength(1); - - const addCall = commands.find((args) => { - if (args[0] !== "collection" || args[1] !== "add") { - return false; - } - const nameIdx = args.indexOf("--name"); - return nameIdx >= 0 && args[nameIdx + 1] === "workspace-main"; - }); - const workspaceAddCall = requireValue(addCall, "workspace collection add command missing"); - expect(workspaceAddCall[2]).toBe(workspaceDir); - expect(workspaceAddCall).toContain("**/*.md"); - }); - - it("migrates unscoped legacy collections before adding scoped names", async () => { - configureQmd({ includeDefaultMemory: true, paths: [] }); - - const legacyCollections = new Map< - string, - { - path: string; - pattern: string; - } - >([ - ["memory-root", { path: workspaceDir, pattern: "MEMORY.md" }], - ["memory-dir", { path: path.join(workspaceDir, "memory"), pattern: "**/*.md" }], - ]); - const removeCalls: string[] = []; - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: JSON.stringify( - [...legacyCollections.entries()].map(([name, info]) => ({ - name, - path: info.path, - mask: info.pattern, - })), - ), - }); - } - if (args[0] === "collection" && args[1] === "remove") { - const child = createMockChild({ autoClose: false }); - const name = args[2] ?? ""; - removeCalls.push(name); - legacyCollections.delete(name); - queueMicrotask(() => child.closeWith(0)); - return child; - } - if (args[0] === "collection" && args[1] === "add") { - const child = createMockChild({ autoClose: false }); - const pathArg = args[2] ?? ""; - const name = args[args.indexOf("--name") + 1] ?? ""; - const globIdx = args.indexOf("--glob"); - const maskIdx = args.indexOf("--mask"); - const pattern = - (globIdx !== -1 ? args[globIdx + 1] : maskIdx !== -1 ? args[maskIdx + 1] : "") ?? ""; - const hasConflict = [...legacyCollections.entries()].some( - ([existingName, info]) => - existingName !== name && info.path === pathArg && info.pattern === pattern, - ); - if (hasConflict) { - emitAndClose(child, "stderr", "collection already exists", 1); - return child; - } - legacyCollections.set(name, { path: pathArg, pattern }); - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(removeCalls).toEqual(["memory-root", "memory-dir"]); - expect(legacyCollections.has("memory-root-main")).toBe(true); - expect(legacyCollections.has("memory-dir-main")).toBe(true); - expect(legacyCollections.has("memory-root")).toBe(false); - expect(legacyCollections.has("memory-dir")).toBe(false); - expect(legacyCollections.has("memory-alt-main")).toBe(false); - expect(legacyCollections.has("memory-alt")).toBe(false); - }); - - it("rebinds conflicting collection name when path+pattern slot is already occupied", async () => { - configureQmd({ includeDefaultMemory: true, paths: [] }); - - const listedCollections = new Map< - string, - { - path: string; - pattern: string; - } - >([["memory-root-sonnet", { path: workspaceDir, pattern: "MEMORY.md" }]]); - const removeCalls: string[] = []; - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: JSON.stringify( - [...listedCollections.entries()].map(([name, info]) => ({ - name, - path: info.path, - mask: info.pattern, - })), - ), - }); - } - if (args[0] === "collection" && args[1] === "remove") { - const child = createMockChild({ autoClose: false }); - const name = args[2] ?? ""; - removeCalls.push(name); - listedCollections.delete(name); - queueMicrotask(() => child.closeWith(0)); - return child; - } - if (args[0] === "collection" && args[1] === "add") { - const child = createMockChild({ autoClose: false }); - const pathArg = args[2] ?? ""; - const name = args[args.indexOf("--name") + 1] ?? ""; - const globIdx = args.indexOf("--glob"); - const maskIdx = args.indexOf("--mask"); - const pattern = - (globIdx !== -1 ? args[globIdx + 1] : maskIdx !== -1 ? args[maskIdx + 1] : "") ?? ""; - const hasConflict = [...listedCollections.entries()].some( - ([existingName, info]) => - existingName !== name && info.path === pathArg && info.pattern === pattern, - ); - if (hasConflict) { - emitAndClose(child, "stderr", "A collection already exists for this path and pattern", 1); - return child; - } - listedCollections.set(name, { path: pathArg, pattern }); - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(removeCalls).toContain("memory-root-sonnet"); - expect(listedCollections.has("memory-root-main")).toBe(true); - expectMockMessageContains(logWarnMock, "rebinding"); - }); - - it("adds canonical memory-root without treating legacy memory-alt as equivalent", async () => { - await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root"); - configureQmd({ includeDefaultMemory: true, paths: [] }); - - const listedCollections = new Map< - string, - { - path: string; - pattern: string; - } - >([["memory-alt", { path: workspaceDir, pattern: "memory.md" }]]); - const removeCalls: string[] = []; - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: JSON.stringify( - [...listedCollections.entries()].map(([name, info]) => ({ - name, - path: info.path, - mask: info.pattern, - })), - ), - }); - } - if (args[0] === "collection" && args[1] === "remove") { - const child = createMockChild({ autoClose: false }); - const name = args[2] ?? ""; - removeCalls.push(name); - listedCollections.delete(name); - queueMicrotask(() => child.closeWith(0)); - return child; - } - if (args[0] === "collection" && args[1] === "add") { - const child = createMockChild({ autoClose: false }); - const pathArg = args[2] ?? ""; - const name = args[args.indexOf("--name") + 1] ?? ""; - const patternIndex = args.includes("--glob") - ? args.indexOf("--glob") + 1 - : args.includes("--mask") - ? args.indexOf("--mask") + 1 - : -1; - const pattern = patternIndex >= 0 ? (args[patternIndex] ?? "") : ""; - const hasConflict = [...listedCollections.entries()].some( - ([existingName, info]) => - existingName !== name && info.path === pathArg && info.pattern === pattern, - ); - if (hasConflict) { - emitAndClose(child, "stderr", "A collection already exists for this path and pattern", 1); - return child; - } - listedCollections.set(name, { path: pathArg, pattern }); - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(removeCalls).not.toContain("memory-alt"); - expect(listedCollections.has("memory-root-main")).toBe(true); - expect(listedCollections.has("memory-alt")).toBe(true); - expectMockMessageNotContains(logWarnMock, "rebinding"); - }); - - it("warns instead of silently succeeding when add conflict metadata is unavailable", async () => { - configureQmd(); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - // Name-only rows do not expose path/mask metadata. - return makeQmdChild({ data: JSON.stringify(["workspace-legacy"]) }); - } - if (args[0] === "collection" && args[1] === "add") { - return makeQmdChild({ stream: "stderr", data: "collection already exists", code: 1 }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expectMockMessageContains(logWarnMock, "qmd collection add skipped for workspace-main"); - }); - - it("surfaces a manual repair hint for stderr-only path-pattern conflicts", async () => { - configureQmd(); - - let staleCollectionExists = true; - const removeCalls: string[] = []; - const addCalls: string[] = []; - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - // Older qmd output may expose only names, so path/pattern matching cannot find this. - return makeQmdChild({ data: JSON.stringify(["workspace-legacy"]) }); - } - if (args[0] === "collection" && args[1] === "remove") { - const child = createMockChild({ autoClose: false }); - const name = args[2] ?? ""; - removeCalls.push(name); - if (name === "workspace-legacy") { - staleCollectionExists = false; - } - queueMicrotask(() => child.closeWith(0)); - return child; - } - if (args[0] === "collection" && args[1] === "add") { - const child = createMockChild({ autoClose: false }); - const name = args[args.indexOf("--name") + 1] ?? ""; - addCalls.push(name); - if (staleCollectionExists && name === "workspace-main") { - emitAndClose( - child, - "stderr", - [ - "A collection already exists for this path and pattern:", - " Name: workspace-legacy (qmd://workspace-legacy/)", - " Pattern: **/*.md", - "", - "Use 'qmd update' to re-index it, or remove it first with 'qmd collection remove workspace-legacy'", - ].join("\n"), - 1, - ); - return child; - } - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(removeCalls).toEqual([]); - expect(addCalls).toEqual(["workspace-main"]); - expectMockMessageNotContains(logWarnMock, "rebinding"); - expectMockMessageContains( - logWarnMock, - "qmd reported existing collection workspace-legacy, but list output did not include verifiable path/pattern metadata", - ); - expectMockMessageContains(logWarnMock, "qmd collection remove workspace-legacy"); - expectMockMessageContains(logWarnMock, "qmd collection add skipped for workspace-main"); - }); - - it("recreates a managed collection when list fails but add reports the same name exists", async () => { - await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root"); - configureQmd({ includeDefaultMemory: true, paths: [] }); - - const removed: string[] = []; - const added = new Map(); - const addAttempts = new Map(); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ stream: "stderr", data: "temporary qmd list failure", code: 1 }); - } - if (args[0] === "collection" && args[1] === "remove") { - const child = createMockChild({ autoClose: false }); - const name = args[2] ?? ""; - removed.push(name); - queueMicrotask(() => child.closeWith(0)); - return child; - } - if (args[0] === "collection" && args[1] === "add") { - const child = createMockChild({ autoClose: false }); - const name = args[args.indexOf("--name") + 1] ?? ""; - const patternIndex = args.includes("--glob") - ? args.indexOf("--glob") + 1 - : args.includes("--mask") - ? args.indexOf("--mask") + 1 - : -1; - const pattern = patternIndex >= 0 ? (args[patternIndex] ?? "") : ""; - const attempts = addAttempts.get(name) ?? 0; - addAttempts.set(name, attempts + 1); - if (name === "memory-root-main" && attempts === 0) { - emitAndClose(child, "stderr", "Collection 'memory-root-main' already exists.", 1); - return child; - } - added.set(name, pattern); - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(removed).toContain("memory-root-main"); - expect(added.get("memory-root-main")).toBe("MEMORY.md"); - expectMockMessageContains( - logWarnMock, - "qmd collection add conflict for memory-root-main: collection name already exists", - ); - expectMockMessageNotContains(logWarnMock, "qmd collection add skipped for memory-root-main"); - }); - - it("rebinds memory-root when qmd table output has a stale broad pattern", async () => { - await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root"); - configureQmd({ includeDefaultMemory: true, paths: [] }); - - const removed: string[] = []; - const added = new Map(); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: [ - "Collections (2):", - "", - "memory-dir-main (qmd://memory-dir-main/)", - " Pattern: **/*.md", - "", - "memory-root-main (qmd://memory-root-main/)", - " Pattern: **/*.md", - "", - ].join("\n"), - }); - } - if (args[0] === "collection" && args[1] === "remove") { - const child = createMockChild({ autoClose: false }); - const name = args[2] ?? ""; - removed.push(name); - queueMicrotask(() => child.closeWith(0)); - return child; - } - if (args[0] === "collection" && args[1] === "add") { - const child = createMockChild({ autoClose: false }); - const name = args[args.indexOf("--name") + 1] ?? ""; - const patternIndex = args.includes("--glob") - ? args.indexOf("--glob") + 1 - : args.includes("--mask") - ? args.indexOf("--mask") + 1 - : -1; - const pattern = patternIndex >= 0 ? (args[patternIndex] ?? "") : ""; - added.set(name, pattern); - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(removed).toContain("memory-root-main"); - expect(added.get("memory-root-main")).toBe("MEMORY.md"); - expect(removed).not.toContain("memory-dir-main"); - }); - - it("falls back to --glob when qmd collection add rejects --mask", async () => { - configureQmd({ includeDefaultMemory: true, paths: [] }); - - const addFlagCalls: string[] = []; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild(); - } - if (args[0] === "collection" && args[1] === "add") { - const child = createMockChild({ autoClose: false }); - const flag = args.includes("--glob") ? "--glob" : args.includes("--mask") ? "--mask" : ""; - addFlagCalls.push(flag); - if (flag === "--mask") { - emitAndClose(child, "stderr", "unknown flag: --mask", 1); - return child; - } - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(addFlagCalls).toEqual(["--mask", "--glob", "--glob"]); - expectMockMessageContains(logWarnMock, "retrying with legacy compatibility flag"); - }); - it("migrates unscoped legacy collections from plain-text collection list output", async () => { - configureQmd({ includeDefaultMemory: true, paths: [] }); - - const removeCalls: string[] = []; - const addCalls: string[] = []; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: [ - "Collections (2):", - "", - "memory-root (qmd://memory-root/)", - " Pattern: MEMORY.md", - "", - "memory-dir (qmd://memory-dir/)", - " Pattern: **/*.md", - "", - ].join("\n"), - }); - } - if (args[0] === "collection" && args[1] === "remove") { - const child = createMockChild({ autoClose: false }); - removeCalls.push(args[2] ?? ""); - queueMicrotask(() => child.closeWith(0)); - return child; - } - if (args[0] === "collection" && args[1] === "add") { - const child = createMockChild({ autoClose: false }); - addCalls.push(args[args.indexOf("--name") + 1] ?? ""); - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(removeCalls).toEqual(["memory-root", "memory-dir"]); - expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]); - }); - - it("does not migrate unscoped collections when listed metadata differs", async () => { - configureQmd({ includeDefaultMemory: true, paths: [] }); - - const differentPath = path.join(tmpRoot, "other-memory"); - await fs.mkdir(differentPath, { recursive: true }); - const removeCalls: string[] = []; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild({ - data: JSON.stringify([{ name: "memory-root", path: differentPath, mask: "MEMORY.md" }]), - }); - } - if (args[0] === "collection" && args[1] === "remove") { - const child = createMockChild({ autoClose: false }); - removeCalls.push(args[2] ?? ""); - queueMicrotask(() => child.closeWith(0)); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - expect(removeCalls).not.toContain("memory-root"); - expectMockMessageContains( - logDebugMock, - "qmd legacy collection migration skipped for memory-root", - ); - }); - - it("times out qmd update during sync when configured", async () => { - vi.useFakeTimers(); - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false, updateTimeoutMs: 20 }, - }); - const updateSpawned = createDeferred(); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - updateSpawned.resolve(); - return createMockChild({ autoClose: false }); - } - return createMockChild(); - }); - - const resolved = resolveMemoryBackendConfigForTest(cfg, agentId); - const createPromise = QmdMemoryManager.create({ - cfg, - agentId, - resolved, - withLease: withLeaseMock, - mode: "status", - }); - await vi.advanceTimersByTimeAsync(0); - const manager = requireValue(trackManager(await createPromise), "manager missing"); - const syncPromise = manager.sync({ reason: "manual" }); - const rejected = expect(syncPromise).rejects.toThrow("qmd update timed out after 20ms"); - await vi.advanceTimersByTimeAsync(0); - await updateSpawned.promise; - await vi.advanceTimersByTimeAsync(20); - await rejected; - await manager.close(); - }); - - it.each([ - { - name: "rebuilds managed collections once when qmd update fails with null-byte ENOTDIR", - error: "ENOTDIR: not a directory, open '/tmp/workspace/MEMORY.md^@'", - warning: "suspected null-byte collection metadata", - }, - { - name: "rebuilds managed collections once when qmd update fails with null-byte ENOENT", - error: "ENOENT: no such file or directory, open '/tmp/workspace/MEMORY.md\\x00'", - warning: "suspected null-byte collection metadata", - }, - { - name: "rebuilds managed collections once when qmd update hits duplicate document constraint", - error: "SQLiteError: UNIQUE constraint failed: documents.collection, documents.path", - warning: "duplicate document constraint", - }, - ])("$name", async ({ error, warning }) => { - configureQmd({ - includeDefaultMemory: true, - update: { interval: "0s", debounceMs: 0, onBoot: false }, - paths: [], - }); - - let updateCalls = 0; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - updateCalls += 1; - const child = createMockChild({ autoClose: false }); - if (updateCalls === 1) { - emitAndClose(child, "stderr", error, 1); - return child; - } - queueMicrotask(() => { - child.closeWith(0); - }); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "status" }); - await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined(); - - const removeCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "collection" && args[1] === "remove") - .map((args) => args[2]); - const addCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "collection" && args[1] === "add") - .map((args) => args[args.indexOf("--name") + 1]); - - expect(updateCalls).toBe(2); - expect(removeCalls).toEqual(["memory-root-main", "memory-dir-main"]); - expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]); - expectMockMessageContains(logWarnMock, warning); - - await manager.close(); - }); - - it("refreshes qmd index config with quoted collection values during update repair", async () => { - const notesDir = path.join(workspaceDir, "Notes #1: blue"); - await fs.mkdir(notesDir, { recursive: true }); - configureQmd({ - update: { interval: "0s", debounceMs: 0, onBoot: false }, - paths: [{ path: notesDir, pattern: "**/* #tag: [draft].md", name: "notes" }], - }); - - let updateCalls = 0; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - updateCalls += 1; - const child = createMockChild({ autoClose: false }); - if (updateCalls === 1) { - emitAndClose( - child, - "stderr", - "SQLiteError: UNIQUE constraint failed: documents.collection, documents.path", - 1, - ); - return child; - } - queueMicrotask(() => { - child.closeWith(0); - }); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "status" }); - await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined(); - - const indexConfig = await fs.readFile(qmdIndexConfigPath(), "utf8"); - expect(indexConfig).toContain(' "notes-main":'); - expect(indexConfig).toContain(` path: ${JSON.stringify(notesDir)}`); - expect(indexConfig).toContain(' pattern: "**/* #tag: [draft].md"'); - expect(updateCalls).toBe(2); - - await manager.close(); - }); - - it("forces repair remove/add even when managed collections are still listed", async () => { - configureQmd({ - includeDefaultMemory: true, - update: { interval: "0s", debounceMs: 0, onBoot: false }, - paths: [], - }); - - let updateCalls = 0; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - const child = createMockChild({ autoClose: false }); - emitAndClose( - child, - "stdout", - JSON.stringify([ - { name: "memory-root-main", path: workspaceDir, mask: "MEMORY.md" }, - { name: "memory-dir-main", path: path.join(workspaceDir, "memory"), mask: "**/*.md" }, - ]), - ); - return child; - } - if (args[0] === "update") { - updateCalls += 1; - const child = createMockChild({ autoClose: false }); - if (updateCalls === 1) { - emitAndClose( - child, - "stderr", - "SQLiteError: UNIQUE constraint failed: documents.collection, documents.path", - 1, - ); - return child; - } - queueMicrotask(() => { - child.closeWith(0); - }); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined(); - - const removeCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "collection" && args[1] === "remove") - .map((args) => args[2]); - const addCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "collection" && args[1] === "add") - .map((args) => args[args.indexOf("--name") + 1]); - - expect(updateCalls).toBe(2); - expect(removeCalls).toEqual(["memory-root-main", "memory-dir-main"]); - expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]); - - await manager.close(); - }); - - it("does not rebuild collections for unrelated unique constraint failures", async () => { - configureQmd({ - includeDefaultMemory: true, - update: { interval: "0s", debounceMs: 0, onBoot: false }, - paths: [], - }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - return makeQmdChild({ - stream: "stderr", - data: "SQLiteError: UNIQUE constraint failed: documents.docid", - code: 1, - }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "status" }); - await expect(manager.sync({ reason: "manual" })).rejects.toThrow( - "SQLiteError: UNIQUE constraint failed: documents.docid", - ); - - const removeCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "collection" && args[1] === "remove"); - expect(removeCalls).toHaveLength(0); - - await manager.close(); - }); - - it("does not rebuild collections for generic qmd update failures", async () => { - configureQmd({ - includeDefaultMemory: true, - update: { interval: "0s", debounceMs: 0, onBoot: false }, - paths: [], - }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - return makeQmdChild({ - stream: "stderr", - data: "ENOTDIR: not a directory, open '/tmp/workspace/MEMORY.md'", - code: 1, - }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "status" }); - await expect(manager.sync({ reason: "manual" })).rejects.toThrow( - "ENOTDIR: not a directory, open '/tmp/workspace/MEMORY.md'", - ); - - const removeCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "collection" && args[1] === "remove"); - expect(removeCalls).toHaveLength(0); - - await manager.close(); - }); - - it("uses configured qmd search mode command", async () => { - configureQmd({ searchMode: "search" }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager, resolved } = await createManager(); - const maxResults = resolved.qmd?.limits.maxResults; - if (!maxResults) { - throw new Error("qmd maxResults missing"); - } - - await expect( - manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - - const searchCall = spawnMock.mock.calls.find( - (call: unknown[]) => (call[1] as string[])?.[0] === "search", - ); - expect(searchCall?.[1]).toEqual([ - "search", - "test", - "--json", - "-n", - String(resolved.qmd?.limits.maxResults), - "-c", - "workspace-main", - ]); - expect( - spawnMock.mock.calls.some((call: unknown[]) => (call[1] as string[])?.[0] === "query"), - ).toBe(false); - expect(maxResults).toBeGreaterThan(0); - await manager.close(); - }); - - it("uses valid qmd query JSON captured before a non-zero exit", async () => { - configureQmd({ searchMode: "query" }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "query") { - const child = createMockChild({ autoClose: false }); - queueMicrotask(() => { - child.stdout.emit("data", "initializing qmd reranker\n"); - child.stdout.emit( - "data", - JSON.stringify( - [ - { - file: "qmd://workspace-main/notes/welcome.md", - score: 0.93, - snippet: "@@ -7,1\nrouter glacier backup", - }, - ], - null, - 2, - ), - ); - child.stderr.emit("data", "ggml-metal-device.m:612 assertion failed"); - child.closeWith(134); - }); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - await expect( - manager.search("router glacier backup", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([ - { - path: "notes/welcome.md", - startLine: 7, - endLine: 7, - score: 0.93, - snippet: "@@ -7,1\nrouter glacier backup", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - expectMockMessageContains( - logWarnMock, - "qmd query exited non-zero after producing valid JSON; using captured search results (code 134)", - ); - await manager.close(); - }); - - it("keeps invalid qmd query stdout failed after a non-zero exit", async () => { - configureQmd({ searchMode: "query" }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "query") { - return makeQmdChild({ data: "not json", code: 134 }); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - await expect( - manager.search("router glacier backup", { sessionKey: "agent:main:slack:dm:u123" }), - ).rejects.toThrow(/qmd query router glacier backup .* failed \(code 134\): not json/); - await manager.close(); - }); - - it("does not use qmd query JSON from a non-crash search failure", async () => { - configureQmd({ searchMode: "query" }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "query") { - const child = createMockChild({ autoClose: false }); - queueMicrotask(() => { - child.stdout.emit("data", "[]"); - child.stderr.emit("data", "SQLITE_BUSY: database is locked"); - child.closeWith(2); - }); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - await expect( - manager.search("router glacier backup", { sessionKey: "agent:main:slack:dm:u123" }), - ).rejects.toThrow(/SQLITE_BUSY: database is locked/); - expect(logWarnMock).not.toHaveBeenCalledWith( - expect.stringContaining("using captured search results"), - ); - await manager.close(); - }); - - it("repairs missing managed collections and retries search once", async () => { - configureQmd({ includeDefaultMemory: true, searchMode: "search", paths: [] }); - - const expectedDocId = "abc123"; - let missingCollectionSeen = false; - let addCallsAfterMissing = 0; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdChild(); - } - if (args[0] === "collection" && args[1] === "add") { - if (missingCollectionSeen) { - addCallsAfterMissing += 1; - } - return createMockChild(); - } - if (args[0] === "search") { - const collectionFlagIndex = args.indexOf("-c"); - const collection = collectionFlagIndex >= 0 ? args[collectionFlagIndex + 1] : ""; - if (collection === "memory-root-main" && !missingCollectionSeen) { - missingCollectionSeen = true; - const child = createMockChild({ autoClose: false }); - queueMicrotask(() => { - child.stdout.emit("data", "[]"); - child.stderr.emit("data", "Collection not found: memory-root-main"); - child.closeWith(1); - }); - return child; - } - if (collection === "memory-root-main") { - return makeQmdChild({ - data: JSON.stringify([ - { docid: expectedDocId, score: 1, snippet: "@@ -1,1\nremember this" }, - ]), - }); - } - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - withLeaseMock.mockClear(); - const inner = manager as unknown as { - db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void }; - }; - inner.db = { - prepare: (_query: string) => ({ - all: (arg: unknown) => { - if (typeof arg === "string" && arg.startsWith(expectedDocId)) { - return [{ collection: "memory-root-main", path: "MEMORY.md" }]; - } - return []; - }, - }), - close: () => {}, - }; - - const callerController = new AbortController(); - await expect( - manager.search("remember", { - sessionKey: "agent:main:slack:dm:u123", - signal: callerController.signal, - }), - ).resolves.toEqual([ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 1, - snippet: "@@ -1,1\nremember this", - source: "memory", - provenance: expectedQmdProvenance("agent"), - }, - ]); - expect(addCallsAfterMissing).toBeGreaterThan(0); - expectMockMessageContains(logWarnMock, "repairing collections and retrying once"); - const repairLeases = writeLeaseCalls(); - expect(repairLeases.some(([options]) => options.signal?.aborted)).toBe(false); - callerController.abort(); - expect(repairLeases.some(([options]) => options.signal?.aborted)).toBe(true); - - await manager.close(); - }); - - it("resolves bare qmd command to a Windows-compatible spawn invocation", async () => { - await withMockedWindowsPlatform(async () => { - const previousPath = process.env.PATH; - try { - const nodeModulesDir = path.join(tmpRoot, "node_modules"); - const shimDir = path.join(nodeModulesDir, ".bin"); - const packageDir = path.join(nodeModulesDir, "qmd"); - const scriptPath = path.join(packageDir, "dist", "cli.js"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(shimDir, { recursive: true }); - await fs.writeFile(path.join(shimDir, "qmd.cmd"), "@echo off\r\n", "utf8"); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "qmd", version: "0.0.0", bin: { qmd: "dist/cli.js" } }), - "utf8", - ); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - process.env.PATH = `${shimDir};${previousPath ?? ""}`; - - const { manager } = await createManager({ mode: "status" }); - await manager.sync({ reason: "manual" }); - - const qmdCalls = spawnMock.mock.calls.filter((call: unknown[]) => { - const args = call[1] as string[] | undefined; - return ( - Array.isArray(args) && - args.some((token) => token === "update" || token === "search" || token === "query") - ); - }); - expect(qmdCalls.length).toBeGreaterThan(0); - for (const call of qmdCalls) { - const command = String(call[0]); - const options = call[2] as { shell?: boolean } | undefined; - expect(command).not.toMatch(/(^|[\\/])qmd\.cmd$/i); - expect(options?.shell).not.toBe(true); - } - - await manager.close(); - } finally { - process.env.PATH = previousPath; - } - }); - }); - - it("keeps mixed Han-script BM25 queries intact before qmd search", async () => { - configureQmd({ searchMode: "search" }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager, resolved } = await createManager(); - const maxResults = resolved.qmd?.limits.maxResults; - if (!maxResults) { - throw new Error("qmd maxResults missing"); - } - - await expect( - manager.search("記憶系統升級 QMD", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - - const searchCall = spawnMock.mock.calls.find( - (call: unknown[]) => (call[1] as string[])?.[0] === "search", - ); - expect(searchCall?.[1]).toEqual([ - "search", - "記憶系統升級 QMD", - "--json", - "-n", - String(maxResults), - "-c", - "workspace-main", - ]); - await manager.close(); - }); - - it("falls back to the original query when Han normalization yields no BM25 tokens", async () => { - configureQmd({ searchMode: "search" }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - await expect(manager.search("記", { sessionKey: "agent:main:slack:dm:u123" })).resolves.toEqual( - [], - ); - - const searchCall = spawnMock.mock.calls.find( - (call: unknown[]) => (call[1] as string[])?.[0] === "search", - ); - expect(searchCall?.[1]?.[1]).toBe("記"); - await manager.close(); - }); - - it("keeps spaced Han queries intact before qmd search", async () => { - configureQmd({ searchMode: "search" }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - const query = "自然 高级感 结论先行 搜索偏好"; - await expect( - manager.search(query, { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - - const searchCall = spawnMock.mock.calls.find( - (call: unknown[]) => (call[1] as string[])?.[0] === "search", - ); - expect(searchCall?.[1]?.[1]).toBe(query); - await manager.close(); - }); - - it("keeps original Han queries in qmd query mode", async () => { - configureQmd({ searchMode: "query" }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "query") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - await expect( - manager.search("記憶系統升級 QMD", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - - const queryCall = spawnMock.mock.calls.find( - (call: unknown[]) => (call[1] as string[])?.[0] === "query", - ); - expect(queryCall?.[1]?.[1]).toBe("記憶系統升級 QMD"); - await manager.close(); - }); - - it("retries search with qmd query when configured mode rejects flags", async () => { - configureQmd({ searchMode: "search" }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild({ stream: "stderr", data: "unknown flag: --json", code: 2 }); - } - if (args[0] === "query") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager, resolved } = await createManager(); - const maxResults = resolved.qmd?.limits.maxResults; - if (!maxResults) { - throw new Error("qmd maxResults missing"); - } - - await expect( - manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - - const searchAndQueryCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1]) - .filter( - (args): args is string[] => Array.isArray(args) && ["search", "query"].includes(args[0]), - ); - expect(searchAndQueryCalls).toEqual([ - ["search", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], - ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], - ]); - await manager.close(); - }); - - it("passes --no-rerank to direct qmd query when query reranking is disabled", async () => { - configureQmd({ searchMode: "query", rerank: false }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "query") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager, resolved } = await createManager(); - const maxResults = resolved.qmd?.limits.maxResults; - if (!maxResults) { - throw new Error("qmd maxResults missing"); - } - - await expect( - manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - - const queryCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "query"); - expect(queryCalls).toEqual([ - ["query", "test", "--json", "-n", String(maxResults), "--no-rerank", "-c", "workspace-main"], - ]); - await manager.close(); - }); - - const abortSearchScenarios = [ - { - name: "aborts the in-flight qmd search subprocess when the caller signal aborts", - qmd: { searchMode: "query" }, - isTarget: (_cmd, args) => args[0] === "query", - }, - { - name: "rejects the qmd search before spawning when the caller signal is already aborted", - qmd: { searchMode: "query" }, - isTarget: (_cmd, args) => args[0] === "query", - preAborted: true, - }, - { - name: "aborts the in-flight grouped qmd search subprocess when the caller signal aborts", - qmd: { sessions: { enabled: true } }, - isTarget: (_cmd, args) => args[0] === "search", - outputFor: (_cmd, args) => - args[0] === "--help" - ? { - stream: "stdout", - data: "-c, --collection Filter by one or more collections", - } - : undefined, - }, - { - name: "aborts the multi-collection capability probe without caching a failure", - qmd: () => ({ - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" }, - ], - }), - isTarget: (_cmd, args) => args[0] === "--help", - outputFor: (_cmd, args) => - args[0] === "search" ? { stream: "stdout", data: "[]" } : undefined, - neverSpawns: (_cmd, args) => args[0] === "search", - }, - { - name: "aborts the in-flight mcporter search subprocess when the caller signal aborts", - qmd: { - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }, - isTarget: (cmd, args) => isMcporterCommand(cmd) && args[0] === "call", - }, - { - name: "rejects the mcporter search before spawning a call subprocess when the caller signal is already aborted", - qmd: { - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }, - isTarget: (cmd, args) => isMcporterCommand(cmd) && args[0] === "call", - preAborted: true, - }, - ] satisfies AbortSearchScenario[]; - - it.each(abortSearchScenarios)("$name", runAbortSearchScenario); - - it("does not pass --no-rerank to direct query fallback from search mode", async () => { - configureQmd({ searchMode: "search", rerank: false }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild({ stream: "stderr", data: "unknown flag: --json", code: 2 }); - } - if (args[0] === "query") { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager, resolved } = await createManager(); - const maxResults = resolved.qmd?.limits.maxResults; - if (!maxResults) { - throw new Error("qmd maxResults missing"); - } - - await expect( - manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - - const searchAndQueryCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1]) - .filter( - (args): args is string[] => Array.isArray(args) && ["search", "query"].includes(args[0]), - ); - expect(searchAndQueryCalls).toEqual([ - ["search", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], - ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], - ]); - await manager.close(); - }); - - it("queues a forced sync behind an in-flight update", async () => { - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false, updateTimeoutMs: 1_000 }, - }); - - const firstUpdateSpawned = createDeferred(); - let updateCalls = 0; - let releaseFirstUpdate: (() => void) | null = null; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - updateCalls += 1; - if (updateCalls === 1) { - const first = createMockChild({ autoClose: false }); - releaseFirstUpdate = () => first.closeWith(0); - firstUpdateSpawned.resolve(); - return first; - } - return createMockChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - const inFlight = manager.sync({ reason: "interval" }); - const forced = manager.sync({ reason: "manual", force: true }); - - await firstUpdateSpawned.promise; - expect(updateCalls).toBe(1); - if (!releaseFirstUpdate) { - throw new Error("first update release missing"); - } - (releaseFirstUpdate as () => void)(); - - await Promise.all([inFlight, forced]); - expect(updateCalls).toBe(2); - await manager.close(); - }); - - it("honors multiple forced sync requests while forced queue is active", async () => { - configureQmd({ - update: { interval: "0s", debounceMs: 0, onBoot: false, updateTimeoutMs: 1_000 }, - }); - - const firstUpdateSpawned = createDeferred(); - const secondUpdateSpawned = createDeferred(); - let updateCalls = 0; - let releaseFirstUpdate: (() => void) | null = null; - let releaseSecondUpdate: (() => void) | null = null; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - updateCalls += 1; - if (updateCalls === 1) { - const first = createMockChild({ autoClose: false }); - releaseFirstUpdate = () => first.closeWith(0); - firstUpdateSpawned.resolve(); - return first; - } - if (updateCalls === 2) { - const second = createMockChild({ autoClose: false }); - releaseSecondUpdate = () => second.closeWith(0); - secondUpdateSpawned.resolve(); - return second; - } - return createMockChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - const inFlight = manager.sync({ reason: "interval" }); - const forcedOne = manager.sync({ reason: "manual", force: true }); - - await firstUpdateSpawned.promise; - expect(updateCalls).toBe(1); - if (!releaseFirstUpdate) { - throw new Error("first update release missing"); - } - (releaseFirstUpdate as () => void)(); - - await secondUpdateSpawned.promise; - const forcedTwo = manager.sync({ reason: "manual-again", force: true }); - - if (!releaseSecondUpdate) { - throw new Error("second update release missing"); - } - (releaseSecondUpdate as () => void)(); - - await Promise.all([inFlight, forcedOne, forcedTwo]); - expect(updateCalls).toBe(3); - await manager.close(); - }); - - const qmdCommandMatrixScenarios = [ - { - name: "scopes qmd queries to managed collections", - configure: () => - configureQmd({ - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" }, - ], - }), - expectedCommands: (maxResults) => [ - qmdQueryArgs("search", maxResults, "workspace-main"), - qmdQueryArgs("search", maxResults, "notes-main"), - ], - }, - { - name: "groups same-source qmd queries when the installed qmd supports multiple collection filters", - configure: () => - configureQmd({ - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" }, - ], - }), - supportsMultiCollection: true, - expectedCommands: (maxResults) => [ - qmdQueryArgs("search", maxResults, "workspace-main", "notes-main"), - ], - }, - { - name: "keeps mixed-source qmd queries in separate source groups", - configure: () => configureQmd({ sessions: { enabled: true } }), - supportsMultiCollection: true, - expectedCommands: (maxResults) => [ - qmdQueryArgs("search", maxResults, "workspace-main"), - qmdQueryArgs("search", maxResults, "sessions-main"), - ], - }, - { - name: "does not query phantom memory-alt collections when MEMORY.md exists", - configure: async () => { - await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root"); - configureQmd({ includeDefaultMemory: true, paths: [] }); - }, - expectedCommands: (maxResults) => [ - qmdQueryArgs("search", maxResults, "memory-root-main"), - qmdQueryArgs("search", maxResults, "memory-dir-main"), - ], - }, - { - name: "uses explicit external custom collection names verbatim at query time", - configure: async () => { - const sharedMirrorDir = path.join(tmpRoot, "shared-notion-mirror"); - await fs.mkdir(sharedMirrorDir); - configureQmd({ - paths: [{ path: sharedMirrorDir, pattern: "**/*.md", name: "notion-mirror" }], - }); - }, - expectedCommands: (maxResults) => [qmdQueryArgs("search", maxResults, "notion-mirror")], - }, - { - name: "runs qmd query per collection when query mode has multiple collection filters", - configure: () => - configureQmd({ - searchMode: "query", - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" }, - ], - }), - expectedCommands: (maxResults) => [ - qmdQueryArgs("query", maxResults, "workspace-main"), - qmdQueryArgs("query", maxResults, "notes-main"), - ], - }, - { - name: "uses per-collection query fallback when search mode rejects flags", - configure: () => - configureQmd({ - searchMode: "search", - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" }, - ], - }), - rejectSearchFlags: true, - expectedCommands: (maxResults) => [ - qmdQueryArgs("search", maxResults, "workspace-main"), - qmdQueryArgs("query", maxResults, "workspace-main"), - qmdQueryArgs("query", maxResults, "notes-main"), - ], - }, - ] satisfies QmdCommandMatrixScenario[]; - - it.each(qmdCommandMatrixScenarios)("$name", runQmdCommandMatrixScenario); - - it("runs qmd searches via mcporter and warns when startDaemon=false", async () => { - configureQmd({ - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "call") { - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - - logWarnMock.mockClear(); - await expect( - manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toStrictEqual([]); - - const mcporterCalls = spawnMock.mock.calls.filter((call: unknown[]) => - isMcporterCommand(call[0]), - ); - expect(mcporterCalls.length).toBeGreaterThan(0); - expect(mcporterCalls.map((call: unknown[]) => (call[1] as string[])[0])).not.toContain( - "daemon", - ); - expectMockMessageContains(logWarnMock, "cold-start"); - - await manager.close(); - }); - - it("uses QMD 1.1+ query tool with searches array via mcporter", async () => { - configureQmd({ - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - const commandPhases: string[] = []; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(commandPhases).toEqual(["pause"]); - // Verify it calls qmd.query (v2) not qmd.deep_search (v1) - expect(args[1]).toBe("qmd.query"); - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - // Verify QMD 1.1+ searches array format - expect(callArgs).toHaveProperty("searches"); - expect(Array.isArray(callArgs.searches)).toBe(true); - const searchTypes = callArgs.searches.map((search: { type?: unknown }) => search.type); - expect(searchTypes).toContain("lex"); - expect(searchTypes).toContain("vec"); - expect(searchTypes).toContain("hyde"); - expect(callArgs).toHaveProperty("collections", ["workspace-main"]); - // Should NOT have flat query/minScore (v1 format) - expect(callArgs).not.toHaveProperty("query"); - expect(callArgs).not.toHaveProperty("minScore"); - expect(callArgs).not.toHaveProperty("collection"); - expect(callArgs).not.toHaveProperty("rerank"); - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - await manager.search("hello", { - sessionKey: "agent:main:slack:dm:u123", - [MEMORY_SEARCH_DEADLINE_CONTROL]: (action) => { - commandPhases.push(action); - }, - }); - expect(commandPhases).toEqual(["pause", "resume"]); - await manager.close(); - }); - - it("passes rerank false to QMD 1.1+ query tool via mcporter when query reranking is disabled", async () => { - configureQmd({ - searchMode: "query", - rerank: false, - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(args[1]).toBe("qmd.query"); - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - expect(callArgs).toMatchObject({ - searches: [ - { type: "lex", query: "hello" }, - { type: "vec", query: "hello" }, - { type: "hyde", query: "hello" }, - ], - limit: expect.any(Number), - collections: ["workspace-main"], - rerank: false, - }); - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - await manager.close(); - }); - - it("disables the LLM reranker (rerank:false) for vsearch mode via mcporter", async () => { - configureQmd({ - searchMode: "vsearch", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - let captured: Record | null = null; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "call") { - captured = JSON.parse(requireArgAfter(args, "--args")); - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - await manager.close(); - - expect(captured).not.toBeNull(); - const sentArgs = captured as unknown as Record; - // vsearch is a vector-only mode (see buildV2Searches) — it must NOT trigger - // QMD's LLM reranker, which the "query" tool enables by default. - expect(sentArgs.rerank).toBe(false); - const searchTypes = (sentArgs.searches as Array<{ type?: unknown }>).map((s) => s.type); - expect(searchTypes).toEqual(["vec"]); - }); - - it("keeps hyphenated tokens in lexical QMD searches while normalizing semantic searches", async () => { - configureQmd({ - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(args[1]).toBe("qmd.query"); - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - expect(callArgs.searches).toEqual([ - { type: "lex", query: "sqlite-vec-qmd backend health 2026-05-04 multi-agent" }, - { type: "vec", query: "sqlite vec qmd backend health 2026 05 04 multi agent" }, - { type: "hyde", query: "sqlite vec qmd backend health 2026 05 04 multi agent" }, - ]); - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - await manager.search("sqlite-vec-qmd backend health 2026-05-04 multi-agent", { - sessionKey: "agent:main:slack:dm:u123", - }); - await manager.close(); - }); - - it("normalizes hyphenated tokens for vector-only QMD searches", async () => { - configureQmd({ - searchMode: "vsearch", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(args[1]).toBe("qmd.query"); - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - expect(callArgs.searches).toEqual([{ type: "vec", query: "sqlite vec backend health" }]); - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - await manager.search("sqlite-vec backend health", { - sessionKey: "agent:main:slack:dm:u123", - }); - await manager.close(); - }); - - it("wraps non-JSON mcporter stdout as a typed error instead of a raw SyntaxError", async () => { - configureQmd({ - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "call") { - // mcporter exits 0 but prints non-JSON to stdout (daemon warning, truncated - // output, or CLI flag mismatch). Without the guard this throws a raw - // SyntaxError out of runQmdSearchViaMcporter; the guard wraps it. - return makeQmdChild({ data: "mcporter: daemon warning: connection unstable\n" }); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - await expect( - manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }), - ).rejects.toThrow(/non-JSON stdout/i); - await manager.close(); - }); - - it("falls back to QMD <1.1 tool names when query tool is not found", async () => { - // qmdMcpToolVersion is an instance field — each createManager() starts fresh. - - configureQmd({ - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - let callCount = 0; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - callCount++; - const toolSelector = args[1]; - if (toolSelector === "qmd.query") { - // Simulate QMD <1.1 — "query" tool does not exist - // The error message appears in stdout (mcporter wraps MCP errors in JSON output) - queueMicrotask(() => { - child.stderr.emit("data", "MCP error -32602: Tool query not found"); - child.closeWith(1); - }); - return child; - } - if (toolSelector === "qmd.deep_search") { - // v1 tool exists — verify v1 args format - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - expect(callArgs).toHaveProperty("query"); - expect(callArgs).not.toHaveProperty("searches"); - // Return empty results (avoids needing a SQLite fixture) - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager } = await createManager(); - // The first search should try v2, fail, then retry with v1 - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - - // Should have logged the v1 fallback warning - expectMockMessageContains(logWarnMock, "falling back to v1 tool names"); - - // One v2 attempt (fails) + one v1 retry (succeeds) per collection - expect(callCount).toBe(2); - - await manager.close(); - }); - - it("uses an explicit mcporter search tool override with flat query args", async () => { - configureQmd({ - searchMode: "query", - searchTool: "hybrid_search", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - let expectedLimit = 0; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(args[1]).toBe("qmd.hybrid_search"); - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - expect(callArgs.query).toBe("hello"); - expect(callArgs.limit).toBe(expectedLimit); - expect(callArgs.minScore).toBe(0); - expect(callArgs.collection).toBe("workspace-main"); - expect(callArgs).not.toHaveProperty("searches"); - expect(callArgs).not.toHaveProperty("collections"); - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager, resolved } = await createManager(); - expectedLimit = resolved.qmd?.limits.maxResults ?? 0; - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - await manager.close(); - }); - - it("prefers mcporter start and end lines over snippet header offsets", async () => { - const expectedDocId = "line-123"; - configureQmd({ - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(args[1]).toBe("qmd.query"); - emitAndClose( - child, - "stdout", - JSON.stringify({ - results: [ - { - docid: expectedDocId, - score: 0.91, - collection: "workspace-main", - start_line: 8, - end_line: 10, - snippet: - "@@ -20,3\nline one\nline two\nline three ", - }, - ], - }), - ); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager } = await createManager(); - const inner = manager as unknown as { - db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void }; - }; - inner.db = { - prepare: (_query: string) => ({ - all: (arg: unknown) => { - if (typeof arg === "string" && arg.startsWith(expectedDocId)) { - return [{ collection: "workspace-main", path: "notes/welcome.md" }]; - } - return []; - }, - }), - close: () => {}, - }; - - await expect( - manager.search("line one", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([ - { - path: "notes/welcome.md", - startLine: 8, - endLine: 10, - score: 0.91, - snippet: "@@ -20,3\nline one\nline two\nline three ", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - - await manager.close(); - }); - - it("keeps per-result and aggregate QMD snippet limits UTF-16 safe", async () => { - const expectedDocId = "unicode-boundary"; - const snippet = "@@ -1,1\nabc😀tail"; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - emitAndClose( - child, - "stdout", - JSON.stringify({ - results: [ - { - docid: expectedDocId, - score: 0.91, - collection: "workspace-main", - snippet, - }, - ], - }), - ); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const searchWithLimits = async (limits: { - maxSnippetChars: number; - maxInjectedChars: number; - }) => { - const testConfig = { - ...cfg, - memory: { - backend: "qmd", - qmd: { - includeDefaultMemory: false, - searchMode: "query", - update: { interval: "0s", debounceMs: 60_000, onBoot: false }, - paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], - limits, - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }, - }, - } as OpenClawConfig; - const { manager } = await createManager({ cfg: testConfig }); - const inner = manager as unknown as { - db: { prepare: () => { all: () => unknown }; close: () => void }; - }; - inner.db = { - prepare: () => ({ - all: () => [{ collection: "workspace-main", path: "notes/unicode.md" }], - }), - close: () => {}, - }; - const results = await manager.search("unicode", { - sessionKey: "agent:main:slack:dm:u123", - }); - await manager.close(); - return results; - }; - - await expect(searchWithLimits({ maxSnippetChars: 12, maxInjectedChars: 100 })).resolves.toEqual( - [expect.objectContaining({ snippet: "@@ -1,1\nabc" })], - ); - await expect(searchWithLimits({ maxSnippetChars: 100, maxInjectedChars: 12 })).resolves.toEqual( - [expect.objectContaining({ snippet: "@@ -1,1\nabc" })], - ); - }); - - it("uses snippet header width when mcporter only returns a start line", async () => { - const expectedDocId = "line-456"; - configureQmd({ - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(args[1]).toBe("qmd.query"); - emitAndClose( - child, - "stdout", - JSON.stringify({ - results: [ - { - docid: expectedDocId, - score: 0.73, - collection: "workspace-main", - start_line: 8, - snippet: "@@ -20,3\nline one\nline two\nline three", - }, - ], - }), - ); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager } = await createManager(); - const inner = manager as unknown as { - db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void }; - }; - inner.db = { - prepare: (_query: string) => ({ - all: (arg: unknown) => { - if (typeof arg === "string" && arg.startsWith(expectedDocId)) { - return [{ collection: "workspace-main", path: "notes/welcome.md" }]; - } - return []; - }, - }), - close: () => {}, - }; - - await expect( - manager.search("line one", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([ - { - path: "notes/welcome.md", - startLine: 8, - endLine: 10, - score: 0.73, - snippet: "@@ -20,3\nline one\nline two\nline three", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - - await manager.close(); - }); - - it('uses unified v2 args when the explicit mcporter search tool override is "query"', async () => { - configureQmd({ - searchMode: "search", - searchTool: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(args[1]).toBe("qmd.query"); - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - expect(callArgs).toHaveProperty("searches", [{ type: "lex", query: "hello" }]); - expect(callArgs).toHaveProperty("collections", ["workspace-main"]); - expect(callArgs).not.toHaveProperty("query"); - expect(callArgs).not.toHaveProperty("minScore"); - expect(callArgs).not.toHaveProperty("collection"); - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager } = await createManager(); - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - await manager.close(); - }); - - it('passes rerank false when explicit mcporter search tool override is "query"', async () => { - configureQmd({ - searchMode: "query", - searchTool: "query", - rerank: false, - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - expect(args[1]).toBe("qmd.query"); - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - expect(callArgs).toMatchObject({ - searches: [ - { type: "lex", query: "hello" }, - { type: "vec", query: "hello" }, - { type: "hyde", query: "hello" }, - ], - collections: ["workspace-main"], - rerank: false, - }); - expect(callArgs).not.toHaveProperty("query"); - expect(callArgs).not.toHaveProperty("minScore"); - expect(callArgs).not.toHaveProperty("collection"); - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager } = await createManager(); - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - await manager.close(); - }); - - it('reuses the cached v1 tool across collections when the explicit mcporter override is "query"', async () => { - configureQmd({ - searchMode: "search", - searchTool: "query", - paths: [ - { path: path.join(workspaceDir, "notes-a"), pattern: "**/*.md", name: "workspace-a" }, - { path: path.join(workspaceDir, "notes-b"), pattern: "**/*.md", name: "workspace-b" }, - ], - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - const selectors: string[] = []; - let expectedLimit = 0; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - const selector = args[1] ?? ""; - selectors.push(selector); - if (selector === "qmd.query") { - queueMicrotask(() => { - child.stderr.emit("data", "MCP error -32602: Tool query not found"); - child.closeWith(1); - }); - return child; - } - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - expect(selector).toBe("qmd.search"); - expect(callArgs.query).toBe("hello"); - expect(callArgs.limit).toBe(expectedLimit); - expect(callArgs.minScore).toBe(0); - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager, resolved } = await createManager(); - expectedLimit = resolved.qmd?.limits.maxResults ?? 0; - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - - expect(selectors).toEqual(["qmd.query", "qmd.search", "qmd.search"]); - - await manager.close(); - }); - - it("uses an explicit mcporter search tool override across multiple collections", async () => { - configureQmd({ - searchMode: "query", - searchTool: "hybrid_search", - paths: [ - { path: path.join(workspaceDir, "notes-a"), pattern: "**/*.md", name: "workspace-a" }, - { path: path.join(workspaceDir, "notes-b"), pattern: "**/*.md", name: "workspace-b" }, - ], - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - const selectors: string[] = []; - const collections: string[] = []; - let expectedLimit = 0; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - selectors.push(args[1] ?? ""); - const callArgs = JSON.parse(requireArgAfter(args, "--args")); - collections.push(String(callArgs.collection ?? "")); - expect(callArgs.query).toBe("hello"); - expect(callArgs.limit).toBe(expectedLimit); - expect(callArgs.minScore).toBe(0); - expect(callArgs).not.toHaveProperty("searches"); - expect(callArgs).not.toHaveProperty("collections"); - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager, resolved } = await createManager(); - expectedLimit = resolved.qmd?.limits.maxResults ?? 0; - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - - expect(selectors).toEqual(["qmd.hybrid_search", "qmd.hybrid_search"]); - expect(collections).toEqual(["workspace-a-main", "workspace-b-main"]); - - await manager.close(); - }); - - it("does not pin v1 fallback when only the serialized query text contains tool-not-found words", async () => { - configureQmd({ - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - const selectors: string[] = []; - let firstQueryCall = true; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - selectors.push(args[1] ?? ""); - if (args[1] === "qmd.query" && firstQueryCall) { - firstQueryCall = false; - queueMicrotask(() => { - child.stderr.emit("data", "backend unavailable"); - child.closeWith(1); - }); - return child; - } - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager } = await createManager(); - - await expect( - manager.search("abc: Tool query not found", { - sessionKey: "agent:main:slack:dm:u123", - }), - ).resolves.toStrictEqual([]); - - await manager.search("hello again", { sessionKey: "agent:main:slack:dm:u123" }); - - expect(selectors.length).toBeGreaterThanOrEqual(2); - expect(selectors.every((selector) => selector === "qmd.query")).toBe(true); - expectMockMessageNotContains(logWarnMock, "falling back to v1 tool names"); - - await manager.close(); - }); - - it("does not pin v1 fallback when a timed out query contains tool-not-found words", async () => { - configureQmd({ - searchMode: "query", - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - const selectors: string[] = []; - let firstQueryCall = true; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (isMcporterCommand(cmd) && args[0] === "call") { - selectors.push(args[1] ?? ""); - if (args[1] === "qmd.query" && firstQueryCall) { - firstQueryCall = false; - return child; - } - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager } = await createManager(); - const commandClient = ( - manager as object as { - commands: { - runMcporter: ( - args: string[], - opts?: { timeoutMs?: number; signal?: AbortSignal }, - ) => Promise<{ stdout: string; stderr: string }>; - }; - } - ).commands; - const originalRunMcporter = commandClient.runMcporter.bind(commandClient); - let injectTimeoutOnce = true; - const runMcporterSpy = vi - .spyOn(commandClient, "runMcporter") - .mockImplementation(async (...args) => { - if (injectTimeoutOnce) { - injectTimeoutOnce = false; - firstQueryCall = false; - throw new Error( - 'mcporter call qmd.query --args {"query":"abc: Tool query not found"} timed out after 5000ms', - ); - } - return await originalRunMcporter(...args); - }); - - await expect( - manager.search("abc: Tool query not found", { - sessionKey: "agent:main:slack:dm:u123", - }), - ).rejects.toThrow("timed out after 5000ms"); - - await manager.search("hello again", { sessionKey: "agent:main:slack:dm:u123" }); - - expect(runMcporterSpy).toHaveBeenCalled(); - expect(selectors.length).toBeGreaterThanOrEqual(1); - expect(selectors.every((selector) => selector === "qmd.query")).toBe(true); - expectMockMessageNotContains(logWarnMock, "falling back to v1 tool names"); - - runMcporterSpy.mockRestore(); - await manager.close(); - }); - - it("resolves mcporter to a direct Windows entrypoint without enabling shell mode", async () => { - await withMockedWindowsPlatform(async () => { - const previousPath = process.env.PATH; - try { - const nodeModulesDir = path.join(tmpRoot, "node_modules"); - const shimDir = path.join(nodeModulesDir, ".bin"); - const packageDir = path.join(nodeModulesDir, "mcporter"); - const scriptPath = path.join(packageDir, "dist", "cli.js"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(shimDir, { recursive: true }); - await fs.writeFile(path.join(shimDir, "mcporter.cmd"), "@echo off\r\n", "utf8"); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "mcporter", version: "0.0.0", bin: { mcporter: "dist/cli.js" } }), - "utf8", - ); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - process.env.PATH = `${shimDir};${previousPath ?? ""}`; - - configureQmd({ - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - const child = createMockChild({ autoClose: false }); - if (args[0] === "call") { - emitAndClose(child, "stdout", JSON.stringify({ results: [] })); - return child; - } - emitAndClose(child, "stdout", "[]"); - return child; - }); - - const { manager } = await createManager(); - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - - const mcporterCall = spawnMock.mock.calls.find((call: unknown[]) => - (call[1] as string[] | undefined)?.includes("call"), - ); - const searchCall = requireValue(mcporterCall, "mcporter search call missing"); - const callCommand = searchCall[0]; - expect(typeof callCommand).toBe("string"); - const options = searchCall[2] as { shell?: boolean } | undefined; - expect(callCommand).not.toBe("mcporter.cmd"); - expect(options?.shell).not.toBe(true); - - await manager.close(); - } finally { - process.env.PATH = previousPath; - } - }); - }); - - it("fails closed on Windows EINVAL cmd-shim failures instead of retrying through the shell", async () => { - await withMockedWindowsPlatform(async () => { - const previousPath = process.env.PATH; - try { - const shimDir = await fs.mkdtemp(path.join(tmpRoot, "mcporter-shim-")); - await fs.writeFile(path.join(shimDir, "mcporter.cmd"), "@echo off\n"); - process.env.PATH = `${shimDir};${previousPath ?? ""}`; - - configureQmd({ - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - let firstCallCommand: string | null = null; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (args[0] === "call" && firstCallCommand === null) { - firstCallCommand = cmd; - } - if (args[0] === "call" && typeof cmd === "string" && cmd.toLowerCase().endsWith(".cmd")) { - const child = createMockChild({ autoClose: false }); - queueMicrotask(() => { - const err = Object.assign(new Error("spawn EINVAL"), { code: "EINVAL" }); - child.emit("error", err); - }); - return child; - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - await expect( - manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }), - ).rejects.toThrow(/without shell execution|EINVAL/); - const attemptedCmdShim = (firstCallCommand ?? "").toLowerCase().endsWith(".cmd"); - if (attemptedCmdShim) { - expect( - spawnMock.mock.calls.some( - (call: unknown[]) => - call[0] === "mcporter" && - (call[2] as { shell?: boolean } | undefined)?.shell === true, - ), - ).toBe(false); - } - await manager.close(); - } finally { - process.env.PATH = previousPath; - } - }); - }); - - it("passes manager-scoped XDG env to mcporter commands", async () => { - configureQmd({ - mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "call") { - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); - - const mcporterCall = spawnMock.mock.calls.find( - (call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])[0] === "call", - ); - const searchCall = requireValue(mcporterCall, "mcporter search call missing"); - const spawnOpts = searchCall[2] as { env?: NodeJS.ProcessEnv } | undefined; - const normalizePath = (value?: string) => value?.replace(/\\/g, "/"); - expect(normalizePath(spawnOpts?.env?.XDG_CONFIG_HOME)).toContain("/agents/main/qmd/xdg-config"); - expect(normalizePath(spawnOpts?.env?.QMD_CONFIG_DIR)).toContain( - "/agents/main/qmd/xdg-config/qmd", - ); - expect(normalizePath(spawnOpts?.env?.XDG_CACHE_HOME)).toContain("/agents/main/qmd/xdg-cache"); - expect(spawnOpts?.env?.PATH?.split(path.delimiter)).toContain(path.dirname(process.execPath)); - - await manager.close(); - }); - - it("retries mcporter daemon start after a failure", async () => { - configureQmd({ - mcporter: { enabled: true, serverName: "qmd", startDaemon: true }, - }); - - let daemonAttempts = 0; - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "daemon") { - daemonAttempts += 1; - return daemonAttempts === 1 - ? makeQmdChild({ stream: "stderr", data: "failed", code: 1 }) - : makeQmdChild({ data: "" }); - } - if (isMcporterCommand(cmd) && args[0] === "call") { - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - - await manager.search("one", { sessionKey: "agent:main:slack:dm:u123" }); - await manager.search("two", { sessionKey: "agent:main:slack:dm:u123" }); - - expect(daemonAttempts).toBe(2); - - await manager.close(); - }); - - it("starts the mcporter daemon only once when enabled", async () => { - configureQmd({ - mcporter: { enabled: true, serverName: "qmd", startDaemon: true }, - }); - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (isMcporterCommand(cmd) && args[0] === "daemon") { - return makeQmdChild({ data: "" }); - } - if (isMcporterCommand(cmd) && args[0] === "call") { - return makeMcporterChild(); - } - return makeQmdChild(); - }); - - const { manager } = await createManager(); - - await manager.search("one", { sessionKey: "agent:main:slack:dm:u123" }); - await manager.search("two", { sessionKey: "agent:main:slack:dm:u123" }); - - const daemonStarts = spawnMock.mock.calls.filter( - (call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])[0] === "daemon", - ); - expect(daemonStarts).toHaveLength(1); - - await manager.close(); - }); - - it("fails closed when no managed collections are configured", async () => { - configureQmd({ paths: [] }); - - const { manager } = await createManager(); - - const results = await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }); - expect(results).toStrictEqual([]); - expect( - spawnMock.mock.calls.some((call: unknown[]) => (call[1] as string[])?.[0] === "query"), - ).toBe(false); - await manager.close(); - }); - - it("diversifies mixed session and memory search results so memory hits are retained", async () => { - configureQmd({ sessions: { enabled: true } }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search" && args.includes("workspace-main")) { - return makeQmdResults({ docid: "m1", score: 0.6, snippet: "@@ -1,1\nmemory fact" }); - } - if (args[0] === "search" && args.includes("sessions-main")) { - return makeQmdResults( - { docid: "s1", score: 0.99, snippet: "@@ -1,1\nsession top 1" }, - { docid: "s2", score: 0.95, snippet: "@@ -1,1\nsession top 2" }, - { docid: "s3", score: 0.91, snippet: "@@ -1,1\nsession top 3" }, - { docid: "s4", score: 0.88, snippet: "@@ -1,1\nsession top 4" }, - ); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - const inner = manager as unknown as { - db: { prepare: (_query: string) => { all: (arg: unknown) => unknown }; close: () => void }; - }; - inner.db = { - prepare: (_query: string) => ({ - all: (arg: unknown) => { - switch (arg) { - case "m1": - return [{ collection: "workspace-main", path: "memory/facts.md" }]; - case "s1": - case "s2": - case "s3": - case "s4": - return [ - { - collection: "sessions-main", - path: `${arg}.md`, - }, - ]; - default: - return []; - } - }, - }), - close: () => {}, - }; - - const results = await manager.search("fact", { - maxResults: 4, - sessionKey: "agent:main:slack:dm:u123", - }); - - expect(results).toHaveLength(4); - const sources = results.map((entry) => entry.source); - expect(sources).toContain("memory"); - expect(sources).toContain("sessions"); - await manager.close(); - }); - - it("logs and continues when qmd embed times out", async () => { - vi.useFakeTimers(); - configureQmd({ - update: { interval: "0s", debounceMs: 0, onBoot: false, embedTimeoutMs: 20 }, - }); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "embed") { - return createMockChild({ autoClose: false }); - } - return createMockChild(); - }); - - const resolved = resolveMemoryBackendConfigForTest(cfg, agentId); - const createPromise = QmdMemoryManager.create({ - cfg, - agentId, - resolved, - withLease: withLeaseMock, - mode: "status", - }); - await vi.advanceTimersByTimeAsync(0); - const manager = requireValue(trackManager(await createPromise), "manager missing"); - const syncPromise = manager.sync({ reason: "manual" }); - const resolvedSync = expect(syncPromise).resolves.toBeUndefined(); - await vi.advanceTimersByTimeAsync(20); - await resolvedSync; - await manager.close(); - }); - - it("does not store qmd embed backoff when the process clock is invalid", async () => { - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }); - const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001); - const { manager } = await createManager({ mode: "status" }); - try { - ( - manager as unknown as { - noteEmbedFailure: (reason: string, err: unknown) => void; - } - ).noteEmbedFailure("manual", new Error("embed failed")); - } finally { - dateNowSpy.mockRestore(); - } - - const status = manager.status() as { custom?: { qmd?: { embedBackoffUntil?: number | null } } }; - expect(status.custom?.qmd?.embedBackoffUntil).toBeNull(); - await manager.close(); - }); - - it("runs periodic embed maintenance even when regular update scheduling is disabled", async () => { - vi.useFakeTimers(); - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false, embedInterval: "5m" }, - }); - - const { manager } = await createManager({ mode: "full" }); - - const commandCallsBefore = spawnMock.mock.calls.filter((call: unknown[]) => { - const args = call[1] as string[]; - return args[0] === "update" || args[0] === "embed"; - }); - expect(commandCallsBefore).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(5 * 60_000); - - const commandCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(commandCalls).toEqual([["update"], ["embed"]]); - - await manager.close(); - }); - - it("runs periodic embed maintenance when embed cadence is faster than update cadence", async () => { - vi.useFakeTimers(); - configureQmd({ - searchMode: "query", - update: { interval: "20m", debounceMs: 0, onBoot: false, embedInterval: "5m" }, - }); - - const { manager } = await createManager({ mode: "full" }); - - await vi.advanceTimersByTimeAsync(5 * 60_000); - - const commandCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(commandCalls).toEqual([["update"], ["embed"]]); - - await manager.close(); - }); - - it("does not schedule redundant embed maintenance when regular updates are already more frequent", async () => { - vi.useFakeTimers(); - configureQmd({ - searchMode: "query", - update: { interval: "5m", debounceMs: 0, onBoot: false, embedInterval: "20m" }, - }); - - const { manager } = await createManager({ mode: "full" }); - - await vi.advanceTimersByTimeAsync(6 * 60_000); - - const commandCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(commandCalls).toEqual([["update"], ["embed"]]); - - await manager.close(); - }); - - it("skips periodic embed maintenance in lexical search mode", async () => { - vi.useFakeTimers(); - configureQmd({ - searchMode: "search", - update: { interval: "0s", debounceMs: 0, onBoot: false, embedInterval: "5m" }, - }); - - const { manager } = await createManager({ mode: "full" }); - - await vi.advanceTimersByTimeAsync(5 * 60_000); - - const commandCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(commandCalls).toStrictEqual([]); - - await manager.close(); - }); - - it("delays the first periodic embed maintenance run by stable startup jitter", async () => { - vi.useFakeTimers(); - embedStartupJitterSpy?.mockRestore(); - embedStartupJitterSpy = vi - .spyOn( - QmdMemoryManager.prototype as unknown as { - resolveEmbedStartupJitterMs: () => number; - }, - "resolveEmbedStartupJitterMs", - ) - .mockReturnValue(60_000); - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false, embedInterval: "5m" }, - }); - - const { manager } = await createManager({ mode: "full" }); - - await vi.advanceTimersByTimeAsync(59_999); - const beforeCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(beforeCalls).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(1); - const commandCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(commandCalls).toEqual([["update"], ["embed"]]); - - await manager.close(); - }); - - it("serializes qmd embeds within a process before taking the shared SQLite lease", async () => { - vi.useFakeTimers(); - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }); - const embedChildren: MockChild[] = []; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "embed") { - const child = createMockChild({ autoClose: false }); - embedChildren.push(child); - return child; - } - return createMockChild(); - }); - - const first = await createManager({ mode: "status" }); - const second = await createManager({ mode: "status" }); - withLeaseMock.mockClear(); - const firstSync = first.manager.sync({ reason: "manual", force: true }); - await vi.advanceTimersByTimeAsync(0); - expect(embedChildren).toHaveLength(1); - const [leaseOptions, leaseTask] = firstEmbedLeaseCall(); - expect(leaseOptions).toMatchObject({ - namespace: "qmd", - key: "embed", - database: { scope: "shared" }, - leaseMs: 15 * 60 * 1000, - waitMs: 15 * 60 * 1000, - }); - expect(typeof leaseTask).toBe("function"); - - const secondSync = second.manager.sync({ reason: "manual", force: true }); - await vi.advanceTimersByTimeAsync(0); - expect(embedChildren).toHaveLength(1); - - embedChildren[0]?.closeWith(0); - await vi.advanceTimersByTimeAsync(0); - expect(embedChildren).toHaveLength(2); - - embedChildren[1]?.closeWith(0); - await expect(firstSync).resolves.toBeUndefined(); - await expect(secondSync).resolves.toBeUndefined(); - await first.manager.close(); - await second.manager.close(); - }); - - it("drops a queued embed when its manager closes", async () => { - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }); - const embedChildren: MockChild[] = []; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "embed") { - const child = createMockChild({ autoClose: false }); - embedChildren.push(child); - return child; - } - return createMockChild(); - }); - - const first = await createManager({ mode: "status" }); - const second = await createManager({ mode: "status" }); - withLeaseMock.mockClear(); - const firstSync = first.manager.sync({ reason: "manual", force: true }); - await waitUntil(() => embedChildren.length === 1); - const secondSync = second.manager.sync({ reason: "manual", force: true }); - await waitUntil(() => writeLeaseCalls().length >= 3); - - await second.manager.close(); - await expect(secondSync).resolves.toBeUndefined(); - expect(embedChildren).toHaveLength(1); - - embedChildren[0]?.closeWith(0); - await expect(firstSync).resolves.toBeUndefined(); - await first.manager.close(); - }); - - it("serializes both the qmd update and embed writes on one per-agent lease (issue #66339)", async () => { - // Regression for #66339: the update AND embed phases both write the same - // qmd index.sqlite. A foreground `memory search` dirty-sync and a background - // gateway update/embed run in separate processes, which the in-process queues - // cannot serialize, so the writers collided with SQLITE_BUSY. Both writes now - // take one per-agent SQLite write lease; embed additionally keeps the - // global SQLite lease for ML-resource serialization. - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }); - spawnMock.mockImplementation(() => createMockChild()); - - const { manager } = await createManager({ mode: "status" }); - withLeaseMock.mockClear(); - await expect(manager.sync({ reason: "manual", force: true })).resolves.toBeUndefined(); - - const [leaseOptions, leaseTask] = firstWriteLeaseCall(); - expect(leaseOptions).toMatchObject({ - namespace: "qmd", - key: "write", - database: { scope: "agent", agentId }, - }); - expect(leaseOptions.leaseMs).toBeGreaterThanOrEqual(5 * 60 * 1000); - expect(leaseOptions.waitMs).toBeGreaterThanOrEqual(5 * 60 * 1000); - expect(typeof leaseTask).toBe("function"); - - // A forced sync runs both the update and the embed write, so both acquire the - // shared per-agent write lease; the embed also still takes the global embed lease. - expect(writeLeaseCalls().length).toBeGreaterThanOrEqual(2); - const embedLeaseTaken = withLeaseMock.mock.calls.some( - (entry) => entry[0].database.scope === "shared" && entry[0].key === "embed", - ); - expect(embedLeaseTaken).toBe(true); - - await manager.close(); - }); - - it("clamps derived lease durations to the public timer-safe maximum", async () => { - configureQmd({ - searchMode: "query", - update: { - interval: "0s", - debounceMs: 0, - onBoot: false, - updateTimeoutMs: MAX_TIMER_TIMEOUT_MS, - embedTimeoutMs: MAX_TIMER_TIMEOUT_MS, - }, - }); - - const { manager } = await createManager({ mode: "status" }); - withLeaseMock.mockClear(); - await manager.sync({ reason: "manual", force: true }); - - expect(withLeaseMock).toHaveBeenCalled(); - for (const [options] of withLeaseMock.mock.calls as LeaseCall[]) { - expect(options.leaseMs).toBeLessThanOrEqual(MAX_TIMER_TIMEOUT_MS); - expect(options.waitMs).toBeLessThanOrEqual(MAX_TIMER_TIMEOUT_MS); - } - await manager.close(); - }); - - it("preserves update and embed intent when the write lease fails after the callback", async () => { - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }); - const { manager } = await createManager({ mode: "status" }); - (manager as unknown as { dirty: boolean }).dirty = true; - (manager as unknown as { lastEmbedAt: number | null }).lastEmbedAt = Date.now(); - let rejectAfterCallback = true; - const leaseLost = new PluginStateLeaseError("write lease lost", { - code: "PLUGIN_STATE_LEASE_LOST", - }); - withLeaseMock.mockImplementation( - async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => { - const result = await run({ - signal: options.signal ?? new AbortController().signal, - assertOwned: vi.fn(), - }); - if (rejectAfterCallback && options.database.scope === "agent") { - throw leaseLost; - } - return result as T; - }, - ); - - await expect(manager.sync({ reason: "manual", force: true })).rejects.toBe(leaseLost); - expect(manager.status().dirty).toBe(true); - expect( - (manager.status() as { custom?: { qmd?: { lastUpdateAt?: number | null } } }).custom?.qmd - ?.lastUpdateAt, - ).toBeNull(); - expect(countQmdCommand((args) => args[0] === "update")).toBe(1); - - rejectAfterCallback = false; - await manager.sync({ reason: "retry" }); - expect(manager.status().dirty).toBe(false); - expect( - (manager.status() as { custom?: { qmd?: { lastUpdateAt?: number | null } } }).custom?.qmd - ?.lastUpdateAt, - ).toEqual(expect.any(Number)); - expect(countQmdCommand((args) => args[0] === "update")).toBe(2); - expect(countQmdCommand((args) => args[0] === "embed")).toBe(1); - await manager.close(); - }); - - it("aborts collection reconciliation when its write lease is lost", async () => { - await configureMemoryCoreDreamingStateForTests(); - const leaseController = new AbortController(); - const leaseLost = new PluginStateLeaseError("reconciliation lease lost", { - code: "PLUGIN_STATE_LEASE_LOST", - }); - let listKill: ReturnType | undefined; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - const child = createMockChild({ autoClose: false }); - const kill = vi.fn(() => queueMicrotask(() => child.emit("close", null))); - Object.assign(child, { kill }); - listKill = kill; - return child; - } - return createMockChild(); - }); - withLeaseMock.mockImplementation( - async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => { - const signal = options.signal - ? AbortSignal.any([options.signal, leaseController.signal]) - : leaseController.signal; - return (await run({ - signal, - assertOwned: () => signal.throwIfAborted(), - })) as T; - }, - ); - - const creating = createManager({ mode: "cli" }); - creating.catch(() => undefined); - await waitUntil(() => listKill !== undefined); - leaseController.abort(leaseLost); - - await expect(creating).rejects.toBe(leaseLost); - expect(listKill).toHaveBeenCalledWith("SIGKILL"); - expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(0); - }); - - it("aborts an in-flight qmd update when its write lease is lost", async () => { - configureQmd({ - searchMode: "search", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }); - const { manager } = await createManager({ mode: "status" }); - const leaseController = new AbortController(); - const leaseLost = new PluginStateLeaseError("update lease lost", { - code: "PLUGIN_STATE_LEASE_LOST", - }); - let updateKill: ReturnType | undefined; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - const child = createMockChild({ autoClose: false }); - const kill = vi.fn(() => queueMicrotask(() => child.emit("close", null))); - Object.assign(child, { kill }); - updateKill = kill; - return child; - } - return createMockChild(); - }); - withLeaseMock.mockImplementation( - async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => { - const signal = options.signal - ? AbortSignal.any([options.signal, leaseController.signal]) - : leaseController.signal; - return (await run({ - signal, - assertOwned: () => signal.throwIfAborted(), - })) as T; - }, - ); - - const syncing = manager.sync({ reason: "manual", force: true }); - syncing.catch(() => undefined); - await waitUntil(() => updateKill !== undefined); - leaseController.abort(leaseLost); - - await expect(syncing).rejects.toBe(leaseLost); - expect(updateKill).toHaveBeenCalledWith("SIGKILL"); - expect( - (manager.status() as { custom?: { qmd?: { lastUpdateAt?: number | null } } }).custom?.qmd - ?.lastUpdateAt, - ).toBeNull(); - await manager.close(); - }); - - it.each(["shared", "agent"] as const)( - "fails closed when the %s embed lease is lost", - async (lostScope) => { - configureQmd({ - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }); - const { manager } = await createManager({ mode: "status" }); - // A forced sync must retain explicit embed intent after lease loss even - // when the normal embed interval would consider a recent embed fresh. - (manager as unknown as { lastEmbedAt: number | null }).lastEmbedAt = Date.now(); - const leaseLost = new PluginStateLeaseError(`${lostScope} embed lease lost`, { - code: "PLUGIN_STATE_LEASE_LOST", - }); - let embedPhase = false; - let targetController: AbortController | undefined; - let embedKill: ReturnType | undefined; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "embed") { - const child = createMockChild({ autoClose: false }); - const kill = vi.fn(() => queueMicrotask(() => child.emit("close", null))); - Object.assign(child, { kill }); - embedKill = kill; - return child; - } - return createMockChild(); - }); - withLeaseMock.mockImplementation( - async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => { - const isSharedEmbed = options.database.scope === "shared" && options.key === "embed"; - if (isSharedEmbed) { - embedPhase = true; - } - const isTarget = lostScope === "shared" ? isSharedEmbed : embedPhase && !isSharedEmbed; - const controller = isTarget ? new AbortController() : undefined; - if (controller) { - targetController = controller; - } - const signals = [options.signal, controller?.signal].filter( - (signal): signal is AbortSignal => signal !== undefined, - ); - const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0]!; - return (await run({ - signal, - assertOwned: () => signal.throwIfAborted(), - })) as T; - }, - ); - - const syncing = manager.sync({ reason: "manual", force: true }); - syncing.catch(() => undefined); - await waitUntil(() => embedKill !== undefined && targetController !== undefined); - targetController?.abort(leaseLost); - - await expect(syncing).rejects.toBe(leaseLost); - expect(embedKill).toHaveBeenCalledWith("SIGKILL"); - expect(manager.status().dirty).toBe(true); - expect( - (manager.status() as { custom?: { qmd?: { lastUpdateAt?: number | null } } }).custom?.qmd - ?.lastUpdateAt, - ).toBeNull(); - - spawnMock.mockImplementation(() => createMockChild()); - withLeaseMock.mockImplementation( - async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => - await run({ - signal: options.signal ?? new AbortController().signal, - assertOwned: vi.fn(), - }), - ); - await expect(manager.sync({ reason: "retry" })).resolves.toBeUndefined(); - expect(countQmdCommand((args) => args[0] === "embed")).toBe(2); - expect(manager.status().dirty).toBe(false); - await manager.close(); - }, - ); - - it("does not hold the per-agent write lease while waiting for embed capacity", async () => { - configureQmd( - { - searchMode: "query", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }, - { - agents: { - ...cfg.agents, - list: [ - { id: agentId, default: true, workspace: workspaceDir }, - { id: "other", workspace: workspaceDir }, - ], - }, - }, - ); - spawnMock.mockImplementation(() => createMockChild()); - - let releaseFirstEmbed!: () => void; - const firstEmbedLocked = new Promise((resolve) => { - withLeaseMock.mockImplementation( - async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ) => { - if (options.database.scope === "shared" && !releaseFirstEmbed) { - resolve(); - await new Promise((release) => { - releaseFirstEmbed = release; - }); - } - return await run({ - signal: options.signal ?? new AbortController().signal, - assertOwned: vi.fn(), - }); - }, - ); - }); - - const first = await createManager({ mode: "status" }); - const second = await createManager({ mode: "status", agentId: "other" }); - withLeaseMock.mockClear(); - const firstSync = first.manager.sync({ reason: "manual", force: true }); - await firstEmbedLocked; - - const secondSync = second.manager.sync({ reason: "manual", force: true }); - try { - await waitUntil(() => writeLeaseCalls().length >= 2); - - // The second manager may run its update, but its embed must not take a store - // write lease while it is still queued behind the first embed. - expect(writeLeaseCalls().length).toBe(2); - } finally { - releaseFirstEmbed(); - } - - await Promise.all([firstSync, secondSync]); - expect(writeLeaseCalls().length).toBeGreaterThanOrEqual(4); - - await first.manager.close(); - await second.manager.close(); - }); - - it("serializes session exports across managers for the same agent", async () => { - configureQmd({ - update: { interval: "0s", debounceMs: 0, onBoot: false }, - sessions: { enabled: true }, - }); - - const sessionsDir = path.join(stateDir, "agents", agentId, "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - await fs.writeFile( - path.join(sessionsDir, "session-1.jsonl"), - '{"type":"message","message":{"role":"user","content":"hello"}}\n', - "utf-8", - ); - - const firstEntered = createDeferred(); - const releaseFirst = createDeferred(); - let activeExports = 0; - let overlapped = false; - const exportSpy = vi - .spyOn( - QmdMemoryManager.prototype as unknown as { - exportSessions: (lease: { - signal: AbortSignal; - assertOwned: () => void; - }) => Promise; - }, - "exportSessions", - ) - .mockImplementation(async (_signal) => { - activeExports += 1; - if (activeExports > 1) { - overlapped = true; - } - if (activeExports === 1) { - firstEntered.resolve(); - await releaseFirst.promise; - } - activeExports -= 1; - }); - - const first = await createManager({ mode: "status" }); - const second = await createManager({ mode: "status" }); - - try { - const firstSync = first.manager.sync({ reason: "manual", force: true }); - await firstEntered.promise; - - const secondSync = second.manager.sync({ reason: "manual", force: true }); - await Promise.resolve(); - - expect(exportSpy).toHaveBeenCalledTimes(1); - expect(overlapped).toBe(false); - - releaseFirst.resolve(); - await Promise.all([firstSync, secondSync]); - - expect(exportSpy).toHaveBeenCalledTimes(2); - expect(overlapped).toBe(false); - } finally { - exportSpy.mockRestore(); - await first.manager.close(); - await second.manager.close(); - } - }); - - it("maps exported QMD artifacts to the persisted session identity", async () => { - configureQmd({ - update: { interval: "0s", debounceMs: 0, onBoot: false }, - sessions: { enabled: true }, - }); - - await seedQmdSessionTranscript({ - agentId, - content: "hello mapped session", - sessionId: "actual-session", - stateDir, - sessionKey: "agent:main:chat:thread", - }); - - const { manager } = await createManager({ mode: "status" }); - await ( - manager as unknown as { - exportSessions: (lease: { signal: AbortSignal; assertOwned: () => void }) => Promise; - } - ).exportSessions({ signal: new AbortController().signal, assertOwned: vi.fn() }); - const indexPath = (manager as unknown as { indexPath: string }).indexPath; - const identity = resolveQmdSessionArtifactIdentity({ - artifactPath: "actual-session.md", - collection: "sessions-main", - indexPath, - searchPath: "qmd/sessions-main/actual-session.md", - }); - - expect(identity).toEqual({ - agentId, - archived: false, - memoryKey: formatSessionTranscriptMemoryHitKey({ - agentId, - sessionId: "actual-session", - }), - sessionId: "actual-session", - }); - - await manager.close(); - }); - - it("does not publish session artifact mappings after lease ownership is lost", async () => { - configureQmd({ - update: { interval: "0s", debounceMs: 0, onBoot: false }, - sessions: { enabled: true }, - }); - await seedQmdSessionTranscript({ - agentId, - content: "lease guarded session", - sessionId: "lease-guarded-session", - stateDir, - sessionKey: "agent:main:chat:lease-guarded", - }); - const { manager } = await createManager({ mode: "status" }); - const leaseLost = new PluginStateLeaseError("session export lease lost", { - code: "PLUGIN_STATE_LEASE_LOST", - }); - let ownershipChecks = 0; - - await expect( - ( - manager as unknown as { - exportSessions: (lease: { - signal: AbortSignal; - assertOwned: () => void; - }) => Promise; - } - ).exportSessions({ - signal: new AbortController().signal, - assertOwned: () => { - ownershipChecks += 1; - if (ownershipChecks === 3) { - throw leaseLost; - } - }, - }), - ).rejects.toBe(leaseLost); - - const indexPath = (manager as unknown as { indexPath: string }).indexPath; - expect( - resolveQmdSessionArtifactIdentity({ - artifactPath: "lease-guarded-session.md", - collection: "sessions-main", - indexPath, - searchPath: "qmd/sessions-main/lease-guarded-session.md", - }), - ).toBeNull(); - await manager.close(); - }); - - it("skips queued session export work after close while waiting on the shared update queue", async () => { - configureQmd({ - update: { interval: "0s", debounceMs: 0, onBoot: false }, - sessions: { enabled: true }, - }); - - const sessionsDir = path.join(stateDir, "agents", agentId, "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - await fs.writeFile( - path.join(sessionsDir, "session-1.jsonl"), - '{"type":"message","message":{"role":"user","content":"hello"}}\n', - "utf-8", - ); - - const firstEntered = createDeferred(); - const releaseFirst = createDeferred(); - const exportSpy = vi - .spyOn( - QmdMemoryManager.prototype as unknown as { - exportSessions: (lease: { - signal: AbortSignal; - assertOwned: () => void; - }) => Promise; - }, - "exportSessions", - ) - .mockImplementation(async (_signal) => { - if (exportSpy.mock.calls.length === 1) { - firstEntered.resolve(); - await releaseFirst.promise; - } - }); - - const first = await createManager({ mode: "status" }); - const second = await createManager({ mode: "status" }); - - try { - const firstSync = first.manager.sync({ reason: "manual", force: true }); - await firstEntered.promise; - - const secondSync = second.manager.sync({ reason: "manual", force: true }); - await Promise.resolve(); - - const closeSecond = second.manager.close(); - await expect(closeSecond).resolves.toBeUndefined(); - - releaseFirst.resolve(); - await Promise.all([firstSync, secondSync]); - - expect(exportSpy).toHaveBeenCalledTimes(1); - const updateCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update"); - expect(updateCalls).toHaveLength(1); - } finally { - exportSpy.mockRestore(); - await first.manager.close(); - await second.manager.close(); - } - }); - - it("skips qmd embed in lexical search mode for forced sync", async () => { - configureQmd({ - searchMode: "search", - update: { interval: "0s", debounceMs: 0, onBoot: false }, - }); - - const { manager } = await createManager({ mode: "status" }); - await manager.sync({ reason: "manual", force: true }); - - const commandCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args: string[]) => args[0] === "update" || args[0] === "embed"); - expect(commandCalls).toEqual([["update"]]); - await manager.close(); - }); - - it("retries boot update when qmd reports a retryable lock error", async () => { - vi.useFakeTimers(); - configureQmd({ - searchMode: "search", - update: { interval: "0s", debounceMs: 60_000, onBoot: true, waitForBootSync: true }, - }); - - let updateCalls = 0; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - updateCalls += 1; - const child = createMockChild({ autoClose: false }); - if (updateCalls === 1) { - emitAndClose(child, "stderr", "SQLITE_BUSY: database is locked", 2); - } else { - emitAndClose(child, "stdout", "", 0); - } - return child; - } - return createMockChild(); - }); - - const managerPromise = createManager({ mode: "full" }); - await waitUntil(() => updateCalls === 1); - await vi.advanceTimersByTimeAsync(500); - await waitUntil(() => updateCalls === 2); - const { manager } = await managerPromise; - - expect(updateCalls).toBe(2); - await manager.close(); - }); - - it("succeeds on qmd update even when stdout exceeds the output cap", async () => { - // Regression test for #24966: large indexes produce >200K chars of stdout - // during `qmd update`, which used to fail with "produced too much output". - const largeOutput = "x".repeat(300_000); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "update") { - const child = createMockChild({ autoClose: false }); - emitAndClose(child, "stdout", largeOutput); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "status" }); - // sync triggers runQmdUpdateOnce -> runQmd(["update"], { discardOutput: true }) - await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined(); - await manager.close(); - }); - - it("scopes by channel for agent-prefixed session keys", async () => { - configureQmd({ - scope: { - default: "deny", - rules: [{ action: "allow", match: { channel: "slack" } }], - }, - }); - const { manager } = await createManager(); - - const isAllowed = (key?: string) => - (manager as unknown as { isScopeAllowed: (key?: string) => boolean }).isScopeAllowed(key); - expect(isAllowed("agent:main:slack:channel:c123")).toBe(true); - expect(isAllowed("agent:main:slack:direct:u123")).toBe(true); - expect(isAllowed("agent:main:slack:dm:u123")).toBe(true); - expect(isAllowed("agent:main:discord:direct:u123")).toBe(false); - expect(isAllowed("agent:main:discord:channel:c123")).toBe(false); - - await manager.close(); - }); - - it("logs when qmd scope denies search", async () => { - configureQmd({ - scope: { - default: "deny", - rules: [{ action: "allow", match: { chatType: "direct" } }], - }, - }); - const { manager } = await createManager(); - - logWarnMock.mockClear(); - const beforeCalls = spawnMock.mock.calls.length; - await expect( - manager.search("blocked", { sessionKey: "agent:main:discord:channel:c123" }), - ).resolves.toStrictEqual([]); - - expect(spawnMock.mock.calls.length).toBe(beforeCalls); - expectMockMessageContains(logWarnMock, "qmd search denied by scope"); - expectMockMessageContains(logWarnMock, "chatType=channel"); - - await manager.close(); - }); - - it("blocks non-markdown or symlink reads for qmd paths", async () => { - const { manager } = await createManager(); - - const textPath = path.join(workspaceDir, "secret.txt"); - await fs.writeFile(textPath, "nope", "utf-8"); - await expect(manager.readFile({ relPath: "qmd/workspace-main/secret.txt" })).rejects.toThrow( - "path required", - ); - - const target = path.join(workspaceDir, "target.md"); - await fs.writeFile(target, "ok", "utf-8"); - const link = path.join(workspaceDir, "link.md"); - await fs.symlink(target, link); - await expect(manager.readFile({ relPath: "qmd/workspace-main/link.md" })).rejects.toThrow( - "path required", - ); - - await manager.close(); - }); - - it("blocks memory_get reads of remember-only session exports", async () => { - configureQmd( - {}, - { - agents: { - ...cfg.agents, - list: [{ id: "main", memory: { search: { rememberAcrossConversations: true } } }], - }, - }, - ); - const { manager } = await createManager(); - - // Remember-only export is search-only for trusted recall; ordinary - // memory_get must not read transcript exports the operator never opted into. - await expect(manager.readFile({ relPath: "qmd/sessions-main/export.md" })).rejects.toThrow( - "path required", - ); - - await manager.close(); - }); - - it("keeps explicitly configured session exports readable via memory_get", async () => { - configureQmd({ sessions: { enabled: true } }); - const { manager } = await createManager(); - - await expect(manager.readFile({ relPath: "qmd/sessions-main/export.md" })).resolves.toEqual({ - path: "qmd/sessions-main/export.md", - text: "", - }); - - await manager.close(); - }); - - it("rejects non-memory workspace markdown reads", async () => { - await fs.writeFile(path.join(workspaceDir, "window.md"), "secret", "utf-8"); - await fs.mkdir(path.join(workspaceDir, ".memory"), { recursive: true }); - await fs.writeFile(path.join(workspaceDir, ".memory", "hidden.md"), "secret", "utf-8"); - - const { manager } = await createManager(); - - await expect(manager.readFile({ relPath: "window.md" })).rejects.toThrow("path required"); - await expect(manager.readFile({ relPath: ".memory/hidden.md" })).rejects.toThrow( - "path required", - ); - - await manager.close(); - }); - - it("reads only requested line ranges from canonical memory files without loading the whole file", async () => { - const readFileSpy = vi.spyOn(fs, "readFile"); - const text = Array.from({ length: 50 }, (_, index) => `line-${index + 1}`).join("\n"); - const relPath = path.join("memory", "window.md"); - await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); - await fs.writeFile(path.join(workspaceDir, relPath), text, "utf-8"); - - const { manager } = await createManager(); - - const result = await manager.readFile({ relPath, from: 10, lines: 3 }); - expect(result).toEqual({ - path: relPath, - text: "line-10\nline-11\nline-12\n\n[More content available. Use from=13 to continue.]", - from: 10, - lines: 3, - truncated: true, - nextFrom: 13, - }); - expect(readFileSpy).not.toHaveBeenCalled(); - - await manager.close(); - readFileSpy.mockRestore(); - }); - - it("defaults non-finite partial read line options before streaming canonical memory files", async () => { - const readFileSpy = vi.spyOn(fs, "readFile"); - const relPath = path.join("memory", "non-finite-window.md"); - await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); - await fs.writeFile( - path.join(workspaceDir, relPath), - ["line-1", "line-2", "line-3"].join("\n"), - "utf-8", - ); - - const { manager } = await createManager(); - - const result = await manager.readFile({ - relPath, - from: Number.NaN, - lines: Number.POSITIVE_INFINITY, - }); - expect(result).toEqual({ - path: relPath, - text: "line-1\nline-2\nline-3", - from: 1, - lines: 3, - }); - expect(readFileSpy).not.toHaveBeenCalled(); - - await manager.close(); - readFileSpy.mockRestore(); - }); - - it("returns a bounded default excerpt for qmd memory reads without explicit lines", async () => { - const relPath = path.join("memory", "default-window.md"); - await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); - await fs.writeFile( - path.join(workspaceDir, relPath), - Array.from({ length: 150 }, (_, index) => `line-${index + 1}`).join("\n"), - "utf-8", - ); - - const { manager } = await createManager(); - - const result = await manager.readFile({ relPath }); - expect(result.path).toBe(relPath); - expect(result.from).toBe(1); - expect(result.lines).toBe(120); - expect(result.truncated).toBe(true); - expect(result.nextFrom).toBe(121); - expect(result.text).toContain("line-1"); - expect(result.text).toContain("line-120"); - expect(result.text).not.toContain("line-121"); - expect(result.text).toContain("Use from=121 to continue."); - - await manager.close(); - }); - - it("returns empty text when qmd files are missing before or during read", async () => { - const relPath = path.join("memory", "qmd-window.md"); - const absPath = path.join(workspaceDir, relPath); - await fs.mkdir(path.dirname(absPath), { recursive: true }); - await fs.writeFile(absPath, "one\ntwo\nthree", "utf-8"); - - const cases = [ - { - name: "missing before read", - request: { relPath: path.join("memory", "ghost.md") }, - expectedPath: path.join("memory", "ghost.md"), - }, - { - name: "disappears before partial read", - request: { relPath, from: 2, lines: 1 }, - expectedPath: relPath, - installOpenSpy: () => { - const realOpen = fs.open; - let injected = false; - const openSpy = vi - .spyOn(fs, "open") - .mockImplementation(async (...args: Parameters) => { - const [target, options] = args; - if (!injected && typeof target === "string" && path.resolve(target) === absPath) { - injected = true; - const err = new Error("gone") as NodeJS.ErrnoException; - err.code = "ENOENT"; - throw err; - } - return await realOpen(target, options); - }); - return () => openSpy.mockRestore(); - }, - }, - ] as const; - - for (const testCase of cases) { - const { manager } = await createManager(); - const restoreOpen = "installOpenSpy" in testCase ? testCase.installOpenSpy() : undefined; - try { - const result = await manager.readFile(testCase.request); - expect(result, testCase.name).toEqual({ text: "", path: testCase.expectedPath }); - } finally { - restoreOpen?.(); - await manager.close(); - } - } - }); - - it("reuses exported session markdown files when inputs are unchanged", async () => { - const exportFile = path.join(stateDir, "agents", agentId, "qmd", "sessions", "session-1.md"); - await seedQmdSessionTranscript({ agentId, content: "hello", sessionId: "session-1", stateDir }); - - const currentMemory = cfg.memory; - configureQmd( - { ...currentMemory?.qmd, sessions: { enabled: true } }, - { search: currentMemory?.search }, - ); - - const { manager } = await createManager(); - - try { - await manager.sync({ reason: "manual" }); - const firstExport = await fs.readFile(exportFile, "utf-8"); - expect(firstExport).toContain("hello"); - - await manager.sync({ reason: "manual" }); - const secondExport = await fs.readFile(exportFile, "utf-8"); - expect(secondExport).toBe(firstExport); - } finally { - await manager.close(); - } - }); - - it("fails closed when sqlite index is busy during doc lookup or search", async () => { - const cases = [ - { - name: "resolveDocLocation", - run: async (manager: QmdMemoryManager) => { - const inner = manager as unknown as { - db: { - prepare: () => { - all: () => never; - get: () => never; - }; - close: () => void; - } | null; - resolveDocLocation: (docid?: string) => Promise; - }; - const busyStmt: { all: () => never; get: () => never } = { - all: () => { - throw new Error("SQLITE_BUSY: database is locked"); - }, - get: () => { - throw new Error("SQLITE_BUSY: database is locked"); - }, - }; - inner.db = { - prepare: () => busyStmt, - close: () => {}, - }; - await expect(inner.resolveDocLocation("abc123")).rejects.toThrow( - "qmd index busy while reading results", - ); - }, - }, - { - name: "search", - run: async (manager: QmdMemoryManager) => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - const child = createMockChild({ autoClose: false }); - emitAndClose( - child, - "stdout", - JSON.stringify([{ docid: "abc123", score: 1, snippet: "@@ -1,1\nremember this" }]), - ); - return child; - } - return createMockChild(); - }); - const inner = manager as unknown as { - db: { prepare: () => { all: () => never }; close: () => void } | null; - }; - inner.db = { - prepare: () => ({ - all: () => { - throw new Error("SQLITE_BUSY: database is locked"); - }, - }), - close: () => {}, - }; - await expect( - manager.search("busy lookup", { sessionKey: "agent:main:slack:dm:u123" }), - ).rejects.toThrow("qmd index busy while reading results"); - }, - }, - ] as const; - - for (const testCase of cases) { - spawnMock.mockClear(); - spawnMock.mockImplementation(() => createMockChild()); - const { manager } = await createManager(); - try { - await testCase.run(manager); - } catch (error) { - throw new Error( - `${testCase.name}: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } finally { - await manager.close(); - } - } - }); - - it("prefers exact docid match before prefix fallback for qmd document lookups", async () => { - const prepareCalls: string[] = []; - const exactDocid = "abc123"; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdResults({ - docid: exactDocid, - score: 1, - snippet: "@@ -5,2\nremember this\nnext line", - }); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - const inner = manager as unknown as { - db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void }; - }; - inner.db = { - prepare: (query: string) => { - prepareCalls.push(query); - return { - all: (arg: unknown) => { - if (query.includes("hash = ?")) { - return []; - } - if (query.includes("hash LIKE ?")) { - expect(arg).toBe(`${exactDocid}%`); - return [ - { - collection: "workspace-main", - path: "notes/welcome.md", - modified_at: "2026-07-01T10:00:00.000Z", - }, - ]; - } - throw new Error(`unexpected sqlite query: ${query}`); - }, - }; - }, - close: () => {}, - }; - - const results = await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }); - expect(results).toEqual([ - { - path: "notes/welcome.md", - startLine: 5, - endLine: 6, - score: 1, - snippet: "@@ -5,2\nremember this\nnext line", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - - expect(prepareCalls).toHaveLength(2); - expect(prepareCalls[0]).toContain("hash = ?"); - expect(prepareCalls[1]).toContain("hash LIKE ?"); - expect(results[0]?.provenance?.observedAt).toBe(Date.parse("2026-07-01T10:00:00.000Z")); - await manager.close(); - }); - - it("prefers collection hint when resolving duplicate qmd document hashes", async () => { - configureQmd({ - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" }, - ], - }); - - const duplicateDocid = "dup-123"; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search" && args.includes("workspace-main")) { - return makeQmdResults({ - docid: duplicateDocid, - score: 0.9, - snippet: "@@ -3,1\nworkspace hit", - }); - } - if (args[0] === "search" && args.includes("notes-main")) { - return makeQmdChild(); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - const inner = manager as unknown as { - db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void }; - }; - inner.db = { - prepare: (_query: string) => ({ - all: (arg: unknown) => { - if (typeof arg === "string" && arg.startsWith(duplicateDocid)) { - return [ - { collection: "stale-workspace", path: "notes/welcome.md" }, - { collection: "workspace-main", path: "notes/welcome.md" }, - ]; - } - return []; - }, - }), - close: () => {}, - }; - - const results = await manager.search("workspace", { sessionKey: "agent:main:slack:dm:u123" }); - expect(results).toEqual([ - { - path: "notes/welcome.md", - startLine: 3, - endLine: 3, - score: 0.9, - snippet: "@@ -3,1\nworkspace hit", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - await manager.close(); - }); - - it("resolves search hits when qmd returns qmd:// file URIs without docid", async () => { - configureQmd(); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdResults({ - file: "qmd://workspace-main/notes/welcome.md", - score: 0.71, - snippet: "@@ -4,1\ntoken unlock", - }); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - const results = await manager.search("token unlock", { - sessionKey: "agent:main:slack:dm:u123", - }); - expect(results).toEqual([ - { - path: "notes/welcome.md", - startLine: 4, - endLine: 4, - score: 0.71, - snippet: "@@ -4,1\ntoken unlock", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - await manager.close(); - }); - - it("returns collection-scoped qmd paths when session exports live under the workspace qmd directory", async () => { - workspaceDir = path.join(stateDir, "agents", agentId); - await fs.mkdir(workspaceDir, { recursive: true }); - configureQmd( - { sessions: { enabled: true } }, - { agents: { list: [{ id: agentId, default: true, workspace: workspaceDir }] } }, - ); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdResults({ - file: "qmd://sessions-main/session-1.md", - score: 0.84, - snippet: "@@ -2,1\nsession canary", - }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - const inner = manager as unknown as { - collectionRoots: Map; - resolveReadPath: (relPath: string) => string; - }; - const sessionRoot = requireValue( - inner.collectionRoots.get("sessions-main"), - "sessions collection root missing", - ); - expect(sessionRoot.path).toContain(path.join("qmd", "sessions")); - const exportedSessionPath = path.join(sessionRoot.path, "session-1.md"); - - const results = await manager.search("session canary", { - sessionKey: "agent:main:slack:dm:u123", - }); - expect(results).toEqual([ - { - path: "qmd/sessions-main/session-1.md", - startLine: 2, - endLine: 2, - score: 0.84, - snippet: "@@ -2,1\nsession canary", - source: "sessions", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - - const result = expectDefined(results[0], "QMD session search result"); - expect(inner.resolveReadPath(result.path)).toBe(exportedSessionPath); - const realLstat = fs.lstat; - const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (target, options) => { - if (typeof target === "string" && path.resolve(target) === exportedSessionPath) { - return { - isFile: () => true, - isSymbolicLink: () => false, - } as Awaited>; - } - return await realLstat(target, options); - }); - const realReadFile = fs.readFile; - const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (target, options) => { - if (typeof target === "string" && path.resolve(target) === exportedSessionPath) { - return "# Session session-1\n\nsession canary\n"; - } - return await realReadFile(target, options as never); - }); - - try { - const readResult = await manager.readFile({ relPath: result.path }); - expect(readResult).toEqual({ - path: "qmd/sessions-main/session-1.md", - text: "# Session session-1\n\nsession canary", - from: 1, - lines: 3, - }); - } finally { - lstatSpy.mockRestore(); - readSpy.mockRestore(); - } - - await manager.close(); - }); - - it("restricts qmd search to session collections before result limiting", async () => { - configureQmd({ sessions: { enabled: true } }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search" && args.includes("workspace-main")) { - return makeQmdResults({ - file: "qmd://workspace-main/notes.md", - score: 0.99, - snippet: "@@ -1,1\nmemory hit", - }); - } - if (args[0] === "search" && args.includes("sessions-main")) { - return makeQmdResults({ - file: "qmd://sessions-main/session-1.md", - score: 0.8, - snippet: "@@ -2,1\nsession hit", - }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - const results = await manager.search("hit", { - sessionKey: "agent:main:slack:dm:u123", - sources: ["sessions"], - maxResults: 1, - }); - - expect(results).toEqual([ - { - path: "qmd/sessions-main/session-1.md", - startLine: 2, - endLine: 2, - score: 0.8, - snippet: "@@ -2,1\nsession hit", - source: "sessions", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - - const searchCalls = spawnMock.mock.calls - .map((call: unknown[]) => call[1] as string[]) - .filter((args) => args[0] === "search"); - expect(searchCalls).toHaveLength(1); - expect(searchCalls[0]).toContain("sessions-main"); - expect(searchCalls[0]).not.toContain("workspace-main"); - - await manager.close(); - }); - - it("preserves multi-collection qmd search hits when results only include file URIs", async () => { - configureQmd({ - paths: [ - { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, - { path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" }, - ], - }); - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search" && args.includes("workspace-main")) { - return makeQmdResults({ - file: "qmd://workspace-main/memory/facts.md", - score: 0.8, - snippet: "@@ -2,1\nworkspace fact", - }); - } - if (args[0] === "search" && args.includes("notes-main")) { - return makeQmdResults({ - file: "qmd://notes-main/guide.md", - score: 0.7, - snippet: "@@ -1,1\nnotes guide", - }); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - const results = await manager.search("fact", { - sessionKey: "agent:main:slack:dm:u123", - }); - expect(results).toEqual([ - { - path: "memory/facts.md", - startLine: 2, - endLine: 2, - score: 0.8, - snippet: "@@ -2,1\nworkspace fact", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - { - path: "notes/guide.md", - startLine: 1, - endLine: 1, - score: 0.7, - snippet: "@@ -1,1\nnotes guide", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - await manager.close(); - }); - - it("errors when qmd output exceeds command output safety cap", async () => { - const noisyPayload = "x".repeat(240_000); - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild({ data: noisyPayload }); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - await expect( - manager.search("noise", { sessionKey: "agent:main:slack:dm:u123" }), - ).rejects.toThrow(/too much output/); - await manager.close(); - }); - - it("treats plain-text no-results markers from stdout/stderr as empty result sets", async () => { - const cases = [ - { name: "stdout with punctuation", stream: "stdout", payload: "No results found." }, - { name: "stdout without punctuation", stream: "stdout", payload: "No results found\n\n" }, - { name: "stderr", stream: "stderr", payload: "No results found.\n" }, - ] as const; - - for (const testCase of cases) { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdChild({ stream: testCase.stream, data: testCase.payload }); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - await expect( - manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), - testCase.name, - ).resolves.toStrictEqual([]); - await manager.close(); - } - }); - - it("uses qmd file hints when docid lookup misses", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdResults({ - docid: "missing-from-openclaw-index", - file: "qmd://workspace-main/notes/welcome.md", - score: 0.91, - snippet: "@@ -3,1\nQMD activation", - }); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - const inner = manager as unknown as { - db: { prepare: () => { all: () => unknown[] }; close: () => void }; - }; - inner.db = { - prepare: () => ({ - all: () => [], - }), - close: () => {}, - }; - - await expect( - manager.search("QMD activation", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([ - { - path: "notes/welcome.md", - startLine: 3, - endLine: 3, - score: 0.91, - snippet: "@@ -3,1\nQMD activation", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - await manager.close(); - }); - - it("uses index record after index recovery, not stale hint-only cache", async () => { - const searchDocid = "recovered-doc-after-empty-index"; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - return makeQmdResults({ - docid: searchDocid, - file: "qmd://workspace-main/notes/welcome.md", - score: 0.91, - snippet: "@@ -3,1\nQMD activation", - }); - } - return createMockChild(); - }); - - const { manager } = await createManager(); - const inner = manager as unknown as { - db: { - prepare: () => { all: (arg: unknown) => unknown[] }; - close: () => void; - } | null; - }; - - inner.db = { - prepare: () => ({ - all: () => [], - }), - close: () => {}, - }; - - await expect( - manager.search("QMD activation", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([ - { - path: "notes/welcome.md", - startLine: 3, - endLine: 3, - score: 0.91, - snippet: "@@ -3,1\nQMD activation", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - - inner.db = { - prepare: () => ({ - all: () => [{ collection: "workspace-main", path: "indexed/path.md" }], - }), - close: () => {}, - }; - - await expect( - manager.search("QMD activation", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([ - { - path: "indexed/path.md", - startLine: 3, - endLine: 3, - score: 0.91, - snippet: "@@ -3,1\nQMD activation", - source: "memory", - provenance: expectedQmdProvenance("untrusted"), - }, - ]); - await manager.close(); - }); - - it("throws when stdout is empty without the no-results marker", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "query") { - const child = createMockChild({ autoClose: false }); - queueMicrotask(() => { - child.stdout.emit("data", " \n"); - child.stderr.emit("data", "unexpected parser error"); - child.closeWith(0); - }); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - await expect( - manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), - ).rejects.toThrow(/qmd query returned invalid JSON/); - await manager.close(); - }); - - it("sets busy_timeout on qmd sqlite connections", async () => { - const { manager } = await createManager(); - const indexPath = (manager as unknown as { indexPath: string }).indexPath; - await fs.mkdir(path.dirname(indexPath), { recursive: true }); - const { DatabaseSync } = requireNodeSqlite(); - const seedDb = new DatabaseSync(indexPath); - seedDb.close(); - - const db = (manager as unknown as { ensureDb: () => DatabaseSync }).ensureDb(); - const row = db.prepare("PRAGMA busy_timeout").get() as - | { busy_timeout?: number; timeout?: number } - | undefined; - const busyTimeout = row?.busy_timeout ?? row?.timeout; - expect(busyTimeout).toBe(1000); - await manager.close(); - }); - - it("uses the configured qmd timeout for status probes", async () => { - vi.useFakeTimers(); - configureQmd({ searchMode: "query", limits: { timeoutMs: 6000 } }); - - let statusKill: Mock | null = null; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "status") { - const child = createMockChild({ autoClose: false }); - statusKill = vi.fn(); - child.kill = statusKill; - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - const probe = manager.probeVectorAvailability(); - await vi.advanceTimersByTimeAsync(5000); - expect(statusKill).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1000); - await expect(probe).resolves.toBe(false); - expect(manager.status().vector).toEqual({ - enabled: true, - available: false, - semanticAvailable: false, - loadError: expect.stringContaining("timed out after 6000ms"), - }); - await manager.close(); - }); - - it("exports valid session transcripts whose IDs contain checkpoint words", async () => { - configureQmd({ sessions: { enabled: true }, update: undefined }); - - await seedQmdSessionTranscript({ - agentId, - content: "live", - sessionId: "live-session", - stateDir, - }); - await seedQmdSessionTranscript({ - agentId, - content: "notes", - sessionId: "team.checkpoint.notes", - stateDir, - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.sync({ reason: "manual", force: true }); - const sessionExportDir = path.join(stateDir, "agents", agentId, "qmd", "sessions"); - const exported = (await fs.readdir(sessionExportDir)).toSorted(); - - expect(exported).toEqual(["live-session.md", "team.checkpoint.notes.md"]); - await expect( - fs.readFile(path.join(sessionExportDir, "team.checkpoint.notes.md"), "utf-8"), - ).resolves.toContain("notes"); - await manager.close(); - }); - - it("reports vector availability as unavailable when qmd status shows zero vectors", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "status") { - return makeQmdChild({ data: "Documents: 12\nVectors: 0\n" }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ - cfg: { - ...cfg, - memory: { - ...cfg.memory, - qmd: { ...cfg.memory?.qmd, searchMode: "query" }, - }, - } as OpenClawConfig, - }); - - await expect(manager.probeVectorAvailability()).resolves.toBe(false); - await expect(manager.probeEmbeddingAvailability()).resolves.toEqual({ - ok: false, - error: "QMD index has 0 vectors; semantic search is unavailable until embeddings finish", - }); - expect(manager.status().vector).toEqual({ - enabled: true, - available: false, - semanticAvailable: false, - loadError: "QMD index has 0 vectors; semantic search is unavailable until embeddings finish", - }); - await manager.close(); - }); - - it("reports vector availability as ready when qmd status shows vectors", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "status") { - return makeQmdChild({ data: "Documents: 12\nVectors: 42\n" }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ - cfg: { - ...cfg, - memory: { - ...cfg.memory, - qmd: { ...cfg.memory?.qmd, searchMode: "query" }, - }, - } as OpenClawConfig, - }); - - await expect(manager.probeVectorAvailability()).resolves.toBe(true); - await expect(manager.probeEmbeddingAvailability()).resolves.toEqual({ - ok: true, - error: undefined, - }); - expect(manager.status().vector).toEqual({ - enabled: true, - available: true, - semanticAvailable: true, - loadError: undefined, - }); - await manager.close(); - }); - - it.each([ - ["equals separator", "Documents: 12\nVectors = 42\n"], - ["tab separator", "Documents: 12\nVectors:\t42\n"], - ["compact separator", "Documents: 12\nVectors:42\n"], - ["embedded suffix", "Documents: 12\nVectors: 42 embedded\n"], - ])("reports vector availability as ready for qmd status %s", async (_name, statusOutput) => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "status") { - return makeQmdChild({ data: statusOutput }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ - cfg: { - ...cfg, - memory: { - ...cfg.memory, - qmd: { ...cfg.memory?.qmd, searchMode: "query" }, - }, - } as OpenClawConfig, - }); - - await expect(manager.probeVectorAvailability()).resolves.toBe(true); - await manager.close(); - }); - - it("does not parse unrelated qmd status vector-like fields", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "status") { - return makeQmdChild({ data: "Documents: 12\nMaxVectors: 42\nVector index: yes\n" }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ - cfg: { - ...cfg, - memory: { - ...cfg.memory, - qmd: { ...cfg.memory?.qmd, searchMode: "query" }, - }, - } as OpenClawConfig, - }); - - await expect(manager.probeVectorAvailability()).resolves.toBe(false); - expect(manager.status().vector).toEqual({ - enabled: true, - available: false, - semanticAvailable: false, - loadError: "Could not determine QMD vector status from `qmd status`", - }); - await manager.close(); - }); - - it("skips qmd status vector probes for lexical search mode", async () => { - const { manager } = await createManager({ - cfg: { - ...cfg, - memory: { - ...cfg.memory, - qmd: { ...cfg.memory?.qmd, searchMode: "search" }, - }, - } as OpenClawConfig, - }); - const baselineCalls = spawnMock.mock.calls.length; - - await expect(manager.probeVectorAvailability()).resolves.toBe(false); - await expect(manager.probeEmbeddingAvailability()).resolves.toEqual({ - ok: true, - checked: false, - }); - expect(spawnMock.mock.calls.length).toBe(baselineCalls); - expect(manager.status().vector).toEqual({ - enabled: false, - available: false, - semanticAvailable: false, - loadError: undefined, - }); - await manager.close(); - }); - - describe("model cache symlink", () => { - let defaultModelsDir: string; - let customModelsDir: string; - let savedXdgCacheHome: string | undefined; - - beforeEach(async () => { - // Redirect XDG_CACHE_HOME so symlinkSharedModels finds our fake models - // directory instead of the real ~/.cache. - savedXdgCacheHome = process.env.XDG_CACHE_HOME; - const fakeCacheHome = path.join(tmpRoot, "fake-cache"); - Reflect.set(process.env, "XDG_CACHE_HOME", fakeCacheHome); - - defaultModelsDir = path.join(fakeCacheHome, "qmd", "models"); - await fs.mkdir(defaultModelsDir, { recursive: true }); - await fs.writeFile(path.join(defaultModelsDir, "model.bin"), "fake-model"); - - customModelsDir = path.join(stateDir, "agents", agentId, "qmd", "xdg-cache", "qmd", "models"); - }); - - afterEach(() => { - if (savedXdgCacheHome === undefined) { - Reflect.deleteProperty(process.env, "XDG_CACHE_HOME"); - } else { - Reflect.set(process.env, "XDG_CACHE_HOME", savedXdgCacheHome); - } - }); - - it("handles first-run symlink, existing dir preservation, and missing default cache", async () => { - const cases: Array<{ - name: string; - setup?: () => Promise; - assert: () => Promise; - }> = [ - { - name: "symlinks default cache on first run", - assert: async () => { - const stat = await fs.lstat(customModelsDir); - expect(stat.isSymbolicLink()).toBe(true); - const target = await fs.readlink(customModelsDir); - expect(target).toBe(defaultModelsDir); - const content = await fs.readFile(path.join(customModelsDir, "model.bin"), "utf-8"); - expect(content).toBe("fake-model"); - }, - }, - { - name: "does not overwrite existing models directory", - setup: async () => { - await fs.mkdir(customModelsDir, { recursive: true }); - await fs.writeFile(path.join(customModelsDir, "custom-model.bin"), "custom"); - }, - assert: async () => { - const stat = await fs.lstat(customModelsDir); - expect(stat.isSymbolicLink()).toBe(false); - expect(stat.isDirectory()).toBe(true); - const content = await fs.readFile( - path.join(customModelsDir, "custom-model.bin"), - "utf-8", - ); - expect(content).toBe("custom"); - }, - }, - { - name: "skips symlink when default models are absent", - setup: async () => { - await fs.rm(defaultModelsDir, { recursive: true, force: true }); - }, - assert: async () => { - await expectPathMissing(customModelsDir); - expectMockMessageNotContains(logWarnMock, "failed to symlink qmd models directory"); - }, - }, - ]; - - for (const testCase of cases) { - await fs.rm(customModelsDir, { recursive: true, force: true }); - await fs.mkdir(defaultModelsDir, { recursive: true }); - await fs.writeFile(path.join(defaultModelsDir, "model.bin"), "fake-model"); - logWarnMock.mockClear(); - await testCase.setup?.(); - const { manager } = await createManager({ mode: "full" }); - try { - await testCase.assert(); - } finally { - await manager.close(); - } - } - }); - }); - - it("rebinds a managed collection when its root path changed (show reveals old path)", async () => { - // Regression: listCollectionsBestEffort gets only the name from `collection list` - // (no path). The fix enriches path via `collection show`; without it shouldRebindCollection - // hits the `!listed.path` branch and skips the rebind, leaving the old path pinned. - const oldWorkspaceDir = path.join(tmpRoot, "old-workspace"); - const newWorkspaceDir = workspaceDir; // the manager is configured for this new path - - configureQmd({ - paths: [{ path: newWorkspaceDir, pattern: "**/*.md", name: "workspace" }], - }); - - const collectionName = `workspace-${agentId}`; - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - // Real qmd: names only, no path/pattern in list output. - return makeQmdResults(collectionName); - } - if (args[0] === "collection" && args[1] === "show" && args[2] === collectionName) { - // Real qmd `collection show` output — exposes the stale (old) path. - return makeQmdChild({ - data: [ - `Collection: ${collectionName}`, - ` Path: ${oldWorkspaceDir}`, - ` Pattern: **/*.md`, - ` Include: yes (default)`, - ].join("\n"), - }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - const commands = spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]); - - const removeCall = commands.find( - (args) => args[0] === "collection" && args[1] === "remove" && args[2] === collectionName, - ); - expect(removeCall).toBeDefined(); // rebind must remove the stale collection - - const addCall = commands.find((args) => { - if (args[0] !== "collection" || args[1] !== "add") { - return false; - } - const nameIdx = args.indexOf("--name"); - return nameIdx >= 0 && args[nameIdx + 1] === collectionName; - }); - expect(addCall).toBeDefined(); - // The new add must target the NEW workspace path, not the old one. - expect(addCall?.[2]).toBe(newWorkspaceDir); - }); - - it("rebinds a stale in-container collection root to the host workspace (sandbox-mode transition)", async () => { - // Sandbox coverage: an agent that previously ran with its workspace bind-mounted under - // /home/node/.openclaw/... stored that in-container path as the collection root. Resolved - // with host paths, `collection show` reveals the stale container path; the rebind is - // path-namespace-agnostic and re-binds to the current host root. - const containerRoot = "/home/node/.openclaw/teams/x/workspace"; - const newWorkspaceDir = workspaceDir; // host path the manager is configured for - - configureQmd({ - paths: [{ path: newWorkspaceDir, pattern: "**/*.md", name: "workspace" }], - }); - - const collectionName = `workspace-${agentId}`; - - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "collection" && args[1] === "list") { - return makeQmdResults(collectionName); - } - if (args[0] === "collection" && args[1] === "show" && args[2] === collectionName) { - return makeQmdChild({ - data: [ - `Collection: ${collectionName}`, - ` Path: ${containerRoot}`, - ` Pattern: **/*.md`, - ` Include: yes (default)`, - ].join("\n"), - }); - } - return createMockChild(); - }); - - const { manager } = await createManager({ mode: "full" }); - await manager.close(); - - const commands = spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]); - const removeCall = commands.find( - (args) => args[0] === "collection" && args[1] === "remove" && args[2] === collectionName, - ); - expect(removeCall).toBeDefined(); - const addCall = commands.find((args) => { - if (args[0] !== "collection" || args[1] !== "add") { - return false; - } - const nameIdx = args.indexOf("--name"); - return nameIdx >= 0 && args[nameIdx + 1] === collectionName; - }); - expect(addCall).toBeDefined(); - // Re-added at the host workspace root, not the stale container path. - expect(addCall?.[2]).toBe(newWorkspaceDir); - }); - - it("parseShownQmdCollection extracts path and pattern from qmd collection show output", () => { - const sampleOutput = [ - "Collection: memory-dir-example", - " Path: /home/node/.openclaw/teams/example-team/workspace-example/memory", - " Pattern: **/*.md", - " Include: yes (default)", - ].join("\n"); - - const result = parseShownQmdCollection(sampleOutput); - expect(result.path).toBe("/home/node/.openclaw/teams/example-team/workspace-example/memory"); - expect(result.pattern).toBe("**/*.md"); - - // Tolerant of missing fields. - expect(parseShownQmdCollection("")).toEqual({}); - expect(parseShownQmdCollection("Collection: no-path-here\n Include: yes")).toEqual({}); - - // Path-only (no pattern line). - const pathOnly = parseShownQmdCollection("Collection: x\n Path: /some/path\n"); - expect(pathOnly.path).toBe("/some/path"); - expect(pathOnly.pattern).toBeUndefined(); - }); - - it("parseListedQmdCollections accepts uppercase bare collection names", () => { - expect(parseListedQmdCollections("Workspace-Main\n")).toEqual( - new Map([["Workspace-Main", {}]]), - ); - }); -}); - -function createDeferred() { - let resolve: ((value: T) => void) | undefined; - let reject: ((reason?: unknown) => void) | undefined; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - if (!resolve || !reject) { - throw new Error("Expected deferred callbacks to be initialized"); - } - return { promise, resolve, reject }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/memory-core/src/memory/qmd-manager.timer-leak.test.ts b/extensions/memory-core/src/memory/qmd-manager.timer-leak.test.ts deleted file mode 100644 index a03a1656d7ea..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager.timer-leak.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { QmdMemoryManager } from "./qmd-manager.js"; - -describe("QmdMemoryManager.waitForPendingUpdateBeforeSearch timer cleanup", () => { - it("clears the wait timeout when the pending update settles first", async () => { - // Real instance carrying the real prototype method. The method reads only - // this.pendingUpdate, so the instance is otherwise uninitialized; the full - // create() factory spawns the external qmd process, which is out of scope here. - const mgr = Object.create(QmdMemoryManager.prototype) as { - pendingUpdate: Promise | null; - waitForPendingUpdateBeforeSearch: () => Promise; - }; - - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); - const iterations = 20; - try { - for (let index = 0; index < iterations; index += 1) { - mgr.pendingUpdate = index % 2 === 0 ? Promise.resolve() : Promise.reject(new Error("done")); - await mgr.waitForPendingUpdateBeforeSearch(); - } - - const waitTimers = setTimeoutSpy.mock.results.map((result) => result.value); - const clearedTimers = new Set(clearTimeoutSpy.mock.calls.map(([timer]) => timer)); - - expect(waitTimers).toHaveLength(iterations); - expect(waitTimers.every((timer) => clearedTimers.has(timer))).toBe(true); - } finally { - setTimeoutSpy.mockRestore(); - clearTimeoutSpy.mockRestore(); - } - }); -}); diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts deleted file mode 100644 index cd6439d52a64..000000000000 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { MemorySearchManager } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { resolveQmdManagerRuntimeConfig, type QmdManagerCreateParams } from "./qmd-manager-base.js"; -import { QmdManagerIo } from "./qmd-manager-io.js"; - -export { resolveQmdMcporterSearchProcessTimeoutMs } from "./qmd-command-client.js"; - -export class QmdMemoryManager extends QmdManagerIo implements MemorySearchManager { - static async create(params: QmdManagerCreateParams): Promise { - const resolved = params.resolved.qmd; - if (!resolved) { - return null; - } - const runtimeConfig = - params.runtimeConfig ?? resolveQmdManagerRuntimeConfig(params.cfg, params.agentId); - const manager = new QmdMemoryManager({ - agentId: params.agentId, - resolved, - runtimeConfig, - withLease: params.withLease, - }); - await manager.initialize(params.mode ?? "full"); - return manager; - } -} diff --git a/extensions/memory-core/src/memory/qmd-runtime-cache.test.ts b/extensions/memory-core/src/memory/qmd-runtime-cache.test.ts deleted file mode 100644 index 69ea5eeca502..000000000000 --- a/extensions/memory-core/src/memory/qmd-runtime-cache.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import path from "node:path"; -import { withTempDir } from "openclaw/plugin-sdk/test-env"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { configureMemoryCoreDreamingState, openMemoryCoreStateStore } from "../dreaming-state.js"; -import { - configureMemoryCoreDreamingStateForTests, - resetMemoryCoreDreamingStateForTests, -} from "../test-helpers.js"; -import { - clearQmdMultiCollectionProbeCache, - readQmdCollectionValidationCache, - readQmdMultiCollectionProbeCache, - type QmdRuntimeCollectionValidationCacheContext, - type QmdRuntimeManagedCollection, - type QmdRuntimeMultiCollectionProbeCacheContext, - writeQmdCollectionValidationCache, - writeQmdMultiCollectionProbeCache, -} from "./qmd-runtime-cache.js"; - -const COLLECTION_VALIDATION_NAMESPACE = "qmd-runtime-cache.collection-validation"; -const MULTI_COLLECTION_PROBE_NAMESPACE = "qmd-runtime-cache.multi-collection-probe"; -const MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000; - -beforeAll(async () => { - await configureMemoryCoreDreamingStateForTests(); -}); - -afterAll(async () => { - resetMemoryCoreDreamingStateForTests(); -}); - -async function clearStore(namespace: string): Promise { - try { - await openMemoryCoreStateStore({ - namespace, - maxEntries: 1_000, - }).clear(); - } catch { - // fail open - } -} - -afterEach(async () => { - await clearStore(COLLECTION_VALIDATION_NAMESPACE); - await clearStore(MULTI_COLLECTION_PROBE_NAMESPACE); -}); - -async function withWorkspace(run: (workspaceDir: string) => Promise): Promise { - return await withTempDir("qmd-runtime-cache-", run); -} - -function managedCollections(): QmdRuntimeManagedCollection[] { - return [ - { - name: "project-notes", - kind: "memory", - path: "/repo/project-notes", - pattern: "*.md", - }, - { - name: "sessions", - kind: "sessions", - path: "/repo/sessions", - pattern: "*", - }, - ]; -} - -function collectionValidationContext( - workspaceDir: string, -): QmdRuntimeCollectionValidationCacheContext { - return { - workspaceDir, - agentId: "agent-a", - qmdCommand: "qmd", - qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"), - searchMode: "search", - collections: managedCollections(), - sources: ["memory", "sessions"], - }; -} - -function multiCollectionProbeContext( - workspaceDir: string, -): QmdRuntimeMultiCollectionProbeCacheContext { - return { - workspaceDir, - agentId: "agent-a", - qmdCommand: "qmd", - qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"), - searchMode: "search", - sources: ["memory", "sessions"], - }; -} - -describe("qmd-runtime-cache", () => { - it("writes and reads collection validation cache entries", async () => { - await withWorkspace(async (workspaceDir) => { - const context = collectionValidationContext(workspaceDir); - const writeStartedAtMs = 1_000; - - const writeOk = await writeQmdCollectionValidationCache(context, writeStartedAtMs); - expect(writeOk).toBe(true); - - const read = await readQmdCollectionValidationCache( - { ...context, sources: ["sessions", "memory"] }, - writeStartedAtMs + 1, - ); - expect(read).toMatchObject({ - state: "hit", - value: { - validation: { - ok: true, - collectionCount: context.collections.length, - }, - }, - }); - }); - }); - - it("writes and reads multi-collection probe cache entries", async () => { - await withWorkspace(async (workspaceDir) => { - const context = multiCollectionProbeContext(workspaceDir); - const writeStartedAtMs = 2_000; - - const writeOk = await writeQmdMultiCollectionProbeCache(context, true, writeStartedAtMs); - expect(writeOk).toBe(true); - - const read = await readQmdMultiCollectionProbeCache(context, writeStartedAtMs + 1); - expect(read).toMatchObject({ - state: "hit", - value: { - multiCollectionProbe: { - supported: true, - }, - }, - }); - }); - }); - - it("scopes cache entries by workspace", async () => { - await withWorkspace(async (firstWorkspace) => { - await withWorkspace(async (secondWorkspace) => { - const context = collectionValidationContext(firstWorkspace); - - expect(await writeQmdCollectionValidationCache(context, 3_000)).toBe(true); - - const sameLogicalDifferentWorkspace: QmdRuntimeCollectionValidationCacheContext = { - ...context, - workspaceDir: secondWorkspace, - qmdIndexPath: path.join(secondWorkspace, ".openclaw", "index.sqlite"), - }; - - const miss = await readQmdCollectionValidationCache(sameLogicalDifferentWorkspace, 3_001); - expect(miss).toStrictEqual({ state: "miss" }); - }); - }); - }); - - it("misses collection validation cache when managed collection paths change", async () => { - await withWorkspace(async (workspaceDir) => { - const context = collectionValidationContext(workspaceDir); - - expect(await writeQmdCollectionValidationCache(context, 3_500)).toBe(true); - - const changedContext: QmdRuntimeCollectionValidationCacheContext = { - ...context, - collections: context.collections.map((collection) => - collection.name === "project-notes" - ? { - name: collection.name, - kind: collection.kind, - path: `${collection.path}-moved`, - pattern: collection.pattern, - } - : collection, - ), - }; - - expect(await readQmdCollectionValidationCache(changedContext, 3_501)).toStrictEqual({ - state: "miss", - }); - }); - }); - - it("misses validation and probe caches when qmd runtime environment changes", async () => { - await withWorkspace(async (workspaceDir) => { - const validationContext = { - ...collectionValidationContext(workspaceDir), - qmdEnvironmentHash: "env-a", - }; - const probeContext = { - ...multiCollectionProbeContext(workspaceDir), - qmdEnvironmentHash: "env-a", - }; - - expect(await writeQmdCollectionValidationCache(validationContext, 3_600)).toBe(true); - expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 3_600)).toBe(true); - - expect( - await readQmdCollectionValidationCache( - { ...validationContext, qmdEnvironmentHash: "env-b" }, - 3_601, - ), - ).toStrictEqual({ state: "miss" }); - expect( - await readQmdMultiCollectionProbeCache( - { ...probeContext, qmdEnvironmentHash: "env-b" }, - 3_601, - ), - ).toStrictEqual({ state: "miss" }); - }); - }); - - it("treats cache misses for malformed values and expired entries", async () => { - await withWorkspace(async (workspaceDir) => { - const context = multiCollectionProbeContext(workspaceDir); - const nowMs = 4_000; - await writeQmdMultiCollectionProbeCache(context, false, nowMs); - - const store = openMemoryCoreStateStore({ - namespace: MULTI_COLLECTION_PROBE_NAMESPACE, - maxEntries: 1_000, - }); - const cached = (await store.entries())[0]; - if (!cached) { - throw new Error("expected probe cache entry"); - } - - await store.register(cached.key, { - version: 1, - createdAtMs: "bad", - expiresAtMs: 0, - keyHash: "bad", - multiCollectionProbe: { supported: true }, - }); - - const malformed = await readQmdMultiCollectionProbeCache(context, nowMs + 1); - expect(malformed).toStrictEqual({ state: "miss" }); - - const expired = await readQmdMultiCollectionProbeCache( - context, - nowMs + MULTI_COLLECTION_PROBE_TTL_MS + 1, - ); - expect(expired).toStrictEqual({ state: "miss" }); - }); - }); - - it("uses separate namespaces for validation and probe entries", async () => { - await withWorkspace(async (workspaceDir) => { - const validationContext = collectionValidationContext(workspaceDir); - const probeContext = multiCollectionProbeContext(workspaceDir); - - expect(await writeQmdCollectionValidationCache(validationContext, 5_000)).toBe(true); - expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 5_000)).toBe(true); - - const validationStore = openMemoryCoreStateStore({ - namespace: COLLECTION_VALIDATION_NAMESPACE, - maxEntries: 1_000, - }); - const probeStore = openMemoryCoreStateStore({ - namespace: MULTI_COLLECTION_PROBE_NAMESPACE, - maxEntries: 1_000, - }); - - expect((await validationStore.entries()).length).toBeGreaterThan(0); - expect((await probeStore.entries()).length).toBeGreaterThan(0); - }); - }); - - it("fails open when state store is unavailable", async () => { - await withWorkspace(async (workspaceDir) => { - const validationContext = collectionValidationContext(workspaceDir); - const probeContext = multiCollectionProbeContext(workspaceDir); - - configureMemoryCoreDreamingState(() => { - throw new Error("state store unavailable"); - }); - - try { - expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({ - state: "miss", - }); - expect(await writeQmdCollectionValidationCache(validationContext)).toBe(false); - expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ - state: "miss", - }); - expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(false); - } finally { - await configureMemoryCoreDreamingStateForTests(); - } - }); - }); - - it("can clear cache keys explicitly", async () => { - await withWorkspace(async (workspaceDir) => { - const probeContext = multiCollectionProbeContext(workspaceDir); - - expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(true); - - await clearQmdMultiCollectionProbeCache(probeContext); - - expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ - state: "miss", - }); - }); - }); -}); diff --git a/extensions/memory-core/src/memory/qmd-runtime-cache.ts b/extensions/memory-core/src/memory/qmd-runtime-cache.ts deleted file mode 100644 index f6ff5dae5453..000000000000 --- a/extensions/memory-core/src/memory/qmd-runtime-cache.ts +++ /dev/null @@ -1,381 +0,0 @@ -// Memory Core QMD runtime cache helpers. -import { createHash } from "node:crypto"; -import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; -import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js"; - -const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE = "qmd-runtime-cache.collection-validation"; -const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE = - "qmd-runtime-cache.multi-collection-probe"; -const QMD_RUNTIME_CACHE_MAX_ENTRIES = 1_000; -const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000; -const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000; - -const QMD_RUNTIME_CACHE_ENTRY_VERSION = 1; - -export type QmdRuntimeManagedCollection = { - name: string; - kind: "memory" | "custom" | "sessions"; - path: string; - pattern: string; -}; - -type QmdRuntimeCacheContextBase = { - workspaceDir: string; - agentId: string; - qmdCommand: string; - qmdVersion?: string; - qmdEnvironmentHash?: string; - qmdIndexPath: string; - searchMode: string; -}; - -export type QmdRuntimeCollectionValidationCacheContext = QmdRuntimeCacheContextBase & { - collections: readonly QmdRuntimeManagedCollection[]; - sources: readonly string[]; -}; - -export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextBase & { - sources: readonly string[]; -}; - -type QmdRuntimeCacheEntryBase = { - version: 1; - createdAtMs: number; - expiresAtMs: number; - keyHash: string; -}; - -type QmdRuntimeCacheCollectionValidationEntry = QmdRuntimeCacheEntryBase & { - validation: { - ok: true; - collectionConfigHash: string; - collectionCount: number; - }; -}; - -type QmdRuntimeCacheMultiCollectionProbeEntry = QmdRuntimeCacheEntryBase & { - multiCollectionProbe: { - supported: boolean; - }; -}; - -type QmdRuntimeCacheResult = - | { - state: "hit"; - value: T; - } - | { state: "miss" }; - -function normalizeText(value: string): string { - return value.trim(); -} - -function hashText(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - -function normalizePathIdentity(value: string): string { - const normalized = - process.platform === "win32" ? normalizeText(value).toLowerCase() : normalizeText(value); - return hashText(normalized); -} - -function sortedUnique(values: readonly string[]): string[] { - return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))].toSorted(); -} - -function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string { - const normalized = collections - .map((collection) => ({ - name: normalizeText(collection.name), - kind: collection.kind, - pathHash: normalizePathIdentity(collection.path), - pattern: normalizeText(collection.pattern), - })) - .toSorted( - (left, right) => - left.name.localeCompare(right.name) || - left.kind.localeCompare(right.kind) || - left.pathHash.localeCompare(right.pathHash) || - left.pattern.localeCompare(right.pattern), - ) - .map((entry) => `${entry.name}|${entry.kind}|${entry.pathHash}|${entry.pattern}`) - .join(";"); - return hashText(normalized); -} - -function buildRuntimeCacheContextRecord( - params: QmdRuntimeCacheContextBase & { sources: readonly string[] }, -) { - return { - agentId: normalizeText(params.agentId), - commandHash: hashText(normalizeText(params.qmdCommand)), - environmentHash: normalizeText(params.qmdEnvironmentHash ?? ""), - indexPathHash: normalizePathIdentity(params.qmdIndexPath), - qmdVersion: normalizeText(params.qmdVersion ?? ""), - searchMode: params.searchMode, - sourceSet: sortedUnique(params.sources), - }; -} - -function buildQmdCollectionValidationCacheContextHash( - params: QmdRuntimeCollectionValidationCacheContext, -): string { - return hashText( - JSON.stringify({ - ...buildRuntimeCacheContextRecord(params), - collectionConfigHash: buildCollectionConfigHash(params.collections), - }), - ); -} - -function buildQmdMultiCollectionProbeCacheContextHash( - params: QmdRuntimeMultiCollectionProbeCacheContext, -): string { - return hashText(JSON.stringify(buildRuntimeCacheContextRecord(params))); -} - -function resolveQmdRuntimeCache( - workspaceDir: string, - namespace: string, - keyHash: string, -): { key: string; store: PluginStateKeyedStore } { - return { - key: memoryCoreWorkspaceEntryKey(workspaceDir, `${namespace}:${keyHash}`), - store: openMemoryCoreStateStore({ namespace, maxEntries: QMD_RUNTIME_CACHE_MAX_ENTRIES }), - }; -} - -type QmdRuntimeCacheEnvelope = { - record: Record; - createdAtMs: number; - expiresAtMs: number; - keyHash: string; -}; - -/** Validates the shared cache-entry envelope: version, expiry window, and key hash. */ -function normalizeCacheEntryEnvelope( - value: unknown, - nowMs: number, - expectedKeyHash: string, -): QmdRuntimeCacheEnvelope | undefined { - const record = asOptionalRecord(value); - if (!record || record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) { - return undefined; - } - - const createdAtMs = - typeof record.createdAtMs === "number" - ? Math.max(0, Math.floor(record.createdAtMs)) - : Number.NaN; - const expiresAtMs = - typeof record.expiresAtMs === "number" - ? Math.max(0, Math.floor(record.expiresAtMs)) - : Number.NaN; - if ( - !Number.isFinite(createdAtMs) || - !Number.isFinite(expiresAtMs) || - !Number.isFinite(nowMs) || - nowMs >= expiresAtMs - ) { - return undefined; - } - - const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : ""); - if (keyHash !== expectedKeyHash) { - return undefined; - } - - return { record, createdAtMs, expiresAtMs, keyHash }; -} - -function normalizeCollectionValidationEntry( - value: unknown, - nowMs: number, - expectedKeyHash: string, -): QmdRuntimeCacheCollectionValidationEntry | undefined { - const envelope = normalizeCacheEntryEnvelope(value, nowMs, expectedKeyHash); - if (!envelope) { - return undefined; - } - const { record, createdAtMs, expiresAtMs, keyHash } = envelope; - - const validation = asOptionalRecord(record.validation); - if ( - !validation || - validation.ok !== true || - typeof validation.collectionConfigHash !== "string" || - typeof validation.collectionCount !== "number" - ) { - return undefined; - } - - return { - version: QMD_RUNTIME_CACHE_ENTRY_VERSION, - createdAtMs, - expiresAtMs, - keyHash, - validation: { - ok: true, - collectionConfigHash: normalizeText(validation.collectionConfigHash), - collectionCount: Math.max(0, Math.floor(validation.collectionCount)), - }, - }; -} - -function normalizeMultiCollectionProbeEntry( - value: unknown, - nowMs: number, - expectedKeyHash: string, -): QmdRuntimeCacheMultiCollectionProbeEntry | undefined { - const envelope = normalizeCacheEntryEnvelope(value, nowMs, expectedKeyHash); - if (!envelope) { - return undefined; - } - const { record, createdAtMs, expiresAtMs, keyHash } = envelope; - - const probe = asOptionalRecord(record.multiCollectionProbe); - if (!probe || typeof probe.supported !== "boolean") { - return undefined; - } - - return { - version: QMD_RUNTIME_CACHE_ENTRY_VERSION, - createdAtMs, - expiresAtMs, - keyHash, - multiCollectionProbe: { - supported: probe.supported, - }, - }; -} - -async function readQmdRuntimeCache(params: { - workspaceDir: string; - namespace: string; - keyHash: string; - nowMs: number; - normalize: (value: unknown, nowMs: number, keyHash: string) => T | undefined; -}): Promise> { - try { - const { store, key } = resolveQmdRuntimeCache( - params.workspaceDir, - params.namespace, - params.keyHash, - ); - const raw = await store.lookup(key); - const validated = raw && params.normalize(raw, params.nowMs, params.keyHash); - return validated ? { state: "hit", value: validated } : { state: "miss" }; - } catch { - return { state: "miss" }; - } -} - -async function writeQmdRuntimeCache(params: { - workspaceDir: string; - namespace: string; - keyHash: string; - nowMs: number; - ttlMs: number; - payload: Omit; -}): Promise { - try { - const { store, key } = resolveQmdRuntimeCache( - params.workspaceDir, - params.namespace, - params.keyHash, - ); - const createdAtMs = Math.max(0, Math.floor(params.nowMs)); - await store.register( - key, - { - version: QMD_RUNTIME_CACHE_ENTRY_VERSION, - createdAtMs, - expiresAtMs: createdAtMs + params.ttlMs, - keyHash: params.keyHash, - ...params.payload, - } as T, - { ttlMs: params.ttlMs }, - ); - return true; - } catch { - return false; - } -} - -export async function readQmdCollectionValidationCache( - params: QmdRuntimeCollectionValidationCacheContext, - nowMs = Date.now(), -): Promise> { - return await readQmdRuntimeCache({ - workspaceDir: params.workspaceDir, - namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE, - keyHash: buildQmdCollectionValidationCacheContextHash(params), - nowMs, - normalize: normalizeCollectionValidationEntry, - }); -} - -export async function writeQmdCollectionValidationCache( - params: QmdRuntimeCollectionValidationCacheContext, - nowMs = Date.now(), -): Promise { - return await writeQmdRuntimeCache({ - workspaceDir: params.workspaceDir, - namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE, - keyHash: buildQmdCollectionValidationCacheContextHash(params), - nowMs, - ttlMs: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS, - payload: { - validation: { - ok: true, - collectionConfigHash: buildCollectionConfigHash(params.collections), - collectionCount: params.collections.length, - }, - }, - }); -} - -export async function readQmdMultiCollectionProbeCache( - params: QmdRuntimeMultiCollectionProbeCacheContext, - nowMs = Date.now(), -): Promise> { - return await readQmdRuntimeCache({ - workspaceDir: params.workspaceDir, - namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE, - keyHash: buildQmdMultiCollectionProbeCacheContextHash(params), - nowMs, - normalize: normalizeMultiCollectionProbeEntry, - }); -} - -export async function writeQmdMultiCollectionProbeCache( - params: QmdRuntimeMultiCollectionProbeCacheContext, - supported: boolean, - nowMs = Date.now(), -): Promise { - return await writeQmdRuntimeCache({ - workspaceDir: params.workspaceDir, - namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE, - keyHash: buildQmdMultiCollectionProbeCacheContextHash(params), - nowMs, - ttlMs: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS, - payload: { multiCollectionProbe: { supported } }, - }); -} - -export async function clearQmdMultiCollectionProbeCache( - params: QmdRuntimeMultiCollectionProbeCacheContext, -): Promise { - try { - const { store, key } = resolveQmdRuntimeCache( - params.workspaceDir, - QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE, - buildQmdMultiCollectionProbeCacheContextHash(params), - ); - await store.delete(key); - } catch { - // fail open - } -} diff --git a/extensions/memory-core/src/memory/qmd-runtime-debug.ts b/extensions/memory-core/src/memory/qmd-runtime-debug.ts deleted file mode 100644 index 63339cd48236..000000000000 --- a/extensions/memory-core/src/memory/qmd-runtime-debug.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; - -export type QmdCollectionValidationDebug = NonNullable< - NonNullable["collectionValidation"] ->; -type QmdMultiCollectionProbeDebug = NonNullable< - NonNullable["multiCollectionProbe"] ->; -type QmdSearchPlanDebug = NonNullable["searchPlan"]>; -export type QmdSearchRuntimeDebugContext = { - collectionValidation?: QmdCollectionValidationDebug; - multiCollectionProbe?: QmdMultiCollectionProbeDebug; - searchPlan?: QmdSearchPlanDebug; -}; diff --git a/extensions/memory-core/src/memory/qmd-session-exporter.archive.test.ts b/extensions/memory-core/src/memory/qmd-session-exporter.archive.test.ts deleted file mode 100644 index 8f7f24e5bfd2..000000000000 --- a/extensions/memory-core/src/memory/qmd-session-exporter.archive.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import zlib from "node:zlib"; -import { - clearConfigCache, - clearRuntimeConfigSnapshot, -} from "openclaw/plugin-sdk/runtime-config-snapshot"; -import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; -import { withTempDir } from "openclaw/plugin-sdk/test-env"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - replaceArtifactMappings: vi.fn(), -})); - -vi.mock("../qmd-session-artifacts.js", () => ({ - refreshQmdSessionArtifactDocIds: vi.fn(), - replaceQmdSessionArtifactMappings: mocks.replaceArtifactMappings, -})); - -import { QmdSessionExporter } from "./qmd-session-exporter.js"; - -const originalStateDir = process.env.OPENCLAW_STATE_DIR; - -afterEach(() => { - if (originalStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = originalStateDir; - } - clearRuntimeConfigSnapshot(); - clearConfigCache(); - closeOpenClawAgentDatabasesForTest(); - mocks.replaceArtifactMappings.mockReset(); -}); - -describe("QmdSessionExporter archives", () => { - it("exports compressed reset/delete history as readable Markdown", async () => { - const codec = zlib as typeof zlib & { - zstdCompressSync?: (data: Buffer) => Buffer; - }; - if (!codec.zstdCompressSync) { - return; - } - await withTempDir("qmd-session-archive-", async (tempDir) => { - process.env.OPENCLAW_STATE_DIR = tempDir; - clearRuntimeConfigSnapshot(); - clearConfigCache(); - const sessionsDir = path.join(tempDir, "agents", "main", "sessions"); - const archiveName = "compressed.jsonl.deleted.2026-07-11T00-00-00.000Z.zst"; - const content = `${JSON.stringify({ - type: "message", - message: { role: "user", content: "Compressed QMD archive" }, - })}\n`; - await fs.mkdir(sessionsDir, { recursive: true }); - await fs.writeFile( - path.join(sessionsDir, archiveName), - codec.zstdCompressSync(Buffer.from(content)), - ); - const exportDir = path.join(tempDir, "exports"); - const exporter = new QmdSessionExporter( - { collectionName: "sessions-main", dir: exportDir }, - "main", - tempDir, - path.join(tempDir, "index.sqlite"), - () => "unused", - ); - - await exporter.exportSessions({ - assertOwned: vi.fn(), - signal: new AbortController().signal, - }); - - await expect( - fs.readFile(path.join(exportDir, `${archiveName}.md`), "utf8"), - ).resolves.toContain("User: Compressed QMD archive"); - }); - }); -}); diff --git a/extensions/memory-core/src/memory/qmd-session-exporter.test.ts b/extensions/memory-core/src/memory/qmd-session-exporter.test.ts deleted file mode 100644 index 7eb8d86d6dcc..000000000000 --- a/extensions/memory-core/src/memory/qmd-session-exporter.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { withTempDir } from "openclaw/plugin-sdk/test-env"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - buildSessionEntry: vi.fn(), - corpusEntries: vi.fn(), - replaceArtifactMappings: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => ({ - buildSessionEntry: mocks.buildSessionEntry, - isSessionArchiveArtifactName: () => false, - listSessionTranscriptCorpusEntriesForAgent: mocks.corpusEntries, - resolveSessionIdentityForTranscriptFile: () => null, -})); - -vi.mock("../qmd-session-artifacts.js", () => ({ - refreshQmdSessionArtifactDocIds: vi.fn(), - replaceQmdSessionArtifactMappings: mocks.replaceArtifactMappings, -})); - -import { QmdSessionExporter } from "./qmd-session-exporter.js"; - -const createLease = () => ({ - assertOwned: vi.fn(), - signal: new AbortController().signal, -}); - -describe("QmdSessionExporter", () => { - beforeEach(() => { - mocks.buildSessionEntry.mockReset(); - mocks.corpusEntries.mockReset(); - mocks.replaceArtifactMappings.mockReset(); - }); - - it("skips unchanged transcript parsing by canonical corpus revision", async () => { - await withTempDir("qmd-session-exporter-", async (tempDir) => { - const exportDir = path.join(tempDir, "exports"); - const corpusEntry = { - agentId: "main", - artifactKind: "active-session" as const, - contentRevision: "sqlite:1:100:1:1", - sessionFile: "sqlite:main:session-1", - sessionId: "session-1", - transcriptSource: "sqlite" as const, - updatedAtMs: 1, - }; - mocks.corpusEntries.mockImplementation(async () => [corpusEntry]); - mocks.buildSessionEntry.mockResolvedValue({ - absPath: corpusEntry.sessionFile, - content: "User: first", - hash: "first", - lineMap: [1], - messageTimestampsMs: [1], - mtimeMs: 1, - path: "sessions/main/session-1.jsonl", - size: 100, - }); - const exporter = new QmdSessionExporter( - { collectionName: "sessions-main", dir: exportDir }, - "main", - tempDir, - path.join(tempDir, "index.sqlite"), - () => "unused", - ); - const lease = createLease(); - - await exporter.exportSessions(lease); - await exporter.exportSessions(lease); - - expect(mocks.buildSessionEntry).toHaveBeenCalledTimes(1); - await expect(fs.readFile(path.join(exportDir, "session-1.md"), "utf8")).resolves.toContain( - "User: first", - ); - - corpusEntry.contentRevision = "sqlite:2:200:2:2"; - mocks.buildSessionEntry.mockResolvedValue({ - absPath: corpusEntry.sessionFile, - content: "User: second", - hash: "second", - lineMap: [1], - messageTimestampsMs: [2], - mtimeMs: 2, - path: "sessions/main/session-1.jsonl", - size: 200, - }); - await exporter.exportSessions(lease); - - expect(mocks.buildSessionEntry).toHaveBeenCalledTimes(2); - await expect(fs.readFile(path.join(exportDir, "session-1.md"), "utf8")).resolves.toContain( - "User: second", - ); - }); - }); - - it("atomically repairs a missing or replaced export without hashing the transcript", async () => { - await withTempDir("qmd-session-exporter-", async (tempDir) => { - const exportDir = path.join(tempDir, "exports"); - const corpusEntry = { - agentId: "main", - artifactKind: "active-session" as const, - contentRevision: "sqlite:1:100:1:1", - sessionFile: "sqlite:main:session-1", - sessionId: "session-1", - transcriptSource: "sqlite" as const, - updatedAtMs: 1, - }; - mocks.corpusEntries.mockResolvedValue([corpusEntry]); - mocks.buildSessionEntry.mockResolvedValue({ - absPath: corpusEntry.sessionFile, - content: "User: canonical", - hash: "canonical", - lineMap: [1], - messageTimestampsMs: [1], - mtimeMs: 1, - path: "sessions/main/session-1.jsonl", - size: 100, - }); - const exporter = new QmdSessionExporter( - { collectionName: "sessions-main", dir: exportDir }, - "main", - tempDir, - path.join(tempDir, "index.sqlite"), - () => "unused", - ); - const target = path.join(exportDir, "session-1.md"); - const lease = createLease(); - - await exporter.exportSessions(lease); - await fs.writeFile(target, "corrupt", "utf8"); - await exporter.exportSessions(lease); - await fs.rm(target); - await exporter.exportSessions(lease); - - expect(mocks.buildSessionEntry).toHaveBeenCalledTimes(3); - await expect(fs.readFile(target, "utf8")).resolves.toContain("User: canonical"); - }); - }); -}); diff --git a/extensions/memory-core/src/memory/qmd-session-exporter.ts b/extensions/memory-core/src/memory/qmd-session-exporter.ts deleted file mode 100644 index 5a5967d9b825..000000000000 --- a/extensions/memory-core/src/memory/qmd-session-exporter.ts +++ /dev/null @@ -1,297 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { - createSubsystemLogger, - isPathInside, - root, -} from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { - buildSessionEntry, - isSessionArchiveArtifactName, - listSessionTranscriptCorpusEntriesForAgent, - type SessionFileEntry, - type SessionTranscriptCorpusEntry, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; -import type { ResolvedQmdConfig } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import type { PluginStateLeaseContext } from "openclaw/plugin-sdk/plugin-state-runtime"; -import { formatSessionTranscriptMemoryHitKey } from "openclaw/plugin-sdk/session-transcript-hit"; -import { - refreshQmdSessionArtifactDocIds, - replaceQmdSessionArtifactMappings, - type QmdSessionArtifactMapping, -} from "../qmd-session-artifacts.js"; -import { sanitizeQmdCollectionNameSegment } from "./qmd-collection-metadata.js"; - -const log = createSubsystemLogger("memory"); - -type QmdSessionExporterConfig = { - dir: string; - retentionMs?: number; - collectionName: string; -}; - -type BuildSearchPath = ( - collection: string, - collectionRelativePath: string, - workspaceRelativePath: string, - absolutePath: string, -) => string; - -type ExportedSessionState = { - entryHash: string; - mtimeMs: number; - revisionToken: string | null; - target: string; - targetRevision: string | null; -}; - -function buildSessionExportRevision(corpusEntry: SessionTranscriptCorpusEntry): string | null { - if (!corpusEntry.contentRevision) { - return null; - } - return [ - corpusEntry.contentRevision, - corpusEntry.sessionKey ?? "", - corpusEntry.updatedAtMs ?? "", - corpusEntry.generatedByDreamingNarrative === true ? "dreaming" : "", - corpusEntry.generatedByCronRun === true ? "cron" : "", - ].join("\0"); -} - -function pathStatRevision(stat: { - dev: number; - ino: number; - mtimeMs: number; - size: number; -}): string { - return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; -} - -export class QmdSessionExporter { - private readonly exportedSessionState = new Map(); - - constructor( - readonly config: QmdSessionExporterConfig, - private readonly agentId: string, - private readonly workspaceDir: string, - private readonly indexPath: string, - private readonly buildSearchPath: BuildSearchPath, - ) {} - - async exportSessions(lease: PluginStateLeaseContext): Promise { - const { signal } = lease; - signal.throwIfAborted(); - const exportDir = this.config.dir; - lease.assertOwned(); - await fs.mkdir(exportDir, { recursive: true }); - signal.throwIfAborted(); - const exportRoot = await root(exportDir); - signal.throwIfAborted(); - const corpusEntries = await listSessionTranscriptCorpusEntriesForAgent(this.agentId); - signal.throwIfAborted(); - const keep = new Set(); - const tracked = new Set(); - const artifactMappings: QmdSessionArtifactMapping[] = []; - const cutoff = this.config.retentionMs ? Date.now() - this.config.retentionMs : null; - for (const corpusEntry of corpusEntries) { - signal.throwIfAborted(); - const sessionFile = corpusEntry.sessionFile; - const targetName = `${this.sessionExportStem(corpusEntry)}.md`; - const target = path.join(exportDir, targetName); - const revisionToken = buildSessionExportRevision(corpusEntry); - const state = this.exportedSessionState.get(sessionFile); - // The corpus owns source revision detection. This hot path only stats the - // derived target, so unchanged transcripts are never reread or rehashed. - const targetRevision = - state?.target === target - ? await exportRoot - .stat(targetName) - .then(pathStatRevision) - .catch(() => null) - : null; - signal.throwIfAborted(); - if ( - revisionToken && - state?.revisionToken === revisionToken && - state.targetRevision !== null && - targetRevision === state.targetRevision - ) { - if (cutoff && state.mtimeMs < cutoff) { - continue; - } - tracked.add(sessionFile); - artifactMappings.push( - this.buildSessionArtifactMapping(sessionFile, targetName, target, corpusEntry), - ); - keep.add(target); - continue; - } - const entry = await buildSessionEntry(sessionFile, { - generatedByDreamingNarrative: corpusEntry.generatedByDreamingNarrative === true, - generatedByCronRun: corpusEntry.generatedByCronRun === true, - ...(corpusEntry.transcriptSource === "sqlite" && corpusEntry.storePath - ? { - agentId: corpusEntry.agentId, - sessionId: corpusEntry.sessionId, - storePath: corpusEntry.storePath, - } - : {}), - ...(corpusEntry.sessionKey ? { sessionKey: corpusEntry.sessionKey } : {}), - ...(corpusEntry.updatedAtMs !== undefined ? { updatedAtMs: corpusEntry.updatedAtMs } : {}), - }); - if (!entry || (cutoff && entry.mtimeMs < cutoff)) { - continue; - } - tracked.add(sessionFile); - artifactMappings.push( - this.buildSessionArtifactMapping(sessionFile, targetName, target, corpusEntry), - ); - const needsWrite = - !state || - state.target !== target || - state.entryHash !== entry.hash || - state.targetRevision === null || - targetRevision !== state.targetRevision; - let nextTargetRevision = targetRevision; - if (needsWrite) { - // fs-safe Root.write stages a sibling and atomically renames it, so a - // failed export cannot expose partially rendered markdown to QMD. - lease.assertOwned(); - await exportRoot.write(targetName, renderSessionMarkdown(entry), { encoding: "utf-8" }); - signal.throwIfAborted(); - nextTargetRevision = await exportRoot - .stat(targetName) - .then(pathStatRevision) - .catch(() => null); - signal.throwIfAborted(); - } - lease.assertOwned(); - this.exportedSessionState.set(sessionFile, { - entryHash: entry.hash, - mtimeMs: entry.mtimeMs, - revisionToken, - target, - targetRevision: nextTargetRevision, - }); - keep.add(target); - } - const exported = await exportRoot.list(".").catch((error: unknown) => { - signal.throwIfAborted(); - log.debug(`failed to list qmd session exports: ${String(error)}`); - return []; - }); - signal.throwIfAborted(); - for (const name of exported) { - if (!name.endsWith(".md")) { - continue; - } - const full = path.join(exportDir, name); - if (!keep.has(full)) { - lease.assertOwned(); - await exportRoot.remove(name).catch((error: unknown) => { - signal.throwIfAborted(); - log.debug(`failed to remove stale qmd session export ${name}: ${String(error)}`); - }); - signal.throwIfAborted(); - } - } - for (const [sessionFile, state] of this.exportedSessionState) { - if (!tracked.has(sessionFile) || !isPathInside(exportDir, state.target)) { - lease.assertOwned(); - this.exportedSessionState.delete(sessionFile); - } - } - signal.throwIfAborted(); - lease.assertOwned(); - replaceQmdSessionArtifactMappings({ - collection: this.config.collectionName, - indexPath: this.indexPath, - mappings: artifactMappings, - }); - } - - refreshArtifactDocIds(lease: PluginStateLeaseContext): void { - const { signal } = lease; - signal.throwIfAborted(); - lease.assertOwned(); - try { - refreshQmdSessionArtifactDocIds({ - assertOwned: () => lease.assertOwned(), - collection: this.config.collectionName, - indexPath: this.indexPath, - }); - } catch (err) { - signal.throwIfAborted(); - log.warn(`failed to refresh qmd session artifact identity docids: ${String(err)}`); - } - } - - private buildSessionArtifactMapping( - sessionFile: string, - artifactPath: string, - target: string, - corpusEntry: SessionTranscriptCorpusEntry, - ): QmdSessionArtifactMapping { - return { - agentId: corpusEntry.agentId, - archived: isSessionArchiveArtifactName(path.basename(sessionFile)), - artifactPath, - collection: this.config.collectionName, - memoryKey: formatSessionTranscriptMemoryHitKey({ - agentId: corpusEntry.agentId, - sessionId: corpusEntry.sessionId, - }), - searchPath: this.buildSearchPath( - this.config.collectionName, - artifactPath, - path.relative(this.workspaceDir, target), - target, - ), - sessionId: corpusEntry.sessionId, - }; - } - - private sessionExportStem(corpusEntry: SessionTranscriptCorpusEntry): string { - return corpusEntry.transcriptSource === "sqlite" - ? corpusEntry.sessionId - : path.basename(corpusEntry.sessionFile, ".jsonl"); - } -} - -export function resolveQmdSessionExporterConfig(params: { - qmd: ResolvedQmdConfig; - agentId: string; - qmdDir: string; -}): QmdSessionExporterConfig | null { - if (!params.qmd.sessions.enabled) { - return null; - } - return { - dir: params.qmd.sessions.exportDir ?? path.join(params.qmdDir, "sessions"), - ...(params.qmd.sessions.retentionDays - ? { retentionMs: params.qmd.sessions.retentionDays * 24 * 60 * 60 * 1000 } - : {}), - collectionName: pickSessionCollectionName(params.qmd, params.agentId), - }; -} - -function pickSessionCollectionName(qmd: ResolvedQmdConfig, agentId: string): string { - const existing = new Set(qmd.collections.map((collection) => collection.name)); - const base = `sessions-${sanitizeQmdCollectionNameSegment(agentId)}`; - if (!existing.has(base)) { - return base; - } - let counter = 2; - let candidate = `${base}-${counter}`; - while (existing.has(candidate)) { - counter += 1; - candidate = `${base}-${counter}`; - } - return candidate; -} - -function renderSessionMarkdown(entry: SessionFileEntry): string { - const header = `# Session ${path.basename(entry.path, path.extname(entry.path))}`; - const body = entry.content?.trim().length ? entry.content.trim() : "(empty)"; - return `${header}\n\n${body}\n`; -} diff --git a/extensions/memory-core/src/memory/runtime-host.ts b/extensions/memory-core/src/memory/runtime-host.ts index a4f7e6f5870e..e812822c3aca 100644 --- a/extensions/memory-core/src/memory/runtime-host.ts +++ b/extensions/memory-core/src/memory/runtime-host.ts @@ -1,4 +1,3 @@ -import { resolveGlobalSingleton } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; import type { PluginStateLeaseRunner } from "openclaw/plugin-sdk/plugin-state-runtime"; import type { MemoryCoreOpenKeyedStore } from "../dreaming-state.js"; import type { MemoryCoreAcquireLocalService } from "./embedding-local-service.js"; @@ -8,30 +7,3 @@ export type MemoryCoreRuntimeHost = { openKeyedStore?: MemoryCoreOpenKeyedStore; withLease?: PluginStateLeaseRunner; }; - -const MEMORY_LEASE_HOST_IDENTITIES_KEY = Symbol.for("openclaw.memoryLeaseHostIdentities"); - -// Manager caches survive module reloads, so lease-host identities must survive too. -// Otherwise a new runtime can reuse a manager whose writes belong to another host. -const LEASE_HOST_IDENTITIES = resolveGlobalSingleton<{ - ids: WeakMap; - nextId: number; -}>(MEMORY_LEASE_HOST_IDENTITIES_KEY, () => ({ - ids: new WeakMap(), - nextId: 1, -})); - -export function resolveMemoryCoreLeaseHostIdentity( - withLease: PluginStateLeaseRunner | undefined, -): string { - if (!withLease) { - return "none"; - } - let id = LEASE_HOST_IDENTITIES.ids.get(withLease); - if (id === undefined) { - id = LEASE_HOST_IDENTITIES.nextId; - LEASE_HOST_IDENTITIES.nextId += 1; - LEASE_HOST_IDENTITIES.ids.set(withLease, id); - } - return String(id); -} diff --git a/extensions/memory-core/src/memory/search-deadline.test.ts b/extensions/memory-core/src/memory/search-deadline.test.ts index 4b4d7e8edfb0..8f12c299c220 100644 --- a/extensions/memory-core/src/memory/search-deadline.test.ts +++ b/extensions/memory-core/src/memory/search-deadline.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { runMemorySearchWithDeadline, type MemorySearchDeadlineAction } from "./search-deadline.js"; +import { runMemorySearchWithDeadline } from "./search-deadline.js"; describe("runMemorySearchWithDeadline", () => { afterEach(() => { @@ -66,32 +66,6 @@ describe("runMemorySearchWithDeadline", () => { expect(vi.getTimerCount()).toBe(0); }); - it.each(["pause", "handoff"] satisfies MemorySearchDeadlineAction[])( - "does not allow %s to override an already-expired deadline", - async (action) => { - vi.useFakeTimers(); - vi.setSystemTime(0); - let controlDeadline: ((action: MemorySearchDeadlineAction) => void) | undefined; - const result = runMemorySearchWithDeadline({ - timeoutMs: 15_000, - run: async (_signal, control) => { - controlDeadline = control; - return await new Promise(() => {}); - }, - }); - const resultAssertion = expect(result).rejects.toThrow("memory_search timed out after 15s"); - await Promise.resolve(); - - // Advance wall time without running the timer callback, matching an I/O - // continuation that reaches the transition before the timers phase. - vi.setSystemTime(15_000); - controlDeadline?.(action); - - await resultAssertion; - expect(vi.getTimerCount()).toBe(0); - }, - ); - it("does not accept task success after the active deadline has expired", async () => { vi.useFakeTimers(); vi.setSystemTime(0); diff --git a/extensions/memory-core/src/memory/search-deadline.ts b/extensions/memory-core/src/memory/search-deadline.ts index bd90f60a2fa1..2bffb35148a7 100644 --- a/extensions/memory-core/src/memory/search-deadline.ts +++ b/extensions/memory-core/src/memory/search-deadline.ts @@ -1,12 +1,4 @@ export const DEFAULT_MEMORY_SEARCH_TIMEOUT_MS = 15_000; -// QMD pauses only for its query subprocess; fallback hands ownership to its -// own fresh default deadline. Manager maintenance remains on this clock. -export const MEMORY_SEARCH_DEADLINE_CONTROL = Symbol("memory-search-deadline-control"); -export type MemorySearchDeadlineAction = "pause" | "resume" | "handoff"; -export type MemorySearchDeadlineControlOptions = { - [MEMORY_SEARCH_DEADLINE_CONTROL]?: (action: MemorySearchDeadlineAction) => void; -}; - export function resolveMemorySearchAbortError(signal: AbortSignal): Error { const { reason } = signal; if (reason instanceof Error) { @@ -22,10 +14,7 @@ function createMemorySearchTimeoutError(timeoutMs: number): Error { export async function runMemorySearchWithDeadline(params: { timeoutMs: number; parentSignal?: AbortSignal; - run: ( - signal: AbortSignal, - controlDeadline: (action: MemorySearchDeadlineAction) => void, - ) => Promise; + run: (signal: AbortSignal) => Promise; }): Promise { if (params.parentSignal?.aborted) { throw resolveMemorySearchAbortError(params.parentSignal); @@ -36,54 +25,27 @@ export async function runMemorySearchWithDeadline(params: { const timeoutOutcome = { type: "timeout" } as const; const parentAbortOutcome = { type: "parent-abort" } as const; let timer: ReturnType | undefined; - let remainingMs = params.timeoutMs; - let deadlineStartedAt = Date.now(); + const deadlineStartedAt = Date.now(); let removeParentAbort: (() => void) | undefined; - let acceptDeadlineUpdates = true; let resolveTimeout!: (outcome: typeof timeoutOutcome) => void; const timeoutPromise = new Promise((resolve) => { resolveTimeout = resolve; }); const reachDefaultDeadline = () => { - acceptDeadlineUpdates = false; // Resolve before aborting so abort-aware tasks cannot replace the stable // deadline error with a provider-wrapped cancellation error. resolveTimeout(timeoutOutcome); controller.abort(timeoutError); }; - const scheduleDefaultDeadline = () => { - deadlineStartedAt = Date.now(); - timer = setTimeout(() => { - timer = undefined; - reachDefaultDeadline(); - }, remainingMs); - timer.unref?.(); - }; - const controlDeadline = (action: MemorySearchDeadlineAction) => { - if (!acceptDeadlineUpdates) { - return; - } - if (timer) { - clearTimeout(timer); - timer = undefined; - remainingMs = Math.max(0, remainingMs - (Date.now() - deadlineStartedAt)); - } - if (remainingMs === 0) { - reachDefaultDeadline(); - return; - } - if (action === "handoff") { - acceptDeadlineUpdates = false; - } else if (action === "resume") { - scheduleDefaultDeadline(); - } - }; - scheduleDefaultDeadline(); + timer = setTimeout(() => { + timer = undefined; + reachDefaultDeadline(); + }, params.timeoutMs); + timer.unref?.(); const parentSignal = params.parentSignal; const parentAbortPromise = parentSignal ? new Promise((resolve) => { const onAbort = () => { - acceptDeadlineUpdates = false; resolve(parentAbortOutcome); controller.abort(resolveMemorySearchAbortError(parentSignal)); }; @@ -91,7 +53,7 @@ export async function runMemorySearchWithDeadline(params: { removeParentAbort = () => parentSignal.removeEventListener("abort", onAbort); }) : undefined; - const task = Promise.resolve().then(() => params.run(controller.signal, controlDeadline)); + const task = Promise.resolve().then(() => params.run(controller.signal)); task.catch(() => undefined); try { @@ -107,17 +69,12 @@ export async function runMemorySearchWithDeadline(params: { if (parentSignal?.aborted) { throw resolveMemorySearchAbortError(parentSignal); } - if ( - acceptDeadlineUpdates && - timer !== undefined && - Date.now() - deadlineStartedAt >= remainingMs - ) { + if (timer !== undefined && Date.now() - deadlineStartedAt >= params.timeoutMs) { reachDefaultDeadline(); throw timeoutError; } return result as T; } finally { - acceptDeadlineUpdates = false; if (timer) { clearTimeout(timer); } diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index 07d46be32dd3..16b5e900678c 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -1,1575 +1,72 @@ -// Memory Core tests cover search manager plugin behavior. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +// Memory Core tests cover builtin search manager acquisition and cleanup. import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import type { checkQmdBinaryAvailability as checkQmdBinaryAvailabilityFn } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; -import type { - PluginStateLeaseContext, - PluginStateLeaseOptions, - PluginStateLeaseRunner, -} from "openclaw/plugin-sdk/plugin-state-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; -type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn; - -function createManagerStatus(params: { - backend: "qmd" | "builtin"; - provider: string; - model: string; - requestedProvider: string; - withMemorySourceCounts?: boolean; -}) { - const base = { - backend: params.backend, - provider: params.provider, - model: params.model, - requestedProvider: params.requestedProvider, - files: 0, - chunks: 0, - dirty: false, - workspaceDir: "/tmp", - dbPath: "/tmp/index.sqlite", - }; - if (!params.withMemorySourceCounts) { - return base; - } - return { - ...base, - sources: ["memory" as const], - sourceCounts: [{ source: "memory" as const, files: 0, chunks: 0 }], - }; -} - -function nativePath(candidate: string): string { - return path.resolve(candidate); -} - -type ManagerSearchParams = Parameters; -type ManagerSearchResult = Awaited>; - -function createManagerMock(params: { - backend: "qmd" | "builtin"; - provider: string; - model: string; - requestedProvider: string; - searchResults?: Array<{ - path: string; - startLine: number; - endLine: number; - score: number; - snippet: string; - source: "memory"; - projectKey?: string; - }>; - withMemorySourceCounts?: boolean; -}) { - return { - search: vi.fn( - async ( - _query: ManagerSearchParams[0], - _opts?: ManagerSearchParams[1], - ): Promise => params.searchResults ?? [], - ), - readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - listCuratedProjectCandidates: vi.fn(async () => params.searchResults ?? []), - status: vi.fn(() => - createManagerStatus({ - backend: params.backend, - provider: params.provider, - model: params.model, - requestedProvider: params.requestedProvider, - withMemorySourceCounts: params.withMemorySourceCounts, - }), - ), - sync: vi.fn(async () => {}), - probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), - probeVectorAvailability: vi.fn(async () => true), - close: vi.fn(async () => {}), - }; -} - -function createQmdManagerInstanceMock() { - return createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); -} - -const mockPrimary = vi.hoisted(() => ({ - ...createQmdManagerInstanceMock(), -})); - -const fallbackManager = vi.hoisted(() => ({ - ...createManagerMock({ - backend: "builtin", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", - searchResults: [ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 1, - snippet: "fallback", - source: "memory", - }, - ], - }), -})); - -const fallbackSearch = fallbackManager.search; -const mockMemoryIndexGet = vi.hoisted(() => vi.fn(async () => fallbackManager)); -const mockCloseAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {})); -const mockCloseMemoryIndexManagersForAgent = vi.hoisted(() => vi.fn(async () => {})); -const checkQmdBinaryAvailability = vi.hoisted(() => - vi.fn(async () => ({ available: true })), -); - -vi.mock("./qmd-manager.js", () => ({ - QmdMemoryManager: { - create: vi.fn(async () => mockPrimary), - }, -})); - -vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => ({ - checkQmdBinaryAvailability, - resolveQmdBinaryUnavailableReason: (result: { reason?: string }) => result.reason ?? "binary", +const builtinManager = vi.hoisted(() => ({ + search: vi.fn(async () => []), + readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), + status: vi.fn(() => ({ backend: "builtin" as const, provider: "openai" })), + probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), + probeVectorAvailability: vi.fn(async () => true), })); +const memoryIndexGet = vi.hoisted(() => vi.fn(async () => builtinManager)); +const closeAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {})); +const closeMemoryIndexManagersForAgent = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../../manager-runtime.js", () => ({ - MemoryIndexManager: { - get: mockMemoryIndexGet, - }, - closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers, - closeMemoryIndexManagersForAgent: mockCloseMemoryIndexManagersForAgent, + MemoryIndexManager: { get: memoryIndexGet }, + closeAllMemoryIndexManagers, + closeMemoryIndexManagersForAgent, })); -import { QmdMemoryManager } from "./qmd-manager.js"; -import { - MEMORY_SEARCH_DEADLINE_CONTROL, - runMemorySearchWithDeadline, - type MemorySearchDeadlineControlOptions, -} from "./search-deadline.js"; import { closeAllMemorySearchManagers, closeMemorySearchManager, - getMemorySearchManager as getMemorySearchManagerWithoutLease, + getMemorySearchManager, } from "./search-manager.js"; -const withLease: PluginStateLeaseRunner = async ( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, -) => - await run({ - signal: options.signal ?? new AbortController().signal, - assertOwned: vi.fn(), - }); -const createLeaseHost = (): PluginStateLeaseRunner => - async function leaseHost( - options: PluginStateLeaseOptions, - run: (lease: PluginStateLeaseContext) => Promise, - ): Promise { - return await withLease(options, run); - }; -const getMemorySearchManager = (params: Parameters[0]) => - getMemorySearchManagerWithoutLease({ ...params, withLease: params.withLease ?? withLease }); -const createQmdManagerMock = vi.mocked(QmdMemoryManager["create"]); -type QmdManagerInstance = Awaited>; -type SearchManagerResult = Awaited>; -type SearchManager = NonNullable; - -function createQmdCfg( - agentId: string, - workspace = "/tmp/workspace", - qmd: Record = {}, -): OpenClawConfig { - return { - memory: { backend: "qmd", qmd }, - agents: { list: [{ id: agentId, default: true, workspace }] }, - }; -} - -function createBuiltinCfg(agentId: string): OpenClawConfig { - return { - memory: { - search: { - provider: "openai", - model: "text-embedding-3-small", - store: { - path: "/tmp/index.sqlite", - vector: { enabled: false }, - }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - query: { minScore: 0, hybrid: { enabled: false } }, - sources: ["memory"], - experimental: { sessionMemory: false }, - }, - }, - - agents: { - defaults: { - workspace: "/tmp/workspace", - }, - list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }], - }, - } as OpenClawConfig; -} - -function requireManager(result: SearchManagerResult): SearchManager { - if (!result.manager) { - throw new Error("manager missing"); - } - return result.manager; -} - -function createDeferred() { - let resolve: ((value: T) => void) | undefined; - let reject: ((reason?: unknown) => void) | undefined; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - if (!resolve || !reject) { - throw new Error("Expected deferred callbacks to be initialized"); - } - return { promise, resolve, reject }; -} - -async function createFailedQmdSearchHarness(params: { agentId: string; errorMessage: string }) { - const cfg = createQmdCfg(params.agentId); - mockPrimary.search.mockRejectedValueOnce(new Error(params.errorMessage)); - const first = await getMemorySearchManager({ cfg, agentId: params.agentId }); - return { cfg, manager: requireManager(first), firstResult: first }; -} - -function qmdCreateParams(index = 0): Record { - const call = createQmdManagerMock.mock.calls[index]; - if (!call) { - throw new Error(`expected QMD manager create call ${index}`); - } - const params = call.at(0); - if (!params || typeof params !== "object") { - throw new Error(`expected QMD manager create params ${index}`); - } - return params as Record; -} - -async function expectPendingQmdReplacement(params: { - agentId: string; - firstCfg: OpenClawConfig; - secondCfg: OpenClawConfig; - firstAvailability: { command: string; cwd: string }; - secondAvailability: { command: string; cwd: string }; -}) { - const firstPrimary = createQmdManagerInstanceMock(); - const secondPrimary = createQmdManagerInstanceMock(); - const firstGate = createDeferred(); - const secondGate = createDeferred(); - createQmdManagerMock - .mockImplementationOnce(async () => await firstGate.promise) - .mockImplementationOnce(async () => await secondGate.promise); - - const firstPromise = getMemorySearchManager({ - cfg: params.firstCfg, - agentId: params.agentId, - }); - await Promise.resolve(); - const secondPromise = getMemorySearchManager({ - cfg: params.secondCfg, - agentId: params.agentId, - }); - await vi.waitFor(() => { - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); +describe("builtin memory search manager", () => { + beforeEach(() => { + memoryIndexGet.mockClear(); + memoryIndexGet.mockResolvedValue(builtinManager); + closeAllMemoryIndexManagers.mockClear(); + closeMemoryIndexManagersForAgent.mockClear(); }); - firstGate.resolve(firstPrimary as unknown as QmdManagerInstance); - await vi.waitFor(() => { - expect(createQmdManagerMock).toHaveBeenCalledTimes(2); + it("returns the builtin manager for every purpose", async () => { + const cfg = {} as OpenClawConfig; + + const result = await getMemorySearchManager({ cfg, agentId: "main", purpose: "status" }); + + expect(result.manager).toBe(builtinManager); + expect(result.error).toBeUndefined(); + expect(result.debug).toMatchObject({ backend: "builtin", purpose: "status" }); + expect(result.debug?.managerMs).toBeGreaterThanOrEqual(0); + expect(memoryIndexGet).toHaveBeenCalledWith({ cfg, agentId: "main", purpose: "status" }); }); - secondGate.resolve(secondPrimary as unknown as QmdManagerInstance); - const [first, second] = await Promise.all([firstPromise, secondPromise]); + it("returns the builtin initialization error", async () => { + memoryIndexGet.mockRejectedValueOnce(new Error("index unavailable")); - requireManager(first); - requireManager(second); - expect(first.manager).not.toBe(second.manager); - expect(firstPrimary.close).toHaveBeenCalledTimes(1); - expect(checkQmdBinaryAvailability).toHaveBeenNthCalledWith(1, { - command: params.firstAvailability.command, - env: process.env, - cwd: nativePath(params.firstAvailability.cwd), - }); - expect(checkQmdBinaryAvailability).toHaveBeenNthCalledWith(2, { - command: params.secondAvailability.command, - env: process.env, - cwd: nativePath(params.secondAvailability.cwd), - }); -} - -beforeEach(async () => { - await closeAllMemorySearchManagers(); - mockPrimary.search.mockClear(); - mockPrimary.readFile.mockClear(); - mockPrimary.listCuratedProjectCandidates.mockClear(); - mockPrimary.status.mockClear(); - mockPrimary.sync.mockClear(); - mockPrimary.probeEmbeddingAvailability.mockClear(); - mockPrimary.probeVectorAvailability.mockClear(); - mockPrimary.close.mockClear(); - fallbackSearch.mockClear(); - fallbackManager.readFile.mockClear(); - fallbackManager.listCuratedProjectCandidates.mockClear(); - fallbackManager.status.mockClear(); - fallbackManager.sync.mockClear(); - fallbackManager.probeEmbeddingAvailability.mockClear(); - fallbackManager.probeVectorAvailability.mockClear(); - fallbackManager.close.mockClear(); - mockCloseAllMemoryIndexManagers.mockClear(); - mockCloseMemoryIndexManagersForAgent.mockClear(); - mockMemoryIndexGet.mockClear(); - mockMemoryIndexGet.mockResolvedValue(fallbackManager); - checkQmdBinaryAvailability.mockClear(); - checkQmdBinaryAvailability.mockResolvedValue({ available: true }); - createQmdManagerMock.mockClear(); -}); - -describe("getMemorySearchManager caching", () => { - it("repairs an invalid shared singleton cache shape before using qmd cache maps", async () => { - await closeAllMemorySearchManagers(); - vi.resetModules(); - const cacheKey = Symbol.for("openclaw.memorySearchManagerCache"); - (globalThis as Record)[cacheKey] = {}; - - const freshModule = await import("./search-manager.js"); - try { - const result = await freshModule.getMemorySearchManager({ - cfg: createQmdCfg("corrupt-cache-agent"), - agentId: "corrupt-cache-agent", - withLease, - }); - const managerStatus = requireManager(result).status(); - expect(managerStatus.backend).toBe("qmd"); - expect(managerStatus.requestedProvider).toBe("qmd"); - } finally { - await freshModule.closeAllMemorySearchManagers(); - delete (globalThis as Record)[cacheKey]; - } + await expect( + getMemorySearchManager({ cfg: {} as OpenClawConfig, agentId: "main" }), + ).resolves.toMatchObject({ manager: null, error: "index unavailable" }); }); - it("does not return a failed-close wrapper after a module reload", async () => { - const agentId = "reload-failed-close"; - const cfg = createQmdCfg(agentId); - const firstManager = requireManager(await getMemorySearchManager({ cfg, agentId })); - mockPrimary.close.mockRejectedValueOnce(new Error("qmd close failed")); - - await expect(closeMemorySearchManager({ cfg, agentId })).rejects.toThrow("qmd close failed"); - - vi.resetModules(); - const freshModule = await import("./search-manager.js"); - try { - const second = await freshModule.getMemorySearchManager({ cfg, agentId, withLease }); - expect(second.manager).not.toBe(firstManager); - } finally { - await freshModule.closeAllMemorySearchManagers(); - } - }); - - it("reuses the same QMD manager instance for repeated calls", async () => { - const cfg = createQmdCfg("main"); - - const first = await getMemorySearchManager({ cfg, agentId: "main" }); - const second = await getMemorySearchManager({ cfg, agentId: "main" }); - - expect(first.manager).toBe(second.manager); - expect(createQmdManagerMock.mock.calls).toHaveLength(1); - expect(first.debug?.managerCacheState).toBe("cached-full-miss"); - expect(second.debug?.managerCacheState).toBe("cached-full-hit"); - expect(first.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/); - expect(second.debug?.qmdIdentityHash).toBe(first.debug?.qmdIdentityHash); - }); - - it("does not reuse QMD fallback managers across local-service hosts", async () => { - const agentId = "local-service-hosts"; - const cfg = createQmdCfg(agentId); - const firstAcquire = vi.fn(async () => undefined); - const secondAcquire = vi.fn(async () => undefined); - const firstPrimary = createQmdManagerInstanceMock(); - const secondPrimary = createQmdManagerInstanceMock(); - secondPrimary.search.mockRejectedValueOnce(new Error("qmd query failed")); - createQmdManagerMock - .mockImplementationOnce(async () => firstPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => secondPrimary as unknown as QmdManagerInstance); - - const first = await getMemorySearchManager({ - cfg, - agentId, - acquireLocalService: firstAcquire, - }); - const second = await getMemorySearchManager({ - cfg, - agentId, - acquireLocalService: secondAcquire, - }); - const secondManager = requireManager(second); - await secondManager.search("hello"); - - expect(Object.is(first.manager, second.manager)).toBe(false); - expect(firstPrimary.close).toHaveBeenCalledTimes(1); - expect(mockMemoryIndexGet).toHaveBeenCalledWith( - expect.objectContaining({ acquireLocalService: secondAcquire }), - ); - }); - - it("does not reuse QMD managers across SQLite lease hosts", async () => { - const agentId = "lease-hosts"; - const cfg = createQmdCfg(agentId); - const firstLease = createLeaseHost(); - const secondLease = createLeaseHost(); - const firstPrimary = createQmdManagerInstanceMock(); - const secondPrimary = createQmdManagerInstanceMock(); - createQmdManagerMock - .mockImplementationOnce(async () => firstPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => secondPrimary as unknown as QmdManagerInstance); - - const first = await getMemorySearchManager({ cfg, agentId, withLease: firstLease }); - const second = await getMemorySearchManager({ cfg, agentId, withLease: secondLease }); - - expect(first.manager).not.toBe(second.manager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(2); - expect(firstPrimary.close).toHaveBeenCalledTimes(1); - expect(qmdCreateParams(1).withLease).toBe(secondLease); - }); - - it("fails QMD closed when the host omits SQLite lease coordination", async () => { - const cfg = createQmdCfg("missing-lease-host"); - - const result = await getMemorySearchManagerWithoutLease({ - cfg, - agentId: "missing-lease-host", - }); - - expect(result.manager).toBe(fallbackManager); - expect(result.debug).toMatchObject({ - backend: "qmd", - managerCacheState: "fallback-builtin", - failureCode: "qmd-unavailable", - }); - expect(createQmdManagerMock).not.toHaveBeenCalled(); - }); - - it("keeps the cached QMD manager active when the caller cancels a search", async () => { - const agentId = "cancelled-search"; - const cfg = createQmdCfg(agentId); - const controller = new AbortController(); - const abortError = new Error("memory_search timed out after 15s"); - mockPrimary.search.mockImplementationOnce(async () => { - controller.abort(abortError); - throw abortError; - }); - - const first = await getMemorySearchManager({ cfg, agentId }); - const firstManager = requireManager(first); - await expect(firstManager.search("hello", { signal: controller.signal })).rejects.toBe( - abortError, - ); - - expect(mockPrimary.close).not.toHaveBeenCalled(); - expect(fallbackSearch).not.toHaveBeenCalled(); - const second = await getMemorySearchManager({ cfg, agentId }); - expect(second.manager).toBe(first.manager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - }); - - it("evicts failed qmd wrapper so next call retries qmd", async () => { - const retryAgentId = "retry-agent"; - const { - cfg, - manager: firstManager, - firstResult: first, - } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd query failed", - }); - - const controller = new AbortController(); - const fallbackResults = await firstManager.search("hello", { signal: controller.signal }); - expect(fallbackResults).toHaveLength(1); - expect(fallbackResults[0]?.path).toBe("MEMORY.md"); - expect(fallbackSearch).toHaveBeenCalledWith("hello", { signal: expect.any(AbortSignal) }); - const fallbackSignal = fallbackSearch.mock.calls[0]?.[1]?.signal; - expect(fallbackSignal).toBeInstanceOf(AbortSignal); - expect(fallbackSignal).not.toBe(controller.signal); - expect(fallbackSignal?.aborted).toBe(false); - - const second = await getMemorySearchManager({ cfg, agentId: retryAgentId }); - requireManager(second); - expect(second.manager).not.toBe(first.manager); - expect(createQmdManagerMock.mock.calls).toHaveLength(2); - }); - - it("blocks qmd reacquisition while a failed primary retires", async () => { - const agentId = "retry-agent-retirement"; - const cfg = createQmdCfg(agentId); - const firstPrimary = createQmdManagerInstanceMock(); - const secondPrimary = createQmdManagerInstanceMock(); - const closeGate = createDeferred(); - firstPrimary.search.mockRejectedValueOnce(new Error("qmd query failed")); - firstPrimary.close.mockImplementationOnce(async () => await closeGate.promise); - createQmdManagerMock - .mockImplementationOnce(async () => firstPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => secondPrimary as unknown as QmdManagerInstance); - - const first = requireManager(await getMemorySearchManager({ cfg, agentId })); - await expect(first.search("hello")).resolves.toHaveLength(1); - await vi.waitFor(() => expect(firstPrimary.close).toHaveBeenCalledTimes(1)); - - const secondPromise = getMemorySearchManager({ cfg, agentId }); - await Promise.resolve(); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - - closeGate.resolve(); - const second = requireManager(await secondPromise); - expect(second).not.toBe(first); - expect(createQmdManagerMock).toHaveBeenCalledTimes(2); - }); - - it("allows builtin acquisition while failed qmd cleanup remains retained", async () => { - const agentId = "retry-agent-retained-cleanup"; - const qmdCfg = createQmdCfg(agentId); - const primary = createQmdManagerInstanceMock(); - primary.search.mockRejectedValueOnce(new Error("qmd query failed")); - primary.close.mockRejectedValue(new Error("qmd close failed")); - createQmdManagerMock.mockImplementationOnce( - async () => primary as unknown as QmdManagerInstance, - ); - - const first = requireManager(await getMemorySearchManager({ cfg: qmdCfg, agentId })); - await expect(first.search("hello")).resolves.toHaveLength(1); - await vi.waitFor(() => expect(primary.close).toHaveBeenCalledTimes(1)); - - const builtin = await getMemorySearchManager({ cfg: createBuiltinCfg(agentId), agentId }); - expect(builtin.manager).toBe(fallbackManager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - - primary.close.mockResolvedValue(undefined); - }); - - it("falls back immediately when the qmd binary is unavailable", async () => { - const cfg = createQmdCfg("missing-qmd"); - checkQmdBinaryAvailability.mockResolvedValueOnce({ - available: false, - reason: "binary", - error: "spawn qmd ENOENT", - }); - - const result = await getMemorySearchManager({ cfg, agentId: "missing-qmd" }); - const manager = requireManager(result); - const searchResults = await manager.search("hello"); - - expect(createQmdManagerMock).not.toHaveBeenCalled(); - expect(mockMemoryIndexGet).toHaveBeenCalled(); - expect(searchResults).toHaveLength(1); - }); - - it("returns the qmd startup failure when builtin fallback is unavailable", async () => { - const cfg = createQmdCfg("missing-qmd-no-builtin"); - checkQmdBinaryAvailability.mockResolvedValueOnce({ - available: false, - reason: "binary", - error: "spawn qmd ENOENT", - }); - mockMemoryIndexGet.mockRejectedValueOnce( - new Error( - 'Memory search unavailable: embedding provider "openai" is configured but unavailable.', - ), - ); - - const result = await getMemorySearchManager({ cfg, agentId: "missing-qmd-no-builtin" }); - - expect(result.manager).toBeNull(); - expect(result.error).toContain("qmd binary unavailable (qmd): spawn qmd ENOENT"); - expect(result.error).toContain( - 'builtin fallback unavailable: Memory search unavailable: embedding provider "openai" is configured but unavailable.', - ); - expect(createQmdManagerMock).not.toHaveBeenCalled(); - expect(mockMemoryIndexGet).toHaveBeenCalledTimes(1); - }); - - it("treats legacy qmd unavailable results without a reason as binary failures", async () => { - const cfg = createQmdCfg("missing-qmd-legacy"); - checkQmdBinaryAvailability.mockResolvedValueOnce({ - available: false, - error: "spawn qmd ENOENT", - }); - - const result = await getMemorySearchManager({ cfg, agentId: "missing-qmd-legacy" }); - const manager = requireManager(result); - const searchResults = await manager.search("hello"); - - expect(createQmdManagerMock).not.toHaveBeenCalled(); - expect(mockMemoryIndexGet).toHaveBeenCalled(); - expect(searchResults).toHaveLength(1); - }); - - it("backs off repeated full qmd open failures until the cooldown expires", async () => { - const agentId = "qmd-open-cooldown"; - const cfg = createQmdCfg(agentId); - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); - createQmdManagerMock.mockRejectedValueOnce(new Error("Cannot find package 'chokidar'")); - - try { - const first = await getMemorySearchManager({ cfg, agentId }); - const second = await getMemorySearchManager({ cfg, agentId }); - - expect(first.manager).toBe(fallbackManager); - expect(second.manager).toBe(fallbackManager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - expect(checkQmdBinaryAvailability).toHaveBeenCalledTimes(1); - - nowSpy.mockReturnValue(62_001); - const third = await getMemorySearchManager({ cfg, agentId }); - const thirdManager = requireManager(third); - - expect(thirdManager.status().backend).toBe("qmd"); - expect(createQmdManagerMock).toHaveBeenCalledTimes(2); - expect(checkQmdBinaryAvailability).toHaveBeenCalledTimes(2); - } finally { - nowSpy.mockRestore(); - } - }); - - it("preserves qmd open-failure cooldown when scoped teardown closes no qmd manager", async () => { - const agentId = "qmd-open-cooldown-scoped-close"; - const cfg = createQmdCfg(agentId); - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); - createQmdManagerMock.mockRejectedValueOnce(new Error("Cannot find package 'chokidar'")); - - try { - const first = await getMemorySearchManager({ cfg, agentId }); - expect(first.manager).toBe(fallbackManager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - - await closeMemorySearchManager({ cfg, agentId }); - - const second = await getMemorySearchManager({ cfg, agentId }); - expect(second.manager).toBe(fallbackManager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - } finally { - nowSpy.mockRestore(); - } - }); - - it("lets status probes bypass and clear a full qmd open-failure cooldown", async () => { - const agentId = "qmd-open-status-bypass"; - const cfg = createQmdCfg(agentId); - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); - createQmdManagerMock.mockRejectedValueOnce(new Error("Cannot find package 'chokidar'")); - - try { - const first = await getMemorySearchManager({ cfg, agentId }); - expect(first.manager).toBe(fallbackManager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - - const status = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - expect(requireManager(status).status().backend).toBe("qmd"); - expect(createQmdManagerMock).toHaveBeenCalledTimes(2); - - const full = await getMemorySearchManager({ cfg, agentId }); - expect(requireManager(full).status().backend).toBe("qmd"); - expect(createQmdManagerMock).toHaveBeenCalledTimes(3); - } finally { - nowSpy.mockRestore(); - } - }); - - it("probes qmd availability from the agent workspace", async () => { - const agentId = "workspace-probe"; - const cfg = createQmdCfg(agentId); - - await getMemorySearchManager({ cfg, agentId }); - - expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({ - command: "qmd", - env: process.env, - cwd: nativePath("/tmp/workspace"), - }); - }); - - it("creates a missing agent workspace before probing qmd availability", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-workspace-")); - const workspace = path.join(tempRoot, "missing", "workspace"); - const agentId = "missing-workspace"; - const cfg = { - memory: { backend: "qmd", qmd: {} }, - agents: { list: [{ id: agentId, default: true, workspace }] }, - } as OpenClawConfig; - - try { - await getMemorySearchManager({ cfg, agentId }); - - const stat = await fs.stat(workspace); - expect(stat.isDirectory()).toBe(true); - expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({ - command: "qmd", - env: process.env, - cwd: workspace, - }); - } finally { - await fs.rm(tempRoot, { recursive: true, force: true }); - } - }); - - it("returns a cached qmd manager without probing the binary again", async () => { - const agentId = "cached-qmd"; - const cfg = createQmdCfg(agentId); - - const first = await getMemorySearchManager({ cfg, agentId }); - const second = await getMemorySearchManager({ cfg, agentId }); - - requireManager(first); - requireManager(second); - expect(first.manager).toBe(second.manager); - expect(checkQmdBinaryAvailability).toHaveBeenCalledTimes(1); - }); - - it("reuses cached full qmd manager across normalized agent ids", async () => { - const cfg = createQmdCfg("Main-Agent"); - - const first = await getMemorySearchManager({ cfg, agentId: "Main-Agent" }); - const second = await getMemorySearchManager({ cfg, agentId: "main-agent" }); - - requireManager(first); - requireManager(second); - expect(first.manager).toBe(second.manager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - const createParams = qmdCreateParams(); - expect(createParams?.agentId).toBe("main-agent"); - expect(createParams?.mode).toBe("full"); - }); - - it("replaces cached full qmd manager across different workspaces", async () => { - const agentId = "cached-qmd-workspace-reload"; - const firstCfg = createQmdCfg(agentId, "/tmp/workspace-a"); - const secondCfg = createQmdCfg(agentId, "/tmp/workspace-b"); - const firstPrimary = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - const secondPrimary = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - createQmdManagerMock - .mockImplementationOnce(async () => firstPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => secondPrimary as unknown as QmdManagerInstance); - - const first = await getMemorySearchManager({ cfg: firstCfg, agentId }); - const firstManager = requireManager(first); - const second = await getMemorySearchManager({ cfg: secondCfg, agentId }); - const secondManager = requireManager(second); - - expect(firstManager).not.toBe(secondManager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(2); - expect(firstPrimary.close).toHaveBeenCalledTimes(1); - await expect(firstManager.search("hello")).rejects.toThrow("replaced by a newer qmd manager"); - expect(() => firstManager.status()).toThrow("replaced by a newer qmd manager"); - expect(checkQmdBinaryAvailability).toHaveBeenNthCalledWith(1, { - command: "qmd", - env: process.env, - cwd: nativePath("/tmp/workspace-a"), - }); - expect(checkQmdBinaryAvailability).toHaveBeenNthCalledWith(2, { - command: "qmd", - env: process.env, - cwd: nativePath("/tmp/workspace-b"), - }); - }); - - it("replaces cached full qmd manager when context limits change", async () => { - const agentId = "cached-qmd-context-limits-reload"; - const firstCfg = createQmdCfg(agentId, "/tmp/workspace"); - const secondCfg = { - ...createQmdCfg(agentId, "/tmp/workspace"), - agents: { - list: [ - { - id: agentId, - default: true, - workspace: "/tmp/workspace", - contextLimits: { - memoryGetMaxChars: 24_000, - }, - }, - ], - }, - } as OpenClawConfig; - const firstPrimary = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - const secondPrimary = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - createQmdManagerMock - .mockImplementationOnce(async () => firstPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => secondPrimary as unknown as QmdManagerInstance); - - const first = await getMemorySearchManager({ cfg: firstCfg, agentId }); - const second = await getMemorySearchManager({ cfg: secondCfg, agentId }); - - requireManager(first); - requireManager(second); - expect(first.manager).not.toBe(second.manager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(2); - expect(firstPrimary.close).toHaveBeenCalledTimes(1); - }); - - it("keeps the existing cached full qmd manager when replacement creation fails", async () => { - const agentId = "cached-qmd-failed-replacement"; - const firstCfg = createQmdCfg(agentId, "/tmp/workspace-a"); - const secondCfg = createQmdCfg(agentId, "/tmp/workspace-b"); - const firstPrimary = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - createQmdManagerMock.mockImplementationOnce( - async () => firstPrimary as unknown as QmdManagerInstance, - ); - checkQmdBinaryAvailability - .mockResolvedValueOnce({ available: true }) - .mockResolvedValueOnce({ available: false, reason: "binary", error: "spawn qmd ENOENT" }); - - const first = await getMemorySearchManager({ cfg: firstCfg, agentId }); - const firstManager = requireManager(first); - const replacementAttempt = await getMemorySearchManager({ cfg: secondCfg, agentId }); - - expect(replacementAttempt.manager).toBe(fallbackManager); - expect(firstPrimary.close).not.toHaveBeenCalled(); - await expect(firstManager.search("hello")).resolves.toStrictEqual([]); - - const firstAgain = await getMemorySearchManager({ cfg: firstCfg, agentId }); - expect(firstAgain.manager).toBe(firstManager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - }); - - it("retains an unused qmd candidate when both replacement closes fail", async () => { - const agentId = "cached-qmd-double-close-failure"; - const firstCfg = createQmdCfg(agentId, "/tmp/workspace-a"); - const secondCfg = createQmdCfg(agentId, "/tmp/workspace-b"); - const firstPrimary = createQmdManagerInstanceMock(); - const secondPrimary = createQmdManagerInstanceMock(); - const thirdPrimary = createQmdManagerInstanceMock(); - firstPrimary.close.mockRejectedValueOnce(new Error("old close failed")); - secondPrimary.close.mockRejectedValueOnce(new Error("candidate close failed")); - createQmdManagerMock - .mockImplementationOnce(async () => firstPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => secondPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => thirdPrimary as unknown as QmdManagerInstance); - - await getMemorySearchManager({ cfg: firstCfg, agentId }); - await expect(getMemorySearchManager({ cfg: secondCfg, agentId })).rejects.toThrow( - "old close failed", - ); - expect(secondPrimary.close).toHaveBeenCalledTimes(1); - - const replacement = await getMemorySearchManager({ cfg: secondCfg, agentId }); - expect(replacement.manager).toBeDefined(); - expect(secondPrimary.close).toHaveBeenCalledTimes(2); - expect(createQmdManagerMock).toHaveBeenCalledTimes(3); - }); - - it("continues scoped teardown when retained candidate cleanup still fails", async () => { - const agentId = "cached-qmd-persistent-close-failure"; - const firstCfg = createQmdCfg(agentId, "/tmp/workspace-a"); - const secondCfg = createQmdCfg(agentId, "/tmp/workspace-b"); - const firstPrimary = createQmdManagerInstanceMock(); - const secondPrimary = createQmdManagerInstanceMock(); - firstPrimary.close.mockRejectedValueOnce(new Error("old close failed")); - secondPrimary.close.mockRejectedValue(new Error("candidate close failed")); - createQmdManagerMock - .mockImplementationOnce(async () => firstPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => secondPrimary as unknown as QmdManagerInstance); - - await getMemorySearchManager({ cfg: firstCfg, agentId }); - await expect(getMemorySearchManager({ cfg: secondCfg, agentId })).rejects.toThrow( - "old close failed", - ); - - await expect(closeMemorySearchManager({ cfg: firstCfg, agentId })).rejects.toThrow( - "candidate close failed", - ); - expect(secondPrimary.close).toHaveBeenCalledTimes(2); - expect(firstPrimary.close).toHaveBeenCalledTimes(2); - expect(mockCloseMemoryIndexManagersForAgent).toHaveBeenCalledWith({ - cfg: firstCfg, - agentId, - }); - - secondPrimary.close.mockResolvedValue(undefined); - await closeMemorySearchManager({ cfg: firstCfg, agentId }); - }); - - it("dedupes concurrent full qmd manager creation for the same agent", async () => { - const agentId = "pending-qmd"; - const cfg = createQmdCfg(agentId); - const createGate = createDeferred(); - createQmdManagerMock.mockImplementationOnce(async () => await createGate.promise); - - const firstPromise = getMemorySearchManager({ cfg, agentId }); - const secondPromise = getMemorySearchManager({ cfg, agentId }); - - createGate.resolve(mockPrimary as unknown as QmdManagerInstance); - const [first, second] = await Promise.all([firstPromise, secondPromise]); - - requireManager(first); - requireManager(second); - expect(first.manager).toBe(second.manager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - expect(checkQmdBinaryAvailability).toHaveBeenCalledTimes(1); - }); - - it("serializes pending full qmd creation before replacing it for a different workspace", async () => { - const agentId = "pending-qmd-workspace-reload"; - const firstCfg = createQmdCfg(agentId, "/tmp/workspace-a"); - const secondCfg = createQmdCfg(agentId, "/tmp/workspace-b"); - await expectPendingQmdReplacement({ - agentId, - firstCfg, - secondCfg, - firstAvailability: { command: "qmd", cwd: "/tmp/workspace-a" }, - secondAvailability: { command: "qmd", cwd: "/tmp/workspace-b" }, - }); - }); - - it("serializes pending full qmd creation before replacing it for a different qmd config", async () => { - const agentId = "pending-qmd-config-reload"; - const firstCfg = createQmdCfg(agentId, "/tmp/workspace", { command: "qmd" }); - const secondCfg = createQmdCfg(agentId, "/tmp/workspace", { command: "qmd-alt" }); - await expectPendingQmdReplacement({ - agentId, - firstCfg, - secondCfg, - firstAvailability: { command: "qmd", cwd: "/tmp/workspace" }, - secondAvailability: { command: "qmd-alt", cwd: "/tmp/workspace" }, - }); - }); - - it("reuses pending full qmd creation when raw cfg differs but qmd inputs match", async () => { - const agentId = "pending-qmd-unrelated-config"; - const firstCfg = createQmdCfg(agentId); - const secondCfg = { - ...createQmdCfg(agentId), - session: { store: "/tmp/alternate-session-store.json" }, - } as OpenClawConfig; - const createGate = createDeferred(); - createQmdManagerMock.mockImplementationOnce(async () => await createGate.promise); - - const firstPromise = getMemorySearchManager({ cfg: firstCfg, agentId }); - await Promise.resolve(); - const secondPromise = getMemorySearchManager({ cfg: secondCfg, agentId }); - - createGate.resolve(mockPrimary as unknown as QmdManagerInstance); - const [first, second] = await Promise.all([firstPromise, secondPromise]); - - requireManager(first); - requireManager(second); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - expect(first.manager).toBe(second.manager); - expect(checkQmdBinaryAvailability).toHaveBeenCalledTimes(1); - }); - - it("does not cache qmd managers for status-only requests", async () => { - const agentId = "status-agent"; - const cfg = createQmdCfg(agentId); - - const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - - requireManager(first); - requireManager(second); - const firstStatus = requireManager(first).status(); - expect(firstStatus.backend).toBe("qmd"); - expect(firstStatus.provider).toBe("qmd"); - expect(firstStatus.model).toBe("qmd"); - expect(firstStatus.requestedProvider).toBe("qmd"); - expect(createQmdManagerMock.mock.calls).toHaveLength(2); - expect(mockMemoryIndexGet).not.toHaveBeenCalled(); - - await first.manager?.close?.(); - await second.manager?.close?.(); - expect(mockPrimary.close).toHaveBeenCalledTimes(2); - }); - - it("does not reuse cached full qmd managers for one-shot CLI requests", async () => { - const agentId = "cli-agent"; - const cfg = createQmdCfg(agentId); - const fullPrimary = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - const cliPrimary = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - createQmdManagerMock - .mockImplementationOnce(async () => fullPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => cliPrimary as unknown as QmdManagerInstance); - - const full = await getMemorySearchManager({ cfg, agentId }); - const cli = await getMemorySearchManager({ cfg, agentId, purpose: "cli" }); - const fullManager = requireManager(full); - const cliManager = requireManager(cli); - - expect(cli.debug?.managerCacheState).toBe("transient-cli"); - expect(full.debug?.managerCacheState).toBe("cached-full-miss"); - expect(full.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/); - expect(cli.debug?.qmdIdentityHash).toBe(full.debug?.qmdIdentityHash); - expect(cliManager).toBe(cliPrimary); - expect(cliManager).not.toBe(fullManager); - const fullCreateParams = qmdCreateParams(); - const cliCreateParams = qmdCreateParams(1); - expect(fullCreateParams?.agentId).toBe(agentId); - expect(fullCreateParams?.mode).toBe("full"); - expect(cliCreateParams?.agentId).toBe(agentId); - expect(cliCreateParams?.mode).toBe("cli"); - - await cli.manager?.close?.(); - expect(cliPrimary.close).toHaveBeenCalledTimes(1); - expect(fullPrimary.close).not.toHaveBeenCalled(); - - const fullAgain = await getMemorySearchManager({ cfg, agentId }); - expect(fullAgain.manager).toBe(fullManager); - }); - - it("does not cache builtin managers for status-only requests", async () => { - const agentId = "builtin-status-agent"; - const cfg = createBuiltinCfg(agentId); - const firstBuiltinManager = createManagerMock({ - backend: "builtin", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", - }); - const secondBuiltinManager = createManagerMock({ - backend: "builtin", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", - }); - mockMemoryIndexGet - .mockResolvedValueOnce(firstBuiltinManager) - .mockResolvedValueOnce(secondBuiltinManager); - - const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - - expect(first.manager).toBe(firstBuiltinManager); - expect(second.manager).toBe(secondBuiltinManager); - expect(second.manager).not.toBe(first.manager); - expect(mockMemoryIndexGet).toHaveBeenCalledTimes(2); - - await first.manager?.close?.(); - await second.manager?.close?.(); - expect(firstBuiltinManager.close).toHaveBeenCalledTimes(1); - expect(secondBuiltinManager.close).toHaveBeenCalledTimes(1); - }); - - it("reports real qmd index counts for status-only requests", async () => { - const agentId = "status-counts-agent"; - const cfg = createQmdCfg(agentId); - mockPrimary.status.mockReturnValueOnce({ - ...createManagerStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }), - files: 10, - chunks: 42, - sourceCounts: [{ source: "memory" as const, files: 10, chunks: 42 }], - }); - - const result = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - const manager = requireManager(result); - - const status = manager.status(); - expect(status.backend).toBe("qmd"); - expect(status.files).toBe(10); - expect(status.chunks).toBe(42); - expect(status.sourceCounts).toEqual([{ source: "memory", files: 10, chunks: 42 }]); - const createParams = qmdCreateParams(); - expect(createParams?.agentId).toBe(agentId); - expect(createParams?.mode).toBe("status"); - }); - - it("reuses cached full qmd manager for status-only requests", async () => { - const agentId = "status-reuses-full-agent"; - const cfg = createQmdCfg(agentId); - - const full = await getMemorySearchManager({ cfg, agentId }); - const status = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - - requireManager(full); - requireManager(status); - expect(status.manager).not.toBe(full.manager); - expect(createQmdManagerMock.mock.calls).toHaveLength(1); - await status.manager?.close?.(); - expect(mockPrimary.close).not.toHaveBeenCalled(); - - const fullAgain = await getMemorySearchManager({ cfg, agentId }); - expect(fullAgain.manager).toBe(full.manager); - }); - - it("does not borrow a cached full qmd manager for status across different workspaces", async () => { - const agentId = "status-workspace-reload"; - const firstCfg = createQmdCfg(agentId, "/tmp/workspace-a"); - const secondCfg = createQmdCfg(agentId, "/tmp/workspace-b"); - const firstPrimary = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - const secondStatusManager = createManagerMock({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }); - createQmdManagerMock - .mockImplementationOnce(async () => firstPrimary as unknown as QmdManagerInstance) - .mockImplementationOnce(async () => secondStatusManager as unknown as QmdManagerInstance); - - const full = await getMemorySearchManager({ cfg: firstCfg, agentId }); - const fullManager = requireManager(full); - const status = await getMemorySearchManager({ cfg: secondCfg, agentId, purpose: "status" }); - - requireManager(status); - expect(status.manager).toBe(secondStatusManager); - expect(createQmdManagerMock.mock.calls).toHaveLength(2); - expect(firstPrimary.close).not.toHaveBeenCalled(); - expect(checkQmdBinaryAvailability).toHaveBeenNthCalledWith(1, { - command: "qmd", - env: process.env, - cwd: nativePath("/tmp/workspace-a"), - }); - expect(checkQmdBinaryAvailability).toHaveBeenNthCalledWith(2, { - command: "qmd", - env: process.env, - cwd: nativePath("/tmp/workspace-b"), - }); - - const fullAgain = await getMemorySearchManager({ cfg: firstCfg, agentId }); - expect(fullAgain.manager).toBe(fullManager); - }); - - it("gets a fresh qmd manager for later status requests after close", async () => { - const agentId = "status-eviction-agent"; - const cfg = createQmdCfg(agentId); - - const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - const firstManager = requireManager(first); - await firstManager.close?.(); - - const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); - requireManager(second); - - expect(createQmdManagerMock.mock.calls).toHaveLength(2); - expect(mockPrimary.close).toHaveBeenCalledTimes(1); - }); - - it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => { - const retryAgentId = "retry-agent-close"; - const { - cfg, - manager: firstManager, - firstResult: first, - } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd query failed", - }); - await firstManager.search("hello"); - - const second = await getMemorySearchManager({ cfg, agentId: retryAgentId }); - const secondManager = requireManager(second); - expect(second.manager).not.toBe(first.manager); - - await firstManager.close?.(); - - const third = await getMemorySearchManager({ cfg, agentId: retryAgentId }); - expect(third.manager).toBe(secondManager); - expect(createQmdManagerMock.mock.calls).toHaveLength(2); - }); - - it("falls back to builtin search when qmd fails with sqlite busy", async () => { - const retryAgentId = "retry-agent-busy"; - const { manager: firstManager } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd index busy while reading results: SQLITE_BUSY: database is locked", - }); - - const results = await firstManager.search("hello"); - expect(results).toHaveLength(1); - expect(results[0]?.path).toBe("MEMORY.md"); - expect(fallbackSearch).toHaveBeenCalledTimes(1); - }); - - it("falls back to builtin when curated project listing fails", async () => { - const agentId = "project-list-fallback"; - const cfg = createQmdCfg(agentId); - mockPrimary.listCuratedProjectCandidates.mockRejectedValueOnce( - new Error("qmd project listing failed"), - ); - const manager = requireManager(await getMemorySearchManager({ cfg, agentId })); - - const results = await manager.listCuratedProjectCandidates?.({ - activeProjectKeys: ["github.com/openclaw/openclaw"], - limit: 3, - }); - - expect(results).toHaveLength(1); - expect(fallbackManager.listCuratedProjectCandidates).toHaveBeenCalledWith({ - activeProjectKeys: ["github.com/openclaw/openclaw"], - limit: 3, - }); - }); - - it("does not wait for failed qmd retirement before starting builtin fallback", async () => { - const retryAgentId = "retry-agent-slow-retirement"; - const { manager: firstManager } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd query failed", - }); - const retirementGate = createDeferred(); - mockPrimary.close.mockImplementationOnce(async () => await retirementGate.promise); - const onDebug = vi.fn(); - - try { - const results = await firstManager.search("hello", { onDebug }); - - expect(results).toHaveLength(1); - expect(onDebug).toHaveBeenCalledWith({ backend: "builtin" }); - expect(mockPrimary.close).toHaveBeenCalledTimes(1); - expect(fallbackSearch).toHaveBeenCalledTimes(1); - } finally { - retirementGate.resolve(); - mockPrimary.close.mockImplementation(async () => {}); - } - }); - - it("signals builtin fallback to calls queued behind the failed qmd primary", async () => { - const retryAgentId = "retry-agent-concurrent-fallback"; - const { manager: firstManager } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd query failed", - }); - const fallbackGate = createDeferred(); - mockMemoryIndexGet.mockImplementation(async () => await fallbackGate.promise); - const firstDebug = vi.fn(); - const secondDebug = vi.fn(); - - const firstSearch = firstManager.search("first", { onDebug: firstDebug }); - await vi.waitFor(() => expect(firstDebug).toHaveBeenCalledWith({ backend: "builtin" })); - const secondSearch = firstManager.search("second", { onDebug: secondDebug }); - await vi.waitFor(() => expect(secondDebug).toHaveBeenCalledWith({ backend: "builtin" })); - - fallbackGate.resolve(fallbackManager); - await expect(Promise.all([firstSearch, secondSearch])).resolves.toHaveLength(2); - expect(fallbackSearch).toHaveBeenCalledTimes(2); - }); - - it("joins and closes builtin fallback creation during wrapper teardown", async () => { - const agentId = "fallback-create-close-race"; - const { manager } = await createFailedQmdSearchHarness({ - agentId, - errorMessage: "qmd query failed", - }); - const fallbackGate = createDeferred(); - mockMemoryIndexGet.mockImplementationOnce(async () => await fallbackGate.promise); - - const searchPromise = manager.search("hello"); - await vi.waitFor(() => expect(mockMemoryIndexGet).toHaveBeenCalledTimes(1)); - const closePromise = manager.close?.() ?? Promise.resolve(); - fallbackGate.resolve(fallbackManager); - - await closePromise; - await expect(searchPromise).rejects.toThrow("memory search manager is closed"); - expect(fallbackManager.close).toHaveBeenCalledTimes(1); - }); - - it("does not start fallback creation after wrapper teardown begins", async () => { - const agentId = "fallback-after-close-race"; - const primarySearchGate = createDeferred(); - mockPrimary.search.mockImplementationOnce(async () => { - await primarySearchGate.promise; - throw new Error("qmd query failed"); - }); - const cfg = createQmdCfg(agentId); - const manager = requireManager(await getMemorySearchManager({ cfg, agentId })); - const primaryCloseGate = createDeferred(); - mockPrimary.close.mockImplementation(async () => await primaryCloseGate.promise); - - const searchPromise = manager.search("hello"); - await vi.waitFor(() => expect(mockPrimary.search).toHaveBeenCalledTimes(1)); - const closePromise = manager.close?.() ?? Promise.resolve(); - primarySearchGate.resolve(); - await vi.waitFor(() => expect(mockPrimary.close).toHaveBeenCalled()); - - primaryCloseGate.resolve(); - await closePromise; - await expect(searchPromise).rejects.toThrow("memory search manager is closed"); - expect(mockMemoryIndexGet).not.toHaveBeenCalled(); - }); - - it("gives same-call qmd-to-builtin fallback a fresh default deadline", async () => { - vi.useFakeTimers(); - try { - const retryAgentId = "retry-agent-fallback-timeout"; - const { manager: firstManager } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd query failed", - }); - mockPrimary.search.mockReset(); - mockPrimary.search.mockImplementationOnce(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10_000); - }); - throw new Error("qmd query failed"); - }); - let fallbackSignal: AbortSignal | undefined; - fallbackSearch.mockImplementationOnce( - async (_query: string, opts?: Parameters[1]) => { - fallbackSignal = opts?.signal; - return await new Promise(() => {}); - }, - ); - const onDebug = vi.fn(); - - let settled = false; - const resultPromise = runMemorySearchWithDeadline({ - timeoutMs: 15_000, - run: async (signal, controlDeadline) => { - const searchOptions: NonNullable & - MemorySearchDeadlineControlOptions = { - signal, - onDebug, - [MEMORY_SEARCH_DEADLINE_CONTROL]: controlDeadline, - }; - return await firstManager.search("hello", searchOptions); - }, - }).then( - () => { - settled = true; - return undefined; - }, - (error: unknown) => { - settled = true; - return error; - }, - ); - await vi.advanceTimersByTimeAsync(9_999); - - expect(fallbackSearch).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - - expect(fallbackSearch).toHaveBeenCalledTimes(1); - expect(onDebug).toHaveBeenCalledWith({ backend: "builtin" }); - await vi.advanceTimersByTimeAsync(14_999); - - expect(settled).toBe(false); - expect(fallbackSignal?.aborted).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - - const error = await resultPromise; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe("memory_search timed out after 15s"); - expect(fallbackSignal?.aborted).toBe(true); - } finally { - vi.useRealTimers(); - } - }); - - it("propagates caller cancellation to a same-call builtin fallback", async () => { - const retryAgentId = "retry-agent-fallback-abort"; - const { manager: firstManager } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd query failed", - }); - let fallbackSignal: AbortSignal | undefined; - fallbackSearch.mockImplementationOnce( - async (_query: string, opts?: Parameters[1]) => { - fallbackSignal = opts?.signal; - return await new Promise(() => {}); - }, - ); - const controller = new AbortController(); - const abortError = new Error("memory_search timed out after 45s"); - - const resultPromise = firstManager.search("hello", { signal: controller.signal }); - await vi.waitFor(() => expect(fallbackSearch).toHaveBeenCalledTimes(1)); - controller.abort(abortError); - - await expect(resultPromise).rejects.toBe(abortError); - expect(fallbackSignal?.aborted).toBe(true); - expect(fallbackSignal?.reason).toBe(abortError); - }); - - it("keeps original qmd error when fallback manager initialization fails", async () => { - const retryAgentId = "retry-agent-no-fallback-auth"; - const { manager: firstManager } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd query failed", - }); - mockMemoryIndexGet.mockRejectedValueOnce(new Error("No API key found for provider openai")); - - await expect(firstManager.search("hello")).rejects.toThrow("qmd query failed"); - }); - - it("closes cached managers on global teardown", async () => { - const cfg = createQmdCfg("teardown-agent"); - const first = await getMemorySearchManager({ cfg, agentId: "teardown-agent" }); - const firstManager = requireManager(first); + it("closes all loaded builtin managers", async () => { + await getMemorySearchManager({ cfg: {} as OpenClawConfig, agentId: "main" }); await closeAllMemorySearchManagers(); - expect(mockPrimary.close).toHaveBeenCalledTimes(1); - expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1); - - const second = await getMemorySearchManager({ cfg, agentId: "teardown-agent" }); - const secondManager = requireManager(second); - expect(secondManager).not.toBe(firstManager); - expect(createQmdManagerMock.mock.calls).toHaveLength(2); + expect(closeAllMemoryIndexManagers).toHaveBeenCalledOnce(); }); - it("closes only the requested agent qmd manager on scoped teardown", async () => { - const mainCfg = createQmdCfg("main"); - const otherPrimary = createQmdManagerInstanceMock(); - createQmdManagerMock.mockImplementationOnce( - async () => mockPrimary as unknown as QmdManagerInstance, - ); - createQmdManagerMock.mockImplementationOnce( - async () => otherPrimary as unknown as QmdManagerInstance, - ); + it("normalizes the agent id before scoped cleanup", async () => { + const cfg = {} as OpenClawConfig; + await getMemorySearchManager({ cfg, agentId: " Main " }); - const main = await getMemorySearchManager({ cfg: mainCfg, agentId: "main" }); - const other = await getMemorySearchManager({ cfg: createQmdCfg("other"), agentId: "other" }); - const mainManager = requireManager(main); - const otherManager = requireManager(other); + await closeMemorySearchManager({ cfg, agentId: " Main " }); - await closeMemorySearchManager({ cfg: mainCfg, agentId: "main" }); - - expect(mockPrimary.close).toHaveBeenCalledTimes(1); - expect(otherPrimary.close).not.toHaveBeenCalled(); - const nextMain = await getMemorySearchManager({ cfg: mainCfg, agentId: "main" }); - const nextOther = await getMemorySearchManager({ - cfg: createQmdCfg("other"), - agentId: "other", - }); - expect(nextMain.manager).not.toBe(mainManager); - expect(nextOther.manager).toBe(otherManager); - }); - - it("blocks qmd replacement while scoped teardown closes its builtin fallback", async () => { - const agentId = "scoped-fallback-close-race"; - const cfg = createQmdCfg(agentId); - const firstManager = requireManager(await getMemorySearchManager({ cfg, agentId })); - (firstManager as unknown as { fallback: typeof fallbackManager }).fallback = fallbackManager; - const fallbackCloseGate = createDeferred(); - fallbackManager.close.mockImplementationOnce(async () => await fallbackCloseGate.promise); - - const closePromise = closeMemorySearchManager({ cfg, agentId }); - await vi.waitFor(() => expect(fallbackManager.close).toHaveBeenCalledTimes(1)); - const secondPromise = getMemorySearchManager({ cfg, agentId }); - await Promise.resolve(); - expect(createQmdManagerMock).toHaveBeenCalledTimes(1); - - fallbackCloseGate.resolve(); - await closePromise; - const secondManager = requireManager(await secondPromise); - expect(secondManager).not.toBe(firstManager); - expect(createQmdManagerMock).toHaveBeenCalledTimes(2); - }); - - it("closes the requested agent builtin index manager on scoped teardown", async () => { - const cfg = createBuiltinCfg("main"); - await getMemorySearchManager({ cfg, agentId: "main" }); - - await closeMemorySearchManager({ cfg, agentId: "main" }); - - expect(mockCloseMemoryIndexManagersForAgent).toHaveBeenCalledWith({ - cfg, - agentId: "main", - }); - }); - - it("waits for pending full qmd manager creation during global teardown", async () => { - const agentId = "teardown-pending-qmd"; - const cfg = createQmdCfg(agentId); - const createGate = createDeferred(); - createQmdManagerMock.mockImplementationOnce(async () => await createGate.promise); - - const firstPromise = getMemorySearchManager({ cfg, agentId }); - await Promise.resolve(); - - const closePromise = closeAllMemorySearchManagers(); - await Promise.resolve(); - - createGate.resolve(mockPrimary as unknown as QmdManagerInstance); - - const first = await firstPromise; - const firstManager = requireManager(first); - await closePromise; - - expect(mockPrimary.close).toHaveBeenCalledTimes(1); - - const second = await getMemorySearchManager({ cfg, agentId }); - expect(second.manager).not.toBe(firstManager); - expect(createQmdManagerMock.mock.calls).toHaveLength(2); - }); - - it("closes builtin index managers on teardown after runtime is loaded", async () => { - const retryAgentId = "teardown-with-fallback"; - const { manager } = await createFailedQmdSearchHarness({ - agentId: retryAgentId, - errorMessage: "qmd query failed", - }); - await manager.search("hello"); - - await closeAllMemorySearchManagers(); - - expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1); + expect(closeMemoryIndexManagersForAgent).toHaveBeenCalledWith({ cfg, agentId: "main" }); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/memory-core/src/memory/search-manager.ts b/extensions/memory-core/src/memory/search-manager.ts index 2e1796bef22e..d5f63dd49f93 100644 --- a/extensions/memory-core/src/memory/search-manager.ts +++ b/extensions/memory-core/src/memory/search-manager.ts @@ -1,264 +1,15 @@ -import { createHash } from "node:crypto"; -// Memory Core plugin module implements search manager behavior. -import fs from "node:fs/promises"; +// Memory Core plugin module owns builtin search manager acquisition and cleanup. import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { - createSubsystemLogger, - resolveAgentContextLimits, - resolveAgentWorkspaceDir, - resolveGlobalSingleton, - resolveMemorySearchSyncConfig, - type OpenClawConfig, -} from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { - checkQmdBinaryAvailability, - resolveQmdBinaryUnavailableReason, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; -import { - resolveMemoryBackendConfig, - type MemoryEmbeddingProbeResult, - type MemorySearchManager, - type MemorySyncParams, - type ResolvedQmdConfig, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; +import type { MemorySearchManager } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import type { PluginStateLeaseRunner } from "openclaw/plugin-sdk/plugin-state-runtime"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; -import { - resolveMemoryCoreLocalServiceHostIdentity, - type MemoryCoreAcquireLocalService, -} from "./embedding-local-service.js"; -import { resolveMemoryCoreLeaseHostIdentity } from "./runtime-host.js"; -import { - DEFAULT_MEMORY_SEARCH_TIMEOUT_MS, - MEMORY_SEARCH_DEADLINE_CONTROL, - runMemorySearchWithDeadline, - type MemorySearchDeadlineControlOptions, -} from "./search-deadline.js"; +import type { MemoryCoreAcquireLocalService } from "./embedding-local-service.js"; -const MEMORY_SEARCH_MANAGER_CACHE_KEY = Symbol.for("openclaw.memorySearchManagerCache"); -type Maybe = T | null; -type MemoryManagerSearchOptions = Parameters[1]; -type QmdManagerRuntimeConfig = { - workspaceDir: string; - syncSettings: ReturnType; - contextLimits: ReturnType; -}; - -type CachedQmdManagerEntry = { - identityKey: string; - manager: MemorySearchManager; -}; - -type PendingQmdManagerCreate = { - identityKey: string; - promise: Promise>; -}; - -type QmdManagerOpenFailure = { - identityKey: string; - reason: string; - retryAfterMs: number; -}; - -type MemorySearchManagerCacheState = - | "cached-full-hit" - | "cached-full-miss" - | "transient-cli" - | "transient-status" - | "pending-create-wait" - | "fallback-builtin" - | "recent-failure-cooldown"; - -type MemorySearchManagerDebug = { - backend?: "builtin" | "qmd"; - purpose?: MemorySearchManagerPurpose; - managerMs?: number; - managerCacheState?: MemorySearchManagerCacheState; - qmdIdentityHash?: string; - failureCode?: "qmd-unavailable"; -}; - -type MemorySearchManagerCacheStore = { - qmdManagerCache: Map; - pendingQmdManagerCreates: Map; - qmdManagerOpenFailures: Map; - retainedQmdManagers: Map>; - scopeLifecycleTails: Map>; - globalClosePromise: Promise | null; -}; - -const QMD_MANAGER_OPEN_FAILURE_COOLDOWN_MS = 60_000; - -function createMemorySearchManagerCacheStore(): MemorySearchManagerCacheStore { - return { - qmdManagerCache: new Map(), - pendingQmdManagerCreates: new Map(), - qmdManagerOpenFailures: new Map(), - retainedQmdManagers: new Map>(), - scopeLifecycleTails: new Map>(), - globalClosePromise: null, - }; -} - -function getMemorySearchManagerCacheStore(): MemorySearchManagerCacheStore { - // Keep caches reachable across `vi.resetModules()` so later cleanup can close older instances. - const resolved = resolveGlobalSingleton( - MEMORY_SEARCH_MANAGER_CACHE_KEY, - createMemorySearchManagerCacheStore, - ); - if ( - typeof resolved === "object" && - resolved !== null && - (resolved as Partial).qmdManagerCache instanceof Map && - (resolved as Partial).pendingQmdManagerCreates instanceof Map - ) { - const cacheStore = resolved as Partial; - if (!(cacheStore.qmdManagerOpenFailures instanceof Map)) { - cacheStore.qmdManagerOpenFailures = new Map(); - } - if (!(cacheStore.scopeLifecycleTails instanceof Map)) { - cacheStore.scopeLifecycleTails = new Map>(); - } - if (!(cacheStore.retainedQmdManagers instanceof Map)) { - cacheStore.retainedQmdManagers = new Map>(); - } - if ( - cacheStore.globalClosePromise !== null && - !(cacheStore.globalClosePromise instanceof Promise) - ) { - cacheStore.globalClosePromise = null; - } - return cacheStore as MemorySearchManagerCacheStore; - } - const repaired = createMemorySearchManagerCacheStore(); - (globalThis as Record)[MEMORY_SEARCH_MANAGER_CACHE_KEY] = repaired; - return repaired; -} - -const log = createSubsystemLogger("memory"); -const MEMORY_SEARCH_MANAGER_CACHE_STORE = getMemorySearchManagerCacheStore(); -const { - qmdManagerCache: QMD_MANAGER_CACHE, - pendingQmdManagerCreates: PENDING_QMD_MANAGER_CREATES, - qmdManagerOpenFailures: QMD_MANAGER_OPEN_FAILURES, -} = MEMORY_SEARCH_MANAGER_CACHE_STORE; - -function retainQmdManagerForCleanup(scopeKey: string, manager: MemorySearchManager): void { - const retained = MEMORY_SEARCH_MANAGER_CACHE_STORE.retainedQmdManagers.get(scopeKey) ?? new Set(); - retained.add(manager); - MEMORY_SEARCH_MANAGER_CACHE_STORE.retainedQmdManagers.set(scopeKey, retained); -} - -function releaseRetainedQmdManager(scopeKey: string, manager: MemorySearchManager): void { - const retained = MEMORY_SEARCH_MANAGER_CACHE_STORE.retainedQmdManagers.get(scopeKey); - if (!retained) { - return; - } - retained.delete(manager); - if (retained.size === 0) { - MEMORY_SEARCH_MANAGER_CACHE_STORE.retainedQmdManagers.delete(scopeKey); - } -} - -async function drainRetainedQmdManagers(scopeKey: string): Promise { - const retained = MEMORY_SEARCH_MANAGER_CACHE_STORE.retainedQmdManagers.get(scopeKey); - if (!retained) { - return; - } - let firstError: unknown; - let closeFailed = false; - for (const manager of retained) { - try { - await manager.close?.(); - retained.delete(manager); - } catch (err) { - if (!closeFailed) { - firstError = err; - } - closeFailed = true; - } - } - if (retained.size === 0) { - MEMORY_SEARCH_MANAGER_CACHE_STORE.retainedQmdManagers.delete(scopeKey); - } - if (closeFailed) { - throw firstError; - } -} - -async function runMemorySearchManagerScopeOperation( - scopeKey: string, - operation: () => Promise, - options: { drainRetained?: boolean } = {}, -): Promise { - while (MEMORY_SEARCH_MANAGER_CACHE_STORE.globalClosePromise) { - const globalClose = MEMORY_SEARCH_MANAGER_CACHE_STORE.globalClosePromise; - try { - await globalClose; - } catch { - if (MEMORY_SEARCH_MANAGER_CACHE_STORE.globalClosePromise === globalClose) { - await closeAllMemorySearchManagers(); - } - } - } - const previous = - MEMORY_SEARCH_MANAGER_CACHE_STORE.scopeLifecycleTails.get(scopeKey) ?? Promise.resolve(); - const run = async () => { - if (options.drainRetained !== false) { - await drainRetainedQmdManagers(scopeKey); - } - return await operation(); - }; - const result = previous.then(run, run); - const tail = result.then( - () => undefined, - () => undefined, - ); - MEMORY_SEARCH_MANAGER_CACHE_STORE.scopeLifecycleTails.set(scopeKey, tail); - try { - return await result; - } finally { - if (MEMORY_SEARCH_MANAGER_CACHE_STORE.scopeLifecycleTails.get(scopeKey) === tail) { - MEMORY_SEARCH_MANAGER_CACHE_STORE.scopeLifecycleTails.delete(scopeKey); - } - } -} - -async function runMemorySearchManagerGlobalClose(operation: () => Promise): Promise { - const previous = MEMORY_SEARCH_MANAGER_CACHE_STORE.globalClosePromise ?? Promise.resolve(); - const closePromise = previous.then(operation, operation); - MEMORY_SEARCH_MANAGER_CACHE_STORE.globalClosePromise = closePromise; - await closePromise; - if (MEMORY_SEARCH_MANAGER_CACHE_STORE.globalClosePromise === closePromise) { - MEMORY_SEARCH_MANAGER_CACHE_STORE.globalClosePromise = null; - } -} - -function retireQmdManagerInScope(scopeKey: string, manager: MemorySearchManager): void { - retainQmdManagerForCleanup(scopeKey, manager); - void runMemorySearchManagerScopeOperation( - scopeKey, - async () => { - await manager.close?.(); - releaseRetainedQmdManager(scopeKey, manager); - }, - { drainRetained: false }, - ).catch((err: unknown) => { - log.warn(`failed to retire qmd memory manager: ${formatErrorMessage(err)}`); - }); -} const managerRuntimeLoader = createLazyRuntimeModule(() => import("../../manager-runtime.js")); const loadManagerRuntime = managerRuntimeLoader; -const loadQmdManagerModule = createLazyRuntimeModule(() => import("./qmd-manager.js")); - -type MemorySearchManagerResult = { - manager: Maybe; - error?: string; - debug?: MemorySearchManagerDebug; -}; - type MemorySearchManagerPurpose = "default" | "status" | "cli"; type MemorySearchManagerParams = { cfg: OpenClawConfig; @@ -268,820 +19,60 @@ type MemorySearchManagerParams = { withLease?: PluginStateLeaseRunner; }; -function isClosedMemorySearchManager(manager: MemorySearchManager): boolean { - const isClosed = Reflect.get(manager, "isClosed"); - return typeof isClosed === "function" && isClosed.call(manager) === true; -} - -function getActiveQmdManagerOpenFailure( - scopeKey: string, - identityKey: string, - nowMs = Date.now(), -): QmdManagerOpenFailure | null { - const failure = QMD_MANAGER_OPEN_FAILURES.get(scopeKey); - if (!failure) { - return null; - } - if (failure.identityKey !== identityKey || failure.retryAfterMs <= nowMs) { - QMD_MANAGER_OPEN_FAILURES.delete(scopeKey); - return null; - } - return failure; -} - -function recordQmdManagerOpenFailure( - scopeKey: string, - identityKey: string, - reason: string, - nowMs = Date.now(), -): void { - QMD_MANAGER_OPEN_FAILURES.set(scopeKey, { - identityKey, - reason, - retryAfterMs: nowMs + QMD_MANAGER_OPEN_FAILURE_COOLDOWN_MS, - }); -} - -function clearQmdManagerOpenFailure(scopeKey: string, identityKey: string): void { - const failure = QMD_MANAGER_OPEN_FAILURES.get(scopeKey); - if (failure?.identityKey === identityKey) { - QMD_MANAGER_OPEN_FAILURES.delete(scopeKey); - } -} - -function hashQmdManagerIdentity(identityKey: string): string { - return createHash("sha256").update(identityKey).digest("hex"); -} - -function applyManagerDebug( - result: MemorySearchManagerResult, - debug: MemorySearchManagerDebug, -): MemorySearchManagerResult { - if (result.debug && Object.keys(result.debug).length > 0 && Object.keys(debug).length === 0) { - return result; - } - return { - ...result, - debug: { - ...result.debug, - ...debug, - }, +type MemorySearchManagerResult = { + manager: MemorySearchManager | null; + error?: string; + debug?: { + backend: "builtin"; + purpose: MemorySearchManagerPurpose; + managerMs: number; }; -} +}; export async function getMemorySearchManager( params: MemorySearchManagerParams, ): Promise { - const scopeKey = buildQmdManagerScopeKey(normalizeAgentId(params.agentId)); - const resolved = resolveMemoryBackendConfig(params); - return await runMemorySearchManagerScopeOperation( - scopeKey, - async () => await getMemorySearchManagerWithinLifecycle(params), - { drainRetained: resolved.backend === "qmd" }, - ); -} - -async function getMemorySearchManagerWithinLifecycle( - params: MemorySearchManagerParams, -): Promise { - const acquireStartedAt = Date.now(); - const purpose = params.purpose ?? "default"; - const finish = ( - result: MemorySearchManagerResult, - debug: MemorySearchManagerDebug, - ): MemorySearchManagerResult => - applyManagerDebug(result, { - purpose, - managerMs: Math.max(0, Date.now() - acquireStartedAt), - ...debug, - }); - const resolved = resolveMemoryBackendConfig(params); - if (resolved.backend === "qmd" && resolved.qmd) { - const qmdResolved = resolved.qmd; - const normalizedAgentId = normalizeAgentId(params.agentId); - const runtimeConfig = resolveQmdManagerRuntimeConfig(params.cfg, normalizedAgentId); - const { workspaceDir } = runtimeConfig; - const transient = params.purpose === "status" || params.purpose === "cli"; - const scopeKey = buildQmdManagerScopeKey(normalizedAgentId); - const identityKey = buildQmdManagerIdentityKey( - normalizedAgentId, - qmdResolved, - runtimeConfig, - params.acquireLocalService, - params.withLease, - ); - const debugIdentityHash = hashQmdManagerIdentity(identityKey); - - const createPrimaryQmdManager = async ( - mode: "full" | "status" | "cli", - ): Promise<{ manager: Maybe; failureReason?: string }> => { - if (!params.withLease) { - const message = "memory-core host does not provide SQLite lease coordination"; - log.warn(`qmd memory unavailable; falling back to builtin: ${message}`); - return { manager: null, failureReason: `qmd memory unavailable: ${message}` }; - } - try { - await fs.mkdir(workspaceDir, { recursive: true }); - } catch (err) { - const message = formatErrorMessage(err); - log.warn( - `qmd workspace unavailable (${workspaceDir}); falling back to builtin: ${message}`, - ); - return { - manager: null, - failureReason: `qmd workspace unavailable (${workspaceDir}): ${message}`, - }; - } - - const qmdBinary = await checkQmdBinaryAvailability({ - command: qmdResolved.command, - env: process.env, - cwd: workspaceDir, - }); - if (!qmdBinary.available) { - const message = qmdBinary.error; - const failurePrefix = - resolveQmdBinaryUnavailableReason(qmdBinary) === "workspace-cwd" - ? `qmd workspace unavailable (${workspaceDir})` - : `qmd binary unavailable (${qmdResolved.command})`; - log.warn(`${failurePrefix}; falling back to builtin: ${message}`); - return { - manager: null, - failureReason: `${failurePrefix}: ${message}`, - }; - } - try { - const { QmdMemoryManager } = await loadQmdManagerModule(); - const primary = await QmdMemoryManager.create({ - cfg: params.cfg, - agentId: normalizedAgentId, - resolved: { ...resolved, qmd: qmdResolved }, - mode, - runtimeConfig, - withLease: params.withLease, - }); - if (primary) { - clearQmdManagerOpenFailure(scopeKey, identityKey); - return { manager: primary }; - } - } catch (err) { - const message = formatErrorMessage(err); - log.warn(`qmd memory unavailable; falling back to builtin: ${message}`); - return { manager: null, failureReason: `qmd memory unavailable: ${message}` }; - } - return { manager: null, failureReason: "qmd memory unavailable: no manager returned" }; - }; - - const createFullQmdManager = async ( - expectedIdentityKey: string, - ): Promise<{ entry: Maybe; failureReason?: string }> => { - const { manager: primary, failureReason } = await createPrimaryQmdManager("full"); - if (!primary) { - return { entry: null, failureReason }; - } - const wrapper = new FallbackMemoryManager( - { - primary, - retirePrimary: () => retireQmdManagerInScope(scopeKey, primary), - fallbackFactory: async () => { - const { MemoryIndexManager } = await loadManagerRuntime(); - return await MemoryIndexManager.get(params); - }, - }, - () => { - const current = QMD_MANAGER_CACHE.get(scopeKey); - if (current === cacheEntry) { - QMD_MANAGER_CACHE.delete(scopeKey); - } - }, - ); - const cacheEntry: CachedQmdManagerEntry = { - identityKey: expectedIdentityKey, - manager: wrapper, - }; - return { entry: cacheEntry }; - }; - - let cached = QMD_MANAGER_CACHE.get(scopeKey); - if (cached && isClosedMemorySearchManager(cached.manager)) { - await cached.manager.close?.(); - if (QMD_MANAGER_CACHE.get(scopeKey) === cached) { - QMD_MANAGER_CACHE.delete(scopeKey); - } - cached = undefined; - } - const cachedMatchesIdentity = cached?.identityKey === identityKey; - if (cachedMatchesIdentity && cached) { - if (params.purpose === "status") { - // Status callers often close the manager they receive. Wrap the live - // full manager with a no-op close so health/status probes do not tear - // down the active QMD manager for the process. - return finish( - { manager: new BorrowedMemoryManager(cached.manager) }, - { - backend: "qmd", - managerCacheState: "cached-full-hit", - qmdIdentityHash: debugIdentityHash, - }, - ); - } - if (params.purpose !== "cli") { - return finish( - { manager: cached.manager }, - { - backend: "qmd", - managerCacheState: "cached-full-hit", - qmdIdentityHash: debugIdentityHash, - }, - ); - } - } - - if (transient) { - const { manager, failureReason } = await createPrimaryQmdManager( - params.purpose === "cli" ? "cli" : "status", - ); - return manager - ? finish( - { manager }, - { - backend: "qmd", - managerCacheState: params.purpose === "cli" ? "transient-cli" : "transient-status", - qmdIdentityHash: debugIdentityHash, - }, - ) - : finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason), { - backend: "qmd", - managerCacheState: "fallback-builtin", - qmdIdentityHash: debugIdentityHash, - failureCode: "qmd-unavailable", - }); - } - - const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey); - if (recentFailure) { - log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`); - return finish( - await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason), - { - backend: "qmd", - managerCacheState: "recent-failure-cooldown", - qmdIdentityHash: debugIdentityHash, - failureCode: "qmd-unavailable", - }, - ); - } - - const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey); - if (pending) { - await pending.promise; - return finish(await getMemorySearchManagerWithinLifecycle(params), { - backend: "qmd", - managerCacheState: "pending-create-wait", - qmdIdentityHash: debugIdentityHash, - }); - } - - let pendingFailureReason: string | undefined; - const pendingCreate: PendingQmdManagerCreate = { - identityKey, - promise: (async () => { - const created = await createFullQmdManager(identityKey); - if (!created.entry) { - pendingFailureReason = created.failureReason ?? "qmd memory unavailable"; - recordQmdManagerOpenFailure(scopeKey, identityKey, pendingFailureReason); - return null; - } - if (cached) { - try { - await closeQmdManagerForReplacement(cached.manager); - } catch (err) { - retainQmdManagerForCleanup(scopeKey, created.entry.manager); - try { - await created.entry.manager.close?.(); - releaseRetainedQmdManager(scopeKey, created.entry.manager); - } catch (closeErr) { - log.warn( - `failed to close unused qmd memory manager: ${formatErrorMessage(closeErr)}`, - ); - } - throw err; - } - } - QMD_MANAGER_CACHE.set(scopeKey, created.entry); - return created.entry.manager; - })().finally(() => { - const currentPending = PENDING_QMD_MANAGER_CREATES.get(scopeKey); - if (currentPending === pendingCreate) { - PENDING_QMD_MANAGER_CREATES.delete(scopeKey); - } - }), - }; - PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate); - const manager = await pendingCreate.promise; - return manager - ? finish( - { manager }, - { - backend: "qmd", - managerCacheState: "cached-full-miss", - qmdIdentityHash: debugIdentityHash, - }, - ) - : finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason), { - backend: "qmd", - managerCacheState: "fallback-builtin", - qmdIdentityHash: debugIdentityHash, - failureCode: "qmd-unavailable", - }); - } - - return finish(await getBuiltinMemorySearchManager(params), { - backend: "builtin", - }); -} - -async function getBuiltinMemorySearchManagerAfterQmdFailure( - params: MemorySearchManagerParams, - qmdFailureReason: string | undefined, -): Promise { - const fallback = await getBuiltinMemorySearchManager(params); - if (fallback.manager || !qmdFailureReason) { - return fallback; - } - const fallbackError = fallback.error?.trim(); + const startedAt = Date.now(); + const result = await getBuiltinMemorySearchManager(params); return { - manager: null, - error: fallbackError - ? `${qmdFailureReason}; builtin fallback unavailable: ${fallbackError}` - : qmdFailureReason, + ...result, + debug: { + backend: "builtin", + purpose: params.purpose ?? "default", + managerMs: Math.max(0, Date.now() - startedAt), + }, }; } async function getBuiltinMemorySearchManager( params: MemorySearchManagerParams, -): Promise { +): Promise> { try { const { MemoryIndexManager } = await loadManagerRuntime(); - const manager = await MemoryIndexManager.get(params); - return { manager }; + return { manager: await MemoryIndexManager.get(params) }; } catch (err) { - const message = formatErrorMessage(err); - return { manager: null, error: message }; + return { manager: null, error: formatErrorMessage(err) }; } } -class BorrowedMemoryManager implements MemorySearchManager { - readonly probeVectorStoreAvailability?: () => Promise; - - constructor(private readonly inner: MemorySearchManager) { - if (inner.probeVectorStoreAvailability) { - const probeVectorStoreAvailability = inner.probeVectorStoreAvailability.bind(inner); - this.probeVectorStoreAvailability = async () => await probeVectorStoreAvailability(); - } - } - - async search(query: string, opts?: MemoryManagerSearchOptions) { - return await this.inner.search(query, opts); - } - - async readFile(params: { relPath: string; from?: number; lines?: number }) { - return await this.inner.readFile(params); - } - - async listCuratedProjectCandidates(opts: { activeProjectKeys: string[]; limit?: number }) { - return (await this.inner.listCuratedProjectCandidates?.(opts)) ?? []; - } - - status() { - return this.inner.status(); - } - - async sync(params?: MemorySyncParams) { - await this.inner.sync?.(params); - } - - async probeEmbeddingAvailability(): Promise { - return await this.inner.probeEmbeddingAvailability(); - } - - getCachedEmbeddingAvailability(): MemoryEmbeddingProbeResult | null { - return this.inner.getCachedEmbeddingAvailability?.() ?? null; - } - - async probeVectorAvailability() { - return await this.inner.probeVectorAvailability(); - } - - async close() {} -} - export async function closeAllMemorySearchManagers(): Promise { - await runMemorySearchManagerGlobalClose(closeAllMemorySearchManagersWithinLifecycle); -} - -async function closeAllMemorySearchManagersWithinLifecycle(): Promise { - const scopeTails = Array.from(MEMORY_SEARCH_MANAGER_CACHE_STORE.scopeLifecycleTails.values()); - if (scopeTails.length > 0) { - await Promise.allSettled(scopeTails); - } - const pendingCreates = Array.from(PENDING_QMD_MANAGER_CREATES.values(), (entry) => entry.promise); - await Promise.allSettled(pendingCreates); - const entries = Array.from(QMD_MANAGER_CACHE.entries()); - QMD_MANAGER_OPEN_FAILURES.clear(); - let firstError: unknown; - let closeFailed = false; - for (const scopeKey of Array.from(MEMORY_SEARCH_MANAGER_CACHE_STORE.retainedQmdManagers.keys())) { - try { - await drainRetainedQmdManagers(scopeKey); - } catch (err) { - if (!closeFailed) { - firstError = err; - } - closeFailed = true; - } - } - for (const [scopeKey, entry] of entries) { - try { - await entry.manager.close?.(); - if (QMD_MANAGER_CACHE.get(scopeKey) === entry) { - QMD_MANAGER_CACHE.delete(scopeKey); - } - } catch (err) { - if (!closeFailed) { - firstError = err; - } - closeFailed = true; - log.warn(`failed to close qmd memory manager: ${String(err)}`); - } - } - if (managerRuntimeLoader.peek()) { - try { - const { closeAllMemoryIndexManagers } = await loadManagerRuntime(); - await closeAllMemoryIndexManagers(); - } catch (err) { - if (!closeFailed) { - firstError = err; - } - closeFailed = true; - } - } - if (closeFailed) { - throw firstError; + if (!managerRuntimeLoader.peek()) { + return; } + const { closeAllMemoryIndexManagers } = await loadManagerRuntime(); + await closeAllMemoryIndexManagers(); } export async function closeMemorySearchManager(params: { cfg: OpenClawConfig; agentId: string; }): Promise { - const scopeKey = buildQmdManagerScopeKey(normalizeAgentId(params.agentId)); - await runMemorySearchManagerScopeOperation( - scopeKey, - async () => await closeMemorySearchManagerWithinLifecycle(params), - { drainRetained: false }, - ); -} - -async function closeMemorySearchManagerWithinLifecycle(params: { - cfg: OpenClawConfig; - agentId: string; -}): Promise { - const normalizedAgentId = normalizeAgentId(params.agentId); - const scopeKey = buildQmdManagerScopeKey(normalizedAgentId); - let closeError: unknown; - let closeFailed = false; - try { - await drainRetainedQmdManagers(scopeKey); - } catch (err) { - closeError = err; - closeFailed = true; - } - const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey); - if (pending) { - await Promise.allSettled([pending.promise]); - } - const cached = QMD_MANAGER_CACHE.get(scopeKey); - if (cached) { - try { - await cached.manager.close?.(); - if (QMD_MANAGER_CACHE.get(scopeKey) === cached) { - QMD_MANAGER_CACHE.delete(scopeKey); - } - QMD_MANAGER_OPEN_FAILURES.delete(scopeKey); - } catch (err) { - closeError = err; - closeFailed = true; - log.warn(`failed to close qmd memory manager for agent ${normalizedAgentId}: ${String(err)}`); - } - } - if (managerRuntimeLoader.peek()) { - try { - const { closeMemoryIndexManagersForAgent } = await loadManagerRuntime(); - await closeMemoryIndexManagersForAgent({ cfg: params.cfg, agentId: normalizedAgentId }); - } catch (err) { - if (!closeFailed) { - closeError = err; - } - closeFailed = true; - } - } - if (closeFailed) { - throw closeError; - } -} - -class FallbackMemoryManager implements MemorySearchManager { - private fallback: Maybe = null; - private fallbackInitPromise: Promise> | null = null; - private primaryFailed = false; - private lastError?: string; - private cacheEvicted = false; - private closed = false; - private closePromise: Promise | null = null; - private closeReason = "memory search manager is closed"; - - constructor( - private readonly deps: { - primary: MemorySearchManager; - retirePrimary: () => void; - fallbackFactory: () => Promise>; - }, - private readonly onClose?: () => void, - ) {} - - async search(query: string, opts?: MemoryManagerSearchOptions) { - this.ensureOpen(); - if (!this.primaryFailed) { - try { - return await this.deps.primary.search(query, opts); - } catch (err) { - // Caller cancellation is request-scoped, not a QMD health failure. - // Keep the shared manager active for concurrent and later searches. - if (opts?.signal?.aborted) { - throw err; - } - this.primaryFailed = true; - this.lastError = formatErrorMessage(err); - log.warn(`qmd memory failed; switching to builtin index: ${this.lastError}`); - this.deps.retirePrimary(); - // Evict the failed wrapper so the next request can retry QMD with a fresh manager. - this.evictCacheEntry(); - } - } - // The fallback owns a fresh default budget. Release any outer QMD clock - // before builtin setup so earlier QMD maintenance cannot shorten it. - (opts as MemorySearchDeadlineControlOptions | undefined)?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.( - "handoff", - ); - // Expose the backend transition before fallback setup starts. This must run - // for concurrent and later calls that observe an already-failed primary too. - opts?.onDebug?.({ backend: "builtin" }); - // Calls already queued on this failed wrapper must receive the same - // bounded builtin setup and search budget as the first fallback call. - return await runMemorySearchWithDeadline({ - timeoutMs: DEFAULT_MEMORY_SEARCH_TIMEOUT_MS, - parentSignal: opts?.signal, - run: async (signal) => { - const fallback = await this.ensureFallback(); - if (!fallback) { - throw new Error(this.lastError ?? "memory search unavailable"); - } - return await fallback.search(query, { ...opts, signal }); - }, - }); - } - - async readFile(params: { relPath: string; from?: number; lines?: number }) { - this.ensureOpen(); - if (!this.primaryFailed) { - return await this.deps.primary.readFile(params); - } - const fallback = await this.ensureFallback(); - if (fallback) { - return await fallback.readFile(params); - } - throw new Error(this.lastError ?? "memory read unavailable"); - } - - async listCuratedProjectCandidates(opts: { activeProjectKeys: string[]; limit?: number }) { - this.ensureOpen(); - if (!this.primaryFailed && this.deps.primary.listCuratedProjectCandidates) { - try { - return await this.deps.primary.listCuratedProjectCandidates(opts); - } catch (err) { - this.primaryFailed = true; - this.lastError = formatErrorMessage(err); - log.warn(`qmd memory failed; switching to builtin index: ${this.lastError}`); - this.deps.retirePrimary(); - this.evictCacheEntry(); - } - } - const fallback = await this.ensureFallback(); - return (await fallback?.listCuratedProjectCandidates?.(opts)) ?? []; - } - - status() { - this.ensureOpen(); - if (!this.primaryFailed) { - return this.deps.primary.status(); - } - const fallbackStatus = this.fallback?.status() ?? this.deps.primary.status(); - const fallbackInfo = { from: "qmd", reason: this.lastError ?? "unknown" }; - return { - ...fallbackStatus, - fallback: fallbackInfo, - custom: { - ...fallbackStatus.custom, - fallback: { disabled: true, reason: this.lastError ?? "unknown" }, - }, - }; - } - - async sync(params?: MemorySyncParams) { - this.ensureOpen(); - if (!this.primaryFailed) { - await this.deps.primary.sync?.(params); - return; - } - const fallback = await this.ensureFallback(); - await fallback?.sync?.(params); - } - - async probeEmbeddingAvailability(): Promise { - this.ensureOpen(); - if (!this.primaryFailed) { - return await this.deps.primary.probeEmbeddingAvailability(); - } - const fallback = await this.ensureFallback(); - if (fallback) { - return await fallback.probeEmbeddingAvailability(); - } - return { ok: false, error: this.lastError ?? "memory embeddings unavailable" }; - } - - getCachedEmbeddingAvailability(): MemoryEmbeddingProbeResult | null { - this.ensureOpen(); - if (!this.primaryFailed) { - return this.deps.primary.getCachedEmbeddingAvailability?.() ?? null; - } - return this.fallback?.getCachedEmbeddingAvailability?.() ?? null; - } - - async probeVectorStoreAvailability() { - this.ensureOpen(); - if (!this.primaryFailed) { - return await (this.deps.primary.probeVectorStoreAvailability?.() ?? - this.deps.primary.probeVectorAvailability()); - } - const fallback = await this.ensureFallback(); - return ( - (await (fallback?.probeVectorStoreAvailability?.() ?? fallback?.probeVectorAvailability())) ?? - false - ); - } - - async probeVectorAvailability() { - this.ensureOpen(); - if (!this.primaryFailed) { - return await this.deps.primary.probeVectorAvailability(); - } - const fallback = await this.ensureFallback(); - return (await fallback?.probeVectorAvailability()) ?? false; - } - - async close() { - const existingClose = this.closePromise; - if (existingClose) { - await existingClose; - return; - } - const closeOperation = this.closeOnce(); - this.closePromise = closeOperation; - try { - await closeOperation; - } catch (err) { - if (this.closePromise === closeOperation) { - this.closePromise = null; - } - throw err; - } - } - - private async closeOnce(): Promise { - this.closed = true; - const pendingFallback = this.fallbackInitPromise; - await this.deps.primary.close?.(); - await pendingFallback; - await this.fallback?.close?.(); - this.fallback = null; - this.evictCacheEntry(); - } - - async invalidate(reason: string) { - this.closeReason = reason; - await this.close(); - } - - private async ensureFallback(): Promise> { - this.ensureOpen(); - if (this.fallback) { - return this.fallback; - } - const pending = this.fallbackInitPromise; - if (pending) { - const fallback = await pending; - this.ensureOpen(); - return fallback; - } - const initialization = (async (): Promise> => { - let fallback: Maybe; - try { - fallback = await this.deps.fallbackFactory(); - if (!fallback) { - log.warn("memory fallback requested but builtin index is unavailable"); - return null; - } - } catch (err) { - const message = formatErrorMessage(err); - log.warn(`memory fallback unavailable: ${message}`); - return null; - } - this.fallback = fallback; - if (this.closed) { - await fallback.close?.(); - if (this.fallback === fallback) { - this.fallback = null; - } - return null; - } - return fallback; - })(); - this.fallbackInitPromise = initialization; - try { - const fallback = await initialization; - this.ensureOpen(); - return fallback; - } finally { - if (this.fallbackInitPromise === initialization) { - this.fallbackInitPromise = null; - } - } - } - - private ensureOpen(): void { - if (this.closed) { - throw new Error(this.closeReason); - } - } - - isClosed(): boolean { - return this.closed; - } - - private evictCacheEntry(): void { - if (this.cacheEvicted) { - return; - } - this.cacheEvicted = true; - this.onClose?.(); - } -} - -async function closeQmdManagerForReplacement(manager: MemorySearchManager): Promise { - if (manager instanceof FallbackMemoryManager) { - await manager.invalidate("memory search manager was replaced by a newer qmd manager"); + if (!managerRuntimeLoader.peek()) { return; } - await manager.close?.(); + const { closeMemoryIndexManagersForAgent } = await loadManagerRuntime(); + await closeMemoryIndexManagersForAgent({ + cfg: params.cfg, + agentId: normalizeAgentId(params.agentId), + }); } - -function buildQmdManagerScopeKey(agentId: string): string { - return agentId; -} - -function buildQmdManagerIdentityKey( - agentId: string, - config: ResolvedQmdConfig, - runtimeConfig: QmdManagerRuntimeConfig, - acquireLocalService: MemoryCoreAcquireLocalService | undefined, - withLease: PluginStateLeaseRunner | undefined, -): string { - // ResolvedQmdConfig is assembled in a stable field order in resolveMemoryBackendConfig. - // Fast stringify avoids deep key-sorting overhead on this hot path. - const localServiceHostId = resolveMemoryCoreLocalServiceHostIdentity(acquireLocalService); - const leaseHostId = resolveMemoryCoreLeaseHostIdentity(withLease); - return `${agentId}:${JSON.stringify(config)}:${JSON.stringify(runtimeConfig.syncSettings ?? null)}:${JSON.stringify(runtimeConfig.contextLimits ?? null)}:${runtimeConfig.workspaceDir}:${localServiceHostId}:${leaseHostId}`; -} - -function resolveQmdManagerRuntimeConfig( - cfg: OpenClawConfig, - agentId: string, -): QmdManagerRuntimeConfig { - return { - workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), - syncSettings: resolveMemorySearchSyncConfig(cfg, agentId), - contextLimits: resolveAgentContextLimits(cfg, agentId), - }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts index 4a6aaeb174b7..1059c18d6953 100644 --- a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts +++ b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts @@ -594,6 +594,53 @@ async function collectRetiredQmdFileLocks(stateDir: string): Promise { return lockPaths; } +async function collectRetiredQmdWorkspaceHomes(stateDir: string): Promise { + const agentsDir = path.join(stateDir, "agents"); + const homes: string[] = []; + for (const entry of await readDirectoryEntries(agentsDir)) { + if (!entry.isDirectory() || entry.name !== normalizeAgentId(entry.name)) { + continue; + } + const agentDir = path.join(agentsDir, entry.name); + const agentEntries = await readDirectoryEntries(agentDir); + if (agentEntries.some((candidate) => candidate.name === "qmd" && candidate.isDirectory())) { + homes.push(path.join(agentDir, "qmd")); + } + } + return homes; +} + +export const qmdWorkspaceStateMigration: PluginDoctorStateMigration = { + id: "memory-core-qmd-workspace-retired", + label: "Memory Core retired QMD workspaces", + doctorOnly: true, + async detectLegacyState(params) { + const homes = await collectRetiredQmdWorkspaceHomes(params.stateDir); + if (homes.length === 0) { + return null; + } + return { + preview: homes.map( + (home) => + `- Retired Memory Core QMD workspace: ${home} -> remove derived index, config, cache, and session-export artifacts`, + ), + }; + }, + async migrateLegacyState(params) { + const changes: string[] = []; + const warnings: string[] = []; + for (const home of await collectRetiredQmdWorkspaceHomes(params.stateDir)) { + try { + await fs.rm(home, { recursive: true, force: true }); + changes.push(`Removed retired Memory Core QMD workspace: ${home}`); + } catch (err) { + warnings.push(`Failed removing retired Memory Core QMD workspace ${home}: ${String(err)}`); + } + } + return { changes, warnings }; + }, +}; + export const qmdLocksStateMigration: PluginDoctorStateMigration = { id: "memory-core-qmd-file-locks-to-sqlite-leases", label: "Memory Core retired QMD file locks", diff --git a/extensions/memory-core/src/migration/doctor-vector-index-provider.test.ts b/extensions/memory-core/src/migration/doctor-vector-index-provider.test.ts index 4c008762418d..cf9ee8b1f2d2 100644 --- a/extensions/memory-core/src/migration/doctor-vector-index-provider.test.ts +++ b/extensions/memory-core/src/migration/doctor-vector-index-provider.test.ts @@ -61,12 +61,6 @@ describe("memory vector index provider doctor diagnostic", () => { warnings: [expect.stringContaining('embedding provider "openai" cannot initialize')], }); - await expect( - vectorIndexProviderDiagnostic.detectLegacyState({ - ...params, - config: { ...config, memory: { backend: "qmd" } }, - }), - ).resolves.toBeNull(); await expect( vectorIndexProviderDiagnostic.detectLegacyState({ ...params, diff --git a/extensions/memory-core/src/migration/doctor-vector-index-provider.ts b/extensions/memory-core/src/migration/doctor-vector-index-provider.ts index d7208b1a8c68..19fd1dda2751 100644 --- a/extensions/memory-core/src/migration/doctor-vector-index-provider.ts +++ b/extensions/memory-core/src/migration/doctor-vector-index-provider.ts @@ -132,9 +132,6 @@ async function collectVectorProviderFindings(params: { env: NodeJS.ProcessEnv; stateDir: string; }): Promise { - if (params.config.memory?.backend === "qmd") { - return []; - } const findings: VectorProviderFinding[] = []; for (const agentId of listConfiguredAgentIds(params.config)) { // Memory indexes always live in the canonical per-agent state DB; a custom diff --git a/extensions/memory-core/src/qmd-session-artifacts.test.ts b/extensions/memory-core/src/qmd-session-artifacts.test.ts deleted file mode 100644 index 678f960ac6fa..000000000000 --- a/extensions/memory-core/src/qmd-session-artifacts.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { requireNodeSqlite } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import { describe, expect, it, vi } from "vitest"; -import { - refreshQmdSessionArtifactDocIds, - replaceQmdSessionArtifactMappings, -} from "./qmd-session-artifacts.js"; - -describe("QMD session artifact mappings", () => { - it("rechecks lease ownership before every doc-id publication and commit", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-artifact-lease-")); - const indexPath = path.join(tempDir, "index.sqlite"); - try { - replaceQmdSessionArtifactMappings({ - collection: "sessions-main", - indexPath, - mappings: [ - { - agentId: "main", - archived: false, - artifactPath: "session-1.md", - collection: "sessions-main", - memoryKey: "session/main/session-1", - searchPath: "qmd/sessions-main/session-1.md", - sessionId: "session-1", - }, - ], - }); - const { DatabaseSync } = requireNodeSqlite(); - const seed = new DatabaseSync(indexPath); - seed.exec(` - CREATE TABLE documents ( - collection TEXT NOT NULL, - path TEXT NOT NULL, - active INTEGER NOT NULL, - hash TEXT NOT NULL - ) STRICT; - `); - seed - .prepare("INSERT INTO documents (collection, path, active, hash) VALUES (?, ?, 1, ?)") - .run("sessions-main", "session-1.md", "doc-1"); - seed.close(); - - const leaseLost = new Error("lease lost"); - let checks = 0; - expect(() => - refreshQmdSessionArtifactDocIds({ - assertOwned: () => { - checks += 1; - if (checks === 2) { - throw leaseLost; - } - }, - collection: "sessions-main", - indexPath, - }), - ).toThrow(leaseLost); - - const verifyRollback = new DatabaseSync(indexPath, { readOnly: true }); - expect( - verifyRollback - .prepare("SELECT docid FROM openclaw_qmd_session_artifacts WHERE artifact_path = ?") - .get("session-1.md"), - ).toEqual({ docid: null }); - verifyRollback.close(); - - const assertOwned = vi.fn(); - refreshQmdSessionArtifactDocIds({ - assertOwned, - collection: "sessions-main", - indexPath, - }); - expect(assertOwned).toHaveBeenCalledTimes(3); - const verifyCommit = new DatabaseSync(indexPath, { readOnly: true }); - expect( - verifyCommit - .prepare("SELECT docid FROM openclaw_qmd_session_artifacts WHERE artifact_path = ?") - .get("session-1.md"), - ).toEqual({ docid: "doc-1" }); - verifyCommit.close(); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - }); -}); diff --git a/extensions/memory-core/src/qmd-session-artifacts.ts b/extensions/memory-core/src/qmd-session-artifacts.ts deleted file mode 100644 index f9cbb94d9d63..000000000000 --- a/extensions/memory-core/src/qmd-session-artifacts.ts +++ /dev/null @@ -1,314 +0,0 @@ -import fsSync from "node:fs"; -import path from "node:path"; -import type { DatabaseSync } from "node:sqlite"; -import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; -import { migrateSqliteSchemaToStrict } from "openclaw/plugin-sdk/plugin-state-runtime"; -import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/sqlite-runtime"; - -const QMD_SESSION_ARTIFACT_TABLE = "openclaw_qmd_session_artifacts"; -const QMD_SESSION_ARTIFACT_SCHEMA = ` - CREATE TABLE IF NOT EXISTS ${QMD_SESSION_ARTIFACT_TABLE} ( - collection TEXT NOT NULL, - artifact_path TEXT NOT NULL, - search_path TEXT NOT NULL, - docid TEXT, - memory_key TEXT NOT NULL, - agent_id TEXT NOT NULL, - session_id TEXT NOT NULL, - archived INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL, - PRIMARY KEY (collection, artifact_path) - ) STRICT; -`; - -const QMD_SESSION_ARTIFACT_HIT: unique symbol = Symbol("openclaw.qmdSessionArtifactHit"); - -export type QmdSessionArtifactMapping = { - agentId: string; - archived: boolean; - artifactPath: string; - collection: string; - memoryKey: string; - searchPath: string; - sessionId: string; -}; - -type QmdSessionArtifactLookup = { - artifactPath?: string; - collection?: string; - docid?: string; - indexPath: string; - searchPath: string; -}; - -type QmdSessionArtifactIdentity = { - agentId: string; - archived: boolean; - memoryKey: string; - sessionId: string; -}; - -type QmdSessionArtifactHitCarrier = MemorySearchResult & { - [QMD_SESSION_ARTIFACT_HIT]?: QmdSessionArtifactIdentity; -}; - -type QmdSessionArtifactRow = { - agentId: string; - artifact_path: string; - archived: number; - collection: string; - docid: string | null; - memoryKey: string; - search_path: string; - sessionId: string; -}; - -function ensureQmdSessionArtifactSchema(db: DatabaseSync): void { - db.exec(QMD_SESSION_ARTIFACT_SCHEMA); - try { - db.exec( - `ALTER TABLE ${QMD_SESSION_ARTIFACT_TABLE} - ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`, - ); - } catch {} - const table = db - .prepare("SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = ?") - .get(QMD_SESSION_ARTIFACT_TABLE) as { strict?: unknown } | undefined; - if (Number(table?.strict ?? 0) !== 1) { - migrateSqliteSchemaToStrict(db, QMD_SESSION_ARTIFACT_SCHEMA, { - databaseLabel: "QMD session artifact mappings", - }); - } - db.exec( - `CREATE INDEX IF NOT EXISTS idx_openclaw_qmd_session_artifacts_docid - ON ${QMD_SESSION_ARTIFACT_TABLE} (docid)`, - ); - db.exec( - `CREATE INDEX IF NOT EXISTS idx_openclaw_qmd_session_artifacts_search_path - ON ${QMD_SESSION_ARTIFACT_TABLE} (search_path)`, - ); -} - -function openQmdSessionArtifactDb(indexPath: string, readOnly = false): DatabaseSync { - if (!readOnly) { - fsSync.mkdirSync(path.dirname(indexPath), { recursive: true }); - } - const db = openNodeSqliteDatabase(indexPath, { readOnly }); - db.exec("PRAGMA busy_timeout = 1000"); - return db; -} - -export function attachQmdSessionArtifactHit( - hit: MemorySearchResult, - identity: QmdSessionArtifactIdentity, -): MemorySearchResult { - Object.defineProperty(hit, QMD_SESSION_ARTIFACT_HIT, { - configurable: true, - enumerable: false, - value: identity, - }); - return hit; -} - -export function copyQmdSessionArtifactHit( - source: MemorySearchResult, - target: MemorySearchResult, -): MemorySearchResult { - const identity = readQmdSessionArtifactIdentity(source); - return identity ? attachQmdSessionArtifactHit(target, identity) : target; -} - -export function readQmdSessionArtifactIdentity( - hit: MemorySearchResult, -): QmdSessionArtifactIdentity | null { - return (hit as QmdSessionArtifactHitCarrier)[QMD_SESSION_ARTIFACT_HIT] ?? null; -} - -export function replaceQmdSessionArtifactMappings(params: { - collection: string; - indexPath: string; - mappings: QmdSessionArtifactMapping[]; -}): void { - const db = openQmdSessionArtifactDb(params.indexPath); - let transactionStarted = false; - try { - ensureQmdSessionArtifactSchema(db); - const deleteCollection = db.prepare( - `DELETE FROM ${QMD_SESSION_ARTIFACT_TABLE} WHERE collection = ?`, - ); - const upsert = db.prepare( - `INSERT INTO ${QMD_SESSION_ARTIFACT_TABLE} - (collection, artifact_path, search_path, docid, memory_key, agent_id, session_id, archived, updated_at) - VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?) - ON CONFLICT(collection, artifact_path) DO UPDATE SET - search_path=excluded.search_path, - docid=NULL, - memory_key=excluded.memory_key, - agent_id=excluded.agent_id, - session_id=excluded.session_id, - archived=excluded.archived, - updated_at=excluded.updated_at`, - ); - db.exec("BEGIN"); - transactionStarted = true; - deleteCollection.run(params.collection); - const updatedAt = Date.now(); - for (const mapping of params.mappings) { - upsert.run( - mapping.collection, - mapping.artifactPath, - mapping.searchPath, - mapping.memoryKey, - mapping.agentId, - mapping.sessionId, - mapping.archived ? 1 : 0, - updatedAt, - ); - } - db.exec("COMMIT"); - } catch (err) { - if (transactionStarted) { - try { - db.exec("ROLLBACK"); - } catch {} - } - throw err; - } finally { - db.close(); - } -} - -export function refreshQmdSessionArtifactDocIds(params: { - assertOwned: () => void; - collection: string; - indexPath: string; -}): void { - const db = openQmdSessionArtifactDb(params.indexPath); - let transactionStarted = false; - try { - ensureQmdSessionArtifactSchema(db); - const rows = db - .prepare( - `SELECT d.hash AS docid, m.artifact_path AS artifact_path - FROM ${QMD_SESSION_ARTIFACT_TABLE} m - JOIN documents d - ON d.collection = m.collection - AND d.path = m.artifact_path - AND d.active = 1 - WHERE m.collection = ?`, - ) - .all(params.collection) as Array<{ artifact_path: string; docid: string }>; - const updateDocId = db.prepare( - `UPDATE ${QMD_SESSION_ARTIFACT_TABLE} - SET docid = ?, updated_at = ? - WHERE collection = ? AND artifact_path = ?`, - ); - params.assertOwned(); - db.exec("BEGIN"); - transactionStarted = true; - const updatedAt = Date.now(); - for (const row of rows) { - params.assertOwned(); - updateDocId.run(row.docid, updatedAt, params.collection, row.artifact_path); - } - params.assertOwned(); - db.exec("COMMIT"); - } catch (err) { - if (transactionStarted) { - try { - db.exec("ROLLBACK"); - } catch {} - } - throw err; - } finally { - db.close(); - } -} - -export function resolveQmdSessionArtifactIdentity( - lookup: QmdSessionArtifactLookup, -): QmdSessionArtifactIdentity | null { - let db: DatabaseSync; - try { - db = openQmdSessionArtifactDb(lookup.indexPath, true); - } catch { - return null; - } - try { - const row = - findQmdSessionArtifactByDocId(db, lookup) ?? findQmdSessionArtifactByPath(db, lookup); - return row - ? { - agentId: row.agentId, - archived: row.archived === 1, - memoryKey: row.memoryKey, - sessionId: row.sessionId, - } - : null; - } catch { - return null; - } finally { - db.close(); - } -} - -function findQmdSessionArtifactByDocId( - db: DatabaseSync, - lookup: QmdSessionArtifactLookup, -): QmdSessionArtifactRow | null { - const docid = lookup.docid?.trim(); - if (!docid) { - return null; - } - const rows = db - .prepare( - `SELECT collection, artifact_path, search_path, docid, archived, memory_key AS memoryKey, - agent_id AS agentId, session_id AS sessionId - FROM ${QMD_SESSION_ARTIFACT_TABLE} - WHERE docid = ?`, - ) - .all(docid) as QmdSessionArtifactRow[]; - return pickQmdSessionArtifactRow(rows, lookup); -} - -function findQmdSessionArtifactByPath( - db: DatabaseSync, - lookup: QmdSessionArtifactLookup, -): QmdSessionArtifactRow | null { - const rows = db - .prepare( - `SELECT collection, artifact_path, search_path, docid, archived, memory_key AS memoryKey, - agent_id AS agentId, session_id AS sessionId - FROM ${QMD_SESSION_ARTIFACT_TABLE} - WHERE search_path = ? - OR (collection = ? AND artifact_path = ?)`, - ) - .all( - lookup.searchPath, - lookup.collection ?? "", - lookup.artifactPath ?? "", - ) as QmdSessionArtifactRow[]; - return pickQmdSessionArtifactRow(rows, lookup); -} - -function pickQmdSessionArtifactRow( - rows: QmdSessionArtifactRow[], - lookup: QmdSessionArtifactLookup, -): QmdSessionArtifactRow | null { - if (rows.length === 0) { - return null; - } - const exact = rows.find((row) => { - if (lookup.collection && row.collection !== lookup.collection) { - return false; - } - if (lookup.artifactPath && row.artifact_path !== lookup.artifactPath) { - return false; - } - return row.search_path === lookup.searchPath; - }); - if (exact) { - return exact; - } - return rows.length === 1 ? (rows[0] ?? null) : null; -} diff --git a/extensions/memory-core/src/rem-evidence.ts b/extensions/memory-core/src/rem-evidence.ts index 345a42d20437..85289990a0c3 100644 --- a/extensions/memory-core/src/rem-evidence.ts +++ b/extensions/memory-core/src/rem-evidence.ts @@ -380,7 +380,7 @@ function summarizeSection( function compactCandidateTitle(title: string): string { let compact = sanitizeSectionTitle(title) - .replace(/\s*\((?:via:|from qmd \+ memory|this session)[^)]+\)\s*/gi, " ") + .replace(/\s*\((?:via:|this session)[^)]+\)\s*/gi, " ") .replace( /\s*[—-]\s*(?:research results.*|in progress.*|working.*|installed.*|booked.*|proposed.*|clarified.*|candidate.*|fixes.*|updates?.*)$/i, "", diff --git a/extensions/memory-core/src/runtime-provider.test.ts b/extensions/memory-core/src/runtime-provider.test.ts index 58c08d1566f8..c0a6279e7662 100644 --- a/extensions/memory-core/src/runtime-provider.test.ts +++ b/extensions/memory-core/src/runtime-provider.test.ts @@ -4,11 +4,9 @@ import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-ru import { describe, expect, it, vi } from "vitest"; const managerDebug = { - backend: "qmd" as const, + backend: "builtin" as const, purpose: "default" as const, managerMs: 7, - managerCacheState: "cached-full-hit" as const, - qmdIdentityHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }; const getMemorySearchManagerMock = vi.hoisted(() => diff --git a/extensions/memory-core/src/session-backfill.ts b/extensions/memory-core/src/session-backfill.ts index cccdd0d2ac5e..703934cb2e93 100644 --- a/extensions/memory-core/src/session-backfill.ts +++ b/extensions/memory-core/src/session-backfill.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { listSessionTranscriptCorpusEntriesForAgent } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +import { listSessionTranscriptCorpusEntriesForAgent } from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import type { SessionIngestionFileState } from "./dreaming-ingestion-state.js"; diff --git a/extensions/memory-core/src/session-ingestion.ts b/extensions/memory-core/src/session-ingestion.ts index 41542600de1f..f0fd1c3ac414 100644 --- a/extensions/memory-core/src/session-ingestion.ts +++ b/extensions/memory-core/src/session-ingestion.ts @@ -8,7 +8,7 @@ import { sessionPathForFile, statSessionEntrySync, type SessionTranscriptCorpusEntry, -} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +} from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { formatMemoryDreamingDay } from "openclaw/plugin-sdk/memory-core-host-status"; import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime"; diff --git a/extensions/memory-core/src/session-search-visibility.qmd.test.ts b/extensions/memory-core/src/session-search-visibility.qmd.test.ts deleted file mode 100644 index 39784cf64782..000000000000 --- a/extensions/memory-core/src/session-search-visibility.qmd.test.ts +++ /dev/null @@ -1,555 +0,0 @@ -// Memory Core tests cover QMD session search visibility behavior. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; -import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - attachQmdSessionArtifactHit, - copyQmdSessionArtifactHit, - replaceQmdSessionArtifactMappings, - resolveQmdSessionArtifactIdentity, -} from "./qmd-session-artifacts.js"; -import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; -import { asOpenClawConfig } from "./tools.test-helpers.js"; - -type TestSessionEntry = { - sessionId: string; - updatedAt: number; - sessionFile: string; - chatType?: "direct" | "group" | "channel"; - origin?: { chatType?: "direct" | "group" | "channel" }; -}; - -const crossAgentStore: Record = { - "agent:peer:only": { - sessionId: "w1", - updatedAt: 1, - sessionFile: "/tmp/sessions/w1.jsonl", - }, -}; -let combinedSessionStore: Record = crossAgentStore; -const tempRoots: string[] = []; - -vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - loadCombinedSessionStoreForGateway: vi.fn(() => ({ - storePath: "(test)", - store: combinedSessionStore, - })), - }; -}); - -describe("filterMemorySearchHitsBySessionVisibility for QMD", () => { - afterEach(async () => { - vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear(); - combinedSessionStore = crossAgentStore; - while (tempRoots.length > 0) { - const root = tempRoots.pop(); - if (root) { - await fs.rm(root, { recursive: true, force: true }); - } - } - }); - - async function createQmdArtifactIndex(params: { - agentId: string; - archived?: boolean; - artifactPath: string; - collection: string; - searchPath: string; - sessionId: string; - }): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-session-artifact-")); - tempRoots.push(root); - const indexPath = path.join(root, "index.sqlite"); - replaceQmdSessionArtifactMappings({ - collection: params.collection, - indexPath, - mappings: [ - { - agentId: params.agentId, - archived: params.archived === true, - artifactPath: params.artifactPath, - collection: params.collection, - memoryKey: sessionTranscriptHit.formatSessionTranscriptMemoryHitKey({ - agentId: params.agentId, - sessionId: params.sessionId, - }), - searchPath: params.searchPath, - sessionId: params.sessionId, - }, - ], - }); - return indexPath; - } - - function attachMappedQmdHit( - hit: MemorySearchResult, - lookup: Parameters[0], - ): MemorySearchResult { - const identity = resolveQmdSessionArtifactIdentity(lookup); - if (!identity) { - throw new Error("expected QMD session artifact identity mapping"); - } - return attachQmdSessionArtifactHit(hit, identity); - } - - it("keeps same-agent QMD-normalized archived reset .md hits when the store has a matching entry", async () => { - combinedSessionStore = { - "agent:main:abc-uuid": { - sessionId: "abc-uuid", - updatedAt: 1, - sessionFile: "/tmp/sessions/abc-uuid.jsonl", - }, - }; - const hit: MemorySearchResult = { - path: "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "agent" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); - - it("keeps QMD .md hits whose live session id looks like an archive name", async () => { - const sessionId = "foo.jsonl.deleted.2026-02-16T22-27-33.000Z"; - combinedSessionStore = { - "agent:main:archive-looking": { - sessionId, - updatedAt: 1, - sessionFile: `/tmp/sessions/${sessionId}.jsonl`, - }, - }; - const hit: MemorySearchResult = { - path: `qmd/sessions-main/${sessionId}.md`, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "self" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:archive-looking", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); - - it("does not authorize QMD archived .md hits through lossy slug fallback", async () => { - combinedSessionStore = { - "agent:main:foo_bar": { - sessionId: "foo_bar", - updatedAt: 1, - sessionFile: "/tmp/sessions/foo_bar.jsonl", - }, - }; - const hit: MemorySearchResult = { - path: "qmd/sessions-main/foo-bar-jsonl-deleted-2026-02-16t22-26-33-000z.md", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "self" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:foo_bar", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("keeps mapped QMD session hits when the artifact filename no longer matches the session id", async () => { - combinedSessionStore = { - "agent:main:actual-key": { - sessionId: "actual-session-id", - updatedAt: 1, - sessionFile: "/tmp/sessions/actual-session-id.jsonl", - }, - }; - const searchPath = "qmd/sessions-main/lossy-export-name.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "main", - artifactPath: "lossy-export-name.md", - collection: "sessions-main", - searchPath, - sessionId: "actual-session-id", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "lossy-export-name.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const copiedHit = copyQmdSessionArtifactHit(hit, { ...hit, snippet: "trimmed" }); - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "self" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:actual-key", - sandboxed: false, - hits: [copiedHit], - }); - - expect(filtered).toEqual([copiedHit]); - }); - - it("denies mapped QMD hits whose transcript identity also has a shared group alias", async () => { - combinedSessionStore = { - "agent:main:telegram:direct:owner": { - sessionId: "current", - updatedAt: 2, - sessionFile: "/tmp/sessions/current.jsonl", - chatType: "direct", - }, - "agent:main:visible-export": { - sessionId: "visible-export", - updatedAt: 1, - sessionFile: "/tmp/sessions/visible-export.jsonl", - chatType: "direct", - }, - "agent:main:explicit:laptop": { - sessionId: "actual-session-id", - updatedAt: 1, - sessionFile: "/tmp/sessions/shared-transcript.jsonl", - chatType: "direct", - }, - // The canonical transcript identity is also exposed through a group alias. - "agent:main:telegram:group:team": { - sessionId: "actual-session-id", - updatedAt: 1, - sessionFile: "/tmp/sessions/shared-transcript.jsonl", - chatType: "group", - }, - }; - // The filename resolves to an allowed decoy session; only the attached QMD - // identity reveals that the actual transcript also has a shared alias. - const searchPath = "qmd/sessions-main/visible-export.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "main", - artifactPath: "visible-export.md", - collection: "sessions-main", - searchPath, - sessionId: "actual-session-id", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "private context", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "visible-export.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:telegram:direct:owner:active-memory:abcdef123456", - sandboxed: false, - hits: [hit], - conversationRecall: { - anchorSessionKey: "agent:main:telegram:direct:owner", - scope: "same-agent-private", - corpus: "sessions", - }, - }); - - expect(filtered).toStrictEqual([]); - }); - - it("allows mapped QMD hits for another same-agent private conversation", async () => { - combinedSessionStore = { - "agent:main:telegram:direct:owner": { - sessionId: "current", - updatedAt: 2, - sessionFile: "/tmp/sessions/current.jsonl", - chatType: "direct", - }, - "agent:main:explicit:laptop": { - sessionId: "actual-session-id", - updatedAt: 1, - sessionFile: "/tmp/sessions/actual-session-id.jsonl", - chatType: "direct", - }, - }; - const searchPath = "qmd/sessions-main/lossy-export-name.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "main", - artifactPath: "lossy-export-name.md", - collection: "sessions-main", - searchPath, - sessionId: "actual-session-id", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "private context", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "lossy-export-name.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:telegram:direct:owner", - sandboxed: false, - hits: [hit], - conversationRecall: { - anchorSessionKey: "agent:main:telegram:direct:owner", - scope: "same-agent-private", - corpus: "sessions", - }, - }); - - expect(filtered).toEqual([hit]); - }); - - it("denies mapped live QMD session hits when no session-store key remains", async () => { - combinedSessionStore = {}; - const searchPath = "qmd/sessions-main/orphan-live.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "main", - artifactPath: "orphan-live.md", - collection: "sessions-main", - searchPath, - sessionId: "orphan-live", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "orphan-live.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "agent" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("denies ownerless legacy QMD orphan hits when no session-store key remains", async () => { - combinedSessionStore = {}; - const hit: MemorySearchResult = { - path: "qmd/sessions-main/ownerless-orphan.md", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg: asOpenClawConfig({ tools: { sessions: { visibility: "agent" } } }), - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("keeps mapped archived QMD session hits when no session-store key remains", async () => { - combinedSessionStore = {}; - const searchPath = "qmd/sessions-main/archived-jsonl-deleted-2026-02-16t22-26-33-000z.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "main", - archived: true, - artifactPath: "archived-jsonl-deleted-2026-02-16t22-26-33-000z.md", - collection: "sessions-main", - searchPath, - sessionId: "archived", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "archived-jsonl-deleted-2026-02-16t22-26-33-000z.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); - - it("denies mapped QMD session hits before deprecated filename fallback", async () => { - combinedSessionStore = { - "agent:main:visible": { - sessionId: "visible", - updatedAt: 1, - sessionFile: "/tmp/sessions/visible.jsonl", - }, - }; - const searchPath = "qmd/sessions-main/visible.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "peer", - artifactPath: "visible.md", - collection: "sessions-main", - searchPath, - sessionId: "peer-session", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "visible.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:visible", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("keeps same-agent QMD archived deleted .md hits when no store entry remains", async () => { - combinedSessionStore = {}; - const hit: MemorySearchResult = { - path: "qmd/sessions-main/abc-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); -}); diff --git a/extensions/memory-core/src/session-search-visibility.test.ts b/extensions/memory-core/src/session-search-visibility.test.ts index b9c65f0dea9d..fe4c8d4388d1 100644 --- a/extensions/memory-core/src/session-search-visibility.test.ts +++ b/extensions/memory-core/src/session-search-visibility.test.ts @@ -1,13 +1,8 @@ // Memory Core tests cover session search visibility plugin behavior. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { normalizeSessionDeliveryState } from "openclaw/plugin-sdk/session-store-runtime"; import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { replaceQmdSessionArtifactMappings } from "./qmd-session-artifacts.js"; import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; import { asOpenClawConfig } from "./tools.test-helpers.js"; @@ -28,7 +23,6 @@ const crossAgentStore: Record = { }, }; let combinedSessionStore: Record = crossAgentStore; -const tempRoots: string[] = []; vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => { const actual = @@ -43,87 +37,9 @@ vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => }); describe("filterMemorySearchHitsBySessionVisibility", () => { - afterEach(async () => { + afterEach(() => { vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear(); combinedSessionStore = crossAgentStore; - while (tempRoots.length > 0) { - const root = tempRoots.pop(); - if (root) { - await fs.rm(root, { recursive: true, force: true }); - } - } - }); - - it("migrates legacy QMD artifact mappings to STRICT without losing rows", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-session-artifact-")); - tempRoots.push(root); - const indexPath = path.join(root, "index.sqlite"); - const legacy = new DatabaseSync(indexPath); - legacy.exec(` - CREATE TABLE openclaw_qmd_session_artifacts ( - collection TEXT NOT NULL, - artifact_path TEXT NOT NULL, - search_path TEXT NOT NULL, - docid TEXT, - memory_key TEXT NOT NULL, - agent_id TEXT NOT NULL, - session_id TEXT NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (collection, artifact_path) - ); - INSERT INTO openclaw_qmd_session_artifacts ( - collection, artifact_path, search_path, docid, memory_key, agent_id, session_id, updated_at - ) VALUES ('legacy', 'old.md', 'qmd/legacy/old.md', NULL, 'old-key', 'main', 'old', 1); - `); - legacy.close(); - - replaceQmdSessionArtifactMappings({ - collection: "current", - indexPath, - mappings: [ - { - agentId: "main", - archived: true, - artifactPath: "new.md", - collection: "current", - memoryKey: "new-key", - searchPath: "qmd/current/new.md", - sessionId: "new", - }, - ], - }); - - const migrated = new DatabaseSync(indexPath); - try { - expect( - migrated - .prepare( - "SELECT strict FROM pragma_table_list WHERE name = 'openclaw_qmd_session_artifacts'", - ) - .get(), - ).toEqual({ strict: 1 }); - expect( - migrated - .prepare( - `SELECT collection, artifact_path, archived - FROM openclaw_qmd_session_artifacts - ORDER BY collection`, - ) - .all(), - ).toEqual([ - { collection: "current", artifact_path: "new.md", archived: 1 }, - { collection: "legacy", artifact_path: "old.md", archived: 0 }, - ]); - expect(() => - migrated - .prepare( - "UPDATE openclaw_qmd_session_artifacts SET archived = ? WHERE collection = 'legacy'", - ) - .run("not-an-integer"), - ).toThrow(); - } finally { - migrated.close(); - } }); it("drops sessions-sourced hits when requester key is missing (fail closed)", async () => { diff --git a/extensions/memory-core/src/session-search-visibility.ts b/extensions/memory-core/src/session-search-visibility.ts index b110795ebed5..0ab0cd88709b 100644 --- a/extensions/memory-core/src/session-search-visibility.ts +++ b/extensions/memory-core/src/session-search-visibility.ts @@ -7,7 +7,6 @@ import { sessionDeliveryOrigin } from "openclaw/plugin-sdk/session-store-runtime import { extractTranscriptIdentityFromSessionsMemoryHit, loadCombinedSessionStoreForGateway, - resolveSessionTranscriptMemoryHitKeyToSessionKeys, resolveTranscriptStemToSessionKeys, } from "openclaw/plugin-sdk/session-transcript-hit"; import { @@ -15,7 +14,6 @@ import { createSessionVisibilityGuard, resolveEffectiveSessionToolsVisibility, } from "openclaw/plugin-sdk/session-visibility"; -import { readQmdSessionArtifactIdentity } from "./qmd-session-artifacts.js"; function normalizeAgentIdForCompare(value: string | undefined): string | undefined { return value?.trim().toLowerCase() || undefined; @@ -298,43 +296,10 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { if (!trustedAgentScope && (!params.requesterSessionKey || (!guard && !conversationRecall))) { continue; } - const artifactIdentity = readQmdSessionArtifactIdentity(hit); - if (artifactIdentity) { - const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId); - const normalizedOwnerAgentId = normalizeAgentIdForCompare(artifactIdentity.agentId); - if ( - normalizedScopedAgentId && - normalizedOwnerAgentId && - normalizedOwnerAgentId !== normalizedScopedAgentId - ) { - continue; - } - const keys = filterSessionKeysByScopedAgent({ - cfg: params.cfg, - scopedAgentId, - keys: resolveSessionTranscriptMemoryHitKeyToSessionKeys({ - store: combinedSessionStore, - key: artifactIdentity.memoryKey, - includeSyntheticFallback: artifactIdentity.archived, - }), - }); - if (keys.length === 0) { - continue; - } - const allowed = areSessionKeysAllowed(keys); - if (!allowed) { - continue; - } - next.push(hit); - continue; - } - // Deprecated migration compatibility for older QMD/session rows that were - // indexed before memory-core stored artifact-to-transcript identity. const identity = extractTranscriptIdentityFromSessionsMemoryHit(hit.path); if (!identity) { continue; } - const isQmdSessionHit = hit.path.replace(/\\/g, "/").startsWith("qmd/"); const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId); const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId); if ( @@ -352,31 +317,19 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { : undefined; const archivedOwnerMatchesScope = Boolean( identity.archived && - ((identity.ownerAgentId && - (!scopedAgentId || - normalizeAgentIdForCompare(identity.ownerAgentId) === - normalizeAgentIdForCompare(scopedAgentId))) || - (isQmdSessionHit && scopedAgentId)), + identity.ownerAgentId && + (!scopedAgentId || + normalizeAgentIdForCompare(identity.ownerAgentId) === + normalizeAgentIdForCompare(scopedAgentId)), ); const archivedOwnerAgentId = archivedOwnerMatchesScope ? (identity.ownerAgentId ?? scopedAgentId) : undefined; - const liveKeys = identity.liveStem - ? resolveTranscriptStemToSessionKeys({ - store: combinedSessionStore, - stem: identity.liveStem, - allowQmdSlugFallback: false, - }) - : []; - const resolvedKeys = - liveKeys.length > 0 - ? liveKeys - : resolveTranscriptStemToSessionKeys({ - store: combinedSessionStore, - stem: identity.stem, - allowQmdSlugFallback: isQmdSessionHit && !identity.archived, - ...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}), - }); + const resolvedKeys = resolveTranscriptStemToSessionKeys({ + store: combinedSessionStore, + stem: identity.stem, + ...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}), + }); const keys = filterSessionKeysByScopedAgent({ cfg: params.cfg, scopedAgentId, diff --git a/extensions/memory-core/src/short-term-promotion-artifacts.ts b/extensions/memory-core/src/short-term-promotion-artifacts.ts index 88569c18b63c..24273b4fa555 100644 --- a/extensions/memory-core/src/short-term-promotion-artifacts.ts +++ b/extensions/memory-core/src/short-term-promotion-artifacts.ts @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import { deriveConceptTags, summarizeConceptTagScriptCoverage, @@ -53,10 +52,6 @@ export function resolveShortTermRecallLockPath(workspaceDir: string): string { export async function auditShortTermPromotionArtifacts(params: { workspaceDir: string; - qmd?: { - dbPath?: string; - collections?: number; - }; }): Promise { const workspaceDir = params.workspaceDir.trim(); const storePath = resolveStorePath(workspaceDir); @@ -155,49 +150,6 @@ export async function auditShortTermPromotionArtifacts(params: { } } - let qmd: ShortTermAuditSummary["qmd"]; - if (params.qmd) { - qmd = { - dbPath: params.qmd.dbPath, - collections: params.qmd.collections, - }; - if (typeof params.qmd.collections === "number" && params.qmd.collections <= 0) { - issues.push({ - severity: "warn", - code: "qmd-collections-empty", - message: "QMD reports zero managed collections.", - fixable: false, - }); - } - const dbPath = params.qmd.dbPath?.trim(); - if (dbPath) { - try { - const stat = await fs.stat(dbPath); - qmd.dbBytes = stat.size; - if (!stat.isFile() || stat.size <= 0) { - issues.push({ - severity: "error", - code: "qmd-index-empty", - message: "QMD index file exists but is empty.", - fixable: false, - }); - } - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - issues.push({ - severity: "error", - code: "qmd-index-missing", - message: "QMD index file is missing.", - fixable: false, - }); - } else { - throw err; - } - } - } - } - return { storePath, lockPath, @@ -211,7 +163,6 @@ export async function auditShortTermPromotionArtifacts(params: { invalidEntryCount, danglingEntryCount, issues, - ...(qmd ? { qmd } : {}), }; } diff --git a/extensions/memory-core/src/short-term-promotion-types.ts b/extensions/memory-core/src/short-term-promotion-types.ts index 116378c6507e..3eeda7bb09c5 100644 --- a/extensions/memory-core/src/short-term-promotion-types.ts +++ b/extensions/memory-core/src/short-term-promotion-types.ts @@ -128,10 +128,7 @@ export type ShortTermAuditIssue = { | "recall-store-dangling" | "recall-store-over-limit" | "recall-lock-stale" - | "recall-lock-unreadable" - | "qmd-index-missing" - | "qmd-index-empty" - | "qmd-collections-empty"; + | "recall-lock-unreadable"; message: string; fixable: boolean; }; @@ -149,13 +146,6 @@ export type ShortTermAuditSummary = { invalidEntryCount: number; danglingEntryCount?: number; issues: ShortTermAuditIssue[]; - qmd?: - | { - dbPath?: string; - collections?: number; - dbBytes?: number; - } - | undefined; }; export type RepairShortTermPromotionArtifactsResult = { diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index 9e60e8a3a8d1..be81eb35d686 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -3177,7 +3177,7 @@ describe("short-term promotion", () => { it("audits and repairs invalid store metadata plus stale locks", async () => { await withTempWorkspace(async (workspaceDir) => { await writeDailyMemoryNote(workspaceDir, "2026-04-01", [ - "Gateway host uses qmd vector search for router notes.", + "Gateway host uses vector search for router notes.", ]); await testing.writeRawRecallStore(workspaceDir, { version: 1, @@ -3189,7 +3189,7 @@ describe("short-term promotion", () => { startLine: 1, endLine: 2, source: "memory", - snippet: "Gateway host uses qmd vector search for router notes.", + snippet: "Gateway host uses vector search for router notes.", recallCount: 2, totalScore: 1.8, maxScore: 0.95, @@ -3635,7 +3635,7 @@ describe("short-term promotion", () => { it("does not rewrite an already normalized healthy recall store", async () => { await withTempWorkspace(async (workspaceDir) => { - const snippet = "Gateway host uses qmd vector search for router notes."; + const snippet = "Gateway host uses vector search for router notes."; await writeDailyMemoryNote(workspaceDir, "2026-04-01", [snippet]); const raw = { version: 1, diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index 24422ec74519..f89ff8f19f98 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -12,7 +12,6 @@ import { getMemorySearchManagerMockParams, getReadAgentMemoryFileMockCalls, resetMemoryToolMockState, - setMemoryBackend, setMemoryReadFileImpl, setMemorySearchImpl, setMemoryWorkspaceDir, @@ -63,7 +62,6 @@ beforeEach(() => { clearMemoryPluginState(); memoryToolsTesting.resetMemorySearchToolCooldowns(); resetMemoryToolMockState({ - backend: "builtin", searchImpl: async () => [ { path: "MEMORY.md", @@ -95,7 +93,6 @@ describe("memory search citations", () => { // The first tool call pays Vitest's cold lazy-runtime transform cost on Node 24 CI. it("appends source information when citations are enabled", async () => { - setMemoryBackend("builtin"); const cfg = asOpenClawConfig({ memory: { citations: "on" }, agents: { list: [{ id: "main", default: true }] }, @@ -109,7 +106,6 @@ describe("memory search citations", () => { }, 180_000); it("leaves snippet untouched when citations are off", async () => { - setMemoryBackend("builtin"); const cfg = asOpenClawConfig({ memory: { citations: "off" }, agents: { list: [{ id: "main", default: true }] }, @@ -122,31 +118,7 @@ describe("memory search citations", () => { expect(firstResult.citation).toBeUndefined(); }); - it("clamps decorated snippets to qmd injected budget", async () => { - setMemoryBackend("qmd"); - setMemorySearchImpl(async () => [ - { - path: "MEMORY.md", - startLine: 5, - endLine: 7, - score: 0.9, - snippet: "abc😀tail", - source: "memory" as const, - }, - ]); - const cfg = asOpenClawConfig({ - memory: { citations: "on", backend: "qmd", qmd: { limits: { maxInjectedChars: 4 } } }, - agents: { list: [{ id: "main", default: true }] }, - }); - const tool = createMemorySearchToolOrThrow({ config: cfg }); - const result = await tool.execute("call_citations_qmd", { query: "notes" }); - const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - const firstResult = expectFirstMemoryResult(details); - expect(firstResult.snippet).toBe("abc"); - }); - it("honors auto mode for direct chats", async () => { - setMemoryBackend("builtin"); const tool = createAutoCitationsMemorySearchTool("agent:main:discord:dm:u123"); const result = await tool.execute("auto_mode_direct", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string }> }; @@ -155,7 +127,6 @@ describe("memory search citations", () => { }); it("suppresses citations for auto mode in group chats", async () => { - setMemoryBackend("builtin"); const tool = createAutoCitationsMemorySearchTool("agent:main:discord:group:c123"); const result = await tool.execute("auto_mode_group", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string }> }; @@ -182,10 +153,8 @@ describe("memory tools", () => { }); it("uses default memory manager mode for shared memory_search", async () => { - setMemoryBackend("qmd"); const tool = createMemorySearchToolOrThrow({ config: asOpenClawConfig({ - memory: { backend: "qmd", qmd: { command: "qmd" } }, agents: { list: [{ id: "main", default: true }] }, }), }); @@ -202,10 +171,8 @@ describe("memory tools", () => { }); it("uses one-shot CLI memory manager mode for explicit local CLI memory_search", async () => { - setMemoryBackend("qmd"); const tool = createMemorySearchToolOrThrow({ config: asOpenClawConfig({ - memory: { backend: "qmd", qmd: { command: "qmd" } }, agents: { list: [{ id: "main", default: true }] }, }), oneShotCliRun: true, @@ -255,7 +222,6 @@ describe("memory tools", () => { }); it("uses the builtin direct memory file path for memory_get", async () => { - setMemoryBackend("builtin"); const tool = createMemoryGetToolOrThrow(); const result = await tool.execute("call_builtin_fast_path", { path: "memory/2026-02-19.md" }); @@ -271,7 +237,6 @@ describe("memory tools", () => { }); it("rejects fractional memory_get ranges before reading files", async () => { - setMemoryBackend("builtin"); const tool = createMemoryGetToolOrThrow(); await expect( @@ -286,7 +251,6 @@ describe("memory tools", () => { }); it("returns truncation metadata and a continuation notice for partial memory_get results", async () => { - setMemoryBackend("builtin"); setMemoryReadFileImpl(async (params: MemoryReadParams) => ({ path: params.relPath, text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]", @@ -312,7 +276,6 @@ describe("memory tools", () => { it("persists short-term recall events from memory_search tool hits", async () => { const workspaceDir = await createTempWorkspace("memory-tools-recall-"); try { - setMemoryBackend("builtin"); setMemoryWorkspaceDir(workspaceDir); setMemorySearchImpl(async () => [ { diff --git a/extensions/memory-core/src/tools.citations.ts b/extensions/memory-core/src/tools.citations.ts index f1de1d96a48c..94b012bfa479 100644 --- a/extensions/memory-core/src/tools.citations.ts +++ b/extensions/memory-core/src/tools.citations.ts @@ -6,7 +6,6 @@ import { } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; export function resolveMemoryCitationsMode(cfg: OpenClawConfig): MemoryCitationsMode { const mode = cfg.memory?.citations; @@ -38,32 +37,6 @@ function formatCitation(entry: MemorySearchResult): string { return `${entry.path}${lineRange}`; } -export function clampResultsByInjectedChars( - results: MemorySearchResult[], - budget?: number, -): MemorySearchResult[] { - if (!budget || budget <= 0) { - return results; - } - let remaining = budget; - const clamped: MemorySearchResult[] = []; - for (const entry of results) { - if (remaining <= 0) { - break; - } - const snippet = entry.snippet ?? ""; - if (snippet.length <= remaining) { - clamped.push(entry); - remaining -= snippet.length; - } else { - const trimmed = truncateUtf16Safe(snippet, remaining); - clamped.push({ ...entry, snippet: trimmed }); - break; - } - } - return clamped; -} - export function shouldIncludeCitations(params: { mode: MemoryCitationsMode; sessionKey?: string; diff --git a/extensions/memory-core/src/tools.recall-tracking.test.ts b/extensions/memory-core/src/tools.recall-tracking.test.ts index f83f622e452c..252dde0ef8b1 100644 --- a/extensions/memory-core/src/tools.recall-tracking.test.ts +++ b/extensions/memory-core/src/tools.recall-tracking.test.ts @@ -2,11 +2,7 @@ import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../api.js"; -import { - resetMemoryToolMockState, - setMemoryBackend, - setMemorySearchImpl, -} from "./memory-tool-manager.test-mocks.js"; +import { resetMemoryToolMockState, setMemorySearchImpl } from "./memory-tool-manager.test-mocks.js"; import { createMemorySearchTool } from "./tools.js"; import { asOpenClawConfig } from "./tools.test-helpers.js"; @@ -41,65 +37,6 @@ describe("memory_search recall tracking", () => { recallTrackingMock.recordShortTermRecalls.mockResolvedValue(undefined); }); - it("records only surfaced results after qmd clamp", async () => { - setMemoryBackend("qmd"); - setMemorySearchImpl(async () => [ - { - path: "memory/2026-04-03.md", - startLine: 1, - endLine: 2, - score: 0.95, - snippet: "A".repeat(80), - source: "memory" as const, - }, - { - path: "memory/2026-04-02.md", - startLine: 1, - endLine: 2, - score: 0.92, - snippet: "B".repeat(80), - source: "memory" as const, - }, - ]); - - const tool = createSearchTool( - asOpenClawConfig({ - agents: { list: [{ id: "main", default: true }] }, - plugins: { - entries: { - "memory-core": { - config: { - dreaming: { - enabled: true, - }, - }, - }, - }, - }, - memory: { - backend: "qmd", - citations: "on", - qmd: { limits: { maxInjectedChars: 100 } }, - }, - }), - ); - - const result = await tool.execute("call_recall_clamp", { query: "backup glacier" }); - const details = result.details as { results: Array<{ path: string }> }; - expect(details.results).toHaveLength(1); - expect(details.results[0]?.path).toBe("memory/2026-04-03.md"); - - expect(recallTrackingMock.recordShortTermRecalls).toHaveBeenCalledTimes(1); - const [firstCall] = recallTrackingMock.recordShortTermRecalls.mock.calls; - if (!firstCall) { - throw new Error("expected short-term recall tracking call"); - } - const recallParams = firstCall[0]; - expect(recallParams.results).toHaveLength(1); - expect(recallParams.results[0]?.path).toBe("memory/2026-04-03.md"); - expect(recallParams.results[0]?.snippet).not.toContain("Source:"); - }); - it("does not block tool results on slow best-effort recall writes", async () => { let resolveRecall: (() => void) | undefined; recallTrackingMock.recordShortTermRecalls.mockImplementationOnce( diff --git a/extensions/memory-core/src/tools.shared.ts b/extensions/memory-core/src/tools.shared.ts index 9e2f8e644a54..6b766e214578 100644 --- a/extensions/memory-core/src/tools.shared.ts +++ b/extensions/memory-core/src/tools.shared.ts @@ -87,7 +87,8 @@ export async function getMemoryManagerContextWithPurpose(params: { ? { manager, debug: { - ...debug, + backend: debug?.backend ?? "builtin", + purpose: debug?.purpose ?? params.purpose ?? "default", managerMs: debug?.managerMs ?? Math.max(0, Date.now() - startedAt), }, } diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index 377b623888cc..1bf21a29c301 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -1,9 +1,6 @@ import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; // Memory Core tests cover tools plugin behavior. -import { - clearMemoryPluginState, - registerMemoryCorpusSupplement, -} from "openclaw/plugin-sdk/memory-host-core"; +import { clearMemoryPluginState } from "openclaw/plugin-sdk/memory-host-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getMemoryCloseMockCalls, @@ -12,19 +9,13 @@ import { getMemorySearchManagerMockParams, getMemorySyncMockCalls, resetMemoryToolMockState, - setMemoryBackend, setMemoryCloseImpl, setMemoryCustomStatus, - setResolvedMemoryBackend, setMemorySearchImpl, setMemorySearchManagerImpl, setMemoryStatusDirty, } from "./memory-tool-manager.test-mocks.js"; import { applyProjectRanking } from "./memory/project-ranking.js"; -import { - MEMORY_SEARCH_DEADLINE_CONTROL, - type MemorySearchDeadlineAction, -} from "./memory/search-deadline.js"; import { createMemorySearchTool, testing as memoryToolsTesting } from "./tools.js"; import { buildMemorySearchUnavailableResult, @@ -52,51 +43,6 @@ const sessionStore = vi.hoisted(() => ({ }, })); -const QMD_SEARCH_TIMEOUT_MS = 45_000; - -function createQmdTimeoutSearchTool(options?: { oneShotCliRun?: boolean }) { - return createMemorySearchToolOrThrow({ - config: asOpenClawConfig({ - agents: { list: [{ id: "main", default: true }] }, - memory: { - backend: "qmd", - qmd: { limits: { timeoutMs: QMD_SEARCH_TIMEOUT_MS } }, - }, - }), - ...(options?.oneShotCliRun ? { oneShotCliRun: true } : {}), - }); -} - -function expectMemorySearchTimeout(details: unknown, seconds: number): void { - expectUnavailableMemorySearchDetails(details, { - error: `memory_search timed out after ${seconds}s`, - warning: "Memory search is unavailable due to an embedding/provider error.", - action: "Check embedding provider configuration and retry memory_search.", - }); -} - -type TestSearchOptions = { - onDebug?: (debug: MemorySearchRuntimeDebug) => void; - signal?: AbortSignal; - [MEMORY_SEARCH_DEADLINE_CONTROL]?: (action: MemorySearchDeadlineAction) => void; -}; - -function createTestSearchManager(params: { - backend: "builtin" | "qmd"; - search: (opts?: TestSearchOptions) => Promise; -}) { - return { - search: vi.fn(async (_query: string, opts?: TestSearchOptions) => await params.search(opts)), - status: () => ({ - backend: params.backend, - provider: params.backend, - workspaceDir: "/workspace", - }), - sync: vi.fn(), - close: vi.fn(async () => {}), - }; -} - vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => { const actual = await importOriginal(); @@ -368,36 +314,6 @@ describe("memory_search unavailable payloads", () => { }); }); - it("keeps qmd setup on the default deadline and closes a late one-shot manager", async () => { - vi.useFakeTimers(); - try { - setMemoryBackend("qmd"); - let resolveManager!: (result: { manager: { close: () => Promise } }) => void; - const close = vi.fn(async () => {}); - setMemorySearchManagerImpl( - async () => - await new Promise((resolve) => { - resolveManager = resolve; - }), - ); - const tool = createQmdTimeoutSearchTool({ oneShotCliRun: true }); - - const resultPromise = tool.execute("late-manager", { query: "hello" }); - await vi.advanceTimersByTimeAsync(15_000); - - const result = await resultPromise; - expectMemorySearchTimeout(result.details, 15); - expect(close).not.toHaveBeenCalled(); - - resolveManager({ manager: { close } }); - await vi.advanceTimersByTimeAsync(0); - - expect(close).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - it("returns unavailable metadata when memory search does not settle", async () => { vi.useFakeTimers(); try { @@ -526,228 +442,6 @@ describe("memory_search unavailable payloads", () => { expect((retry.details as { results?: unknown[] }).results).toHaveLength(1); }); - it("allows qmd search to complete after the default deadline", async () => { - vi.useFakeTimers(); - try { - setMemoryBackend("qmd"); - let searchSignal: AbortSignal | undefined; - setMemorySearchImpl(async (opts) => { - searchSignal = opts?.signal; - opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.("pause"); - try { - return await new Promise((resolve) => { - setTimeout( - () => - resolve([ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 0.9, - snippet: "slow qmd result", - source: "memory", - }, - ]), - 16_000, - ); - }); - } finally { - opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.("resume"); - } - }); - const tool = createQmdTimeoutSearchTool(); - - let settled = false; - const resultPromise = tool.execute("slow-qmd", { query: "hello" }).then((result) => { - settled = true; - return result; - }); - await vi.advanceTimersByTimeAsync(15_000); - - expect(settled).toBe(false); - expect(searchSignal).toBeInstanceOf(AbortSignal); - expect(searchSignal?.aborted).toBe(false); - - await vi.advanceTimersByTimeAsync(1_000); - - const result = await resultPromise; - expect((result.details as { results?: unknown[] }).results).toHaveLength(1); - expect(searchSignal?.aborted).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it("counts qmd maintenance against the default deadline around the command phase", async () => { - vi.useFakeTimers(); - try { - setMemoryBackend("qmd"); - let searchSignal: AbortSignal | undefined; - setMemorySearchImpl( - async (opts) => - await new Promise(() => { - searchSignal = opts?.signal; - setTimeout(() => { - opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.("pause"); - setTimeout(() => { - opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.("resume"); - }, 16_000); - }, 10_000); - }), - ); - const tool = createQmdTimeoutSearchTool(); - - let settled = false; - const resultPromise = tool - .execute("qmd-maintenance-timeout", { query: "hello" }) - .then((result) => { - settled = true; - return result; - }); - await vi.advanceTimersByTimeAsync(10_000); - await vi.advanceTimersByTimeAsync(16_000); - await vi.advanceTimersByTimeAsync(4_999); - - expect(settled).toBe(false); - expect(searchSignal?.aborted).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - const result = await resultPromise; - expectMemorySearchTimeout(result.details, 15); - expect(searchSignal?.aborted).toBe(true); - } finally { - vi.useRealTimers(); - } - }); - - it("bounds one-shot qmd cleanup with the default deadline", async () => { - vi.useFakeTimers(); - try { - setMemoryBackend("qmd"); - let searchSignal: AbortSignal | undefined; - setMemorySearchImpl(async (opts) => { - searchSignal = opts?.signal; - opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.("pause"); - try { - return await new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error("qmd query timed out after 45s")), 45_000); - }); - } finally { - opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.("resume"); - } - }); - setMemoryCloseImpl(async () => await new Promise(() => {})); - const tool = createQmdTimeoutSearchTool({ oneShotCliRun: true }); - - let settled = false; - const resultPromise = tool - .execute("qmd-cli-cleanup-timeout", { query: "hello" }) - .then((result) => { - settled = true; - return result; - }); - await vi.advanceTimersByTimeAsync(45_000); - - expect(searchSignal).toBeInstanceOf(AbortSignal); - expect(searchSignal?.aborted).toBe(false); - expect(getMemoryCloseMockCalls()).toBe(1); - expect(settled).toBe(false); - - await vi.advanceTimersByTimeAsync(14_999); - expect(settled).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - const result = await resultPromise; - expectUnavailableMemorySearchDetails(result.details, { - error: "qmd query timed out after 45s", - warning: "Memory search is unavailable due to an embedding/provider error.", - action: "Check embedding provider configuration and retry memory_search.", - }); - } finally { - vi.useRealTimers(); - } - }); - - it("keeps qmd-configured wiki-only searches on the default deadline", async () => { - vi.useFakeTimers(); - try { - setMemoryBackend("qmd"); - registerMemoryCorpusSupplement("memory-wiki", { - search: async () => await new Promise(() => {}), - get: async () => null, - }); - const tool = createQmdTimeoutSearchTool(); - - let settled = false; - const resultPromise = tool - .execute("qmd-wiki-timeout", { query: "hello", corpus: "wiki" }) - .then((result) => { - settled = true; - return result; - }); - await vi.advanceTimersByTimeAsync(15_000); - - expect(settled).toBe(true); - const result = await resultPromise; - expectMemorySearchTimeout(result.details, 15); - expect(getMemorySearchManagerMockCalls()).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it("keeps qmd-to-builtin fallback searches on the default deadline", async () => { - vi.useFakeTimers(); - try { - setResolvedMemoryBackend("qmd"); - setMemoryBackend("builtin"); - let searchSignal: AbortSignal | undefined; - setMemorySearchImpl(async (opts) => { - searchSignal = opts?.signal; - return await new Promise(() => {}); - }); - const tool = createQmdTimeoutSearchTool(); - - let settled = false; - const resultPromise = tool - .execute("qmd-fallback-timeout", { query: "hello" }) - .then((result) => { - settled = true; - return result; - }); - await vi.advanceTimersByTimeAsync(15_000); - - expect(settled).toBe(true); - const result = await resultPromise; - expectMemorySearchTimeout(result.details, 15); - expect(searchSignal?.aborted).toBe(true); - } finally { - vi.useRealTimers(); - } - }); - - it("keeps zero-hit one-shot qmd sync on the default deadline", async () => { - vi.useFakeTimers(); - try { - const manager = createTestSearchManager({ backend: "qmd", search: async () => [] }); - manager.sync.mockImplementation(async () => await new Promise(() => {})); - setMemorySearchManagerImpl(async () => ({ manager })); - const tool = createQmdTimeoutSearchTool({ oneShotCliRun: true }); - - const resultPromise = tool - .execute("qmd-zero-hit-sync-timeout", { query: "hello" }) - .then((result) => result); - await vi.advanceTimersByTimeAsync(15_000); - - const result = await resultPromise; - expectMemorySearchTimeout(result.details, 15); - expect(manager.search).toHaveBeenCalledTimes(1); - expect(manager.sync).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - it("re-resolves the manager once when a cached sqlite handle was closed", async () => { let searchCalls = 0; setMemorySearchImpl(async () => { @@ -795,109 +489,6 @@ describe("memory_search unavailable payloads", () => { expect(getMemoryCloseMockCalls()).toBe(0); }); - it("keeps closed qmd manager reacquisition on the default deadline", async () => { - vi.useFakeTimers(); - try { - setResolvedMemoryBackend("qmd"); - const initial = createTestSearchManager({ - backend: "qmd", - search: async () => { - throw new Error("database is not open"); - }, - }); - let managerCalls = 0; - setMemorySearchManagerImpl(async () => { - managerCalls += 1; - if (managerCalls === 1) { - return { manager: initial }; - } - return await new Promise(() => {}); - }); - const tool = createQmdTimeoutSearchTool(); - - let settled = false; - const resultPromise = tool.execute("closed-qmd-setup", { query: "hello" }).then((result) => { - settled = true; - return result; - }); - await vi.advanceTimersByTimeAsync(14_999); - expect(settled).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - const result = await resultPromise; - expectMemorySearchTimeout(result.details, 15); - expect(managerCalls).toBe(2); - } finally { - vi.useRealTimers(); - } - }); - - it("leaves refreshed qmd search on the qmd-owned deadline", async () => { - vi.useFakeTimers(); - try { - setResolvedMemoryBackend("qmd"); - const initial = createTestSearchManager({ - backend: "builtin", - search: async () => { - throw new Error("database is not open"); - }, - }); - let replacementSignal: AbortSignal | undefined; - const replacement = createTestSearchManager({ - backend: "qmd", - search: async (opts) => { - replacementSignal = opts?.signal; - opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.("pause"); - try { - return await new Promise((resolve) => { - setTimeout( - () => - resolve([ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 0.9, - snippet: "reacquired qmd result", - source: "memory", - }, - ]), - 16_000, - ); - }); - } finally { - opts?.[MEMORY_SEARCH_DEADLINE_CONTROL]?.("resume"); - } - }, - }); - let managerCalls = 0; - setMemorySearchManagerImpl(async () => ({ - manager: managerCalls++ === 0 ? initial : replacement, - })); - const tool = createQmdTimeoutSearchTool(); - - let settled = false; - const resultPromise = tool - .execute("closed-builtin-to-qmd", { query: "hello" }) - .then((result) => { - settled = true; - return result; - }); - await vi.advanceTimersByTimeAsync(15_000); - - expect(settled).toBe(false); - expect(replacementSignal).toBeInstanceOf(AbortSignal); - expect(replacementSignal?.aborted).toBe(false); - - await vi.advanceTimersByTimeAsync(1_000); - const result = await resultPromise; - expect((result.details as { results?: unknown[] }).results).toHaveLength(1); - expect(replacementSignal?.aborted).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - it("re-resolves and closes one-shot CLI managers when a cached sqlite handle was closed", async () => { let searchCalls = 0; setMemorySearchImpl(async () => { @@ -1000,115 +591,6 @@ describe("memory_search unavailable payloads", () => { expect(getMemorySyncMockCalls()).toBe(1); }); - it("keeps the zero-hit bootstrap retry for one-shot qmd searches", async () => { - setMemoryBackend("qmd"); - let searchCalls = 0; - setMemorySearchImpl(async () => { - searchCalls += 1; - if (searchCalls === 1) { - return []; - } - return [ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 0.9, - snippet: "Thread-hidden codename: ORBIT-22.", - source: "memory" as const, - }, - ]; - }); - - const tool = createMemorySearchToolOrThrow({ - config: { - agents: { list: [{ id: "main", default: true }] }, - memory: { backend: "qmd", citations: "off" }, - }, - oneShotCliRun: true, - }); - const result = await tool.execute("qmd-zero-hit-cli", { - query: "hidden thread codename", - }); - - expect((result.details as { results?: Array<{ path: string }> }).results?.[0]?.path).toBe( - "MEMORY.md", - ); - expect(searchCalls).toBe(2); - expect(getMemorySyncMockCalls()).toBe(1); - }); - - it("returns qmd runtime debug without forcing a zero-hit retry", async () => { - setMemoryBackend("qmd"); - let searchCalls = 0; - setMemorySearchImpl(async (opts) => { - searchCalls += 1; - opts?.onDebug?.({ - backend: "qmd", - configuredMode: "search", - effectiveMode: "search", - qmd: { - collectionValidation: { - cacheState: "hit", - elapsedMs: 2, - collectionCount: 2, - listCalls: 0, - showCalls: 0, - }, - multiCollectionProbe: { - cacheState: "hit", - elapsedMs: 1, - supported: true, - }, - searchPlan: { - command: "search", - collectionCount: 2, - groupCount: 2, - sources: ["memory", "sessions"], - }, - }, - }); - return []; - }); - - const tool = createMemorySearchToolOrThrow({ - config: { - agents: { list: [{ id: "main", default: true }] }, - memory: { backend: "qmd", citations: "off" }, - }, - }); - const result = await tool.execute("zero-hit-debug-single", { - query: "hidden thread codename", - }); - const details = result.details as { - debug?: { - effectiveMode?: string; - fallback?: string; - qmd?: MemorySearchRuntimeDebug["qmd"]; - }; - }; - - expect((result.details as { results?: Array }).results).toEqual([]); - expect(searchCalls).toBe(1); - expect(getMemorySyncMockCalls()).toBe(0); - expect(details.debug?.effectiveMode).toBe("search"); - expect(details.debug?.fallback).toBeUndefined(); - expect(details.debug?.qmd?.collectionValidation).toMatchObject({ - cacheState: "hit", - collectionCount: 2, - }); - expect(details.debug?.qmd?.multiCollectionProbe).toMatchObject({ - cacheState: "hit", - supported: true, - }); - expect(details.debug?.qmd?.searchPlan).toEqual({ - command: "search", - collectionCount: 2, - groupCount: 2, - sources: ["memory", "sessions"], - }); - }); - it("surfaces embedding bootstrap degradation when keyword search has no hits", async () => { let searchCalls = 0; setMemorySearchImpl(async (opts) => { @@ -1183,103 +665,6 @@ describe("memory_search unavailable payloads", () => { expect(getMemorySyncMockCalls()).toBe(0); }); - it("returns structured search debug metadata for qmd results", async () => { - setMemoryBackend("qmd"); - setMemorySearchImpl(async (opts) => { - opts?.onDebug?.({ - backend: "qmd", - configuredMode: opts.qmdSearchModeOverride ?? "query", - effectiveMode: "query", - fallback: "unsupported-search-flags", - qmd: { - searchPlan: { - command: "query", - collectionCount: 2, - groupCount: 2, - sources: ["memory", "sessions"], - }, - }, - }); - return [ - { - path: "MEMORY.md", - startLine: 1, - endLine: 2, - score: 0.9, - snippet: "ramen", - source: "memory", - }, - ]; - }); - - const tool = createMemorySearchToolOrThrow({ - config: { - plugins: { - entries: { - "active-memory": { - config: { - qmd: { - searchMode: "search", - }, - }, - }, - }, - }, - memory: { - backend: "qmd", - qmd: { - searchMode: "query", - limits: { - maxInjectedChars: 1000, - }, - }, - }, - }, - agentSessionKey: "agent:main:main:active-memory:debug", - }); - const result = await tool.execute("debug", { query: "favorite food" }); - const details = result.details as { - mode?: unknown; - debug?: { - backend?: unknown; - configuredMode?: unknown; - effectiveMode?: unknown; - fallback?: unknown; - hits?: unknown; - searchMs?: number; - toolMs?: number; - managerMs?: number; - outsideSearchMs?: number; - managerCacheState?: unknown; - qmd?: { - searchPlan?: { - command?: unknown; - collectionCount?: unknown; - groupCount?: unknown; - sources?: unknown; - }; - }; - }; - }; - expect(details.mode).toBe("query"); - expect(details.debug?.backend).toBe("qmd"); - expect(details.debug?.configuredMode).toBe("search"); - expect(details.debug?.effectiveMode).toBe("query"); - expect(details.debug?.fallback).toBe("unsupported-search-flags"); - expect(details.debug?.hits).toBe(1); - expect(details.debug?.searchMs).toBeGreaterThanOrEqual(0); - expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0); - expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0); - expect(details.debug?.managerMs).toBeGreaterThanOrEqual(0); - expect(details.debug?.managerCacheState).toBeUndefined(); - expect(details.debug?.qmd?.searchPlan).toEqual({ - command: "query", - collectionCount: 2, - groupCount: 2, - sources: ["memory", "sessions"], - }); - }); - it("includes manager acquisition timing and cache-state debug payload", async () => { setMemorySearchManagerImpl(async () => ({ manager: { @@ -1297,10 +682,10 @@ describe("memory_search unavailable payloads", () => { }), readFile: vi.fn(), status: vi.fn(() => ({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", + backend: "builtin", + provider: "openai", + model: "text-embedding-3-small", + requestedProvider: "openai", files: 0, chunks: 0, dirty: false, @@ -1314,8 +699,9 @@ describe("memory_search unavailable payloads", () => { probeVectorAvailability: vi.fn(async () => true), }, debug: { + backend: "builtin", + purpose: "default", managerMs: 17, - managerCacheState: "cached-full-hit", }, })); setMemorySearchImpl(async () => [ @@ -1332,7 +718,6 @@ describe("memory_search unavailable payloads", () => { const tool = createMemorySearchToolOrThrow({ config: { agents: { list: [{ id: "main", default: true }] }, - memory: { backend: "qmd" }, }, }); const result = await tool.execute("manager-debug", { query: "favorite food" }); @@ -1342,17 +727,15 @@ describe("memory_search unavailable payloads", () => { managerMs?: number; toolMs?: number; outsideSearchMs?: number; - managerCacheState?: string; hits?: number; searchMs?: number; }; }; - expect(details.debug?.backend).toBe("qmd"); + expect(details.debug?.backend).toBe("builtin"); expect(details.debug?.managerMs).toBe(17); expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0); expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0); - expect(details.debug?.managerCacheState).toBe("cached-full-hit"); }); }); @@ -1387,8 +770,6 @@ describe("memory_search corpus labels", () => { list: [{ id: "main", default: true }], }, memory: { - backend: "builtin", - search: { provider: "ollama", model: "nomic-embed-text", @@ -1401,8 +782,6 @@ describe("memory_search corpus labels", () => { list: [{ id: "main", default: true }], }, memory: { - backend: "builtin", - search: { provider: "openai", model: "text-embedding-3-small", diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index e6a17347f8ee..d9a977188914 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -31,16 +31,12 @@ import { asRecord } from "./dreaming-shared.js"; import type { MemoryCoreAcquireLocalService } from "./memory/embedding-local-service.js"; import { DEFAULT_MEMORY_SEARCH_TIMEOUT_MS, - MEMORY_SEARCH_DEADLINE_CONTROL, resolveMemorySearchAbortError, runMemorySearchWithDeadline, - type MemorySearchDeadlineAction, - type MemorySearchDeadlineControlOptions, } from "./memory/search-deadline.js"; import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; import { recordShortTermRecalls } from "./short-term-promotion.js"; import { - clampResultsByInjectedChars, decorateCitations, resolveMemoryCitationsMode, shouldIncludeCitations, @@ -63,9 +59,7 @@ type MemoryManagerContext = Awaited; type MemoryManagerSearchOptions = NonNullable< Parameters[1] -> & - MemorySearchDeadlineControlOptions; -type QmdRuntimeDebug = NonNullable; +>; const MEMORY_SEARCH_TOOL_COOLDOWN_MS = 60_000; @@ -91,28 +85,6 @@ function readCorpusParam( throw new Error(`corpus must be one of: ${allowed.join(", ")}`); } -function mergeQmdRuntimeDebug( - entries: readonly MemorySearchRuntimeDebug[], -): MemorySearchRuntimeDebug["qmd"] | undefined { - const merged: QmdRuntimeDebug = {}; - for (const entry of entries) { - const qmd = entry.qmd; - if (!qmd) { - continue; - } - if (!merged.collectionValidation && qmd.collectionValidation) { - merged.collectionValidation = qmd.collectionValidation; - } - if (qmd.multiCollectionProbe) { - merged.multiCollectionProbe = qmd.multiCollectionProbe; - } - if (qmd.searchPlan) { - merged.searchPlan = qmd.searchPlan; - } - } - return Object.keys(merged).length > 0 ? merged : undefined; -} - function mergeEmbeddingBootstrapRuntimeDebug( entries: readonly MemorySearchRuntimeDebug[], ): MemorySearchRuntimeDebug["embeddingBootstrap"] | undefined { @@ -323,40 +295,6 @@ function queueShortTermRecallTracking(params: { }); } -function normalizeActiveMemoryQmdSearchMode( - value: unknown, -): "inherit" | "search" | "vsearch" | "query" { - return value === "inherit" || value === "search" || value === "vsearch" || value === "query" - ? value - : "search"; -} - -function isActiveMemorySessionKey(sessionKey?: string): boolean { - return typeof sessionKey === "string" && sessionKey.includes(":active-memory:"); -} - -function resolveActiveMemoryQmdSearchModeOverride( - cfg: OpenClawConfig, - sessionKey?: string, -): "search" | "vsearch" | "query" | undefined { - if (!isActiveMemorySessionKey(sessionKey)) { - return undefined; - } - const entry = cfg.plugins?.entries?.["active-memory"]; - const entryRecord = - entry && typeof entry === "object" && !Array.isArray(entry) - ? (entry as { config?: unknown }) - : undefined; - const pluginConfig = - entryRecord?.config && - typeof entryRecord.config === "object" && - !Array.isArray(entryRecord.config) - ? (entryRecord.config as { qmd?: { searchMode?: unknown } }) - : undefined; - const searchMode = normalizeActiveMemoryQmdSearchMode(pluginConfig?.qmd?.searchMode); - return searchMode === "inherit" ? undefined : searchMode; -} - async function getSupplementMemoryReadResult(params: { relPath: string; from?: number; @@ -526,10 +464,7 @@ export function createMemorySearchTool(options: { } }; const runWithDefaultDeadline = async ( - task: ( - signal: AbortSignal, - controlDeadline: (action: MemorySearchDeadlineAction) => void, - ) => Promise, + task: (signal: AbortSignal) => Promise, ): Promise => await runMemorySearchWithDeadline({ timeoutMs: DEFAULT_MEMORY_SEARCH_TIMEOUT_MS, @@ -564,8 +499,6 @@ export function createMemorySearchTool(options: { "memory", async () => await runWithDefaultDeadline(async () => { - const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime(); - const resolvedMemoryBackend = resolveMemoryBackendConfig({ cfg, agentId }); const context = trackMemoryManager( await getMemoryManagerContextWithPurpose({ cfg, @@ -575,7 +508,7 @@ export function createMemorySearchTool(options: { withLease: options.withLease, }), ); - return { context, resolvedMemoryBackend }; + return { context }; }), ) : null; @@ -614,7 +547,6 @@ export function createMemorySearchTool(options: { | Exclude, null> | undefined; let managerMs: number | undefined; - let managerCacheState: string | undefined; let searchDebug: | { backend: string; @@ -625,9 +557,7 @@ export function createMemorySearchTool(options: { managerMs?: number; outsideSearchMs?: number; searchMs: number; - managerCacheState?: string; embeddingBootstrap?: MemorySearchRuntimeDebug["embeddingBootstrap"]; - qmd?: MemorySearchRuntimeDebug["qmd"]; hits: number; } | undefined; @@ -635,10 +565,6 @@ export function createMemorySearchTool(options: { await runUnavailablePhase("memory", async () => { let activeMemory = memory; const runtimeDebug: MemorySearchRuntimeDebug[] = []; - const qmdSearchModeOverride = resolveActiveMemoryQmdSearchModeOverride( - cfg, - options.agentSessionKey, - ); const memorySearchConfig = resolveMemorySearchConfig(cfg, agentId); const defaultSearchSources = memorySearchConfig?.searchSources; const trustedConfiguredRecall = options.conversationRecall?.corpus === "configured"; @@ -659,15 +585,11 @@ export function createMemorySearchTool(options: { : requestedCorpus == null || requestedCorpus === "all" ? effectiveSearchSources : undefined; - const createSearchOptions = ( - signal: AbortSignal, - controlDeadline: (action: MemorySearchDeadlineAction) => void, - ) => + const createSearchOptions = (signal: AbortSignal) => ({ maxResults, minScore, sessionKey: options.agentSessionKey, - qmdSearchModeOverride, activeProjectKeys: options.activeProjectKeys ? [...options.activeProjectKeys] : undefined, @@ -675,19 +597,14 @@ export function createMemorySearchTool(options: { onDebug: (debug: MemorySearchRuntimeDebug) => { runtimeDebug.push(debug); }, - [MEMORY_SEARCH_DEADLINE_CONTROL]: controlDeadline, ...(searchSources ? { sources: searchSources } : {}), }) satisfies MemoryManagerSearchOptions; const searchActiveMemory = async (): Promise => await runWithDefaultDeadline( - async (signal, controlDeadline) => - await activeMemory.manager.search( - query, - createSearchOptions(signal, controlDeadline), - ), + async (signal) => + await activeMemory.manager.search(query, createSearchOptions(signal)), ); managerMs = memory.debug?.managerMs; - managerCacheState = memory.debug?.managerCacheState; try { rawResults = await searchActiveMemory(); } catch (error) { @@ -709,7 +626,6 @@ export function createMemorySearchTool(options: { throw error; } managerMs = refreshed.debug?.managerMs; - managerCacheState = refreshed.debug?.managerCacheState; activeMemory = refreshed; rawResults = await searchActiveMemory(); } @@ -719,13 +635,11 @@ export function createMemorySearchTool(options: { if (pausedIndexIdentityReason) { return; } - // One-shot CLI managers have no background lifecycle, so keep their bootstrap - // retry. Long-lived QMD managers must not run update work in the tool hot path. + // Retry once after an empty result so the builtin index can finish bootstrap. if ( rawResults.length === 0 && !runtimeDebug.some((entry) => entry.embeddingBootstrap) && - activeMemory.manager.sync && - (statusBeforeRetry.backend !== "qmd" || options.oneShotCliRun === true) + activeMemory.manager.sync ) { await runWithDefaultDeadline(async () => { // Sync may join shared/background manager maintenance and has @@ -767,13 +681,7 @@ export function createMemorySearchTool(options: { snippet: stripMemoryAnnotationCarriers(result.snippet), })); const decorated = decorateCitations(payloadResults, includeCitations); - const memoryResults = - status.backend === "qmd" - ? clampResultsByInjectedChars( - decorated, - memorySetup.resolvedMemoryBackend.qmd?.limits.maxInjectedChars, - ) - : decorated; + const memoryResults = decorated; surfacedMemoryResults = memoryResults.map((result) => ({ ...result, corpus: result.source, @@ -791,23 +699,17 @@ export function createMemorySearchTool(options: { model = status.model; fallback = status.fallback; const latestDebug = runtimeDebug.at(-1); - const qmdDebug = mergeQmdRuntimeDebug(runtimeDebug); const embeddingBootstrap = mergeEmbeddingBootstrapRuntimeDebug(runtimeDebug); searchMode = latestDebug?.effectiveMode; const searchMs = Math.max(0, Date.now() - searchStartedAt); searchDebug = { backend: status.backend, configuredMode: latestDebug?.configuredMode, - effectiveMode: - status.backend === "qmd" - ? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode) - : "n/a", + effectiveMode: "n/a", fallback: latestDebug?.fallback, managerMs, searchMs, - managerCacheState, embeddingBootstrap, - qmd: qmdDebug, hits: rawResults.length, }; }); @@ -914,7 +816,7 @@ export function createMemoryGetTool(options: { const from = readPositiveIntegerParam(rawParams, "from"); const lines = readPositiveIntegerParam(rawParams, "lines"); const requestedCorpus = readCorpusParam(rawParams, ["memory", "wiki", "all"]); - const { readAgentMemoryFile, resolveMemoryBackendConfig } = await loadMemoryToolRuntime(); + const { readAgentMemoryFile } = await loadMemoryToolRuntime(); if (requestedCorpus === "wiki") { const supplement = await getSupplementMemoryReadResult({ relPath, @@ -934,39 +836,11 @@ export function createMemoryGetTool(options: { }, ); } - const resolved = resolveMemoryBackendConfig({ cfg, agentId }); - if (resolved.backend === "builtin") { - return await executeMemoryReadResult({ - read: async () => - await readAgentMemoryFile({ - cfg, - agentId, - relPath, - from: from ?? undefined, - lines: lines ?? undefined, - }), - requestedCorpus, - relPath, - from: from ?? undefined, - lines: lines ?? undefined, - agentId, - agentSessionKey: options.agentSessionKey, - sandboxed: options.sandboxed, - }); - } - const memory = await getMemoryManagerContextWithPurpose({ - cfg, - agentId, - purpose: "status", - acquireLocalService: options.acquireLocalService, - withLease: options.withLease, - }); - if ("error" in memory) { - return jsonResult({ path: relPath, text: "", disabled: true, error: memory.error }); - } return await executeMemoryReadResult({ read: async () => - await memory.manager.readFile({ + await readAgentMemoryFile({ + cfg, + agentId, relPath, from: from ?? undefined, lines: lines ?? undefined, diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index 5a6602a9dd34..d3c0f3eb925f 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -3,7 +3,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { filterMemorySearchHitsBySessionVisibility } from "@openclaw/memory-core/api.js"; -import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../api.js"; import { compileMemoryWikiVault } from "./compile.js"; @@ -187,13 +186,7 @@ describe("getMemoryWikiPage", () => { readResult: { path: "MEMORY.md", text: "memory" }, }); getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); - for (const relPath of [ - "sessions/child-session.jsonl", - "qmd/sessions/child-session.md", - "qmd/sessions-main/child-session.md", - "qmd\\sessions-main\\child-session.md", - "qmd/sessions", - ]) { + for (const relPath of ["sessions/child-session.jsonl"]) { loadCombinedSessionStoreForGatewayMock.mockClear(); await getMemoryWikiPage({ config, @@ -207,7 +200,6 @@ describe("getMemoryWikiPage", () => { for (const relPath of [ "sessionsx/child-session.jsonl", - "qmd/sessionsxxx", "wiki/sessions/foo.md", "wiki\\sessions\\foo.md", ]) { @@ -851,7 +843,7 @@ describe("searchMemoryWiki", () => { source: "sessions", }, { - path: "qmd/sessions-main/sibling-session.md", + path: "sessions/main/sibling-session.jsonl", startLine: 3, endLine: 4, score: 20, @@ -904,7 +896,7 @@ describe("searchMemoryWiki", () => { source: "sessions", }, { - path: "qmd/sessions-main/sibling-session.md", + path: "sessions/main/sibling-session.jsonl", startLine: 3, endLine: 4, score: 20, @@ -938,60 +930,6 @@ describe("searchMemoryWiki", () => { ]); }); - it("delegates protected QMD recall with the raw hit identity intact", async () => { - const { config } = await createQueryVault({ - initialize: true, - config: { search: { backend: "shared", corpus: "memory" } }, - }); - const appConfig = createSessionVisibilityAppConfig(); - const conversationRecall = { - anchorSessionKey: "agent:main:telegram:direct:owner", - scope: "same-agent-private", - corpus: "sessions", - } as const; - const requesterSessionKey = "agent:main:telegram:direct:owner:active-memory:abcdef123456"; - const qmdIdentity = Symbol("qmd-identity"); - const qmdHit: MemorySearchResult = { - path: "qmd/sessions-main/visible-export.md", - startLine: 1, - endLine: 2, - score: 30, - snippet: "protected transcript", - source: "sessions", - }; - Object.defineProperty(qmdHit, qmdIdentity, { - value: { agentId: "peer", sessionId: "peer-session" }, - }); - const manager = createMemoryManager({ searchResults: [qmdHit] }); - getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); - vi.mocked(filterMemorySearchHitsBySessionVisibility).mockResolvedValueOnce([]); - - const results = await searchMemoryWiki({ - config, - appConfig, - agentId: "main", - agentSessionKey: requesterSessionKey, - sandboxed: false, - conversationRecall, - query: "protected", - }); - - expect(results).toStrictEqual([]); - expect(filterMemorySearchHitsBySessionVisibility).toHaveBeenCalledWith({ - cfg: appConfig, - agentId: "main", - requesterSessionKey, - sandboxed: false, - hits: [qmdHit], - conversationRecall, - trustedAgentScope: false, - }); - expect(Reflect.get(qmdHit, qmdIdentity)).toEqual({ - agentId: "peer", - sessionId: "peer-session", - }); - }); - it.each([ { configuredCorpus: "wiki" as const, requestedCorpus: undefined }, { configuredCorpus: "all" as const, requestedCorpus: undefined }, @@ -1068,59 +1006,6 @@ describe("searchMemoryWiki", () => { }, ); - it("keeps QMD archived session search hits inside visibility policy", async () => { - const { config } = await createQueryVault({ - initialize: true, - config: { - search: { backend: "shared", corpus: "memory" }, - }, - }); - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(test)", - store: { - "agent:main:abc-uuid": { - sessionId: "abc-uuid", - updatedAt: 1, - sessionFile: "/tmp/openclaw/abc-uuid.jsonl", - }, - }, - }); - const manager = createMemoryManager({ - searchResults: [ - { - path: "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md", - startLine: 1, - endLine: 2, - score: 30, - snippet: "archived transcript", - source: "sessions", - }, - { - path: "abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md", - startLine: 3, - endLine: 4, - score: 20, - snippet: "normal markdown", - source: "sessions", - }, - ], - }); - getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); - - const results = await searchMemoryWiki({ - config, - appConfig: createSessionVisibilityAppConfig(), - agentSessionKey: "agent:main:abc-uuid", - sandboxed: true, - query: "transcript", - maxResults: 10, - }); - - expect(results.map((result) => result.path)).toEqual([ - "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md", - ]); - }); - it("scopes gateway-style session memory search by agent", async () => { const { config } = await createQueryVault({ initialize: true, @@ -1211,7 +1096,7 @@ describe("searchMemoryWiki", () => { source: "sessions", }, { - path: "qmd/sessions-secondary/private-session.md", + path: "sessions/secondary/private-session.jsonl", startLine: 1, endLine: 1, score: 1, @@ -1220,7 +1105,7 @@ describe("searchMemoryWiki", () => { }, ], readResult: { - path: "qmd/sessions-secondary/private-session.md", + path: "sessions/secondary/private-session.jsonl", text: "other agent transcript", }, }); @@ -1231,7 +1116,7 @@ describe("searchMemoryWiki", () => { const forbiddenPage = await getMemoryWikiPage({ config, appConfig, - lookup: "qmd/sessions-secondary/private-session.md", + lookup: "sessions/secondary/private-session.jsonl", }); expect(results.map((result) => result.path)).toEqual(["sessions/visible-session.jsonl"]); @@ -1377,43 +1262,6 @@ describe("searchMemoryWiki", () => { expect(results.map((result) => result.path)).toEqual(["sessions/secondary/live-orphan.jsonl"]); }); - it("drops cross-agent and ownerless QMD live orphan session hits", async () => { - const { config } = await createQueryVault({ - initialize: true, - config: { search: { backend: "shared", corpus: "memory" } }, - }); - const manager = createMemoryManager({ - searchResults: [ - { - path: "sessions/other/live-orphan.jsonl", - startLine: 1, - endLine: 2, - score: 30, - snippet: "cross-agent orphan", - source: "sessions", - }, - { - path: "qmd/sessions-secondary/ownerless-orphan.md", - startLine: 1, - endLine: 2, - score: 20, - snippet: "ownerless qmd orphan", - source: "sessions", - }, - ], - }); - getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); - - const results = await searchMemoryWiki({ - config, - appConfig: createAppConfig(), - agentId: "secondary", - query: "orphan", - }); - - expect(results).toStrictEqual([]); - }); - it("does not treat an orphan filename as proven self-session lineage", async () => { const { config } = await createQueryVault({ initialize: true, @@ -2015,7 +1863,7 @@ describe("getMemoryWikiPage", () => { }); }); - it("skips session memory reads outside the caller visibility policy", async () => { + it("does not expose indexed session transcripts through wiki_get", async () => { const { config } = await createQueryVault({ initialize: true, config: { @@ -2025,138 +1873,7 @@ describe("getMemoryWikiPage", () => { mockSessionTranscriptStore(); const manager = createMemoryManager({ readResult: { - path: "qmd/sessions-main/sibling-session.md", - text: "sibling transcript content", - }, - }); - getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); - - const result = await getMemoryWikiPage({ - config, - appConfig: createSessionVisibilityAppConfig(), - agentSessionKey: "agent:main:child-session", - sandboxed: true, - lookup: "qmd/sessions-main/sibling-session.md", - }); - - expect(result).toBeNull(); - expect(manager.readFile).not.toHaveBeenCalled(); - }); - - it.each([ - { visibility: "self" as const, groupAlias: false, allowed: true }, - { visibility: "agent" as const, groupAlias: true, allowed: false }, - ])( - "applies protected recall authorization to session reads ($visibility, group alias: $groupAlias)", - async ({ visibility, groupAlias, allowed }) => { - const { config } = await createQueryVault({ - initialize: true, - config: { search: { backend: "shared", corpus: "memory" } }, - }); - const anchorSessionKey = "agent:main:telegram:direct:owner"; - const requesterSessionKey = `${anchorSessionKey}:active-memory:abcdef123456`; - const friendSessionKey = "agent:main:webchat:direct:friend"; - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(test)", - store: { - [anchorSessionKey]: { - sessionId: "current", - updatedAt: 1, - sessionFile: "/tmp/current.jsonl", - chatType: "direct", - }, - [friendSessionKey]: { - sessionId: "friend", - updatedAt: 2, - sessionFile: "/tmp/friend.jsonl", - chatType: "direct", - }, - ...(groupAlias - ? { - "agent:main:telegram:group:team": { - sessionId: "friend", - updatedAt: 3, - sessionFile: "/tmp/friend.jsonl", - chatType: "group", - }, - } - : {}), - }, - }); - const manager = createMemoryManager({ - readResult: { path: "qmd/sessions-main/friend.md", text: "private transcript" }, - }); - getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); - const request = { - config, - appConfig: { - ...createSessionVisibilityAppConfig(), - tools: { sessions: { visibility } }, - }, - agentId: "main", - agentSessionKey: requesterSessionKey, - conversationRecall: { - anchorSessionKey, - scope: "same-agent-private", - corpus: "sessions", - } as const, - lookup: "qmd/sessions-main/friend.md", - }; - - const result = await getMemoryWikiPage(request); - const tool = createWikiGetTool(config, request.appConfig, { - agentId: request.agentId, - agentSessionKey: request.agentSessionKey, - conversationRecall: request.conversationRecall, - }); - const toolResult = await tool.execute("protected-wiki-get", { lookup: request.lookup }); - expect(toolResult.details).toMatchObject({ found: allowed }); - - if (allowed) { - expect(result?.content).toBe("private transcript"); - } else { - expect(result).toBeNull(); - expect(manager.readFile).not.toHaveBeenCalled(); - } - }, - ); - - it("does not read ownerless QMD live orphan session paths", async () => { - const { config } = await createQueryVault({ - initialize: true, - config: { search: { backend: "shared", corpus: "memory" } }, - }); - const manager = createMemoryManager({ - readResult: { - path: "qmd/sessions-main/ownerless-orphan.md", - text: "ownerless orphan transcript", - }, - }); - getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); - - const result = await getMemoryWikiPage({ - config, - appConfig: createSessionVisibilityAppConfig(), - agentSessionKey: "agent:main:main", - sandboxed: true, - lookup: "qmd/sessions-main/ownerless-orphan.md", - }); - - expect(result).toBeNull(); - expect(manager.readFile).not.toHaveBeenCalled(); - }); - - it("permits session memory reads inside the caller visibility policy", async () => { - const { config } = await createQueryVault({ - initialize: true, - config: { - search: { backend: "shared", corpus: "memory" }, - }, - }); - mockSessionTranscriptStore(); - const manager = createMemoryManager({ - readResult: { - path: "qmd/sessions-main/child-session.md", + path: "sessions/main/child-session.jsonl", text: "own transcript content", }, }); @@ -2167,56 +1884,11 @@ describe("getMemoryWikiPage", () => { appConfig: createSessionVisibilityAppConfig(), agentSessionKey: "agent:main:child-session", sandboxed: true, - lookup: "qmd/sessions-main/child-session.md", + lookup: "sessions/main/child-session.jsonl", }); - expectFields(result, { - corpus: "memory", - path: "qmd/sessions-main/child-session.md", - content: "own transcript content", - }); - expect(manager.readFile).toHaveBeenCalledTimes(1); - expect(manager.readFile).toHaveBeenCalledWith({ - relPath: "qmd/sessions-main/child-session.md", - from: 1, - lines: 200, - }); - }); - - it("permits QMD archived deleted session reads when the live store entry is gone", async () => { - const { config } = await createQueryVault({ - initialize: true, - config: { - search: { backend: "shared", corpus: "memory" }, - }, - }); - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ storePath: "(test)", store: {} }); - const manager = createMemoryManager({ - readResult: { - path: "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md", - text: "deleted archive transcript", - }, - }); - getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); - - const result = await getMemoryWikiPage({ - config, - appConfig: createSessionVisibilityAppConfig(), - agentSessionKey: "agent:main:deleted-uuid", - sandboxed: true, - lookup: "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md", - }); - - expectFields(result, { - corpus: "memory", - path: "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md", - content: "deleted archive transcript", - }); - expect(manager.readFile).toHaveBeenCalledWith({ - relPath: "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md", - from: 1, - lines: 200, - }); + expect(result).toBeNull(); + expect(manager.readFile).not.toHaveBeenCalled(); }); it("requires appConfig for session-bound shared memory reads", async () => { diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index c740547fadf6..7692df1d7ad2 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -970,16 +970,10 @@ function assertSessionVisibilityAppConfig(params: { } } -const SESSION_MEMORY_PATH_PREFIXES = ["sessions/", "qmd/sessions/", "qmd/sessions-"] as const; -const SESSION_MEMORY_ROOT_PATHS = ["qmd/sessions"] as const; - // Keep these path shapes aligned with source: "sessions" hits in session-search-visibility and session-transcript-hit. function isSessionMemoryPath(relPath: string): boolean { const normalized = relPath.replace(/\\/g, "/"); - return ( - SESSION_MEMORY_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix)) || - SESSION_MEMORY_ROOT_PATHS.some((rootPath) => normalized === rootPath) - ); + return normalized.startsWith("sessions/"); } function shouldSearchWiki(config: ResolvedMemoryWikiConfig): boolean { diff --git a/extensions/policy/src/doctor/data-auth-findings.ts b/extensions/policy/src/doctor/data-auth-findings.ts index bf860dd08a70..97ce89258a76 100644 --- a/extensions/policy/src/doctor/data-auth-findings.ts +++ b/extensions/policy/src/doctor/data-auth-findings.ts @@ -81,9 +81,6 @@ function scopedDataHandlingAgentMatches( policyAgentId: string, entries: readonly PolicyDataHandlingEvidence[], ): boolean { - if (entry.id === "memory-qmd-session-transcripts") { - return true; - } if (scopedAgentIdMatches(entry.agentId, policyAgentId)) { return true; } diff --git a/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts b/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts index f9f648c0308d..9bc9651951c8 100644 --- a/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts +++ b/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts @@ -101,7 +101,7 @@ describe("registerPolicyDoctorChecks", () => { ...cfgWithPolicy(), diagnostics: { otel: { enabled: true, captureContent: true } }, session: { maintenance: { mode: "warn" } }, - memory: { backend: "qmd", qmd: { sessions: { enabled: true } } }, + memory: { search: { rememberAcrossConversations: true } }, } as unknown as OpenClawConfig; const configPath = await writeDataHandlingPolicyFixture({ sensitiveLogging: { requireRedaction: true }, @@ -132,7 +132,7 @@ describe("registerPolicyDoctorChecks", () => { }), expect.objectContaining({ kind: "memorySessionTranscriptIndexing", - source: "oc://openclaw.config/memory/qmd/sessions/enabled", + source: "oc://openclaw.config/memory/search/rememberAcrossConversations", value: true, }), ]), @@ -151,7 +151,7 @@ describe("registerPolicyDoctorChecks", () => { }), expect.objectContaining({ checkId: "policy/data-handling-session-transcript-memory-enabled", - ocPath: "oc://openclaw.config/memory/qmd/sessions/enabled", + ocPath: "oc://openclaw.config/memory/search/rememberAcrossConversations", requirement: "oc://policy.jsonc/dataHandling/memory/denySessionTranscriptIndexing", }), ]), @@ -302,7 +302,6 @@ describe("registerPolicyDoctorChecks", () => { const cfg = { ...cfgWithPolicy(), memory: { - qmd: { sessions: { enabled: true } }, search: { enabled: false, rememberAcrossConversations: true, diff --git a/extensions/policy/src/policy-state-data.ts b/extensions/policy/src/policy-state-data.ts index 2d56e133fcee..a7501217128f 100644 --- a/extensions/policy/src/policy-state-data.ts +++ b/extensions/policy/src/policy-state-data.ts @@ -131,19 +131,6 @@ function pushMemorySessionTranscriptIndexing( cfg: Record, ): void { const memory = isRecord(cfg.memory) ? cfg.memory : {}; - const qmd = isRecord(memory.qmd) ? memory.qmd : {}; - const qmdSessions = isRecord(qmd.sessions) ? qmd.sessions : {}; - if (qmdSessions.enabled !== undefined) { - entries.push({ - id: "memory-qmd-session-transcripts", - kind: "memorySessionTranscriptIndexing", - source: "oc://openclaw.config/memory/qmd/sessions/enabled", - scope: "global", - value: memory.backend === "qmd" && readBoolean(qmdSessions.enabled) === true, - explicit: true, - }); - } - const defaultsMemorySearch = isRecord(memory.search) ? memory.search : {}; const defaultSessionMemory = memorySearchSessionTranscriptIndexing(defaultsMemorySearch); if (defaultSessionMemory !== undefined) { diff --git a/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.test.ts b/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.test.ts index faa58abdfd69..0ddeb1470933 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.test.ts @@ -146,7 +146,7 @@ describe("startQaLiveLaneGateway", () => { }, slots: { memory: "memory-core", - contextEngine: "qmd", + contextEngine: "custom-context", }, }, memory: { @@ -163,7 +163,7 @@ describe("startQaLiveLaneGateway", () => { expect(cfg?.plugins?.allow).toEqual(["acpx", "qa-channel"]); expect(cfg?.plugins?.entries).not.toHaveProperty("memory-core"); expect(cfg?.plugins?.slots?.memory).toBe("none"); - expect(cfg?.plugins?.slots?.contextEngine).toBe("qmd"); + expect(cfg?.plugins?.slots?.contextEngine).toBe("custom-context"); expect(cfg?.memory?.search?.enabled).toBe(false); }); diff --git a/extensions/qa-lab/src/qa-gateway-config.ts b/extensions/qa-lab/src/qa-gateway-config.ts index 976b7df2d4d4..bd509d87d53f 100644 --- a/extensions/qa-lab/src/qa-gateway-config.ts +++ b/extensions/qa-lab/src/qa-gateway-config.ts @@ -218,7 +218,6 @@ export function buildQaGatewayConfig(params: { lastTouchedVersion: OPENCLAW_VERSION, }, memory: { - backend: "builtin", search: { ...mockMemorySearch, }, diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index 4ec92fa6ba59..990b814cb005 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -389,8 +389,8 @@ "openclaw/plugin-sdk/memory-core-host-engine-fs": [ "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-fs.d.ts" ], - "openclaw/plugin-sdk/memory-core-host-engine-qmd": [ - "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-qmd.d.ts" + "openclaw/plugin-sdk/memory-core-host-engine-sessions": [ + "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-sessions.d.ts" ], "openclaw/plugin-sdk/memory-core-host-engine-schema": [ "../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-schema.d.ts" diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index f8534baa0471..1adb1945b3b1 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -386,8 +386,8 @@ "openclaw/plugin-sdk/memory-core-host-engine-fs": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-fs.d.ts" ], - "openclaw/plugin-sdk/memory-core-host-engine-qmd": [ - "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-qmd.d.ts" + "openclaw/plugin-sdk/memory-core-host-engine-sessions": [ + "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-sessions.d.ts" ], "openclaw/plugin-sdk/memory-core-host-engine-schema": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/memory-core-host-engine-schema.d.ts" diff --git a/package.json b/package.json index 012dad5a3317..7966d1c6a85b 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "!dist/plugin-sdk/memory-core-host-engine-curated.d.ts", "!dist/plugin-sdk/memory-core-host-engine-embeddings.d.ts", "!dist/plugin-sdk/memory-core-host-engine-fs.d.ts", - "!dist/plugin-sdk/memory-core-host-engine-qmd.d.ts", + "!dist/plugin-sdk/memory-core-host-engine-sessions.d.ts", "!dist/plugin-sdk/memory-core-host-engine-schema.d.ts", "!dist/plugin-sdk/memory-core-host-engine-storage.d.ts", "!dist/plugin-sdk/memory-core-host-runtime-cli.d.ts", @@ -1229,8 +1229,8 @@ "./plugin-sdk/memory-core-host-engine-fs": { "default": "./dist/plugin-sdk/memory-core-host-engine-fs.js" }, - "./plugin-sdk/memory-core-host-engine-qmd": { - "default": "./dist/plugin-sdk/memory-core-host-engine-qmd.js" + "./plugin-sdk/memory-core-host-engine-sessions": { + "default": "./dist/plugin-sdk/memory-core-host-engine-sessions.js" }, "./plugin-sdk/memory-core-host-engine-schema": { "default": "./dist/plugin-sdk/memory-core-host-engine-schema.js" @@ -2099,6 +2099,7 @@ "shiki": "4.3.1", "signal-utils": "0.21.1", "sigstore": "5.0.0", + "string-width": "8.2.2", "stylelint": "17.14.1", "stylelint-config-recommended": "18.0.0", "tsdown": "0.22.1", diff --git a/packages/memory-host-sdk/package.json b/packages/memory-host-sdk/package.json index ca70e46b7c3c..1bd8b32f5113 100644 --- a/packages/memory-host-sdk/package.json +++ b/packages/memory-host-sdk/package.json @@ -17,7 +17,7 @@ "./engine-foundation": "./src/engine-foundation.ts", "./engine-storage": "./src/engine-storage.ts", "./engine-embeddings": "./src/engine-embeddings.ts", - "./engine-qmd": "./src/engine-qmd.ts", + "./engine-sessions": "./src/engine-sessions.ts", "./multimodal": "./src/multimodal.ts", "./query": "./src/query.ts", "./secret": "./src/secret.ts", diff --git a/packages/memory-host-sdk/src/engine-foundation.ts b/packages/memory-host-sdk/src/engine-foundation.ts index 2b1c4d66e77d..13383a3a9e3e 100644 --- a/packages/memory-host-sdk/src/engine-foundation.ts +++ b/packages/memory-host-sdk/src/engine-foundation.ts @@ -36,13 +36,6 @@ export { truncateUtf16Safe, } from "./host/openclaw-runtime-io.js"; export type { OpenClawConfig } from "./host/openclaw-runtime-config.js"; -export type { SessionSendPolicyConfig } from "./host/openclaw-runtime-config.js"; export type { SecretInput } from "./host/openclaw-runtime-config.js"; -export type { - MemoryBackend, - MemoryCitationsMode, - MemoryQmdConfig, - MemoryQmdIndexPath, - MemoryQmdSearchMode, -} from "./host/openclaw-runtime-config.js"; +export type { MemoryCitationsMode } from "./host/openclaw-runtime-config.js"; export type { MemorySearchConfig } from "./host/openclaw-runtime-config.js"; diff --git a/packages/memory-host-sdk/src/engine-qmd.ts b/packages/memory-host-sdk/src/engine-sessions.ts similarity index 66% rename from packages/memory-host-sdk/src/engine-qmd.ts rename to packages/memory-host-sdk/src/engine-sessions.ts index d722a3081a7f..935c398408bd 100644 --- a/packages/memory-host-sdk/src/engine-qmd.ts +++ b/packages/memory-host-sdk/src/engine-sessions.ts @@ -1,4 +1,4 @@ -// Real workspace contract for QMD/session/query helpers used by the memory engine. +// Session transcript and query helpers shared by memory engines. export { extractKeywords, isQueryStopWordToken } from "./host/query-expansion.js"; export { @@ -29,18 +29,3 @@ export { parseSqliteSessionFileMarker, parseUsageCountedSessionIdFromFileName, } from "./host/openclaw-runtime-session.js"; -export { parseQmdQueryJson, type QmdQueryResult } from "./host/qmd-query-parser.js"; -export { - deriveQmdScopeChannel, - deriveQmdScopeChatType, - isQmdScopeAllowed, -} from "./host/qmd-scope.js"; -export { - checkQmdBinaryAvailability, - resolveCliSpawnInvocation, - resolveQmdBinaryUnavailableReason, - runCliCommand, - type QmdBinaryAvailability, - type QmdBinaryUnavailable, - type QmdBinaryUnavailableReason, -} from "./host/qmd-process.js"; diff --git a/packages/memory-host-sdk/src/engine-storage.ts b/packages/memory-host-sdk/src/engine-storage.ts index 489c8fb4c7df..eed81f49d02e 100644 --- a/packages/memory-host-sdk/src/engine-storage.ts +++ b/packages/memory-host-sdk/src/engine-storage.ts @@ -34,11 +34,7 @@ export { } from "./host/read-file-shared.js"; export { resolveMemoryBackendConfig } from "./host/backend-config.js"; export { resolveMemorySearchStaleness } from "./host/types.js"; -export type { - ResolvedMemoryBackendConfig, - ResolvedQmdConfig, - ResolvedQmdMcporterConfig, -} from "./host/backend-config.js"; +export type { ResolvedMemoryBackendConfig } from "./host/backend-config.js"; export type { MemoryEmbeddingProbeResult, MemoryEntryProvenance, diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index 2128acf5d85c..1df195ffb418 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -1,882 +1,20 @@ -// Memory Host SDK tests cover backend config behavior. -import syncFs from "node:fs"; -import type { Dirent } from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { resolveMemoryBackendConfig } from "./backend-config.js"; -import type { OpenClawConfig } from "./config-utils.js"; - -type ResolvedMemoryBackendConfig = ReturnType; - -const resolveComparablePath = (value: string, workspaceDir = "/workspace/root"): string => - path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value); - -const memoryFileEntry = (name: string): Dirent => - ({ - name, - isFile: () => true, - isSymbolicLink: () => false, - }) as Dirent; - -const withMemoryRootEntries = (entries: Dirent[], test: () => T): T => { - const readdirSpy = vi - .spyOn(syncFs, "readdirSync") - .mockReturnValue(entries as unknown as ReturnType); - try { - return test(); - } finally { - readdirSpy.mockRestore(); - } -}; - -const rootMemoryConfig = (workspaceDir: string): OpenClawConfig => - ({ - agents: { - defaults: { workspace: workspaceDir }, - list: [{ id: "main", default: true, workspace: workspaceDir }], - }, - memory: { - backend: "qmd", - qmd: {}, - }, - }) as OpenClawConfig; - -const collectionNames = (resolved: ResolvedMemoryBackendConfig): string[] => - (resolved.qmd?.collections ?? []).map((collection) => collection.name).toSorted(); - -function requireQmdConfig( - resolved: ResolvedMemoryBackendConfig, -): NonNullable { - if (!resolved.qmd) { - throw new Error("expected qmd memory backend config"); - } - return resolved.qmd; -} - -function requireQmdCollection( - resolved: ResolvedMemoryBackendConfig, - name: string, -): NonNullable["collections"][number] { - const collection = requireQmdConfig(resolved).collections.find( - (candidate) => candidate.name === name, - ); - if (!collection) { - throw new Error(`expected qmd collection ${name}`); - } - return collection; -} - -const customQmdCollections = ( - resolved: ResolvedMemoryBackendConfig, -): NonNullable["collections"] => - (resolved.qmd?.collections ?? []).filter((collection) => collection.kind === "custom"); - -const customCollectionPaths = (resolved: ResolvedMemoryBackendConfig): string[] => - customQmdCollections(resolved) - .map((collection) => collection.path) - .toSorted(); - -let fixtureRoot: string; -let fixtureId = 0; - -beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-")); -}); - -afterAll(async () => { - await fs.rm(fixtureRoot, { recursive: true, force: true }); -}); - -async function createFixtureDir(name: string): Promise { - const dir = path.join(fixtureRoot, `${name}-${fixtureId++}`); - await fs.mkdir(dir, { recursive: true }); - return dir; -} describe("resolveMemoryBackendConfig", () => { - it("defaults to builtin backend when config missing", () => { - const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } } } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.backend).toBe("builtin"); - expect(resolved.citations).toBe("auto"); - expect(resolved.qmd).toBeUndefined(); - }); - - it("resolves qmd backend with default collections", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: {}, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.backend).toBe("qmd"); - const qmd = requireQmdConfig(resolved); - expect(qmd.collections.length).toBe(2); - expect(qmd.command).toBe("qmd"); - expect(qmd.searchMode).toBe("search"); - expect(qmd.update.intervalMs).toBe(300_000); - expect(qmd.update.debounceMs).toBe(15_000); - expect(qmd.update.onBoot).toBe(true); - expect(qmd.update.startup).toBe("off"); - expect(qmd.update.startupDelayMs).toBe(120_000); - expect(qmd.update.waitForBootSync).toBe(false); - expect(qmd.update.embedIntervalMs).toBe(3_600_000); - expect(qmd.update.commandTimeoutMs).toBe(30_000); - expect(qmd.update.updateTimeoutMs).toBe(120_000); - expect(qmd.update.embedTimeoutMs).toBe(120_000); - expect(collectionNames(resolved)).toStrictEqual(["memory-dir-main", "memory-root-main"]); - expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); - }); - - it("keeps QMD session export off by default when DM isolation is configured", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - session: { dmScope: "per-peer" }, - memory: { backend: "qmd", qmd: {} }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - - expect(requireQmdConfig(resolved).sessions.enabled).toBe(false); - }); - - it("enables QMD session export when an agent remembers across conversations", () => { - const cfg = { - agents: { - defaults: { workspace: "/tmp/memory-test" }, - list: [ - { - id: "personal", - memory: { search: { rememberAcrossConversations: true } }, - }, - ], - }, - memory: { backend: "qmd", qmd: {} }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "personal" }); - - expect(requireQmdConfig(resolved).sessions.enabled).toBe(true); - // Remember-only export stays search-only for trusted recall; ordinary - // memory_get must not read transcript exports the operator never opted into. - expect(requireQmdConfig(resolved).sessions.readable).toBe(false); - }); - - it("ignores a configured exportDir for remember-only implied session exports", () => { - const cfg = { - agents: { - defaults: { workspace: "/tmp/memory-test" }, - list: [ - { - id: "personal", - memory: { search: { rememberAcrossConversations: true } }, - }, - ], - }, - // sessions.enabled is not set: exportDir was configured for an export - // feature the operator never turned on. Honoring it here could write - // transcripts into workspace memory/ and leak them into the ordinary - // memory corpus. - memory: { backend: "qmd", qmd: { sessions: { exportDir: "memory/session-exports" } } }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "personal" }); - - expect(requireQmdConfig(resolved).sessions.enabled).toBe(true); - expect(requireQmdConfig(resolved).sessions.readable).toBe(false); - expect(requireQmdConfig(resolved).sessions.exportDir).toBeUndefined(); - }); - - it("keeps a configured exportDir for explicitly enabled session exports", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { sessions: { enabled: true, exportDir: "session-exports" } }, - }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - - expect(requireQmdConfig(resolved).sessions.exportDir).toBe("/tmp/memory-test/session-exports"); - }); - - it("keeps explicitly configured QMD session exports readable", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { backend: "qmd", qmd: { sessions: { enabled: true } } }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - - expect(requireQmdConfig(resolved).sessions.enabled).toBe(true); - expect(requireQmdConfig(resolved).sessions.readable).toBe(true); - }); - - it("keeps uppercase MEMORY.md as the root pattern when only lowercase memory.md exists", () => { - const workspaceDir = "/workspace/root"; - withMemoryRootEntries([memoryFileEntry("memory.md")], () => { - const cfg = rootMemoryConfig(workspaceDir); - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); - expect(collectionNames(resolved)).toStrictEqual(["memory-dir-main", "memory-root-main"]); + it("uses the builtin backend with automatic citations by default", () => { + expect(resolveMemoryBackendConfig({ cfg: {}, agentId: "main" })).toEqual({ + backend: "builtin", + citations: "auto", }); }); - it("prefers MEMORY.md over legacy memory.md when both root files exist", () => { - const workspaceDir = "/workspace/root"; - withMemoryRootEntries([memoryFileEntry("MEMORY.md"), memoryFileEntry("memory.md")], () => { - const cfg = rootMemoryConfig(workspaceDir); - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); - expect(collectionNames(resolved)).toStrictEqual(["memory-dir-main", "memory-root-main"]); - }); - }); - - it("parses quoted qmd command paths", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { - command: '"/Applications/QMD Tools/qmd" --flag', - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(requireQmdConfig(resolved).command).toBe("/Applications/QMD Tools/qmd"); - }); - - it("preserves unquoted Windows absolute qmd command paths", () => { - const command = String.raw`C:\Users\penny\AppData\Roaming\npm\node_modules\@tobilu\qmd\dist\cli\qmd.js`; - const cfg = { - agents: { defaults: { workspace: String.raw`C:\Users\penny\.openclaw\workspace` } }, - memory: { - backend: "qmd", - qmd: { - command, - }, - }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - - expect(requireQmdConfig(resolved).command).toBe(command); - }); - - it("preserves Windows wrapper command paths before extra args", () => { - const cfg = { - agents: { defaults: { workspace: String.raw`C:\Users\penny\.openclaw\workspace` } }, - memory: { - backend: "qmd", - qmd: { - command: String.raw`C:\Program Files\qmd\qmd.cmd --json`, - }, - }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - - expect(requireQmdConfig(resolved).command).toBe(String.raw`C:\Program Files\qmd\qmd.cmd`); - }); - - it("preserves unquoted UNC qmd command paths", () => { - const command = String.raw`\\fileserver\tools\qmd\dist\cli\qmd.js`; - const cfg = { - agents: { defaults: { workspace: String.raw`C:\Users\penny\.openclaw\workspace` } }, - memory: { - backend: "qmd", - qmd: { - command, - }, - }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - - expect(requireQmdConfig(resolved).command).toBe(command); - }); - - it("resolves custom paths relative to workspace", () => { - const cfg = { - agents: { - defaults: { workspace: "/workspace/root" }, - list: [{ id: "main", workspace: "/workspace/root" }], - }, - memory: { - backend: "qmd", - qmd: { - paths: [ - { - path: "notes", - name: "custom-notes", - pattern: "**/*.md", - }, - ], - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const custom = requireQmdConfig(resolved).collections.find((c) => - c.name.startsWith("custom-notes"), - ); - if (!custom) { - throw new Error("expected custom-notes qmd collection"); - } - expect(custom.path).toBe(path.resolve("/workspace/root", "notes")); - }); - - it("normalizes direct file qmd paths to escaped exact-file patterns", async () => { - const workspaceDir = await createFixtureDir("direct-file-path"); - const notesPath = path.join(workspaceDir, "notes{a,b}[1].md"); - await fs.writeFile(notesPath, "# Notes\n", "utf8"); - - const cfg = { - agents: { - defaults: { workspace: workspaceDir }, - list: [{ id: "main", workspace: workspaceDir }], - }, - memory: { - backend: "qmd", - qmd: { - paths: [{ path: "notes{a,b}[1].md", name: "direct-note", pattern: "**/*.md" }], - }, - }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const custom = resolved.qmd?.collections.find((c) => c.name.startsWith("direct-note")); - expect(custom).toMatchObject({ - path: workspaceDir, - pattern: String.raw`notes\{a,b\}\[1\].md`, - }); - }); - - it("scopes qmd collection names per agent", () => { - const cfg = { - agents: { - defaults: { workspace: "/workspace/root" }, - list: [ - { id: "main", default: true, workspace: "/workspace/root" }, - { id: "dev", workspace: "/workspace/dev" }, - ], - }, - memory: { - backend: "qmd", - qmd: { - includeDefaultMemory: true, - paths: [{ path: "notes", name: "workspace", pattern: "**/*.md" }], - }, - }, - } as OpenClawConfig; - const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" }); - const mainNames = collectionNames(mainResolved); - const devNames = collectionNames(devResolved); - expect(mainNames).toStrictEqual(["memory-dir-main", "memory-root-main", "workspace-main"]); - expect(devNames).toStrictEqual(["memory-dir-dev", "memory-root-dev", "workspace-dev"]); - }); - - it("merges default and per-agent qmd extra collections", () => { - const cfg = { - agents: { - defaults: { - workspace: "/workspace/root", - }, - list: [ - { - id: "main", - default: true, - workspace: "/workspace/root", - memory: { - search: { - qmd: { - extraCollections: [ - { - path: "notes", - name: "notes", - pattern: "**/*.md", - }, - ], - }, - }, - }, - }, - ], - }, - memory: { - backend: "qmd", - search: { - qmd: { - extraCollections: [ - { - path: "/shared/team-notes", - name: "team-notes", - pattern: "**/*.md", - }, - ], - }, - }, - qmd: { - includeDefaultMemory: false, - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const names = collectionNames(resolved); - expect(names).toStrictEqual(["notes-main", "team-notes"]); - }); - - it("preserves explicit custom collection names for paths outside the workspace", () => { - const cfg = { - agents: { - defaults: { workspace: "/workspace/root" }, - list: [ - { id: "main", default: true, workspace: "/workspace/root" }, - { id: "dev", workspace: "/workspace/dev" }, - ], - }, - memory: { - backend: "qmd", - qmd: { - includeDefaultMemory: true, - paths: [{ path: "/shared/notion-mirror", name: "notion-mirror", pattern: "**/*.md" }], - }, - }, - } as OpenClawConfig; - const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" }); - const mainNames = collectionNames(mainResolved); - const devNames = collectionNames(devResolved); - expect(mainNames).toStrictEqual(["memory-dir-main", "memory-root-main", "notion-mirror"]); - expect(devNames).toStrictEqual(["memory-dir-dev", "memory-root-dev", "notion-mirror"]); - }); - - it("keeps symlinked workspace paths agent-scoped when deciding custom collection names", async () => { - const tmpRoot = await createFixtureDir("symlinked-workspace"); - const workspaceDir = path.join(tmpRoot, "workspace"); - const workspaceAliasDir = path.join(tmpRoot, "workspace-alias"); - await fs.mkdir(workspaceDir, { recursive: true }); - await fs.symlink(workspaceDir, workspaceAliasDir); - const cfg = { - agents: { - defaults: { workspace: workspaceDir }, - list: [{ id: "main", default: true, workspace: workspaceDir }], - }, - memory: { - backend: "qmd", - qmd: { - includeDefaultMemory: false, - paths: [{ path: workspaceAliasDir, name: "workspace", pattern: "**/*.md" }], - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const names = collectionNames(resolved); - expect(names).toStrictEqual(["workspace-main"]); - }); - - it("keeps unresolved child paths under a symlinked workspace agent-scoped", async () => { - const tmpRoot = await createFixtureDir("symlinked-child"); - const realRootDir = path.join(tmpRoot, "real-root"); - const aliasRootDir = path.join(tmpRoot, "alias-root"); - const workspaceDir = path.join(realRootDir, "workspace"); - const workspaceAliasDir = path.join(aliasRootDir, "workspace"); - await fs.mkdir(workspaceDir, { recursive: true }); - await fs.symlink(realRootDir, aliasRootDir); - const cfg = { - agents: { - defaults: { workspace: workspaceDir }, - list: [{ id: "main", default: true, workspace: workspaceDir }], - }, - memory: { - backend: "qmd", - qmd: { - includeDefaultMemory: false, - paths: [ - { path: path.join(workspaceAliasDir, "notes"), name: "notes", pattern: "**/*.md" }, - ], - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const names = collectionNames(resolved); - expect(names).toStrictEqual(["notes-main"]); - }); - - it("uses fixed qmd update timeouts", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { - update: { - waitForBootSync: true, - commandTimeoutMs: 12_000, - updateTimeoutMs: 480_000, - embedTimeoutMs: 360_000, - }, - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const update = requireQmdConfig(resolved).update; - expect(update.waitForBootSync).toBe(false); - expect(update.commandTimeoutMs).toBe(30_000); - expect(update.updateTimeoutMs).toBe(120_000); - expect(update.embedTimeoutMs).toBe(120_000); - }); - - it("keeps supported sub-unit qmd numeric overrides while update timeouts stay fixed", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { - sessions: { - enabled: true, - retentionDays: 0.5, - }, - update: { - commandTimeoutMs: 0.5, - updateTimeoutMs: 0.5, - embedTimeoutMs: 0.5, - }, - limits: { - maxResults: 0.5, - maxSnippetChars: 0.5, - maxInjectedChars: 0.5, - timeoutMs: 0.5, - }, - }, - }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const qmd = requireQmdConfig(resolved); - - expect(qmd.sessions.retentionDays).toBe(1); - expect(qmd.update.commandTimeoutMs).toBe(30_000); - expect(qmd.update.updateTimeoutMs).toBe(120_000); - expect(qmd.update.embedTimeoutMs).toBe(120_000); - expect(qmd.limits).toMatchObject({ - maxResults: 1, - maxSnippetChars: 1, - maxInjectedChars: 1, - timeoutMs: 1, - }); - }); - - it("falls back for non-finite qmd numeric overrides", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { - sessions: { - enabled: true, - retentionDays: Number.NaN, - }, - update: { - commandTimeoutMs: Number.POSITIVE_INFINITY, - updateTimeoutMs: Number.NaN, - embedTimeoutMs: Number.NEGATIVE_INFINITY, - }, - limits: { - maxResults: Number.NaN, - maxSnippetChars: Number.POSITIVE_INFINITY, - maxInjectedChars: Number.NEGATIVE_INFINITY, - timeoutMs: Number.NaN, - }, - }, - }, - } as OpenClawConfig; - - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const qmd = requireQmdConfig(resolved); - - expect(qmd.sessions.retentionDays).toBeUndefined(); - expect(qmd.update.commandTimeoutMs).toBe(30_000); - expect(qmd.update.updateTimeoutMs).toBe(120_000); - expect(qmd.update.embedTimeoutMs).toBe(120_000); - expect(qmd.limits).toMatchObject({ - maxResults: 4, - maxSnippetChars: 450, - maxInjectedChars: 2_200, - timeoutMs: 4_000, - }); - }); - - it("uses the fixed lazy qmd startup policy", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { - update: { - startup: "idle", - startupDelayMs: 45_000, - }, - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const update = requireQmdConfig(resolved).update; - expect(update.startup).toBe("off"); - expect(update.startupDelayMs).toBe(120_000); - expect(update.onBoot).toBe(true); - }); - - it("resolves qmd search mode override", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { - searchMode: "vsearch", - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(requireQmdConfig(resolved).searchMode).toBe("vsearch"); - }); - - it("resolves qmd rerank override", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { - searchMode: "query", - rerank: false, - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const qmd = requireQmdConfig(resolved); - expect(qmd.searchMode).toBe("query"); - expect(qmd.rerank).toBe(false); - }); - - it("resolves qmd mcporter search tool override", () => { - const cfg = { - agents: { defaults: { workspace: "/tmp/memory-test" } }, - memory: { - backend: "qmd", - qmd: { - searchMode: "query", - searchTool: " hybrid_search ", - }, - }, - } as OpenClawConfig; - const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const qmd = requireQmdConfig(resolved); - expect(qmd.searchMode).toBe("query"); - expect(qmd.searchTool).toBe("hybrid_search"); - }); -}); - -describe("memorySearch.extraPaths integration", () => { - it("maps memory.search.extraPaths to QMD collections", () => { - const cfg = { - memory: { - backend: "qmd", - search: { - extraPaths: ["/home/user/docs", "/home/user/vault"], - }, - }, - - agents: { - defaults: { - workspace: "/workspace/root", - }, - }, - } as OpenClawConfig; - const result = resolveMemoryBackendConfig({ cfg, agentId: "test-agent" }); - expect(result.backend).toBe("qmd"); - const paths = customCollectionPaths(result); - expect(paths).toStrictEqual([ - resolveComparablePath("/home/user/docs"), - resolveComparablePath("/home/user/vault"), - ]); - }); - - it("merges default and per-agent memorySearch.extraPaths for QMD collections", () => { - const cfg = { - memory: { - backend: "qmd", - search: { - extraPaths: ["/default/path"], - }, - }, - - agents: { - defaults: { - workspace: "/workspace/root", - }, - list: [ - { - id: "my-agent", - memory: { - search: { - extraPaths: ["/agent/specific/path"], - }, - }, - }, - ], - }, - } as OpenClawConfig; - const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); - expect(result.backend).toBe("qmd"); - const paths = customCollectionPaths(result); - expect(paths).toStrictEqual([ - resolveComparablePath("/agent/specific/path"), - resolveComparablePath("/default/path"), - ]); - }); - - it("falls back to defaults when agent has no overrides", () => { - const cfg = { - memory: { - backend: "qmd", - search: { - extraPaths: ["/default/path"], - }, - }, - - agents: { - defaults: { - workspace: "/workspace/root", - }, - list: [ - { - id: "other-agent", - memory: { - search: { - extraPaths: ["/other/path"], - }, - }, - }, - ], - }, - } as OpenClawConfig; - const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); - expect(result.backend).toBe("qmd"); - const paths = customCollectionPaths(result); - expect(paths).toStrictEqual([resolveComparablePath("/default/path")]); - }); - - it("deduplicates merged memorySearch.extraPaths for QMD collections", () => { - const cfg = { - memory: { - backend: "qmd", - search: { - extraPaths: ["/shared/path", " /shared/path "], - }, - }, - - agents: { - defaults: { - workspace: "/workspace/root", - }, - list: [ - { - id: "my-agent", - memory: { - search: { - extraPaths: ["/shared/path", "/agent-only"], - }, - }, - }, - ], - }, - } as OpenClawConfig; - - const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); - const paths = customCollectionPaths(result); - - expect(paths).toStrictEqual([ - resolveComparablePath("/agent-only"), - resolveComparablePath("/shared/path"), - ]); - }); - - it("keeps unnamed extra paths agent-scoped even when they resolve outside the workspace", () => { - const cfg = { - memory: { - backend: "qmd", - search: { - extraPaths: ["/shared/path"], - }, - }, - - agents: { - defaults: { - workspace: "/workspace/root", - }, - }, - } as OpenClawConfig; - const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); - expect(customQmdCollections(result).map((collection) => collection.name)).toStrictEqual([ - "custom-1-my-agent", - ]); - }); - - it("matches per-agent memorySearch.extraPaths using normalized agent ids", () => { - const cfg = { - memory: { backend: "qmd" }, - agents: { - defaults: { - workspace: "/workspace/root", - }, - list: [ - { - id: "My-Agent", - memory: { - search: { - extraPaths: ["/agent/mixed-case"], - }, - }, - }, - ], - }, - } as OpenClawConfig; - - const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); - - expect(customCollectionPaths(result)).toStrictEqual([ - resolveComparablePath("/agent/mixed-case"), - ]); - }); - - it("deduplicates identical roots shared by memory.qmd.paths and memorySearch.extraPaths", () => { - const cfg = { - memory: { - backend: "qmd", - search: { - extraPaths: ["./docs"], - }, - qmd: { - paths: [{ path: "docs", pattern: "**/*.md", name: "workspace-docs" }], - }, - }, - agents: { - defaults: { - workspace: "/workspace/root", - }, - }, - } as OpenClawConfig; - - const result = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const docsCollections = customQmdCollections(result).filter( - (collection) => - collection.path === resolveComparablePath("./docs") && collection.pattern === "**/*.md", - ); - - expect(docsCollections).toHaveLength(1); + it("preserves the configured citation mode", () => { + expect( + resolveMemoryBackendConfig({ + cfg: { memory: { citations: "off" } }, + agentId: "main", + }), + ).toEqual({ backend: "builtin", citations: "off" }); }); }); diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 29d9a9a09bf2..536644847a23 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -1,492 +1,19 @@ -// Memory Host SDK module implements backend config behavior. -import fs from "node:fs"; -import path from "node:path"; -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { - normalizeStringEntries, - uniqueStrings, -} from "@openclaw/normalization-core/string-normalization"; -import { - MEMORY_HOST_ROOT_FILENAME, - type MemoryBackend, - type MemoryCitationsMode, - type MemoryQmdConfig, - type MemoryQmdIndexPath, - type MemoryQmdSearchMode, - type MemoryQmdStartupMode, - type OpenClawConfig, - resolveMemoryHostAgentWorkspaceDir, - resolveMemoryHostSearchPathConfig, - normalizeAgentId, - resolveMemoryHostUserPath, - type SessionSendPolicyConfig, - splitShellArgs, -} from "./config-utils.js"; -import { isPathInside } from "./fs-utils.js"; -import { parseDurationMs } from "./openclaw-runtime-config.js"; - -function escapeQmdExactFilePattern(fileName: string): string { - return fileName.replace(/[\\*?[\]{}()!+@]/g, "\\$&"); -} - -const WINDOWS_COMMAND_EXTENSION_RE = - /^((?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:bat|cmd|cjs|exe|js|mjs|ps1))(?:\s+|$)/i; - -function resolveQmdCommand(rawCommand: string): string { - const trimmedCommand = rawCommand.trim(); - const windowsCommand = resolveWindowsAbsoluteCommand(trimmedCommand); - if (windowsCommand) { - return windowsCommand; - } - - const parsedCommand = splitShellArgs(trimmedCommand); - return parsedCommand?.[0] || trimmedCommand.split(/\s+/)[0] || "qmd"; -} - -function resolveWindowsAbsoluteCommand(rawCommand: string): string | undefined { - if (!path.win32.isAbsolute(rawCommand)) { - return undefined; - } - - const extensionMatch = WINDOWS_COMMAND_EXTENSION_RE.exec(rawCommand); - if (extensionMatch) { - return extensionMatch[1]; - } - - const firstWhitespace = rawCommand.search(/\s/); - return firstWhitespace === -1 ? rawCommand : rawCommand.slice(0, firstWhitespace); -} +// Memory Host SDK resolves the sole builtin backend and citation mode. +import type { MemoryCitationsMode, OpenClawConfig } from "./config-utils.js"; export type ResolvedMemoryBackendConfig = { - backend: MemoryBackend; + backend: "builtin"; citations: MemoryCitationsMode; - qmd?: ResolvedQmdConfig; }; -/** @public */ export type ResolvedQmdCollection = { - name: string; - path: string; - pattern: string; - kind: "memory" | "custom" | "sessions"; -}; - -/** @public */ export type ResolvedQmdUpdateConfig = { - intervalMs: number; - debounceMs: number; - onBoot: boolean; - startup: MemoryQmdStartupMode; - startupDelayMs: number; - waitForBootSync: boolean; - embedIntervalMs: number; - commandTimeoutMs: number; - updateTimeoutMs: number; - embedTimeoutMs: number; -}; - -/** @public */ export type ResolvedQmdLimitsConfig = { - maxResults: number; - maxSnippetChars: number; - maxInjectedChars: number; - timeoutMs: number; -}; - -/** @public */ export type ResolvedQmdSessionConfig = { - enabled: boolean; - /** - * Whether ordinary memory searches and memory_get may access exported - * session transcripts. Only explicit memory.qmd.sessions.enabled opts - * transcripts into the ordinary memory corpus; remember-across-conversations - * implies export for trusted recall search only. - */ - readable: boolean; - exportDir?: string; - retentionDays?: number; -}; - -export type ResolvedQmdMcporterConfig = { - enabled: boolean; - serverName: string; - startDaemon: boolean; -}; - -export type ResolvedQmdConfig = { - command: string; - mcporter: ResolvedQmdMcporterConfig; - searchMode: MemoryQmdSearchMode; - rerank?: boolean; - searchTool?: string; - collections: ResolvedQmdCollection[]; - sessions: ResolvedQmdSessionConfig; - update: ResolvedQmdUpdateConfig; - limits: ResolvedQmdLimitsConfig; - includeDefaultMemory: boolean; - scope?: SessionSendPolicyConfig; -}; - -const DEFAULT_BACKEND: MemoryBackend = "builtin"; const DEFAULT_CITATIONS: MemoryCitationsMode = "auto"; -const DEFAULT_QMD_INTERVAL = "5m"; -const DEFAULT_QMD_DEBOUNCE_MS = 15_000; -const DEFAULT_QMD_TIMEOUT_MS = 4_000; -// Defaulting to `query` can be extremely slow on CPU-only systems (query expansion + rerank). -// Prefer a faster mode for interactive use; users can opt into `query` for best recall. -const DEFAULT_QMD_SEARCH_MODE: MemoryQmdSearchMode = "search"; -const DEFAULT_QMD_STARTUP: MemoryQmdStartupMode = "off"; -const DEFAULT_QMD_STARTUP_DELAY_MS = 120_000; -const DEFAULT_QMD_EMBED_INTERVAL = "60m"; -const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000; -const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000; -const DEFAULT_QMD_EMBED_TIMEOUT_MS = 120_000; -const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = { - maxResults: 4, - maxSnippetChars: 450, - maxInjectedChars: 2_200, - timeoutMs: DEFAULT_QMD_TIMEOUT_MS, -}; -const DEFAULT_QMD_MCPORTER: ResolvedQmdMcporterConfig = { - enabled: false, - serverName: "qmd", - startDaemon: true, -}; - -const DEFAULT_QMD_SCOPE: SessionSendPolicyConfig = { - default: "deny", - rules: [ - { - action: "allow", - match: { chatType: "direct" }, - }, - ], -}; - -function sanitizeName(input: string): string { - const lower = normalizeLowercaseStringOrEmpty(input).replace(/[^a-z0-9-]+/g, "-"); - const trimmed = lower.replace(/^-+|-+$/g, ""); - return trimmed || "collection"; -} - -function scopeCollectionBase(base: string, agentId: string): string { - return `${base}-${sanitizeName(agentId)}`; -} - -function canonicalizePathForContainment(rawPath: string): string { - const resolved = path.resolve(rawPath); - let current = resolved; - const suffix: string[] = []; - while (true) { - try { - const canonical = path.normalize(fs.realpathSync.native(current)); - return path.normalize(path.join(canonical, ...suffix)); - } catch { - const parent = path.dirname(current); - if (parent === current) { - return path.normalize(resolved); - } - suffix.unshift(path.basename(current)); - current = parent; - } - } -} - -function isPathInsideRoot(candidatePath: string, rootPath: string): boolean { - return isPathInside( - canonicalizePathForContainment(rootPath), - canonicalizePathForContainment(candidatePath), - ); -} - -function ensureUniqueName(base: string, existing: Set): string { - const name = sanitizeName(base); - if (!existing.has(name)) { - existing.add(name); - return name; - } - let suffix = 2; - while (existing.has(`${name}-${suffix}`)) { - suffix += 1; - } - const unique = `${name}-${suffix}`; - existing.add(unique); - return unique; -} - -function resolvePath(raw: string, workspaceDir: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - throw new Error("path required"); - } - if (trimmed.startsWith("~") || path.isAbsolute(trimmed)) { - return path.normalize(resolveMemoryHostUserPath(trimmed)); - } - return path.normalize(path.resolve(workspaceDir, trimmed)); -} - -function resolveIntervalMs(raw: string | undefined): number { - const value = raw?.trim(); - if (!value) { - return parseDurationMs(DEFAULT_QMD_INTERVAL, { defaultUnit: "m" }); - } - try { - return parseDurationMs(value, { defaultUnit: "m" }); - } catch { - return parseDurationMs(DEFAULT_QMD_INTERVAL, { defaultUnit: "m" }); - } -} - -function resolveEmbedIntervalMs(raw: string | undefined): number { - const value = raw?.trim(); - if (!value) { - return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" }); - } - try { - return parseDurationMs(value, { defaultUnit: "m" }); - } catch { - return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" }); - } -} - -function resolveDebounceMs(raw: number | undefined): number { - if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { - return Math.floor(raw); - } - return DEFAULT_QMD_DEBOUNCE_MS; -} - -function resolvePositiveIntegerConfig(raw: number | undefined, fallback: number): number; -function resolvePositiveIntegerConfig(raw: number | undefined): number | undefined; -function resolvePositiveIntegerConfig( - raw: number | undefined, - fallback?: number, -): number | undefined { - if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { - return fallback; - } - return Math.max(1, Math.floor(raw)); -} - -function resolveStartupDelayMs(raw: number | undefined): number { - if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { - return Math.floor(raw); - } - return DEFAULT_QMD_STARTUP_DELAY_MS; -} - -function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig { - return { - maxResults: resolvePositiveIntegerConfig(raw?.maxResults, DEFAULT_QMD_LIMITS.maxResults), - maxSnippetChars: resolvePositiveIntegerConfig( - raw?.maxSnippetChars, - DEFAULT_QMD_LIMITS.maxSnippetChars, - ), - maxInjectedChars: resolvePositiveIntegerConfig( - raw?.maxInjectedChars, - DEFAULT_QMD_LIMITS.maxInjectedChars, - ), - timeoutMs: resolvePositiveIntegerConfig(raw?.timeoutMs, DEFAULT_QMD_LIMITS.timeoutMs), - }; -} - -function resolveSearchMode(raw?: MemoryQmdConfig["searchMode"]): MemoryQmdSearchMode { - if (raw === "search" || raw === "vsearch" || raw === "query") { - return raw; - } - return DEFAULT_QMD_SEARCH_MODE; -} - -function resolveSearchTool(raw?: MemoryQmdConfig["searchTool"]): string | undefined { - const value = raw?.trim(); - return value ? value : undefined; -} - -function resolveSessionConfig( - cfg: MemoryQmdConfig["sessions"], - workspaceDir: string, - options: { explicit: boolean }, -): ResolvedQmdSessionConfig { - const enabled = Boolean(cfg?.enabled); - const exportDirRaw = cfg?.exportDir?.trim(); - // A configured exportDir belongs to explicit session export. When export is - // only implied by rememberAcrossConversations, honoring it could write - // transcripts into workspace memory/ and leak them into the ordinary memory - // corpus; implied exports always use the default private location. - const exportDir = - options.explicit && exportDirRaw ? resolvePath(exportDirRaw, workspaceDir) : undefined; - const retentionDays = resolvePositiveIntegerConfig(cfg?.retentionDays); - return { - enabled, - readable: enabled && options.explicit, - exportDir, - retentionDays, - }; -} - -function resolveCustomPaths( - rawPaths: MemoryQmdIndexPath[] | undefined, - workspaceDir: string, - existing: Set, - agentId: string, -): ResolvedQmdCollection[] { - if (!rawPaths?.length) { - return []; - } - const collections: ResolvedQmdCollection[] = []; - const seenRoots = new Set(); - rawPaths.forEach((entry, index) => { - const trimmedPath = entry?.path?.trim(); - if (!trimmedPath) { - return; - } - let resolved: string; - let collectionPath: string; - try { - resolved = resolvePath(trimmedPath, workspaceDir); - } catch { - return; - } - collectionPath = resolved; - let pattern = entry.pattern?.trim() || "**/*.md"; - try { - const stat = fs.statSync(resolved); - if (stat.isFile()) { - // When the configured path points directly to a file, normalize into a - // parent-directory collection with an exact-filename pattern, regardless - // of any user-supplied glob (a glob does not apply to a single file). - collectionPath = path.dirname(resolved); - pattern = escapeQmdExactFilePattern(path.basename(resolved)); - } - } catch { - // not a file or can't stat, use as-is - } - const dedupeKey = `${collectionPath}\u0000${pattern}`; - if (seenRoots.has(dedupeKey)) { - return; - } - seenRoots.add(dedupeKey); - const explicitName = entry.name?.trim(); - const baseName = - explicitName && !isPathInsideRoot(collectionPath, workspaceDir) - ? explicitName - : scopeCollectionBase(explicitName || `custom-${index + 1}`, agentId); - const name = ensureUniqueName(baseName, existing); - collections.push({ - name, - path: collectionPath, - pattern, - kind: "custom", - }); - }); - return collections; -} - -function resolveDefaultCollections( - include: boolean, - workspaceDir: string, - existing: Set, - agentId: string, -): ResolvedQmdCollection[] { - if (!include) { - return []; - } - const entries: Array<{ path: string; pattern: string; base: string }> = [ - { path: workspaceDir, pattern: MEMORY_HOST_ROOT_FILENAME, base: "memory-root" }, - { path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" }, - ]; - return entries.map((entry) => ({ - name: ensureUniqueName(scopeCollectionBase(entry.base, agentId), existing), - path: entry.path, - pattern: entry.pattern, - kind: "memory", - })); -} export function resolveMemoryBackendConfig(params: { cfg: OpenClawConfig; agentId: string; }): ResolvedMemoryBackendConfig { - const normalizedAgentId = normalizeAgentId(params.agentId); - const backend = params.cfg.memory?.backend ?? DEFAULT_BACKEND; - const citations = params.cfg.memory?.citations ?? DEFAULT_CITATIONS; - if (backend !== "qmd") { - return { backend: "builtin", citations }; - } - - const workspaceDir = resolveMemoryHostAgentWorkspaceDir(params.cfg, normalizedAgentId); - const qmdCfg = params.cfg.memory?.qmd; - const memorySearch = resolveMemoryHostSearchPathConfig(params.cfg, normalizedAgentId); - const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false; - const nameSet = new Set(); - const agentEntry = params.cfg.agents?.list?.find( - (entry) => normalizeAgentId(entry?.id) === normalizedAgentId, - ); - const mergedExtraPaths = normalizeStringEntries( - [ - ...(params.cfg.memory?.search?.extraPaths ?? []), - ...(agentEntry?.memory?.search?.extraPaths ?? []), - ].filter((value): value is string => typeof value === "string"), - ); - const dedupedExtraPaths = uniqueStrings(mergedExtraPaths); - const searchExtraPaths = dedupedExtraPaths.map( - (pathValue): { path: string; pattern?: string; name?: string } => ({ path: pathValue }), - ); - const mergedExtraCollections = [ - ...(params.cfg.memory?.search?.qmd?.extraCollections ?? []), - ...(agentEntry?.memory?.search?.qmd?.extraCollections ?? []), - ].filter( - (value): value is MemoryQmdIndexPath => - value !== null && typeof value === "object" && typeof value.path === "string", - ); - - // Combine QMD-specific paths with extraPaths and per-agent cross-agent collections. - const allQmdPaths: MemoryQmdIndexPath[] = [ - ...(qmdCfg?.paths ?? []), - ...searchExtraPaths, - ...mergedExtraCollections, - ]; - - const collections = [ - ...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet, normalizedAgentId), - ...resolveCustomPaths(allQmdPaths, workspaceDir, nameSet, normalizedAgentId), - ]; - - const rawCommand = qmdCfg?.command?.trim() || "qmd"; - const command = resolveQmdCommand(rawCommand); - const resolved: ResolvedQmdConfig = { - command, - mcporter: { ...DEFAULT_QMD_MCPORTER }, - searchMode: resolveSearchMode(qmdCfg?.searchMode), - rerank: qmdCfg?.rerank, - searchTool: resolveSearchTool(qmdCfg?.searchTool), - collections, - includeDefaultMemory, - sessions: resolveSessionConfig( - { - ...qmdCfg?.sessions, - enabled: qmdCfg?.sessions?.enabled === true || memorySearch?.rememberAcrossConversations, - }, - workspaceDir, - // Remember-only export is search-only for trusted recall; ordinary - // memory_get reads and sessions options require explicit sessions.enabled. - { explicit: qmdCfg?.sessions?.enabled === true }, - ), - update: { - intervalMs: resolveIntervalMs(undefined), - debounceMs: resolveDebounceMs(undefined), - onBoot: true, - startup: DEFAULT_QMD_STARTUP, - startupDelayMs: resolveStartupDelayMs(undefined), - waitForBootSync: false, - embedIntervalMs: resolveEmbedIntervalMs(undefined), - commandTimeoutMs: DEFAULT_QMD_COMMAND_TIMEOUT_MS, - updateTimeoutMs: DEFAULT_QMD_UPDATE_TIMEOUT_MS, - embedTimeoutMs: DEFAULT_QMD_EMBED_TIMEOUT_MS, - }, - limits: resolveLimits(qmdCfg?.limits), - scope: qmdCfg?.scope ?? DEFAULT_QMD_SCOPE, - }; - return { - backend: "qmd", - citations, - qmd: resolved, + backend: "builtin", + citations: params.cfg.memory?.citations ?? DEFAULT_CITATIONS, }; } diff --git a/packages/memory-host-sdk/src/host/config-utils.test.ts b/packages/memory-host-sdk/src/host/config-utils.test.ts index 0f5d23798232..510cf4a986ab 100644 --- a/packages/memory-host-sdk/src/host/config-utils.test.ts +++ b/packages/memory-host-sdk/src/host/config-utils.test.ts @@ -1,16 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveRememberAcrossConversations, splitShellArgs } from "./config-utils.js"; - -describe("splitShellArgs", () => { - it("preserves quoted command arguments through the focused re-export", () => { - expect(splitShellArgs('qmd query --collection "Project Notes"')).toEqual([ - "qmd", - "query", - "--collection", - "Project Notes", - ]); - }); -}); +import { resolveRememberAcrossConversations } from "./config-utils.js"; describe("resolveRememberAcrossConversations", () => { it("honors keyed per-agent memory overrides", () => { diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index dafc38bb62fe..1c1286c028bc 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -12,83 +12,17 @@ import { uniqueStrings, } from "@openclaw/normalization-core/string-normalization"; export { normalizeAgentId }; -export { splitShellArgs } from "../../../../src/utils/shell-argv.js"; -// Shared OpenClaw config helpers used by memory host, QMD, and agent context code. +// Shared OpenClaw config helpers used by memory host and agent context code. -/** Chat shape used by memory send-policy matching. */ -type ChatType = "direct" | "group" | "channel"; type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; -/** Memory backend selected by user config. */ -export type MemoryBackend = "builtin" | "qmd"; /** Citation injection behavior for memory search results. */ export type MemoryCitationsMode = "auto" | "on" | "off"; -/** QMD command mode used for search calls. */ -export type MemoryQmdSearchMode = "query" | "search" | "vsearch"; -/** QMD startup policy for background indexing. */ -export type MemoryQmdStartupMode = "off" | "idle" | "immediate"; - -/** Action returned by a session send-policy rule. */ -type SessionSendPolicyAction = "allow" | "deny"; -/** Match criteria for one memory send-policy rule. */ -type SessionSendPolicyMatch = { - channel?: string; - chatType?: ChatType; - keyPrefix?: string; - rawKeyPrefix?: string; -}; -/** One ordered rule in session send-policy config. */ -type SessionSendPolicyRule = { - action: SessionSendPolicyAction; - match?: SessionSendPolicyMatch; -}; -/** Memory send-policy config with default action and ordered rules. */ -export type SessionSendPolicyConfig = { - default?: SessionSendPolicyAction; - rules?: SessionSendPolicyRule[]; -}; - -/** QMD collection path plus optional display name and glob pattern. */ -export type MemoryQmdIndexPath = { - path: string; - name?: string; - pattern?: string; -}; - -/** QMD session export config. */ -type MemoryQmdSessionConfig = { - enabled?: boolean; - exportDir?: string; - retentionDays?: number; -}; - -/** Search and injection limits for QMD memory results. */ -type MemoryQmdLimitsConfig = { - maxResults?: number; - maxSnippetChars?: number; - maxInjectedChars?: number; - timeoutMs?: number; -}; - -/** Full QMD-backed memory config. */ -export type MemoryQmdConfig = { - command?: string; - searchMode?: MemoryQmdSearchMode; - rerank?: boolean; - searchTool?: string; - includeDefaultMemory?: boolean; - paths?: MemoryQmdIndexPath[]; - sessions?: MemoryQmdSessionConfig; - limits?: MemoryQmdLimitsConfig; - scope?: SessionSendPolicyConfig; -}; /** Top-level memory config shared by host and runtime callers. */ type MemoryConfig = { - backend?: MemoryBackend; citations?: MemoryCitationsMode; search?: MemorySearchConfig; - qmd?: MemoryQmdConfig; }; /** Per-agent memory search enablement and extra collection paths. */ @@ -96,9 +30,6 @@ type MemorySearchConfig = { enabled?: boolean; rememberAcrossConversations?: boolean; extraPaths?: string[]; - qmd?: { - extraCollections?: MemoryQmdIndexPath[]; - }; }; /** Agent context limits that bound memory file reads. */ @@ -215,7 +146,7 @@ function resolveRequiredHomeDir( } /** Resolve standalone memory-host paths without importing core home-directory policy. */ -export function resolveMemoryHostUserPath( +function resolveMemoryHostUserPath( input: string, env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, diff --git a/packages/memory-host-sdk/src/host/multimodal.ts b/packages/memory-host-sdk/src/host/multimodal.ts index d0036c055123..67a2701bbf74 100644 --- a/packages/memory-host-sdk/src/host/multimodal.ts +++ b/packages/memory-host-sdk/src/host/multimodal.ts @@ -87,7 +87,7 @@ export function buildMemoryMultimodalLabel( return `${MEMORY_MULTIMODAL_SPECS[modality].labelPrefix}: ${normalizedPath}`; } -/** Build a glob that matches an extension case-insensitively for QMD sources. */ +/** Build a glob that matches an extension case-insensitively for indexed sources. */ export function buildCaseInsensitiveExtensionGlob(extension: string): string { const normalized = normalizeLowercaseStringOrEmpty(extension).replace(/^\./, ""); if (!normalized) { diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts index be30d15fa463..f5bb69f99160 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts @@ -11,13 +11,8 @@ export { resolveStateDir, } from "./openclaw-runtime.js"; export type { - MemoryBackend, MemoryCitationsMode, - MemoryQmdConfig, - MemoryQmdIndexPath, - MemoryQmdSearchMode, MemorySearchConfig, OpenClawConfig, SecretInput, - SessionSendPolicyConfig, } from "./openclaw-runtime.js"; diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-io.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-io.ts index c2bada2ccdd0..b19b80b84757 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-io.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-io.ts @@ -9,11 +9,9 @@ export { detectMime, estimateStringChars, installProcessWarningFilter, - materializeWindowsSpawnProgram, redactSensitiveText, resolveGlobalSingleton, resolveUserPath, - resolveWindowsSpawnProgram, runTasksWithConcurrency, shortenHomeInString, shortenHomePath, diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime.ts b/packages/memory-host-sdk/src/host/openclaw-runtime.ts index da0bcdd964f7..8a8578a51e85 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime.ts @@ -86,14 +86,7 @@ export function readTranscriptContentRevisionSync(params: { stats.lastObservedMutationAtMs ?? "", ].join(":"); } -export type { SessionSendPolicyConfig } from "../../../../src/config/types.base.js"; -export type { - MemoryBackend, - MemoryCitationsMode, - MemoryQmdConfig, - MemoryQmdIndexPath, - MemoryQmdSearchMode, -} from "../../../../src/config/types.memory.js"; +export type { MemoryCitationsMode } from "../../../../src/config/types.memory.js"; export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, @@ -170,8 +163,4 @@ export { shortenHomePath, truncateUtf16Safe, } from "../../../../src/utils.js"; -export { - materializeWindowsSpawnProgram, - resolveWindowsSpawnProgram, -} from "../../../../src/plugin-sdk/windows-spawn.js"; export { resolveGlobalSingleton } from "../../../../src/shared/global-singleton.js"; diff --git a/packages/memory-host-sdk/src/host/qmd-process.real.test.ts b/packages/memory-host-sdk/src/host/qmd-process.real.test.ts deleted file mode 100644 index 947f275334c8..000000000000 --- a/packages/memory-host-sdk/src/host/qmd-process.real.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -// Memory Host SDK real-process tests cover QMD process-tree cleanup. -import { spawnSync } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { runCliCommand } from "./qmd-process.js"; - -type ProcessTreePids = { - parent: number; - grandchild: number; -}; - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ESRCH") { - return false; - } - throw error; - } -} - -async function waitUntil(params: { - condition: () => boolean | Promise; - description: string; - timeoutMs?: number; -}): Promise { - const deadline = Date.now() + (params.timeoutMs ?? 5_000); - while (!(await params.condition())) { - if (Date.now() >= deadline) { - throw new Error(`timed out waiting for ${params.description}`); - } - await new Promise((resolve) => { - setTimeout(resolve, 5); - }); - } -} - -async function readProcessTreePids(pidFile: string): Promise { - let pids: ProcessTreePids | undefined; - await waitUntil({ - description: "the process-tree PID file", - condition: async () => { - try { - pids = JSON.parse(await fs.readFile(pidFile, "utf8")) as ProcessTreePids; - return Number.isInteger(pids.parent) && Number.isInteger(pids.grandchild); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return false; - } - throw error; - } - }, - }); - if (!pids) { - throw new Error("process-tree PID file was not populated"); - } - return pids; -} - -function killProcessTree(parentPid: number): void { - if (process.platform === "win32") { - const systemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? "C:\\Windows"; - spawnSync( - path.win32.join(systemRoot, "System32", "taskkill.exe"), - ["/PID", String(parentPid), "/T", "/F"], - { stdio: "ignore", windowsHide: true }, - ); - return; - } - // The production abort path already force-kills the group. Cleanup after a - // failed assertion starts gracefully so it cannot kill a reused group id. - process.kill(-parentPid, "SIGTERM"); -} - -describe("runCliCommand real process UTF-8 framing", () => { - it("preserves a multibyte character split across real stdout and stderr pipes", async () => { - const script = ` - const { setTimeout } = require("node:timers/promises"); - const { finished } = require("node:stream/promises"); - (async () => { - const cat = Buffer.from("猫"); - process.stdout.write(Buffer.concat([Buffer.from('{"value":"'), cat.subarray(0, 1)])); - process.stderr.write(Buffer.concat([Buffer.from("path: "), cat.subarray(0, 1)])); - await setTimeout(50); - process.stdout.write(Buffer.concat([cat.subarray(1), Buffer.from('"}\\n')])); - process.stderr.write(Buffer.concat([cat.subarray(1), Buffer.from(" denied\\n")])); - process.exitCode = 1; - process.stdout.end(); - process.stderr.end(); - await Promise.all([finished(process.stdout), finished(process.stderr)]); - })(); - `; - - await expect( - runCliCommand({ - commandSummary: "real qmd utf8 pipe fixture", - spawnInvocation: { command: process.execPath, argv: ["-e", script] }, - env: process.env, - cwd: process.cwd(), - timeoutMs: 5_000, - maxOutputChars: 10_000, - }), - ).rejects.toMatchObject({ - code: 1, - stdout: '{"value":"猫"}\n', - stderr: "path: 猫 denied\n", - }); - }, 10_000); -}); - -describe("runCliCommand real process lifecycle", () => { - it("kills the command and its descendant when the caller aborts", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-abort-")); - const pidFile = path.join(tempDir, "pids.json"); - const controller = new AbortController(); - const abortError = new Error("memory_search timed out after 15s"); - let pending: ReturnType | undefined; - let pids: ProcessTreePids | undefined; - - const childScript = ` - const { spawn } = require("node:child_process"); - const { renameSync, writeFileSync } = require("node:fs"); - const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - stdio: "ignore", - }); - const pidFile = process.argv[1]; - const temporaryPidFile = pidFile + ".tmp"; - writeFileSync(temporaryPidFile, JSON.stringify({ - parent: process.pid, - grandchild: grandchild.pid, - })); - renameSync(temporaryPidFile, pidFile); - setInterval(() => {}, 1000); - `; - - try { - pending = runCliCommand({ - commandSummary: "real qmd process-tree fixture", - spawnInvocation: { command: process.execPath, argv: ["-e", childScript, pidFile] }, - env: process.env, - cwd: tempDir, - timeoutMs: 60_000, - maxOutputChars: 10_000, - signal: controller.signal, - }); - - pids = await readProcessTreePids(pidFile); - expect(isProcessRunning(pids.parent)).toBe(true); - expect(isProcessRunning(pids.grandchild)).toBe(true); - - controller.abort(abortError); - await expect(pending).rejects.toBe(abortError); - await waitUntil({ - description: "the detached process tree to exit", - condition: () => - pids !== undefined && - !isProcessRunning(pids.parent) && - !isProcessRunning(pids.grandchild), - }); - } finally { - if (!controller.signal.aborted) { - controller.abort(abortError); - } - await pending?.catch(() => undefined); - if (pids?.parent && isProcessRunning(pids.parent)) { - try { - killProcessTree(pids.parent); - } catch { - // Best-effort cleanup when an assertion failed after the child exited. - } - } - await fs.rm(tempDir, { recursive: true, force: true }); - } - }, 15_000); -}); diff --git a/packages/memory-host-sdk/src/host/qmd-process.test.ts b/packages/memory-host-sdk/src/host/qmd-process.test.ts deleted file mode 100644 index fe0e9b146dc6..000000000000 --- a/packages/memory-host-sdk/src/host/qmd-process.test.ts +++ /dev/null @@ -1,640 +0,0 @@ -// Memory Host SDK tests cover qmd process behavior. -import { EventEmitter } from "node:events"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, - type MockInstance, -} from "vitest"; -import { MAX_SAFE_TIMEOUT_DELAY_MS } from "../../../gateway-client/src/timeouts.js"; - -const spawnMock = vi.hoisted(() => vi.fn()); - -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: spawnMock, - }; -}); - -import { - checkQmdBinaryAvailability, - resolveCliSpawnInvocation, - resolveQmdBinaryUnavailableReason, - runCliCommand, - type QmdBinaryAvailability, -} from "./qmd-process.js"; - -function createMockChild(params: { pid?: number } = {}) { - const child = new EventEmitter() as EventEmitter & { - pid?: number; - stdout: EventEmitter & { setEncoding: ReturnType }; - stderr: EventEmitter & { setEncoding: ReturnType }; - exitCode: number | null; - signalCode: NodeJS.Signals | null; - kill: ReturnType; - unref: ReturnType; - closeWith: (code?: number | null, signal?: NodeJS.Signals | null) => void; - }; - child.pid = params.pid; - child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); - child.exitCode = null; - child.signalCode = null; - child.kill = vi.fn(); - child.unref = vi.fn(); - child.closeWith = (code: number | null = 0, signal: NodeJS.Signals | null = null) => { - child.exitCode = code; - child.signalCode = signal; - child.emit("close", code, signal); - }; - return child; -} - -function createClosingTaskkillChild(code = 0) { - const child = createMockChild(); - queueMicrotask(() => child.closeWith(code)); - return child; -} - -let fixtureRoot = ""; -let tempDir = ""; -let platformSpy: MockInstance<() => NodeJS.Platform> | null = null; -let fixtureId = 0; -const originalPath = process.env.PATH; -const originalPathExt = process.env.PATHEXT; -const originalSystemRoot = process.env.SystemRoot; -const originalWindir = process.env.WINDIR; -const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe"); - -function restoreEnvValue(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; -} - -function runQmdCommand(overrides: Partial[0]> = {}) { - return runCliCommand({ - commandSummary: "qmd query test", - spawnInvocation: { command: "qmd", argv: ["query", "test", "--json"] }, - env: process.env, - cwd: tempDir, - maxOutputChars: 10_000, - ...overrides, - }); -} - -beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-win-spawn-")); - platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); -}); - -afterAll(async () => { - platformSpy?.mockRestore(); - platformSpy = null; - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - } -}); - -beforeEach(async () => { - tempDir = path.join(fixtureRoot, `case-${fixtureId++}`); - await fs.mkdir(tempDir, { recursive: true }); - process.env.SystemRoot = "C:\\Windows"; - delete process.env.WINDIR; - spawnMock.mockReset(); - spawnMock.mockImplementation(() => createClosingTaskkillChild()); -}); - -afterEach(() => { - vi.useRealTimers(); - process.env.PATH = originalPath; - process.env.PATHEXT = originalPathExt; - restoreEnvValue("SystemRoot", originalSystemRoot); - restoreEnvValue("WINDIR", originalWindir); - platformSpy?.mockReturnValue("win32"); - spawnMock.mockReset(); - tempDir = ""; -}); - -describe("resolveCliSpawnInvocation", () => { - it("unwraps npm cmd shims to a direct node entrypoint", async () => { - const binDir = path.join(tempDir, "node_modules", ".bin"); - const packageDir = path.join(tempDir, "node_modules", "qmd"); - const scriptPath = path.join(packageDir, "dist", "cli.js"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(binDir, { recursive: true }); - await fs.writeFile(path.join(binDir, "qmd.cmd"), "@echo off\r\n", "utf8"); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "qmd", version: "0.0.0", bin: { qmd: "dist/cli.js" } }), - "utf8", - ); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - - process.env.PATH = `${binDir};${originalPath ?? ""}`; - process.env.PATHEXT = ".CMD;.EXE"; - - const invocation = resolveCliSpawnInvocation({ - command: "qmd", - args: ["query", "hello"], - env: process.env, - packageName: "qmd", - }); - - expect(invocation.command).toBe(process.execPath); - expect(invocation.argv).toEqual([scriptPath, "query", "hello"]); - expect(invocation.shell).not.toBe(true); - expect(invocation.windowsHide).toBe(true); - }); - - it("fails closed when a Windows cmd shim cannot be resolved without shell execution", async () => { - const binDir = path.join(tempDir, "bad-bin"); - await fs.mkdir(binDir, { recursive: true }); - await fs.writeFile(path.join(binDir, "qmd.cmd"), "@echo off\r\nREM no entrypoint\r\n", "utf8"); - - process.env.PATH = `${binDir};${originalPath ?? ""}`; - process.env.PATHEXT = ".CMD;.EXE"; - - expect(() => - resolveCliSpawnInvocation({ - command: "qmd", - args: ["query", "hello"], - env: process.env, - packageName: "qmd", - }), - ).toThrow(/without shell execution/); - }); - - it("keeps bare commands bare when no Windows wrapper exists on PATH", () => { - process.env.PATH = originalPath ?? ""; - process.env.PATHEXT = ".CMD;.EXE"; - - const invocation = resolveCliSpawnInvocation({ - command: "qmd", - args: ["query", "hello"], - env: process.env, - packageName: "qmd", - }); - - expect(invocation.command).toBe("qmd"); - expect(invocation.argv).toEqual(["query", "hello"]); - expect(invocation.shell).not.toBe(true); - }); -}); - -describe("checkQmdBinaryAvailability", () => { - it("keeps legacy unavailable probe results source-compatible", () => { - const legacyUnavailable: QmdBinaryAvailability = { - available: false, - error: "spawn qmd ENOENT", - }; - - expect(resolveQmdBinaryUnavailableReason(legacyUnavailable)).toBe("binary"); - }); - - it("returns available when the qmd process spawns successfully", async () => { - const child = createMockChild({ pid: 12344 }); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => child.emit("spawn")); - return child; - }); - - await expect( - checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }), - ).resolves.toEqual({ available: true }); - expect(spawnMock).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", String(child.pid), "/T"], { - stdio: "ignore", - windowsHide: true, - }); - expect(child.kill).not.toHaveBeenCalled(); - }); - - it("force-kills Windows availability probes when graceful taskkill fails", async () => { - const child = createMockChild({ pid: 12345 }); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => child.emit("spawn")); - return child; - }); - spawnMock.mockImplementationOnce(() => createClosingTaskkillChild(1)); - - await expect( - checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }), - ).resolves.toEqual({ available: true }); - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(3)); - - expect(spawnMock).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T"], { - stdio: "ignore", - windowsHide: true, - }); - expect(spawnMock).toHaveBeenNthCalledWith(3, taskkillPath, ["/PID", "12345", "/T", "/F"], { - stdio: "ignore", - windowsHide: true, - }); - expect(child.kill).not.toHaveBeenCalled(); - }); - - it("keeps the event loop responsive and does not retry a pid after taskkill times out", async () => { - vi.useFakeTimers(); - const child = createMockChild({ pid: 12346 }); - const taskkill = createMockChild(); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => child.emit("spawn")); - return child; - }); - spawnMock.mockReturnValueOnce(taskkill); - - await expect( - checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }), - ).resolves.toEqual({ available: true }); - - const heartbeat = vi.fn(); - setTimeout(heartbeat, 1); - await vi.advanceTimersByTimeAsync(1); - expect(heartbeat).toHaveBeenCalledOnce(); - expect(taskkill.kill).not.toHaveBeenCalled(); - expect(taskkill.unref).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(4_999); - expect(spawnMock).toHaveBeenCalledTimes(2); - expect(spawnMock).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12346", "/T"], { - stdio: "ignore", - windowsHide: true, - }); - expect(taskkill.kill).toHaveBeenCalledWith("SIGKILL"); - expect(taskkill.unref).toHaveBeenCalledOnce(); - expect(child.kill).toHaveBeenCalledWith(); - }); - - it("keeps a timed-out taskkill result when kill synchronously emits an error", async () => { - vi.useFakeTimers(); - const child = createMockChild({ pid: 12347 }); - const taskkill = createMockChild(); - taskkill.kill.mockImplementationOnce(() => { - taskkill.emit("error", new Error("taskkill kill failed")); - return true; - }); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => child.emit("spawn")); - return child; - }); - spawnMock.mockReturnValueOnce(taskkill); - - await expect( - checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }), - ).resolves.toEqual({ available: true }); - - await vi.advanceTimersByTimeAsync(5_000); - expect(spawnMock).toHaveBeenCalledTimes(2); - expect(taskkill.kill).toHaveBeenCalledWith("SIGKILL"); - expect(child.kill).toHaveBeenCalledWith(); - }); - - it("does not retry taskkill after the qmd child exits", async () => { - const child = createMockChild({ pid: 12348 }); - const taskkill = createMockChild(); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => child.emit("spawn")); - return child; - }); - spawnMock.mockReturnValueOnce(taskkill); - - await expect( - checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }), - ).resolves.toEqual({ available: true }); - - child.closeWith(0); - taskkill.closeWith(1); - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(spawnMock).toHaveBeenCalledTimes(2); - expect(child.kill).not.toHaveBeenCalled(); - }); - - it("returns unavailable when the qmd process cannot be spawned", async () => { - const child = createMockChild(); - const err = Object.assign(new Error("spawn qmd ENOENT"), { code: "ENOENT" }); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => child.emit("error", err)); - return child; - }); - - await expect( - checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }), - ).resolves.toEqual({ available: false, reason: "binary", error: "spawn qmd ENOENT" }); - }); - - it("returns an explicit workspace error when cwd is missing", async () => { - const missingDir = path.join(tempDir, "missing-workspace"); - - await expect( - checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: missingDir }), - ).resolves.toEqual({ - available: false, - reason: "workspace-cwd", - error: `workspace directory missing: ${missingDir}`, - }); - expect(spawnMock).not.toHaveBeenCalled(); - }); - - it("does not treat close-before-spawn as a successful availability probe", async () => { - const child = createMockChild(); - const err = Object.assign(new Error("spawn qmd ENOENT"), { code: "ENOENT" }); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => child.closeWith()); - queueMicrotask(() => child.emit("error", err)); - return child; - }); - - await expect( - checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }), - ).resolves.toEqual({ available: false, reason: "binary", error: "spawn qmd ENOENT" }); - }); - - it("caps oversized availability probe timeouts before scheduling", async () => { - vi.useFakeTimers(); - const child = createMockChild(); - const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); - spawnMock.mockReturnValueOnce(child); - - void checkQmdBinaryAvailability({ - command: "qmd", - env: process.env, - cwd: tempDir, - timeoutMs: Number.MAX_SAFE_INTEGER, - }); - - expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS); - }); - it("kills timed-out availability probes by process group on POSIX", async () => { - platformSpy?.mockReturnValue("linux"); - const killProcess = vi.spyOn(process, "kill").mockImplementation(() => true); - const child = createMockChild({ pid: 4321 }); - spawnMock.mockReturnValueOnce(child); - - try { - await expect( - checkQmdBinaryAvailability({ - command: "qmd", - env: process.env, - cwd: tempDir, - timeoutMs: 1, - }), - ).resolves.toEqual({ - available: false, - reason: "binary", - error: "spawn qmd timed out after 1ms", - }); - - expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({ detached: true }); - expect(killProcess).toHaveBeenCalledWith(-4321, "SIGKILL"); - expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); - } finally { - killProcess.mockRestore(); - } - }); -}); - -describe("runCliCommand", () => { - it("keeps stdout and stderr on non-zero exits", async () => { - const child = createMockChild(); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => { - child.stdout.emit("data", '[{"docid":"abc","score":0.93}]'); - child.stderr.emit("data", "ggml-metal-device.m:612"); - child.closeWith(134); - }); - return child; - }); - - try { - await runQmdCommand(); - throw new Error("expected runCliCommand to reject"); - } catch (err) { - expect(err).toBeInstanceOf(Error); - if (!(err instanceof Error)) { - throw err; - } - expect(err.name).toBe("CliCommandError"); - expect(err).toMatchObject({ - code: 134, - signal: null, - stdout: '[{"docid":"abc","score":0.93}]', - stderr: "ggml-metal-device.m:612", - }); - expect(err.message).toContain("qmd query test failed (code 134)"); - expect(child.stdout.setEncoding).toHaveBeenCalledWith("utf8"); - expect(child.stderr.setEncoding).toHaveBeenCalledWith("utf8"); - } - }); - - it("records signal-only command failures", async () => { - const child = createMockChild(); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => { - child.stdout.emit("data", "[]"); - child.closeWith(null, "SIGABRT"); - }); - return child; - }); - - await expect(runQmdCommand()).rejects.toMatchObject({ - code: null, - signal: "SIGABRT", - stdout: "[]", - }); - }); - - it("does not expose truncated output as a recoverable command failure", async () => { - const child = createMockChild(); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => { - child.stdout.emit("data", "too much output"); - child.closeWith(1); - }); - return child; - }); - - await expect(runQmdCommand({ maxOutputChars: 4 })).rejects.toThrow(/produced too much output/); - }); - - it("counts surrogate pairs as one character when capping failed command output", async () => { - const child = createMockChild(); - spawnMock.mockImplementationOnce(() => { - queueMicrotask(() => { - child.stderr.emit("data", "a🙂"); - child.closeWith(1); - }); - return child; - }); - - await expect(runQmdCommand({ maxOutputChars: 2 })).rejects.toThrow(/🙂/); - }); - - it("caps oversized command timeouts before scheduling", async () => { - vi.useFakeTimers(); - const child = createMockChild(); - const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); - spawnMock.mockReturnValueOnce(child); - - void runQmdCommand({ - timeoutMs: Number.MAX_SAFE_INTEGER, - }).catch(() => undefined); - - expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS); - }); - - it("kills aborted cli command process groups on POSIX and rejects with the abort reason", async () => { - platformSpy?.mockReturnValue("linux"); - const killProcess = vi.spyOn(process, "kill").mockImplementation(() => true); - const child = createMockChild({ pid: 7654 }); - spawnMock.mockReturnValueOnce(child); - const controller = new AbortController(); - - try { - const pending = runQmdCommand({ - commandSummary: "qmd query slow", - spawnInvocation: { command: "qmd", argv: ["query", "slow", "--json"] }, - timeoutMs: 60_000, - signal: controller.signal, - }); - - controller.abort(new Error("memory_search timed out after 15s")); - - await expect(pending).rejects.toThrow("memory_search timed out after 15s"); - expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({ detached: true }); - expect(killProcess).toHaveBeenCalledWith(-7654, "SIGKILL"); - expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); - } finally { - killProcess.mockRestore(); - } - }); - - it("rejects immediately without spawning when the signal is already aborted", async () => { - const controller = new AbortController(); - controller.abort(new Error("memory_search timed out after 15s")); - - await expect( - runQmdCommand({ - signal: controller.signal, - }), - ).rejects.toThrow("memory_search timed out after 15s"); - expect(spawnMock).not.toHaveBeenCalled(); - }); - - it("kills timed-out cli command process groups on POSIX", async () => { - platformSpy?.mockReturnValue("linux"); - const killProcess = vi.spyOn(process, "kill").mockImplementation(() => true); - const child = createMockChild({ pid: 8765 }); - spawnMock.mockReturnValueOnce(child); - - try { - const pending = runQmdCommand({ - timeoutMs: 1, - }); - const timeoutAssertion = expect(pending).rejects.toThrow( - "qmd query test timed out after 1ms", - ); - - await timeoutAssertion; - - expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({ detached: true }); - expect(killProcess).toHaveBeenCalledWith(-8765, "SIGKILL"); - expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); - } finally { - killProcess.mockRestore(); - } - }); - - it("force-kills timed-out Windows cli commands with taskkill", async () => { - const child = createMockChild({ pid: 12346 }); - spawnMock.mockReturnValueOnce(child); - - const pending = runQmdCommand({ - timeoutMs: 1, - }); - - await expect(pending).rejects.toThrow("qmd query test timed out after 1ms"); - - expect(spawnMock).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12346", "/T", "/F"], { - stdio: "ignore", - windowsHide: true, - }); - expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); - }); - - it("falls back to direct SIGKILL when Windows taskkill times out", async () => { - vi.useFakeTimers(); - const child = createMockChild({ pid: 12347 }); - const taskkill = createMockChild(); - spawnMock.mockReturnValueOnce(child); - spawnMock.mockReturnValueOnce(taskkill); - - const pending = runQmdCommand({ - timeoutMs: 1, - }); - - const timeoutAssertion = expect(pending).rejects.toThrow("qmd query test timed out after 1ms"); - await vi.advanceTimersByTimeAsync(1); - await timeoutAssertion; - expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); - - await vi.advanceTimersByTimeAsync(5_000); - expect(spawnMock).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12347", "/T", "/F"], { - stdio: "ignore", - windowsHide: true, - }); - expect(taskkill.kill).toHaveBeenCalledWith("SIGKILL"); - expect(taskkill.unref).toHaveBeenCalledOnce(); - expect(child.kill).toHaveBeenCalledWith("SIGKILL"); - }); - - it.each(["stdout", "stderr"] as const)( - "rejects when %s stream emits an error", - async (streamName) => { - const child = createMockChild(); - spawnMock.mockReturnValueOnce(child); - const streamError = new Error(`${streamName} EPIPE`); - - const pending = runQmdCommand(); - - child[streamName].emit("error", streamError); - - await expect(pending).rejects.toMatchObject({ - message: `qmd query test ${streamName} error: ${streamName} EPIPE`, - cause: streamError, - }); - expect(child.kill).toHaveBeenCalledOnce(); - expect(child.kill).toHaveBeenCalledWith("SIGKILL"); - }, - ); - - it("keeps the other stream guarded after stdout error", async () => { - const child = createMockChild(); - spawnMock.mockReturnValueOnce(child); - - const pending = runQmdCommand(); - - child.stdout.emit("error", new Error("stdout EPIPE")); - - expect(() => { - child.stderr.emit("error", new Error("stderr later")); - }).not.toThrow(); - - await expect(pending).rejects.toThrow("qmd query test stdout error: stdout EPIPE"); - expect(child.kill).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/memory-host-sdk/src/host/qmd-process.ts b/packages/memory-host-sdk/src/host/qmd-process.ts deleted file mode 100644 index bd724514360e..000000000000 --- a/packages/memory-host-sdk/src/host/qmd-process.ts +++ /dev/null @@ -1,506 +0,0 @@ -// Memory Host SDK module implements qmd process behavior. -import { spawn } from "node:child_process"; -import { statSync } from "node:fs"; -import path from "node:path"; -import { resolveSafeTimeoutDelayMs } from "../../../gateway-client/src/timeouts.js"; -import { formatErrorMessage } from "./error-utils.js"; -import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram } from "./windows-spawn.js"; - -type CliSpawnInvocation = { - command: string; - argv: string[]; - shell?: boolean; - windowsHide?: boolean; -}; - -type QmdChildProcess = { - pid?: number; - exitCode: number | null; - signalCode: NodeJS.Signals | null; - kill: (signal?: NodeJS.Signals) => boolean; -}; - -type WindowsTaskkillResult = "success" | "failure" | "timed-out"; - -const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows"; -const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000; - -export type QmdBinaryUnavailableReason = "binary" | "workspace-cwd"; - -export type QmdBinaryUnavailable = { - available: false; - /** - * Optional for source compatibility with older plugin SDK callers that - * returned only `{ available: false, error }`. - */ - reason?: QmdBinaryUnavailableReason; - error: string; -}; - -export type QmdBinaryAvailability = { available: true } | QmdBinaryUnavailable; - -export function resolveQmdBinaryUnavailableReason( - result: QmdBinaryUnavailable, -): QmdBinaryUnavailableReason { - return result.reason ?? "binary"; -} - -export function resolveCliSpawnInvocation(params: { - command: string; - args: string[]; - env: NodeJS.ProcessEnv; - packageName: string; -}): CliSpawnInvocation { - const program = resolveWindowsSpawnProgram({ - command: params.command, - platform: process.platform, - env: params.env, - execPath: process.execPath, - packageName: params.packageName, - allowShellFallback: false, - }); - return materializeWindowsSpawnProgram(program, params.args); -} - -export async function checkQmdBinaryAvailability(params: { - command: string; - env: NodeJS.ProcessEnv; - cwd?: string; - timeoutMs?: number; -}): Promise { - let spawnInvocation: CliSpawnInvocation; - try { - spawnInvocation = resolveCliSpawnInvocation({ - command: params.command, - args: [], - env: params.env, - packageName: "qmd", - }); - } catch (err) { - return { available: false, reason: "binary", error: formatErrorMessage(err) }; - } - - const cwd = params.cwd ?? process.cwd(); - const cwdError = validateQmdProbeCwd(cwd); - if (cwdError) { - return cwdError; - } - - return await new Promise((resolve) => { - let settled = false; - let didSpawn = false; - const finish = (result: QmdBinaryAvailability) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - const child = spawn(spawnInvocation.command, spawnInvocation.argv, { - env: params.env, - cwd, - shell: spawnInvocation.shell, - windowsHide: spawnInvocation.windowsHide, - stdio: "ignore", - detached: shouldUseQmdProcessGroup(), - }); - const timeoutMs = resolveSafeTimeoutDelayMs(params.timeoutMs ?? 2_000, { minMs: 0 }); - const timer = setTimeout(() => { - void signalQmdProcessTree(child, "SIGKILL"); - finish({ - available: false, - reason: "binary", - error: `spawn ${params.command} timed out after ${timeoutMs}ms`, - }); - }, timeoutMs); - - child.once("error", (err) => { - finish({ available: false, reason: "binary", error: formatErrorMessage(err) }); - }); - child.once("spawn", () => { - didSpawn = true; - void signalQmdProcessTree(child); - finish({ available: true }); - }); - child.once("close", () => { - if (!didSpawn) { - return; - } - finish({ available: true }); - }); - }); -} - -function validateQmdProbeCwd(cwd: string): QmdBinaryAvailability | null { - try { - const stat = statSync(cwd); - if (!stat.isDirectory()) { - return { - available: false, - reason: "workspace-cwd", - error: `workspace directory is not a directory: ${cwd}`, - }; - } - return null; - } catch (err) { - if (typeof err === "object" && err && "code" in err && err.code === "ENOENT") { - return { - available: false, - reason: "workspace-cwd", - error: `workspace directory missing: ${cwd}`, - }; - } - return { - available: false, - reason: "workspace-cwd", - error: `workspace directory unavailable: ${cwd} (${formatErrorMessage(err)})`, - }; - } -} - -/** - * Normalize an aborted signal into the error used to reject a killed command. - * Prefers the caller-supplied abort reason (so a deadline message survives) and - * falls back to a stable per-command abort error. - */ -function abortReason(signal: AbortSignal | undefined, commandSummary: string): Error { - const reason = signal?.reason; - if (reason instanceof Error) { - return reason; - } - if (typeof reason === "string" && reason.length > 0) { - return new Error(reason); - } - return new Error(`${commandSummary} aborted`); -} - -export async function runCliCommand(params: { - commandSummary: string; - spawnInvocation: CliSpawnInvocation; - env: NodeJS.ProcessEnv; - cwd: string; - timeoutMs?: number; - maxOutputChars: number; - discardStdout?: boolean; - /** - * Caller-owned cancellation. When the signal aborts, the spawned child is - * killed immediately and the call rejects, so a caller that already stopped - * waiting (for example after its own deadline) does not leave an orphaned - * process running for the full command timeout. - */ - signal?: AbortSignal; -}): Promise<{ stdout: string; stderr: string }> { - return await new Promise((resolve, reject) => { - const { signal } = params; - if (signal?.aborted) { - reject(abortReason(signal, params.commandSummary)); - return; - } - const child = spawn(params.spawnInvocation.command, params.spawnInvocation.argv, { - env: params.env, - cwd: params.cwd, - shell: params.spawnInvocation.shell, - windowsHide: params.spawnInvocation.windowsHide, - detached: shouldUseQmdProcessGroup(), - }); - let stdout = ""; - let stderr = ""; - let stdoutTruncated = false; - let stderrTruncated = false; - let settled = false; - const discardStdout = params.discardStdout === true; - // Let the streams carry partial UTF-8 sequences across pipe chunks before - // qmd JSON, paths, or diagnostics reach the character-based output cap. - if (!discardStdout) { - child.stdout.setEncoding("utf8"); - } - child.stderr.setEncoding("utf8"); - const timeoutMs = - params.timeoutMs === undefined ? undefined : resolveSafeTimeoutDelayMs(params.timeoutMs); - const timer = timeoutMs - ? setTimeout(() => { - void signalQmdProcessTree(child, "SIGKILL"); - settle(() => - reject(new Error(`${params.commandSummary} timed out after ${timeoutMs}ms`)), - ); - }, timeoutMs) - : null; - const onAbort = () => { - void signalQmdProcessTree(child, "SIGKILL"); - settle(() => reject(abortReason(signal, params.commandSummary))); - }; - function settle(run: () => void): void { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - signal?.removeEventListener("abort", onAbort); - run(); - } - signal?.addEventListener("abort", onAbort, { once: true }); - child.stdout.on("data", (data: string) => { - if (discardStdout) { - return; - } - const next = appendOutputWithCap(stdout, data, params.maxOutputChars); - stdout = next.text; - stdoutTruncated = stdoutTruncated || next.truncated; - }); - child.stderr.on("data", (data: string) => { - const next = appendOutputWithCap(stderr, data, params.maxOutputChars); - stderr = next.text; - stderrTruncated = stderrTruncated || next.truncated; - }); - - // Guard stdout/stderr against stream errors (e.g. EPIPE when the - // child exits before all pipe data is consumed). Without listeners, - // Node.js throws an uncaught exception that crashes the process. - for (const streamName of ["stdout", "stderr"] as const) { - child[streamName].on("error", (error: Error) => { - if (settled) { - return; - } - void signalQmdProcessTree(child, "SIGKILL"); - settle(() => - reject( - new Error(`${params.commandSummary} ${streamName} error: ${error.message}`, { - cause: error, - }), - ), - ); - }); - } - - child.on("error", (err) => { - if (timer) { - clearTimeout(timer); - } - settle(() => reject(err)); - }); - child.on("close", (code, closeSignal) => { - if (timer) { - clearTimeout(timer); - } - settle(() => { - if (!discardStdout && (stdoutTruncated || stderrTruncated)) { - reject( - new Error( - `${params.commandSummary} produced too much output (limit ${params.maxOutputChars} chars)`, - ), - ); - return; - } - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject( - new CliCommandError({ - commandSummary: params.commandSummary, - code, - signal: closeSignal ?? null, - stdout, - stderr, - }), - ); - } - }); - }); - }); -} - -function shouldUseQmdProcessGroup(): boolean { - return process.platform !== "win32"; -} - -function getEnvValueCaseInsensitive( - env: Record, - expectedKey: string, -): string | undefined { - const direct = env[expectedKey]; - if (direct !== undefined) { - return direct; - } - const expected = expectedKey.toUpperCase(); - const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected); - return actualKey ? env[actualKey] : undefined; -} - -function normalizeWindowsSystemRoot(raw: string | undefined): string | null { - const trimmed = raw?.trim(); - if ( - !trimmed || - trimmed.includes("\0") || - trimmed.includes("\r") || - trimmed.includes("\n") || - trimmed.includes(";") - ) { - return null; - } - const normalized = path.win32.normalize(trimmed); - if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) { - return null; - } - const parsed = path.win32.parse(normalized); - if (!/^[A-Za-z]:\\$/.test(parsed.root) || normalized.length <= parsed.root.length) { - return null; - } - return normalized.replace(/[\\/]+$/, ""); -} - -function resolveWindowsTaskkillPath(env: Record = process.env): string { - const systemRoot = - normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ?? - normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ?? - DEFAULT_WINDOWS_SYSTEM_ROOT; - return path.win32.join(systemRoot, "System32", "taskkill.exe"); -} - -function runWindowsTaskkill(params: { - taskkillPath: string; - args: string[]; -}): Promise { - return new Promise((resolve) => { - let taskkill: ReturnType; - try { - taskkill = spawn(params.taskkillPath, params.args, { - stdio: "ignore", - windowsHide: true, - }); - } catch { - resolve("failure"); - return; - } - - let settled = false; - const finish = (result: WindowsTaskkillResult) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve(result); - }; - taskkill.once("error", () => finish("failure")); - taskkill.once("close", (code) => finish(code === 0 ? "success" : "failure")); - const timeout = setTimeout(() => { - // Fix the timeout result before kill() can synchronously emit an error. - finish("timed-out"); - try { - taskkill.kill("SIGKILL"); - } catch { - // The retained qmd child handle remains the final fallback below. - } - taskkill.unref(); - // Do not wait for a stalled system utility after its deadline. Its late - // events stay guarded by finish(), while qmd cleanup uses the child handle. - }, WINDOWS_TASKKILL_TIMEOUT_MS); - timeout.unref?.(); - }); -} - -function isQmdChildAlive(child: QmdChildProcess): boolean { - return child.exitCode === null && child.signalCode === null; -} - -async function signalQmdProcessTree( - child: QmdChildProcess, - signal?: NodeJS.Signals, -): Promise { - if (shouldUseQmdProcessGroup() && typeof child.pid === "number") { - try { - if (signal === undefined) { - process.kill(-child.pid); - } else { - process.kill(-child.pid, signal); - } - return; - } catch { - // Fall back to the direct child if the process group already disappeared. - } - } - if (!shouldUseQmdProcessGroup() && typeof child.pid === "number" && isQmdChildAlive(child)) { - const taskkillPath = resolveWindowsTaskkillPath(); - const args = ["/PID", String(child.pid), "/T"]; - if (signal === "SIGKILL") { - args.push("/F"); - } - const result = await runWindowsTaskkill({ taskkillPath, args }); - if (result === "success") { - return; - } - // taskkill /T requires a live root PID. Retrying an exited, reusable PID - // can target an unrelated process tree. - if (signal !== "SIGKILL" && result !== "timed-out" && isQmdChildAlive(child)) { - const forceResult = await runWindowsTaskkill({ taskkillPath, args: [...args, "/F"] }); - if (forceResult === "success") { - return; - } - } - } - if (!isQmdChildAlive(child)) { - return; - } - try { - if (signal === undefined) { - child.kill(); - } else { - child.kill(signal); - } - } catch { - // The child may already have exited while asynchronous tree cleanup ran. - } -} - -class CliCommandError extends Error { - readonly code: number | null; - readonly signal: NodeJS.Signals | null; - readonly stdout: string; - readonly stderr: string; - - constructor(params: { - commandSummary: string; - code: number | null; - signal: NodeJS.Signals | null; - stdout: string; - stderr: string; - }) { - super(formatCliCommandFailureMessage(params)); - this.name = "CliCommandError"; - this.code = params.code; - this.signal = params.signal; - this.stdout = params.stdout; - this.stderr = params.stderr; - } -} - -function formatCliCommandFailureMessage(params: { - commandSummary: string; - code: number | null; - signal: NodeJS.Signals | null; - stdout: string; - stderr: string; -}): string { - const exit = - params.code === null ? `signal ${params.signal ?? "unknown"}` : `code ${String(params.code)}`; - return `${params.commandSummary} failed (${exit}): ${params.stderr || params.stdout}`; -} - -function appendOutputWithCap( - current: string, - chunk: string, - maxChars: number, -): { text: string; truncated: boolean } { - const appended = current + chunk; - const chars = Array.from(appended); - if (chars.length <= maxChars) { - return { text: appended, truncated: false }; - } - return { text: chars.slice(-maxChars).join(""), truncated: true }; -} diff --git a/packages/memory-host-sdk/src/host/qmd-query-parser.test.ts b/packages/memory-host-sdk/src/host/qmd-query-parser.test.ts deleted file mode 100644 index 9c8e55de75f8..000000000000 --- a/packages/memory-host-sdk/src/host/qmd-query-parser.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Memory Host SDK tests cover qmd query parser behavior. -import { describe, expect, it, vi } from "vitest"; -import { parseQmdQueryJson } from "./qmd-query-parser.js"; - -describe("parseQmdQueryJson", () => { - it("parses clean qmd JSON output", () => { - const results = parseQmdQueryJson('[{"docid":"abc","score":1,"snippet":"@@ -1,1\\none"}]', ""); - expect(results).toEqual([ - { - docid: "abc", - score: 1, - snippet: "@@ -1,1\none", - }, - ]); - }); - - it.each([ - { name: "null", stdout: "[null]" }, - { name: "string", stdout: '["noise"]' }, - { name: "number", stdout: "[1]" }, - { name: "boolean", stdout: "[true]" }, - { name: "nested array", stdout: '[[{"docid":"abc"}]]' }, - { name: "mixed object and primitive", stdout: '[{"docid":"abc"},null]' }, - ])("rejects a $name QMD result before it reaches memory consumers", ({ stdout }) => { - expect(() => parseQmdQueryJson(stdout, "")).toThrow(/qmd query returned invalid JSON/i); - }); - - it("extracts embedded result arrays from noisy stdout", () => { - const results = parseQmdQueryJson( - `initializing -{"payload":"ok"} -[{"docid":"abc","score":0.5}] -complete`, - "", - ); - expect(results).toEqual([{ docid: "abc", score: 0.5 }]); - }); - - it.each([ - { - name: "bracketed log prefix", - stdout: '[qmd] initializing search\n[{"docid":"abc","score":0.5}]', - }, - { - name: "bracketed log with an empty array", - stdout: '[qmd] filters=[]\n[{"docid":"abc","score":0.5}]', - }, - { - name: "bracketed log with result-shaped data", - stdout: '[qmd] previous=[{"docid":"stale","score":1}]\n[{"docid":"abc","score":0.5}]', - }, - { - name: "leading no-results marker", - stdout: 'warning: no results found\n[{"docid":"abc","score":0.5}]', - }, - { - name: "trailing no-results marker", - stdout: '[{"docid":"abc","score":0.5}]\nwarning: no results found', - }, - { - name: "bracketed no-results marker", - stdout: '[qmd] warning: no results found\n[{"docid":"abc","score":0.5}]', - }, - ])("preserves query results after a $name", ({ stdout }) => { - expect(parseQmdQueryJson(stdout, "")).toEqual([{ docid: "abc", score: 0.5 }]); - }); - - it("preserves explicit qmd line metadata when present", () => { - const results = parseQmdQueryJson( - '[{"docid":"abc","score":0.5,"start_line":4,"end_line":6,"snippet":"@@ -10,1\\nignored"}]', - "", - ); - expect(results).toEqual([ - { - docid: "abc", - score: 0.5, - snippet: "@@ -10,1\nignored", - startLine: 4, - endLine: 6, - }, - ]); - }); - - it("drops non-integer qmd line metadata", () => { - const results = parseQmdQueryJson( - `[{"docid":"abc","start_line":4.5,"end_line":${Number.MAX_SAFE_INTEGER + 1}}]`, - "", - ); - - expect(results).toEqual([{ docid: "abc" }]); - }); - - it("treats plain-text no-results from stderr as an empty result set", () => { - const results = parseQmdQueryJson("", "No results found\n"); - expect(results).toStrictEqual([]); - }); - - it("treats prefixed no-results marker output as an empty result set", () => { - expect(parseQmdQueryJson("warning: no results found", "")).toStrictEqual([]); - expect(parseQmdQueryJson("", "[qmd] warning: no results found\n")).toStrictEqual([]); - expect( - parseQmdQueryJson("[qmd] initializing search\nwarning: no results found", ""), - ).toStrictEqual([]); - }); - - it("keeps bounded stderr context UTF-16 safe", () => { - const prefix = "a".repeat(116); - const stderr = `${prefix}😀${"b".repeat(120)}`; - - expect(() => parseQmdQueryJson("", stderr)).toThrow( - `qmd query returned invalid JSON: stdout empty (stderr: ${prefix}...)`, - ); - }); - - it("does not treat arbitrary non-marker text as no-results output", () => { - expect(() => - parseQmdQueryJson("warning: search completed; no results found for this query", ""), - ).toThrow(/qmd query returned invalid JSON/i); - }); - - it("throws when stdout cannot be interpreted as qmd JSON", () => { - expect(() => parseQmdQueryJson("this is not json", "")).toThrow( - /qmd query returned invalid JSON/i, - ); - }); - - it("rejects malformed bracket-heavy stdout without repeated rescanning", () => { - expect(() => parseQmdQueryJson("[".repeat(4_096), "")).toThrow( - /qmd query returned invalid JSON/i, - ); - }); - - it("routes invalid-output diagnostics through console capture", () => { - vi.stubEnv("VITEST", ""); - vi.stubEnv("NODE_ENV", "production"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - - try { - expect(() => parseQmdQueryJson("this is not json", "")).toThrow( - /qmd query returned invalid JSON/i, - ); - expect(warn).toHaveBeenCalledWith( - "qmd query returned invalid JSON: qmd query JSON response was not an array", - ); - } finally { - warn.mockRestore(); - vi.unstubAllEnvs(); - } - }); -}); diff --git a/packages/memory-host-sdk/src/host/qmd-query-parser.ts b/packages/memory-host-sdk/src/host/qmd-query-parser.ts deleted file mode 100644 index cd702c5653ba..000000000000 --- a/packages/memory-host-sdk/src/host/qmd-query-parser.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Memory Host SDK module implements qmd query parser behavior. -import { asPositiveSafeInteger as parseQmdLineNumber } from "@openclaw/normalization-core/number-coercion"; -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { formatErrorMessage } from "./error-utils.js"; - -// Parser for qmd query JSON output, including noisy CLI wrapper output. - -/** Normalized qmd query result consumed by memory search. */ -export type QmdQueryResult = { - docid?: string; - score?: number; - collection?: string; - file?: string; - snippet?: string; - body?: string; - startLine?: number; - endLine?: number; -}; - -/** Parse qmd stdout/stderr into normalized results, accepting known no-result markers. */ -export function parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResult[] { - const trimmedStdout = stdout.trim(); - const trimmedStderr = stderr.trim(); - const stderrIsMarker = trimmedStderr.length > 0 && isQmdNoResultsOutput(trimmedStderr); - if (!trimmedStdout && stderrIsMarker) { - return []; - } - if (!trimmedStdout) { - const context = trimmedStderr ? ` (stderr: ${summarizeQmdStderr(trimmedStderr)})` : ""; - const message = `stdout empty${context}`; - warnQmdQueryParseError(message); - throw new Error(`qmd query returned invalid JSON: ${message}`); - } - try { - const parsed = parseQmdQueryResultArray(trimmedStdout); - if (parsed !== null) { - return parsed; - } - const noisyPayload = extractFirstJsonArray(trimmedStdout); - if (!noisyPayload) { - if (isQmdNoResultsOutput(trimmedStdout)) { - return []; - } - throw new Error("qmd query JSON response was not an array"); - } - const fallback = parseQmdQueryResultArray(noisyPayload); - if (fallback !== null) { - return fallback; - } - throw new Error("qmd query JSON response was not an array"); - } catch (err) { - const message = formatErrorMessage(err); - warnQmdQueryParseError(message); - throw new Error(`qmd query returned invalid JSON: ${message}`, { cause: err }); - } -} - -/** Emit parse warnings outside tests so broken qmd output is visible to operators. */ -function warnQmdQueryParseError(message: string): void { - if (process.env.VITEST || process.env.NODE_ENV === "test") { - return; - } - console.warn(`qmd query returned invalid JSON: ${message}`); -} - -/** Detect qmd no-result marker output on stdout or stderr. */ -function isQmdNoResultsOutput(raw: string): boolean { - const lines = raw - .split(/\r?\n/) - .map((line) => normalizeLowercaseStringOrEmpty(line).replace(/\s+/g, " ")) - .filter((line) => line.length > 0); - return lines.some((line) => isQmdNoResultsLine(line)); -} - -/** Match qmd no-result lines with optional warning/info prefixes. */ -function isQmdNoResultsLine(line: string): boolean { - if (line === "no results found" || line === "no results found.") { - return true; - } - return /^(?:\[[^\]]+\]\s*)?(?:(?:warn(?:ing)?|info|error|qmd)\s*:\s*)+no results found\.?$/.test( - line, - ); -} - -/** Bound stderr context included in parse errors. */ -function summarizeQmdStderr(raw: string): string { - return raw.length <= 120 ? raw : `${truncateUtf16Safe(raw, 117)}...`; -} - -/** Parse and normalize a strict qmd JSON array payload. */ -function parseQmdQueryResultArray(raw: string): QmdQueryResult[] | null { - try { - const parsed = JSON.parse(raw) as unknown; - if ( - !Array.isArray(parsed) || - parsed.some((item) => typeof item !== "object" || item === null || Array.isArray(item)) - ) { - return null; - } - return parsed.map((item) => { - const record = item as Record; - const docid = typeof record.docid === "string" ? record.docid : undefined; - const score = - typeof record.score === "number" && Number.isFinite(record.score) - ? record.score - : undefined; - const collection = typeof record.collection === "string" ? record.collection : undefined; - const file = typeof record.file === "string" ? record.file : undefined; - const snippet = typeof record.snippet === "string" ? record.snippet : undefined; - const body = typeof record.body === "string" ? record.body : undefined; - return { - docid, - score, - collection, - file, - snippet, - body, - startLine: parseQmdLineNumber(record.start_line ?? record.startLine), - endLine: parseQmdLineNumber(record.end_line ?? record.endLine), - } as QmdQueryResult; - }); - } catch { - return null; - } -} - -/** Extract the first complete, standalone JSON result array from noisy stdout. */ -function extractFirstJsonArray(raw: string): string | null { - let start = -1; - let depth = 0; - let inString = false; - let escaped = false; - let atLineStart = true; - - for (let i = 0; i < raw.length; i += 1) { - const char = raw[i]; - if (char === undefined) { - break; - } - - if (start < 0) { - if (char === "\n") { - atLineStart = true; - continue; - } - if (atLineStart && (char === " " || char === "\t" || char === "\r")) { - continue; - } - // QMD emits result arrays on their own line; log fields can contain arrays too. - if (!atLineStart || char !== "[") { - atLineStart = false; - continue; - } - start = i; - depth = 1; - atLineStart = false; - continue; - } - - if (inString) { - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === '"') { - inString = false; - } - continue; - } - if (char === '"') { - inString = true; - continue; - } - if (char === "[") { - depth += 1; - continue; - } - if (char !== "]" || --depth !== 0) { - continue; - } - - const candidate = raw.slice(start, i + 1); - if (parseQmdQueryResultArray(candidate) !== null) { - return candidate; - } - start = -1; - } - return null; -} diff --git a/packages/memory-host-sdk/src/host/qmd-scope.test.ts b/packages/memory-host-sdk/src/host/qmd-scope.test.ts deleted file mode 100644 index 1b8e70b8fa7a..000000000000 --- a/packages/memory-host-sdk/src/host/qmd-scope.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Memory Host SDK tests cover qmd scope behavior. -import { describe, expect, it } from "vitest"; -import type { ResolvedQmdConfig } from "./backend-config.js"; -import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js"; - -describe("qmd scope", () => { - const allowDirect: ResolvedQmdConfig["scope"] = { - default: "deny", - rules: [{ action: "allow", match: { chatType: "direct" } }], - }; - - it("derives channel and chat type from canonical keys once", () => { - expect(deriveQmdScopeChannel("Workspace:group:123")).toBe("workspace"); - expect(deriveQmdScopeChatType("Workspace:group:123")).toBe("group"); - }); - - it("derives channel and chat type from stored key suffixes", () => { - expect(deriveQmdScopeChannel("agent:agent-1:workspace:channel:chan-123")).toBe("workspace"); - expect(deriveQmdScopeChatType("agent:agent-1:workspace:channel:chan-123")).toBe("channel"); - }); - - it("treats parsed keys with no chat prefix as direct", () => { - expect(deriveQmdScopeChannel("agent:agent-1:peer-direct")).toBeUndefined(); - expect(deriveQmdScopeChatType("agent:agent-1:peer-direct")).toBe("direct"); - expect(isQmdScopeAllowed(allowDirect, "agent:agent-1:peer-direct")).toBe(true); - expect(isQmdScopeAllowed(allowDirect, "agent:agent-1:peer:group:abc")).toBe(false); - }); - - it("applies scoped key-prefix checks against normalized key", () => { - const scope: ResolvedQmdConfig["scope"] = { - default: "deny", - rules: [{ action: "allow", match: { keyPrefix: "workspace:" } }], - }; - expect(isQmdScopeAllowed(scope, "agent:agent-1:workspace:group:123")).toBe(true); - expect(isQmdScopeAllowed(scope, "agent:agent-1:other:group:123")).toBe(false); - }); - - it("supports rawKeyPrefix matches for agent-prefixed keys", () => { - const scope: ResolvedQmdConfig["scope"] = { - default: "allow", - rules: [{ action: "deny", match: { rawKeyPrefix: "agent:main:guildchat:" } }], - }; - expect(isQmdScopeAllowed(scope, "agent:main:guildchat:channel:c123")).toBe(false); - expect(isQmdScopeAllowed(scope, "agent:main:workspace:channel:c123")).toBe(true); - }); - - it("keeps legacy agent-prefixed keyPrefix rules working", () => { - const scope: ResolvedQmdConfig["scope"] = { - default: "allow", - rules: [{ action: "deny", match: { keyPrefix: "agent:main:guildchat:" } }], - }; - expect(isQmdScopeAllowed(scope, "agent:main:guildchat:channel:c123")).toBe(false); - expect(isQmdScopeAllowed(scope, "agent:main:workspace:channel:c123")).toBe(true); - }); -}); diff --git a/packages/memory-host-sdk/src/host/qmd-scope.ts b/packages/memory-host-sdk/src/host/qmd-scope.ts deleted file mode 100644 index 43a3570f6038..000000000000 --- a/packages/memory-host-sdk/src/host/qmd-scope.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Memory Host SDK module implements qmd scope behavior. -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, -} from "@openclaw/normalization-core/string-coerce"; -import type { ResolvedQmdConfig } from "./backend-config.js"; - -type ParsedQmdSessionScope = { - channel?: string; - chatType?: "channel" | "group" | "direct"; - normalizedKey?: string; -}; - -export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey?: string): boolean { - if (!scope) { - return true; - } - const parsed = parseQmdSessionScope(sessionKey); - const channel = parsed.channel; - const chatType = parsed.chatType; - const normalizedKey = parsed.normalizedKey ?? ""; - const rawKey = normalizeLowercaseStringOrEmpty(sessionKey ?? ""); - for (const rule of scope.rules ?? []) { - if (!rule) { - continue; - } - const match = rule.match ?? {}; - if (match.channel && match.channel !== channel) { - continue; - } - if (match.chatType && match.chatType !== chatType) { - continue; - } - const normalizedPrefix = normalizeOptionalLowercaseString(match.keyPrefix) || undefined; - const rawPrefix = normalizeOptionalLowercaseString(match.rawKeyPrefix) || undefined; - - if (rawPrefix && !rawKey.startsWith(rawPrefix)) { - continue; - } - if (normalizedPrefix) { - // Backward compat: older configs used `keyPrefix: "agent::..."` to match raw keys. - const isLegacyRaw = normalizedPrefix.startsWith("agent:"); - if (isLegacyRaw) { - if (!rawKey.startsWith(normalizedPrefix)) { - continue; - } - } else if (!normalizedKey.startsWith(normalizedPrefix)) { - continue; - } - } - return rule.action === "allow"; - } - const fallback = scope.default ?? "allow"; - return fallback === "allow"; -} - -export function deriveQmdScopeChannel(key?: string): string | undefined { - return parseQmdSessionScope(key).channel; -} - -export function deriveQmdScopeChatType(key?: string): "channel" | "group" | "direct" | undefined { - return parseQmdSessionScope(key).chatType; -} - -function parseQmdSessionScope(key?: string): ParsedQmdSessionScope { - const normalized = normalizeQmdSessionKey(key); - if (!normalized) { - return {}; - } - const parts = normalized.split(":").filter(Boolean); - let chatType: ParsedQmdSessionScope["chatType"]; - if ( - parts.length >= 2 && - (parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm") - ) { - if (parts.includes("group")) { - chatType = "group"; - } else if (parts.includes("channel")) { - chatType = "channel"; - } - return { - normalizedKey: normalized, - channel: normalizeOptionalLowercaseString(parts[0]), - chatType: chatType ?? "direct", - }; - } - if (normalized.includes(":group:")) { - return { normalizedKey: normalized, chatType: "group" }; - } - if (normalized.includes(":channel:")) { - return { normalizedKey: normalized, chatType: "channel" }; - } - return { normalizedKey: normalized, chatType: "direct" }; -} - -function normalizeQmdSessionKey(key?: string): string | undefined { - if (!key) { - return undefined; - } - const trimmed = key.trim(); - if (!trimmed) { - return undefined; - } - const parsed = parseAgentSessionKey(trimmed); - const normalized = normalizeLowercaseStringOrEmpty(parsed?.rest ?? trimmed); - if (normalized.startsWith("subagent:")) { - return undefined; - } - return normalized; -} - -function parseAgentSessionKey(sessionKey: string | undefined | null): { rest: string } | null { - const raw = normalizeOptionalLowercaseString(sessionKey); - if (!raw) { - return null; - } - const parts = raw.split(":").filter(Boolean); - if (parts.length < 3 || parts[0] !== "agent") { - return null; - } - const rest = parts.slice(2).join(":"); - return rest ? { rest } : null; -} diff --git a/packages/memory-host-sdk/src/host/session-transcript-corpus.ts b/packages/memory-host-sdk/src/host/session-transcript-corpus.ts index de4455db86f9..18dd67597d98 100644 --- a/packages/memory-host-sdk/src/host/session-transcript-corpus.ts +++ b/packages/memory-host-sdk/src/host/session-transcript-corpus.ts @@ -1,4 +1,4 @@ -// Accessor-backed transcript corpus discovery for memory/QMD session indexing. +// Accessor-backed transcript corpus discovery for memory session indexing. import fsSync from "node:fs"; import path from "node:path"; import { normalizeAgentId } from "./config-utils.js"; @@ -440,7 +440,7 @@ export function listSessionTranscriptCorpusEntriesForAgentSync( } /** - * Lists transcript corpus entries for QMD/memory indexing. + * Lists transcript corpus entries for memory indexing. * * Active sessions come from the session accessor seam; retained reset/delete * transcript artifacts remain explicit file artifacts until core owns archive diff --git a/packages/memory-host-sdk/src/host/types.ts b/packages/memory-host-sdk/src/host/types.ts index b7fe08ade570..2cb04b8cd4f8 100644 --- a/packages/memory-host-sdk/src/host/types.ts +++ b/packages/memory-host-sdk/src/host/types.ts @@ -1,5 +1,4 @@ -// Public memory host contracts shared by runtime, QMD, builtin search, and -// package consumers. +// Public memory host contracts shared by runtime, builtin search, and package consumers. export type MemorySource = "memory" | "sessions"; export type MemoryOriginClass = "owner" | "agent" | "untrusted" | "system"; @@ -69,36 +68,8 @@ export type MemorySyncParams = { progress?: (update: MemorySyncProgressUpdate) => void; }; -/** @public Runtime backend/mode diagnostics for memory search. */ -export type MemorySearchRuntimeQmdCollectionValidationDebug = { - cacheState?: "hit" | "miss" | "write" | "bypass-force" | "error"; - elapsedMs: number; - collectionCount: number; - listCalls?: number; - showCalls?: number; -}; - -/** @public */ export type MemorySearchRuntimeQmdMultiCollectionProbeDebug = { - cacheState?: "hit" | "miss" | "write" | "error"; - elapsedMs: number; - supported: boolean; -}; - -/** @public */ export type MemorySearchRuntimeQmdSearchPlanDebug = { - command?: "query" | "search" | "vsearch"; - collectionCount?: number; - groupCount?: number; - sources?: MemorySource[]; -}; - -/** @public */ export type MemorySearchRuntimeQmdDebug = { - collectionValidation?: MemorySearchRuntimeQmdCollectionValidationDebug; - multiCollectionProbe?: MemorySearchRuntimeQmdMultiCollectionProbeDebug; - searchPlan?: MemorySearchRuntimeQmdSearchPlanDebug; -}; - export type MemorySearchRuntimeDebug = { - backend: "builtin" | "qmd"; + backend: "builtin"; configuredMode?: string; effectiveMode?: string; fallback?: string; @@ -108,7 +79,6 @@ export type MemorySearchRuntimeDebug = { reason: string; degradedTo: "keyword-only"; }; - qmd?: MemorySearchRuntimeQmdDebug; }; /** Result of reading a memory file, optionally paginated/truncated. */ @@ -129,7 +99,7 @@ export type MemoryVectorIndexState = | { state: "unverified" }; export type MemoryProviderStatus = { - backend: "builtin" | "qmd"; + backend: "builtin"; provider: string; model?: string; requestedProvider?: string; @@ -206,7 +176,6 @@ export interface MemorySearchManager { lexicalOnly?: boolean; /** Active repository identities used only for project-aware ranking. */ activeProjectKeys?: string[]; - qmdSearchModeOverride?: "query" | "search" | "vsearch"; onDebug?: (debug: MemorySearchRuntimeDebug) => void; sources?: MemorySource[]; /** Optional caller cancellation; managers consume it where their runtime supports cancellation. */ diff --git a/packages/memory-host-sdk/src/host/windows-spawn.ts b/packages/memory-host-sdk/src/host/windows-spawn.ts deleted file mode 100644 index e2f0d5379e31..000000000000 --- a/packages/memory-host-sdk/src/host/windows-spawn.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Public Windows spawn facade for memory host callers. - -export { - materializeWindowsSpawnProgram, - resolveWindowsSpawnProgram, -} from "./openclaw-runtime-io.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ea909a71847..6a8f87638bb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -347,6 +347,9 @@ importers: sigstore: specifier: 5.0.0 version: 5.0.0(supports-color@10.2.2) + string-width: + specifier: 8.2.2 + version: 8.2.2 stylelint: specifier: 17.14.1 version: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 5dcb324bbec1..fbb1e36fcae5 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -259,7 +259,7 @@ "memory-core-host-engine-curated", "memory-core-host-engine-foundation", "memory-core-host-engine-fs", - "memory-core-host-engine-qmd", + "memory-core-host-engine-sessions", "memory-core-host-engine-schema", "memory-core-host-engine-storage", "memory-core-host-secret", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index e9e5dd2f425b..b0681df38d01 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -67,7 +67,7 @@ "memory-core-host-engine-curated", "memory-core-host-engine-embeddings", "memory-core-host-engine-fs", - "memory-core-host-engine-qmd", + "memory-core-host-engine-sessions", "memory-core-host-engine-schema", "memory-core-host-engine-storage", "memory-core-host-runtime-cli", diff --git a/scripts/lib/policy-config-coverage.jsonc b/scripts/lib/policy-config-coverage.jsonc index 6aeb6250edfd..894453b89428 100644 --- a/scripts/lib/policy-config-coverage.jsonc +++ b/scripts/lib/policy-config-coverage.jsonc @@ -28,7 +28,6 @@ "gateway.nodes.commands.deny.*", "gateway.tailscale.mode", "gateway.tailscale.preserveFunnel", - "memory.qmd.sessions.enabled", "mcp.servers.*.command", "mcp.servers.*.transport", "mcp.servers.*.url", @@ -187,13 +186,6 @@ "policy": "dataHandling.retention.requireSessionMaintenance", "reason": "Policy observes session maintenance enforcement posture.", }, - { - "pattern": "memory.qmd.sessions.enabled", - "status": "observed", - "area": "dataHandling", - "policy": "dataHandling.memory.denySessionTranscriptIndexing", - "reason": "Policy observes QMD session-transcript indexing.", - }, { "pattern": "memory.search.enabled", "status": "observed", diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index e58dbfcc8eef..871f10272151 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { checkQmdBinaryAvailability as checkQmdBinaryAvailabilityFn } from "../memory-host-sdk/engine-qmd.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; const note = vi.hoisted(() => vi.fn()); @@ -24,10 +23,6 @@ const hasAuthProfileStoreSourceForProvider = vi.hoisted(() => vi.fn(() => true)) const isConfiguredAwsSdkAuthProfileForProvider = vi.hoisted(() => vi.fn(() => false)); const getActiveMemorySearchManager = vi.hoisted(() => vi.fn()); const resolveActiveMemoryBackendConfig = vi.hoisted(() => vi.fn()); -type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn; -const checkQmdBinaryAvailability = vi.hoisted(() => - vi.fn(async () => ({ available: true })), -); const auditDreamingArtifacts = vi.hoisted(() => vi.fn()); const auditShortTermPromotionArtifacts = vi.hoisted(() => vi.fn()); const repairDreamingArtifacts = vi.hoisted(() => vi.fn()); @@ -67,11 +62,6 @@ vi.mock("../plugins/memory-runtime.js", () => ({ resolveActiveMemoryBackendConfig, })); -vi.mock("../memory-host-sdk/engine-qmd.js", () => ({ - checkQmdBinaryAvailability, - resolveQmdBinaryUnavailableReason: (result: { reason?: string }) => result.reason ?? "binary", -})); - vi.mock("../plugin-sdk/memory-core-bundled-runtime.js", () => ({ auditDreamingArtifacts, auditShortTermPromotionArtifacts, @@ -200,12 +190,12 @@ describe("noteMemorySearchHealth", () => { ...skippedGatewayOptions, skipAuthProfileResolution: true, } satisfies NonNullable[1]>; - const qmdSessionMemory = { + const sessionMemory = { sources: ["memory", "sessions"], experimental: { sessionMemory: true }, }; const conversationRecall = { - ...qmdSessionMemory, + ...sessionMemory, rememberAcrossConversations: true, }; const openAiEmbeddingModel = { model: "text-embedding-3-small" }; @@ -255,18 +245,11 @@ describe("noteMemorySearchHealth", () => { await runMemorySearchHealth(provider, options, overrides, config); } - function qmdConfig( - qmd: Record = { command: "qmd" }, - extra: Record = {}, - ): OpenClawConfig { - return { memory: { backend: "qmd", qmd }, ...extra } as OpenClawConfig; - } - function conversationRecallConfig( plugins?: OpenClawConfig["plugins"], rememberAcrossConversations = true, ): OpenClawConfig { - return qmdConfig(undefined, { + return { agents: { list: [ { @@ -276,7 +259,7 @@ describe("noteMemorySearchHealth", () => { ], }, ...(plugins ? { plugins } : {}), - }); + } as OpenClawConfig; } async function runConversationRecallHealth( @@ -285,9 +268,9 @@ describe("noteMemorySearchHealth", () => { overrides: Record = conversationRecall, ) { await runConfiguredMemorySearch( - "auto", + "none", conversationRecallConfig(plugins, rememberAcrossConversations), - { skipQmdBinaryProbe: true }, + undefined, overrides, ); } @@ -321,20 +304,13 @@ describe("noteMemorySearchHealth", () => { isConfiguredAwsSdkAuthProfileForProvider.mockReturnValue(false); getActiveMemorySearchManager.mockReset(); resolveActiveMemoryBackendConfig.mockReset(); - resolveActiveMemoryBackendConfig.mockImplementation( - ({ cfg: cfgLocal }: { cfg: OpenClawConfig }) => - cfgLocal.memory?.backend === "qmd" - ? { backend: "qmd", qmd: cfgLocal.memory.qmd ?? {} } - : { backend: "builtin" }, - ); + resolveActiveMemoryBackendConfig.mockReturnValue({ backend: "builtin" }); getActiveMemorySearchManager.mockResolvedValue({ manager: { status: () => ({ workspaceDir: "/tmp/agent-default/workspace", backend: "builtin" }), close: vi.fn(async () => {}), }, }); - checkQmdBinaryAvailability.mockReset(); - checkQmdBinaryAvailability.mockResolvedValue({ available: true }); resetMemoryRecallMocks(); }); @@ -543,7 +519,6 @@ describe("noteMemorySearchHealth", () => { await runMemorySearchHealth("auto", {}); expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); - expect(checkQmdBinaryAvailability).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledTimes(1); expect(firstNoteMessage()).toContain("No active memory plugin is registered"); }); @@ -583,7 +558,6 @@ describe("noteMemorySearchHealth", () => { const config = { session: { dmScope: "per-peer" }, plugins } as unknown as OpenClawConfig; await runConfiguredMemorySearch("auto", config); expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); - expect(checkQmdBinaryAvailability).not.toHaveBeenCalled(); if (isActive) { expect(note).not.toHaveBeenCalled(); } else { @@ -614,7 +588,6 @@ describe("noteMemorySearchHealth", () => { resolveActiveMemoryBackendConfig.mockReturnValue(null); await runMemorySearchHealth("auto", options); expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); - expect(checkQmdBinaryAvailability).not.toHaveBeenCalled(); if (shouldWarn) { expect(note).toHaveBeenCalledTimes(1); expect(firstNoteMessage()).toContain("No active memory plugin is registered"); @@ -710,8 +683,7 @@ describe("noteMemorySearchHealth", () => { }); it("warns when an opted-in agent has memory search disabled", async () => { - const qmdCfg = { - memory: { backend: "qmd", qmd: { command: "qmd" } }, + const memoryCfg = { agents: { list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], }, @@ -722,144 +694,13 @@ describe("noteMemorySearchHealth", () => { : { provider: "auto", local: {}, remote: {}, sources: ["memory"] }, ); - await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + await noteMemorySearchHealth(memoryCfg); expect(firstNoteMessage()).toBe( 'Remember across conversations is effectively enabled for agent "personal", but memory search is disabled. Enable memory search or set memory.search.rememberAcrossConversations to false.', ); }); - it("does not warn when QMD backend is active", async () => { - const qmdCfg = qmdConfig(); - await runConfiguredMemorySearch("auto", qmdCfg); - - expect(note).not.toHaveBeenCalled(); - expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({ - command: "qmd", - env: process.env, - cwd: "/tmp/agent-default/workspace", - }); - }); - - it("skips QMD binary probing while preserving QMD session export warnings", async () => { - const qmdCfg = qmdConfig({ command: "custom-qmd" }); - await runConfiguredMemorySearch( - "auto", - qmdCfg, - { - includeWorkspaceMemoryHealth: false, - skipQmdBinaryProbe: true, - }, - qmdSessionMemory, - ); - - expect(noteWorkspaceMemoryHealth).not.toHaveBeenCalled(); - expect(checkQmdBinaryAvailability).not.toHaveBeenCalled(); - expect(note).toHaveBeenCalledTimes(1); - expect(firstNoteMessage()).toContain("QMD session transcript export is not enabled"); - }); - - it.each([ - [ - "warns when QMD backend is active but the qmd binary is unavailable", - { - available: false as const, - reason: "binary" as const, - error: "spawn qmd ENOENT", - }, - [ - "QMD memory backend is configured", - "spawn qmd ENOENT", - "npm install -g @tobilu/qmd", - "bun install -g @tobilu/qmd", - ], - [], - ], - [ - "treats legacy QMD unavailable results without a reason as binary failures", - { available: false as const, error: "spawn qmd ENOENT" }, - ["qmd binary could not be started", "spawn qmd ENOENT", "npm install -g @tobilu/qmd"], - ["agent workspace directory could not be used"], - ], - [ - "warns with a workspace-specific fix when the QMD probe cwd is missing", - { - available: false as const, - reason: "workspace-cwd" as const, - error: "workspace directory missing: /tmp/agent-default/workspace", - }, - [ - "agent workspace directory could not be used", - "workspace directory missing: /tmp/agent-default/workspace", - "Create the missing workspace directory", - ], - ["npm install -g @tobilu/qmd"], - ], - ])("%s", async (_name, availability, contains, excludes) => { - checkQmdBinaryAvailability.mockResolvedValueOnce(availability); - await runConfiguredMemorySearch("auto", qmdConfig()); - expect(note).toHaveBeenCalledTimes(1); - expectFirstNoteContains(...contains); - expectFirstNoteExcludes(...excludes); - }); - - it("warns when QMD backend uses session sources but QMD session export is disabled", async () => { - const qmdCfg = qmdConfig(); - await runConfiguredMemorySearch("auto", qmdCfg, {}, qmdSessionMemory); - - expect(note).toHaveBeenCalledTimes(1); - expectFirstNoteContains( - "memory.search.sources with sessions", - "memory.qmd.sessions.enabled is not true", - "openclaw config set memory.qmd.sessions.enabled true", - ); - }); - - it("warns when QMD session export is explicitly disabled", async () => { - const qmdCfg = qmdConfig({ command: "qmd", sessions: { enabled: false } }); - await runConfiguredMemorySearch("auto", qmdCfg, {}, qmdSessionMemory); - - expect(note).toHaveBeenCalledTimes(1); - expectFirstNoteContains("QMD session transcript export is not enabled"); - }); - - it("does not warn about QMD session export when session sources are not enabled", async () => { - const qmdCfg = qmdConfig(); - await runConfiguredMemorySearch( - "auto", - qmdCfg, - {}, - { - sources: ["memory"], - experimental: { sessionMemory: true }, - }, - ); - - expect(note).not.toHaveBeenCalled(); - }); - - it("reports QMD binary and session export warnings independently", async () => { - const qmdCfg = qmdConfig(); - checkQmdBinaryAvailability.mockResolvedValueOnce({ - available: false, - error: "spawn qmd ENOENT", - }); - await runConfiguredMemorySearch("auto", qmdCfg, {}, qmdSessionMemory); - - expect(note).toHaveBeenCalledTimes(2); - expect(String(note.mock.calls[0]?.[0] ?? "")).toContain("spawn qmd ENOENT"); - expect(String(note.mock.calls[1]?.[0] ?? "")).toContain( - "QMD session transcript export is not enabled", - ); - }); - - it("does not warn when QMD session sources and QMD session export are both enabled", async () => { - const qmdCfg = qmdConfig({ command: "qmd", sessions: { enabled: true } }); - await runConfiguredMemorySearch("auto", qmdCfg, {}, qmdSessionMemory); - - expect(note).not.toHaveBeenCalled(); - }); - it.each([ [ "does not warn when remote apiKey is configured for explicit provider", @@ -1310,7 +1151,6 @@ describe("memory recall doctor integration", () => { expect(auditShortTermPromotionArtifacts).toHaveBeenCalledWith({ workspaceDir: "/tmp/agent-default/workspace", - qmd: undefined, }); expect(note).toHaveBeenCalledTimes(2); expectFirstNoteContains( diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index b6446d933a92..d502b2d8424c 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -31,10 +31,6 @@ import { resolveMemoryDreamingConfig, resolveMemoryDreamingPluginConfig, } from "../memory-host-sdk/dreaming.js"; -import { - checkQmdBinaryAvailability, - resolveQmdBinaryUnavailableReason, -} from "../memory-host-sdk/engine-qmd.js"; import { resolveRememberAcrossConversations } from "../memory-host-sdk/host/config-utils.js"; import { hasConfiguredMemorySecretInput } from "../memory-host-sdk/secret.js"; import { @@ -59,9 +55,6 @@ import { isRecord } from "./doctor/shared/legacy-config-record-shared.js"; type RuntimeMemoryAuditContext = { workspaceDir?: string; - backend?: string; - dbPath?: string; - qmdCollections?: number; }; type MemoryDoctorAgentScope = { @@ -276,14 +269,8 @@ async function resolveRuntimeMemoryAuditContext( } try { const status = manager.status(); - const customQmd = - isRecord(status.custom) && isRecord(status.custom.qmd) ? status.custom.qmd : null; return { workspaceDir: status.workspaceDir?.trim(), - backend: status.backend, - dbPath: status.dbPath, - qmdCollections: - typeof customQmd?.collections === "number" ? customQmd.collections : undefined, }; } finally { await manager.close?.().catch(() => undefined); @@ -337,16 +324,7 @@ export async function noteMemoryRecallHealth(cfg: OpenClawConfig): Promise if (!workspaceDir) { continue; } - const audit = await auditShortTermPromotionArtifacts({ - workspaceDir, - qmd: - context?.backend === "qmd" - ? { - dbPath: context.dbPath, - collections: context.qmdCollections, - } - : undefined, - }); + const audit = await auditShortTermPromotionArtifacts({ workspaceDir }); const message = buildMemoryRecallIssueNote(audit); if (message) { note(formatAgentMessage(scope.agentId, labelAgents, message), "Memory search"); @@ -399,16 +377,7 @@ export async function maybeRepairMemoryRecallHealth(params: { if (!workspaceDir) { continue; } - const audit = await auditShortTermPromotionArtifacts({ - workspaceDir, - qmd: - context?.backend === "qmd" - ? { - dbPath: context.dbPath, - collections: context.qmdCollections, - } - : undefined, - }); + const audit = await auditShortTermPromotionArtifacts({ workspaceDir }); const hasFixableRecallIssue = audit.issues.some((issue) => issue.fixable); if (hasFixableRecallIssue) { const approved = await params.prompter.confirmRuntimeRepair({ @@ -587,9 +556,7 @@ function noteRememberAcrossConversationsHealth(params: { /** * Check whether memory search has a usable embedding provider. - * Runs as part of `openclaw doctor` — config-only checks where possible; - * may spawn a short-lived probe process when `memory.backend=qmd` to verify - * the configured `qmd` binary is available. + * Runs as part of `openclaw doctor` using config-only checks where possible. */ type MemorySearchHealthOptions = { gatewayMemoryProbe?: { @@ -601,7 +568,6 @@ type MemorySearchHealthOptions = { }; noteFn?: typeof note; includeWorkspaceMemoryHealth?: boolean; - skipQmdBinaryProbe?: boolean; skipAuthProfileResolution?: boolean; }; @@ -637,7 +603,7 @@ async function noteMemorySearchHealthForAgent( scope: MemoryDoctorAgentScope, opts: MemorySearchHealthOptions, ): Promise { - const { agentId, agentDir, workspaceDir } = scope; + const { agentId, agentDir } = scope; const noteFn = opts.noteFn ?? note; const recallHealth = noteRememberAcrossConversationsHealth({ cfg, @@ -659,8 +625,6 @@ async function noteMemorySearchHealthForAgent( const provider = resolved.provider === "auto" ? DEFAULT_MEMORY_EMBEDDING_PROVIDER : resolved.provider; - // QMD backend handles embeddings internally (e.g. embeddinggemma) — no - // separate embedding provider is needed. Skip the provider check entirely. const backendConfig = resolveActiveMemoryBackendConfig({ cfg, agentId }); if (!backendConfig) { if (opts?.gatewayMemoryProbe?.checked && opts.gatewayMemoryProbe.ready) { @@ -672,66 +636,6 @@ async function noteMemorySearchHealthForAgent( noteFn("No active memory plugin is registered for the current config.", "Memory search"); return; } - if (backendConfig.backend === "qmd") { - if (opts?.skipQmdBinaryProbe !== true) { - const qmdCheck = await checkQmdBinaryAvailability({ - command: backendConfig.qmd?.command ?? "qmd", - env: process.env, - cwd: workspaceDir, - }); - if (!qmdCheck.available) { - const workspaceProbeFailed = - resolveQmdBinaryUnavailableReason(qmdCheck) === "workspace-cwd"; - const probeError = qmdCheck.error.trim(); - noteFn( - [ - workspaceProbeFailed - ? "QMD memory backend is configured, but the agent workspace directory could not be used for the QMD startup probe." - : `QMD memory backend is configured, but the qmd binary could not be started (${backendConfig.qmd?.command ?? "qmd"}).`, - probeError ? `Probe error: ${probeError}` : null, - "", - "Fix (pick one):", - workspaceProbeFailed - ? "- Create the missing workspace directory or update the agent workspace path to an existing directory." - : "- Install the supported QMD package: npm install -g @tobilu/qmd (or bun install -g @tobilu/qmd)", - workspaceProbeFailed - ? "- Verify the resolved workspace path for the affected agent before retrying." - : `- Set an explicit binary path: ${formatCliCommand("openclaw config set memory.qmd.command /absolute/path/to/qmd")}`, - `- Or switch back to builtin memory: ${formatCliCommand("openclaw config set memory.backend builtin")}`, - "", - `Verify: ${formatCliCommand("openclaw memory status --deep")}`, - ] - .filter(Boolean) - .join("\n"), - "Memory search", - ); - } - } - if ( - resolved.sources?.includes("sessions") && - !resolved.rememberAcrossConversations && - cfg.memory?.qmd?.sessions?.enabled !== true - ) { - noteFn( - [ - "QMD memory backend is configured and the default agent resolves memory.search.sources with sessions,", - "but QMD session transcript export is not enabled (memory.qmd.sessions.enabled is not true).", - "Session transcript hits will not appear in QMD-backed memory search until QMD session export is enabled.", - "", - "Fix (pick one):", - `- Enable QMD session export: ${formatCliCommand( - "openclaw config set memory.qmd.sessions.enabled true", - )}`, - "- Or remove sessions from the default agent's memory.search.sources if QMD session recall is not intended.", - "", - `Verify: ${formatCliCommand("openclaw memory status --deep")}`, - ].join("\n"), - "Memory search", - ); - } - return; - } - if (provider === "none") { return; } diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index d9ae4b0f05ab..3d87c10e750e 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -270,29 +270,6 @@ describe("structured state integrity findings", () => { }); }); -async function runOrphanTranscriptCheckWithQmdSessions(enabled: boolean, homeDir: string) { - const cfg: OpenClawConfig = { - agents: { - defaults: {}, - entries: { main: { default: true } }, - }, - memory: { - backend: "qmd", - qmd: { - sessions: { enabled }, - }, - - search: { rememberAcrossConversations: false }, - }, - }; - setupSessionState(cfg, process.env, homeDir); - const sessionsDir = resolveSessionTranscriptsDirForAgent("main", process.env, () => homeDir); - fs.writeFileSync(path.join(sessionsDir, "orphan-session.jsonl"), '{"type":"session"}\n'); - const confirmRuntimeRepair = vi.fn(async () => false); - await noteStateIntegrity(cfg, { confirmRuntimeRepair, note: noteMock }); - return confirmRuntimeRepair; -} - describe("doctor state integrity oauth dir checks", () => { let envSnapshot: ReturnType; let tempHome = ""; @@ -705,24 +682,6 @@ describe("doctor state integrity oauth dir checks", () => { }, ); - it("suppresses orphan transcript warnings when QMD sessions are enabled", async () => { - const confirmRuntimeRepair = await runOrphanTranscriptCheckWithQmdSessions(true, tempHome); - - expect(stateIntegrityText()).not.toContain( - "These .jsonl files are no longer referenced by sessions.json", - ); - expect(confirmRuntimeRepair).not.toHaveBeenCalled(); - }); - - it("still detects orphan transcripts when QMD sessions are disabled", async () => { - const confirmRuntimeRepair = await runOrphanTranscriptCheckWithQmdSessions(false, tempHome); - - expect(stateIntegrityText()).toContain( - "These .jsonl files are no longer referenced by sessions.json", - ); - expect(confirmRuntimeRepair).toHaveBeenCalled(); - }); - it("prints openclaw-only verification hints when recent sessions are missing transcripts", async () => { const cfg: OpenClawConfig = {}; writeSessionStore(cfg, { diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 45d9f42051ca..c48c2911b46c 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -44,7 +44,6 @@ import { loadLegacySessionStore, updateLegacySessionStore, } from "../infra/state-migrations.legacy-session-store.js"; -import { resolveMemoryBackendConfig } from "../memory-host-sdk/engine-storage.js"; import { listConfiguredChannelIdsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; import { LEGACY_IMPLICIT_AGENT_ID } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -769,11 +768,6 @@ function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boo return false; } -function shouldSuppressOrphanTranscriptWarning(cfg: OpenClawConfig, agentId: string): boolean { - const backendConfig = resolveMemoryBackendConfig({ cfg, agentId }); - return backendConfig?.backend === "qmd" && backendConfig.qmd?.sessions.enabled === true; -} - export function detectStateIntegrityHealthIssues( cfg: OpenClawConfig, params?: { @@ -1076,9 +1070,6 @@ export async function noteStateIntegrity( const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir); const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir); const linuxVolatileStateDir = detectLinuxVolatileStateDir(stateDir); - const suppressOrphanTranscriptWarning = agentId - ? shouldSuppressOrphanTranscriptWarning(cfg, agentId) - : false; if (cloudSyncedStateDir) { warnings.push( @@ -1522,7 +1513,7 @@ export async function noteStateIntegrity( .filter( (filePath) => !referencedTranscriptPaths.has(resolveComparableTranscriptPath(filePath)), ); - if (orphanTranscriptPaths.length > 0 && !suppressOrphanTranscriptWarning) { + if (orphanTranscriptPaths.length > 0) { const orphanCount = countLabel(orphanTranscriptPaths.length, "orphan transcript file"); const orphanPreview = formatFilePreview(orphanTranscriptPaths); warnings.push( diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 683ab9e06edc..1eb0ae445aea 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -914,7 +914,7 @@ describe("collectCodexRouteWarnings", () => { it("does not overwrite a non-Lossless context-engine slot", () => { const result = maybeRepairCodexRoutes({ - plugins: { slots: { contextEngine: "qmd" } }, + plugins: { slots: { contextEngine: "custom-context" } }, agents: { defaults: { model: "openai/gpt-5.5", @@ -953,7 +953,7 @@ describe("collectCodexRouteWarnings", () => { it("preserves local Lossless models when inherited provider migration is blocked", () => { const result = maybeRepairCodexRoutes({ - plugins: { slots: { contextEngine: "qmd" } }, + plugins: { slots: { contextEngine: "custom-context" } }, agents: { defaults: { model: "openai/gpt-5.5", compaction: { provider: "lossless-claw" } }, list: [ @@ -1053,7 +1053,7 @@ describe("collectCodexRouteWarnings", () => { models: { providers: { openai: { baseUrl: "https://proxy.example.test/v1" } }, }, - plugins: { slots: { contextEngine: "qmd" } }, + plugins: { slots: { contextEngine: "custom-context" } }, agents: { defaults: { model: "openai/gpt-5.5", @@ -1089,7 +1089,7 @@ describe("collectCodexRouteWarnings", () => { it("points inherited Lossless model warnings at defaults when migration is blocked", () => { const result = maybeRepairCodexRoutes({ - plugins: { slots: { contextEngine: "qmd" } }, + plugins: { slots: { contextEngine: "custom-context" } }, agents: { defaults: { model: "openai/gpt-5.5", compaction: { model: "openai/gpt-5.4-mini" } }, list: [ @@ -1127,7 +1127,7 @@ describe("collectCodexRouteWarnings", () => { models: { providers: { openai: { baseUrl: "https://proxy.example.test/v1" } }, }, - plugins: { slots: { contextEngine: "qmd" } }, + plugins: { slots: { contextEngine: "custom-context" } }, agents: { defaults: { model: "anthropic/claude-sonnet-4-6", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-memory-qmd.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-memory-qmd.ts new file mode 100644 index 000000000000..eff441f2211f --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-memory-qmd.ts @@ -0,0 +1,169 @@ +import { + defineLegacyConfigMigration, + ensureRecord, + getRecord, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "../../../config/legacy.shared.js"; +import { visitAgentConfigScopes } from "./legacy-config-migrations.runtime.tier-eval.js"; +import { deleteRetiredPath } from "./legacy-config-record-shared.js"; + +const rule = ( + path: string[], + message: string, + match?: LegacyConfigRule["match"], +): LegacyConfigRule => ({ + path, + message: `${message} Run "openclaw doctor --fix".`, + ...(match ? { match } : {}), +}); + +function hasRetiredAgentMemoryQmd(value: unknown): boolean { + const memory = getRecord(getRecord(value)?.memory); + const search = getRecord(memory?.search); + return Boolean(search && Object.hasOwn(search, "qmd")); +} + +type RetiredQmdExternalPath = { + path: string; + pattern?: string; +}; + +function readRetiredQmdExternalPaths(value: unknown): RetiredQmdExternalPath[] { + if (!Array.isArray(value)) { + return []; + } + const paths: RetiredQmdExternalPath[] = []; + for (const candidate of value) { + const entry = getRecord(candidate); + const path = typeof entry?.path === "string" ? entry.path.trim() : ""; + if (!path) { + continue; + } + const pattern = typeof entry?.pattern === "string" ? entry.pattern.trim() : ""; + paths.push({ path, ...(pattern ? { pattern } : {}) }); + } + return paths; +} + +function migrateRetiredQmdExternalPaths(params: { + changes: string[]; + entries: RetiredQmdExternalPath[]; + scope: Record; + sourcePath: string; + targetPath: string; +}): void { + if (params.entries.length === 0) { + return; + } + const memory = ensureRecord(params.scope, "memory"); + const search = ensureRecord(memory, "search"); + const existingPaths = Array.isArray(search.extraPaths) + ? search.extraPaths.filter((value): value is string => typeof value === "string") + : []; + const nextPaths = [...existingPaths]; + const seen = new Set(existingPaths.map((value) => value.trim()).filter(Boolean)); + let added = 0; + let unsupportedPatterns = 0; + for (const entry of params.entries) { + if (!seen.has(entry.path)) { + seen.add(entry.path); + nextPaths.push(entry.path); + added += 1; + } + if (entry.pattern && entry.pattern !== "**/*.md") { + unsupportedPatterns += 1; + } + } + if (added > 0) { + search.extraPaths = nextPaths; + params.changes.push( + `Migrated ${added} external QMD path${added === 1 ? "" : "s"} from ${params.sourcePath} → ${params.targetPath}.`, + ); + } + if (unsupportedPatterns > 0) { + params.changes.push( + `Removed ${unsupportedPatterns} QMD path pattern filter${unsupportedPatterns === 1 ? "" : "s"} from ${params.sourcePath}; builtin memory indexes supported files under the preserved paths.`, + ); + } +} + +function migrateRetiredMemoryQmd(raw: Record, changes: string[]): void { + const memory = getRecord(raw.memory); + const search = getRecord(memory?.search); + const qmd = getRecord(memory?.qmd); + const searchQmd = getRecord(search?.qmd); + migrateRetiredQmdExternalPaths({ + changes, + entries: [ + ...readRetiredQmdExternalPaths(qmd?.paths), + ...readRetiredQmdExternalPaths(searchQmd?.extraCollections), + ], + scope: raw, + sourcePath: "memory.qmd.paths and memory.search.qmd.extraCollections", + targetPath: "memory.search.extraPaths", + }); + let removed = false; + for (const path of [ + ["memory", "backend"], + ["memory", "qmd"], + ["memory", "search", "qmd"], + ] as const) { + removed = deleteRetiredPath(raw, path) || removed; + } + visitAgentConfigScopes(raw, (scope, scopePath) => { + const agentSearch = getRecord(getRecord(scope.memory)?.search); + const agentSearchQmd = getRecord(agentSearch?.qmd); + migrateRetiredQmdExternalPaths({ + changes, + entries: readRetiredQmdExternalPaths(agentSearchQmd?.extraCollections), + scope, + sourcePath: `${scopePath}.memory.search.qmd.extraCollections`, + targetPath: `${scopePath}.memory.search.extraPaths`, + }); + removed = deleteRetiredPath(scope, ["memory", "search", "qmd"]) || removed; + }); + if (removed) { + changes.push( + "Removed retired QMD memory configuration; builtin memory is now the only memory engine.", + ); + } +} + +export const LEGACY_CONFIG_MIGRATION_RUNTIME_MEMORY_QMD: LegacyConfigMigrationSpec = + defineLegacyConfigMigration({ + id: "runtime.memory-qmd-retired", + describe: "Remove retired QMD memory configuration", + legacyRules: [ + rule( + ["memory", "backend"], + "memory.backend is retired; builtin memory is now the only memory engine.", + ), + rule( + ["memory", "qmd"], + "memory.qmd is retired because the QMD memory backend was removed; configured external paths migrate to memory.search.extraPaths.", + ), + rule( + ["memory", "search", "qmd"], + "memory.search.qmd is retired because the QMD memory backend was removed; configured external collections migrate to memory.search.extraPaths.", + ), + rule( + ["agents", "defaults", "memory", "search", "qmd"], + "agents.defaults.memory.search.qmd is retired because the QMD memory backend was removed; configured external collections migrate to agents.defaults.memory.search.extraPaths.", + ), + rule( + ["agents", "entries"], + "agents.entries.*.memory.search.qmd is retired because the QMD memory backend was removed; configured external collections migrate to the matching agent memory.search.extraPaths.", + (value) => { + const entries = getRecord(value); + return entries ? Object.values(entries).some(hasRetiredAgentMemoryQmd) : false; + }, + ), + rule( + ["agents", "list"], + "agents.list.*.memory.search.qmd is retired because the QMD memory backend was removed; configured external collections migrate to the matching agent memory.search.extraPaths.", + (value) => Array.isArray(value) && value.some(hasRetiredAgentMemoryQmd), + ), + ], + apply: migrateRetiredMemoryQmd, + }); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.memory-qmd.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.memory-qmd.test.ts new file mode 100644 index 000000000000..9597446d6f69 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.memory-qmd.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { findLegacyConfigIssues } from "../../../config/legacy.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED } from "./legacy-config-migrations.runtime.retired.js"; + +function applyRetired(raw: Record) { + const changes: string[] = []; + for (const migration of LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED) { + migration.apply(raw, changes); + } + return { raw, changes }; +} + +describe("retired QMD memory config migration", () => { + it("reports every retired QMD memory config scope", () => { + const issues = findLegacyConfigIssues({ + memory: { + backend: "builtin", + qmd: {}, + search: { qmd: { extraCollections: [] } }, + }, + agents: { + defaults: { memory: { search: { qmd: {} } } }, + entries: { research: { memory: { search: { qmd: {} } } } }, + list: [{ id: "legacy", memory: { search: { qmd: {} } } }], + }, + }); + + expect(issues.map((issue) => issue.path)).toEqual( + expect.arrayContaining([ + "memory.backend", + "memory.qmd", + "memory.search.qmd", + "agents.defaults.memory.search.qmd", + "agents.entries", + "agents.list", + ]), + ); + expect(issues.every((issue) => issue.message.includes("doctor --fix"))).toBe(true); + }); + + it("removes retired QMD config while preserving builtin memory siblings", () => { + const result = applyRetired({ + memory: { + backend: "qmd", + citations: "on", + qmd: { + sessions: { enabled: true }, + paths: [ + { path: "/tmp/global" }, + { path: "/tmp/patterned", pattern: "notes/*.md" }, + { path: " /tmp/shared ", pattern: "**/*.md" }, + { path: " " }, + ], + }, + search: { + provider: "openai", + extraPaths: ["notes", "/tmp/shared"], + qmd: { extraCollections: [{ path: "/tmp/search" }] }, + }, + }, + agents: { + defaults: { + memory: { + search: { + extraPaths: ["notes"], + qmd: { extraCollections: [{ path: "/tmp/defaults" }] }, + }, + }, + }, + entries: { + research: { + memory: { + search: { + enabled: false, + extraPaths: ["/tmp/existing"], + qmd: { + extraCollections: [{ path: "/tmp/research", pattern: "*.md" }], + }, + }, + }, + }, + }, + list: [ + { + id: "legacy", + memory: { + search: { qmd: { extraCollections: [{ path: "/tmp/list" }] } }, + }, + }, + ], + }, + }); + + expect(result.raw).not.toHaveProperty("memory.backend"); + expect(result.raw).not.toHaveProperty("memory.qmd"); + expect(result.raw).not.toHaveProperty("memory.search.qmd"); + expect(result.raw).not.toHaveProperty("agents.defaults.memory.search.qmd"); + expect(result.raw).not.toHaveProperty("agents.entries.research.memory.search.qmd"); + expect(result.raw).not.toHaveProperty("agents.list.0.memory.search.qmd"); + expect(result.raw).toHaveProperty("memory.citations", "on"); + expect(result.raw).toHaveProperty("memory.search.provider", "openai"); + expect(result.raw).toHaveProperty("memory.search.extraPaths", [ + "notes", + "/tmp/shared", + "/tmp/global", + "/tmp/patterned", + "/tmp/search", + ]); + expect(result.raw).toHaveProperty("agents.defaults.memory.search.extraPaths", [ + "notes", + "/tmp/defaults", + ]); + expect(result.raw).toHaveProperty("agents.entries.research.memory.search.enabled", false); + expect(result.raw).toHaveProperty("agents.entries.research.memory.search.extraPaths", [ + "/tmp/existing", + "/tmp/research", + ]); + expect(result.raw).toHaveProperty("agents.list.0.memory.search.extraPaths", ["/tmp/list"]); + expect(result.changes).toContain( + "Migrated 3 external QMD paths from memory.qmd.paths and memory.search.qmd.extraCollections → memory.search.extraPaths.", + ); + expect(result.changes).toContain( + "Removed 1 QMD path pattern filter from memory.qmd.paths and memory.search.qmd.extraCollections; builtin memory indexes supported files under the preserved paths.", + ); + expect(result.changes).toContain( + "Removed 1 QMD path pattern filter from agents.entries.research.memory.search.qmd.extraCollections; builtin memory indexes supported files under the preserved paths.", + ); + expect(result.changes).toContain( + "Removed retired QMD memory configuration; builtin memory is now the only memory engine.", + ); + expect(applyRetired(result.raw).changes).toEqual([]); + }); +}); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts index 5c70ddd22c8e..509319c34b20 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts @@ -21,6 +21,7 @@ import { moveVoice, stripRetiredTuningKnobs, } from "./legacy-config-migrations.runtime.retired-media.js"; +import { LEGACY_CONFIG_MIGRATION_RUNTIME_MEMORY_QMD } from "./legacy-config-migrations.runtime.retired-memory-qmd.js"; import { migrateTierEvalTranche } from "./legacy-config-migrations.runtime.tier-eval.js"; import { visitChannelEntries } from "./legacy-config-record-shared.js"; @@ -419,6 +420,7 @@ function migrateFinalLayoutKills(raw: Record, changes: string[] } export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec[] = [ + LEGACY_CONFIG_MIGRATION_RUNTIME_MEMORY_QMD, defineLegacyConfigMigration({ id: "runtime.retired-internal-hook-handlers", describe: "Remove retired internal hook handler registrations", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.tier-eval.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.tier-eval.ts index a78400f69cc9..a20e66c37491 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.tier-eval.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.tier-eval.ts @@ -12,8 +12,6 @@ const TIER_EVAL_RETIRED_ROOT_PATHS = [ ["logging", "redactSensitive"], ["commands", "useAccessGroups"], ["gateway", "controlUi", "allowInsecureAuth"], - ["memory", "qmd", "mcporter"], - ["memory", "qmd", "update"], ["memory", "search", "remote", "nonBatchConcurrency"], ["memory", "search", "remote", "batch", "wait"], ["memory", "search", "remote", "batch", "concurrency"], @@ -45,7 +43,7 @@ const TIER_EVAL_RETIRED_AGENT_PATHS = [ ["heartbeat", "suppressToolErrorWarnings"], ] as const; -function visitAgentConfigScopes( +export function visitAgentConfigScopes( raw: Record, visitor: (scope: Record, path: string) => void, ): void { diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts index a2c8a50d17ba..8514318cc4a4 100644 --- a/src/commands/status.scan.shared.test.ts +++ b/src/commands/status.scan.shared.test.ts @@ -659,42 +659,6 @@ describe("resolveSharedMemoryStatusSnapshot", () => { }); }); - it("uses semantic vector probes for non-builtin memory-slot runtimes", async () => { - const manager = { - probeVectorStoreAvailability: vi.fn(async () => true), - probeVectorAvailability: vi.fn(async () => true), - status: vi.fn(() => ({ - backend: "qmd" as const, - provider: "qmd", - files: 5, - chunks: 5, - vector: { enabled: true, available: true, semanticAvailable: true }, - })), - close: vi.fn(async () => {}), - }; - const getMemorySearchManager = vi.fn(async () => ({ manager })); - - const result = await resolveSharedMemoryStatusSnapshot({ - cfg: { plugins: { slots: { memory: "qmd" } } }, - agentStatus: { defaultId: "main" }, - memoryPlugin: { enabled: true, slot: "qmd" }, - resolveMemoryConfig: vi.fn(() => null), - getMemorySearchManager, - requireDefaultDatabasePath: vi.fn(), - }); - - expect(manager.probeVectorStoreAvailability).not.toHaveBeenCalled(); - expect(manager.probeVectorAvailability).toHaveBeenCalled(); - expect(result).toEqual({ - agentId: "main", - backend: "qmd", - provider: "qmd", - files: 5, - chunks: 5, - vector: { enabled: true, available: true, semanticAvailable: true }, - }); - }); - it("keeps default memory-core on the cold-start store shortcut", async () => { const resolveMemoryConfig = vi.fn(() => null); const getMemorySearchManager = vi.fn(async () => ({ manager: null })); diff --git a/src/config/__snapshots__/schema.help.quality.test.ts.snap b/src/config/__snapshots__/schema.help.quality.test.ts.snap index 82e0769c3de2..b7f6a56ad1eb 100644 --- a/src/config/__snapshots__/schema.help.quality.test.ts.snap +++ b/src/config/__snapshots__/schema.help.quality.test.ts.snap @@ -207,8 +207,6 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`] "mcp.servers.*.oauth.authProfileId", "mcp.servers.*.transport", "mcp.servers.*.url", - "memory.qmd.scope.default", - "memory.qmd.scope.rules.*.action", "memory.search.enabled", "memory.search.model", "memory.search.provider", diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 88e24afb235c..e52dadc35c81 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -167,48 +167,6 @@ describe("config schema regressions", () => { expect(res.ok).toBe(false); }); - it("accepts memorySearch.qmd.extraCollections", () => { - const res = validateConfigObject({ - memory: { - search: { - qmd: { - extraCollections: [ - { path: "/shared/team-notes", name: "team-notes", pattern: "**/*.md" }, - ], - }, - }, - }, - - agents: { - defaults: {}, - }, - }); - - expect(res.ok).toBe(true); - }); - - it("accepts agents.entries.*.memory.search.qmd.extraCollections", () => { - const res = validateConfigObject({ - agents: { - entries: { - main: { - memory: { - search: { - qmd: { - extraCollections: [ - { path: "/shared/team-notes", name: "team-notes", pattern: "**/*.md" }, - ], - }, - }, - }, - }, - }, - }, - }); - - expect(res.ok).toBe(true); - }); - it("accepts agents.defaults.startupContext overrides", () => { const res = validateConfigObject({ agents: { diff --git a/src/config/dead-config-keys.test.ts b/src/config/dead-config-keys.test.ts index 10e39f8baf77..67e5903ecfba 100644 --- a/src/config/dead-config-keys.test.ts +++ b/src/config/dead-config-keys.test.ts @@ -115,8 +115,10 @@ describe("dead config keys", () => { "memory.search.store.driver", "memory.search.sync", "memory.search.query.hybrid", - "memory.qmd.mcporter", - "memory.qmd.update", + "memory.backend", + "memory.qmd", + "memory.search.qmd", + "agents.entries.test.memory.search.qmd", "memory.search.cache.maxEntries", "agents.defaults.runRetries", "agents.entries.test.memory.search.chunking", diff --git a/src/config/schema.help.models.ts b/src/config/schema.help.models.ts index 0b7b19a911f7..95f510ab3fa3 100644 --- a/src/config/schema.help.models.ts +++ b/src/config/schema.help.models.ts @@ -204,16 +204,6 @@ export const MODEL_FIELD_HELP: Record = { 'Chooses which sources are indexed: "memory" reads MEMORY.md + memory files, and "sessions" includes transcript history. Keep ["memory"] unless you need recall from prior chat transcripts.', "memory.search.extraPaths": "Adds extra directories or .md files to the memory index beyond default memory files. Use this when key reference docs live elsewhere in your repo; when multimodal memory is enabled, matching image/audio files under these paths are also eligible for indexing.", - "memory.search.qmd": - "Use this when one agent should query another agent's transcript collections; QMD-specific extra collections let you opt into cross-agent memory search without flattening everything into one shared namespace.", - "memory.search.qmd.extraCollections": - "Use this when you need directional transcript search across agents; add collections here to scope QMD recalls without creating a shared global transcript namespace.", - "memory.search.qmd.extraCollections.path": - "Use an absolute or workspace-relative filesystem path for the extra QMD collection; keep it pointed at the transcript directory or note folder you actually want this agent to search.", - "memory.search.qmd.extraCollections.name": - "Preserves the configured collection label only when the path points outside the agent workspace; paths inside the workspace stay agent-scoped even if a name is provided. Use this for shared cross-agent transcript roots that live outside the workspace.", - "memory.search.qmd.extraCollections.pattern": - "Use a glob pattern to restrict which files inside the collection are indexed; keep the default `**/*.md` unless you need a narrower subset.", "memory.search.multimodal": 'Optional multimodal memory settings for indexing image and audio files from configured extra paths. Keep this off unless your embedding model explicitly supports cross-modal embeddings, and set `memory.search.fallback` to "none" while it is enabled. Matching files are uploaded to the configured remote embedding provider during indexing.', "memory.search.multimodal.enabled": @@ -258,43 +248,7 @@ export const MODEL_FIELD_HELP: Record = { "Minimum relevance score threshold for including memory results in final recall output. Increase to reduce weak/noisy matches, or lower when you need more permissive retrieval.", "memory.search.cache.enabled": "Caches computed chunk embeddings in SQLite so reindexing and incremental updates run faster (default: true). Keep this enabled unless investigating cache correctness or minimizing disk usage.", - memory: "Memory backend configuration (global).", - "memory.backend": - 'Selects the global memory engine: "builtin" uses OpenClaw memory internals, while "qmd" uses the QMD sidecar pipeline. Keep "builtin" unless you intentionally operate QMD.', + memory: "Built-in memory configuration (global).", "memory.citations": 'Controls citation visibility in replies: "auto" shows citations when useful, "on" always shows them, and "off" hides them. Keep "auto" for a balanced signal-to-noise default.', - "memory.qmd.command": - "Sets the executable path for the `qmd` binary used by the QMD backend (default: resolved from PATH). Use an explicit absolute path when multiple qmd installs exist or PATH differs across environments.", - "memory.qmd.searchMode": - 'Selects the QMD retrieval path: "query" uses standard query flow, "search" uses search-oriented retrieval, and "vsearch" emphasizes vector retrieval. Keep default unless tuning relevance quality.', - "memory.qmd.rerank": - 'Controls QMD query reranking. Set to false with searchMode "query" and QMD 2.1+ to skip QMD reranking for faster hybrid results; leave unset for QMD defaults.', - "memory.qmd.searchTool": - "Overrides the exact QMD search command while preserving `searchMode` as the semantic retrieval mode. Keep it unset for the normal built-in tool mapping.", - "memory.qmd.includeDefaultMemory": - "Automatically indexes default memory files (MEMORY.md and memory/**/*.md) into QMD collections. Keep enabled unless you want indexing controlled only through explicit custom paths.", - "memory.qmd.paths": - "Adds custom directories or files to include in QMD indexing, each with an optional name and glob pattern. Use this for project-specific knowledge locations that are outside default memory paths.", - "memory.qmd.paths.path": - "Defines the root location QMD should scan, using an absolute path or `~`-relative path. Use stable directories so collection identity does not drift across environments.", - "memory.qmd.paths.pattern": - "Filters files under each indexed root using a glob pattern, with default `**/*.md`. Use narrower patterns to reduce noise and indexing cost when directories contain mixed file types.", - "memory.qmd.paths.name": - "Sets a stable collection name for an indexed path instead of deriving it from filesystem location. Use this when paths vary across machines but you want consistent collection identity.", - "memory.qmd.sessions.enabled": - "Indexes session transcripts into QMD so recall can include prior conversation content (experimental, default: false). Enable only when transcript memory is required and you accept larger index churn.", - "memory.qmd.sessions.exportDir": - "Overrides where sanitized session exports are written before QMD indexing. Use this when default state storage is constrained or when exports must land on a managed volume.", - "memory.qmd.sessions.retentionDays": - "Defines how long exported session files are kept before automatic pruning, in days (default: unlimited). Set a finite value for storage hygiene or compliance retention policies.", - "memory.qmd.limits.maxResults": - "Limits how many QMD hits are returned into the agent loop for each recall request (default: 6). Increase for broader recall context, or lower to keep prompts tighter and faster.", - "memory.qmd.limits.maxSnippetChars": - "Caps per-result snippet length extracted from QMD hits in characters (default: 700). Lower this when prompts bloat quickly, and raise only if answers consistently miss key details.", - "memory.qmd.limits.maxInjectedChars": - "Caps how much QMD text can be injected into one turn across all hits. Use lower values to control prompt bloat and latency; raise only when context is consistently truncated.", - "memory.qmd.limits.timeoutMs": - "Sets per-query QMD search timeout in milliseconds (default: 4000). Increase for larger indexes or slower environments, and lower to keep request latency bounded.", - "memory.qmd.scope": - "Defines which sessions/channels are eligible for QMD recall using session.sendPolicy-style rules. Keep default direct-only scope unless you intentionally want cross-chat memory sharing.", }; diff --git a/src/config/schema.help.quality.test-fixtures.ts b/src/config/schema.help.quality.test-fixtures.ts index 400bb87778ff..792f8ff1a8ec 100644 --- a/src/config/schema.help.quality.test-fixtures.ts +++ b/src/config/schema.help.quality.test-fixtures.ts @@ -39,33 +39,10 @@ export const ROOT_SECTIONS = [ export const TARGET_KEYS = [ "memory.citations", - "memory.backend", - "memory.qmd.searchMode", - "memory.qmd.rerank", - "memory.qmd.searchTool", - "memory.qmd.scope", - "memory.qmd.includeDefaultMemory", - "memory.qmd.command", - "memory.qmd.paths", - "memory.qmd.paths.path", - "memory.qmd.paths.pattern", - "memory.qmd.paths.name", - "memory.qmd.sessions.enabled", - "memory.qmd.sessions.exportDir", - "memory.qmd.sessions.retentionDays", - "memory.qmd.limits.maxResults", - "memory.qmd.limits.maxSnippetChars", - "memory.qmd.limits.maxInjectedChars", - "memory.qmd.limits.timeoutMs", "memory.search.provider", "memory.search.fallback", "memory.search.sources", "memory.search.extraPaths", - "memory.search.qmd", - "memory.search.qmd.extraCollections", - "memory.search.qmd.extraCollections.path", - "memory.search.qmd.extraCollections.name", - "memory.search.qmd.extraCollections.pattern", "memory.search.multimodal", "memory.search.multimodal.enabled", "memory.search.multimodal.modalities", @@ -358,8 +335,6 @@ export const TARGET_KEYS = [ export const ENUM_EXPECTATIONS: Record = { "skills.workshop.autonomous.mode": ['"off"', '"propose"', '"auto"'], "memory.citations": ['"auto"', '"on"', '"off"'], - "memory.backend": ['"builtin"', '"qmd"'], - "memory.qmd.searchMode": ['"query"', '"search"', '"vsearch"'], "models.mode": ['"merge"', '"replace"'], "models.providers.*.auth": ['"api-key"', '"token"', '"oauth"', '"aws-sdk"'], "gateway.reload.mode": ['"off"', '"hybrid"'], diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index b434711bba95..130a67bd069c 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -219,10 +219,6 @@ describe("config help copy quality", () => { ["memory.citations", ['"auto"', '"on"', '"off"', /always|always shows/i, /hides|hide/i]], ], }, - { - name: "includes a concrete example on memory path fields", - fields: [["memory.qmd.paths.pattern", ["**/*.md"]]], - }, { name: "documents cron retention formats", fields: [ diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 2b98827c5633..6ef2fe77b712 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -443,11 +443,6 @@ export const FIELD_LABELS: Record = { "memory.search.rememberAcrossConversations": "Remember Across Conversations", "memory.search.sources": "Memory Search Sources", "memory.search.extraPaths": "Extra Memory Paths", - "memory.search.qmd": "Memory Search QMD Collections", - "memory.search.qmd.extraCollections": "QMD Extra Collections", - "memory.search.qmd.extraCollections.path": "QMD Extra Collection Path", - "memory.search.qmd.extraCollections.name": "QMD Extra Collection Name", - "memory.search.qmd.extraCollections.pattern": "QMD Extra Collection Pattern", "memory.search.multimodal": "Memory Search Multimodal", "memory.search.multimodal.enabled": "Enable Memory Search Multimodal", "memory.search.multimodal.modalities": "Memory Search Multimodal Modalities", @@ -471,25 +466,7 @@ export const FIELD_LABELS: Record = { "memory.search.query.minScore": "Memory Search Min Score", "memory.search.cache.enabled": "Memory Search Embedding Cache", memory: "Memory", - "memory.backend": "Memory Backend", "memory.citations": "Memory Citations Mode", - "memory.qmd.command": "QMD Binary", - "memory.qmd.searchMode": "QMD Search Mode", - "memory.qmd.rerank": "QMD Rerank", - "memory.qmd.searchTool": "QMD Search Tool Override", - "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", - "memory.qmd.paths": "QMD Extra Paths", - "memory.qmd.paths.path": "QMD Path", - "memory.qmd.paths.pattern": "QMD Path Pattern", - "memory.qmd.paths.name": "QMD Path Name", - "memory.qmd.sessions.enabled": "QMD Session Indexing", - "memory.qmd.sessions.exportDir": "QMD Session Export Directory", - "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", - "memory.qmd.limits.maxResults": "QMD Max Results", - "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", - "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", - "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", - "memory.qmd.scope": "QMD Surface Scope", auth: "Auth", "auth.profiles": "Auth Profiles", "auth.order": "Auth Profile Order", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index d3fc8f8d9ba7..93af36fcb889 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -168,19 +168,6 @@ describe("config schema", () => { expect(res.generatedAt.trim().length).toBeGreaterThan(0); }); - it("accepts qmd query rerank override", () => { - const result = OpenClawSchema.safeParse({ - memory: { - backend: "qmd", - qmd: { - searchMode: "query", - rerank: false, - }, - }, - }); - expect(result.success).toBe(true); - }); - it("rejects retired status reaction emoji overrides", () => { const result = OpenClawSchema.safeParse({ messages: { diff --git a/src/config/schema.tiers.ts b/src/config/schema.tiers.ts index 6d554cf31edf..e0e838a6ac09 100644 --- a/src/config/schema.tiers.ts +++ b/src/config/schema.tiers.ts @@ -132,8 +132,7 @@ hooks.mappings.*.agentId hooks.mappings.*.model hooks.token mcp.apps.enabled mcp.servers.*.args mcp.servers.*.auth mcp.servers.*.command mcp.servers.*.cwd mcp.servers.*.enabled mcp.servers.*.env mcp.servers.*.headers mcp.servers.*.oauth.authProfileId mcp.servers.*.transport mcp.servers.*.url -memory.qmd.scope.default memory.qmd.scope.rules.*.action memory.search.enabled -memory.search.model memory.search.provider memory.search.rememberAcrossConversations +memory.search.enabled memory.search.model memory.search.provider memory.search.rememberAcrossConversations memory.search.remote.apiKey memory.search.sources models.providers.*.api models.providers.*.apiKey models.providers.*.auth models.providers.*.baseUrl models.providers.*.models.*.id diff --git a/src/config/types.memory.ts b/src/config/types.memory.ts index 8618fe1ecf97..dccb75daa812 100644 --- a/src/config/types.memory.ts +++ b/src/config/types.memory.ts @@ -1,44 +1,17 @@ /** * Memory config types shared by core context-engine paths and memory host/plugin runtimes. - * Builtin memory stays core-owned; qmd settings describe the external QMD integration. + * Builtin memory stays core-owned. */ -import type { SessionSendPolicyConfig } from "./types.base.js"; import type { SecretInput } from "./types.secrets.js"; -/** Memory backend family selected for retrieval and session memory features. */ -export type MemoryBackend = "builtin" | "qmd"; /** Citation rendering mode for memory-injected context. */ export type MemoryCitationsMode = "auto" | "on" | "off"; -/** QMD search command flavor used for retrieval. */ -export type MemoryQmdSearchMode = "query" | "search" | "vsearch"; /** Top-level memory config block. */ export type MemoryConfig = { - backend?: MemoryBackend; citations?: MemoryCitationsMode; /** Shared embedding/search defaults. Per-agent overrides live under agents.entries.*.memory.search. */ search?: MemorySearchConfig; - qmd?: MemoryQmdConfig; -}; - -/** QMD-specific memory backend config. */ -export type MemoryQmdConfig = { - command?: string; - searchMode?: MemoryQmdSearchMode; - rerank?: boolean; - searchTool?: string; - includeDefaultMemory?: boolean; - paths?: MemoryQmdIndexPath[]; - sessions?: MemoryQmdSessionConfig; - limits?: MemoryQmdLimitsConfig; - scope?: SessionSendPolicyConfig; -}; - -/** Additional QMD index path entry. */ -export type MemoryQmdIndexPath = { - path: string; - name?: string; - pattern?: string; }; export type MemorySearchConfig = { @@ -50,11 +23,6 @@ export type MemorySearchConfig = { sources?: Array<"memory" | "sessions">; /** Extra paths to include in memory search (directories or .md files). */ extraPaths?: string[]; - /** Optional QMD-specific extra collections for cross-agent search. */ - qmd?: { - /** Additional QMD collections appended for this agent's search scope. */ - extraCollections?: MemoryQmdIndexPath[]; - }; /** Optional multimodal file indexing for selected extra paths. */ multimodal?: { /** Enable image/audio embeddings from extraPaths. */ @@ -129,18 +97,3 @@ export type MemorySearchConfig = { enabled?: boolean; }; }; - -/** Session export settings for QMD memory indexing. */ -export type MemoryQmdSessionConfig = { - enabled?: boolean; - exportDir?: string; - retentionDays?: number; -}; - -/** Retrieval and injection limits for QMD memory results. */ -export type MemoryQmdLimitsConfig = { - maxResults?: number; - maxSnippetChars?: number; - maxInjectedChars?: number; - timeoutMs?: number; -}; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index f3ef0414503a..fe9962be26a6 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -753,22 +753,6 @@ export const MemorySearchSchema = z rememberAcrossConversations: z.boolean().optional(), sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(), extraPaths: z.array(z.string()).optional(), - qmd: z - .object({ - extraCollections: z - .array( - z - .object({ - path: z.string(), - name: z.string().optional(), - pattern: z.string().optional(), - }) - .strict(), - ) - .optional(), - }) - .strict() - .optional(), multimodal: z .object({ enabled: z.boolean().optional(), diff --git a/src/config/zod-schema.root-support.ts b/src/config/zod-schema.root-support.ts index f87442d8d8fc..d8f8b70b998a 100644 --- a/src/config/zod-schema.root-support.ts +++ b/src/config/zod-schema.root-support.ts @@ -6,7 +6,6 @@ import { MemorySearchSchema } from "./zod-schema.agent-runtime.js"; import { SecretInputSchema } from "./zod-schema.core.js"; import { NodeHostAgentRunsSchema } from "./zod-schema.node-host.js"; import { sensitive } from "./zod-schema.sensitive.js"; -import { SessionSendPolicySchema } from "./zod-schema.session.js"; type ConfigSchemaShape = { [Key in keyof T]-?: z.ZodType; @@ -96,25 +95,6 @@ export const AccessGroupsSchema = z ) .optional(); -const MemoryQmdPathSchema = z.strictObject({ - path: z.string(), - name: z.string().optional(), - pattern: z.string().optional(), -}); - -const MemoryQmdSessionSchema = z.strictObject({ - enabled: z.boolean().optional(), - exportDir: z.string().optional(), - retentionDays: z.number().int().nonnegative().optional(), -}); - -const MemoryQmdLimitsSchema = z.strictObject({ - maxResults: z.number().int().positive().optional(), - maxSnippetChars: z.number().int().positive().optional(), - maxInjectedChars: z.number().int().positive().optional(), - timeoutMs: z.number().int().nonnegative().optional(), -}); - export const LoggingLevelSchema = z.union([ z.literal("silent"), z.literal("fatal"), @@ -125,24 +105,10 @@ export const LoggingLevelSchema = z.union([ z.literal("trace"), ]); -const MemoryQmdSchema = z.strictObject({ - command: z.string().optional(), - searchMode: z.union([z.literal("query"), z.literal("search"), z.literal("vsearch")]).optional(), - rerank: z.boolean().optional(), - searchTool: z.string().trim().min(1).optional(), - includeDefaultMemory: z.boolean().optional(), - paths: z.array(MemoryQmdPathSchema).optional(), - sessions: MemoryQmdSessionSchema.optional(), - limits: MemoryQmdLimitsSchema.optional(), - scope: SessionSendPolicySchema.optional(), -}); - export const MemorySchema = z .strictObject({ - backend: z.union([z.literal("builtin"), z.literal("qmd")]).optional(), citations: z.union([z.literal("auto"), z.literal("on"), z.literal("off")]).optional(), search: MemorySearchSchema, - qmd: MemoryQmdSchema.optional(), }) .optional(); diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 5fd4e7ceb545..edc14e5cbd52 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -40,7 +40,7 @@ const PositiveDurationSchema = z.union([z.string(), z.number()]).superRefine((va } }); -export const SessionSendPolicySchema = createAllowDenyChannelRulesSchema(); +const SessionSendPolicySchema = createAllowDenyChannelRulesSchema(); export const SessionSchema = z .object({ diff --git a/src/flows/doctor-health-contribution-runners.workspace.ts b/src/flows/doctor-health-contribution-runners.workspace.ts index 5ee4503fc10d..7aa9e974c608 100644 --- a/src/flows/doctor-health-contribution-runners.workspace.ts +++ b/src/flows/doctor-health-contribution-runners.workspace.ts @@ -207,8 +207,6 @@ function memorySearchNoteToFinding(message: string): HealthFinding | null { let path = "memory.search.provider"; if (firstLine.includes("No active memory plugin")) { path = "plugins.slots.memory"; - } else if (firstLine.includes("QMD memory backend")) { - path = "memory.backend"; } else if (firstLine.includes("OpenAI-compatible embeddings endpoint")) { path = "memory.search.remote.baseUrl"; } else if (firstLine.includes("OpenAI-compatible embedding model")) { @@ -230,7 +228,6 @@ export async function collectMemorySearchHealthFindings( const notes: string[] = []; await noteMemorySearchHealth(ctx.cfg, { includeWorkspaceMemoryHealth: false, - skipQmdBinaryProbe: true, skipAuthProfileResolution: true, gatewayMemoryProbe: { checked: false, ready: false, skipped: true }, noteFn: (message) => notes.push(String(message)), diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 79238210aa03..ac65565fc17d 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -2313,7 +2313,6 @@ describe("doctor health contributions", () => { {}, expect.objectContaining({ includeWorkspaceMemoryHealth: false, - skipQmdBinaryProbe: true, skipAuthProfileResolution: true, gatewayMemoryProbe: { checked: false, ready: false, skipped: true }, noteFn: expect.any(Function), diff --git a/src/gateway/config-diff.ts b/src/gateway/config-diff.ts index 2362fbd8d815..65c7ecf9a3ed 100644 --- a/src/gateway/config-diff.ts +++ b/src/gateway/config-diff.ts @@ -26,7 +26,7 @@ export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): stri return paths; } if (Array.isArray(prev) && Array.isArray(next)) { - // Arrays can contain object entries (for example memory.qmd.paths/scope.rules); + // Arrays can contain object entries (for example agent bindings); // compare structurally so identical values are not reported as changed. if (isDeepStrictEqual(prev, next)) { return []; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index a1968bc22c72..042b7e8b5ce5 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -95,44 +95,22 @@ describe("diffConfigPaths", () => { it("does not report unchanged arrays of objects as changed", () => { const prev = { - memory: { - qmd: { - paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }], - scope: { - rules: [{ when: { channel: "slack" }, include: ["docs"] }], - }, - }, - }, + bindings: [{ agentId: "main", match: { channel: "slack", accountId: "default" } }], }; const next = { - memory: { - qmd: { - paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }], - scope: { - rules: [{ when: { channel: "slack" }, include: ["docs"] }], - }, - }, - }, + bindings: [{ agentId: "main", match: { channel: "slack", accountId: "default" } }], }; expect(diffConfigPaths(prev, next)).toStrictEqual([]); }); it("reports changed arrays of objects", () => { const prev = { - memory: { - qmd: { - paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }], - }, - }, + bindings: [{ agentId: "main", match: { channel: "slack" } }], }; const next = { - memory: { - qmd: { - paths: [{ path: "~/docs", pattern: "**/*.txt", name: "docs" }], - }, - }, + bindings: [{ agentId: "main", match: { channel: "discord" } }], }; - expect(diffConfigPaths(prev, next)).toContain("memory.qmd.paths"); + expect(diffConfigPaths(prev, next)).toContain("bindings"); }); it("collapses changed agent heartbeat entries to agents.entries", () => { diff --git a/src/gateway/server-startup-memory.test.ts b/src/gateway/server-startup-memory.test.ts deleted file mode 100644 index e8ceb684736f..000000000000 --- a/src/gateway/server-startup-memory.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** Gateway startup memory-service tests. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; - -const { getMemorySearchManagerMock, resolveMemorySearchConfigMock } = vi.hoisted(() => ({ - getMemorySearchManagerMock: vi.fn(), - resolveMemorySearchConfigMock: vi.fn(), -})); - -vi.mock("../plugins/memory-runtime.js", () => ({ - getActiveMemorySearchManager: getMemorySearchManagerMock, -})); - -vi.mock("../agents/agent-scope.js", () => ({ - listAgentEntries: (cfg: OpenClawConfig) => cfg.agents?.list ?? [], - listAgentIds: (cfg: OpenClawConfig) => cfg.agents?.list?.map((entry) => entry.id) ?? ["main"], - resolveDefaultAgentId: (cfg: OpenClawConfig) => - cfg.agents?.list?.find((entry) => entry.default)?.id ?? "main", -})); - -vi.mock("../agents/memory-search.js", () => ({ - resolveMemorySearchConfig: resolveMemorySearchConfigMock, -})); - -import { startGatewayMemoryBackend } from "./server-startup-memory.js"; - -function createGatewayLogMock() { - return { info: vi.fn(), warn: vi.fn() }; -} - -describe("startGatewayMemoryBackend", () => { - beforeEach(() => { - getMemorySearchManagerMock.mockClear(); - resolveMemorySearchConfigMock.mockReset().mockReturnValue({}); - }); - - it("skips initialization when memory backend is not qmd", async () => { - const log = createGatewayLogMock(); - await startGatewayMemoryBackend({ - cfg: { agents: { list: [{ id: "main", default: true }] }, memory: { backend: "builtin" } }, - log, - }); - - expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); - expect(log.info).not.toHaveBeenCalled(); - expect(log.warn).not.toHaveBeenCalled(); - }); - - it("keeps qmd managers lazy under the fixed startup policy", async () => { - const log = createGatewayLogMock(); - await startGatewayMemoryBackend({ - cfg: { agents: { list: [{ id: "main", default: true }] }, memory: { backend: "qmd" } }, - log, - }); - - expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); - expect(log.info).not.toHaveBeenCalled(); - expect(log.warn).not.toHaveBeenCalled(); - }); -}); diff --git a/src/gateway/server-startup-memory.ts b/src/gateway/server-startup-memory.ts deleted file mode 100644 index a3082e7e2edc..000000000000 --- a/src/gateway/server-startup-memory.ts +++ /dev/null @@ -1,152 +0,0 @@ -// Gateway memory startup helper. -// Starts qmd memory boot sync for eligible agents without loading every agent. -import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolveMemorySearchConfig } from "../agents/memory-search.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - resolveMemoryBackendConfig, - type ResolvedQmdConfig, -} from "../memory-host-sdk/host/backend-config.js"; -import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { SecretSurfaceUnavailableError } from "../secrets/runtime-degraded-state.js"; - -/** True when qmd memory config opts into Gateway startup manager work. */ -function shouldRunQmdStartupManager(qmd: ResolvedQmdConfig): boolean { - return ( - qmd.update.startup !== "off" && (qmd.update.onBoot || shouldKeepQmdStartupManagerAlive(qmd)) - ); -} - -/** True when startup needs the full manager to own QMD background timers. */ -function shouldKeepQmdStartupManagerAlive(qmd: ResolvedQmdConfig): boolean { - return qmd.update.intervalMs > 0 || qmd.update.embedIntervalMs > 0; -} - -/** Check whether an agent overrides memory search instead of inheriting defaults. */ -function hasExplicitAgentMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { - return listAgentEntries(cfg).some( - (entry) => normalizeAgentId(entry.id) === agentId && entry.memory?.search != null, - ); -} - -/** Decide whether an agent's qmd memory manager should start during Gateway boot. */ -function shouldEagerlyStartAgentMemory(params: { - cfg: OpenClawConfig; - agentId: string; - agentCount: number; -}): boolean { - if (params.agentCount <= 1) { - return true; - } - if (params.agentId === resolveDefaultAgentId(params.cfg)) { - return true; - } - if (params.cfg.memory?.search?.enabled === true) { - return true; - } - return hasExplicitAgentMemorySearchConfig(params.cfg, params.agentId); -} - -/** Start qmd memory boot sync for eligible agents without eagerly loading every agent. */ -export async function startGatewayMemoryBackend(params: { - cfg: OpenClawConfig; - log: { info?: (msg: string) => void; warn: (msg: string) => void }; -}): Promise { - const agentIds = listAgentIds(params.cfg); - const bootSyncAgentIds: string[] = []; - const initializedAgentIds: string[] = []; - const deferredAgentIds: string[] = []; - for (const agentId of agentIds) { - try { - if (!resolveMemorySearchConfig(params.cfg, agentId)) { - continue; - } - } catch (error) { - if (!(error instanceof SecretSurfaceUnavailableError)) { - throw error; - } - // One isolated provider must not prevent healthy agents from completing - // Gateway-owned boot sync and background manager initialization. - params.log.warn(`memory startup unavailable for agent "${agentId}": ${error.message}`); - continue; - } - const resolved = resolveMemoryBackendConfig({ cfg: params.cfg, agentId }); - if (!resolved) { - continue; - } - if (resolved.backend !== "qmd" || !resolved.qmd) { - continue; - } - if (!shouldRunQmdStartupManager(resolved.qmd)) { - continue; - } - if ( - !shouldEagerlyStartAgentMemory({ - cfg: params.cfg, - agentId, - agentCount: agentIds.length, - }) - ) { - // Multi-agent configs keep unconfigured non-default agents lazy so - // Gateway startup does not initialize every possible qmd store. - deferredAgentIds.push(agentId); - continue; - } - - const keepManagerAlive = shouldKeepQmdStartupManagerAlive(resolved.qmd); - const { manager, error } = await getActiveMemorySearchManager({ - cfg: params.cfg, - agentId, - purpose: keepManagerAlive ? "default" : "cli", - }); - if (!manager) { - params.log.warn( - `qmd memory startup initialization failed for agent "${agentId}": ${error ?? "unknown error"}`, - ); - continue; - } - if (keepManagerAlive) { - initializedAgentIds.push(agentId); - continue; - } - try { - await manager.sync?.({ reason: "boot", force: true }); - } catch (err) { - params.log.warn(`qmd memory startup boot sync failed for agent "${agentId}": ${String(err)}`); - continue; - } finally { - await manager.close?.().catch((err: unknown) => { - params.log.warn( - `qmd memory startup manager close failed for agent "${agentId}": ${String(err)}`, - ); - }); - } - bootSyncAgentIds.push(agentId); - } - if (bootSyncAgentIds.length > 0) { - params.log.info?.( - `qmd memory startup boot sync completed for ${formatAgentCount(bootSyncAgentIds.length)}: ${bootSyncAgentIds - .map((agentId) => `"${agentId}"`) - .join(", ")}`, - ); - } - if (initializedAgentIds.length > 0) { - params.log.info?.( - `qmd memory startup manager initialized for ${formatAgentCount(initializedAgentIds.length)}: ${initializedAgentIds - .map((agentId) => `"${agentId}"`) - .join(", ")}`, - ); - } - if (deferredAgentIds.length > 0) { - params.log.info?.( - `qmd memory startup initialization deferred for ${formatAgentCount(deferredAgentIds.length)}: ${deferredAgentIds - .map((agentId) => `"${agentId}"`) - .join(", ")}`, - ); - } -} - -function formatAgentCount(count: number): string { - return count === 1 ? "1 agent" : `${count} agents`; -} diff --git a/src/gateway/server-startup-outcomes.test-support.ts b/src/gateway/server-startup-outcomes.test-support.ts index 6e28c9e4c6ca..e6abe432a2e5 100644 --- a/src/gateway/server-startup-outcomes.test-support.ts +++ b/src/gateway/server-startup-outcomes.test-support.ts @@ -13,7 +13,6 @@ type GatewayStartupOutcome = Parameters[0][ const inactiveParams: GatewayStartupOutcomeRecorderParams = { cfg: {}, gatewayStartHooks: false, - memoryStartupMode: "off", env: {}, }; @@ -22,7 +21,6 @@ describe("gateway startup outcomes", () => { const outcomes: GatewayStartupOutcome[] = [ { subsystem: "gmail-model", status: "scheduled" }, { subsystem: "gmail-watcher", status: "skipped", reason: "no-gmail-account" }, - { subsystem: "memory-qmd", status: "skipped", reason: "startup-disabled" }, { subsystem: "gateway-start-hooks", status: "scheduled" }, { subsystem: "internal-startup-hook", status: "scheduled" }, { subsystem: "internal-hooks", status: "loaded" }, @@ -31,7 +29,6 @@ describe("gateway startup outcomes", () => { expect(formatGatewayStartupOutcomes(outcomes)).toBe( "gateway startup outcomes: internal-hooks=loaded; " + "internal-startup-hook=scheduled; gateway-start-hooks=scheduled; " + - "memory-qmd=skipped (startup-disabled); " + "gmail-watcher=skipped (no-gmail-account); gmail-model=scheduled", ); }); @@ -44,21 +41,19 @@ describe("gateway startup outcomes", () => { "internal-hooks=skipped (not-configured)", "internal-startup-hook=skipped (no-handlers-loaded)", "gateway-start-hooks=skipped (no-handlers-loaded)", - "memory-qmd=skipped (not-configured)", "gmail-watcher=skipped (hooks-disabled)", "gmail-model=skipped (not-configured)", ], }, { - name: "explicitly disabled and default-off qmd", + name: "explicitly disabled internal hooks", params: { ...inactiveParams, - cfg: { hooks: { internal: { enabled: false } }, memory: { backend: "qmd" } }, + cfg: { hooks: { internal: { enabled: false } } }, } satisfies GatewayStartupOutcomeRecorderParams, expected: [ "internal-hooks=skipped (hooks-disabled)", "internal-startup-hook=skipped (hooks-disabled)", - "memory-qmd=skipped (startup-disabled)", ], }, { @@ -70,17 +65,14 @@ describe("gateway startup outcomes", () => { internal: { enabled: true }, gmail: { account: "operator@example.com", model: "openai/gpt-5.5" }, }, - memory: { backend: "qmd" }, }, gatewayStartHooks: true, - memoryStartupMode: "immediate", env: {}, } satisfies GatewayStartupOutcomeRecorderParams, expected: [ "internal-hooks=skipped (no-handlers-loaded)", "internal-startup-hook=skipped (no-handlers-loaded)", "gateway-start-hooks=scheduled", - "memory-qmd=scheduled", "gmail-watcher=scheduled", "gmail-model=scheduled", ], diff --git a/src/gateway/server-startup-outcomes.ts b/src/gateway/server-startup-outcomes.ts index 657f8dd0ccab..7556c9de2d1e 100644 --- a/src/gateway/server-startup-outcomes.ts +++ b/src/gateway/server-startup-outcomes.ts @@ -8,7 +8,6 @@ const GATEWAY_STARTUP_SUBSYSTEMS = [ "internal-hooks", "internal-startup-hook", "gateway-start-hooks", - "memory-qmd", "gmail-watcher", "gmail-model", ] as const; @@ -20,8 +19,7 @@ type GatewayStartupSkippedReason = | "no-handlers-loaded" | "disabled-by-environment" | "hooks-disabled" - | "no-gmail-account" - | "startup-disabled"; + | "no-gmail-account"; type GatewayStartupOutcome = | { subsystem: GatewayStartupSubsystem; status: "loaded" | "scheduled" } @@ -35,7 +33,6 @@ type GatewayStartupOutcome = type GatewayStartupOutcomePlan = { internalHooks: "configured" | "not-configured" | "hooks-disabled"; gatewayStartHooks: boolean; - memoryQmd: "scheduled" | "not-configured" | "startup-disabled"; gmailWatcher: "scheduled" | "disabled-by-environment" | "hooks-disabled" | "no-gmail-account"; gmailModel: "scheduled" | "not-configured"; }; @@ -48,7 +45,6 @@ export type GatewayStartupOutcomeRecorder = { type GatewayStartupOutcomeRecorderParams = { cfg: OpenClawConfig; gatewayStartHooks: boolean; - memoryStartupMode: "off" | "immediate" | "idle"; env?: NodeJS.ProcessEnv; }; @@ -68,12 +64,6 @@ function resolveOutcomePlan( : hasConfiguredInternalHooks(params.cfg) ? "configured" : "not-configured"; - const memoryQmd: GatewayStartupOutcomePlan["memoryQmd"] = - params.cfg.memory?.backend !== "qmd" - ? "not-configured" - : params.memoryStartupMode === "off" - ? "startup-disabled" - : "scheduled"; const gmailWatcher: GatewayStartupOutcomePlan["gmailWatcher"] = !params.cfg.hooks?.enabled ? "hooks-disabled" : !params.cfg.hooks.gmail?.account @@ -85,7 +75,6 @@ function resolveOutcomePlan( return { internalHooks, gatewayStartHooks: params.gatewayStartHooks, - memoryQmd, gmailWatcher, gmailModel: params.cfg.hooks?.gmail?.model ? "scheduled" : "not-configured", }; @@ -113,12 +102,6 @@ export function createGatewayStartupOutcomeRecorder( ? { subsystem: "gateway-start-hooks", status: "scheduled" } : skipped("gateway-start-hooks", "no-handlers-loaded"), ], - [ - "memory-qmd", - plan.memoryQmd === "scheduled" - ? { subsystem: "memory-qmd", status: "scheduled" } - : skipped("memory-qmd", plan.memoryQmd), - ], [ "gmail-watcher", plan.gmailWatcher === "scheduled" diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 4f3d0e698059..660326648cc0 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -29,7 +29,6 @@ const hoisted = vi.hoisted(() => { const startupHookEvent = { type: "gateway", action: "startup", sessionKey: "gateway:startup" }; const createInternalHookEvent = vi.fn(() => startupHookEvent); const triggerInternalHook = vi.fn(async () => {}); - const startGatewayMemoryBackend = vi.fn(async () => {}); const scheduleGatewayUpdateCheck = vi.fn(() => () => {}); const startGatewayTailscaleExposure = vi.fn(async () => null); const logGatewayStartup = vi.fn(); @@ -88,7 +87,6 @@ const hoisted = vi.hoisted(() => { startupHookEvent, createInternalHookEvent, triggerInternalHook, - startGatewayMemoryBackend, scheduleGatewayUpdateCheck, startGatewayTailscaleExposure, logGatewayStartup, @@ -184,10 +182,6 @@ vi.mock("./server-restart-sentinel.js", () => ({ scheduleRestartSentinelWake: hoisted.scheduleRestartSentinelWake, })); -vi.mock("./server-startup-memory.js", () => ({ - startGatewayMemoryBackend: hoisted.startGatewayMemoryBackend, -})); - vi.mock("./server-startup-log.js", () => ({ logGatewayStartup: hoisted.logGatewayStartup, })); @@ -456,7 +450,6 @@ describe("startGatewayPostAttachRuntime", () => { hoisted.hasInternalHookListeners.mockReturnValue(false); hoisted.createInternalHookEvent.mockClear(); hoisted.triggerInternalHook.mockClear(); - hoisted.startGatewayMemoryBackend.mockClear(); hoisted.scheduleGatewayUpdateCheck.mockClear(); hoisted.startGatewayTailscaleExposure.mockClear(); hoisted.logGatewayStartup.mockClear(); @@ -592,7 +585,6 @@ describe("startGatewayPostAttachRuntime", () => { }); expect(hoisted.scheduleSubagentRegistrySweep).toHaveBeenCalledWith(); expect(methodsAtRecoveryRegistration).toStrictEqual([["chat.history", "models.list"]]); - expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); }); it("fences startup recovery as soon as its gateway close prelude begins", async () => { @@ -714,7 +706,6 @@ describe("startGatewayPostAttachRuntime", () => { "gateway startup outcomes: internal-hooks=skipped (hooks-disabled); " + "internal-startup-hook=skipped (hooks-disabled); " + "gateway-start-hooks=skipped (no-handlers-loaded); " + - "memory-qmd=skipped (not-configured); " + "gmail-watcher=skipped (hooks-disabled); gmail-model=skipped (not-configured)", ); expect(events).toEqual([ @@ -1332,26 +1323,6 @@ describe("startGatewayPostAttachRuntime", () => { expect(events).toEqual(["startup-loaded-start", "startup-loaded-end", "sidecars"]); }); - it("keeps the qmd memory backend lazy by default", async () => { - const log = { info: vi.fn(), warn: vi.fn() }; - await startGatewayPostAttachRuntime({ - ...createPostAttachParams(), - log, - gatewayPluginConfigAtStart: { - hooks: { internal: { enabled: false } }, - memory: { backend: "qmd" }, - } as never, - }); - - expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); - expect(log.info).toHaveBeenCalledWith( - expect.stringContaining("memory-qmd=skipped (startup-disabled)"), - ); - expect( - testing.resolveGatewayMemoryStartupPolicy({ memory: { backend: "qmd" } } as never), - ).toEqual({ mode: "off" }); - }); - it("waits for sidecars by default before returning", async () => { let resumeSidecars: (() => void) | undefined; const sidecarsReady = new Promise<{ pluginServices: null; postReadySidecars: [] }>( diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index a4b7a98aa996..9a081aa865eb 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -53,11 +53,6 @@ const DEFERRED_SIDECAR_START_DELAY_MS = 100; const SKIP_STARTUP_MODEL_PREWARM_ENV = "OPENCLAW_SKIP_STARTUP_MODEL_PREWARM"; type Awaitable = T | Promise; -type GatewayMemoryStartupPolicy = - | { mode: "off" } - | { mode: "immediate" } - | { mode: "idle"; delayMs: number }; - const loadMainSessionRestartRecoveryModule = createLazyRuntimeModule( () => import("../agents/main-session-restart-recovery.js"), ); @@ -128,35 +123,6 @@ function shouldSkipStartupModelPrewarm(env: NodeJS.ProcessEnv = process.env): bo return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; } -function resolveGatewayMemoryStartupPolicy(cfg: OpenClawConfig): GatewayMemoryStartupPolicy { - void cfg; - return { mode: "off" }; -} - -function scheduleGatewayMemoryBackend(params: { - cfg: OpenClawConfig; - log: { warn: (msg: string) => void }; - policy: GatewayMemoryStartupPolicy; -}): void { - if (params.policy.mode === "off") { - return; - } - const start = () => { - void runWithGatewayIndependentRootWorkAdmission(async () => { - const { startGatewayMemoryBackend } = await import("./server-startup-memory.js"); - await startGatewayMemoryBackend({ cfg: params.cfg, log: params.log }); - }).catch((err: unknown) => { - params.log.warn(`qmd memory startup initialization failed: ${String(err)}`); - }); - }; - if (params.policy.mode === "immediate") { - setImmediate(start); - return; - } - const timer = setTimeout(start, params.policy.delayMs); - timer.unref?.(); -} - function schedulePostAttachUpdateSentinelRefresh(params: { startupTrace?: GatewayStartupTrace; log: { warn: (msg: string) => void }; @@ -749,14 +715,6 @@ export async function startGatewaySidecars(params: { }); } - await measureStartup(params.startupTrace, "sidecars.memory", async () => { - const policy = resolveGatewayMemoryStartupPolicy(params.cfg); - if (policy.mode === "off") { - return; - } - scheduleGatewayMemoryBackend({ cfg: params.cfg, log: params.log, policy }); - }); - let restartSentinelWake: GatewayPostReadySidecarHandle | undefined; postReadySidecars.push( schedulePostReadySidecarTask({ @@ -1143,11 +1101,9 @@ export async function startGatewayPostAttachRuntime( }; await loadStartupPluginsIfNeeded(); - const memoryStartupPolicy = resolveGatewayMemoryStartupPolicy(params.gatewayPluginConfigAtStart); const startupOutcomes = createGatewayStartupOutcomeRecorder({ cfg: params.gatewayPluginConfigAtStart, gatewayStartHooks: hasGatewayStartHooks(pluginRegistry), - memoryStartupMode: memoryStartupPolicy.mode, }); const startupLogPromise = measureStartup(params.startupTrace, "post-attach.log", () => @@ -1432,7 +1388,6 @@ export const testing = { publishConfiguredModelRuntimeSnapshots, publishStartupModelRuntime, refreshLatestUpdateRestartSentinelIfPresent, - resolveGatewayMemoryStartupPolicy, scheduleProviderAuthStatePrewarm, scheduleRestartSentinelWakeAfterReady, shouldSkipStartupModelPrewarm, diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index aeeac6e67e3e..e5c2d7b8a02d 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -367,9 +367,9 @@ describe("Ghost reminder bug (issue #13317)", () => { replyText: "Relay this cron update now", reason: "interval", enqueue: (sessionKey) => { - enqueueSystemEvent("Cron: QMD maintenance completed", { + enqueueSystemEvent("Cron: memory maintenance completed", { sessionKey, - contextKey: "cron:qmd-maintenance", + contextKey: "cron:memory-maintenance", }); }, }); @@ -377,7 +377,7 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(replyCallCount).toBe(1); expect(calledCtx?.Provider).toBe("cron-event"); expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); - expect(calledCtx?.Body).toContain("Cron: QMD maintenance completed"); + expect(calledCtx?.Body).toContain("Cron: memory maintenance completed"); expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md"); expect(sendTelegram).toHaveBeenCalled(); }); @@ -594,9 +594,9 @@ describe("Ghost reminder bug (issue #13317)", () => { .mockResolvedValueOnce({ text: "HEARTBEAT_OK" }); const { cfg, sessionKey } = await createConfig({ tmpDir, storePath }); - enqueueSystemEvent("Cron: QMD maintenance completed", { + enqueueSystemEvent("Cron: memory maintenance completed", { sessionKey, - contextKey: "cron:qmd-maintenance", + contextKey: "cron:memory-maintenance", }); const first = await runHeartbeatOnce({ @@ -631,17 +631,17 @@ describe("Ghost reminder bug (issue #13317)", () => { Body?: string; }; expect(firstCtx.Provider).toBe("cron-event"); - expect(firstCtx.Body).toContain("Cron: QMD maintenance completed"); + expect(firstCtx.Body).toContain("Cron: memory maintenance completed"); expect(secondCtx.Provider).toBe("heartbeat"); expect(secondCtx.Body).toContain("Heartbeat monitor scratch:"); - expect(secondCtx.Body).not.toContain("Cron: QMD maintenance completed"); + expect(secondCtx.Body).not.toContain("Cron: memory maintenance completed"); }); }); it("retains a cron reminder until a suppressed heartbeat can actually deliver it", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => { const { cfg, sessionKey } = await createConfig({ tmpDir, storePath }); - const reminder = "Cron: QMD maintenance completed"; + const reminder = "Cron: memory maintenance completed"; const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "155462274", @@ -653,7 +653,7 @@ describe("Ghost reminder bug (issue #13317)", () => { enqueueSystemEvent(reminder, { sessionKey, - contextKey: "cron:qmd-maintenance", + contextKey: "cron:memory-maintenance", }); const runOnce = async () => diff --git a/src/infra/heartbeat-runner.isolated-key-stability.test.ts b/src/infra/heartbeat-runner.isolated-key-stability.test.ts index 6f019e1b5ce8..96783f91696b 100644 --- a/src/infra/heartbeat-runner.isolated-key-stability.test.ts +++ b/src/infra/heartbeat-runner.isolated-key-stability.test.ts @@ -262,9 +262,9 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { .mockResolvedValueOnce({ text: "Relay this cron update now" }) .mockResolvedValueOnce({ text: "HEARTBEAT_OK" }); - enqueueSystemEvent("Cron: QMD maintenance completed", { + enqueueSystemEvent("Cron: memory maintenance completed", { sessionKey: baseSessionKey, - contextKey: "cron:qmd-maintenance", + contextKey: "cron:memory-maintenance", }); await runHeartbeatOnce({ @@ -295,9 +295,9 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { expect(firstCtx.SessionKey).toBe(`${baseSessionKey}:heartbeat`); expect(firstCtx.Provider).toBe("cron-event"); - expect(firstCtx.Body).toContain("Cron: QMD maintenance completed"); + expect(firstCtx.Body).toContain("Cron: memory maintenance completed"); expect(secondCtx.SessionKey).toBe(`${baseSessionKey}:heartbeat`); - expect(secondCtx.Body).not.toContain("Cron: QMD maintenance completed"); + expect(secondCtx.Body).not.toContain("Cron: memory maintenance completed"); }); }); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index f25e753a2974..584a43e4e828 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -1558,9 +1558,9 @@ describe("runHeartbeatOnce", () => { const sessionKey = resolveMainSessionKey(cfg); await seedWhatsAppSession(storePath, sessionKey); if (params.queueCronEvent) { - enqueueSystemEvent("Cron: QMD maintenance completed", { + enqueueSystemEvent("Cron: memory maintenance completed", { sessionKey, - contextKey: "cron:qmd-maintenance", + contextKey: "cron:memory-maintenance", }); } if (params.queueSystemEvent) { diff --git a/src/memory-host-sdk/engine-qmd.ts b/src/memory-host-sdk/engine-qmd.ts deleted file mode 100644 index 4ec9a5f110fd..000000000000 --- a/src/memory-host-sdk/engine-qmd.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Core-facing facade for qmd engine availability checks. The package owns the - * binary probing contract; repo callers import through this stable local path. - */ -export { - checkQmdBinaryAvailability, - resolveQmdBinaryUnavailableReason, -} from "../../packages/memory-host-sdk/src/engine-qmd.js"; diff --git a/src/memory-host-sdk/host/backend-config.ts b/src/memory-host-sdk/host/backend-config.ts deleted file mode 100644 index 07a98405fb7b..000000000000 --- a/src/memory-host-sdk/host/backend-config.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Host backend config barrel for memory hosts. The package implementation owns - * schema/default details; core imports this path to avoid deep package paths. - */ -export * from "../../../packages/memory-host-sdk/src/host/backend-config.js"; diff --git a/src/plugin-sdk/memory-core-bundled-runtime.ts b/src/plugin-sdk/memory-core-bundled-runtime.ts index d978ced3137d..21d7fd31b0dc 100644 --- a/src/plugin-sdk/memory-core-bundled-runtime.ts +++ b/src/plugin-sdk/memory-core-bundled-runtime.ts @@ -51,10 +51,7 @@ export type ShortTermAuditIssue = { | "recall-store-dangling" | "recall-store-over-limit" | "recall-lock-stale" - | "recall-lock-unreadable" - | "qmd-index-missing" - | "qmd-index-empty" - | "qmd-collections-empty"; + | "recall-lock-unreadable"; message: string; fixable: boolean; }; @@ -72,11 +69,6 @@ export type ShortTermAuditSummary = { invalidEntryCount: number; danglingEntryCount?: number; issues: ShortTermAuditIssue[]; - qmd?: { - dbPath?: string; - collections?: number; - dbBytes?: number; - }; }; export type RepairShortTermPromotionArtifactsResult = { @@ -111,10 +103,6 @@ type RuntimeFacadeModule = { }) => Promise; auditShortTermPromotionArtifacts: (params: { workspaceDir: string; - qmd?: { - dbPath?: string; - collections?: number; - }; }) => Promise; repairDreamingArtifacts: (params: { workspaceDir: string; diff --git a/src/plugin-sdk/memory-core-host-engine-qmd.ts b/src/plugin-sdk/memory-core-host-engine-qmd.ts deleted file mode 100644 index bf4a63e2e8fc..000000000000 --- a/src/plugin-sdk/memory-core-host-engine-qmd.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Public SDK subpath for memory host QMD engine helpers. - */ -export { - buildSessionEntry, - checkQmdBinaryAvailability, - deriveQmdScopeChannel, - deriveQmdScopeChatType, - extractKeywords, - isQmdScopeAllowed, - isSessionArchiveArtifactName, - isUsageCountedSessionTranscriptFileName, - listSessionTranscriptCorpusEntriesForAgent, - parseCanonicalSessionSyncTargetFromPath, - parseQmdQueryJson, - parseSqliteSessionFileMarker, - parseUsageCountedSessionIdFromFileName, - resolveCliSpawnInvocation, - resolveQmdBinaryUnavailableReason, - resolveSessionFileForSyncTarget, - resolveSessionIdentityForTranscriptFile, - runCliCommand, - sessionPathForFile, - sessionPathForSessionIdentity, - statSessionEntrySync, -} from "../../packages/memory-host-sdk/src/engine-qmd.js"; -export type { - QmdQueryResult, - SessionFileEntry, - SessionTranscriptCorpusEntry, - SessionTranscriptCorpusOptions, -} from "../../packages/memory-host-sdk/src/engine-qmd.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-sessions.ts b/src/plugin-sdk/memory-core-host-engine-sessions.ts new file mode 100644 index 000000000000..4a4a1cff710a --- /dev/null +++ b/src/plugin-sdk/memory-core-host-engine-sessions.ts @@ -0,0 +1,31 @@ +/** Private-local SDK subpath for memory session transcript helpers. */ +export { + buildSessionEntry, + extractKeywords, + isQueryStopWordToken, + isSessionArchiveArtifactName, + isUsageCountedSessionTranscriptFileName, + listSessionFilesForAgent, + listSessionTranscriptCorpusEntriesForAgent, + loadDreamingNarrativeTranscriptPathSetForAgent, + loadSessionTranscriptClassificationForAgent, + normalizeSessionTranscriptPathForComparison, + parseCanonicalSessionSyncTargetFromPath, + parseSqliteSessionFileMarker, + parseUsageCountedSessionIdFromFileName, + resolveSessionFileForSyncTarget, + resolveSessionIdentityForTranscriptFile, + sessionPathForFile, + sessionPathForSessionIdentity, + statSessionEntrySync, +} from "../../packages/memory-host-sdk/src/engine-sessions.js"; +export type { + BuildSessionEntryOptions, + ResolvedMemorySessionSyncTarget, + ResolvedSessionTranscriptIdentity, + SessionFileEntry, + SessionFileState, + SessionTranscriptClassification, + SessionTranscriptCorpusEntry, + SessionTranscriptCorpusOptions, +} from "../../packages/memory-host-sdk/src/engine-sessions.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-storage.ts b/src/plugin-sdk/memory-core-host-engine-storage.ts index b7e518620db0..70438585e91e 100644 --- a/src/plugin-sdk/memory-core-host-engine-storage.ts +++ b/src/plugin-sdk/memory-core-host-engine-storage.ts @@ -85,7 +85,5 @@ export type { MemorySessionSyncTarget, MemorySyncParams, ResolvedMemoryBackendConfig, - ResolvedQmdConfig, - ResolvedQmdMcporterConfig, MemoryVectorIndexState, } from "../../packages/memory-host-sdk/src/engine-storage.js"; diff --git a/src/plugin-sdk/session-transcript-hit.test.ts b/src/plugin-sdk/session-transcript-hit.test.ts index 33c8a4d6a3bd..5ececd248086 100644 --- a/src/plugin-sdk/session-transcript-hit.test.ts +++ b/src/plugin-sdk/session-transcript-hit.test.ts @@ -1,4 +1,4 @@ -// Session transcript hit tests cover transcript match formatting and path resolution. +// Session transcript hit tests cover builtin transcript paths and key resolution. import fs from "node:fs"; import { describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions/types.js"; @@ -35,142 +35,9 @@ it("filters incognito rows from the plugin cross-session store view", () => { }); }); -describe("extractTranscriptStemFromSessionsMemoryHit", () => { - it("strips sessions/ and .jsonl for builtin paths", () => { - expect(extractTranscriptStemFromSessionsMemoryHit("sessions/abc-uuid.jsonl")).toBe("abc-uuid"); - }); - - it("handles plain basename jsonl", () => { - expect(extractTranscriptStemFromSessionsMemoryHit("def-topic-thread.jsonl")).toBe( - "def-topic-thread", - ); - }); - - it("uses .md basename for QMD exports", () => { - expect(extractTranscriptStemFromSessionsMemoryHit("qmd/sessions/x/y/z.md")).toBe("z"); - }); - - it("strips .jsonl.reset. archive suffix so rotated transcripts resolve to the live stem", () => { - expect( - extractTranscriptStemFromSessionsMemoryHit( - "sessions/abc-uuid.jsonl.reset.2026-02-16T22-26-33.000Z", - ), - ).toBe("abc-uuid"); - }); - - it("strips .jsonl.deleted. archive suffix the same way", () => { - expect( - extractTranscriptStemFromSessionsMemoryHit( - "sessions/def-uuid.jsonl.deleted.2026-02-16T22-27-33.000Z", - ), - ).toBe("def-uuid"); - }); - - it("handles archive suffix on bare basenames without the sessions/ prefix", () => { - expect( - extractTranscriptStemFromSessionsMemoryHit("ghi-thread.jsonl.reset.2026-02-16T22-28-33.000Z"), - ).toBe("ghi-thread"); - }); - - it("recognizes QMD-normalized archived reset transcript .md stems", () => { - expect( - extractTranscriptStemFromSessionsMemoryHit( - "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16T22-26-33.000Z.md", - ), - ).toBe("abc-uuid"); - }); - - it("recognizes QMD-normalized archived deleted transcript .md stems", () => { - expect( - extractTranscriptStemFromSessionsMemoryHit( - "qmd/sessions-main/def-uuid-jsonl-deleted-2026-02-16T22-27-33.000Z.md", - ), - ).toBe("def-uuid"); - }); - - it("recognizes real QMD-slugified archived reset transcript .md stems", () => { - expect( - extractTranscriptStemFromSessionsMemoryHit( - "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md", - ), - ).toBe("abc-uuid"); - }); - - it("returns non-archived identity for QMD .md stems that are not archive patterns", () => { - const identity = extractTranscriptIdentityFromSessionsMemoryHit( - "qmd/sessions-main/normal-session.md", - ); - expect(identity).toEqual({ stem: "normal-session", archived: false }); - }); - - it("returns archived identity for QMD-normalized reset .md stems", () => { - const identity = extractTranscriptIdentityFromSessionsMemoryHit( - "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16T22-26-33.000Z.md", - ); - expect(identity).toEqual({ - stem: "abc-uuid", - liveStem: "abc-uuid-jsonl-reset-2026-02-16T22-26-33.000Z", - archived: true, - }); - }); - - it("recognizes QMD-exported dot-form archived reset .md paths", () => { - expect( - extractTranscriptStemFromSessionsMemoryHit( - "qmd/sessions-main/abc-uuid.jsonl.reset.2026-02-16T22-26-33.000Z.md", - ), - ).toBe("abc-uuid"); - }); - - it("recognizes QMD-exported dot-form archived deleted .md paths", () => { - expect( - extractTranscriptStemFromSessionsMemoryHit( - "qmd/sessions-main/def-uuid.jsonl.deleted.2026-02-16T22-27-33.000Z.md", - ), - ).toBe("def-uuid"); - }); - - it("returns archived identity for QMD-exported dot-form reset .md paths", () => { - const identity = extractTranscriptIdentityFromSessionsMemoryHit( - "qmd/sessions-main/abc-uuid.jsonl.reset.2026-02-16T22-26-33.000Z.md", - ); - expect(identity).toEqual({ - stem: "abc-uuid", - liveStem: "abc-uuid.jsonl.reset.2026-02-16T22-26-33.000Z", - archived: true, - }); - }); - - it("does not treat QMD .md names with invalid archive timestamps as archives", () => { - const identity = extractTranscriptIdentityFromSessionsMemoryHit( - "qmd/sessions-main/abc.jsonl.reset.not-a-timestamp.md", - ); - expect(identity).toEqual({ - stem: "abc.jsonl.reset.not-a-timestamp", - archived: false, - }); - }); - - it("does not treat non-QMD .md names with archive-looking timestamps as archives", () => { - const identity = extractTranscriptIdentityFromSessionsMemoryHit( - "abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md", - ); - expect(identity).toEqual({ - stem: "abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z", - archived: false, - }); - }); - - it("does not mistake arbitrary suffixes containing .jsonl. for archives", () => { - // Not a real archive pattern: suffix after .jsonl. must be `reset` or `deleted`. - expect( - extractTranscriptStemFromSessionsMemoryHit("sessions/weird.jsonl.backup.2026-01-01.zst"), - ).toBeNull(); - }); -}); - describe("extractTranscriptIdentityFromSessionsMemoryHit", () => { - it("extracts owner metadata from agent-scoped session archive paths", () => { + it("extracts builtin live and archived transcript identities", () => { + expect(extractTranscriptStemFromSessionsMemoryHit("sessions/abc-uuid.jsonl")).toBe("abc-uuid"); expect( extractTranscriptIdentityFromSessionsMemoryHit( "sessions/main/deleted-uuid.jsonl.deleted.2026-02-16T22-27-33.000Z", @@ -182,31 +49,11 @@ describe("extractTranscriptIdentityFromSessionsMemoryHit", () => { }); }); - it("does not derive owner metadata from lossy QMD session collection names", () => { + it("rejects paths outside the builtin transcript format", () => { + expect(extractTranscriptStemFromSessionsMemoryHit("sessions/note.md")).toBeNull(); expect( - extractTranscriptIdentityFromSessionsMemoryHit( - "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-27-33-000z.md", - ), - ).toEqual({ - stem: "deleted-uuid", - liveStem: "deleted-uuid-jsonl-deleted-2026-02-16t22-27-33-000z", - archived: true, - }); - }); - - it("does not invent owner metadata for legacy basename-only paths", () => { - expect(extractTranscriptIdentityFromSessionsMemoryHit("sessions/abc-uuid.jsonl")).toEqual({ - stem: "abc-uuid", - archived: false, - }); - }); - - it("extracts owner metadata from agent-scoped live session paths", () => { - expect(extractTranscriptIdentityFromSessionsMemoryHit("sessions/main/abc-uuid.jsonl")).toEqual({ - stem: "abc-uuid", - ownerAgentId: "main", - archived: false, - }); + extractTranscriptStemFromSessionsMemoryHit("sessions/weird.jsonl.backup.2026-01-01.zst"), + ).toBeNull(); }); }); @@ -217,122 +64,33 @@ describe("resolveTranscriptStemToSessionKeys", () => { ...overrides, }); - it("returns keys for every agent whose store entry matches the stem", () => { + it("returns every non-incognito key with an exact session identity", () => { const store: Record = { - "agent:main:s1": baseEntry({ - sessionFile: "/data/sessions/stem-a.jsonl", - }), - "agent:peer:s2": baseEntry({ - sessionFile: "/other/volume/stem-a.jsonl", - }), - }; - const keys = resolveTranscriptStemToSessionKeys({ store, stem: "stem-a" }).toSorted(); - expect(keys).toEqual(["agent:main:s1", "agent:peer:s2"]); - }); - - it("never resolves incognito transcript hits", () => { - const incognitoKey = "agent:main:dashboard:incognito-private"; - const store: Record = { - "agent:main:dashboard:visible": baseEntry(), - [incognitoKey]: baseEntry({ incognito: true }), + "agent:main:s1": baseEntry(), + "agent:peer:s2": baseEntry(), + "agent:main:dashboard:incognito-private": baseEntry({ incognito: true }), }; - expect(resolveTranscriptStemToSessionKeys({ store, stem: "stem-a" })).toEqual([ - "agent:main:dashboard:visible", - ]); - const memoryKey = formatSessionTranscriptMemoryHitKey({ - agentId: "main", - sessionId: "stem-a", - }); - expect(resolveSessionTranscriptMemoryHitKeyToSessionKeys({ key: memoryKey, store })).toEqual([ - "agent:main:dashboard:visible", + expect(resolveTranscriptStemToSessionKeys({ store, stem: "stem-a" }).toSorted()).toEqual([ + "agent:main:s1", + "agent:peer:s2", ]); }); - it("falls back to archived owner metadata when deleted archives are gone from the live store", () => { - const keys = resolveTranscriptStemToSessionKeys({ - store: {}, - stem: "deleted-stem", - archivedOwnerAgentId: "main", - }); - - expect(keys).toEqual(["agent:main:deleted-stem"]); - }); - - it("matches QMD-slugified stems to unique session ids with safe punctuation", () => { - const store: Record = { - "agent:main:s1": baseEntry({ sessionId: "foo_bar.v1" }), - }; - + it("falls back to archived owner metadata after the live row is removed", () => { expect( resolveTranscriptStemToSessionKeys({ - store, - stem: "foo-bar-v1", - allowQmdSlugFallback: true, + store: {}, + stem: "deleted-stem", + archivedOwnerAgentId: "main", }), - ).toEqual(["agent:main:s1"]); - }); - - it("ignores store entries without session ids during QMD-slugified fallback", () => { - const store: Record = { - "agent:main:non-session": { - updatedAt: 1, - } as SessionEntry, - "agent:main:s1": baseEntry({ sessionId: "foo_bar.v1" }), - }; - - expect( - resolveTranscriptStemToSessionKeys({ - store, - stem: "foo-bar-v1", - allowQmdSlugFallback: true, - }), - ).toEqual(["agent:main:s1"]); - }); - - it("does not use QMD-slugified fallback unless requested", () => { - const store: Record = { - "agent:main:s1": baseEntry({ sessionId: "foo_bar.v1" }), - }; - - expect(resolveTranscriptStemToSessionKeys({ store, stem: "foo-bar-v1" })).toEqual([]); - }); - - it("prefers exact stem matches before QMD-slugified fallback matches", () => { - const store: Record = { - "agent:main:exact": baseEntry({ sessionId: "foo-bar" }), - "agent:main:slug": baseEntry({ sessionId: "foo_bar" }), - }; - - expect( - resolveTranscriptStemToSessionKeys({ - store, - stem: "foo-bar", - allowQmdSlugFallback: true, - }), - ).toEqual(["agent:main:exact"]); - }); - - it("does not guess when QMD-slugified fallback matches multiple sessions", () => { - const store: Record = { - "agent:main:dot": baseEntry({ sessionId: "foo.bar" }), - "agent:main:underscore": baseEntry({ sessionId: "foo_bar" }), - }; - - expect( - resolveTranscriptStemToSessionKeys({ - store, - stem: "foo-bar", - allowQmdSlugFallback: true, - }), - ).toEqual([]); + ).toEqual(["agent:main:deleted-stem"]); }); }); describe("session transcript memory hit key compatibility exports", () => { it("keeps hit-subpath memory helpers off the runtime writer import path", () => { const source = fs.readFileSync(new URL("./session-transcript-hit.ts", import.meta.url), "utf8"); - expect(source).not.toContain("session-transcript-runtime.js"); }); diff --git a/src/plugin-sdk/session-transcript-hit.ts b/src/plugin-sdk/session-transcript-hit.ts index 122b93f07e8f..3e4d67af0c58 100644 --- a/src/plugin-sdk/session-transcript-hit.ts +++ b/src/plugin-sdk/session-transcript-hit.ts @@ -1,6 +1,5 @@ // Session transcript hit helpers describe and load matched transcript snippets for plugins. import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; import { uniqueStrings } from "../../packages/normalization-core/src/string-normalization.js"; import { parseUsageCountedSessionIdFromFileName } from "../config/sessions/artifacts.js"; @@ -38,44 +37,9 @@ export function loadCombinedSessionStoreForGateway( }; } -const QMD_ARCHIVE_STEM_RE = /^(.+)-jsonl-(reset|deleted)-(.+)$/; -const QMD_ARCHIVE_TIMESTAMP_RE = - /^(\d{4}-\d{2}-\d{2})[tT](\d{2}-\d{2}-\d{2})(?:(?:\.|-)(\d{3}))?[zZ]$/; - -function restoreQmdNormalizedArchiveTimestamp(timestamp: string): string | null { - const match = QMD_ARCHIVE_TIMESTAMP_RE.exec(timestamp); - if (!match) { - return null; - } - const [, date, time, milliseconds] = match; - return `${date}T${time}${milliseconds ? `.${milliseconds}` : ""}Z`; -} - -function restoreQmdNormalizedArchiveName(mdStem: string): string | null { - const match = QMD_ARCHIVE_STEM_RE.exec(mdStem); - if (!match) { - return null; - } - const [, sessionId, reason, timestamp] = match; - const restoredTimestamp = restoreQmdNormalizedArchiveTimestamp( - expectDefined(timestamp, "session transcript hit timestamp"), - ); - return restoredTimestamp ? `${sessionId}.jsonl.${reason}.${restoredTimestamp}` : null; -} - -function normalizeQmdSessionStem(stem: string): string { - return stem - .normalize("NFKD") - .toLowerCase() - .replace(/[^\p{Letter}\p{Number}]+/gu, "-") - .replace(/-{2,}/g, "-") - .replace(/^-+|-+$/g, ""); -} - /** Canonical session identity parsed from a transcript search-hit path. */ export type SessionTranscriptHitIdentity = { stem: string; - liveStem?: string; ownerAgentId?: string; archived: boolean; }; @@ -96,7 +60,7 @@ function parseSessionsPath(hitPath: string): { base: string; ownerAgentId?: stri /** * Derive transcript stem `S` from a memory search hit path for `source === "sessions"`. - * Builtin index uses `sessions/.jsonl`; QMD exports use `.md`. + * Builtin index uses `sessions/.jsonl`. * Archived transcripts (`.jsonl.reset.` / `.jsonl.deleted.`) resolve * to the same stem as the live `.jsonl` they were rotated from. */ @@ -108,7 +72,6 @@ export function extractTranscriptStemFromSessionsMemoryHit(hitPath: string): str export function extractTranscriptIdentityFromSessionsMemoryHit( hitPath: string, ): SessionTranscriptHitIdentity | null { - const isQmdPath = hitPath.replace(/\\/g, "/").startsWith("qmd/"); const { base, ownerAgentId } = parseSessionsPath(hitPath); const archivedStem = parseUsageCountedSessionIdFromFileName(base); if (archivedStem && base !== `${archivedStem}.jsonl`) { @@ -118,26 +81,6 @@ export function extractTranscriptIdentityFromSessionsMemoryHit( const stem = base.slice(0, -".jsonl".length); return stem ? { stem, ownerAgentId, archived: false } : null; } - if (base.endsWith(".md")) { - const mdStem = base.slice(0, -".md".length); - if (!mdStem) { - return null; - } - if (isQmdPath) { - const exportedArchiveStem = parseUsageCountedSessionIdFromFileName(mdStem); - if (exportedArchiveStem && mdStem !== `${exportedArchiveStem}.jsonl`) { - return { stem: exportedArchiveStem, liveStem: mdStem, ownerAgentId, archived: true }; - } - const restoredArchiveName = restoreQmdNormalizedArchiveName(mdStem); - if (restoredArchiveName) { - const archivedStemLocal = parseUsageCountedSessionIdFromFileName(restoredArchiveName); - if (archivedStemLocal && restoredArchiveName !== `${archivedStemLocal}.jsonl`) { - return { stem: archivedStemLocal, liveStem: mdStem, ownerAgentId, archived: true }; - } - } - } - return { stem: mdStem, ownerAgentId, archived: false }; - } return null; } @@ -150,7 +93,6 @@ export function resolveTranscriptStemToSessionKeys(params: { store: Record; stem: string; archivedOwnerAgentId?: string; - allowQmdSlugFallback?: boolean; }): string[] { const { store } = params; const matches: string[] = []; @@ -169,22 +111,6 @@ export function resolveTranscriptStemToSessionKeys(params: { if (deduped.length > 0) { return deduped; } - const normalizedStem = normalizeQmdSessionStem(params.stem); - if (params.allowQmdSlugFallback === true && normalizedStem) { - for (const [sessionKey, entry] of Object.entries(store)) { - if (isIncognitoSessionKey(sessionKey)) { - continue; - } - const entrySessionId = normalizeOptionalString(entry.sessionId); - if (entrySessionId && normalizeQmdSessionStem(entrySessionId) === normalizedStem) { - matches.push(sessionKey); - } - } - } - const normalizedDeduped = uniqueStrings(matches); - if (normalizedDeduped.length > 0) { - return normalizedDeduped.length === 1 ? normalizedDeduped : []; - } const archivedOwnerAgentId = normalizeOptionalString(params.archivedOwnerAgentId); if (!archivedOwnerAgentId) { return []; diff --git a/src/plugins/contracts/extension-package-project-boundaries.test.ts b/src/plugins/contracts/extension-package-project-boundaries.test.ts index 7557e447ad18..357fd1657def 100644 --- a/src/plugins/contracts/extension-package-project-boundaries.test.ts +++ b/src/plugins/contracts/extension-package-project-boundaries.test.ts @@ -47,7 +47,7 @@ type PackageJson = { const MEMORY_HOST_SDK_EXPORTS = [ "./engine-embeddings", "./engine-foundation", - "./engine-qmd", + "./engine-sessions", "./engine-storage", "./multimodal", "./query", @@ -57,7 +57,6 @@ const MEMORY_HOST_SDK_EXPORTS = [ "./status", ] as const; const MEMORY_HOST_SDK_ALLOWED_CORE_BRIDGE_FILES = [ - "packages/memory-host-sdk/src/host/config-utils.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-auth.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-kysely.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-network.ts", diff --git a/src/plugins/loader.registration.test-utils.ts b/src/plugins/loader.registration.test-utils.ts index 4fcb97a2d793..888c420eb26e 100644 --- a/src/plugins/loader.registration.test-utils.ts +++ b/src/plugins/loader.registration.test-utils.ts @@ -754,7 +754,7 @@ describe("loadOpenClawPlugins", () => { return { manager: null, error: "snapshot" }; }, resolveMemoryBackendConfig() { - return { backend: "qmd", qmd: {} }; + return { backend: "builtin" }; }, }, }); diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 2ab3cb08fcde..27fc2d27f644 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -874,7 +874,7 @@ describe("provider-runtime", () => { plugins: { entries: { demo: { enabled: true, config: { endpoint: "https://demo.example" } }, - "active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } }, + "active-memory": { enabled: true, config: { queryMode: "recent" } }, }, }, } as OpenClawConfig; @@ -1168,7 +1168,7 @@ describe("provider-runtime", () => { plugins: { entries: { demo: { enabled: true, config: { endpoint: "https://demo.example" } }, - "active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } }, + "active-memory": { enabled: true, config: { queryMode: "recent" } }, }, }, } as OpenClawConfig; diff --git a/src/plugins/registry-contribution-types.ts b/src/plugins/registry-contribution-types.ts index bb30be36a52b..1275c9f229ef 100644 --- a/src/plugins/registry-contribution-types.ts +++ b/src/plugins/registry-contribution-types.ts @@ -254,13 +254,7 @@ export type MemoryFlushPlanResolver = (params: { export type RegisteredMemorySearchManager = MemorySearchManager; -type MemoryRuntimeQmdConfig = { - command?: string; -}; - -type MemoryRuntimeBackendConfig = - | { backend: "builtin" } - | { backend: "qmd"; qmd?: MemoryRuntimeQmdConfig }; +type MemoryRuntimeBackendConfig = { backend: "builtin" }; export type MemoryPluginRuntime = { getMemorySearchManager(params: { @@ -270,19 +264,9 @@ export type MemoryPluginRuntime = { }): Promise<{ manager: RegisteredMemorySearchManager | null; debug?: { - backend?: "builtin" | "qmd"; + backend?: "builtin"; purpose?: "default" | "status" | "cli"; managerMs?: number; - managerCacheState?: - | "cached-full-hit" - | "cached-full-miss" - | "transient-cli" - | "transient-status" - | "pending-create-wait" - | "fallback-builtin" - | "recent-failure-cooldown"; - qmdIdentityHash?: string; - failureCode?: "qmd-unavailable"; }; error?: string; }>; diff --git a/src/utils/utils-misc.test.ts b/src/utils/utils-misc.test.ts index 9518f9438766..4f48eda0fd9d 100644 --- a/src/utils/utils-misc.test.ts +++ b/src/utils/utils-misc.test.ts @@ -56,8 +56,8 @@ describe("parseBooleanValue", () => { describe("splitShellArgs", () => { it("splits whitespace and respects quotes", () => { - expect(splitShellArgs(`qmd --foo "bar baz"`)).toEqual(["qmd", "--foo", "bar baz"]); - expect(splitShellArgs(`qmd --foo 'bar baz'`)).toEqual(["qmd", "--foo", "bar baz"]); + expect(splitShellArgs(`search --foo "bar baz"`)).toEqual(["search", "--foo", "bar baz"]); + expect(splitShellArgs(`search --foo 'bar baz'`)).toEqual(["search", "--foo", "bar baz"]); }); it("supports backslash escapes inside double quotes", () => { diff --git a/taxonomy.yaml b/taxonomy.yaml index 6cd1c66f757a..5c1f729d504e 100644 --- a/taxonomy.yaml +++ b/taxonomy.yaml @@ -2182,7 +2182,7 @@ surfaces: features: - name: Memory Backend Storage coverageIds: [session-memory.memory-backend-storage] - description: Covers Memory Backend Storage across memory backend config, SQLite schema, vector acceleration, embedding provider selection, remote embedding fetch, QMD process/query parsing, session transcript indexing for search, extra paths, and backend security boundaries. + description: Covers Memory Backend Storage across built-in memory config, SQLite schema, vector acceleration, embedding provider selection, remote embedding fetch, session transcript indexing for search, extra paths, and memory security boundaries. - name: "Embedding Search — QA channel" coverageIds: [session-memory.embedding-search-qa-channel] description: "Exact coverage for qa channel within Embedding Search." @@ -2248,7 +2248,6 @@ surfaces: description: "Exact coverage for memory recall within Active Memory." docs: - docs/reference/memory-config.md - - docs/concepts/memory-qmd.md - docs/concepts/memory.md - docs/channels/discord.md search_anchors: diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index 8327850a42b0..5feae695eb0a 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -65,7 +65,6 @@ export const forcedUnitFastTestFiles = [ "packages/memory-host-sdk/src/host/embeddings-remote-fetch.test.ts", "packages/memory-host-sdk/src/host/internal.test.ts", "packages/memory-host-sdk/src/host/post-json.test.ts", - "packages/memory-host-sdk/src/host/qmd-process.test.ts", "packages/memory-host-sdk/src/host/session-files.test.ts", "src/acp/client.test.ts", "src/acp/control-plane/manager.failover.test.ts", diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index f245137f064b..cb9b781288f4 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -285,7 +285,7 @@ describe("subtitleForRoute", () => { lobsterdex: "Every lobster palette that has visited this browser.", automation: "Commands, hooks, automations, and plugins.", mcp: "MCP servers, auth, tools, and diagnostics.", - memory: "Memory engine, backend, search, and dreaming.", + memory: "Memory engine, search, and dreaming.", talk: "Realtime voice: provider, model, and speaker voice.", infrastructure: "Gateway, browser, node host, discovery, and ACP settings.", labs: "Experimental agent and tool capabilities.", diff --git a/ui/src/e2e/memory-settings-defaults.e2e.test.ts b/ui/src/e2e/memory-settings-defaults.e2e.test.ts index a3e7dfd9d1ce..06d36d2c9808 100644 --- a/ui/src/e2e/memory-settings-defaults.e2e.test.ts +++ b/ui/src/e2e/memory-settings-defaults.e2e.test.ts @@ -73,7 +73,7 @@ async function captureProof(page: Page, name: string, locator?: Locator) { } suite.define(() => { - it("persists engine, backend, and dreaming resets and reloads inherited defaults", async () => { + it("persists engine and dreaming resets and reloads inherited defaults", async () => { await suite.withPage( { colorScheme: "dark", @@ -84,7 +84,6 @@ suite.define(() => { async ({ page }) => { const config = { agents: { defaults: { userTimezone: "Asia/Singapore" } }, - memory: { backend: "qmd" }, plugins: { slots: { memory: "memory-core" }, entries: { @@ -121,23 +120,19 @@ suite.define(() => { expect(response?.status()).toBe(200); const engineRow = settingsRow(page, "Memory engine"); - const backendRow = settingsRow(page, "Retrieval backend"); const frequencyRow = settingsRow(page, "Dreaming frequency"); await expect.poll(() => engineRow.textContent()).toContain("Default: OpenClaw Memory"); - await expect.poll(() => backendRow.textContent()).toContain("Default: Built-in"); await expect.poll(() => frequencyRow.textContent()).toContain("Default: 0 3 * * *"); await expect.poll(() => frequencyRow.getByRole("textbox").inputValue()).toBe("0 6 * * *"); - await captureProof(page, "01-explicit-engine-backend.png"); + await captureProof(page, "01-explicit-engine.png"); await captureProof(page, "02-explicit-dreaming.png", scheduleSection(page)); await engineRow.getByRole("button", { name: "Reset to default" }).click(); - await backendRow.getByRole("button", { name: "Reset to default" }).click(); await frequencyRow.getByRole("button", { name: "Reset to default" }).click(); const saved = requestRaw(await gateway.waitForRequest("config.set")); expect(saved).not.toHaveProperty("plugins.slots.memory"); - expect(saved).not.toHaveProperty("memory.backend"); expect(saved).not.toHaveProperty("plugins.entries.memory-core.config.dreaming.frequency"); expect(saved).toHaveProperty( "plugins.entries.memory-core.config.dreaming.verboseLogging", @@ -149,14 +144,10 @@ suite.define(() => { await page.reload(); const reloadedEngineRow = settingsRow(page, "Memory engine"); - const reloadedBackendRow = settingsRow(page, "Retrieval backend"); const reloadedFrequencyRow = settingsRow(page, "Dreaming frequency"); await expect .poll(() => reloadedEngineRow.textContent()) .toContain("Using default: OpenClaw Memory"); - await expect - .poll(() => reloadedBackendRow.textContent()) - .toContain("Using default: Built-in"); await expect .poll(() => reloadedFrequencyRow.textContent()) .toContain("Using default: 0 3 * * *"); @@ -168,7 +159,7 @@ suite.define(() => { .poll(() => page.getByRole("button", { name: "Reset to default" }).count()) .toBe(1); - await captureProof(page, "03-inherited-engine-backend.png"); + await captureProof(page, "03-inherited-engine.png"); await captureProof(page, "04-inherited-dreaming.png", scheduleSection(page)); }, ); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 6c86744132d8..70a43689d73f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1971,7 +1971,7 @@ export const en: TranslationMap = { lobsterdex: "Every lobster palette that has visited this browser.", automation: "Commands, hooks, automations, and plugins.", mcp: "MCP servers, auth, tools, and diagnostics.", - memory: "Memory engine, backend, search, and dreaming.", + memory: "Memory engine, search, and dreaming.", talk: "Realtime voice: provider, model, and speaker voice.", infrastructure: "Gateway, browser, node host, discovery, and ACP settings.", labs: "Experimental agent and tool capabilities.", @@ -2471,18 +2471,6 @@ export const en: TranslationMap = { "The memory slot points at this plugin, but the plugin itself is disabled, so memory is not running.", enable: "Enable", }, - backend: { - title: "Backend", - description: "How memory is stored and retrieved for the selected engine.", - rowTitle: "Retrieval backend", - builtin: "Built-in", - qmd: "QMD", - invalid: "Invalid configured value", - builtinHint: "Memory files are indexed and searched by OpenClaw itself.", - qmdHint: "Retrieval is delegated to QMD. Its settings appear below.", - invalidHint: - "The configured memory.backend value is invalid. Choose a backend or reset it to use the default.", - }, addons: { title: "Add-ons", description: diff --git a/ui/src/pages/config/memory-defaults.test.ts b/ui/src/pages/config/memory-defaults.test.ts index 07de030831f0..87d70a1b74a9 100644 --- a/ui/src/pages/config/memory-defaults.test.ts +++ b/ui/src/pages/config/memory-defaults.test.ts @@ -1,27 +1,23 @@ import { describe, expect, it, vi } from "vitest"; import { dreamingConfigPath, - resetMemoryBackend, resetMemoryEngine, resolveDreamingTimezoneDefault, } from "./memory-defaults.ts"; describe("memory curated defaults", () => { - it("resets the slot and backend by removing only their authored keys", () => { + it("resets the slot by removing only its authored key", () => { const removeFormValue = vi.fn(); const config = { removeFormValue }; resetMemoryEngine(config); - resetMemoryBackend(config); - expect(removeFormValue).toHaveBeenNthCalledWith(1, ["plugins", "slots", "memory"]); - expect(removeFormValue).toHaveBeenNthCalledWith(2, ["memory", "backend"]); + expect(removeFormValue).toHaveBeenCalledWith(["plugins", "slots", "memory"]); }); it("does not reset controls while their effective mutation gate is closed", () => { const removeFormValue = vi.fn(); expect(resetMemoryEngine({ removeFormValue }, true)).toBe(false); - expect(resetMemoryBackend({ removeFormValue }, true)).toBe(false); expect(removeFormValue).not.toHaveBeenCalled(); }); diff --git a/ui/src/pages/config/memory-defaults.ts b/ui/src/pages/config/memory-defaults.ts index bce9a03bde74..401a20b066f1 100644 --- a/ui/src/pages/config/memory-defaults.ts +++ b/ui/src/pages/config/memory-defaults.ts @@ -11,14 +11,6 @@ export function resetMemoryEngine(config: ConfigRemover, disabled = false): bool return true; } -export function resetMemoryBackend(config: ConfigRemover, disabled = false): boolean { - if (disabled) { - return false; - } - config.removeFormValue(["memory", "backend"]); - return true; -} - export function dreamingConfigPath(pluginId: string, path: readonly string[]) { return ["plugins", "entries", pluginId, "config", "dreaming", ...path]; } diff --git a/ui/src/pages/config/memory-memories.test.ts b/ui/src/pages/config/memory-memories.test.ts index db3d0b7d66c1..5751671b4289 100644 --- a/ui/src/pages/config/memory-memories.test.ts +++ b/ui/src/pages/config/memory-memories.test.ts @@ -213,11 +213,10 @@ describe("MemoryMemoriesElement", () => { } }); - it("keeps session, QMD, absolute, and escaping paths non-expandable", async () => { + it("keeps session, absolute, and escaping paths non-expandable", async () => { const nonExpandable = [ { ...result, path: "sessions/main/session-1.jsonl", source: "sessions" as const }, { ...result, path: "sessions/main/mislabeled.jsonl" }, - { ...result, path: "qmd/workspace-main/memory/notes.md" }, { ...result, path: "/external/MEMORY.md" }, { ...result, path: "C:\\external\\MEMORY.md" }, { ...result, path: "memory/../outside.md" }, @@ -234,10 +233,10 @@ describe("MemoryMemoriesElement", () => { try { await typeQuery(element, "memory"); submit(element); - await waitForFast(() => expect(element.querySelectorAll("article")).toHaveLength(7)); + await waitForFast(() => expect(element.querySelectorAll("article")).toHaveLength(6)); expect(element.querySelectorAll("article > button")).toHaveLength(1); - expect(element.querySelectorAll("article > div.settings-row")).toHaveLength(6); + expect(element.querySelectorAll("article > div.settings-row")).toHaveLength(5); expect( request.mock.calls.filter(([method]) => method === "agents.workspace.get"), ).toHaveLength(0); diff --git a/ui/src/pages/config/memory-memories.ts b/ui/src/pages/config/memory-memories.ts index b396cf2027e3..e4a16dcb479a 100644 --- a/ui/src/pages/config/memory-memories.ts +++ b/ui/src/pages/config/memory-memories.ts @@ -32,7 +32,7 @@ function isExpandableWorkspaceResult(result: SearchResult): boolean { normalizedPath.split("/").every((segment) => segment && segment !== "." && segment !== ".."); const workspaceMemoryPath = normalizedPath === "MEMORY.md" || normalizedPath.startsWith("memory/"); - // workspace.get is workspace-contained; sessions/* and qmd/* are logical manager paths. + // workspace.get is workspace-contained; sessions/* are logical manager paths. return result.source === "memory" && safeRelativePath && workspaceMemoryPath; } diff --git a/ui/src/pages/config/memory-page.backend.test.ts b/ui/src/pages/config/memory-page.backend.test.ts deleted file mode 100644 index 07d89f0db939..000000000000 --- a/ui/src/pages/config/memory-page.backend.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* @vitest-environment jsdom */ - -import { ContextProvider } from "@lit/context"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { applicationContext, type ApplicationContext } from "../../app/context.ts"; -import { waitForFast } from "../../test-helpers/wait-for.ts"; -import { configRouteData } from "./route-data.ts"; -import "./memory-page.ts"; - -type MemoryPageElement = HTMLElement & { - configObject: Record; - routeData: ReturnType; -}; - -afterEach(() => { - document.body.replaceChildren(); -}); - -describe("MemorySettingsPage backend repair", () => { - it("removes a malformed explicit backend through the curated reset action", async () => { - const removeFormValue = vi.fn(); - const subscribe = () => () => undefined; - const request = vi.fn((method: string) => { - if (method === "plugins.list") { - return Promise.resolve({ - plugins: [ - { - id: "memory-core", - name: "memory-core", - installed: true, - enabled: true, - state: "enabled", - kind: ["memory"], - }, - ], - diagnostics: [], - mutationAllowed: true, - }); - } - return Promise.resolve({}); - }); - const context = { - basePath: "", - gateway: { - snapshot: { - client: { request }, - phase: "connected", - hello: { auth: { role: "operator", scopes: ["operator.admin"] }, features: {} }, - }, - subscribe, - }, - runtimeConfig: { - state: { - client: {}, - connected: true, - configSaving: false, - configApplying: false, - configForm: { memory: { backend: "retired-backend" } }, - configSnapshot: null, - }, - subscribe, - lookupSchemaPath: vi.fn(async () => ({ type: "object" })), - patchForm: vi.fn(), - removeFormValue, - waitForPendingWrites: vi.fn(async () => undefined), - refresh: vi.fn(async () => undefined), - ensureLoaded: vi.fn(async () => undefined), - }, - agents: { - state: { - agentsList: { defaultId: "main", agents: [{ id: "main" }] }, - agentsLoading: false, - }, - subscribe, - ensureList: vi.fn(async () => undefined), - }, - navigate: vi.fn(), - replace: vi.fn(), - } as unknown as ApplicationContext; - const element = document.createElement("openclaw-memory-settings") as MemoryPageElement; - element.configObject = { memory: { backend: "retired-backend" } }; - element.routeData = configRouteData({ - pathname: "/settings/memory/settings", - search: "", - hash: "", - }); - (element as unknown as { context: ApplicationContext }).context = context; - const provider = new ContextProvider(element, { - context: applicationContext, - initialValue: context, - }); - provider.setValue(context); - document.body.append(element); - - await waitForFast(() => expect(element.textContent).toContain("Invalid configured value")); - const backendRow = [...element.querySelectorAll(".settings-row")].find((row) => - row.textContent?.includes("Retrieval backend"), - ); - - backendRow?.querySelector('button[aria-label="Reset to default"]')?.click(); - - expect(removeFormValue).toHaveBeenCalledWith(["memory", "backend"]); - }); -}); diff --git a/ui/src/pages/config/memory-page.test.ts b/ui/src/pages/config/memory-page.test.ts index 532529189b19..d8f22ebf99e6 100644 --- a/ui/src/pages/config/memory-page.test.ts +++ b/ui/src/pages/config/memory-page.test.ts @@ -701,7 +701,6 @@ describe("MemorySettingsPage tab routing", () => { ["/settings/memory?tab=search", "/settings/memory/settings"], ["/settings/memory?tab=overview", "/settings/memory"], ["/settings/memory?section=memory", "/settings/memory/settings"], - ["/settings/memory#memory-backend", "/settings/memory/settings#memory-backend"], ["/settings/memory#config-section-memory", "/settings/memory/settings#config-section-memory"], [ "/settings/memory#config-section-memory-search", diff --git a/ui/src/pages/config/memory-page.ts b/ui/src/pages/config/memory-page.ts index ea66f5affe13..b68ce0388375 100644 --- a/ui/src/pages/config/memory-page.ts +++ b/ui/src/pages/config/memory-page.ts @@ -33,7 +33,6 @@ import "./memory-dreaming-page.ts"; import "./memory-memories.ts"; import { dreamingConfigPath, - resetMemoryBackend, resetMemoryEngine, resolveDreamingTimezoneDefault, } from "./memory-defaults.ts"; @@ -43,7 +42,6 @@ import { canonicalMemoryRouteLocation, memoryTabForRoute, memorySchemaKeysForTab, - resolveMemoryBackendSelection, resolveMemoryEngineSelection, selectedEngineId, type MemoryEngineSelection, @@ -640,7 +638,6 @@ class MemorySettingsPage extends OpenClawLightDomElement { const engineSelection = resolveMemoryEngineSelection(this.configObject); const engineMutationDisabled = this.mutationDisabled || (this.catalog.kind === "ready" && !this.catalog.mutationAllowed); - const backendSelection = resolveMemoryBackendSelection(this.configObject); const activeTab = this.activeTab(); const agentId = this.resolveAgentId(); return renderMemory({ @@ -657,14 +654,6 @@ class MemorySettingsPage extends OpenClawLightDomElement { this.engineOutcome = null; } }, - backendSelection, - backendBusy: this.mutationDisabled, - onBackendChange: (next) => { - if (!this.mutationDisabled) { - runtimeConfig.patchForm(["memory", "backend"], next); - } - }, - onBackendReset: () => resetMemoryBackend(runtimeConfig, this.mutationDisabled), addons: buildMemoryAddonRows(this.catalog, { busy: this.addonBusy, errors: this.addonErrors, @@ -706,9 +695,7 @@ class MemorySettingsPage extends OpenClawLightDomElement { `, dreams: html` `, editor: - activeTab === "settings" - ? this.buildEditor(memorySchemaKeysForTab("settings", backendSelection?.backend ?? null)) - : html``, + activeTab === "settings" ? this.buildEditor(memorySchemaKeysForTab("settings")) : html``, dreamingSettings: activeTab === "settings" ? this.renderDreamingControls() : html``, }); } diff --git a/ui/src/pages/config/memory-schema.ts b/ui/src/pages/config/memory-schema.ts index fc22b1be49df..22b8ca76b4e1 100644 --- a/ui/src/pages/config/memory-schema.ts +++ b/ui/src/pages/config/memory-schema.ts @@ -12,12 +12,6 @@ import { memoryTabFromPath, pathForMemoryTab, type MemoryRouteTab } from "../../ export type MemoryTab = MemoryRouteTab; -export type MemoryBackend = "builtin" | "qmd"; -export type MemoryBackendSelection = - | { kind: "default"; backend: "builtin" } - | { kind: "pinned"; backend: MemoryBackend } - | { kind: "invalid"; backend: null; value: unknown }; - /** * How `plugins.slots.memory` reads today, mirroring resolveSlotSelection in * src/plugins/slots.ts. `off` is the explicit `none` sentinel; `auto` is an @@ -31,9 +25,6 @@ export type MemoryEngineSelection = export const DEFAULT_MEMORY_ENGINE_ID = defaultSlotIdForKey("memory"); -/** Scroll target for `memory.backend`, which Settings curates out of the editor. */ -export const MEMORY_BACKEND_ANCHOR_ID = "memory-backend"; - const MEMORY_TABS: readonly MemoryTab[] = ["overview", "memories", "dreams", "settings"]; /** Reads a `?tab=` value from a settings-search destination or a shared link. */ @@ -66,11 +57,7 @@ export function memoryTabForRoute( return tab; } const target = route.targetBlockId ?? ""; - if ( - route.section === "memory" || - target === MEMORY_BACKEND_ANCHOR_ID || - target.startsWith("config-section-memory") - ) { + if (route.section === "memory" || target.startsWith("config-section-memory")) { return "settings"; } return pathTab; @@ -112,12 +99,6 @@ export function selectedEngineId(selection: MemoryEngineSelection): string | nul return selection.kind === "off" ? null : selection.engineId; } -// memory-core is the only plugin registering the memory runtime that resolves -// `memory.backend`, so any other engine hides the backend row. This is a -// runtime-ownership fact, not the slot default; the slot comes from -// resolveSlotSelection. -const MEMORY_CORE_PLUGIN_ID = "memory-core"; - /** * Mirrors the runtime exactly: resolveSlotSelection owns the rule, so an unset * slot reports the slot's default owner instead of guessing from the catalog. @@ -137,31 +118,6 @@ export function resolveMemoryEngineSelection( } } -/** - * The retrieval backend the page shows, or null when the slot owner runs its own - * retrieval and `memory.backend` would save a value nothing consumes. Settings - * search resolves it from the same config so both agree on what is visible. - */ -export function resolveMemoryBackendSelection( - configObject: Record, -): MemoryBackendSelection | null { - if (selectedEngineId(resolveMemoryEngineSelection(configObject)) !== MEMORY_CORE_PLUGIN_ID) { - return null; - } - const memory = asConfigRecord(configObject.memory); - if (!memory || !Object.hasOwn(memory, "backend")) { - return { kind: "default", backend: "builtin" }; - } - const backend = memory.backend; - return backend === "builtin" || backend === "qmd" - ? { kind: "pinned", backend } - : { kind: "invalid", backend: null, value: backend }; -} - -export function resolveMemoryBackend(configObject: Record): MemoryBackend | null { - return resolveMemoryBackendSelection(configObject)?.backend ?? null; -} - type JsonRecord = Record; // One narrowed schema object per (source schema, key set): the config view caches @@ -198,34 +154,17 @@ export function narrowMemorySchema(schema: unknown, keys: readonly string[]): un return narrowed; } -/** - * The `memory.*` children Settings renders as curated rows instead of through - * the embedded editor. They have no `#config-section-*` id, so settings search - * routes their deep links to MEMORY_BACKEND_ANCHOR_ID. - */ -export const MEMORY_CURATED_SCHEMA_KEYS: readonly string[] = ["backend"]; - /** Which `memory.*` children the embedded editor shows for a tab. */ -export function memorySchemaKeysForTab( - tab: MemoryTab, - backend: MemoryBackend | null, -): readonly string[] { +export function memorySchemaKeysForTab(tab: MemoryTab): readonly string[] { if (tab !== "settings") { return []; } // Keep the old Overview fields before the old Search slice while rendering // one editor, which in turn keeps one autosave status and apply banner. - return backend === "qmd" ? ["citations", "qmd", "search"] : ["citations", "search"]; + return ["citations", "search"]; } -/** - * Every `memory.*` child the page surfaces for a config: the merged editor plus - * `backend`, which Settings renders as a curated row rather than through the - * editor. `qmd` and `backend` disappear with the backend/engine choice, so - * settings search filters the section through this before matching — otherwise a - * `memory.qmd` hit routes to Settings while its editor omits the matched field. - */ -export function memoryVisibleSchemaKeys(backend: MemoryBackend | null): readonly string[] { - const editor = memorySchemaKeysForTab("settings", backend); - return backend === null ? editor : [...editor, "backend"]; +/** Every `memory.*` child the Settings editor surfaces. */ +export function memoryVisibleSchemaKeys(): readonly string[] { + return memorySchemaKeysForTab("settings"); } diff --git a/ui/src/pages/config/memory.test.ts b/ui/src/pages/config/memory.test.ts index b3d33daf233b..583bf505bb40 100644 --- a/ui/src/pages/config/memory.test.ts +++ b/ui/src/pages/config/memory.test.ts @@ -8,8 +8,6 @@ import { memoryTabForRoute, memoryVisibleSchemaKeys, narrowMemorySchema, - resolveMemoryBackend, - resolveMemoryBackendSelection, } from "./memory-schema.ts"; import { renderMemory } from "./memory.ts"; @@ -30,10 +28,6 @@ function createProps(overrides: Partial = {}): MemoryViewProps engineOutcome: null, onEngineChange: vi.fn(), onEngineReset: vi.fn(), - backendSelection: { kind: "default", backend: "builtin" }, - backendBusy: false, - onBackendChange: vi.fn(), - onBackendReset: vi.fn(), addons: [ { id: "active-memory", @@ -223,73 +217,6 @@ describe("renderMemory", () => { expect(container.textContent).not.toContain("pinned in config"); }); - it("hides the retrieval backend row for an engine that owns its own retrieval", () => { - expect( - renderInto(createProps({ backendSelection: { kind: "default", backend: "builtin" } })) - .textContent, - ).toContain("Retrieval backend"); - expect(renderInto(createProps({ backendSelection: null })).textContent).not.toContain( - "Retrieval backend", - ); - }); - - it("shows backend provenance and only offers reset for an explicit value", () => { - const inherited = renderInto(createProps()); - expect(inherited.textContent).toContain("Using default: Built-in"); - - const pinned = renderInto( - createProps({ backendSelection: { kind: "pinned", backend: "builtin" } }), - ); - const backendRow = [...pinned.querySelectorAll(".settings-row")].find((row) => - row.textContent?.includes("Retrieval backend"), - ); - expect(backendRow?.textContent).toContain("Default: Built-in"); - expect(backendRow?.querySelector('button[aria-label="Reset to default"]')).not.toBeNull(); - }); - - it("keeps a malformed explicit backend visible and repairable", () => { - const onBackendReset = vi.fn(); - const container = renderInto( - createProps({ - backendSelection: { kind: "invalid", backend: null, value: "retired-backend" }, - onBackendReset, - }), - ); - const backendRow = [...container.querySelectorAll(".settings-row")].find((row) => - row.textContent?.includes("Retrieval backend"), - ); - const active = backendRow?.querySelector("wa-radio.settings-segmented__btn--active"); - - expect(backendRow?.textContent).toContain("Invalid configured value"); - expect(backendRow?.textContent).toContain("Default: Built-in"); - expect(backendRow?.textContent).not.toContain("Using default: Built-in"); - expect(active?.getAttribute("value")).toBe("__invalid__"); - backendRow?.querySelector('button[aria-label="Reset to default"]')?.click(); - expect(onBackendReset).toHaveBeenCalledOnce(); - }); - - it("routes engine and backend restore actions to their reset callbacks", () => { - const onEngineReset = vi.fn(); - const onBackendReset = vi.fn(); - const container = renderInto( - createProps({ - engineSelection: { kind: "pinned", engineId: "memory-core" }, - backendSelection: { kind: "pinned", backend: "qmd" }, - onEngineReset, - onBackendReset, - }), - ); - const resets = container.querySelectorAll( - 'button[aria-label="Reset to default"]', - ); - - resets[0]?.click(); - resets[1]?.click(); - - expect(onEngineReset).toHaveBeenCalledOnce(); - expect(onBackendReset).toHaveBeenCalledOnce(); - }); - it("renders enabled and disabled add-ons as accessible toggles", () => { const container = renderInto(createProps()); @@ -445,7 +372,7 @@ describe("memoryTabForRoute", () => { }); it("routes old tabless schema links to Settings without changing the plain landing", () => { - expect(memoryTabForRoute({ section: "memory", targetBlockId: "memory-backend" })).toBe( + expect(memoryTabForRoute({ section: "memory", targetBlockId: "config-section-memory" })).toBe( "settings", ); expect(memoryTabForRoute({ targetBlockId: "config-section-memory" })).toBe("settings"); @@ -458,7 +385,7 @@ describe("memoryTabForRoute", () => { pathname: "/settings/memory/dreams", tab: "settings", section: "memory", - targetBlockId: "memory-backend", + targetBlockId: "config-section-memory", }), ).toBe("dreams"); expect(memoryTabForRoute({ pathname: "/settings/memory" })).toBe("overview"); @@ -466,66 +393,17 @@ describe("memoryTabForRoute", () => { }); describe("memorySchemaKeysForTab", () => { - it("reveals qmd sub-config only when qmd is the selected backend", () => { - expect(memorySchemaKeysForTab("overview", "builtin")).toEqual([]); - expect(memorySchemaKeysForTab("memories", "builtin")).toEqual([]); - expect(memorySchemaKeysForTab("dreams", "qmd")).toEqual([]); - expect(memorySchemaKeysForTab("settings", "builtin")).toEqual(["citations", "search"]); - expect(memorySchemaKeysForTab("settings", "qmd")).toEqual(["citations", "qmd", "search"]); - // No applicable backend: qmd's sub-config belongs to a backend nothing reads. - expect(memorySchemaKeysForTab("settings", null)).toEqual(["citations", "search"]); + it("shows builtin memory settings only on Settings", () => { + expect(memorySchemaKeysForTab("overview")).toEqual([]); + expect(memorySchemaKeysForTab("memories")).toEqual([]); + expect(memorySchemaKeysForTab("dreams")).toEqual([]); + expect(memorySchemaKeysForTab("settings")).toEqual(["citations", "search"]); }); }); describe("memoryVisibleSchemaKeys", () => { - it("hides qmd until qmd is the selected backend and backend when no engine reads it", () => { - expect([...memoryVisibleSchemaKeys("builtin")].toSorted()).toEqual([ - "backend", - "citations", - "search", - ]); - expect([...memoryVisibleSchemaKeys("qmd")].toSorted()).toEqual([ - "backend", - "citations", - "qmd", - "search", - ]); - expect([...memoryVisibleSchemaKeys(null)].toSorted()).toEqual(["citations", "search"]); - }); -}); - -describe("resolveMemoryBackend", () => { - it("reports a backend only for the memory-core slot owner", () => { - expect(resolveMemoryBackend({})).toBe("builtin"); - expect(resolveMemoryBackend({ memory: { backend: "qmd" } })).toBe("qmd"); - // Another engine owns the slot, so nothing reads memory.backend. - expect( - resolveMemoryBackend({ - memory: { backend: "qmd" }, - plugins: { slots: { memory: "memory-lancedb" } }, - }), - ).toBeNull(); - expect(resolveMemoryBackend({ plugins: { slots: { memory: "none" } } })).toBeNull(); - }); - - it("preserves whether the effective backend is inherited or pinned", () => { - expect(resolveMemoryBackendSelection({})).toEqual({ kind: "default", backend: "builtin" }); - expect(resolveMemoryBackendSelection({ memory: { backend: "builtin" } })).toEqual({ - kind: "pinned", - backend: "builtin", - }); - expect(resolveMemoryBackendSelection({ memory: { backend: "qmd" } })).toEqual({ - kind: "pinned", - backend: "qmd", - }); - for (const value of ["retired-backend", null, { name: "qmd" }]) { - expect(resolveMemoryBackendSelection({ memory: { backend: value } })).toEqual({ - kind: "invalid", - backend: null, - value, - }); - } - expect(resolveMemoryBackend({ memory: { backend: "retired-backend" } })).toBeNull(); + it("matches the builtin Settings editor", () => { + expect(memoryVisibleSchemaKeys()).toEqual(["citations", "search"]); }); }); @@ -536,10 +414,8 @@ describe("narrowMemorySchema", () => { memory: { type: "object", properties: { - backend: { type: "string" }, citations: { type: "string" }, search: { type: "object" }, - qmd: { type: "object" }, }, }, tools: { type: "object" }, diff --git a/ui/src/pages/config/memory.ts b/ui/src/pages/config/memory.ts index 3e128ff9e218..ed17797db7aa 100644 --- a/ui/src/pages/config/memory.ts +++ b/ui/src/pages/config/memory.ts @@ -18,9 +18,6 @@ import type { PluginCatalogItem } from "../../lib/plugins/index.ts"; import { selectedEngineId, DEFAULT_MEMORY_ENGINE_ID, - MEMORY_BACKEND_ANCHOR_ID, - type MemoryBackend, - type MemoryBackendSelection, type MemoryEngineSelection, type MemoryTab, } from "./memory-schema.ts"; @@ -168,11 +165,6 @@ type MemoryViewProps = { engineOutcome: MemoryEngineOutcome | null; onEngineChange: (engineId: string | null) => void; onEngineReset: () => void; - /** null when the slot owner runs its own retrieval, so this row does not apply. */ - backendSelection: MemoryBackendSelection | null; - backendBusy: boolean; - onBackendChange: (backend: MemoryBackend) => void; - onBackendReset: () => void; addons: readonly MemoryAddonRow[]; canToggleAddons: boolean; onAddonChange: (pluginId: string, enabled: boolean) => void; @@ -198,7 +190,6 @@ const MEMORY_PANEL_ID = "memory-settings-panel"; const MEMORY_DOCS_URL = "https://docs.openclaw.ai/concepts/memory"; const MEMORY_ENGINE_OFF = ""; -const MEMORY_BACKEND_INVALID = "__invalid__"; function engineHintKey(selection: MemoryEngineSelection): string { switch (selection.kind) { @@ -315,71 +306,6 @@ function renderDisabledEngineRow(props: MemoryViewProps, engineId: string | null }); } -function renderBackendSection(props: MemoryViewProps) { - // builtin/qmd is resolved by the memory runtime the slot owner registers - // (resolveActiveMemoryBackendConfig in src/plugins/memory-runtime.ts). An - // engine that registers none ignores it, so the row must not appear there. - if (props.backendSelection === null) { - return nothing; - } - const invalid = props.backendSelection.kind === "invalid"; - const backend = props.backendSelection.backend; - const defaultState = renderSettingsDefaultState({ - value: t("memoryPage.backend.builtin"), - overridden: props.backendSelection.kind !== "default", - disabled: props.backendBusy, - onReset: props.onBackendReset, - }); - const controlValue = - props.backendSelection.kind === "invalid" - ? MEMORY_BACKEND_INVALID - : props.backendSelection.backend; - const options: Array<{ - value: MemoryBackend | typeof MEMORY_BACKEND_INVALID; - label: unknown; - }> = []; - if (invalid) { - options.push({ value: MEMORY_BACKEND_INVALID, label: t("memoryPage.backend.invalid") }); - } - options.push( - { value: "builtin", label: t("memoryPage.backend.builtin") }, - { value: "qmd", label: t("memoryPage.backend.qmd") }, - ); - // Anchor target for settings search: `backend` is curated out of the schema - // editor, so it has no `#config-section-*` id of its own to scroll to. - return html`
- ${renderSettingsSection( - { title: t("memoryPage.backend.title"), description: t("memoryPage.backend.description") }, - renderSettingsRow({ - title: t("memoryPage.backend.rowTitle"), - description: html` - ${invalid - ? t("memoryPage.backend.invalidHint") - : backend === "qmd" - ? t("memoryPage.backend.qmdHint") - : t("memoryPage.backend.builtinHint")} - ${defaultState.description} - `, - stacked: true, - control: html` - ${defaultState.action} - ${renderSettingsSegmented({ - value: controlValue, - options, - disabled: props.backendBusy, - ariaLabel: t("memoryPage.backend.rowTitle"), - onChange: (value) => { - if (value !== MEMORY_BACKEND_INVALID) { - props.onBackendChange(value); - } - }, - })} - `, - }), - )} -
`; -} - // Only `enabled` is a positive claim; the other three are deliberately muted so // an unread catalog never looks like a decided "off". function renderAddonStatus(state: MemoryPluginState) { @@ -447,7 +373,7 @@ function renderAddonsSection(props: MemoryViewProps) { function renderSettingsTab(props: MemoryViewProps) { return html`
- ${renderEngineSection(props)} ${renderBackendSection(props)} ${renderAddonsSection(props)} + ${renderEngineSection(props)} ${renderAddonsSection(props)}

${t("memoryPage.search.intro")}

${props.editor} diff --git a/ui/src/pages/config/settings-search.test.ts b/ui/src/pages/config/settings-search.test.ts index cd39f51592ca..db40c394681d 100644 --- a/ui/src/pages/config/settings-search.test.ts +++ b/ui/src/pages/config/settings-search.test.ts @@ -59,53 +59,6 @@ describe("findSettingsSearchBlocks", () => { ]); }); - it("routes a curated backend match to the anchor above the editor", () => { - const matches = findSettingsSearchBlocks({ - query: "backend", - schema: { - type: "object", - properties: { - memory: { - type: "object", - properties: { - backend: { type: "string", title: "Backend" }, - }, - }, - }, - }, - value: { memory: { backend: "builtin" } }, - uiHints: { "memory.backend": { advanced: false } }, - }); - - // `backend` is curated out of the schema editor, so #config-section-memory - // would scroll past the control the searcher matched. - expect(matches).toEqual([ - expect.objectContaining({ - routeId: "memory", - pathname: "/settings/memory/settings", - hash: "#memory-backend", - }), - ]); - - expect( - findSettingsSearchBlocks({ - query: "backend", - schema: { - type: "object", - properties: { - memory: { - type: "object", - properties: { backend: { type: "string", title: "Backend" } }, - }, - }, - }, - value: { memory: { backend: "builtin" } }, - uiHints: { "memory.backend": { advanced: false } }, - basePath: "/ui", - }), - ).toEqual([expect.objectContaining({ pathname: "/ui/settings/memory/settings" })]); - }); - it("opens every Memory schema match on the merged Settings tab", () => { const memorySchema = { type: "object", @@ -113,7 +66,6 @@ describe("findSettingsSearchBlocks", () => { memory: { type: "object", properties: { - backend: { type: "string", title: "Backend" }, search: { type: "object", properties: { embeddingModel: { type: "string", title: "Embedding model" } }, @@ -123,7 +75,6 @@ describe("findSettingsSearchBlocks", () => { }, }; const uiHints = { - "memory.backend": { advanced: false }, "memory.search": { advanced: false }, "memory.search.embeddingModel": { advanced: false }, }; @@ -154,58 +105,6 @@ describe("findSettingsSearchBlocks", () => { hash: "#config-section-memory", }), ]); - - // Curated-only: Settings still wins, with the row-specific anchor. - const backendOnly = findSettingsSearchBlocks({ - query: "backend", - schema: memorySchema, - value: {}, - uiHints, - }); - expect(backendOnly).toEqual([ - expect.objectContaining({ - routeId: "memory", - pathname: "/settings/memory/settings", - hash: "#memory-backend", - }), - ]); - }); - - it("offers memory.qmd only while qmd is the backend the page reveals", () => { - const memorySchema = { - type: "object", - properties: { - memory: { - type: "object", - properties: { - backend: { type: "string", title: "Backend" }, - qmd: { - type: "object", - properties: { binaryPath: { type: "string", title: "QMD binary path" } }, - }, - }, - }, - }, - }; - const uiHints = { - "memory.backend": { advanced: false }, - "memory.qmd": { advanced: false }, - "memory.qmd.binaryPath": { advanced: false }, - }; - const find = (value: Record) => - findSettingsSearchBlocks({ query: "qmd binary path", schema: memorySchema, value, uiHints }); - - // Overview's editor omits memory.qmd under the built-in backend, so a hit - // there would open a page that cannot show the matched field. - expect(find({ memory: { backend: "builtin" } })).toEqual([]); - // Another plugin owns the slot: memory.backend and its sub-config are unread. - expect(find({ plugins: { slots: { memory: "memory-lancedb" } } })).toEqual([]); - expect(find({ memory: { backend: "qmd" } })).toEqual([ - expect.objectContaining({ - routeId: "memory", - pathname: "/settings/memory/settings", - }), - ]); }); it("routes moved static blocks to their dedicated pages", () => { diff --git a/ui/src/pages/config/settings-search.ts b/ui/src/pages/config/settings-search.ts index ede8a22da1e7..6bc3360edcd7 100644 --- a/ui/src/pages/config/settings-search.ts +++ b/ui/src/pages/config/settings-search.ts @@ -10,12 +10,7 @@ import { schemaType, type JsonSchema } from "../../components/config-form.shared import { splitConfigSchemaByTier } from "../../components/config-form.tiers.ts"; import { t } from "../../i18n/index.ts"; import { configPageForSection } from "./config-sections.ts"; -import { - memoryVisibleSchemaKeys, - resolveMemoryBackend, - MEMORY_BACKEND_ANCHOR_ID, - MEMORY_CURATED_SCHEMA_KEYS, -} from "./memory-schema.ts"; +import { memoryVisibleSchemaKeys } from "./memory-schema.ts"; import { SETTINGS_SEARCH_TARGETS, type SettingsSearchTarget } from "./settings-targets.ts"; type StaticSettingsBlock = SettingsSearchBlock & { @@ -37,20 +32,14 @@ function resolveStaticSettingsBlock(block: SettingsSearchTarget): StaticSettings } /** - * The Memory page hides `memory.*` children the current engine/backend makes - * inapplicable — `memory.qmd` only renders once qmd is the selected backend. - * Matching the raw section would offer a destination whose editor omits the very - * field that matched, so search sees only what the page can show. + * Search only the `memory.*` children surfaced by the dedicated Memory page. */ -function visibleMemorySchema( - sectionSchema: JsonSchema, - config: Record, -): JsonSchema { +function visibleMemorySchema(sectionSchema: JsonSchema): JsonSchema { const properties = sectionSchema.properties; if (!properties) { return sectionSchema; } - const visible = new Set(memoryVisibleSchemaKeys(resolveMemoryBackend(config))); + const visible = new Set(memoryVisibleSchemaKeys()); return { ...sectionSchema, properties: Object.fromEntries( @@ -59,49 +48,6 @@ function visibleMemorySchema( }; } -/** - * Every memory schema field lives on Settings. Backend remains a curated row, - * so its unique matches use that row's anchor instead of the editor section. - */ -function memoryDestination(params: { - key: string; - schema: JsonSchema; - value: unknown; - hints: ConfigUiHints; - query: string; - editorHash: string; -}): { hash: string } { - const properties = params.schema.properties; - if (!properties) { - return { hash: params.editorHash }; - } - const sliceMatches = (keys: readonly string[]) => { - const sliced = Object.fromEntries( - Object.entries(properties).filter(([child]) => keys.includes(child)), - ); - return ( - Object.keys(sliced).length > 0 && - matchesConfigSectionSearch({ - key: params.key, - schema: { ...params.schema, properties: sliced }, - value: params.value, - hints: params.hints, - query: params.query, - textMatcher: settingsSearchTextMatches, - }) - ); - }; - const onlySliceMatches = (keys: readonly string[]) => - sliceMatches(keys) && - !sliceMatches(Object.keys(properties).filter((child) => !keys.includes(child))); - // memoryVisibleSchemaKeys drops `backend` when no engine renders the curated - // row, so a match here always has the anchor on the page to scroll to. - if (onlySliceMatches(MEMORY_CURATED_SCHEMA_KEYS)) { - return { hash: `#${MEMORY_BACKEND_ANCHOR_ID}` }; - } - return { hash: params.editorHash }; -} - export function findSettingsSearchBlocks(params: { query: string; schema: unknown; @@ -133,7 +79,7 @@ export function findSettingsSearchBlocks(params: { for (const [key, rawSectionSchema] of Object.entries(schema.properties)) { const routeId = configPageForSection(key); const sectionSchema = - routeId === "memory" ? visibleMemorySchema(rawSectionSchema, value) : rawSectionSchema; + routeId === "memory" ? visibleMemorySchema(rawSectionSchema) : rawSectionSchema; const meta = SECTION_META[key]; const tierSplit = splitConfigSchemaByTier({ schema: sectionSchema, @@ -161,17 +107,7 @@ export function findSettingsSearchBlocks(params: { } const encodedKey = encodeURIComponent(key); const editorHash = `#config-section-${encodedKey}`; - const destination = - routeId === "memory" - ? memoryDestination({ - key, - schema: sectionSchema, - value: value[key], - hints: params.uiHints, - query: params.query, - editorHash, - }) - : { search: "", hash: editorHash }; + const destination = { search: "", hash: editorHash }; matches.push( routeId === "memory" ? {