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
This commit is contained in:
Peter Steinberger 2026-08-09 03:05:47 -07:00 committed by GitHub
parent 6f56d797d7
commit 8b0735e89f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
208 changed files with 1079 additions and 24137 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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[] = []) {

View file

@ -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

View file

@ -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

View file

@ -675,6 +675,10 @@
"source": "Memory overview",
"target": "记忆概览"
},
{
"source": "QMD memory backend removal",
"target": "QMD 记忆后端移除"
},
{
"source": "Active memory",
"target": "主动记忆"

View file

@ -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",

View file

@ -27,13 +27,13 @@ openclaw memory status [--agent <id>] [--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

View file

@ -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

View file

@ -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:

View file

@ -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.<id>.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.<id>.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

View file

@ -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.

View file

@ -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 <query> [-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)

View file

@ -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/<agentId>/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.
<Info>
The first search may be slow - QMD auto-downloads GGUF models (~2 GB) for
reranking and query expansion on the first `qmd query` run.
</Info>
## 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/<collection>/<relative-path>` 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/<id>/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: <path>#L<line>` (or `#L<start>-L<end>`) 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.

View file

@ -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

View file

@ -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
<CardGroup cols={3}>
<Card title="Builtin (default)" icon="database" href="/concepts/memory-builtin">
SQLite-based. Works out of the box with keyword search, vector similarity, and
hybrid search. No extra dependencies.
</Card>
<Card title="QMD" icon="search" href="/concepts/memory-qmd">
Local-first sidecar with reranking, query expansion, and the ability to index
directories outside the workspace.
</Card>
<Card title="Honcho" icon="brain" href="/concepts/memory-honcho">
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.

View file

@ -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)

View file

@ -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",

View file

@ -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:

View file

@ -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.

View file

@ -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.
</Accordion>
<Accordion title="13b. Memory search readiness">
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"`.

View file

@ -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.
</Warning>
### 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.

View file

@ -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 |

View file

@ -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:

View file

@ -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
<Card title="Builtin engine" href="/concepts/memory-builtin">
Default SQLite backend.
</Card>
<Card title="QMD engine" href="/concepts/memory-qmd">
Local-first sidecar.
</Card>
<Card title="Memory search" href="/concepts/memory-search">
Search pipeline and tuning.
</Card>
@ -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:
<Tabs>
<Tab title="Builtin backend">
```json5
{
memory: {
search: {
experimental: { sessionMemory: true },
sources: ["memory", "sessions"],
},
},
tools: {
sessions: { visibility: "agent" },
},
}
```
</Tab>
<Tab title="QMD backend">
```json5
{
memory: {
backend: "qmd",
search: {
experimental: { sessionMemory: true },
sources: ["memory", "sessions"],
},
qmd: {
sessions: { enabled: true },
},
},
tools: {
sessions: { visibility: "agent" },
},
}
```
</Tab>
</Tabs>
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.
<Note>
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.
</Note>
<AccordionGroup>
<Accordion title="Limits">
| 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 |
</Accordion>
<Accordion title="Scope">
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:<id>:`.
</Accordion>
<Accordion title="Citations">
`memory.citations` applies to all backends:
| Value | Behavior |
| ------------------ | ------------------------------------------------------ |
| `auto` (default) | Include `Source: <path#line>` footer in snippets |
| `on` | Always include footer |
| `off` | Omit footer (path still passed to agent internally) |
</Accordion>
</AccordionGroup>
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: <path#line>` when useful |
| `on` | Always include the source footer |
| `off` | Omit the footer; the path remains available internally |
---

View file

@ -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,

View file

@ -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 {

View file

@ -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);

View file

@ -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,

View file

@ -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."
}
}
}

View file

@ -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,

View file

@ -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: [] });
});

View file

@ -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(() => []),

View file

@ -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,

View file

@ -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");

View file

@ -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,
];

View file

@ -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";

View file

@ -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<string | null> {
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,

View file

@ -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 {

View file

@ -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) : "<unknown>",
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) {

View file

@ -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<void>) {
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<void>) {
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();
});
});

View file

