@lincoln504/pi-research 1.7.8 → 1.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +5 -1
  2. package/agent-skill/pi-research/README.md +3 -3
  3. package/agent-skill/pi-research/SKILL.md +1 -1
  4. package/dist/cli-bin.mjs +3 -0
  5. package/dist/cli.mjs +673 -383
  6. package/dist/pdf-extract-worker.mjs +1 -1
  7. package/dist/thread-worker.mjs +6 -6
  8. package/docs/AGENT-SKILL.md +6 -4
  9. package/docs/ARCHITECTURE.md +64 -50
  10. package/docs/CHANGELOG.md +41 -0
  11. package/docs/CONFIGURATION.md +2 -2
  12. package/docs/PI-EXTENSION.md +4 -0
  13. package/docs/SDK.md +10 -7
  14. package/docs/THIRD-PARTY-NOTICES.md +11 -7
  15. package/package.json +10 -8
  16. package/scripts/camoufox-layout.cjs +455 -0
  17. package/scripts/cleanup.cjs +72 -29
  18. package/scripts/setup.cjs +73 -111
  19. package/src/cli-bin.mjs +3 -0
  20. package/src/cli.ts +12 -13
  21. package/src/core/pi-version.ts +9 -1
  22. package/src/healthcheck/index.ts +1 -1
  23. package/src/infrastructure/browser/browser-cleanup.ts +1 -1
  24. package/src/infrastructure/browser/browser-error-utils.ts +5 -4
  25. package/src/infrastructure/browser/camoufox-layout.ts +450 -0
  26. package/src/infrastructure/browser/config.ts +46 -103
  27. package/src/infrastructure/browser/ensure-browser.ts +90 -52
  28. package/src/infrastructure/browser/thread-worker-browser.ts +38 -42
  29. package/src/infrastructure/browser/thread-worker.mjs +6 -6
  30. package/src/infrastructure/knowledge-store-service.ts +6 -6
  31. package/src/orchestration/researcher-executor.ts +47 -1
  32. package/src/tui/research-health.ts +1 -1
  33. package/src/utils/log-utils.ts +1 -1
  34. package/src/web-research/browser-search.ts +8 -10
  35. package/src/web-research/pdf-extract-worker.mjs +1 -1
  36. package/src/web-research/scraper-utils.ts +11 -2
  37. package/src/web-research/web-scraper.ts +1 -1
@@ -13,28 +13,28 @@
13
13
  *
14
14
  * Non-regression guarantee for the other install paths: when the browser is
15
15
  * already present — which it always is after the pi-extension / plain-npm
16
- * postinstall fetch — this is a cheap `existsSync` check that returns
17
- * immediately and changes nothing. It only ever does work when the binary is
18
- * genuinely absent.
16
+ * postinstall fetch — this is a cheap layout check that returns
17
+ * immediately and changes nothing. It only ever does work when the paired
18
+ * browser build is genuinely absent (or a legacy flat install needs migrating).
19
19
  */
20
20
 
21
21
  import { spawn } from 'node:child_process';
22
22
  import { createRequire } from 'node:module';
23
23
  import { createHash } from 'node:crypto';
24
- import { existsSync, readdirSync, statSync, fstatSync, openSync, closeSync, rmSync } from 'node:fs';
24
+ import { existsSync, fstatSync, openSync, closeSync, rmSync, readFileSync, createWriteStream } from 'node:fs';
25
25
  import { join, dirname } from 'node:path';
26
- import { tmpdir } from 'node:os';
26
+ import { tmpdir, platform, homedir } from 'node:os';
27
27
  import { logger } from '../../logger.ts';
28
28
  import { getCamoufoxBinaryPath } from './config.ts';
29
+ import { resolveInstall, isUsable, provisionBrowser, loadPin, findPackageRoot, type BrowserPin } from './camoufox-layout.ts';
29
30
 
30
- /** Bound the ~500MB camoufox-js 0.12 browser download (measured 484MB; the
31
- * 0.10 pin was ~100MB and the old bound assumed that) so a stalled network
32
- * fails eventually instead of hanging forever, while a slow-but-working link
33
- * can actually finish. */
31
+ /** Bound the ~1.3GB Camoufox browser download (the paired build is ~1.29 GB) so a stalled
32
+ * network fails eventually instead of hanging forever, while a slow-but-working link can
33
+ * actually finish. */
34
34
  const FETCH_TIMEOUT_MS = 45 * 60 * 1000;
