mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 10:26:05 +00:00
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:
parent
6f56d797d7
commit
8b0735e89f
208 changed files with 1079 additions and 24137 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
1
.github/workflows/codeql.yml
vendored
1
.github/workflows/codeql.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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[] = []) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -675,6 +675,10 @@
|
|||
"source": "Memory overview",
|
||||
"target": "记忆概览"
|
||||
},
|
||||
{
|
||||
"source": "QMD memory backend removal",
|
||||
"target": "QMD 记忆后端移除"
|
||||
},
|
||||
{
|
||||
"source": "Active memory",
|
||||
"target": "主动记忆"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => []),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ describe("concept vocabulary", () => {
|
|||
summarizeConceptTagScriptCoverage([
|
||||
["routeur", "sauvegarde"],
|
||||
["路由器", "备份"],
|
||||
["qmd", "路由器"],
|
||||
["vectors", "路由器"],
|
||||
["сервер"],
|
||||
]),
|
||||
).toEqual({
|
||||
|
|
|
|||
|
|
@ -247,7 +247,6 @@ const PROTECTED_GLOSSARY = [
|
|||
"kv",
|
||||
"network",
|
||||
"openai",
|
||||
"qmd",
|
||||
"router",
|
||||
"s3",
|
||||
"vlan",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ describe("memory manager reindex recovery", () => {
|
|||
}): OpenClawConfig {
|
||||
return {
|
||||
memory: {
|
||||
backend: "builtin",
|
||||
search: {
|
||||
provider: params.provider ?? "openai",
|
||||
model: "mock-embed",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 ");
|
||||
}
|
||||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"];
|
||||
}
|
||||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue