@lincoln504/pi-research 1.5.3 → 1.6.1

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
@@ -27,7 +27,7 @@
27
27
  <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-97ca00?style=flat-square" /></a><br />
28
28
  <a href="https://github.com/Lincoln504/pi-research"><img alt="GitHub repository" src="https://img.shields.io/badge/GitHub-Lincoln504%2Fpi--research-181717?style=flat-square&logo=github&logoColor=white" /></a>
29
29
 
30
- Search and scraping run locally through a stealth browser with no search provider and no monthly cap. You pay only for the LLM tokens you use.
30
+ Search and scraping run locally through a stealth browser with no search provider and no monthly cap. The only cost is LLM tokens.
31
31
 
32
32
  ### Install
33
33
 
@@ -44,38 +44,38 @@ npm install -g @lincoln504/pi-research
44
44
  pi-research skill install
45
45
  ```
46
46
 
47
- In pi it works out of the box on your session's model and pi's configuration. Standalone use ([agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md)) needs a model configured. See [Configuration](docs/CONFIGURATION.md). The first install pulls the stealth browser engine, which takes a few minutes.
47
+ In pi it works out of the box on the session's model and pi's configuration. Standalone use ([agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md)) needs a model configured. See [Configuration](docs/CONFIGURATION.md). The first install pulls the stealth browser engine, which takes a few minutes.
48
48
 
49
49
  ### How it works
50
50
 
51
51
  A research run loops through agent teams: a coordinator plans and opens with a search, researcher agents scrape and read in parallel, and a research lead decides whether to go another round — then writes the report from every report collected. The result is one cited Markdown report with findings optionally saved to the knowledge store.
52
52
 
53
- Three depth levels (normal, deep, ultra) set the team size and number of rounds. Ask in natural language and the tool picks the right one:
53
+ Three depth levels (normal, deep, ultra) set the team size and number of rounds. A natural-language request is enough — the tool picks the right one:
54
54
 
55
55
  ![Prompt-driven multi-round research in the pi TUI](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/02-prompt-research.gif)
56
56
 
57
57
  ### Use cases
58
58
 
59
- - Researching for your pi. Plug and play with no API key needed.
60
- - Researching from Claude Code, Codex, or another coding agent while a cheaper or local model drives the run so it doesn't spend your main agent's budget.
59
+ - Researching inside pi. Plug and play with no API key needed.
60
+ - Researching from Claude Code, Codex, or another coding agent while a cheaper or local model drives the run, so it doesn't spend the main agent's budget.
61
61
  - Keeping a persistent and searchable knowledge store of findings, scoped globally or per project.
62
- - Building agent systems that find and read web content or populating a dataset of web sources.
62
+ - Building agent systems that find and read web content, or populating a dataset of web sources.
63
63
 
64
64
  ### Why pi-research
65
65
 
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
- - **You own your index.** Every finding can be saved to a local [LanceDB](https://lancedb.com) knowledge store that seeds future runs, so repeat questions get faster and cheaper answers.
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 files, so treat it as you would any read-only agent with access to your machine.
67
+ - **The index stays local.** Every finding can be saved to a local [LanceDB](https://lancedb.com) knowledge store that seeds future runs, so repeat questions get faster and cheaper answers.
68
+ - **Cited reports rather than raw dumps.** Each run returns 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 — the right shape for an agent whose whole job is reading untrusted web pages. Prompt injection picked up mid-run has nothing to act on.
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
73
73
 
74
74
  - Node.js >= 22.19.0
75
- - An LLM with a 100k+ context window (bring your own key or use a local model)
75
+ - An LLM with a 100k+ context window (an API key or a local model)
76
76
  - Internet access on a residential IP, since search, scraping, and YouTube transcripts get bot-blocked from datacenter/VPS/cloud IPs
77
77
  - pi. The pi extension uses the host's copy while the standalone CLI and agent skill install it as a dependency.
78
- - Cloudflare and similar systems block scraping on some sites. A run flags pages it can't reach and compensates with a wide pool of search results to cite.
78
+ - Cloudflare and similar systems block scraping on some sites. A run flags unreachable pages and compensates with a wide pool of search results to cite.
79
79
 
80
80
  ### Channels
81
81
 
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.5.3", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.6.1", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/dist/cli.mjs CHANGED
@@ -2216,6 +2216,29 @@ function lastCitedLinksHeader(text) {
2216
2216
  }
2217
2217
  return { lineStart, textStart };
2218
2218
  }
2219
+ function stripReportPreamble(text) {
2220
+ const trimmed = text.trimStart();
2221
+ const breakAt = trimmed.indexOf("\n\n");
2222
+ if (breakAt < 0) return { body: text, preamble: "" };
2223
+ const head = trimmed.slice(0, breakAt).trim();
2224
+ const rest = trimmed.slice(breakAt + 2).trimStart();
2225
+ if (head.includes("\n")) return { body: text, preamble: "" };
2226
+ if (!rest) return { body: text, preamble: "" };
2227
+ if (!REPORT_NARRATION_OPENER.test(head)) return { body: text, preamble: "" };
2228
+ if (!REPORT_NARRATION_SUBJECT.test(head)) return { body: text, preamble: "" };
2229
+ return { body: rest, preamble: head };
2230
+ }
2231
+ function synthesisProseBody(text) {
2232
+ const { lineStart } = lastCitedLinksHeader(text);
2233
+ return (lineStart < 0 ? text : text.slice(0, lineStart)).trim();
2234
+ }
2235
+ function isAnalysisFreeSynthesis(text, minProseChars) {
2236
+ const prose = synthesisProseBody(text);
2237
+ const stripped = prose.replace(/^[ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)/gm, "").trim();
2238
+ if (minProseChars > 0 && stripped.length < minProseChars) return true;
2239
+ if (!/[.!?。!?।॥۔؟።]/.test(stripped)) return true;
2240
+ return false;
2241
+ }
2219
2242
  function parseCitations(report) {
2220
2243
  const headerIdx = lastCitedLinksHeaderIndex(report);
2221
2244
  if (headerIdx < 0) return [];
@@ -2268,11 +2291,14 @@ function parseCitations(report) {
2268
2291
  }
2269
2292
  return citations;
2270
2293
  }
2294
+ var REPORT_NARRATION_OPENER, REPORT_NARRATION_SUBJECT;
2271
2295
  var init_text_utils = __esm({
2272
2296
  "src/utils/text-utils.ts"() {
2273
2297
  "use strict";
2274
2298
  init_logger();
2275
2299
  init_url_utils();
2300
+ REPORT_NARRATION_OPENER = /^(?:I|I'll|I'm|Let me|Now I|Next I|First,? I)\b/i;
2301
+ REPORT_NARRATION_SUBJECT = /\b(?:report|synthesi[sz]\w*|findings|material|sources|scraped|gathered|research\w*|summar\w+)\b/i;
2276
2302
  }
2277
2303
  });
2278
2304
 
@@ -3217,7 +3243,7 @@ function getMaxScrapeBatches(config, cachingActive = false) {
3217
3243
  function resolveExcludedTools(callerExclusions, configDisabled = []) {
3218
3244
  return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
3219
3245
  }
3220
- 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;
3246
+ 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, MIN_SYNTHESIS_PROSE_CHARS, QUICK_THIN_REPORT_NOTICE, SYNTHESIS_FALLBACK_NOTICE, RESEARCH_TOOL_NAMES;
3221
3247
  var init_constants = __esm({
3222
3248
  "src/constants.ts"() {
3223
3249
  "use strict";
@@ -3249,6 +3275,9 @@ var init_constants = __esm({
3249
3275
  MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
3250
3276
  MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
3251
3277
  DEFAULT_EXCLUDED_TOOLS = ["grep"];
3278
+ MIN_SYNTHESIS_PROSE_CHARS = 800;
3279
+ QUICK_THIN_REPORT_NOTICE = "_Note: this run did not produce a substantive write-up. The sources below were retrieved and are cited accurately, but the summary above should not be treated as a complete answer \u2014 consider re-running, or running at a deeper level._";
3280
+ SYNTHESIS_FALLBACK_NOTICE = "_Note: the synthesis step did not produce a usable summary for this run, so the individual researcher reports are included below in full. The sources cited at the end of this document are unaffected._";
3252
3281
  RESEARCH_TOOL_NAMES = [
3253
3282
  "search",
3254
3283
  "scrape",
@@ -7273,11 +7302,10 @@ function assertValidEmbeddingVectors(vectors, expectedCount, declaredDim) {
7273
7302
  }
7274
7303
  }
7275
7304
  async function addDocumentsToStore(table, docs, embedder, isClosing, workspace, isGlobal) {
7276
- if (docs.length === 0) return;
7305
+ if (docs.length === 0) return "empty";
7277
7306
  if (isClosing()) {
7278
7307
  logger.warn("[store] Ignoring addDocuments during close");
7279
- metrics.increment("knowledge_store_add_documents_total", 1, { status: "ignored_closing" });
7280
- return;
7308
+ return "ignored_closing";
7281
7309
  }
7282
7310
  const startTime = Date.now();
7283
7311
  try {
@@ -7297,14 +7325,13 @@ async function addDocumentsToStore(table, docs, embedder, isClosing, workspace,
7297
7325
  await table.add(data);
7298
7326
  const duration = Date.now() - startTime;
7299
7327
  metrics.observe("knowledge_store_add_documents_duration_ms", duration);
7300
- metrics.increment("knowledge_store_add_documents_total", 1, { status: "success" });
7301
7328
  metrics.increment("knowledge_store_chunks_added_total", docs.length);
7302
7329
  logger.log(`[store] Added ${docs.length} chunk(s) for ${docs[0]?.url} [workspace=${workspace}, global=${isGlobal}]`);
7330
+ return "written";
7303
7331
  } catch (err) {
7304
7332
  const duration = Date.now() - startTime;
7305
7333
  metrics.observe("knowledge_store_add_documents_duration_ms", duration, { status: "error" });
7306
- metrics.increment("knowledge_store_add_documents_total", 1, { status: "error" });
7307
- logger.error("[store] Failed to add documents:", err);
7334
+ logger.debug(`[store] add attempt failed after ${duration}ms: ${err instanceof Error ? err.message : String(err)}`);
7308
7335
  throw err;
7309
7336
  }
7310
7337
  }
@@ -7876,18 +7903,19 @@ var init_store = __esm({
7876
7903
  try {
7877
7904
  if (this.isClosing) {
7878
7905
  logger.warn(`[store] Skipping addDocuments \u2014 store is closing`);
7906
+ metrics.increment("knowledge_store_add_documents_total", 1, { status: "ignored_closing" });
7879
7907
  return;
7880
7908
  }
7881
7909
  let table = await this.getFreshTable();
7882
7910
  const workspace = this.getWorkspace();
7883
- await this.withEmbedderReconnect(async (embedder) => {
7911
+ const outcome = await this.withEmbedderReconnect(async (embedder) => {
7884
7912
  let retryCount = 0;
7885
7913
  const MAX_RETRIES = 5;
7886
7914
  const BASE_DELAY = 100;
7887
7915
  while (retryCount <= MAX_RETRIES) {
7888
7916
  try {
7889
7917
  const isGlobal = this.options.knowledgeMode === "global";
7890
- await addDocumentsToStore(
7918
+ return await addDocumentsToStore(
7891
7919
  table,
7892
7920
  docs,
7893
7921
  embedder,
@@ -7895,7 +7923,6 @@ var init_store = __esm({
7895
7923
  workspace,
7896
7924
  isGlobal
7897
7925
  );
7898
- return;
7899
7926
  } catch (err) {
7900
7927
  if (isLanceCommitConflict(err)) {
7901
7928
  retryCount++;
@@ -7909,7 +7936,17 @@ var init_store = __esm({
7909
7936
  throw err;
7910
7937
  }
7911
7938
  }
7939
+ throw new Error("[store] addDocuments retry loop exited without result");
7912
7940
  });
7941
+ if (outcome !== "empty") {
7942
+ metrics.increment("knowledge_store_add_documents_total", 1, {
7943
+ status: outcome === "ignored_closing" ? "ignored_closing" : "success"
7944
+ });
7945
+ }
7946
+ } catch (err) {
7947
+ metrics.increment("knowledge_store_add_documents_total", 1, { status: "error" });
7948
+ logger.error("[store] Failed to add documents:", err);
7949
+ throw err;
7913
7950
  } finally {
7914
7951
  this.activeWrites.delete(writePromise);
7915
7952
  writeResolve();
@@ -12436,6 +12473,101 @@ function getClientAgent() {
12436
12473
  return clientAgent;
12437
12474
  }
12438
12475
 
12476
+ // src/infrastructure/browser/browser-error-utils.ts
12477
+ init_circuit_breaker();
12478
+ function isTransientSocketError(error) {
12479
+ if (!error || typeof error !== "object") return false;
12480
+ const err = error;
12481
+ return typeof err.message === "string" && (err.message.includes("ECONNREFUSED") || err.message.includes("ECONNRESET") || err.message.includes("socket hang up") || err.message.includes("EPIPE") || err.message.includes("ETIMEDOUT") || err.message.includes("timed out") || err.message.includes("pool busy") || err.message.includes("unreachable") || // WorkerPoolManager throws this during the window between forceSchedulerRestart
12482
+ // clearing the scheduler reference and the old pool's 1500ms drain completing.
12483
+ // It is a transient state — retry after a short delay succeeds once drain finishes.
12484
+ err.message.includes("Worker pool is shutting down") || // Poolifier throws this when pool.execute() is called while the pool is being
12485
+ // destroyed (e.g., during scheduler restart). Treat as transient — the pool will
12486
+ // be ready for new tasks once destruction completes and a new pool is initialized.
12487
+ err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // The leader answered 503 because it is shutting down after losing
12488
+ // leadership. Retrying re-elects and reaches the new leader, so this is
12489
+ // transient in exactly the same sense as a pool drain.
12490
+ err.message.includes("Browser pool leader is draining"));
12491
+ }
12492
+ function isCloudflareBlockError(error) {
12493
+ if (!error || typeof error !== "object") return false;
12494
+ const err = error;
12495
+ return typeof err.message === "string" && (err.message.includes("Fetch blocked: Cloudflare") || err.message.includes("Cloudflare challenge"));
12496
+ }
12497
+ function isTaskTimeoutError(error) {
12498
+ if (!error || typeof error !== "object") return false;
12499
+ const err = error;
12500
+ return typeof err.message === "string" && (err.message.includes("timed out after") || err.message.includes("Search task timed out") || err.message.includes("Scrape task timed out") || err.message.includes("Health check timed out") || err.message.includes("[BrowserClient] Request to"));
12501
+ }
12502
+ function isServerDrainingError(error) {
12503
+ if (!error || typeof error !== "object") return false;
12504
+ const err = error;
12505
+ return typeof err.message === "string" && err.message.includes("Browser pool leader is draining");
12506
+ }
12507
+ function isPoolShutdownError(error) {
12508
+ if (!error || typeof error !== "object") return false;
12509
+ const err = error;
12510
+ return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
12511
+ // pool. Its own text says "please retry", but until it was listed here no
12512
+ // classifier matched it, so the first attempt rethrew instead of retrying —
12513
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
12514
+ // exactly when a burst of tasks is in flight and would all die together.
12515
+ err.message.includes("Worker pool is being reset") || // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
12516
+ // while it is in progress). A queued closure holding a pool captured before
12517
+ // an auto-recovery destroy dispatches into exactly this window; it is the
12518
+ // same transient swap the "being reset" string covers, not a hard fault.
12519
+ err.message.includes("Cannot execute a task on not started pool"));
12520
+ }
12521
+ var BENIGN_NAVIGATION_FAILURE = /page\.goto: (?:Timeout \d+ *ms exceeded|net::|NS_ERROR_|SEC_ERROR_|MOZILLA_PKIX_ERROR_)/;
12522
+ function isBenignScrapeFailure(error) {
12523
+ const msg2 = error instanceof Error ? error.message : String(error);
12524
+ return msg2.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
12525
+ msg2.startsWith("Fetch returned stub:") || // nav-only / near-empty page
12526
+ msg2.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
12527
+ msg2.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
12528
+ msg2.startsWith("Unsupported download:") || // attachment-served non-PDF (zip/xlsx/…) — nothing to extract
12529
+ BENIGN_NAVIGATION_FAILURE.test(msg2) || // remote TLS/DNS/timeout — the URL could not be loaded
12530
+ msg2.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
12531
+ msg2.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
12532
+ /^HTTP \d{3}\b/.test(msg2) || // remote 4xx/5xx status
12533
+ isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
12534
+ }
12535
+ var sessionCircuitBreakers = /* @__PURE__ */ new Map();
12536
+ var MAX_SESSION_BREAKERS = 256;
12537
+ var DEFAULT_BREAKER_CONFIG = {
12538
+ // Raised from 3→8: parallel search bursts of 20 queries frequently produce
12539
+ // simultaneous timeouts on slow/CF-protected sites. A threshold of 3 caused
12540
+ // the breaker to open mid-burst, blocking all subsequent requests for the round.
12541
+ failureThreshold: 8,
12542
+ // Raised from 15s→45s: enough time for one full search burst + retry window
12543
+ // to complete before the breaker attempts to half-open.
12544
+ resetTimeoutMs: 45e3,
12545
+ name: "BrowserPool",
12546
+ isTransientError: (error) => {
12547
+ if (isPoolShutdownError(error)) return false;
12548
+ if (isServerDrainingError(error)) return false;
12549
+ if (isCloudflareBlockError(error)) return false;
12550
+ if (isTaskTimeoutError(error)) return false;
12551
+ return isTransientSocketError(error);
12552
+ }
12553
+ };
12554
+ function getBrowserCircuitBreaker(sessionId) {
12555
+ let breaker2 = sessionCircuitBreakers.get(sessionId);
12556
+ if (!breaker2) {
12557
+ if (sessionCircuitBreakers.size >= MAX_SESSION_BREAKERS) {
12558
+ const oldest = sessionCircuitBreakers.keys().next().value;
12559
+ if (oldest !== void 0) sessionCircuitBreakers.delete(oldest);
12560
+ }
12561
+ breaker2 = new CircuitBreaker({ ...DEFAULT_BREAKER_CONFIG, name: `BrowserPool-${sessionId}` });
12562
+ sessionCircuitBreakers.set(sessionId, breaker2);
12563
+ }
12564
+ return breaker2;
12565
+ }
12566
+ function clearSessionCircuitBreaker(sessionId) {
12567
+ sessionCircuitBreakers.delete(sessionId);
12568
+ }
12569
+ var browserCircuitBreaker = new CircuitBreaker(DEFAULT_BREAKER_CONFIG);
12570
+
12439
12571
  // src/infrastructure/browser/browser-client.ts
12440
12572
  var CLIENT_PATIENCE_MARGIN_MS = 15e3;
12441
12573
  var CLIENT_TIMEOUT_CAP_MS = 3e5;
@@ -12597,12 +12729,16 @@ var BrowserClient = class {
12597
12729
  errorType = "unknown";
12598
12730
  }
12599
12731
  const error = new Error(errorMsg);
12600
- logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path20} failed:`, errorMsg);
12601
- errorTracker.trackError(error, {
12602
- component: "browser-manager",
12603
- operation,
12604
- errorType
12605
- });
12732
+ if (isTransientSocketError(error)) {
12733
+ logger.debug(`[BrowserClient] Transient failure talking to http://127.0.0.1:${this.port}${path20} (caller will retry): ${errorMsg}`);
12734
+ } else {
12735
+ logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path20} failed:`, errorMsg);
12736
+ errorTracker.trackError(error, {
12737
+ component: "browser-manager",
12738
+ operation,
12739
+ errorType
12740
+ });
12741
+ }
12606
12742
  reject(error);
12607
12743
  });
12608
12744
  req.write(JSON.stringify(data));
@@ -12644,89 +12780,6 @@ init_logger();
12644
12780
  init_http_body();
12645
12781
  import * as http5 from "node:http";
12646
12782
  import * as crypto5 from "node:crypto";
12647
-
12648
- // src/infrastructure/browser/browser-error-utils.ts
12649
- init_circuit_breaker();
12650
- function isTransientSocketError(error) {
12651
- if (!error || typeof error !== "object") return false;
12652
- const err = error;
12653
- return typeof err.message === "string" && (err.message.includes("ECONNREFUSED") || err.message.includes("ECONNRESET") || err.message.includes("socket hang up") || err.message.includes("EPIPE") || err.message.includes("ETIMEDOUT") || err.message.includes("timed out") || err.message.includes("pool busy") || err.message.includes("unreachable") || // WorkerPoolManager throws this during the window between forceSchedulerRestart
12654
- // clearing the scheduler reference and the old pool's 1500ms drain completing.
12655
- // It is a transient state — retry after a short delay succeeds once drain finishes.
12656
- err.message.includes("Worker pool is shutting down") || // Poolifier throws this when pool.execute() is called while the pool is being
12657
- // destroyed (e.g., during scheduler restart). Treat as transient — the pool will
12658
- // be ready for new tasks once destruction completes and a new pool is initialized.
12659
- err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // The leader answered 503 because it is shutting down after losing
12660
- // leadership. Retrying re-elects and reaches the new leader, so this is
12661
- // transient in exactly the same sense as a pool drain.
12662
- err.message.includes("Browser pool leader is draining"));
12663
- }
12664
- function isCloudflareBlockError(error) {
12665
- if (!error || typeof error !== "object") return false;
12666
- const err = error;
12667
- return typeof err.message === "string" && (err.message.includes("Fetch blocked: Cloudflare") || err.message.includes("Cloudflare challenge"));
12668
- }
12669
- function isTaskTimeoutError(error) {
12670
- if (!error || typeof error !== "object") return false;
12671
- const err = error;
12672
- return typeof err.message === "string" && (err.message.includes("timed out after") || err.message.includes("Search task timed out") || err.message.includes("Scrape task timed out") || err.message.includes("Health check timed out") || err.message.includes("[BrowserClient] Request to"));
12673
- }
12674
- function isServerDrainingError(error) {
12675
- if (!error || typeof error !== "object") return false;
12676
- const err = error;
12677
- return typeof err.message === "string" && err.message.includes("Browser pool leader is draining");
12678
- }
12679
- function isPoolShutdownError(error) {
12680
- if (!error || typeof error !== "object") return false;
12681
- const err = error;
12682
- return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
12683
- // pool. Its own text says "please retry", but until it was listed here no
12684
- // classifier matched it, so the first attempt rethrew instead of retrying —
12685
- // and auto-recovery fires only after 3 consecutive worker failures, i.e.
12686
- // exactly when a burst of tasks is in flight and would all die together.
12687
- err.message.includes("Worker pool is being reset") || // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
12688
- // while it is in progress). A queued closure holding a pool captured before
12689
- // an auto-recovery destroy dispatches into exactly this window; it is the
12690
- // same transient swap the "being reset" string covers, not a hard fault.
12691
- err.message.includes("Cannot execute a task on not started pool"));
12692
- }
12693
- var sessionCircuitBreakers = /* @__PURE__ */ new Map();
12694
- var MAX_SESSION_BREAKERS = 256;
12695
- var DEFAULT_BREAKER_CONFIG = {
12696
- // Raised from 3→8: parallel search bursts of 20 queries frequently produce
12697
- // simultaneous timeouts on slow/CF-protected sites. A threshold of 3 caused
12698
- // the breaker to open mid-burst, blocking all subsequent requests for the round.
12699
- failureThreshold: 8,
12700
- // Raised from 15s→45s: enough time for one full search burst + retry window
12701
- // to complete before the breaker attempts to half-open.
12702
- resetTimeoutMs: 45e3,
12703
- name: "BrowserPool",
12704
- isTransientError: (error) => {
12705
- if (isPoolShutdownError(error)) return false;
12706
- if (isServerDrainingError(error)) return false;
12707
- if (isCloudflareBlockError(error)) return false;
12708
- if (isTaskTimeoutError(error)) return false;
12709
- return isTransientSocketError(error);
12710
- }
12711
- };
12712
- function getBrowserCircuitBreaker(sessionId) {
12713
- let breaker2 = sessionCircuitBreakers.get(sessionId);
12714
- if (!breaker2) {
12715
- if (sessionCircuitBreakers.size >= MAX_SESSION_BREAKERS) {
12716
- const oldest = sessionCircuitBreakers.keys().next().value;
12717
- if (oldest !== void 0) sessionCircuitBreakers.delete(oldest);
12718
- }
12719
- breaker2 = new CircuitBreaker({ ...DEFAULT_BREAKER_CONFIG, name: `BrowserPool-${sessionId}` });
12720
- sessionCircuitBreakers.set(sessionId, breaker2);
12721
- }
12722
- return breaker2;
12723
- }
12724
- function clearSessionCircuitBreaker(sessionId) {
12725
- sessionCircuitBreakers.delete(sessionId);
12726
- }
12727
- var browserCircuitBreaker = new CircuitBreaker(DEFAULT_BREAKER_CONFIG);
12728
-
12729
- // src/infrastructure/browser/browser-server.ts
12730
12783
  var _authSecret = null;
12731
12784
  function getOrCreateAuthSecret() {
12732
12785
  if (!_authSecret) {
@@ -12868,6 +12921,8 @@ var BrowserServer = class {
12868
12921
  logger.warn("[BrowserServer] Request blocked by bot protection:", error instanceof Error ? error.message : String(error));
12869
12922
  } else if (isPoolShutdownError(error)) {
12870
12923
  logger.debug("[BrowserServer] Request abandoned during shutdown:", error instanceof Error ? error.message : String(error));
12924
+ } else if (isBenignScrapeFailure(error)) {
12925
+ logger.debug("[BrowserServer] Expected per-URL scrape outcome:", error instanceof Error ? error.message.split("\n")[0] : String(error));
12871
12926
  } else {
12872
12927
  logger.error("[BrowserServer] Error handling request:", error);
12873
12928
  }
@@ -18060,17 +18115,6 @@ import * as dnsCb from "node:dns";
18060
18115
  import * as net3 from "node:net";
18061
18116
  import crypto9 from "node:crypto";
18062
18117
  import { NodeHtmlMarkdown } from "node-html-markdown";
18063
- function isBenignScrapeFailure(error) {
18064
- const msg2 = error instanceof Error ? error.message : String(error);
18065
- return msg2.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
18066
- msg2.startsWith("Fetch returned stub:") || // nav-only / near-empty page
18067
- msg2.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
18068
- msg2.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
18069
- msg2.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
18070
- msg2.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
18071
- /^HTTP \d{3}\b/.test(msg2) || // remote 4xx/5xx status
18072
- isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
18073
- }
18074
18118
  function getRandomUserAgent() {
18075
18119
  try {
18076
18120
  const index = crypto9.randomInt(0, USER_AGENTS.length);
@@ -21971,9 +22015,14 @@ async function doMint(identifiers, opts) {
21971
22015
  const requestKey = opts.requestKey || process.env["PI_RESEARCH_YOUTUBE_POTOKEN_REQUEST_KEY"] || DEFAULT_REQUEST_KEY;
21972
22016
  const fetchImpl = opts.fetchImpl ?? fetch;
21973
22017
  const started = Date.now();
21974
- const bgMod = await import("bgutils-js");
21975
- const BG = bgMod.BG ?? bgMod.default;
21976
- const { buildURL: buildURL2, getHeaders } = bgMod;
22018
+ const [bgGuard, bgWebPo, bgUtils] = await Promise.all([
22019
+ import("bgutils-js/botguard"),
22020
+ import("bgutils-js/webpo"),
22021
+ import("bgutils-js/utils")
22022
+ ]);
22023
+ const { getChallenge, BotGuardClient } = bgGuard;
22024
+ const { WebPoMinter } = bgWebPo;
22025
+ const { buildURL: buildURL2, getHeaders } = bgUtils;
21977
22026
  const { JSDOM } = await import("jsdom");
21978
22027
  const dom = new JSDOM('<!DOCTYPE html><html lang="en"><body></body></html>', {
21979
22028
  url: "https://www.youtube.com/"
@@ -22004,24 +22053,24 @@ async function doMint(identifiers, opts) {
22004
22053
  };
22005
22054
  bridge();
22006
22055
  try {
22007
- const bgConfig = {
22008
- fetch: (input, init) => fetchImpl(input, {
22056
+ const challenge = await getChallenge({
22057
+ requestKey,
22058
+ fetchFunction: ((input, init) => fetchImpl(input, {
22009
22059
  ...init,
22010
22060
  signal: init?.signal ?? opts.signal
22011
- }),
22012
- globalObj: globalThis,
22013
- identifier: identifiers[0],
22014
- requestKey
22015
- };
22016
- const challenge = await BG.Challenge.create(bgConfig);
22061
+ }))
22062
+ });
22017
22063
  if (!challenge) throw new Error("BotGuard challenge unavailable");
22018
22064
  const interpreter = challenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
22019
22065
  if (!interpreter) throw new Error("BotGuard interpreter script missing");
22020
22066
  new Function(interpreter)();
22021
- const botguard = await BG.BotGuardClient.create({
22067
+ if (!challenge.program || !challenge.globalName) {
22068
+ throw new Error("BotGuard challenge missing program/globalName");
22069
+ }
22070
+ const botguard = await BotGuardClient.create({
22022
22071
  program: challenge.program,
22023
22072
  globalName: challenge.globalName,
22024
- globalObj: globalThis
22073
+ globalObject: globalThis
22025
22074
  });
22026
22075
  const webPoSignalOutput = [];
22027
22076
  const botguardResponse = await botguard.snapshot({ webPoSignalOutput });
@@ -22039,7 +22088,7 @@ async function doMint(identifiers, opts) {
22039
22088
  if (!integrityToken) {
22040
22089
  throw new Error("Integrity token rejected by attestation server (likely a non-residential IP)");
22041
22090
  }
22042
- const minter = await BG.WebPoMinter.create({ integrityToken }, webPoSignalOutput);
22091
+ const minter = await WebPoMinter.create({ integrityToken }, webPoSignalOutput);
22043
22092
  const tokens = /* @__PURE__ */ new Map();
22044
22093
  for (const id of identifiers) {
22045
22094
  tokens.set(id, await minter.mintAsWebsafeString(id));
@@ -23846,6 +23895,19 @@ ${userMessage}`);
23846
23895
  const synthesisService = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
