From 219d4f03bd57bdaaf2c07cb968d4d30134abe836 Mon Sep 17 00:00:00 2001 From: Amine Harch el korane <95189778+Vitalcheffe@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:58:42 +0100 Subject: [PATCH] fix: wire memorySearch.extraPaths to QMD indexing (#57315) * fix: wire memorySearch.extraPaths to QMD indexing The 'agents.defaults.memorySearch.extraPaths' config field was documented to add extra directories to the memory index, but the paths were never actually passed to the QMD backend. Only 'memory.qmd.paths' worked. This fix reads extraPaths from the memorySearch config and maps them to QMD custom path collections, so users can simply configure: memorySearch: extraPaths: - odd-vault - /Users/odd/workspace - /Users/odd/docs And have those directories indexed alongside the default memory files. Closes #57302 * fix: handle per-agent memorySearch.extraPaths overrides + add tests - Read per-agent overrides from agents.list[].memorySearch.extraPaths - Agent-specific overrides take priority over defaults - Falls back to defaults when agent has no overrides - Added 3 test cases for the feature * fix: merge defaults + agent overrides instead of replacing * fix: remove any types from tests, fix merge behavior assertion * fix(memory): merge qmd extra path collections * fix(memory): normalize qmd extra path resolution * fix(memory): type qmd extra path merge --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../src/host/backend-config.test.ts | 171 ++++++++++++++++++ .../src/host/backend-config.ts | 32 +++- 3 files changed, 201 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5faab182a5f..aaefce8741a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - ACPX/runtime: derive the bundled ACPX expected version from the extension package metadata instead of hardcoding a separate literal, so plugin-local ACPX installs stop drifting out of health-check parity after version bumps. (#49089) Thanks @jiejiesks and @vincentkoc. - Gateway/auth: make local-direct `trusted-proxy` fallback require the configured shared token instead of silently authenticating same-host callers, while keeping same-host reverse proxy identity-header flows on the normal trusted-proxy path. Thanks @zhangning-agent and @vincentkoc. - Memory/QMD: send MCP `query` collection filters as the upstream `collections` array instead of the legacy singular `collection` field, so mcporter-backed QMD 1.1+ searches still scope correctly after the unified `query` tool migration. (#54728) Thanks @armanddp and @vincentkoc. +- Memory/QMD: include deduplicated default plus per-agent `memorySearch.extraPaths` when building QMD custom collections, so shared and agent-specific extra roots both get indexed consistently. (#57315) Thanks @Vitalcheffe and @vincentkoc. - Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman. - LINE/ACP: add current-conversation binding and inbound binding-routing parity so `/acp spawn ... --thread here`, configured ACP bindings, and active conversation-bound ACP sessions work on LINE like the other conversation channels. - LINE/markdown: preserve underscores inside Latin, Cyrillic, and CJK words when stripping markdown, while still removing standalone `_italic_` markers on the shared text-runtime path used by LINE and TTS. (#47465) Thanks @jackjin1997. diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index ff21d063e56..95e523072b4 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -144,3 +144,174 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.qmd?.searchMode).toBe("vsearch"); }); }); + +describe("memorySearch.extraPaths integration", () => { + it("maps agents.defaults.memorySearch.extraPaths to QMD collections", () => { + const cfg = { + memory: { backend: "qmd" }, + agents: { + defaults: { + workspace: "/workspace/root", + memorySearch: { + extraPaths: ["/home/user/docs", "/home/user/vault"], + }, + }, + }, + } as OpenClawConfig; + const result = resolveMemoryBackendConfig({ cfg, agentId: "test-agent" }); + expect(result.backend).toBe("qmd"); + const customCollections = (result.qmd?.collections ?? []).filter( + (collection) => collection.kind === "custom", + ); + expect(customCollections.length).toBeGreaterThanOrEqual(2); + expect(customCollections.map((collection) => collection.path)).toEqual( + expect.arrayContaining(["/home/user/docs", "/home/user/vault"]), + ); + }); + + it("merges default and per-agent memorySearch.extraPaths for QMD collections", () => { + const cfg = { + memory: { backend: "qmd" }, + agents: { + defaults: { + workspace: "/workspace/root", + memorySearch: { + extraPaths: ["/default/path"], + }, + }, + list: [ + { + id: "my-agent", + memorySearch: { + extraPaths: ["/agent/specific/path"], + }, + }, + ], + }, + } as OpenClawConfig; + const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); + expect(result.backend).toBe("qmd"); + const customCollections = (result.qmd?.collections ?? []).filter( + (collection) => collection.kind === "custom", + ); + const paths = customCollections.map((collection) => collection.path); + expect(paths).toContain("/agent/specific/path"); + expect(paths).toContain("/default/path"); + }); + + it("falls back to defaults when agent has no overrides", () => { + const cfg = { + memory: { backend: "qmd" }, + agents: { + defaults: { + workspace: "/workspace/root", + memorySearch: { + extraPaths: ["/default/path"], + }, + }, + list: [ + { + id: "other-agent", + memorySearch: { + extraPaths: ["/other/path"], + }, + }, + ], + }, + } as OpenClawConfig; + const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); + expect(result.backend).toBe("qmd"); + const customCollections = (result.qmd?.collections ?? []).filter( + (collection) => collection.kind === "custom", + ); + const paths = customCollections.map((collection) => collection.path); + expect(paths).toContain("/default/path"); + }); + + it("deduplicates merged memorySearch.extraPaths for QMD collections", () => { + const cfg = { + memory: { backend: "qmd" }, + agents: { + defaults: { + workspace: "/workspace/root", + memorySearch: { + extraPaths: ["/shared/path", " /shared/path "], + }, + }, + list: [ + { + id: "my-agent", + memorySearch: { + extraPaths: ["/shared/path", "/agent-only"], + }, + }, + ], + }, + } as OpenClawConfig; + + const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); + const customCollections = (result.qmd?.collections ?? []).filter( + (collection) => collection.kind === "custom", + ); + const paths = customCollections.map((collection) => collection.path); + + expect(paths.filter((collectionPath) => collectionPath === "/shared/path")).toHaveLength(1); + expect(paths).toContain("/agent-only"); + }); + + it("matches per-agent memorySearch.extraPaths using normalized agent ids", () => { + const cfg = { + memory: { backend: "qmd" }, + agents: { + defaults: { + workspace: "/workspace/root", + }, + list: [ + { + id: "My-Agent", + memorySearch: { + extraPaths: ["/agent/mixed-case"], + }, + }, + ], + }, + } as OpenClawConfig; + + const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); + const customCollections = (result.qmd?.collections ?? []).filter( + (collection) => collection.kind === "custom", + ); + + expect(customCollections.map((collection) => collection.path)).toContain("/agent/mixed-case"); + }); + + it("deduplicates identical roots shared by memory.qmd.paths and memorySearch.extraPaths", () => { + const cfg = { + memory: { + backend: "qmd", + qmd: { + paths: [{ path: "docs", pattern: "**/*.md", name: "workspace-docs" }], + }, + }, + agents: { + defaults: { + workspace: "/workspace/root", + memorySearch: { + extraPaths: ["./docs"], + }, + }, + }, + } as OpenClawConfig; + + const result = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const customCollections = (result.qmd?.collections ?? []).filter( + (collection) => collection.kind === "custom", + ); + const docsCollections = customCollections.filter( + (collection) => + collection.path === "/workspace/root/docs" && collection.pattern === "**/*.md", + ); + + expect(docsCollections).toHaveLength(1); + }); +}); diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index cd59bfe29d0..2907b272937 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -11,6 +11,7 @@ import type { MemoryQmdMcporterConfig, MemoryQmdSearchMode, } from "../../../../src/config/types.memory.js"; +import { normalizeAgentId } from "../../../../src/routing/session-key.js"; import { resolveUserPath } from "../../../../src/utils.js"; import { splitShellArgs } from "../../../../src/utils/shell-argv.js"; @@ -227,6 +228,7 @@ function resolveCustomPaths( return []; } const collections: ResolvedQmdCollection[] = []; + const seenRoots = new Set(); rawPaths.forEach((entry, index) => { const trimmedPath = entry?.path?.trim(); if (!trimmedPath) { @@ -239,6 +241,11 @@ function resolveCustomPaths( return; } const pattern = entry.pattern?.trim() || "**/*.md"; + const dedupeKey = `${resolved}\u0000${pattern}`; + if (seenRoots.has(dedupeKey)) { + return; + } + seenRoots.add(dedupeKey); const baseName = scopeCollectionBase(entry.name?.trim() || `custom-${index + 1}`, agentId); const name = ensureUniqueName(baseName, existing); collections.push({ @@ -298,19 +305,38 @@ export function resolveMemoryBackendConfig(params: { cfg: OpenClawConfig; agentId: string; }): ResolvedMemoryBackendConfig { + const normalizedAgentId = normalizeAgentId(params.agentId); const backend = params.cfg.memory?.backend ?? DEFAULT_BACKEND; const citations = params.cfg.memory?.citations ?? DEFAULT_CITATIONS; if (backend !== "qmd") { return { backend: "builtin", citations }; } - const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, normalizedAgentId); const qmdCfg = params.cfg.memory?.qmd; const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false; const nameSet = new Set(); + const agentEntry = params.cfg.agents?.list?.find( + (entry) => normalizeAgentId(entry?.id) === normalizedAgentId, + ); + const mergedExtraPaths = [ + ...(params.cfg.agents?.defaults?.memorySearch?.extraPaths ?? []), + ...(agentEntry?.memorySearch?.extraPaths ?? []), + ] + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + const dedupedExtraPaths = Array.from(new Set(mergedExtraPaths)); + const searchExtraPaths = dedupedExtraPaths.map( + (pathValue): { path: string; pattern?: string; name?: string } => ({ path: pathValue }), + ); + + // Combine QMD-specific paths with memorySearch extraPaths + const allQmdPaths: MemoryQmdIndexPath[] = [...(qmdCfg?.paths ?? []), ...searchExtraPaths]; + const collections = [ - ...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet, params.agentId), - ...resolveCustomPaths(qmdCfg?.paths, workspaceDir, nameSet, params.agentId), + ...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet, normalizedAgentId), + ...resolveCustomPaths(allQmdPaths, workspaceDir, nameSet, normalizedAgentId), ]; const rawCommand = qmdCfg?.command?.trim() || "qmd";