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