23847
23896
  synthesisService.storeReport(this.options.researchId, "quick", result);
23848
23897
  result = synthesisService.getReport(this.options.researchId, "quick") ?? result;
23898
+ const { body: withoutPreamble, preamble } = stripReportPreamble(result);
23899
+ if (preamble) {
23900
+ logger.debug(`[QuickOrchestrator] Dropped a ${preamble.length}-char narration preamble from the report: ${preamble.slice(0, 120)}`);
23901
+ metrics.increment("research_report_preamble_stripped_total", 1, { mode: "quick" });
23902
+ result = withoutPreamble;
23903
+ }
23904
+ if (isAnalysisFreeSynthesis(result, 0)) {
23905
+ logger.warn(`[QuickOrchestrator] Report contains no complete sentence (${synthesisProseBody(result).length} char(s) of prose) \u2014 flagging it in the report`);
23906
+ metrics.increment("research_synthesis_analysis_free_total", 1, { mode: "quick" });
23907
+ result = `${result.trim()}
23908
+
23909
+ ${QUICK_THIN_REPORT_NOTICE}`;
23910
+ }
23849
23911
  result = synthesisService.ensureCitedLinks(this.options.researchId, result);
23850
23912
  const citationSource = result;
23851
23913
  const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
@@ -23956,6 +24018,7 @@ ${result}`);
23956
24018
  init_logger();
23957
24019
  init_metrics();
23958
24020
  init_constants();
24021
+ init_text_utils();
23959
24022
  init_service_interfaces();
23960
24023
  init_service_registry();
23961
24024
  init_config();
@@ -24271,6 +24334,13 @@ var DeepResearchOrchestrator = class {
24271
24334
  observer?.onEvaluationDecision?.("synthesize", finalReport, this.currentRound);
24272
24335
  }
24273
24336
  let result = finalReport.content || "";
24337
+ try {
24338
+ const parsed = JSON.parse(result);
24339
+ if (parsed && typeof parsed === "object" && typeof parsed["content"] === "string") {
24340
+ result = parsed["content"];
24341
+ }
24342
+ } catch {
24343
+ }
24274
24344
  if (!result.trim()) {
24275
24345
  if (hasReports) {
24276
24346
  logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisCheckService.getReportCount(researchId)} reports`);
