@lincoln504/pi-research 1.3.10 → 1.3.12

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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +264 -61
  3. package/dist/prompts/system-coordinator.md +1 -1
  4. package/dist/prompts/system-lead-evaluator.md +1 -1
  5. package/dist/thread-worker.mjs +31 -21
  6. package/docs/ARCHITECTURE.md +60 -22
  7. package/docs/CHANGELOG.md +103 -1
  8. package/docs/CONFIGURATION.md +6 -5
  9. package/package.json +18 -10
  10. package/scripts/cleanup.cjs +26 -5
  11. package/scripts/setup.cjs +25 -1
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +94 -13
  14. package/src/config.ts +6 -15
  15. package/src/constants.ts +13 -0
  16. package/src/core/service-registry.ts +19 -3
  17. package/src/infrastructure/browser/config.ts +36 -5
  18. package/src/infrastructure/browser/task-execution-service.ts +12 -2
  19. package/src/infrastructure/browser/thread-worker-browser.ts +34 -18
  20. package/src/infrastructure/browser/thread-worker.mjs +31 -21
  21. package/src/infrastructure/gpu-resource-service.ts +15 -1
  22. package/src/knowledge/embedder-init.ts +34 -2
  23. package/src/knowledge/store.ts +75 -6
  24. package/src/knowledge/writer-queue.ts +28 -0
  25. package/src/orchestration/research-orchestration-service.ts +35 -1
  26. package/src/orchestration/research-synthesis-service.ts +4 -2
  27. package/src/orchestration/researcher.ts +2 -13
  28. package/src/prompts/system-coordinator.md +1 -1
  29. package/src/prompts/system-lead-evaluator.md +1 -1
  30. package/src/sdk.ts +10 -1
  31. package/src/security/nvd-client.ts +55 -4
  32. package/src/security/osv-client.ts +13 -1
  33. package/src/tools/research-knowledge-search.ts +18 -1
  34. package/src/tools/research-tool-definition.ts +8 -1
  35. package/src/utils/citation-utils.ts +4 -2
  36. package/src/utils/log-utils.ts +16 -1
  37. package/src/utils/text-utils.ts +17 -3
  38. package/src/web-research/scraper-utils.ts +55 -14
