@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.
Files changed (39) hide show
  1. package/README.md +7 -2
  2. package/dist/cli.mjs +242 -95
  3. package/dist/thread-worker.mjs +122 -50
  4. package/docs/CHANGELOG.md +76 -0
  5. package/package.json +1 -1
  6. package/skills/pi-research/SKILL.md +1 -1
  7. package/src/core/service-registry.ts +37 -3
  8. package/src/healthcheck/index.ts +9 -7
  9. package/src/infrastructure/browser/browser-client.ts +5 -2
  10. package/src/infrastructure/browser/task-execution-service.ts +21 -5
  11. package/src/infrastructure/browser/thread-worker-browser.ts +49 -1
  12. package/src/infrastructure/browser/thread-worker-messaging.ts +70 -25
  13. package/src/infrastructure/browser/thread-worker.mjs +122 -50
  14. package/src/infrastructure/knowledge-store-service.ts +27 -2
  15. package/src/knowledge/chunker.ts +14 -1
  16. package/src/orchestration/quick-research-orchestrator.ts +13 -2
  17. package/src/orchestration/research-orchestration-service.ts +16 -3
  18. package/src/sdk.ts +11 -2
  19. package/src/security/github-advisories.ts +13 -4
  20. package/src/security/osv-types.ts +1 -1
  21. package/src/skill-install/skill-installer.ts +69 -6
  22. package/src/stackexchange/index.ts +15 -0
  23. package/src/stackexchange/rest-client.ts +13 -24
  24. package/src/tools/research-tool-definition.ts +3 -2
  25. package/src/tools/scrape.ts +6 -4
  26. package/src/tools/security.ts +13 -4
  27. package/src/utils/input-validation.ts +0 -10
  28. package/src/utils/json-utils.ts +0 -78
  29. package/src/utils/log-rotation.ts +21 -6
  30. package/src/utils/log-utils.ts +43 -33
  31. package/src/utils/metrics.ts +0 -8
  32. package/src/utils/shared-links.ts +0 -14
  33. package/src/utils/stdio-capture.ts +44 -7
  34. package/src/utils/text-utils.ts +6 -2
  35. package/src/utils/url-utils.ts +7 -1
  36. package/src/web-research/scraper-utils.ts +8 -3
  37. package/src/web-research/web-scraper.ts +33 -1
  38. package/src/youtube/potoken.ts +26 -2
  39. 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
- doResetBrowser();
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
- async function createPageSafe(context: any): Promise<any> {
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
- try {
27
- const pagePromise = context.newPage();
28
- pagePromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background page creation rejection: ${err.message}`));
29
- let timeoutId: NodeJS.Timeout | undefined;
30
- try {
31
- const result = await Promise.race([
32
- pagePromise,
33
- new Promise<never>((_, reject) => {
34
- timeoutId = setTimeout(() => reject(new Error('Browser page creation timed out after 60000ms')), 60000);
35
- })
36
- ]);
37
- if (timeoutId) clearTimeout(timeoutId);
38
- return result;
39
- } catch (err) {
40
- if (timeoutId) clearTimeout(timeoutId);
41
- // The timeout (or another error) won the race. If newPage() later resolves,
42
- // close that orphaned page so it doesn't accumulate in the long-lived context.
43
- pagePromise.then((p: any) => { try { p?.close?.(); } catch { /* already gone */ } }).catch(() => {});
44
- throw err;
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 = parseInt(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'] || '45000', 10);
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 = parseInt(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'] || '15000', 10);
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 = parseInt(process.env['PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS'] || '0', 10);
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
- let keep = MAX_LOG_MESSAGE_LENGTH;
49
- if (message.length > keep) {
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
- var LogRotation;
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
- try {
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
- try {
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 decoder = new TextDecoder();
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 : decoder.decode(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 : decoder.decode(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 message = typeof chunk === "string" ? chunk : decoder.decode(chunk);
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
- try {
1918
- const pagePromise = context2.newPage();
1919
- pagePromise.catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Background page creation rejection: ${err.message}`));
1920
- let timeoutId;
1921
- try {
1922
- const result = await Promise.race([
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 = parseInt(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"] || "45000", 10);
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 = parseInt(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"] || "15000", 10);
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 = parseInt(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"] || "0", 10);
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
- doResetBrowser();
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
- this._disposalPromise = (async () => {
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
- this._disposalPromise = null;
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
  }
@@ -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 < end) {
230
+ } else if (start + 1 <= end) {
218
231
  start += 1;
219
232
  }
220
233
  }