@@ -24278,13 +24348,15 @@ var DeepResearchOrchestrator = class {
24278
24348
  } else {
24279
24349
  throw new Error("Research produced no report \u2014 no source material was collected.");
24280
24350
  }
24281
- }
24282
- try {
24283
- const parsed = JSON.parse(result);
24284
- if (parsed && typeof parsed === "object" && typeof parsed["content"] === "string") {
24285
- result = parsed["content"];
24286
- }
24287
- } catch {
24351
+ } else if (hasReports && isAnalysisFreeSynthesis(result, MIN_SYNTHESIS_PROSE_CHARS)) {
24352
+ const proseChars = synthesisProseBody(result).length;
24353
+ logger.warn(`[DeepOrchestrator] LLM synthesis for ${researchId} carried no analysis (${proseChars} char(s) of prose, min ${MIN_SYNTHESIS_PROSE_CHARS}) \u2014 appending ${synthesisCheckService.getReportCount(researchId)} researcher report(s) below it`);
24354
+ metrics.increment("research_synthesis_analysis_free_total", 1, { mode: "deep" });
24355
+ result = [
24356
+ result.trim(),
24357
+ SYNTHESIS_FALLBACK_NOTICE,
24358
+ synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound)
24359
+ ].join("\n\n");
24288
24360
  }
