diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 0c7a2185f..7efe8f10a 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -1,4 +1,3 @@ -import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -8,11 +7,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { writeFileWithinRoot } from "../infra/fs-safe.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { - materializeWindowsSpawnProgram, - resolveWindowsSpawnProgram, -} from "../plugin-sdk/windows-spawn.js"; import { isFileMissingError, statRegularFile } from "./fs-utils.js"; +import { + isWindowsCommandShimEinval, + resolveCliSpawnInvocation, + runCliCommand, +} from "./qmd-process.js"; import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js"; import { listSessionFilesForAgent, @@ -51,53 +51,6 @@ const QMD_BM25_HAN_KEYWORD_LIMIT = 12; let qmdEmbedQueueTail: Promise = Promise.resolve(); -function resolveWindowsCommandShim(command: string): string { - if (process.platform !== "win32") { - return command; - } - const trimmed = command.trim(); - if (!trimmed) { - return command; - } - const ext = path.extname(trimmed).toLowerCase(); - if (ext === ".cmd" || ext === ".exe" || ext === ".bat") { - return command; - } - const base = path.basename(trimmed).toLowerCase(); - if (base === "qmd" || base === "mcporter") { - return `${trimmed}.cmd`; - } - return command; -} - -function resolveSpawnInvocation(params: { - command: string; - args: string[]; - env: NodeJS.ProcessEnv; - packageName: string; -}) { - const program = resolveWindowsSpawnProgram({ - command: resolveWindowsCommandShim(params.command), - platform: process.platform, - env: params.env, - execPath: process.execPath, - packageName: params.packageName, - allowShellFallback: true, - }); - return materializeWindowsSpawnProgram(program, params.args); -} - -function isWindowsCmdSpawnEinval(err: unknown, command: string): boolean { - if (process.platform !== "win32") { - return false; - } - const errno = err as NodeJS.ErrnoException | undefined; - if (errno?.code !== "EINVAL") { - return false; - } - return /(^|[\\/])mcporter\.cmd$/i.test(command); -} - function hasHanScript(value: string): boolean { return HAN_SCRIPT_RE.test(value); } @@ -1235,70 +1188,20 @@ export class QmdMemoryManager implements MemorySearchManager { args: string[], opts?: { timeoutMs?: number; discardOutput?: boolean }, ): Promise<{ stdout: string; stderr: string }> { - return await new Promise((resolve, reject) => { - const spawnInvocation = resolveSpawnInvocation({ + return await runCliCommand({ + commandSummary: `qmd ${args.join(" ")}`, + spawnInvocation: resolveCliSpawnInvocation({ command: this.qmd.command, args, env: this.env, packageName: "qmd", - }); - const child = spawn(spawnInvocation.command, spawnInvocation.argv, { - env: this.env, - cwd: this.workspaceDir, - shell: spawnInvocation.shell, - windowsHide: spawnInvocation.windowsHide, - }); - let stdout = ""; - let stderr = ""; - let stdoutTruncated = false; - let stderrTruncated = false; - // When discardOutput is set, skip stdout accumulation entirely and keep - // only a small stderr tail for diagnostics -- never fail on truncation. - // This prevents large `qmd update` runs from hitting the output cap. - const discard = opts?.discardOutput === true; - const timer = opts?.timeoutMs - ? setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`qmd ${args.join(" ")} timed out after ${opts.timeoutMs}ms`)); - }, opts.timeoutMs) - : null; - child.stdout.on("data", (data) => { - if (discard) { - return; // drain without accumulating - } - const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars); - stdout = next.text; - stdoutTruncated = stdoutTruncated || next.truncated; - }); - child.stderr.on("data", (data) => { - const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars); - stderr = next.text; - stderrTruncated = stderrTruncated || next.truncated; - }); - child.on("error", (err) => { - if (timer) { - clearTimeout(timer); - } - reject(err); - }); - child.on("close", (code) => { - if (timer) { - clearTimeout(timer); - } - if (!discard && (stdoutTruncated || stderrTruncated)) { - reject( - new Error( - `qmd ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`, - ), - ); - return; - } - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject(new Error(`qmd ${args.join(" ")} failed (code ${code}): ${stderr || stdout}`)); - } - }); + }), + env: this.env, + cwd: this.workspaceDir, + timeoutMs: opts?.timeoutMs, + maxOutputChars: this.maxQmdOutputChars, + // Large `qmd update` runs can easily exceed the output cap; keep only stderr. + discardStdout: opts?.discardOutput, }); } @@ -1347,62 +1250,17 @@ export class QmdMemoryManager implements MemorySearchManager { shell?: boolean; windowsHide?: boolean; }): Promise<{ stdout: string; stderr: string }> => - await new Promise((resolve, reject) => { - const commandSummary = `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`; - const child = spawn(spawnInvocation.command, spawnInvocation.argv, { - // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. - env: this.env, - cwd: this.workspaceDir, - shell: spawnInvocation.shell, - windowsHide: spawnInvocation.windowsHide, - }); - let stdout = ""; - let stderr = ""; - let stdoutTruncated = false; - let stderrTruncated = false; - const timer = opts?.timeoutMs - ? setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`mcporter ${args.join(" ")} timed out after ${opts.timeoutMs}ms`)); - }, opts.timeoutMs) - : null; - child.stdout.on("data", (data) => { - const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars); - stdout = next.text; - stdoutTruncated = stdoutTruncated || next.truncated; - }); - child.stderr.on("data", (data) => { - const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars); - stderr = next.text; - stderrTruncated = stderrTruncated || next.truncated; - }); - child.on("error", (err) => { - if (timer) { - clearTimeout(timer); - } - reject(err); - }); - child.on("close", (code) => { - if (timer) { - clearTimeout(timer); - } - if (stdoutTruncated || stderrTruncated) { - reject( - new Error( - `mcporter ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`, - ), - ); - return; - } - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject(new Error(`${commandSummary} failed (code ${code}): ${stderr || stdout}`)); - } - }); + await runCliCommand({ + commandSummary: `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`, + spawnInvocation, + // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. + env: this.env, + cwd: this.workspaceDir, + timeoutMs: opts?.timeoutMs, + maxOutputChars: this.maxQmdOutputChars, }); - const primaryInvocation = resolveSpawnInvocation({ + const primaryInvocation = resolveCliSpawnInvocation({ command: "mcporter", args, env: this.env, @@ -1411,7 +1269,13 @@ export class QmdMemoryManager implements MemorySearchManager { try { return await runWithInvocation(primaryInvocation); } catch (err) { - if (!isWindowsCmdSpawnEinval(err, primaryInvocation.command)) { + if ( + !isWindowsCommandShimEinval({ + err, + command: primaryInvocation.command, + commandBase: "mcporter", + }) + ) { throw err; } // Some Windows npm cmd shims can still throw EINVAL on spawn; retry through @@ -2232,15 +2096,3 @@ export class QmdMemoryManager implements MemorySearchManager { return [command, normalizedQuery, "--json", "-n", String(limit)]; } } - -function appendOutputWithCap( - current: string, - chunk: string, - maxChars: number, -): { text: string; truncated: boolean } { - const appended = current + chunk; - if (appended.length <= maxChars) { - return { text: appended, truncated: false }; - } - return { text: appended.slice(-maxChars), truncated: true }; -} diff --git a/src/memory/qmd-process.ts b/src/memory/qmd-process.ts new file mode 100644 index 000000000..7c0b1a6c3 --- /dev/null +++ b/src/memory/qmd-process.ts @@ -0,0 +1,144 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { + materializeWindowsSpawnProgram, + resolveWindowsSpawnProgram, +} from "../plugin-sdk/windows-spawn.js"; + +export type CliSpawnInvocation = { + command: string; + argv: string[]; + shell?: boolean; + windowsHide?: boolean; +}; + +function resolveWindowsCommandShim(command: string): string { + if (process.platform !== "win32") { + return command; + } + const trimmed = command.trim(); + if (!trimmed) { + return command; + } + const ext = path.extname(trimmed).toLowerCase(); + if (ext === ".cmd" || ext === ".exe" || ext === ".bat") { + return command; + } + const base = path.basename(trimmed).toLowerCase(); + if (base === "qmd" || base === "mcporter") { + return `${trimmed}.cmd`; + } + return command; +} + +export function resolveCliSpawnInvocation(params: { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + packageName: string; +}): CliSpawnInvocation { + const program = resolveWindowsSpawnProgram({ + command: resolveWindowsCommandShim(params.command), + platform: process.platform, + env: params.env, + execPath: process.execPath, + packageName: params.packageName, + allowShellFallback: true, + }); + return materializeWindowsSpawnProgram(program, params.args); +} + +export function isWindowsCommandShimEinval(params: { + err: unknown; + command: string; + commandBase: string; +}): boolean { + if (process.platform !== "win32") { + return false; + } + const errno = params.err as NodeJS.ErrnoException | undefined; + if (errno?.code !== "EINVAL") { + return false; + } + const escapedBase = params.commandBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\\\/])${escapedBase}\\.cmd$`, "i").test(params.command); +} + +export async function runCliCommand(params: { + commandSummary: string; + spawnInvocation: CliSpawnInvocation; + env: NodeJS.ProcessEnv; + cwd: string; + timeoutMs?: number; + maxOutputChars: number; + discardStdout?: boolean; +}): Promise<{ stdout: string; stderr: string }> { + return await new Promise((resolve, reject) => { + const child = spawn(params.spawnInvocation.command, params.spawnInvocation.argv, { + env: params.env, + cwd: params.cwd, + shell: params.spawnInvocation.shell, + windowsHide: params.spawnInvocation.windowsHide, + }); + let stdout = ""; + let stderr = ""; + let stdoutTruncated = false; + let stderrTruncated = false; + const discardStdout = params.discardStdout === true; + const timer = params.timeoutMs + ? setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`${params.commandSummary} timed out after ${params.timeoutMs}ms`)); + }, params.timeoutMs) + : null; + child.stdout.on("data", (data) => { + if (discardStdout) { + return; + } + const next = appendOutputWithCap(stdout, data.toString("utf8"), params.maxOutputChars); + stdout = next.text; + stdoutTruncated = stdoutTruncated || next.truncated; + }); + child.stderr.on("data", (data) => { + const next = appendOutputWithCap(stderr, data.toString("utf8"), params.maxOutputChars); + stderr = next.text; + stderrTruncated = stderrTruncated || next.truncated; + }); + child.on("error", (err) => { + if (timer) { + clearTimeout(timer); + } + reject(err); + }); + child.on("close", (code) => { + if (timer) { + clearTimeout(timer); + } + if (!discardStdout && (stdoutTruncated || stderrTruncated)) { + reject( + new Error( + `${params.commandSummary} produced too much output (limit ${params.maxOutputChars} chars)`, + ), + ); + return; + } + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`${params.commandSummary} failed (code ${code}): ${stderr || stdout}`)); + } + }); + }); +} + +function appendOutputWithCap( + current: string, + chunk: string, + maxChars: number, +): { text: string; truncated: boolean } { + const appended = current + chunk; + if (appended.length <= maxChars) { + return { text: appended, truncated: false }; + } + return { text: appended.slice(-maxChars), truncated: true }; +}