@lincoln504/pi-research 1.3.12 → 1.4.3

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 (86) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5670 -4705
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-lead-router.md +122 -0
  6. package/dist/prompts/system-lead-synthesizer.md +46 -0
  7. package/dist/thread-worker.mjs +169 -62
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +225 -0
  10. package/docs/CONFIGURATION.md +106 -7
  11. package/package.json +2 -2
  12. package/scripts/build.cjs +8 -1
  13. package/skills/pi-research/SKILL.md +1 -1
  14. package/src/cli.ts +5 -1
  15. package/src/config.ts +22 -12
  16. package/src/constants.ts +1 -1
  17. package/src/core/interfaces/observer-interfaces.ts +3 -3
  18. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  19. package/src/core/interfaces/planning-interfaces.ts +7 -1
  20. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  21. package/src/core/llm/agentic-repair.ts +7 -2
  22. package/src/core/llm/inject-date.ts +4 -1
  23. package/src/core/llm/llm-utils.ts +7 -0
  24. package/src/core/planning-service.ts +766 -91
  25. package/src/core/planning-utils.ts +21 -5
  26. package/src/core/service-initialization.ts +19 -3
  27. package/src/healthcheck/index.ts +11 -2
  28. package/src/index.ts +4 -3
  29. package/src/infrastructure/browser/browser-client.ts +68 -34
  30. package/src/infrastructure/browser/browser-task-scheduler.ts +236 -43
  31. package/src/infrastructure/browser/config.ts +79 -1
  32. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  33. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  34. package/src/infrastructure/browser/task-execution-service.ts +5 -5
  35. package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
  37. package/src/infrastructure/browser/thread-worker.mjs +169 -62
  38. package/src/infrastructure/browser/thread-worker.ts +13 -5
  39. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  40. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  41. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  42. package/src/infrastructure/embedding/embedding-server.ts +34 -0
  43. package/src/infrastructure/file-lock-service.ts +214 -19
  44. package/src/infrastructure/knowledge-store-service.ts +14 -0
  45. package/src/infrastructure/research-run-semaphore.ts +1 -1
  46. package/src/infrastructure/service-initialization.ts +7 -0
  47. package/src/knowledge/embedder-init.ts +46 -19
  48. package/src/knowledge/embedder.ts +11 -3
  49. package/src/knowledge/index.ts +36 -10
  50. package/src/knowledge/store.ts +29 -5
  51. package/src/logger.ts +11 -0
  52. package/src/orchestration/deep-research-orchestrator.ts +143 -87
  53. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  54. package/src/orchestration/research-orchestration-service.ts +8 -0
  55. package/src/orchestration/research-synthesis-service.ts +53 -1
  56. package/src/orchestration/researcher-executor.ts +53 -2
  57. package/src/orchestration/researcher.ts +15 -2
  58. package/src/orchestration/session-state.ts +16 -0
  59. package/src/prompts/research-tool-usage.md +1 -1
  60. package/src/prompts/researcher.md +1 -0
  61. package/src/prompts/system-lead-router.md +122 -0
  62. package/src/prompts/system-lead-synthesizer.md +46 -0
  63. package/src/sdk.ts +10 -0
  64. package/src/skill-install/skill-installer.ts +5 -1
  65. package/src/stackexchange/index.ts +3 -0
  66. package/src/stackexchange/rest-client.ts +7 -12
  67. package/src/tools/research-knowledge-search.ts +11 -3
  68. package/src/tools/research-tool-definition.ts +38 -7
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +31 -10
  72. package/src/tools/stackexchange.ts +13 -7
  73. package/src/tools/youtube-transcript.ts +26 -12
  74. package/src/utils/cancellation.ts +36 -0
  75. package/src/utils/coverage-digest.ts +204 -0
  76. package/src/utils/host-config.ts +12 -1
  77. package/src/utils/llm-usage.ts +33 -6
  78. package/src/utils/log-utils.ts +10 -0
  79. package/src/utils/metrics-summary.ts +20 -4
  80. package/src/utils/metrics.ts +27 -1
  81. package/src/web-research/browser-search.ts +102 -22
  82. package/src/web-research/web-scraper.ts +27 -16
  83. package/src/youtube/potoken.ts +8 -1
  84. package/src/youtube/transcript-client.ts +56 -5
  85. package/dist/prompts/system-lead-evaluator.md +0 -118
  86. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -71,6 +71,8 @@ function redactSecrets(message) {
71
71
  }
