@apmantza/greedysearch-pi 1.6.6 → 1.7.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.
@@ -1,1242 +1,1550 @@
1
- #!/usr/bin/env node
2
- // search.mjs — unified CLI for GreedySearch extractors
3
- //
4
- // Usage:
5
- // node search.mjs <engine> "<query>"
6
- // node search.mjs all "<query>"
7
- //
8
- // Engines:
9
- // perplexity | pplx | p
10
- // bing | copilot | b
11
- // google | g
12
- // gemini | gem
13
- // all — fan-out to all engines in parallel
14
- //
15
- // Output: JSON to stdout, errors to stderr
16
- //
17
- // Examples:
18
- // node search.mjs p "what is memoization"
19
- // node search.mjs gem "latest React features"
20
- // node search.mjs all "how does TCP congestion control work"
21
-
22
- import { spawn } from "node:child_process";
23
- import {
24
- existsSync,
25
- mkdirSync,
26
- readFileSync,
27
- renameSync,
28
- unlinkSync,
29
- writeFileSync,
30
- } from "node:fs";
31
- import http from "node:http";
32
- import { tmpdir } from "node:os";
33
- import { dirname, join } from "node:path";
34
- import { fileURLToPath } from "node:url";
35
-
36
- const __dir = dirname(fileURLToPath(import.meta.url));
37
- const CDP = join(__dir, "cdp.mjs");
38
- const PAGES_CACHE = `${tmpdir().replace(/\\/g, "/")}/cdp-pages.json`;
39
-
1
+ #!/usr/bin/env node
2
+ // search.mjs — unified CLI for GreedySearch extractors
3
+ //
4
+ // Usage:
5
+ // node search.mjs <engine> "<query>"
6
+ // node search.mjs all "<query>"
7
+ //
8
+ // Engines:
9
+ // perplexity | pplx | p
10
+ // bing | copilot | b
11
+ // google | g
12
+ // gemini | gem
13
+ // all — fan-out to all engines in parallel
14
+ //
15
+ // Output: JSON to stdout, errors to stderr
16
+ //
17
+ // Examples:
18
+ // node search.mjs p "what is memoization"
19
+ // node search.mjs gem "latest React features"
20
+ // node search.mjs all "how does TCP congestion control work"
21
+
22
+ import { spawn } from "node:child_process";
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ renameSync,
28
+ unlinkSync,
29
+ writeFileSync,
30
+ } from "node:fs";
31
+ import http from "node:http";
32
+ import { tmpdir } from "node:os";
33
+ import { dirname, join } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import { fetchSourceHttp, shouldUseBrowser } from "../src/fetcher.mjs";
36
+ import { fetchGitHubContent, parseGitHubUrl } from "../src/github.mjs";
37
+ import { trimContentHeadTail } from "../src/utils/content.mjs";
38
+
39
+ const __dir = dirname(fileURLToPath(import.meta.url));
40
+ const CDP = join(__dir, "cdp.mjs");
41
+ const PAGES_CACHE = `${tmpdir().replace(/\\/g, "/")}/cdp-pages.json`;
42
+
40
43
  const GREEDY_PORT = 9222;
