diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ae0c66ade..2ce8d426b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -316,6 +316,7 @@ Docs: https://docs.openclaw.ai - Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases. - CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release. - Tests/security audit: isolate audit-test home and personal skill resolution so local `~/.agents/skills` installs no longer make maintainer prep runs fail nondeterministically. (#54473) thanks @huntharo +- Memory/QMD: preserve explicit custom collection names for shared paths outside the agent workspace so `memory_search` stops appending `-` to externally managed QMD collections. (#52539) Thanks @lobsrice and @vincentkoc. ## 2026.3.24-beta.1 diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 6f57ae9f0c8..9a6782e954c 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -1747,6 +1747,46 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("uses explicit external custom collection names verbatim at query time", async () => { + const sharedMirrorDir = path.join(tmpRoot, "shared-notion-mirror"); + await fs.mkdir(sharedMirrorDir); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: sharedMirrorDir, pattern: "**/*.md", name: "notion-mirror" }], + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + + const { manager, resolved } = await createManager(); + + await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }); + const maxResults = resolved.qmd?.limits.maxResults; + if (!maxResults) { + throw new Error("qmd maxResults missing"); + } + const searchCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "search"); + expect(searchCalls).toEqual([ + ["search", "test", "--json", "-n", String(maxResults), "-c", "notion-mirror"], + ]); + await manager.close(); + }); + it("runs qmd query per collection when query mode has multiple collection filters", async () => { cfg = { ...cfg, 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 07f6d079265..9b944760f22 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js"; @@ -111,6 +113,66 @@ describe("resolveMemoryBackendConfig", () => { expect(devNames.has("workspace-dev")).toBe(true); }); + it("preserves explicit custom collection names for paths outside the workspace", () => { + const cfg = { + agents: { + defaults: { workspace: "/workspace/root" }, + list: [ + { id: "main", default: true, workspace: "/workspace/root" }, + { id: "dev", workspace: "/workspace/dev" }, + ], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + paths: [{ path: "/shared/notion-mirror", name: "notion-mirror", pattern: "**/*.md" }], + }, + }, + } as OpenClawConfig; + const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" }); + const mainNames = new Set( + (mainResolved.qmd?.collections ?? []).map((collection) => collection.name), + ); + const devNames = new Set( + (devResolved.qmd?.collections ?? []).map((collection) => collection.name), + ); + expect(mainNames.has("memory-dir-main")).toBe(true); + expect(devNames.has("memory-dir-dev")).toBe(true); + expect(mainNames.has("notion-mirror")).toBe(true); + expect(devNames.has("notion-mirror")).toBe(true); + }); + + it("keeps symlinked workspace paths agent-scoped when deciding custom collection names", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-")); + const workspaceDir = path.join(tmpRoot, "workspace"); + const workspaceAliasDir = path.join(tmpRoot, "workspace-alias"); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.symlink(workspaceDir, workspaceAliasDir); + const cfg = { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "main", default: true, workspace: workspaceDir }], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + paths: [{ path: workspaceAliasDir, name: "workspace", pattern: "**/*.md" }], + }, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name)); + expect(names.has("workspace-main")).toBe(true); + expect(names.has("workspace")).toBe(false); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + } + }); + it("resolves qmd update timeout overrides", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, @@ -283,6 +345,25 @@ describe("memorySearch.extraPaths integration", () => { expect(paths).toContain(resolveComparablePath("/agent-only")); }); + it("keeps unnamed extra paths agent-scoped even when they resolve outside the workspace", () => { + const cfg = { + memory: { backend: "qmd" }, + agents: { + defaults: { + workspace: "/workspace/root", + memorySearch: { + extraPaths: ["/shared/path"], + }, + }, + }, + } as OpenClawConfig; + const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" }); + const customCollections = (result.qmd?.collections ?? []).filter( + (collection) => collection.kind === "custom", + ); + expect(customCollections.map((collection) => collection.name)).toContain("custom-1-my-agent"); + }); + it("matches per-agent memorySearch.extraPaths using normalized agent ids", () => { const cfg = { memory: { backend: "qmd" }, diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 27cb8df9268..8d69d7b2f8b 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js"; import { parseDurationMs } from "../../../../src/cli/parse-duration.js"; @@ -115,6 +116,23 @@ function scopeCollectionBase(base: string, agentId: string): string { return `${base}-${sanitizeName(agentId)}`; } +function canonicalizePathForContainment(rawPath: string): string { + const resolved = path.resolve(rawPath); + try { + return path.normalize(fs.realpathSync.native(resolved)); + } catch { + return path.normalize(resolved); + } +} + +function isPathInsideRoot(candidatePath: string, rootPath: string): boolean { + const relative = path.relative( + canonicalizePathForContainment(rootPath), + canonicalizePathForContainment(candidatePath), + ); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + function ensureUniqueName(base: string, existing: Set): string { let name = sanitizeName(base); if (!existing.has(name)) { @@ -252,7 +270,11 @@ function resolveCustomPaths( return; } seenRoots.add(dedupeKey); - const baseName = scopeCollectionBase(entry.name?.trim() || `custom-${index + 1}`, agentId); + const explicitName = entry.name?.trim(); + const baseName = + explicitName && !isPathInsideRoot(resolved, workspaceDir) + ? explicitName + : scopeCollectionBase(explicitName || `custom-${index + 1}`, agentId); const name = ensureUniqueName(baseName, existing); collections.push({ name,