72
72
  if (out.length > MAX_LOG_MESSAGE_LENGTH) {
73
73
  out = `${out.slice(0, keep)}\u2026[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
74
+ } else if (rawLength > scanEnd) {
75
+ out = `${out}\u2026[truncated ${rawLength - scanEnd} chars]`;
74
76
  }
75
77
  return out;
76
78
  }
@@ -247,7 +249,7 @@ var init_error_tracker = __esm({
247
249
  * @param error - The error to track (Error object, string message, or unknown)
248
250
  * @param context - Optional context information about where the error occurred
249
251
  */
250
- trackError(error, context2 = {}) {
252
+ trackError(error, context = {}) {
251
253
  let message;
252
254
  if (error instanceof Error) {
253
255
  message = error.message;
@@ -276,15 +278,15 @@ var init_error_tracker = __esm({
276
278
  }
277
279
  pattern.count++;
278
280
  pattern.lastSeen = now;
279
- if (context2.domain) {
280
- const existing = pattern.domainCounts.get(context2.domain);
281
+ if (context.domain) {
282
+ const existing = pattern.domainCounts.get(context.domain);
281
283
  if (existing !== void 0) {
282
- pattern.domainCounts.set(context2.domain, existing + 1);
284
+ pattern.domainCounts.set(context.domain, existing + 1);
283
285
  } else if (pattern.domainCounts.size < this.MAX_DOMAINS_PER_PATTERN) {
284
- pattern.domainCounts.set(context2.domain, 1);
286
+ pattern.domainCounts.set(context.domain, 1);
285
287
  }
286
288
  }
287
- pattern.contexts.push(context2);
289
+ pattern.contexts.push(context);
288
290
  if (pattern.contexts.length > this.MAX_CONTEXTS_PER_PATTERN) {
289
291
  pattern.contexts.shift();
290
292
  }
@@ -1060,6 +1062,17 @@ var init_logger = __esm({
1060
1062
  timestamp,
1061
1063
  level,
1062
1064
  ...getLogContext(),
1065
+ // Every process writes to the SAME log file — up to three concurrent research
1066
+ // runs plus the elected browser-pool leader — and until this field existed
1067
+ // there was nothing to tell their lines apart. Reconstructing a lock
1068
+ // contention from the log then meant inferring process boundaries from
1069
+ // timing, which is guesswork: two runs a second apart interleave into what
1070
+ // reads like one process contradicting itself. Twelve bytes a line buys
1071
+ // attributable forensics.
1072
+ //
1073
+ // After the context spread, not before: which process wrote a line is not
1074
+ // something a scoped context gets to claim otherwise.
1075
+ pid: process.pid,
1063
1076
  message,
1064
1077
  ...firstError ? { errorMessage: redactSecrets(firstError.message), errorStack: redactSecrets(firstError.stack ?? "") } : {}
1065
1078
  };
@@ -1098,8 +1111,8 @@ var init_logger = __esm({
1098
1111
  error(...args) {
1099
1112
  const errArg = args.find((arg) => arg instanceof Error);
1100
1113
  if (errArg) {
1101
- const context2 = getLogContext();
1102
- errorTracker.trackError(errArg, context2);
1114
+ const context = getLogContext();
1115
+ errorTracker.trackError(errArg, context);
1103
1116
  }
1104
1117
  this.emit("ERROR" /* ERROR */, ...args);
1105
1118
  }
@@ -1166,7 +1179,7 @@ var init_metrics = __esm({
1166
1179
  increment(name, value = 1, labels) {
1167
1180
  const key = this.getKey(name, labels);
1168
1181
  this.counters.set(key, (this.counters.get(key) ?? 0) + value);
1169
- if (name === "llm_cost_total" || name === "llm_tokens_total") {
1182
+ if (name.startsWith("llm_")) {
1170
1183
  logger.debug(`[Metrics] Incremented ${name} by ${value}${labels ? ` (labels: ${JSON.stringify(labels)})` : ""}`);
1171
1184
  }
1172
1185
  }
@@ -1270,6 +1283,28 @@ var init_metrics = __esm({
1270
1283
  async measure(name, action, labels) {
1271
1284
  return this.getActive().measure(name, action, labels);
1272
1285
  }
1286
+ /**
1287
+ * Emit API that ALWAYS targets the session registry, ignoring any active run
1288
+ * context.
1289
+ *
1290
+ * For process-lifetime callbacks that happen to be *registered* during a run.
1291
+ * AsyncLocalStorage resolves at emit time from the async resource that fired,
1292
+ * and for a listener installed on a long-lived emitter that resource is the
1293
+ * emitter itself — created, for every lazily-initialized subsystem here, inside
1294
+ * whichever run first needed it. So a worker-pool error handler installed during
1295
+ * run 1 keeps writing into run 1's registry for the rest of the process: those
1296
+ * counts disappear from every later run's summary (the registry was discarded
1297
+ * when the run ended) and the dead registry stays reachable through the closure.
1298
+ *
1299
+ * Use this only where the emission is genuinely process-scoped. Anything that
1300
+ * describes work a run asked for belongs in the run registry, which is what the
1301
+ * plain emit methods above give you.
1302
+ */
1303
+ session = {
1304
+ increment: (name, value = 1, labels) => this._session.increment(name, value, labels),
1305
+ setGauge: (name, value, labels) => this._session.setGauge(name, value, labels),
1306
+ observe: (name, value, labels) => this._session.observe(name, value, labels)
1307
+ };
1273
1308
  // ── Session-level read API ─────────────────────────────────────────────
1274
1309
  /** Snapshot of the session registry (infrastructure / cross-run metrics). */
1275
1310
  getSessionSnapshot() {
@@ -1992,7 +2027,7 @@ var workerId2 = "";
1992
2027
  var pageCreationLock = Promise.resolve();
1993
2028
  var PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1e3;
1994
2029
  var wedgedContext = null;
1995
- async function createPageSafe(context2) {
2030
+ async function createPageSafe(context) {
1996
2031
  let release;
1997
2032
  const nextLock = new Promise((resolve) => {
1998
2033
  release = resolve;
@@ -2000,11 +2035,11 @@ async function createPageSafe(context2) {
2000
2035
  const currentLock = pageCreationLock;
2001
2036
  pageCreationLock = currentLock.then(() => nextLock);
2002
2037
  await currentLock;
2003
- if (context2 === wedgedContext) {
2038
+ if (context === wedgedContext) {
2004
2039
  release();
2005
2040
  throw new Error("Browser context is wedged from a prior page-creation call that never settled; a new browser context is required.");
2006
2041
  }
2007
- const pagePromise = context2.newPage();
2042
+ const pagePromise = context.newPage();
2008
2043
  let hardReleased = false;
2009
2044
  const releaseOnce = () => {
2010
2045
  if (hardReleased) return;
@@ -2014,17 +2049,17 @@ async function createPageSafe(context2) {
2014
2049
  };
2015
2050
  const hardReleaseTimer = setTimeout(() => {
2016
2051
  logToDebugFile2("WARN", "[ThreadWorker] page-creation mutex force-released \u2014 newPage() did not settle within the hard timeout");
2017
- wedgedContext = context2;
2052
+ wedgedContext = context;
2018
2053
  releaseOnce();
2019
2054
  }, PAGE_CREATION_HARD_RELEASE_MS);
2020
2055
  safeUnref(hardReleaseTimer);
2021
2056
  pagePromise.then(
2022
2057
  () => {
2023
- if (wedgedContext === context2) wedgedContext = null;
2058
+ if (wedgedContext === context) wedgedContext = null;
2024
2059
  releaseOnce();
2025
2060
  },
2026
2061
  () => {
2027
- if (wedgedContext === context2) wedgedContext = null;
2062
+ if (wedgedContext === context) wedgedContext = null;
2028
2063
  releaseOnce();
2029
2064
  }
2030
2065
  );
@@ -2086,12 +2121,12 @@ function logToDebugFile2(level, ...args) {
2086
2121
  } catch {
2087
2122
  }
2088
2123
  }
2089
- async function setupMocking(context2) {
2124
+ async function setupMocking(context) {
2090
2125
  const mockSearch = process.env["PI_RESEARCH_MOCK_SEARCH"] === "true";
2091
2126
  const mockScrape = process.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
2092
2127
  if (!mockSearch && !mockScrape) return;
2093
2128
  logToDebugFile2("INFO", `[Worker-${workerId2}] Setting up browser mocking (Search: ${mockSearch}, Scrape: ${mockScrape})`);
2094
- await context2.route("**", (route, request) => {
2129
+ await context.route("**", (route, request) => {
2095
2130
  const urlStr = request.url();
2096
2131
  let url;
2097
2132
  try {
@@ -2170,11 +2205,21 @@ async function extractSearchResults(page) {
2170
2205
  return found;
2171
2206
  });
2172
2207
  }
2173
- async function executeSearchTask(_context, query) {
2208
+ function bindTaskAbortToPage(page, signal) {
2209
+ if (!signal) return;
2210
+ const onAbort = () => {
2211
+ page.close().catch(() => {
2212
+ });
2213
+ };
2214
+ if (signal.aborted) onAbort();
2215
+ else signal.addEventListener("abort", onAbort, { once: true });
2216
+ }
2217
+ async function executeSearchTask(_context, query, signal) {
2174
2218
  const page = await createPageSafe(_context);
2175
2219
  const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
2176
2220
  page.setDefaultTimeout(SEARCH_TIMEOUT);
2177
2221
  page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
2222
+ bindTaskAbortToPage(page, signal);
2178
2223
  try {
2179
2224
  logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting search for: ${query}`);
