Memory/QMD: harden multi-collection search and embed scheduling

This commit is contained in:
Vignesh Natarajan
2026-02-20 19:41:13 -08:00
parent 282a545130
commit a305dfe626
6 changed files with 369 additions and 79 deletions

View File

@@ -156,17 +156,17 @@ describe("QmdMemoryManager", () => {
const baselineCalls = spawnMock.mock.calls.length;
await manager.sync({ reason: "manual" });
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 1);
await manager.sync({ reason: "manual-again" });
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 1);
(manager as unknown as { lastUpdateAt: number | null }).lastUpdateAt =
Date.now() - (resolved.qmd?.update.debounceMs ?? 0) - 10;
await manager.sync({ reason: "after-wait" });
// By default we refresh embeddings less frequently than index updates.
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 3);
// `search` mode does not require qmd embed side effects.
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
await manager.close();
});
@@ -359,7 +359,7 @@ describe("QmdMemoryManager", () => {
expect(addSessions?.[2]).toBe(path.join(stateDir, "agents", devAgentId, "qmd", "sessions"));
});
it("rebinds sessions collection when qmd only reports collection names", async () => {
it("rebinds managed collections when qmd only reports collection names", async () => {
cfg = {
...cfg,
memory: {
@@ -396,6 +396,11 @@ describe("QmdMemoryManager", () => {
args[0] === "collection" && args[1] === "remove" && args[2] === sessionCollectionName,
);
expect(removeSessions).toBeDefined();
const removeWorkspace = commands.find(
(args) =>
args[0] === "collection" && args[1] === "remove" && args[2] === `workspace-${agentId}`,
);
expect(removeWorkspace).toBeDefined();
const addSessions = commands.find((args) => {
if (args[0] !== "collection" || args[1] !== "add") {
@@ -415,6 +420,7 @@ describe("QmdMemoryManager", () => {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: {
interval: "0s",
debounceMs: 0,
@@ -792,23 +798,16 @@ describe("QmdMemoryManager", () => {
const { manager, resolved } = await createManager();
await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" });
const searchCall = spawnMock.mock.calls.find(
(call: unknown[]) => (call[1] as string[])?.[0] === "search",
);
const maxResults = resolved.qmd?.limits.maxResults;
if (!maxResults) {
throw new Error("qmd maxResults missing");
}
expect(searchCall?.[1]).toEqual([
"search",
"test",
"--json",
"-n",
String(maxResults),
"-c",
"workspace-main",
"-c",
"notes-main",
const searchCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "search");
expect(searchCalls).toEqual([
["search", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"],
["search", "test", "--json", "-n", String(maxResults), "-c", "notes-main"],
]);
await manager.close();
});
@@ -904,17 +903,7 @@ describe("QmdMemoryManager", () => {
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "search" || args[0] === "query");
expect(searchAndQueryCalls).toEqual([
[
"search",
"test",
"--json",
"-n",
String(maxResults),
"-c",
"workspace-main",
"-c",
"notes-main",
],
["search", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"],
["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"],
["query", "test", "--json", "-n", String(maxResults), "-c", "notes-main"],
]);
@@ -984,6 +973,70 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("skips qmd embed in search mode even for forced sync", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "search",
update: { interval: "0s", debounceMs: 0, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "status" });
await manager.sync({ reason: "manual", force: true });
const commandCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
expect(commandCalls).toEqual([["update"]]);
await manager.close();
});
it("retries boot update when qmd reports a retryable lock error", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "search",
update: {
interval: "0s",
debounceMs: 60_000,
onBoot: true,
waitForBootSync: true,
},
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
let updateCalls = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "update") {
updateCalls += 1;
const child = createMockChild({ autoClose: false });
if (updateCalls === 1) {
emitAndClose(child, "stderr", "SQLITE_BUSY: database is locked", 2);
} else {
emitAndClose(child, "stdout", "", 0);
}
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
expect(updateCalls).toBe(2);
await manager.close();
});
it("scopes by channel for agent-prefixed session keys", async () => {
cfg = {
...cfg,
@@ -1170,7 +1223,7 @@ describe("QmdMemoryManager", () => {
};
inner.db = {
prepare: () => ({
get: () => {
all: () => {
throw new Error("SQLITE_BUSY: database is locked");
},
}),
@@ -1198,11 +1251,11 @@ describe("QmdMemoryManager", () => {
const { manager } = await createManager();
const inner = manager as unknown as {
db: { prepare: () => { get: () => never }; close: () => void } | null;
db: { prepare: () => { all: () => never }; close: () => void } | null;
};
inner.db = {
prepare: () => ({
get: () => {
all: () => {
throw new Error("SQLITE_BUSY: database is locked");
},
}),
@@ -1235,19 +1288,19 @@ describe("QmdMemoryManager", () => {
const { manager } = await createManager();
const inner = manager as unknown as {
db: { prepare: (query: string) => { get: (arg: unknown) => unknown }; close: () => void };
db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void };
};
inner.db = {
prepare: (query: string) => {
prepareCalls.push(query);
return {
get: (arg: unknown) => {
all: (arg: unknown) => {
if (query.includes("hash = ?")) {
return undefined;
return [];
}
if (query.includes("hash LIKE ?")) {
expect(arg).toBe(`${exactDocid}%`);
return { collection: "workspace-main", path: "notes/welcome.md" };
return [{ collection: "workspace-main", path: "notes/welcome.md" }];
}
throw new Error(`unexpected sqlite query: ${query}`);
},
@@ -1274,6 +1327,76 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("prefers collection hint when resolving duplicate qmd document hashes", 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;
const duplicateDocid = "dup-123";
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search" && args.includes("workspace-main")) {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([
{ docid: duplicateDocid, score: 0.9, snippet: "@@ -3,1\nworkspace hit" },
]),
);
return child;
}
if (args[0] === "search" && args.includes("notes-main")) {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager();
const inner = manager as unknown as {
db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void };
};
inner.db = {
prepare: (_query: string) => ({
all: (arg: unknown) => {
if (typeof arg === "string" && arg.startsWith(duplicateDocid)) {
return [
{ collection: "stale-workspace", path: "notes/welcome.md" },
{ collection: "workspace-main", path: "notes/welcome.md" },
];
}
return [];
},
}),
close: () => {},
};
const results = await manager.search("workspace", { sessionKey: "agent:main:slack:dm:u123" });
expect(results).toEqual([
{
path: "notes/welcome.md",
startLine: 3,
endLine: 3,
score: 0.9,
snippet: "@@ -3,1\nworkspace hit",
source: "memory",
},
]);
await manager.close();
});
it("errors when qmd output exceeds command output safety cap", async () => {
const noisyPayload = "x".repeat(240_000);
spawnMock.mockImplementation((_cmd: string, args: string[]) => {