@apmantza/greedysearch-pi 1.9.0 → 1.9.2

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.
@@ -15,16 +15,10 @@ const __dir =
15
15
  import.meta.dirname ||
16
16
  new URL(".", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
17
17
 
18
- export async function synthesizeWithGemini(
19
- query,
20
- results,
21
- { grounded = false, tabPrefix = null } = {},
18
+ export async function runGeminiPrompt(
19
+ prompt,
20
+ { tabPrefix = null, timeoutMs = 180000 } = {},
22
21
  ) {
23
- const sources = Array.isArray(results._sources)
24
- ? results._sources
25
- : buildSourceRegistry(results);
26
- const prompt = buildSynthesisPrompt(query, results, sources, { grounded });
27
-
28
22
  return new Promise((resolve, reject) => {
29
23
  const extraArgs = tabPrefix ? ["--tab", String(tabPrefix)] : [];
30
24
  const proc = spawn(
@@ -39,7 +33,7 @@ export async function synthesizeWithGemini(
39
33
  env: { ...process.env, CDP_PROFILE_DIR: GREEDY_PROFILE_DIR },
40
34
  },
41
35
  );
42
- // Pipe synthesis prompt via stdin to avoid leaking the full prompt in process table
36
+ // Pipe prompts via stdin to avoid leaking them in process tables.
43
37
  proc.stdin.write(prompt);
44
38
  proc.stdin.end();
45
39
  let out = "";
@@ -48,49 +42,61 @@ export async function synthesizeWithGemini(
48
42
  proc.stderr.on("data", (d) => (err += d));
49
43
  const t = setTimeout(() => {
50
44
  proc.kill();
51
- reject(new Error("Gemini synthesis timed out after 180s"));
52
- }, 180000);
45
+ reject(new Error(`Gemini prompt timed out after ${timeoutMs / 1000}s`));
46
+ }, timeoutMs);
53
47
  proc.on("close", (code) => {
54
48
  clearTimeout(t);
55
- if (code !== 0)
49
+ if (code !== 0) {
56
50
  reject(new Error(err.trim() || "gemini extractor failed"));
57
- else {
58
- try {
59
- const raw = JSON.parse(out.trim());
60
- let structured = parseStructuredJson(raw.answer || "");
61
-
62
- // Detect if Gemini echoed back the engine summaries instead of a synthesis.
63
- // Happens when Gemini can't synthesize (e.g. only 1 engine responded) and
64
- // echoes the prompt JSON. The engine summary JSON has per-engine keys
65
- // (perplexity/bing/google) but no synthesis fields (answer/agreement).
66
- const SYNTHESIS_FIELDS = [
67
- "answer",
68
- "agreement",
69
- "claims",
70
- "differences",
71
- "caveats",
72
- ];
73
- const hasSynthesisFields =
74
- structured && SYNTHESIS_FIELDS.some((f) => f in structured);
75
- const hasEngineKeys =
76
- structured &&
77
- ["perplexity", "bing", "google"].some((e) => e in structured);
78
- if (hasEngineKeys && !hasSynthesisFields) {
79
- structured = null; // Treat as parse failure — Gemini echoed input
80
- }
81
-
82
- resolve({
83
- ...normalizeSynthesisPayload(structured, sources, raw.answer || ""),
84
- rawAnswer: raw.answer || "",
85
- geminiSources: raw.sources || [],
86
- });
87
- } catch {
88
- reject(new Error(`bad JSON from gemini: ${out.slice(0, 100)}`));
89
- }
51
+ return;
52
+ }
53
+ try {
54
+ resolve(JSON.parse(out.trim()));
55
+ } catch {
56
+ reject(new Error(`bad JSON from gemini: ${out.slice(0, 100)}`));
90
57
  }
91
58
  });
92
59
  });
93
60
  }
94
61
 
62
+ export async function synthesizeWithGemini(
63
+ query,
64
+ results,
65
+ { grounded = false, tabPrefix = null } = {},
66
+ ) {
67
+ const sources = Array.isArray(results._sources)
68
+ ? results._sources
69
+ : buildSourceRegistry(results);
70
+ const prompt = buildSynthesisPrompt(query, results, sources, { grounded });
71
+
72
+ const raw = await runGeminiPrompt(prompt, { tabPrefix, timeoutMs: 180000 });
73
+ let structured = parseStructuredJson(raw.answer || "");
74
+
75
+ // Detect if Gemini echoed back the engine summaries instead of a synthesis.
76
+ // Happens when Gemini can't synthesize (e.g. only 1 engine responded) and
77
+ // echoes the prompt JSON. The engine summary JSON has per-engine keys
78
+ // (perplexity/bing/google) but no synthesis fields (answer/agreement).
79
+ const SYNTHESIS_FIELDS = [
80
+ "answer",
81
+ "agreement",
82
+ "claims",
83
+ "differences",
84
+ "caveats",
85
+ ];
86
+ const hasSynthesisFields =
87
+ structured && SYNTHESIS_FIELDS.some((f) => f in structured);
88
+ const hasEngineKeys =
89
+ structured && ["perplexity", "bing", "google"].some((e) => e in structured);
90
+ if (hasEngineKeys && !hasSynthesisFields) {
91
+ structured = null; // Treat as parse failure — Gemini echoed input
92
+ }
93
+
94
+ return {
95
+ ...normalizeSynthesisPayload(structured, sources, raw.answer || ""),
96
+ rawAnswer: raw.answer || "",
97
+ geminiSources: raw.sources || [],
98
+ };
99
+ }
100
+
95
101
  // Need to import buildSourceRegistry for fallback
96
102
  import { buildSourceRegistry } from "./sources.mjs";
@@ -1,124 +1,298 @@
1
- /**
2
- * greedy_search tool handler — multi-engine AI web search
3
- */
4
-
5
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
- import { Type } from "@sinclair/typebox";
7
- import { formatResults } from "../formatters/results.js";
8
- import {
9
- ALL_ENGINES,
10
- cdpAvailable,
11
- cdpMissingResult,
12
- errorResult,
13
- makeProgressTracker,
14
- runSearch,
15
- stripQuotes,
16
- } from "./shared.js";
17
-
18
- export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
19
- pi.registerTool({
20
- name: "greedy_search",
21
- label: "Greedy Search",
22
- description:
23
- "WEB SEARCH ONLY — searches live web via Perplexity, Bing Copilot, and Google AI in parallel. " +
24
- "Optionally synthesizes results with Gemini, deduplicates sources by consensus. " +
25
- "Use for: library docs, recent framework changes, error messages, best practices, current events. " +
26
- "Reports streaming progress as each engine completes.",
27
- promptSnippet: "Multi-engine AI web search with streaming progress",
28
- parameters: Type.Object({
29
- query: Type.String({ description: "The search query" }),
30
- engine: Type.String({
31
- description:
32
- 'Engine to use: "all" (default), "perplexity", "bing", "google", "gemini", "gem". "all" fans out to Perplexity, Bing, and Google in parallel.',
33
- default: "all",
34
- }),
35
- depth: Type.String({
36
- description:
37
- 'Search depth: "fast" (single engine, ~15-30s), "standard" (3 engines + synthesis, ~30-90s), "deep" (3 engines + source fetching + synthesis + confidence, ~60-180s). Default: "standard".',
38
- default: "standard",
39
- }),
40
- fullAnswer: Type.Optional(
41
- Type.Boolean({
42
- description:
43
- "When true, returns the complete answer instead of a truncated preview (default: false, answers are shortened to ~300 chars to save tokens).",
44
- default: false,
45
- }),
46
- ),
47
- headless: Type.Optional(
48
- Type.Boolean({
49
- description:
50
- "Set to false to show Chrome window (headless is the default). Set GREEDY_SEARCH_VISIBLE=1 to disable headless globally.",
51
- default: true,
52
- }),
53
- ),
54
- visible: Type.Optional(
55
- Type.Boolean({
56
- description:
57
- "Set to true to always use visible Chrome for this search. Alias for headless: false.",
58
- default: false,
59
- }),
60
- ),
61
- alwaysVisible: Type.Optional(
62
- Type.Boolean({
63
- description:
64
- "Set to true to keep GreedySearch in visible Chrome mode for this search. Alias for visible: true.",
65
- default: false,
66
- }),
67
- ),
68
- }),
69
- execute: async (_toolCallId, params, signal, onUpdate) => {
70
- const { query, fullAnswer: fullAnswerParam } = params as {
71
- query: string;
72
- engine: string;
73
- depth?: "fast" | "standard" | "deep";
74
- fullAnswer?: boolean;
75
- headless?: boolean;
76
- visible?: boolean;
77
- alwaysVisible?: boolean;
78
- };
79
- const engine = stripQuotes((params as any).engine ?? "all") || "all";
80
- const depth = (stripQuotes((params as any).depth ?? "standard") ||
81
- "standard") as "fast" | "standard" | "deep";
82
- const visible =
83
- (params as any).visible === true ||
84
- (params as any).alwaysVisible === true ||
85
- (params as any).headless === false ||
86
- process.env.GREEDY_SEARCH_VISIBLE === "1" ||
87
- process.env.GREEDY_SEARCH_ALWAYS_VISIBLE === "1";
88
- const headless = !visible;
89
-
90
- if (!cdpAvailable(baseDir)) return cdpMissingResult();
91
-
92
- const flags: string[] = [];
93
- const fullAnswer = fullAnswerParam ?? engine !== "all";
94
- if (fullAnswer) flags.push("--full");
95
- if (depth === "deep") flags.push("--depth", "deep");
96
- else if (depth === "standard" && engine === "all")
97
- flags.push("--synthesize");
98
-
99
- const onProgress =
100
- engine === "all"
101
- ? makeProgressTracker(ALL_ENGINES, onUpdate, "Searching", depth)
102
- : undefined;
103
-
104
- try {
105
- const data = await runSearch(
106
- engine,
107
- query,
108
- flags,
109
- `${baseDir}/bin/search.mjs`,
110
- signal,
111
- onProgress,
112
- headless,
113
- );
114
- const text = formatResults(engine, data);
115
- return {
116
- content: [{ type: "text", text: text || "No results returned." }],
117
- details: { raw: data },
118
- };
119
- } catch (e) {
120
- return errorResult("Search failed", e);
121
- }
122
- },
123
- });
124
- }
1
+ /**
2
+ * greedy_search tool handler — multi-engine AI web search
3
+ */
4
+
5
+ import { Type } from "@sinclair/typebox";
6
+
7
+ type ExtensionAPI = {
8
+ registerTool(tool: Record<string, unknown>): void;
9
+ };
10
+ import { formatResults } from "../formatters/results.js";
11
+ import {
12
+ ALL_ENGINES,
13
+ cdpAvailable,
14
+ cdpMissingResult,
15
+ errorResult,
16
+ makeProgressTracker,
17
+ runSearch,
18
+ stripQuotes,
19
+ } from "./shared.js";
20
+
21
+ class Text {
22
+ constructor(
23
+ private text: string,
24
+ private paddingX = 0,
25
+ private paddingY = 0,
26
+ ) {}
27
+
28
+ render(width: number): string[] {
29
+ const horizontal = " ".repeat(this.paddingX);
30
+ const blank = "";
31
+ const contentWidth = Math.max(1, width - this.paddingX * 2);
32
+ const lines = this.text.split("\n").flatMap((line) => {
33
+ if (line.length <= contentWidth) return [`${horizontal}${line}`];
34
+ const wrapped: string[] = [];
35
+ for (let i = 0; i < line.length; i += contentWidth) {
36
+ wrapped.push(`${horizontal}${line.slice(i, i + contentWidth)}`);
37
+ }
38
+ return wrapped;
39
+ });
40
+ return [
41
+ ...Array.from({ length: this.paddingY }, () => blank),
42
+ ...lines,
43
+ ...Array.from({ length: this.paddingY }, () => blank),
44
+ ];
45
+ }
46
+
47
+ invalidate() {}
48
+ }
49
+
50
+ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
51
+ pi.registerTool({
52
+ name: "greedy_search",
53
+ label: "Greedy Search",
54
+ 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. " +
57
+ "Use for: library docs, recent framework changes, error messages, best practices, current events. " +
58
+ "Reports streaming progress as each engine completes.",
59
+ promptSnippet: "Multi-engine AI web search with streaming progress",
60
+ parameters: Type.Object({
61
+ query: Type.String({ description: "The search query" }),
62
+ engine: Type.String({
63
+ description:
64
+ 'Engine to use: "all" (default), "perplexity", "bing", "google", "gemini", "gem". "all" fans out to Perplexity, Bing, and Google in parallel.',
65
+ default: "all",
66
+ }),
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
+ }),
72
+ breadth: Type.Optional(
73
+ Type.Number({
74
+ description:
75
+ 'Only for depth="research": number of parallel research directions per round, 1-5 (default: 3).',
76
+ default: 3,
77
+ }),
78
+ ),
79
+ iterations: Type.Optional(
80
+ Type.Number({
81
+ description:
82
+ 'Only for depth="research": number of iterative research rounds, 1-3 (default: 2).',
83
+ default: 2,
84
+ }),
85
+ ),
86
+ maxSources: Type.Optional(
87
+ Type.Number({
88
+ description:
89
+ 'Only for depth="research": maximum fetched sources for the final report, 3-12.',
90
+ }),
91
+ ),
92
+ fullAnswer: Type.Optional(
93
+ Type.Boolean({
94
+ description:
95
+ "When true, returns the complete answer instead of a truncated preview (default: false, answers are shortened to ~300 chars to save tokens).",
96
+ default: false,
97
+ }),
98
+ ),
99
+ headless: Type.Optional(
100
+ Type.Boolean({
101
+ description:
102
+ "Set to false to show Chrome window (headless is the default). Set GREEDY_SEARCH_VISIBLE=1 to disable headless globally.",
103
+ default: true,
104
+ }),
105
+ ),
106
+ visible: Type.Optional(
107
+ Type.Boolean({
108
+ description:
109
+ "Set to true to always use visible Chrome for this search. Alias for headless: false.",
110
+ default: false,
111
+ }),
112
+ ),
113
+ alwaysVisible: Type.Optional(
114
+ Type.Boolean({
115
+ description:
116
+ "Set to true to keep GreedySearch in visible Chrome mode for this search. Alias for visible: true.",
117
+ default: false,
118
+ }),
119
+ ),
120
+ }),
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;
138
+ const visible =
139
+ (params as any).visible === true ||
140
+ (params as any).alwaysVisible === true ||
141
+ (params as any).headless === false ||
142
+ process.env.GREEDY_SEARCH_VISIBLE === "1" ||
143
+ process.env.GREEDY_SEARCH_ALWAYS_VISIBLE === "1";
144
+ const headless = !visible;
145
+
146
+ if (!cdpAvailable(baseDir)) return cdpMissingResult();
147
+
148
+ const flags: string[] = [];
149
+ const fullAnswer = fullAnswerParam ?? effectiveEngine !== "all";
150
+ if (fullAnswer) flags.push("--full");
151
+ if (depth === "research") {
152
+ 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");
163
+
164
+ const onProgress =
165
+ effectiveEngine === "all"
166
+ ? makeProgressTracker(
167
+ ALL_ENGINES,
168
+ onUpdate,
169
+ depth === "research" ? "Researching" : "Searching",
170
+ depth,
171
+ )
172
+ : undefined;
173
+
174
+ try {
175
+ const data = await runSearch(
176
+ effectiveEngine,
177
+ query,
178
+ flags,
179
+ `${baseDir}/bin/search.mjs`,
180
+ signal,
181
+ onProgress,
182
+ headless,
183
+ );
184
+ const text = formatResults(effectiveEngine, data);
185
+ return {
186
+ content: [{ type: "text", text: text || "No results returned." }],
187
+ details: { raw: data },
188
+ };
189
+ } catch (e) {
190
+ return errorResult("Search failed", e);
191
+ }
192
+ },
193
+
194
+ renderCall(args, theme) {
195
+ const q = (args.query || "").slice(0, 60);
196
+ const qDisplay = q.length < (args.query || "").length ? `${q}...` : q;
197
+ const engineDisplay =
198
+ args.engine && args.engine !== "all"
199
+ ? theme.fg("dim", ` (${args.engine})`)
200
+ : "";
201
+ return new Text(
202
+ `${theme.fg("toolTitle", theme.bold("greedy_search"))} "${theme.fg("accent", qDisplay)}"${engineDisplay}`,
203
+ 0,
204
+ 0,
205
+ );
206
+ },
207
+
208
+ renderResult(result, { expanded, isPartial }, theme) {
209
+ if (isPartial) {
210
+ const progressText = (
211
+ result.content.find((c) => c.type === "text") as any
212
+ )?.text as string | undefined;
213
+ const display = progressText
214
+ ? progressText.replace(/\*\*/g, "")
215
+ : "Searching...";
216
+ return new Text(theme.fg("warning", display), 0, 0);
217
+ }
218
+
219
+ const textContent = result.content.find((c) => c.type === "text");
220
+ const raw = (result.details as any)?.raw as
221
+ | Record<string, unknown>
222
+ | undefined;
223
+
224
+ // Collapsed: one-line summary only
225
+ if (!expanded) {
226
+ const needsHuman = raw?._needsHumanVerification as
227
+ | Record<string, unknown>
228
+ | undefined;
229
+ if (needsHuman) {
230
+ return new Text(
231
+ theme.fg("warning", " → Manual verification required"),
232
+ 0,
233
+ 0,
234
+ );
235
+ }
236
+
237
+ const synthesis = raw?._synthesis as
238
+ | Record<string, unknown>
239
+ | undefined;
240
+ const sources = raw?._sources as Array<unknown> | undefined;
241
+ if (synthesis) {
242
+ const sourceCount = Array.isArray(sources) ? sources.length : 0;
243
+ const agreement = (
244
+ synthesis.agreement as Record<string, unknown> | undefined
245
+ )?.level as string | undefined;
246
+ let summary = " → Synthesized";
247
+ if (sourceCount > 0)
248
+ summary += ` · ${sourceCount} source${sourceCount > 1 ? "s" : ""}`;
249
+ if (agreement) summary += ` · ${agreement}`;
250
+ return new Text(theme.fg("muted", summary), 0, 0);
251
+ }
252
+
253
+ // Single engine: count its sources
254
+ const engineKeys = Object.keys(raw || {}).filter(
255
+ (k) => !k.startsWith("_"),
256
+ );
257
+ let totalSources = 0;
258
+ for (const key of engineKeys) {
259
+ const eng = (raw as any)[key] as Record<string, unknown> | undefined;
260
+ const s = eng?.sources as Array<unknown> | undefined;
261
+ if (Array.isArray(s)) totalSources += s.length;
262
+ }
263
+ if (totalSources > 0) {
264
+ return new Text(
265
+ theme.fg(
266
+ "muted",
267
+ ` → ${totalSources} source${totalSources > 1 ? "s" : ""}`,
268
+ ),
269
+ 0,
270
+ 0,
271
+ );
272
+ }
273
+
274
+ // No structured data — show content text as error/fallback
275
+ const snippet = (textContent as any)?.text as string | undefined;
276
+ if (snippet) {
277
+ return new Text(
278
+ theme.fg("warning", ` → ${snippet.slice(0, 80)}`),
279
+ 0,
280
+ 0,
281
+ );
282
+ }
283
+ return new Text(theme.fg("muted", " → Done"), 0, 0);
284
+ }
285
+
286
+ // Expanded: full output
287
+ if (!textContent || textContent.type !== "text") {
288
+ return new Text("", 0, 0);
289
+ }
290
+
291
+ const lines = textContent.text
292
+ .split("\n")
293
+ .map((line) => theme.fg("toolOutput", line))
294
+ .join("\n");
295
+ return new Text(`\n${lines}`, 0, 0);
296
+ },
297
+ });
298
+ }