@lincoln504/pi-research 1.3.9 → 1.3.11

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/README.md CHANGED
@@ -66,7 +66,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
66
66
  - **No quota and no monthly fee.** Most AI search providers cap free searches and then charge, with results served from their index on their servers. pi-research searches and scrapes locally through DuckDuckGo in a stealth browser.
67
67
  - **You own your index.** Every finding is written to a local [LanceDB](https://lancedb.com) knowledge store that seeds future runs, so repeat questions get faster and cheaper answers.
68
68
  - **Cited reports rather than raw dumps.** You get one sourced Markdown report instead of raw web content pasted into the conversation. Context efficient and easy to verify.
69
- - **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything. It can read and search files, so treat it as you would any read-only agent with access to your machine.
69
+ - **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything. It can read files, so treat it as you would any read-only agent with access to your machine.
70
70
  - **Search a little or a lot.** Depth levels span a quick pass to a large-scale investigation.
71
71
 
72
72
  ### Requirements / limitations
package/dist/cli.mjs CHANGED
@@ -2148,13 +2148,18 @@ function isPlausibleCitationUrl(url) {
2148
2148
  }
2149
2149
  }
2150
2150
  function lastCitedLinksHeaderIndex(text) {
2151
+ return lastCitedLinksHeader(text).textStart;
2152
+ }
2153
+ function lastCitedLinksHeader(text) {
2151
2154
  const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
2152
- let last = -1;
2155
+ let lineStart = -1;
2156
+ let textStart = -1;
2153
2157
  let m;
2154
2158
  while ((m = re.exec(text)) !== null) {
2155
- last = m.index + m[1].length + m[2].length;
2159
+ lineStart = m.index + m[1].length;
2160
+ textStart = m.index + m[1].length + m[2].length;
2156
2161
  }
2157
- return last;
2162
+ return { lineStart, textStart };
2158
2163
  }
