@apmantza/greedysearch-pi 1.9.1 → 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.
@@ -2,9 +2,11 @@
2
2
  * greedy_search tool handler — multi-engine AI web search
3
3
  */
4
4
 
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { Text } from "@earendil-works/pi-tui";
7
5
  import { Type } from "@sinclair/typebox";
6
+
7
+ type ExtensionAPI = {
8
+ registerTool(tool: Record<string, unknown>): void;
9
+ };
8
10
  import { formatResults } from "../formatters/results.js";
9
11
  import {
10
12
  ALL_ENGINES,
@@ -14,15 +16,75 @@ import {
14
16
  makeProgressTracker,
15
17
  runSearch,
16
18
  stripQuotes,
19
+ type ProgressUpdate,
20
+ type ToolResult,
17
21
  } from "./shared.js";
18
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
+
50
+ class Text {
51
+ constructor(
52
+ private text: string,
53
+ private paddingX = 0,
54
+ private paddingY = 0,
55
+ ) {}
56
+
57
+ render(width: number): string[] {
58
+ const horizontal = " ".repeat(this.paddingX);
59
+ const blank = "";
60
+ const contentWidth = Math.max(1, width - this.paddingX * 2);
61
+ const lines = this.text.split("\n").flatMap((line) => {
62
+ if (line.length <= contentWidth) return [`${horizontal}${line}`];
63
+ const wrapped: string[] = [];
64
+ for (let i = 0; i < line.length; i += contentWidth) {
65
+ wrapped.push(`${horizontal}${line.slice(i, i + contentWidth)}`);
66
+ }
67
+ return wrapped;
68
+ });
69
+ return [
70
+ ...Array.from({ length: this.paddingY }, () => blank),
71
+ ...lines,
72
+ ...Array.from({ length: this.paddingY }, () => blank),
73
+ ];
74
+ }
75
+
76
+ invalidate() {}
77
+ }
78
+
19
79
  export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
20
80
  pi.registerTool({
21
81
  name: "greedy_search",
22
82
  label: "Greedy Search",
23
83
  description:
24
- "WEB SEARCH ONLY — searches live web via Perplexity, Bing Copilot, and Google AI in parallel. " +
25
- "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. " +
26
88
  "Use for: library docs, recent framework changes, error messages, best practices, current events. " +
27
89
  "Reports streaming progress as each engine completes.",
28
90
  promptSnippet: "Multi-engine AI web search with streaming progress",
@@ -30,14 +92,61 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
30
92
  query: Type.String({ description: "The search query" }),
31
93
  engine: Type.String({
32
94
  description:
33
- '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.',
34
96
  default: "all",
35
97
  }),
36
- depth: Type.String({
37
- description:
38
- 'Search depth: "fast" (no synthesis/source fetch, ~15-30s), "standard" (synthesis + sources, ~30-90s), "deep" (synthesis + source fetching + confidence, ~60-180s). Default: "standard". Note: single-engine searches always run in fast mode regardless of this setting — synthesis requires multiple engines.',
39
- default: "standard",
40
- }),
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
+ ),
117
+ breadth: Type.Optional(
118
+ Type.Number({
119
+ description:
120
+ 'Only for depth="research": number of parallel research directions per round, 1-5 (default: 3).',
121
+ default: 3,
122
+ }),
123
+ ),
124
+ iterations: Type.Optional(
125
+ Type.Number({
126
+ description:
127
+ 'Only for depth="research": number of iterative research rounds, 1-3 (default: 2).',
128
+ default: 2,
129
+ }),
130
+ ),
131
+ maxSources: Type.Optional(
132
+ Type.Number({
133
+ description:
134
+ 'Only for depth="research": maximum fetched sources for the final report, 3-12.',
135
+ }),
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
+ ),
41
150
  fullAnswer: Type.Optional(
42
151
  Type.Boolean({
43
152
  description:
@@ -67,23 +176,33 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
67
176
  }),
68
177
  ),
69
178
  }),