41
-
42
- const ENGINES = {
43
- perplexity: "perplexity.mjs",
44
- pplx: "perplexity.mjs",
45
- p: "perplexity.mjs",
46
- bing: "bing-copilot.mjs",
47
- copilot: "bing-copilot.mjs",
48
- b: "bing-copilot.mjs",
49
- google: "google-ai.mjs",
50
- g: "google-ai.mjs",
51
- gemini: "gemini.mjs",
52
- gem: "gemini.mjs",
53
- };
54
-
55
- const ALL_ENGINES = ["perplexity", "bing", "google"];
56
-
57
- const ENGINE_DOMAINS = {
58
- perplexity: "perplexity.ai",
59
- bing: "copilot.microsoft.com",
60
- google: "google.com",
61
- gemini: "gemini.google.com",
62
- };
63
-
64
- const TRACKING_PARAMS = [
65
- "fbclid",
66
- "gclid",
67
- "ref",
68
- "ref_src",
69
- "ref_url",
70
- "source",
71
- "utm_campaign",
72
- "utm_content",
73
- "utm_medium",
74
- "utm_source",
75
- "utm_term",
76
- ];
77
-
78
- const COMMUNITY_HOSTS = [
79
- "dev.to",
80
- "hashnode.com",
81
- "medium.com",
82
- "reddit.com",
83
- "stackoverflow.com",
84
- "stackexchange.com",
85
- "substack.com",
86
- ];
87
-
88
- const NEWS_HOSTS = [
89
- "arstechnica.com",
90
- "techcrunch.com",
91
- "theverge.com",
92
- "venturebeat.com",
93
- "wired.com",
94
- "zdnet.com",
95
- ];
96
-
97
- function trimText(text = "", maxChars = 240) {
98
- const clean = String(text).replace(/\s+/g, " ").trim();
99
- if (clean.length <= maxChars) return clean;
100
- return `${clean.slice(0, maxChars).replace(/\s+\S*$/, "")}...`;
101
- }
102
-
103
- function normalizeSourceTitle(title = "") {
104
- const clean = trimText(title, 180);
105
- if (!clean) return "";
106
- if (/^https?:\/\//i.test(clean)) return "";
107
-
108
- const wordCount = clean.split(/\s+/).filter(Boolean).length;
109
- const hasUppercase = /[A-Z]/.test(clean);
110
- const hasDigit = /\d/.test(clean);
111
- const looksLikeFragment =
112
- clean === clean.toLowerCase() &&
113
- wordCount <= 4 &&
114
- !hasUppercase &&
115
- !hasDigit;
116
- return looksLikeFragment ? "" : clean;
117
- }
118
-
119
- function pickPreferredTitle(currentTitle = "", nextTitle = "") {
120
- const current = normalizeSourceTitle(currentTitle);
121
- const next = normalizeSourceTitle(nextTitle);
122
- if (!next) return current;
123
- if (!current) return next;
124
- const currentLooksLikeUrl = /^https?:\/\//i.test(current);
125
- const nextLooksLikeUrl = /^https?:\/\//i.test(next);
126
- if (currentLooksLikeUrl && !nextLooksLikeUrl) return next;
127
- if (!currentLooksLikeUrl && nextLooksLikeUrl) return current;
128
- return next.length > current.length ? next : current;
129
- }
130
-
131
- function normalizeUrl(rawUrl) {
132
- if (!rawUrl) return null;
133
- try {
134
- const url = new URL(rawUrl);
135
- if (!["http:", "https:"].includes(url.protocol)) return null;
136
- url.hash = "";
137
- url.hostname = url.hostname.toLowerCase();
138
- if (
139
- (url.protocol === "https:" && url.port === "443") ||
140
- (url.protocol === "http:" && url.port === "80")
141
- ) {
142
- url.port = "";
143
- }
144
- for (const key of [...url.searchParams.keys()]) {
145
- const lower = key.toLowerCase();
146
- if (TRACKING_PARAMS.includes(lower) || lower.startsWith("utm_")) {
147
- url.searchParams.delete(key);
148
- }
149
- }
150
- url.searchParams.sort();
151
- const normalizedPath = url.pathname.replace(/\/+$/, "") || "/";
152
- url.pathname = normalizedPath;
153
- const normalized = url.toString();
154
- return normalizedPath === "/" ? normalized.replace(/\/$/, "") : normalized;
155
- } catch {
156
- return null;
157
- }
158
- }
159
-
160
- function getDomain(rawUrl) {
161
- try {
162
- const domain = new URL(rawUrl).hostname.toLowerCase();
163
- return domain.replace(/^www\./, "");
164
- } catch {
165
- return "";
166
- }
167
- }
168
-
169
- function matchesDomain(domain, hosts) {
170
- return hosts.some((host) => domain === host || domain.endsWith(`.${host}`));
171
- }
172
-
173
- function classifySourceType(domain, title = "", rawUrl = "") {
174
- const lowerTitle = title.toLowerCase();
175
- const lowerUrl = rawUrl.toLowerCase();
176
-
177
- if (domain === "github.com" || domain === "gitlab.com") return "repo";
178
- if (matchesDomain(domain, COMMUNITY_HOSTS)) return "community";
179
- if (matchesDomain(domain, NEWS_HOSTS)) return "news";
180
- if (
181
- domain.startsWith("docs.") ||
182
- domain.startsWith("developer.") ||
183
- domain.startsWith("developers.") ||
184
- domain.startsWith("api.") ||
185
- lowerTitle.includes("documentation") ||
186
- lowerTitle.includes("docs") ||
187
- lowerTitle.includes("reference") ||
188
- lowerUrl.includes("/docs/") ||
189
- lowerUrl.includes("/reference/") ||
190
- lowerUrl.includes("/api/")
191
- ) {
192
- return "official-docs";
193
- }
194
- if (domain.startsWith("blog.") || lowerUrl.includes("/blog/"))
195
- return "maintainer-blog";
196
- return "website";
197
- }
198
-
199
- function sourceTypePriority(sourceType) {
200
- switch (sourceType) {
201
- case "official-docs":
202
- return 5;
203
- case "repo":
204
- return 4;
205
- case "maintainer-blog":
206
- return 3;
207
- case "website":
208
- return 2;
209
- case "community":
210
- return 1;
211
- case "news":
212
- return 0;
213
- default:
214
- return 0;
215
- }
216
- }
217
-
218
- function bestRank(source) {
219
- const ranks = Object.values(source.perEngine || {}).map((v) => v?.rank || 99);
220
- return ranks.length ? Math.min(...ranks) : 99;
221
- }
222
-
223
- function buildSourceRegistry(out) {
224
- const seen = new Map();
225
- const engineOrder = ["perplexity", "bing", "google"];
226
-
227
- for (const engine of engineOrder) {
228
- const result = out[engine];
229
- if (!result?.sources) continue;
230
-
231
- for (let i = 0; i < result.sources.length; i++) {
232
- const source = result.sources[i];
233
- const canonicalUrl = normalizeUrl(source.url);
234
- if (!canonicalUrl || canonicalUrl.length < 10) continue;
235
-
236
- const title = normalizeSourceTitle(source.title || "");
237
- const domain = getDomain(canonicalUrl);
238
- const sourceType = classifySourceType(domain, title, canonicalUrl);
239
- const existing = seen.get(canonicalUrl) || {
240
- id: "",
241
- canonicalUrl,
242
- displayUrl: source.url || canonicalUrl,
243
- domain,
244
- title: "",
245
- engines: [],
246
- engineCount: 0,
247
- perEngine: {},
248
- sourceType,
249
- isOfficial: sourceType === "official-docs",
250
- };
251
-
252
- existing.title = pickPreferredTitle(existing.title, title);
253
- existing.displayUrl = existing.displayUrl || source.url || canonicalUrl;
254
- existing.sourceType = existing.sourceType || sourceType;
255
- existing.isOfficial =
256
- existing.isOfficial || sourceType === "official-docs";
257
-
258
- if (!existing.engines.includes(engine)) {
259
- existing.engines.push(engine);
260
- }
261
- existing.perEngine[engine] = {
262
- rank: i + 1,
263
- title: pickPreferredTitle(
264
- existing.perEngine[engine]?.title || "",
265
- title,
266
- ),
267
- };
268
-
269
- seen.set(canonicalUrl, existing);
270
- }
271
- }
272
-
273
- const sources = Array.from(seen.values())
274
- .map((source) => ({
275
- ...source,
276
- engineCount: source.engines.length,
277
- }))
278
- .sort((a, b) => {
279
- if (b.engineCount !== a.engineCount) return b.engineCount - a.engineCount;
280
- if (
281
- sourceTypePriority(b.sourceType) !== sourceTypePriority(a.sourceType)
282
- ) {
283
- return (
284
- sourceTypePriority(b.sourceType) - sourceTypePriority(a.sourceType)
285
- );
286
- }
287
- if (bestRank(a) !== bestRank(b)) return bestRank(a) - bestRank(b);
288
- return a.domain.localeCompare(b.domain);
289
- })
290
- .slice(0, 12)
291
- .map((source, index) => ({
292
- ...source,
293
- id: `S${index + 1}`,
294
- title: source.title || source.domain || source.canonicalUrl,
295
- }));
296
-
297
- return sources;
298
- }
299
-
300
- function mergeFetchDataIntoSources(sources, fetchedSources) {
301
- const byId = new Map(fetchedSources.map((source) => [source.id, source]));
302
- return sources.map((source) => {
303
- const fetched = byId.get(source.id);
304
- if (!fetched) return source;
305
-
306
- const title = pickPreferredTitle(source.title, fetched.title || "");
307
- return {
308
- ...source,
309
- title: title || source.title,
310
- fetch: {
311
- attempted: true,
312
- ok: !fetched.error,
313
- status: fetched.status || null,
314
- finalUrl: fetched.finalUrl || fetched.url || source.canonicalUrl,
315
- contentType: fetched.contentType || "",
316
- lastModified: fetched.lastModified || "",
317
- title: fetched.title || "",
318
- snippet: fetched.snippet || "",
319
- contentChars: fetched.contentChars || 0,
320
- error: fetched.error || "",
321
- },
322
- };
323
- });
324
- }
325
-
326
- function parseStructuredJson(text) {
327
- if (!text) return null;
328
- const trimmed = String(text).trim();
329
- const candidates = [
330
- trimmed,
331
- trimmed
332
- .replace(/^```json\s*/i, "")
333
- .replace(/^```\s*/i, "")
334
- .replace(/```$/i, "")
335
- .trim(),
336
- ];
337
-
338
- const objectMatch = trimmed.match(/\{[\s\S]*\}/);
339
- if (objectMatch) candidates.push(objectMatch[0]);
340
-
341
- for (const candidate of candidates) {
342
- try {
343
- return JSON.parse(candidate);
344
- } catch {
345
- // try next candidate
346
- }
347
- }
348
- return null;
349
- }
350
-
351
- function normalizeSynthesisPayload(payload, sources, fallbackAnswer = "") {
352
- const sourceIds = new Set(sources.map((source) => source.id));
353
- const agreementLevel = [
354
- "high",
355
- "medium",
356
- "low",
357
- "mixed",
358
- "conflicting",
359
- ].includes(payload?.agreement?.level)
360
- ? payload.agreement.level
361
- : "mixed";
362
- const claims = Array.isArray(payload?.claims)
363
- ? payload.claims
364
- .map((claim) => ({
365
- claim: trimText(claim?.claim || "", 260),
366
- support: ["strong", "moderate", "weak", "conflicting"].includes(
367
- claim?.support,
368
- )
369
- ? claim.support
370
- : "moderate",
371
- sourceIds: Array.isArray(claim?.sourceIds)
372
- ? claim.sourceIds.filter((id) => sourceIds.has(id))
373
- : [],
374
- }))
375
- .filter((claim) => claim.claim)
376
- : [];
377
- const recommendedSources = Array.isArray(payload?.recommendedSources)
378
- ? payload.recommendedSources.filter((id) => sourceIds.has(id)).slice(0, 6)
379
- : [];
380
-
381
- return {
382
- answer: trimText(payload?.answer || fallbackAnswer, 4000),
383
- agreement: {
384
- level: agreementLevel,
385
- summary: trimText(payload?.agreement?.summary || "", 280),
386
- },
387
- differences: Array.isArray(payload?.differences)
388
- ? payload.differences
389
- .map((item) => trimText(item, 220))
390
- .filter(Boolean)
391
- .slice(0, 5)
392
- : [],
393
- caveats: Array.isArray(payload?.caveats)
394
- ? payload.caveats
395
- .map((item) => trimText(item, 220))
396
- .filter(Boolean)
397
- .slice(0, 5)
398
- : [],
399
- claims,
400
- recommendedSources,
401
- };
402
- }
403
-
404
- function buildSynthesisPrompt(
405
- query,
406
- results,
407
- sources,
408
- { grounded = false } = {},
409
- ) {
410
- const engineSummaries = {};
411
- for (const engine of ["perplexity", "bing", "google"]) {
412
- const result = results[engine];
413
- if (!result) continue;
414
- if (result.error) {
415
- engineSummaries[engine] = {
416
- status: "error",
417
- error: String(result.error),
418
- };
419
- continue;
420
- }
421
-
422
- engineSummaries[engine] = {
423
- status: "ok",
424
- answer: trimText(result.answer || "", grounded ? 4500 : 2200),
425
- sourceIds: sources
426
- .filter((source) => source.engines.includes(engine))
427
- .sort(
428
- (a, b) =>
429
- (a.perEngine[engine]?.rank || 99) -
430
- (b.perEngine[engine]?.rank || 99),
431
- )
432
- .map((source) => source.id)
433
- .slice(0, 6),
434
- };
435
- }
436
-
437
- const sourceRegistry = sources.slice(0, grounded ? 10 : 8).map((source) => ({
438
- id: source.id,
439
- title: source.title,
440
- domain: source.domain,
441
- canonicalUrl: source.canonicalUrl,
442
- sourceType: source.sourceType,
443
- isOfficial: source.isOfficial,
444
- engines: source.engines,
445
- engineCount: source.engineCount,
446
- perEngine: source.perEngine,
447
- fetch:
448
- grounded && source.fetch?.attempted
449
- ? {
450
- ok: source.fetch.ok,
451
- status: source.fetch.status,
452
- lastModified: source.fetch.lastModified,
453
- snippet: trimText(source.fetch.snippet || "", 700),
454
- }
455
- : undefined,
456
- }));
457
-
458
- return [
459
- "You are synthesizing results from Perplexity, Bing Copilot, and Google AI.",
460
- grounded
461
- ? "Use the fetched source snippets as the strongest evidence. Use engine answers for perspective and conflict detection."
462
- : "Use the engine answers for perspective. Use the source registry for provenance and citations.",
463
- "Prefer official docs, release notes, repositories, and maintainer-authored sources when available.",
464
- "If the engines disagree, say so explicitly.",
465
- "Do not invent sources. Only reference source IDs from the source registry.",
466
- "Return valid JSON only. No markdown fences, no prose outside the JSON object.",
467
- "",
468
- "JSON schema:",
469
- "{",
470
- ' "answer": "short direct answer",',
471
- ' "agreement": { "level": "high|medium|low|mixed|conflicting", "summary": "..." },',
472
- ' "differences": ["..."],',
473
- ' "caveats": ["..."],',
474
- ' "claims": [',
475
- ' { "claim": "...", "support": "strong|moderate|weak|conflicting", "sourceIds": ["S1"] }',
476
- " ],",
477
- ' "recommendedSources": ["S1", "S2"]',
478
- "}",
479
- "",
480
- `User query: ${query}`,
481
- "",
482
- `Engine results:\n${JSON.stringify(engineSummaries, null, 2)}`,
483
- "",
484
- `Source registry:\n${JSON.stringify(sourceRegistry, null, 2)}`,
485
- ].join("\n");
486
- }
487
-
488
- function buildConfidence(out) {
489
- const sources = Array.isArray(out._sources) ? out._sources : [];
490
- const topConsensus = sources.length > 0 ? sources[0]?.engineCount || 0 : 0;
491
- const officialSourceCount = sources.filter(
492
- (source) => source.isOfficial,
493
- ).length;
494
- const firstPartySourceCount = sources.filter(
495
- (source) => source.isOfficial || source.sourceType === "maintainer-blog",
496
- ).length;
497
- const fetchedAttempted = sources.filter(
498
- (source) => source.fetch?.attempted,
499
- ).length;
500
- const fetchedSucceeded = sources.filter((source) => source.fetch?.ok).length;
501
- const sourceTypeBreakdown = sources.reduce((acc, source) => {
502
- acc[source.sourceType] = (acc[source.sourceType] || 0) + 1;
503
- return acc;
504
- }, {});
505
- const synthesisLevel = out._synthesis?.agreement?.level;
506
-
507
- return {
508
- sourcesCount: sources.length,
509
- topSourceConsensus: topConsensus,
510
- agreementLevel:
511
- synthesisLevel ||
512
- (topConsensus >= 3 ? "high" : topConsensus >= 2 ? "medium" : "low"),
513
- enginesResponded: ALL_ENGINES.filter(
514
- (engine) => out[engine]?.answer && !out[engine]?.error,
515
- ),
516
- enginesFailed: ALL_ENGINES.filter((engine) => out[engine]?.error),
517
- officialSourceCount,
518
- firstPartySourceCount,
519
- fetchedSourceSuccessRate:
520
- fetchedAttempted > 0
521
- ? Number((fetchedSucceeded / fetchedAttempted).toFixed(2))
522
- : 0,
523
- sourceTypeBreakdown,
524
- };
525
- }
526
-
527
- function getFullTabFromCache(engine) {
528
- try {
529
- if (!existsSync(PAGES_CACHE)) return null;
530
- const pages = JSON.parse(readFileSync(PAGES_CACHE, "utf8"));
531
- const found = pages.find((p) => p.url.includes(ENGINE_DOMAINS[engine]));
532
- return found ? found.targetId : null;
533
- } catch {
534
- return null;
535
- }
536
- }
537
-
538
- function cdp(args, timeoutMs = 15000) {
539
- return new Promise((resolve, reject) => {
540
- const proc = spawn("node", [CDP, ...args], {
541
- stdio: ["ignore", "pipe", "pipe"],
542
- });
543
- let out = "",
544
- err = "";
545
- proc.stdout.on("data", (d) => (out += d));
546
- proc.stderr.on("data", (d) => (err += d));
547
- const t = setTimeout(() => {
548
- proc.kill();
549
- reject(new Error(`cdp timeout: ${args[0]}`));
550
- }, timeoutMs);
551
- proc.on("close", (code) => {
552
- clearTimeout(t);
553
- if (code !== 0) reject(new Error(err.trim() || `cdp exit ${code}`));
554
- else resolve(out.trim());
555
- });
556
- });
557
- }
558
-
559
- async function getAnyTab() {
560
- const list = await cdp(["list"]);
561
- const first = list.split("\n")[0];
562
- if (!first) throw new Error("No Chrome tabs found");
563
- return first.slice(0, 8);
564
- }
565
-
566
- async function _getOrReuseBlankTab() {
567
- // Reuse an existing about:blank tab rather than always creating a new one
568
- const listOut = await cdp(["list"]);
569
- const lines = listOut.split("\n").filter(Boolean);
570
- for (const line of lines) {
571
- if (line.includes("about:blank")) {
572
- return line.slice(0, 8); // prefix of the blank tab's targetId
573
- }
574
- }
575
- // No blank tab — open a new one
576
- const anchor = await getAnyTab();
577
- const raw = await cdp([
578
- "evalraw",
579
- anchor,
580
- "Target.createTarget",
581
- '{"url":"about:blank"}',
582
- ]);
583
- const { targetId } = JSON.parse(raw);
584
- return targetId;
585
- }
586
-
587
- async function openNewTab() {
588
- const anchor = await getAnyTab();
589
- const raw = await cdp([
590
- "evalraw",
591
- anchor,
592
- "Target.createTarget",
593
- '{"url":"about:blank"}',
594
- ]);
595
- const { targetId } = JSON.parse(raw);
596
- return targetId;
597
- }
598
-
599
- async function _getOrOpenEngineTab(engine) {
600
- await cdp(["list"]);
601
- return getFullTabFromCache(engine) || openNewTab();
602
- }
603
-
604
- async function activateTab(targetId) {
605
- try {
606
- const anchor = await getAnyTab();
607
- await cdp([
608
- "evalraw",
609
- anchor,
610
- "Target.activateTarget",
611
- JSON.stringify({ targetId }),
612
- ]);
613
- } catch {
614
- // best-effort
615
- }
616
- }
617
-
618
- async function closeTabs(targetIds = []) {
619
- for (const targetId of targetIds) {
620
- if (!targetId) continue;
621
- await closeTab(targetId);
622
- }
623
- if (targetIds.length > 0) {
624
- await new Promise((r) => setTimeout(r, 300));
625
- await cdp(["list"]).catch(() => null);
626
- }
627
- }
628
-
629
- async function closeTab(targetId) {
630
- try {
631
- const anchor = await getAnyTab();
632
- await cdp([
633
- "evalraw",
634
- anchor,
635
- "Target.closeTarget",
636
- JSON.stringify({ targetId }),
637
- ]);
638
- } catch {
639
- /* best-effort */
640
- }
641
- }
642
-
643
- function runExtractor(
644
- script,
645
- query,
646
- tabPrefix = null,
647
- short = false,
648
- timeoutMs = null, // null = auto-select based on engine
649
- ) {
650
- // Gemini is slower - use longer timeout
651
- if (timeoutMs === null) {
652
- timeoutMs = script.includes("gemini") ? 180000 : 90000;
653
- }
654
- const extraArgs = [
655
- ...(tabPrefix ? ["--tab", tabPrefix] : []),
656
- ...(short ? ["--short"] : []),
657
- ];
44
+ const SOURCE_FETCH_CONCURRENCY = Math.max(
45
+ 1,
46
+ parseInt(process.env.GREEDY_FETCH_CONCURRENCY || "2", 10) || 2,
47
+ );
48
+
49
+ const ENGINES = {
50
+ perplexity: "perplexity.mjs",
51
+ pplx: "perplexity.mjs",
52
+ p: "perplexity.mjs",
53
+ bing: "bing-copilot.mjs",
54
+ copilot: "bing-copilot.mjs",
55
+ b: "bing-copilot.mjs",
56
+ google: "google-ai.mjs",
57
+ g: "google-ai.mjs",
58
+ gemini: "gemini.mjs",
59
+ gem: "gemini.mjs",
60
+ };
61
+
62
+ const ALL_ENGINES = ["perplexity", "bing", "google"];
63
+
64
+ const ENGINE_DOMAINS = {
65
+ perplexity: "perplexity.ai",
66
+ bing: "copilot.microsoft.com",
67
+ google: "google.com",
68
+ gemini: "gemini.google.com",
69
+ };
70
+
71
+ const TRACKING_PARAMS = [
72
+ "fbclid",
73
+ "gclid",
74
+ "ref",
75
+ "ref_src",
76
+ "ref_url",
77
+ "source",
78
+ "utm_campaign",
79
+ "utm_content",
80
+ "utm_medium",
81
+ "utm_source",
82
+ "utm_term",
83
+ ];
84
+
85
+ const COMMUNITY_HOSTS = [
86
+ "dev.to",
87
+ "hashnode.com",
88
+ "medium.com",
89
+ "reddit.com",
90
+ "stackoverflow.com",
91
+ "stackexchange.com",
92
+ "substack.com",
93
+ ];
94
+
95
+ const NEWS_HOSTS = [
96
+ "arstechnica.com",
97
+ "techcrunch.com",
98
+ "theverge.com",
99
+ "venturebeat.com",
100
+ "wired.com",
101
+ "zdnet.com",
102
+ ];
103
+
104
+ /**
105
+ * Infer preferred domains based on query keywords
106
+ * Returns domains that should be boosted for this query
107
+ */
108
+ function inferPreferredDomains(query) {
109
+ const normalized = query.toLowerCase();
110
+ const matches = [];
111
+
112
+ if (
113
+ normalized.includes("openai") ||
114
+ normalized.includes("gpt") ||
115
+ normalized.includes("chatgpt")
116
+ ) {
117
+ matches.push("openai.com", "platform.openai.com", "help.openai.com");
118
+ }
119
+ if (normalized.includes("anthropic") || normalized.includes("claude")) {
120
+ matches.push("anthropic.com", "docs.anthropic.com");
121
+ }
122
+ if (normalized.includes("bun")) {
123
+ matches.push("bun.sh", "bun.com");
124
+ }
125
+ if (normalized.includes("next.js") || normalized.includes("nextjs")) {
126
+ matches.push("nextjs.org", "vercel.com");
127
+ }
128
+ if (normalized.includes("playwright")) {
129
+ matches.push("playwright.dev");
130
+ }
131
+ if (normalized.includes("supabase")) {
132
+ matches.push("supabase.com", "supabase.io");
133
+ }
134
+ if (normalized.includes("prisma")) {
135
+ matches.push("prisma.io");
136
+ }
137
+ if (normalized.includes("tailwind")) {
138
+ matches.push("tailwindcss.com");
139
+ }
140
+ if (normalized.includes("vite")) {
141
+ matches.push("vitejs.dev", "vite.dev");
142
+ }
143
+ if (normalized.includes("astro")) {
144
+ matches.push("astro.build");
145
+ }
146
+ if (normalized.includes("svelte")) {
147
+ matches.push("svelte.dev");
148
+ }
149
+ if (normalized.includes("solid")) {
150
+ matches.push("solidjs.com");
151
+ }
152
+ if (normalized.includes("vue") || normalized.includes("nuxt")) {
153
+ matches.push("vuejs.org", "nuxt.com");
154
+ }
155
+ if (normalized.includes("react") || normalized.includes("react native")) {
156
+ matches.push("react.dev", "reactnative.dev");
157
+ }
158
+ if (normalized.includes("angular")) {
159
+ matches.push("angular.io", "angular.dev");
160
+ }
161
+ if (normalized.includes("node.js") || normalized.includes("nodejs")) {
162
+ matches.push("nodejs.org", "nodejs.dev", "npmjs.com");
163
+ }
164
+ if (normalized.includes("deno")) {
165
+ matches.push("deno.land", "deno.com");
166
+ }
167
+ if (normalized.includes("fresh")) {
168
+ matches.push("fresh.deno.dev");
169
+ }
170
+ if (normalized.includes("typescript") || normalized.includes("ts")) {
171
+ matches.push("typescriptlang.org");
172
+ }
173
+ if (normalized.includes("python")) {
174
+ matches.push("python.org", "docs.python.org");
175
+ }
176
+ if (normalized.includes("rust")) {
177
+ matches.push("rust-lang.org", "docs.rs", "crates.io");
178
+ }
179
+ if (normalized.includes("go") || normalized.includes("golang")) {
180
+ matches.push("go.dev", "golang.org", "pkg.go.dev");
181
+ }
182
+ if (normalized.includes("zig")) {
183
+ matches.push("ziglang.org");
184
+ }
185
+ if (normalized.includes("docker")) {
186
+ matches.push("docker.com", "docs.docker.com", "hub.docker.com");
187
+ }
188
+ if (normalized.includes("kubernetes") || normalized.includes("k8s")) {
189
+ matches.push("kubernetes.io", "k8s.io");
190
+ }
191
+ if (normalized.includes("postgres") || normalized.includes("postgresql")) {
192
+ matches.push("postgresql.org", "neon.tech", "supabase.com");
193
+ }
194
+ if (normalized.includes("redis")) {
195
+ matches.push("redis.io");
196
+ }
197
+ if (normalized.includes("sqlite")) {
198
+ matches.push("sqlite.org");
199
+ }
200
+ if (normalized.includes("cloudflare")) {
201
+ matches.push("developers.cloudflare.com", "cloudflare.com");
202
+ }
203
+ if (normalized.includes("vercel")) {
204
+ matches.push("vercel.com", "nextjs.org");
205
+ }
206
+ if (normalized.includes("netlify")) {
207
+ matches.push("netlify.com", "docs.netlify.com");
208
+ }
209
+ if (normalized.includes("stripe")) {
210
+ matches.push("stripe.com", "docs.stripe.com");
211
+ }
212
+ if (normalized.includes("github")) {
213
+ matches.push("github.com", "docs.github.com");
214
+ }
215
+ if (normalized.includes("gitlab")) {
216
+ matches.push("gitlab.com", "docs.gitlab.com");
217
+ }
218
+ if (normalized.includes("aws")) {
219
+ matches.push("aws.amazon.com", "docs.aws.amazon.com");
220
+ }
221
+ if (normalized.includes("azure")) {
222
+ matches.push("azure.microsoft.com", "learn.microsoft.com");
223
+ }
224
+ if (normalized.includes("gcp") || normalized.includes("google cloud")) {
225
+ matches.push("cloud.google.com", "developers.google.com");
226
+ }
227
+ if (normalized.includes("gemini") || normalized.includes("google ai")) {
228
+ matches.push("ai.google.dev", "developers.google.com");
229
+ }
230
+
231
+ return [...new Set(matches)];
232
+ }
233
+
234
+ /**
235
+ * Check if a domain matches a preferred domain (exact or subdomain)
236
+ */
237
+ function domainMatches(hostname, candidate) {
238
+ return hostname === candidate || hostname.endsWith(`.${candidate}`);
239
+ }
240
+
241
+ function trimText(text = "", maxChars = 240) {
242
+ const clean = String(text).replace(/\s+/g, " ").trim();
243
+ if (clean.length <= maxChars) return clean;
244
+ return `${clean.slice(0, maxChars).replace(/\s+\S*$/, "")}...`;
245
+ }
246
+
247
+ function normalizeSourceTitle(title = "") {
248
+ const clean = trimText(title, 180);
249
+ if (!clean) return "";
250
+ if (/^https?:\/\//i.test(clean)) return "";
251
+
252
+ const wordCount = clean.split(/\s+/).filter(Boolean).length;
253
+ const hasUppercase = /[A-Z]/.test(clean);
254
+ const hasDigit = /\d/.test(clean);
255
+ const looksLikeFragment =
256
+ clean === clean.toLowerCase() &&
257
+ wordCount <= 4 &&
258
+ !hasUppercase &&
259
+ !hasDigit;
260
+ return looksLikeFragment ? "" : clean;
261
+ }
262
+
263
+ function pickPreferredTitle(currentTitle = "", nextTitle = "") {
264
+ const current = normalizeSourceTitle(currentTitle);
265
+ const next = normalizeSourceTitle(nextTitle);
266
+ if (!next) return current;
267
+ if (!current) return next;
268
+ const currentLooksLikeUrl = /^https?:\/\//i.test(current);
269
+ const nextLooksLikeUrl = /^https?:\/\//i.test(next);
270
+ if (currentLooksLikeUrl && !nextLooksLikeUrl) return next;
271
+ if (!currentLooksLikeUrl && nextLooksLikeUrl) return current;
272
+ return next.length > current.length ? next : current;
273
+ }
274
+
275
+ function normalizeUrl(rawUrl) {
276
+ if (!rawUrl) return null;
277
+ try {
278
+ const url = new URL(rawUrl);
279
+ if (!["http:", "https:"].includes(url.protocol)) return null;
280
+ url.hash = "";
281
+ url.hostname = url.hostname.toLowerCase();
282
+ if (
283
+ (url.protocol === "https:" && url.port === "443") ||
284
+ (url.protocol === "http:" && url.port === "80")
285
+ ) {
286
+ url.port = "";
287
+ }
288
+ for (const key of [...url.searchParams.keys()]) {
289
+ const lower = key.toLowerCase();
290
+ if (TRACKING_PARAMS.includes(lower) || lower.startsWith("utm_")) {
291
+ url.searchParams.delete(key);
292
+ }
293
+ }
294
+ url.searchParams.sort();
295
+ const normalizedPath = url.pathname.replace(/\/+$/, "") || "/";
296
+ url.pathname = normalizedPath;
297
+ const normalized = url.toString();
298
+ return normalizedPath === "/" ? normalized.replace(/\/$/, "") : normalized;
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
304
+ function getDomain(rawUrl) {
305
+ try {
306
+ const domain = new URL(rawUrl).hostname.toLowerCase();
307
+ return domain.replace(/^www\./, "");
308
+ } catch {
309
+ return "";
310
+ }
311
+ }
312
+
313
+ function matchesDomain(domain, hosts) {
314
+ return hosts.some((host) => domain === host || domain.endsWith(`.${host}`));
315
+ }
316
+
317
+ function classifySourceType(domain, title = "", rawUrl = "") {
318
+ const lowerTitle = title.toLowerCase();
319
+ const lowerUrl = rawUrl.toLowerCase();
320
+
321
+ if (domain === "github.com" || domain === "gitlab.com") return "repo";
322
+ if (matchesDomain(domain, COMMUNITY_HOSTS)) return "community";
323
+ if (matchesDomain(domain, NEWS_HOSTS)) return "news";
324
+ if (
325
+ domain.startsWith("docs.") ||
326
+ domain.startsWith("developer.") ||
327
+ domain.startsWith("developers.") ||
328
+ domain.startsWith("api.") ||
329
+ lowerTitle.includes("documentation") ||
330
+ lowerTitle.includes("docs") ||
331
+ lowerTitle.includes("reference") ||
332
+ lowerUrl.includes("/docs/") ||
333
+ lowerUrl.includes("/reference/") ||
334
+ lowerUrl.includes("/api/")
335
+ ) {
336
+ return "official-docs";
337
+ }
338
+ if (domain.startsWith("blog.") || lowerUrl.includes("/blog/"))
339
+ return "maintainer-blog";
340
+ return "website";
341
+ }
342
+
343
+ function sourceTypePriority(sourceType) {
344
+ switch (sourceType) {
345
+ case "official-docs":
346
+ return 5;
347
+ case "repo":
348
+ return 4;
349
+ case "maintainer-blog":
350
+ return 3;
351
+ case "website":
352
+ return 2;
353
+ case "community":
354
+ return 1;
355
+ case "news":
356
+ return 0;
357
+ default:
358
+ return 0;
359
+ }
360
+ }
361
+
362
+ function bestRank(source) {
363
+ const ranks = Object.values(source.perEngine || {}).map((v) => v?.rank || 99);
364
+ return ranks.length ? Math.min(...ranks) : 99;
365
+ }
366
+
367
+ function buildSourceRegistry(out, query = "") {
368
+ const seen = new Map();
369
+ const engineOrder = ["perplexity", "bing", "google"];
370
+
371
+ // Get preferred domains for this query
372
+ const preferredDomains = inferPreferredDomains(query);
373
+
374
+ for (const engine of engineOrder) {
375
+ const result = out[engine];
376
+ if (!result?.sources) continue;
377
+
378
+ for (let i = 0; i < result.sources.length; i++) {
379
+ const source = result.sources[i];
380
+ const canonicalUrl = normalizeUrl(source.url);
381
+ if (!canonicalUrl || canonicalUrl.length < 10) continue;
382
+
383
+ const title = normalizeSourceTitle(source.title || "");
384
+ const domain = getDomain(canonicalUrl);
385
+ const sourceType = classifySourceType(domain, title, canonicalUrl);
386
+
387
+ // Calculate smart score boost
388
+ let smartScore = 0;
389
+
390
+ // Boost preferred domains for this query
391
+ if (preferredDomains.some((pd) => domainMatches(domain, pd))) {
392
+ smartScore += 10; // Strong boost for query-relevant official docs
393
+ }
394
+
395
+ // Boost docs/developer sites
396
+ if (sourceType === "official-docs") {
397
+ smartScore += 3;
398
+ }
399
+
400
+ // Boost based on URL path patterns
401
+ const lowerUrl = canonicalUrl.toLowerCase();
402
+ if (
403
+ /\/docs\/|\/documentation\/|\.dev\/|\/api\/|\/reference\//.test(
404
+ lowerUrl,
405
+ )
406
+ ) {
407
+ smartScore += 2;
408
+ }
409
+
410
+ // Penalize community/discussion sites for technical queries
411
+ if (sourceType === "community" && preferredDomains.length > 0) {
412
+ smartScore -= 2;
413
+ }
414
+
415
+ const existing = seen.get(canonicalUrl) || {
416
+ id: "",
417
+ canonicalUrl,
418
+ displayUrl: source.url || canonicalUrl,
419
+ domain,
420
+ title: "",
421
+ engines: [],
422
+ engineCount: 0,
423
+ perEngine: {},
424
+ sourceType,
425
+ isOfficial: sourceType === "official-docs",
426
+ smartScore: 0,
427
+ };
428
+
429
+ existing.title = pickPreferredTitle(existing.title, title);
430
+ existing.displayUrl = existing.displayUrl || source.url || canonicalUrl;
431
+ existing.sourceType = existing.sourceType || sourceType;
432
+ existing.isOfficial =
433
+ existing.isOfficial || sourceType === "official-docs";
434
+ existing.smartScore = Math.max(existing.smartScore, smartScore);
435
+
436
+ if (!existing.engines.includes(engine)) {
437
+ existing.engines.push(engine);
438
+ }
439
+ existing.perEngine[engine] = {
440
+ rank: i + 1,
441
+ title: pickPreferredTitle(
442
+ existing.perEngine[engine]?.title || "",
443
+ title,
444
+ ),
445
+ };
446
+
447
+ seen.set(canonicalUrl, existing);
448
+ }
449
+ }
450
+
451
+ const sources = Array.from(seen.values())
452
+ .map((source) => ({
453
+ ...source,
454
+ engineCount: source.engines.length,
455
+ }))
456
+ .sort((a, b) => {
457
+ // Primary: smart score (query-aware domain boosting)
458
+ if (b.smartScore !== a.smartScore) return b.smartScore - a.smartScore;
459
+
460
+ // Secondary: consensus (sources found by more engines)
461
+ if (b.engineCount !== a.engineCount) return b.engineCount - a.engineCount;
462
+
463
+ // Tertiary: source type priority
464
+ if (
465
+ sourceTypePriority(b.sourceType) !== sourceTypePriority(a.sourceType)
466
+ ) {
467
+ return (
468
+ sourceTypePriority(b.sourceType) - sourceTypePriority(a.sourceType)
469
+ );
470
+ }
471
+
472
+ // Quaternary: best rank across engines
473
+ if (bestRank(a) !== bestRank(b)) return bestRank(a) - bestRank(b);
474
+
475
+ return a.domain.localeCompare(b.domain);
476
+ })
477
+ .slice(0, 12)
478
+ .map((source, index) => ({
479
+ ...source,
480
+ id: `S${index + 1}`,
481
+ title: source.title || source.domain || source.canonicalUrl,
482
+ }));
483
+
484
+ return sources;
485
+ }
486
+
487
+ function mergeFetchDataIntoSources(sources, fetchedSources) {
488
+ const byId = new Map(fetchedSources.map((source) => [source.id, source]));
489
+ return sources.map((source) => {
490
+ const fetched = byId.get(source.id);
491
+ if (!fetched) return source;
492
+
493
+ const title = pickPreferredTitle(source.title, fetched.title || "");
494
+ return {
495
+ ...source,
496
+ title: title || source.title,
497
+ fetch: {
498
+ attempted: true,
499
+ ok: !fetched.error && fetched.contentChars > 100,
500
+ status: fetched.status || null,
501
+ finalUrl: fetched.finalUrl || fetched.url || source.canonicalUrl,
502
+ contentType: fetched.contentType || "",
503
+ lastModified: fetched.lastModified || "",
504
+ title: fetched.title || "",
505
+ snippet: fetched.snippet || "",
506
+ contentChars: fetched.contentChars || 0,
507
+ source: fetched.source || "unknown", // "http" | "browser"
508
+ duration: fetched.duration || 0,
509
+ error: fetched.error || "",
510
+ },
511
+ };
512
+ });
513
+ }
514
+
515
+ function parseStructuredJson(text) {
516
+ if (!text) return null;
517
+ const trimmed = String(text).trim();
518
+ const candidates = [
519
+ trimmed,
520
+ trimmed
521
+ .replace(/^```json\s*/i, "")
522
+ .replace(/^```\s*/i, "")
523
+ .replace(/```$/i, "")
524
+ .trim(),
525
+ ];
526
+
527
+ const objectMatch = trimmed.match(/\{[\s\S]*\}/);
528
+ if (objectMatch) candidates.push(objectMatch[0]);
529
+
530
+ for (const candidate of candidates) {
531
+ try {
532
+ return JSON.parse(candidate);
533
+ } catch {
534
+ // try next candidate
535
+ }
536
+ }
537
+ return null;
538
+ }
539
+
540
+ function normalizeSynthesisPayload(payload, sources, fallbackAnswer = "") {
541
+ const sourceIds = new Set(sources.map((source) => source.id));
542
+ const agreementLevel = [
543
+ "high",
544
+ "medium",
545
+ "low",
546
+ "mixed",
547
+ "conflicting",
548
+ ].includes(payload?.agreement?.level)
549
+ ? payload.agreement.level
550
+ : "mixed";
551
+ const claims = Array.isArray(payload?.claims)
552
+ ? payload.claims
553
+ .map((claim) => ({
554
+ claim: trimText(claim?.claim || "", 260),
555
+ support: ["strong", "moderate", "weak", "conflicting"].includes(
556
+ claim?.support,
557
+ )
558
+ ? claim.support
559
+ : "moderate",
560
+ sourceIds: Array.isArray(claim?.sourceIds)
561
+ ? claim.sourceIds.filter((id) => sourceIds.has(id))
562
+ : [],
563
+ }))
564
+ .filter((claim) => claim.claim)
565
+ : [];
566
+ const recommendedSources = Array.isArray(payload?.recommendedSources)
567
+ ? payload.recommendedSources.filter((id) => sourceIds.has(id)).slice(0, 6)
568
+ : [];
569
+
570
+ return {
571
+ answer: trimText(payload?.answer || fallbackAnswer, 4000),
572
+ agreement: {
573
+ level: agreementLevel,
574
+ summary: trimText(payload?.agreement?.summary || "", 280),
575
+ },
576
+ differences: Array.isArray(payload?.differences)
577
+ ? payload.differences
578
+ .map((item) => trimText(item, 220))
579
+ .filter(Boolean)
580
+ .slice(0, 5)
581
+ : [],
582
+ caveats: Array.isArray(payload?.caveats)
583
+ ? payload.caveats
584
+ .map((item) => trimText(item, 220))
585
+ .filter(Boolean)
586
+ .slice(0, 5)
587
+ : [],
588
+ claims,
589
+ recommendedSources,
590
+ };
591
+ }
592
+
593
+ function buildSynthesisPrompt(
594
+ query,
595
+ results,
596
+ sources,
597
+ { grounded = false } = {},
598
+ ) {
599
+ const engineSummaries = {};
600
+ for (const engine of ["perplexity", "bing", "google"]) {
601
+ const result = results[engine];
602
+ if (!result) continue;
603
+ if (result.error) {
604
+ engineSummaries[engine] = {
605
+ status: "error",
606
+ error: String(result.error),
607
+ };
608
+ continue;
609
+ }
610
+
611
+ engineSummaries[engine] = {
612
+ status: "ok",
613
+ answer: trimText(result.answer || "", grounded ? 4500 : 2200),
614
+ sourceIds: sources
615
+ .filter((source) => source.engines.includes(engine))
616
+ .sort(
617
+ (a, b) =>
618
+ (a.perEngine[engine]?.rank || 99) -
619
+ (b.perEngine[engine]?.rank || 99),
620
+ )
621
+ .map((source) => source.id)
622
+ .slice(0, 6),
623
+ };
624
+ }
625
+
626
+ const sourceRegistry = sources.slice(0, grounded ? 10 : 8).map((source) => ({
627
+ id: source.id,
628
+ title: source.title,
629
+ domain: source.domain,
630
+ canonicalUrl: source.canonicalUrl,
631
+ sourceType: source.sourceType,
632
+ isOfficial: source.isOfficial,
633
+ engines: source.engines,
634
+ engineCount: source.engineCount,
635
+ perEngine: source.perEngine,
636
+ fetch:
637
+ grounded && source.fetch?.attempted
638
+ ? {
639
+ ok: source.fetch.ok,
640
+ status: source.fetch.status,
641
+ lastModified: source.fetch.lastModified,
642
+ snippet: trimText(source.fetch.snippet || "", 700),
643
+ }
644
+ : undefined,
645
+ }));
646
+
647
+ return [
648
+ "You are synthesizing results from Perplexity, Bing Copilot, and Google AI.",
649
+ grounded
650
+ ? "Use the fetched source snippets as the strongest evidence. Use engine answers for perspective and conflict detection."
651
+ : "Use the engine answers for perspective. Use the source registry for provenance and citations.",
652
+ "Prefer official docs, release notes, repositories, and maintainer-authored sources when available.",
653
+ "If the engines disagree, say so explicitly.",
654
+ "Do not invent sources. Only reference source IDs from the source registry.",
655
+ "Return valid JSON only. No markdown fences, no prose outside the JSON object.",
656
+ "",
657
+ "JSON schema:",
658
+ "{",
659
+ ' "answer": "short direct answer",',
660
+ ' "agreement": { "level": "high|medium|low|mixed|conflicting", "summary": "..." },',
661
+ ' "differences": ["..."],',
662
+ ' "caveats": ["..."],',
663
+ ' "claims": [',
664
+ ' { "claim": "...", "support": "strong|moderate|weak|conflicting", "sourceIds": ["S1"] }',
665
+ " ],",
666
+ ' "recommendedSources": ["S1", "S2"]',
667
+ "}",
668
+ "",
669
+ `User query: ${query}`,
670
+ "",
671
+ `Engine results:\n${JSON.stringify(engineSummaries, null, 2)}`,
672
+ "",
673
+ `Source registry:\n${JSON.stringify(sourceRegistry, null, 2)}`,
674
+ ].join("\n");
675
+ }
676
+
677
+ function buildConfidence(out) {
678
+ const sources = Array.isArray(out._sources) ? out._sources : [];
679
+ const topConsensus = sources.length > 0 ? sources[0]?.engineCount || 0 : 0;
680
+ const officialSourceCount = sources.filter(
681
+ (source) => source.isOfficial,
682
+ ).length;
683
+ const firstPartySourceCount = sources.filter(
684
+ (source) => source.isOfficial || source.sourceType === "maintainer-blog",
685
+ ).length;
686
+ const fetchedAttempted = sources.filter(
687
+ (source) => source.fetch?.attempted,
688
+ ).length;
689
+ const fetchedSucceeded = sources.filter((source) => source.fetch?.ok).length;
690
+ const sourceTypeBreakdown = sources.reduce((acc, source) => {
691
+ acc[source.sourceType] = (acc[source.sourceType] || 0) + 1;
692
+ return acc;
693
+ }, {});
694
+ const synthesisLevel = out._synthesis?.agreement?.level;
695
+
696
+ return {
697
+ sourcesCount: sources.length,
698
+ topSourceConsensus: topConsensus,
699
+ agreementLevel:
700
+ synthesisLevel ||
701
+ (topConsensus >= 3 ? "high" : topConsensus >= 2 ? "medium" : "low"),
702
+ enginesResponded: ALL_ENGINES.filter(
703
+ (engine) => out[engine]?.answer && !out[engine]?.error,
704
+ ),
705
+ enginesFailed: ALL_ENGINES.filter((engine) => out[engine]?.error),
706
+ officialSourceCount,
707
+ firstPartySourceCount,
708
+ fetchedSourceSuccessRate:
709
+ fetchedAttempted > 0
710
+ ? Number((fetchedSucceeded / fetchedAttempted).toFixed(2))
711
+ : 0,
712
+ sourceTypeBreakdown,
713
+ };
714
+ }
715
+
716
+ function getFullTabFromCache(engine) {
717
+ try {
718
+ if (!existsSync(PAGES_CACHE)) return null;
719
+ const pages = JSON.parse(readFileSync(PAGES_CACHE, "utf8"));
720
+ const found = pages.find((p) => p.url.includes(ENGINE_DOMAINS[engine]));
721
+ return found ? found.targetId : null;
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+
727
+ function cdp(args, timeoutMs = 15000) {
728
+ return new Promise((resolve, reject) => {
729
+ const proc = spawn("node", [CDP, ...args], {
730
+ stdio: ["ignore", "pipe", "pipe"],
731
+ });
732
+ let out = "",
733
+ err = "";
734
+ proc.stdout.on("data", (d) => (out += d));
735
+ proc.stderr.on("data", (d) => (err += d));
736
+ const t = setTimeout(() => {
737
+ proc.kill();
738
+ reject(new Error(`cdp timeout: ${args[0]}`));
739
+ }, timeoutMs);
740
+ proc.on("close", (code) => {
741
+ clearTimeout(t);
742
+ if (code !== 0) reject(new Error(err.trim() || `cdp exit ${code}`));
743
+ else resolve(out.trim());
744
+ });
745
+ });
746
+ }
747
+
748
+ async function getAnyTab() {
749
+ const list = await cdp(["list"]);
750
+ const first = list.split("\n")[0];
751
+ if (!first) throw new Error("No Chrome tabs found");
752
+ return first.slice(0, 8);
753
+ }
754
+
755
+ async function _getOrReuseBlankTab() {
756
+ // Reuse an existing about:blank tab rather than always creating a new one
757
+ const listOut = await cdp(["list"]);
758
+ const lines = listOut.split("\n").filter(Boolean);
759
+ for (const line of lines) {
760
+ if (line.includes("about:blank")) {
761
+ return line.slice(0, 8); // prefix of the blank tab's targetId
762
+ }
763
+ }
764
+ // No blank tab — open a new one
765
+ const anchor = await getAnyTab();
766
+ const raw = await cdp([
767
+ "evalraw",
768
+ anchor,
769
+ "Target.createTarget",
770
+ '{"url":"about:blank"}',
771
+ ]);
772
+ const { targetId } = JSON.parse(raw);
773
+ return targetId;
774
+ }
775
+
776
+ async function openNewTab() {
777
+ const anchor = await getAnyTab();
778
+ const raw = await cdp([
779
+ "evalraw",
780
+ anchor,
781
+ "Target.createTarget",
782
+ '{"url":"about:blank"}',
783
+ ]);
784
+ const { targetId } = JSON.parse(raw);
785
+ return targetId;
786
+ }
787
+
788
+ async function _getOrOpenEngineTab(engine) {
789
+ await cdp(["list"]);
790
+ return getFullTabFromCache(engine) || openNewTab();
791
+ }
792
+
793
+ async function activateTab(targetId) {
794
+ try {
795
+ const anchor = await getAnyTab();
796
+ await cdp([
797
+ "evalraw",
798
+ anchor,
799
+ "Target.activateTarget",
800
+ JSON.stringify({ targetId }),
801
+ ]);
802
+ } catch {
803
+ // best-effort
804
+ }
805
+ }
806
+
807
+ async function closeTabs(targetIds = []) {
808
+ for (const targetId of targetIds) {
809
+ if (!targetId) continue;
810
+ await closeTab(targetId);
811
+ }
812
+ if (targetIds.length > 0) {
813
+ await new Promise((r) => setTimeout(r, 300));
814
+ await cdp(["list"]).catch(() => null);
815
+ }
816
+ }
817
+
818
+ async function closeTab(targetId) {
819
+ try {
820
+ const anchor = await getAnyTab();
821
+ await cdp([
822
+ "evalraw",
823
+ anchor,
824
+ "Target.closeTarget",
825
+ JSON.stringify({ targetId }),
826
+ ]);
827
+ } catch {
828
+ /* best-effort */
829
+ }
830
+ }
831
+
832
+ function runExtractor(
833
+ script,
834
+ query,
835
+ tabPrefix = null,
836
+ short = false,
837
+ timeoutMs = null, // null = auto-select based on engine
838
+ ) {
839
+ // Gemini is slower - use longer timeout
840
+ if (timeoutMs === null) {
841
+ timeoutMs = script.includes("gemini") ? 180000 : 90000;
842
+ }
843
+ const extraArgs = [
844
+ ...(tabPrefix ? ["--tab", tabPrefix] : []),
845
+ ...(short ? ["--short"] : []),
846
+ ];
658
847
  return new Promise((resolve, reject) => {
659
848
  const proc = spawn(
660
849
  "node",
661
- [join(__dir, "extractors", script), query, ...extraArgs],
850
+ [join(__dir, "..", "extractors", script), query, ...extraArgs],
662
851
  {
663
852
  stdio: ["ignore", "pipe", "pipe"],
664
853
  env: { ...process.env, CDP_PROFILE_DIR: GREEDY_PROFILE_DIR },
665
- },
666
- );
667
- let out = "";
668
- let err = "";
669
- proc.stdout.on("data", (d) => (out += d));
670
- proc.stderr.on("data", (d) => (err += d));
671
- const t = setTimeout(() => {
672
- proc.kill();
673
- reject(new Error(`${script} timed out after ${timeoutMs / 1000}s`));
674
- }, timeoutMs);
675
- proc.on("close", (code) => {
676
- clearTimeout(t);
677
- if (code !== 0) reject(new Error(err.trim() || `extractor exit ${code}`));
678
- else {
679
- try {
680
- resolve(JSON.parse(out.trim()));
681
- } catch {
682
- reject(new Error(`bad JSON from ${script}: ${out.slice(0, 100)}`));
683
- }
684
- }
685
- });
686
- });
687
- }
688
-
689
- async function fetchTopSource(url) {
690
- const tab = await openNewTab();
691
- await cdp(["list"]); // refresh cache so the new tab is findable
692
- try {
693
- await cdp(["nav", tab, url], 30000);
694
- await new Promise((r) => setTimeout(r, 1500));
695
- const content = await cdp([
696
- "eval",
697
- tab,
698
- `
699
- (function(){
700
- var el = document.querySelector('article, [role="main"], main, .post-content, .article-body, #content, .content');
701
- var text = (el || document.body).innerText;
702
- return text.replace(/\\s+/g, ' ').trim();
703
- })()
704
- `,
705
- ]);
706
- return { url, content };
707
- } catch (e) {
708
- return { url, content: null, error: e.message };
709
- } finally {
710
- await closeTab(tab);
711
- }
712
- }
713
-
714
- async function fetchSourceContent(url, maxChars = 5000) {
715
- try {
716
- const controller = new AbortController();
717
- const timeout = setTimeout(() => controller.abort(), 15000);
718
-
719
- const res = await fetch(url, {
720
- signal: controller.signal,
721
- headers: {
722
- "User-Agent":
723
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
724
- Accept: "text/html,application/xhtml+xml",
725
- "Accept-Language": "en-US,en;q=0.9",
726
- },
727
- });
728
- clearTimeout(timeout);
729
-
730
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
731
-
732
- const html = await res.text();
733
-
734
- // Simple HTML extraction - remove tags and extract text
735
- const content = html
736
- .replace(/<script[\s\S]*?<\/script>/gi, "")
737
- .replace(/<style[\s\S]*?<\/style>/gi, "")
738
- .replace(/<nav[\s\S]*?<\/nav>/gi, "")
739
- .replace(/<header[\s\S]*?<\/header>/gi, "")
740
- .replace(/<footer[\s\S]*?<\/footer>/gi, "")
741
- .replace(/<[^>]+>/g, " ")
742
- .replace(/&[a-z]+;/gi, " ")
743
- .replace(/\s+/g, " ")
744
- .trim()
745
- .slice(0, maxChars);
746
-
747
- // Extract title
748
- const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
749
- const title = titleMatch ? titleMatch[1].trim() : "";
750
- const finalUrl = res.url || url;
751
- const snippet = trimText(content, 320);
854
+ },
855
+ );
856
+ let out = "";
857
+ let err = "";
858
+ proc.stdout.on("data", (d) => (out += d));
859
+ proc.stderr.on("data", (d) => (err += d));
860
+ const t = setTimeout(() => {
861
+ proc.kill();
862
+ reject(new Error(`${script} timed out after ${timeoutMs / 1000}s`));
863
+ }, timeoutMs);
864
+ proc.on("close", (code) => {
865
+ clearTimeout(t);
866
+ if (code !== 0) reject(new Error(err.trim() || `extractor exit ${code}`));
867
+ else {
868
+ try {
869
+ resolve(JSON.parse(out.trim()));
870
+ } catch {
871
+ reject(new Error(`bad JSON from ${script}: ${out.slice(0, 100)}`));
872
+ }
873
+ }
874
+ });
875
+ });
876
+ }
877
+
878
+ async function fetchTopSource(url) {
879
+ const tab = await openNewTab();
880
+ await cdp(["list"]); // refresh cache so the new tab is findable
881
+ try {
882
+ await cdp(["nav", tab, url], 30000);
883
+ await new Promise((r) => setTimeout(r, 1500));
884
+ const content = await cdp([
885
+ "eval",
886
+ tab,
887
+ `
888
+ (function(){
889
+ var el = document.querySelector('article, [role="main"], main, .post-content, .article-body, #content, .content');
890
+ var text = (el || document.body).innerText;
891
+ return text.replace(/\\s+/g, ' ').trim();
892
+ })()
893
+ `,
894
+ ]);
895
+ return { url, content };
896
+ } catch (e) {
897
+ return { url, content: null, error: e.message };
898
+ } finally {
899
+ await closeTab(tab);
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Fetch source content via HTTP with Readability extraction.
905
+ * Falls back to browser if HTTP fails or content quality is low.
906
+ * @param {string} url - URL to fetch
907
+ * @param {number} maxChars - Max characters to return
908
+ * @returns {Promise<object>} Fetch result
909
+ */
910
+ async function fetchSourceContent(url, maxChars = 8000) {
911
+ const start = Date.now();
912
+
913
+ // Check if it's a GitHub URL (tree/root - use clone, blob - let fetcher handle via raw)
914
+ if (parseGitHubUrl(url)) {
915
+ const parsed = parseGitHubUrl(url);
916
+ // Use cloning for tree/root URLs, or blob URLs that might need exploration
917
+ if (
918
+ parsed &&
919
+ (parsed.type === "root" ||
920
+ parsed.type === "tree" ||
921
+ (parsed.type === "blob" && !parsed.path?.includes(".")))
922
+ ) {
923
+ const ghResult = await fetchGitHubContent(url);
924
+ if (ghResult.ok) {
925
+ const content = trimContentHeadTail(ghResult.content, maxChars);
926
+ return {
927
+ url,
928
+ finalUrl: url,
929
+ status: 200,
930
+ contentType: "text/markdown",
931
+ lastModified: "",
932
+ title: ghResult.title,
933
+ snippet: content.slice(0, 320),
934
+ content,
935
+ contentChars: content.length,
936
+ source: "github-clone",
937
+ localPath: ghResult.localPath,
938
+ ...(ghResult.tree && { tree: ghResult.tree }),
939
+ duration: Date.now() - start,
940
+ };
941
+ }
942
+ // If GitHub clone failed, fall through to HTTP (which will use raw for blobs)
943
+ process.stderr.write(
944
+ `[greedysearch] GitHub clone failed, trying HTTP: ${ghResult.error}\n`,
945
+ );
946
+ }
947
+ }
948
+
949
+ // Try HTTP first
950
+ const httpResult = await fetchSourceHttp(url, { timeoutMs: 15000 });
951
+
952
+ if (httpResult.ok) {
953
+ const content = trimContentHeadTail(httpResult.markdown, maxChars);
954
+ return {
955
+ url,
956
+ finalUrl: httpResult.finalUrl,
957
+ status: httpResult.status,
958
+ contentType: "text/markdown",
959
+ lastModified: "",
960
+ title: httpResult.title,
961
+ snippet: httpResult.excerpt,
962
+ content,
963
+ contentChars: content.length,
964
+ source: "http",
965
+ duration: Date.now() - start,
966
+ };
967
+ }
968
+
969
+ // HTTP failed or blocked - fall back to browser
970
+ process.stderr.write(
971
+ `[greedysearch] HTTP failed for ${url.slice(0, 60)}, trying browser...\n`,
972
+ );
973
+ return await fetchSourceContentBrowser(url, maxChars);
974
+ }
975
+
976
+ /**
977
+ * Browser fallback for source fetching (original CDP-based method)
978
+ */
979
+ async function fetchSourceContentBrowser(url, maxChars = 8000) {
980
+ const start = Date.now();
981
+ const tab = await openNewTab();
982
+
983
+ try {
984
+ await cdp(["nav", tab, url], 30000);
985
+ await new Promise((r) => setTimeout(r, 1500));
986
+
987
+ const content = await cdp([
988
+ "eval",
989
+ tab,
990
+ `
991
+ (function(){
992
+ var el = document.querySelector('article, [role="main"], main, .post-content, .article-body, #content, .content');
993
+ var text = (el || document.body).innerText;
994
+ return JSON.stringify({
995
+ title: document.title,
996
+ content: text.replace(/\\s+/g, ' ').trim(),
997
+ url: location.href
998
+ });
999
+ })()
1000
+ `,
1001
+ ]);
1002
+
1003
+ const parsed = JSON.parse(content);
1004
+ const finalContent = trimContentHeadTail(parsed.content, maxChars);
1005
+
1006
+ return {
1007
+ url,
1008
+ finalUrl: parsed.url || url,
1009
+ status: 200,
1010
+ contentType: "text/plain",
1011
+ lastModified: "",
1012
+ title: parsed.title,
1013
+ snippet: trimText(finalContent, 320),
1014
+ content: finalContent,
1015
+ contentChars: finalContent.length,
1016
+ source: "browser",
1017
+ duration: Date.now() - start,
1018
+ };
1019
+ } catch (error) {
1020
+ return {
1021
+ url,
1022
+ title: "",
1023
+ content: null,
1024
+ snippet: "",
1025
+ contentChars: 0,
1026
+ error: error.message,
1027
+ source: "browser",
1028
+ duration: Date.now() - start,
1029
+ };
1030
+ } finally {
1031
+ await closeTab(tab);
1032
+ }
1033
+ }
1034
+
1035
+ async function fetchMultipleSources(
1036
+ sources,
1037
+ maxSources = 5,
1038
+ maxChars = 8000,
1039
+ concurrency = SOURCE_FETCH_CONCURRENCY,
1040
+ ) {
1041
+ const toFetch = sources.slice(0, maxSources);
1042
+ if (toFetch.length === 0) return [];
752
1043
 
753
- return {
754
- url,
755
- finalUrl,
756
- status: res.status,
757
- contentType: res.headers.get("content-type") || "",
758
- lastModified: res.headers.get("last-modified") || "",
759
- title,
760
- snippet,
761
- content,
762
- contentChars: content.length,
763
- };
764
- } catch (e) {
765
- return {
766
- url,
767
- title: "",
768
- content: null,
769
- snippet: "",
770
- contentChars: 0,
771
- error: e.message,
772
- };
773
- }
774
- }
1044
+ const workerCount = Math.min(
1045
+ toFetch.length,
1046
+ Math.max(1, parseInt(String(concurrency), 10) || SOURCE_FETCH_CONCURRENCY),
1047
+ );
775
1048
 
776
- async function fetchMultipleSources(sources, maxSources = 5, maxChars = 5000) {
777
1049
  process.stderr.write(
778
- `[greedysearch] Fetching content from ${Math.min(sources.length, maxSources)} sources...\n`,
1050
+ `[greedysearch] Fetching content from ${toFetch.length} sources via HTTP (concurrency ${workerCount})...\n`,
779
1051
  );
780
1052
 
781
- // Fetch sources sequentially (CDP doesn't handle parallel tab operations well)
782
- const toFetch = sources.slice(0, maxSources);
783
- const fetched = [];
1053
+ const fetched = new Array(toFetch.length);
1054
+ let nextIndex = 0;
1055
+ let completed = 0;
1056
+
1057
+ async function worker() {
1058
+ while (true) {
1059
+ const index = nextIndex++;
1060
+ if (index >= toFetch.length) return;
784
1061
 
785
- for (let i = 0; i < toFetch.length; i++) {
786
- const s = toFetch[i];
787
- process.stderr.write(
788
- `[greedysearch] Fetching ${i + 1}/${toFetch.length}: ${(s.canonicalUrl || s.url).slice(0, 60)}...\n`,
789
- );
790
- try {
791
- const result = await fetchSourceContent(
792
- s.canonicalUrl || s.url,
793
- maxChars,
1062
+ const s = toFetch[index];
1063
+ const url = s.canonicalUrl || s.url;
1064
+ process.stderr.write(
1065
+ `[greedysearch] [${index + 1}/${toFetch.length}] Fetching: ${url.slice(0, 60)}...\n`,
794
1066
  );
795
- fetched.push({ id: s.id, ...result });
1067
+
1068
+ const result = await fetchSourceContent(url, maxChars);
1069
+ fetched[index] = {
1070
+ id: s.id,
1071
+ ...result,
1072
+ };
1073
+
796
1074
  if (result.content && result.content.length > 100) {
797
1075
  process.stderr.write(
798
- `[greedysearch] ✓ Got ${result.content.length} chars\n`,
1076
+ `[greedysearch] ✓ ${result.source}: ${result.content.length} chars\n`,
799
1077
  );
800
- } else {
801
- process.stderr.write(`[greedysearch] ✗ Empty or too short\n`);
1078
+ } else if (result.error) {
1079
+ process.stderr.write(`[greedysearch] ✗ ${result.error.slice(0, 80)}\n`);
802
1080
  }
803
- } catch (e) {
804
- fetched.push({
805
- id: s.id,
806
- url: s.canonicalUrl || s.url,
807
- error: e.message,
808
- });
809
- process.stderr.write(
810
- `[greedysearch] ✗ Failed: ${e.message.slice(0, 80)}\n`,
811
- );
812
- }
813
- process.stderr.write(`PROGRESS:fetch:${i + 1}/${toFetch.length}\n`);
814
- }
815
-
816
- return fetched;
817
- }
818
1081
 
819
- function pickTopSource(out) {
820
- if (Array.isArray(out._sources) && out._sources.length > 0)
821
- return out._sources[0];
822
- for (const engine of ["perplexity", "google", "bing"]) {
823
- const r = out[engine];
824
- if (r?.sources?.length > 0) return r.sources[0];
1082
+ completed += 1;
1083
+ process.stderr.write(`PROGRESS:fetch:${completed}/${toFetch.length}\n`);
1084
+ }
825
1085
  }
826
- return null;
827
- }
828
-
829
- async function synthesizeWithGemini(
830
- query,
831
- results,
832
- { grounded = false, tabPrefix = null } = {},
833
- ) {
834
- const sources = Array.isArray(results._sources)
835
- ? results._sources
836
- : buildSourceRegistry(results);
837
- const prompt = buildSynthesisPrompt(query, results, sources, { grounded });
838
1086
 
1087
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1088
+
1089
+ // Log summary
1090
+ const successful = fetched.filter((f) => f.content && f.content.length > 100);
1091
+ const httpCount = fetched.filter((f) => f.source === "http").length;
1092
+ const browserCount = fetched.filter((f) => f.source === "browser").length;
1093
+
1094
+ process.stderr.write(
1095
+ `[greedysearch] Fetched ${successful.length}/${fetched.length} sources ` +
1096
+ `(HTTP: ${httpCount}, Browser: ${browserCount})\n`,
1097
+ );
1098
+
1099
+ return fetched;
1100
+ }
1101
+
1102
+ function pickTopSource(out) {
1103
+ if (Array.isArray(out._sources) && out._sources.length > 0)
1104
+ return out._sources[0];
1105
+ for (const engine of ["perplexity", "google", "bing"]) {
1106
+ const r = out[engine];
1107
+ if (r?.sources?.length > 0) return r.sources[0];
1108
+ }
1109
+ return null;
1110
+ }
1111
+
1112
+ async function synthesizeWithGemini(
1113
+ query,
1114
+ results,
1115
+ { grounded = false, tabPrefix = null } = {},
1116
+ ) {
1117
+ const sources = Array.isArray(results._sources)
1118
+ ? results._sources
1119
+ : buildSourceRegistry(results);
1120
+ const prompt = buildSynthesisPrompt(query, results, sources, { grounded });
1121
+
839
1122
  return new Promise((resolve, reject) => {
840
1123
  const extraArgs = tabPrefix ? ["--tab", String(tabPrefix)] : [];
841
1124
  const proc = spawn(
842
1125
  "node",
843
- [join(__dir, "extractors", "gemini.mjs"), prompt, ...extraArgs],
1126
+ [join(__dir, "..", "extractors", "gemini.mjs"), prompt, ...extraArgs],
844
1127
  {
845
1128
  stdio: ["ignore", "pipe", "pipe"],
846
1129
  env: { ...process.env, CDP_PROFILE_DIR: GREEDY_PROFILE_DIR },
847
- },
848
- );
849
- let out = "";
850
- let err = "";
851
- proc.stdout.on("data", (d) => (out += d));
852
- proc.stderr.on("data", (d) => (err += d));
853
- const t = setTimeout(() => {
854
- proc.kill();
855
- reject(new Error("Gemini synthesis timed out after 180s"));
856
- }, 180000);
857
- proc.on("close", (code) => {
858
- clearTimeout(t);
859
- if (code !== 0)
860
- reject(new Error(err.trim() || "gemini extractor failed"));
861
- else {
862
- try {
863
- const raw = JSON.parse(out.trim());
864
- const structured = parseStructuredJson(raw.answer || "");
865
- resolve({
866
- ...normalizeSynthesisPayload(structured, sources, raw.answer || ""),
867
- rawAnswer: raw.answer || "",
868
- geminiSources: raw.sources || [],
869
- });
870
- } catch {
871
- reject(new Error(`bad JSON from gemini: ${out.slice(0, 100)}`));
872
- }
873
- }
874
- });
875
- });
876
- }
877
-
878
- function slugify(query) {
879
- return query
880
- .toLowerCase()
881
- .replace(/[^a-z0-9]+/g, "-")
882
- .replace(/^-|-$/g, "")
883
- .slice(0, 60);
884
- }
885
-
1130
+ },
1131
+ );
1132
+ let out = "";
1133
+ let err = "";
1134
+ proc.stdout.on("data", (d) => (out += d));
1135
+ proc.stderr.on("data", (d) => (err += d));
1136
+ const t = setTimeout(() => {
1137
+ proc.kill();
1138
+ reject(new Error("Gemini synthesis timed out after 180s"));
1139
+ }, 180000);
1140
+ proc.on("close", (code) => {
1141
+ clearTimeout(t);
1142
+ if (code !== 0)
1143
+ reject(new Error(err.trim() || "gemini extractor failed"));
1144
+ else {
1145
+ try {
1146
+ const raw = JSON.parse(out.trim());
1147
+ const structured = parseStructuredJson(raw.answer || "");
1148
+ resolve({
1149
+ ...normalizeSynthesisPayload(structured, sources, raw.answer || ""),
1150
+ rawAnswer: raw.answer || "",
1151
+ geminiSources: raw.sources || [],
1152
+ });
1153
+ } catch {
1154
+ reject(new Error(`bad JSON from gemini: ${out.slice(0, 100)}`));
1155
+ }
1156
+ }
1157
+ });
1158
+ });
1159
+ }
1160
+
1161
+ function slugify(query) {
1162
+ return query
1163
+ .toLowerCase()
1164
+ .replace(/[^a-z0-9]+/g, "-")
1165
+ .replace(/^-|-$/g, "")
1166
+ .slice(0, 60);
1167
+ }
1168
+
886
1169
  function resultsDir() {
887
- const dir = join(__dir, "results");
1170
+ const dir = join(__dir, "..", "results");
888
1171
  mkdirSync(dir, { recursive: true });
889
1172
  return dir;
890
1173
  }
