@lincoln504/pi-research 1.3.3 → 1.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/cli.mjs +242 -95
- package/dist/thread-worker.mjs +122 -50
- package/docs/CHANGELOG.md +76 -0
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/core/service-registry.ts +37 -3
- package/src/healthcheck/index.ts +9 -7
- package/src/infrastructure/browser/browser-client.ts +5 -2
- package/src/infrastructure/browser/task-execution-service.ts +21 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +49 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +70 -25
- package/src/infrastructure/browser/thread-worker.mjs +122 -50
- package/src/infrastructure/knowledge-store-service.ts +27 -2
- package/src/knowledge/chunker.ts +14 -1
- package/src/orchestration/quick-research-orchestrator.ts +13 -2
- package/src/orchestration/research-orchestration-service.ts +16 -3
- package/src/sdk.ts +11 -2
- package/src/security/github-advisories.ts +13 -4
- package/src/security/osv-types.ts +1 -1
- package/src/skill-install/skill-installer.ts +69 -6
- package/src/stackexchange/index.ts +15 -0
- package/src/stackexchange/rest-client.ts +13 -24
- package/src/tools/research-tool-definition.ts +3 -2
- package/src/tools/scrape.ts +6 -4
- package/src/tools/security.ts +13 -4
- package/src/utils/input-validation.ts +0 -10
- package/src/utils/json-utils.ts +0 -78
- package/src/utils/log-rotation.ts +21 -6
- package/src/utils/log-utils.ts +43 -33
- package/src/utils/metrics.ts +0 -8
- package/src/utils/shared-links.ts +0 -14
- package/src/utils/stdio-capture.ts +44 -7
- package/src/utils/text-utils.ts +6 -2
- package/src/utils/url-utils.ts +7 -1
- package/src/web-research/scraper-utils.ts +8 -3
- package/src/web-research/web-scraper.ts +33 -1
- package/src/youtube/potoken.ts +26 -2
- package/src/youtube/transcript-client.ts +5 -2
|
@@ -30,6 +30,12 @@ let activeTaskCount = 0;
|
|
|
30
30
|
// A reset that resetBrowser() deferred because sibling tasks were still
|
|
31
31
|
// active, to be applied once the last one finishes.
|
|
32
32
|
let resetPending = false;
|
|
33
|
+
// The browser instance resetPending was requested against, captured at defer
|
|
34
|
+
// time. An intervening resetBrowser() call from a SIBLING task can take the
|
|
35
|
+
// immediate path (doResetBrowser() + a fresh initBrowser() relaunch) while
|
|
36
|
+
// this flag is still pending — see taskFinished() below for why the flag
|
|
37
|
+
// alone isn't enough to know whether it's still safe to apply.
|
|
38
|
+
let resetPendingForBrowser: any = null;
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
41
|
* Set the worker ID for logging purposes
|
|
@@ -48,7 +54,19 @@ export function taskFinished(): void {
|
|
|
48
54
|
activeTaskCount = Math.max(0, activeTaskCount - 1);
|
|
49
55
|
if (activeTaskCount === 0 && resetPending) {
|
|
50
56
|
resetPending = false;
|
|
51
|
-
|
|
57
|
+
const capturedBrowser = resetPendingForBrowser;
|
|
58
|
+
resetPendingForBrowser = null;
|
|
59
|
+
// A sibling task's OWN crash can call resetBrowser() while this flag is
|
|
60
|
+
// still pending; if that call sees activeTaskCount <= 1 it takes the
|
|
61
|
+
// IMMEDIATE path (doResetBrowser() below), which tears the browser down
|
|
62
|
+
// and lets the next task's initBrowser() relaunch a brand-new, unrelated
|
|
63
|
+
// instance — all without ever touching this flag. Applying a stale
|
|
64
|
+
// deferred reset unconditionally would then tear down that later,
|
|
65
|
+
// healthy instance instead of being the no-op it should be. Only apply
|
|
66
|
+
// it if the browser is still the exact instance it was requested against.
|
|
67
|
+
if (browser === capturedBrowser) {
|
|
68
|
+
doResetBrowser();
|
|
69
|
+
}
|
|
52
70
|
}
|
|
53
71
|
}
|
|
54
72
|
|
|
@@ -175,6 +193,35 @@ export async function initBrowser(): Promise<void> {
|
|
|
175
193
|
colorDepth: 24,
|
|
176
194
|
pixelRatio: 1,
|
|
177
195
|
},
|
|
196
|
+
// Mute all audio output at the engine level. The general scrape
|
|
197
|
+
// path drives a REAL Firefox (Camoufox), and a researcher can be
|
|
198
|
+
// pointed at a youtube.com watch page through the ordinary
|
|
199
|
+
// `scrape` tool (as opposed to the dedicated `youtube_transcript`
|
|
200
|
+
// tool, which never touches the browser) — YouTube watch pages
|
|
201
|
+
// autoplay video with sound, so without an explicit mute the
|
|
202
|
+
// process plays audible audio through the host's sound device.
|
|
203
|
+
//
|
|
204
|
+
// media.volume_scale forces the browser's effective output
|
|
205
|
+
// volume to 0 regardless of per-tab mute/autoplay state or any
|
|
206
|
+
// page script re-enabling audio — the most reliable, engine-level
|
|
207
|
+
// mute available for headless Firefox/Camoufox (the same pref
|
|
208
|
+
// Selenium/Playwright Firefox test grids use to silence CI runs).
|
|
209
|
+
// media.autoplay.default=5 additionally blocks autoplay outright
|
|
210
|
+
// (belt-and-suspenders; some sites route around the volume scale
|
|
211
|
+
// via Web Audio gain nodes). Neither pref affects rendering — the
|
|
212
|
+
// page still loads and paints normally, only the audio output
|
|
213
|
+
// device is silenced.
|
|
214
|
+
//
|
|
215
|
+
// Set on every platform/headless-mode combination, not just
|
|
216
|
+
// Linux: this was only *observed* on Linux (true headless and
|
|
217
|
+
// Xvfb-backed 'virtual' mode both retain a real PulseAudio/
|
|
218
|
+
// PipeWire/ALSA sink for the Firefox process to play through),
|
|
219
|
+
// but headless Firefox on macOS/Windows is not guaranteed to be
|
|
220
|
+
// silent either, so the mute is applied unconditionally.
|
|
221
|
+
firefox_user_prefs: {
|
|
222
|
+
'media.volume_scale': '0.0',
|
|
223
|
+
'media.autoplay.default': 5,
|
|
224
|
+
},
|
|
178
225
|
});
|
|
179
226
|
// Guard with a hard timeout. We catch the launchPromise rejection so it
|
|
180
227
|
// doesn't surface as an UnhandledPromiseRejection if it rejects after the
|
|
@@ -301,6 +348,7 @@ function doResetBrowser(): void {
|
|
|
301
348
|
export function resetBrowser(): void {
|
|
302
349
|
if (activeTaskCount > 1) {
|
|
303
350
|
resetPending = true;
|
|
351
|
+
resetPendingForBrowser = browser;
|
|
304
352
|
return;
|
|
305
353
|
}
|
|
306
354
|
doResetBrowser();
|
|
@@ -16,35 +16,64 @@ import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts
|
|
|
16
16
|
*/
|
|
17
17
|
let pageCreationLock = Promise.resolve();
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** How long createPageSafe() waits for a straggling newPage() before force-releasing
|
|
20
|
+
* the mutex. Must stay well above the 60s soft timeout below — it exists only to
|
|
21
|
+
* bound the pathological case where newPage() never settles at all (see comment below). */
|
|
22
|
+
const PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** Exported for unit testing of the page-creation mutex's timeout behavior. */
|
|
25
|
+
export async function createPageSafe(context: any): Promise<any> {
|
|
20
26
|
let release!: () => void;
|
|
21
27
|
const nextLock = new Promise<void>(resolve => { release = resolve; });
|
|
22
28
|
const currentLock = pageCreationLock;
|
|
23
29
|
pageCreationLock = currentLock.then(() => nextLock);
|
|
24
30
|
|
|
25
31
|
await currentLock;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
} finally {
|
|
32
|
+
const pagePromise = context.newPage();
|
|
33
|
+
// Release the mutex once newPage() itself has actually settled — NOT when the
|
|
34
|
+
// timeout race below settles. Releasing in an outer `finally` (the old shape)
|
|
35
|
+
// fired as soon as the 60s timeout won the race, while newPage() was still
|
|
36
|
+
// pending in the background — so the NEXT queued caller started its OWN
|
|
37
|
+
// newPage() concurrently with this still-unsettled one, exactly the concurrent-
|
|
38
|
+
// newPage() scenario this lock exists to prevent (see the comment above), and
|
|
39
|
+
// it happens precisely when the browser is already under the stress that
|
|
40
|
+
// causes 60s+ page-creation stalls in the first place. Attached before the
|
|
41
|
+
// race starts so it's independent of which branch below returns/throws.
|
|
42
|
+
//
|
|
43
|
+
// A hard release timer bounds the case where newPage() never settles at all
|
|
44
|
+
// (rather than just being slow) — without it, a single wedged newPage() call
|
|
45
|
+
// would hold this lock forever and livelock every future createPageSafe() call
|
|
46
|
+
// on this worker thread.
|
|
47
|
+
let hardReleased = false;
|
|
48
|
+
const releaseOnce = (): void => {
|
|
49
|
+
if (hardReleased) return;
|
|
50
|
+
hardReleased = true;
|
|
51
|
+
clearTimeout(hardReleaseTimer);
|
|
47
52
|
release();
|
|
53
|
+
};
|
|
54
|
+
const hardReleaseTimer = setTimeout(() => {
|
|
55
|
+
logToDebugFile('WARN', '[ThreadWorker] page-creation mutex force-released — newPage() did not settle within the hard timeout');
|
|
56
|
+
releaseOnce();
|
|
57
|
+
}, PAGE_CREATION_HARD_RELEASE_MS);
|
|
58
|
+
pagePromise.then(releaseOnce, releaseOnce);
|
|
59
|
+
pagePromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background page creation rejection: ${err.message}`));
|
|
60
|
+
|
|
61
|
+
let timeoutId: NodeJS.Timeout | undefined;
|
|
62
|
+
try {
|
|
63
|
+
const result = await Promise.race([
|
|
64
|
+
pagePromise,
|
|
65
|
+
new Promise<never>((_, reject) => {
|
|
66
|
+
timeoutId = setTimeout(() => reject(new Error('Browser page creation timed out after 60000ms')), 60000);
|
|
67
|
+
})
|
|
68
|
+
]);
|
|
69
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
70
|
+
return result;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
73
|
+
// The timeout (or another error) won the race. If newPage() later resolves,
|
|
74
|
+
// close that orphaned page so it doesn't accumulate in the long-lived context.
|
|
75
|
+
pagePromise.then((p: any) => { try { p?.close?.(); } catch { /* already gone */ } }).catch(() => {});
|
|
76
|
+
throw err;
|
|
48
77
|
}
|
|
49
78
|
}
|
|
50
79
|
|
|
@@ -55,6 +84,22 @@ export function setWorkerId(id: string): void {
|
|
|
55
84
|
workerId = id;
|
|
56
85
|
}
|
|
57
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Parse a millisecond timeout from an env var. Uses Number() rather than
|
|
89
|
+
* parseInt() so trailing garbage is REJECTED rather than silently truncated —
|
|
90
|
+
* parseInt('1m', 10) === 1, which turned a plausible duration-shorthand typo
|
|
91
|
+
* into a near-instant timeout instead of falling back to the default.
|
|
92
|
+
*/
|
|
93
|
+
function parseTimeoutMs(envVar: string | undefined, def: number): number {
|
|
94
|
+
if (envVar === undefined || envVar.trim() === '') return def;
|
|
95
|
+
const n = Number(envVar.trim());
|
|
96
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
97
|
+
logToDebugFile('WARN', `[ThreadWorker] Invalid timeout value "${envVar}", using default: ${def}ms`);
|
|
98
|
+
return def;
|
|
99
|
+
}
|
|
100
|
+
return n;
|
|
101
|
+
}
|
|
102
|
+
|
|
58
103
|
/**
|
|
59
104
|
* Log to debug file
|
|
60
105
|
*/
|
|
@@ -197,7 +242,7 @@ export async function executeSearchTask(
|
|
|
197
242
|
query: string
|
|
198
243
|
): Promise<{ results: any[]; jitter: number }> {
|
|
199
244
|
const page = await createPageSafe(_context);
|
|
200
|
-
const SEARCH_TIMEOUT =
|
|
245
|
+
const SEARCH_TIMEOUT = parseTimeoutMs(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'], 45000);
|
|
201
246
|
page.setDefaultTimeout(SEARCH_TIMEOUT);
|
|
202
247
|
page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
|
|
203
248
|
|
|
@@ -314,7 +359,7 @@ export async function executeScrapeTask(
|
|
|
314
359
|
signal?: AbortSignal
|
|
315
360
|
): Promise<{ contentType: string; html?: string; bufferB64?: string; jitter: number }> {
|
|
316
361
|
const page = await createPageSafe(_context);
|
|
317
|
-
const SCRAPE_TIMEOUT =
|
|
362
|
+
const SCRAPE_TIMEOUT = parseTimeoutMs(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'], 15000);
|
|
318
363
|
page.setDefaultTimeout(SCRAPE_TIMEOUT);
|
|
319
364
|
page.setDefaultNavigationTimeout(SCRAPE_TIMEOUT);
|
|
320
365
|
|
|
@@ -658,7 +703,7 @@ export async function executeHealthCheck(
|
|
|
658
703
|
): Promise<{ success: boolean; navMs: number }> {
|
|
659
704
|
// Nav timeout: read from env (passed through getBrowserEnv), floor at 10s.
|
|
660
705
|
// The outer BrowserTaskScheduler.runHealthCheck() has its own 45s hard deadline.
|
|
661
|
-
const configuredMs =
|
|
706
|
+
const configuredMs = parseTimeoutMs(process.env['PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS'], 0);
|
|
662
707
|
const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(10000, configuredMs) : 10000;
|
|
663
708
|
|
|
664
709
|
if (initMs > 3000) {
|
|
@@ -45,12 +45,8 @@ function formatArg(arg) {
|
|
|
45
45
|
return String(arg);
|
|
46
46
|
}
|
|
47
47
|
function redactSecrets(message) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const lastKeptUnit = message.charCodeAt(keep - 1);
|
|
51
|
-
if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
|
|
52
|
-
}
|
|
53
|
-
let out = message.length > MAX_LOG_MESSAGE_LENGTH ? `${message.slice(0, keep)}\u2026[truncated ${message.length - keep} chars]` : message;
|
|
48
|
+
const rawLength = message.length;
|
|
49
|
+
let out = rawLength > REDACT_SCAN_LENGTH ? message.slice(0, REDACT_SCAN_LENGTH) : message;
|
|
54
50
|
out = out.replace(ANSI_PATTERN, "");
|
|
55
51
|
out = out.replace(URL_CREDENTIALS_PATTERN, "$1[REDACTED]@");
|
|
56
52
|
out = out.replace(JWT_PATTERN, "[REDACTED]");
|
|
@@ -61,12 +57,20 @@ function redactSecrets(message) {
|
|
|
61
57
|
out = out.replace(KNOWN_TOKEN_PATTERN, "[REDACTED]");
|
|
62
58
|
out = out.replace(PROVIDER_TOKEN_PATTERN, "[REDACTED]");
|
|
63
59
|
out = out.replace(LONG_HEX_SECRET_PATTERN, "[REDACTED]");
|
|
60
|
+
let keep = MAX_LOG_MESSAGE_LENGTH;
|
|
61
|
+
if (out.length > keep) {
|
|
62
|
+
const lastKeptUnit = out.charCodeAt(keep - 1);
|
|
63
|
+
if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
|
|
64
|
+
}
|
|
65
|
+
if (out.length > MAX_LOG_MESSAGE_LENGTH) {
|
|
66
|
+
out = `${out.slice(0, keep)}\u2026[truncated ${rawLength - keep} chars]`;
|
|
67
|
+
}
|
|
64
68
|
return out;
|
|
65
69
|
}
|
|
66
70
|
function stripTerminalEscapes(message) {
|
|
67
71
|
return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
|
|
68
72
|
}
|
|
69
|
-
var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, 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, TERMINAL_ESCAPE_PATTERN;
|
|
73
|
+
var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, 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, TERMINAL_ESCAPE_PATTERN;
|
|
70
74
|
var init_log_utils = __esm({
|
|
71
75
|
"src/utils/log-utils.ts"() {
|
|
72
76
|
"use strict";
|
|
@@ -75,7 +79,7 @@ var init_log_utils = __esm({
|
|
|
75
79
|
ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
76
80
|
URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
|
|
77
81
|
COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
|
|
78
|
-
SENSITIVE_KV_PATTERN = /\b(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)\b(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
|
|
82
|
+
SENSITIVE_KV_PATTERN = /\b(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(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
|
|
79
83
|
KNOWN_TOKEN_PATTERN = /\b(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/g;
|
|
80
84
|
JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
|
81
85
|
BASIC_AUTH_PATTERN = /\bBasic\s+[A-Za-z0-9+/]{16,}={0,2}/gi;
|
|
@@ -83,6 +87,7 @@ var init_log_utils = __esm({
|
|
|
83
87
|
PROVIDER_TOKEN_PATTERN = /\b(AIza[0-9A-Za-z_-]{35}|ya29\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})\b/g;
|
|
84
88
|
LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
|
|
85
89
|
CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
|
|
90
|
+
REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
|
|
86
91
|
TERMINAL_ESCAPE_PATTERN = new RegExp(
|
|
87
92
|
"\\x1b\\[[0-?]*[ -/]*[@-~]?|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?|\\x1b.?",
|
|
88
93
|
"g"
|
|
@@ -354,10 +359,17 @@ var init_error_tracker = __esm({
|
|
|
354
359
|
// src/utils/log-rotation.ts
|
|
355
360
|
import * as fs2 from "node:fs";
|
|
356
361
|
import * as path2 from "node:path";
|
|
357
|
-
|
|
362
|
+
function touchOwnerOnly(filePath, mode) {
|
|
363
|
+
try {
|
|
364
|
+
fs2.closeSync(fs2.openSync(filePath, APPEND_CREATE_NOFOLLOW_FLAGS, mode));
|
|
365
|
+
} catch {
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
var APPEND_CREATE_NOFOLLOW_FLAGS, LogRotation;
|
|
358
369
|
var init_log_rotation = __esm({
|
|
359
370
|
"src/utils/log-rotation.ts"() {
|
|
360
371
|
"use strict";
|
|
372
|
+
APPEND_CREATE_NOFOLLOW_FLAGS = fs2.constants.O_WRONLY | fs2.constants.O_APPEND | fs2.constants.O_CREAT | (typeof fs2.constants.O_NOFOLLOW === "number" ? fs2.constants.O_NOFOLLOW : 0);
|
|
361
373
|
LogRotation = class {
|
|
362
374
|
MAX_LOG_SIZE = 10 * 1024 * 1024;
|
|
363
375
|
// 10MB max file size
|
|
@@ -375,10 +387,7 @@ var init_log_rotation = __esm({
|
|
|
375
387
|
try {
|
|
376
388
|
if (fs2.existsSync(logFile)) {
|
|
377
389
|
fs2.unlinkSync(logFile);
|
|
378
|
-
|
|
379
|
-
fs2.closeSync(fs2.openSync(logFile, "a", 384));
|
|
380
|
-
} catch {
|
|
381
|
-
}
|
|
390
|
+
touchOwnerOnly(logFile, 384);
|
|
382
391
|
try {
|
|
383
392
|
fs2.chmodSync(logFile, 384);
|
|
384
393
|
} catch {
|
|
@@ -418,10 +427,7 @@ var init_log_rotation = __esm({
|
|
|
418
427
|
this.logger.log("[Logger] Log rotation skipped (rename failed; file likely in use):", renameErr);
|
|
419
428
|
return;
|
|
420
429
|
}
|
|
421
|
-
|
|
422
|
-
fs2.closeSync(fs2.openSync(logFile, "a", 384));
|
|
423
|
-
} catch {
|
|
424
|
-
}
|
|
430
|
+
touchOwnerOnly(logFile, 384);
|
|
425
431
|
try {
|
|
426
432
|
fs2.chmodSync(logFile, 384);
|
|
427
433
|
} catch {
|
|
@@ -588,7 +594,10 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
|
|
|
588
594
|
unkeyedCaptureDepth++;
|
|
589
595
|
isAnyLoggerCapturingOutput = true;
|
|
590
596
|
}
|
|
591
|
-
const
|
|
597
|
+
const stderrDecoder = new TextDecoder();
|
|
598
|
+
const stdoutDecoder = new TextDecoder();
|
|
599
|
+
const fd1SyncDecoder = new TextDecoder();
|
|
600
|
+
const fd2SyncDecoder = new TextDecoder();
|
|
592
601
|
const originalStderrWrite = process.stderr.write;
|
|
593
602
|
const originalStdoutWrite = process.stdout.write;
|
|
594
603
|
const originalFsWriteSync = fs4.writeSync;
|
|
@@ -634,7 +643,11 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
|
|
|
634
643
|
console.debug = createConsolePatch("debug", logFile, hasSufficientDiskSpace);
|
|
635
644
|
process.stderr.write = (chunk, encodingOrCb, callback) => {
|
|
636
645
|
const cb = typeof encodingOrCb === "function" ? encodingOrCb : callback;
|
|
637
|
-
const message = typeof chunk === "string" ? chunk :
|
|
646
|
+
const message = typeof chunk === "string" ? chunk : stderrDecoder.decode(chunk, { stream: true });
|
|
647
|
+
if (message.length === 0) {
|
|
648
|
+
if (typeof cb === "function") cb();
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
638
651
|
if (!hasSufficientDiskSpace()) {
|
|
639
652
|
if (typeof cb === "function") cb();
|
|
640
653
|
return true;
|
|
@@ -655,7 +668,7 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
|
|
|
655
668
|
return true;
|
|
656
669
|
};
|
|
657
670
|
process.stdout.write = (chunk, encodingOrCb, callback) => {
|
|
658
|
-
const message = typeof chunk === "string" ? chunk :
|
|
671
|
+
const message = typeof chunk === "string" ? chunk : stdoutDecoder.decode(chunk, { stream: true });
|
|
659
672
|
const isNative = isNativeLog(message);
|
|
660
673
|
const complexTui = isComplexTui(message);
|
|
661
674
|
const boxDrawing = isBoxDrawing(message);
|
|
@@ -709,7 +722,11 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
|
|
|
709
722
|
if (!descriptor || (descriptor.writable || descriptor.set)) {
|
|
710
723
|
fs4.writeSync = (fd, chunk, ...args) => {
|
|
711
724
|
if (fd === 1 || fd === 2) {
|
|
712
|
-
const
|
|
725
|
+
const fdDecoder = fd === 1 ? fd1SyncDecoder : fd2SyncDecoder;
|
|
726
|
+
const message = typeof chunk === "string" ? chunk : fdDecoder.decode(chunk, { stream: true });
|
|
727
|
+
if (fd === 2 && message.length === 0) {
|
|
728
|
+
return typeof chunk === "string" ? Buffer.from(chunk).length : chunk.length;
|
|
729
|
+
}
|
|
713
730
|
const isNative = isNativeLog(message);
|
|
714
731
|
const boxDrawing = isBoxDrawing(message);
|
|
715
732
|
const complexTui = isComplexTui(message);
|
|
@@ -1489,6 +1506,7 @@ function isBenignScrapeFailure(error) {
|
|
|
1489
1506
|
return msg.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
|
|
1490
1507
|
msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
|
|
1491
1508
|
msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
|
|
1509
|
+
msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
|
|
1492
1510
|
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
1493
1511
|
isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
|
|
1494
1512
|
}
|
|
@@ -1906,6 +1924,7 @@ init_scraper_types();
|
|
|
1906
1924
|
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
1907
1925
|
var workerId2 = "";
|
|
1908
1926
|
var pageCreationLock = Promise.resolve();
|
|
1927
|
+
var PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1e3;
|
|
1909
1928
|
async function createPageSafe(context2) {
|
|
1910
1929
|
let release;
|
|
1911
1930
|
const nextLock = new Promise((resolve) => {
|
|
@@ -1914,37 +1933,54 @@ async function createPageSafe(context2) {
|
|
|
1914
1933
|
const currentLock = pageCreationLock;
|
|
1915
1934
|
pageCreationLock = currentLock.then(() => nextLock);
|
|
1916
1935
|
await currentLock;
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
pagePromise,
|
|
1924
|
-
new Promise((_, reject) => {
|
|
1925
|
-
timeoutId = setTimeout(() => reject(new Error("Browser page creation timed out after 60000ms")), 6e4);
|
|
1926
|
-
})
|
|
1927
|
-
]);
|
|
1928
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
1929
|
-
return result;
|
|
1930
|
-
} catch (err) {
|
|
1931
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
1932
|
-
pagePromise.then((p) => {
|
|
1933
|
-
try {
|
|
1934
|
-
p?.close?.();
|
|
1935
|
-
} catch {
|
|
1936
|
-
}
|
|
1937
|
-
}).catch(() => {
|
|
1938
|
-
});
|
|
1939
|
-
throw err;
|
|
1940
|
-
}
|
|
1941
|
-
} finally {
|
|
1936
|
+
const pagePromise = context2.newPage();
|
|
1937
|
+
let hardReleased = false;
|
|
1938
|
+
const releaseOnce = () => {
|
|
1939
|
+
if (hardReleased) return;
|
|
1940
|
+
hardReleased = true;
|
|
1941
|
+
clearTimeout(hardReleaseTimer);
|
|
1942
1942
|
release();
|
|
1943
|
+
};
|
|
1944
|
+
const hardReleaseTimer = setTimeout(() => {
|
|
1945
|
+
logToDebugFile2("WARN", "[ThreadWorker] page-creation mutex force-released \u2014 newPage() did not settle within the hard timeout");
|
|
1946
|
+
releaseOnce();
|
|
1947
|
+
}, PAGE_CREATION_HARD_RELEASE_MS);
|
|
1948
|
+
pagePromise.then(releaseOnce, releaseOnce);
|
|
1949
|
+
pagePromise.catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Background page creation rejection: ${err.message}`));
|
|
1950
|
+
let timeoutId;
|
|
1951
|
+
try {
|
|
1952
|
+
const result = await Promise.race([
|
|
1953
|
+
pagePromise,
|
|
1954
|
+
new Promise((_, reject) => {
|
|
1955
|
+
timeoutId = setTimeout(() => reject(new Error("Browser page creation timed out after 60000ms")), 6e4);
|
|
1956
|
+
})
|
|
1957
|
+
]);
|
|
1958
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1959
|
+
return result;
|
|
1960
|
+
} catch (err) {
|
|
1961
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1962
|
+
pagePromise.then((p) => {
|
|
1963
|
+
try {
|
|
1964
|
+
p?.close?.();
|
|
1965
|
+
} catch {
|
|
1966
|
+
}
|
|
1967
|
+
}).catch(() => {
|
|
1968
|
+
});
|
|
1969
|
+
throw err;
|
|
1943
1970
|
}
|
|
1944
1971
|
}
|
|
1945
1972
|
function setWorkerId2(id) {
|
|
1946
1973
|
workerId2 = id;
|
|
1947
1974
|
}
|
|
1975
|
+
function parseTimeoutMs(envVar, def) {
|
|
1976
|
+
if (envVar === void 0 || envVar.trim() === "") return def;
|
|
1977
|
+
const n = Number(envVar.trim());
|
|
1978
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
1979
|
+
logToDebugFile2("WARN", `[ThreadWorker] Invalid timeout value "${envVar}", using default: ${def}ms`);
|
|
1980
|
+
return def;
|
|
1981
|
+
}
|
|
1982
|
+
return n;
|
|
1983
|
+
}
|
|
1948
1984
|
function logToDebugFile2(level, ...args) {
|
|
1949
1985
|
const logFile = process.env["PI_RESEARCH_LOG_FILE"];
|
|
1950
1986
|
if (!logFile) return;
|
|
@@ -2054,7 +2090,7 @@ async function extractSearchResults(page) {
|
|
|
2054
2090
|
}
|
|
2055
2091
|
async function executeSearchTask(_context, query) {
|
|
2056
2092
|
const page = await createPageSafe(_context);
|
|
2057
|
-
const SEARCH_TIMEOUT =
|
|
2093
|
+
const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
|
|
2058
2094
|
page.setDefaultTimeout(SEARCH_TIMEOUT);
|
|
2059
2095
|
page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
|
|
2060
2096
|
try {
|
|
@@ -2123,7 +2159,7 @@ function seedSubresourceVerdict(validatedUrl) {
|
|
|
2123
2159
|
}
|
|
2124
2160
|
async function executeScrapeTask(_context, url, signal) {
|
|
2125
2161
|
const page = await createPageSafe(_context);
|
|
2126
|
-
const SCRAPE_TIMEOUT =
|
|
2162
|
+
const SCRAPE_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"], 15e3);
|
|
2127
2163
|
page.setDefaultTimeout(SCRAPE_TIMEOUT);
|
|
2128
2164
|
page.setDefaultNavigationTimeout(SCRAPE_TIMEOUT);
|
|
2129
2165
|
const onAbort = () => {
|
|
@@ -2289,7 +2325,7 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
|
|
|
2289
2325
|
}
|
|
2290
2326
|
}
|
|
2291
2327
|
async function executeHealthCheck(_context, initMs) {
|
|
2292
|
-
const configuredMs =
|
|
2328
|
+
const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
|
|
2293
2329
|
const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
|
|
2294
2330
|
if (initMs > 3e3) {
|
|
2295
2331
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
|
|
@@ -2339,6 +2375,7 @@ init_logger();
|
|
|
2339
2375
|
|
|
2340
2376
|
// src/utils/url-utils.ts
|
|
2341
2377
|
init_logger();
|
|
2378
|
+
init_log_utils();
|
|
2342
2379
|
|
|
2343
2380
|
// src/config.ts
|
|
2344
2381
|
var ConfigSchema = Type.Object({
|
|
@@ -2508,6 +2545,7 @@ var initPromise = null;
|
|
|
2508
2545
|
var workerId3 = "";
|
|
2509
2546
|
var activeTaskCount = 0;
|
|
2510
2547
|
var resetPending = false;
|
|
2548
|
+
var resetPendingForBrowser = null;
|
|
2511
2549
|
function setWorkerId3(id) {
|
|
2512
2550
|
workerId3 = id;
|
|
2513
2551
|
}
|
|
@@ -2518,7 +2556,11 @@ function taskFinished() {
|
|
|
2518
2556
|
activeTaskCount = Math.max(0, activeTaskCount - 1);
|
|
2519
2557
|
if (activeTaskCount === 0 && resetPending) {
|
|
2520
2558
|
resetPending = false;
|
|
2521
|
-
|
|
2559
|
+
const capturedBrowser = resetPendingForBrowser;
|
|
2560
|
+
resetPendingForBrowser = null;
|
|
2561
|
+
if (browser === capturedBrowser) {
|
|
2562
|
+
doResetBrowser();
|
|
2563
|
+
}
|
|
2522
2564
|
}
|
|
2523
2565
|
}
|
|
2524
2566
|
var isBrowserConnected = () => {
|
|
@@ -2576,6 +2618,35 @@ async function initBrowser() {
|
|
|
2576
2618
|
height: 1080,
|
|
2577
2619
|
colorDepth: 24,
|
|
2578
2620
|
pixelRatio: 1
|
|
2621
|
+
},
|
|
2622
|
+
// Mute all audio output at the engine level. The general scrape
|
|
2623
|
+
// path drives a REAL Firefox (Camoufox), and a researcher can be
|
|
2624
|
+
// pointed at a youtube.com watch page through the ordinary
|
|
2625
|
+
// `scrape` tool (as opposed to the dedicated `youtube_transcript`
|
|
2626
|
+
// tool, which never touches the browser) — YouTube watch pages
|
|
2627
|
+
// autoplay video with sound, so without an explicit mute the
|
|
2628
|
+
// process plays audible audio through the host's sound device.
|
|
2629
|
+
//
|
|
2630
|
+
// media.volume_scale forces the browser's effective output
|
|
2631
|
+
// volume to 0 regardless of per-tab mute/autoplay state or any
|
|
2632
|
+
// page script re-enabling audio — the most reliable, engine-level
|
|
2633
|
+
// mute available for headless Firefox/Camoufox (the same pref
|
|
2634
|
+
// Selenium/Playwright Firefox test grids use to silence CI runs).
|
|
2635
|
+
// media.autoplay.default=5 additionally blocks autoplay outright
|
|
2636
|
+
// (belt-and-suspenders; some sites route around the volume scale
|
|
2637
|
+
// via Web Audio gain nodes). Neither pref affects rendering — the
|
|
2638
|
+
// page still loads and paints normally, only the audio output
|
|
2639
|
+
// device is silenced.
|
|
2640
|
+
//
|
|
2641
|
+
// Set on every platform/headless-mode combination, not just
|
|
2642
|
+
// Linux: this was only *observed* on Linux (true headless and
|
|
2643
|
+
// Xvfb-backed 'virtual' mode both retain a real PulseAudio/
|
|
2644
|
+
// PipeWire/ALSA sink for the Firefox process to play through),
|
|
2645
|
+
// but headless Firefox on macOS/Windows is not guaranteed to be
|
|
2646
|
+
// silent either, so the mute is applied unconditionally.
|
|
2647
|
+
firefox_user_prefs: {
|
|
2648
|
+
"media.volume_scale": "0.0",
|
|
2649
|
+
"media.autoplay.default": 5
|
|
2579
2650
|
}
|
|
2580
2651
|
});
|
|
2581
2652
|
launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
|
|
@@ -2655,6 +2726,7 @@ function doResetBrowser() {
|
|
|
2655
2726
|
function resetBrowser() {
|
|
2656
2727
|
if (activeTaskCount > 1) {
|
|
2657
2728
|
resetPending = true;
|
|
2729
|
+
resetPendingForBrowser = browser;
|
|
2658
2730
|
return;
|
|
2659
2731
|
}
|
|
2660
2732
|
doResetBrowser();
|
|
@@ -311,6 +311,17 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
|
|
|
311
311
|
} catch {
|
|
312
312
|
// init failed; its own catch already reset state — nothing to wait on.
|
|
313
313
|
}
|
|
314
|
+
// Re-check: a SECOND concurrent dispose() call can reach this same point
|
|
315
|
+
// (its own _disposalPromise check above also saw null, before either of
|
|
316
|
+
// us had published one) and, if it settles the await first, publish its
|
|
317
|
+
// own disposal while we were still awaiting. Without this, both calls
|
|
318
|
+
// would go on to capture the SAME embedder/store/writerQueue/initLock
|
|
319
|
+
// references below and each independently call .dispose()/.close() on
|
|
320
|
+
// them — the same double-teardown-of-one-instance class of bug fixed at
|
|
321
|
+
// the ServiceContainer level (disposeAll() vs clear()/replace()).
|
|
322
|
+
if (this._disposalPromise) {
|
|
323
|
+
return this._disposalPromise;
|
|
324
|
+
}
|
|
314
325
|
}
|
|
315
326
|
|
|
316
327
|
this.lifecycle = ServiceLifecycle.DISPOSING;
|
|
@@ -330,7 +341,10 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
|
|
|
330
341
|
const writerQueue = this._writerQueue;
|
|
331
342
|
const initLock = this._initLock;
|
|
332
343
|
|
|
333
|
-
|
|
344
|
+
// Named locally (mirrors initialize()'s initPromise) so the finally below
|
|
345
|
+
// can identity-check before clearing — see there for why.
|
|
346
|
+
let disposePromise: Promise<void> | null = null;
|
|
347
|
+
disposePromise = (async () => {
|
|
334
348
|
try {
|
|
335
349
|
if (writerQueue) {
|
|
336
350
|
await writerQueue.dispose?.();
|
|
@@ -370,9 +384,20 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
|
|
|
370
384
|
logger.error('[KnowledgeStoreService] Error during disposal:', err);
|
|
371
385
|
} finally {
|
|
372
386
|
this.lifecycle = ServiceLifecycle.DISPOSED;
|
|
373
|
-
|
|
387
|
+
// Identity-guarded, mirroring initialize()'s own marker clear (see its
|
|
388
|
+
// finally): only the disposal that still owns _disposalPromise may
|
|
389
|
+
// null it. The re-check above closes the common race, but this is the
|
|
390
|
+
// same defense-in-depth initialize() uses — without it, a stale
|
|
391
|
+
// closure's completion could null a NEWER disposal's still-in-flight
|
|
392
|
+
// marker, making a concurrent initialize() believe nothing is
|
|
393
|
+
// disposing and rebuild components while that newer disposal is still
|
|
394
|
+
// tearing them down.
|
|
395
|
+
if (this._disposalPromise === disposePromise) {
|
|
396
|
+
this._disposalPromise = null;
|
|
397
|
+
}
|
|
374
398
|
}
|
|
375
399
|
})();
|
|
400
|
+
this._disposalPromise = disposePromise;
|
|
376
401
|
|
|
377
402
|
return this._disposalPromise;
|
|
378
403
|
}
|
package/src/knowledge/chunker.ts
CHANGED
|
@@ -209,12 +209,25 @@ export class Chunker {
|
|
|
209
209
|
// pair's high half is guaranteed already present in the chunk just
|
|
210
210
|
// pushed, so skipping the low half here loses nothing, it just narrows
|
|
211
211
|
// this chunk's overlap by one code unit instead of widening it.
|
|
212
|
+
//
|
|
213
|
+
// The push-forward bound is `<= end`, not `< end`: landing exactly on
|
|
214
|
+
// `end` (start === end, zero overlap for this transition) is a
|
|
215
|
+
// perfectly safe position — `end` is always a valid index up to
|
|
216
|
+
// text.length, and the NEXT chunk's own bounds are recomputed fresh
|
|
217
|
+
// from `start` at the top of the loop, independent of this `end`. A
|
|
218
|
+
// strict `<` excluded that boundary case, so at tight targetSize/
|
|
219
|
+
// overlap ratios (e.g. targetSize:2, overlap:1, straddling a surrogate
|
|
220
|
+
// pair) BOTH escapes could be simultaneously blocked — the pull-back
|
|
221
|
+
// correctly refused (it would violate forward-progress) and the
|
|
222
|
+
// push-forward wrongly refused too (start+1 landed exactly on `end`) —
|
|
223
|
+
// leaving `start` unmoved on the lone low surrogate, corrupting the
|
|
224
|
+
// next chunk's opening character.
|
|
212
225
|
if (start > 0) {
|
|
213
226
|
const code = text.charCodeAt(start);
|
|
214
227
|
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
215
228
|
if (start - 1 > iterationStart) {
|
|
216
229
|
start -= 1;
|
|
217
|
-
} else if (start + 1
|
|
230
|
+
} else if (start + 1 <= end) {
|
|
218
231
|
start += 1;
|
|
219
232
|
}
|
|
220
233
|
}
|