@lincoln504/pi-research 1.0.6 → 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;
@@ -21414,12 +21446,13 @@ var ResearchOrchestrationService = class {
21414
21446
  const researchers = plan.researchers || [];
21415
21447
  const active = /* @__PURE__ */ new Set();
21416
21448
  const maxConcurrent = orchestratorOptions.config?.MAX_CONCURRENT_RESEARCHERS ?? DEFAULTS.MAX_CONCURRENT_RESEARCHERS;
21449
+ const maxFailedResearchers = orchestratorOptions.config?.MAX_FAILED_RESEARCHERS ?? DEFAULTS.MAX_FAILED_RESEARCHERS;
21417
21450
  for (const configItem of researchers) {
21418
21451
  if (signal?.aborted) break;
21419
- if (shouldStopResearch(sessionId, researchId)) {
21452
+ if (shouldStopResearch(sessionId, researchId, maxFailedResearchers)) {
21420
21453
  const sessionService = await getService(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
21421
21454
  await sessionService.abortAllSessions(researchId);
21422
- throw new Error(getResearchStopMessage(sessionId, researchId));
21455
+ throw createResearchStopError(sessionId, researchId);
21423
21456
  }
21424
21457
  if (active.size >= maxConcurrent) {
21425
21458
  if (signal?.aborted) break;
@@ -21488,11 +21521,10 @@ var ResearchOrchestrationService = class {
21488
21521
  });
21489
21522
  if (signal?.aborted) break;
21490
21523
  }
21491
- if (shouldStopResearch(sessionId, researchId)) {
21524
+ if (shouldStopResearch(sessionId, researchId, maxFailedResearchers)) {
21492
21525
  const sessionService = await getService(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
21493
21526
  await sessionService.abortAllSessions(researchId);
21494
- const stopMessage = getResearchStopMessage(sessionId, researchId);
21495
- throw new Error(stopMessage);
21527
+ throw createResearchStopError(sessionId, researchId);
21496
21528
  }
21497
21529
  }
21498
21530
  await Promise.all(active);
@@ -21889,18 +21921,26 @@ Research performed using ${modelId}`;
21889
21921
  ensureCitedLinks(sessionId, synthesis) {
21890
21922
  const reports = this.getSessionReports(sessionId);
21891
21923
  let globalCitations = reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
21892
- if (globalCitations.length === 0) {
21924
+ if (reports.size > 0) {
21893
21925
  const scraped = getScrapedLinks(sessionId);
21894
21926
  if (scraped.length > 0) {
21895
- globalCitations = scraped.map((url, i) => ({
21896
- id: i + 1,
21897
- url,
21898
- description: "",
21899
- source: isTranscribedLink(sessionId, url) ? "YouTube Transcript" : "Scrape"
21900
- }));
21901
- logger.warn(
21902
- `[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape/transcript provenance.`
21903
- );
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
+ }
21904
21944
  }
21905
21945
  }
21906
21946
  if (globalCitations.length > 0) {
@@ -22114,6 +22154,79 @@ init_metrics();
22114
22154
 
22115
22155
  // src/utils/metrics-summary.ts
22116
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
+ }
22117
22230
 
22118
22231
  // src/sdk.ts
22119
22232
  init_error_tracker();
@@ -22135,6 +22248,7 @@ var _isRunning = false;
22135
22248
  var _lastResearchId = null;
22136
22249
  var _lastRunSummary = null;
22137
22250
  var _lastErrorReport = null;
22251
+ var _lastResearcherOutcome = null;
22138
22252
  var _onSignal = null;
22139
22253
  var _sigintHandler = null;
22140
22254
  var _sigtermHandler = null;
@@ -22321,6 +22435,7 @@ async function runDeepResearch(query, options = {}, signal) {
22321
22435
  metrics.recordRunSummary(_lastRunSummary);
22322
22436
  _lastErrorReport = runTracker.getReport();
22323
22437
  logRunErrorSummary(_lastErrorReport, depthLabel, "success");
22438
+ _lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
22324
22439
  return result;
22325
22440
  } catch (err) {
22326
22441
  const completedAt = Date.now();
@@ -22336,6 +22451,7 @@ async function runDeepResearch(query, options = {}, signal) {
22336
22451
  metrics.recordRunSummary(_lastRunSummary);
22337
22452
  _lastErrorReport = runTracker.getReport();
22338
22453
  logRunErrorSummary(_lastErrorReport, depthLabel, "error");
22454
+ _lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
22339
22455
  throw err;
22340
22456
  } finally {
22341
22457
  try {
@@ -22345,11 +22461,35 @@ async function runDeepResearch(query, options = {}, signal) {
22345
22461
  _isRunning = false;
22346
22462
  }
22347
22463
  }
22464
+ function getLastErrorReport() {
22465
+ return _lastErrorReport;
22466
+ }
22467
+ function getLastResearcherOutcome() {
22468
+ return _lastResearcherOutcome;
22469
+ }
22348
22470
  function logRunErrorSummary(report, depthLabel, status) {
22349
22471
  if (!report || report.totalErrors === 0) return;
22350
22472
  const top = report.patterns.slice(0, 3).map((p) => `"${String(p.message ?? p.signature ?? "error").slice(0, 48)}" \xD7${p.count}`).join(", ");
22351
22473
  logger.warn(`[SDK] run ${status} with ${report.totalErrors} tracked error(s) across ${report.uniquePatterns} pattern(s) [${depthLabel}]${top ? ` \u2014 top: ${top}` : ""}`);
22352
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
+ }
22353
22493
  async function searchKnowledge(queries, signal) {
22354
22494
  if (!isInitialized || !globalContainer) {
22355
22495
  throw new Error("SDK not initialized. Call initResearchSDK() first.");
@@ -22433,6 +22573,7 @@ async function _doShutdown() {
22433
22573
  _lastResearchId = null;
22434
22574
  _lastRunSummary = null;
22435
22575
  _lastErrorReport = null;
22576
+ _lastResearcherOutcome = null;
22436
22577
  resetConfig();
22437
22578
  _removeSignalHandlers();
22438
22579
  if (errors.length > 0) {
@@ -22938,6 +23079,8 @@ Error: ${vmsg}
22938
23079
  `);
22939
23080
  const observer = makeProgressObserver();
22940
23081
  let exit = EXIT.OK;
23082
+ const abortController = new AbortController();
23083
+ activeResearchAbortController = abortController;
22941
23084
  try {
22942
23085
  await initResearchSDK({
22943
23086
  model: runModel,
@@ -22954,7 +23097,7 @@ Error: ${vmsg}
22954
23097
  observer,
22955
23098
  ...args.excludeTools ? { excludeTools: args.excludeTools } : {},
22956
23099
  ...args.initialLinks ? { initialLinks: args.initialLinks } : {}
22957
- });
23100
+ }, abortController.signal);
22958
23101
  let reportPath = null;
22959
23102
  const exportCfg = getConfig(process.cwd(), "cli");
22960
23103
  if (exportCfg.RESEARCH_REPORT_EXPORT_ENABLED) {
@@ -22966,13 +23109,25 @@ Error: ${vmsg}
22966
23109
  }
22967
23110
  }
22968
23111
  if (args.json) {
22969
- 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
+ }));
22970
23124
  } else {
22971
23125
  toStdout(report.endsWith("\n") ? report : report + "\n");
22972
23126
  }
