Memory/QMD: add configurable search mode

This commit is contained in:
Vignesh Natarajan
2026-02-07 19:48:03 -08:00
committed by Vignesh
parent c2f9f2e1cd
commit 6d9d4d04ed
9 changed files with 907 additions and 8 deletions
+15
View File
@@ -25,6 +25,7 @@ describe("resolveMemoryBackendConfig", () => {
expect(resolved.backend).toBe("qmd");
expect(resolved.qmd?.collections.length).toBeGreaterThanOrEqual(3);
expect(resolved.qmd?.command).toBe("qmd");
expect(resolved.qmd?.searchMode).toBe("query");
expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0);
expect(resolved.qmd?.update.waitForBootSync).toBe(false);
expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000);
@@ -93,4 +94,18 @@ describe("resolveMemoryBackendConfig", () => {
expect(resolved.qmd?.update.updateTimeoutMs).toBe(480_000);
expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000);
});
it("resolves qmd search mode override", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
memory: {
backend: "qmd",
qmd: {
searchMode: "vsearch",
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.qmd?.searchMode).toBe("vsearch");
});
});
+11
View File
@@ -6,6 +6,7 @@ import type {
MemoryCitationsMode,
MemoryQmdConfig,
MemoryQmdIndexPath,
MemoryQmdSearchMode,
} from "../config/types.memory.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { parseDurationMs } from "../cli/parse-duration.js";
@@ -51,6 +52,7 @@ export type ResolvedQmdSessionConfig = {
export type ResolvedQmdConfig = {
command: string;
searchMode: MemoryQmdSearchMode;
collections: ResolvedQmdCollection[];
sessions: ResolvedQmdSessionConfig;
update: ResolvedQmdUpdateConfig;
@@ -64,6 +66,7 @@ const DEFAULT_CITATIONS: MemoryCitationsMode = "auto";
const DEFAULT_QMD_INTERVAL = "5m";
const DEFAULT_QMD_DEBOUNCE_MS = 15_000;
const DEFAULT_QMD_TIMEOUT_MS = 4_000;
const DEFAULT_QMD_SEARCH_MODE: MemoryQmdSearchMode = "query";
const DEFAULT_QMD_EMBED_INTERVAL = "60m";
const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000;
const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000;
@@ -171,6 +174,13 @@ function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig
return parsed;
}
function resolveSearchMode(raw?: MemoryQmdConfig["searchMode"]): MemoryQmdSearchMode {
if (raw === "search" || raw === "vsearch" || raw === "query") {
return raw;
}
return DEFAULT_QMD_SEARCH_MODE;
}
function resolveSessionConfig(
cfg: MemoryQmdConfig["sessions"],
workspaceDir: string,
@@ -265,6 +275,7 @@ export function resolveMemoryBackendConfig(params: {
const command = parsedCommand?.[0] || rawCommand.split(/\s+/)[0] || "qmd";
const resolved: ResolvedQmdConfig = {
command,
searchMode: resolveSearchMode(qmdCfg?.searchMode),
collections,
includeDefaultMemory,
sessions: resolveSessionConfig(qmdCfg?.sessions, workspaceDir),
+41
View File
@@ -285,6 +285,47 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("uses configured qmd search mode command", 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.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");
}
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);
expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "query")).toBe(false);
await manager.close();
});
it("queues a forced sync behind an in-flight update", async () => {
cfg = {
...cfg,
+45 -3
View File
@@ -260,7 +260,8 @@ export class QmdMemoryManager implements MemorySearchManager {
log.warn("qmd query skipped: no managed collections configured");
return [];
}
const args = ["query", trimmed, "--json", "-n", String(limit), ...collectionFilterArgs];
const qmdSearchCommand = this.qmd.searchMode;
const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit, collectionFilterArgs);
let stdout: string;
let stderr: string;
try {
@@ -268,8 +269,25 @@ export class QmdMemoryManager implements MemorySearchManager {
stdout = result.stdout;
stderr = result.stderr;
} catch (err) {
log.warn(`qmd query 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, collectionFilterArgs),
{ timeoutMs: this.qmd.limits.timeoutMs },
);
stdout = fallback.stdout;
stderr = fallback.stderr;
} 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));
}
}
const parsed = parseQmdQueryJson(stdout, stderr);
const results: MemorySearchResult[] = [];
@@ -953,6 +971,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}`);
@@ -976,4 +1006,16 @@ export class QmdMemoryManager implements MemorySearchManager {
}
return names.flatMap((name) => ["-c", name]);
}
private buildSearchArgs(
command: "query" | "search" | "vsearch",
query: string,
limit: number,
collectionFilterArgs: string[],
): string[] {
if (command === "query") {
return ["query", query, "--json", "-n", String(limit), ...collectionFilterArgs];
}
return [command, query, "--json"];
}
}