@lincoln504/pi-research 1.7.8 → 1.7.9
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/agent-skill/pi-research/README.md +3 -3
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli-bin.mjs +3 -0
- package/dist/cli.mjs +652 -380
- package/dist/pdf-extract-worker.mjs +1 -1
- package/dist/thread-worker.mjs +6 -6
- package/docs/AGENT-SKILL.md +3 -3
- package/docs/ARCHITECTURE.md +64 -50
- package/docs/CHANGELOG.md +21 -0
- package/docs/CONFIGURATION.md +2 -2
- package/docs/SDK.md +7 -6
- package/docs/THIRD-PARTY-NOTICES.md +11 -7
- package/package.json +10 -8
- package/scripts/camoufox-layout.cjs +455 -0
- package/scripts/cleanup.cjs +72 -29
- package/scripts/setup.cjs +72 -111
- package/src/cli-bin.mjs +3 -0
- package/src/cli.ts +12 -13
- package/src/healthcheck/index.ts +1 -1
- package/src/infrastructure/browser/browser-cleanup.ts +1 -1
- package/src/infrastructure/browser/browser-error-utils.ts +5 -4
- package/src/infrastructure/browser/camoufox-layout.ts +450 -0
- package/src/infrastructure/browser/config.ts +46 -103
- package/src/infrastructure/browser/ensure-browser.ts +63 -49
- package/src/infrastructure/browser/thread-worker-browser.ts +38 -42
- package/src/infrastructure/browser/thread-worker.mjs +6 -6
- package/src/infrastructure/knowledge-store-service.ts +6 -6
- package/src/orchestration/researcher-executor.ts +47 -1
- package/src/tui/research-health.ts +1 -1
- package/src/utils/log-utils.ts +1 -1
- package/src/web-research/browser-search.ts +8 -10
- package/src/web-research/pdf-extract-worker.mjs +1 -1
- package/src/web-research/scraper-utils.ts +11 -2
- 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
|
|
17
|
-
* immediately and changes nothing. It only ever does work when the
|
|
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,
|
|
24
|
+
import { existsSync, fstatSync, openSync, closeSync, rmSync, readFileSync } 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 ~
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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,55 +45,68 @@ 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
|
|
50
|
-
*
|
|
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
|
-
|
|
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
|
|
69
|
-
function
|
|
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
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
|
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
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
function runFetch(): Promise<boolean> {
|
|
94
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
95
|
+
const cli = resolveCamoufoxCli();
|
|
96
|
+
if (!cli) {
|
|
97
|
+
reject(new Error('@camoufox/camoufox is not installed (cannot find its CLI); run npm install'));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// The launcher reads only XDG_CACHE_HOME / LOCALAPPDATA: hand it the override derived
|
|
101
|
+
// from the user's CAMOUFOX_INSTALL_DIR / PLAYWRIGHT_BROWSERS_PATH so the fetch lands
|
|
102
|
+
// where the lookup (getCamoufoxBinaryPath) expects it.
|
|
103
|
+
const install = resolveInstall(process.env, platform(), homedir());
|
|
104
|
+
const env: NodeJS.ProcessEnv = { ...process.env, ...install.overrides };
|
|
105
|
+
delete env['CAMOUFOX_INSTALL_DIR'];
|
|
106
|
+
delete env['PLAYWRIGHT_BROWSERS_PATH'];
|
|
107
|
+
logger.info(`[ensure-browser] Camoufox not found; fetching the browser (this runs once, ~1.3GB): ${process.execPath} ${cli} fetch`);
|
|
108
|
+
|
|
109
|
+
const child = spawn(process.execPath, [cli, 'fetch'], { stdio: 'inherit', env });
|
|
97
110
|
const timer = setTimeout(() => {
|
|
98
111
|
child.kill('SIGKILL');
|
|
99
112
|
reject(new Error(`camoufox fetch timed out after ${FETCH_TIMEOUT_MS}ms`));
|
|
@@ -105,8 +118,7 @@ function runFetch(): Promise<void> {
|
|
|
105
118
|
});
|
|
106
119
|
child.on('exit', (code) => {
|
|
107
120
|
clearTimeout(timer);
|
|
108
|
-
|
|
109
|
-
else reject(new Error(`camoufox fetch exited with code ${code}`));
|
|
121
|
+
resolve(code === 0);
|
|
110
122
|
});
|
|
111
123
|
});
|
|
112
124
|
}
|
|
@@ -175,12 +187,14 @@ async function provision(): Promise<void> {
|
|
|
175
187
|
|
|
176
188
|
try {
|
|
177
189
|
if (isBrowserBinaryPresent()) return; // re-check under lock
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
190
|
+
// Guarded: a legacy flat camoufox-js install is moved aside first (the fetch, and the
|
|
191
|
+
// launcher itself, delete a non-empty dir without the compat flag), and is removed
|
|
192
|
+
// only after the new build verifies. A failed fetch restores it. See camoufox-layout.ts.
|
|
193
|
+
const result = await provisionBrowser(getCamoufoxBinaryPath(), runFetch, (m) => logger.info(`[ensure-browser] ${m}`), currentPin());
|
|
194
|
+
if (result.status === 'failed') {
|
|
195
|
+
throw new Error(result.reason ?? 'camoufox fetch failed');
|
|
183
196
|
}
|
|
197
|
+
logger.info('[ensure-browser] Camoufox browser is ready.');
|
|
184
198
|
} finally {
|
|
185
199
|
if (haveLock) {
|
|
186
200
|
try {
|
|
@@ -198,7 +212,7 @@ async function provision(): Promise<void> {
|
|
|
198
212
|
* Idempotent and concurrency-safe: concurrent callers share a single fetch, and
|
|
199
213
|
* a cross-process lock prevents two processes from fetching at the same time.
|
|
200
214
|
* Best-effort — if the fetch fails, the subsequent browser launch surfaces the
|
|
201
|
-
* existing actionable "run npx camoufox
|
|
215
|
+
* existing actionable "run npx camoufox fetch" error rather than this throwing.
|
|
202
216
|
*/
|
|
203
217
|
export function ensureBrowserInstalled(): Promise<void> {
|
|
204
218
|
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
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// way to force a version for
|
|
189
|
-
// propagate to installers of a
|
|
190
|
-
// mid-stream `only-allow pnpm` guard
|
|
191
|
-
// floating ranges breaking fresh global
|
|
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
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
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
|
|
203
|
+
CamoufoxModule = await import('@camoufox/camoufox');
|
|
208
204
|
} catch (e: unknown) {
|
|
209
|
-
throw new Error(`[Worker] camoufox
|
|
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
|
|
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
|
|
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
|
|
3041
|
+
CamoufoxModule = await import("@camoufox/camoufox");
|
|
3042
3042
|
} catch (e) {
|
|
3043
|
-
throw new Error(`[Worker] camoufox
|
|
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
|
|
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
|
|
218
|
-
// large
|
|
219
|
-
//
|
|
220
|
-
// waiting caller would abandon and throw "no sign of
|
|
221
|
-
// holder" against a holder that was never actually
|
|
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
|
-
|
|
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
|
|
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
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -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.
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
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:
|
|
335
|
-
`ships prebuilt
|
|
336
|
-
`
|
|
337
|
-
`
|
|
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
|
|
44
|
+
playwrightAvailable = checkModule('playwright-core') && checkModule('@camoufox/camoufox');
|
|
45
45
|
}
|
|
46
46
|
initScraperDependencies();
|
|
47
47
|
|