fix(memory/qmd): scope query to managed collections (#11645)

This commit is contained in:
Vignesh
2026-02-09 23:35:27 -08:00
committed by GitHub
parent 40919b1fc8
commit ef4a0e92b7
5 changed files with 148 additions and 2 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -19,6 +19,7 @@ vi.mock("@buape/carbon", () => ({
PresenceUpdateListener: class {},
Row: class {},
StringSelectMenu: class {},
BaseMessageInteractiveComponent: class {},
}));
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {

View File

@@ -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 });

View File

@@ -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<void>((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]);
}
}