diff --git a/CHANGELOG.md b/CHANGELOG.md index 5367cde6f..c10918e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow. - TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds. - Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output. +- Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency. - Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev. ## 2026.2.14 diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 3fef9fed3..cb906b354 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -829,6 +829,71 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("prefers exact docid match before prefix fallback for qmd document lookups", async () => { + const prepareCalls: string[] = []; + const exactDocid = "abc123"; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([ + { docid: exactDocid, score: 1, snippet: "@@ -5,2\nremember this\nnext line" }, + ]), + ); + 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 inner = manager as unknown as { + db: { prepare: (query: string) => { get: (arg: unknown) => unknown }; close: () => void }; + }; + inner.db = { + prepare: (query: string) => { + prepareCalls.push(query); + return { + get: (arg: unknown) => { + if (query.includes("hash = ?")) { + return undefined; + } + if (query.includes("hash LIKE ?")) { + expect(arg).toBe(`${exactDocid}%`); + return { collection: "workspace", path: "notes/welcome.md" }; + } + throw new Error(`unexpected sqlite query: ${query}`); + }, + }; + }, + close: () => {}, + }; + + const results = await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }); + expect(results).toEqual([ + { + path: "notes/welcome.md", + startLine: 5, + endLine: 6, + score: 1, + snippet: "@@ -5,2\nremember this\nnext line", + source: "memory", + }, + ]); + + expect(prepareCalls).toHaveLength(2); + expect(prepareCalls[0]).toContain("hash = ?"); + expect(prepareCalls[1]).toContain("hash LIKE ?"); + 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[]) => { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 46764d233..ec65cbdaa 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -692,9 +692,17 @@ export class QmdMemoryManager implements MemorySearchManager { const db = this.ensureDb(); let row: { collection: string; path: string } | undefined; try { - row = db - .prepare("SELECT collection, path FROM documents WHERE hash LIKE ? AND active = 1 LIMIT 1") - .get(`${normalized}%`) as { collection: string; path: string } | undefined; + const exact = db + .prepare("SELECT collection, path FROM documents WHERE hash = ? AND active = 1 LIMIT 1") + .get(normalized) as { collection: string; path: string } | undefined; + row = exact; + if (!row) { + row = db + .prepare( + "SELECT collection, path FROM documents WHERE hash LIKE ? AND active = 1 LIMIT 1", + ) + .get(`${normalized}%`) as { collection: string; path: string } | undefined; + } } catch (err) { if (this.isSqliteBusyError(err)) { log.debug(`qmd index is busy while resolving doc path: ${String(err)}`);