2159
2164
  function parseCitations(report) {
2160
2165
  const headerIdx = lastCitedLinksHeaderIndex(report);
@@ -2889,7 +2894,7 @@ function parseEnvNumber(env, key, def, min, max, integer = false) {
2889
2894
  return n;
2890
2895
  }
2891
2896
  function parseEnvBool(env, key, def) {
2892
- const v = env[key];
2897
+ const v = env[key]?.trim();
2893
2898
  if (v === void 0 || v === "") return def;
2894
2899
  return v.toLowerCase() === "true";
2895
2900
  }
@@ -2897,7 +2902,7 @@ function parseEnvString(env, key, def) {
2897
2902
  return env[key] || def;
2898
2903
  }
2899
2904
  function parseEnvEnum(env, key, allowed, def) {
2900
- const raw = env[key];
2905
+ const raw = env[key]?.trim();
2901
2906
  if (raw === void 0 || raw === "") return def;
2902
2907
  if (allowed.includes(raw)) return raw;
2903
2908
  logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(", ")}). Using default "${def}".`);
@@ -3049,7 +3054,8 @@ var init_config = __esm({
3049
3054
  /**
3050
3055
  * Comma-separated list of research tools to DISABLE for every researcher in a run
3051
3056
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
3052
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
3057
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
3058
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
3053
3059
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
3054
3060
  * tools are both removed from each researcher's toolset AND named in the
3055
3061
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -3132,10 +3138,9 @@ function getMaxScrapeBatches(config) {
3132
3138
  }
3133
3139
  }
3134
3140
  function resolveExcludedTools(callerExclusions, configDisabled = []) {
3135
- const base = callerExclusions && callerExclusions.length > 0 ? callerExclusions : DEFAULT_EXCLUDED_TOOLS;
3136
- return [.../* @__PURE__ */ new Set([...base, ...configDisabled])];
3141
+ return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
3137
3142
  }
3138
- 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;
3143
+ 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;
3139
3144
  var init_constants = __esm({
3140
3145
  "src/constants.ts"() {
3141
3146
  "use strict";
@@ -3167,6 +3172,15 @@ var init_constants = __esm({
3167
3172
  MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
3168
3173
  MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
3169
3174
  DEFAULT_EXCLUDED_TOOLS = ["grep"];
3175
+ RESEARCH_TOOL_NAMES = [
3176
+ "search",
3177
+ "scrape",
3178
+ "security_search",
3179
+ "stackexchange",
3180
+ "youtube_transcript",
3181
+ "grep",
3182
+ "read"
3183
+ ];
3170
3184
  }
3171
3185
  });
3172
3186
 
@@ -5384,6 +5398,9 @@ var init_store = __esm({
5384
5398
  logger.warn("[store] Failed to read table schema:", schemaErr);
5385
5399
  }
5386
5400
  } else {
5401
+ if (this.options.embedder.getDimension() === null) {
5402
+ await this.withEmbedderReconnect((embedder) => embedder.embedMany([" "]));
5403
+ }
5387
5404
  this.table = await this.createTable();
5388
5405
  }
5389
5406
  await this.evictOldRecords();
@@ -5765,6 +5782,45 @@ var init_store = __esm({
5765
5782
  }
5766
5783
  }
5767
5784
  }
5785
+ /**
5786
+ * Refresh the timestamp of one CURRENT generation for url+type — the
5787
+ * revalidation "touch" for the writer-queue's content-unchanged skip. Without
5788
+ * it a stable page kept its original timestamp forever: with
5789
+ * KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS set, the serve-age gate then treated the
5790
+ * entry as permanently stale (a live re-scrape on every run, for exactly the
5791
+ * content that never changes), and TTL eviction eventually deleted an entry
5792
+ * that had been re-verified byte-identical possibly hours earlier.
5793
+ */
5794
+ async touchByUrlAndType(url, ingestionType, generationTs, newTs) {
5795
+ return this.trackOperation("touchByUrlAndType", void 0, async () => {
5796
+ let retryCount = 0;
5797
+ const MAX_RETRIES = 5;
5798
+ const BASE_DELAY = 100;
5799
+ const scopeFilter = this.getScopeFilter();
5800
+ while (retryCount <= MAX_RETRIES) {
5801
+ const table = await this.getFreshTable();
5802
+ try {
5803
+ const escapedUrl = url.replace(/'/g, "''");
5804
+ const escapedType = ingestionType.replace(/'/g, "''");
5805
+ await table.update({
5806
+ where: `url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND timestamp = ${Math.floor(generationTs)} AND (${scopeFilter})`,
5807
+ valuesSql: { timestamp: String(Math.floor(newTs)) }
5808
+ });
5809
+ logger.log(`[store] Refreshed timestamp for unchanged ${ingestionType} chunks of ${url}`);
5810
+ return;
5811
+ } catch (err) {
5812
+ if (isLanceCommitConflict(err)) {
5813
+ retryCount++;
5814
+ if (retryCount > MAX_RETRIES) throw err;
5815
+ const delay2 = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
5816
+ await new Promise((r) => setTimeout(r, delay2));
5817
+ continue;
5818
+ }
5819
+ throw err;
5820
+ }
5821
+ }
5822
+ });
5823
+ }
5768
5824
  async findDocumentsByUrl(url) {
5769
5825
  return this.trackOperation("findDocumentsByUrl", [], async () => {
5770
5826
  const table = await this.getFreshTable();
@@ -6002,7 +6058,10 @@ var init_store = __esm({
6002
6058
  backupDirs.map(async (name) => ({ name, ts: await this.backupDirTimestamp(name) }))
6003
6059
  );
6004
6060
  ranked.sort((a, b) => a.ts - b.ts);
6005
- for (const { name } of ranked.slice(0, -1)) {
6061
+ const ttlDays = this.options.ttlDays ?? 30;
6062
+ const backupCutoff = Math.trunc(Date.now() - ttlDays * 24 * 60 * 60 * 1e3);
6063
+ for (const { name, ts } of ranked.slice(0, -1)) {
6064
+ if (ts >= backupCutoff) continue;
6006
6065
  const fullPath = path13.join(this.options.dbDir, name);
6007
6066
  logger.info(`[store] Removing superseded backup directory: ${name}`);
6008
6067
  await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
@@ -6298,6 +6357,17 @@ var init_writer_queue = __esm({
6298
6357
  metrics.increment("knowledge_ingest_stale_prune_failed_total", 1);
6299
6358
  });
6300
6359
  }
6360
+ if (this.options.store.touchByUrlAndType) {
6361
+ try {
6362
+ const recheck = (await this.options.store.findByUrl(normalizedUrl)).filter((c) => c.metadata["ingestionType"] === incomingType);
6363
+ const stillNewest = recheck.length > 0 && Math.max(...recheck.map((d) => Number(d.timestamp) || 0)) === newestTs;
6364
+ if (stillNewest) {
6365
+ await this.options.store.touchByUrlAndType(normalizedUrl, incomingType, newestTs, Date.now());
6366
+ }
6367
+ } catch (err) {
6368
+ logger.warn(`[writer-queue] Timestamp refresh for unchanged ${normalizedUrl} (${incomingType}) failed:`, err);
6369
+ }
6370
+ }
6301
6371
  logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) \u2014 content unchanged.`);
