@apmantza/greedysearch-pi 1.9.2 → 2.1.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +132 -2
  2. package/README.md +82 -47
  3. package/bin/cdp.mjs +1153 -1108
  4. package/bin/launch.mjs +9 -0
  5. package/bin/search.mjs +318 -81
  6. package/extractors/bing-copilot.mjs +48 -18
  7. package/extractors/chatgpt.mjs +553 -0
  8. package/extractors/common.mjs +213 -22
  9. package/extractors/consensus.mjs +655 -0
  10. package/extractors/consent.mjs +182 -18
  11. package/extractors/gemini.mjs +350 -217
  12. package/extractors/google-ai.mjs +129 -128
  13. package/extractors/logically.mjs +629 -0
  14. package/extractors/perplexity.mjs +547 -217
  15. package/extractors/selectors.mjs +3 -2
  16. package/extractors/semantic-scholar.mjs +219 -0
  17. package/package.json +8 -4
  18. package/skills/greedy-search/skill.md +20 -12
  19. package/src/fetcher.mjs +23 -1
  20. package/src/formatters/results.ts +185 -128
  21. package/src/search/browser-lifecycle.mjs +27 -5
  22. package/src/search/challenge-detect.mjs +205 -0
  23. package/src/search/chrome.mjs +653 -590
  24. package/src/search/constants.mjs +155 -39
  25. package/src/search/engines.mjs +114 -76
  26. package/src/search/fetch-source.mjs +566 -451
  27. package/src/search/pdf.mjs +68 -0
  28. package/src/search/progress.mjs +145 -0
  29. package/src/search/recovery.mjs +73 -45
  30. package/src/search/research.mjs +1419 -62
  31. package/src/search/scale-aware.mjs +93 -0
  32. package/src/search/simple-research.mjs +520 -0
  33. package/src/search/sources.mjs +52 -22
  34. package/src/search/synthesis-runner.mjs +105 -26
  35. package/src/search/synthesis.mjs +286 -246
  36. package/src/tools/greedy-search-handler.ts +129 -59
  37. package/src/tools/shared.ts +312 -186
  38. package/src/types.ts +110 -104
  39. package/test.mjs +537 -18
@@ -6,23 +6,56 @@
6
6
  // no-API browser engines and source fetchers instead of Firecrawl/OpenAI.
7
7
 
8
8
  import { spawn } from "node:child_process";
9
+ import { mkdirSync, writeFileSync } from "node:fs";
9
10
  import { join } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import {
12
13
  buildSourceRegistry,
14
+ classifySourceType,
13
15
  computeCompositeScore,
14
16
  mergeFetchDataIntoSources,
15
17
  normalizeUrl,
16
18
  trimText,
17
19
  } from "./sources.mjs";
18
20
  import { parseStructuredJson } from "./synthesis.mjs";
21
+ import { RESEARCH_ENGINES } from "./constants.mjs";
19
22
  import { runGeminiPrompt } from "./synthesis-runner.mjs";
23
+ import { classifyResearchComplexity } from "./scale-aware.mjs";
24
+ import { runSimpleResearchMode } from "./simple-research.mjs";
25
+ import { createProgressTracker } from "./progress.mjs";
20
26
 
21
27
  const __dir = fileURLToPath(new URL(".", import.meta.url)).replace(
22
28
  /^\/([A-Z]:)/,
23
29
  "$1",
24
30
  );
25
31
  const SEARCH_BIN = join(__dir, "..", "..", "bin", "search.mjs");
32
+ const DEFAULT_RESEARCH_BUNDLE_ROOT = join(
33
+ process.cwd(),
34
+ ".pi",
35
+ "greedysearch-research",
36
+ );
37
+
38
+ function slugifyResearchName(value) {
39
+ const slug = String(value || "research")
40
+ .toLowerCase()
41
+ .replaceAll(/[^a-z0-9]+/g, "-")
42
+ .replaceAll(/^-|-$/g, "")
43
+ .slice(0, 60);
44
+ return slug || "research";
45
+ }
46
+
47
+ function uniqueStrings(items, limit = Infinity) {
48
+ const seen = new Set();
49
+ const out = [];
50
+ for (const item of items || []) {
51
+ const clean = trimText(String(item || ""), 1000);
52
+ if (!clean || seen.has(clean)) continue;
53
+ seen.add(clean);
54
+ out.push(clean);
55
+ if (out.length >= limit) break;
56
+ }
57
+ return out;
58
+ }
26
59
 