24289
24361
  const synthesisService = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
24290
24362
  result = synthesisService.ensureCitedLinks(researchId, result);
@@ -25152,7 +25224,7 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
25152
25224
  if (reportCount === 0) {
25153
25225
  synthesis += "No researcher reports were generated before the process stopped.";
25154
25226
  } else {
25155
- synthesis += `This is an automated synthesis of ${reportCount} individual researcher report(s) gathered before the process was interrupted.
25227
+ synthesis += `This is an automated compilation of ${reportCount} individual researcher report(s). The synthesis step did not produce a usable summary for this run, so the findings are presented as collected.
25156
25228
 
25157
25229
  `;
25158
25230
  synthesis += Array.from(reports.entries()).map(([id, report]) => `Researcher ${id}
@@ -62,6 +62,8 @@ Write your report immediately after scraping is complete, or as soon as a tool r
62
62
  ## Report Format
63
63
 
64
64
  {{digest_section}}
65
+ Emit the report and nothing else. No preamble, and no narration of what you are about to do — not "I have gathered extensive material…", not "Let me now synthesize my findings…". Begin at the report's own first line.
66
+
65
67
  Use [N] inline citations throughout. Write in plain prose — no markdown headings, no bullet points, no bold or italic text. Use clear paragraphs separated by blank lines. The full CITED LINKS list goes at the very end.
66
68
 
67
69
  [Topic Title]