fix(memory/qmd): scope query to managed collections (#11645)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("@buape/carbon", () => ({
|
||||
PresenceUpdateListener: class {},
|
||||
Row: class {},
|
||||
StringSelectMenu: class {},
|
||||
BaseMessageInteractiveComponent: class {},
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user