2180
2225
  if (process.env["PI_RESEARCH_MOCK_SEARCH"] === "true") {
@@ -2254,6 +2299,7 @@ async function executeScrapeTask(_context, url, signal) {
2254
2299
  }
2255
2300
  let poisonedError = null;
2256
2301
  const pendingAddrChecks = [];
2302
+ let oversizedPdfError = null;
2257
2303
  try {
2258
2304
  logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting scrape for: ${url}`);
2259
2305
  const { validateUrlForSSRF: validateForWorker, validateUrlForSSRFSync: validateForWorkerSync } = await Promise.resolve().then(() => (init_scraper_utils(), scraper_utils_exports));
@@ -2285,6 +2331,20 @@ async function executeScrapeTask(_context, url, signal) {
2285
2331
  try {
2286
2332
  if (resp.request().resourceType() === "document" && resp.frame() === page.mainFrame()) {
2287
2333
  finalMainFrameAddr = resp.serverAddr().catch(() => null);
2334
+ const check2 = Promise.resolve(resp.headerValue("content-type")).then(async (ct) => {
2335
+ const looksLikePdf = ct?.includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
2336
+ if (!looksLikePdf || oversizedPdfError || poisonedError) return;
2337
+ const declared = parseInt(await resp.headerValue("content-length").catch(() => null) || "", 10);
2338
+ if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
2339
+ const sizeMB = Math.round(declared / 1024 / 1024);
2340
+ oversizedPdfError = new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2341
+ logToDebugFile2("DEBUG", `[Worker-${workerId2}] Aborting oversized PDF (${sizeMB}MB declared) before download completes: ${url}`);
2342
+ page.close().catch(() => {
2343
+ });
2344
+ }
2345
+ }).catch(() => {
2346
+ });
2347
+ pendingAddrChecks.push(check2);
2288
2348
  }
2289
2349
  const check = Promise.resolve(resp.serverAddr()).then((addr) => {
2290
2350
  const ip = addr?.ipAddress;
@@ -2314,7 +2374,7 @@ async function executeScrapeTask(_context, url, signal) {
2314
2374
  }
2315
2375
  const contentType = await response?.headerValue("content-type") || "";
2316
2376
  const status = response?.status?.() ?? 0;
2317
- if (contentType.includes("application/pdf")) {
2377
+ if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
2318
2378
  if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
2319
2379
  if (status >= 400) throw new Error(`HTTP ${status}`);
2320
2380
  const declaredLength = parseInt(await response.headerValue("content-length").catch(() => null) || "", 10);
@@ -2322,6 +2382,7 @@ async function executeScrapeTask(_context, url, signal) {
2322
2382
  const sizeMB = Math.round(declaredLength / 1024 / 1024);
2323
2383
  throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2324
2384
  }
2385
+ if (oversizedPdfError) throw oversizedPdfError;
2325
2386
  const buffer = await response.body();
2326
2387
  if (buffer.byteLength > MAX_PDF_SIZE) {
2327
2388
  const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
@@ -2379,6 +2440,7 @@ async function executeScrapeTask(_context, url, signal) {
2379
2440
  return { contentType, html, jitter };
2380
2441
  } catch (error) {
2381
2442
  await page.close().catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
2443
+ if (oversizedPdfError) throw oversizedPdfError;
2382
2444
  if (poisonedError) throw poisonedError;
2383
2445
  throw error;
2384
2446
  } finally {
@@ -2387,10 +2449,11 @@ async function executeScrapeTask(_context, url, signal) {
2387
2449
  }
2388
2450
  var HEALTH_PRIMARY_URL = "https://lite.duckduckgo.com/lite/";
2389
2451
  var HEALTH_FALLBACK_URL = "https://example.com/";
2390
- async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL) {
2452
+ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL, signal) {
2391
2453
  const page = await createPageSafe(_context);
2392
2454
  page.setDefaultTimeout(navTimeoutMs);
2393
2455
  page.setDefaultNavigationTimeout(navTimeoutMs);
2456
+ bindTaskAbortToPage(page, signal);
2394
2457
  try {
2395
2458
  const navStart = Date.now();
2396
2459
  await page.goto(url, { waitUntil: "domcontentloaded" });
@@ -2406,25 +2469,25 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
2406
2469
  throw error;
2407
2470
  }
2408
2471
  }
2409
- async function executeHealthCheck(_context, initMs) {
2472
+ async function executeHealthCheck(_context, initMs, signal) {
2410
2473
  const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
2411
2474
  const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
2412
2475
  if (initMs > 3e3) {
2413
2476
  logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
2414
2477
  }
2415
2478
  try {
2416
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
2479
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
2417
2480
  } catch (firstError) {
2418
2481
  logToDebugFile2("WARN", `[Worker-${workerId2}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
2419
2482
  try {
2420
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
2483
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
2421
2484
  } catch (retryError) {
2422
2485
  const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
2423
2486
  const isTimeout = /tim(e|ed)\s*out|timeout/i.test(retryMsg);
2424
2487
  if (!isTimeout) {
2425
2488
  logToDebugFile2("WARN", `[Worker-${workerId2}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
2426
2489
  try {
2427
- const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
2490
+ const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
2428
2491
  logToDebugFile2("WARN", `[Worker-${workerId2}] Fallback endpoint reachable \u2014 open web is up, search provider is degraded. Proceeding with research.`);
2429
2492
  return fallback;
2430
2493
  } catch (fallbackError) {
@@ -2522,13 +2585,19 @@ var ConfigSchema = Type.Object({
2522
2585
  SEARCH_TIMEOUT_MS: Type.Number({ minimum: 5e3, maximum: 12e4, default: 45e3 }),
2523
2586
  /** TUI refresh debounce in milliseconds (default: 100ms) */
2524
2587
  TUI_REFRESH_DEBOUNCE_MS: Type.Number({ minimum: 0, maximum: 1e3, default: 100 }),
2525
- /** Queue-wait / overhead margin (ms) added on top of each browser operation's own nav
2526
- * timeout to form its scheduler task-timeout ceiling: a search task times out after
2527
- * SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this. Sized so a task is
2528
- * never killed before it has used its full nav budget plus time waiting in the queue.
2588
+ /** Overhead margin (ms) added on top of each browser operation's own nav timeout to
2589
+ * form its scheduler task-timeout ceiling: a search task times out after
2590
+ * SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this — covering
2591
+ * per-task work beyond raw navigation (page setup, result parsing). Queue wait is
2592
+ * NOT part of this margin: the deadline is armed on dispatch, not on enqueue, so
2593
+ * time spent waiting for a free worker never counts against it (see
2594
+ * browser-task-scheduler.ts). Cold browser-launch/context-creation time is also
2595
+ * separate from this margin — COLD_START_ALLOWANCE_MS
2596
+ * (infrastructure/browser/config.ts) covers that unconditionally on top of it,
2597
+ * since it isn't something a user should need to tune.
2529
2598
  * (default: 10000) */
2530
2599
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
2531
- /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
2600
+ /** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
2532
2601
  * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
2533
2602
  * Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
2534
2603
  * large-context synthesis/plan call can legitimately run >10min, and the old
@@ -2536,7 +2605,7 @@ var ConfigSchema = Type.Object({
2536
2605
  * (e.g. 900000), causing those calls to time out mid-generation. */
2537
2606
  LLM_TIMEOUT_MS: Type.Number({ minimum: 6e4, maximum: 18e5, default: 3e5 }),
2538
2607
  /** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
2539
- * evaluator, synthesis, JSON-repair, knowledge extraction) AND the researcher
2608
+ * router, synthesizer, JSON-repair, knowledge extraction) AND the researcher
2540
2609
  * sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
2541
2610
  * open-ended reasoning, so thinking only burns the output-token budget (often
2542
2611
  * truncating before the JSON/report block) and adds latency for at most a marginal
@@ -2551,22 +2620,26 @@ var ConfigSchema = Type.Object({
2551
2620
  Type.Literal("medium"),
2552
2621
  Type.Literal("high")
2553
2622
  ], { default: "off" }),
2554
- /** Max output tokens for the coordinator plan + mid-round evaluator decision (and,
2555
- * when the evaluator synthesizes mid-round, the report). Default 16384. The blanket
2623
+ /** Max output tokens for the coordinator's round-1 plan. Default 16384. The routing
2624
+ * decision has its OWN, much smaller ceiling (it emits a decision, never a report,
2625
+ * and cannot synthesize mid-round under the split protocol); the final report uses
2626
+ * SYNTHESIS_MAX_TOKENS below. The blanket
2556
2627
  * 4096 cap previously throttled every call and could be exhausted by a thinking block
2557
2628
  * before any text was emitted. Capped at the model's real ceiling at call time.
2558
2629
  * Not exposed in TUI — controlled via PI_RESEARCH_PLANNING_MAX_TOKENS env var. */
2559
2630
  PLANNING_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
2560
- /** Max output tokens for the final synthesized research report (the forced-synthesis
2561
- * evaluator call). Default 32768 so a full cited report is never truncated. Capped at
2631
+ /** Max output tokens for the final synthesized research report (the synthesizer call,
2632
+ * and each pass of a reduce when the corpus does not fit in one). Default 32768 so a
2633
+ * full cited report is never truncated. It is also the reserve subtracted from the
2634
+ * model's context window when budgeting that corpus. Capped at
2562
2635
  * the model's real maxTokens at call time.
2563
2636
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2564
2637
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
2565
2638
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2566
2639
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2567
2640
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
2568
- * and the knowledge synthesis background LLM. The coordinator and evaluator always
2569
- * use the caller's model (ctx.model).
2641
+ * and the knowledge synthesis background LLM. The coordinator and the research lead
2642
+ * always use the caller's model (ctx.model).
2570
2643
  */
2571
2644
  RESEARCH_MODEL: Type.Optional(Type.String()),
2572
2645
  /** Explicit directory for the knowledge store database (overrides default) */
@@ -2620,10 +2693,13 @@ function resolveHeadlessMode() {
2620
2693
  if (process.env["PI_RESEARCH_USE_XVFB"] === "true") return "virtual";
2621
2694
  return true;
2622
2695
  }
2696
+ var HEALTHCHECK_MIN_BUDGET_MS = 45e3 + 6e4;
2697
+ var BROWSER_LAUNCH_TIMEOUT_MS = 9e4;
2698
+ var CONTEXT_CREATION_TIMEOUT_MS = 3e4;
2699
+ var COLD_START_ALLOWANCE_MS = BROWSER_LAUNCH_TIMEOUT_MS + CONTEXT_CREATION_TIMEOUT_MS;
2623
2700
 
2624
2701
  // src/infrastructure/browser/thread-worker-browser.ts
2625
2702
  var browser = null;
2626
- var context = null;
2627
2703
  var initPromise = null;
2628
2704
  var workerId3 = "";
2629
2705
  var activeTaskCount = 0;
@@ -2677,11 +2753,11 @@ function logToDebugFile3(level, ...args) {
2677
2753
  }
2678
2754
  }
2679
2755
  async function initBrowser() {
2680
- if (isBrowserConnected() && context) return;
2756
+ if (isBrowserConnected()) return;
2681
2757
  if (initPromise) return initPromise;
2682
2758
  initPromise = (async () => {
2683
2759
  try {
2684
- if (!isBrowserConnected() || !context) {
2760
+ if (!isBrowserConnected()) {
2685
2761
  logToDebugFile3("INFO", `[Worker-${workerId3}] Initializing browser instance...`);
2686
2762
  let CamoufoxModule;
2687
2763
  try {
@@ -2690,7 +2766,7 @@ async function initBrowser() {
2690
2766
  throw new Error(`[Worker] camoufox-js not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
2691
2767
  }
2692
2768
  const { Camoufox } = CamoufoxModule;
2693
- const launchTimeoutMs = 9e4;
2769
+ const launchTimeoutMs = BROWSER_LAUNCH_TIMEOUT_MS;
2694
2770
  const launchOnce = async (headless) => {
2695
2771
  const launchPromise = Camoufox({
2696
2772
  headless,
@@ -2734,11 +2810,24 @@ async function initBrowser() {
2734
2810
  });
2735
2811
  launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
2736
2812
  let launchTimeoutId;
2813
+ let raceLost = false;
2814
+ launchPromise.then((b) => {
2815
+ if (!raceLost) return;
2816
+ logToDebugFile3("WARN", `[Worker-${workerId3}] Browser launch completed after its deadline; closing the orphaned instance.`);
2817
+ try {
2818
+ b?.close?.();
2819
+ } catch {
2820
+ }
2821
+ }).catch(() => {
2822
+ });
2737
2823
  try {
2738
2824
  return await Promise.race([
2739
2825
  launchPromise,
2740
2826
  new Promise((_, reject) => {
2741
- launchTimeoutId = setTimeout(() => reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`)), launchTimeoutMs);
2827
+ launchTimeoutId = setTimeout(() => {
2828
+ raceLost = true;
2829
+ reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`));
2830
+ }, launchTimeoutMs);
2742
2831
  })
2743
2832
  ]);
2744
2833
  } finally {
@@ -2760,17 +2849,6 @@ async function initBrowser() {
2760
2849
  }
2761
2850
  }
2762
2851
  browser = launchedBrowser;
2763
- const contextPromise = browser.newContext();
2764
- contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
2765
- let contextTimeoutId;
2766
- context = await Promise.race([
2767
- contextPromise,
2768
- new Promise((_, reject) => {
2769
- contextTimeoutId = setTimeout(() => reject(new Error("Browser context creation timed out after 30000ms")), 3e4);
2770
- })
2771
- ]);
2772
- if (contextTimeoutId) clearTimeout(contextTimeoutId);
2773
- await setupMocking(context);
2774
2852
  logToDebugFile3("INFO", `[Worker-${workerId3}] Browser initialized.`);
2775
2853
  }
2776
2854
  } catch (e) {
@@ -2778,7 +2856,6 @@ async function initBrowser() {
2778
2856
  Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during failed init: ${err.message}`));
2779
2857
  }
2780
2858
  browser = null;
2781
- context = null;
2782
2859
  const msg = e instanceof Error ? e.message : String(e);
2783
2860
  if (msg.includes("Camoufox is not installed") || msg.includes("Version information not found")) {
2784
2861
  throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox-js fetch' to install them.`, { cause: e });
@@ -2797,13 +2874,39 @@ async function initBrowser() {
2797
2874
  })();
2798
2875
  return initPromise;
2799
2876
  }
2800
- function getContext() {
2801
- return context;
2877
+ async function acquireTaskContext() {
2878
+ const contextPromise = browser.newContext();
2879
+ contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
2880
+ let contextTimeoutId;
2881
+ let contextRaceLost = false;
2882
+ contextPromise.then((c) => {
2883
+ if (!contextRaceLost) return;
2884
+ logToDebugFile3("WARN", `[Worker-${workerId3}] Browser context creation completed after its deadline; closing the orphaned context.`);
2885
+ try {
2886
+ c?.close?.();
2887
+ } catch {
2888
+ }
2889
+ }).catch(() => {
2890
+ });
2891
+ let newContext;
2892
+ try {
2893
+ newContext = await Promise.race([
2894
+ contextPromise,
2895
+ new Promise((_, reject) => {
2896
+ contextTimeoutId = setTimeout(() => {
2897
+ contextRaceLost = true;
2898
+ reject(new Error(`Browser context creation timed out after ${CONTEXT_CREATION_TIMEOUT_MS}ms`));
2899
+ }, CONTEXT_CREATION_TIMEOUT_MS);
2900
+ })
2901
+ ]);
2902
+ } finally {
2903
+ if (contextTimeoutId) clearTimeout(contextTimeoutId);
2904
+ }
2905
+ await setupMocking(newContext);
2906
+ return newContext;
2802
2907
  }
2803
2908
  function doResetBrowser() {
2804
- if (context) Promise.resolve(context.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed context close error during reset: ${err.message}`));
2805
2909
  if (browser) Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during reset: ${err.message}`));
2806
- context = null;
2807
2910
  browser = null;
2808
2911
  }
2809
2912
  function resetBrowser() {
@@ -2817,7 +2920,6 @@ function resetBrowser() {
2817
2920
  async function cleanupBrowser2() {
2818
2921
  const timeoutMs = 2e3;
2819
2922
  const cleanup = async () => {
2820
- if (context) await Promise.resolve(context.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed context close error during cleanup: ${err.message}`));
2821
2923
  if (browser) await Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during cleanup: ${err.message}`));
2822
2924
  };
2823
2925
  try {
@@ -2827,7 +2929,6 @@ async function cleanupBrowser2() {
2827
2929
  ]);
2828
2930
  } catch {
2829
2931
  } finally {
2830
- context = null;
2831
2932
  browser = null;
2832
2933
  }
2833
2934
  }
@@ -2876,20 +2977,22 @@ async function runTask(data) {
2876
2977
  taskTimer = setTimeout(() => abortController.abort(new Error(`Task timed out after ${taskTimeoutMs}ms`)), taskTimeoutMs);
2877
2978
  }
2878
2979
  taskStarted();
2980
+ let taskContext;
2879
2981
  try {
2880
2982
  await initBrowser();
2881
2983
  const initMs = Date.now() - startTime;
2984
+ taskContext = await acquireTaskContext();
2882
2985
  let result;
2883
2986
  if (type === "search") {
2884
2987
  if (!query) throw new Error("Search task requires a query");
2885
- const searchResult = await executeSearchTask(getContext(), query);
2988
+ const searchResult = await executeSearchTask(taskContext, query, abortController.signal);
2886
2989
  result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
2887
2990
  } else if (type === "scrape") {
2888
2991
  if (!url) throw new Error("Scrape task requires a URL");
2889
- const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
2992
+ const scrapeResult = await executeScrapeTask(taskContext, url, abortController.signal);
2890
2993
  result = { ...scrapeResult, duration: Date.now() - startTime };
2891
2994
  } else if (type === "healthcheck") {
2892
- const healthResult = await executeHealthCheck(getContext(), initMs);
2995
+ const healthResult = await executeHealthCheck(taskContext, initMs, abortController.signal);
2893
2996
  result = { ...healthResult, duration: Date.now() - startTime };
2894
2997
  } else {
2895
2998
  result = { error: "Unknown task type", duration: Date.now() - startTime };
@@ -2912,6 +3015,10 @@ async function runTask(data) {
2912
3015
  };
2913
3016
  } finally {
2914
3017
  if (taskTimer !== void 0) clearTimeout(taskTimer);
3018
+ if (taskContext) {
3019
+ Promise.resolve(taskContext.close()).catch(() => {
3020
+ });
3021
+ }
2915
3022
  taskFinished();
2916
3023
  }
2917
3024
  }
@@ -31,8 +31,8 @@ pi CLI
31
31
  search burst, then hands each researcher a set of result URLs to start from.
32
32
  4. Researchers scrape and read those pages through the scrape tooling and return cited
33
33
  reports. They consider only what they scraped this session.
34
- 5. The evaluator reviews the round and either runs another round or synthesizes the
35
- final report.
34
+ 5. The research lead's **router** reviews the round and either runs another round or ends
35
+ the loop; its **synthesizer** then writes the final report from every report collected.
36
36
  6. The result is returned as a single cited Markdown report; the cited URLs and their
37
37
  summaries are queued into the knowledge store for future runs.
38
38
 
@@ -49,11 +49,19 @@ agent skill can pass). The pi extension's `research` tool has a minimum depth of
49
49
  an in-session agent can never request quick mode.
