From 8dbd30ba7470e7b6e999a5718c2bf963cdb98a16 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 13:05:24 -0700 Subject: [PATCH] feat(memory): per-entry glob patterns for extraPaths (#121209) * feat(memory): support globbed extra paths Allow root-relative glob patterns on existing memory.search.extraPaths entries across discovery, watching, reindex identity, and QMD migration. * fix(memory): drop stale lease type import * fix(memory): enforce extra path glob scope --- docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- .../.generated/plugin-sdk-api-baseline.sha256 | 228 +++++++++--------- docs/concepts/memory-qmd.md | 7 +- docs/reference/memory-config.md | 12 +- .../memory-core/src/cli-runtime-common.ts | 17 +- extensions/memory-core/src/cli.test.ts | 17 ++ .../src/memory/manager-reindex-state.test.ts | 48 ++++ .../src/memory/manager-reindex-state.ts | 14 +- .../src/memory/manager-watch-ops.ts | 42 +++- .../src/memory/manager.watcher-config.test.ts | 34 +++ .../memory-host-sdk/src/engine-storage.ts | 4 + .../src/host/config-utils.test.ts | 23 +- .../memory-host-sdk/src/host/config-utils.ts | 35 ++- .../memory-host-sdk/src/host/internal.test.ts | 52 ++++ packages/memory-host-sdk/src/host/internal.ts | 71 ++++-- .../src/host/read-file.test.ts | 30 ++- .../memory-host-sdk/src/host/read-file.ts | 20 +- packages/memory-host-sdk/src/host/types.ts | 5 +- src/agents/memory-search.test.ts | 19 +- src/agents/memory-search.ts | 15 +- ...g-migrations.runtime.retired-memory-qmd.ts | 39 +-- ...rations.runtime.retired.memory-qmd.test.ts | 13 +- src/config/config.schema-regressions.test.ts | 26 ++ src/config/schema.help.models.ts | 6 +- src/config/schema.labels.ts | 2 + src/config/types.memory.ts | 7 +- src/config/zod-schema.agent-runtime.ts | 9 +- src/memory-host-sdk/host/config-utils.ts | 5 +- .../memory-core-host-engine-storage.ts | 3 + 30 files changed, 589 insertions(+), 220 deletions(-) diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 74f5d364ae75..f3a318d2b56b 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2284, + "core": 2288, "channel": 3718, "plugin": 4053 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index da713061518c..40363a78efae 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -f34db5e8582e38f1a81098b29eec7852301a1d496a45a889115d597dc4ffee50 config-baseline.json -8cbea1bb4c3c0bd933da5641c08a3086e4579d8bcc496468cc951d7e388978c9 config-baseline.core.json +d7e15b44c0f74635ce38421e72f48f42db439d8c87f29696640ee03c594e73c5 config-baseline.json +25b006d7596d8f607c1ad8d973a60a1a5da4b33fc6c2f56a2b3f02b479d9c503 config-baseline.core.json 9b08f3be75acaf4459d4bdb383b90bd04c408f09db3fdea211518f8d4ae14b84 config-baseline.channel.json 7ce54344ec431d11f8a1ac92a112a9352fc55a4e43be71a08b91f9a06f924fb6 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f9708ee8fb4b..e6419559d3b8 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,151 +1,151 @@ -a06111c1cdb17e9ea1f2bbf2f90b86b8e93150c091b964b6fc5f5e67f5a4b632 module/account-core -d20eb6a6a77f72566266b88d191b30155757a1cc3d3886d3b6c437176bbac771 module/account-helpers +a592581a61518734a8a410ec9f71069529ee5fbb54848daf650199eb1cb557ca module/account-core +2b55fba911cc5c81076b706e8c817ab487e57c5a8da53d53afa39756a48e4dbc module/account-helpers 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id -006e581db74e461dcf4d7fd299b09d285f32c91ac12a56de1d21307d272e5a0a module/account-resolution -3fe118210b885af40088457ed81ffa5ede18c8e695295731a2ee059af46843cc module/agent-config-primitives -4f8b033a23a7bd17edf27911485fe3a4bd204440aa6320d11fa735e8749e55dc module/agent-harness -1e320b19a8ffc35faef38cbe863d815d7bb15a222cae4a8008ff1a9d39af2b52 module/agent-harness-runtime -595ef30046b8ba4bfd427e5a33b65ca28ab1a699ce7e90d6afa2ba2b187e22c0 module/agent-media-payload -c2e2abd35f38b2c34a4c642e965c94071b894c08d6b7ec1ea4b8f96e3d51f269 module/agent-runtime -72fa7e17dd1694a24f373dece9d3035c3fc25e1e113954328e16df03d9be7b55 module/agent-scope-runtime +64fc61ddb3ed2367ad193c8174e5ddf4f563d7456acd44ab3529219a8d0776fb module/account-resolution +4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives +1fbd58fb125a1ab69bacc7a700cac67a8a1d6c0d3b229212561c8cd1c8d72381 module/agent-harness +789148fb788284586e15d65e8c5115bbfe7df91d201aff952dd63b70c37dda8f module/agent-harness-runtime +762a2c3df44621e9464fdd3ef437be2a878bfa9a7e8ca7670f88ae549e601614 module/agent-media-payload +8913627dfb1dded389ccfba6609af2b1f45b9e434098b29500988c618ce3fc9c module/agent-runtime +66cad8b985017a2487e47d6794dede2d16348f717e7f445cd5df4af2eb7f95f4 module/agent-scope-runtime 8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from -ac6c71c3b7c3d1f6d3219d7514a1d1e0c44198358e6e9a4eae06e318d52a8433 module/allowlist-config-edit -136ddb70973082ec051eee5a5c2a78da5359f6d0e2dc058be5f7049ad5d2608e module/approval-auth-runtime -cdf0f3cab2f9e19bf9a7b9125872b4e9986bcd0530d17a3bdc76ec00aa9d8388 module/approval-client-runtime -235abceca3e9392ab39e5e63231337ae9dc35bbb773fb79e0e5a0b56be403455 module/approval-delivery-runtime -b784be7a3514d186ff62c8d1688fc38d6c0dbad00ecca438e5429d95c0b3b6c9 module/approval-gateway-runtime -9750dd4c52ac4b14142be0d39205da33faf584fdb48bb4d2942972e608147ccd module/approval-handler-adapter-runtime -fa1d9baff79016a97bdab8f44d6fd6f534fd69d2bf7c3de34a744b278a97a654 module/approval-handler-runtime -a0904844a521a3391cbb183e8657b69f7550b6245f88a7bcd7b5c12b2e3fae24 module/approval-native-runtime -343b855a38a460a8c3b65b005698ed5c311e9f6b93e7d42a142c5cddb3a08a6f module/approval-reply-runtime -9657575dca903644b9281b14c6522ecf90e95878368ade8a8ad19850314eaef4 module/approval-runtime +f0cd21c1854e209363fc2cbc684ba9dee9668ba7b5d0b57a96d4c8da825d7337 module/allowlist-config-edit +6a76c827ecd4c7f66a9b93533849572d5211da2090a4121758f5a26450a8d77c module/approval-auth-runtime +9765ea0869a5b0b84b97617e82c93f2bf7a1a33df03cfe38e661657c746069c8 module/approval-client-runtime +6b6611999dacdb86603d98b741526b337eed58e7d4a479b71aa849339f71845f module/approval-delivery-runtime +afba69ce95b3a939511c1f1e6b27a5999e8793304cba7578b0785c7edc342580 module/approval-gateway-runtime +a9f10f7c70287d7dc8ad224ca6424234652e74908ae7de2fc7edf4c98c91de4a module/approval-handler-adapter-runtime +f8b5c31c956b567827d58e9a1f6493846448b3c0ae30904cc7f4d872b8071d26 module/approval-handler-runtime +cb52e36a0de53874de5115ec797d39e576533cd3dda576c9b6c57af4f2888dca module/approval-native-runtime +b102781a2d78bbfafcf4205aeb2c169ad69bc4010edaf376647036cf3fba48d7 module/approval-reply-runtime +c93d529d020ac1ee52c230e0883f9880656908e8404ef96251bcf7c93afd39f4 module/approval-runtime 01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param -1b87d321f91ac51be8010cfd45163e1b1f13e4f68de60539fa8518f54258b4b5 module/channel-actions -665c32fdc411e106469713fce1862d4b38287723dae1527b29bb50703f93a3a0 module/channel-config-helpers +076689cd8e62c842580d61a34d97bcf42c90406287d428fc5d0d3eb373ec274d module/channel-actions +d1341161e2f5d44eaf7eca3a777ab3f106c7db1db32778bf98c75c7fa7d57ca4 module/channel-config-helpers c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives -76ad615d374431580ea1755594e2ce3ce047ac1de9fd4621053dca0fbc3afc4d module/channel-config-schema -63d4f5bc22d6e7779fcfa1f73e70df689189fcc876af26e05a4e3334b95675a9 module/channel-contract -9ca88ca940d0f07b307093c76d32a1c4c2093538628f6cd9ab65abd62e4dc08f module/channel-core -f6ddf9086bc224b4b1a516cd7e056467a529d19435930b1c8ddff7052d890ffa module/channel-dm-policy -116ad3ac327d8185cd5097cc79c31d94a01c5528a03297d1fd9f07f8e5862937 module/channel-entry-contract -b14ed3e3235fab5725a5eac86bb7910174a21ae1d7f2b4433891867396dcc566 module/channel-feedback -cdc5b1312806beed04fedbbd16bb34509cc5250b424ff3c18963428efade9f04 module/channel-inbound -f58349b93db16be763c7ba4c00cbc13b1b64911c8eefa5a1bfba1e95809e81cf module/channel-inbound-debounce -cb7f865c9953b5f0925c918b2bed29fb6629fbd643f4f69bd2bf345970864cbf module/channel-ingress-runtime -af775123f5526907732b7bebd6e2258ffe4ec733b2435007361bf7aba5b4eaf4 module/channel-lifecycle +9fa7e5f9b3515f0bbc252210b034d39c25de9eb431def30dddce3198c309efac module/channel-config-schema +a6af4971aa36345f1a32d97981bd36cc29794f62f30bd57859df2816c65f6fde module/channel-contract +02a07336e2ccb44ffe617f75dd95590b46e4b7780860c9f73f6c3ee0d376cb3b module/channel-core +40f8e726de245e17cf7bf908bb7d6fb084f4927f5ccc20b0c089f47af1bd7a0e module/channel-dm-policy +58918972c9b991058b1b82c2e36f37e15b7d01d1a9bd8dec29e4080b9ab2b532 module/channel-entry-contract +56b185eb99eb4981056befd38cd9511b655923934acb1c22aeec7d9c504bdf96 module/channel-feedback +deffb7f24c1d0dc4494e31e5f2041b91d5f052b90e7ff9083c6c8f9003bced79 module/channel-inbound +780abefd1600c55da219da43c769c873d071ab616b5e02d22f365677ed5a7be4 module/channel-inbound-debounce +b72485da4fe5d7f73352f4474d00e30b749b64c8a2066755e104958cd96036c2 module/channel-ingress-runtime +f83762680c0d0aa6fa1bad9e53c70bfdf3694126dbdd9491db764a69ed3f6d67 module/channel-lifecycle 0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging -1b151db6f444da029d8115dd06450467146d0a587676502673f799db74ca9776 module/channel-message -cbb1dd1f2765cad226b57b18e8bcc293e801375975afb0d5e7d5cecb55138c77 module/channel-outbound -0b64eeb4eaca3c295bae24479f5afd68f7714384bcc721640b36ee07dd4af3ba module/channel-pairing -d025a8eb7542dbfca088c163b8216b2030ccca326b8a688e883cde8813a2db78 module/channel-plugin-common -113d135501f2777f308c4c3a59dc29f722f8c385ddfe5628994f14ed37246442 module/channel-policy -c3bdfac92ace16eccca1bd44c95a3e73e39d7f996f1714cdfdf102647305df55 module/channel-reply-pipeline +67f3db77df2c786a6dc3429b4f94dbad89e152591dd3727ce3ad33bbb0b9c5e0 module/channel-message +401b0346239cadfb4db1c48e07f4e76f8f793839a926e828c47566667c645655 module/channel-outbound +1108ed0e70c34994add0fcb5f6b0681568ef81a714b2bf7441edf9a5775283fc module/channel-pairing +a1157e965dfa77ce935f73bc54bf393fd6bfb1495d95b5154c43c5e13f22d4a6 module/channel-plugin-common +c2c7900e47c0595bbc8a166aa60cfbc5a0e02f9a4c0985d5fd630c7dec4c3dca module/channel-policy +fde35c74c5db0f89b235210a1eca1b4e0712181c456f24793958fa73ee0ce042 module/channel-reply-pipeline 482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context -121aaced4cfdbd2dd16462e685699d021107d5d1bc4f9324363173c086b72605 module/channel-secret-basic-runtime -965cc7241ab4f6398611052f873f06b47b996fd72c948ea973c2cac3eaf4a820 module/channel-secret-runtime -9d25865ba44e3160b8716b08ed0ad93ab95e2c1bdde28047f65dd03fe66efd83 module/channel-send-result -b89224a72be296c82eeb1933b179ec0479627e80e1c61717b6cc7a778dbfa5b2 module/channel-setup -329fee4cb0ca430ea9249e4090e889c459402a40d42acd9c0f2a20053167502b module/channel-status -95dc206f832a0f563238dcea72d41554f409a3a9df081f41c52a8241c7dc5161 module/channel-streaming +32c2e9ea4f954a6b04d6e61b392e63d42b5cc74ccbdc1404c94a4c97463f0ca1 module/channel-secret-basic-runtime +941673d941b3ab9bd553ba9153305af7933019254a504a603907a237d06b5e79 module/channel-secret-runtime +4647a0e849e3aa193af751c71d1b137947887a489b999043618bfaebb1a37002 module/channel-send-result +4493cf54a54d7159e27d33884b1cd231cd54a038a85a3d5d3676e149954444a7 module/channel-setup +66df387345c0d64083904f4271228bb1e5660e40b00f42afc5d802d3ff07627f module/channel-status +198ed5042f86da7731dad61bc66f44bcb249fd9e369d79cf4509df172d5cab7f module/channel-streaming 67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config fdeffe356c7c4edeec9f8fd03edcadc375eabc7a9412e582b10c3180e3ef40fc module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -0c1e2add15c5cb0e1d735467950d3bea327d668638d517bfab7faf5bd6db1f3f module/command-auth -5bf07a178a69219c3822f4ee899680d71477b13c260b522a174d41191a7cf29a module/command-auth-native -4db2a3623b116976a2e4cb366163194a8073c8d56a9c74105127bee59c7d07f4 module/command-detection -0f6cf0b06cd65f2bfded9d1054873166fa6ad22c71f4f1cc5afb7a49711cc365 module/command-primitives-runtime -03eb97e7b47a79ad4b40b37a2bac418c14f85e90969f70fd6fc5c80bb27b8c49 module/command-status -b56349e8d8034152be55161d4bb4c9bf69d0f68f7ea07431f28a4ee7a707d576 module/config-contracts -9d2c42377ef981ea6732f96bd3a9c1a16fa2852936516a3fca5adc2b6b0b736e module/config-mutation -6c70300fd4bf84808f742687eaad92339fc942abd5cfa2378807a0756decdbd1 module/config-runtime -d5a157da395c2b548347b7fae054af0a90afbcae71c7171d0be5c66d92271328 module/conversation-runtime -e096d5aa2ed29b0a1d705f81aa69a23d481b2d1bbdc5e20fce18e747ae7d55d7 module/core -2704dd2371a9374a385d65dd8b4f3a3c27450b212be5b22d0ac71ceb7b1ab31d module/dedupe-runtime +8134198b58398da63ddc6e04040d1150d9f462bf8ae56f944acbc84db2a5362e module/command-auth +38214365f960d43d71625f58d5fa5cc7a28d4b01fa0f9c8a621bacd9c3bac41e module/command-auth-native +396f60be14d8fe1144076cffeafc2e2ddc0379b5e664f9fd45d13a0edc4a9f2f module/command-detection +5155909167a810ca258ec9baf4814e7d495cf25bd287fbb22be55eb1a8d88dfe module/command-primitives-runtime +0ab02df5c3904386bfb062e7dce0a24e2ed835b54dcc0112e367346624cb0efe module/command-status +02011f70a5de5f1861609206038b101d3e9bbf39bf45a3cfaaae56596299225b module/config-contracts +7ee5bc35d30165be33ab229a96dd6a6e3e04c620fa4c41753d568798c514feb3 module/config-mutation +08beaa7b5b8797bf248068c73332847b196c5ce47a74c67e788b3281aea46a97 module/config-runtime +069ca892c9d9c0d82f9a0d1e72970da2b1bc5d3c4fc15e127b18bbf78292cd2a module/conversation-runtime +82e34c7a2af309677ce2f54477ad026369b2880b96684e38989e1896dd12cf83 module/core +eceacf43fa56f443298bb5f489ae78677cb9f13df4d4abad7634b5d5537a0e01 module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap -4fe9beab67598950c144d86add5fd3b1ce475aaf37644d2f9798703052d98210 module/diagnostic-runtime -734898717c8669f1c3a35dca93db121508368edac7dafcd1ccce71f432680979 module/directory-runtime -93c2f1688a65651c844d5317c0cf0bace3bf2e0477047947b269fa1e6a3ef160 module/discord -2b01f2e5a52713158665372b358fb6059903f68b6efbedff700741d9feca7696 module/error-runtime -03eeafd10471b94a2651e0d42380a32f5ffd4f33c47e4b0161205093163e9407 module/extension-shared +9ecd22bc3acae3f9b0556b305c4232abe54a7993b910caa789cba6cf917dd1e2 module/diagnostic-runtime +6d90412b97efcc675d16acc3aeb2752520a613ce4bb25b87e47f84fd6b36736d module/directory-runtime +841d0b1fcb267198e4422be8b3ebdc6625bf24ec5ab513606ff72ac2353993ab module/discord +f2d69888d0c799e12d97b5f92393906aa843be25f61cb6a6c55d32f1db800a44 module/error-runtime +1caf8cc5552ca5e392599457e3f9616e033de199d9f47c12320cdee617a6b0e9 module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -b5dfcf2977a334afb5a138ba9af196902ae8baf54a843c57496ed826e410178b module/gateway-runtime +721c77e48f30f92bd41f9eddab0a573e1791a01f3f98d6f1ef1e8de4fc09916d module/gateway-runtime 575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access -f3595668fc4c20b2b34df67d3fa4d5432c16fa3be5fec9e63d8f14512ab6732f module/health -60d126e420e212415f25b6e90c2aca7513a1275e14b2bad27b06aeb6ab12eaa5 module/hook-runtime -da9d83537008db2c9339f3a4235ed2b7ee3ac986cacdc8bf8fdfaf1a025bd6a9 module/inbound-envelope +77726dc396a269ca62fe958c4c797de54d94aba47b7b166e0b66052b604c4b93 module/health +74252b820ea1d21457f01a4e3b868233737e83b134f3d8b8cecfb2392a9647e7 module/hook-runtime +4d9f7d3c3e59113b493f3cac812b3c117968035af10a751a92f2aca8e2ec8806 module/inbound-envelope 4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery -b15ed4974df49e68b0b250429a16eaa2356b1b2b5053d6cd1e09eb27b3bb58fd module/inbound-reply-dispatch -539921d6c5596381beb17505394c67be44edb4a5ac533d555f07c43c4c991e8f module/infra-runtime +a551c9ef5540138c0e0b40c341860150688e35505d5a06f5047a0b5be5e80375 module/inbound-reply-dispatch +7f75e1166081708656b561095753ab05480265a55bc40a8e5ca34e221a4798e2 module/infra-runtime ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once -31449afd7ea7f0c8dc7dee439a3693c3d1092af67c6bed67dd34e7b7b50bc74e module/interactive-runtime +2fdc37835ccc4651c580847250adf8e42c165483eb239907aea981a6552e24b4 module/interactive-runtime 408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store e907fd3a98185f2c261f2aafcaa5a19ee1d7b459d519a498397d629f84c68312 module/lazy-runtime -09735d3ed2b372352a4e32aa8e80d7b8ccf7f78768d2fdb76d00bbe9aa659f7f module/logging-core +e5acf130491ef188b193a3e3dc5284e53a29f7e08278caa188f5ddb96e860dc4 module/logging-core f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix -c0a6a1e65d02a5cc8727881a69d8f4ec692a33dc78fe13320d8372c33b4b75ed module/media-local-roots +8cef237e683b323062fa4134a88a0b5b8ff97f1c5d356654c8062fdbf5eed87c module/media-local-roots f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime -819c3b13113fe8cce4d9792f1b649d3ee48e9a9ab2f44936f553c294fc4b8405 module/media-runtime +d8e039f3ebb4c4bf7e30217105e2aae84934f73df9e05be27db4f746ab45f1a9 module/media-runtime 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store -b3fdb9f96d2724d5824063f434f3e482b605e4c06cad9770a94c78079dc36454 module/media-understanding -544a6d47a391e64574b146f649e8065b70a57955088b85cbdbdacb872889475a module/media-understanding-runtime -e84b586895c8c25f1d2f3a3141859f5bcc250addf38e88bb5031dff35233e23d module/meeting-runtime -a6aac1a3f85d3ee7dc9fbc7a1c6ef7dd00a9c2d3bdb6dc8d57a9704a821f806d module/memory-core-host-engine-foundation -16884f67221837372dbda6ff0487fa514c81609b0ea9ae96480ffc139dc80067 module/memory-host-core +6e5b09a983a69041873ece5bc3b719c0dc913e5a69e2afdf6c7958889e711097 module/media-understanding +7f74e947cca0cf894869bede29815863d5eacca016d04d6262e9d41b3999ae94 module/media-understanding-runtime +cf4f49e9e1c45913acd6a9332f8bb35a8b7de102edcc6eeebacf3fa666101552 module/meeting-runtime +e5d2b540090c17602a1c36b42559f7b516e20730e06bbdd8606597c66ef2ef95 module/memory-core-host-engine-foundation +451082d20bde79f9e50570a3e38dd4f7f880337055840774ca1df02381cad98b module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets -8647dda41c0c3ab67191c397aa405de914fd8f277f55f550a307524e3c20d98d module/model-session-runtime -488be4321795c9135a2d47ce89a2b71b8e3c957cf288ea1ef0dd7a9a0687b631 module/models-provider-runtime -504c61546d566814cda2d8126b458efc9ad70b75f1406ab9d6635ac10225200c module/native-command-config-runtime -d808e6681668e70b2dbb2ac590b455072f98d54970593e25ef6ad5c2322d3a51 module/native-command-registry -ca6ee4fa75f976d590210b9ff6dc66374bb829a05d4f70972eef9137f5548b88 module/param-readers +3b35256be44a7f2f69a1c2ebb33b1028d6bdc8c714a426f3b466a61056c5c5d7 module/model-session-runtime +0b554d2ebaddc7d4cddc3fb31585371b036381170ce1c5640741c7858c86f960 module/models-provider-runtime +b518b4caa7f138d448fe08f5fc9e5f67da5d4b26aafaee2370feb2300e1baa21 module/native-command-config-runtime +1e8b5e7bd8234919d339e750368cbe6425e7463d7e63eb5200d728cc17d6e959 module/native-command-registry +15d1d490e4f7909d2d563b83988258a9cc5fa1344cffb0cd3dcd7592c0467632 module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe -3c73ab232d86b49ebb6e5f302da2a2a4b5c7f62bff5e4cc0d3505edc5ba4f5cd module/plugin-config-runtime -3c0c121d55b828d3c41d7814b95daeec19fc000e8a6c76195091549b59c4db66 module/plugin-entry -11058d6921227019f45f89986cef65611816500153eb3a85e81cb9c3e9c978aa module/plugin-runtime -d22f0b4bd95f098d1759c22347b31580d7ce045aefd490a4cbea47d8f6320465 module/provider-auth -526efe506796df2330a7f927f2c77e04e98eecd4725f2353013f5ce358b3414f module/provider-catalog-runtime +f8b250da3eab2f85b2bf358872f910b29e541ea04358498a6deab353fa0520fc module/plugin-config-runtime +405b226c64bcdcd6c4d630cf4f223b00028cd9cddb111e82b24d406a3fc7e202 module/plugin-entry +ee2c2ccde86934c67a0d39ec2203a30fddd16b3a3ef7aec338b45694f1f7a375 module/plugin-runtime +9a84a66ddde2759e863b6ecf34e97cb99825e0911f36a0af14fc7e80869f03be module/provider-auth +474c4a92d7b4050f023bb5586617cc978c2c31578d5723e64c03ea127ac70e0d module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture -8f01e5ed6bccbb1e13cda4dd0a0b18f39af08ad36d7f0080b12217565f2e576c module/question-gateway-runtime -a479cd5c96a34c6f0a2ed4d4239aad63b56cb0e47a970848a141ad7fb67b9e11 module/reply-chunking -f2219c0b81efdc590828bc79c6005767d10817de779a0ecc20b3e89fd7c0335a module/reply-dispatch-runtime +913c32adcd94ea9c8b7efac0f7c65231500c49129e638c0682eb632da3d5a0c8 module/question-gateway-runtime +f319c52a0f1655de752efa55a983893e56e4afa0727f6cc4896c3238c984e91c module/reply-chunking +1fde835f8398ce0f627c794fedfa735db750685909dbba1552b005834b59f9e7 module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history -d3bf7e4a7fcaebc1cf3173f4fbb60203e6ec46257cddcca7096800df946708fc module/reply-payload -72e31359f00c5a3d0fc86ddd57705bd37884e383d1b66d8e033d03d76fe00e39 module/reply-runtime +ecc6e3190892ff416ce69d26c8ce0aa08a468c8689ca3606c18b0271e2d87597 module/reply-payload +33479b0cc629940332a7724ee380912be19614894b70a369d1d85ceda47fa6b6 module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk -02c0e5cae6772159a1c5ff43209de542ac7976dc30541d406f9dc6c37141a69d module/routing +292405cda407e2569f731212f6f981b12bfe682836d5f6e58c6ae194ba27f144 module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command -bd15eb9689fd7070dc942cb4bd0db6c2c1d4fb1a971ce9d0a6349a5679309535 module/runtime -ccb6aad96b4ea156738f45bac04b6eda5826e17b36eb8b5e26567f400b3be217 module/runtime-config-snapshot -2e3c692a9f911ab227e34a9a3870f8e139f90ceaff631301b55ce712dec53736 module/runtime-env +14c9f7ce9f8b8a47799f6a870110d1d8469223a01a2dba01fc8bc1f11a35cfe0 module/runtime +f7900c5ff5827e1b53a926367d807bd4c81b313ab74fd0409d571ae881226a60 module/runtime-config-snapshot +8e33ccbbe610c6c3ad04a140f7511a16cf406733c8459216b1fb4b1e7f0208d9 module/runtime-env 7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy -274fbf53ae00c89455a5f6a812d5bc241b661fa4688957ba4940cdb0876f849d module/runtime-store +0d38198bd13bd5b0ed2f04ccefd805a6dbe9213b3b1a1c75bde1ce42c87b08c7 module/runtime-store d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file 8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input -1806dba733bb6d88b4e997d3dc989761f0d7dde7638d96e1218705fd27f0921b module/secret-input-runtime -8e0e6d67db89eeee760a33ac984e7141bde171080365dddf534858b6abb3d56d module/secret-ref-runtime -c810981c42d32923e84c42d20137c9dc393aabc0ea079786e01d19bd59fa5277 module/security-runtime -ae1cfe5ad81718ad64381d6bcebb9746f94291d3340d002e789d262dd234d663 module/session-catalog -2c72b851264af36b6720c796a14da1f5b3653b3cc4e69a4d94a5fc9ccab48280 module/session-discussion -fd2dcb08a59df7bb68c8f36f272d134f6904d12b9e192bc8d7a4858d3039b595 module/session-store-runtime -73f4a776c027d974f010250360822694486a8033bf1b51e96a0ff2440b05dbfe module/setup -f1ec91331ad72c3fd9edb2cb11b4c6a35acd55ef3e4f541c5907149eed21b15f module/setup-runtime +5958846179128ea6b29135fb90ce4e40b8df7de63d1c65d1696e52294cbc0f52 module/secret-input-runtime +72ca4634e0038ad6dc80c4ce12eb34014e1fa990c7eabe19bb4501b2722ee685 module/secret-ref-runtime +6db2af355b0d1d46f493a5d34a63a6ea5231dafbd8b896d976a52da681868f67 module/security-runtime +f700943c355142a66fe0a5033ff1c04c60acdc963529662f60ca5d1370a1e557 module/session-catalog +48b55e67e967e5ff8aa5b258b8dff308729945b2d024ddec7a5f920a81a4382a module/session-discussion +cd31b3430b196d78604de2682802f95453ce8e32c2222ae5f94241e422a2e1e6 module/session-store-runtime +e54f5c86a55683028a8c4a7c821cf9b9f11b48f91ae9b08e79f8e94b91eb5fde module/setup +41c4790efda179c600fcc8cc4f2cd9c0505efd86df15a1a840aeb99c839bc627 module/setup-runtime 44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools -00e8794c6e7aabbdeec14189d885d4021f5118fcb08f7e34b85fafc473e62bed module/skill-commands-runtime -583b4d4357b38d5db45cbb0b7799da27e81cc66c2b3ef59d6a19ad7d4a278ef7 module/speech-settings -f01b661de86de0d0b1d3bff395330092f3fe1114f4516968ca5fca79cf7eac50 module/ssrf-policy -0296f2c837f8116aa3a7e8b02d3de8b756d44e0a004842dbd24a5aa627f74a54 module/ssrf-runtime -eace34246d7a827b00a67bde258f8401bab35f527dcad29e6f982e4ae39cf013 module/state-paths -8576354be3aa9ea9d4429c3ef048753a4b02c3d1771538a9c563d2504998f1ae module/status-helpers +dc901275501e473a69a8e9435e1c95096c47f1bb569cf3240c1e82c769fffaa0 module/skill-commands-runtime +68a81d3ec617556543ecc74189a4b01ca4d5431461e82029c0fa6e1d07b32648 module/speech-settings +1e4df0f48b50a8eb546459af1e947ba143c287e3d9c13706c45b67d411c46ad5 module/ssrf-policy +e4940099376e18e34c234d4d90d836cbe234c3e891e40b7b4879168b386b1121 module/ssrf-runtime +3c3c812d2d0c997ec81d117346347b802a3445e8e0d76aa6d0e3259f3cf55f98 module/state-paths +0f912449caf8203fb876fd308e209f13aa50447b2fbd47f826e42766342159e0 module/status-helpers f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-coerce-runtime -c6ea76a9fa7f56771cbfc54617aa9ceec3d87241bab38a094622ea9351f3cc87 module/telegram-account +5b0e9ebde0a5a65f728434aca1bf09ad0579fbd02921fd6f7c0811665eda9b9e module/telegram-account aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path 87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking -2ce8b180da90b5b1665bc65dd9cae9af7eb4e0988cc2a64685e8e6f9cb9a87c3 module/text-runtime -a82a2087f07ad3f8ba933d9af65af568e54e9f3595a3e0606aa3a8fc9575763a module/tool-plugin +50d2fa56b2ad57edd12d74201d18ab43045b5c9da0ab98cc158cebe9bd8b768b module/text-runtime +9bdbe8da529a24ee4413a50bfe92b239684e2499236a2e37c0b9961f52fb96ee module/tool-plugin dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results -3c97f778d2844ba1bfd3e77fbccd3bfed94bc102053c091b00a0d3d2aaff6a99 module/tool-send +5d4f1dbde1238799e5244b9c0b799aa2fb7d607d0c072d9a70659978825ef4ee module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -284c7c41168c8361dd9b5ddae517bbfc5c32245595d9b7e11e4acbfbdccc07c8 module/webhook-ingress -216e54c25ec0985fc483899d4de3d2582053a4fc3a2d1c98a8b5e7cdd2f136e1 module/webhook-request-guards +18f0f10817717fba6d3a4c660c62e58d83c3b75a15be848b24da30ee31399075 module/webhook-ingress +ddd05cb74246c393fef4d3fd8331eddd4db72a000fe45e9e515357b757636854 module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index 4f1fe3f461dc..50d355203a67 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -12,9 +12,8 @@ The optional QMD memory backend has been removed. Builtin memory is now the only 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. +collections in the corresponding `memory.search.extraPaths` setting, including root-relative glob +patterns. QMD indexes, exported session Markdown, downloaded models, and collection metadata are +derived state and do not require migration. See [Memory](/concepts/memory) for the current architecture and configuration. diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index ead4d5c8e73b..5635587ba451 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -384,21 +384,23 @@ auto-injected. ## Additional memory paths -| Key | Type | Description | -| ------------ | ---------- | ---------------------------------------- | -| `extraPaths` | `string[]` | Additional directories or files to index | +| Key | Type | Description | +| ------------ | ----------------------------------------------------- | ---------------------------------------- | +| `extraPaths` | `Array` | Additional directories or files to index | ```json5 { memory: { search: { - extraPaths: ["../team-docs", "/srv/shared-notes"], + extraPaths: ["../team-docs", { path: "/srv/shared-notes", pattern: "runbooks/**/*.md" }], }, }, } ``` -Paths can be absolute or workspace-relative. Directories are scanned recursively for `.md` files. The builtin engine skips symlinks. +Paths can be absolute or workspace-relative. Directories are scanned recursively for supported +files. Object entries narrow a directory with a root-relative glob using `/` separators; direct +file entries are indexed exactly. The builtin engine skips symlinks. --- diff --git a/extensions/memory-core/src/cli-runtime-common.ts b/extensions/memory-core/src/cli-runtime-common.ts index 93bd3210a929..2b3b46ac7b57 100644 --- a/extensions/memory-core/src/cli-runtime-common.ts +++ b/extensions/memory-core/src/cli-runtime-common.ts @@ -3,6 +3,10 @@ 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-sessions"; +import { + normalizeExtraMemoryPathEntries, + type MemoryExtraPath, +} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { defaultRuntime, @@ -150,8 +154,11 @@ function resolveAgentIds(cfg: OpenClawConfig, agent?: string): string[] { } return listAgentIds(cfg); } -export function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[] { - return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry)); +export function formatExtraPaths(workspaceDir: string, extraPaths: MemoryExtraPath[]): string[] { + return normalizeExtraMemoryPathEntries(workspaceDir, extraPaths).map((entry) => { + const root = shortenHomePath(entry.path); + return entry.pattern ? `${root} (pattern: ${entry.pattern})` : root; + }); } async function withMemoryManagerForAgent(params: { cfg: OpenClawConfig; @@ -258,7 +265,7 @@ async function scanSessionFiles(agentId: string): Promise { } async function scanMemoryFiles( workspaceDir: string, - extraPaths: string[] = [], + extraPaths: MemoryExtraPath[] = [], ): Promise { const issues: string[] = []; const memoryFile = path.join(workspaceDir, "MEMORY.md"); @@ -308,7 +315,7 @@ async function scanMemoryFiles( let listed: string[] = []; let listedOk = false; try { - listed = await listMemoryFiles(workspaceDir, resolvedExtraPaths); + listed = await listMemoryFiles(workspaceDir, extraPaths); listedOk = true; } catch (err) { const code = (err as NodeJS.ErrnoException).code; @@ -340,7 +347,7 @@ export async function scanMemorySources(params: { workspaceDir: string; agentId: string; sources: MemorySourceName[]; - extraPaths?: string[]; + extraPaths?: MemoryExtraPath[]; }): Promise { const scans: SourceScan[] = []; const extraPaths = params.extraPaths ?? []; diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index 6983e4afbf82..226949ed776d 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -505,6 +505,23 @@ describe("memory cli", () => { expect(close).toHaveBeenCalled(); }); + it("prints extra path glob patterns in status output", async () => { + const close = vi.fn(async () => {}); + mockManager({ + status: () => + makeMemoryStatus({ + extraPaths: [{ path: "notes", pattern: "runbooks/**/*.md" }], + }), + close, + }); + + const log = spyRuntimeLogs(defaultRuntime); + await runMemoryCli(["status"]); + + expectLogged(log, "Extra paths: /tmp/openclaw/notes (pattern: runbooks/**/*.md)"); + expect(close).toHaveBeenCalled(); + }); + it("still aborts status when its own memory SecretRef cannot be resolved", async () => { getRuntimeConfig.mockReturnValue({ memory: { diff --git a/extensions/memory-core/src/memory/manager-reindex-state.test.ts b/extensions/memory-core/src/memory/manager-reindex-state.test.ts index 4a947dc7aaeb..8450e61a2385 100644 --- a/extensions/memory-core/src/memory/manager-reindex-state.test.ts +++ b/extensions/memory-core/src/memory/manager-reindex-state.test.ts @@ -255,6 +255,54 @@ describe("memory reindex state", () => { ).toBe(true); }); + it("includes extra path patterns in stable scope identity", () => { + const workspaceDir = "/tmp/workspace"; + const multimodal = { + enabled: false, + modalities: [], + maxFileBytes: 20 * 1024 * 1024, + }; + const firstScopeHash = resolveConfiguredScopeHash({ + workspaceDir, + extraPaths: [ + { path: "notes", pattern: "runbooks/**/*.md" }, + { path: "notes", pattern: "decisions/**/*.md" }, + ], + multimodal, + }); + const reorderedScopeHash = resolveConfiguredScopeHash({ + workspaceDir, + extraPaths: [ + { path: "notes", pattern: "decisions/**/*.md" }, + { path: "notes", pattern: "runbooks/**/*.md" }, + ], + multimodal, + }); + const changedScopeHash = resolveConfiguredScopeHash({ + workspaceDir, + extraPaths: [{ path: "notes", pattern: "archive/**/*.md" }], + multimodal, + }); + + expect(reorderedScopeHash).toBe(firstScopeHash); + expect(changedScopeHash).not.toBe(firstScopeHash); + expect(resolveConfiguredScopeHash({ workspaceDir, extraPaths: ["notes"], multimodal })).toBe( + resolveConfiguredScopeHash({ + workspaceDir, + extraPaths: [{ path: "notes" }], + multimodal, + }), + ); + expect( + isMemoryIndexIdentityDirty( + createIdentityParams({ + meta: createMeta({ scopeHash: firstScopeHash }), + configuredScopeHash: changedScopeHash, + }), + ), + ).toBe(true); + }); + it("marks identity dirty when configured sources add sessions", () => { expect( isMemoryIndexIdentityDirty( diff --git a/extensions/memory-core/src/memory/manager-reindex-state.ts b/extensions/memory-core/src/memory/manager-reindex-state.ts index ecaa9b3c1bb9..72f8af2788eb 100644 --- a/extensions/memory-core/src/memory/manager-reindex-state.ts +++ b/extensions/memory-core/src/memory/manager-reindex-state.ts @@ -2,7 +2,8 @@ import { hashText, MEMORY_CHUNKING_VERSION, - normalizeExtraMemoryPaths, + normalizeExtraMemoryPathEntries, + type MemoryExtraPath, type MemorySource, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; @@ -107,16 +108,19 @@ function configuredMetaSourcesDiffer(params: { export function resolveConfiguredScopeHash(params: { workspaceDir: string; - extraPaths?: string[]; + extraPaths?: MemoryExtraPath[]; multimodal: { enabled: boolean; modalities: string[]; maxFileBytes: number; }; }): string { - const extraPaths = normalizeExtraMemoryPaths(params.workspaceDir, params.extraPaths) - .map((value) => value.replace(/\\/g, "/")) - .toSorted(); + const extraPaths = normalizeExtraMemoryPathEntries(params.workspaceDir, params.extraPaths) + .map((entry) => { + const path = entry.path.replaceAll("\\", "/"); + return entry.pattern ? { path, pattern: entry.pattern } : path; + }) + .toSorted((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); return hashText( JSON.stringify({ extraPaths, diff --git a/extensions/memory-core/src/memory/manager-watch-ops.ts b/extensions/memory-core/src/memory/manager-watch-ops.ts index e559f569a7b8..f784a72d1db2 100644 --- a/extensions/memory-core/src/memory/manager-watch-ops.ts +++ b/extensions/memory-core/src/memory/manager-watch-ops.ts @@ -7,7 +7,10 @@ import { createSubsystemLogger, type ResolvedMemorySearchConfig, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { normalizeExtraMemoryPaths } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { + matchesExtraMemoryPathEntry, + normalizeExtraMemoryPathEntries, +} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { MemoryManagerSyncBase } from "./manager-sync-base.js"; @@ -99,6 +102,11 @@ function shouldIgnoreMemoryWatchPath( return classifyMemoryMultimodalPath(normalized, multimodalSettings) === null; } +function isWithinMemoryWatchRoot(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + function runDetachedMemorySync(sync: () => Promise, reason: "interval" | "watch") { void sync().catch((err: unknown) => { log.warn(`memory sync failed (${reason}): ${String(err)}`); @@ -123,30 +131,48 @@ export abstract class MemoryManagerWatchOps extends MemoryManagerSyncBase { path.join(this.workspaceDir, "MEMORY.md"), path.join(this.workspaceDir, "USER.md"), ]); - const dirWatchPaths = new Set([path.join(this.workspaceDir, "memory")]); - const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths); + const memoryDir = path.join(this.workspaceDir, "memory"); + const dirWatchPaths = new Set([memoryDir]); + const additionalPaths = normalizeExtraMemoryPathEntries( + this.workspaceDir, + this.settings.extraPaths, + ); for (const entry of additionalPaths) { try { - const stat = fsSync.lstatSync(entry); + const stat = fsSync.lstatSync(entry.path); if (stat.isSymbolicLink()) { continue; } if (stat.isDirectory()) { - dirWatchPaths.add(entry); + dirWatchPaths.add(entry.path); continue; } if ( stat.isFile() && - (normalizeLowercaseStringOrEmpty(entry).endsWith(".md") || - classifyMemoryMultimodalPath(entry, this.settings.multimodal) !== null) + (normalizeLowercaseStringOrEmpty(entry.path).endsWith(".md") || + classifyMemoryMultimodalPath(entry.path, this.settings.multimodal) !== null) ) { - fileWatchPaths.add(entry); + fileWatchPaths.add(entry.path); } } catch { // Skip missing/unreadable additional paths. } } const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => { + if (watchPath && stats && !stats.isDirectory?.()) { + const normalizedWatchPath = path.resolve(watchPath); + const matchingEntries = isWithinMemoryWatchRoot(memoryDir, normalizedWatchPath) + ? [] + : additionalPaths.filter((entry) => + isWithinMemoryWatchRoot(entry.path, normalizedWatchPath), + ); + if ( + matchingEntries.length > 0 && + !matchingEntries.some((entry) => matchesExtraMemoryPathEntry(entry, normalizedWatchPath)) + ) { + return; + } + } recordMemoryWatchEventPath(this.pendingWatchPaths, watchPath, stats); this.dirty = true; this.scheduleWatchSync(); diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 7962f118ef46..e12374bf8274 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -319,6 +319,40 @@ describe("memory watcher config", () => { ).toBe(false); }); + it("filters patterned extra path file events while watching the directory root", async () => { + await setupWatcherWorkspace({ name: "seed.md", contents: "seed" }); + await fs.mkdir(path.join(extraDir, "notes"), { recursive: true }); + await fs.mkdir(path.join(extraDir, "drafts"), { recursive: true }); + await fs.writeFile(path.join(extraDir, "notes", "keep.md"), "keep"); + await fs.writeFile(path.join(extraDir, "drafts", "skip.md"), "skip"); + const cfg = createWatcherConfig({ + extraPaths: [{ path: extraDir, pattern: "notes/**/*.md" }], + }); + + await expectWatcherManager(cfg); + const extraWatcher = createdNativeWatchers.find( + (watcher) => watcher.dir === extraDir && watcher.recursive, + ); + expect(extraWatcher).toBeDefined(); + vi.useFakeTimers(); + const syncSpy = vi + .spyOn( + manager as unknown as { + sync: (params?: { reason?: string }) => Promise; + }, + "sync", + ) + .mockResolvedValue(undefined); + + extraWatcher?.emit("change", path.join("drafts", "skip.md")); + await vi.advanceTimersByTimeAsync(BUILT_IN_WATCH_DEBOUNCE_MS); + expect(syncSpy).not.toHaveBeenCalled(); + + extraWatcher?.emit("change", path.join("notes", "keep.md")); + await vi.advanceTimersByTimeAsync(BUILT_IN_WATCH_DEBOUNCE_MS); + expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" }); + }); + it("does not start watchers for one-shot CLI managers", async () => { await setupWatcherWorkspace({ name: "notes.md", contents: "hello" }); const cfg = createWatcherConfig(); diff --git a/packages/memory-host-sdk/src/engine-storage.ts b/packages/memory-host-sdk/src/engine-storage.ts index eed81f49d02e..99d940927bb8 100644 --- a/packages/memory-host-sdk/src/engine-storage.ts +++ b/packages/memory-host-sdk/src/engine-storage.ts @@ -10,7 +10,9 @@ export { hashText, INVALID_PROJECT_ANNOTATION_KEY, listMemoryFiles, + matchesExtraMemoryPathEntry, MEMORY_CHUNKING_VERSION, + normalizeExtraMemoryPathEntries, normalizeProjectAnnotationKey, normalizeExtraMemoryPaths, parseEmbedding, @@ -22,6 +24,7 @@ export { type CuratedProjectAnnotations, type MemoryChunk, type MemoryFileEntry, + type NormalizedExtraMemoryPath, } from "./host/internal.js"; export { readMemoryFile } from "./host/read-file.js"; export { isTransientMemoryReadError, retryTransientMemoryRead } from "./host/read-retry.js"; @@ -38,6 +41,7 @@ export type { ResolvedMemoryBackendConfig } from "./host/backend-config.js"; export type { MemoryEmbeddingProbeResult, MemoryEntryProvenance, + MemoryExtraPath, MemoryOriginClass, MemoryProviderStatus, MemorySearchManager, diff --git a/packages/memory-host-sdk/src/host/config-utils.test.ts b/packages/memory-host-sdk/src/host/config-utils.test.ts index 510cf4a986ab..e26401834e2a 100644 --- a/packages/memory-host-sdk/src/host/config-utils.test.ts +++ b/packages/memory-host-sdk/src/host/config-utils.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveRememberAcrossConversations } from "./config-utils.js"; +import { + normalizeConfiguredMemoryExtraPaths, + resolveRememberAcrossConversations, +} from "./config-utils.js"; describe("resolveRememberAcrossConversations", () => { it("honors keyed per-agent memory overrides", () => { @@ -15,3 +18,21 @@ describe("resolveRememberAcrossConversations", () => { expect(resolveRememberAcrossConversations(config, "support")).toBe(false); }); }); + +describe("normalizeConfiguredMemoryExtraPaths", () => { + it("preserves distinct patterns and canonicalizes unpatterned objects", () => { + expect( + normalizeConfiguredMemoryExtraPaths([ + " notes ", + { path: "notes" }, + { path: " notes ", pattern: " runbooks/**/*.md " }, + { path: "notes", pattern: "runbooks/**/*.md" }, + { path: "notes", pattern: "decisions/**/*.md" }, + ]), + ).toEqual([ + "notes", + { path: "notes", pattern: "runbooks/**/*.md" }, + { path: "notes", pattern: "decisions/**/*.md" }, + ]); + }); +}); diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index 1c1286c028bc..cfb95b9aca4f 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -7,10 +7,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { - normalizeStringEntries, - uniqueStrings, -} from "@openclaw/normalization-core/string-normalization"; +import type { MemoryExtraPath } from "./types.js"; export { normalizeAgentId }; // Shared OpenClaw config helpers used by memory host and agent context code. @@ -29,9 +26,27 @@ type MemoryConfig = { type MemorySearchConfig = { enabled?: boolean; rememberAcrossConversations?: boolean; - extraPaths?: string[]; + extraPaths?: MemoryExtraPath[]; }; +/** Trim and deduplicate configured extra-memory roots without losing pattern identity. */ +export function normalizeConfiguredMemoryExtraPaths( + extraPaths?: MemoryExtraPath[], +): MemoryExtraPath[] { + const normalized = new Map(); + for (const entry of extraPaths ?? []) { + const configuredPath = (typeof entry === "string" ? entry : entry.path).trim(); + const pattern = typeof entry === "string" ? "" : entry.pattern?.trim() || ""; + if (configuredPath) { + normalized.set( + `${configuredPath}\0${pattern}`, + pattern ? { path: configuredPath, pattern } : configuredPath, + ); + } + } + return Array.from(normalized.values()); +} + /** Agent context limits that bound memory file reads. */ type AgentContextLimitsConfig = { memoryGetMaxChars?: number; @@ -287,20 +302,24 @@ export function resolveMemoryHostAgentContextLimits( export function resolveMemoryHostSearchPathConfig( cfg: OpenClawConfig, agentId: string, -): { enabled: boolean; rememberAcrossConversations: boolean; extraPaths: string[] } | null { +): { + enabled: boolean; + rememberAcrossConversations: boolean; + extraPaths: MemoryExtraPath[]; +} | null { const defaults = cfg.memory?.search; const overrides = resolveAgentConfig(cfg, agentId)?.memory?.search; const enabled = overrides?.enabled ?? defaults?.enabled ?? true; if (!enabled) { return null; } - const rawPaths = normalizeStringEntries([ + const extraPaths = normalizeConfiguredMemoryExtraPaths([ ...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? []), ]); return { enabled, rememberAcrossConversations: resolveRememberAcrossConversations(cfg, agentId), - extraPaths: uniqueStrings(rawPaths), + extraPaths, }; } diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 16f52ff8748a..83e0a8271b13 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -12,6 +12,7 @@ import { ensureDir, isMemoryPath, listMemoryFiles, + normalizeExtraMemoryPathEntries, normalizeExtraMemoryPaths, remapChunkLines, runWithConcurrency, @@ -57,6 +58,19 @@ function expectFileEntry(entry: Awaited>): Fil return entry; } +function tryCreateSymlink(target: string, linkPath: string, type?: "dir"): boolean { + try { + fsSync.symlinkSync(target, linkPath, type); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES") { + return false; + } + throw err; + } +} + function expectMultimodalIndexingChunk( built: Awaited>, ): MultimodalIndexingChunk { @@ -150,6 +164,16 @@ describe("memory host SDK package internals", () => { path.join(os.homedir(), "shared-notes"), os.homedir(), ]); + expect( + normalizeExtraMemoryPathEntries(workspaceDir, [ + { path: " notes ", pattern: " runbooks/**/*.md " }, + { path: "notes", pattern: "runbooks/**/*.md" }, + { path: "notes", pattern: "archive/**/*.md" }, + ]), + ).toEqual([ + { path: path.resolve(workspaceDir, "notes"), pattern: "runbooks/**/*.md" }, + { path: path.resolve(workspaceDir, "notes"), pattern: "archive/**/*.md" }, + ]); }); it("lists canonical markdown and enabled multimodal files", async () => { @@ -179,6 +203,34 @@ describe("memory host SDK package internals", () => { ]); }); + it("filters extra directories by glob while preserving symlink skips", async () => { + const tmpDir = getTmpDir(); + const extraDir = path.join(tmpDir, "extra"); + const outsideDir = path.join(tmpDir, "outside"); + fsSync.mkdirSync(path.join(extraDir, "notes", "nested"), { recursive: true }); + fsSync.mkdirSync(path.join(extraDir, "drafts"), { recursive: true }); + fsSync.mkdirSync(outsideDir, { recursive: true }); + fsSync.writeFileSync(path.join(extraDir, "root.md"), "root"); + fsSync.writeFileSync(path.join(extraDir, "notes", "keep.md"), "keep"); + fsSync.writeFileSync(path.join(extraDir, "notes", "nested", "keep.md"), "nested"); + fsSync.writeFileSync(path.join(extraDir, "drafts", "skip.md"), "skip"); + fsSync.writeFileSync(path.join(extraDir, "notes", "ignore.txt"), "ignore"); + fsSync.writeFileSync(path.join(outsideDir, "linked.md"), "linked"); + tryCreateSymlink(path.join(outsideDir, "linked.md"), path.join(extraDir, "notes", "linked.md")); + tryCreateSymlink(outsideDir, path.join(extraDir, "notes", "linked-dir"), "dir"); + + const files = await listMemoryFiles(tmpDir, [ + { path: extraDir, pattern: "root.md" }, + { path: extraDir, pattern: "notes/**/*.md" }, + ]); + + expect(files.map((file) => path.relative(extraDir, file)).toSorted()).toEqual([ + path.join("notes", "keep.md"), + path.join("notes", "nested", "keep.md"), + "root.md", + ]); + }); + it("allows top-level dreams path casing variants", () => { expect(isMemoryPath("USER.md")).toBe(true); expect(isMemoryPath("dreams.md")).toBe(true); diff --git a/packages/memory-host-sdk/src/host/internal.ts b/packages/memory-host-sdk/src/host/internal.ts index b938989f6791..98cf63efed1b 100644 --- a/packages/memory-host-sdk/src/host/internal.ts +++ b/packages/memory-host-sdk/src/host/internal.ts @@ -4,9 +4,8 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; -import { normalizeStringEntries, uniqueStrings } from "@openclaw/normalization-core"; import { runWithConcurrency as runWithConcurrencyImpl } from "./concurrency.js"; -import { MEMORY_HOST_ROOT_FILENAME } from "./config-utils.js"; +import { MEMORY_HOST_ROOT_FILENAME, normalizeConfiguredMemoryExtraPaths } from "./config-utils.js"; import { estimateStructuredEmbeddingInputBytes } from "./embedding-input-limits.js"; import { buildTextEmbeddingInput, type EmbeddingInput } from "./embedding-inputs.js"; import { @@ -33,7 +32,7 @@ import { shouldSkipRootMemoryAuxiliaryPath, } from "./openclaw-runtime-memory.js"; import { retryTransientMemoryRead } from "./read-retry.js"; -import type { MemoryEntryProvenance } from "./types.js"; +import type { MemoryEntryProvenance, MemoryExtraPath } from "./types.js"; export { hashText } from "./hash.js"; import { hashText } from "./hash.js"; @@ -98,16 +97,52 @@ function expandHomePath(value: string): string { return value; } -export function normalizeExtraMemoryPaths(workspaceDir: string, extraPaths?: string[]): string[] { - if (!extraPaths?.length) { - return []; +export type NormalizedExtraMemoryPath = { path: string; pattern?: string }; + +export function normalizeExtraMemoryPathEntries( + workspaceDir: string, + extraPaths?: MemoryExtraPath[], +): NormalizedExtraMemoryPath[] { + return normalizeConfiguredMemoryExtraPaths(extraPaths).map((entry) => { + const configuredPath = typeof entry === "string" ? entry : entry.path; + const normalized: NormalizedExtraMemoryPath = { + path: path.resolve(workspaceDir, expandHomePath(configuredPath)), + }; + if (typeof entry !== "string") { + normalized.pattern = entry.pattern?.replaceAll("\\", "/"); + } + return normalized; + }); +} + +export function normalizeExtraMemoryPaths( + workspaceDir: string, + extraPaths?: MemoryExtraPath[], +): string[] { + return Array.from( + new Set(normalizeExtraMemoryPathEntries(workspaceDir, extraPaths).map((entry) => entry.path)), + ); +} + +export function matchesExtraMemoryPathEntry( + entry: NormalizedExtraMemoryPath, + candidatePath: string, +): boolean { + if (!entry.pattern) { + return true; + } + const relativePath = path.relative(entry.path, candidatePath); + if (!relativePath) { + return true; + } + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return false; + } + try { + return path.posix.matchesGlob(relativePath.replaceAll(path.sep, "/"), entry.pattern); + } catch { + return false; } - const resolved = normalizeStringEntries(extraPaths) - .map((value) => expandHomePath(value)) - .map((value) => - path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value), - ); - return uniqueStrings(resolved); } export function isMemoryPath(relPath: string): boolean { @@ -149,6 +184,7 @@ async function collectMemoryFilesFromDir( files: string[], multimodal?: MemoryMultimodalSettings, shouldSkipPath?: (absPath: string) => boolean, + extraPathEntry?: NormalizedExtraMemoryPath, ): Promise { const scan = await walkDirectory(dir, { symlinks: "skip", @@ -156,14 +192,15 @@ async function collectMemoryFilesFromDir( include: (entry) => !shouldSkipPath?.(entry.path) && entry.kind === "file" && - isAllowedMemoryFilePath(entry.path, multimodal), + isAllowedMemoryFilePath(entry.path, multimodal) && + (!extraPathEntry || matchesExtraMemoryPathEntry(extraPathEntry, entry.path)), }); files.push(...scan.entries.map((entry) => entry.path)); } export async function listMemoryFiles( workspaceDir: string, - extraPaths?: string[], + extraPaths?: MemoryExtraPath[], multimodal?: MemoryMultimodalSettings, ): Promise { const result: string[] = []; @@ -197,9 +234,10 @@ export async function listMemoryFiles( } } catch {} - const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); + const normalizedExtraPaths = normalizeExtraMemoryPathEntries(workspaceDir, extraPaths); if (normalizedExtraPaths.length > 0) { - for (const inputPath of normalizedExtraPaths) { + for (const entry of normalizedExtraPaths) { + const inputPath = entry.path; if (shouldSkipWorkspaceMemoryPath(inputPath)) { continue; } @@ -214,6 +252,7 @@ export async function listMemoryFiles( result, multimodal, shouldSkipWorkspaceMemoryPath, + entry, ); continue; } diff --git a/packages/memory-host-sdk/src/host/read-file.test.ts b/packages/memory-host-sdk/src/host/read-file.test.ts index ac0713b98c5e..bb229525bf79 100644 --- a/packages/memory-host-sdk/src/host/read-file.test.ts +++ b/packages/memory-host-sdk/src/host/read-file.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { readMemoryFile } from "./read-file.js"; +import { readAgentMemoryFile, readMemoryFile } from "./read-file.js"; async function createDirectorySymlink(target: string, linkPath: string): Promise { try { @@ -111,6 +111,34 @@ describe("readMemoryFile", () => { } }); + it("enforces configured extra path glob patterns through agent reads", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-read-file-")); + try { + const workspaceDir = path.join(tmpRoot, "workspace"); + const extraDir = path.join(tmpRoot, "extra"); + const allowedPath = path.join(extraDir, "runbooks", "team", "allowed.md"); + const excludedPath = path.join(extraDir, "private.md"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(path.dirname(allowedPath), { recursive: true }); + await fs.writeFile(allowedPath, "allowed", "utf-8"); + await fs.writeFile(excludedPath, "private", "utf-8"); + + const extraPaths = [{ path: extraDir, pattern: "runbooks/**/*.md" }]; + const cfg = { + agents: { entries: { main: { workspace: workspaceDir } } }, + memory: { search: { extraPaths } }, + }; + await expect( + readAgentMemoryFile({ cfg, agentId: "main", relPath: allowedPath }), + ).resolves.toMatchObject({ text: "allowed" }); + await expect( + readAgentMemoryFile({ cfg, agentId: "main", relPath: excludedPath }), + ).rejects.toThrow("path required"); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + } + }); + it("retries transient read errors for workspace memory files", async () => { const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-read-file-")); try { diff --git a/packages/memory-host-sdk/src/host/read-file.ts b/packages/memory-host-sdk/src/host/read-file.ts index 733c6edd89ac..8dbd92c1f474 100644 --- a/packages/memory-host-sdk/src/host/read-file.ts +++ b/packages/memory-host-sdk/src/host/read-file.ts @@ -16,13 +16,18 @@ import { root, statRegularFile, } from "./fs-utils.js"; -import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js"; +import { + isMemoryPath, + matchesExtraMemoryPathEntry, + normalizeExtraMemoryPathEntries, +} from "./internal.js"; import { buildMemoryReadResult, DEFAULT_MEMORY_READ_LINES, type MemoryReadResult, } from "./read-file-shared.js"; import { retryTransientMemoryRead } from "./read-retry.js"; +import type { MemoryExtraPath } from "./types.js"; // Secure markdown memory-file reader for workspace and configured extra paths. @@ -66,7 +71,7 @@ function isFileDisappearedDuringReadError(err: unknown): boolean { /** Read a validated memory markdown file from workspace or configured extra paths. */ export async function readMemoryFile(params: { workspaceDir: string; - extraPaths?: string[]; + extraPaths?: MemoryExtraPath[]; relPath: string; from?: number; lines?: number; @@ -85,15 +90,18 @@ export async function readMemoryFile(params: { const allowedWorkspace = inWorkspace && isMemoryPath(relPath); let allowedAdditional = false; if (!allowedWorkspace && (params.extraPaths?.length ?? 0) > 0) { - const additionalPaths = normalizeExtraMemoryPaths(params.workspaceDir, params.extraPaths); + const additionalPaths = normalizeExtraMemoryPathEntries(params.workspaceDir, params.extraPaths); for (const additionalPath of additionalPaths) { try { - const stat = await fs.lstat(additionalPath); + const stat = await fs.lstat(additionalPath.path); if (stat.isSymbolicLink()) { continue; } if (stat.isDirectory()) { - if (await isAllowedAdditionalDirectoryPath(additionalPath, absPath)) { + if ( + matchesExtraMemoryPathEntry(additionalPath, absPath) && + (await isAllowedAdditionalDirectoryPath(additionalPath.path, absPath)) + ) { const candidateStat = await fs.lstat(absPath).catch(() => null); if (candidateStat?.isSymbolicLink()) { continue; @@ -103,7 +111,7 @@ export async function readMemoryFile(params: { } continue; } - if (stat.isFile() && absPath === additionalPath && absPath.endsWith(".md")) { + if (stat.isFile() && absPath === additionalPath.path && absPath.endsWith(".md")) { allowedAdditional = true; break; } diff --git a/packages/memory-host-sdk/src/host/types.ts b/packages/memory-host-sdk/src/host/types.ts index 2cb04b8cd4f8..f5f432e0750d 100644 --- a/packages/memory-host-sdk/src/host/types.ts +++ b/packages/memory-host-sdk/src/host/types.ts @@ -5,6 +5,9 @@ export type MemoryOriginClass = "owner" | "agent" | "untrusted" | "system"; export type MemorySessionKind = "interactive" | "cron" | "heartbeat" | "subagent" | "unknown"; +/** Additional memory root, optionally narrowed by a root-relative glob. */ +export type MemoryExtraPath = string | { path: string; pattern?: string }; + export type MemoryEntryProvenance = { originClass: MemoryOriginClass; sessionKind: MemorySessionKind; @@ -108,7 +111,7 @@ export type MemoryProviderStatus = { dirty?: boolean; workspaceDir?: string; dbPath?: string; - extraPaths?: string[]; + extraPaths?: MemoryExtraPath[]; sources?: MemorySource[]; sourceCounts?: Array<{ source: MemorySource; files: number; chunks: number }>; cache?: { enabled: boolean; entries?: number; maxEntries?: number }; diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 18f3ff0d8acf..ddd466fa5038 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -579,7 +579,11 @@ describe("memory search config", () => { const cfg = asConfig({ memory: { search: { - extraPaths: ["/shared/notes", " docs "], + extraPaths: [ + "/shared/notes", + " docs ", + { path: "../team-notes", pattern: "runbooks/**/*.md" }, + ], }, }, @@ -591,7 +595,11 @@ describe("memory search config", () => { default: true, memory: { search: { - extraPaths: ["/shared/notes", "../team-notes"], + extraPaths: [ + "/shared/notes", + { path: "../team-notes", pattern: "runbooks/**/*.md" }, + { path: "../team-notes", pattern: "decisions/**/*.md" }, + ], }, }, }, @@ -599,7 +607,12 @@ describe("memory search config", () => { }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); - expect(resolved?.extraPaths).toEqual(["/shared/notes", "docs", "../team-notes"]); + expect(resolved?.extraPaths).toEqual([ + "/shared/notes", + "docs", + { path: "../team-notes", pattern: "runbooks/**/*.md" }, + { path: "../team-notes", pattern: "decisions/**/*.md" }, + ]); }); it("normalizes multimodal settings", () => { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index aac1bbc6793d..9d54dc90280c 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -6,13 +6,13 @@ import { MAX_TIMER_TIMEOUT_MS, resolvePositiveTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; -import { - normalizeStringEntries, - uniqueStrings, -} from "@openclaw/normalization-core/string-normalization"; import type { OpenClawConfig, MemorySearchConfig } from "../config/config.js"; import type { SecretInput } from "../config/types.secrets.js"; -import { resolveRememberAcrossConversations } from "../memory-host-sdk/host/config-utils.js"; +import { + normalizeConfiguredMemoryExtraPaths, + resolveRememberAcrossConversations, +} from "../memory-host-sdk/host/config-utils.js"; +import type { MemoryExtraPath } from "../memory-host-sdk/host/types.js"; import { isMemoryMultimodalEnabled, normalizeMemoryMultimodalSettings, @@ -32,7 +32,7 @@ export type ResolvedMemorySearchConfig = { sources: Array<"memory" | "sessions">; /** Sources searched when memory_search omits an explicit corpus. */ searchSources: Array<"memory" | "sessions">; - extraPaths: string[]; + extraPaths: MemoryExtraPath[]; multimodal: MemoryMultimodalSettings; provider: string; remote?: { @@ -270,11 +270,10 @@ function mergeConfig( rememberAcrossConversations ? [...searchSources, "sessions"] : configuredSources, sessionMemory, ); - const rawPaths = normalizeStringEntries([ + const extraPaths = normalizeConfiguredMemoryExtraPaths([ ...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? []), ]); - const extraPaths = uniqueStrings(rawPaths); const multimodal = normalizeMemoryMultimodalSettings({ enabled: overrides?.multimodal?.enabled ?? defaults?.multimodal?.enabled, modalities: overrides?.multimodal?.modalities ?? defaults?.multimodal?.modalities, diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-memory-qmd.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-memory-qmd.ts index eff441f2211f..72dc9edc8f1a 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-memory-qmd.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-memory-qmd.ts @@ -5,6 +5,8 @@ import { type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; +import { normalizeConfiguredMemoryExtraPaths } from "../../../memory-host-sdk/host/config-utils.js"; +import type { MemoryExtraPath } from "../../../memory-host-sdk/host/types.js"; import { visitAgentConfigScopes } from "./legacy-config-migrations.runtime.tier-eval.js"; import { deleteRetiredPath } from "./legacy-config-record-shared.js"; @@ -58,22 +60,28 @@ function migrateRetiredQmdExternalPaths(params: { } const memory = ensureRecord(params.scope, "memory"); const search = ensureRecord(memory, "search"); - const existingPaths = Array.isArray(search.extraPaths) - ? search.extraPaths.filter((value): value is string => typeof value === "string") - : []; - const nextPaths = [...existingPaths]; - const seen = new Set(existingPaths.map((value) => value.trim()).filter(Boolean)); + const existingPaths = normalizeConfiguredMemoryExtraPaths( + Array.isArray(search.extraPaths) + ? search.extraPaths.filter( + (entry): entry is MemoryExtraPath => + typeof entry === "string" || typeof getRecord(entry)?.path === "string", + ) + : [], + ); + const nextPaths: MemoryExtraPath[] = [...existingPaths]; + const entryKey = (entry: MemoryExtraPath) => + typeof entry === "string" ? `${entry}\0` : `${entry.path}\0${entry.pattern?.trim() ?? ""}`; + const seen = new Set(existingPaths.map(entryKey)); let added = 0; - let unsupportedPatterns = 0; for (const entry of params.entries) { - if (!seen.has(entry.path)) { - seen.add(entry.path); - nextPaths.push(entry.path); - added += 1; - } - if (entry.pattern && entry.pattern !== "**/*.md") { - unsupportedPatterns += 1; + const nextEntry: MemoryExtraPath = entry.pattern ? entry : entry.path; + const key = entryKey(nextEntry); + if (seen.has(key)) { + continue; } + seen.add(key); + nextPaths.push(nextEntry); + added += 1; } if (added > 0) { search.extraPaths = nextPaths; @@ -81,11 +89,6 @@ function migrateRetiredQmdExternalPaths(params: { `Migrated ${added} external QMD path${added === 1 ? "" : "s"} from ${params.sourcePath} → ${params.targetPath}.`, ); } - if (unsupportedPatterns > 0) { - params.changes.push( - `Removed ${unsupportedPatterns} QMD path pattern filter${unsupportedPatterns === 1 ? "" : "s"} from ${params.sourcePath}; builtin memory indexes supported files under the preserved paths.`, - ); - } } function migrateRetiredMemoryQmd(raw: Record, changes: string[]): void { diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.memory-qmd.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.memory-qmd.test.ts index 9597446d6f69..42c4f52b2a2a 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.memory-qmd.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.memory-qmd.test.ts @@ -103,7 +103,8 @@ describe("retired QMD memory config migration", () => { "notes", "/tmp/shared", "/tmp/global", - "/tmp/patterned", + { path: "/tmp/patterned", pattern: "notes/*.md" }, + { path: "/tmp/shared", pattern: "**/*.md" }, "/tmp/search", ]); expect(result.raw).toHaveProperty("agents.defaults.memory.search.extraPaths", [ @@ -113,17 +114,11 @@ describe("retired QMD memory config migration", () => { expect(result.raw).toHaveProperty("agents.entries.research.memory.search.enabled", false); expect(result.raw).toHaveProperty("agents.entries.research.memory.search.extraPaths", [ "/tmp/existing", - "/tmp/research", + { path: "/tmp/research", pattern: "*.md" }, ]); expect(result.raw).toHaveProperty("agents.list.0.memory.search.extraPaths", ["/tmp/list"]); expect(result.changes).toContain( - "Migrated 3 external QMD paths from memory.qmd.paths and memory.search.qmd.extraCollections → memory.search.extraPaths.", - ); - expect(result.changes).toContain( - "Removed 1 QMD path pattern filter from memory.qmd.paths and memory.search.qmd.extraCollections; builtin memory indexes supported files under the preserved paths.", - ); - expect(result.changes).toContain( - "Removed 1 QMD path pattern filter from agents.entries.research.memory.search.qmd.extraCollections; builtin memory indexes supported files under the preserved paths.", + "Migrated 4 external QMD paths from memory.qmd.paths and memory.search.qmd.extraCollections → memory.search.extraPaths.", ); expect(result.changes).toContain( "Removed retired QMD memory configuration; builtin memory is now the only memory engine.", diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index e52dadc35c81..e9bf978fb5b7 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -148,6 +148,32 @@ describe("config schema regressions", () => { ).toBe(true); }); + it("accepts mixed extra memory path entries", () => { + expect( + validateConfigObject({ + memory: { + search: { + extraPaths: ["../team-notes", { path: "../shared", pattern: "runbooks/**/*.md" }], + }, + }, + agents: { defaults: {} }, + }).ok, + ).toBe(true); + }); + + it.each([ + { pattern: "**/*.md" }, + { path: "../shared", pattern: 42 }, + { path: "../shared", name: "legacy-qmd-name" }, + ])("rejects invalid extra memory path object %j", (entry) => { + expect( + validateConfigObject({ + memory: { search: { extraPaths: [entry] } }, + agents: { defaults: {} }, + }).ok, + ).toBe(false); + }); + it("rejects local memorySearch GPU policy", () => { const res = validateConfigObject({ memory: { diff --git a/src/config/schema.help.models.ts b/src/config/schema.help.models.ts index 95f510ab3fa3..5f25a15bfd1e 100644 --- a/src/config/schema.help.models.ts +++ b/src/config/schema.help.models.ts @@ -203,7 +203,11 @@ export const MODEL_FIELD_HELP: Record = { "memory.search.sources": 'Chooses which sources are indexed: "memory" reads MEMORY.md + memory files, and "sessions" includes transcript history. Keep ["memory"] unless you need recall from prior chat transcripts.', "memory.search.extraPaths": - "Adds extra directories or .md files to the memory index beyond default memory files. Use this when key reference docs live elsewhere in your repo; when multimodal memory is enabled, matching image/audio files under these paths are also eligible for indexing.", + "Adds extra directories or .md files to the memory index beyond default memory files. Entries may be path strings or objects with a root-relative glob pattern. When multimodal memory is enabled, matching image/audio files under these paths are also eligible for indexing.", + "memory.search.extraPaths.*.path": + "Sets the extra memory directory or file. Relative paths resolve from the agent workspace; direct file entries are indexed exactly.", + "memory.search.extraPaths.*.pattern": + 'Limits a directory entry to supported files matching this root-relative glob, for example "runbooks/**/*.md". Omit it to scan all supported files recursively.', "memory.search.multimodal": 'Optional multimodal memory settings for indexing image and audio files from configured extra paths. Keep this off unless your embedding model explicitly supports cross-modal embeddings, and set `memory.search.fallback` to "none" while it is enabled. Matching files are uploaded to the configured remote embedding provider during indexing.', "memory.search.multimodal.enabled": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 6ef2fe77b712..a455a117082a 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -443,6 +443,8 @@ export const FIELD_LABELS: Record = { "memory.search.rememberAcrossConversations": "Remember Across Conversations", "memory.search.sources": "Memory Search Sources", "memory.search.extraPaths": "Extra Memory Paths", + "memory.search.extraPaths.*.path": "Extra Memory Path", + "memory.search.extraPaths.*.pattern": "Extra Memory Path Pattern", "memory.search.multimodal": "Memory Search Multimodal", "memory.search.multimodal.enabled": "Enable Memory Search Multimodal", "memory.search.multimodal.modalities": "Memory Search Multimodal Modalities", diff --git a/src/config/types.memory.ts b/src/config/types.memory.ts index dccb75daa812..4c70eac5fd95 100644 --- a/src/config/types.memory.ts +++ b/src/config/types.memory.ts @@ -1,9 +1,12 @@ +import type { MemoryExtraPath } from "../memory-host-sdk/host/types.js"; /** * Memory config types shared by core context-engine paths and memory host/plugin runtimes. * Builtin memory stays core-owned. */ import type { SecretInput } from "./types.secrets.js"; +export type { MemoryExtraPath } from "../memory-host-sdk/host/types.js"; + /** Citation rendering mode for memory-injected context. */ export type MemoryCitationsMode = "auto" | "on" | "off"; @@ -21,8 +24,8 @@ export type MemorySearchConfig = { rememberAcrossConversations?: boolean; /** Sources to index and search (default: ["memory"]). */ sources?: Array<"memory" | "sessions">; - /** Extra paths to include in memory search (directories or .md files). */ - extraPaths?: string[]; + /** Extra paths to include in memory search, optionally filtered by a glob. */ + extraPaths?: MemoryExtraPath[]; /** Optional multimodal file indexing for selected extra paths. */ multimodal?: { /** Enable image/audio embeddings from extraPaths. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index fe9962be26a6..d339e6c71a08 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -752,7 +752,14 @@ export const MemorySearchSchema = z enabled: z.boolean().optional(), rememberAcrossConversations: z.boolean().optional(), sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(), - extraPaths: z.array(z.string()).optional(), + extraPaths: z + .array( + z.union([ + z.string(), + z.object({ path: z.string(), pattern: z.string().optional() }).strict(), + ]), + ) + .optional(), multimodal: z .object({ enabled: z.boolean().optional(), diff --git a/src/memory-host-sdk/host/config-utils.ts b/src/memory-host-sdk/host/config-utils.ts index a3ad1cb0400a..1f0d1b8fc795 100644 --- a/src/memory-host-sdk/host/config-utils.ts +++ b/src/memory-host-sdk/host/config-utils.ts @@ -1,2 +1,5 @@ /** Core-facing memory host config helpers. */ -export { resolveRememberAcrossConversations } from "../../../packages/memory-host-sdk/src/host/config-utils.js"; +export { + normalizeConfiguredMemoryExtraPaths, + resolveRememberAcrossConversations, +} from "../../../packages/memory-host-sdk/src/host/config-utils.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-storage.ts b/src/plugin-sdk/memory-core-host-engine-storage.ts index 70438585e91e..239b9e512037 100644 --- a/src/plugin-sdk/memory-core-host-engine-storage.ts +++ b/src/plugin-sdk/memory-core-host-engine-storage.ts @@ -25,6 +25,7 @@ export { isTransientMemoryReadError, listMemoryFiles, loadSqliteVecExtension, + matchesExtraMemoryPathEntry, MEMORY_CHUNKING_VERSION, MEMORY_EMBEDDING_CACHE_TABLE, MEMORY_INDEX_CHUNKS_TABLE, @@ -37,6 +38,7 @@ export { MEMORY_INDEX_STATE_TABLE, MEMORY_INDEX_VECTOR_TABLE, normalizeProjectAnnotationKey, + normalizeExtraMemoryPathEntries, normalizeExtraMemoryPaths, parseEmbedding, readMemoryFile, @@ -58,6 +60,7 @@ export type { CuratedMarkdownEntry, CuratedProjectAnnotations, MemoryEntryProvenance, + MemoryExtraPath, MemoryOriginClass, MemorySearchResult, MemorySessionKind,