diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 0cd667eda5..8b50051383 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -135,9 +135,10 @@ out to QMD for retrieval. Key points: blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous blocking behavior. - Searches run via `memory.qmd.searchMode` (default `qmd query --json`; also - supports `search` and `vsearch`). If QMD fails or the binary is missing, - OpenClaw automatically falls back to the builtin SQLite manager so memory tools - keep working. + supports `search` and `vsearch`). If the selected mode rejects flags on your + QMD build, OpenClaw retries with `qmd query`. 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 controlled by QMD itself. - **First search may be slow**: QMD may download local GGUF models (reranker/query diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 81d13864de..fda4a8875d 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -295,13 +295,79 @@ describe("QmdMemoryManager", () => { if (!manager) { throw new Error("manager missing"); } + const maxResults = resolved.qmd?.limits.maxResults; + if (!maxResults) { + throw new Error("qmd maxResults missing"); + } await expect( manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), ).resolves.toEqual([]); - expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "search")).toBe(true); + const searchCall = spawnMock.mock.calls.find((call) => call[1]?.[0] === "search"); + expect(searchCall?.[1]).toEqual(["search", "test", "--json"]); expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "query")).toBe(false); + expect(maxResults).toBeGreaterThan(0); + await manager.close(); + }); + + it("retries search with qmd query when configured mode rejects flags", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "search", + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + setTimeout(() => { + child.stderr.emit("data", "unknown flag: --json"); + child.closeWith(2); + }, 0); + return child; + } + 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 expect( + manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), + ).resolves.toEqual([]); + + const searchAndQueryCalls = spawnMock.mock.calls + .map((call) => call[1]) + .filter( + (args): args is string[] => Array.isArray(args) && ["search", "query"].includes(args[0]), + ); + expect(searchAndQueryCalls).toEqual([ + ["search", "test", "--json"], + ["query", "test", "--json", "-n", String(maxResults)], + ]); await manager.close(); }); diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 2764a6aaaf..41331a3b0e 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -254,14 +254,29 @@ export class QmdMemoryManager implements MemorySearchManager { opts?.maxResults ?? this.qmd.limits.maxResults, ); const qmdSearchCommand = this.qmd.searchMode; - const args = [qmdSearchCommand, trimmed, "--json", "-n", String(limit)]; + const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit); let stdout: string; try { const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs }); stdout = result.stdout; } catch (err) { - log.warn(`qmd ${qmdSearchCommand} failed: ${String(err)}`); - throw err instanceof Error ? err : new Error(String(err)); + if (qmdSearchCommand !== "query" && this.isUnsupportedQmdOptionError(err)) { + log.warn( + `qmd ${qmdSearchCommand} does not support configured flags; retrying search with qmd query`, + ); + try { + const fallback = await this.runQmd(this.buildSearchArgs("query", trimmed, limit), { + timeoutMs: this.qmd.limits.timeoutMs, + }); + stdout = fallback.stdout; + } catch (fallbackErr) { + log.warn(`qmd query fallback failed: ${String(fallbackErr)}`); + throw fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr)); + } + } else { + log.warn(`qmd ${qmdSearchCommand} failed: ${String(err)}`); + throw err instanceof Error ? err : new Error(String(err)); + } } let parsed: QmdQueryResult[] = []; try { @@ -881,6 +896,18 @@ export class QmdMemoryManager implements MemorySearchManager { return normalized.includes("sqlite_busy") || normalized.includes("database is locked"); } + private isUnsupportedQmdOptionError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + const normalized = message.toLowerCase(); + return ( + normalized.includes("unknown flag") || + normalized.includes("unknown option") || + normalized.includes("unrecognized option") || + normalized.includes("flag provided but not defined") || + normalized.includes("unexpected argument") + ); + } + private createQmdBusyError(err: unknown): Error { const message = err instanceof Error ? err.message : String(err); return new Error(`qmd index busy while reading results: ${message}`); @@ -896,4 +923,15 @@ export class QmdMemoryManager implements MemorySearchManager { new Promise((resolve) => setTimeout(resolve, SEARCH_PENDING_UPDATE_WAIT_MS)), ]); } + + private buildSearchArgs( + command: "query" | "search" | "vsearch", + query: string, + limit: number, + ): string[] { + if (command === "query") { + return ["query", query, "--json", "-n", String(limit)]; + } + return [command, query, "--json"]; + } }