35
35
  /** A lock older than this is considered stale (crashed mid-fetch) and may be stolen.
36
36
  * Must exceed FETCH_TIMEOUT_MS: stealing a LIVE fetcher's lock mid-download would
37
- * start a second 500MB download beside the first. */
37
+ * start a second download beside the first. */
38
38
  const STALE_LOCK_MS = 55 * 60 * 1000;
39
39
  /** When another process holds the fetch lock, poll this long for the browser to appear.
40
40
  * Spans a full FETCH_TIMEOUT_MS fetch, so a legitimate slow peer is waited out rather
@@ -45,68 +45,104 @@ const POLL_INTERVAL_MS = 2000;
45
45
  /** In-process dedupe: concurrent pool inits / scrapers share one fetch. */
46
46
  let inFlight: Promise<void> | null = null;
47
47
 
48
+ /** The paired browser build this installed @camoufox/camoufox was released with. */
49
+ function currentPin(): BrowserPin | null {
50
+ const root = findPackageRoot(import.meta.url);
51
+ return root ? loadPin(root) : null;
52
+ }
53
+
48
54
  /**
49
- * True when a camoufox browser binary is actually present (cache dir exists and
50
- * contains at least one version subdirectory) — mirrors the check in setup.cjs.
55
+ * True when a usable Camoufox browser is installed: the new layout's compat flag plus
56
+ * the build this launcher is paired with (any build when the user chose one explicitly).
57
+ * A bare "directory exists" is NOT enough: a legacy flat camoufox-js install is a
58
+ * non-empty directory too, and the launcher deletes it on first use.
51
59
  */
52
60
  export function isBrowserBinaryPresent(): boolean {
53
61
  try {
54
- const cacheDir = getCamoufoxBinaryPath();
55
- if (!existsSync(cacheDir)) return false;
56
- return readdirSync(cacheDir).some((entry) => {
57
- try {
58
- return statSync(join(cacheDir, entry)).isDirectory();
59
- } catch {
60
- return false;
61
- }
62
- });
62
+ return isUsable(getCamoufoxBinaryPath(), currentPin());
63
63
  } catch {
64
64
  return false;
65
65
  }
66
66
  }
67
67
 