27
60
  async function fetchMultipleResearchSources(...args) {
28
61
  const { fetchMultipleSources } = await import("./fetch-source.mjs");
@@ -327,22 +360,28 @@ export function buildFallbackQueriesFromGaps(
327
360
  ) {
328
361
  const fallbacks = [];
329
362
  const angles = [
330
- { template: (g) => `${g} official documentation`, label: "official docs" },
331
363
  {
332
- template: (g) => `${g} GitHub issues discussions`,
364
+ template: (gap) => `${gap} official documentation`,
365
+ label: "official docs",
366
+ },
367
+ {
368
+ template: (gap) => `${gap} GitHub issues discussions`,
333
369
  label: "community signals",
334
370
  },
335
371
  {
336
- template: (g) => `${g} benchmarks performance comparison`,
372
+ template: (gap) => `${gap} benchmarks performance comparison`,
337
373
  label: "benchmarks",
338
374
  },
339
- { template: (g) => `${g} limitations risks caveats`, label: "limitations" },
340
375
  {
341
- template: (g) => `${g} production deployment experience`,
376
+ template: (gap) => `${gap} limitations risks caveats`,
377
+ label: "limitations",
378
+ },
379
+ {
380
+ template: (gap) => `${gap} production deployment experience`,
342
381
  label: "production usage",
343
382
  },
344
383
  {
345
- template: (g) => `${originalQuery} ${g} counter evidence`,
384
+ template: (gap) => `${originalQuery} ${gap} counter evidence`,
346
385
  label: "counter-evidence",
347
386
  },
348
387
  ];
@@ -350,7 +389,7 @@ export function buildFallbackQueriesFromGaps(
350
389
  for (let i = 0; i < gaps.length && fallbacks.length < nextBreadth; i++) {
351
390
  const gap = gaps[i];
352
391
  const angle = angles[i % angles.length];
353
- const candidate = angle.template(originalQuery, gap);
392
+ const candidate = angle.template(gap);
354
393
  if (!isDuplicateQuery(candidate, usedQueries, { roundIndex })) {
355
394
  fallbacks.push({
356
395
  query: candidate,
@@ -438,7 +477,9 @@ async function evaluateResearchQuality(
438
477
 
439
478
  function summarizeEngineAnswers(result) {
440
479
  const summaries = {};
441
- for (const engine of ["perplexity", "bing", "google"]) {
480
+ for (const engine of Object.keys(result || {}).filter(
481
+ (key) => !key.startsWith("_"),
482
+ )) {
442
483
  const value = result?.[engine];
443
484
  if (!value) continue;
444
485
  summaries[engine] = value.error
@@ -598,9 +639,10 @@ async function executeResearchAction(
598
639
  engines: ["fetch"],
599
640
  engineCount: 1,
600
641
  perEngine: {},
601
- sourceType: classifySourceTypeFromDomain(
642
+ sourceType: classifySourceType(
602
643
  domain,
603
644
  fetchResult.title || "",
645
+ fetchResult.finalUrl || normalizedUrl,
604
646
  ),
605
647
  isOfficial: false,
606
648
  smartScore: 0,
@@ -737,30 +779,6 @@ function getDomainFromUrl(rawUrl) {
737
779
  }
738
780
  }
739
781
 
740
- function classifySourceTypeFromDomain(domain, title = "") {
741
- const { matchesDomain, SOCIAL_HOSTS, COMMUNITY_HOSTS, NEWS_HOSTS } =
742
- require("./sources.mjs");
743
- const lowerTitle = title.toLowerCase();
744
-
745
- if (domain === "github.com" || domain === "gitlab.com") return "repo";
746
- if (matchesDomain(domain, SOCIAL_HOSTS)) return "social";
747
- if (matchesDomain(domain, COMMUNITY_HOSTS)) return "community";
748
- if (matchesDomain(domain, NEWS_HOSTS)) return "news";
749
- if (
750
- domain.startsWith("docs.") ||
751
- domain.startsWith("developer.") ||
752
- domain.startsWith("developers.") ||
753
- domain.startsWith("api.") ||
754
- lowerTitle.includes("documentation") ||
755
- lowerTitle.includes("docs") ||
756
- lowerTitle.includes("reference")
757
- ) {
758
- return "official-docs";
759
- }
760
- if (domain.startsWith("blog.")) return "maintainer-blog";
761
- return "website";
762
- }
763
-
764
782
  /**
765
783
  * Normalize a GitHub root/tree URL into specific fetchable pages.
766
784
  * Expands github.com/owner/repo into [README, CONTRIBUTING, CHANGELOG, key files].
@@ -855,11 +873,161 @@ export function queriesToActions(queries) {
855
873
  .filter((a) => a.query);
856
874
  }
857
875
 
876
+ function sourceKey(source) {
877
+ return (
878
+ normalizeUrl(
879
+ source?.finalUrl || source?.canonicalUrl || source?.url || "",
880
+ ) ||
881
+ source?.id ||
882
+ ""
883
+ );
884
+ }
885
+
886
+ function buildEvidenceExtractionPrompt(
887
+ originalQuery,
888
+ questions,
889
+ fetchedSources,
890
+ alreadyExtracted = new Set(),
891
+ ) {
892
+ const openQuestions = (questions || [])
893
+ .filter((q) => q.status !== "closed")
894
+ .slice(0, 12)
895
+ .map((q) => ({ id: q.id, question: q.question }));
896
+ const sourceSnippets = (fetchedSources || [])
897
+ .filter((source) => source?.content || source?.snippet)
898
+ .filter((source) => !alreadyExtracted.has(sourceKey(source)))
899
+ .slice(0, 6)
900
+ .map((source, index) => ({
901
+ id: source.id || `F${index + 1}`,
902
+ title: source.title || "",
903
+ url: source.finalUrl || source.url || source.canonicalUrl || "",
904
+ content: trimText(source.content || source.snippet || "", 5000),
905
+ }));
906
+
907
+ return [
908
+ "You are doing goal-based evidence extraction for an iterative research run.",
909
+ "For each source, extract only information that helps answer the open questions.",
910
+ "Use original wording/details where useful. Do not invent answers; leave questions open if evidence is insufficient.",
911
+ "If a source answers one or more tracked questions, identify those question IDs explicitly.",
912
+ "Also propose genuinely new sub-questions discovered from the evidence.",
913
+ "",
914
+ `Original research question: ${originalQuery}`,
915
+ `Open question ledger: ${JSON.stringify(openQuestions, null, 2)}`,
916
+ `Fetched sources: ${JSON.stringify(sourceSnippets, null, 2)}`,
917
+ "",
918
+ "Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
919
+ "BEGIN_JSON",
920
+ JSON.stringify(
921
+ {
922
+ extractions: [
923
+ {
924
+ sourceId: "S1",
925
+ url: "https://example.com/source",
926
+ rational: "why this source matters for the goal",
927
+ evidence:
928
+ "specific quoted/paraphrased evidence with numbers, dates, caveats",
929
+ summary: "concise contribution to the research question",
930
+ answers: [
931
+ {
932
+ id: "Q1",
933
+ evidence: "brief evidence that closes the question",
934
+ },
935
+ ],
936
+ newQuestions: ["new sub-question raised by this source"],
937
+ },
938
+ ],
939
+ },
940
+ null,
941
+ 2,
942
+ ),
943
+ "END_JSON",
944
+ ].join("\n");
945
+ }
946
+
947
+ function normalizeEvidenceExtractions(payload, fetchedSources) {
948
+ const raw = Array.isArray(payload?.extractions) ? payload.extractions : [];
949
+ const byUrl = new Map();
950
+ const byId = new Map();
951
+ for (const source of fetchedSources || []) {
952
+ if (source?.id) byId.set(String(source.id), source);
953
+ const key = sourceKey(source);
954
+ if (key) byUrl.set(key, source);
955
+ }
956
+ return raw
957
+ .map((item) => {
958
+ const source =
959
+ byId.get(String(item?.sourceId || "")) ||
960
+ byUrl.get(normalizeUrl(item?.url || "") || "");
961
+ const sourceId = String(item?.sourceId || source?.id || "");
962
+ const url = normalizeUrl(
963
+ item?.url || source?.finalUrl || source?.url || "",
964
+ );
965
+ const answers = Array.isArray(item?.answers)
966
+ ? item.answers
967
+ .map((answer) => ({
968
+ id: String(answer?.id || ""),
969
+ evidence: trimText(answer?.evidence || "", 500),
970
+ sourceIds: [sourceId].filter(Boolean),
971
+ }))
972
+ .filter((answer) => answer.id)
973
+ : [];
974
+ return {
975
+ sourceId,
976
+ url,
977
+ title: source?.title || item?.title || "",
978
+ rational: trimText(item?.rational || "", 700),
979
+ evidence: trimText(item?.evidence || "", 1600),
980
+ summary: trimText(item?.summary || "", 700),
981
+ answers,
982
+ newQuestions: uniqueStrings(item?.newQuestions || [], 6),
983
+ };
984
+ })
985
+ .filter(
986
+ (item) => item.sourceId || item.url || item.summary || item.evidence,
987
+ );
988
+ }
989
+
990
+ export async function extractEvidenceFromSources({
991
+ query,
992
+ questions,
993
+ fetchedSources,
994
+ extractedSourceKeys,
995
+ }) {
996
+ const pending = (fetchedSources || []).filter(
997
+ (source) =>
998
+ (source?.content || source?.snippet) &&
999
+ !extractedSourceKeys.has(sourceKey(source)),
1000
+ );
1001
+ if (pending.length === 0) return { evidence: [], error: "" };
1002
+ try {
1003
+ const raw = await runGeminiPrompt(
1004
+ buildEvidenceExtractionPrompt(
1005
+ query,
1006
+ questions,
1007
+ pending,
1008
+ extractedSourceKeys,
1009
+ ),
1010
+ { timeoutMs: 120000 },
1011
+ );
1012
+ const parsed = parseGeminiJson(raw, { extractions: [] });
1013
+ const evidence = normalizeEvidenceExtractions(parsed, pending);
1014
+ for (const source of pending) {
1015
+ const key = sourceKey(source);
1016
+ if (key) extractedSourceKeys.add(key);
1017
+ }
1018
+ return { evidence, error: "" };
1019
+ } catch (error) {
1020
+ return { evidence: [], error: error.message || String(error) };
1021
+ }
1022
+ }
1023
+
858
1024
  function buildLearningPrompt(
859
1025
  originalQuery,
860
1026
  roundQueries,
861
1027
  searchSummaries,
862
1028
  fetchedSources,
1029
+ questions = [],
1030
+ evidenceItems = [],
863
1031
  ) {
864
1032
  const sourceSnippets = fetchedSources
865
1033
  .filter((source) => source?.content || source?.snippet)
@@ -878,6 +1046,8 @@ function buildLearningPrompt(
878
1046
  "",
879
1047
  `Original research question: ${originalQuery}`,
880
1048
  `Round queries: ${JSON.stringify(roundQueries, null, 2)}`,
1049
+ `Question ledger: ${JSON.stringify(questions, null, 2)}`,
1050
+ `Extracted source evidence: ${JSON.stringify(evidenceItems.slice(-12), null, 2)}`,
881
1051
  `Engine summaries: ${JSON.stringify(searchSummaries, null, 2)}`,
882
1052
  `Fetched source snippets: ${JSON.stringify(sourceSnippets, null, 2)}`,
883
1053
  "",
@@ -886,6 +1056,14 @@ function buildLearningPrompt(
886
1056
  JSON.stringify(
887
1057
  {
888
1058
  learnings: ["concise, information-dense learning"],
1059
+ answeredQuestions: [
1060
+ {
1061
+ id: "Q1",
1062
+ evidence: "brief evidence that closes this question",
1063
+ sourceIds: ["S1"],
1064
+ },
1065
+ ],
1066
+ newQuestions: ["new sub-question discovered from the evidence"],
889
1067
  followUpQueries: ["specific next search query"],
890
1068
  gaps: ["important uncertainty or missing evidence"],
891
1069
  },
@@ -896,7 +1074,13 @@ function buildLearningPrompt(
896
1074
  ].join("\n");
897
1075
  }
898
1076
 
899
- function buildFinalReportPrompt(originalQuery, rounds, sources) {
1077
+ export function buildFinalReportPrompt(
1078
+ originalQuery,
1079
+ rounds,
1080
+ sources,
1081
+ questions = [],
1082
+ evidenceItems = [],
1083
+ ) {
900
1084
  const learnings = rounds.flatMap((round) => round.learnings || []);
901
1085
  const gaps = rounds.flatMap((round) => round.gaps || []);
902
1086
  const sourceRegistry = sources.slice(0, 12).map((source) => ({
@@ -932,6 +1116,8 @@ function buildFinalReportPrompt(originalQuery, rounds, sources) {
932
1116
  `Original research question: ${originalQuery}`,
933
1117
  `Learnings: ${JSON.stringify(learnings, null, 2)}`,
934
1118
  `Known gaps/caveats: ${JSON.stringify(gaps, null, 2)}`,
1119
+ `Question ledger: ${JSON.stringify(questions, null, 2)}`,
1120
+ `Goal-based extracted evidence: ${JSON.stringify(evidenceItems.slice(-20), null, 2)}`,
935
1121
  `Source registry: ${JSON.stringify(sourceRegistry, null, 2)}`,
936
1122
  "",
937
1123
  "Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
@@ -961,6 +1147,81 @@ function buildFinalReportPrompt(originalQuery, rounds, sources) {
961
1147
  ].join("\n");
962
1148
  }
963
1149
 
1150
+ /**
1151
+ * Build a synthesis prompt that derives the final report directly from
1152
+ * previously extracted evidence (no per-round learnings required). This is
1153
+ * used as a fallback when the regular final-report path returns no
1154
+ * structured learnings (for example when Gemini's input field rejected the
1155
+ * per-round learning prompt but the goal-based extraction step succeeded).
1156
+ */
1157
+ export function buildSynthesisFromEvidencePrompt(
1158
+ originalQuery,
1159
+ sources = [],
1160
+ questions = [],
1161
+ evidenceItems = [],
1162
+ ) {
1163
+ const sourceRegistry = sources.slice(0, 12).map((source) => ({
1164
+ id: source.id,
1165
+ title: source.title,
1166
+ domain: source.domain,
1167
+ url: source.canonicalUrl,
1168
+ type: source.sourceType,
1169
+ engines: source.engines,
1170
+ }));
1171
+ const evidenceSlice = evidenceItems.slice(-20);
1172
+ const answerableQuestionIds = new Set();
1173
+ for (const item of evidenceSlice) {
1174
+ for (const ans of item.answers || []) {
1175
+ if (ans?.id) answerableQuestionIds.add(ans.id);
1176
+ }
1177
+ }
1178
+ const openQuestionSummary = (questions || [])
1179
+ .filter((q) => q.status !== "closed")
1180
+ .map((q) => ({ id: q.id, question: q.question }));
1181
+
1182
+ return [
1183
+ "You are writing the final research report from goal-based extracted evidence.",
1184
+ "Per-round learnings were not produced, but the per-source evidence extraction step succeeded.",
1185
+ "Synthesize a thorough markdown report using ONLY the evidence below. Every substantive claim MUST be backed by an [S1] citation.",
1186
+ "",
1187
+ "Report structure:",
1188
+ "1. ## Summary — A 2-4 sentence executive summary of findings",
1189
+ "2. ## Key Findings — The main findings, organized by theme or question, each with inline citations",
1190
+ "3. ## Limitations & Caveats — Important qualifiers, gaps, or uncertainties",
1191
+ "",
1192
+ `Original research question: ${originalQuery}`,
1193
+ `Per-source extracted evidence: ${JSON.stringify(evidenceSlice, null, 2)}`,
1194
+ `Source registry: ${JSON.stringify(sourceRegistry, null, 2)}`,
1195
+ `Questions already answered by the evidence: ${JSON.stringify(Array.from(answerableQuestionIds))}`,
1196
+ `Questions still open after this evidence: ${JSON.stringify(openQuestionSummary)}`,
1197
+ "",
1198
+ "Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
1199
+ "BEGIN_JSON",
1200
+ JSON.stringify(
1201
+ {
1202
+ answer: "markdown report with sections and inline [S1] citations",
1203
+ agreement: {
1204
+ level: "high|medium|low|mixed|conflicting",
1205
+ summary: "one-sentence confidence summary",
1206
+ },
1207
+ differences: ["notable disagreement or conflict between sources"],
1208
+ caveats: ["important caveat or qualification"],
1209
+ claims: [
1210
+ {
1211
+ claim: "specific factual statement supported by the evidence",
1212
+ support: "strong|moderate|weak|conflicting",
1213
+ sourceIds: ["S1", "S2"],
1214
+ },
1215
+ ],
1216
+ recommendedSources: ["S1", "S2"],
1217
+ },
1218
+ null,
1219
+ 2,
1220
+ ),
1221
+ "END_JSON",
1222
+ ].join("\n");
1223
+ }
1224
+
964
1225
  async function runFastAllSearch(query, { locale = null, short = true } = {}) {
965
1226
  const args = [SEARCH_BIN, "all", "--inline", "--stdin", "--fast"];
966
1227
  if (!short) args.push("--full");
@@ -1046,7 +1307,9 @@ function shouldForwardChildStderr(line) {
1046
1307
  return (
1047
1308
  /^PROGRESS:/.test(line) ||
1048
1309
  /^\[greedysearch\]/.test(line) ||
1049
- /^\[(bing|perplexity|google|gemini)\]/.test(line) ||
1310
+ /^\[(bing|perplexity|google|gemini|chatgpt|logically|semantic-scholar)\]/.test(
1311
+ line,
1312
+ ) ||
1050
1313
  /^GreedySearch Chrome/.test(line) ||
1051
1314
  /^Launching GreedySearch Chrome/.test(line) ||
1052
1315
  /^Headless mode/.test(line) ||
@@ -1141,6 +1404,811 @@ export function auditCitations(answer, sources) {
1141
1404
  };
1142
1405
  }
1143
1406
 
1407
+ /**
1408
+ * Check reachability of cited source URLs via HEAD requests.
1409
+ * Returns { reachable, dead, skipped } with per-URL status.
1410
+ */
1411
+ export async function checkCitationUrls(
1412
+ sources,
1413
+ { timeoutMs = 6000, concurrency = 4 } = {},
1414
+ ) {
1415
+ const safeConcurrency = Math.max(1, Math.floor(concurrency || 1));
1416
+ const citedSources = (sources || []).filter(
1417
+ (s) => s?.id && (s?.canonicalUrl || s?.finalUrl || s?.url),
1418
+ );
1419
+ if (citedSources.length === 0) {
1420
+ return { reachable: [], dead: [], skipped: [], ok: true };
1421
+ }
1422
+
1423
+ const reachable = [];
1424
+ const dead = [];
1425
+ const skipped = [];
1426
+
1427
+ // Process in batches to avoid overwhelming
1428
+ for (let i = 0; i < citedSources.length; i += safeConcurrency) {
1429
+ const batch = citedSources.slice(i, i + safeConcurrency);
1430
+ const results = await Promise.allSettled(
1431
+ batch.map(async (source) => {
1432
+ const url =
1433
+ source.fetch?.finalUrl ||
1434
+ source.canonicalUrl ||
1435
+ source.finalUrl ||
1436
+ source.url;
1437
+ if (!url) return { id: source.id, url: "", status: "skipped" };
1438
+
1439
+ // Skip non-HTTP URLs and known-unreachable patterns
1440
+ try {
1441
+ const parsed = new URL(url);
1442
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1443
+ return { id: source.id, url, status: "skipped" };
1444
+ }
1445
+ } catch {
1446
+ return { id: source.id, url, status: "skipped" };
1447
+ }
1448
+
1449
+ try {
1450
+ const controller = new AbortController();
1451
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1452
+ try {
1453
+ const response = await fetch(url, {
1454
+ method: "HEAD",
1455
+ redirect: "follow",
1456
+ signal: controller.signal,
1457
+ headers: {
1458
+ "User-Agent":
1459
+ "Mozilla/5.0 (compatible; GreedySearch/2.0; +https://github.com/apmantza/greedysearch-pi)",
1460
+ },
1461
+ });
1462
+ clearTimeout(timer);
1463
+ const ok = response.status >= 200 && response.status < 400;
1464
+ return {
1465
+ id: source.id,
1466
+ url,
1467
+ status: ok ? "reachable" : "dead",
1468
+ httpStatus: response.status,
1469
+ };
1470
+ } catch (fetchError) {
1471
+ clearTimeout(timer);
1472
+ return {
1473
+ id: source.id,
1474
+ url,
1475
+ status: "dead",
1476
+ error:
1477
+ fetchError.name === "AbortError"
1478
+ ? "timeout"
1479
+ : fetchError.message,
1480
+ };
1481
+ }
1482
+ } catch (error) {
1483
+ return {
1484
+ id: source.id,
1485
+ url,
1486
+ status: "dead",
1487
+ error: error.message,
1488
+ };
1489
+ }
1490
+ }),
1491
+ );
1492
+
1493
+ for (const result of results) {
1494
+ const value =
1495
+ result.status === "fulfilled"
1496
+ ? result.value
1497
+ : {
1498
+ id: "?",
1499
+ url: "",
1500
+ status: "dead",
1501
+ error: result.reason?.message || "unknown",
1502
+ };
1503
+ if (value.status === "reachable") reachable.push(value);
1504
+ else if (value.status === "dead") dead.push(value);
1505
+ else skipped.push(value);
1506
+ }
1507
+ }
1508
+
1509
+ return {
1510
+ reachable,
1511
+ dead,
1512
+ skipped,
1513
+ ok: dead.length === 0,
1514
+ };
1515
+ }
1516
+
1517
+ /**
1518
+ * Shared orchestration: run citation URL check with logging.
1519
+ * Used by both runResearchMode() and runSimpleResearchMode() to avoid
1520
+ * duplicating the try/catch/logging block.
1521
+ */
1522
+ export async function runCitationUrlCheck(combinedSources) {
1523
+ process.stderr.write("PROGRESS:research:check-urls\n");
1524
+ try {
1525
+ const citationUrls = await checkCitationUrls(combinedSources, {
1526
+ timeoutMs: 6000,
1527
+ concurrency: 4,
1528
+ });
1529
+ if (!citationUrls.ok) {
1530
+ process.stderr.write(
1531
+ `[greedysearch] ${citationUrls.dead.length} dead citation URL(s) detected\n`,
1532
+ );
1533
+ }
1534
+ return citationUrls;
1535
+ } catch (error) {
1536
+ process.stderr.write(
1537
+ `[greedysearch] URL reachability check failed: ${error.message}\n`,
1538
+ );
1539
+ return null;
1540
+ }
1541
+ }
1542
+
1543
+ export function computeResearchFloor({
1544
+ sources = [],
1545
+ fetchedSources = [],
1546
+ synthesis = {},
1547
+ citationAudit = null,
1548
+ gaps = [],
1549
+ questions = [],
1550
+ rounds = [],
1551
+ qualityScore = 0,
1552
+ qualityThreshold = 8.5,
1553
+ maxSources = 8,
1554
+ requireCitations = true,
1555
+ requireQuestions = true,
1556
+ } = {}) {
1557
+ const fetchedOk = fetchedSources.filter(
1558
+ (source) =>
1559
+ source?.fetch?.ok ||
1560
+ (source?.contentChars || 0) > 100 ||
1561
+ String(source?.content || "").length > 100,
1562
+ );
1563
+ const primarySources = sources.filter((source) =>
1564
+ ["official-docs", "repo", "maintainer-blog", "academic"].includes(
1565
+ String(source?.sourceType || ""),
1566
+ ),
1567
+ );
1568
+ const claims = Array.isArray(synthesis?.claims) ? synthesis.claims : [];
1569
+ const citedCount = citationAudit ? citationAudit.cited?.length || 0 : 0;
1570
+ const questionStats = questionProgress(questions);
1571
+ // Follow-up questions discovered during a run are useful handoff gaps, not a
1572
+ // reason to fail a short research run forever. The deterministic floor only
1573
+ // requires the original/root questions to close; newly-created questions stay
1574
+ // visible in STATUS.md and `gaps` for deeper follow-up rounds.
1575
+ const requiredQuestions = (questions || []).filter(
1576
+ (q) => !q.createdRound || q.reason === "Original research question",
1577
+ );
1578
+ const requiredQuestionStats = questionProgress(requiredQuestions);
1579
+ // Scale the minimum fetched sources by the number of rounds. The
1580
+ // simple research path runs 1 round with fewer sources, so requiring
1581
+ // 2-4 sources would be too strict. Iterative research (2+ rounds)
1582
+ // gets the full minFetched requirement.
1583
+ const roundCount = (rounds || []).length;
1584
+ const baseMin = Math.min(4, Math.max(2, Number(maxSources) || 8));
1585
+ const minFetched = roundCount <= 1 ? Math.min(2, baseMin) : baseMin;
1586
+ const checks = {
1587
+ roundsRun: rounds.length >= 1,
1588
+ fetchedSources: fetchedOk.length >= minFetched,
1589
+ primarySources: primarySources.length >= 1,
1590
+ qualityScore: qualityScore >= Math.min(qualityThreshold, 8),
1591
+ claimsExtracted: !requireCitations || claims.length > 0,
1592
+ citationsPresent: !requireCitations || citedCount > 0,
1593
+ citationsValid: !requireCitations || citationAudit?.ok === true,
1594
+ unfetchedCitations:
1595
+ !requireCitations || (citationAudit?.unfetched || []).length === 0,
1596
+ requiredQuestionsClosed:
1597
+ !requireQuestions || requiredQuestionStats.open === 0,
1598
+ };
1599
+ return {
1600
+ floorMet: Object.values(checks).every(Boolean),
1601
+ checks,
1602
+ metrics: {
1603
+ fetchedOk: fetchedOk.length,
1604
+ primarySources: primarySources.length,
1605
+ claims: claims.length,
1606
+ cited: citedCount,
1607
+ gaps: gaps.length,
1608
+ openQuestions: questionStats.open,
1609
+ closedQuestions: questionStats.closed,
1610
+ totalQuestions: questionStats.total,
1611
+ openRequiredQuestions: requiredQuestionStats.open,
1612
+ closedRequiredQuestions: requiredQuestionStats.closed,
1613
+ totalRequiredQuestions: requiredQuestionStats.total,
1614
+ qualityScore,
1615
+ minFetched,
1616
+ },
1617
+ };
1618
+ }
1619
+
1620
+ function annotateFetchedSourcesWithIds(fetchedSources, sources) {
1621
+ const byUrl = new Map();
1622
+ for (const source of sources || []) {
1623
+ const key = normalizeUrl(
1624
+ source?.canonicalUrl || source?.finalUrl || source?.url,
1625
+ );
1626
+ if (key && source?.id) byUrl.set(key, source.id);
1627
+ }
1628
+ return (fetchedSources || []).map((source, index) => {
1629
+ const key = normalizeUrl(
1630
+ source?.finalUrl || source?.canonicalUrl || source?.url,
1631
+ );
1632
+ return {
1633
+ ...source,
1634
+ id: source?.id || byUrl.get(key) || `F${index + 1}`,
1635
+ };
1636
+ });
1637
+ }
1638
+
1639
+ export function createQuestionLedger(query) {
1640
+ return [
1641
+ {
1642
+ id: "Q1",
1643
+ question: trimText(sanitizeResearchQuery(query), 320),
1644
+ status: "open",
1645
+ reason: "Original research question",
1646
+ evidence: [],
1647
+ sourceIds: [],
1648
+ },
1649
+ ];
1650
+ }
1651
+
1652
+ function nextQuestionId(questions) {
1653
+ let max = 0;
1654
+ for (const q of questions || []) {
1655
+ const n = Number.parseInt(String(q.id || "").replace(/^Q/i, ""), 10);
1656
+ if (Number.isFinite(n)) max = Math.max(max, n);
1657
+ }
1658
+ return `Q${max + 1}`;
1659
+ }
1660
+
1661
+ function findSimilarQuestion(questions, question) {
1662
+ const normalized = sanitizeResearchQuery(question).toLowerCase();
1663
+ return (questions || []).find(
1664
+ (q) =>
1665
+ q.question?.toLowerCase() === normalized ||
1666
+ jaccardSimilarity(q.question || "", normalized) >= 0.82,
1667
+ );
1668
+ }
1669
+
1670
+ function addQuestion(questions, question, { reason = "", round = null } = {}) {
1671
+ const clean = trimText(sanitizeResearchQuery(question), 320);
1672
+ if (!clean) return null;
1673
+ const existing = findSimilarQuestion(questions, clean);
1674
+ if (existing) return existing;
1675
+ const item = {
1676
+ id: nextQuestionId(questions),
1677
+ question: clean,
1678
+ status: "open",
1679
+ reason: trimText(reason, 240),
1680
+ createdRound: round,
1681
+ evidence: [],
1682
+ sourceIds: [],
1683
+ };
1684
+ questions.push(item);
1685
+ return item;
1686
+ }
1687
+
1688
+ function closeQuestion(
1689
+ questions,
1690
+ idOrQuestion,
1691
+ { evidence = "", sourceIds = [], round = null } = {},
1692
+ ) {
1693
+ const target =
1694
+ questions.find((q) => q.id === idOrQuestion) ||
1695
+ findSimilarQuestion(questions, idOrQuestion);
1696
+ if (!target) return null;
1697
+ target.status = "closed";
1698
+ target.closedRound = target.closedRound || round;
1699
+ if (evidence)
1700
+ target.evidence = uniqueStrings([...(target.evidence || []), evidence], 4);
1701
+ if (Array.isArray(sourceIds)) {
1702
+ target.sourceIds = uniqueStrings(
1703
+ [...(target.sourceIds || []), ...sourceIds],
1704
+ 8,
1705
+ );
1706
+ }
1707
+ return target;
1708
+ }
1709
+
1710
+ function questionProgress(questions) {
1711
+ const total = questions.length;
1712
+ const closed = questions.filter((q) => q.status === "closed").length;
1713
+ return { total, closed, open: Math.max(0, total - closed) };
1714
+ }
1715
+
1716
+ export function updateQuestionLedger(
1717
+ questions,
1718
+ { roundNumber, actions = [], learningPayload = {} } = {},
1719
+ ) {
1720
+ for (const run of actions) {
1721
+ const action = run?.action || run;
1722
+ const goal =
1723
+ action?.researchGoal && action.researchGoal !== "Original user query"
1724
+ ? action.researchGoal
1725
+ : action?.query || action?.url || "";
1726
+ if (goal) {
1727
+ addQuestion(questions, goal, {
1728
+ reason: "Planned research action",
1729
+ round: roundNumber,
1730
+ });
1731
+ }
1732
+ }
1733
+
1734
+ // Cap the open-question ledger growth. Discovered gap/follow-up questions
1735
+ // are useful handoffs but Gemini tends to emit one per evidence slot, which
1736
+ // blows up the ledger and inflates the `requiredQuestionsClosed` floor
1737
+ // check. Keep at most MAX_OPEN_FOLLOWUPS of them across the whole run;
1738
+ // older ones are auto-resolved as "covered by later evidence" so they
1739
+ // don't block the floor forever.
1740
+ const MAX_OPEN_FOLLOWUPS = 5;
1741
+ const followupOpen = questions.filter(
1742
+ (q) => q.status === "open" && q.reason === "Discovered gap/follow-up",
1743
+ );
1744
+ if (followupOpen.length > MAX_OPEN_FOLLOWUPS) {
1745
+ const overflow = followupOpen
1746
+ .sort((a, b) => (a.createdRound || 0) - (b.createdRound || 0))
1747
+ .slice(0, followupOpen.length - MAX_OPEN_FOLLOWUPS);
1748
+ for (const q of overflow) {
1749
+ q.status = "resolved";
1750
+ q.closedRound = roundNumber;
1751
+ q.evidence = uniqueStrings(
1752
+ [...(q.evidence || []), "Auto-resolved to cap open-question ledger"],
1753
+ 4,
1754
+ );
1755
+ }
1756
+ }
1757
+
1758
+ const answered = Array.isArray(learningPayload.answeredQuestions)
1759
+ ? learningPayload.answeredQuestions
1760
+ : [];
1761
+ for (const item of answered) {
1762
+ if (typeof item === "string") {
1763
+ closeQuestion(questions, item, { round: roundNumber });
1764
+ continue;
1765
+ }
1766
+ const id = item?.id || item?.question;
1767
+ if (!id && item?.question) {
1768
+ const added = addQuestion(questions, item.question, {
1769
+ reason: "Answered during learning extraction",
1770
+ round: roundNumber,
1771
+ });
1772
+ if (added) closeQuestion(questions, added.id, { round: roundNumber });
1773
+ continue;
1774
+ }
1775
+ closeQuestion(questions, id, {
1776
+ evidence: item?.evidence || item?.answer || "",
1777
+ sourceIds: Array.isArray(item?.sourceIds) ? item.sourceIds : [],
1778
+ round: roundNumber,
1779
+ });
1780
+ }
1781
+
1782
+ // Keep STATUS.md as a true question ledger, not a dump of every search query
1783
+ // or caveat. Follow-up queries and raw gaps stay in their own fields; only
1784
+ // explicit newQuestions become open ledger items.
1785
+ const newQuestions = Array.isArray(learningPayload.newQuestions)
1786
+ ? learningPayload.newQuestions
1787
+ : [];
1788
+ for (const question of newQuestions) {
1789
+ addQuestion(questions, question, {
1790
+ reason: "Discovered gap/follow-up",
1791
+ round: roundNumber,
1792
+ });
1793
+ }
1794
+
1795
+ return questions;
1796
+ }
1797
+
1798
+ /**
1799
+ * Pick direct-fetch targets from known academic source domains (arXiv,
1800
+ * semanticscholar.org, DOI redirect). Returns the canonical URL plus a
1801
+ * short label for the researchGoal. Filters out anything already fetched.
1802
+ */
1803
+ function pickAcademicFetchTargets(combinedSources, usedUrls) {
1804
+ if (!Array.isArray(combinedSources) || combinedSources.length === 0)
1805
+ return [];
1806
+ const ACADEMIC_HOSTS = ["arxiv.org", "semanticscholar.org", "doi.org"];
1807
+ const seen = new Set();
1808
+ const targets = [];
1809
+ for (const source of combinedSources) {
1810
+ const url = source?.canonicalUrl || source?.finalUrl || source?.url || "";
1811
+ if (!url) continue;
1812
+ let domain = "";
1813
+ try {
1814
+ domain = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
1815
+ } catch {
1816
+ continue;
1817
+ }
1818
+ if (!ACADEMIC_HOSTS.some((h) => domain === h || domain.endsWith(`.${h}`))) {
1819
+ continue;
1820
+ }
1821
+ if (usedUrls.has(url) || seen.has(url)) continue;
1822
+ seen.add(url);
1823
+ // Prefer the HTML/abs page over PDF for direct fetch — the source
1824
+ // fetcher handles both, but the HTML page gives the synthesizer
1825
+ // readable text + abstract immediately.
1826
+ const htmlUrl = url.includes("/pdf/")
1827
+ ? url.replace(/\/pdf\//, "/html/").replace(/\.pdf$/i, "")
1828
+ : url;
1829
+ targets.push({
1830
+ url: htmlUrl,
1831
+ label: source?.title || source?.id || domain,
1832
+ });
1833
+ }
1834
+ return targets.slice(0, 2);
1835
+ }
1836
+
1837
+ export function reconcileQuestionsFromSynthesis(
1838
+ questions,
1839
+ synthesis,
1840
+ citationAudit,
1841
+ ) {
1842
+ if (!synthesis?.answer || citationAudit?.ok !== true) return questions;
1843
+ const claims = Array.isArray(synthesis.claims) ? synthesis.claims : [];
1844
+ const citedIds = Array.isArray(citationAudit.cited)
1845
+ ? citationAudit.cited
1846
+ : [];
1847
+ if (claims.length === 0 || citedIds.length === 0) return questions;
1848
+
1849
+ for (const question of questions) {
1850
+ if (question.status === "closed") continue;
1851
+ let bestClaim = null;
1852
+ let bestScore = 0;
1853
+ for (const claim of claims) {
1854
+ const score = jaccardSimilarity(
1855
+ question.question || "",
1856
+ claim.claim || "",
1857
+ );
1858
+ if (score > bestScore) {
1859
+ bestScore = score;
1860
+ bestClaim = claim;
1861
+ }
1862
+ }
1863
+ if (question.id === "Q1" || bestScore >= 0.18) {
1864
+ closeQuestion(questions, question.id, {
1865
+ evidence: bestClaim?.claim || "Answered in final cited synthesis",
1866
+ sourceIds: Array.isArray(bestClaim?.sourceIds)
1867
+ ? bestClaim.sourceIds
1868
+ : citedIds.slice(0, 4),
1869
+ });
1870
+ }
1871
+ }
1872
+ return questions;
1873
+ }
1874
+
1875
+ function renderQuestionStatus(questions) {
1876
+ if (!questions.length) return "No tracked questions.";
1877
+ return questions
1878
+ .map((q) => {
1879
+ const ids = q.sourceIds?.length ? ` (${q.sourceIds.join(", ")})` : "";
1880
+ return `- [${q.status === "closed" ? "x" : " "}] ${q.id}: ${q.question}${ids}`;
1881
+ })
1882
+ .join("\n");
1883
+ }
1884
+
1885
+ function markdownList(items, fallback = "None recorded.") {
1886
+ const unique = uniqueStrings(items);
1887
+ return unique.length
1888
+ ? unique.map((item) => `- ${item}`).join("\n")
1889
+ : fallback;
1890
+ }
1891
+
1892
+ /**
1893
+ * Write a human-readable provenance sidecar next to the research bundle.
1894
+ * Records date, rounds, sources, verification status, and floor results.
1895
+ */
1896
+ export function writeProvenanceSidecar(
1897
+ dir,
1898
+ {
1899
+ query,
1900
+ rounds,
1901
+ sources,
1902
+ fetchedSources,
1903
+ citationAudit,
1904
+ citationUrls,
1905
+ floor,
1906
+ manifest,
1907
+ },
1908
+ ) {
1909
+ const fetchedOk = (fetchedSources || []).filter(
1910
+ (s) => s?.contentChars > 100 || s?.fetch?.ok,
1911
+ );
1912
+ const primarySources = (sources || []).filter((s) =>
1913
+ ["official-docs", "repo", "maintainer-blog", "academic"].includes(
1914
+ String(s?.sourceType || ""),
1915
+ ),
1916
+ );
1917
+ const citedIds = new Set(citationAudit?.cited || []);
1918
+ const citedSources = (sources || []).filter((s) => citedIds.has(s?.id));
1919
+
1920
+ const lines = [
1921
+ `# Provenance: ${query}`,
1922
+ "",
1923
+ `- **Date:** ${manifest?.startedAt || new Date().toISOString()}`,
1924
+ `- **Duration:** ${manifest?.durationMs ? `${(manifest.durationMs / 1000).toFixed(1)}s` : "unknown"}`,
1925
+ `- **Mode:** ${manifest?.terminationReason === "simple_single_pass" ? "simple (single-pass)" : "iterative"}`,
1926
+ `- **Rounds:** ${manifest?.rounds || rounds?.length || 1}`,
1927
+ "",
1928
+ "## Sources",
1929
+ "",
1930
+ `- **Consulted:** ${sources?.length || 0}`,
1931
+ `- **Fetched successfully:** ${fetchedOk.length}`,
1932
+ `- **Primary sources:** ${primarySources.length}`,
1933
+ `- **Cited in report:** ${citedSources.length}`,
1934
+ "",
1935
+ ];
1936
+
1937
+ // Cited source details
1938
+ if (citedSources.length > 0) {
1939
+ lines.push("### Cited sources", "");
1940
+ for (const source of citedSources) {
1941
+ const url = source.canonicalUrl || source.finalUrl || source.url || "";
1942
+ const fetched = source.fetch?.ok ? "✓" : "✗";
1943
+ lines.push(
1944
+ `- **${source.id}:** [${source.title || url}](${url}) (${source.sourceType || "unknown"}, fetched: ${fetched})`,
1945
+ );
1946
+ }
1947
+ lines.push("");
1948
+ }
1949
+
1950
+ // URL reachability
1951
+ if (
1952
+ citationUrls &&
1953
+ (citationUrls.reachable.length > 0 || citationUrls.dead.length > 0)
1954
+ ) {
1955
+ lines.push("## URL reachability", "");
1956
+ if (citationUrls.dead.length > 0) {
1957
+ lines.push("");
1958
+ lines.push("**Dead links:**");
1959
+ for (const d of citationUrls.dead) {
1960
+ lines.push(
1961
+ `- ${d.id}: ${d.url} (${d.httpStatus || d.error || "unknown"})`,
1962
+ );
1963
+ }
1964
+ }
1965
+ if (citationUrls.reachable.length > 0) {
1966
+ lines.push("");
1967
+ lines.push(
1968
+ `**Reachable:** ${citationUrls.reachable.length}/${citationUrls.reachable.length + citationUrls.dead.length}`,
1969
+ );
1970
+ }
1971
+ lines.push("");
1972
+ }
1973
+
1974
+ // Verification status
1975
+ const verificationStatus = !citationAudit
1976
+ ? "NOT CHECKED"
1977
+ : citationAudit.ok && (citationUrls?.ok ?? true)
1978
+ ? "PASS"
1979
+ : citationAudit.ok === false
1980
+ ? "FAIL (missing citations)"
1981
+ : "FAIL (dead links)";
1982
+
1983
+ lines.push(
1984
+ "## Verification",
1985
+ "",
1986
+ `- **Citations:** ${citationAudit?.ok ? "PASS" : `FAIL — missing: ${(citationAudit?.missing || []).join(", ")}`}`,
1987
+ `- **URL reachability:** ${citationUrls ? (citationUrls.ok ? "PASS" : `FAIL — ${citationUrls.dead.length} dead`) : "SKIPPED"}`,
1988
+ `- **Floor:** ${floor?.floorMet ? "PASS" : "PARTIAL"}`,
1989
+ `- **Overall:** ${verificationStatus}`,
1990
+ "",
1991
+ );
1992
+
1993
+ // Floor checks
1994
+ if (floor?.checks) {
1995
+ lines.push("## Floor checks", "");
1996
+ for (const [name, ok] of Object.entries(floor.checks)) {
1997
+ lines.push(`- [${ok ? "x" : " "}] ${name}`);
1998
+ }
1999
+ lines.push("");
2000
+ }
2001
+
2002
+ writeFileSync(join(dir, "provenance.md"), lines.join("\n"), "utf8");
2003
+ }
2004
+
2005
+ export async function writeResearchBundle({
2006
+ query,
2007
+ rounds,
2008
+ sources,
2009
+ fetchedSources,
2010
+ evidenceItems = [],
2011
+ synthesis,
2012
+ citationAudit,
2013
+ floor,
2014
+ manifest,
2015
+ allGaps = [],
2016
+ questions = [],
2017
+ citationUrls = null,
2018
+ outDir = null,
2019
+ }) {
2020
+ const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-").slice(0, 19);
2021
+ const dir =
2022
+ outDir ||
2023
+ join(
2024
+ DEFAULT_RESEARCH_BUNDLE_ROOT,
2025
+ `${stamp}_${slugifyResearchName(query)}`,
2026
+ );
2027
+ const reportsDir = join(dir, "reports");
2028
+ const sourcesDir = join(dir, "sources");
2029
+ const dataDir = join(dir, "data");
2030
+ mkdirSync(reportsDir, { recursive: true });
2031
+ mkdirSync(sourcesDir, { recursive: true });
2032
+ mkdirSync(dataDir, { recursive: true });
2033
+
2034
+ const sourceFiles = await writeResearchSourcesToFiles(
2035
+ fetchedSources,
2036
+ sourcesDir,
2037
+ );
2038
+ const gaps = uniqueStrings([
2039
+ ...allGaps,
2040
+ ...rounds.flatMap((round) => round.gaps || []),
2041
+ ]);
2042
+ writeFileSync(
2043
+ join(dir, "STATUS.md"),
2044
+ [
2045
+ floor.floorMet ? "STATUS: DONE" : "STATUS: PARTIAL",
2046
+ "",
2047
+ `Query: ${query}`,
2048
+ `Stop reason: ${manifest.terminationReason || "max_rounds"}`,
2049
+ "",
2050
+ "## Deterministic floor checks",
2051
+ ...Object.entries(floor.checks).map(
2052
+ ([name, ok]) => `- [${ok ? "x" : " "}] ${name}`,
2053
+ ),
2054
+ "",
2055
+ "## Questions",
2056
+ renderQuestionStatus(questions),
2057
+ "",
2058
+ "## Open gaps",
2059
+ markdownList(gaps),
2060
+ "",
2061
+ ].join("\n"),
2062
+ "utf8",
2063
+ );
2064
+ writeFileSync(
2065
+ join(dir, "OUTLINE.md"),
2066
+ [
2067
+ "# Research bundle outline",
2068
+ "",
2069
+ "- `reports/SUMMARY.md` — final cited report",
2070
+ "- `reports/CLAIMS.md` — extracted claims with support/source IDs",
2071
+ "- `reports/EVIDENCE.md` — goal-based source evidence",
2072
+ "- `reports/GAPS.md` — remaining caveats and uncertainties",
2073
+ "- `provenance.md` — human-readable run metadata and verification",
2074
+ "- `sources/` — fetched source markdown files",
2075
+ "- `data/manifest.json` — machine-readable run metadata",
2076
+ "- `data/rounds.json` — per-round actions/learnings/gaps",
2077
+ "- `data/sources.json` — ranked source registry",
2078
+ "- `data/questions.json` — open/closed question ledger",
2079
+ "",
2080
+ ].join("\n"),
2081
+ "utf8",
2082
+ );
2083
+ writeFileSync(
2084
+ join(reportsDir, "SUMMARY.md"),
2085
+ String(synthesis.answer || ""),
2086
+ "utf8",
2087
+ );
2088
+ writeFileSync(
2089
+ join(reportsDir, "CLAIMS.md"),
2090
+ [
2091
+ "# Key claims",
2092
+ "",
2093
+ ...(Array.isArray(synthesis.claims) && synthesis.claims.length
2094
+ ? synthesis.claims.map((claim) => {
2095
+ const ids = Array.isArray(claim.sourceIds)
2096
+ ? claim.sourceIds.join(", ")
2097
+ : "";
2098
+ return `- ${claim.claim || ""} (${claim.support || "support unknown"}${ids ? `; ${ids}` : ""})`;
2099
+ })
2100
+ : ["No structured claims were extracted."]),
2101
+ "",
2102
+ ].join("\n"),
2103
+ "utf8",
2104
+ );
2105
+ writeFileSync(
2106
+ join(reportsDir, "EVIDENCE.md"),
2107
+ [
2108
+ "# Extracted evidence",
2109
+ "",
2110
+ ...(evidenceItems.length
2111
+ ? evidenceItems.map((item) =>
2112
+ [
2113
+ `## ${item.sourceId || item.url || "Source"}`,
2114
+ item.url ? `<${item.url}>` : "",
2115
+ item.rational ? `**Rational:** ${item.rational}` : "",
2116
+ item.evidence ? `**Evidence:** ${item.evidence}` : "",
2117
+ item.summary ? `**Summary:** ${item.summary}` : "",
2118
+ "",
2119
+ ]
2120
+ .filter(Boolean)
2121
+ .join("\n"),
2122
+ )
2123
+ : ["No goal-based evidence was extracted."]),
2124
+ "",
2125
+ ].join("\n"),
2126
+ "utf8",
2127
+ );
2128
+ writeFileSync(
2129
+ join(reportsDir, "GAPS.md"),
2130
+ [
2131
+ "# Gaps and caveats",
2132
+ "",
2133
+ "## Caveats",
2134
+ markdownList(synthesis.caveats || []),
2135
+ "",
2136
+ "## Research gaps",
2137
+ markdownList(gaps),
2138
+ "",
2139
+ ].join("\n"),
2140
+ "utf8",
2141
+ );
2142
+ writeFileSync(
2143
+ join(dataDir, "manifest.json"),
2144
+ JSON.stringify({ ...manifest, floor, citationAudit }, null, 2),
2145
+ "utf8",
2146
+ );
2147
+ writeFileSync(
2148
+ join(dataDir, "rounds.json"),
2149
+ JSON.stringify(rounds, null, 2),
2150
+ "utf8",
2151
+ );
2152
+ writeFileSync(
2153
+ join(dataDir, "sources.json"),
2154
+ JSON.stringify(sources, null, 2),
2155
+ "utf8",
2156
+ );
2157
+ writeFileSync(
2158
+ join(dataDir, "questions.json"),
2159
+ JSON.stringify(questions, null, 2),
2160
+ "utf8",
2161
+ );
2162
+ writeFileSync(
2163
+ join(dataDir, "evidence.json"),
2164
+ JSON.stringify(evidenceItems, null, 2),
2165
+ "utf8",
2166
+ );
2167
+ writeFileSync(
2168
+ join(sourcesDir, "index.md"),
2169
+ [
2170
+ "# Source index",
2171
+ "",
2172
+ ...sourceFiles.map((source) => {
2173
+ const label = source.title || source.url;
2174
+ const url = source.finalUrl || source.url;
2175
+ const path = source.contentPath ? ` — ${source.contentPath}` : "";
2176
+ return `- ${source.id || "?"}: [${label}](${url})${path}`;
2177
+ }),
2178
+ "",
2179
+ ].join("\n"),
2180
+ "utf8",
2181
+ );
2182
+
2183
+ // Provenance sidecar — human-readable run metadata (non-critical)
2184
+ try {
2185
+ writeProvenanceSidecar(dir, {
2186
+ query,
2187
+ rounds,
2188
+ sources,
2189
+ fetchedSources,
2190
+ citationAudit,
2191
+ citationUrls,
2192
+ floor,
2193
+ manifest,
2194
+ });
2195
+ } catch (sidecarError) {
2196
+ process.stderr.write(
2197
+ `[greedysearch] Provenance sidecar write failed (non-critical): ${sidecarError.message}\n`,
2198
+ );
2199
+ }
2200
+
2201
+ return {
2202
+ dir,
2203
+ statusPath: join(dir, "STATUS.md"),
2204
+ summaryPath: join(reportsDir, "SUMMARY.md"),
2205
+ manifestPath: join(dataDir, "manifest.json"),
2206
+ provenancePath: join(dir, "provenance.md"),
2207
+ sourceCount: sourceFiles.length,
2208
+ sourceFiles,
2209
+ };
2210
+ }
2211
+
1144
2212
  export async function runResearchMode({
1145
2213
  query,
1146
2214
  breadth = 3,
@@ -1149,14 +2217,63 @@ export async function runResearchMode({
1149
2217
  locale = null,
1150
2218
  short = false,
1151
2219
  qualityThreshold = 8.5,
2220
+ writeBundle = process.env.GREEDY_RESEARCH_BUNDLE !== "0",
2221
+ researchOutDir = null,
1152
2222
  } = {}) {
1153
2223
  const options = clampResearchOptions({ breadth, iterations, maxSources });
2224
+
2225
+ // ── Scale-aware fast path ────────────────────────────────────────────────
2226
+ // When breadth and iterations are at defaults (not user-specified), classify
2227
+ // the query complexity. Simple queries bypass the iterative loop entirely
2228
+ // for ~70% faster results and lower API cost.
2229
+ const userSpecifiedBreadth = typeof breadth === "number";
2230
+ const userSpecifiedIterations = typeof iterations === "number";
2231
+ const atDefaults = !userSpecifiedBreadth && !userSpecifiedIterations;
2232
+
2233
+ if (atDefaults) {
2234
+ try {
2235
+ const classification = await classifyResearchComplexity(query);
2236
+ process.stderr.write(
2237
+ `[greedysearch] Complexity: ${classification.complexity} (${classification.reasoning})\n`,
2238
+ );
2239
+ if (classification.complexity === "simple") {
2240
+ process.stderr.write(
2241
+ `[greedysearch] Simple query detected — using fast single-pass path\n`,
2242
+ );
2243
+ return runSimpleResearchMode({
2244
+ query,
2245
+ locale,
2246
+ maxSources: Math.min(maxSources ?? 5, 5),
2247
+ qualityThreshold,
2248
+ writeBundle,
2249
+ researchOutDir,
2250
+ });
2251
+ }
2252
+ // For moderate/complex: use classifier suggestions as hints if user
2253
+ // didn't specify values. This tightens the loop for moderate queries
2254
+ // without changing the user-explicit path.
2255
+ if (!userSpecifiedBreadth) {
2256
+ options.breadth = classification.suggestedBreadth;
2257
+ }
2258
+ if (!userSpecifiedIterations) {
2259
+ options.iterations = classification.suggestedIterations;
2260
+ }
2261
+ } catch (error) {
2262
+ process.stderr.write(
2263
+ `[greedysearch] Scale classification failed, using defaults: ${error.message}\n`,
2264
+ );
2265
+ }
2266
+ }
2267
+
1154
2268
  const rounds = [];
1155
2269
  let allLearnings = [];
1156
2270
  let allGaps = [];
2271
+ const questions = createQuestionLedger(query);
1157
2272
  let activeActions = null;
1158
2273
  let combinedSources = [];
1159
2274
  let fetchedSources = [];
2275
+ let evidenceItems = [];
2276
+ const extractedSourceKeys = new Set();
1160
2277
  const usedQueries = new Set();
1161
2278
  const usedUrls = new Set();
1162
2279
  const qualityHistory = [];
@@ -1170,8 +2287,20 @@ export async function runResearchMode({
1170
2287
  let totalFetches = 0;
1171
2288
  const engineFailures = [];
1172
2289
 
2290
+ // Progress bar with ETA — pre-compute totals from plan so the bar
2291
+ // reflects the full run, not just the current round. The actual
2292
+ // actions per round come from Gemini's plan; we estimate 1 fetch
2293
+ // per academic source found.
2294
+ const progressTracker = createProgressTracker({
2295
+ totalActions: options.iterations * options.breadth,
2296
+ totalRounds: options.iterations,
2297
+ totalFetches: options.iterations, // estimate: ~1 fetch per round
2298
+ silent: process.env.GREEDY_RESEARCH_QUIET === "1",
2299
+ });
2300
+ progressTracker.startRound(1);
2301
+
1173
2302
  process.stderr.write(
1174
- `[greedysearch] Research mode: breadth ${options.breadth}, iterations ${options.iterations}, qualityThreshold ${qualityThreshold}\n`,
2303
+ `[greedysearch] Research mode: breadth ${options.breadth}, iterations ${options.iterations}, qualityThreshold ${qualityThreshold}, engines ${RESEARCH_ENGINES.join(",")}, synthesizer gemini\n`,
1175
2304
  );
1176
2305
 
1177
2306
  for (let roundIndex = 0; roundIndex < options.iterations; roundIndex++) {
@@ -1254,6 +2383,26 @@ export async function runResearchMode({
1254
2383
  });
1255
2384
 
1256
2385
  const roundActions = noveltyFiltered.slice(0, roundBreadth);
2386
+
2387
+ // Force at least one fetchUrl per round when a known academic source
2388
+ // (arXiv, semantic-scholar, DOI) is present in combinedSources. The
2389
+ // Gemini planner occasionally emits all-search actions even when the
2390
+ // answer is in a single arXiv PDF; direct fetching gives the synthesizer
2391
+ // real PDF text and reliably passes citation audits.
2392
+ const academicTargets = pickAcademicFetchTargets(combinedSources, usedUrls);
2393
+ const hasFetch = roundActions.some((a) => a.type === "fetchUrl");
2394
+ if (!hasFetch && academicTargets.length > 0) {
2395
+ const injectTarget = academicTargets[0];
2396
+ roundActions.push({
2397
+ type: "fetchUrl",
2398
+ url: injectTarget.url,
2399
+ researchGoal: `Direct fetch of known academic source: ${injectTarget.label || injectTarget.url}`,
2400
+ });
2401
+ process.stderr.write(
2402
+ `[greedysearch] Forced fetchUrl for academic source: ${injectTarget.url}\n`,
2403
+ );
2404
+ }
2405
+
1257
2406
  const actionRuns = [];
1258
2407
  for (let i = 0; i < roundActions.length; i++) {
1259
2408
  const action = roundActions[i];
@@ -1263,6 +2412,10 @@ export async function runResearchMode({
1263
2412
  process.stderr.write(
1264
2413
  `[greedysearch] Action ${i + 1}/${roundActions.length} [${action.type}]: ${(action.query || action.url).slice(0, 80)}\n`,
1265
2414
  );
2415
+ progressTracker.startAction(
2416
+ action.type,
2417
+ (action.query || action.url || "").slice(0, 60),
2418
+ );
1266
2419
  const run = await executeResearchAction(action, {
1267
2420
  locale,
1268
2421
  short,
@@ -1270,10 +2423,14 @@ export async function runResearchMode({
1270
2423
  usedUrls,
1271
2424
  maxChars: 8000,
1272
2425
  });
2426
+ progressTracker.endAction();
1273
2427
  actionRuns.push(run);
1274
2428
  totalActionsRun++;
1275
2429
  if (action.type === "search") totalSearches++;
1276
- if (action.type === "fetchUrl") totalFetches++;
2430
+ if (action.type === "fetchUrl") {
2431
+ totalFetches++;
2432
+ progressTracker.endFetch(run.ok);
2433
+ }
1277
2434
  if (!run.ok) {
1278
2435
  engineFailures.push({
1279
2436
  round: roundNumber,
@@ -1292,6 +2449,7 @@ export async function runResearchMode({
1292
2449
  const fetchActionRuns = actionRuns.filter(
1293
2450
  (r) => r.action.type === "fetchUrl",
1294
2451
  );
2452
+ updateQuestionLedger(questions, { roundNumber, actions: actionRuns });
1295
2453
 
1296
2454
  combinedSources = dedupeSources([
1297
2455
  combinedSources,
@@ -1329,6 +2487,33 @@ export async function runResearchMode({
1329
2487
  fetchedSources,
1330
2488
  );
1331
2489
  }
2490
+ fetchedSources = annotateFetchedSourcesWithIds(
2491
+ fetchedSources,
2492
+ combinedSources,
2493
+ );
2494
+
2495
+ process.stderr.write(`PROGRESS:research:round-${roundNumber}:evidence\n`);
2496
+ const evidenceRun = await extractEvidenceFromSources({
2497
+ query,
2498
+ questions,
2499
+ fetchedSources,
2500
+ extractedSourceKeys,
2501
+ });
2502
+ if (evidenceRun.error) {
2503
+ process.stderr.write(
2504
+ `[greedysearch] Evidence extraction failed: ${evidenceRun.error}\n`,
2505
+ );
2506
+ }
2507
+ evidenceItems = [...evidenceItems, ...evidenceRun.evidence];
2508
+ for (const evidence of evidenceRun.evidence) {
2509
+ updateQuestionLedger(questions, {
2510
+ roundNumber,
2511
+ learningPayload: {
2512
+ answeredQuestions: evidence.answers || [],
2513
+ newQuestions: evidence.newQuestions || [],
2514
+ },
2515
+ });
2516
+ }
1332
2517
 
1333
2518
  // Build round query summary for learning extraction
1334
2519
  const roundQueries = actionRuns.map((run) => ({
@@ -1351,6 +2536,8 @@ export async function runResearchMode({
1351
2536
  engines: summarizeEngineAnswers(run.result),
1352
2537
  })),
1353
2538
  fetchedSources,
2539
+ questions,
2540
+ evidenceItems,
1354
2541
  ),
1355
2542
  { timeoutMs: 120000 },
1356
2543
  );
@@ -1377,8 +2564,14 @@ export async function runResearchMode({
1377
2564
  .filter(Boolean)
1378
2565
  .slice(0, 6)
1379
2566
  : [];
1380
- allLearnings = [...new Set([...allLearnings, ...learnings])];
1381
- allGaps = [...new Set([...allGaps, ...gaps])];
2567
+ allLearnings = uniqueStrings([...allLearnings, ...learnings]);
2568
+ allGaps = uniqueStrings([...allGaps, ...gaps]);
2569
+ updateQuestionLedger(questions, {
2570
+ roundNumber,
2571
+ actions: [],
2572
+ learningPayload,
2573
+ gaps,
2574
+ });
1382
2575
  rounds.push({
1383
2576
  round: roundNumber,
1384
2577
  actions: actionRuns.map((run) => ({
@@ -1391,11 +2584,17 @@ export async function runResearchMode({
1391
2584
  })),
1392
2585
  learnings,
1393
2586
  gaps,
2587
+ evidence: evidenceRun.evidence,
2588
+ evidenceError: evidenceRun.error,
1394
2589
  learningError,
1395
2590
  });
1396
2591
 
1397
2592
  // Quality evaluation
1398
2593
  process.stderr.write(`PROGRESS:research:round-${roundNumber}:evaluating\n`);
2594
+ progressTracker.endRound();
2595
+ if (roundNumber < options.iterations) {
2596
+ progressTracker.startRound(roundNumber + 1);
2597
+ }
1399
2598
  const evaluation = await evaluateResearchQuality(
1400
2599
  query,
1401
2600
  rounds,
@@ -1404,19 +2603,38 @@ export async function runResearchMode({
1404
2603
  qualityHistory,
1405
2604
  );
1406
2605
  qualityHistory.push(evaluation.score);
2606
+ allGaps = uniqueStrings([...allGaps, ...(evaluation.knowledgeGaps || [])]);
2607
+ updateQuestionLedger(questions, {
2608
+ roundNumber,
2609
+ gaps: evaluation.knowledgeGaps || [],
2610
+ });
2611
+ const preliminaryFloor = computeResearchFloor({
2612
+ sources: combinedSources,
2613
+ fetchedSources,
2614
+ gaps: allGaps,
2615
+ questions,
2616
+ rounds,
2617
+ qualityScore: evaluation.score,
2618
+ qualityThreshold,
2619
+ maxSources: options.maxSources,
2620
+ requireCitations: false,
2621
+ requireQuestions: false,
2622
+ });
1407
2623
  process.stderr.write(
1408
- `[greedysearch] Quality score round ${roundNumber}: ${evaluation.score.toFixed(1)} (shouldContinue: ${evaluation.shouldContinue})\n`,
2624
+ `[greedysearch] Quality score round ${roundNumber}: ${evaluation.score.toFixed(1)} (shouldContinue: ${evaluation.shouldContinue}, floor: ${preliminaryFloor.floorMet})\n`,
1409
2625
  );
1410
2626
 
1411
- // Early termination
2627
+ // Early termination is outcome-first: Gemini quality alone is not enough.
2628
+ // Stop early only when the score is high AND deterministic source/floor checks pass.
1412
2629
  if (
1413
2630
  evaluation.score >= qualityThreshold &&
2631
+ preliminaryFloor.floorMet &&
1414
2632
  (!evaluation.shouldContinue ||
1415
2633
  evaluation.terminationReason === "quality_threshold")
1416
2634
  ) {
1417
2635
  terminationReason = evaluation.terminationReason || "quality_threshold";
1418
2636
  process.stderr.write(
1419
- `[greedysearch] Quality threshold ${qualityThreshold} reached (score: ${evaluation.score.toFixed(1)}). Terminating early.\n`,
2637
+ `[greedysearch] Research floor reached (score: ${evaluation.score.toFixed(1)}). Terminating early.\n`,
1420
2638
  );
1421
2639
  break;
1422
2640
  }
@@ -1490,16 +2708,26 @@ export async function runResearchMode({
1490
2708
  };
1491
2709
  try {
1492
2710
  const rawReport = await runGeminiPrompt(
1493
- buildFinalReportPrompt(query, rounds, combinedSources),
2711
+ buildFinalReportPrompt(
2712
+ query,
2713
+ rounds,
2714
+ combinedSources,
2715
+ questions,
2716
+ evidenceItems,
2717
+ ),
1494
2718
  { timeoutMs: 180000 },
1495
2719
  );
1496
2720
  const parsed = parseGeminiJson(rawReport, {});
2721
+ const hasClaims = Array.isArray(parsed?.claims) && parsed.claims.length > 0;
1497
2722
  synthesis = {
1498
2723
  ...synthesis,
1499
2724
  ...parsed,
1500
2725
  rawAnswer: rawReport.answer || "",
1501
2726
  geminiSources: rawReport.sources || [],
1502
- synthesized: true,
2727
+ // Only mark as synthesized if Gemini actually returned structured
2728
+ // claims. An empty/minimal response should not block the evidence
2729
+ // fallback from running.
2730
+ synthesized: hasClaims,
1503
2731
  };
1504
2732
  } catch (error) {
1505
2733
  process.stderr.write(
@@ -1508,15 +2736,128 @@ export async function runResearchMode({
1508
2736
  synthesis.error = error.message;
1509
2737
  }
1510
2738
 
1511
- const fetchedFiles = await writeResearchSourcesToFiles(fetchedSources);
2739
+ // Fallback: when no structured learnings were produced but per-source
2740
+ // evidence was extracted successfully, ask Gemini to synthesize a final
2741
+ // report directly from the evidence. This rescues runs whose per-round
2742
+ // learning prompt failed (e.g. transient Gemini input field rejection)
2743
+ // but whose evidence extraction step still captured real data.
2744
+ const hasStructuredSynthesis =
2745
+ synthesis.synthesized === true &&
2746
+ Array.isArray(synthesis.claims) &&
2747
+ synthesis.claims.length > 0;
2748
+ if (!hasStructuredSynthesis && evidenceItems.length > 0) {
2749
+ process.stderr.write(
2750
+ "[greedysearch] Falling back to evidence-based synthesis (no per-round learnings).\n",
2751
+ );
2752
+ try {
2753
+ const evidencePrompt = buildSynthesisFromEvidencePrompt(
2754
+ query,
2755
+ combinedSources,
2756
+ questions,
2757
+ evidenceItems,
2758
+ );
2759
+ const rawEvidenceReport = await runGeminiPrompt(evidencePrompt, {
2760
+ timeoutMs: 180000,
2761
+ });
2762
+ const parsedEvidence = parseGeminiJson(rawEvidenceReport, {});
2763
+ synthesis = {
2764
+ ...synthesis,
2765
+ ...parsedEvidence,
2766
+ rawAnswer: rawEvidenceReport.answer || synthesis.answer || "",
2767
+ geminiSources:
2768
+ rawEvidenceReport.sources || synthesis.geminiSources || [],
2769
+ synthesized: true,
2770
+ synthesisMode: "evidence_fallback",
2771
+ };
2772
+ } catch (error) {
2773
+ process.stderr.write(
2774
+ `[greedysearch] Evidence-based synthesis failed: ${error.message}\n`,
2775
+ );
2776
+ synthesis.evidenceFallbackError = error.message;
2777
+ }
2778
+ }
2779
+
1512
2780
  const finishedAt = new Date().toISOString();
1513
2781
  const durationMs = Date.now() - startMs;
2782
+ const qualityScore = qualityHistory.at(-1) || 0;
2783
+ fetchedSources = annotateFetchedSourcesWithIds(
2784
+ fetchedSources,
2785
+ combinedSources,
2786
+ );
1514
2787
 
1515
- // Citation audit
2788
+ // Citation audit + final question reconciliation + deterministic completion floor
1516
2789
  process.stderr.write("PROGRESS:research:audit-citations\n");
1517
2790
  const citationAudit = auditCitations(synthesis.answer || "", combinedSources);
1518
2791
 
2792
+ // Citation URL reachability check
2793
+ const citationUrls = await runCitationUrlCheck(combinedSources);
2794
+
2795
+ reconcileQuestionsFromSynthesis(questions, synthesis, citationAudit);
2796
+ const floor = computeResearchFloor({
2797
+ sources: combinedSources,
2798
+ fetchedSources,
2799
+ synthesis,
2800
+ citationAudit,
2801
+ gaps: allGaps,
2802
+ questions,
2803
+ rounds,
2804
+ qualityScore,
2805
+ qualityThreshold,
2806
+ maxSources: options.maxSources,
2807
+ });
2808
+ if (floor.floorMet && terminationReason === "max_rounds") {
2809
+ terminationReason = "done_floor_met";
2810
+ } else if (!floor.floorMet && terminationReason === "quality_threshold") {
2811
+ terminationReason = "max_rounds_floor_unmet";
2812
+ }
2813
+
2814
+ const manifest = {
2815
+ startedAt,
2816
+ finishedAt,
2817
+ durationMs,
2818
+ engines: RESEARCH_ENGINES,
2819
+ synthesizer: "gemini",
2820
+ rounds: rounds.length,
2821
+ actionsRun: totalActionsRun,
2822
+ searches: totalSearches,
2823
+ fetches: totalFetches,
2824
+ sourcesFetched: fetchedSources.filter((s) => s?.contentChars > 100).length,
2825
+ engineFailures,
2826
+ terminationReason,
2827
+ floorMet: floor.floorMet,
2828
+ };
2829
+ let bundle = null;
2830
+ let fetchedFiles;
2831
+ if (writeBundle) {
2832
+ process.stderr.write("PROGRESS:research:bundle\n");
2833
+ try {
2834
+ bundle = await writeResearchBundle({
2835
+ query,
2836
+ rounds,
2837
+ sources: combinedSources,
2838
+ fetchedSources,
2839
+ evidenceItems,
2840
+ synthesis,
2841
+ citationAudit,
2842
+ citationUrls,
2843
+ floor,
2844
+ manifest,
2845
+ allGaps,
2846
+ questions,
2847
+ outDir: researchOutDir,
2848
+ });
2849
+ fetchedFiles = bundle.sourceFiles;
2850
+ delete bundle.sourceFiles;
2851
+ } catch (error) {
2852
+ bundle = { error: error.message || String(error) };
2853
+ fetchedFiles = await writeResearchSourcesToFiles(fetchedSources);
2854
+ }
2855
+ } else {
2856
+ fetchedFiles = await writeResearchSourcesToFiles(fetchedSources);
2857
+ }
2858
+
1519
2859
  process.stderr.write("PROGRESS:research:done\n");
2860
+ progressTracker.finish();
1520
2861
 
1521
2862
  return {
1522
2863
  query,
@@ -1527,23 +2868,19 @@ export async function runResearchMode({
1527
2868
  maxSources: options.maxSources,
1528
2869
  rounds,
1529
2870
  learnings: allLearnings,
2871
+ gaps: allGaps,
2872
+ evidence: evidenceItems,
2873
+ questions,
2874
+ questionProgress: questionProgress(questions),
1530
2875
  qualityHistory,
1531
2876
  terminationReason,
1532
2877
  qualityThreshold,
1533
- manifest: {
1534
- startedAt,
1535
- finishedAt,
1536
- durationMs,
1537
- rounds: rounds.length,
1538
- actionsRun: totalActionsRun,
1539
- searches: totalSearches,
1540
- fetches: totalFetches,
1541
- sourcesFetched: fetchedSources.filter((s) => s?.contentChars > 100)
1542
- .length,
1543
- engineFailures,
1544
- },
2878
+ floor,
2879
+ bundle,
2880
+ manifest,
1545
2881
  },
1546
2882
  _citationAudit: citationAudit,
2883
+ _citationUrls: citationUrls,
1547
2884
  _sources: combinedSources,
1548
2885
  _fetchedSources: fetchedFiles,
1549
2886
  _synthesis: synthesis,
@@ -1559,23 +2896,43 @@ export async function runResearchMode({
1559
2896
  )
1560
2897
  : 0,
1561
2898
  agreementLevel: synthesis.agreement?.level || "mixed",
2899
+ floorMet: floor.floorMet,
1562
2900
  },
1563
2901
  };
1564
2902
  }
1565
2903
 
1566
2904
  function dedupeFetchedSources(sources) {
1567
- const seen = new Map();
2905
+ const byUrl = new Map();
1568
2906
  for (const source of sources) {
1569
2907
  const key =
1570
2908
  source?.id || normalizeUrl(source?.finalUrl || source?.url || "");
1571
2909
  if (!key) continue;
1572
- const existing = seen.get(key);
2910
+ const existing = byUrl.get(key);
1573
2911
  if (
1574
2912
  !existing ||
1575
2913
  (source.contentChars || 0) > (existing.contentChars || 0)
1576
2914
  ) {
1577
- seen.set(key, source);
2915
+ byUrl.set(key, source);
2916
+ }
2917
+ }
2918
+
2919
+ const out = [];
2920
+ for (const source of byUrl.values()) {
2921
+ const content = String(source.content || source.snippet || "");
2922
+ const duplicateIndex = out.findIndex((existing) => {
2923
+ const other = String(existing.content || existing.snippet || "");
2924
+ if (content.length < 400 || other.length < 400) return false;
2925
+ return (
2926
+ jaccardSimilarity(content.slice(0, 4000), other.slice(0, 4000)) >= 0.9
2927
+ );
2928
+ });
2929
+ if (duplicateIndex === -1) {
2930
+ out.push(source);
2931
+ continue;
2932
+ }
2933
+ if ((source.contentChars || 0) > (out[duplicateIndex].contentChars || 0)) {
2934
+ out[duplicateIndex] = source;
1578
2935
  }
1579
2936
  }
1580
- return Array.from(seen.values());
2937
+ return out;
1581
2938
  }