70
- execute: async (_toolCallId, params, signal, onUpdate) => {
71
- const { query, fullAnswer: fullAnswerParam } = params as {
72
- query: string;
73
- engine: string;
74
- depth?: "fast" | "standard" | "deep";
75
- fullAnswer?: boolean;
76
- headless?: boolean;
77
- visible?: boolean;
78
- alwaysVisible?: boolean;
79
- };
80
- const engine = stripQuotes((params as any).engine ?? "all") || "all";
81
- const depth = (stripQuotes((params as any).depth ?? "standard") ||
82
- "standard") as "fast" | "standard" | "deep";
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;
83
202
  const visible =
84
- (params as any).visible === true ||
85
- (params as any).alwaysVisible === true ||
86
- (params as any).headless === false ||
203
+ params.visible === true ||
204
+ params.alwaysVisible === true ||
205
+ params.headless === false ||
87
206
  process.env.GREEDY_SEARCH_VISIBLE === "1" ||
88
207
  process.env.GREEDY_SEARCH_ALWAYS_VISIBLE === "1";
89
208
  const headless = !visible;
@@ -91,29 +210,48 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
91
210
  if (!cdpAvailable(baseDir)) return cdpMissingResult();
92
211
 
93
212
  const flags: string[] = [];
94
- const fullAnswer = fullAnswerParam ?? engine !== "all";
213
+ const fullAnswer = fullAnswerParam ?? effectiveEngine !== "all";
95
214
  if (fullAnswer) flags.push("--full");
96
- if (depth === "deep") flags.push("--depth", "deep");
97
- else if (depth === "fast") flags.push("--fast");
98
- else if (depth === "standard" && engine === "all")
99
- flags.push("--synthesize");
215
+ if (researchMode) {
216
+ flags.push("--depth", "research");
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
+ }
100
233
 
101
234
  const onProgress =
102
- engine === "all"
103
- ? makeProgressTracker(ALL_ENGINES, onUpdate, "Searching", depth)
235
+ effectiveEngine === "all"
236
+ ? makeProgressTracker(
237
+ ALL_ENGINES,
238
+ onUpdate,
239
+ researchMode ? "Researching" : "Searching",
240
+ synthesize,
241
+ )
104
242
  : undefined;
105
243
 
106
244
  try {
107
245
  const data = await runSearch(
108
- engine,
246
+ effectiveEngine,
109
247
  query,
110
248
  flags,
111
249
  `${baseDir}/bin/search.mjs`,
112
250
  signal,
113
251
  onProgress,
114
- headless,
252
+ { headless },
115
253
  );
116
- const text = formatResults(engine, data);
254
+ const text = formatResults(effectiveEngine, data);
117
255
  return {
118
256
  content: [{ type: "text", text: text || "No results returned." }],
119
257
  details: { raw: data },
@@ -123,7 +261,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
123
261
  }
124
262
  },
125
263
 
126
- renderCall(args, theme) {
264
+ renderCall(args: Partial<GreedySearchParams>, theme: ToolTheme) {
127
265
  const q = (args.query || "").slice(0, 60);
128
266
  const qDisplay = q.length < (args.query || "").length ? `${q}...` : q;
129
267
  const engineDisplay =
@@ -137,9 +275,15 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
137
275
  );
138
276
  },
139
277
 
140
- renderResult(result, { expanded, isPartial }, theme) {
278
+ renderResult(
279
+ result: ToolResult,
280
+ { expanded, isPartial }: RenderState,
281
+ theme: ToolTheme,
282
+ ) {
141
283
  if (isPartial) {
142
- const progressText = (result.content.find((c) => c.type === "text") as any)?.text as string | undefined;
284
+ const progressText = result.content.find(
285
+ (c) => c.type === "text",
286
+ )?.text;
143
287
  const display = progressText
144
288
  ? progressText.replace(/\*\*/g, "")
145
289
  : "Searching...";
@@ -147,9 +291,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
147
291
  }
148
292
 
149
293
  const textContent = result.content.find((c) => c.type === "text");
150
- const raw = (result.details as any)?.raw as
151
- | Record<string, unknown>
152
- | undefined;
294
+ const raw = result.details?.raw as Record<string, unknown> | undefined;
153
295
 
154
296
  // Collapsed: one-line summary only
155
297
  if (!expanded) {
@@ -170,7 +312,9 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
170
312
  const sources = raw?._sources as Array<unknown> | undefined;
171
313
  if (synthesis) {
172
314
  const sourceCount = Array.isArray(sources) ? sources.length : 0;
173
- const agreement = (synthesis.agreement as Record<string, unknown> | undefined)?.level as string | undefined;
315
+ const agreement = (
316
+ synthesis.agreement as Record<string, unknown> | undefined
317
+ )?.level as string | undefined;
174
318
  let summary = " → Synthesized";
175
319
  if (sourceCount > 0)
176
320
  summary += ` · ${sourceCount} source${sourceCount > 1 ? "s" : ""}`;
@@ -184,7 +328,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
184
328
  );
185
329
  let totalSources = 0;
186
330
  for (const key of engineKeys) {
187
- const eng = (raw as any)[key] as Record<string, unknown> | undefined;
331
+ const eng = raw?.[key] as Record<string, unknown> | undefined;
188
332
  const s = eng?.sources as Array<unknown> | undefined;
189
333
  if (Array.isArray(s)) totalSources += s.length;
190
334
  }
@@ -200,7 +344,7 @@ export function registerGreedySearchTool(pi: ExtensionAPI, baseDir: string) {
200
344
  }
201
345
 
202
346
  // No structured data — show content text as error/fallback
203
- const snippet = (textContent as any)?.text as string | undefined;
347
+ const snippet = textContent?.text;
204
348
  if (snippet) {
205
349
  return new Text(
206
350
  theme.fg("warning", ` → ${snippet.slice(0, 80)}`),