Skip to content

Commit 4ebec8b

Browse files
committed
fix(memory): group qmd collection searches
1 parent eb1a201 commit 4ebec8b

5 files changed

Lines changed: 178 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
6262
- Ollama/WSL2: warn when GPU-backed WSL2 installs combine CUDA visibility with an autostarting `ollama.service` using `Restart=always`, and document the systemd, `.wslconfig`, and keep-alive mitigation for crash loops. Carries forward #61022; fixes #61185. Thanks @yhyatt.
6363
- Ollama/onboarding: de-dupe suggested bare local models against installed `:latest` tags and skip redundant pulls, so setup shows the installed model once and no longer says it is downloading an already available model. Fixes #68952. Thanks @tleyden.
6464
- Memory-core/doctor: keep `doctor.memory.status` on the cached path by default and only run live embedding pings for explicit deep probes, preventing slow local embedding backends from blocking Gateway status checks. Fixes #71568. Thanks @apex-system.
65+
- Memory/QMD: group same-source collections into one QMD search invocation when the installed QMD supports multiple `-c` filters, while keeping older QMD builds on the per-collection fallback. Fixes #72484; supersedes #72485 and #69583. Thanks @BsnizND and @zeroaltitude.
6566
- Memory/QMD: skip QMD vector status probes and embedding maintenance in lexical `searchMode: "search"`, so BM25-only QMD setups on ARM do not trigger llama.cpp/Vulkan builds during status checks or embed cycles. Fixes #59234 and #67113. Thanks @PrinceOfEgypt, @Vksh07, @Snipe76, @NomLom, @t4r3e2q1-commits, and @dmak.
6667
- Memory/QMD: report the live watcher dirty state in memory status, so changed QMD-backed memory files show as dirty until the queued sync finishes. Fixes #60244. Thanks @xinzf.
6768
- Compaction: skip oversized pre-compaction checkpoint snapshots and prune duplicate long user turns from compaction input and rotated successor transcripts, preventing retry storms from being preserved across checkpoint cycles. Fixes #72780. Thanks @SweetSophia.

docs/concepts/memory-qmd.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ present.
6060
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic
6161
vector readiness probes and embedding maintenance in that mode. If a mode
6262
fails, OpenClaw retries with `qmd query`.
63+
- With QMD releases that advertise multi-collection filters, OpenClaw groups
64+
same-source collections into one QMD search invocation. Older QMD releases
65+
keep the compatible per-collection fallback.
6366
- If QMD fails entirely, OpenClaw falls back to the builtin SQLite engine.
6467

6568
<Info>

docs/reference/memory-config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ Set `memory.backend = "qmd"` to enable. All QMD settings live under `memory.qmd`
451451

452452
`searchMode: "search"` is lexical/BM25-only. OpenClaw does not run semantic vector readiness probes or QMD embedding maintenance for that mode, including during `memory status --deep`; `vsearch` and `query` continue to require QMD vector readiness and embeddings.
453453

454-
OpenClaw prefers current QMD collection and MCP query shapes, but keeps older QMD releases working by trying compatible collection pattern flags and older MCP tool names when needed.
454+
OpenClaw prefers current QMD collection and MCP query shapes, but keeps older QMD releases working by trying compatible collection pattern flags and older MCP tool names when needed. When QMD advertises support for multiple collection filters, same-source collections are searched with one QMD process; older QMD builds keep the per-collection compatibility path.
455455

456456
<Note>
457457
QMD model overrides stay on the QMD side, not OpenClaw config. If you need to override QMD's models globally, set environment variables such as `QMD_EMBED_MODEL`, `QMD_RERANK_MODEL`, and `QMD_GENERATE_MODEL` in the gateway runtime environment.

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2134,6 +2134,115 @@ describe("QmdMemoryManager", () => {
21342134
await manager.close();
21352135
});
21362136

