diff --git a/CHANGELOG.md b/CHANGELOG.md index c10918e5430..6a6f0480e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - 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. +- Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. - 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-query-parser.test.ts b/src/memory/qmd-query-parser.test.ts new file mode 100644 index 00000000000..ed94b18e957 --- /dev/null +++ b/src/memory/qmd-query-parser.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parseQmdQueryJson } from "./qmd-query-parser.js"; + +describe("parseQmdQueryJson", () => { + it("parses clean qmd JSON output", () => { + const results = parseQmdQueryJson('[{"docid":"abc","score":1,"snippet":"@@ -1,1\\none"}]', ""); + expect(results).toEqual([ + { + docid: "abc", + score: 1, + snippet: "@@ -1,1\none", + }, + ]); + }); + + it("extracts embedded result arrays from noisy stdout", () => { + const results = parseQmdQueryJson( + `initializing +{"payload":"ok"} +[{"docid":"abc","score":0.5}] +complete`, + "", + ); + expect(results).toEqual([{ docid: "abc", score: 0.5 }]); + }); + + it("treats plain-text no-results from stderr as an empty result set", () => { + const results = parseQmdQueryJson("", "No results found\n"); + expect(results).toEqual([]); + }); + + it("throws when stdout cannot be interpreted as qmd JSON", () => { + expect(() => parseQmdQueryJson("this is not json", "")).toThrow( + /qmd query returned invalid JSON/i, + ); + }); +}); diff --git a/src/memory/qmd-query-parser.ts b/src/memory/qmd-query-parser.ts index 2cf86619e97..9aae1e9c0b3 100644 --- a/src/memory/qmd-query-parser.ts +++ b/src/memory/qmd-query-parser.ts @@ -25,11 +25,19 @@ export function parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResul throw new Error(`qmd query returned invalid JSON: ${message}`); } try { - const parsed = JSON.parse(trimmedStdout) as unknown; - if (!Array.isArray(parsed)) { + const parsed = parseQmdQueryResultArray(trimmedStdout); + if (parsed !== null) { + return parsed; + } + const noisyPayload = extractFirstJsonArray(trimmedStdout); + if (!noisyPayload) { throw new Error("qmd query JSON response was not an array"); } - return parsed as QmdQueryResult[]; + const fallback = parseQmdQueryResultArray(noisyPayload); + if (fallback !== null) { + return fallback; + } + throw new Error("qmd query JSON response was not an array"); } catch (err) { const message = err instanceof Error ? err.message : String(err); log.warn(`qmd query returned invalid JSON: ${message}`); @@ -45,3 +53,56 @@ function isQmdNoResultsOutput(raw: string): boolean { function summarizeQmdStderr(raw: string): string { return raw.length <= 120 ? raw : `${raw.slice(0, 117)}...`; } + +function parseQmdQueryResultArray(raw: string): QmdQueryResult[] | null { + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return null; + } + return parsed as QmdQueryResult[]; + } catch { + return null; + } +} + +function extractFirstJsonArray(raw: string): string | null { + const start = raw.indexOf("["); + if (start < 0) { + return null; + } + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < raw.length; i += 1) { + const char = raw[i]; + if (char === undefined) { + break; + } + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === "[") { + depth += 1; + } else if (char === "]") { + depth -= 1; + if (depth === 0) { + return raw.slice(start, i + 1); + } + } + } + return null; +}