68
- /** Resolve the camoufox-js CLI bin, mirroring scripts/setup.cjs (handles hoisting). */
69
- function resolveCamoufoxBin(): string | null {
68
+ /** Resolve the `camoufox` CLI entry (JS file) of @camoufox/camoufox, mirroring scripts/setup.cjs. */
69
+ function resolveCamoufoxCli(): string | null {
70
70
  try {
71
71
  const require = createRequire(import.meta.url);
72
- const pkgJson = require.resolve('camoufox-js/package.json');
73
- const bin = join(dirname(pkgJson), '..', '.bin', process.platform === 'win32' ? 'camoufox-js.cmd' : 'camoufox-js');
74
- if (existsSync(bin)) return bin;
72
+ const pkgJson = require.resolve('@camoufox/camoufox/package.json');
73
+ const pkg = JSON.parse(readFileSync(pkgJson, 'utf8')) as { bin?: string | Record<string, string> };
74
+ const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['camoufox'];
75
+ if (rel) {
76
+ const entry = join(dirname(pkgJson), rel);
77
+ if (existsSync(entry)) return entry;
78
+ }
75
79
  } catch {
76
- /* fall through to npx */
80
+ /* fall through */
77
81
  }
78
82
  return null;
79
83
  }
80
84
 
81
85
  const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
82
86
 
83
- /** Run `camoufox-js fetch`, resolving on exit 0 and rejecting otherwise / on timeout. */
84
- function runFetch(): Promise<void> {
85
- return new Promise<void>((resolve, reject) => {
86
- const bin = resolveCamoufoxBin();
87
- const [cmd, args] = bin ? [bin, ['fetch']] : ['npx', ['camoufox-js', 'fetch']];
88
- logger.info(`[ensure-browser] Camoufox not found; fetching the browser (this runs once, ~500MB): ${cmd} ${args.join(' ')}`);
89
-
90
- // Windows needs shell:true to run the .cmd shim (Node CVE-2024-27980 fix makes a
91
- // shell-less spawn of .cmd throw EINVAL), but shell:true performs NO quoting, so a
92
- // spaced install path (C:\Users\John Smith\…) must be quoted explicitly. The args
93
- // here are fixed literals ('fetch'), never user input, so quoting the command alone
94
- // is sufficient and this stays injection-free.
95
- const useShell = process.platform === 'win32';
96
- const child = spawn(useShell ? `"${cmd}"` : cmd, args, { stdio: 'inherit', shell: useShell });
87
+ /**
88
+ * Run `camoufox fetch`, resolving true on exit 0, false on any other exit, rejecting on
89
+ * spawn error / timeout. Runs the CLI's JS entry with process.execPath and an argv array:
90
+ * no shell, so neither a spaced Windows path nor a metacharacter in an install directory
91
+ * can be reinterpreted.
92
+ *
93
+ * The child's stdout is captured in a temp file, not inherited: a child with an
94
+ * inherited fd 1 writes raw to the terminal, which inside a pi session bypasses the
95
+ * host's JS-level stdout redirection (pi redirects `process.stdout.write` for its TUI)
96
+ * and interleaves the download's progress with the host's output stream — in
97
+ * print/JSON/RPC modes stdout is the protocol itself, so raw child output corrupts it.
98
+ * The launcher's progress is noise either way; its errors go to stderr, which stays
99
+ * inherited and is therefore still visible.
100
+ */
101
+ function runFetch(): Promise<boolean> {
102
+ return new Promise<boolean>((resolve, reject) => {
103
+ const cli = resolveCamoufoxCli();
104
+ if (!cli) {
105
+ reject(new Error('@camoufox/camoufox is not installed (cannot find its CLI); run npm install'));
106
+ return;
107
+ }
108
+ // The launcher reads only XDG_CACHE_HOME / LOCALAPPDATA: hand it the override derived
109
+ // from the user's CAMOUFOX_INSTALL_DIR / PLAYWRIGHT_BROWSERS_PATH so the fetch lands
110
+ // where the lookup (getCamoufoxBinaryPath) expects it.
111
+ const install = resolveInstall(process.env, platform(), homedir());
112
+ const env: NodeJS.ProcessEnv = { ...process.env, ...install.overrides };
113
+ delete env['CAMOUFOX_INSTALL_DIR'];
114
+ delete env['PLAYWRIGHT_BROWSERS_PATH'];
115
+ logger.info(`[ensure-browser] Camoufox not found; fetching the browser (this runs once, ~1.3GB): ${process.execPath} ${cli} fetch`);
116
+
117
+ const outLogPath = join(tmpdir(), `pi-research-camoufox-fetch-${process.pid}-${Date.now()}.log`);
118
+ const outStream = createWriteStream(outLogPath, { mode: 0o600 });
119
+ const child = spawn(process.execPath, [cli, 'fetch'], { stdio: ['ignore', 'pipe', 'inherit'], env });
120
+ if (child.stdout) {
121
+ child.stdout.pipe(outStream);
122
+ }
97
123
  const timer = setTimeout(() => {
98
124
  child.kill('SIGKILL');
99
125
  reject(new Error(`camoufox fetch timed out after ${FETCH_TIMEOUT_MS}ms`));
100
126
  }, FETCH_TIMEOUT_MS);
101
127
 
102
- child.on('error', (err) => {
128
+ const finish = (err: Error | null, ok: boolean): void => {
103
129
  clearTimeout(timer);
104
- reject(err);
130
+ void outStream.close();
131
+ if (err) reject(err);
132
+ else resolve(ok);
133
+ };
134
+
135
+ child.on('error', (err) => {
136
+ const message = err instanceof Error ? err.message : String(err);
137
+ finish(new Error(`${message} (fetch output logged at ${outLogPath})`), false);
105
138
  });
106
139
  child.on('exit', (code) => {
107
- clearTimeout(timer);
108
- if (code === 0) resolve();
109
- else reject(new Error(`camoufox fetch exited with code ${code}`));
140
+ if (code === 0) {
141
+ try { rmSync(outLogPath, { force: true }); } catch { /* best-effort */ }
142
+ finish(null, true);
143
+ } else {
144
+ finish(new Error(`camoufox fetch exited with code ${code} (fetch output logged at ${outLogPath})`), false);
145
+ }
110
146
  });
111
147
  });
112
148
  }
@@ -175,12 +211,14 @@ async function provision(): Promise<void> {
175
211
 
176
212
  try {
177
213
  if (isBrowserBinaryPresent()) return; // re-check under lock
178
- await runFetch();
179
- if (isBrowserBinaryPresent()) {
180
- logger.info('[ensure-browser] Camoufox browser is ready.');
181
- } else {
182
- logger.warn('[ensure-browser] camoufox fetch reported success but no binary was found.');
214
+ // Guarded: a legacy flat camoufox-js install is moved aside first (the fetch, and the
215
+ // launcher itself, delete a non-empty dir without the compat flag), and is removed
216
+ // only after the new build verifies. A failed fetch restores it. See camoufox-layout.ts.
217
+ const result = await provisionBrowser(getCamoufoxBinaryPath(), runFetch, (m) => logger.info(`[ensure-browser] ${m}`), currentPin());
218
+ if (result.status === 'failed') {
219
+ throw new Error(result.reason ?? 'camoufox fetch failed');
183
220
  }
221
+ logger.info('[ensure-browser] Camoufox browser is ready.');
184
222
  } finally {
185
223
  if (haveLock) {
186
224
  try {
@@ -198,7 +236,7 @@ async function provision(): Promise<void> {
198
236
  * Idempotent and concurrency-safe: concurrent callers share a single fetch, and
199
237
  * a cross-process lock prevents two processes from fetching at the same time.
200
238
  * Best-effort — if the fetch fails, the subsequent browser launch surfaces the
201
- * existing actionable "run npx camoufox-js fetch" error rather than this throwing.
239
+ * existing actionable "run npx camoufox fetch" error rather than this throwing.
202
240
  */
203
241
  export function ensureBrowserInstalled(): Promise<void> {
204
242
  if (!inFlight) {
@@ -158,55 +158,51 @@ export async function initBrowser(): Promise<void> {
158
158
  logToDebugFile('INFO', `[Worker-${workerId}] Initializing browser instance...`);
159
159
 
160
160
  // ---------------------------------------------------------------------
161
- // Pinned browser stack — DO NOT caret-bump these three in isolation.
162
- // They are one coupled set (see package.json + docs/ARCHITECTURE.md).
163
- // Each was a real fresh-install/runtime break that our lockfile masked
164
- // (consumers install without our lockfile, so a floating range resolves
165
- // to a newer, broken version):
161
+ // Pinned browser stack — DO NOT caret-bump these in isolation. They are one
162
+ // coupled set (see package.json + docs/ARCHITECTURE.md). Each was a real
163
+ // fresh-install/runtime break that our lockfile masked (consumers install
164
+ // without our lockfile, so a floating range resolves to a newer, broken
165
+ // version):
166
166
  //
167
- // • camoufox-js ^0.12.0 (2026-08-30 refresh) → the 0.10.x pin survived
168
- // two refresh cycles on three since-lapsed blockers (Windows assets
169
- // returned in v152.0.4-beta.26, 2026-07-16; impit's `only-allow pnpm`
170
- // guard existed only in 0.13.1/0.14.0; and better-sqlite3 13's changed
171
- // install model). The refresh accepted better-sqlite3 13. MEASURED on
172
- // 13.0.3: `prebuilds/` for all eight platform/arch pairs ship INSIDE
173
- // the tarball and load at runtime via node-gyp-build — no install
174
- // script, no toolchain needed on modern npm (≥11.19 skips the
175
- // injected `node-gyp rebuild` that a binding.gyp without an install
176
- // script triggers; it was that needless rebuild, not a missing
177
- // binary, that broke toolchain-less Windows installs). A future bump
178
- // must still check better-sqlite3 first, not camoufox.
179
- // • playwright-core 1.60.0 (exact) → playwright-core and the camoufox
180
- // binary are one matched Juggler-protocol pair. 1.61 added a
181
- // viewport.isMobile field the build REJECTS ("property
182
- // viewport.isMobile not described in this scheme"), failing every
183
- // launch below. Corroborated upstream: camoufox-js 0.12.0 declares
184
- // `peerDependencies: playwright-core "<1.61.0"`, the same bound this
185
- // pin has held by hand.
186
- // • impit 0.14.4 (exact, a direct dep though only camoufox-js uses it;
187
- // 2026-08-30 refresh from 0.13.0) → kept EXACT because it is the only
188
- // way to force a version for consumers: npm `overrides` don't
189
- // propagate to installers of a published package, and impit's
190
- // mid-stream `only-allow pnpm` guard incident (0.13.1/0.14.0) showed
191
- // floating ranges breaking fresh global installs our lockfile masks.
167
+ // • @camoufox/camoufox ^0.5.7 (replaced camoufox-js 0.12.0, 2026-10-05).
168
+ // The launcher ships `dist/data-files/browser-pin.json` naming the ONE
169
+ // browser build it was released and tested with (v156.0.1-beta.34 at
170
+ // 0.5.7), and by default fetches and launches exactly that build. The
171
+ // old camoufox-js had no such pairing: it took "the newest non-prerelease
172
+ // GitHub release", so when upstream flagged v156.0.1-beta.34
173
+ // non-prerelease on 2026-10-03 every fresh install got a browser whose
174
+ // schema dropped `navigator.product` and every launch threw
175
+ // `UnknownProperty` (CI went red on all three OSes; machines with an
176
+ // older cached browser never noticed). A new pi-research release that
177
+ // bumps this dependency therefore moves the browser deliberately, with
178
+ // the launcher it was verified against, and no longer on upstream's
179
+ // release timing.
180
+ // • playwright-core 1.62.1 (exact) → playwright-core and the Camoufox
181
+ // build are one matched Juggler-protocol pair. @camoufox/camoufox
182
+ // declares `peerDependencies: playwright-core "<1.63"` and mirrors it
183
+ // from its Python twin ("every Playwright minor is free to change
184
+ // Juggler, so the ceiling is bumped deliberately"). 1.61 added a
185
+ // viewport.isMobile field the OLD build rejected; the paired v156 build
186
+ // was verified under 1.62.x. Re-verify a REAL launch before raising it.
187
+ // • impit 0.14.5 (exact, a direct dep though only the launcher uses it):
188
+ // kept EXACT because it is the only way to force a version for
189
+ // consumers — npm `overrides` don't propagate to installers of a
190
+ // published package, and impit's mid-stream `only-allow pnpm` guard
191
+ // incident (0.13.1/0.14.0) showed floating ranges breaking fresh global
192
+ // installs our lockfile masks.
192
193
  //
193
- // What is NOT pinned: the browser BINARY. `camoufox-js fetch` takes no
194
- // version argument — it walks the GitHub releases newest-first and takes
195
- // the first non-prerelease one with an asset for this OS/arch. A consumer
196
- // therefore gets whatever camoufox published most recently, whichever
197
- // camoufox-js they have, and a future release could break launches with no
198
- // change on our side. Both the current newest (v152.0.4-beta.28, Firefox
199
- // 152) and the older v135.0.1-beta.24 an existing cache may hold were
200
- // verified launching and driving under playwright-core 1.60.0.
194
+ // better-sqlite3 is gone: it was camoufox-js's dependency, not ours, and
195
+ // @camoufox/camoufox has none. The one native module left on the launch
196
+ // path is impit, probed by probeBrowserNativeDeps().
201
197
  //
202
198
  // Always re-verify a REAL headless run after touching any of this: the
203
199
  // unit and integration suites mock the browser, so a Juggler mismatch
204
200
  // reaches production with a fully green suite.
205
201
  let CamoufoxModule: any;
206
202
  try {
207
- CamoufoxModule = await import('camoufox-js');
203
+ CamoufoxModule = await import('@camoufox/camoufox');
208
204
  } catch (e: unknown) {
209
- throw new Error(`[Worker] camoufox-js not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
205
+ throw new Error(`[Worker] @camoufox/camoufox not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
210
206
  }
211
207
 
212
208
  const { Camoufox } = CamoufoxModule;
@@ -344,7 +340,7 @@ export async function initBrowser(): Promise<void> {
344
340
  } catch (e: unknown) {
345
341
  // Close any partially-launched browser to avoid orphaning the process.
346
342
  // Promise.resolve() wraps the call safely: in headless:'virtual' mode,
347
- // camoufox-js browser.close() is synchronous (returns void), not a Promise.
343
+ // camoufox browser.close() is synchronous (returns void), not a Promise.
348
344
  if (browser && typeof browser.close === 'function') {
349
345
  Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during failed init: ${err.message}`));
350
346
  }
@@ -352,7 +348,7 @@ export async function initBrowser(): Promise<void> {
352
348
  const msg = e instanceof Error ? e.message : String(e);
353
349
 
354
350
  if (msg.includes('Camoufox is not installed') || msg.includes('Version information not found')) {
355
- throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox-js fetch' to install them.`, { cause: e });
351
+ throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox fetch' to install them.`, { cause: e });
356
352
  }
357
353
 
358
354
  // camoufox headless: "virtual" spawns Xvfb on Linux when no DISPLAY is set.
@@ -107,7 +107,7 @@ var init_log_utils = __esm({
107
107
  "gi"
108
108
  );
109
109
  KNOWN_TOKEN_PATTERN = new RegExp(
110
- `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
110
+ `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|hf_[A-Za-z0-9]{30,}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
111
111
  "g"
112
112
  );
113
113
  JWT_PATTERN = new RegExp(
@@ -1761,9 +1761,9 @@ function formatErrorWithCause(err) {
1761
1761
  function isPrivateIp(ip) {
1762
1762
  const parts = ip.split(".").map(Number);
1763
1763
  if (parts.length !== 4 || parts.some((p) => isNaN(p))) return false;
1764
- const [a, b] = parts;
1764
+ const [a, b, c] = parts;
1765
1765
  if (a === void 0 || b === void 0) return false;
1766
- return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a >= 224;
1766
+ return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a === 192 && b === 0 && (c === 0 || c === 2) || a === 198 && b === 51 && c === 100 || a === 203 && b === 0 && c === 113 || a >= 224;
1767
1767
  }
1768
1768
  function isPrivateIpv6(ip) {
1769
1769
  const normalized = ip.toLowerCase().replace(/^\[|\]$/g, "");
@@ -3038,9 +3038,9 @@ async function initBrowser() {
3038
3038
  logToDebugFile3("INFO", `[Worker-${workerId3}] Initializing browser instance...`);
3039
3039
  let CamoufoxModule;
3040
3040
  try {
3041
- CamoufoxModule = await import("camoufox-js");
3041
+ CamoufoxModule = await import("@camoufox/camoufox");
3042
3042
  } catch (e) {
3043
- throw new Error(`[Worker] camoufox-js not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
3043
+ throw new Error(`[Worker] @camoufox/camoufox not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
3044
3044
  }
3045
3045
  const { Camoufox } = CamoufoxModule;
3046
3046
  const launchTimeoutMs = BROWSER_LAUNCH_TIMEOUT_MS;
@@ -3146,7 +3146,7 @@ async function initBrowser() {
3146
3146
  browser = null;
3147
3147
  const msg = e instanceof Error ? e.message : String(e);
3148
3148
  if (msg.includes("Camoufox is not installed") || msg.includes("Version information not found")) {
3149
- throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox-js fetch' to install them.`, { cause: e });
3149
+ throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox fetch' to install them.`, { cause: e });
3150
3150
  }
3151
3151
  const errName = e instanceof Error ? e.constructor?.name ?? "" : "";
3152
3152
  if (errName === "CannotFindXvfb" || errName === "CannotExecuteXvfb" || msg.includes("Xvfb") || msg.includes("virtual display")) {
@@ -214,12 +214,12 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
214
214
  // liveOwnerStaleThreshold (120s default) that actually governs when a
215
215
  // provably-alive holder's lock may be physically reclaimed. This lock
216
216
  // protects a post-run FTS rebuild plus optimize with no fixed duration; if
217
- // that holder has a stretch of non-yielding synchronous work (a single
218
- // large better-sqlite3 call blocks the event loop for its full duration,
219
- // during which the heartbeat literally cannot fire) longer than 20s, a
220
- // waiting caller would abandon and throw "no sign of progress from its
221
- // holder" against a holder that was never actually wedged — up to 100s
222
- // before the lock would even become reclaim-eligible. Raised to match
217
+ // that holder has a stretch of non-yielding synchronous work longer than
218
+ // 20s (a single large native call — the FTS rebuild or optimize — blocks the
219
+ // event loop for its full duration, during which the heartbeat literally
220
+ // cannot fire), a waiting caller would abandon and throw "no sign of
221
+ // progress from its holder" against a holder that was never actually
222
+ // wedged — up to 100s before the lock would even become reclaim-eligible. Raised to match
223
223
  // liveOwnerStaleThreshold's own default so a live holder is given the same
224
224
  // grace period here that the reclaim logic itself already grants it.
225
225
  lockTimeout: 120000,
@@ -60,6 +60,36 @@ export function isUnretriableResearcherError(error: unknown): boolean {
60
60
  || message.includes('insufficient_quota');
61
61
  }
62
62
 
63
+ /**
64
+ * OpenRouter's *in-flight budget* 402 — a different condition from a spent balance, and a
65
+ * transient one: the request is refused because too many of THIS account's requests are
66
+ * still open, and the provider states how long the window needs in `Retry-After`. It must
67
+ * stay retriable (the condition clears by itself), but the ordinary 1-2s exponential
68
+ * backoff cannot outlast a 120s settle window: the run burns its three attempts, fails,
69
+ * and the round then degrades to a raw-compilation synthesis. Matched on the provider's
70
+ * wording rather than a bare `402`, for the same reason as the unretriable gate above.
71
+ */
72
+ export function isInFlightBudgetError(error: unknown): boolean {
73
+ const lower = (error instanceof Error ? error.message : String(error)).toLowerCase();
74
+ return lower.includes('in_flight_budget_exhausted')
75
+ || lower.includes('would exceed your available credits given your current in-flight requests');
76
+ }
77
+
78
+ /**
79
+ * The `Retry-After` a provider error asked for, in ms, clamped to [5s, 60s], or null when
80
+ * the error carries none. Read out of the message because the researcher retry loop sees a
81
+ * serialized provider error, not the raw Response headers: pi's `after_provider_response`
82
+ * hook (src/index.ts) is the only place with headers, and it does not feed this loop.
83
+ */
84
+ export function parseRetryAfterMs(error: unknown): number | null {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ const m = /["']?retry-after["']?\s*[:=]\s*["']?(\d{1,4})/i.exec(message);
87
+ if (!m) return null;
88
+ const seconds = Number(m[1]);
89
+ if (!Number.isFinite(seconds) || seconds <= 0) return null;
90
+ return Math.min(Math.max(seconds, 5), 60) * 1000;
91
+ }
92
+
63
93
  /**
64
94
  * Run a single researcher with retries
65
95
  */
@@ -214,6 +244,10 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
214
244
  .replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
215
245
  .trim();
216
246
 
247
+ // Set when the previous attempt failed on the provider's in-flight budget: the next
248
+ // backoff uses the provider's Retry-After instead of the small exponential step.
249
+ let inFlightBackoffMs: number | null = null;
250
+
217
251
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
218
252
  // Launch boundary: never build a session for a run that is already cancelled
219
253
  // or fast-stopped. The abort sentinel keeps the wrapper's clean-cancel
@@ -225,7 +259,11 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
225
259
  return;
226
260
  }
227
261
  if (attempt > 1) {
228
- const delay = Math.min(1000 * Math.pow(2, attempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
262
+ // A provider-declared in-flight window overrides the exponential step for exactly
263
+ // one attempt: the condition cannot clear in 1-2s, and the provider already said
264
+ // how long it needs (isInFlightBudgetError / parseRetryAfterMs).
265
+ const delay = inFlightBackoffMs ?? Math.min(1000 * Math.pow(2, attempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
266
+ inFlightBackoffMs = null;
229
267
  logger.warn(`[ResearcherExecutor] Researcher ${id} retry ${attempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
230
268
  observer?.onResearcherProgress?.(id, 'retry');
231
269
  // Abortable: a cancel during this backoff must wake the sleep immediately —
@@ -594,6 +632,14 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
594
632
  break;
595
633
  }
596
634
 
635
+ // Transient provider condition that needs a real wait, not a retry loop: honor the
636
+ // Retry-After it returned (clamped) for the next attempt instead of 1-2s.
637
+ if (isInFlightBudgetError(err) && attempt < maxAttempts) {
638
+ inFlightBackoffMs = parseRetryAfterMs(err) ?? 15000;
639
+ metrics.increment('researcher_inflight_budget_backoff_total', 1, { mode: 'deep', complexity: String(complexity) });
640
+ logger.warn(`[ResearcherExecutor] Researcher ${id} hit the provider's in-flight budget; backing off ${inFlightBackoffMs}ms (provider Retry-After, else 15s) before attempt ${attempt + 1}`);
641
+ }
642
+
597
643
  if (attempt < maxAttempts) {
598
644
  logger.warn(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} failed: ${errMsg}; will retry`);
599
645
  } else {
@@ -69,7 +69,7 @@ export function formatHealthError(raw: string): string {
69
69
  if (raw.includes('Xvfb') || raw.includes('xvfb') || raw.includes('virtual display') || raw.includes('display server') || raw.includes('DISPLAY not set')) {
70
70
  return 'No display server found on Linux. Install Xvfb for TTY/Wayland use: sudo apt install xvfb';
71
71
  } else if (raw.includes('not found') || raw.includes('not installed') || raw.includes('binaries')) {
72
- return 'Browser engine not installed. Run `npx camoufox-js fetch` to install it.';
72
+ return 'Browser engine not installed. Run `npx camoufox fetch` to install it.';
73
73
  } else if (raw.includes('Timeout') || raw.includes('timeout') || raw.includes('timed out')) {
74
74
  // The readiness probes check browser/state/knowledge availability — they do NOT perform a
75
75
  // web search, so a timeout here is NOT the search engine blocking us. It is almost always
@@ -142,7 +142,7 @@ const AUTH_SCHEME_PATTERN = new RegExp(
142
142
  );
143
143
  // Well-known opaque credential formats (OpenAI, GitHub, AWS, Slack, Anthropic).
144
144
  const KNOWN_TOKEN_PATTERN = new RegExp(
145
- `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
145
+ `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|hf_[A-Za-z0-9]{30,}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
146
146
  'g',
147
147
  );
148
148
  // JSON Web Tokens — header.payload.signature, each a base64url segment.
@@ -323,20 +323,18 @@ export async function performSearch(
323
323
 
324
324
  // A missing native addon is a BROKEN INSTALL, and the generic advice below
325
325
  // actively misleads: it sends the reader to check their network and their
326
- // machine load when neither is involved and neither can be acted on. npm 12
327
- // defaults `allowScripts` to off, so a plain install no longer builds
328
- // camoufox-js's better-sqlite3 dependency and every browser worker dies
329
- // identically. Say that instead, and say what fixes it.
326
+ // machine load when neither is involved and neither can be acted on. A
327
+ // dropped platform package (npm/cli#4828) or an install that skipped a native
328
+ // dependency's binding makes every browser worker die identically. Say that
329
+ // instead, and say what fixes it.
330
330
  if (isNativeBindingError(sampleWorkerError)) {
331
331
  metrics.increment('browser_search_total_failures_total', 1, { cause: 'native_binding' });
332
332
  throw new Error(
333
333
  `Search completely failed: ${reason}, all with a missing native module. ` +
334
- `This is an incomplete install, not a network problem: better-sqlite3 13 ` +
335
- `ships prebuilt bindings for every supported platform and loads them at ` +
336
- `runtime, so a missing binding means an old or broken npm install. ` +
337
- `Upgrade npm to ≥12 (npm install -g npm@12) and reinstall this package — ` +
338
- `modern npm skips the needless recompile that fails on toolchain-less ` +
339
- `machines, and the prebuilt binding then loads as-is.` +
334
+ `This is an incomplete install, not a network problem: the browser launcher's ` +
335
+ `native dependency (impit) ships a prebuilt binding per platform as a separate ` +
336
+ `package, and npm sometimes drops it (npm/cli#4828). ` +
337
+ `Reinstall this package (npm install, or pi install again) to restore it.` +
340
338
  (sampleWorkerError ? ` Last worker error: ${sampleWorkerError}` : '')
341
339
  );
342
340
  }
@@ -221,7 +221,7 @@ var AUTH_SCHEME_PATTERN = new RegExp(
221
221
  "gi"
222
222
  );
223
223
  var KNOWN_TOKEN_PATTERN = new RegExp(
224
- `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
224
+ `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|hf_[A-Za-z0-9]{30,}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
225
225
  "g"
226
226
  );
227
227
  var JWT_PATTERN = new RegExp(
@@ -382,18 +382,27 @@ export function formatErrorWithCause(err: unknown): string {
382
382
  function isPrivateIp(ip: string): boolean {
383
383
  const parts = ip.split('.').map(Number);
384
384
  if (parts.length !== 4 || parts.some(p => isNaN(p))) return false;
385
- const [a, b] = parts;
385
+ const [a, b, c] = parts;
386
386
  if (a === undefined || b === undefined) return false;
387
387
  // 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (RFC 6598 CGNAT / shared address space,
388
388
  // routinely routed to internal infra by cloud/hosting providers), 127.0.0.0/8,
389
389
  // 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4 (multicast),
390
- // 240.0.0.0/4 (reserved)
390
+ // 240.0.0.0/4 (reserved).
391
+ // Also blocked: 198.18.0.0/15 (RFC 2544 benchmarking, sometimes routed by
392
+ // broken tooling), 192.0.0.0/24 (RFC 6890 IETF protocol assignments) and the
393
+ // three documentation ranges 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24
394
+ // (TEST-NET-1/2/3, RFC 5737). None is a legitimate scrape target, and a
395
+ // mis-issued NAT or a hostile page's SSRF probe can name them.
391
396
  return (
392
397
  a === 0 || a === 10 || a === 127 ||
393
398
  (a === 100 && b >= 64 && b <= 127) ||
394
399
  (a === 169 && b === 254) ||
395
400
  (a === 172 && b >= 16 && b <= 31) ||
396
401
  (a === 192 && b === 168) ||
402
+ (a === 198 && (b === 18 || b === 19)) ||
403
+ (a === 192 && b === 0 && (c === 0 || c === 2)) ||
404
+ (a === 198 && b === 51 && c === 100) ||
405
+ (a === 203 && b === 0 && c === 113) ||
397
406
  a >= 224
398
407
  );
399
408
  }
@@ -41,7 +41,7 @@ let playwrightAvailable: boolean = false;
41
41
  let markdownConverterPromise: Promise<(html: string) => Promise<string>> | null = null;
42
42
 
43
43
  export function initScraperDependencies(): void {
44
- playwrightAvailable = checkModule('playwright-core') && checkModule('camoufox-js');
44
+ playwrightAvailable = checkModule('playwright-core') && checkModule('@camoufox/camoufox');
45
45
  }
46
46
  initScraperDependencies();
47
47