From 2f1f82674a368e8a9246bc89f73410240ba05501 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Wed, 11 Feb 2026 15:12:33 -0800 Subject: [PATCH] Memory/QMD: harden no-results parsing --- src/memory/qmd-manager.test.ts | 3 --- src/memory/qmd-manager.ts | 47 ++-------------------------------- src/memory/qmd-query-parser.ts | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 48 deletions(-) create mode 100644 src/memory/qmd-query-parser.ts diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 05907170b35..bcf0e142de9 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -733,7 +733,6 @@ describe("QmdMemoryManager", () => { await manager.close(); }); - it("treats plain-text no-results stdout as an empty result set", async () => { spawnMock.mockImplementation((_cmd: string, args: string[]) => { if (args[0] === "query") { @@ -838,7 +837,6 @@ describe("QmdMemoryManager", () => { ).rejects.toThrow(/qmd query returned invalid JSON/); await manager.close(); }); - describe("model cache symlink", () => { let defaultModelsDir: string; let customModelsDir: string; @@ -921,7 +919,6 @@ describe("QmdMemoryManager", () => { await manager!.close(); }); }); - }); }); async function waitForCondition(check: () => boolean, timeoutMs: number): Promise { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 5a34b7ced34..c3b985ecf2f 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -24,20 +24,13 @@ import { requireNodeSqlite } from "./sqlite.js"; type SqliteDatabase = import("node:sqlite").DatabaseSync; import type { ResolvedMemoryBackendConfig, ResolvedQmdConfig } from "./backend-config.js"; +import { parseQmdQueryJson } from "./qmd-query-parser.js"; const log = createSubsystemLogger("memory"); const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/; const SEARCH_PENDING_UPDATE_WAIT_MS = 500; -type QmdQueryResult = { - docid?: string; - score?: number; - file?: string; - snippet?: string; - body?: string; -}; - type CollectionRoot = { path: string; kind: MemorySource; @@ -278,7 +271,7 @@ export class QmdMemoryManager implements MemorySearchManager { log.warn(`qmd query failed: ${String(err)}`); throw err instanceof Error ? err : new Error(String(err)); } - const parsed = this.parseQmdQueryJson(stdout, stderr); + const parsed = parseQmdQueryJson(stdout, stderr); const results: MemorySearchResult[] = []; for (const entry of parsed) { const doc = await this.resolveDocLocation(entry.docid); @@ -976,42 +969,6 @@ export class QmdMemoryManager implements MemorySearchManager { ]); } - private parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResult[] { - const trimmedStdout = stdout.trim(); - const trimmedStderr = stderr.trim(); - const stdoutIsMarker = Boolean(trimmedStdout) && this.isQmdNoResultsOutput(trimmedStdout); - const stderrIsMarker = Boolean(trimmedStderr) && this.isQmdNoResultsOutput(trimmedStderr); - if (stdoutIsMarker || (!trimmedStdout && stderrIsMarker)) { - return []; - } - if (!trimmedStdout) { - const context = trimmedStderr ? ` (stderr: ${this.summarizeQmdStderr(trimmedStderr)})` : ""; - const message = `stdout empty${context}`; - log.warn(`qmd query returned invalid JSON: ${message}`); - throw new Error(`qmd query returned invalid JSON: ${message}`); - } - try { - const parsed = JSON.parse(trimmedStdout) as unknown; - if (!Array.isArray(parsed)) { - throw new Error("qmd query JSON response was not an array"); - } - return parsed as QmdQueryResult[]; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log.warn(`qmd query returned invalid JSON: ${message}`); - throw new Error(`qmd query returned invalid JSON: ${message}`, { cause: err }); - } - } - - private isQmdNoResultsOutput(raw: string): boolean { - const normalized = raw.trim().toLowerCase().replace(/\s+/g, " "); - return normalized === "no results found" || normalized === "no results found."; - } - - private summarizeQmdStderr(raw: string): string { - return raw.length <= 120 ? raw : `${raw.slice(0, 117)}...`; - } - private buildCollectionFilterArgs(): string[] { const names = this.qmd.collections.map((collection) => collection.name).filter(Boolean); if (names.length === 0) { diff --git a/src/memory/qmd-query-parser.ts b/src/memory/qmd-query-parser.ts new file mode 100644 index 00000000000..2cf86619e97 --- /dev/null +++ b/src/memory/qmd-query-parser.ts @@ -0,0 +1,47 @@ +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("memory"); + +export type QmdQueryResult = { + docid?: string; + score?: number; + file?: string; + snippet?: string; + body?: string; +}; + +export function parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResult[] { + const trimmedStdout = stdout.trim(); + const trimmedStderr = stderr.trim(); + const stdoutIsMarker = trimmedStdout.length > 0 && isQmdNoResultsOutput(trimmedStdout); + const stderrIsMarker = trimmedStderr.length > 0 && isQmdNoResultsOutput(trimmedStderr); + if (stdoutIsMarker || (!trimmedStdout && stderrIsMarker)) { + return []; + } + if (!trimmedStdout) { + const context = trimmedStderr ? ` (stderr: ${summarizeQmdStderr(trimmedStderr)})` : ""; + const message = `stdout empty${context}`; + log.warn(`qmd query returned invalid JSON: ${message}`); + throw new Error(`qmd query returned invalid JSON: ${message}`); + } + try { + const parsed = JSON.parse(trimmedStdout) as unknown; + if (!Array.isArray(parsed)) { + throw new Error("qmd query JSON response was not an array"); + } + return parsed as QmdQueryResult[]; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn(`qmd query returned invalid JSON: ${message}`); + throw new Error(`qmd query returned invalid JSON: ${message}`, { cause: err }); + } +} + +function isQmdNoResultsOutput(raw: string): boolean { + const normalized = raw.trim().toLowerCase().replace(/\s+/g, " "); + return normalized === "no results found" || normalized === "no results found."; +} + +function summarizeQmdStderr(raw: string): string { + return raw.length <= 120 ? raw : `${raw.slice(0, 117)}...`; +}