22973
23127
  } catch (err) {
22974
23128
  exit = reportError(err, "research", args.json);
22975
23129
  } finally {
23130
+ if (activeResearchAbortController === abortController) activeResearchAbortController = null;
22976
23131
  await safeShutdown();
22977
23132
  }
22978
23133
  return exit;
@@ -23249,7 +23404,8 @@ function describeUninstall(r) {
23249
23404
  function reportError(err, what, json) {
23250
23405
  const msg2 = err instanceof Error ? err.message : String(err);
23251
23406
  const lower = msg2.toLowerCase();
23252
- 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"));
23253
23409
  const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
23254
23410
  const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23255
23411
  if (json) {
@@ -23557,6 +23713,7 @@ var flushAndExit = (code) => {
23557
23713
  });
23558
23714
  });
23559
23715
  };
23716
+ var activeResearchAbortController = null;
23560
23717
  async function main(argv) {
23561
23718
  let _signalCleanupDone = false;
23562
23719
  const onSignal = (sig) => {
@@ -23565,6 +23722,7 @@ async function main(argv) {
23565
23722
  toStderr(`
23566
23723
  [pi-research] ${sig} \u2014 cleaning up\u2026
23567
23724
  `);
23725
+ activeResearchAbortController?.abort();
23568
23726
  void safeShutdown().finally(() => flushAndExit(EXIT.SOFTWARE));
23569
23727
  };
23570
23728
  process.once("SIGINT", () => onSignal("SIGINT"));
@@ -23634,5 +23792,6 @@ export {
23634
23792
  EXIT,
23635
23793
  UsageError,
23636
23794
  bridgeConfigEnv,
23637
- parseArgs
23795
+ parseArgs,
23796
+ reportError
23638
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) */
@@ -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
 
@@ -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.
@@ -73,6 +73,7 @@ Research
73
73
  | `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
76
+ | `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
76
77
  | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
77
78
  | `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
78
79
  | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
package/docs/SDK.md CHANGED
@@ -76,7 +76,10 @@ await shutdownResearchSDK();
76
76
  `runResearchDetailed`, `searchKnowledge`, `scrapeUrl`, `getResearchHealth`,
77
77
  `getLastRunStats`, and `getSessionMetrics`, plus `exportKnowledge` (write the
78
78
  knowledge store to a web-consumable JSON file) and the post-run telemetry accessors
79
- `getLastRunMetrics`, `getLastRunSummary`, and `getLastErrorReport`. Both
79
+ `getLastRunMetrics`, `getLastRunSummary`, `getLastErrorReport`, and
80
+ `getLastResearcherOutcome` (planned/launched/succeeded/failed researcher counts plus
81
+ per-researcher failure reasons for the most recent run — lets a caller tell a thin
82
+ report caused by a sparse topic apart from one where most researchers failed). Both
80
83
  `@lincoln504/pi-research` and `@lincoln504/pi-research/sdk` export these symbols.
81
84
 
82
85
  > Concurrency: a single initialized SDK instance runs one research call at a time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -37,7 +37,8 @@
37
37
  "scripts/prepare.cjs",
38
38
  "LICENSE",
39
39
  "docs/THIRD-PARTY-NOTICES.md",
40
- "README.md"
40
+ "README.md",
41
+ "docs/CHANGELOG.md"
41
42
  ],
42
43
  "repository": {
43
44
  "type": "git",
@@ -109,9 +110,9 @@
109
110
  "image": "https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/hero.png"
110
111
  },
111
112
  "dependencies": {
112
- "@earendil-works/pi-ai": ">=0.80.2 <1",
113
- "@earendil-works/pi-coding-agent": ">=0.80.2 <1",
114
- "@earendil-works/pi-tui": ">=0.80.2 <1",
113
+ "@earendil-works/pi-ai": ">=0.80.7 <1",
114
+ "@earendil-works/pi-coding-agent": ">=0.80.7 <1",
115
+ "@earendil-works/pi-tui": ">=0.80.7 <1",
115
116
  "@huggingface/transformers": "4.2.0",
116
117
  "@kreuzberg/html-to-markdown-node": "^3.5.5",
117
118
  "@lancedb/lancedb": "0.29.0",