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 <vincentkoc@ieee.org>
This commit is contained in:
Amine Harch el korane 2026-03-30 00:58:42 +01:00 committed by GitHub
parent cbceb1db76
commit 219d4f03bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 201 additions and 3 deletions

View File

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

View File

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

View File

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