6302
6372
  return;
6303
6373
  }
@@ -6776,7 +6846,17 @@ async function loadPipelineWithTimeout(model, device, timeoutMs, useCache) {
6776
6846
  interOpNumThreads: 1
6777
6847
  }
6778
6848
  });
6779
- return await withTimeout3(pipelinePromise, timeoutMs, errorMessage);
6849
+ let timedOut = true;
6850
+ try {
6851
+ const p = await withTimeout3(pipelinePromise, timeoutMs, errorMessage);
6852
+ timedOut = false;
6853
+ return p;
6854
+ } finally {
6855
+ if (timedOut) {
6856
+ pipelinePromise.then((late) => late.dispose?.()).catch(() => {
6857
+ });
6858
+ }
6859
+ }
6780
6860
  });
6781
6861
  return { pipeline: loadedPipeline, errorMessage };
6782
6862
  }
@@ -10421,7 +10501,7 @@ function normalizeCitations(reports) {
10421
10501
  localToGlobal.set(localKey, globalId);
10422
10502
  }
10423
10503
  });
10424
- const headerIdx = lastCitedLinksHeaderIndex(report);
10504
+ const headerIdx = lastCitedLinksHeader(report).lineStart;
10425
10505
  let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
10426
10506
  content = rewriteCitationMarkers(
10427
10507
  content,
@@ -17024,17 +17104,30 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
17024
17104
  return callback(null, safe[0].address, safe[0].family);
17025
17105
  });
17026
17106
  }
17027
- var cachedSsrfDispatcher = void 0;
17028
- async function getSsrfSafeDispatcher() {
17029
- if (cachedSsrfDispatcher !== void 0) return cachedSsrfDispatcher;
17107
+ function isSsrfBlockError(error) {
17108
+ let e = error;
17109
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
17110
+ if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
17111
+ return true;
17112
+ }
17113
+ e = e.cause;
17114
+ }
17115
+ return false;
17116
+ }
17117
+ var cachedSsrfFetcher = void 0;
17118
+ async function getSsrfSafeFetcher() {
17119
+ if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
17030
17120
  try {
17031
- const { Agent: Agent2 } = await import("undici");
17032
- cachedSsrfDispatcher = new Agent2({ connect: { lookup: ssrfSafeLookup } });
17121
+ const { Agent: Agent2, fetch: undiciFetch } = await import("undici");
17122
+ cachedSsrfFetcher = {
17123
+ fetch: undiciFetch,
17124
+ dispatcher: new Agent2({ connect: { lookup: ssrfSafeLookup } })
17125
+ };
17033
17126
  } catch (e) {
17034
17127
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
17035
- cachedSsrfDispatcher = null;
17128
+ cachedSsrfFetcher = null;
17036
17129
  }
17037
- return cachedSsrfDispatcher;
17130
+ return cachedSsrfFetcher;
17038
17131
  }