50
50
 
51
51
  Depth 1–3 — deep (`DeepResearchOrchestrator`): the run proceeds in **rounds**. A round
52
- is one coordinate → research → evaluate cycle — the round's agenda is planned (by the
53
- coordinator in round 1, by the evaluator thereafter), a batch of **researchers** runs it
54
- in parallel, and the evaluator then decides whether to run another round or synthesize.
55
- Two limits apply independently: how many researchers run *within* a round, and how many
56
- rounds the run may take.
52
+ is one coordinate → research → route cycle — the round's agenda is planned (by the
53
+ coordinator in round 1, by the research lead's **router** thereafter), a batch of
54
+ **researchers** runs it in parallel, and the router then decides whether to run another
55
+ round or end the loop. Two limits apply independently: how many researchers run *within*
56
+ a round, and how many rounds the run may take.
57
+
58
+ The research lead is two roles, not one call doing both jobs. The router only decides; it
59
+ reads each report in full on the one round it arrives and only that report's short
60
+ coverage digest thereafter, so its input grows with team size rather than with the square
61
+ of the round count. The **synthesizer** runs exactly once, at the end, reads every report
62
+ in full, and writes the report — under a corpus budget derived from the model's context
63
+ window, reducing in partial passes and merging when the corpus does not fit. The two
64
+ prompts are `src/prompts/system-lead-router.md` and `system-lead-synthesizer.md`.
57
65
 
