From ef4a0e92b7fd067c749494a9b11ae1f37e6d249f Mon Sep 17 00:00:00 2001 From: Vignesh Date: Mon, 9 Feb 2026 23:35:27 -0800 Subject: [PATCH] fix(memory/qmd): scope query to managed collections (#11645) --- CHANGELOG.md | 3 + docs/concepts/memory.md | 3 +- src/discord/monitor.slash.test.ts | 1 + src/memory/qmd-manager.test.ts | 128 ++++++++++++++++++++++++++++++ src/memory/qmd-manager.ts | 15 +++- 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e58cc84dee..a79ee64b744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. - Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj. - Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204. +- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07. - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy. @@ -89,6 +90,8 @@ Docs: https://docs.openclaw.ai - Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204. - Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204. - Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. +- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07. +- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi. - Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek. - Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 5b97015a1d1..4d4bf7f118f 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -135,7 +135,8 @@ out to QMD for retrieval. Key points: - Boot refresh now runs in the background by default so chat startup is not blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous blocking behavior. -- Searches run via `qmd query --json`. If QMD fails or the binary is missing, +- Searches run via `qmd query --json`, scoped to OpenClaw-managed collections. + If QMD fails or the binary is missing, OpenClaw automatically falls back to the builtin SQLite manager so memory tools keep working. - OpenClaw does not expose QMD embed batch-size tuning today; batch behavior is diff --git a/src/discord/monitor.slash.test.ts b/src/discord/monitor.slash.test.ts index 409f557a58b..86631a2c272 100644 --- a/src/discord/monitor.slash.test.ts +++ b/src/discord/monitor.slash.test.ts @@ -19,6 +19,7 @@ vi.mock("@buape/carbon", () => ({ PresenceUpdateListener: class {}, Row: class {}, StringSelectMenu: class {}, + BaseMessageInteractiveComponent: class {}, })); vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 56b4784197a..e2e8c1d727c 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -409,6 +409,87 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("scopes qmd queries to managed collections", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [ + { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, + { path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" }, + ], + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "query") { + const child = createMockChild({ autoClose: false }); + setTimeout(() => { + child.stdout.emit("data", "[]"); + child.closeWith(0); + }, 0); + return child; + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + const maxResults = resolved.qmd?.limits.maxResults; + if (!maxResults) { + throw new Error("qmd maxResults missing"); + } + + await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }); + const queryCall = spawnMock.mock.calls.find((call) => call[1]?.[0] === "query"); + expect(queryCall?.[1]).toEqual([ + "query", + "test", + "--json", + "-n", + String(maxResults), + "-c", + "workspace", + "-c", + "notes", + ]); + await manager.close(); + }); + + it("fails closed when no managed collections are configured", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + const results = await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }); + expect(results).toEqual([]); + expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "query")).toBe(false); + await manager.close(); + }); + it("logs and continues when qmd embed times out", async () => { vi.useFakeTimers(); cfg = { @@ -475,6 +556,9 @@ describe("QmdMemoryManager", () => { const isAllowed = (key?: string) => (manager as unknown as { isScopeAllowed: (key?: string) => boolean }).isScopeAllowed(key); expect(isAllowed("agent:main:slack:channel:c123")).toBe(true); + expect(isAllowed("agent:main:slack:direct:u123")).toBe(true); + expect(isAllowed("agent:main:slack:dm:u123")).toBe(true); + expect(isAllowed("agent:main:discord:direct:u123")).toBe(false); expect(isAllowed("agent:main:discord:channel:c123")).toBe(false); await manager.close(); @@ -516,6 +600,50 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("symlinks shared qmd models into the agent cache", async () => { + const defaultCacheHome = path.join(tmpRoot, "default-cache"); + const sharedModelsDir = path.join(defaultCacheHome, "qmd", "models"); + await fs.mkdir(sharedModelsDir, { recursive: true }); + const previousXdgCacheHome = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = defaultCacheHome; + const symlinkSpy = vi.spyOn(fs, "symlink"); + + try { + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + const targetModelsDir = path.join( + stateDir, + "agents", + agentId, + "qmd", + "xdg-cache", + "qmd", + "models", + ); + const modelsStat = await fs.lstat(targetModelsDir); + expect(modelsStat.isSymbolicLink() || modelsStat.isDirectory()).toBe(true); + expect( + symlinkSpy.mock.calls.some( + (call) => call[0] === sharedModelsDir && call[1] === targetModelsDir, + ), + ).toBe(true); + + await manager.close(); + } finally { + symlinkSpy.mockRestore(); + if (previousXdgCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME; + } else { + process.env.XDG_CACHE_HOME = previousXdgCacheHome; + } + } + }); + it("blocks non-markdown or symlink reads for qmd paths", async () => { const resolved = resolveMemoryBackendConfig({ cfg, agentId }); const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 078f0e16ff8..70c8391287f 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -262,7 +262,12 @@ export class QmdMemoryManager implements MemorySearchManager { this.qmd.limits.maxResults, opts?.maxResults ?? this.qmd.limits.maxResults, ); - const args = ["query", trimmed, "--json", "-n", String(limit)]; + const collectionFilterArgs = this.buildCollectionFilterArgs(); + if (collectionFilterArgs.length === 0) { + log.warn("qmd query skipped: no managed collections configured"); + return []; + } + const args = ["query", trimmed, "--json", "-n", String(limit), ...collectionFilterArgs]; let stdout: string; try { const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs }); @@ -975,4 +980,12 @@ export class QmdMemoryManager implements MemorySearchManager { new Promise((resolve) => setTimeout(resolve, SEARCH_PENDING_UPDATE_WAIT_MS)), ]); } + + private buildCollectionFilterArgs(): string[] { + const names = this.qmd.collections.map((collection) => collection.name).filter(Boolean); + if (names.length === 0) { + return []; + } + return names.flatMap((name) => ["-c", name]); + } }