@ -106,7 +106,7 @@ describe("concept vocabulary", () => {
summarizeConceptTagScriptCoverage([
["routeur", "sauvegarde"],
["路由器", "备份"],
["qmd", "路由器"],
["vectors", "路由器"],
["сервер"],
]),
).toEqual({

View file

@ -247,7 +247,6 @@ const PROTECTED_GLOSSARY = [
"kv",
"network",
"openai",
"qmd",
"router",
"s3",
"vlan",

View file

@ -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", () => {

View file

@ -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)
) {

View file

@ -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}`;

View file

@ -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<unknown[]>;
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<ReturnType<typeof getMemorySearchManager>>["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<string, unknown> | undefined;
@ -60,7 +55,7 @@ const stubManager = {
search: vi.fn(async (_query: string, opts?: Parameters<SearchImpl>[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<MemoryReadResult>;
}): void {
backend = overrides?.backend ?? "builtin";
resolvedBackend = undefined;
workspaceDir = "/workspace";
statusDirty = false;
customStatus = undefined;

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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<typeof import("openclaw/plugin-sdk/memory-core-host-engine-qmd")>();
await importOriginal<typeof import("openclaw/plugin-sdk/memory-core-host-engine-sessions")>();
const basename = (filePath: string) => filePath.split(/[\\/]/).pop() ?? filePath;
return {
...actual,

View file

@ -61,7 +61,6 @@ describe("memory manager reindex recovery", () => {
}): OpenClawConfig {
return {
memory: {
backend: "builtin",
search: {
provider: params.provider ?? "openai",
model: "mock-embed",

View file

@ -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,

View file

@ -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<QmdRuntimeCollectionValidationCacheContext>,
) {}
consumePendingValidationDebug(): QmdCollectionValidationDebug | undefined {
const debug = this.pendingValidationDebug;
this.pendingValidationDebug = undefined;
return debug;
}
async ensureCollections(options: {
force?: boolean;
debugContext?: QmdSearchRuntimeDebugContext;
lease: PluginStateLeaseContext;
}): Promise<void> {
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<boolean> {
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<boolean> {
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<void> {
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<boolean> {
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<Map<string, ListedQmdCollection>> {
const existing = new Map<string, ListedQmdCollection>();
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<string, ListedQmdCollection>;
addErrorMessage: string;
lease: PluginStateLeaseContext;
}): Promise<boolean> {
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<string, ListedQmdCollection>,
lease: PluginStateLeaseContext,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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})`);
}
}

View file

@ -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<string, ListedQmdCollection> {
const listed = new Map<string, ListedQmdCollection>();
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<string, ListedQmdCollection>;
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);
}

View file

@ -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<void> | 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<McporterState>(MCPORTER_STATE_KEY, () => ({
coldStartWarned: false,
daemonStart: null,
}));
}
async function runInQmdCommandPhase<T>(
report: QmdCommandPhaseReporter | undefined,
task: () => Promise<T>,
): Promise<T> {
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<QmdQueryResult[]> {
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<QmdQueryResult[]> {
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<string, unknown> = 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<QmdQueryResult[]> {
const bestByDocId = new Map<string, QmdQueryResult>();
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<void> {
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 ");
}

View file

@ -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")
);
}

View file

@ -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"]);
});
});

View file

@ -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"];
}

View file

@ -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<string, QmdDocLocation>();
constructor(
private readonly workspaceDir: string,
private readonly collectionRoots: ReadonlyMap<string, QmdCollectionRoot>,
private readonly ensureDb: () => SqliteDatabase,
private readonly sessionCollectionsReadable: boolean,
) {}
clearCache(): void {
this.docPathCache.clear();
}
async resolveDocLocation(docid?: string, hints?: QmdDocHints): Promise<QmdDocLocation | null> {
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/<collection>/...` 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)}`);
}

View file