2137+
it("groups same-source qmd queries when the installed qmd supports multiple collection filters", async () => {
2138+
cfg = {
2139+
...cfg,
2140+
memory: {
2141+
backend: "qmd",
2142+
qmd: {
2143+
includeDefaultMemory: false,
2144+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2145+
paths: [
2146+
{ path: workspaceDir, pattern: "**/*.md", name: "workspace" },
2147+
{ path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" },
2148+
],
2149+
},
2150+
},
2151+
} as OpenClawConfig;
2152+
2153+
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
2154+
if (args[0] === "--help") {
2155+
const child = createMockChild({ autoClose: false });
2156+
emitAndClose(
2157+
child,
2158+
"stdout",
2159+
"-c, --collection <name> Filter by one or more collections",
2160+
);
2161+
return child;
2162+
}
2163+
if (args[0] === "search") {
2164+
const child = createMockChild({ autoClose: false });
2165+
emitAndClose(child, "stdout", "[]");
2166+
return child;
2167+
}
2168+
return createMockChild();
2169+
});
2170+
2171+
const { manager, resolved } = await createManager();
2172+
2173+
await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" });
2174+
const maxResults = resolved.qmd?.limits.maxResults;
2175+
if (!maxResults) {
2176+
throw new Error("qmd maxResults missing");
2177+
}
2178+
const searchCalls = spawnMock.mock.calls
2179+
.map((call: unknown[]) => call[1] as string[])
2180+
.filter((args: string[]) => args[0] === "search");
2181+
expect(searchCalls).toEqual([
2182+
[
2183+
"search",
2184+
"test",
2185+
"--json",
2186+
"-n",
2187+
String(maxResults),
2188+
"-c",
2189+
"workspace-main",
2190+
"-c",
2191+
"notes-main",
2192+
],
2193+
]);
2194+
await manager.close();
2195+
});
2196+
2197+
it("keeps mixed-source qmd queries in separate source groups", async () => {
2198+
cfg = {
2199+
...cfg,
2200+
memory: {
2201+
backend: "qmd",
2202+
qmd: {
2203+
includeDefaultMemory: false,
2204+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2205+
sessions: { enabled: true },
2206+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
2207+
},
2208+
},
2209+
} as OpenClawConfig;
2210+
2211+
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
2212+
if (args[0] === "--help") {
2213+
const child = createMockChild({ autoClose: false });
2214+
emitAndClose(
2215+
child,
2216+
"stdout",
2217+
"-c, --collection <name> Filter by one or more collections",
2218+
);
2219+
return child;
2220+
}
2221+
if (args[0] === "search") {
2222+
const child = createMockChild({ autoClose: false });
2223+
emitAndClose(child, "stdout", "[]");
2224+
return child;
2225+
}
2226+
return createMockChild();
2227+
});
2228+
2229+
const { manager, resolved } = await createManager();
2230+
2231+
await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" });
2232+
const maxResults = resolved.qmd?.limits.maxResults;
2233+
if (!maxResults) {
2234+
throw new Error("qmd maxResults missing");
2235+
}
2236+
const searchCalls = spawnMock.mock.calls
2237+
.map((call: unknown[]) => call[1] as string[])
2238+
.filter((args: string[]) => args[0] === "search");
2239+
expect(searchCalls).toEqual([
2240+
["search", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"],
2241+
["search", "test", "--json", "-n", String(maxResults), "-c", "sessions-main"],
2242+
]);
2243+
await manager.close();
2244+
});
2245+
21372246
it("does not query phantom memory-alt collections when MEMORY.md exists", async () => {
21382247
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root");
21392248
cfg = {

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

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ export class QmdMemoryManager implements MemorySearchManager {
338338
private attemptedDuplicateDocumentRepair = false;
339339
private readonly sessionWarm = new Set<string>();
340340
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask";
341+
private multiCollectionFilterSupported: boolean | null = null;
341342

342343
private constructor(params: {
343344
agentId: string;
@@ -1150,16 +1151,17 @@ export class QmdMemoryManager implements MemorySearchManager {
11501151
timeoutMs: this.qmd.limits.timeoutMs,
11511152
});
11521153
}
1153-
if (collectionNames.length > 1) {
1154-
return await this.runQueryAcrossCollections(
1154+
const collectionGroups = await this.resolveCollectionSearchGroups(collectionNames);
1155+
if (collectionGroups.length > 1) {
1156+
return await this.runQueryAcrossCollectionGroups(
11551157
trimmed,
11561158
limit,
1157-
collectionNames,
1159+
collectionGroups,
11581160
qmdSearchCommand,
11591161
);
11601162
}
11611163
const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit);
1162-
args.push(...this.buildCollectionFilterArgs(collectionNames));
1164+
args.push(...this.buildCollectionFilterArgs(collectionGroups[0] ?? collectionNames));
11631165
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
11641166
return parseQmdQueryJson(result.stdout, result.stderr);
11651167
} catch (err) {
@@ -1177,11 +1179,19 @@ export class QmdMemoryManager implements MemorySearchManager {
11771179
`qmd ${qmdSearchCommand} does not support configured flags; retrying search with qmd query`,
11781180
);
11791181
try {
1180-
if (collectionNames.length > 1) {
1181-
return await this.runQueryAcrossCollections(trimmed, limit, collectionNames, "query");
1182+
const collectionGroups = await this.resolveCollectionSearchGroups(collectionNames);
1183+
if (collectionGroups.length > 1) {
1184+
return await this.runQueryAcrossCollectionGroups(
1185+
trimmed,
1186+
limit,
1187+
collectionGroups,
1188+
"query",
1189+
);
11821190
}
11831191
const fallbackArgs = this.buildSearchArgs("query", trimmed, limit);
1184-
fallbackArgs.push(...this.buildCollectionFilterArgs(collectionNames));
1192+
fallbackArgs.push(
1193+
...this.buildCollectionFilterArgs(collectionGroups[0] ?? collectionNames),
1194+
);
11851195
const fallback = await this.runQmd(fallbackArgs, {
11861196
timeoutMs: this.qmd.limits.timeoutMs,
11871197
});
@@ -2884,24 +2894,53 @@ export class QmdMemoryManager implements MemorySearchManager {
28842894
]);
28852895
}
28862896

2887-
private async runQueryAcrossCollections(
2897+
private async resolveCollectionSearchGroups(collectionNames: string[]): Promise<string[][]> {
2898+
if (collectionNames.length <= 1) {
2899+
return [collectionNames];
2900+
}
2901+
if (!(await this.supportsQmdMultiCollectionFilters())) {
2902+
return collectionNames.map((collectionName) => [collectionName]);
2903+
}
2904+
return this.groupCollectionNamesBySource(collectionNames);
2905+
}
2906+
2907+
private async supportsQmdMultiCollectionFilters(): Promise<boolean> {
2908+
if (this.multiCollectionFilterSupported !== null) {
2909+
return this.multiCollectionFilterSupported;
2910+
}
2911+
try {
2912+
const result = await this.runQmd(["--help"], {
2913+
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000),
2914+
});
2915+
const helpText = `${result.stdout}\n${result.stderr}`;
2916+
this.multiCollectionFilterSupported =
2917+
/\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText);
2918+
} catch (err) {
2919+
this.multiCollectionFilterSupported = false;
2920+
log.debug(`qmd multi-collection filter probe failed: ${String(err)}`);
2921+
}
2922+
return this.multiCollectionFilterSupported;
2923+
}
2924+
2925+
private async runQueryAcrossCollectionGroups(
28882926
query: string,
28892927
limit: number,
2890-
collectionNames: string[],
2928+
collectionGroups: string[][],
28912929
command: "query" | "search" | "vsearch",
28922930
): Promise<QmdQueryResult[]> {
28932931
log.debug(
2894-
`qmd ${command} multi-collection workaround active (${collectionNames.length} collections)`,
2932+
`qmd ${command} multi-source collection grouping active (${collectionGroups.length} groups)`,
28952933
);
28962934
const bestByResultKey = new Map<string, QmdQueryResult>();
2897-
for (const collectionName of collectionNames) {
2935+
for (const collectionNames of collectionGroups) {
28982936
const args = this.buildSearchArgs(command, query, limit);
2899-
args.push("-c", collectionName);
2937+
args.push(...this.buildCollectionFilterArgs(collectionNames));
29002938
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
29012939
const parsed = parseQmdQueryJson(result.stdout, result.stderr);
29022940
for (const entry of parsed) {
2941+
const defaultCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
29032942
const normalizedHints = this.normalizeDocHints({
2904-
preferredCollection: entry.collection ?? collectionName,
2943+
preferredCollection: entry.collection ?? defaultCollection,
29052944
preferredFile: entry.file,
29062945
});
29072946
const normalizedDocId =
@@ -2911,7 +2950,7 @@ export class QmdMemoryManager implements MemorySearchManager {
29112950
const withCollection = {
29122951
...entry,
29132952
docid: normalizedDocId,
2914-
collection: normalizedHints.preferredCollection ?? entry.collection ?? collectionName,
2953+
collection: normalizedHints.preferredCollection ?? entry.collection ?? defaultCollection,
29152954
file: normalizedHints.preferredFile ?? entry.file,
29162955
} satisfies QmdQueryResult;
29172956
const resultKey = this.buildQmdResultKey(withCollection);
@@ -2932,6 +2971,17 @@ export class QmdMemoryManager implements MemorySearchManager {
29322971
return [...bestByResultKey.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0));
29332972
}
29342973

2974+
private groupCollectionNamesBySource(collectionNames: string[]): string[][] {
2975+
const groups = new Map<string, string[]>();
2976+
for (const collectionName of collectionNames) {
2977+
const source = this.collectionRoots.get(collectionName)?.kind ?? collectionName;
2978+
const group = groups.get(source) ?? [];
2979+
group.push(collectionName);
2980+
groups.set(source, group);
2981+
}
2982+
return [...groups.values()];
2983+
}
2984+
29352985
private buildQmdResultKey(entry: QmdQueryResult): string | null {
29362986
if (typeof entry.docid === "string" && entry.docid.trim().length > 0) {
29372987
return `docid:${entry.docid}`;

0 commit comments

Comments
 (0)