@apmantza/greedysearch-pi 1.4.1 → 1.4.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.
package/index.ts CHANGED
@@ -1,623 +1,623 @@
1
- /**
2
- * GreedySearch Pi Extension
3
- *
4
- * Adds a `greedy_search` tool to Pi that fans out queries to Perplexity,
5
- * Bing Copilot, and Google AI in parallel, returning synthesized AI answers.
6
- *
7
- * Reports streaming progress as each engine completes.
8
- * Requires Chrome to be running (or it auto-launches a dedicated instance).
9
- */
10
-
11
- import { spawn } from "node:child_process";
12
- import { existsSync } from "node:fs";
13
- import { join, dirname } from "node:path";
14
- import { fileURLToPath } from "node:url";
15
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
- import { Type } from "@sinclair/typebox";
17
-
18
- const __dir = dirname(fileURLToPath(import.meta.url));
19
-
20
- const ALL_ENGINES = ["perplexity", "bing", "google"] as const;
21
-
22
- function cdpAvailable(): boolean {
23
- return existsSync(join(__dir, "cdp.mjs"));
24
- }
25
-
26
- function runSearch(
27
- engine: string,
28
- query: string,
29
- flags: string[] = [],
30
- signal?: AbortSignal,
31
- onProgress?: (engine: string, status: "done" | "error") => void,
32
- ): Promise<Record<string, unknown>> {
33
- return new Promise((resolve, reject) => {
34
- const proc = spawn("node", [__dir + "/search.mjs", engine, "--inline", ...flags, query], {
35
- stdio: ["ignore", "pipe", "pipe"],
36
- });
37
- let out = "";
38
- let err = "";
39
-
40
- const onAbort = () => { proc.kill("SIGTERM"); reject(new Error("Aborted")); };
41
- signal?.addEventListener("abort", onAbort, { once: true });
42
-
43
- // Watch stderr for progress events (PROGRESS:engine:done|error)
44
- proc.stderr.on("data", (d: Buffer) => {
45
- err += d;
46
- const lines = d.toString().split("\n");
47
- for (const line of lines) {
48
- const match = line.match(/^PROGRESS:(\w+):(done|error)$/);
49
- if (match && onProgress) {
50
- onProgress(match[1], match[2] as "done" | "error");
51
- }
52
- }
53
- });
54
-
55
- proc.stdout.on("data", (d: Buffer) => (out += d));
56
- proc.on("close", (code: number) => {
57
- signal?.removeEventListener("abort", onAbort);
58
- if (code !== 0) {
59
- reject(new Error(err.trim() || `search.mjs exited with code ${code}`));
60
- } else {
61
- try {
62
- resolve(JSON.parse(out.trim()));
63
- } catch {
64
- reject(new Error(`Invalid JSON from search.mjs: ${out.slice(0, 200)}`));
65
- }
66
- }
67
- });
68
- });
69
- }
70
-
71
- function formatEngineName(engine: string): string {
72
- if (engine === "bing") return "Bing Copilot";
73
- if (engine === "google") return "Google AI";
74
- return engine.charAt(0).toUpperCase() + engine.slice(1);
75
- }
76
-
77
- function humanizeSourceType(sourceType: string): string {
78
- if (!sourceType) return "";
79
- if (sourceType === "official-docs") return "official docs";
80
- return sourceType.replace(/-/g, " ");
81
- }
82
-
83
- function sourceUrl(source: Record<string, unknown>): string {
84
- return String(source.displayUrl || source.canonicalUrl || source.url || "");
85
- }
86
-
87
- function sourceLabel(source: Record<string, unknown>): string {
88
- return String(source.title || source.domain || sourceUrl(source) || "Untitled source");
89
- }
90
-
91
- function sourceConsensus(source: Record<string, unknown>): number {
92
- if (typeof source.engineCount === "number") return source.engineCount;
93
- const engines = Array.isArray(source.engines) ? (source.engines as string[]) : [];
94
- return engines.length;
95
- }
96
-
97
- function formatAgreementLevel(level: string): string {
98
- if (!level) return "Mixed";
99
- return level.charAt(0).toUpperCase() + level.slice(1);
100
- }
101
-
102
- function getSourceMap(sources: Array<Record<string, unknown>>): Map<string, Record<string, unknown>> {
103
- return new Map(
104
- sources
105
- .map((source) => [String(source.id || ""), source] as const)
106
- .filter(([id]) => id),
107
- );
108
- }
109
-
110
- function formatSourceLine(source: Record<string, unknown>): string {
111
- const id = String(source.id || "?");
112
- const url = sourceUrl(source);
113
- const title = sourceLabel(source);
114
- const domain = String(source.domain || "");
115
- const engines = Array.isArray(source.engines) ? (source.engines as string[]) : [];
116
- const consensus = sourceConsensus(source);
117
- const typeLabel = humanizeSourceType(String(source.sourceType || ""));
118
- const fetch = source.fetch as Record<string, unknown> | undefined;
119
- const fetchStatus = fetch?.ok ? `fetched ${fetch.status || 200}` : fetch?.attempted ? "fetch failed" : "";
120
- const pieces = [
121
- `${id} - [${title}](${url})`,
122
- domain,
123
- typeLabel,
124
- engines.length ? `cited by ${engines.map(formatEngineName).join(", ")} (${consensus}/3)` : `${consensus}/3`,
125
- fetchStatus,
126
- ].filter(Boolean);
127
- return `- ${pieces.join(" - ")}`;
128
- }
129
-
130
- function renderSourceEvidence(lines: string[], source: Record<string, unknown>): void {
131
- const fetch = source.fetch as Record<string, unknown> | undefined;
132
- if (!fetch?.attempted) return;
133
-
134
- const snippet = String(fetch.snippet || "").trim();
135
- const lastModified = String(fetch.lastModified || "").trim();
136
- if (snippet) lines.push(` Evidence: ${snippet}`);
137
- if (lastModified) lines.push(` Last-Modified: ${lastModified}`);
138
- if (fetch.error) lines.push(` Fetch error: ${String(fetch.error)}`);
139
- }
140
-
141
- function pickSources(
142
- sources: Array<Record<string, unknown>>,
143
- recommendedIds: string[] = [],
144
- max = 6,
145
- ): Array<Record<string, unknown>> {
146
- if (!sources.length) return [];
147
- const sourceMap = getSourceMap(sources);
148
- const recommended = recommendedIds
149
- .map((id) => sourceMap.get(id))
150
- .filter((source): source is Record<string, unknown> => Boolean(source));
151
- if (recommended.length > 0) return recommended.slice(0, max);
152
- return sources.slice(0, max);
153
- }
154
-
155
- function renderSynthesis(
156
- lines: string[],
157
- synthesis: Record<string, unknown>,
158
- sources: Array<Record<string, unknown>>,
159
- maxSources = 6,
160
- ): void {
161
- if (synthesis.answer) {
162
- lines.push("## Answer");
163
- lines.push(String(synthesis.answer));
164
- lines.push("");
165
- }
166
-
167
- const agreement = synthesis.agreement as Record<string, unknown> | undefined;
168
- const agreementSummary = String(agreement?.summary || "").trim();
169
- const agreementLevel = String(agreement?.level || "").trim();
170
- if (agreementSummary || agreementLevel) {
171
- lines.push("## Consensus");
172
- lines.push(`- ${formatAgreementLevel(agreementLevel)}${agreementSummary ? ` - ${agreementSummary}` : ""}`);
173
- lines.push("");
174
- }
175
-
176
- const differences = Array.isArray(synthesis.differences) ? (synthesis.differences as string[]) : [];
177
- if (differences.length > 0) {
178
- lines.push("## Where Engines Differ");
179
- for (const difference of differences) lines.push(`- ${difference}`);
180
- lines.push("");
181
- }
182
-
183
- const caveats = Array.isArray(synthesis.caveats) ? (synthesis.caveats as string[]) : [];
184
- if (caveats.length > 0) {
185
- lines.push("## Caveats");
186
- for (const caveat of caveats) lines.push(`- ${caveat}`);
187
- lines.push("");
188
- }
189
-
190
- const claims = Array.isArray(synthesis.claims)
191
- ? (synthesis.claims as Array<Record<string, unknown>>)
192
- : [];
193
- if (claims.length > 0) {
194
- lines.push("## Key Claims");
195
- for (const claim of claims) {
196
- const sourceIds = Array.isArray(claim.sourceIds) ? (claim.sourceIds as string[]) : [];
197
- const support = String(claim.support || "moderate");
198
- lines.push(`- ${String(claim.claim || "")} [${support}${sourceIds.length ? `; ${sourceIds.join(", ")}` : ""}]`);
199
- }
200
- lines.push("");
201
- }
202
-
203
- const recommendedIds = Array.isArray(synthesis.recommendedSources)
204
- ? (synthesis.recommendedSources as string[])
205
- : [];
206
- const topSources = pickSources(sources, recommendedIds, maxSources);
207
- if (topSources.length > 0) {
208
- lines.push("## Top Sources");
209
- for (const source of topSources) lines.push(formatSourceLine(source));
210
- lines.push("");
211
- }
212
- }
213
-
214
- function formatResults(engine: string, data: Record<string, unknown>): string {
215
- const lines: string[] = [];
216
-
217
- if (engine === "all") {
218
- const synthesis = data._synthesis as Record<string, unknown> | undefined;
219
- const dedupedSources = data._sources as Array<Record<string, unknown>> | undefined;
220
- if (synthesis?.answer) {
221
- renderSynthesis(lines, synthesis, dedupedSources || [], 6);
222
- lines.push("*Synthesized from Perplexity, Bing Copilot, and Google AI*\n");
223
- return lines.join("\n").trim();
224
- }
225
-
226
- for (const [eng, result] of Object.entries(data)) {
227
- if (eng.startsWith("_")) continue;
228
- lines.push(`\n## ${formatEngineName(eng)}`);
229
- const r = result as Record<string, unknown>;
230
- if (r.error) {
231
- lines.push(`Error: ${r.error}`);
232
- } else {
233
- if (r.answer) lines.push(String(r.answer));
234
- if (Array.isArray(r.sources) && r.sources.length > 0) {
235
- lines.push("\nSources:");
236
- for (const s of r.sources.slice(0, 3)) {
237
- const src = s as Record<string, string>;
238
- lines.push(`- [${src.title || src.url}](${src.url})`);
239
- }
240
- }
241
- }
242
- }
243
- } else {
244
- if (data.error) {
245
- lines.push(`Error: ${data.error}`);
246
- } else {
247
- if (data.answer) lines.push(String(data.answer));
248
- if (Array.isArray(data.sources) && data.sources.length > 0) {
249
- lines.push("\nSources:");
250
- for (const s of data.sources.slice(0, 5)) {
251
- const src = s as Record<string, string>;
252
- lines.push(`- [${src.title || src.url}](${src.url})`);
253
- }
254
- }
255
- }
256
- }
257
-
258
- return lines.join("\n").trim();
259
- }
260
-
261
- function formatDeepResearch(data: Record<string, unknown>): string {
262
- const lines: string[] = [];
263
- const confidence = data._confidence as Record<string, unknown> | undefined;
264
- const dedupedSources = data._sources as Array<Record<string, unknown>> | undefined;
265
- const synthesis = data._synthesis as Record<string, unknown> | undefined;
266
-
267
- lines.push("# Deep Research Report\n");
268
-
269
- if (confidence) {
270
- const enginesResponded = (confidence.enginesResponded as string[]) || [];
271
- const enginesFailed = (confidence.enginesFailed as string[]) || [];
272
- const agreementLevel = String(confidence.agreementLevel || "mixed");
273
- const firstPartySourceCount = Number(confidence.firstPartySourceCount || 0);
274
- const sourceTypeBreakdown = confidence.sourceTypeBreakdown as Record<string, number> | undefined;
275
-
276
- lines.push("## Confidence\n");
277
- lines.push(`- Agreement: ${formatAgreementLevel(agreementLevel)}`);
278
- lines.push(`- Engines responded: ${enginesResponded.map(formatEngineName).join(", ") || "none"}`);
279
- if (enginesFailed.length > 0) {
280
- lines.push(`- Engines failed: ${enginesFailed.map(formatEngineName).join(", ")}`);
281
- }
282
- lines.push(`- Top source consensus: ${confidence.topSourceConsensus || 0}/3 engines`);
283
- lines.push(`- Total unique sources: ${confidence.sourcesCount || 0}`);
284
- lines.push(`- Official sources: ${confidence.officialSourceCount || 0}`);
285
- lines.push(`- First-party sources: ${firstPartySourceCount}`);
286
- lines.push(`- Fetch success rate: ${confidence.fetchedSourceSuccessRate || 0}`);
287
- if (sourceTypeBreakdown && Object.keys(sourceTypeBreakdown).length > 0) {
288
- lines.push(`- Source mix: ${Object.entries(sourceTypeBreakdown).map(([type, count]) => `${humanizeSourceType(type)} ${count}`).join(", ")}`);
289
- }
290
- lines.push("");
291
- }
292
-
293
- if (synthesis?.answer) renderSynthesis(lines, synthesis, dedupedSources || [], 8);
294
-
295
- lines.push("## Engine Perspectives\n");
296
- for (const engine of ["perplexity", "bing", "google"]) {
297
- const r = data[engine] as Record<string, unknown> | undefined;
298
- if (!r) continue;
299
- lines.push(`### ${formatEngineName(engine)}`);
300
- if (r.error) {
301
- lines.push(`⚠️ Error: ${r.error}`);
302
- } else if (r.answer) {
303
- lines.push(String(r.answer).slice(0, 2000));
304
- }
305
- lines.push("");
306
- }
307
-
308
- if (dedupedSources && dedupedSources.length > 0) {
309
- lines.push("## Source Registry\n");
310
- for (const source of dedupedSources) {
311
- lines.push(formatSourceLine(source));
312
- renderSourceEvidence(lines, source);
313
- }
314
- lines.push("");
315
- }
316
-
317
- return lines.join("\n").trim();
318
- }
319
-
320
- function formatCodingTask(data: Record<string, unknown> | Record<string, Record<string, unknown>>): string {
321
- const lines: string[] = [];
322
-
323
- // Check if it's multi-engine result
324
- const hasMultipleEngines = "gemini" in data || "copilot" in data;
325
-
326
- if (hasMultipleEngines) {
327
- // Multi-engine result
328
- for (const [engineName, result] of Object.entries(data)) {
329
- const r = result as Record<string, unknown>;
330
- lines.push(`## ${engineName.charAt(0).toUpperCase() + engineName.slice(1)}\n`);
331
-
332
- if (r.error) {
333
- lines.push(`⚠️ Error: ${r.error}\n`);
334
- } else {
335
- if (r.explanation) lines.push(String(r.explanation));
336
- if (Array.isArray(r.code) && r.code.length > 0) {
337
- for (const block of r.code) {
338
- const b = block as { language: string; code: string };
339
- lines.push(`\n\`\`\`${b.language}\n${b.code}\n\`\`\`\n`);
340
- }
341
- }
342
- if (r.url) lines.push(`*Source: ${r.url}*`);
343
- }
344
- lines.push("");
345
- }
346
- } else {
347
- // Single engine result
348
- const r = data as Record<string, unknown>;
349
- if (r.explanation) lines.push(String(r.explanation));
350
- if (Array.isArray(r.code) && r.code.length > 0) {
351
- for (const block of r.code) {
352
- const b = block as { language: string; code: string };
353
- lines.push(`\n\`\`\`${b.language}\n${b.code}\n\`\`\`\n`);
354
- }
355
- }
356
- if (r.url) lines.push(`*Source: ${r.url}*`);
357
- }
358
-
359
- return lines.join("\n").trim();
360
- }
361
-
362
- export default function greedySearchExtension(pi: ExtensionAPI) {
363
- pi.on("session_start", async (_event, ctx) => {
364
- if (!cdpAvailable()) {
365
- ctx.ui.notify(
366
- "GreedySearch: cdp.mjs missing from package directory — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi",
367
- "warning",
368
- );
369
- }
370
- });
371
-
372
- pi.registerTool({
373
- name: "greedy_search",
374
- label: "Greedy Search",
375
- description:
376
- "Search the web using AI-powered engines (Perplexity, Bing Copilot, Google AI) in parallel. " +
377
- "Optionally synthesize results with Gemini — deduplicates sources by consensus and returns one grounded answer. " +
378
- "Reports streaming progress as each engine completes. " +
379
- "Use for current information, library docs, error messages, best practices, or any question where training data may be stale.",
380
- promptSnippet: "Multi-engine AI web search with streaming progress",
381
- parameters: Type.Object({
382
- query: Type.String({ description: "The search query" }),
383
- engine: Type.Union(
384
- [
385
- Type.Literal("all"),
386
- Type.Literal("perplexity"),
387
- Type.Literal("bing"),
388
- Type.Literal("google"),
389
- Type.Literal("gemini"),
390
- Type.Literal("gem"),
391
- ],
392
- {
393
- description: 'Engine to use. "all" fans out to Perplexity, Bing, and Google in parallel (default).',
394
- default: "all",
395
- },
396
- ),
397
- synthesize: Type.Optional(Type.Boolean({
398
- description: 'When true and engine is "all", deduplicates sources across engines and feeds them to Gemini for a single grounded synthesis. Adds ~30s but saves tokens and improves answer quality.',
399
- default: false,
400
- })),
401
- fullAnswer: Type.Optional(Type.Boolean({
402
- description: 'When true, returns the complete answer instead of a truncated preview (default: false, answers are shortened to ~300 chars to save tokens).',
403
- default: false,
404
- })),
405
- }),
406
- execute: async (_toolCallId, params, signal, onUpdate) => {
407
- const { query, engine = "all", synthesize = false, fullAnswer = false } = params as {
408
- query: string; engine: string; synthesize?: boolean; fullAnswer?: boolean;
409
- };
410
-
411
- if (!cdpAvailable()) {
412
- return {
413
- content: [{ type: "text", text: "cdp.mjs missing — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi" }],
414
- details: {} as { raw?: Record<string, unknown> },
415
- };
416
- }
417
-
418
- const flags: string[] = [];
419
- if (fullAnswer) flags.push("--full");
420
- if (synthesize && engine === "all") flags.push("--synthesize");
421
-
422
- // Track progress for "all" engine mode
423
- const completed = new Set<string>();
424
-
425
- const onProgress = (eng: string, status: "done" | "error") => {
426
- completed.add(eng);
427
- const parts: string[] = [];
428
- for (const e of ALL_ENGINES) {
429
- if (completed.has(e)) parts.push(`✅ ${e} done`);
430
- else parts.push(`⏳ ${e}`);
431
- }
432
- if (synthesize && completed.size >= 3) parts.push("🔄 synthesizing");
433
-
434
- onUpdate?.({
435
- content: [{ type: "text", text: `**Searching...** ${parts.join(" · ")}` }],
436
- details: { _progress: true },
437
- } as any);
438
- };
439
-
440
- try {
441
- const data = await runSearch(engine, query, flags, signal, engine === "all" ? onProgress : undefined);
442
- const text = formatResults(engine, data);
443
- return {
444
- content: [{ type: "text", text: text || "No results returned." }],
445
- details: { raw: data },
446
- };
447
- } catch (e) {
448
- const msg = e instanceof Error ? e.message : String(e);
449
- return {
450
- content: [{ type: "text", text: `Search failed: ${msg}` }],
451
- details: {} as { raw?: Record<string, unknown> },
452
- };
453
- }
454
- },
455
- });
456
-
457
- // ─── deep_research ─────────────────────────────────────────────────────────
458
- pi.registerTool({
459
- name: "deep_research",
460
- label: "Deep Research",
461
- description:
462
- "Comprehensive multi-engine research with source fetching and synthesis. " +
463
- "Runs Perplexity, Bing Copilot, and Google AI in parallel with full answers, " +
464
- "deduplicates and ranks sources by consensus, fetches content from top sources, " +
465
- "and synthesizes via Gemini. Returns a structured research document with confidence scores. " +
466
- "Use for architecture decisions, library comparisons, best practices, or any research where the answer matters.",
467
- promptSnippet: "Deep multi-engine research with source deduplication and synthesis",
468
- parameters: Type.Object({
469
- query: Type.String({ description: "The research question" }),
470
- }),
471
- execute: async (_toolCallId, params, signal, onUpdate) => {
472
- const { query } = params as { query: string };
473
-
474
- if (!cdpAvailable()) {
475
- return {
476
- content: [{ type: "text", text: "cdp.mjs missing — try reinstalling." }],
477
- details: {} as { raw?: Record<string, unknown> },
478
- };
479
- }
480
-
481
- const completed = new Set<string>();
482
-
483
- const onProgress = (eng: string, status: "done" | "error") => {
484
- completed.add(eng);
485
- const parts: string[] = [];
486
- for (const e of ALL_ENGINES) {
487
- if (completed.has(e)) parts.push(`✅ ${e}`);
488
- else parts.push(`⏳ ${e}`);
489
- }
490
- if (completed.size >= 3) parts.push("🔄 synthesizing");
491
-
492
- onUpdate?.({
493
- content: [{ type: "text", text: `**Researching...** ${parts.join(" · ")}` }],
494
- details: { _progress: true },
495
- } as any);
496
- };
497
-
498
- try {
499
- // Run deep research (includes full answers, synthesis, and source fetching)
500
- const data = await runSearch("all", query, ["--deep-research"], signal, onProgress);
501
- const text = formatDeepResearch(data);
502
- return {
503
- content: [{ type: "text", text: text || "No results returned." }],
504
- details: { raw: data },
505
- };
506
- } catch (e) {
507
- const msg = e instanceof Error ? e.message : String(e);
508
- return {
509
- content: [{ type: "text", text: `Deep research failed: ${msg}` }],
510
- details: {} as { raw?: Record<string, unknown> },
511
- };
512
- }
513
- },
514
- });
515
-
516
- // ─── coding_task ───────────────────────────────────────────────────────────
517
- pi.registerTool({
518
- name: "coding_task",
519
- label: "Coding Task",
520
- description:
521
- "Delegate a coding task to Gemini and/or Copilot via browser automation. " +
522
- "Returns extracted code blocks and explanations. Supports multiple modes: " +
523
- "'code' (write/modify code), 'review' (senior engineer code review), " +
524
- "'plan' (architect risk assessment), 'test' (edge case testing), " +
525
- "'debug' (fresh-eyes root cause analysis). " +
526
- "Best for getting a 'second opinion' on hard problems, debugging tricky issues, " +
527
- "or risk-assessing major refactors. Use engine 'all' for both perspectives.",
528
- promptSnippet: "Browser-based coding assistant with Gemini and Copilot",
529
- parameters: Type.Object({
530
- task: Type.String({ description: "The coding task or question" }),
531
- engine: Type.Union(
532
- [
533
- Type.Literal("all"),
534
- Type.Literal("gemini"),
535
- Type.Literal("copilot"),
536
- ],
537
- {
538
- description: 'Engine to use. "all" runs both Gemini and Copilot in parallel.',
539
- default: "gemini",
540
- },
541
- ),
542
- mode: Type.Union(
543
- [
544
- Type.Literal("code"),
545
- Type.Literal("review"),
546
- Type.Literal("plan"),
547
- Type.Literal("test"),
548
- Type.Literal("debug"),
549
- ],
550
- {
551
- description: "Task mode: code (default), review (code review), plan (architect review), test (write tests), debug (root cause analysis)",
552
- default: "code",
553
- },
554
- ),
555
- context: Type.Optional(Type.String({
556
- description: "Optional code context/snippet to include with the task",
557
- })),
558
- }),
559
- execute: async (_toolCallId, params, signal, onUpdate) => {
560
- const { task, engine = "gemini", mode = "code", context } = params as {
561
- task: string; engine: string; mode: string; context?: string;
562
- };
563
-
564
- if (!cdpAvailable()) {
565
- return {
566
- content: [{ type: "text", text: "cdp.mjs missing — try reinstalling." }],
567
- details: {} as { raw?: Record<string, unknown> },
568
- };
569
- }
570
-
571
- const flags: string[] = ["--engine", engine, "--mode", mode];
572
- if (context) flags.push("--context", context);
573
-
574
- try {
575
- onUpdate?.({
576
- content: [{ type: "text", text: `**Coding task...** 🔄 ${engine === "all" ? "Gemini + Copilot" : engine} (${mode} mode)` }],
577
- details: { _progress: true },
578
- } as any);
579
-
580
- const data = await new Promise<Record<string, unknown>>((resolve, reject) => {
581
- const proc = spawn("node", [__dir + "/coding-task.mjs", task, ...flags], {
582
- stdio: ["ignore", "pipe", "pipe"],
583
- });
584
- let out = "";
585
- let err = "";
586
-
587
- const onAbort = () => { proc.kill("SIGTERM"); reject(new Error("Aborted")); };
588
- signal?.addEventListener("abort", onAbort, { once: true });
589
-
590
- proc.stdout.on("data", (d: Buffer) => (out += d));
591
- proc.stderr.on("data", (d: Buffer) => { err += d; });
592
- proc.on("close", (code: number) => {
593
- signal?.removeEventListener("abort", onAbort);
594
- if (code !== 0) {
595
- reject(new Error(err.trim() || `coding-task.mjs exited with code ${code}`));
596
- } else {
597
- try {
598
- resolve(JSON.parse(out.trim()));
599
- } catch {
600
- reject(new Error(`Invalid JSON from coding-task.mjs: ${out.slice(0, 200)}`));
601
- }
602
- }
603
- });
604
-
605
- // Timeout after 3 minutes
606
- setTimeout(() => { proc.kill("SIGTERM"); reject(new Error("Coding task timed out after 180s")); }, 180000);
607
- });
608
-
609
- const text = formatCodingTask(data);
610
- return {
611
- content: [{ type: "text", text: text || "No response." }],
612
- details: { raw: data },
613
- };
614
- } catch (e) {
615
- const msg = e instanceof Error ? e.message : String(e);
616
- return {
617
- content: [{ type: "text", text: `Coding task failed: ${msg}` }],
618
- details: {} as { raw?: Record<string, unknown> },
619
- };
620
- }
621
- },
622
- });
623
- }
1
+ /**
2
+ * GreedySearch Pi Extension
3
+ *
4
+ * Adds a `greedy_search` tool to Pi that fans out queries to Perplexity,
5
+ * Bing Copilot, and Google AI in parallel, returning synthesized AI answers.
6
+ *
7
+ * Reports streaming progress as each engine completes.
8
+ * Requires Chrome to be running (or it auto-launches a dedicated instance).
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
+ import { Type } from "@sinclair/typebox";
17
+
18
+ const __dir = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const ALL_ENGINES = ["perplexity", "bing", "google"] as const;
21
+
22
+ function cdpAvailable(): boolean {
23
+ return existsSync(join(__dir, "cdp.mjs"));
24
+ }
25
+
26
+ function runSearch(
27
+ engine: string,
28
+ query: string,
29
+ flags: string[] = [],
30
+ signal?: AbortSignal,
31
+ onProgress?: (engine: string, status: "done" | "error") => void,
32
+ ): Promise<Record<string, unknown>> {
33
+ return new Promise((resolve, reject) => {
34
+ const proc = spawn("node", [__dir + "/search.mjs", engine, "--inline", ...flags, query], {
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ });
37
+ let out = "";
38
+ let err = "";
39
+
40
+ const onAbort = () => { proc.kill("SIGTERM"); reject(new Error("Aborted")); };
41
+ signal?.addEventListener("abort", onAbort, { once: true });
42
+
43
+ // Watch stderr for progress events (PROGRESS:engine:done|error)
44
+ proc.stderr.on("data", (d: Buffer) => {
45
+ err += d;
46
+ const lines = d.toString().split("\n");
47
+ for (const line of lines) {
48
+ const match = line.match(/^PROGRESS:(\w+):(done|error)$/);
49
+ if (match && onProgress) {
50
+ onProgress(match[1], match[2] as "done" | "error");
51
+ }
52
+ }
53
+ });
54
+
55
+ proc.stdout.on("data", (d: Buffer) => (out += d));
56
+ proc.on("close", (code: number) => {
57
+ signal?.removeEventListener("abort", onAbort);
58
+ if (code !== 0) {
59
+ reject(new Error(err.trim() || `search.mjs exited with code ${code}`));
60
+ } else {
61
+ try {
62
+ resolve(JSON.parse(out.trim()));
63
+ } catch {
64
+ reject(new Error(`Invalid JSON from search.mjs: ${out.slice(0, 200)}`));
65
+ }
66
+ }
67
+ });
68
+ });
69
+ }
70
+
71
+ function formatEngineName(engine: string): string {
72
+ if (engine === "bing") return "Bing Copilot";
73
+ if (engine === "google") return "Google AI";
74
+ return engine.charAt(0).toUpperCase() + engine.slice(1);
75
+ }
76
+
77
+ function humanizeSourceType(sourceType: string): string {
78
+ if (!sourceType) return "";
79
+ if (sourceType === "official-docs") return "official docs";
80
+ return sourceType.replace(/-/g, " ");
81
+ }
82
+
83
+ function sourceUrl(source: Record<string, unknown>): string {
84
+ return String(source.displayUrl || source.canonicalUrl || source.url || "");
85
+ }
86
+
87
+ function sourceLabel(source: Record<string, unknown>): string {
88
+ return String(source.title || source.domain || sourceUrl(source) || "Untitled source");
89
+ }
90
+
91
+ function sourceConsensus(source: Record<string, unknown>): number {
92
+ if (typeof source.engineCount === "number") return source.engineCount;
93
+ const engines = Array.isArray(source.engines) ? (source.engines as string[]) : [];
94
+ return engines.length;
95
+ }
96
+
97
+ function formatAgreementLevel(level: string): string {
98
+ if (!level) return "Mixed";
99
+ return level.charAt(0).toUpperCase() + level.slice(1);
100
+ }
101
+
102
+ function getSourceMap(sources: Array<Record<string, unknown>>): Map<string, Record<string, unknown>> {
103
+ return new Map(
104
+ sources
105
+ .map((source) => [String(source.id || ""), source] as const)
106
+ .filter(([id]) => id),
107
+ );
108
+ }
109
+
110
+ function formatSourceLine(source: Record<string, unknown>): string {
111
+ const id = String(source.id || "?");
112
+ const url = sourceUrl(source);
113
+ const title = sourceLabel(source);
114
+ const domain = String(source.domain || "");
115
+ const engines = Array.isArray(source.engines) ? (source.engines as string[]) : [];
116
+ const consensus = sourceConsensus(source);
117
+ const typeLabel = humanizeSourceType(String(source.sourceType || ""));
118
+ const fetch = source.fetch as Record<string, unknown> | undefined;
119
+ const fetchStatus = fetch?.ok ? `fetched ${fetch.status || 200}` : fetch?.attempted ? "fetch failed" : "";
120
+ const pieces = [
121
+ `${id} - [${title}](${url})`,
122
+ domain,
123
+ typeLabel,
124
+ engines.length ? `cited by ${engines.map(formatEngineName).join(", ")} (${consensus}/3)` : `${consensus}/3`,
125
+ fetchStatus,
126
+ ].filter(Boolean);
127
+ return `- ${pieces.join(" - ")}`;
128
+ }
129
+
130
+ function renderSourceEvidence(lines: string[], source: Record<string, unknown>): void {
131
+ const fetch = source.fetch as Record<string, unknown> | undefined;
132
+ if (!fetch?.attempted) return;
133
+
134
+ const snippet = String(fetch.snippet || "").trim();
135
+ const lastModified = String(fetch.lastModified || "").trim();
136
+ if (snippet) lines.push(` Evidence: ${snippet}`);
137
+ if (lastModified) lines.push(` Last-Modified: ${lastModified}`);
138
+ if (fetch.error) lines.push(` Fetch error: ${String(fetch.error)}`);
139
+ }
140
+
141
+ function pickSources(
142
+ sources: Array<Record<string, unknown>>,
143
+ recommendedIds: string[] = [],
144
+ max = 6,
145
+ ): Array<Record<string, unknown>> {
146
+ if (!sources.length) return [];
147
+ const sourceMap = getSourceMap(sources);
148
+ const recommended = recommendedIds
149
+ .map((id) => sourceMap.get(id))
150
+ .filter((source): source is Record<string, unknown> => Boolean(source));
151
+ if (recommended.length > 0) return recommended.slice(0, max);
152
+ return sources.slice(0, max);
153
+ }
154
+
155
+ function renderSynthesis(
156
+ lines: string[],
157
+ synthesis: Record<string, unknown>,
158
+ sources: Array<Record<string, unknown>>,
159
+ maxSources = 6,
160
+ ): void {
161
+ if (synthesis.answer) {
162
+ lines.push("## Answer");
163
+ lines.push(String(synthesis.answer));
164
+ lines.push("");
165
+ }
166
+
167
+ const agreement = synthesis.agreement as Record<string, unknown> | undefined;
168
+ const agreementSummary = String(agreement?.summary || "").trim();
169
+ const agreementLevel = String(agreement?.level || "").trim();
170
+ if (agreementSummary || agreementLevel) {
171
+ lines.push("## Consensus");
172
+ lines.push(`- ${formatAgreementLevel(agreementLevel)}${agreementSummary ? ` - ${agreementSummary}` : ""}`);
173
+ lines.push("");
174
+ }
175
+
176
+ const differences = Array.isArray(synthesis.differences) ? (synthesis.differences as string[]) : [];
177
+ if (differences.length > 0) {
178
+ lines.push("## Where Engines Differ");
179
+ for (const difference of differences) lines.push(`- ${difference}`);
180
+ lines.push("");
181
+ }
182
+
183
+ const caveats = Array.isArray(synthesis.caveats) ? (synthesis.caveats as string[]) : [];
184
+ if (caveats.length > 0) {
185
+ lines.push("## Caveats");
186
+ for (const caveat of caveats) lines.push(`- ${caveat}`);
187
+ lines.push("");
188
+ }
189
+
190
+ const claims = Array.isArray(synthesis.claims)
191
+ ? (synthesis.claims as Array<Record<string, unknown>>)
192
+ : [];
193
+ if (claims.length > 0) {
194
+ lines.push("## Key Claims");
195
+ for (const claim of claims) {
196
+ const sourceIds = Array.isArray(claim.sourceIds) ? (claim.sourceIds as string[]) : [];
197
+ const support = String(claim.support || "moderate");
198
+ lines.push(`- ${String(claim.claim || "")} [${support}${sourceIds.length ? `; ${sourceIds.join(", ")}` : ""}]`);
199
+ }
200
+ lines.push("");
201
+ }
202
+
203
+ const recommendedIds = Array.isArray(synthesis.recommendedSources)
204
+ ? (synthesis.recommendedSources as string[])
205
+ : [];
206
+ const topSources = pickSources(sources, recommendedIds, maxSources);
207
+ if (topSources.length > 0) {
208
+ lines.push("## Top Sources");
209
+ for (const source of topSources) lines.push(formatSourceLine(source));
210
+ lines.push("");
211
+ }
212
+ }
213
+
214
+ function formatResults(engine: string, data: Record<string, unknown>): string {
215
+ const lines: string[] = [];
216
+
217
+ if (engine === "all") {
218
+ const synthesis = data._synthesis as Record<string, unknown> | undefined;
219
+ const dedupedSources = data._sources as Array<Record<string, unknown>> | undefined;
220
+ if (synthesis?.answer) {
221
+ renderSynthesis(lines, synthesis, dedupedSources || [], 6);
222
+ lines.push("*Synthesized from Perplexity, Bing Copilot, and Google AI*\n");
223
+ return lines.join("\n").trim();
224
+ }
225
+
226
+ for (const [eng, result] of Object.entries(data)) {
227
+ if (eng.startsWith("_")) continue;
228
+ lines.push(`\n## ${formatEngineName(eng)}`);
229
+ const r = result as Record<string, unknown>;
230
+ if (r.error) {
231
+ lines.push(`Error: ${r.error}`);
232
+ } else {
233
+ if (r.answer) lines.push(String(r.answer));
234
+ if (Array.isArray(r.sources) && r.sources.length > 0) {
235
+ lines.push("\nSources:");
236
+ for (const s of r.sources.slice(0, 3)) {
237
+ const src = s as Record<string, string>;
238
+ lines.push(`- [${src.title || src.url}](${src.url})`);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ } else {
244
+ if (data.error) {
245
+ lines.push(`Error: ${data.error}`);
246
+ } else {
247
+ if (data.answer) lines.push(String(data.answer));
248
+ if (Array.isArray(data.sources) && data.sources.length > 0) {
249
+ lines.push("\nSources:");
250
+ for (const s of data.sources.slice(0, 5)) {
251
+ const src = s as Record<string, string>;
252
+ lines.push(`- [${src.title || src.url}](${src.url})`);
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ return lines.join("\n").trim();
259
+ }
260
+
261
+ function formatDeepResearch(data: Record<string, unknown>): string {
262
+ const lines: string[] = [];
263
+ const confidence = data._confidence as Record<string, unknown> | undefined;
264
+ const dedupedSources = data._sources as Array<Record<string, unknown>> | undefined;
265
+ const synthesis = data._synthesis as Record<string, unknown> | undefined;
266
+
267
+ lines.push("# Deep Research Report\n");
268
+
269
+ if (confidence) {
270
+ const enginesResponded = (confidence.enginesResponded as string[]) || [];
271
+ const enginesFailed = (confidence.enginesFailed as string[]) || [];
272
+ const agreementLevel = String(confidence.agreementLevel || "mixed");
273
+ const firstPartySourceCount = Number(confidence.firstPartySourceCount || 0);
274
+ const sourceTypeBreakdown = confidence.sourceTypeBreakdown as Record<string, number> | undefined;
275
+
276
+ lines.push("## Confidence\n");
277
+ lines.push(`- Agreement: ${formatAgreementLevel(agreementLevel)}`);
278
+ lines.push(`- Engines responded: ${enginesResponded.map(formatEngineName).join(", ") || "none"}`);
279
+ if (enginesFailed.length > 0) {
280
+ lines.push(`- Engines failed: ${enginesFailed.map(formatEngineName).join(", ")}`);
281
+ }
282
+ lines.push(`- Top source consensus: ${confidence.topSourceConsensus || 0}/3 engines`);
283
+ lines.push(`- Total unique sources: ${confidence.sourcesCount || 0}`);
284
+ lines.push(`- Official sources: ${confidence.officialSourceCount || 0}`);
285
+ lines.push(`- First-party sources: ${firstPartySourceCount}`);
286
+ lines.push(`- Fetch success rate: ${confidence.fetchedSourceSuccessRate || 0}`);
287
+ if (sourceTypeBreakdown && Object.keys(sourceTypeBreakdown).length > 0) {
288
+ lines.push(`- Source mix: ${Object.entries(sourceTypeBreakdown).map(([type, count]) => `${humanizeSourceType(type)} ${count}`).join(", ")}`);
289
+ }
290
+ lines.push("");
291
+ }
292
+
293
+ if (synthesis?.answer) renderSynthesis(lines, synthesis, dedupedSources || [], 8);
294
+
295
+ lines.push("## Engine Perspectives\n");
296
+ for (const engine of ["perplexity", "bing", "google"]) {
297
+ const r = data[engine] as Record<string, unknown> | undefined;
298
+ if (!r) continue;
299
+ lines.push(`### ${formatEngineName(engine)}`);
300
+ if (r.error) {
301
+ lines.push(`⚠️ Error: ${r.error}`);
302
+ } else if (r.answer) {
303
+ lines.push(String(r.answer).slice(0, 2000));
304
+ }
305
+ lines.push("");
306
+ }
307
+
308
+ if (dedupedSources && dedupedSources.length > 0) {
309
+ lines.push("## Source Registry\n");
310
+ for (const source of dedupedSources) {
311
+ lines.push(formatSourceLine(source));
312
+ renderSourceEvidence(lines, source);
313
+ }
314
+ lines.push("");
315
+ }
316
+
317
+ return lines.join("\n").trim();
318
+ }
319
+
320
+ function formatCodingTask(data: Record<string, unknown> | Record<string, Record<string, unknown>>): string {
321
+ const lines: string[] = [];
322
+
323
+ // Check if it's multi-engine result
324
+ const hasMultipleEngines = "gemini" in data || "copilot" in data;
325
+
326
+ if (hasMultipleEngines) {
327
+ // Multi-engine result
328
+ for (const [engineName, result] of Object.entries(data)) {
329
+ const r = result as Record<string, unknown>;
330
+ lines.push(`## ${engineName.charAt(0).toUpperCase() + engineName.slice(1)}\n`);
331
+
332
+ if (r.error) {
333
+ lines.push(`⚠️ Error: ${r.error}\n`);
334
+ } else {
335
+ if (r.explanation) lines.push(String(r.explanation));
336
+ if (Array.isArray(r.code) && r.code.length > 0) {
337
+ for (const block of r.code) {
338
+ const b = block as { language: string; code: string };
339
+ lines.push(`\n\`\`\`${b.language}\n${b.code}\n\`\`\`\n`);
340
+ }
341
+ }
342
+ if (r.url) lines.push(`*Source: ${r.url}*`);
343
+ }
344
+ lines.push("");
345
+ }
346
+ } else {
347
+ // Single engine result
348
+ const r = data as Record<string, unknown>;
349
+ if (r.explanation) lines.push(String(r.explanation));
350
+ if (Array.isArray(r.code) && r.code.length > 0) {
351
+ for (const block of r.code) {
352
+ const b = block as { language: string; code: string };
353
+ lines.push(`\n\`\`\`${b.language}\n${b.code}\n\`\`\`\n`);
354
+ }
355
+ }
356
+ if (r.url) lines.push(`*Source: ${r.url}*`);
357
+ }
358
+
359
+ return lines.join("\n").trim();
360
+ }
361
+
362
+ export default function greedySearchExtension(pi: ExtensionAPI) {
363
+ pi.on("session_start", async (_event, ctx) => {
364
+ if (!cdpAvailable()) {
365
+ ctx.ui.notify(
366
+ "GreedySearch: cdp.mjs missing from package directory — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi",
367
+ "warning",
368
+ );
369
+ }
370
+ });
371
+
372
+ pi.registerTool({
373
+ name: "greedy_search",
374
+ label: "Greedy Search",
375
+ description:
376
+ "Search the web using AI-powered engines (Perplexity, Bing Copilot, Google AI) in parallel. " +
377
+ "Optionally synthesize results with Gemini — deduplicates sources by consensus and returns one grounded answer. " +
378
+ "Reports streaming progress as each engine completes. " +
379
+ "Use for current information, library docs, error messages, best practices, or any question where training data may be stale.",
380
+ promptSnippet: "Multi-engine AI web search with streaming progress",
381
+ parameters: Type.Object({
382
+ query: Type.String({ description: "The search query" }),
383
+ engine: Type.Union(
384
+ [
385
+ Type.Literal("all"),
386
+ Type.Literal("perplexity"),
387
+ Type.Literal("bing"),
388
+ Type.Literal("google"),
389
+ Type.Literal("gemini"),
390
+ Type.Literal("gem"),
391
+ ],
392
+ {
393
+ description: 'Engine to use. "all" fans out to Perplexity, Bing, and Google in parallel (default).',
394
+ default: "all",
395
+ },
396
+ ),
397
+ synthesize: Type.Optional(Type.Boolean({
398
+ description: 'When true and engine is "all", deduplicates sources across engines and feeds them to Gemini for a single grounded synthesis. Adds ~30s but saves tokens and improves answer quality.',
399
+ default: false,
400
+ })),
401
+ fullAnswer: Type.Optional(Type.Boolean({
402
+ description: 'When true, returns the complete answer instead of a truncated preview (default: false, answers are shortened to ~300 chars to save tokens).',
403
+ default: false,
404
+ })),
405
+ }),
406
+ execute: async (_toolCallId, params, signal, onUpdate) => {
407
+ const { query, engine = "all", synthesize = false, fullAnswer = false } = params as {
408
+ query: string; engine: string; synthesize?: boolean; fullAnswer?: boolean;
409
+ };
410
+
411
+ if (!cdpAvailable()) {
412
+ return {
413
+ content: [{ type: "text", text: "cdp.mjs missing — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi" }],
414
+ details: {} as { raw?: Record<string, unknown> },
415
+ };
416
+ }
417
+
418
+ const flags: string[] = [];
419
+ if (fullAnswer) flags.push("--full");
420
+ if (synthesize && engine === "all") flags.push("--synthesize");
421
+
422
+ // Track progress for "all" engine mode
423
+ const completed = new Set<string>();
424
+
425
+ const onProgress = (eng: string, status: "done" | "error") => {
426
+ completed.add(eng);
427
+ const parts: string[] = [];
428
+ for (const e of ALL_ENGINES) {
429
+ if (completed.has(e)) parts.push(`✅ ${e} done`);
430
+ else parts.push(`⏳ ${e}`);
431
+ }
432
+ if (synthesize && completed.size >= 3) parts.push("🔄 synthesizing");
433
+
434
+ onUpdate?.({
435
+ content: [{ type: "text", text: `**Searching...** ${parts.join(" · ")}` }],
436
+ details: { _progress: true },
437
+ } as any);
438
+ };
439
+
440
+ try {
441
+ const data = await runSearch(engine, query, flags, signal, engine === "all" ? onProgress : undefined);
442
+ const text = formatResults(engine, data);
443
+ return {
444
+ content: [{ type: "text", text: text || "No results returned." }],
445
+ details: { raw: data },
446
+ };
447
+ } catch (e) {
448
+ const msg = e instanceof Error ? e.message : String(e);
449
+ return {
450
+ content: [{ type: "text", text: `Search failed: ${msg}` }],
451
+ details: {} as { raw?: Record<string, unknown> },
452
+ };
453
+ }
454
+ },
455
+ });
456
+
457
+ // ─── deep_research ─────────────────────────────────────────────────────────
458
+ pi.registerTool({
459
+ name: "deep_research",
460
+ label: "Deep Research",
461
+ description:
462
+ "Comprehensive multi-engine research with source fetching and synthesis. " +
463
+ "Runs Perplexity, Bing Copilot, and Google AI in parallel with full answers, " +
464
+ "deduplicates and ranks sources by consensus, fetches content from top sources, " +
465
+ "and synthesizes via Gemini. Returns a structured research document with confidence scores. " +
466
+ "Use for architecture decisions, library comparisons, best practices, or any research where the answer matters.",
467
+ promptSnippet: "Deep multi-engine research with source deduplication and synthesis",
468
+ parameters: Type.Object({
469
+ query: Type.String({ description: "The research question" }),
470
+ }),
471
+ execute: async (_toolCallId, params, signal, onUpdate) => {
472
+ const { query } = params as { query: string };
473
+
474
+ if (!cdpAvailable()) {
475
+ return {
476
+ content: [{ type: "text", text: "cdp.mjs missing — try reinstalling." }],
477
+ details: {} as { raw?: Record<string, unknown> },
478
+ };
479
+ }
480
+
481
+ const completed = new Set<string>();
482
+
483
+ const onProgress = (eng: string, status: "done" | "error") => {
484
+ completed.add(eng);
485
+ const parts: string[] = [];
486
+ for (const e of ALL_ENGINES) {
487
+ if (completed.has(e)) parts.push(`✅ ${e}`);
488
+ else parts.push(`⏳ ${e}`);
489
+ }
490
+ if (completed.size >= 3) parts.push("🔄 synthesizing");
491
+
492
+ onUpdate?.({
493
+ content: [{ type: "text", text: `**Researching...** ${parts.join(" · ")}` }],
494
+ details: { _progress: true },
495
+ } as any);
496
+ };
497
+
498
+ try {
499
+ // Run deep research (includes full answers, synthesis, and source fetching)
500
+ const data = await runSearch("all", query, ["--deep-research"], signal, onProgress);
501
+ const text = formatDeepResearch(data);
502
+ return {
503
+ content: [{ type: "text", text: text || "No results returned." }],
504
+ details: { raw: data },
505
+ };
506
+ } catch (e) {
507
+ const msg = e instanceof Error ? e.message : String(e);
508
+ return {
509
+ content: [{ type: "text", text: `Deep research failed: ${msg}` }],
510
+ details: {} as { raw?: Record<string, unknown> },
511
+ };
512
+ }
513
+ },
514
+ });
515
+
516
+ // ─── coding_task ───────────────────────────────────────────────────────────
517
+ pi.registerTool({
518
+ name: "coding_task",
519
+ label: "Coding Task",
520
+ description:
521
+ "Delegate a coding task to Gemini and/or Copilot via browser automation. " +
522
+ "Returns extracted code blocks and explanations. Supports multiple modes: " +
523
+ "'code' (write/modify code), 'review' (senior engineer code review), " +
524
+ "'plan' (architect risk assessment), 'test' (edge case testing), " +
525
+ "'debug' (fresh-eyes root cause analysis). " +
526
+ "Best for getting a 'second opinion' on hard problems, debugging tricky issues, " +
527
+ "or risk-assessing major refactors. Use engine 'all' for both perspectives.",
528
+ promptSnippet: "Browser-based coding assistant with Gemini and Copilot",
529
+ parameters: Type.Object({
530
+ task: Type.String({ description: "The coding task or question" }),
531
+ engine: Type.Union(
532
+ [
533
+ Type.Literal("all"),
534
+ Type.Literal("gemini"),
535
+ Type.Literal("copilot"),
536
+ ],
537
+ {
538
+ description: 'Engine to use. "all" runs both Gemini and Copilot in parallel.',
539
+ default: "gemini",
540
+ },
541
+ ),
542
+ mode: Type.Union(
543
+ [
544
+ Type.Literal("code"),
545
+ Type.Literal("review"),
546
+ Type.Literal("plan"),
547
+ Type.Literal("test"),
548
+ Type.Literal("debug"),
549
+ ],
550
+ {
551
+ description: "Task mode: code (default), review (code review), plan (architect review), test (write tests), debug (root cause analysis)",
552
+ default: "code",
553
+ },
554
+ ),
555
+ context: Type.Optional(Type.String({
556
+ description: "Optional code context/snippet to include with the task",
557
+ })),
558
+ }),
559
+ execute: async (_toolCallId, params, signal, onUpdate) => {
560
+ const { task, engine = "gemini", mode = "code", context } = params as {
561
+ task: string; engine: string; mode: string; context?: string;
562
+ };
563
+
564
+ if (!cdpAvailable()) {
565
+ return {
566
+ content: [{ type: "text", text: "cdp.mjs missing — try reinstalling." }],
567
+ details: {} as { raw?: Record<string, unknown> },
568
+ };
569
+ }
570
+
571
+ const flags: string[] = ["--engine", engine, "--mode", mode];
572
+ if (context) flags.push("--context", context);
573
+
574
+ try {
575
+ onUpdate?.({
576
+ content: [{ type: "text", text: `**Coding task...** 🔄 ${engine === "all" ? "Gemini + Copilot" : engine} (${mode} mode)` }],
577
+ details: { _progress: true },
578
+ } as any);
579
+
580
+ const data = await new Promise<Record<string, unknown>>((resolve, reject) => {
581
+ const proc = spawn("node", [__dir + "/coding-task.mjs", task, ...flags], {
582
+ stdio: ["ignore", "pipe", "pipe"],
583
+ });
584
+ let out = "";
585
+ let err = "";
586
+
587
+ const onAbort = () => { proc.kill("SIGTERM"); reject(new Error("Aborted")); };
588
+ signal?.addEventListener("abort", onAbort, { once: true });
589
+
590
+ proc.stdout.on("data", (d: Buffer) => (out += d));
591
+ proc.stderr.on("data", (d: Buffer) => { err += d; });
592
+ proc.on("close", (code: number) => {
593
+ signal?.removeEventListener("abort", onAbort);
594
+ if (code !== 0) {
595
+ reject(new Error(err.trim() || `coding-task.mjs exited with code ${code}`));
596
+ } else {
597
+ try {
598
+ resolve(JSON.parse(out.trim()));
599
+ } catch {
600
+ reject(new Error(`Invalid JSON from coding-task.mjs: ${out.slice(0, 200)}`));
601
+ }
602
+ }
603
+ });
604
+
605
+ // Timeout after 3 minutes
606
+ setTimeout(() => { proc.kill("SIGTERM"); reject(new Error("Coding task timed out after 180s")); }, 180000);
607
+ });
608
+
609
+ const text = formatCodingTask(data);
610
+ return {
611
+ content: [{ type: "text", text: text || "No response." }],
612
+ details: { raw: data },
613
+ };
614
+ } catch (e) {
615
+ const msg = e instanceof Error ? e.message : String(e);
616
+ return {
617
+ content: [{ type: "text", text: `Coding task failed: ${msg}` }],
618
+ details: {} as { raw?: Record<string, unknown> },
619
+ };
620
+ }
621
+ },
622
+ });
623
+ }