@ -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<typeof resolveMemorySearchSyncConfig>;
contextLimits: ReturnType<typeof resolveAgentContextLimits>;
};
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<typeof resolveAgentContextLimits>;
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<typeof resolveMemorySearchSyncConfig>;
protected readonly managedCollectionNames: string[];
protected readonly collectionRoots = new Map<string, CollectionRoot>();
protected readonly sources = new Set<MemorySource>();
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<void> | null = null;
protected queuedForcedUpdate: Promise<void> | null = null;
protected queuedForcedRuns = 0;
protected dirty = false;
protected closed = false;
protected mode: QmdManagerMode = "full";
protected readonly closeSignal: Promise<void>;
protected resolveCloseSignal!: () => void;
protected readonly closeAbortController = new AbortController();
protected qmdRuntimeIdentityPromise: Promise<string> | 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<string>();
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<void>((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<void> {
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<QmdRuntimeCollectionValidationCacheContext> {
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<QmdRuntimeMultiCollectionProbeCacheContext> {
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<string> {
if (signal) {
return this.readQmdRuntimeIdentity(signal);
}
this.qmdRuntimeIdentityPromise ??= this.readQmdRuntimeIdentity();
return this.qmdRuntimeIdentityPromise;
}
protected async readQmdRuntimeIdentity(signal?: AbortSignal): Promise<string> {
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<void> {
await this.withQmdStoreWriteLease(async (lease) => {
await this.collectionController.ensureCollections({ ...options, lease });
}, options?.parentSignal);
}
protected async tryRepairNullByteCollections(
err: unknown,
reason: string,
lease: PluginStateLeaseContext,
): Promise<boolean> {
return await this.collectionController.tryRepairNullByteCollections(err, reason, lease);
}
protected async tryRepairDuplicateDocumentConstraint(
err: unknown,
reason: string,
lease: PluginStateLeaseContext,
): Promise<boolean> {
return await this.collectionController.tryRepairDuplicateDocumentConstraint(err, reason, lease);
}
protected computeManagedCollectionNames(): string[] {
const seen = new Set<string>();
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<void> {
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<void>;
protected abstract ensureWatcher(): void;
protected abstract shouldScheduleEmbedTimer(): boolean;
protected abstract resolveEmbedStartupJitterMs(): number;
protected abstract withQmdStoreWriteLease<T>(
task: (lease: PluginStateLeaseContext) => Promise<T>,
parentSignal?: AbortSignal,
): Promise<T>;
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 };

View file

@ -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<void>;
};
type QmdUpdateQueueState = {
tails: Map<string, Promise<void>>;
};
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<QmdEmbedQueueState>(QMD_EMBED_QUEUE_KEY, () => ({
tail: Promise.resolve(),
}));
}
export function getQmdUpdateQueueState(): QmdUpdateQueueState {
return resolveGlobalSingleton<QmdUpdateQueueState>(QMD_UPDATE_QUEUE_KEY, () => ({
tails: new Map<string, Promise<void>>(),
}));
}
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);
}

View file

@ -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<MemoryReadResult> {
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<ReturnType<typeof statRegularFile>>;
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<MemoryEmbeddingProbeResult> {
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<boolean> {
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<MemorySource, { files: number; chunks: number }>();
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,
})),
};
}
}
}

View file