891
-
892
- function writeOutput(
893
- data,
894
- outFile,
895
- { inline = false, synthesize = false, query = "" } = {},
896
- ) {
897
- const json = `${JSON.stringify(data, null, 2)}\n`;
898
-
899
- if (outFile) {
900
- writeFileSync(outFile, json, "utf8");
901
- process.stderr.write(`Results written to ${outFile}\n`);
902
- return;
903
- }
904
-
905
- if (inline) {
906
- process.stdout.write(json);
907
- return;
908
- }
909
-
910
- const ts = new Date()
911
- .toISOString()
912
- .replace("T", "_")
913
- .replace(/[:.]/g, "-")
914
- .slice(0, 19);
915
- const slug = slugify(query);
916
- const base = join(resultsDir(), `${ts}_${slug}`);
917
-
918
- writeFileSync(`${base}.json`, json, "utf8");
919
-
920
- if (synthesize && data._synthesis?.answer) {
921
- writeFileSync(`${base}-synthesis.md`, data._synthesis.answer, "utf8");
922
- process.stdout.write(`${base}-synthesis.md\n`);
923
- } else {
924
- process.stdout.write(`${base}.json\n`);
925
- }
926
- }
927
-
928
- const GREEDY_PROFILE_DIR = `${tmpdir().replace(/\\/g, "/")}/greedysearch-chrome-profile`;
929
- const ACTIVE_PORT_FILE = `${GREEDY_PROFILE_DIR}/DevToolsActivePort`;
930
-
931
- // Tell cdp.mjs to prefer the GreedySearch Chrome profile's DevToolsActivePort,
932
- // so searches never accidentally attach to the user's main Chrome session.
933
- process.env.CDP_PROFILE_DIR = GREEDY_PROFILE_DIR;
934
-
935
- function probeGreedyChrome(timeoutMs = 3000) {
936
- return new Promise((resolve) => {
937
- const req = http.get(
938
- `http://localhost:${GREEDY_PORT}/json/version`,
939
- (res) => {
940
- res.resume();
941
- resolve(res.statusCode === 200);
942
- },
943
- );
944
- req.on("error", () => resolve(false));
945
- req.setTimeout(timeoutMs, () => {
946
- req.destroy();
947
- resolve(false);
948
- });
949
- });
950
- }
951
-
952
- // Write (or refresh) the DevToolsActivePort file for the GreedySearch Chrome so
953
- // cdp.mjs always connects to the right port rather than the user's main Chrome.
954
- // Uses atomic write (write to temp + rename) to prevent corruption from parallel processes.
1174
+
1175
+ function writeOutput(
1176
+ data,
1177
+ outFile,
1178
+ { inline = false, synthesize = false, query = "" } = {},
1179
+ ) {
1180
+ const json = `${JSON.stringify(data, null, 2)}\n`;
1181
+
1182
+ if (outFile) {
1183
+ writeFileSync(outFile, json, "utf8");
1184
+ process.stderr.write(`Results written to ${outFile}\n`);
1185
+ return;
1186
+ }
1187
+
1188
+ if (inline) {
1189
+ process.stdout.write(json);
1190
+ return;
1191
+ }
1192
+
1193
+ const ts = new Date()
1194
+ .toISOString()
1195
+ .replace("T", "_")
1196
+ .replace(/[:.]/g, "-")
1197
+ .slice(0, 19);
1198
+ const slug = slugify(query);
1199
+ const base = join(resultsDir(), `${ts}_${slug}`);
1200
+
1201
+ writeFileSync(`${base}.json`, json, "utf8");
1202
+
1203
+ if (synthesize && data._synthesis?.answer) {
1204
+ writeFileSync(`${base}-synthesis.md`, data._synthesis.answer, "utf8");
1205
+ process.stdout.write(`${base}-synthesis.md\n`);
1206
+ } else {
1207
+ process.stdout.write(`${base}.json\n`);
1208
+ }
1209
+ }
1210
+
1211
+ const GREEDY_PROFILE_DIR = `${tmpdir().replace(/\\/g, "/")}/greedysearch-chrome-profile`;
1212
+ const ACTIVE_PORT_FILE = `${GREEDY_PROFILE_DIR}/DevToolsActivePort`;
1213
+
1214
+ // Tell cdp.mjs to prefer the GreedySearch Chrome profile's DevToolsActivePort,
1215
+ // so searches never accidentally attach to the user's main Chrome session.
1216
+ process.env.CDP_PROFILE_DIR = GREEDY_PROFILE_DIR;
1217
+
1218
+ function probeGreedyChrome(timeoutMs = 3000) {
1219
+ return new Promise((resolve) => {
1220
+ const req = http.get(
1221
+ `http://localhost:${GREEDY_PORT}/json/version`,
1222
+ (res) => {
1223
+ res.resume();
1224
+ resolve(res.statusCode === 200);
1225
+ },
1226
+ );
1227
+ req.on("error", () => resolve(false));
1228
+ req.setTimeout(timeoutMs, () => {
1229
+ req.destroy();
1230
+ resolve(false);
1231
+ });
1232
+ });
1233
+ }
1234
+
1235
+ // Write (or refresh) the DevToolsActivePort file for the GreedySearch Chrome so
1236
+ // cdp.mjs always connects to the right port rather than the user's main Chrome.
1237
+ // Uses atomic write (write to temp + rename) to prevent corruption from parallel processes.
955
1238
  async function refreshPortFile() {
956
1239
  const LOCK_FILE = `${ACTIVE_PORT_FILE}.lock`;
957
1240
  const TEMP_FILE = `${ACTIVE_PORT_FILE}.tmp`;
1241
+ const LOCK_STALE_MS = 5000;
1242
+ const LOCK_WAIT_MS = 1000;
958
1243
 
959
- // Simple file-based lock with timeout (prevents parallel writes from corrupting the port file)
1244
+ // File-based lock with exclusive create + stale lock recovery
960
1245
  const lockAcquired = await new Promise((resolve) => {
961
1246
  const start = Date.now();
962
1247
  const tryLock = () => {
963
1248
  try {
964
- writeFileSync(LOCK_FILE, `${process.pid}`, "utf8");
1249
+ const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
1250
+ writeFileSync(LOCK_FILE, payload, { encoding: "utf8", flag: "wx" });
965
1251
  resolve(true);
966
- } catch {
967
- // Lock file exists - check if stale (older than 5 seconds)
1252
+ } catch (e) {
1253
+ if (e?.code !== "EEXIST") {
1254
+ if (Date.now() - start < LOCK_WAIT_MS) {
1255
+ setTimeout(tryLock, 50);
1256
+ } else {
1257
+ resolve(false);
1258
+ }
1259
+ return;
1260
+ }
1261
+
968
1262
  try {
969
- const lockTime = parseInt(readFileSync(LOCK_FILE, "utf8"), 10);
970
- if (Date.now() - lockTime > 5000) {
971
- // Stale lock - overwrite
972
- writeFileSync(LOCK_FILE, `${process.pid}`, "utf8");
973
- resolve(true);
974
- } else if (Date.now() - start < 1000) {
1263
+ const lockRaw = readFileSync(LOCK_FILE, "utf8").trim();
1264
+ const parsed = lockRaw.startsWith("{")
1265
+ ? JSON.parse(lockRaw)
1266
+ : { ts: Number(lockRaw) };
1267
+ const lockTime = Number(parsed?.ts) || 0;
1268
+
1269
+ if (lockTime > 0 && Date.now() - lockTime > LOCK_STALE_MS) {
1270
+ try {
1271
+ unlinkSync(LOCK_FILE);
1272
+ } catch {}
1273
+ }
1274
+
1275
+ if (Date.now() - start < LOCK_WAIT_MS) {
975
1276
  setTimeout(tryLock, 50);
976
1277
  } else {
977
- resolve(false); // Give up after 1s
1278
+ resolve(false);
978
1279
  }
979
1280
  } catch {
980
- setTimeout(tryLock, 50);
1281
+ if (Date.now() - start < LOCK_WAIT_MS) {
1282
+ setTimeout(tryLock, 50);
1283
+ } else {
1284
+ resolve(false);
1285
+ }
981
1286
  }
982
1287
  }
983
1288
  };
984
- tryLock();
985
- });
986
-
987
- try {
988
- const body = await new Promise((res, rej) => {
989
- const req = http.get(
990
- `http://localhost:${GREEDY_PORT}/json/version`,
991
- (r) => {
992
- let b = "";
993
- r.on("data", (d) => (b += d));
994
- r.on("end", () => res(b));
995
- },
996
- );
997
- req.on("error", rej);
998
- req.setTimeout(3000, () => {
999
- req.destroy();
1000
- rej(new Error("timeout"));
1001
- });
1002
- });
1003
- const { webSocketDebuggerUrl } = JSON.parse(body);
1004
- const wsPath = new URL(webSocketDebuggerUrl).pathname;
1005
-
1006
- // Atomic write: write to temp file, then rename
1007
- if (lockAcquired) {
1008
- writeFileSync(TEMP_FILE, `${GREEDY_PORT}\n${wsPath}`, "utf8");
1009
- try {
1010
- unlinkSync(ACTIVE_PORT_FILE);
1011
- } catch {}
1012
- renameSync(TEMP_FILE, ACTIVE_PORT_FILE);
1013
- }
1014
- } catch {
1015
- /* best-effort — launch.mjs already wrote the file on first start */
1016
- } finally {
1017
- if (lockAcquired) {
1018
- try {
1019
- unlinkSync(LOCK_FILE);
1020
- } catch {}
1021
- }
1022
- }
1023
- }
1024
-
1025
- async function ensureChrome() {
1026
- const ready = await probeGreedyChrome();
1027
- if (!ready) {
1028
- process.stderr.write(
1029
- `GreedySearch Chrome not running on port ${GREEDY_PORT} — auto-launching...\n`,
1030
- );
1031
- await new Promise((resolve, reject) => {
1032
- const proc = spawn("node", [join(__dir, "launch.mjs")], {
1033
- stdio: ["ignore", process.stderr, process.stderr],
1034
- });
1035
- proc.on("close", (code) =>
1036
- code === 0 ? resolve() : reject(new Error("launch.mjs failed")),
1037
- );
1038
- });
1039
- } else {
1040
- // Chrome already running — refresh the port file so cdp.mjs always picks
1041
- // up the right port, even if the file was stale from a previous session.
1042
- await refreshPortFile();
1043
- }
1044
- }
1045
-
1046
- async function main() {
1047
- const args = process.argv.slice(2);
1048
- if (args.length < 2 || args[0] === "--help") {
1049
- process.stderr.write(
1050
- `${[
1051
- 'Usage: node search.mjs <engine> "<query>"',
1052
- "",
1053
- "Engines: perplexity (p), bing (b), google (g), gemini (gem), all",
1054
- "",
1055
- "Flags:",
1056
- " --full Return complete answers (~3000+ chars)",
1057
- " --synthesize Synthesize results via Gemini (adds ~30s)",
1058
- " --deep-research Full research: full answers + source fetching + synthesis",
1059
- " --fetch-top-source Fetch content from top source",
1060
- " --inline Output JSON to stdout (for piping)",
1061
- "",
1062
- "Examples:",
1063
- ' node search.mjs p "what is memoization"',
1064
- ' node search.mjs all "TCP congestion control"',
1065
- ' node search.mjs all "RAG vs fine-tuning" --deep-research',
1066
- ].join("\n")}\n`,
1067
- );
1068
- process.exit(1);
1069
- }
1070
-
1071
- await ensureChrome();
1072
-
1073
- // Parse --depth or fall back to deprecated flags
1074
- const depthIdx = args.indexOf("--depth");
1075
- let depth = "fast"; // default for single engine
1076
- if (depthIdx !== -1 && args[depthIdx + 1]) {
1077
- depth = args[depthIdx + 1];
1078
- } else if (args.includes("--deep-research")) {
1079
- depth = "deep";
1080
- } else if (args.includes("--synthesize")) {
1081
- depth = "standard";
1082
- }
1083
- // For "all" engine, default to standard if not specified
1084
- const engineArg = args.find((a) => !a.startsWith("--"))?.toLowerCase();
1085
- if (
1086
- engineArg === "all" &&
1087
- depthIdx === -1 &&
1088
- !args.includes("--deep-research") &&
1089
- !args.includes("--synthesize")
1090
- ) {
1091
- depth = "standard";
1092
- }
1093
-
1094
- const full = args.includes("--full") || depth === "deep";
1095
- const short = !full;
1096
- const fetchSource = args.includes("--fetch-top-source");
1097
- const inline = args.includes("--inline");
1098
- const outIdx = args.indexOf("--out");
1099
- const outFile = outIdx !== -1 ? args[outIdx + 1] : null;
1100
- const rest = args.filter(
1101
- (a, i) =>
1102
- a !== "--full" &&
1103
- a !== "--short" &&
1104
- a !== "--fetch-top-source" &&
1105
- a !== "--synthesize" &&
1106
- a !== "--deep-research" &&
1107
- a !== "--inline" &&
1108
- a !== "--depth" &&
1109
- (depthIdx === -1 || i !== depthIdx + 1) &&
1110
- (outIdx === -1 || i !== outIdx + 1),
1111
- );
1112
- const engine = rest[0].toLowerCase();
1113
- const query = rest.slice(1).join(" ");
1114
-
1115
- if (engine === "all") {
1116
- await cdp(["list"]); // refresh pages cache
1117
-
1118
- // PARALLEL-SAFE: Always create fresh tabs for each engine to avoid race conditions
1119
- // when multiple "all" searches run concurrently. Previously, reusing cached tabs
1120
- // caused ERR_ABORTED and Uncaught errors as multiple processes fought over the same tab.
1121
- const tabs = [];
1289
+ tryLock();
1290
+ });
1291
+
1292
+ try {
1293
+ const body = await new Promise((res, rej) => {
1294
+ const req = http.get(
1295
+ `http://localhost:${GREEDY_PORT}/json/version`,
1296
+ (r) => {
1297
+ let b = "";
1298
+ r.on("data", (d) => (b += d));
1299
+ r.on("end", () => res(b));
1300
+ },
1301
+ );
1302
+ req.on("error", rej);
1303
+ req.setTimeout(3000, () => {
1304
+ req.destroy();
1305
+ rej(new Error("timeout"));
1306
+ });
1307
+ });
1308
+ const { webSocketDebuggerUrl } = JSON.parse(body);
1309
+ const wsPath = new URL(webSocketDebuggerUrl).pathname;
1310
+
1311
+ // Atomic write: write to temp file, then rename
1312
+ if (lockAcquired) {
1313
+ writeFileSync(TEMP_FILE, `${GREEDY_PORT}\n${wsPath}`, "utf8");
1314
+ try {
1315
+ unlinkSync(ACTIVE_PORT_FILE);
1316
+ } catch {}
1317
+ renameSync(TEMP_FILE, ACTIVE_PORT_FILE);
1318
+ }
1319
+ } catch {
1320
+ /* best-effort — launch.mjs already wrote the file on first start */
1321
+ } finally {
1322
+ if (lockAcquired) {
1323
+ try {
1324
+ unlinkSync(LOCK_FILE);
1325
+ } catch {}
1326
+ }
1327
+ }
1328
+ }
1329
+
1330
+ async function ensureChrome() {
1331
+ const ready = await probeGreedyChrome();
1332
+ if (!ready) {
1333
+ process.stderr.write(
1334
+ `GreedySearch Chrome not running on port ${GREEDY_PORT} — auto-launching...\n`,
1335
+ );
1336
+ await new Promise((resolve, reject) => {
1337
+ const proc = spawn("node", [join(__dir, "launch.mjs")], {
1338
+ stdio: ["ignore", process.stderr, process.stderr],
1339
+ });
1340
+ proc.on("close", (code) =>
1341
+ code === 0 ? resolve() : reject(new Error("launch.mjs failed")),
1342
+ );
1343
+ });
1344
+ } else {
1345
+ // Chrome already running — refresh the port file so cdp.mjs always picks
1346
+ // up the right port, even if the file was stale from a previous session.
1347
+ await refreshPortFile();
1348
+ }
1349
+ }
1350
+
1351
+ async function main() {
1352
+ const args = process.argv.slice(2);
1353
+ if (args.length < 2 || args[0] === "--help") {
1354
+ process.stderr.write(
1355
+ `${[
1356
+ 'Usage: node search.mjs <engine> "<query>"',
1357
+ "",
1358
+ "Engines: perplexity (p), bing (b), google (g), gemini (gem), all",
1359
+ "",
1360
+ "Flags:",
1361
+ " --fast Quick mode: no source fetching or synthesis",
1362
+ " --synthesize Deprecated: synthesis is now default for multi-engine",
1363
+ " --deep-research Deprecated: source fetching is now default",
1364
+ " --fetch-top-source Fetch content from top source",
1365
+ " --inline Output JSON to stdout (for piping)",
1366
+ "",
1367
+ "Examples:",
1368
+ ' node search.mjs all "Node.js streams" # Default: sources + synthesis',
1369
+ ' node search.mjs all "quick check" --fast # Fast: no sources/synthesis',
1370
+ ' node search.mjs p "what is memoization" # Single engine: fast mode',
1371
+ ].join("\n")}\n`,
1372
+ );
1373
+ process.exit(1);
1374
+ }
1375
+
1376
+ await ensureChrome();
1377
+
1378
+ // Depth modes: fast (no synthesis/fetch), standard (synthesis+fetch 5 sources)
1379
+ const depthIdx = args.indexOf("--depth");
1380
+ let depth = "standard"; // DEFAULT: all "all" searches now include synthesis + source fetch
1381
+
1382
+ if (depthIdx !== -1 && args[depthIdx + 1]) {
1383
+ depth = args[depthIdx + 1];
1384
+ } else if (args.includes("--fast")) {
1385
+ depth = "fast"; // Explicit fast mode requested
1386
+ }
1387
+
1388
+ // For single engine (not "all"), default to fast unless explicit
1389
+ const engineArg = args.find((a) => !a.startsWith("--"))?.toLowerCase();
1390
+ if (engineArg !== "all" && depthIdx === -1 && !args.includes("--fast")) {
1391
+ // Single engine: default to fast for speed (no synthesis overhead)
1392
+ depth = "fast";
1393
+ }
1394
+
1395
+ // --deep-research flag maps to standard (backward compat)
1396
+ if (args.includes("--deep-research")) {
1397
+ depth = "standard";
1398
+ }
1399
+
1400
+ // For "all" engine with no explicit flags, standard is already default
1401
+
1402
+ const full = args.includes("--full");
1403
+ const short = !full;
1404
+ const fetchSource = args.includes("--fetch-top-source");
1405
+ const inline = args.includes("--inline");
1406
+ const outIdx = args.indexOf("--out");
1407
+ const outFile = outIdx !== -1 ? args[outIdx + 1] : null;
1408
+ const rest = args.filter(
1409
+ (a, i) =>
1410
+ a !== "--full" &&
1411
+ a !== "--short" &&
1412
+ a !== "--fast" &&
1413
+ a !== "--fetch-top-source" &&
1414
+ a !== "--synthesize" &&
1415
+ a !== "--deep-research" &&
1416
+ a !== "--inline" &&
1417
+ a !== "--depth" &&
1418
+ a !== "--out" &&
1419
+ (depthIdx === -1 || i !== depthIdx + 1) &&
1420
+ (outIdx === -1 || i !== outIdx + 1),
1421
+ );
1422
+ const engine = rest[0].toLowerCase();
1423
+ const query = rest.slice(1).join(" ");
1424
+
1425
+ if (engine === "all") {
1426
+ await cdp(["list"]); // refresh pages cache
1427
+
1428
+ // PARALLEL-SAFE: Always create fresh tabs for each engine to avoid race conditions
1429
+ // when multiple "all" searches run concurrently. Previously, reusing cached tabs
1430
+ // caused ERR_ABORTED and Uncaught errors as multiple processes fought over the same tab.
1431
+ const engineTabs = [];
1122
1432
  for (let i = 0; i < ALL_ENGINES.length; i++) {
1123
1433
  if (i > 0) await new Promise((r) => setTimeout(r, 300)); // small delay between tab opens
1124
1434
  const tab = await openNewTab();
1125
- tabs.push(tab);
1435
+ engineTabs.push(tab);
1126
1436
  }
1127
-
1128
- // All tabs assigned — run extractors in parallel
1129
- try {
1130
- const results = await Promise.allSettled(
1437
+
1438
+ // All tabs assigned — run extractors in parallel
1439
+ try {
1440
+ const results = await Promise.allSettled(
1131
1441
  ALL_ENGINES.map((e, i) =>
1132
- runExtractor(ENGINES[e], query, tabs[i], short)
1133
- .then((r) => {
1134
- process.stderr.write(`PROGRESS:${e}:done\n`);
1135
- return { engine: e, ...r };
1136
- })
1137
- .catch((err) => {
1138
- process.stderr.write(`PROGRESS:${e}:error\n`);
1139
- throw err;
1140
- }),
1141
- ),
1142
- );
1143
-
1144
- const out = {};
1145
- for (let i = 0; i < results.length; i++) {
1146
- const r = results[i];
1147
- if (r.status === "fulfilled") {
1148
- out[r.value.engine] = r.value;
1149
- } else {
1150
- out[ALL_ENGINES[i]] = { error: r.reason?.message || "unknown error" };
1151
- }
1152
- }
1153
-
1154
- await closeTabs(tabs);
1155
-
1156
- // Build a canonical source registry across all engines
1157
- out._sources = buildSourceRegistry(out);
1158
-
1159
- if (depth === "deep") {
1160
- process.stderr.write("PROGRESS:deep-research:start\n");
1161
- const fetchedSources =
1162
- out._sources.length > 0
1163
- ? await fetchMultipleSources(out._sources, 5, 8000)
1164
- : [];
1165
-
1166
- out._sources = mergeFetchDataIntoSources(out._sources, fetchedSources);
1167
- out._fetchedSources = fetchedSources;
1168
- process.stderr.write(
1169
- out._sources.length > 0
1170
- ? "PROGRESS:deep-research:done\n"
1171
- : "PROGRESS:deep-research:no-sources\n",
1172
- );
1173
- }
1174
-
1175
- // Synthesize with Gemini for standard and deep modes
1176
- if (depth !== "fast") {
1177
- process.stderr.write("PROGRESS:synthesis:start\n");
1178
- process.stderr.write(
1179
- "[greedysearch] Synthesizing results with Gemini...\n",
1180
- );
1442
+ runExtractor(ENGINES[e], query, engineTabs[i], short)
1443
+ .then((r) => {
1444
+ process.stderr.write(`PROGRESS:${e}:done\n`);
1445
+ return { engine: e, ...r };
1446
+ })
1447
+ .catch((err) => {
1448
+ process.stderr.write(`PROGRESS:${e}:error\n`);
1449
+ throw err;
1450
+ }),
1451
+ ),
1452
+ );
1453
+
1454
+ const out = {};
1455
+ for (let i = 0; i < results.length; i++) {
1456
+ const r = results[i];
1457
+ if (r.status === "fulfilled") {
1458
+ out[r.value.engine] = r.value;
1459
+ } else {
1460
+ out[ALL_ENGINES[i]] = { error: r.reason?.message || "unknown error" };
1461
+ }
1462
+ }
1463
+
1464
+ await closeTabs(engineTabs);
1465
+
1466
+ // Build a canonical source registry across all engines
1467
+ out._sources = buildSourceRegistry(out, query);
1468
+
1469
+ // Source fetching: default for all "all" searches (was deep-research only)
1470
+ if (depth !== "fast" && out._sources.length > 0) {
1471
+ process.stderr.write("PROGRESS:source-fetch:start\n");
1472
+ const fetchedSources = await fetchMultipleSources(
1473
+ out._sources,
1474
+ 5,
1475
+ 8000,
1476
+ );
1477
+
1478
+ out._sources = mergeFetchDataIntoSources(out._sources, fetchedSources);
1479
+ out._fetchedSources = fetchedSources;
1480
+ process.stderr.write("PROGRESS:source-fetch:done\n");
1481
+ }
1482
+
1483
+ // Synthesize with Gemini for all non-fast modes (now default)
1484
+ if (depth !== "fast") {
1485
+ process.stderr.write("PROGRESS:synthesis:start\n");
1486
+ process.stderr.write(
1487
+ "[greedysearch] Synthesizing results with Gemini...\n",
1488
+ );
1181
1489
  try {
1182
- // Create fresh Gemini tab per search (not cached) to avoid conflicts in parallel searches
1183
- const geminiTab = await openNewTab();
1184
- tabs.push(geminiTab); // ensure cleanup in finally block
1490
+ const geminiTab = await getOrOpenEngineTab("gemini");
1185
1491
  await activateTab(geminiTab);
1186
1492
  const synthesis = await synthesizeWithGemini(query, out, {
1187
1493
  grounded: depth === "deep",
1188
1494
  tabPrefix: geminiTab,
1189
1495
  });
1496
+ await activateTab(geminiTab);
1190
1497
  out._synthesis = {
1191
1498
  ...synthesis,
1192
1499
  synthesized: true,
1193
1500
  };
1194
- process.stderr.write("PROGRESS:synthesis:done\n");
1195
- } catch (e) {
1196
- process.stderr.write(
1197
- `[greedysearch] Synthesis failed: ${e.message}\n`,
1198
- );
1199
- out._synthesis = { error: e.message, synthesized: false };
1200
- }
1201
- }
1202
-
1203
- if (fetchSource) {
1204
- const top = pickTopSource(out);
1205
- if (top)
1206
- out._topSource = await fetchTopSource(top.canonicalUrl || top.url);
1207
- }
1208
-
1209
- if (depth === "deep") out._confidence = buildConfidence(out);
1210
-
1211
- writeOutput(out, outFile, {
1212
- inline,
1213
- synthesize: depth !== "fast",
1214
- query,
1215
- });
1216
- return;
1217
- } finally {
1218
- await closeTabs(tabs);
1501
+ process.stderr.write("PROGRESS:synthesis:done\n");
1502
+ } catch (e) {
1503
+ process.stderr.write(
1504
+ `[greedysearch] Synthesis failed: ${e.message}\n`,
1505
+ );
1506
+ out._synthesis = { error: e.message, synthesized: false };
1507
+ }
1508
+ }
1509
+
1510
+ if (fetchSource) {
1511
+ const top = pickTopSource(out);
1512
+ if (top)
1513
+ out._topSource = await fetchTopSource(top.canonicalUrl || top.url);
1514
+ }
1515
+
1516
+ // Always include confidence metrics for non-fast searches
1517
+ if (depth !== "fast") out._confidence = buildConfidence(out);
1518
+
1519
+ writeOutput(out, outFile, {
1520
+ inline,
1521
+ synthesize: depth !== "fast",
1522
+ query,
1523
+ });
1524
+ return;
1525
+ } finally {
1526
+ await closeTabs(engineTabs);
1219
1527
  }
1220
1528
  }
