@apmantza/greedysearch-pi 1.9.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,8 +16,37 @@ import {
16
16
  makeProgressTracker,
17
17
  runSearch,
18
18
  stripQuotes,
19
+ type ProgressUpdate,
20
+ type ToolResult,
19
21
  } from "./shared.js";
20
22
 
23
+ type GreedySearchParams = {
24
+ query: string;
25
+ engine?: string;
26
+ synthesize?: boolean;
27
+ synthesizer?: string;
28
+ depth?: "fast" | "standard" | "deep" | "research" | string;
29
+ breadth?: number;
30
+ iterations?: number;
31
+ maxSources?: number;
32
+ researchOutDir?: string;
33
+ writeResearchBundle?: boolean;
34
+ fullAnswer?: boolean;
35
+ headless?: boolean;
36
+ visible?: boolean;
37
+ alwaysVisible?: boolean;
38
+ };
39
+
40
+ type ToolTheme = {
41
+ fg(style: string, text: string): string;
42
+ bold(text: string): string;
43
+ };
44
+
45
+ type RenderState = {
46
+ expanded: boolean;
47
+ isPartial?: boolean;
48
+ };
49
+
21
50
  class Text {
22
51
  constructor(
23
52
  private text: string,
@@ -52,8 +81,10 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
52
81
  name: "greedy_search",
53
82
  label: "Greedy Search",
54
83
  description:
55
- "WEB SEARCH ONLY — searches live web via Perplexity, Bing Copilot, and Google AI in parallel. " +
56
- "Optionally synthesizes results with Gemini, deduplicates sources by consensus. " +
84
+ "WEB/RESEARCH SEARCH ONLY — searches live web via Perplexity, Google AI, ChatGPT, and Gemini, plus opt-in research through Semantic Scholar and Logically. " +
85
+ "Research mode reuses the configured ~/.pi/greedyconfig engines for child searches and Gemini for planning/final synthesis. " +
86
+ "Research mode is the centerpiece: it plans follow-up actions, fetches sources, audits citations, " +
87
+ "and writes a structured research bundle on disk. " +
57
88
  "Use for: library docs, recent framework changes, error messages, best practices, current events. " +
58
89
  "Reports streaming progress as each engine completes.",
59
90
  promptSnippet: "Multi-engine AI web search with streaming progress",
@@ -61,14 +92,28 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
61
92
  query: Type.String({ description: "The search query" }),
62
93
  engine: Type.String({
63
94
  description:
64
- 'Engine to use: "all" (default), "perplexity", "bing", "google", "gemini", "gem". "all" fans out to Perplexity, Bing, and Google in parallel.',
95
+ 'Engine to use: "all" (default), "perplexity", "google", "chatgpt", "gemini", "gem". Research engines: "semantic-scholar" (alias "s2") and "logically". "all" fans out to the configured engines and fetches top sources. Customize via ~/.pi/greedyconfig. Bing Copilot is still available as "bing" for signed-in users.',
65
96
  default: "all",
66
97
  }),
67
- depth: Type.String({
68
- description:
69
- 'Search depth: "fast" (no synthesis/source fetch, ~15-30s), "standard" (synthesis + sources, ~30-90s), "deep" (stronger grounding, ~60-180s), "research" (iterative query/learnings loop; slowest). Default: "standard". Note: single-engine searches default to fast unless depth is "research".',
70
- default: "standard",
71
- }),
98
+ synthesize: Type.Optional(
99
+ Type.Boolean({
100
+ description:
101
+ 'Only for engine="all": synthesize the multi-engine results and fetched sources. Default: false.',
102
+ default: false,
103
+ }),
104
+ ),
105
+ synthesizer: Type.Optional(
106
+ Type.String({
107
+ description:
108
+ 'Synthesis engine for synthesize=true. Defaults to ~/.pi/greedyconfig synthesizer (currently "gemini" by default). Supported: "gemini", "chatgpt".',
109
+ }),
110
+ ),
111
+ depth: Type.Optional(
112
+ Type.String({
113
+ description:
114
+ 'Deprecated except "research". Use depth="research" for the iterative research workflow. Research child searches use ~/.pi/greedyconfig engines; Gemini handles research planning/final synthesis. Legacy values: "fast" skips source fetching; "standard"/"deep" alias synthesize=true.',
115
+ }),
116
+ ),
72
117
  breadth: Type.Optional(
73
118
  Type.Number({
74
119
  description:
@@ -89,6 +134,19 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
89
134
  'Only for depth="research": maximum fetched sources for the final report, 3-12.',
90
135
  }),
91
136
  ),
137
+ researchOutDir: Type.Optional(
138
+ Type.String({
139
+ description:
140
+ 'Only for depth="research": optional directory for the structured research bundle. Defaults to .pi/greedysearch-research/<timestamp>_<query>.',
141
+ }),
142
+ ),
143
+ writeResearchBundle: Type.Optional(
144
+ Type.Boolean({
145
+ description:
146
+ 'Only for depth="research": write the structured research bundle to disk (default true).',
147
+ default: true,
148
+ }),
149
+ ),
92
150
  fullAnswer: Type.Optional(
93
151
  Type.Boolean({
94
152
  description:
@@ -118,27 +176,33 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
118
176
  }),
119
177
  ),
120
178
  }),
