@lincoln504/pi-research 1.0.5 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -2295,6 +2295,7 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2295
2295
  PI_RESEARCH_MAX_RESEARCHERS: String(config.MAX_CONCURRENT_RESEARCHERS),
2296
2296
  PI_RESEARCH_MAX_RETRIES: String(config.RESEARCHER_MAX_RETRIES),
2297
2297
  PI_RESEARCH_RETRY_DELAY_MS: String(config.RESEARCHER_MAX_RETRY_DELAY_MS),
2298
+ PI_RESEARCH_MAX_FAILED_RESEARCHERS: String(config.MAX_FAILED_RESEARCHERS ?? DEFAULTS.MAX_FAILED_RESEARCHERS),
2298
2299
  PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS: String(config.HEALTH_CHECK_TIMEOUT_MS ?? DEFAULTS.HEALTH_CHECK_TIMEOUT_MS),
2299
2300
  PI_RESEARCH_SEARCH_TIMEOUT_MS: String(config.SEARCH_TIMEOUT_MS),
2300
2301
  PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS: String(config.TUI_REFRESH_DEBOUNCE_MS),
@@ -2457,6 +2458,7 @@ function createConfig(env, processEnv) {
2457
2458
  MAX_CONCURRENT_RESEARCHERS: parseEnvNumber(e, "PI_RESEARCH_MAX_RESEARCHERS", DEFAULTS.MAX_CONCURRENT_RESEARCHERS, 1, 5, true),
2458
2459
  RESEARCHER_MAX_RETRIES: parseEnvNumber(e, "PI_RESEARCH_MAX_RETRIES", DEFAULTS.RESEARCHER_MAX_RETRIES, 0, 5, true),
2459
2460
  RESEARCHER_MAX_RETRY_DELAY_MS: parseEnvNumber(e, "PI_RESEARCH_RETRY_DELAY_MS", DEFAULTS.RESEARCHER_MAX_RETRY_DELAY_MS, 100, 1e4),
2461
+ MAX_FAILED_RESEARCHERS: parseEnvNumber(e, "PI_RESEARCH_MAX_FAILED_RESEARCHERS", DEFAULTS.MAX_FAILED_RESEARCHERS, 1, 10, true),
2460
2462
  DEFAULT_RESEARCH_DEPTH: parseEnvNumber(e, "PI_RESEARCH_DEFAULT_RESEARCH_DEPTH", DEFAULTS.DEFAULT_RESEARCH_DEPTH, 1, 3, true),
2461
2463
  MAX_SCRAPE_BATCHES: parseEnvNumber(e, "PI_RESEARCH_MAX_SCRAPE_BATCHES", DEFAULTS.MAX_SCRAPE_BATCHES, 0, 99, true),
2462
2464
  MAX_GATHERING_CALLS: parseEnvNumber(e, "PI_RESEARCH_MAX_GATHERING_CALLS", DEFAULTS.MAX_GATHERING_CALLS, 1, 100, true),
@@ -2580,6 +2582,8 @@ var init_config = __esm({
2580
2582
  RESEARCHER_MAX_RETRIES: Type.Number({ minimum: 0, maximum: 5, default: 2 }),
2581
2583
  /** Base delay between retries in milliseconds (default: 2000, range: 100-10000) */
2582
2584
  RESEARCHER_MAX_RETRY_DELAY_MS: Type.Number({ minimum: 100, maximum: 1e4, default: 2e3 }),
2585
+ /** Unique researcher failures that abort the entire run (default: 2, range: 1-10) */
2586
+ MAX_FAILED_RESEARCHERS: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
2583
2587
  /** Target depth for recursive research (default: 1, range: 1-3) */
2584
2588
  DEFAULT_RESEARCH_DEPTH: Type.Number({ minimum: 1, maximum: 3, default: 1 }),
2585
2589
  /** Number of batches to allow for a single scrape tool call (default: 2, 0=unlimited) */
@@ -14650,7 +14654,7 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
14650
14654
  if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
14651
14655
  lastRestartTime = now;
14652
14656
  logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
14653
- await forceSchedulerRestart2(true, container);
14657
+ await forceSchedulerRestart2(false, container);
14654
14658
  await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
14655
14659
  } else {
14656
14660
  logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
@@ -14685,7 +14689,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
14685
14689
  if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
14686
14690
  lastRestartTime = now;
14687
14691
  logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
14688
- await forceSchedulerRestart2(true, container);
14692
+ await forceSchedulerRestart2(false, container);
14689
14693
  await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
14690
14694
  } else {
14691
14695
  await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
@@ -14724,7 +14728,7 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
14724
14728
  if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
14725
14729
  lastRestartTime = now;
14726
14730
  logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
14727
- await forceSchedulerRestart2(true, container);
14731
+ await forceSchedulerRestart2(false, container);
14728
14732
  await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
14729
14733
  } else {
14730
14734
  await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
@@ -19445,10 +19449,8 @@ function formatResults(results, rejected) {
19445
19449
  if (typeof r.charCount === "number") meta.push(`${r.charCount.toLocaleString()} chars`);
19446
19450
  if (meta.length) md += `**${meta.join(" \xB7 ")}**
19447
19451
  `;
19448
- if (r.title) {
19449
- md += `**Cite as:** ${r.url} \u2014 '${r.title}'${r.author ? ` by ${r.author}` : ""}
19452
+ md += `**Cite as:** ${r.url}${r.title ? ` \u2014 '${r.title}'${r.author ? ` by ${r.author}` : ""}` : ""}
19450
19453
  `;
19451
- }
19452
19454
  md += `
19453
19455
  ${r.text}
19454
19456
 
@@ -20108,10 +20110,10 @@ function getFailedResearchers(piSessionId, researchId) {
20108
20110
  const failures = state.failures.get(researchId) || [];
20109
20111
  return [...new Set(failures)];
20110
20112
  }
20111
- var MAX_FAILED_RESEARCHERS = 2;
20112
- function shouldStopResearch(piSessionId, researchId) {
20113
+ var DEFAULT_MAX_FAILED_RESEARCHERS = 2;
20114
+ function shouldStopResearch(piSessionId, researchId, maxFailedResearchers = DEFAULT_MAX_FAILED_RESEARCHERS) {
20113
20115
  const sid = normalizeSessionId(piSessionId);
20114
- return getFailedResearchers(sid, researchId).length >= MAX_FAILED_RESEARCHERS;
20116
+ return getFailedResearchers(sid, researchId).length >= maxFailedResearchers;
20115
20117
  }
20116
20118
  function getResearchStopMessage(piSessionId, researchId) {
20117
20119
  const sid = normalizeSessionId(piSessionId);
@@ -20137,6 +20139,12 @@ function getResearchStopMessage(piSessionId, researchId) {
20137
20139
  "Partial results may be available below."
20138
20140
  ].join("\n");
20139
20141
  }
20142
+ var RESEARCH_STOPPED_ERROR_CODE = "RESEARCH_STOPPED";
20143
+ function createResearchStopError(piSessionId, researchId) {
20144
+ const err = new Error(getResearchStopMessage(piSessionId, researchId));
20145
+ err.code = RESEARCH_STOPPED_ERROR_CODE;
20146
+ return err;
20147
+ }
20140
20148
 
20141
20149
  // src/orchestration/researcher-executor.ts
20142
20150
  async function runResearcher(options) {
@@ -20171,18 +20179,42 @@ ${currentPlan.allQueries.map((q) => `- ${q}`).join("\n")}
20171
20179
  Previous summary: ${e.description}`).join("\n");
20172
20180
  }
20173
20181
  const researcherPromptTemplate = loadPrompt("researcher");
20174
- if (initialLinks.length === 0 && historicalUrls.length === 0) {
20175
- logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links; skipping.`);
20176
- recordResearcherFailure(sessionId, researchId, id, "no initial search results or historical links (search produced nothing usable)");
20177
- metrics.increment("researcher_skipped_total", 1, { mode: "deep", complexity: String(complexity), reason: "no_initial_links" });
20178
- observer?.onResearcherFailure?.(id, "No initial search results or historical links available");
20179
- return;
20182
+ let effectiveInitialLinks = initialLinks;
20183
+ if (effectiveInitialLinks.length === 0 && historicalUrls.length === 0) {
20184
+ const retryQueries = researcherConfig.queries?.length ? researcherConfig.queries : [researcherConfig.goal];
20185
+ const maxLinkAttempts = config.RESEARCHER_MAX_RETRIES + 1;
20186
+ for (let linkAttempt = 1; linkAttempt <= maxLinkAttempts && effectiveInitialLinks.length === 0; linkAttempt++) {
20187
+ if (linkAttempt > 1) {
20188
+ if (signal?.aborted || container?.isDisposing) break;
20189
+ const delay2 = Math.min(1e3 * Math.pow(2, linkAttempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
20190
+ logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results; retry ${linkAttempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay2}ms`);
20191
+ await new Promise((r) => setTimeout(r, delay2));
20192
+ }
20193
+ if (signal?.aborted || container?.isDisposing) break;
20194
+ try {
20195
+ const queryResults = await search(retryQueries, config, signal, void 0, container);
20196
+ const urls = /* @__PURE__ */ new Set();
20197
+ for (const qr of queryResults) {
20198
+ for (const r of qr.results || []) urls.add(r.url);
20199
+ }
20200
+ effectiveInitialLinks = [...urls];
20201
+ } catch (err) {
20202
+ logger.debug(`[ResearcherExecutor] Researcher ${id} link-retry search burst failed:`, err);
20203
+ }
20204
+ }
20205
+ if (effectiveInitialLinks.length === 0) {
20206
+ logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links after ${maxLinkAttempts} attempt(s); skipping.`);
20207
+ recordResearcherFailure(sessionId, researchId, id, "no initial search results or historical links (search produced nothing usable)");
20208
+ metrics.increment("researcher_skipped_total", 1, { mode: "deep", complexity: String(complexity), reason: "no_initial_links" });
20209
+ observer?.onResearcherFailure?.(id, "No initial search results or historical links available");
20210
+ return;
20211
+ }
20180
20212
  }
20181
20213
  let evidenceSection = "";
20182
- if (initialLinks.length > 0) {
20214
+ if (effectiveInitialLinks.length > 0) {
20183
20215
  evidenceSection = `## Evidence Provided
20184
20216
  Initial search results provided the following URLs to investigate:
20185
- ${initialLinks.map((l) => `- ${l}`).join("\n")}`;
20217
+ ${effectiveInitialLinks.map((l) => `- ${l}`).join("\n")}`;
20186
20218
  }
20187
20219
  const maxAttempts = config.RESEARCHER_MAX_RETRIES + 1;
20188
20220
  let lastError;
@@ -20428,11 +20460,11 @@ init_constants();
20428
20460
  // src/orchestration/headless-observer.ts
20429
20461
  init_logger();
20430
20462
  function makeSafeObserver(observer) {
20431
- return new Proxy(observer, {
20432
- get(target, prop) {
20463
+ return new Proxy({}, {
20464
+ get(_target, prop) {
20433
20465
  let value;
20434
20466
  try {
20435
- value = Reflect.get(target, prop, target);
20467
+ value = Reflect.get(observer, prop, observer);
20436
20468
  } catch (err) {
20437
20469
  logger.warn(`[observer] Ignored error reading observer.${String(prop)}:`, err);
20438
20470
  return void 0;
@@ -20440,7 +20472,7 @@ function makeSafeObserver(observer) {
20440
20472
  if (typeof value !== "function") return value;
20441
20473
  return (...args) => {
20442
20474
  try {
20443
- const out = value.apply(target, args);
20475
+ const out = value.apply(observer, args);
20444
20476
  if (out && typeof out.then === "function") {
20445
20477
  return out.catch((err) => {
20446
20478
  logger.warn(`[observer] Ignored rejection from observer.${String(prop)}:`, err);
@@ -20452,6 +20484,17 @@ function makeSafeObserver(observer) {
20452
20484
  return void 0;
20453
20485
  }
20454
20486
  };
20487
+ },
20488
+ has(_target, prop) {
20489
+ try {
20490
+ return Reflect.has(observer, prop);
20491
+ } catch {
20492
+ return false;
20493
+ }
20494
+ },
20495
+ // Keep `instanceof` transparent (legal on an extensible target).
20496
+ getPrototypeOf() {
20497
+ return Reflect.getPrototypeOf(observer);
20455
20498
  }
20456
20499
  });
20457
20500
  }
@@ -21403,12 +21446,13 @@ var ResearchOrchestrationService = class {
21403
21446
  const researchers = plan.researchers || [];
21404
21447
  const active = /* @__PURE__ */ new Set();
21405
21448
  const maxConcurrent = orchestratorOptions.config?.MAX_CONCURRENT_RESEARCHERS ?? DEFAULTS.MAX_CONCURRENT_RESEARCHERS;
21449
+ const maxFailedResearchers = orchestratorOptions.config?.MAX_FAILED_RESEARCHERS ?? DEFAULTS.MAX_FAILED_RESEARCHERS;
21406
21450
  for (const configItem of researchers) {
21407
21451
  if (signal?.aborted) break;
21408
- if (shouldStopResearch(sessionId, researchId)) {
21452
+ if (shouldStopResearch(sessionId, researchId, maxFailedResearchers)) {
21409
21453
  const sessionService = await getService(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
21410
21454
  await sessionService.abortAllSessions(researchId);
21411
- throw new Error(getResearchStopMessage(sessionId, researchId));
21455
+ throw createResearchStopError(sessionId, researchId);
21412
21456
  }
21413
21457
  if (active.size >= maxConcurrent) {
21414
21458
  if (signal?.aborted) break;
@@ -21477,11 +21521,10 @@ var ResearchOrchestrationService = class {
21477
21521
  });
21478
21522
  if (signal?.aborted) break;
21479
21523
  }
21480
- if (shouldStopResearch(sessionId, researchId)) {
21524
+ if (shouldStopResearch(sessionId, researchId, maxFailedResearchers)) {
21481
21525
  const sessionService = await getService(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
21482
21526
  await sessionService.abortAllSessions(researchId);
21483
- const stopMessage = getResearchStopMessage(sessionId, researchId);
21484
- throw new Error(stopMessage);
21527
+ throw createResearchStopError(sessionId, researchId);
21485
21528
  }
21486
21529
  }
21487
21530
  await Promise.all(active);
@@ -21878,18 +21921,26 @@ Research performed using ${modelId}`;
21878
21921
  ensureCitedLinks(sessionId, synthesis) {
21879
21922
  const reports = this.getSessionReports(sessionId);
21880
21923
  let globalCitations = reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
21881
- if (globalCitations.length === 0) {
21924
+ if (reports.size > 0) {
21882
21925
  const scraped = getScrapedLinks(sessionId);
21883
21926
  if (scraped.length > 0) {
21884
- globalCitations = scraped.map((url, i) => ({
21885
- id: i + 1,
21886
- url,
21887
- description: "",
21888
- source: isTranscribedLink(sessionId, url) ? "YouTube Transcript" : "Scrape"
21889
- }));
21890
- logger.warn(
21891
- `[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape/transcript provenance.`
21892
- );
21927
+ const known = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
21928
+ const missing = scraped.filter((url) => !known.has(normalizeUrl(url)));
21929
+ if (missing.length > 0) {
21930
+ const startId = globalCitations.reduce((max, c) => Math.max(max, c.id), 0) + 1;
21931
+ globalCitations = [
21932
+ ...globalCitations,
21933
+ ...missing.map((url, i) => ({
21934
+ id: startId + i,
21935
+ url,
21936
+ description: "",
21937
+ source: isTranscribedLink(sessionId, url) ? "YouTube Transcript" : "Scrape"
21938
+ }))
21939
+ ];
21940
+ logger.warn(
21941
+ `[ResearchSynthesisService] Folded in ${missing.length} source(s) from scrape/transcript provenance not covered by parsed citations.`
21942
+ );
21943
+ }
21893
21944
  }
21894
21945
  }
21895
21946
  if (globalCitations.length > 0) {
@@ -22103,6 +22154,79 @@ init_metrics();
22103
22154
 
22104
22155
  // src/utils/metrics-summary.ts
22105
22156
  init_text_utils();
22157
+ function sumCounter(counters, baseName) {
22158
+ let total = 0;
22159
+ for (const [key, value] of Object.entries(counters)) {
22160
+ if (key === baseName || key.startsWith(baseName + "{")) {
22161
+ total += value;
22162
+ }
22163
+ }
22164
+ return total;
22165
+ }
22166
+ function getLabeledCounter(counters, baseName, labels) {
22167
+ const labelParts = Object.entries(labels).map(([k, v]) => `${k}="${v}"`);
22168
+ const suffix = `{${labelParts.join(",")}}`;
22169
+ for (const [key, value] of Object.entries(counters)) {
22170
+ if (key === `${baseName}${suffix}`) {
22171
+ return value;
22172
+ }
22173
+ }
22174
+ return 0;
22175
+ }
22176
+ function extractRunStats(snapshot) {
22177
+ const counters = snapshot.counters || {};
22178
+ const histograms = snapshot.histograms || {};
22179
+ const researchersLaunched = sumCounter(counters, "researchers_launched_total");
22180
+ const browserSearchQueries = sumCounter(counters, "browser_search_queries_total");
22181
+ const searchQueries = sumCounter(counters, "search_queries_total") + browserSearchQueries;
22182
+ const urlsDiscovered = sumCounter(counters, "browser_search_results_total") + sumCounter(counters, "browser_search_unique_urls_total");
22183
+ const fetchSuccess = getLabeledCounter(counters, "scrape_results_total", { outcome: "fetch_success" });
22184
+ const browserSuccess = getLabeledCounter(counters, "scrape_results_total", { outcome: "browser_success" });
22185
+ const totalFailure = getLabeledCounter(counters, "scrape_results_total", { outcome: "total_failure" });
22186
+ const browserFallbacks = sumCounter(counters, "scrape_layer_fallbacks_total");
22187
+ const urlsAnalyzed = fetchSuccess + browserSuccess;
22188
+ const urlsFailed = totalFailure;
22189
+ const toolUsage = {
22190
+ searches: sumCounter(counters, "tool_search_calls_total"),
22191
+ scrapes: sumCounter(counters, "tool_scrape_calls_total"),
22192
+ securitySearches: sumCounter(counters, "tool_security_search_calls_total"),
22193
+ stackexchangeQueries: sumCounter(counters, "stackexchange_requests_total"),
22194
+ knowledgeLookups: sumCounter(counters, "research_knowledge_search_total")
22195
+ };
22196
+ const errors = sumCounter(counters, "researcher_errors_total") + sumCounter(counters, "llm_api_errors_total");
22197
+ const tokens = sumCounter(counters, "llm_tokens_total");
22198
+ const cost = sumCounter(counters, "llm_cost_total");
22199
+ const durationMs = histograms['research_session_duration_ms{mode="deep",complexity="1",status="success"}']?.max || histograms['research_session_duration_ms{mode="deep",complexity="2",status="success"}']?.max || histograms['research_session_duration_ms{mode="deep",complexity="3",status="success"}']?.max || histograms['research_session_duration_ms{mode="quick",complexity="0",status="success"}']?.max || 0;
22200
+ const roundsCompleted = sumCounter(counters, "evaluator_runs_total");
22201
+ let complexity = 1;
22202
+ for (const key of Object.keys(counters)) {
22203
+ if (key.startsWith("research_sessions_total{")) {
22204
+ const match = key.match(/complexity="(\d+)"/);
22205
+ if (match?.[1]) complexity = parseInt(match[1], 10);
22206
+ break;
22207
+ }
22208
+ }
22209
+ if (researchersLaunched === 0 && searchQueries === 0 && urlsAnalyzed === 0 && tokens === 0) {
22210
+ return null;
22211
+ }
22212
+ return {
22213
+ durationMs,
22214
+ complexity,
22215
+ researchersLaunched,
22216
+ roundsCompleted,
22217
+ searchQueries,
22218
+ urlsDiscovered,
22219
+ fetchSuccess,
22220
+ browserSuccess,
22221
+ browserFallbacks,
22222
+ urlsAnalyzed,
22223
+ urlsFailed,
22224
+ errors,
22225
+ tokens,
22226
+ cost,
22227
+ toolUsage
22228
+ };
22229
+ }
22106
22230
 
22107
22231
  // src/sdk.ts
22108
22232
  init_error_tracker();
@@ -22124,6 +22248,7 @@ var _isRunning = false;
22124
22248
  var _lastResearchId = null;
22125
22249
  var _lastRunSummary = null;
22126
22250
  var _lastErrorReport = null;
22251
+ var _lastResearcherOutcome = null;
22127
22252
  var _onSignal = null;
22128
22253
  var _sigintHandler = null;
22129
22254
  var _sigtermHandler = null;
@@ -22310,6 +22435,7 @@ async function runDeepResearch(query, options = {}, signal) {
22310
22435
  metrics.recordRunSummary(_lastRunSummary);
22311
22436
  _lastErrorReport = runTracker.getReport();
22312
22437
  logRunErrorSummary(_lastErrorReport, depthLabel, "success");
22438
+ _lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
22313
22439
  return result;
22314
22440
  } catch (err) {
22315
22441
  const completedAt = Date.now();
@@ -22325,6 +22451,7 @@ async function runDeepResearch(query, options = {}, signal) {
22325
22451
  metrics.recordRunSummary(_lastRunSummary);
22326
22452
  _lastErrorReport = runTracker.getReport();
22327
22453
  logRunErrorSummary(_lastErrorReport, depthLabel, "error");
22454
+ _lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
22328
22455
  throw err;
22329
22456
  } finally {
22330
22457
  try {
@@ -22334,11 +22461,35 @@ async function runDeepResearch(query, options = {}, signal) {
22334
22461
  _isRunning = false;
22335
22462
  }
22336
22463
  }
22464
+ function getLastErrorReport() {
22465
+ return _lastErrorReport;
22466
+ }
22467
+ function getLastResearcherOutcome() {
22468
+ return _lastResearcherOutcome;
22469
+ }
22337
22470
  function logRunErrorSummary(report, depthLabel, status) {
22338
22471
  if (!report || report.totalErrors === 0) return;
22339
22472
  const top = report.patterns.slice(0, 3).map((p) => `"${String(p.message ?? p.signature ?? "error").slice(0, 48)}" \xD7${p.count}`).join(", ");
22340
22473
  logger.warn(`[SDK] run ${status} with ${report.totalErrors} tracked error(s) across ${report.uniquePatterns} pattern(s) [${depthLabel}]${top ? ` \u2014 top: ${top}` : ""}`);
22341
22474
  }
22475
+ async function captureResearcherOutcome(sessionId, researchId, snapshot) {
22476
+ try {
22477
+ const planningService = await getService(ServiceNames.PLANNING, void 0, globalContainer);
22478
+ const planned = planningService.getTotalResearchersPlanned(researchId);
22479
+ const launched = extractRunStats(snapshot)?.researchersLaunched ?? 0;
22480
+ const failed = getFailedResearchers(sessionId, researchId);
22481
+ return {
22482
+ planned,
22483
+ launched,
22484
+ succeeded: Math.max(0, launched - failed.length),
22485
+ failed: failed.length,
22486
+ failureReasons: getResearcherFailureReasons(sessionId, researchId)
22487
+ };
22488
+ } catch (err) {
22489
+ logger.debug("[SDK] Failed to capture researcher outcome (non-fatal):", err);
22490
+ return null;
22491
+ }
22492
+ }
22342
22493
  async function searchKnowledge(queries, signal) {
22343
22494
  if (!isInitialized || !globalContainer) {
22344
22495
  throw new Error("SDK not initialized. Call initResearchSDK() first.");
@@ -22422,6 +22573,7 @@ async function _doShutdown() {
22422
22573
  _lastResearchId = null;
22423
22574
  _lastRunSummary = null;
22424
22575
  _lastErrorReport = null;
22576
+ _lastResearcherOutcome = null;
22425
22577
  resetConfig();
22426
22578
  _removeSignalHandlers();
22427
22579
  if (errors.length > 0) {
@@ -22927,6 +23079,8 @@ Error: ${vmsg}
22927
23079
  `);
22928
23080
  const observer = makeProgressObserver();
22929
23081
  let exit = EXIT.OK;
23082
+ const abortController = new AbortController();
23083
+ activeResearchAbortController = abortController;
22930
23084
  try {
22931
23085
  await initResearchSDK({
22932
23086
  model: runModel,
@@ -22943,7 +23097,7 @@ Error: ${vmsg}
22943
23097
  observer,
22944
23098
  ...args.excludeTools ? { excludeTools: args.excludeTools } : {},
22945
23099
  ...args.initialLinks ? { initialLinks: args.initialLinks } : {}
22946
- });
23100
+ }, abortController.signal);
22947
23101
  let reportPath = null;
22948
23102
  const exportCfg = getConfig(process.cwd(), "cli");
22949
23103
  if (exportCfg.RESEARCH_REPORT_EXPORT_ENABLED) {
@@ -22955,13 +23109,25 @@ Error: ${vmsg}
22955
23109
  }
22956
23110
  }
22957
23111
  if (args.json) {
22958
- toStdout(pretty({ ok: true, depth, report, ...reportPath ? { reportPath } : {} }));
23112
+ const outcome = getLastResearcherOutcome();
23113
+ const errorReport = getLastErrorReport();
23114
+ toStdout(pretty({
23115
+ ok: true,
23116
+ depth,
23117
+ report,
23118
+ ...reportPath ? { reportPath } : {},
23119
+ ...outcome ? { researchers: { planned: outcome.planned, launched: outcome.launched, succeeded: outcome.succeeded, failed: outcome.failed } } : {},
23120
+ ...errorReport && errorReport.totalErrors > 0 ? {
23121
+ trackedErrors: { total: errorReport.totalErrors, uniquePatterns: errorReport.uniquePatterns }
23122
+ } : {}
23123
+ }));
22959
23124
  } else {
22960
23125
  toStdout(report.endsWith("\n") ? report : report + "\n");
22961
23126
  }
22962
23127
  } catch (err) {
22963
23128
  exit = reportError(err, "research", args.json);
22964
23129
  } finally {
23130
+ if (activeResearchAbortController === abortController) activeResearchAbortController = null;
22965
23131
  await safeShutdown();
22966
23132
  }
22967
23133
  return exit;
@@ -23238,7 +23404,8 @@ function describeUninstall(r) {
23238
23404
  function reportError(err, what, json) {
23239
23405
  const msg2 = err instanceof Error ? err.message : String(err);
23240
23406
  const lower = msg2.toLowerCase();
23241
- const isConfigError = lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key");
23407
+ const isTaggedResearchStop = err?.code === RESEARCH_STOPPED_ERROR_CODE;
23408
+ const isConfigError = !isTaggedResearchStop && (lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key"));
23242
23409
  const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
23243
23410
  const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23244
23411
  if (json) {
@@ -23546,6 +23713,7 @@ var flushAndExit = (code) => {
23546
23713
  });
23547
23714
  });
23548
23715
  };
23716
+ var activeResearchAbortController = null;
23549
23717
  async function main(argv) {
23550
23718
  let _signalCleanupDone = false;
23551
23719
  const onSignal = (sig) => {
@@ -23554,6 +23722,7 @@ async function main(argv) {
23554
23722
  toStderr(`
23555
23723
  [pi-research] ${sig} \u2014 cleaning up\u2026
23556
23724
  `);
23725
+ activeResearchAbortController?.abort();
23557
23726
  void safeShutdown().finally(() => flushAndExit(EXIT.SOFTWARE));
23558
23727
  };
23559
23728
  process.once("SIGINT", () => onSignal("SIGINT"));
@@ -23623,5 +23792,6 @@ export {
23623
23792
  EXIT,
23624
23793
  UsageError,
23625
23794
  bridgeConfigEnv,
23626
- parseArgs
23795
+ parseArgs,
23796
+ reportError
23627
23797
  };
@@ -85,6 +85,7 @@ The coordinator will plan as many researchers as needed (up to the max). You do
85
85
 
86
86
  **Multiple Topics:**
87
87
  - If you need to research multiple unrelated topics of **truly distinct scope**, you may emit multiple `research` calls **simultaneously in a single turn**. Each call gets its own isolated research run with its own coordinator and researchers.
88
+ - **Maximum 4 simultaneous calls** — Do NOT make more than 4 `research` calls in a single turn, even if you have more unrelated topics. Exceeding 4 simultaneous research runs can overload the system and degrade performance. If you have more than 4 truly distinct topics, they can likely be consolidated into fewer calls (group related subtopics, use broader queries, or batch across multiple turns).
88
89
  - Example: `research("bananas")` and `research("quantum computing")` in the same turn — these have zero overlap.
89
90
  - **Group related topics**: If topics share ANY scope or could inform each other, combine them into a single `research` call. The internal coordinator will decompose them into parallel researcher agents. Do NOT split related sub-topics into separate calls.
90
91
  - Example: `research("Python async performance and Rust async performance comparison")` — NOT separate calls for Python and Rust, since the comparison IS the research goal.
@@ -2219,6 +2219,8 @@ var ConfigSchema = Type.Object({
2219
2219
  RESEARCHER_MAX_RETRIES: Type.Number({ minimum: 0, maximum: 5, default: 2 }),
2220
2220
  /** Base delay between retries in milliseconds (default: 2000, range: 100-10000) */
2221
2221
  RESEARCHER_MAX_RETRY_DELAY_MS: Type.Number({ minimum: 100, maximum: 1e4, default: 2e3 }),
2222
+ /** Unique researcher failures that abort the entire run (default: 2, range: 1-10) */
2223
+ MAX_FAILED_RESEARCHERS: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
2222
2224
  /** Target depth for recursive research (default: 1, range: 1-3) */
2223
2225
  DEFAULT_RESEARCH_DEPTH: Type.Number({ minimum: 1, maximum: 3, default: 1 }),
2224
2226
  /** Number of batches to allow for a single scrape tool call (default: 2, 0=unlimited) */
@@ -25,8 +25,8 @@ pi CLI
25
25
  in-session, the calling agent picks the depth (1–3) from the user's wording and the
26
26
  task's complexity, guided by the tool's usage prompt (`src/prompts/research-tool-usage.md`).
27
27
  CLI and SDK callers pass the depth explicitly.
28
- 2. Depth 0 takes the quick path; depth 1–3 takes the deep path (below). The TUI and agent
29
- skill are restricted to levels 1–3.
28
+ 2. Depth 0 takes the quick path; depth 1–3 takes the deep path (below). The pi extension's
29
+ tool and the TUI are restricted to levels 1–3; the CLI, SDK, and agent skill can pass 0.
30
30
  3. On the deep path the coordinator plans the research tracks and runs one initial
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
@@ -84,10 +84,23 @@ Two conventions apply:
84
84
  report. A mid-round evaluation that cannot be parsed continues the existing agenda
85
85
  rather than finalizing early, so a parse failure never truncates a run.
86
86
 
87
- ### Research tools
87
+ ### Tool inventory
88
88
 
89
- Each researcher has a fixed tool set and a shared budget of 12 gathering calls per phase
90
- (`MAX_GATHERING_CALLS`).
89
+ This is the canonical list of every tool the system exposes, on both surfaces.
90
+
91
+ **Host-facing tools** — registered with the pi session (`src/index.ts`) for the calling
92
+ agent to invoke:
93
+
94
+ | Tool | Purpose |
95
+ |------|---------|
96
+ | `research` | Run a full multi-source research session and return the cited Markdown report |
97
+ | `research_knowledge_search` | Instant local search of the knowledge store — checked before live research; always registered, reports why when the store is disabled |
98
+ | `health` | Verify system status (browser pool, knowledge store, GPU lock); optional liveness probe |
99
+
100
+ **Researcher-agent tools** — the fixed set each researcher sub-agent works with
101
+ (`src/tools/index.ts`). `search`, `security_search`, `stackexchange`, and
102
+ `youtube_transcript` share a budget of 12 gathering calls per phase
103
+ (`MAX_GATHERING_CALLS`); `scrape` and local `grep` have their own budgets:
91
104
 
92
105
  | Tool | Quick | Deep | Backend |
93
106
  |------|-------|------|---------|
@@ -102,7 +115,9 @@ Each researcher has a fixed tool set and a shared budget of 12 gathering calls p
102
115
  In deep research `search` is excluded — the coordinator runs the search burst and hands
103
116
  out URLs directly. In quick research `grep` is excluded — the single session is not
104
117
  expected to traverse a local codebase. Researchers cannot write files, run shell
105
- commands, or reach the network outside these tools.
118
+ commands, or reach the network outside these tools. Individual researcher tools can be
119
+ disabled per run with `PI_RESEARCH_DISABLED_TOOLS`
120
+ (see [CONFIGURATION.md](CONFIGURATION.md)).
106
121
 
107
122
  ### Browser infrastructure
108
123
 
@@ -154,7 +169,8 @@ WriterQueue (async, non-blocking)
154
169
  Key files: `src/knowledge/store.ts` (LanceDB operations), `embedder.ts` (model load +
155
170
  batched inference), `writer-queue.ts` (async writes + content-hash dedup), `chunker.ts`
156
171
  (chunking), `webgpu-viability.ts` (out-of-process GPU probe + cached verdict),
157
- `migration.ts` (model-change migration: drop / backup / re-embed).
172
+ `migration.ts` (migration strategy types — the drop / backup / re-embed logic
173
+ itself lives in `store.ts`).
158
174
 
159
175
  The store needs native ONNX-runtime and LanceDB bindings. On platforms with no prebuilt
160
176
  binary — notably Intel macOS (`darwin-x64`) — it is absent: the health check reports it
@@ -0,0 +1,108 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.8] - 2024-07-17
9
+
10
+ ### Added
11
+ - **CHANGELOG** — Added comprehensive CHANGELOG.md documenting all versions from 0.1.13 through 1.0.7
12
+ - **System prompt constraint** — Added "Maximum 4 simultaneous calls" constraint to research-tool-usage prompt to prevent system overload
13
+
14
+ ### Changed
15
+ - **Topic consolidation guidance** — Emphasized that topics can likely be consolidated to fit the 4-call maximum, with strategies for grouping related subtopics
16
+ - **Package structure** — CHANGELOG.md now included in published package (docs/CHANGELOG.md)
17
+
18
+ ## [1.0.7] - 2024-07-14
19
+
20
+ ### Fixed
21
+ - **Browser-pool leader-election race** - Fixed a critical bug where a single client's transient socket error could force-clear shared leader state, causing a live, healthy leader to self-destruct via the leadership-miss watchdog. The fix reserves the "unreachable, force clear" path for cases where liveness probes actually confirm the registered leader PID or port is gone.
22
+ - **Researcher retry-exemption** - Fixed the "no initial search results or historical links" failure path that bypassed the retry loop entirely. This now routes through the same `RESEARCHER_MAX_RETRIES`/`RESEARCHER_MAX_RETRY_DELAY_MS` retry loop as other failures.
23
+ - **Exit-code misclassification** - Fixed false-positive classification of "Research stopped" errors as config errors (exit 78) via substring matching on the boilerplate "API key" advice text. Error classification now properly distinguishes infrastructure failures from credential problems.
24
+ - **Unsurfaced quality signals** - Added per-run error tracking and per-researcher grounding signals to the log output for better debugging of report quality variance.
25
+ - **SIGINT/SIGTERM cancellation wiring** - Improved cancellation handling for graceful shutdown on interrupt signals.
26
+ - **Multi-researcher citation-provenance folding** - Fixed citation provenance tracking when multiple researchers contribute to a report.
27
+ - **Untitled YouTube video citation line** - Fixed citation formatting for YouTube videos without titles.
28
+
29
+ ### Security
30
+ - Several hardening improvements from the adversarial-review audit:
31
+ - Migration abort safety gates
32
+ - Hollow-run detection gates
33
+ - Streaming caps for large responses
34
+ - Socket drain on Content-Length pre-screen throws
35
+ - Observer isolation improvements
36
+
37
+ ## [1.0.6] - 2024-07-01
38
+
39
+ ### Fixed
40
+ - **Observer isolation** - Fixed `makeSafeObserver` proxying the observer directly, which could cause state leakage.
41
+
42
+ ### Documentation
43
+ - Corrected depth-0 availability documentation
44
+ - Fixed migration.ts attribution in architecture docs
45
+
46
+ ## [1.0.5] - 2024-06-28
47
+
48
+ ### Fixed
49
+ - **Content-Length pre-screen throws** - Added proper body draining to prevent resource leaks
50
+ - **TTL refresh for transcribed links** - Fixed TTL handling for YouTube transcripts
51
+
52
+ ### Fixed
53
+ - **YouTube transcript provenance** - Fixed transcript provenance tracking in final report citations
54
+
55
+ ## [1.0.4] - 2024-06-25
56
+
57
+ ### Fixed
58
+ - **Credential detection** - Detect pi keys by pi's own semantics, not file presence; registry no longer gated on auth.json
59
+ - **Configuration clarity** - Updated docs to reflect that pi has configuration files, not a named 'auth' feature
60
+
61
+ ## [1.0.3] - 2024-06-20
62
+
63
+ ### Fixed
64
+ - **Model resolution** - Require a configured model on the standalone CLI/skill; align SDK session model with RESEARCH_MODEL
65
+ - **Configuration requirement** - Added proper error messages when model is not configured
66
+
67
+ ### Documentation
68
+ - Updated installation and configuration documentation
69
+
70
+ ## [1.0.2] - 2024-06-15
71
+
72
+ ### Fixed
73
+ - **Timeout robustness** - Improved timeout handling for long-running operations
74
+ - De-emphasized the markdown-conversion fallback in favor of native HTML-to-markdown conversion
75
+
76
+ ## [1.0.1] - 2024-06-10
77
+
78
+ ### Fixed
79
+ - Initial stable release after rebuild against current pi APIs
80
+ - Stealth-browser stack integration
81
+
82
+ ## [0.1.13] - 2024-04-15
83
+
84
+ ### Added
85
+ - Initial research functionality
86
+ - Knowledge store integration
87
+ - Multi-agent orchestration
88
+
89
+ ---
90
+
91
+ ## Deferred Findings (from v1.0.7 audit)
92
+
93
+ The following issues were identified during the v1.0.7 audit but intentionally deferred to future releases:
94
+
95
+ 1. **KnowledgeStore.close() timeout behavior** - The close() method proceeds even when the pending-write wait times out. A correct fix requires threading cancellation tokens through write paths or documenting the 10s timeout as intended behavior.
96
+
97
+ 2. **Cross-session teardown via shared singletons** - Session shutdown handlers run process-wide cleanup for reload/new/fork events. The correct behavior depends on pi's session model semantics and needs further investigation.
98
+
99
+ 3. **Lower-severity hardening items** - Various low-confidence or low-impact findings noted for future investigation:
100
+ - SIGINT-adjacent cancellation race
101
+ - Knowledge-store migration vs. concurrent access
102
+ - Global activity gate reused for per-session decisions
103
+ - Zombie queued browser tasks after timeout
104
+ - Non-atomic `clearBrowserServer`
105
+ - Inconsistent `knowledge --json` payload shapes
106
+ - Backward-clock-step tolerance across multiple rate limiters
107
+
108
+ See `DEFERRED-FINDINGS-2026-07-14.md` for full details.