1221
-
1222
- const script = ENGINES[engine];
1223
- if (!script) {
1224
- process.stderr.write(
1225
- `Unknown engine: "${engine}"\nAvailable: ${Object.keys(ENGINES).join(", ")}\n`,
1226
- );
1227
- process.exit(1);
1228
- }
1229
-
1230
- try {
1231
- const result = await runExtractor(script, query, null, short);
1232
- if (fetchSource && result.sources?.length > 0) {
1233
- result.topSource = await fetchTopSource(result.sources[0].url);
1234
- }
1235
- writeOutput(result, outFile, { inline, synthesize: false, query });
1236
- } catch (e) {
1237
- process.stderr.write(`Error: ${e.message}\n`);
1238
- process.exit(1);
1239
- }
1240
- }
1241
-
1242
- main();
1529
+
1530
+ const script = ENGINES[engine];
1531
+ if (!script) {
1532
+ process.stderr.write(
1533
+ `Unknown engine: "${engine}"\nAvailable: ${Object.keys(ENGINES).join(", ")}\n`,
1534
+ );
1535
+ process.exit(1);
1536
+ }
1537
+
1538
+ try {
1539
+ const result = await runExtractor(script, query, null, short);
1540
+ if (fetchSource && result.sources?.length > 0) {
1541
+ result.topSource = await fetchTopSource(result.sources[0].url);
1542
+ }
1543
+ writeOutput(result, outFile, { inline, synthesize: false, query });
1544
+ } catch (e) {
1545
+ process.stderr.write(`Error: ${e.message}\n`);
1546
+ process.exit(1);
1547
+ }
1548
+ }
1549
+
1550
+ main();