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
3 changed files with 201 additions and 3 deletions

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