121
- execute: async (_toolCallId, params, signal, onUpdate) => {
122
- const { query, fullAnswer: fullAnswerParam } = params as {
123
- query: string;
124
- engine: string;
125
- depth?: "fast" | "standard" | "deep" | "research";
126
- breadth?: number;
127
- iterations?: number;
128
- maxSources?: number;
129
- fullAnswer?: boolean;
130
- headless?: boolean;
131
- visible?: boolean;
132
- alwaysVisible?: boolean;
133
- };
134
- const engine = stripQuotes((params as any).engine ?? "all") || "all";
135
- const depth = (stripQuotes((params as any).depth ?? "standard") ||
136
- "standard") as "fast" | "standard" | "deep" | "research";
137
- const effectiveEngine = depth === "research" ? "all" : engine;
179
+ execute: async (
180
+ _toolCallId: string,
181
+ params: GreedySearchParams,
182
+ signal?: AbortSignal,
183
+ onUpdate?: (update: ProgressUpdate) => void,
184
+ ) => {
185
+ const { query, fullAnswer: fullAnswerParam } = params;
186
+ const engine = stripQuotes(params.engine ?? "all") || "all";
187
+ const depthRaw = stripQuotes(params.depth ?? "") as
188
+ | "fast"
189
+ | "standard"
190
+ | "deep"
191
+ | "research"
192
+ | "";
193
+ const researchMode = depthRaw === "research";
194
+ const legacyFast = depthRaw === "fast";
195
+ const legacySynthesisDepth =
196
+ depthRaw === "standard" || depthRaw === "deep";
197
+ const synthesize =
198
+ engine === "all" &&
199
+ !legacyFast &&
200
+ (params.synthesize === true || legacySynthesisDepth);
201
+ const effectiveEngine = researchMode ? "all" : engine;
138
202
  const visible =
139
- (params as any).visible === true ||
140
- (params as any).alwaysVisible === true ||
141
- (params as any).headless === false ||
203
+ params.visible === true ||
204
+ params.alwaysVisible === true ||
205
+ params.headless === false ||
142
206
  process.env.GREEDY_SEARCH_VISIBLE === "1" ||
143
207
  process.env.GREEDY_SEARCH_ALWAYS_VISIBLE === "1";
144
208
  const headless = !visible;
@@ -148,26 +212,32 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
148
212
  const flags: string[] = [];
149
213
  const fullAnswer = fullAnswerParam ?? effectiveEngine !== "all";
150
214
  if (fullAnswer) flags.push("--full");
151
- if (depth === "research") {
215
+ if (researchMode) {
152
216
  flags.push("--depth", "research");
153
- if (typeof (params as any).breadth === "number")
154
- flags.push("--breadth", String((params as any).breadth));
155
- if (typeof (params as any).iterations === "number")
156
- flags.push("--iterations", String((params as any).iterations));
157
- if (typeof (params as any).maxSources === "number")
158
- flags.push("--max-sources", String((params as any).maxSources));
159
- } else if (depth === "deep") flags.push("--depth", "deep");
160
- else if (depth === "fast") flags.push("--fast");
161
- else if (depth === "standard" && engine === "all")
162
- flags.push("--synthesize");
217
+ if (typeof params.breadth === "number")
218
+ flags.push("--breadth", String(params.breadth));
219
+ if (typeof params.iterations === "number")
220
+ flags.push("--iterations", String(params.iterations));
221
+ if (typeof params.maxSources === "number")
222
+ flags.push("--max-sources", String(params.maxSources));
223
+ if (typeof params.researchOutDir === "string")
224
+ flags.push("--research-out-dir", params.researchOutDir);
225
+ if (params.writeResearchBundle === false)
226
+ flags.push("--no-research-bundle");
227
+ } else if (legacyFast) flags.push("--fast");
228
+ else if (depthRaw === "deep") flags.push("--depth", "deep");
229
+ else if (synthesize) flags.push("--synthesize");
230
+ if (synthesize && typeof params.synthesizer === "string") {
231
+ flags.push("--synthesizer", params.synthesizer);
232
+ }
163
233
 
164
234
  const onProgress =
165
235
  effectiveEngine === "all"
166
236
  ? makeProgressTracker(
167
237
  ALL_ENGINES,
168
238
  onUpdate,
169
- depth === "research" ? "Researching" : "Searching",
170
- depth,
239
+ researchMode ? "Researching" : "Searching",
240
+ synthesize,
171
241
  )
172
242
  : undefined;
173
243
 
@@ -179,7 +249,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
179
249
  `${baseDir}/bin/search.mjs`,
180
250
  signal,
181
251
  onProgress,
182
- headless,
252
+ { headless },
183
253
  );
184
254
  const text = formatResults(effectiveEngine, data);
185
255
  return {
@@ -191,7 +261,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
191
261
  }
192
262
  },
