@lincoln504/pi-research 1.5.2 → 1.6.0

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
 
@@ -37,45 +37,45 @@ As a [Pi](https://github.com/earendil-works/pi) Coding Agent extension:
37
37
  pi install npm:@lincoln504/pi-research
38
38
  ```
39
39
 
40
- Standalone, as an [agent skill](docs/AGENT-SKILL.md) for Claude Code, Codex, and OpenClaw:
40
+ Standalone, as an [agent skill](docs/AGENT-SKILL.md) for Claude Code, Codex, and other skills-compatible agents:
41
41
 
42
42
  ```bash
43
43
  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, OpenClaw, 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
 
@@ -3,8 +3,8 @@
3
3
  A portable [Agent Skill](https://agentskills.io/specification) that gives any
4
4
  coding agent multi-agent web research by driving the
5
5
  [pi-research](https://github.com/Lincoln504/pi-research) engine. It is the same
6
- engine the `pi` extension uses, packaged as a skill so Claude, Cursor,
7
- Codex, OpenClaw, and other Agent-Skills-compatible tools can use it.
6
+ engine the `pi` extension uses, packaged as a skill so Claude, Codex, and other
7
+ Agent-Skills-compatible tools can use it.
8
8
 
9
9
  ### What it exposes
10
10
 
@@ -50,9 +50,9 @@ node "<skill_dir>/scripts/run.mjs" status # verify detection
50
50
  ### Install the skill
51
51
 
52
52
  Recommended. From the pi extension, run `/research-config` and choose
53
- Install in External Agents. It detects Claude, Codex, and OpenClaw and
54
- symlinks this skill into each one that is installed. Remove from External Agents
55
- removes those symlinks.
53
+ Install in External Agents. It detects the supported agents set up on this machine
54
+ and symlinks this skill into each one. Remove from External Agents removes those
55
+ symlinks.
56
56
 
57
57
  Uninstalling the npm package does **not** remove them: npm 7 and newer do not run a
58
58
  package's own `preuninstall` script (verified against npm 11). Use Remove from
@@ -60,9 +60,6 @@ External Agents first, or run `node scripts/cleanup.cjs` from the package direct
60
60
  otherwise the symlinks, `~/.pi/research/state` and the `~/.cache/pi-research` model
61
61
  cache are all left behind.
62
62
 
63
- Cursor is not auto-installed — it has no global skills directory and only reads
64
- project-level `.cursor/skills/`. Symlink it per-project instead (see the table).
65
-
66
63
  Manual. Or symlink this `pi-research/` directory into your agent's skills folder:
67
64
 
68
65
  | Agent | Location |
@@ -71,7 +68,6 @@ Manual. Or symlink this `pi-research/` directory into your agent's skills folder
71
68
  | Claude (project) | `<project>/.claude/skills/pi-research/` |
72
69
  | Codex CLI (personal) | `~/.codex/skills/pi-research/` |
73
70
  | Codex CLI (project) | `<project>/.codex/skills/pi-research/` |
74
- | Cursor (project-only) | `<project>/.cursor/skills/pi-research/` |
75
71
  | OpenClaw (personal) | `~/.openclaw/skills/pi-research/` |
76
72
 
77
73
  Then ask your agent to pi-research something — its skill system matches the
@@ -2,15 +2,16 @@
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.2", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.6.0", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
9
9
  # pi-research skill
10
10
 
11
- Run via your shell tool — the **Bash** tool in Claude Code, the **`exec`** tool in
12
- OpenClaw. `<SKILL_DIR>` is this skill's own directory: substitute the absolute path
13
- your harness reports for this skill (in OpenClaw, `{baseDir}` resolves to it).
11
+ Run via your shell tool — the **Bash** tool in Claude Code, whatever equivalent
12
+ your harness exposes elsewhere. `<SKILL_DIR>` is this skill's own directory:
13
+ substitute the absolute path your harness reports for this skill (some harnesses
14
+ expose it as `{baseDir}`).
14
15
 
15
16
  ```
16
17
  node "<SKILL_DIR>/scripts/run.mjs" research "<query>" --depth 1
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // skills/pi-research/scripts/run.ts
3
+ // agent-skill/pi-research/scripts/run.ts
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import { homedir, constants as osConstants } from "node:os";
package/dist/cli.mjs CHANGED
@@ -7273,11 +7273,10 @@ function assertValidEmbeddingVectors(vectors, expectedCount, declaredDim) {
7273
7273
  }
7274
7274
  }
7275
7275
  async function addDocumentsToStore(table, docs, embedder, isClosing, workspace, isGlobal) {
7276
- if (docs.length === 0) return;
7276
+ if (docs.length === 0) return "empty";
7277
7277
  if (isClosing()) {
7278
7278
  logger.warn("[store] Ignoring addDocuments during close");
7279
- metrics.increment("knowledge_store_add_documents_total", 1, { status: "ignored_closing" });
7280
- return;
7279
+ return "ignored_closing";
7281
7280
  }
7282
7281
  const startTime = Date.now();
7283
7282
  try {
@@ -7297,14 +7296,13 @@ async function addDocumentsToStore(table, docs, embedder, isClosing, workspace,
7297
7296
  await table.add(data);
7298
7297
  const duration = Date.now() - startTime;
7299
7298
  metrics.observe("knowledge_store_add_documents_duration_ms", duration);
7300
- metrics.increment("knowledge_store_add_documents_total", 1, { status: "success" });
7301
7299
  metrics.increment("knowledge_store_chunks_added_total", docs.length);
7302
7300
  logger.log(`[store] Added ${docs.length} chunk(s) for ${docs[0]?.url} [workspace=${workspace}, global=${isGlobal}]`);
7301
+ return "written";
7303
7302
  } catch (err) {
7304
7303
  const duration = Date.now() - startTime;
7305
7304
  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);
7305
+ logger.debug(`[store] add attempt failed after ${duration}ms: ${err instanceof Error ? err.message : String(err)}`);
7308
7306
  throw err;
7309
7307
  }
7310
7308
  }
@@ -7876,18 +7874,19 @@ var init_store = __esm({
7876
7874
  try {
7877
7875
  if (this.isClosing) {
7878
7876
  logger.warn(`[store] Skipping addDocuments \u2014 store is closing`);
7877
+ metrics.increment("knowledge_store_add_documents_total", 1, { status: "ignored_closing" });
7879
7878
  return;
7880
7879
  }
7881
7880
  let table = await this.getFreshTable();
7882
7881
  const workspace = this.getWorkspace();
7883
- await this.withEmbedderReconnect(async (embedder) => {
7882
+ const outcome = await this.withEmbedderReconnect(async (embedder) => {
7884
7883
  let retryCount = 0;
7885
7884
  const MAX_RETRIES = 5;
7886
7885
  const BASE_DELAY = 100;
7887
7886
  while (retryCount <= MAX_RETRIES) {
7888
7887
  try {
7889
7888
  const isGlobal = this.options.knowledgeMode === "global";
7890
- await addDocumentsToStore(
7889
+ return await addDocumentsToStore(
7891
7890
  table,
7892
7891
  docs,
7893
7892
  embedder,
@@ -7895,7 +7894,6 @@ var init_store = __esm({
7895
7894
  workspace,
7896
7895
  isGlobal
7897
7896
  );
7898
- return;
7899
7897
  } catch (err) {
7900
7898
  if (isLanceCommitConflict(err)) {
7901
7899
  retryCount++;
@@ -7909,7 +7907,17 @@ var init_store = __esm({
7909
7907
  throw err;
7910
7908
  }
7911
7909
  }
7910
+ throw new Error("[store] addDocuments retry loop exited without result");
7912
7911
  });
7912
+ if (outcome !== "empty") {
7913
+ metrics.increment("knowledge_store_add_documents_total", 1, {
7914
+ status: outcome === "ignored_closing" ? "ignored_closing" : "success"
7915
+ });
7916
+ }
7917
+ } catch (err) {
7918
+ metrics.increment("knowledge_store_add_documents_total", 1, { status: "error" });
7919
+ logger.error("[store] Failed to add documents:", err);
7920
+ throw err;
7913
7921
  } finally {
7914
7922
  this.activeWrites.delete(writePromise);
7915
7923
  writeResolve();
@@ -10157,7 +10165,8 @@ function resolveSkillSourceDir(explicit) {
10157
10165
  if (!start) continue;
10158
10166
  let dir = start;
10159
10167
  for (let i = 0; i < 8; i++) {
10160
- candidates.push(path18.join(dir, "skills", SKILL_NAME));
10168
+ candidates.push(path18.join(dir, SKILL_SOURCE_DIR, SKILL_NAME));
10169
+ candidates.push(path18.join(dir, LEGACY_SKILL_SOURCE_DIR, SKILL_NAME));
10161
10170
  const parent = path18.dirname(dir);
10162
10171
  if (parent === dir) break;
10163
10172
  dir = parent;
@@ -10169,7 +10178,7 @@ function resolveSkillSourceDir(explicit) {
10169
10178
  } catch {
10170
10179
  }
10171
10180
  }
10172
- throw new Error("Could not locate the bundled research skill (skills/pi-research/SKILL.md). Set PI_RESEARCH_SKILL_DIR to its path.");
10181
+ throw new Error("Could not locate the bundled research skill (agent-skill/pi-research/SKILL.md). Set PI_RESEARCH_SKILL_DIR to its path.");
10173
10182
  }
10174
10183
  function getManifestPath(opts) {
10175
10184
  return path18.join(homeDir(opts), ".pi", "research", "installed-skills.json");
@@ -10213,7 +10222,7 @@ function isOwnedSymlink(targetPath, expectedSource) {
10213
10222
  if (!st.isSymbolicLink()) return false;
10214
10223
  const dest = path18.resolve(path18.dirname(targetPath), fs16.readlinkSync(targetPath));
10215
10224
  if (expectedSource && path18.resolve(expectedSource) === dest) return true;
10216
- return /[/\\]pi-research[/\\]skills[/\\]pi-research$/.test(dest);
10225
+ return OWNED_SYMLINK_DEST.test(dest);
10217
10226
  } catch {
10218
10227
  return false;
10219
10228
  }
@@ -10564,20 +10573,28 @@ function copyDir(src, dest) {
10564
10573
  fs16.copyFileSync(src, dest);
10565
10574
  }
10566
10575
  }
10567
- var HARNESSES, SKILL_NAME, PACKAGE_NAME, SKILL_AGENT_TARGETS, manifestTmpSeq, isTarget;
10576
+ var HARNESSES, SKILL_NAME, PACKAGE_NAME, SKILL_SOURCE_DIR, LEGACY_SKILL_SOURCE_DIR, OWNED_SYMLINK_DEST, SKILL_AGENT_TARGETS, manifestTmpSeq, isTarget;
10568
10577
  var init_skill_installer = __esm({
10569
10578
  "src/skill-install/skill-installer.ts"() {
10570
10579
  "use strict";
10571
10580
  HARNESSES = [
10572
10581
  { id: "claude", label: "Claude", baseDir: ".claude", skillsDir: path18.join(".claude", "skills"), confidence: "confirmed" },
10573
10582
  { id: "openclaw", label: "OpenClaw", baseDir: ".openclaw", skillsDir: path18.join(".openclaw", "skills"), confidence: "confirmed", note: "OpenClaw reads ~/.openclaw/skills as a managed skill root and accepts symlinked skill folders (docs.openclaw.ai/tools/skills)." },
10574
- { id: "pi", label: "pi", baseDir: ".pi", skillsDir: path18.join(".pi", "skills"), confidence: "confirmed" },
10575
- { id: "cursor", label: "Cursor", baseDir: ".cursor", skillsDir: path18.join(".cursor", "skills"), confidence: "partial", note: "Cursor project-level skills are documented; the global ~/.cursor/skills path is community-reported." },
10583
+ // pi's own skill root is under its agent dir (~/.pi/agent/skills), NOT ~/.pi/skills.
10584
+ // Listed for DETECTION only — pi is never an install target (see SKILL_AGENT_TARGETS):
10585
+ // the host already exposes research as a native tool, so a skill there would just
10586
+ // duplicate it through a slower subprocess.
10587
+ { id: "pi", label: "pi", baseDir: ".pi", skillsDir: path18.join(".pi", "agent", "skills"), confidence: "confirmed" },
10576
10588
  { id: "codex", label: "OpenAI Codex CLI", baseDir: ".codex", skillsDir: path18.join(".codex", "skills"), confidence: "unverified", note: "Codex skills support is emerging; path not confirmed by official docs." },
10577
10589
  { id: "agents", label: "Cross-tool (~/.agents/skills)", baseDir: ".agents", skillsDir: path18.join(".agents", "skills"), confidence: "unverified", note: "Proposed Universal Agents convention; not yet a ratified standard." }
10578
10590
  ];
10579
10591
  SKILL_NAME = "pi-research";
10580
10592
  PACKAGE_NAME = "@lincoln504/pi-research";
10593
+ SKILL_SOURCE_DIR = "agent-skill";
10594
+ LEGACY_SKILL_SOURCE_DIR = "skills";
10595
+ OWNED_SYMLINK_DEST = new RegExp(
10596
+ `[/\\\\]${SKILL_NAME}[/\\\\](?:${SKILL_SOURCE_DIR}|${LEGACY_SKILL_SOURCE_DIR})[/\\\\]${SKILL_NAME}$`
10597
+ );
10581
10598
  SKILL_AGENT_TARGETS = ["claude", "codex", "openclaw"];
10582
10599
  manifestTmpSeq = 0;
10583
10600
  isTarget = (id) => SKILL_AGENT_TARGETS.includes(id);
@@ -12427,6 +12444,101 @@ function getClientAgent() {
12427
12444
  return clientAgent;
12428
12445
  }
12429
12446
 
12447
+ // src/infrastructure/browser/browser-error-utils.ts
12448
+ init_circuit_breaker();
12449
+ function isTransientSocketError(error) {
12450
+ if (!error || typeof error !== "object") return false;
12451
+ const err = error;
12452
+ 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
12453
+ // clearing the scheduler reference and the old pool's 1500ms drain completing.
12454
+ // It is a transient state — retry after a short delay succeeds once drain finishes.
12455
+ err.message.includes("Worker pool is shutting down") || // Poolifier throws this when pool.execute() is called while the pool is being
12456
+ // destroyed (e.g., during scheduler restart). Treat as transient — the pool will
12457
+ // be ready for new tasks once destruction completes and a new pool is initialized.
12458
+ 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
12459
+ // leadership. Retrying re-elects and reaches the new leader, so this is
12460
+ // transient in exactly the same sense as a pool drain.
12461
+ err.message.includes("Browser pool leader is draining"));
12462
+ }
12463
+ function isCloudflareBlockError(error) {
12464
+ if (!error || typeof error !== "object") return false;
12465
+ const err = error;
12466
+ return typeof err.message === "string" && (err.message.includes("Fetch blocked: Cloudflare") || err.message.includes("Cloudflare challenge"));
12467
+ }
12468
+ function isTaskTimeoutError(error) {
12469
+ if (!error || typeof error !== "object") return false;
12470
+ const err = error;
12471
+ 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"));
12472
+ }
12473
+ function isServerDrainingError(error) {
12474
+ if (!error || typeof error !== "object") return false;
12475
+ const err = error;
12476
+ return typeof err.message === "string" && err.message.includes("Browser pool leader is draining");
12477
+ }
12478
+ function isPoolShutdownError(error) {
12479
+ if (!error || typeof error !== "object") return false;
12480
+ const err = error;
12481
+ 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
12482
+ // pool. Its own text says "please retry", but until it was listed here no
12483
+ // classifier matched it, so the first attempt rethrew instead of retrying —
12484
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
12485
+ // exactly when a burst of tasks is in flight and would all die together.
12486
+ err.message.includes("Worker pool is being reset") || // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
12487
+ // while it is in progress). A queued closure holding a pool captured before
12488
+ // an auto-recovery destroy dispatches into exactly this window; it is the
12489
+ // same transient swap the "being reset" string covers, not a hard fault.
12490
+ err.message.includes("Cannot execute a task on not started pool"));
12491
+ }
12492
+ var BENIGN_NAVIGATION_FAILURE = /page\.goto: (?:Timeout \d+ *ms exceeded|net::|NS_ERROR_|SEC_ERROR_|MOZILLA_PKIX_ERROR_)/;
12493
+ function isBenignScrapeFailure(error) {
12494
+ const msg2 = error instanceof Error ? error.message : String(error);
12495
+ return msg2.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
12496
+ msg2.startsWith("Fetch returned stub:") || // nav-only / near-empty page
12497
+ msg2.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
12498
+ msg2.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
12499
+ msg2.startsWith("Unsupported download:") || // attachment-served non-PDF (zip/xlsx/…) — nothing to extract
12500
+ BENIGN_NAVIGATION_FAILURE.test(msg2) || // remote TLS/DNS/timeout — the URL could not be loaded
12501
+ msg2.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
12502
+ msg2.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
12503
+ /^HTTP \d{3}\b/.test(msg2) || // remote 4xx/5xx status
12504
+ isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
12505
+ }
12506
+ var sessionCircuitBreakers = /* @__PURE__ */ new Map();
12507
+ var MAX_SESSION_BREAKERS = 256;
12508
+ var DEFAULT_BREAKER_CONFIG = {
12509
+ // Raised from 3→8: parallel search bursts of 20 queries frequently produce
12510
+ // simultaneous timeouts on slow/CF-protected sites. A threshold of 3 caused
12511
+ // the breaker to open mid-burst, blocking all subsequent requests for the round.
12512
+ failureThreshold: 8,
12513
+ // Raised from 15s→45s: enough time for one full search burst + retry window
12514
+ // to complete before the breaker attempts to half-open.
12515
+ resetTimeoutMs: 45e3,
12516
+ name: "BrowserPool",
12517
+ isTransientError: (error) => {
12518
+ if (isPoolShutdownError(error)) return false;
12519
+ if (isServerDrainingError(error)) return false;
12520
+ if (isCloudflareBlockError(error)) return false;
12521
+ if (isTaskTimeoutError(error)) return false;
12522
+ return isTransientSocketError(error);
12523
+ }
12524
+ };
12525
+ function getBrowserCircuitBreaker(sessionId) {
12526
+ let breaker2 = sessionCircuitBreakers.get(sessionId);
12527
+ if (!breaker2) {
12528
+ if (sessionCircuitBreakers.size >= MAX_SESSION_BREAKERS) {
12529
+ const oldest = sessionCircuitBreakers.keys().next().value;
12530
+ if (oldest !== void 0) sessionCircuitBreakers.delete(oldest);
12531
+ }
12532
+ breaker2 = new CircuitBreaker({ ...DEFAULT_BREAKER_CONFIG, name: `BrowserPool-${sessionId}` });
12533
+ sessionCircuitBreakers.set(sessionId, breaker2);
12534
+ }
12535
+ return breaker2;
12536
+ }
12537
+ function clearSessionCircuitBreaker(sessionId) {
12538
+ sessionCircuitBreakers.delete(sessionId);
12539
+ }
12540
+ var browserCircuitBreaker = new CircuitBreaker(DEFAULT_BREAKER_CONFIG);
12541
+
12430
12542
  // src/infrastructure/browser/browser-client.ts
12431
12543
  var CLIENT_PATIENCE_MARGIN_MS = 15e3;
12432
12544
  var CLIENT_TIMEOUT_CAP_MS = 3e5;
@@ -12588,12 +12700,16 @@ var BrowserClient = class {
12588
12700
  errorType = "unknown";
12589
12701
  }
12590
12702
  const error = new Error(errorMsg);
12591
- logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path20} failed:`, errorMsg);
12592
- errorTracker.trackError(error, {
12593
- component: "browser-manager",
12594
- operation,
12595
- errorType
12596
- });
12703
+ if (isTransientSocketError(error)) {
12704
+ logger.debug(`[BrowserClient] Transient failure talking to http://127.0.0.1:${this.port}${path20} (caller will retry): ${errorMsg}`);
12705
+ } else {
12706
+ logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path20} failed:`, errorMsg);
12707
+ errorTracker.trackError(error, {
12708
+ component: "browser-manager",
12709
+ operation,
12710
+ errorType
12711
+ });
12712
+ }
12597
12713
  reject(error);
12598
12714
  });
12599
12715
  req.write(JSON.stringify(data));
@@ -12635,89 +12751,6 @@ init_logger();
12635
12751
  init_http_body();
12636
12752
  import * as http5 from "node:http";
12637
12753
  import * as crypto5 from "node:crypto";
12638
-
12639
- // src/infrastructure/browser/browser-error-utils.ts
12640
- init_circuit_breaker();
12641
- function isTransientSocketError(error) {
12642
- if (!error || typeof error !== "object") return false;
12643
- const err = error;
12644
- 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
12645
- // clearing the scheduler reference and the old pool's 1500ms drain completing.
12646
- // It is a transient state — retry after a short delay succeeds once drain finishes.
12647
- err.message.includes("Worker pool is shutting down") || // Poolifier throws this when pool.execute() is called while the pool is being
12648
- // destroyed (e.g., during scheduler restart). Treat as transient — the pool will
12649
- // be ready for new tasks once destruction completes and a new pool is initialized.
12650
- 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
12651
- // leadership. Retrying re-elects and reaches the new leader, so this is
12652
- // transient in exactly the same sense as a pool drain.
12653
- err.message.includes("Browser pool leader is draining"));
12654
- }
12655
- function isCloudflareBlockError(error) {
12656
- if (!error || typeof error !== "object") return false;
12657
- const err = error;
12658
- return typeof err.message === "string" && (err.message.includes("Fetch blocked: Cloudflare") || err.message.includes("Cloudflare challenge"));
12659
- }
12660
- function isTaskTimeoutError(error) {
12661
- if (!error || typeof error !== "object") return false;
12662
- const err = error;
12663
- 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"));
12664
- }
12665
- function isServerDrainingError(error) {
12666
- if (!error || typeof error !== "object") return false;
12667
- const err = error;
12668
- return typeof err.message === "string" && err.message.includes("Browser pool leader is draining");
12669
- }
12670
- function isPoolShutdownError(error) {
12671
- if (!error || typeof error !== "object") return false;
12672
- const err = error;
12673
- 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
12674
- // pool. Its own text says "please retry", but until it was listed here no
12675
- // classifier matched it, so the first attempt rethrew instead of retrying —
12676
- // and auto-recovery fires only after 3 consecutive worker failures, i.e.
12677
- // exactly when a burst of tasks is in flight and would all die together.
12678
- err.message.includes("Worker pool is being reset") || // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
12679
- // while it is in progress). A queued closure holding a pool captured before
12680
- // an auto-recovery destroy dispatches into exactly this window; it is the
12681
- // same transient swap the "being reset" string covers, not a hard fault.
12682
- err.message.includes("Cannot execute a task on not started pool"));
12683
- }
12684
- var sessionCircuitBreakers = /* @__PURE__ */ new Map();
12685
- var MAX_SESSION_BREAKERS = 256;
12686
- var DEFAULT_BREAKER_CONFIG = {
12687
- // Raised from 3→8: parallel search bursts of 20 queries frequently produce
12688
- // simultaneous timeouts on slow/CF-protected sites. A threshold of 3 caused
12689
- // the breaker to open mid-burst, blocking all subsequent requests for the round.
12690
- failureThreshold: 8,
12691
- // Raised from 15s→45s: enough time for one full search burst + retry window
12692
- // to complete before the breaker attempts to half-open.
12693
- resetTimeoutMs: 45e3,
12694
- name: "BrowserPool",
12695
- isTransientError: (error) => {
12696
- if (isPoolShutdownError(error)) return false;
12697
- if (isServerDrainingError(error)) return false;
12698
- if (isCloudflareBlockError(error)) return false;
12699
- if (isTaskTimeoutError(error)) return false;
12700
- return isTransientSocketError(error);
12701
- }
12702
- };
12703
- function getBrowserCircuitBreaker(sessionId) {
12704
- let breaker2 = sessionCircuitBreakers.get(sessionId);
12705
- if (!breaker2) {
12706
- if (sessionCircuitBreakers.size >= MAX_SESSION_BREAKERS) {
12707
- const oldest = sessionCircuitBreakers.keys().next().value;
12708
- if (oldest !== void 0) sessionCircuitBreakers.delete(oldest);
12709
- }
12710
- breaker2 = new CircuitBreaker({ ...DEFAULT_BREAKER_CONFIG, name: `BrowserPool-${sessionId}` });
12711
- sessionCircuitBreakers.set(sessionId, breaker2);
12712
- }
12713
- return breaker2;
12714
- }
12715
- function clearSessionCircuitBreaker(sessionId) {
12716
- sessionCircuitBreakers.delete(sessionId);
12717
- }
12718
- var browserCircuitBreaker = new CircuitBreaker(DEFAULT_BREAKER_CONFIG);
12719
-
12720
- // src/infrastructure/browser/browser-server.ts
12721
12754
  var _authSecret = null;
12722
12755
  function getOrCreateAuthSecret() {
12723
12756
  if (!_authSecret) {
@@ -12859,6 +12892,8 @@ var BrowserServer = class {
12859
12892
  logger.warn("[BrowserServer] Request blocked by bot protection:", error instanceof Error ? error.message : String(error));
12860
12893
  } else if (isPoolShutdownError(error)) {
12861
12894
  logger.debug("[BrowserServer] Request abandoned during shutdown:", error instanceof Error ? error.message : String(error));
12895
+ } else if (isBenignScrapeFailure(error)) {
12896
+ logger.debug("[BrowserServer] Expected per-URL scrape outcome:", error instanceof Error ? error.message.split("\n")[0] : String(error));
12862
12897
  } else {
12863
12898
  logger.error("[BrowserServer] Error handling request:", error);
12864
12899
  }
@@ -18051,17 +18086,6 @@ import * as dnsCb from "node:dns";
18051
18086
  import * as net3 from "node:net";
18052
18087
  import crypto9 from "node:crypto";
18053
18088
  import { NodeHtmlMarkdown } from "node-html-markdown";
18054
- function isBenignScrapeFailure(error) {
18055
- const msg2 = error instanceof Error ? error.message : String(error);
18056
- return msg2.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
18057
- msg2.startsWith("Fetch returned stub:") || // nav-only / near-empty page
18058
- msg2.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
18059
- msg2.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
18060
- msg2.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
18061
- msg2.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
18062
- /^HTTP \d{3}\b/.test(msg2) || // remote 4xx/5xx status
18063
- isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
18064
- }
18065
18089
  function getRandomUserAgent() {
18066
18090
  try {
18067
18091
  const index = crypto9.randomInt(0, USER_AGENTS.length);
@@ -21962,9 +21986,14 @@ async function doMint(identifiers, opts) {
21962
21986
  const requestKey = opts.requestKey || process.env["PI_RESEARCH_YOUTUBE_POTOKEN_REQUEST_KEY"] || DEFAULT_REQUEST_KEY;
21963
21987
  const fetchImpl = opts.fetchImpl ?? fetch;
21964
21988
  const started = Date.now();
21965
- const bgMod = await import("bgutils-js");
21966
- const BG = bgMod.BG ?? bgMod.default;
21967
- const { buildURL: buildURL2, getHeaders } = bgMod;
21989
+ const [bgGuard, bgWebPo, bgUtils] = await Promise.all([
21990
+ import("bgutils-js/botguard"),
21991
+ import("bgutils-js/webpo"),
21992
+ import("bgutils-js/utils")
21993
+ ]);
21994
+ const { getChallenge, BotGuardClient } = bgGuard;
21995
+ const { WebPoMinter } = bgWebPo;
21996
+ const { buildURL: buildURL2, getHeaders } = bgUtils;
21968
21997
  const { JSDOM } = await import("jsdom");
21969
21998
  const dom = new JSDOM('<!DOCTYPE html><html lang="en"><body></body></html>', {
21970
21999
  url: "https://www.youtube.com/"
@@ -21995,24 +22024,24 @@ async function doMint(identifiers, opts) {
21995
22024
  };
21996
22025
  bridge();
21997
22026
  try {
21998
- const bgConfig = {
21999
- fetch: (input, init) => fetchImpl(input, {
22027
+ const challenge = await getChallenge({
22028
+ requestKey,
22029
+ fetchFunction: ((input, init) => fetchImpl(input, {
22000
22030
  ...init,
22001
22031
  signal: init?.signal ?? opts.signal
22002
- }),
22003
- globalObj: globalThis,
22004
- identifier: identifiers[0],
22005
- requestKey
22006
- };
22007
- const challenge = await BG.Challenge.create(bgConfig);
22032
+ }))
22033
+ });
22008
22034
  if (!challenge) throw new Error("BotGuard challenge unavailable");
22009
22035
  const interpreter = challenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
22010
22036
  if (!interpreter) throw new Error("BotGuard interpreter script missing");
22011
22037
  new Function(interpreter)();
22012
- const botguard = await BG.BotGuardClient.create({
22038
+ if (!challenge.program || !challenge.globalName) {
22039
+ throw new Error("BotGuard challenge missing program/globalName");
22040
+ }
22041
+ const botguard = await BotGuardClient.create({
22013
22042
  program: challenge.program,
22014
22043
  globalName: challenge.globalName,
22015
- globalObj: globalThis
22044
+ globalObject: globalThis
22016
22045
  });
22017
22046
  const webPoSignalOutput = [];
22018
22047
  const botguardResponse = await botguard.snapshot({ webPoSignalOutput });
@@ -22030,7 +22059,7 @@ async function doMint(identifiers, opts) {
22030
22059
  if (!integrityToken) {
22031
22060
  throw new Error("Integrity token rejected by attestation server (likely a non-residential IP)");
22032
22061
  }
22033
- const minter = await BG.WebPoMinter.create({ integrityToken }, webPoSignalOutput);
22062
+ const minter = await WebPoMinter.create({ integrityToken }, webPoSignalOutput);
22034
22063
  const tokens = /* @__PURE__ */ new Map();
22035
22064
  for (const id of identifiers) {
22036
22065
  tokens.set(id, await minter.mintAsWebsafeString(id));