Skip to content

Commit b7de04f

Browse files
authored
fix(memory): preserve shared qmd collection names (#57628)
* fix(memory): preserve shared qmd collection names * fix(memory): canonicalize qmd path containment
1 parent 85f3136 commit b7de04f

4 files changed

Lines changed: 145 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ Docs: https://docs.openclaw.ai
316316
- 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.
317317
- 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.
318318
- 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
319+
- Memory/QMD: preserve explicit custom collection names for shared paths outside the agent workspace so `memory_search` stops appending `-<agentId>` to externally managed QMD collections. (#52539) Thanks @lobsrice and @vincentkoc.
319320

320321
## 2026.3.24-beta.1
321322

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,6 +1747,46 @@ describe("QmdMemoryManager", () => {
17471747
await manager.close();
17481748
});
17491749

1750+
it("uses explicit external custom collection names verbatim at query time", async () => {
1751+
const sharedMirrorDir = path.join(tmpRoot, "shared-notion-mirror");
1752+
await fs.mkdir(sharedMirrorDir);
1753+
cfg = {
1754+
...cfg,
1755+
memory: {
1756+
backend: "qmd",
1757+
qmd: {
1758+
includeDefaultMemory: false,
1759+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
1760+
paths: [{ path: sharedMirrorDir, pattern: "**/*.md", name: "notion-mirror" }],
1761+
},
1762+
},
1763+
} as OpenClawConfig;
1764+
1765+
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
1766+
if (args[0] === "search") {
1767+
const child = createMockChild({ autoClose: false });
1768+
emitAndClose(child, "stdout", "[]");
1769+
return child;
1770+
}
1771+
return createMockChild();
1772+
});
1773+
1774+
const { manager, resolved } = await createManager();
1775+
1776+
await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" });
1777+
const maxResults = resolved.qmd?.limits.maxResults;
1778+
if (!maxResults) {
1779+
throw new Error("qmd maxResults missing");
1780+
}
1781+
const searchCalls = spawnMock.mock.calls
1782+
.map((call: unknown[]) => call[1] as string[])
1783+
.filter((args: string[]) => args[0] === "search");
1784+
expect(searchCalls).toEqual([
1785+
["search", "test", "--json", "-n", String(maxResults), "-c", "notion-mirror"],
1786+
]);
1787+
await manager.close();
1788+
});
1789+
17501790
it("runs qmd query per collection when query mode has multiple collection filters", async () => {
17511791
cfg = {
17521792
...cfg,

packages/memory-host-sdk/src/host/backend-config.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
13
import path from "node:path";
24
import { describe, expect, it } from "vitest";
35
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
@@ -111,6 +113,66 @@ describe("resolveMemoryBackendConfig", () => {
111113
expect(devNames.has("workspace-dev")).toBe(true);
112114
});
113115

116+
it("preserves explicit custom collection names for paths outside the workspace", () => {
117+
const cfg = {
118+
agents: {
119+
defaults: { workspace: "/workspace/root" },
120+
list: [
121+
{ id: "main", default: true, workspace: "/workspace/root" },
122+
{ id: "dev", workspace: "/workspace/dev" },
123+
],
124+
},
125+
memory: {
126+
backend: "qmd",
127+
qmd: {
128+
includeDefaultMemory: true,
129+
paths: [{ path: "/shared/notion-mirror", name: "notion-mirror", pattern: "**/*.md" }],
130+
},
131+
},
132+
} as OpenClawConfig;
133+
const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
134+
const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" });
135+
const mainNames = new Set(
136+
(mainResolved.qmd?.collections ?? []).map((collection) => collection.name),
137+
);
138+
const devNames = new Set(
139+
(devResolved.qmd?.collections ?? []).map((collection) => collection.name),
140+
);
141+
expect(mainNames.has("memory-dir-main")).toBe(true);
142+
expect(devNames.has("memory-dir-dev")).toBe(true);
143+
expect(mainNames.has("notion-mirror")).toBe(true);
144+
expect(devNames.has("notion-mirror")).toBe(true);
145+
});
146+
147+
it("keeps symlinked workspace paths agent-scoped when deciding custom collection names", async () => {
148+
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-"));
149+
const workspaceDir = path.join(tmpRoot, "workspace");
150+
const workspaceAliasDir = path.join(tmpRoot, "workspace-alias");
151+
try {
152+
await fs.mkdir(workspaceDir, { recursive: true });
153+
await fs.symlink(workspaceDir, workspaceAliasDir);
154+
const cfg = {
155+
agents: {
156+
defaults: { workspace: workspaceDir },
157+
list: [{ id: "main", default: true, workspace: workspaceDir }],
158+
},
159+
memory: {
160+
backend: "qmd",
161+
qmd: {
162+
includeDefaultMemory: false,
163+
paths: [{ path: workspaceAliasDir, name: "workspace", pattern: "**/*.md" }],
164+
},
165+
},
166+
} as OpenClawConfig;
167+
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
168+
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
169+
expect(names.has("workspace-main")).toBe(true);
170+
expect(names.has("workspace")).toBe(false);
171+
} finally {
172+
await fs.rm(tmpRoot, { recursive: true, force: true });
173+
}
174+
});
175+
114176
it("resolves qmd update timeout overrides", () => {
115177
const cfg = {
116178
agents: { defaults: { workspace: "/tmp/memory-test" } },
@@ -283,6 +345,25 @@ describe("memorySearch.extraPaths integration", () => {
283345
expect(paths).toContain(resolveComparablePath("/agent-only"));
284346
});
285347

348+
it("keeps unnamed extra paths agent-scoped even when they resolve outside the workspace", () => {
349+
const cfg = {
350+
memory: { backend: "qmd" },
351+
agents: {
352+
defaults: {
353+
workspace: "/workspace/root",
354+
memorySearch: {
355+
extraPaths: ["/shared/path"],
356+
},
357+
},
358+
},
359+
} as OpenClawConfig;
360+
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
361+
const customCollections = (result.qmd?.collections ?? []).filter(
362+
(collection) => collection.kind === "custom",
363+
);
364+
expect(customCollections.map((collection) => collection.name)).toContain("custom-1-my-agent");
365+
});
366+
286367
it("matches per-agent memorySearch.extraPaths using normalized agent ids", () => {
287368
const cfg = {
288369
memory: { backend: "qmd" },

packages/memory-host-sdk/src/host/backend-config.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs";
12
import path from "node:path";
23
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
34
import { parseDurationMs } from "../../../../src/cli/parse-duration.js";
@@ -115,6 +116,23 @@ function scopeCollectionBase(base: string, agentId: string): string {
115116
return `${base}-${sanitizeName(agentId)}`;
116117
}
117118

119+
function canonicalizePathForContainment(rawPath: string): string {
120+
const resolved = path.resolve(rawPath);
121+
try {
122+
return path.normalize(fs.realpathSync.native(resolved));
123+
} catch {
124+
return path.normalize(resolved);
125+
}
126+
}
127+
128+
function isPathInsideRoot(candidatePath: string, rootPath: string): boolean {
129+
const relative = path.relative(
130+
canonicalizePathForContainment(rootPath),
131+
canonicalizePathForContainment(candidatePath),
132+
);
133+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
134+
}
135+
118136
function ensureUniqueName(base: string, existing: Set<string>): string {
119137
let name = sanitizeName(base);
120138
if (!existing.has(name)) {
@@ -252,7 +270,11 @@ function resolveCustomPaths(
252270
return;
253271
}
254272
seenRoots.add(dedupeKey);
255-
const baseName = scopeCollectionBase(entry.name?.trim() || `custom-${index + 1}`, agentId);
273+
const explicitName = entry.name?.trim();
274+
const baseName =
275+
explicitName && !isPathInsideRoot(resolved, workspaceDir)
276+
? explicitName
277+
: scopeCollectionBase(explicitName || `custom-${index + 1}`, agentId);
256278
const name = ensureUniqueName(baseName, existing);
257279
collections.push({
258280
name,

0 commit comments

Comments
 (0)