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: 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: 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: 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. - 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. - 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: 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. - 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: 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. - 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. - 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. - 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 - 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 blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous
blocking behavior. 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 OpenClaw automatically falls back to the builtin SQLite manager so memory tools
keep working. keep working.
- OpenClaw does not expose QMD embed batch-size tuning today; batch behavior is - 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 {}, PresenceUpdateListener: class {},
Row: class {}, Row: class {},
StringSelectMenu: class {}, StringSelectMenu: class {},
BaseMessageInteractiveComponent: class {},
})); }));
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {

View File

@@ -409,6 +409,87 @@ describe("QmdMemoryManager", () => {
await manager.close(); 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 () => { it("logs and continues when qmd embed times out", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
cfg = { cfg = {
@@ -475,6 +556,9 @@ describe("QmdMemoryManager", () => {
const isAllowed = (key?: string) => const isAllowed = (key?: string) =>
(manager as unknown as { isScopeAllowed: (key?: string) => boolean }).isScopeAllowed(key); (manager as unknown as { isScopeAllowed: (key?: string) => boolean }).isScopeAllowed(key);
expect(isAllowed("agent:main:slack:channel:c123")).toBe(true); 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); expect(isAllowed("agent:main:discord:channel:c123")).toBe(false);
await manager.close(); await manager.close();
@@ -516,6 +600,50 @@ describe("QmdMemoryManager", () => {
await manager.close(); 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 () => { it("blocks non-markdown or symlink reads for qmd paths", async () => {
const resolved = resolveMemoryBackendConfig({ cfg, agentId }); const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });

View File

@@ -262,7 +262,12 @@ export class QmdMemoryManager implements MemorySearchManager {
this.qmd.limits.maxResults, this.qmd.limits.maxResults,
opts?.maxResults ?? 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; let stdout: string;
try { try {
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs }); 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)), 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]);
}
} }