package/dist/cli.mjs CHANGED
@@ -269,7 +269,10 @@ var init_log_utils = __esm({
269
269
  `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
270
270
  "g"
271
271
  );
272
- JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, "g");
272
+ JWT_PATTERN = new RegExp(
273
+ `${B_LEFT}eyJ[A-Za-z0-9_-]{1,1024}\\.[A-Za-z0-9_-]{1,8192}\\.[A-Za-z0-9_-]{1,4096}`,
274
+ "g"
275
+ );
273
276
  BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, "gi");
274
277
  BEARER_AUTH_PATTERN = new RegExp(`${B_LEFT}Bearer\\s+[A-Za-z0-9._~+/-]+=*`, "gi");
275
278
  PROVIDER_TOKEN_PATTERN = new RegExp(
@@ -1158,8 +1161,16 @@ var init_service_registry = __esm({
1158
1161
  */
1159
1162
  async registerAndReplace(name, factory, options = {}) {
1160
1163
  const mergedOptions = { ...this.defaultOptions, ...options, allowOverwrite: true };
1161
- if (this.services.has(name)) {
1162
- const registration = this.services.get(name);
1164
+ const registration = this.services.get(name);
1165
+ if (registration) {
1166
+ while (registration.disposalPromise) {
1167
+ await registration.disposalPromise.catch(() => {
1168
+ });
1169
+ }
1170
+ if (registration.initializationPromise) {
1171
+ await registration.initializationPromise.catch(() => {
1172
+ });
1173
+ }
1163
1174
  if (registration.instance && registration.instance.dispose) {
1164
1175
  try {
1165
1176
  await registration.instance.dispose();
@@ -2148,13 +2159,18 @@ function isPlausibleCitationUrl(url) {
2148
2159
  }
2149
2160
  }
2150
2161
  function lastCitedLinksHeaderIndex(text) {
2162
+ return lastCitedLinksHeader(text).textStart;
2163
+ }
2164
+ function lastCitedLinksHeader(text) {
2151
2165
  const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
2152
- let last = -1;
2166
+ let lineStart = -1;
2167
+ let textStart = -1;
2153
2168
  let m;
2154
2169
  while ((m = re.exec(text)) !== null) {
2155
- last = m.index + m[1].length + m[2].length;
2170
+ lineStart = m.index + m[1].length;
2171
+ textStart = m.index + m[1].length + m[2].length;
2156
2172
  }
2157
- return last;
2173
+ return { lineStart, textStart };
2158
2174
  }
2159
2175
  function parseCitations(report) {
2160
2176
  const headerIdx = lastCitedLinksHeaderIndex(report);
@@ -2662,7 +2678,6 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2662
2678
  PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
2663
2679
  PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
2664
2680
  PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
2665
- PI_RESEARCH_RESEARCHER_MAX_TOKENS: String(config.RESEARCHER_MAX_TOKENS),
2666
2681
  PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
2667
2682
  PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
2668
2683
  ...config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {},
@@ -2827,7 +2842,6 @@ function createConfig(env, processEnv) {
2827
2842
  LLM_THINKING_LEVEL: parseEnvEnum(e, "PI_RESEARCH_LLM_THINKING_LEVEL", ["off", "minimal", "low", "medium", "high"], DEFAULTS.LLM_THINKING_LEVEL),
2828
2843
  PLANNING_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_PLANNING_MAX_TOKENS", DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
2829
2844
  SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_SYNTHESIS_MAX_TOKENS", DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
2830
- RESEARCHER_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_RESEARCHER_MAX_TOKENS", DEFAULTS.RESEARCHER_MAX_TOKENS, 1024, 131072),
2831
2845
  MIGRATION_STRATEGY: parseEnvEnum(e, "PI_RESEARCH_MIGRATION_STRATEGY", ["drop", "re-embed", "backup"], DEFAULTS.MIGRATION_STRATEGY),
2832
2846
  CONSOLE_LOG: parseEnvBool(e, "PI_RESEARCH_CONSOLE_LOG", DEFAULTS.CONSOLE_LOG),
2833
2847
  RESEARCH_MODEL: parseEnvString(e, "PI_RESEARCH_MODEL", DEFAULTS.RESEARCH_MODEL),
@@ -2891,7 +2905,7 @@ function parseEnvNumber(env, key, def, min, max, integer = false) {
2891
2905
  return n;
2892
2906
  }
2893
2907
  function parseEnvBool(env, key, def) {
2894
- const v = env[key];
2908
+ const v = env[key]?.trim();
2895
2909
  if (v === void 0 || v === "") return def;
2896
2910
  return v.toLowerCase() === "true";
2897
2911
  }
@@ -2899,7 +2913,7 @@ function parseEnvString(env, key, def) {
2899
2913
  return env[key] || def;
2900
2914
  }
2901
2915
  function parseEnvEnum(env, key, allowed, def) {
2902
- const raw = env[key];
2916
+ const raw = env[key]?.trim();
2903
2917
  if (raw === void 0 || raw === "") return def;
2904
2918
  if (allowed.includes(raw)) return raw;
2905
2919
  logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(", ")}). Using default "${def}".`);
@@ -3015,15 +3029,6 @@ var init_config = __esm({
3015
3029
  * the model's real maxTokens at call time.
3016
3030
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
3017
3031
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
3018
- /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
3019
- * Without a cap the session inherits the model's full catalog ceiling as the
3020
- * per-request max_tokens — ~230k for large-context models — which providers that
3021
- * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
3022
- * reject outright on low balances, even though the actual response would cost
3023
- * cents. Default 16384: researcher turns are tool calls plus a cited report, far
3024
- * below this. Clamped to the model's real ceiling at session build time.
3025
- * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
3026
- RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
3027
3032
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
3028
3033
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
3029
3034
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -3060,7 +3065,8 @@ var init_config = __esm({
3060
3065
  /**
3061
3066
  * Comma-separated list of research tools to DISABLE for every researcher in a run
3062
3067
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
3063
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
3068
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
3069
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
3064
3070
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
3065
3071
  * tools are both removed from each researcher's toolset AND named in the
3066
3072
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -3101,7 +3107,6 @@ var init_config = __esm({
3101
3107
  "PI_RESEARCH_LLM_THINKING_LEVEL",
3102
3108
  "PI_RESEARCH_PLANNING_MAX_TOKENS",
3103
3109
  "PI_RESEARCH_SYNTHESIS_MAX_TOKENS",
3104
- "PI_RESEARCH_RESEARCHER_MAX_TOKENS",
3105
3110
  "PI_RESEARCH_MIGRATION_STRATEGY",
3106
3111
  "PI_RESEARCH_CONSOLE_LOG",
3107
3112
  "PI_RESEARCH_MODEL",
@@ -3146,7 +3151,7 @@ function getMaxScrapeBatches(config) {
3146
3151
  function resolveExcludedTools(callerExclusions, configDisabled = []) {
3147
3152
  return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
3148
3153
  }
3149
- var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS;
3154
+ var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS, RESEARCH_TOOL_NAMES;
3150
3155
  var init_constants = __esm({
3151
3156
  "src/constants.ts"() {
3152
3157
  "use strict";
@@ -3178,6 +3183,15 @@ var init_constants = __esm({
3178
3183
  MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
3179
3184
  MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
3180
3185
  DEFAULT_EXCLUDED_TOOLS = ["grep"];
3186
+ RESEARCH_TOOL_NAMES = [
3187
+ "search",
3188
+ "scrape",
3189
+ "security_search",
3190
+ "stackexchange",
3191
+ "youtube_transcript",
3192
+ "grep",
3193
+ "read"
3194
+ ];
3181
3195
  }
3182
3196
  });
3183
3197
 
@@ -5395,6 +5409,9 @@ var init_store = __esm({
5395
5409
  logger.warn("[store] Failed to read table schema:", schemaErr);
5396
5410
  }
5397
5411
  } else {
5412
+ if (this.options.embedder.getDimension() === null) {
5413
+ await this.withEmbedderReconnect((embedder) => embedder.embedMany([" "]));
5414
+ }
5398
5415
  this.table = await this.createTable();
5399
5416
  }
5400
5417
  await this.evictOldRecords();
@@ -5776,6 +5793,45 @@ var init_store = __esm({
5776
5793
  }
5777
5794
  }
5778
5795
  }
5796
+ /**
5797
+ * Refresh the timestamp of one CURRENT generation for url+type — the
5798
+ * revalidation "touch" for the writer-queue's content-unchanged skip. Without
5799
+ * it a stable page kept its original timestamp forever: with
5800
+ * KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS set, the serve-age gate then treated the
5801
+ * entry as permanently stale (a live re-scrape on every run, for exactly the
5802
+ * content that never changes), and TTL eviction eventually deleted an entry
5803
+ * that had been re-verified byte-identical possibly hours earlier.
5804
+ */
5805
+ async touchByUrlAndType(url, ingestionType, generationTs, newTs) {
5806
+ return this.trackOperation("touchByUrlAndType", void 0, async () => {
5807
+ let retryCount = 0;
5808
+ const MAX_RETRIES = 5;
5809
+ const BASE_DELAY = 100;
5810
+ const scopeFilter = this.getScopeFilter();
5811
+ while (retryCount <= MAX_RETRIES) {
5812
+ const table = await this.getFreshTable();
5813
+ try {
5814
+ const escapedUrl = url.replace(/'/g, "''");
5815
+ const escapedType = ingestionType.replace(/'/g, "''");
5816
+ await table.update({
5817
+ where: `url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND timestamp = ${Math.floor(generationTs)} AND (${scopeFilter})`,
5818
+ valuesSql: { timestamp: String(Math.floor(newTs)) }
5819
+ });
5820
+ logger.log(`[store] Refreshed timestamp for unchanged ${ingestionType} chunks of ${url}`);
5821
+ return;
5822
+ } catch (err) {
5823
+ if (isLanceCommitConflict(err)) {
5824
+ retryCount++;
5825
+ if (retryCount > MAX_RETRIES) throw err;
5826
+ const delay2 = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
5827
+ await new Promise((r) => setTimeout(r, delay2));
5828
+ continue;
5829
+ }
5830
+ throw err;
5831
+ }
5832
+ }
5833
+ });
5834
+ }
5779
5835
  async findDocumentsByUrl(url) {
5780
5836
  return this.trackOperation("findDocumentsByUrl", [], async () => {
5781
5837
  const table = await this.getFreshTable();
@@ -6013,7 +6069,10 @@ var init_store = __esm({
6013
6069
  backupDirs.map(async (name) => ({ name, ts: await this.backupDirTimestamp(name) }))
6014
6070
  );
6015
6071
  ranked.sort((a, b) => a.ts - b.ts);
6016
- for (const { name } of ranked.slice(0, -1)) {
6072
+ const ttlDays = this.options.ttlDays ?? 30;
6073
+ const backupCutoff = Math.trunc(Date.now() - ttlDays * 24 * 60 * 60 * 1e3);
6074
+ for (const { name, ts } of ranked.slice(0, -1)) {
6075
+ if (ts >= backupCutoff) continue;
6017
6076
  const fullPath = path13.join(this.options.dbDir, name);
6018
6077
  logger.info(`[store] Removing superseded backup directory: ${name}`);
6019
6078
  await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
@@ -6309,6 +6368,17 @@ var init_writer_queue = __esm({
6309
6368
  metrics.increment("knowledge_ingest_stale_prune_failed_total", 1);
6310
6369
  });
6311
6370
  }
6371
+ if (this.options.store.touchByUrlAndType) {
6372
+ try {
6373
+ const recheck = (await this.options.store.findByUrl(normalizedUrl)).filter((c) => c.metadata["ingestionType"] === incomingType);
6374
+ const stillNewest = recheck.length > 0 && Math.max(...recheck.map((d) => Number(d.timestamp) || 0)) === newestTs;
6375
+ if (stillNewest) {
6376
+ await this.options.store.touchByUrlAndType(normalizedUrl, incomingType, newestTs, Date.now());
6377
+ }
6378
+ } catch (err) {
6379
+ logger.warn(`[writer-queue] Timestamp refresh for unchanged ${normalizedUrl} (${incomingType}) failed:`, err);
6380
+ }
6381
+ }
6312
6382
  logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) \u2014 content unchanged.`);
6313
6383
  return;
6314
6384
  }
@@ -6787,7 +6857,17 @@ async function loadPipelineWithTimeout(model, device, timeoutMs, useCache) {
6787
6857
  interOpNumThreads: 1
6788
6858
  }
6789
6859
  });
6790
- return await withTimeout3(pipelinePromise, timeoutMs, errorMessage);
6860
+ let timedOut = true;
6861
+ try {
6862
+ const p = await withTimeout3(pipelinePromise, timeoutMs, errorMessage);
6863
+ timedOut = false;
6864
+ return p;
6865
+ } finally {
6866
+ if (timedOut) {
6867
+ pipelinePromise.then((late) => late.dispose?.()).catch(() => {
6868
+ });
6869
+ }
6870
+ }
6791
6871
  });
6792
6872
  return { pipeline: loadedPipeline, errorMessage };
6793
6873
  }
@@ -6916,7 +6996,17 @@ async function loadModelOnCPU(model, initializationTimeoutMs, useCache) {
6916
6996
  interOpNumThreads: 1
6917
6997
  }
6918
6998
  });
6919
- return await withTimeout3(pipelinePromise, initializationTimeoutMs, "CPU fallback model load timed out");
6999
+ let timedOut = true;
7000
+ try {
7001
+ const p = await withTimeout3(pipelinePromise, initializationTimeoutMs, "CPU fallback model load timed out");
7002
+ timedOut = false;
7003
+ return p;
7004
+ } finally {
7005
+ if (timedOut) {
7006
+ pipelinePromise.then((late) => late.dispose?.()).catch(() => {
7007
+ });
7008
+ }
7009
+ }
6920
7010
  });
6921
7011
  return loadedPipeline;
6922
7012
  }
@@ -9113,6 +9203,7 @@ function formatQueriesBlock(queries) {
9113
9203
  }
9114
9204
  function showKnowledgeSearchWidget(ctx) {
9115
9205
  if (ctx.mode !== "tui" || !ctx.hasUI) return;
9206
+ knowledgeWidgetRefCount++;
9116
9207
  try {
9117
9208
  const panelFactory = createKnowledgeSearchPanel();
9118
9209
  ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, panelFactory, { placement: "aboveEditor" });
@@ -9122,6 +9213,8 @@ function showKnowledgeSearchWidget(ctx) {
9122
9213
  }
9123
9214
  function hideKnowledgeSearchWidget(ctx) {
9124
9215
  if (ctx.mode !== "tui" || !ctx.hasUI) return;
9216
+ knowledgeWidgetRefCount = Math.max(0, knowledgeWidgetRefCount - 1);
9217
+ if (knowledgeWidgetRefCount > 0) return;
9125
9218
  try {
9126
9219
  ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, void 0, { placement: "aboveEditor" });
9127
9220
  } catch (err) {
@@ -9601,7 +9694,7 @@ function createResearchKnowledgeSearchTool(iface) {
9601
9694
  }
9602
9695
  };
9603
9696
  }
9604
- var ResearchKnowledgeSearchParams, MAX_REFERENCE_CHARS, MAX_DOCUMENTS, MAX_CANDIDATES, TRIAGE_DESCRIPTION_MAX_CHARS, TRIAGE_MAX_TOKENS, KNOWLEDGE_WIDGET_ID, RESEARCH_KNOWLEDGE_MISS_STRING, RESEARCH_KNOWLEDGE_MAYBE_STRING, KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS, KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS;
9697
+ var ResearchKnowledgeSearchParams, MAX_REFERENCE_CHARS, MAX_DOCUMENTS, MAX_CANDIDATES, TRIAGE_DESCRIPTION_MAX_CHARS, TRIAGE_MAX_TOKENS, KNOWLEDGE_WIDGET_ID, knowledgeWidgetRefCount, RESEARCH_KNOWLEDGE_MISS_STRING, RESEARCH_KNOWLEDGE_MAYBE_STRING, KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS, KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS;
9605
9698
  var init_research_knowledge_search = __esm({
9606
9699
  "src/tools/research-knowledge-search.ts"() {
9607
9700
  "use strict";
@@ -9636,6 +9729,7 @@ var init_research_knowledge_search = __esm({
9636
9729
  TRIAGE_DESCRIPTION_MAX_CHARS = 600;
9637
9730
  TRIAGE_MAX_TOKENS = 2048;
9638
9731
  KNOWLEDGE_WIDGET_ID = "pi-research-knowledge-search";
9732
+ knowledgeWidgetRefCount = 0;
9639
9733
  RESEARCH_KNOWLEDGE_MISS_STRING = "No results found. Live research can get the info.";
9640
9734
  RESEARCH_KNOWLEDGE_MAYBE_STRING = "Partial results found in knowledge store. Live research can fill gaps.";
9641
9735
  KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS = 3;
@@ -10432,7 +10526,7 @@ function normalizeCitations(reports) {
10432
10526
  localToGlobal.set(localKey, globalId);
10433
10527
  }
10434
10528
  });
10435
- const headerIdx = lastCitedLinksHeaderIndex(report);
10529
+ const headerIdx = lastCitedLinksHeader(report).lineStart;
10436
10530
  let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
10437
10531
  content = rewriteCitationMarkers(
10438
10532
  content,
@@ -11391,9 +11485,13 @@ import { platform, homedir as homedir2 } from "node:os";
11391
11485
  function getWindowsCamoufoxDir() {
11392
11486
  return join7(homedir2(), "AppData", "Local", "camoufox", "camoufox", "Cache");
11393
11487
  }
11488
+ function getCustomCamoufoxDir() {
11489
+ return process.env["CAMOUFOX_INSTALL_DIR"] || process.env["PLAYWRIGHT_BROWSERS_PATH"] || void 0;
11490
+ }
11394
11491
  function getBrowserCacheDir() {
11395
- if (process.env["PLAYWRIGHT_BROWSERS_PATH"]) {
11396
- return process.env["PLAYWRIGHT_BROWSERS_PATH"];
11492
+ const custom = getCustomCamoufoxDir();
11493
+ if (custom) {
11494
+ return custom;
11397
11495
  }
11398
11496
  const osPlatform = platform();
11399
11497
  if (osPlatform === "win32") {
@@ -11424,11 +11522,13 @@ function getBrowserProfileDir(config) {
11424
11522
  }
11425
11523
  function getBrowserEnv(config) {
11426
11524
  const env = { ...process.env };
11427
- const customPath = process.env["PLAYWRIGHT_BROWSERS_PATH"];
11525
+ const customPath = getCustomCamoufoxDir();
11428
11526
  if (customPath) {
11429
11527
  env["PLAYWRIGHT_BROWSERS_PATH"] = customPath;
11528
+ env["CAMOUFOX_INSTALL_DIR"] = customPath;
11430
11529
  } else {
11431
11530
  delete env["PLAYWRIGHT_BROWSERS_PATH"];
11531
+ delete env["CAMOUFOX_INSTALL_DIR"];
11432
11532
  }
11433
11533
  if (!env["PI_RESEARCH_LOG_FILE"]) {
11434
11534
  const logFilePath = getLogger().getLogFilePath();
@@ -11457,7 +11557,7 @@ function ensureBrowserCacheDir() {
11457
11557
  return cacheDir;
11458
11558
  }
11459
11559
  function getCamoufoxBinaryPath() {
11460
- const customPath = process.env["PLAYWRIGHT_BROWSERS_PATH"];
11560
+ const customPath = getCustomCamoufoxDir();
11461
11561
  if (customPath) {
11462
11562
  return customPath;
11463
11563
  }
@@ -14235,8 +14335,22 @@ var GPUResourceService = class {
14235
14335
  name = ServiceNames.GPU_RESOURCE_SERVICE;
14236
14336
  lifecycle = "uninitialized" /* UNINITIALIZED */;
14237
14337
  processLifecycle = null;
14238
- gpuLockStaleThresholdMs = 1e3 * 60 * 5;
14239
- // 5 minutes default
14338
+ /**
14339
+ * Backstop for a holder that is alive but wedged. Step 3 already reclaims from
14340
+ * *dead* owners via PID+startTime liveness, so this only ever fires against a
14341
+ * process we just proved is running — which makes it a lock STEAL, and the
14342
+ * threshold must therefore exceed the longest legitimate hold.
14343
+ *
14344
+ * It previously did not: at 5 minutes it exactly equalled the default
14345
+ * EMBEDDING_MODEL_INIT_TIMEOUT_MS (300000, schema max 600000), and the
14346
+ * embedder holds this lock across the whole model download + pipeline load.
14347
+ * A first-time HuggingFace fetch running near its own permitted budget would
14348
+ * have the GPU lock taken out from under it, admitting exactly the concurrent
14349
+ * WebGPU/Dawn init this lock exists to prevent. Keep this strictly above that
14350
+ * schema maximum.
14351
+ */
14352
+ gpuLockStaleThresholdMs = 1e3 * 60 * 15;
14353
+ // 15 min > EMBEDDING_MODEL_INIT_TIMEOUT_MS max (10 min)
14240
14354
  constructor(options) {
14241
14355
  if (options) {
14242
14356
  this.processLifecycle = options.processLifecycle;
@@ -17046,19 +17160,33 @@ function isSsrfBlockError(error) {
17046
17160
  return false;
17047
17161
  }
17048
17162
  var cachedSsrfFetcher = void 0;
17049
- async function getSsrfSafeFetcher() {
17050
- if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
17163
+ function getSsrfSafeFetcher() {
17164
+ cachedSsrfFetcher ??= (async () => {
17165
+ try {
17166
+ const { Agent: Agent2, fetch: undiciFetch } = await import("undici");
17167
+ return {
17168
+ fetch: undiciFetch,
17169
+ dispatcher: new Agent2({ connect: { lookup: ssrfSafeLookup } })
17170
+ };
17171
+ } catch (e) {
17172
+ logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
17173
+ return null;
17174
+ }
17175
+ })();
17176
+ return cachedSsrfFetcher;
17177
+ }
17178
+ async function disposeSsrfSafeFetcher() {
17179
+ const pending = cachedSsrfFetcher;
17180
+ cachedSsrfFetcher = void 0;
17181
+ if (pending === void 0) return;
17182
+ const cached = await pending.catch(() => null);
17183
+ const closable = cached?.dispatcher;
17184
+ if (!closable?.close) return;
17051
17185
  try {
17052
- const { Agent: Agent2, fetch: undiciFetch } = await import("undici");
17053
- cachedSsrfFetcher = {
17054
- fetch: undiciFetch,
17055
- dispatcher: new Agent2({ connect: { lookup: ssrfSafeLookup } })
17056
- };
17186
+ await closable.close();
17057
17187
  } catch (e) {
17058
- logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
17059
- cachedSsrfFetcher = null;
17188
+ logger.debug(`[Scrapers] Error closing SSRF dispatcher: ${e instanceof Error ? e.message : String(e)}`);
17060
17189
  }
17061
- return cachedSsrfFetcher;
17062
17190
  }
17063
17191
  function formatErrorWithCause(err) {
17064
17192
  try {
@@ -17917,7 +18045,6 @@ import { Value as Value7 } from "typebox/value";
17917
18045
  // src/security/nvd-client.ts
17918
18046
  init_retry_utils();
17919
18047
  init_circuit_breaker();
17920
- init_safe_unref();
17921
18048
  init_metrics();
17922
18049
  init_http_body();
17923
18050
 
@@ -17980,7 +18107,6 @@ var NVDRateLimiter = class {
17980
18107
  signal?.removeEventListener("abort", onAbort);
17981
18108
  resolve9();
17982
18109
  }, waitTime);
17983
- safeUnref(timeoutId);
17984
18110
  const onAbort = () => {
17985
18111
  clearTimeout(timeoutId);
17986
18112
  reject(Object.assign(new Error("NVD search aborted"), { name: "AbortError" }));
@@ -18202,6 +18328,7 @@ async function fetchPaginated(term, options, maxResults, severityVersion, admitE
18202
18328
  const signal = options?.signal;
18203
18329
  let startIndex = 0;
18204
18330
  let totalPagesFetched = 0;
18331
+ let seekedToNewest = false;
18205
18332
  while (totalPagesFetched < maxPages && allVulnerabilities.length < maxResults) {
18206
18333
  if (signal?.aborted) break;
18207
18334
  const url = buildURL(term, options, pageSize, startIndex, severityVersion);
@@ -18222,9 +18349,17 @@ async function fetchPaginated(term, options, maxResults, severityVersion, admitE
18222
18349
  break;
18223
18350
  }
18224
18351
  metrics.increment("nvd_cache_hits_total", 1);
18225
- allVulnerabilities.push(...admitted);
18226
18352
  const responseData = data;
18227
- if (responseData?.["totalResults"] !== void 0 && typeof responseData["totalResults"] === "number" && startIndex + pageSize >= responseData["totalResults"]) {
18353
+ const reportedTotal = typeof responseData?.["totalResults"] === "number" ? responseData["totalResults"] : void 0;
18354
+ if (!seekedToNewest && startIndex === 0 && reportedTotal !== void 0 && reportedTotal > maxResults) {
18355
+ seekedToNewest = true;
18356
+ startIndex = reportedTotal - maxResults;
18357
+ allVulnerabilities.length = 0;
18358
+ totalPagesFetched++;
18359
+ continue;
18360
+ }
18361
+ allVulnerabilities.push(...admitted);
18362
+ if (reportedTotal !== void 0 && startIndex + pageSize >= reportedTotal) {
18228
18363
  break;
18229
18364
  }
18230
18365
  startIndex += pageSize;
@@ -18232,6 +18367,14 @@ async function fetchPaginated(term, options, maxResults, severityVersion, admitE
18232
18367
  }
18233
18368
  metrics.increment("nvd_pagination_requests_total", totalPagesFetched);
18234
18369
  metrics.observe("nvd_pagination_pages_fetched", totalPagesFetched);
18370
+ allVulnerabilities.sort((a, b) => {
18371
+ const ta = a.published ? Date.parse(a.published) : NaN;
18372
+ const tb = b.published ? Date.parse(b.published) : NaN;
18373
+ if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
18374
+ if (Number.isNaN(ta)) return 1;
18375
+ if (Number.isNaN(tb)) return -1;
18376
+ return tb - ta;
18377
+ });
18235
18378
  return allVulnerabilities.slice(0, maxResults);
18236
18379
  }
18237
18380
  function shouldSupplementWithV2(options) {
@@ -19242,7 +19385,14 @@ async function searchOSV(terms, options) {
19242
19385
  uniqueVulns.set(vuln.id, vuln);
19243
19386
  }
19244
19387
  }
19245
- vulnerabilities.push(...Array.from(uniqueVulns.values()));
19388
+ vulnerabilities.push(...Array.from(uniqueVulns.values()).sort((a, b) => {
19389
+ const ta = a.published ? Date.parse(a.published) : NaN;
19390
+ const tb = b.published ? Date.parse(b.published) : NaN;
19391
+ if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
19392
+ if (Number.isNaN(ta)) return 1;
19393
+ if (Number.isNaN(tb)) return -1;
19394
+ return tb - ta;
19395
+ }));
19246
19396
  if (failures.length > 0 && failures.length === terms.length) {
19247
19397
  throw new Error(`All OSV lookups failed: ${failures.join("; ")}`);
19248
19398
  }
@@ -21549,8 +21699,6 @@ async function createResearcherSession(options) {
21549
21699
  cwd
21550
21700
  });
21551
21701
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
21552
- const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
21553
- const sessionModel = modelToUse.maxTokens > outputCap ? { ...modelToUse, maxTokens: outputCap } : modelToUse;
21554
21702
  try {
21555
21703
  const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
21556
21704
  const globalLinks = updateGlobalLinks || (() => {
@@ -21591,7 +21739,7 @@ async function createResearcherSession(options) {
21591
21739
  sessionManager: SessionManager.inMemory(),
21592
21740
  // Each researcher gets its own isolated session
21593
21741
  settingsManager: researcherSettings,
21594
- model: sessionModel,
21742
+ model: modelToUse,
21595
21743
  // pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
21596
21744
  // with the async `modelRuntime`. Pass the runtime backing OUR registry when
21597
21745
  // this run built one (standalone CLI/SDK): it carries any explicit API key
@@ -21622,7 +21770,7 @@ async function createResearcherSession(options) {
21622
21770
  throw new Error("Session creation returned invalid result");
21623
21771
  }
21624
21772
  sessionRef.session = result.session;
21625
- return { session: result.session, resolvedModel: sessionModel };
21773
+ return { session: result.session, resolvedModel: modelToUse };
21626
21774
  } catch (tailError) {
21627
21775
  const session = result?.session;
21628
21776
  if (session && typeof session.abort === "function") {
@@ -23091,6 +23239,17 @@ var ResearchOrchestrationService = class {
23091
23239
  const { ctx, query, depth = 0, observer, onUpdate, sessionId, researchId, config, excludeTools } = options;
23092
23240
  const researchConfig = config || getConfig(ctx.cwd);
23093
23241
  const configDisabledTools = researchConfig.DISABLED_TOOLS ? researchConfig.DISABLED_TOOLS.split(",").map((s) => s.trim()).filter(Boolean) : [];
23242
+ for (const [surface, names] of [
23243
+ ["excludeTools", excludeTools ?? []],
23244
+ ["PI_RESEARCH_DISABLED_TOOLS", configDisabledTools]
23245
+ ]) {
23246
+ const unknown = names.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
23247
+ if (unknown.length > 0) {
23248
+ logger.warn(
23249
+ `[ResearchOrchestrationService] ${surface}: ignoring unknown tool name${unknown.length === 1 ? "" : "s"} ${unknown.map((t) => `"${t}"`).join(", ")} \u2014 these exclude nothing. Valid names: ${RESEARCH_TOOL_NAMES.join(", ")}.`
23250
+ );
23251
+ }
23252
+ }
23094
23253
  const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
23095
23254
  const selectedModel = await this.resolveResearchModel(options);
23096
23255
  logger.info(`[ResearchOrchestrationService] Using model: ${selectedModel.provider}/${selectedModel.id}`);
@@ -23104,6 +23263,13 @@ var ResearchOrchestrationService = class {
23104
23263
  });
23105
23264
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
23106
23265
  } catch (err) {
23266
+ const isTerminal = err instanceof ResearchRunCapacityError || err instanceof Error && err.name === "AbortError";
23267
+ if (isTerminal) {
23268
+ try {
23269
+ observer?.onError?.(err);
23270
+ } catch {
23271
+ }
23272
+ }
23107
23273
  if (err instanceof ResearchRunCapacityError) throw err;
23108
23274
  if (err instanceof Error && err.name === "AbortError") throw err;
23109
23275
  logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
@@ -23855,7 +24021,7 @@ Research performed using ${modelId}`;
23855
24021
  if (globalId !== void 0) localToGlobal.set(localKey, globalId);
23856
24022
  else droppedNumbers.add(localKey);
23857
24023
  });
23858
- let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
24024
+ let body = synthesis.slice(0, lastCitedLinksHeader(synthesis).lineStart).trimEnd();
23859
24025
  if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
23860
24026
  body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
23861
24027
  }
@@ -24502,6 +24668,12 @@ async function _doShutdown() {
24502
24668
  logger.error("[SDK] Error clearing session state:", err);
24503
24669
  errors.push(err instanceof Error ? err : new Error(String(err)));
24504
24670
  }
24671
+ try {
24672
+ await disposeSsrfSafeFetcher();
24673
+ } catch (err) {
24674
+ logger.error("[SDK] Error disposing SSRF fetcher:", err);
24675
+ errors.push(err instanceof Error ? err : new Error(String(err)));
24676
+ }
24505
24677
  if (globalContainer) {
24506
24678
  try {
24507
24679
  await disposeCoreServices(globalContainer);
@@ -24778,6 +24950,7 @@ Total cost: ${costStr}`;
24778
24950
  // src/cli.ts
24779
24951
  init_config();
24780
24952
  init_model_registry_factory();
24953
+ init_constants();
24781
24954
  import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
24782
24955
  function readPkgVersion() {
24783
24956
  try {
@@ -24898,14 +25071,15 @@ function makeProgressObserver() {
24898
25071
  var ConfigFileError = class extends Error {
24899
25072
  };
24900
25073
  function bridgeConfigEnv(explicitConfigPath) {
24901
- const basePath = explicitConfigPath ?? getGlobalEnvFilePath();
25074
+ const globalBasePath = getGlobalEnvFilePath();
25075
+ const basePath = explicitConfigPath ?? globalBasePath;
24902
25076
  const cliOverlayPath = getInterfaceEnvFilePath("cli");
24903
25077
  let loaded = false;
24904
25078
  const merged = {};
24905
25079
  const fromExplicitBase = /* @__PURE__ */ new Set();
24906
- const layers = explicitConfigPath !== void 0 ? [cliOverlayPath, basePath] : [basePath, cliOverlayPath];
25080
+ const layers = explicitConfigPath !== void 0 ? [globalBasePath, cliOverlayPath, explicitConfigPath] : [globalBasePath, cliOverlayPath];
24907
25081
  for (const filePath of layers) {
24908
- const isExplicit = explicitConfigPath !== void 0 && filePath === basePath;
25082
+ const isExplicit = explicitConfigPath !== void 0 && filePath === explicitConfigPath;
24909
25083
  try {
24910
25084
  if (!fs17.existsSync(filePath)) {
24911
25085
  if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
@@ -25042,7 +25216,11 @@ async function detectCredentials(explicitModel) {
25042
25216
  modelFrom,
25043
25217
  piAuthPresent,
25044
25218
  piModelsPresent,
25045
- problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json)."
25219
+ // A model configured with no usable key is a different (and misleading to
25220
+ // conflate) gap than nothing configured at all: SKILL.md tells agents to relay
25221
+ // this text verbatim, so "No model … is configured" pointed users who HAD set
25222
+ // PI_RESEARCH_MODEL at the wrong knob.
25223
+ problem: model ? `A model is configured (${model}) but no API key is available for ${provider ? `provider "${provider}"` : "its provider"}. Provide one via the PI_RESEARCH_API_KEY environment variable (with PI_RESEARCH_PROVIDER), or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json).` : "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json)."
25046
25224
  };
25047
25225
  }
25048
25226
  function configBlock(det, extraNote) {
@@ -25112,6 +25290,8 @@ Error: ${vmsg}
25112
25290
  ...args.excludeTools ? { excludeTools: args.excludeTools } : {},
25113
25291
  ...args.initialLinks ? { initialLinks: args.initialLinks } : {}
25114
25292
  }, abortController.signal);
25293
+ const outcome = getLastResearcherOutcome();
25294
+ const errorReport = getLastErrorReport();
25115
25295
  let reportPath = null;
25116
25296
  const exportCfg = getConfig(process.cwd(), "cli");
25117
25297
  if (exportCfg.RESEARCH_REPORT_EXPORT_ENABLED) {
@@ -25126,8 +25306,6 @@ Error: ${vmsg}
25126
25306
  exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
25127
25307
  }
25128
25308
  if (args.json) {
25129
- const outcome = getLastResearcherOutcome();
25130
- const errorReport = getLastErrorReport();
25131
25309
  toStdout(pretty({
25132
25310
  ok: true,
25133
25311
  depth,
@@ -25204,8 +25382,11 @@ Configure in:
25204
25382
  if (activeResearchAbortController === abortController) activeResearchAbortController = null;
25205
25383
  await safeShutdown();
25206
25384
  }
25385
+ if (cancellationRequested) {
25386
+ exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
25387
+ }
25207
25388
  if (json) {
25208
- toStdout(pretty({ ok: true, ...result }));
25389
+ toStdout(pretty({ ok: true, ...cancellationRequested ? { cancelled: true } : {}, ...result }));
25209
25390
  } else {
25210
25391
  toStdout(result.text.endsWith("\n") ? result.text : result.text + "\n");
25211
25392
  }
@@ -25215,6 +25396,7 @@ async function cmdStatus(json) {
25215
25396
  const det = await detectCredentials();
25216
25397
  const paths = resolvedConfigPaths();
25217
25398
  const cfg = getConfig(process.cwd(), "cli");
25399
+ const browserInstalled = isBrowserAvailable();
25218
25400
  const summary = {
25219
25401
  package: "@lincoln504/pi-research",
25220
25402
  version: PKG_VERSION,
@@ -25228,6 +25410,12 @@ async function cmdStatus(json) {
25228
25410
  modelFrom: det.modelFrom,
25229
25411
  problem: det.problem ?? null
25230
25412
  },
25413
+ browser: {
25414
+ installed: browserInstalled,
25415
+ // Same remedy the health check prints, so both surfaces name one command.
25416
+ fix: browserInstalled ? null : "npx camoufox-js fetch",
25417
+ note: browserInstalled ? null : "Stealth browser not installed \u2014 scraping falls back to plain fetch, which some sites block. npm 11.19+ blocks install scripts by default, which skips this package's postinstall."
25418
+ },
25231
25419
  knowledgeStoreMode: cfg.KNOWLEDGE_STORE_MODE,
25232
25420
  defaultDepth: cfg.DEFAULT_RESEARCH_DEPTH,
25233
25421
  paths
@@ -25242,6 +25430,13 @@ async function cmdStatus(json) {
25242
25430
 
25243
25431
  `);
25244
25432
  toStdout(configBlock(det, summary.ready ? void 0 : "\u2014 fix the problem above, then re-run.") + "\n");
25433
+ if (!summary.browser.installed) {
25434
+ toStdout(`
25435
+ stealth browser: NOT INSTALLED \u2014 run: ${summary.browser.fix}
25436
+ `);
25437
+ toStdout(` ${summary.browser.note}
25438
+ `);
25439
+ }
25245
25440
  toStdout(`
25246
25441
  knowledge store mode: ${cfg.KNOWLEDGE_STORE_MODE} (default depth: ${cfg.DEFAULT_RESEARCH_DEPTH})
25247
25442
  `);
@@ -25440,7 +25635,7 @@ async function reportError(err, what, json) {
25440
25635
  const isRateLimit = !isCancellation && (lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests"));
25441
25636
  const exitCode = isCancellation ? cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED : isCapacity ? EXIT.TEMPFAIL : isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
25442
25637
  if (json) {
25443
- const payload = { ok: false, error: msg2, exitCode };
25638
+ const payload = isCancellation ? { ok: false, error: `pi-research ${what} cancelled.`, cause: msg2, exitCode } : { ok: false, error: msg2, exitCode };
25444
25639
  if ((isCapacity || isRateLimit) && !isCancellation) payload["retryable"] = true;
25445
25640
  if (isCancellation) payload["cancelled"] = true;
25446
25641
  if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
@@ -25540,6 +25735,7 @@ COMMANDS
25540
25735
  --dry-run | --json Plan only; JSON output.
25541
25736
 
25542
25737
  help, --help, -h Show this help.
25738
+ --version, -v Print the pi-research version.
25543
25739
 
25544
25740
  CONFIGURE
25545
25741
  Credentials are resolved in this order (first match wins):
@@ -25729,6 +25925,12 @@ function parseArgs(argv) {
25729
25925
  } else if (a === "--exclude-tools") {
25730
25926
  const v = takeOptionValue(rest, ++i, "--exclude-tools");
25731
25927
  excludeTools = v.split(",").map((s) => s.trim()).filter(Boolean);
25928
+ const unknown = excludeTools.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
25929
+ if (unknown.length > 0) {
25930
+ throw new UsageError(
25931
+ `--exclude-tools: unknown tool name${unknown.length === 1 ? "" : "s"} ${unknown.map((t) => `"${t}"`).join(", ")}. Valid names: ${RESEARCH_TOOL_NAMES.join(", ")}.`
25932
+ );
25933
+ }
25732
25934
  if (excludeTools.length === 0) excludeTools = void 0;
25733
25935
  } else if (a === "--initial-links") {
25734
25936
  let j = i + 1;
@@ -25742,6 +25944,7 @@ function parseArgs(argv) {
25742
25944
  }
25743
25945
  j++;
25744
25946
  }
25947
+ if (j === i + 1) throw new UsageError("--initial-links requires at least one URL.");
25745
25948
  i = j - 1;
25746
25949
  } else if (a === "--config") {
25747
25950
  configPath = takeOptionValue(rest, ++i, "--config");