17039
17132
  function formatErrorWithCause(err) {
17040
17133
  try {
@@ -17284,7 +17377,7 @@ function decodeHtmlBody(bytes, contentTypeHeader) {
17284
17377
  }
17285
17378
  }
17286
17379
  function trackFetchLayerFailure(url, error) {
17287
- const isSsrf = error instanceof Error && error.message.includes("not allowed");
17380
+ const isSsrf = isSsrfBlockError(error);
17288
17381
  errorTracker.trackError(error instanceof Error ? error : String(error), {
17289
17382
  component: "scrapers",
17290
17383
  operation: "fetch",
@@ -17296,7 +17389,8 @@ function trackFetchLayerFailure(url, error) {
17296
17389
  }
17297
17390
  async function scrapeWithFetch(url, signal) {
17298
17391
  await validateUrlForSSRF(url);
17299
- const dispatcher = await getSsrfSafeDispatcher();
17392
+ const fetcher = await getSsrfSafeFetcher();
17393
+ const doFetch = fetcher?.fetch ?? ((u, init) => fetch(u, init));
17300
17394
  const controller = new AbortController();
17301
17395
  const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
17302
17396
  safeUnref(timeoutId);
@@ -17315,15 +17409,15 @@ async function scrapeWithFetch(url, signal) {
17315
17409
  let response;
17316
17410
  const userAgent = getRandomUserAgent();
17317
17411
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
17318
- response = await fetch(currentUrl, {
17412
+ response = await doFetch(currentUrl, {
17319
17413
  signal: controller.signal,
17320
17414
  redirect: "manual",
17321
17415
  headers: {
17322
17416
  "User-Agent": userAgent,
17323
17417
  "Accept": "text/html,application/xhtml+xml,application/pdf,*/*;q=0.8"
17324
17418
  },
17325
- // RequestInit's standard type omits undici's `dispatcher`; cast to attach it.
17326
- ...dispatcher ? { dispatcher } : {}
17419
+ // Only ever attached alongside the fetch that honours it (see above).
17420
+ ...fetcher ? { dispatcher: fetcher.dispatcher } : {}
17327
17421
  });
17328
17422
  if (response.status >= 300 && response.status < 400) {
17329
17423
  const location = response.headers.get("location");
@@ -17424,7 +17518,7 @@ async function scrapeWithFetch(url, signal) {
17424
17518
  metrics.observe("scrape_latency_ms", fetchDuration, { layer: "fetch", content_type: "html", status: "success" });
17425
17519
  return { source: "fetch", layer: "fetch", markdown };
17426
17520
  } catch (error) {
17427
- if (error instanceof Error && error.message.includes("not allowed")) {
17521
+ if (isSsrfBlockError(error)) {
17428
17522
  metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "ssrf_blocked" });
17429
17523
  metrics.observe("scrape_latency_ms", Date.now() - fetchStart, { layer: "fetch", status: "ssrf_blocked" });
17430
17524
  throw error;
@@ -21618,7 +21712,7 @@ async function createResearcherSession(options) {
21618
21712
 
21619
21713
  // src/orchestration/researcher-executor.ts
21620
21714
  init_text_utils();
21621
- init_llm();
21715
+ init_llm_usage();
21622
21716
  init_logger();
21623
21717
  init_metrics();
21624
21718
  init_service_interfaces();
@@ -22054,12 +22148,13 @@ ${prompt}`);
22054
22148
  if (msg2?.["role"] !== "assistant") return;
22055
22149
  const rawUsage = msg2["usage"];
22056
22150
  if (rawUsage) {
22057
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
22151
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
22152
+ component: "researcher",
22153
+ complexity,
22154
+ observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) }
22155
+ });
22058
22156
  if (tokens > 0 || cost > 0) {
22059
- metrics.increment("llm_tokens_total", tokens, { component: "researcher", complexity: String(complexity) });
22060
- metrics.increment("llm_cost_total", cost, { component: "researcher", complexity: String(complexity) });
22061
22157
  safeObserve(() => observer?.onResearcherProgress?.(id, void 0, tokens, cost));
22062
- safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
22063
22158
  }
22064
22159
  }
22065
22160
  } else if (event.type === "tool_execution_start") {
@@ -22186,7 +22281,7 @@ ${responseText}`);
22186
22281
 
22187
22282
  // src/orchestration/quick-research-orchestrator.ts
22188
22283
  init_prompts();
22189
- init_llm();
22284
+ init_llm_usage();
22190
22285
  init_logger();
22191
22286
  init_config();
22192
22287
  init_text_utils();
@@ -22467,12 +22562,13 @@ ${prompt}`);
22467
22562
  if (msg2?.["role"] !== "assistant") return;
22468
22563
  const rawUsage = msg2["usage"];
22469
22564
  if (rawUsage) {
22470
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
22565
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
22566
+ component: "quick_researcher",
22567
+ complexity: 0,
22568
+ observer
22569
+ });
22471
22570
  if (tokens > 0 || cost > 0) {
22472
- metrics.increment("llm_tokens_total", tokens, { component: "quick_researcher", complexity: "0" });
22473
- metrics.increment("llm_cost_total", cost, { component: "quick_researcher", complexity: "0" });
22474
22571
  observer?.onResearcherProgress?.("quick", void 0, tokens, cost);
22475
- observer?.onTokensConsumed?.(tokens, cost);
22476
22572
  }
22477
22573
  }
22478
22574
  } else if (event.type === "tool_execution_start") {
@@ -23075,6 +23171,13 @@ var ResearchOrchestrationService = class {
23075
23171
  });
23076
23172
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
23077
23173
  } catch (err) {
23174
+ const isTerminal = err instanceof ResearchRunCapacityError || err instanceof Error && err.name === "AbortError";
23175
+ if (isTerminal) {
23176
+ try {
23177
+ observer?.onError?.(err);
23178
+ } catch {
23179
+ }
23180
+ }
23078
23181
  if (err instanceof ResearchRunCapacityError) throw err;
23079
23182
  if (err instanceof Error && err.name === "AbortError") throw err;
23080
23183
  logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
@@ -23826,7 +23929,7 @@ Research performed using ${modelId}`;
23826
23929
  if (globalId !== void 0) localToGlobal.set(localKey, globalId);
23827
23930
  else droppedNumbers.add(localKey);
23828
23931
  });
23829
- let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
23932
+ let body = synthesis.slice(0, lastCitedLinksHeader(synthesis).lineStart).trimEnd();
23830
23933
  if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
23831
23934
  body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
23832
23935
  }
@@ -24749,6 +24852,7 @@ Total cost: ${costStr}`;
24749
24852
  // src/cli.ts
24750
24853
  init_config();
24751
24854
  init_model_registry_factory();
24855
+ init_constants();
24752
24856
  import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
24753
24857
  function readPkgVersion() {
24754
24858
  try {
@@ -24869,14 +24973,15 @@ function makeProgressObserver() {
24869
24973
  var ConfigFileError = class extends Error {
24870
24974
  };
24871
24975
  function bridgeConfigEnv(explicitConfigPath) {
24872
- const basePath = explicitConfigPath ?? getGlobalEnvFilePath();
24976
+ const globalBasePath = getGlobalEnvFilePath();
24977
+ const basePath = explicitConfigPath ?? globalBasePath;
24873
24978
  const cliOverlayPath = getInterfaceEnvFilePath("cli");
24874
24979
  let loaded = false;
24875
24980
  const merged = {};
24876
24981
  const fromExplicitBase = /* @__PURE__ */ new Set();
24877
- const layers = explicitConfigPath !== void 0 ? [cliOverlayPath, basePath] : [basePath, cliOverlayPath];
24982
+ const layers = explicitConfigPath !== void 0 ? [globalBasePath, cliOverlayPath, explicitConfigPath] : [globalBasePath, cliOverlayPath];
24878
24983
  for (const filePath of layers) {
24879
- const isExplicit = explicitConfigPath !== void 0 && filePath === basePath;
24984
+ const isExplicit = explicitConfigPath !== void 0 && filePath === explicitConfigPath;
24880
24985
  try {
24881
24986
  if (!fs17.existsSync(filePath)) {
24882
24987
  if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
@@ -25013,7 +25118,11 @@ async function detectCredentials(explicitModel) {
25013
25118
  modelFrom,
25014
25119
  piAuthPresent,
25015
25120
  piModelsPresent,
25016
- 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)."
25121
+ // A model configured with no usable key is a different (and misleading to
25122
+ // conflate) gap than nothing configured at all: SKILL.md tells agents to relay
25123
+ // this text verbatim, so "No model … is configured" pointed users who HAD set
25124
+ // PI_RESEARCH_MODEL at the wrong knob.
25125
+ 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)."
25017
25126
  };
25018
25127
  }
25019
25128
  function configBlock(det, extraNote) {
@@ -25083,6 +25192,8 @@ Error: ${vmsg}
25083
25192
  ...args.excludeTools ? { excludeTools: args.excludeTools } : {},
25084
25193
  ...args.initialLinks ? { initialLinks: args.initialLinks } : {}
25085
25194
  }, abortController.signal);
25195
+ const outcome = getLastResearcherOutcome();
25196
+ const errorReport = getLastErrorReport();
25086
25197
  let reportPath = null;
25087
25198
  const exportCfg = getConfig(process.cwd(), "cli");
25088
25199
  if (exportCfg.RESEARCH_REPORT_EXPORT_ENABLED) {
@@ -25097,8 +25208,6 @@ Error: ${vmsg}
25097
25208
  exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
25098
25209
  }
25099
25210
  if (args.json) {
25100
- const outcome = getLastResearcherOutcome();
25101
- const errorReport = getLastErrorReport();
25102
25211
  toStdout(pretty({
25103
25212
  ok: true,
25104
25213
  depth,
@@ -25175,8 +25284,11 @@ Configure in:
25175
25284
  if (activeResearchAbortController === abortController) activeResearchAbortController = null;
25176
25285
  await safeShutdown();
25177
25286
  }
25287
+ if (cancellationRequested) {
25288
+ exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
25289
+ }
25178
25290
  if (json) {
25179
- toStdout(pretty({ ok: true, ...result }));
25291
+ toStdout(pretty({ ok: true, ...cancellationRequested ? { cancelled: true } : {}, ...result }));
25180
25292
  } else {
25181
25293
  toStdout(result.text.endsWith("\n") ? result.text : result.text + "\n");
25182
25294
  }
@@ -25511,6 +25623,7 @@ COMMANDS
25511
25623
  --dry-run | --json Plan only; JSON output.
25512
25624
 
25513
25625
  help, --help, -h Show this help.
25626
+ --version, -v Print the pi-research version.
25514
25627
 
25515
25628
  CONFIGURE
25516
25629
  Credentials are resolved in this order (first match wins):
@@ -25700,6 +25813,12 @@ function parseArgs(argv) {
25700
25813
  } else if (a === "--exclude-tools") {
25701
25814
  const v = takeOptionValue(rest, ++i, "--exclude-tools");
25702
25815
  excludeTools = v.split(",").map((s) => s.trim()).filter(Boolean);
25816
+ const unknown = excludeTools.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
25817
+ if (unknown.length > 0) {
25818
+ throw new UsageError(
25819
+ `--exclude-tools: unknown tool name${unknown.length === 1 ? "" : "s"} ${unknown.map((t) => `"${t}"`).join(", ")}. Valid names: ${RESEARCH_TOOL_NAMES.join(", ")}.`
25820
+ );
25821
+ }
25703
25822
  if (excludeTools.length === 0) excludeTools = void 0;
25704
25823
  } else if (a === "--initial-links") {
25705
25824
  let j = i + 1;
@@ -25713,6 +25832,7 @@ function parseArgs(argv) {
25713
25832
  }
25714
25833
  j++;
25715
25834
  }
25835
+ if (j === i + 1) throw new UsageError("--initial-links requires at least one URL.");
25716
25836
  i = j - 1;
25717
25837
  } else if (a === "--config") {
25718
25838
  configPath = takeOptionValue(rest, ++i, "--config");
@@ -1512,8 +1512,9 @@ __export(scraper_utils_exports, {
1512
1512
  extractDomain: () => extractDomain,
1513
1513
  formatErrorWithCause: () => formatErrorWithCause,
1514
1514
  getRandomUserAgent: () => getRandomUserAgent,
1515
- getSsrfSafeDispatcher: () => getSsrfSafeDispatcher,
1515
+ getSsrfSafeFetcher: () => getSsrfSafeFetcher,
1516
1516
  isBenignScrapeFailure: () => isBenignScrapeFailure,
1517
+ isSsrfBlockError: () => isSsrfBlockError,
1517
1518
  ssrfSafeLookup: () => ssrfSafeLookup,
1518
1519
  stripImageLinks: () => stripImageLinks,
1519
1520
  validateContent: () => validateContent,
@@ -1633,16 +1634,29 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
1633
1634
  return callback(null, safe[0].address, safe[0].family);
1634
1635
  });
1635
1636
  }
1636
- async function getSsrfSafeDispatcher() {
1637
- if (cachedSsrfDispatcher !== void 0) return cachedSsrfDispatcher;
1637
+ function isSsrfBlockError(error) {
1638
+ let e = error;
1639
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
1640
+ if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
1641
+ return true;
1642
+ }
1643
+ e = e.cause;
1644
+ }
1645
+ return false;
1646
+ }
1647
+ async function getSsrfSafeFetcher() {
1648
+ if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
1638
1649
  try {
1639
- const { Agent } = await import("undici");
1640
- cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
1650
+ const { Agent, fetch: undiciFetch } = await import("undici");
1651
+ cachedSsrfFetcher = {
1652
+ fetch: undiciFetch,
1653
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1654
+ };
1641
1655
  } catch (e) {
1642
1656
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1643
- cachedSsrfDispatcher = null;
1657
+ cachedSsrfFetcher = null;
1644
1658
  }
1645
- return cachedSsrfDispatcher;
1659
+ return cachedSsrfFetcher;
1646
1660
  }
1647
1661
  function formatErrorWithCause(err) {
1648
1662
  try {
@@ -1776,7 +1790,7 @@ function validateContent(html, markdown, url) {
1776
1790
  throw error;
1777
1791
  }
1778
1792
  }
1779
- var cachedSsrfDispatcher;
1793
+ var cachedSsrfFetcher;
1780
1794
  var init_scraper_utils = __esm({
1781
1795
  "src/web-research/scraper-utils.ts"() {
1782
1796
  "use strict";
@@ -1785,7 +1799,7 @@ var init_scraper_utils = __esm({
1785
1799
  init_logger();
1786
1800
  init_scraper_types();
1787
1801
  init_browser_error_utils();
1788
- cachedSsrfDispatcher = void 0;
1802
+ cachedSsrfFetcher = void 0;
1789
1803
  }
1790
1804
  });
1791
1805
 
@@ -2566,7 +2580,8 @@ var ConfigSchema = Type.Object({
2566
2580
  /**
2567
2581
  * Comma-separated list of research tools to DISABLE for every researcher in a run
2568
2582
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
2569
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
2583
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
2584
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
2570
2585
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
2571
2586
  * tools are both removed from each researcher's toolset AND named in the
2572
2587
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -12,7 +12,7 @@ pi CLI
12
12
  └── pi-research extension (src/index.ts)
13
13
  ├── Registered Tools research, health, research_knowledge_search (always registered; reports why when the store is disabled)
14
14
  ├── Commands /research, /research-config, /knowledge-store
15
- ├── Events input (mid-run steering), session_shutdown (cleanup)
15
+ ├── Events input (mid-run steering), session_shutdown (cleanup), session_before_compact / session_compact, before_agent_start, after_provider_response
16
16
  └── Layers
17
17
  ├── Orchestration quick/deep research coordination
18
18
  ├── Agent Tools search, scrape, youtube_transcript, security_search, stackexchange, grep, read
@@ -106,21 +106,23 @@ agent to invoke:
106
106
  |------|-------|------|---------|
107
107
  | `search` | ✓ | — | DuckDuckGo Lite via the stealth browser |
108
108
  | `scrape` | ✓ | ✓ | Batch page fetch → Markdown via the stealth browser (up to 6 URLs per call) |
109
- | `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos, one call per researcher) |
109
+ | `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos by default, configurable 1–5; one call per researcher) |
110
110
  | `security_search` | ✓ | ✓ | NVD, CISA KEV, GitHub Advisories, OSV |
111
111
  | `stackexchange` | ✓ | ✓ | Stack Exchange network |
112
- | `grep` | — | — | Local ripgrep (from pi-coding-agent) — off by default, see below |
112
+ | `grep` | — | — | Local ripgrep (from pi-coding-agent) — always excluded, see below |
113
113
  | `read` | ✓ | ✓ | Local file reads (from pi-coding-agent) |
114
114
 
115
115
  In deep research `search` is excluded — the coordinator runs the search burst and hands
116
116
  out URLs directly.
117
117
 
118
- `grep` is excluded by default at **every** depth and on every front-end (CLI, SDK, agent
119
- skill, pi extension): this is web research, and a capable model otherwise spends turns
120
- searching the local filesystem. Passing an explicit `excludeTools` list — `--exclude-tools`
121
- on the CLI, the `excludeTools` tool parameter in the extension — REPLACES that default, so
122
- naming other tools re-enables `grep`. `PI_RESEARCH_DISABLED_TOOLS` is separate and strictly
123
- additive: it can only ever remove capabilities (see [CONFIGURATION.md](CONFIGURATION.md)).
118
+ `grep` is excluded at **every** depth and on every front-end (CLI, SDK, agent skill,
119
+ pi extension): this is web research, and a capable model otherwise spends turns
120
+ searching the local filesystem. Both exclusion surfaces — the `excludeTools` list
121
+ (`--exclude-tools` on the CLI, the `excludeTools` tool parameter in the extension) and
122
+ `PI_RESEARCH_DISABLED_TOOLS` — are strictly additive on top of that default: they can
123
+ only ever remove capabilities (see [CONFIGURATION.md](CONFIGURATION.md)). Before 1.3.10
124
+ a non-empty `excludeTools` list replaced the default, so naming any other tool silently
125
+ re-enabled `grep`.
124
126
 
125
127
  Researchers cannot write files, run shell commands, or reach the network outside these
126
128
  tools.
@@ -288,11 +290,12 @@ src/
288
290
  ### Key design decisions
289
291
 
290
292
  Read-only researchers — researcher agents are limited to the tool set above. They cannot
291
- write files, spawn processes, or make arbitrary network calls. They *can* read and grep
292
- files: `read` and `grep` are registered unconditionally and the researcher exclusion list
293
- (`bash`, `write`, `edit`, `repl`, `git`, `terminal`) does not cover them. The `cwd` passed
294
- to those tools is a resolution base, not a jail — an absolute path resolves to itself — so
295
- the boundary is "no mutation", not "only this directory".
293
+ write files, spawn processes, or make arbitrary network calls. They *can* read files:
294
+ `read` is registered and the researcher exclusion list (`bash`, `write`, `edit`, `repl`,
295
+ `git`, `terminal`) does not cover it. Local `grep` is registered but always excluded (see
296
+ the tool table above). The `cwd` passed to `read` is a resolution base, not a jail — an
297
+ absolute path resolves to itself — so the boundary is "no mutation", not "only this
298
+ directory".
296
299
 
297
300
  Worker pool over direct browser — browser processes are isolated in workers so a crash
298
301
  in one cannot affect the orchestrator or other sessions.