@lincoln504/pi-research 1.7.7 → 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 +10 -1
- package/dist/cli.mjs +692 -387
- package/dist/pdf-extract-worker.mjs +12 -3
- package/dist/thread-worker.mjs +41 -12
- package/docs/AGENT-SKILL.md +3 -3
- package/docs/ARCHITECTURE.md +64 -50
- package/docs/CHANGELOG.md +31 -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 +10 -1
- package/src/cli.ts +27 -15
- 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 +41 -12
- package/src/infrastructure/knowledge-store-service.ts +6 -6
- package/src/knowledge/store.ts +1 -1
- package/src/orchestration/researcher-executor.ts +47 -1
- package/src/tui/research-health.ts +1 -1
- package/src/utils/log-utils.ts +19 -4
- package/src/utils/research-export.ts +1 -1
- package/src/web-research/browser-search.ts +8 -10
- package/src/web-research/pdf-extract-worker.mjs +12 -3
- package/src/web-research/scraper-utils.ts +42 -10
- 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.
|
|
@@ -60,7 +60,12 @@ function redactSecrets(message) {
|
|
|
60
60
|
out = out.replace(BASIC_AUTH_PATTERN, "Basic [REDACTED]");
|
|
61
61
|
out = out.replace(BEARER_AUTH_PATTERN, "Bearer [REDACTED]");
|
|
62
62
|
out = out.replace(COOKIE_HEADER_PATTERN, (_m, key, sep) => `${key}${sep}[REDACTED]`);
|
|
63
|
-
out = out.replace(
|
|
63
|
+
out = out.replace(AUTH_SCHEME_PATTERN, (_m, key, sep, scheme) => `${key}${sep}${scheme} [REDACTED]`);
|
|
64
|
+
out = out.replace(SENSITIVE_KV_PATTERN, (_m, key, sep, val) => {
|
|
65
|
+
const q = val[0] === '"' || val[0] === "'" ? val[0] : "";
|
|
66
|
+
const closed = q !== "" && val.length > 1 && val.endsWith(q);
|
|
67
|
+
return `${key}${sep}${q}[REDACTED]${closed ? q : ""}`;
|
|
68
|
+
});
|
|
64
69
|
out = out.replace(KNOWN_TOKEN_PATTERN, "[REDACTED]");
|
|
65
70
|
out = out.replace(PROVIDER_TOKEN_PATTERN, "[REDACTED]");
|
|
66
71
|
out = out.replace(LONG_HEX_SECRET_PATTERN, "[REDACTED]");
|
|
@@ -79,7 +84,7 @@ function redactSecrets(message) {
|
|
|
79
84
|
function stripTerminalEscapes(message) {
|
|
80
85
|
return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
|
|
81
86
|
}
|
|
82
|
-
var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, B_LEFT, B_RIGHT, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, REDACT_SCAN_LENGTH, TOKEN_CHAR_PATTERN, REDACT_SCAN_EXTENSION_MAX, TERMINAL_ESCAPE_PATTERN;
|
|
87
|
+
var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, B_LEFT, B_RIGHT, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, AUTH_SCHEME_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, REDACT_SCAN_LENGTH, TOKEN_CHAR_PATTERN, REDACT_SCAN_EXTENSION_MAX, TERMINAL_ESCAPE_PATTERN;
|
|
83
88
|
var init_log_utils = __esm({
|
|
84
89
|
"src/utils/log-utils.ts"() {
|
|
85
90
|
"use strict";
|
|
@@ -94,11 +99,15 @@ var init_log_utils = __esm({
|
|
|
94
99
|
"gi"
|
|
95
100
|
);
|
|
96
101
|
SENSITIVE_KV_PATTERN = new RegExp(
|
|
97
|
-
`${B_LEFT}(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)${B_RIGHT}(["']?\\s*[:=]\\s*["']?
|
|
102
|
+
`${B_LEFT}(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|token|key)${B_RIGHT}(["']?\\s*[:=]\\s*)("[^"\\r\\n]{0,1024}"|'[^'\\r\\n]{0,1024}'|["']?[^\\s"',&)]+)`,
|
|
103
|
+
"gi"
|
|
104
|
+
);
|
|
105
|
+
AUTH_SCHEME_PATTERN = new RegExp(
|
|
106
|
+
`${B_LEFT}(authorization)${B_RIGHT}(["']?\\s*[:=]\\s*["']?)([A-Za-z][A-Za-z0-9_-]{0,31})[ \\t]+[^\\s"',&)]+`,
|
|
98
107
|
"gi"
|
|
99
108
|
);
|
|
100
109
|
KNOWN_TOKEN_PATTERN = new RegExp(
|
|
101
|
-
`${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}`,
|
|
102
111
|
"g"
|
|
103
112
|
);
|
|
104
113
|
JWT_PATTERN = new RegExp(
|
|
@@ -1561,6 +1570,7 @@ var init_browser_error_utils = __esm({
|
|
|
1561
1570
|
var scraper_utils_exports = {};
|
|
1562
1571
|
__export(scraper_utils_exports, {
|
|
1563
1572
|
cleanConvertedMarkdown: () => cleanConvertedMarkdown,
|
|
1573
|
+
collapseBlankLines: () => collapseBlankLines,
|
|
1564
1574
|
createJsMarkdownConverter: () => createJsMarkdownConverter,
|
|
1565
1575
|
createNativeMarkdownConverter: () => createNativeMarkdownConverter,
|
|
1566
1576
|
disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
|
|
@@ -1751,9 +1761,9 @@ function formatErrorWithCause(err) {
|
|
|
1751
1761
|
function isPrivateIp(ip) {
|
|
1752
1762
|
const parts = ip.split(".").map(Number);
|
|
1753
1763
|
if (parts.length !== 4 || parts.some((p) => isNaN(p))) return false;
|
|
1754
|
-
const [a, b] = parts;
|
|
1764
|
+
const [a, b, c] = parts;
|
|
1755
1765
|
if (a === void 0 || b === void 0) return false;
|
|
1756
|
-
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;
|
|
1757
1767
|
}
|
|
1758
1768
|
function isPrivateIpv6(ip) {
|
|
1759
1769
|
const normalized = ip.toLowerCase().replace(/^\[|\]$/g, "");
|
|
@@ -1815,10 +1825,28 @@ ${kept.join("\n")}
|
|
|
1815
1825
|
${rest}` : rest;
|
|
1816
1826
|
}
|
|
1817
1827
|
function cleanConvertedMarkdown(markdown) {
|
|
1818
|
-
return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown)))
|
|
1828
|
+
return collapseBlankLines(stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown)))).trim();
|
|
1829
|
+
}
|
|
1830
|
+
function collapseBlankLines(markdown) {
|
|
1831
|
+
if (!markdown.includes("\n\n\n")) return markdown;
|
|
1832
|
+
const out = [];
|
|
1833
|
+
let fence = null;
|
|
1834
|
+
for (const line of markdown.split("\n")) {
|
|
1835
|
+
const m = CODE_FENCE.exec(line);
|
|
1836
|
+
if (fence === null) {
|
|
1837
|
+
if (m) fence = m[1];
|
|
1838
|
+
} else if (m && m[1][0] === fence[0] && m[1].length >= fence.length && line.slice(m[0].length).trim() === "") {
|
|
1839
|
+
fence = null;
|
|
1840
|
+
}
|
|
1841
|
+
if (fence === null && line === "" && out.length > 0 && out[out.length - 1] === "") continue;
|
|
1842
|
+
out.push(line);
|
|
1843
|
+
}
|
|
1844
|
+
return out.join("\n");
|
|
1819
1845
|
}
|
|
1820
1846
|
function stripImageLinks(markdown) {
|
|
1821
|
-
return
|
|
1847
|
+
return collapseBlankLines(
|
|
1848
|
+
markdown.replace(MARKDOWN_IMAGE_PATTERN, "").replace(IMAGE_LINK_PATTERN, "$1")
|
|
1849
|
+
).trim();
|
|
1822
1850
|
}
|
|
1823
1851
|
function createNativeMarkdownConverter(nativeModule) {
|
|
1824
1852
|
return async (html) => {
|
|
@@ -1880,7 +1908,7 @@ function validateContent(html, markdown, url) {
|
|
|
1880
1908
|
throw error;
|
|
1881
1909
|
}
|
|
1882
1910
|
}
|
|
1883
|
-
var cachedSsrfFetcher, FRONT_MATTER_SLOTS, MAX_FRONT_MATTER_CHARS, FRONT_MATTER_KEY;
|
|
1911
|
+
var cachedSsrfFetcher, FRONT_MATTER_SLOTS, MAX_FRONT_MATTER_CHARS, FRONT_MATTER_KEY, CODE_FENCE;
|
|
1884
1912
|
var init_scraper_utils = __esm({
|
|
1885
1913
|
"src/web-research/scraper-utils.ts"() {
|
|
1886
1914
|
"use strict";
|
|
@@ -1907,6 +1935,7 @@ var init_scraper_utils = __esm({
|
|
|
1907
1935
|
];
|
|
1908
1936
|
MAX_FRONT_MATTER_CHARS = 2e4;
|
|
1909
1937
|
FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
|
|
1938
|
+
CODE_FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
1910
1939
|
}
|
|
1911
1940
|
});
|
|
1912
1941
|
|
|
@@ -3009,9 +3038,9 @@ async function initBrowser() {
|
|
|
3009
3038
|
logToDebugFile3("INFO", `[Worker-${workerId3}] Initializing browser instance...`);
|
|
3010
3039
|
let CamoufoxModule;
|
|
3011
3040
|
try {
|
|
3012
|
-
CamoufoxModule = await import("camoufox
|
|
3041
|
+
CamoufoxModule = await import("@camoufox/camoufox");
|
|
3013
3042
|
} catch (e) {
|
|
3014
|
-
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 });
|
|
3015
3044
|
}
|
|
3016
3045
|
const { Camoufox } = CamoufoxModule;
|
|
3017
3046
|
const launchTimeoutMs = BROWSER_LAUNCH_TIMEOUT_MS;
|
|
@@ -3117,7 +3146,7 @@ async function initBrowser() {
|
|
|
3117
3146
|
browser = null;
|
|
3118
3147
|
const msg = e instanceof Error ? e.message : String(e);
|
|
3119
3148
|
if (msg.includes("Camoufox is not installed") || msg.includes("Version information not found")) {
|
|
3120
|
-
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 });
|
|
3121
3150
|
}
|
|
3122
3151
|
const errName = e instanceof Error ? e.constructor?.name ?? "" : "";
|
|
3123
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,
|
package/src/knowledge/store.ts
CHANGED
|
@@ -279,7 +279,7 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
279
279
|
|
|
280
280
|
try {
|
|
281
281
|
if (!fs.existsSync(this.options.dbDir)) {
|
|
282
|
-
fs.mkdirSync(this.options.dbDir, { recursive: true });
|
|
282
|
+
fs.mkdirSync(this.options.dbDir, { recursive: true, mode: 0o700 });
|
|
283
283
|
}
|
|
284
284
|
this.db = await (await getLancedb()).connect(this.options.dbDir);
|
|
285
285
|
|
|
@@ -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
|
@@ -126,13 +126,23 @@ const COOKIE_HEADER_PATTERN = new RegExp(
|
|
|
126
126
|
// sort key, a map key) is the accepted trade-off — same one already made for
|
|
127
127
|
// the equally generic bare `session`/`cookie` entries below.
|
|
128
128
|
const SENSITIVE_KV_PATTERN = new RegExp(
|
|
129
|
-
`${B_LEFT}(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)${B_RIGHT}` +
|
|
130
|
-
|
|
129
|
+
`${B_LEFT}(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|token|key)${B_RIGHT}` +
|
|
130
|
+
// A quoted value is consumed whole (bounded, so no backtracking blow-up): the
|
|
131
|
+
// old unquoted class stopped at the first space/comma/&, so `password="a b"`
|
|
132
|
+
// left ` b"` in the clear. An unterminated quote falls through to that class.
|
|
133
|
+
`(["']?\\s*[:=]\\s*)("[^"\\r\\n]{0,1024}"|'[^'\\r\\n]{0,1024}'|["']?[^\\s"',&)]+)`,
|
|
134
|
+
'gi',
|
|
135
|
+
);
|
|
136
|
+
// `Authorization: <scheme> <credential>` for schemes the Bearer/Basic patterns do
|
|
137
|
+
// not know (Token, Digest, ApiKey, ...). The KV pass alone would mask only the
|
|
138
|
+
// scheme word and leave the credential after the space.
|
|
139
|
+
const AUTH_SCHEME_PATTERN = new RegExp(
|
|
140
|
+
`${B_LEFT}(authorization)${B_RIGHT}(["']?\\s*[:=]\\s*["']?)([A-Za-z][A-Za-z0-9_-]{0,31})[ \\t]+[^\\s"',&)]+`,
|
|
131
141
|
'gi',
|
|
132
142
|
);
|
|
133
143
|
// Well-known opaque credential formats (OpenAI, GitHub, AWS, Slack, Anthropic).
|
|
134
144
|
const KNOWN_TOKEN_PATTERN = new RegExp(
|
|
135
|
-
`${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}`,
|
|
136
146
|
'g',
|
|
137
147
|
);
|
|
138
148
|
// JSON Web Tokens — header.payload.signature, each a base64url segment.
|
|
@@ -237,7 +247,12 @@ export function redactSecrets(message: string): string {
|
|
|
237
247
|
// Whole-header cookie masking before the KV pass, which would otherwise stop
|
|
238
248
|
// at the first whitespace and leave every subsequent pair in the clear.
|
|
239
249
|
out = out.replace(COOKIE_HEADER_PATTERN, (_m, key: string, sep: string) => `${key}${sep}[REDACTED]`);
|
|
240
|
-
out = out.replace(
|
|
250
|
+
out = out.replace(AUTH_SCHEME_PATTERN, (_m, key: string, sep: string, scheme: string) => `${key}${sep}${scheme} [REDACTED]`);
|
|
251
|
+
out = out.replace(SENSITIVE_KV_PATTERN, (_m, key: string, sep: string, val: string) => {
|
|
252
|
+
const q = val[0] === '"' || val[0] === "'" ? val[0] : '';
|
|
253
|
+
const closed = q !== '' && val.length > 1 && val.endsWith(q);
|
|
254
|
+
return `${key}${sep}${q}[REDACTED]${closed ? q : ''}`;
|
|
255
|
+
});
|
|
241
256
|
out = out.replace(KNOWN_TOKEN_PATTERN, '[REDACTED]');
|
|
242
257
|
out = out.replace(PROVIDER_TOKEN_PATTERN, '[REDACTED]');
|
|
243
258
|
out = out.replace(LONG_HEX_SECRET_PATTERN, '[REDACTED]');
|
|
@@ -188,7 +188,7 @@ export async function exportResearchReport(
|
|
|
188
188
|
const filepath = join(targetDir, filename);
|
|
189
189
|
|
|
190
190
|
try {
|
|
191
|
-
await fs.writeFile(filepath, result, { flag: 'wx' });
|
|
191
|
+
await fs.writeFile(filepath, result, { flag: 'wx', mode: 0o600 });
|
|
192
192
|
logger.log(`[export] Research report saved to: ${filepath}`);
|
|
193
193
|
return filepath;
|
|
194
194
|
} catch (error) {
|