From b32ae6fa0c234bfaaa9fc36e958ccdd1ceab8e9d Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 15 Feb 2026 20:14:37 -0800 Subject: [PATCH] fix (memory/qmd): isolate managed collections per agent --- src/memory/backend-config.test.ts | 31 ++++++++++++++++++ src/memory/backend-config.ts | 14 +++++--- src/memory/qmd-manager.test.ts | 54 +++++++++++++++++++------------ src/memory/qmd-manager.ts | 34 ++++++++++++++++--- 4 files changed, 104 insertions(+), 29 deletions(-) diff --git a/src/memory/backend-config.test.ts b/src/memory/backend-config.test.ts index 78fd73967e..c352385e84 100644 --- a/src/memory/backend-config.test.ts +++ b/src/memory/backend-config.test.ts @@ -31,6 +31,10 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000); expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000); expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000); + const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name)); + expect(names.has("memory-root-main")).toBe(true); + expect(names.has("memory-alt-main")).toBe(true); + expect(names.has("memory-dir-main")).toBe(true); }); it("parses quoted qmd command paths", () => { @@ -73,6 +77,33 @@ describe("resolveMemoryBackendConfig", () => { expect(custom?.path).toBe(path.resolve(workspaceRoot, "notes")); }); + it("scopes qmd collection names per agent", () => { + 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: "notes", name: "workspace", 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("workspace-main")).toBe(true); + expect(devNames.has("workspace-dev")).toBe(true); + }); + it("resolves qmd update timeout overrides", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, diff --git a/src/memory/backend-config.ts b/src/memory/backend-config.ts index 53f0a55845..d632f4e8cb 100644 --- a/src/memory/backend-config.ts +++ b/src/memory/backend-config.ts @@ -95,6 +95,10 @@ function sanitizeName(input: string): string { return trimmed || "collection"; } +function scopeCollectionBase(base: string, agentId: string): string { + return `${base}-${sanitizeName(agentId)}`; +} + function ensureUniqueName(base: string, existing: Set): string { let name = sanitizeName(base); if (!existing.has(name)) { @@ -203,6 +207,7 @@ function resolveCustomPaths( rawPaths: MemoryQmdIndexPath[] | undefined, workspaceDir: string, existing: Set, + agentId: string, ): ResolvedQmdCollection[] { if (!rawPaths?.length) { return []; @@ -220,7 +225,7 @@ function resolveCustomPaths( return; } const pattern = entry.pattern?.trim() || "**/*.md"; - const baseName = entry.name?.trim() || `custom-${index + 1}`; + const baseName = scopeCollectionBase(entry.name?.trim() || `custom-${index + 1}`, agentId); const name = ensureUniqueName(baseName, existing); collections.push({ name, @@ -236,6 +241,7 @@ function resolveDefaultCollections( include: boolean, workspaceDir: string, existing: Set, + agentId: string, ): ResolvedQmdCollection[] { if (!include) { return []; @@ -246,7 +252,7 @@ function resolveDefaultCollections( { path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" }, ]; return entries.map((entry) => ({ - name: ensureUniqueName(entry.base, existing), + name: ensureUniqueName(scopeCollectionBase(entry.base, agentId), existing), path: entry.path, pattern: entry.pattern, kind: "memory", @@ -268,8 +274,8 @@ export function resolveMemoryBackendConfig(params: { const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false; const nameSet = new Set(); const collections = [ - ...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet), - ...resolveCustomPaths(qmdCfg?.paths, workspaceDir, nameSet), + ...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet, params.agentId), + ...resolveCustomPaths(qmdCfg?.paths, workspaceDir, nameSet, params.agentId), ]; const rawCommand = qmdCfg?.command?.trim() || "qmd"; diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 05eab63d7e..25afac6f24 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -313,6 +313,7 @@ describe("QmdMemoryManager", () => { }, } as OpenClawConfig; + const sessionCollectionName = `sessions-${devAgentId}`; const wrongSessionsPath = path.join(stateDir, "agents", agentId, "qmd", "sessions"); spawnMock.mockImplementation((_cmd: string, args: string[]) => { if (args[0] === "collection" && args[1] === "list") { @@ -320,7 +321,7 @@ describe("QmdMemoryManager", () => { emitAndClose( child, "stdout", - JSON.stringify([{ name: "sessions", path: wrongSessionsPath, mask: "**/*.md" }]), + JSON.stringify([{ name: sessionCollectionName, path: wrongSessionsPath, mask: "**/*.md" }]), ); return child; } @@ -339,7 +340,7 @@ describe("QmdMemoryManager", () => { const commands = spawnMock.mock.calls.map((call) => call[1] as string[]); const removeSessions = commands.find( - (args) => args[0] === "collection" && args[1] === "remove" && args[2] === "sessions", + (args) => args[0] === "collection" && args[1] === "remove" && args[2] === sessionCollectionName, ); expect(removeSessions).toBeDefined(); @@ -348,7 +349,7 @@ describe("QmdMemoryManager", () => { return false; } const nameIdx = args.indexOf("--name"); - return nameIdx >= 0 && args[nameIdx + 1] === "sessions"; + return nameIdx >= 0 && args[nameIdx + 1] === sessionCollectionName; }); expect(addSessions).toBeDefined(); expect(addSessions?.[2]).toBe(path.join(stateDir, "agents", devAgentId, "qmd", "sessions")); @@ -368,10 +369,11 @@ describe("QmdMemoryManager", () => { }, } as OpenClawConfig; + const sessionCollectionName = `sessions-${agentId}`; spawnMock.mockImplementation((_cmd: string, args: string[]) => { if (args[0] === "collection" && args[1] === "list") { const child = createMockChild({ autoClose: false }); - emitAndClose(child, "stdout", JSON.stringify(["workspace", "sessions"])); + emitAndClose(child, "stdout", JSON.stringify([`workspace-${agentId}`, sessionCollectionName])); return child; } return createMockChild(); @@ -382,7 +384,7 @@ describe("QmdMemoryManager", () => { const commands = spawnMock.mock.calls.map((call) => call[1] as string[]); const removeSessions = commands.find( - (args) => args[0] === "collection" && args[1] === "remove" && args[2] === "sessions", + (args) => args[0] === "collection" && args[1] === "remove" && args[2] === sessionCollectionName, ); expect(removeSessions).toBeDefined(); @@ -391,7 +393,7 @@ describe("QmdMemoryManager", () => { return false; } const nameIdx = args.indexOf("--name"); - return nameIdx >= 0 && args[nameIdx + 1] === "sessions"; + return nameIdx >= 0 && args[nameIdx + 1] === sessionCollectionName; }); expect(addSessions).toBeDefined(); }); @@ -484,8 +486,8 @@ describe("QmdMemoryManager", () => { .map((args) => args[args.indexOf("--name") + 1]); expect(updateCalls).toBe(2); - expect(removeCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]); - expect(addCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]); + expect(removeCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); + expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); expect(logWarnMock).toHaveBeenCalledWith( expect.stringContaining("suspected null-byte collection metadata"), ); @@ -573,7 +575,7 @@ describe("QmdMemoryManager", () => { "-n", String(resolved.qmd?.limits.maxResults), "-c", - "workspace", + "workspace-main", ]); expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "query")).toBe(false); expect(maxResults).toBeGreaterThan(0); @@ -623,8 +625,8 @@ describe("QmdMemoryManager", () => { (args): args is string[] => Array.isArray(args) && ["search", "query"].includes(args[0]), ); expect(searchAndQueryCalls).toEqual([ - ["search", "test", "--json", "-n", String(maxResults), "-c", "workspace"], - ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace"], + ["search", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], + ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], ]); await manager.close(); }); @@ -789,9 +791,9 @@ describe("QmdMemoryManager", () => { "-n", String(maxResults), "-c", - "workspace", + "workspace-main", "-c", - "notes", + "notes-main", ]); await manager.close(); }); @@ -836,8 +838,8 @@ describe("QmdMemoryManager", () => { .map((call) => call[1] as string[]) .filter((args) => args[0] === "query"); expect(queryCalls).toEqual([ - ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace"], - ["query", "test", "--json", "-n", String(maxResults), "-c", "notes"], + ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], + ["query", "test", "--json", "-n", String(maxResults), "-c", "notes-main"], ]); await manager.close(); }); @@ -887,9 +889,19 @@ describe("QmdMemoryManager", () => { .map((call) => call[1] as string[]) .filter((args) => args[0] === "search" || args[0] === "query"); expect(searchAndQueryCalls).toEqual([ - ["search", "test", "--json", "-n", String(maxResults), "-c", "workspace", "-c", "notes"], - ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace"], - ["query", "test", "--json", "-n", String(maxResults), "-c", "notes"], + [ + "search", + "test", + "--json", + "-n", + String(maxResults), + "-c", + "workspace-main", + "-c", + "notes-main", + ], + ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"], + ["query", "test", "--json", "-n", String(maxResults), "-c", "notes-main"], ]); await manager.close(); }); @@ -1020,7 +1032,7 @@ describe("QmdMemoryManager", () => { const textPath = path.join(workspaceDir, "secret.txt"); await fs.writeFile(textPath, "nope", "utf-8"); - await expect(manager.readFile({ relPath: "qmd/workspace/secret.txt" })).rejects.toThrow( + await expect(manager.readFile({ relPath: "qmd/workspace-main/secret.txt" })).rejects.toThrow( "path required", ); @@ -1028,7 +1040,7 @@ describe("QmdMemoryManager", () => { await fs.writeFile(target, "ok", "utf-8"); const link = path.join(workspaceDir, "link.md"); await fs.symlink(target, link); - await expect(manager.readFile({ relPath: "qmd/workspace/link.md" })).rejects.toThrow( + await expect(manager.readFile({ relPath: "qmd/workspace-main/link.md" })).rejects.toThrow( "path required", ); @@ -1182,7 +1194,7 @@ describe("QmdMemoryManager", () => { } if (query.includes("hash LIKE ?")) { expect(arg).toBe(`${exactDocid}%`); - return { collection: "workspace", path: "notes/welcome.md" }; + return { collection: "workspace-main", path: "notes/welcome.md" }; } throw new Error(`unexpected sqlite query: ${query}`); }, diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 3b422d883f..3353b18d3e 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -162,6 +162,9 @@ export class QmdMemoryManager implements MemorySearchManager { await fs.mkdir(this.xdgConfigHome, { recursive: true }); await fs.mkdir(this.xdgCacheHome, { recursive: true }); await fs.mkdir(path.dirname(this.indexPath), { recursive: true }); + if (this.sessionExporter) { + await fs.mkdir(this.sessionExporter.dir, { recursive: true }); + } // QMD stores its ML models under $XDG_CACHE_HOME/qmd/models/. Because we // override XDG_CACHE_HOME to isolate the index per-agent, qmd would not @@ -257,6 +260,7 @@ export class QmdMemoryManager implements MemorySearchManager { } } try { + await this.ensureCollectionPath(collection); await this.addCollection(collection.path, collection.name, collection.pattern); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -268,6 +272,21 @@ export class QmdMemoryManager implements MemorySearchManager { } } + private async ensureCollectionPath(collection: { + path: string; + pattern: string; + kind: "memory" | "custom" | "sessions"; + }): Promise { + if (!this.isDirectoryGlobPattern(collection.pattern)) { + return; + } + await fs.mkdir(collection.path, { recursive: true }); + } + + private isDirectoryGlobPattern(pattern: string): boolean { + return pattern.includes("*") || pattern.includes("?") || pattern.includes("["); + } + private isCollectionAlreadyExistsError(message: string): boolean { const lower = message.toLowerCase(); return lower.includes("already exists") || lower.includes("exists"); @@ -843,18 +862,25 @@ export class QmdMemoryManager implements MemorySearchManager { private pickSessionCollectionName(): string { const existing = new Set(this.qmd.collections.map((collection) => collection.name)); - if (!existing.has("sessions")) { - return "sessions"; + const base = `sessions-${this.sanitizeCollectionNameSegment(this.agentId)}`; + if (!existing.has(base)) { + return base; } let counter = 2; - let candidate = `sessions-${counter}`; + let candidate = `${base}-${counter}`; while (existing.has(candidate)) { counter += 1; - candidate = `sessions-${counter}`; + candidate = `${base}-${counter}`; } return candidate; } + private sanitizeCollectionNameSegment(input: string): string { + const lower = input.toLowerCase().replace(/[^a-z0-9-]+/g, "-"); + const trimmed = lower.replace(/^-+|-+$/g, ""); + return trimmed || "agent"; + } + private async resolveDocLocation( docid?: string, ): Promise<{ rel: string; abs: string; source: MemorySource } | null> {