@ -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<void> | null = null;
protected ensureWatcher(): void {
if (!this.syncSettings?.watch || this.watcher || this.closed) {
return;
}
const watchPaths = new Set<string>();
const watchRoots = new Set<string>();
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<void> {
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<void> {
if (this.mode === "cli" || !this.syncSettings?.onSearch || !this.dirty) {
return;
}
await this.sync({ reason: "search" });
}
async close(): Promise<void> {
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<void> {
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;
}
}
}

View file

@ -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<MemorySearchRuntimeDebug["qmd"]> = {};
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<boolean> {
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<QmdQueryResult[]> {
return await this.commands.search(args, command, signal, reportCommandPhase);
}
protected async resolveDocLocation(
docid?: string,
hints?: { preferredCollection?: string; preferredFile?: string },
): Promise<DocLocation | null> {
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() || "<none>";
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<MemorySource, MemorySearchResult[]>();
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<void> {
const pending = this.pendingUpdate;
if (!pending) {
return;
}
// Release the losing timer when the pending update settles first.
let timeout: ReturnType<typeof setTimeout> | undefined;
const wait = new Promise<void>((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<string[][]> {
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<boolean> {
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<void> {
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<QmdQueryResult[]> {
qmdManagerLog.debug(
`qmd ${command} multi-source collection grouping active (${collectionGroups.length} groups)`,
);
const bestByResultKey = new Map<string, QmdQueryResult>();
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<string, string[]>();
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);
}
}

View file

@ -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<MemorySearchResult[]> {
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<QmdQueryResult[]> => {
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));
}
}

View file

@ -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<void>;
runQmdUpdateWithRetry: (reason: string, lease: PluginStateLeaseContext) => Promise<void>;
};
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<QmdRetryHarness["runQmdUpdateOnce"]>()
.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<QmdRetryHarness["runQmdUpdateOnce"]>()
.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<QmdRetryHarness["runQmdUpdateOnce"]>(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<QmdRetryHarness["runQmdUpdateOnce"]>()
.mockRejectedValue(error);
const manager = createQmdRetryHarness(runQmdUpdateOnce);
await expect(
manager.runQmdUpdateWithRetry(reason, createQmdRetryLease(new AbortController().signal)),
).rejects.toBe(error);
expect(runQmdUpdateOnce).toHaveBeenCalledOnce();
}
});
});

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void>): Promise<boolean> {
const queue = getQmdEmbedQueueState();
const previous = queue.tail;
let releaseCurrent!: () => void;
const current = new Promise<void>((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<T>(
task: (lease: PluginStateLeaseContext) => Promise<T>,
): Promise<T> {
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<T>(
task: (lease: PluginStateLeaseContext) => Promise<T>,
parentSignal?: AbortSignal,
): Promise<T> {
// 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<T>(
task: (lease: PluginStateLeaseContext) => Promise<T>,
): Promise<T> {
const queue = getQmdUpdateQueueState();
const key = this.qmdDir;
const previous = queue.tails.get(key) ?? Promise.resolve();
let releaseCurrent!: () => void;
const current = new Promise<void>((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<void> {
this.queuedForcedRuns += 1;
if (!this.queuedForcedUpdate) {
this.queuedForcedUpdate = this.drainForcedUpdates(reason).finally(() => {
this.queuedForcedUpdate = null;
});
}
return this.queuedForcedUpdate;
}
protected async drainForcedUpdates(reason: string): Promise<void> {
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<void> {
await this.sessionExporter?.exportSessions(lease);
}
protected refreshSessionArtifactDocIds(lease: PluginStateLeaseContext): void {
this.sessionExporter?.refreshArtifactDocIds(lease);
}
}

View file

@ -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<typeof vi.fn> };
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<typeof import("node:child_process")>("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 <T>(
options: { signal?: AbortSignal },
run: (lease: { signal: AbortSignal; assertOwned: () => void }) => Promise<T>,
) =>
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<QmdMemoryManager>();
function trackManager<T extends QmdMemoryManager | null>(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/<collection>/ 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,
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -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<void> | null;
waitForPendingUpdateBeforeSearch: () => Promise<void>;
};
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();
}
});
});

View file

@ -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<QmdMemoryManager | null> {
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;
}
}

View file

@ -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<void> {
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<T>(run: (workspaceDir: string) => Promise<T>): Promise<T> {
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",
});
});
});
});

View file