193
263
 
194
- renderCall(args, theme) {
264
+ renderCall(args: Partial<GreedySearchParams>, theme: ToolTheme) {
195
265
  const q = (args.query || "").slice(0, 60);
196
266
  const qDisplay = q.length < (args.query || "").length ? `${q}...` : q;
197
267
  const engineDisplay =
@@ -205,11 +275,15 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
205
275
  );
206
276
  },
207
277
 
208
- renderResult(result, { expanded, isPartial }, theme) {
278
+ renderResult(
279
+ result: ToolResult,
280
+ { expanded, isPartial }: RenderState,
281
+ theme: ToolTheme,
282
+ ) {
209
283
  if (isPartial) {
210
- const progressText = (
211
- result.content.find((c) => c.type === "text") as any
212
- )?.text as string | undefined;
284
+ const progressText = result.content.find(
285
+ (c) => c.type === "text",
286
+ )?.text;
213
287
  const display = progressText
214
288
  ? progressText.replace(/\*\*/g, "")
215
289
  : "Searching...";
@@ -217,9 +291,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
217
291
  }
218
292
 
219
293
  const textContent = result.content.find((c) => c.type === "text");
220
- const raw = (result.details as any)?.raw as
221
- | Record<string, unknown>
222
- | undefined;
294
+ const raw = result.details?.raw as Record<string, unknown> | undefined;
223
295
 
224
296
  // Collapsed: one-line summary only
225
297
  if (!expanded) {
@@ -256,7 +328,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
256
328
  );
257
329
  let totalSources = 0;
258
330
  for (const key of engineKeys) {
259
- const eng = (raw as any)[key] as Record<string, unknown> | undefined;
331
+ const eng = raw?.[key] as Record<string, unknown> | undefined;
260
332
  const s = eng?.sources as Array<unknown> | undefined;
261
333
  if (Array.isArray(s)) totalSources += s.length;
262
334
  }
@@ -272,7 +344,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
272
344
  }
273
345
 
274
346
  // No structured data — show content text as error/fallback
275
- const snippet = (textContent as any)?.text as string | undefined;
347
+ const snippet = textContent?.text;
276
348
  if (snippet) {
277
349
  return new Text(
278
350
  theme.fg("warning", ` → ${snippet.slice(0, 80)}`),
@@ -1,186 +1,187 @@
1
- /**
2
- * Shared types, utilities, and runSearch for Pi tool handlers
3
- */
4
-
5
- import { spawn } from "node:child_process";
6
- import { existsSync } from "node:fs";
7
- import { join } from "node:path";
8
- import type { ProgressUpdate, ToolResult } from "../types.js";
9
-
10
- export type { ProgressUpdate, ToolResult } from "../types.js";
11
-
12
- // Canonical source is src/search/constants.mjs keep in sync
13
- const ALL_ENGINES = ["perplexity", "bing", "google"] as const;
14
-
15
- export { ALL_ENGINES };
16
-
17
- /** Strip surrounding double-quotes that some framework versions inject into string params */
18
- export function stripQuotes(val: string): string {
19
- return val.replace(/^"|"$/g, "");
20
- }
21
-
22
- /**
23
- * Check if the CDP module is available in the package directory
24
- */
25
- export function cdpAvailable(baseDir: string): boolean {
26
- return existsSync(join(baseDir, "bin", "cdp.mjs"));
27
- }
28
-
29
- /**
30
- * Create a "cdp missing" error result
31
- */
32
- export function cdpMissingResult(): ToolResult {
33
- return {
34
- content: [
35
- {
36
- type: "text",
37
- text: "cdp.mjs missing — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi",
38
- },
39
- ],
40
- details: {} as Record<string, unknown>,
41
- };
42
- }
43
-
44
- /**
45
- * Create an error result with a message
46
- */
47
- export function errorResult(prefix: string, e: unknown): ToolResult {
48
- const msg = e instanceof Error ? e.message : String(e);
49
- return {
50
- content: [{ type: "text", text: `${prefix}: ${msg}` }],
51
- details: {} as Record<string, unknown>,
52
- };
53
- }
54
-
55
- /**
56
- * Spawn search.mjs and collect JSON results, with progress streaming via stderr.
57
- * Shared by GreedySearch tool handlers.
58
- */
59
- export function runSearch(
60
- engine: string,
61
- query: string,
62
- flags: string[],
63
- searchBin: string,
64
- signal?: AbortSignal,
65
- onProgress?: (
66
- engine: string,
67
- status: "done" | "error" | "needs-human",
68
- ) => void,
69
- headless?: boolean, // defaults to true (headless is the default)
70
- ): Promise<Record<string, unknown>> {
71
- return new Promise((resolve, reject) => {
72
- const allFlags = [...flags];
73
- // Headless is default — only skip if explicitly false or GREEDY_SEARCH_VISIBLE=1
74
- if (headless !== false && process.env.GREEDY_SEARCH_VISIBLE !== "1")
75
- allFlags.push("--headless");
76
- if (headless === false) allFlags.push("--always-visible");
77
- // Propagate visibility preference via env (--headless flag is informational;
78
- // the actual headless control in search.mjs / launch.mjs reads the env var).
79
- const procEnv = { ...process.env };
80
- if (headless === false) {
81
- procEnv.GREEDY_SEARCH_VISIBLE = "1";
82
- procEnv.GREEDY_SEARCH_ALWAYS_VISIBLE = "1";
83
- }
84
- const proc = spawn(
85
- process.execPath,
86
- [searchBin, engine, "--inline", "--stdin", ...allFlags],
87
- { stdio: ["pipe", "pipe", "pipe"], env: procEnv },
88
- );
89
- // Pipe query via stdin to avoid leaking it in process table command-line
90
- proc.stdin.write(query);
91
- proc.stdin.end();
92
- let out = "";
93
- let err = "";
94
-
95
- const onAbort = () => {
96
- proc.kill("SIGTERM");
97
- reject(new Error("Aborted"));
98
- };
99
- signal?.addEventListener("abort", onAbort, { once: true });
100
-
101
- proc.stderr.on("data", (d: Buffer) => {
102
- err += d;
103
- for (const line of d.toString().split("\n")) {
104
- // Engine progress: perplexity/bing/google
105
- const engineMatch = line.match(
106
- /^PROGRESS:(perplexity|bing|google):(done|error|needs-human)$/,
107
- );
108
- if (engineMatch && onProgress) {
109
- onProgress(
110
- engineMatch[1],
111
- engineMatch[2] as "done" | "error" | "needs-human",
112
- );
113
- }
114
- // Synthesis progress: skipped (manual verification) or done/error
115
- const synthMatch = line.match(
116
- /^PROGRESS:synthesis:(done|error|skipped)$/,
117
- );
118
- if (synthMatch && onProgress) {
119
- onProgress(
120
- "synthesis",
121
- synthMatch[1] as "done" | "error" | "needs-human",
122
- );
123
- }
124
- }
125
- });
126
-
127
- proc.stdout.on("data", (d: Buffer) => (out += d));
128
- proc.on("close", (code: number) => {
129
- signal?.removeEventListener("abort", onAbort);
130
- if (code !== 0) {
131
- reject(new Error(err.trim() || `search.mjs exited with code ${code}`));
132
- } else {
133
- try {
134
- resolve(JSON.parse(out.trim()));
135
- } catch {
136
- reject(
137
- new Error(`Invalid JSON from search.mjs: ${out.slice(0, 200)}`),
138
- );
139
- }
140
- }
141
- });
142
- });
143
- }
144
-
145
- /**
146
- * Build a progress callback that tracks completed engines.
147
- * Returns an onProgress function suitable for runSearch.
148
- */
149
- export function makeProgressTracker(
150
- engines: readonly string[],
151
- onUpdate: ((update: ProgressUpdate) => void) | undefined,
152
- suffix: "Searching" | "Researching",
153
- depth: string,
154
- ) {
155
- const completed = new Map<string, "done" | "error" | "needs-human">();
156
-
157
- return (eng: string, status: "done" | "error" | "needs-human") => {
158
- completed.set(eng, status);
159
- const parts: string[] = [];
160
- for (const e of engines) {
161
- const s = completed.get(e);
162
- if (s === "done") parts.push(`✅ ${e} done`);
163
- else if (s === "error") parts.push(`❌ ${e} failed`);
164
- else if (s === "needs-human")
165
- parts.push(`🔓 ${e} needs manual verification`);
166
- else parts.push(`⏳ ${e}`);
167
- }
168
- // Synthesis status: when all engines complete in non-fast mode,
169
- // show synthesis progress. Handle "skipped" status (emitted when
170
- // manual verification is needed and synthesis is bypassed).
171
- if (depth !== "fast" && completed.size >= 3) {
172
- const synStatus = completed.get("synthesis");
173
- if (synStatus === "done") parts.push("✅ synthesized");
174
- else if (synStatus === "error") parts.push(" synthesis failed");
175
- else if (synStatus === "needs-human") parts.push("⏭️ synthesis skipped");
176
- else parts.push("🔄 synthesizing");
177
- }
178
-
179
- onUpdate?.({
180
- content: [
181
- { type: "text", text: `**${suffix}...** ${parts.join(" · ")}` },
182
- ],
183
- details: { _progress: true },
184
- } satisfies ProgressUpdate);
185
- };
186
- }
1
+ /**
2
+ * Shared types, utilities, and runSearch for Pi tool handlers
3
+ */
4
+
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { ProgressUpdate, ToolResult } from "../types.js";
9
+
10
+ export type { ProgressUpdate, ToolResult } from "../types.js";
11
+
12
+ // Import and re-export ALL_ENGINES from constants.mjs so it's always in sync.
13
+ // constants.mjs reads ~/.pi/greedyconfig for user overrides.
14
+ import { ALL_ENGINES } from "../search/constants.mjs";
15
+ export { ALL_ENGINES };
16
+
17
+ /** Strip surrounding double-quotes that some framework versions inject into string params */
18
+ export function stripQuotes(val: string): string {
19
+ return val.replace(/^"|"$/g, "");
20
+ }
21
+
22
+ /**
23
+ * Check if the CDP module is available in the package directory
24
+ */
25
+ export function cdpAvailable(baseDir: string): boolean {
26
+ return existsSync(join(baseDir, "bin", "cdp.mjs"));
27
+ }
28
+
29
+ /**
30
+ * Create a "cdp missing" error result
31
+ */
32
+ export function cdpMissingResult(): ToolResult {
33
+ return {
34
+ content: [
35
+ {
36
+ type: "text",
37
+ text: "cdp.mjs missing — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi",
38
+ },
39
+ ],
40
+ details: {} as Record<string, unknown>,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Create an error result with a message
46
+ */
47
+ export function errorResult(prefix: string, e: unknown): ToolResult {
48
+ const msg = e instanceof Error ? e.message : String(e);
49
+ return {
50
+ content: [{ type: "text", text: `${prefix}: ${msg}` }],
51
+ details: {} as Record<string, unknown>,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Spawn search.mjs and collect JSON results, with progress streaming via stderr.
57
+ * Shared by GreedySearch tool handlers.
58
+ */
59
+ export function runSearch(
60
+ engine: string,
61
+ query: string,
62
+ flags: string[],
63
+ searchBin: string,
64
+ signal?: AbortSignal,
65
+ onProgress?: (
66
+ engine: string,
67
+ status: "done" | "error" | "needs-human",
68
+ ) => void,
69
+ options: { headless?: boolean } = {},
70
+ ): Promise<Record<string, unknown>> {
71
+ return new Promise((resolve, reject) => {
72
+ const { headless = true } = options;
73
+ const allFlags = [...flags];
74
+ // Headless is default — only skip if explicitly false or GREEDY_SEARCH_VISIBLE=1
75
+ if (headless !== false && process.env.GREEDY_SEARCH_VISIBLE !== "1")
76
+ allFlags.push("--headless");
77
+ if (headless === false) allFlags.push("--always-visible");
78
+ // Propagate visibility preference via env (--headless flag is informational;
79
+ // the actual headless control in search.mjs / launch.mjs reads the env var).
80
+ const procEnv = { ...process.env };
81
+ if (headless === false) {
82
+ procEnv.GREEDY_SEARCH_VISIBLE = "1";
83
+ procEnv.GREEDY_SEARCH_ALWAYS_VISIBLE = "1";
84
+ }
85
+ const proc = spawn(
86
+ process.execPath,
87
+ [searchBin, engine, "--inline", "--stdin", ...allFlags],
88
+ { stdio: ["pipe", "pipe", "pipe"], env: procEnv },
89
+ );
90
+ // Pipe query via stdin to avoid leaking it in process table command-line
91
+ proc.stdin.write(query);
92
+ proc.stdin.end();
93
+ let out = "";
94
+ let err = "";
95
+
96
+ const onAbort = () => {
97
+ proc.kill("SIGTERM");
98
+ reject(new Error("Aborted"));
99
+ };
100
+ signal?.addEventListener("abort", onAbort, { once: true });
101
+
102
+ proc.stderr.on("data", (d: Buffer) => {
103
+ err += d;
104
+ // Match PROGRESS lines for any known engine.
105
+ const ENGINE_PROGRESS_RE =
106
+ /^PROGRESS:(perplexity|google|chatgpt|bing|gemini|semantic-scholar|semanticscholar|s2|logically):(done|error|needs-human)$/;
107
+ for (const line of d.toString().split("\n")) {
108
+ // Engine progress: any known engine
109
+ const engineMatch = line.match(ENGINE_PROGRESS_RE);
110
+ if (engineMatch && onProgress) {
111
+ onProgress(
112
+ engineMatch[1],
113
+ engineMatch[2] as "done" | "error" | "needs-human",
114
+ );
115
+ }
116
+ // Synthesis progress: skipped (manual verification) or done/error
117
+ const synthMatch = line.match(
118
+ /^PROGRESS:synthesis:(done|error|skipped)$/,
119
+ );
120
+ if (synthMatch && onProgress) {
121
+ onProgress(
122
+ "synthesis",
123
+ synthMatch[1] as "done" | "error" | "needs-human",
124
+ );
125
+ }
126
+ }
127
+ });
128
+
129
+ proc.stdout.on("data", (d: Buffer) => (out += d));
130
+ proc.on("close", (code: number) => {
131
+ signal?.removeEventListener("abort", onAbort);
132
+ if (code !== 0) {
133
+ reject(new Error(err.trim() || `search.mjs exited with code ${code}`));
134
+ } else {
135
+ try {
136
+ resolve(JSON.parse(out.trim()));
137
+ } catch {
138
+ reject(
139
+ new Error(`Invalid JSON from search.mjs: ${out.slice(0, 200)}`),
140
+ );
141
+ }
142
+ }
143
+ });
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Build a progress callback that tracks completed engines.
149
+ * Returns an onProgress function suitable for runSearch.
150
+ */
151
+ export function makeProgressTracker(
152
+ engines: readonly string[],
153
+ onUpdate: ((update: ProgressUpdate) => void) | undefined,
154
+ suffix: "Searching" | "Researching",
155
+ showSynthesis: boolean,
156
+ ) {
157
+ const completed = new Map<string, "done" | "error" | "needs-human">();
158
+
159
+ return (eng: string, status: "done" | "error" | "needs-human") => {
160
+ completed.set(eng, status);
161
+ const parts: string[] = [];
162
+ for (const e of engines) {
163
+ const s = completed.get(e);
164
+ if (s === "done") parts.push(`✅ ${e} done`);
165
+ else if (s === "error") parts.push(`❌ ${e} failed`);
166
+ else if (s === "needs-human")
167
+ parts.push(`🔓 ${e} needs manual verification`);
168
+ else parts.push(`⏳ ${e}`);
169
+ }
170
+ // Synthesis status is shown only when the caller explicitly requested
171
+ // Gemini synthesis for a multi-engine search.
172
+ if (showSynthesis && completed.size >= engines.length) {
173
+ const synStatus = completed.get("synthesis");
174
+ if (synStatus === "done") parts.push(" synthesized");
175
+ else if (synStatus === "error") parts.push(" synthesis failed");
176
+ else if (synStatus === "needs-human") parts.push("⏭️ synthesis skipped");
177
+ else parts.push("🔄 synthesizing");
178
+ }
179
+
180
+ onUpdate?.({
181
+ content: [
182
+ { type: "text", text: `**${suffix}...** ${parts.join(" · ")}` },
183
+ ],
184
+ details: { _progress: true },
185
+ } satisfies ProgressUpdate);
186
+ };
187
+ }