58
66
  | Depth | Label | Researchers per round (max) | Rounds (max) |
59
67
  |-------|--------|-----------------------------|--------------|
@@ -61,7 +69,7 @@ rounds the run may take.
61
69
  | 2 | deep | 3 | 3 |
62
70
  | 3 | ultra | 5 | 3 |
63
71
 
64
- These are ceilings, not targets: the coordinator and evaluator use as many researchers
72
+ These are ceilings, not targets: the coordinator and router use as many researchers
65
73
  and rounds as the topic needs. A depth-2 run, for example, may spawn up to 3 researchers
66
74
  in each of up to 3 rounds. Queued steering messages (Alt+Enter) can unlock a few extra
67
75
  rounds past the cap (`MAX_EXTRA_ROUNDS_WITH_STEERING`).
@@ -70,7 +78,7 @@ The coordinator also runs the initial search burst and distributes its result UR
70
78
  round 1's researchers (`distributeSearchResults`), so in deep mode the researchers
71
79
  themselves do not call `search`.
72
80
 
73
- LLM-call conventions. Coordinator, evaluator, synthesis, JSON-repair, and
81
+ LLM-call conventions. Coordinator, router, synthesizer, JSON-repair, and
74
82
  knowledge-extraction calls go through `completeSimple` (`src/core/llm/pi-ai-completion.ts`)
75
83
  with `buildSafeOptions` (`src/core/llm/llm-utils.ts`); researcher sub-agents go through
76
84
  `createAgentSession`.
@@ -369,7 +377,7 @@ stable `Agent` connector API, so it follows `^8`.)
369
377
 
370
378
  Transient-failure resilience — every LLM call is a potential single point of failure on a
371
379
  streaming endpoint that can drop mid-response (undici surfaces this as `terminated`). The
372
- coordinator and evaluator calls retry fast transient transport failures (socket aborts, 5xx,
380
+ coordinator and research-lead calls retry fast transient transport failures (socket aborts, 5xx,
373
381
  429, provider overload) with bounded exponential backoff — mirroring the per-researcher retry
374
382
  (`PI_RESEARCH_MAX_RETRIES`) — and, if still failing, degrade to a deterministic fallback plan
375
383
  rather than aborting the run. An app-level LLM timeout is not retried (it already spent the