@ -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<T> =
| {
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<T extends QmdRuntimeCacheEntryBase>(
workspaceDir: string,
namespace: string,
keyHash: string,
): { key: string; store: PluginStateKeyedStore<T> } {
return {
key: memoryCoreWorkspaceEntryKey(workspaceDir, `${namespace}:${keyHash}`),
store: openMemoryCoreStateStore<T>({ namespace, maxEntries: QMD_RUNTIME_CACHE_MAX_ENTRIES }),
};
}
type QmdRuntimeCacheEnvelope = {
record: Record<string, unknown>;
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<T extends QmdRuntimeCacheEntryBase>(params: {
workspaceDir: string;
namespace: string;
keyHash: string;
nowMs: number;
normalize: (value: unknown, nowMs: number, keyHash: string) => T | undefined;
}): Promise<QmdRuntimeCacheResult<T>> {
try {
const { store, key } = resolveQmdRuntimeCache<T>(
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<T extends QmdRuntimeCacheEntryBase>(params: {
workspaceDir: string;
namespace: string;
keyHash: string;
nowMs: number;
ttlMs: number;
payload: Omit<T, keyof QmdRuntimeCacheEntryBase>;
}): Promise<boolean> {
try {
const { store, key } = resolveQmdRuntimeCache<T>(
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<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
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<boolean> {
return await writeQmdRuntimeCache<QmdRuntimeCacheCollectionValidationEntry>({
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<QmdRuntimeCacheResult<QmdRuntimeCacheMultiCollectionProbeEntry>> {
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<boolean> {
return await writeQmdRuntimeCache<QmdRuntimeCacheMultiCollectionProbeEntry>({
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<void> {
try {
const { store, key } = resolveQmdRuntimeCache<QmdRuntimeCacheMultiCollectionProbeEntry>(
params.workspaceDir,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
buildQmdMultiCollectionProbeCacheContextHash(params),
);
await store.delete(key);
} catch {
// fail open
}
}

View file

@ -1,14 +0,0 @@
import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
export type QmdCollectionValidationDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["collectionValidation"]
>;
type QmdMultiCollectionProbeDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["multiCollectionProbe"]
>;
type QmdSearchPlanDebug = NonNullable<NonNullable<MemorySearchRuntimeDebug["qmd"]>["searchPlan"]>;
export type QmdSearchRuntimeDebugContext = {
collectionValidation?: QmdCollectionValidationDebug;
multiCollectionProbe?: QmdMultiCollectionProbeDebug;
searchPlan?: QmdSearchPlanDebug;
};

View file

@ -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");
});
});
});

View file

@ -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");
});
});
});

View file

@ -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<string, ExportedSessionState>();
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<void> {
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<string>();
const tracked = new Set<string>();
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`;
}

View file

@ -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<PluginStateLeaseRunner, number>;
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);
}

View file

@ -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);

View file

@ -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<T>(params: {
timeoutMs: number;
parentSignal?: AbortSignal;
run: (
signal: AbortSignal,
controlDeadline: (action: MemorySearchDeadlineAction) => void,
) => Promise<T>;
run: (signal: AbortSignal) => Promise<T>;
}): Promise<T> {
if (params.parentSignal?.aborted) {
throw resolveMemorySearchAbortError(params.parentSignal);
@ -36,54 +25,27 @@ export async function runMemorySearchWithDeadline<T>(params: {
const timeoutOutcome = { type: "timeout" } as const;
const parentAbortOutcome = { type: "parent-abort" } as const;
let timer: ReturnType<typeof setTimeout> | 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<typeof timeoutOutcome>((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<typeof parentAbortOutcome>((resolve) => {
const onAbort = () => {
acceptDeadlineUpdates = false;
resolve(parentAbortOutcome);
controller.abort(resolveMemorySearchAbortError(parentSignal));
};
@ -91,7 +53,7 @@ export async function runMemorySearchWithDeadline<T>(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<T>(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);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -594,6 +594,53 @@ async function collectRetiredQmdFileLocks(stateDir: string): Promise<string[]> {
return lockPaths;
}
async function collectRetiredQmdWorkspaceHomes(stateDir: string): Promise<string[]> {
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",

View file

@ -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,

View file

@ -132,9 +132,6 @@ async function collectVectorProviderFindings(params: {
env: NodeJS.ProcessEnv;
stateDir: string;
}): Promise<VectorProviderFinding[]> {
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

View file

@ -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 });
}
});
});

View file

@ -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;
}

View file

@ -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,
"",

View file

@ -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(() =>

View file

@ -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";

View file

@ -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";

View file

@ -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<string, TestSessionEntry> = {
"agent:peer:only": {
sessionId: "w1",
updatedAt: 1,
sessionFile: "/tmp/sessions/w1.jsonl",
},
};
let combinedSessionStore: Record<string, TestSessionEntry> = crossAgentStore;
const tempRoots: string[] = [];
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-hit")>();
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<string> {
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<typeof resolveQmdSessionArtifactIdentity>[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]);
});
});

View file

@ -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<string, TestSessionEntry> = {
},
};
let combinedSessionStore: Record<string, TestSessionEntry> = 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 () => {

View file

@ -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,

View file

@ -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<ShortTermAuditSummary> {
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 } : {}),
};
}

View file

@ -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 = {

View file

@ -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,

Some files were not shown because too many files have changed in this diff Show more