From 91aed291dd8f3ecb7fbf12adef853ffd5110092a Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 20:39:26 -0800 Subject: [PATCH] fix(memory): handle qmd search results without docid --- CHANGELOG.md | 1 + src/memory/qmd-manager.test.ts | 126 +++++++++++++++++++++++++++++++ src/memory/qmd-manager.ts | 133 ++++++++++++++++++++++++++++++--- 3 files changed, 248 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3da3312376..e091bacf569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Memory/QMD search result decoding: accept `qmd search` hits that only include `file` URIs (for example `qmd://collection/path.md`) without `docid`, resolve them through managed collection roots, and keep multi-collection results keyed by file fallback so valid QMD hits no longer collapse to empty `memory_search` output. (#28181) Thanks @0x76696265. - Memory/QMD collection-name conflict recovery: when `qmd collection add` fails because another collection already occupies the same `path + pattern`, detect the conflicting collection from `collection list`, remove it, and retry add so agent-scoped managed collections are created deterministically instead of being silently skipped; also add warning-only fallback when qmd metadata is unavailable to avoid destructive guesses. (#25496) Thanks @Ramsbaby. - Slack/app_mention race dedupe: when `app_mention` dispatch wins while same-`ts` `message` prepare is still in-flight, suppress the later message dispatch so near-simultaneous Slack deliveries do not produce duplicate replies; keep single-retry behavior and add regression coverage for both dropped and successful message-prepare outcomes. (#37033) Thanks @Takhoffman. - Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 54fb9028412..fa1323cda30 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -2385,6 +2385,132 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("resolves search hits when qmd returns qmd:// file URIs without docid", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([ + { + file: "qmd://workspace-main/notes/welcome.md", + score: 0.71, + snippet: "@@ -4,1\ntoken unlock", + }, + ]), + ); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager(); + + const results = await manager.search("token unlock", { + sessionKey: "agent:main:slack:dm:u123", + }); + expect(results).toEqual([ + { + path: "notes/welcome.md", + startLine: 4, + endLine: 4, + score: 0.71, + snippet: "@@ -4,1\ntoken unlock", + source: "memory", + }, + ]); + await manager.close(); + }); + + it("preserves multi-collection qmd search hits when results only include file URIs", 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] === "search" && args.includes("workspace-main")) { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([ + { + file: "qmd://workspace-main/memory/facts.md", + score: 0.8, + snippet: "@@ -2,1\nworkspace fact", + }, + ]), + ); + return child; + } + if (args[0] === "search" && args.includes("notes-main")) { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([ + { + file: "qmd://notes-main/guide.md", + score: 0.7, + snippet: "@@ -1,1\nnotes guide", + }, + ]), + ); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager(); + + const results = await manager.search("fact", { + sessionKey: "agent:main:slack:dm:u123", + }); + expect(results).toEqual([ + { + path: "memory/facts.md", + startLine: 2, + endLine: 2, + score: 0.8, + snippet: "@@ -2,1\nworkspace fact", + source: "memory", + }, + { + path: "notes/guide.md", + startLine: 1, + endLine: 1, + score: 0.7, + snippet: "@@ -1,1\nnotes guide", + 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[]) => { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index aa58964cb9e..fb2deb9754c 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -873,10 +873,11 @@ export class QmdMemoryManager implements MemorySearchManager { } const results: MemorySearchResult[] = []; for (const entry of parsed) { - const doc = await this.resolveDocLocation(entry.docid, { + const docHints = this.normalizeDocHints({ preferredCollection: entry.collection, preferredFile: entry.file, }); + const doc = await this.resolveDocLocation(entry.docid, docHints); if (!doc) { continue; } @@ -1614,14 +1615,15 @@ export class QmdMemoryManager implements MemorySearchManager { docid?: string, hints?: { preferredCollection?: string; preferredFile?: string }, ): Promise<{ rel: string; abs: string; source: MemorySource } | null> { + const normalizedHints = this.normalizeDocHints(hints); if (!docid) { - return null; + return this.resolveDocLocationFromHints(normalizedHints); } const normalized = docid.startsWith("#") ? docid.slice(1) : docid; if (!normalized) { return null; } - const cacheKey = `${hints?.preferredCollection ?? "*"}:${normalized}`; + const cacheKey = `${normalizedHints.preferredCollection ?? "*"}:${normalized}`; const cached = this.docPathCache.get(cacheKey); if (cached) { return cached; @@ -1647,7 +1649,7 @@ export class QmdMemoryManager implements MemorySearchManager { if (rows.length === 0) { return null; } - const location = this.pickDocLocation(rows, hints); + const location = this.pickDocLocation(rows, normalizedHints); if (!location) { return null; } @@ -1655,6 +1657,86 @@ export class QmdMemoryManager implements MemorySearchManager { return location; } + private resolveDocLocationFromHints(hints: { + preferredCollection?: string; + preferredFile?: string; + }): { rel: string; abs: string; source: MemorySource } | null { + if (!hints.preferredCollection || !hints.preferredFile) { + return null; + } + const collectionRelativePath = this.toCollectionRelativePath( + hints.preferredCollection, + hints.preferredFile, + ); + if (!collectionRelativePath) { + return null; + } + return this.toDocLocation(hints.preferredCollection, collectionRelativePath); + } + + private normalizeDocHints(hints?: { preferredCollection?: string; preferredFile?: string }): { + preferredCollection?: string; + preferredFile?: string; + } { + const preferredCollection = hints?.preferredCollection?.trim(); + const preferredFile = hints?.preferredFile?.trim(); + if (!preferredFile) { + return preferredCollection ? { preferredCollection } : {}; + } + + const parsedQmdFile = this.parseQmdFileUri(preferredFile); + return { + preferredCollection: parsedQmdFile?.collection ?? preferredCollection, + preferredFile: parsedQmdFile?.collectionRelativePath ?? preferredFile, + }; + } + + private parseQmdFileUri(fileRef: string): { + collection?: string; + collectionRelativePath?: string; + } | null { + if (!fileRef.toLowerCase().startsWith("qmd://")) { + return null; + } + try { + const parsed = new URL(fileRef); + const collection = decodeURIComponent(parsed.hostname).trim(); + const pathname = decodeURIComponent(parsed.pathname).replace(/^\/+/, "").trim(); + if (!collection && !pathname) { + return null; + } + return { + collection: collection || undefined, + collectionRelativePath: pathname || undefined, + }; + } catch { + return null; + } + } + + private toCollectionRelativePath(collection: string, filePath: string): string | null { + const root = this.collectionRoots.get(collection); + if (!root) { + return null; + } + const trimmedFilePath = filePath.trim(); + if (!trimmedFilePath) { + return null; + } + const normalizedInput = path.normalize(trimmedFilePath); + const absolutePath = path.isAbsolute(normalizedInput) + ? normalizedInput + : path.resolve(root.path, normalizedInput); + if (!this.isWithinRoot(root.path, absolutePath)) { + return null; + } + const relative = path.relative(root.path, absolutePath); + if (!relative || relative === ".") { + return null; + } + return relative.replace(/\\/g, "/"); + } + private pickDocLocation( rows: Array<{ collection: string; path: string }>, hints?: { preferredCollection?: string; preferredFile?: string }, @@ -1982,37 +2064,64 @@ export class QmdMemoryManager implements MemorySearchManager { log.debug( `qmd ${command} multi-collection workaround active (${collectionNames.length} collections)`, ); - const bestByDocId = new Map(); + const bestByResultKey = new Map(); for (const collectionName of collectionNames) { const args = this.buildSearchArgs(command, query, limit); args.push("-c", collectionName); const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs }); const parsed = parseQmdQueryJson(result.stdout, result.stderr); for (const entry of parsed) { + const normalizedHints = this.normalizeDocHints({ + preferredCollection: entry.collection ?? collectionName, + preferredFile: entry.file, + }); const normalizedDocId = typeof entry.docid === "string" && entry.docid.trim().length > 0 ? entry.docid : undefined; - if (!normalizedDocId) { - continue; - } const withCollection = { ...entry, docid: normalizedDocId, - collection: entry.collection ?? collectionName, + collection: normalizedHints.preferredCollection ?? entry.collection ?? collectionName, + file: normalizedHints.preferredFile ?? entry.file, } satisfies QmdQueryResult; - const prev = bestByDocId.get(normalizedDocId); + const resultKey = this.buildQmdResultKey(withCollection); + if (!resultKey) { + continue; + } + const prev = bestByResultKey.get(resultKey); const prevScore = typeof prev?.score === "number" ? prev.score : Number.NEGATIVE_INFINITY; const nextScore = typeof withCollection.score === "number" ? withCollection.score : Number.NEGATIVE_INFINITY; if (!prev || nextScore > prevScore) { - bestByDocId.set(normalizedDocId, withCollection); + bestByResultKey.set(resultKey, withCollection); } } } - return [...bestByDocId.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return [...bestByResultKey.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0)); + } + + private buildQmdResultKey(entry: QmdQueryResult): string | null { + if (typeof entry.docid === "string" && entry.docid.trim().length > 0) { + return `docid:${entry.docid}`; + } + const hints = this.normalizeDocHints({ + preferredCollection: entry.collection, + preferredFile: entry.file, + }); + if (!hints.preferredCollection || !hints.preferredFile) { + return null; + } + const collectionRelativePath = this.toCollectionRelativePath( + hints.preferredCollection, + hints.preferredFile, + ); + if (!collectionRelativePath) { + return null; + } + return `file:${hints.preferredCollection}:${collectionRelativePath}`; } private async runMcporterAcrossCollections(params: {