@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 +12 -12
- package/{skills → agent-skill}/pi-research/README.md +5 -9
- package/{skills → agent-skill}/pi-research/SKILL.md +5 -4
- package/{skills → agent-skill}/pi-research/scripts/run.mjs +1 -1
- package/dist/cli.mjs +158 -129
- package/dist/thread-worker.mjs +105 -17
- package/docs/AGENT-SKILL.md +26 -40
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +52 -0
- package/docs/CONFIGURATION.md +4 -4
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +73 -10
- package/package.json +9 -21
- package/scripts/build.cjs +4 -4
- package/scripts/cleanup.cjs +8 -3
- package/scripts/prepare.cjs +1 -1
- package/src/cli.ts +1 -1
- package/src/infrastructure/browser/browser-client.ts +24 -6
- package/src/infrastructure/browser/browser-error-utils.ts +53 -0
- package/src/infrastructure/browser/browser-server.ts +9 -1
- package/src/infrastructure/browser/thread-worker-browser.ts +4 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +186 -3
- package/src/infrastructure/browser/thread-worker.mjs +105 -17
- package/src/knowledge/store-operations.ts +18 -7
- package/src/knowledge/store.ts +21 -3
- package/src/skill-install/skill-installer.ts +59 -21
- package/src/web-research/scraper-utils.ts +4 -36
- package/src/youtube/potoken.ts +47 -34
- /package/{skills → agent-skill}/pi-research/references/configuration.md +0 -0
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|

|
|
56
56
|
|
|
57
57
|
### Use cases
|
|
58
58
|
|
|
59
|
-
- Researching
|
|
60
|
-
- Researching from Claude Code, Codex,
|
|
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
|
-
- **
|
|
68
|
-
- **Cited reports rather than raw dumps.**
|
|
69
|
-
- **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything
|
|
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 (
|
|
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
|
|
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,
|
|
7
|
-
|
|
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
|
|
54
|
-
symlinks this skill into each one
|
|
55
|
-
|
|
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
|
+
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,
|
|
12
|
-
|
|
13
|
-
your harness reports for this skill (
|
|
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
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
-
|
|
10575
|
-
|
|
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
|
-
|
|
12592
|
-
|
|
12593
|
-
|
|
12594
|
-
|
|
12595
|
-
|
|
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
|
|
21966
|
-
|
|
21967
|
-
|
|
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
|
|
21999
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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));
|