Skip to content

Commit fd7cba6

Browse files
committed
fix: harden qmd error diagnostics
Signed-off-by: Jay Zeng <jayzeng@users.noreply.github.com>
1 parent 0ac4595 commit fd7cba6

3 files changed

Lines changed: 75 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![npm downloads](https://img.shields.io/npm/dm/pi-memory?color=cb3837&logo=npm)](https://www.npmjs.com/package/pi-memory)
55
[![license](https://img.shields.io/npm/l/pi-memory)](LICENSE)
66

7-
**The most popular memory extension for [pi](https://github.com/mariozechner/pi-mono)** — listed in the [official pi package directory](https://pi.dev/packages?name=pi-memory), with semantic search powered by [qmd](https://github.com/tobi/qmd).
7+
**The most popular memory extension for [pi](https://github.com/earendil-works/pi/)** — listed in the [official pi package directory](https://pi.dev/packages?name=pi-memory), with semantic search powered by [qmd](https://github.com/tobi/qmd).
88

99
Thanks to https://github.com/skyfallsin/pi-mem for inspiration.
1010

‎index.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,12 @@ export function buildQmdSpawn(
858858
return { file: "node", args: [qmdJsPath, ...args] };
859859
}
860860

861+
export function buildQmdEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
862+
const qmdEnv: NodeJS.ProcessEnv = { ...env, NO_COLOR: "1" };
863+
delete qmdEnv.FORCE_COLOR;
864+
return qmdEnv;
865+
}
866+
861867
const execFileWithQmdOptions: ExecFileFn = ((
862868
file: string,
863869
args: readonly string[],
@@ -866,7 +872,8 @@ const execFileWithQmdOptions: ExecFileFn = ((
866872
) => {
867873
const qmdJs = process.platform === "win32" && isQmdCommand(file) ? resolveQmdJsPath() : null;
868874
const spawn = buildQmdSpawn(file, args ?? [], process.platform, qmdJs);
869-
return execFile(spawn.file, spawn.args, options, callback as any);
875+
const execOptions = isQmdCommand(file) ? { ...options, env: buildQmdEnv(options.env ?? process.env) } : options;
876+
return execFile(spawn.file, spawn.args, execOptions, callback as any);
870877
}) as ExecFileFn;
871878

872879
let execFileFn: ExecFileFn = execFileWithQmdOptions;
@@ -1181,8 +1188,9 @@ function getQmdResultText(r: QmdSearchResult): string {
11811188
function stripAnsi(text: string): string {
11821189
// qmd may emit spinners/progress bars even with --json, especially on first model download.
11831190
// Strip ANSI CSI/OSC sequences so we can reliably find and parse JSON payloads.
1191+
// CSI parameter bytes include private-mode sequences such as ESC[?25l / ESC[?25h.
11841192
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences
1185-
return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "").replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, "");
1193+
return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, "");
11861194
}
11871195

11881196
function parseQmdJson(stdout: string): unknown {
@@ -1216,7 +1224,13 @@ export function runQmdSearch(
12161224
return new Promise((resolve, reject) => {
12171225
execFileFn("qmd", args, { timeout: 60_000 }, (err, stdout, stderr) => {
12181226
if (err) {
1219-
reject(new Error(stderr?.trim() || err.message));
1227+
const cleaned = stripAnsi(stderr ?? "").trim();
1228+
const cleanedMessage = stripAnsi(err.message).trim();
1229+
const timedOut = (err as NodeJS.ErrnoException & { killed?: boolean }).killed === true;
1230+
const hint = timedOut
1231+
? " (qmd timed out after 60s — first semantic/deep search may download or load models; retry shortly)"
1232+
: "";
1233+
reject(new Error(`${cleaned || cleanedMessage}${hint}`));
12201234
return;
12211235
}
12221236
try {

‎test/unit.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
_setExecFileForTest,
2525
_setQmdAvailable,
2626
buildMemoryContext,
27+
buildQmdEnv,
2728
buildQmdSpawn,
2829
clampSearchLimit,
2930
dailyPath,
@@ -37,6 +38,7 @@ import {
3738
readFileSafe,
3839
resolveMemoryDir,
3940
resolveQmdJsPath,
41+
runQmdSearch,
4042
type ScratchpadItem,
4143
scheduleQmdUpdate,
4244
scratchpadAdd,
@@ -1121,6 +1123,61 @@ describe("memory_read tool", () => {
11211123
// 8. Tool: memory_search
11221124
// ==========================================================================
11231125

1126+
describe("runQmdSearch qmd diagnostics", () => {
1127+
afterEach(() => {
1128+
_resetExecFileForTest();
1129+
});
1130+
1131+
test("strips qmd spinner control sequences from stderr failures", async () => {
1132+
_setExecFileForTest(((_file: string, _args: string[], _opts: any, cb: any) => {
1133+
cb(
1134+
new Error("Command failed: qmd vsearch"),
1135+
"",
1136+
"\u001b[?25l\u001b[?25h\u001b[2K\u001b[1A\u001b[Greal diagnostic",
1137+
);
1138+
}) as any);
1139+
1140+
await expect(runQmdSearch("semantic", "query", 5)).rejects.toThrow("real diagnostic");
1141+
await expect(runQmdSearch("semantic", "query", 5)).rejects.not.toThrow("[?25");
1142+
});
1143+
1144+
test("strips qmd spinner control sequences from the fallback error message", async () => {
1145+
const spinner = "\u001b[?25l\u001b[?25h";
1146+
const commandError = new Error(`Command failed: qmd vsearch\n${spinner}`);
1147+
_setExecFileForTest(((_file: string, _args: string[], _opts: any, cb: any) => {
1148+
cb(commandError, "", spinner);
1149+
}) as any);
1150+
1151+
let failure: unknown;
1152+
try {
1153+
await runQmdSearch("semantic", "query", 5);
1154+
} catch (err) {
1155+
failure = err;
1156+
}
1157+
1158+
expect(failure).toBeInstanceOf(Error);
1159+
expect((failure as Error).message).toContain("Command failed: qmd vsearch");
1160+
expect((failure as Error).message).not.toContain("\u001b");
1161+
});
1162+
1163+
test("annotates qmd timeouts with a retryable cold-start hint", async () => {
1164+
const timeoutErr = Object.assign(new Error("Command failed: qmd vsearch"), { killed: true });
1165+
_setExecFileForTest(((_file: string, _args: string[], _opts: any, cb: any) => {
1166+
cb(timeoutErr, "", "\u001b[?25l\u001b[?25h");
1167+
}) as any);
1168+
1169+
await expect(runQmdSearch("semantic", "query", 5)).rejects.toThrow("qmd timed out after 60s");
1170+
});
1171+
1172+
test("removes FORCE_COLOR and sets NO_COLOR for qmd child processes", () => {
1173+
const env = buildQmdEnv({ FORCE_COLOR: "3", NO_COLOR: undefined, PATH: "bin" });
1174+
1175+
expect(env.FORCE_COLOR).toBeUndefined();
1176+
expect(env.NO_COLOR).toBe("1");
1177+
expect(env.PATH).toBe("bin");
1178+
});
1179+
});
1180+
11241181
describe("memory_search tool", () => {
11251182
let tools: Record<string, any>;
11261183

0 commit comments

Comments
 (0)