@lincoln504/pi-research 1.2.0 → 1.3.0

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 (81) hide show
  1. package/dist/cli.mjs +1334 -750
  2. package/dist/thread-worker.mjs +194 -136
  3. package/docs/ARCHITECTURE.md +1 -1
  4. package/docs/CHANGELOG.md +114 -16
  5. package/package.json +1 -1
  6. package/scripts/cleanup.cjs +64 -2
  7. package/skills/pi-research/SKILL.md +1 -1
  8. package/src/cleanup/research-cleanup.ts +12 -4
  9. package/src/cli.ts +228 -70
  10. package/src/config.ts +78 -2
  11. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  12. package/src/core/interfaces/planning-interfaces.ts +8 -0
  13. package/src/core/interfaces/service-names.ts +0 -1
  14. package/src/core/interfaces/state-manager-interfaces.ts +10 -2
  15. package/src/core/llm/agentic-repair.ts +14 -0
  16. package/src/core/planning-service.ts +43 -1
  17. package/src/core/service-initialization.ts +6 -14
  18. package/src/core/service-interfaces.ts +1 -1
  19. package/src/healthcheck/index.ts +7 -2
  20. package/src/infrastructure/browser/browser-client.ts +64 -9
  21. package/src/infrastructure/browser/browser-server.ts +24 -7
  22. package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
  23. package/src/infrastructure/browser/cleanup-utils.ts +50 -2
  24. package/src/infrastructure/browser/client-agent.ts +6 -6
  25. package/src/infrastructure/browser/config.ts +14 -3
  26. package/src/infrastructure/browser/scheduler-factory.ts +18 -3
  27. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  28. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  29. package/src/infrastructure/browser/thread-worker.mjs +194 -136
  30. package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
  31. package/src/infrastructure/embedding/embedding-factory.ts +35 -6
  32. package/src/infrastructure/embedding/embedding-server.ts +40 -7
  33. package/src/infrastructure/file-lock-service.ts +72 -2
  34. package/src/infrastructure/knowledge-store-service.ts +48 -3
  35. package/src/infrastructure/process-lifecycle-service.ts +28 -5
  36. package/src/infrastructure/research-run-semaphore.ts +37 -3
  37. package/src/infrastructure/state/state-browser-api.ts +5 -2
  38. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  39. package/src/infrastructure/state/state-manager-service.ts +4 -4
  40. package/src/infrastructure/state/state-manager.ts +15 -3
  41. package/src/knowledge/chunker.ts +88 -1
  42. package/src/knowledge/embedder.ts +136 -43
  43. package/src/knowledge/store.ts +66 -12
  44. package/src/logger.ts +5 -1
  45. package/src/observers/research-observer-impl.ts +31 -0
  46. package/src/orchestration/abort-utils.ts +60 -0
  47. package/src/orchestration/deep-research-orchestrator.ts +12 -2
  48. package/src/orchestration/headless-observer.ts +8 -0
  49. package/src/orchestration/research-orchestration-service.ts +63 -11
  50. package/src/orchestration/research-session-service.ts +37 -11
  51. package/src/orchestration/researcher-executor.ts +100 -20
  52. package/src/orchestration/researcher.ts +39 -20
  53. package/src/orchestration/session-state.ts +11 -0
  54. package/src/sdk.ts +125 -19
  55. package/src/security/index.ts +51 -30
  56. package/src/security/nvd-client.ts +7 -2
  57. package/src/skill-install/skill-installer.ts +18 -2
  58. package/src/stackexchange/index.ts +20 -5
  59. package/src/stackexchange/output/table.ts +7 -2
  60. package/src/stackexchange/rest-client.ts +35 -1
  61. package/src/tools/research-tool-definition.ts +8 -2
  62. package/src/tools/scrape.ts +48 -25
  63. package/src/tools/search.ts +10 -1
  64. package/src/tools/security.ts +68 -7
  65. package/src/tui/research-health.ts +18 -2
  66. package/src/utils/circuit-breaker.ts +9 -2
  67. package/src/utils/disk-space-checker.ts +11 -6
  68. package/src/utils/json-utils.ts +116 -76
  69. package/src/utils/llm-usage.ts +17 -1
  70. package/src/utils/log-rotation.ts +42 -10
  71. package/src/utils/log-utils.ts +40 -0
  72. package/src/utils/metrics-summary.ts +27 -9
  73. package/src/utils/stdio-capture.ts +63 -22
  74. package/src/utils/text-utils.ts +8 -1
  75. package/src/utils/tool-usage-tracker.ts +9 -2
  76. package/src/web-research/browser-search.ts +26 -7
  77. package/src/web-research/scraper-utils.ts +13 -0
  78. package/src/web-research/search.ts +6 -2
  79. package/src/web-research/types.ts +4 -2
  80. package/src/web-research/web-scraper.ts +65 -4
  81. package/src/core/health-check-service.ts +0 -265
@@ -51,13 +51,17 @@ function redactSecrets(message) {
51
51
  out = out.replace(JWT_PATTERN, "[REDACTED]");
52
52
  out = out.replace(BASIC_AUTH_PATTERN, "Basic [REDACTED]");
53
53
  out = out.replace(BEARER_AUTH_PATTERN, "Bearer [REDACTED]");
54
+ out = out.replace(COOKIE_HEADER_PATTERN, (_m, key, sep) => `${key}${sep}[REDACTED]`);
54
55
  out = out.replace(SENSITIVE_KV_PATTERN, (_m, key, sep) => `${key}${sep}[REDACTED]`);
55
56
  out = out.replace(KNOWN_TOKEN_PATTERN, "[REDACTED]");
56
57
  out = out.replace(PROVIDER_TOKEN_PATTERN, "[REDACTED]");
57
58
  out = out.replace(LONG_HEX_SECRET_PATTERN, "[REDACTED]");
58
59
  return out;
59
60
  }
60
- var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN;
61
+ function stripTerminalEscapes(message) {
62
+ return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
63
+ }
64
+ 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;
61
65
  var init_log_utils = __esm({
62
66
  "src/utils/log-utils.ts"() {
63
67
  "use strict";
@@ -65,6 +69,7 @@ var init_log_utils = __esm({
65
69
  MAX_LOG_MESSAGE_LENGTH = 1e4;
66
70
  ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
67
71
  URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
72
+ COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
68
73
  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;
69
74
  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;
70
75
  JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
@@ -72,6 +77,106 @@ var init_log_utils = __esm({
72
77
  BEARER_AUTH_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi;
73
78
  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;
74
79
  LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
80
+ CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
81
+ TERMINAL_ESCAPE_PATTERN = new RegExp(
82
+ "\\x1b\\[[0-?]*[ -/]*[@-~]?|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?|\\x1b.?",
83
+ "g"
84
+ );
85
+ }
86
+ });
87
+
88
+ // src/utils/user-agent.ts
89
+ var USER_AGENTS;
90
+ var init_user_agent = __esm({
91
+ "src/utils/user-agent.ts"() {
92
+ "use strict";
93
+ USER_AGENTS = [
94
+ // Chrome on Windows (updated 2025)
95
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
96
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
97
+ // Chrome on macOS
98
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
99
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
100
+ // Chrome on Linux
101
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
102
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
103
+ // Firefox on Windows (updated 2025)
104
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0",
105
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0",
106
+ // Firefox on macOS
107
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
108
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
109
+ ];
110
+ }
111
+ });
112
+
113
+ // src/web-research/scraper-types.ts
114
+ var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN;
115
+ var init_scraper_types = __esm({
116
+ "src/web-research/scraper-types.ts"() {
117
+ "use strict";
118
+ init_user_agent();
119
+ MAX_HTML_SIZE = 25 * 1024 * 1024;
120
+ MAX_PDF_SIZE = 100 * 1024 * 1024;
121
+ INTERNAL_NETWORK_PATTERNS = [
122
+ /^127\./,
123
+ // IPv4 loopback
124
+ /^0\./,
125
+ // IPv4 "this" network
126
+ /^::1$/,
127
+ // IPv6 loopback
128
+ /^fe80::/i,
129
+ // IPv6 link-local
130
+ /^f[cd][0-9a-f]{2}:/i,
131
+ // IPv6 unique local — full fc00::/7 block (fc00::–fdff::)
132
+ /^169\.254\./,
133
+ // IPv4 link-local (and metadata)
134
+ /^10\./,
135
+ // RFC 1918 Class A private
136
+ /^192\.168\./,
137
+ // RFC 1918 Class C private
138
+ /^172\.(1[6-9]|2[0-9]|3[01])\./,
139
+ // RFC 1918 Class B private
140
+ /^::ffff:(127\.|0\.|169\.254\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/i
141
+ // IPv4-mapped IPv6
142
+ ];
143
+ FILTERED_TAGS = [
144
+ "nav",
145
+ "header",
146
+ "footer",
147
+ "aside",
148
+ "script",
149
+ "style",
150
+ "noscript",
151
+ "form",
152
+ "input",
153
+ "select",
154
+ "textarea",
155
+ "button",
156
+ "object",
157
+ "embed",
158
+ "svg",
159
+ "symbol",
160
+ "use",
161
+ "defs",
162
+ "path",
163
+ "circle",
164
+ "rect",
165
+ "line",
166
+ "polygon",
167
+ "img",
168
+ "iframe"
169
+ ];
170
+ BOT_PATTERNS = [
171
+ ["_cf_chl_", "Cloudflare challenge"],
172
+ ["cf_chl_opt", "Cloudflare challenge opt"],
173
+ ["cdn-cgi/challenge-platform", "Cloudflare challenge platform"],
174
+ ["ddos-guard", "DDoS-Guard challenge"],
175
+ ["Just a moment...", "Cloudflare interstitial"],
176
+ ["Checking your browser before accessing", "Cloudflare interstitial"]
177
+ ];
178
+ IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
179
+ MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
75
180
  }
76
181
  });
77
182
 
@@ -265,6 +370,14 @@ var init_log_rotation = __esm({
265
370
  try {
266
371
  if (fs2.existsSync(logFile)) {
267
372
  fs2.unlinkSync(logFile);
373
+ try {
374
+ fs2.closeSync(fs2.openSync(logFile, "a", 384));
375
+ } catch {
376
+ }
377
+ try {
378
+ fs2.chmodSync(logFile, 384);
379
+ } catch {
380
+ }
268
381
  }
269
382
  const files = fs2.readdirSync(logDir);
270
383
  const baseName = path2.basename(logFile);
@@ -297,12 +410,16 @@ var init_log_rotation = __esm({
297
410
  try {
298
411
  fs2.renameSync(logFile, archivePath);
299
412
  } catch (renameErr) {
300
- if (process.platform === "win32") {
301
- fs2.copyFileSync(logFile, archivePath);
302
- fs2.unlinkSync(logFile);
303
- } else {
304
- throw renameErr;
305
- }
413
+ this.logger.log("[Logger] Log rotation skipped (rename failed; file likely in use):", renameErr);
414
+ return;
415
+ }
416
+ try {
417
+ fs2.closeSync(fs2.openSync(logFile, "a", 384));
418
+ } catch {
419
+ }
420
+ try {
421
+ fs2.chmodSync(logFile, 384);
422
+ } catch {
306
423
  }
307
424
  try {
308
425
  const files = fs2.readdirSync(logDir);
@@ -372,10 +489,13 @@ var init_disk_space_checker = __esm({
372
489
  const availableBytes = stats.bavail * stats.bsize;
373
490
  if (availableBytes < this.MIN_DISK_SPACE_BYTES) {
374
491
  this.hasDiskSpace = false;
375
- process.stderr.write(
376
- `[pi-research] Insufficient disk space for logging: ${Math.round(availableBytes / 1024 / 1024)}MB available, minimum ${this.MIN_DISK_SPACE_BYTES / 1024 / 1024}MB required
492
+ try {
493
+ process.stderr.write(
494
+ `[pi-research] Insufficient disk space for logging: ${Math.round(availableBytes / 1024 / 1024)}MB available, minimum ${this.MIN_DISK_SPACE_BYTES / 1024 / 1024}MB required
377
495
  `
378
- );
496
+ );
497
+ } catch {
498
+ }
379
499
  } else {
380
500
  this.hasDiskSpace = true;
381
501
  }
@@ -482,7 +602,7 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
482
602
  if (shouldRedirectStderr(stat)) {
483
603
  savedFd2 = fs4.openSync("/dev/fd/2", "a");
484
604
  fs4.closeSync(2);
485
- const newFd = fs4.openSync(logFile, "a");
605
+ const newFd = fs4.openSync(logFile, "a", 384);
486
606
  if (newFd === 2) {
487
607
  fd2Redirected = true;
488
608
  } else {
@@ -614,42 +734,52 @@ async function captureStdio(logFile, hasSufficientDiskSpace, task, sessionId) {
614
734
  }
615
735
  } catch (_e) {
616
736
  }
737
+ const frame = {
738
+ retired: false,
739
+ restore: () => {
740
+ process.stderr.write = originalStderrWrite;
741
+ process.stdout.write = originalStdoutWrite;
742
+ try {
743
+ fs4.writeSync = originalFsWriteSync;
744
+ } catch {
745
+ }
746
+ console.log = originalConsole.log;
747
+ console.info = originalConsole.info;
748
+ console.warn = originalConsole.warn;
749
+ console.error = originalConsole.error;
750
+ console.debug = originalConsole.debug;
751
+ if (fd2Redirected && savedFd2 >= 0) {
752
+ try {
753
+ fs4.closeSync(2);
754
+ const r = fs4.openSync(`/dev/fd/${savedFd2}`, "a");
755
+ if (r !== 2 && r >= 0) {
756
+ }
757
+ try {
758
+ fs4.closeSync(savedFd2);
759
+ } catch {
760
+ }
761
+ } catch {
762
+ }
763
+ }
764
+ }
765
+ };
766
+ captureStack.push(frame);
617
767
  try {
618
768
  return await task();
619
769
  } finally {
620
- process.stderr.write = originalStderrWrite;
621
- process.stdout.write = originalStdoutWrite;
622
- try {
623
- fs4.writeSync = originalFsWriteSync;
624
- } catch {
625
- }
626
- console.log = originalConsole.log;
627
- console.info = originalConsole.info;
628
- console.warn = originalConsole.warn;
629
- console.error = originalConsole.error;
630
- console.debug = originalConsole.debug;
631
770
  if (sessionId) {
632
771
  setSessionCapturing(sessionId, false);
633
772
  } else {
634
773
  unkeyedCaptureDepth = Math.max(0, unkeyedCaptureDepth - 1);
635
774
  recomputeGlobalCapturing();
636
775
  }
637
- if (fd2Redirected && savedFd2 >= 0) {
638
- try {
639
- fs4.closeSync(2);
640
- const r = fs4.openSync(`/dev/fd/${savedFd2}`, "a");
641
- if (r !== 2 && r >= 0) {
642
- }
643
- try {
644
- fs4.closeSync(savedFd2);
645
- } catch {
646
- }
647
- } catch {
648
- }
776
+ frame.retired = true;
777
+ while (captureStack.length > 0 && captureStack[captureStack.length - 1].retired) {
778
+ captureStack.pop().restore();
649
779
  }
650
780
  }
651
781
  }
652
- var fs4, sessionCaptureStates, unkeyedCaptureDepth, isAnyLoggerCapturingOutput, NATIVE_LOG_PATTERNS, COMPLEX_TUI_PATTERNS, BOX_DRAWING_CHARS;
782
+ var fs4, sessionCaptureStates, unkeyedCaptureDepth, isAnyLoggerCapturingOutput, captureStack, NATIVE_LOG_PATTERNS, COMPLEX_TUI_PATTERNS, BOX_DRAWING_CHARS;
653
783
  var init_stdio_capture = __esm({
654
784
  "src/utils/stdio-capture.ts"() {
655
785
  "use strict";
@@ -658,6 +788,7 @@ var init_stdio_capture = __esm({
658
788
  sessionCaptureStates = /* @__PURE__ */ new Map();
659
789
  unkeyedCaptureDepth = 0;
660
790
  isAnyLoggerCapturingOutput = false;
791
+ captureStack = [];
661
792
  NATIVE_LOG_PATTERNS = [
662
793
  "Warning:",
663
794
  "Error:",
@@ -699,7 +830,7 @@ var init_stdio_capture = __esm({
699
830
  });
700
831
 
701
832
  // src/logger.ts
702
- import { appendFileSync, existsSync as existsSync2, mkdirSync, openSync, closeSync } from "node:fs";
833
+ import { appendFileSync, existsSync as existsSync2, mkdirSync, openSync as openSync2, closeSync as closeSync2 } from "node:fs";
703
834
  import { appendFile as appendFile2 } from "node:fs/promises";
704
835
  import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
705
836
  import * as path3 from "node:path";
@@ -859,7 +990,7 @@ var init_logger = __esm({
859
990
  mkdirSync(this.logDir, { recursive: true, mode: 448 });
860
991
  }
861
992
  try {
862
- closeSync(openSync(this.logFile, "wx", 384));
993
+ closeSync2(openSync2(this.logFile, "wx", 384));
863
994
  } catch {
864
995
  }
865
996
  } catch {
@@ -894,7 +1025,7 @@ var init_logger = __esm({
894
1025
  if (this.consoleLog) {
895
1026
  const color = level === "ERROR" /* ERROR */ ? "\x1B[31m" : level === "WARN" /* WARN */ ? "\x1B[33m" : level === "DEBUG" /* DEBUG */ ? "\x1B[90m" : "\x1B[36m";
896
1027
  const reset = "\x1B[0m";
897
- const msg = message.replace(/[\r\n]/g, "");
1028
+ const msg = stripTerminalEscapes(message.replace(/[\r\n]/g, ""));
898
1029
  const rawSid = this.sessionId ?? "";
899
1030
  const sid = rawSid.replace(/[\r\n]/g, "");
900
1031
  const prefix = sid ? `[${sid}] ` : "";
@@ -1133,101 +1264,6 @@ var init_metrics = __esm({
1133
1264
  }
1134
1265
  });
1135
1266
 
1136
- // src/utils/user-agent.ts
1137
- var USER_AGENTS;
1138
- var init_user_agent = __esm({
1139
- "src/utils/user-agent.ts"() {
1140
- "use strict";
1141
- USER_AGENTS = [
1142
- // Chrome on Windows (updated 2025)
1143
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
1144
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
1145
- // Chrome on macOS
1146
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
1147
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
1148
- // Chrome on Linux
1149
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
1150
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
1151
- // Firefox on Windows (updated 2025)
1152
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0",
1153
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0",
1154
- // Firefox on macOS
1155
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
1156
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
1157
- ];
1158
- }
1159
- });
1160
-
1161
- // src/web-research/scraper-types.ts
1162
- var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN;
1163
- var init_scraper_types = __esm({
1164
- "src/web-research/scraper-types.ts"() {
1165
- "use strict";
1166
- init_user_agent();
1167
- MAX_HTML_SIZE = 25 * 1024 * 1024;
1168
- MAX_PDF_SIZE = 100 * 1024 * 1024;
1169
- INTERNAL_NETWORK_PATTERNS = [
1170
- /^127\./,
1171
- // IPv4 loopback
1172
- /^0\./,
1173
- // IPv4 "this" network
1174
- /^::1$/,
1175
- // IPv6 loopback
1176
- /^fe80::/i,
1177
- // IPv6 link-local
1178
- /^f[cd][0-9a-f]{2}:/i,
1179
- // IPv6 unique local — full fc00::/7 block (fc00::–fdff::)
1180
- /^169\.254\./,
1181
- // IPv4 link-local (and metadata)
1182
- /^10\./,
1183
- // RFC 1918 Class A private
1184
- /^192\.168\./,
1185
- // RFC 1918 Class C private
1186
- /^172\.(1[6-9]|2[0-9]|3[01])\./,
1187
- // RFC 1918 Class B private
1188
- /^::ffff:(127\.|0\.|169\.254\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/i
1189
- // IPv4-mapped IPv6
1190
- ];
1191
- FILTERED_TAGS = [
1192
- "nav",
1193
- "header",
1194
- "footer",
1195
- "aside",
1196
- "script",
1197
- "style",
1198
- "noscript",
1199
- "form",
1200
- "input",
1201
- "select",
1202
- "textarea",
1203
- "button",
1204
- "object",
1205
- "embed",
1206
- "svg",
1207
- "symbol",
1208
- "use",
1209
- "defs",
1210
- "path",
1211
- "circle",
1212
- "rect",
1213
- "line",
1214
- "polygon",
1215
- "img",
1216
- "iframe"
1217
- ];
1218
- BOT_PATTERNS = [
1219
- ["_cf_chl_", "Cloudflare challenge"],
1220
- ["cf_chl_opt", "Cloudflare challenge opt"],
1221
- ["cdn-cgi/challenge-platform", "Cloudflare challenge platform"],
1222
- ["ddos-guard", "DDoS-Guard challenge"],
1223
- ["Just a moment...", "Cloudflare interstitial"],
1224
- ["Checking your browser before accessing", "Cloudflare interstitial"]
1225
- ];
1226
- IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
1227
- MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
1228
- }
1229
- });
1230
-
1231
1267
  // src/utils/circuit-breaker.ts
1232
1268
  var CircuitBreaker;
1233
1269
  var init_circuit_breaker = __esm({
@@ -1277,7 +1313,7 @@ var init_circuit_breaker = __esm({
1277
1313
  const result = await action();
1278
1314
  const duration = Date.now() - startTime;
1279
1315
  metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "success" });
1280
- this.onSuccess();
1316
+ this.onSuccess(admittedAsProbe);
1281
1317
  return result;
1282
1318
  } catch (error) {
1283
1319
  const duration = Date.now() - startTime;
@@ -1295,9 +1331,10 @@ var init_circuit_breaker = __esm({
1295
1331
  if (admittedAsProbe) this.halfOpenInFlight = Math.max(0, this.halfOpenInFlight - 1);
1296
1332
  }
1297
1333
  }
1298
- onSuccess() {
1334
+ onSuccess(admittedAsProbe) {
1299
1335
  metrics.increment("circuit_breaker_success_total", 1, { breaker: this.options.name, state: this.state });
1300
1336
  if (this.state === "HALF_OPEN") {
1337
+ if (!admittedAsProbe) return;
1301
1338
  this.successCount++;
1302
1339
  if (this.successCount >= this.options.halfOpenMaxCalls) {
1303
1340
  this.transitionTo("CLOSED");
@@ -1599,12 +1636,14 @@ function isPrivateIpv6(ip) {
1599
1636
  if (normalized === "::1") return true;
1600
1637
  if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
1601
1638
  if (normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb")) return true;
1639
+ if (normalized.startsWith("fec") || normalized.startsWith("fed") || normalized.startsWith("fee") || normalized.startsWith("fef")) return true;
1602
1640
  const mapped = extractMappedIpv4(normalized);
1603
1641
  if (mapped) {
1604
1642
  return isPrivateIp(mapped);
1605
1643
  }
1606
1644
  if (normalized.startsWith("2002:")) return true;
1607
1645
  if (/^2001:0{0,4}:/.test(normalized) || normalized.startsWith("2001::")) return true;
1646
+ if (normalized.startsWith("64:ff9b:")) return true;
1608
1647
  if (normalized === "::" || normalized === "0:0:0:0:0:0:0:0") return true;
1609
1648
  return false;
1610
1649
  }
@@ -1857,6 +1896,7 @@ import { platform as platform2 } from "node:os";
1857
1896
 
1858
1897
  // src/infrastructure/browser/thread-worker-messaging.ts
1859
1898
  init_log_utils();
1899
+ init_scraper_types();
1860
1900
  import { appendFileSync as appendFileSync2 } from "node:fs";
1861
1901
  var workerId2 = "";
1862
1902
  var pageCreationLock = Promise.resolve();
@@ -2149,17 +2189,31 @@ async function executeScrapeTask(_context, url, signal) {
2149
2189
  validateForWorkerSync(ip.includes(":") ? `http://[${ip}]/` : `http://${ip}/`);
2150
2190
  }
2151
2191
  const contentType = await response?.headerValue("content-type") || "";
2192
+ const status = response?.status?.() ?? 0;
2152
2193
  if (contentType.includes("application/pdf")) {
2153
2194
  if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
2195
+ if (status >= 400) throw new Error(`HTTP ${status}`);
2196
+ const declaredLength = parseInt(await response.headerValue("content-length").catch(() => null) || "", 10);
2197
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_PDF_SIZE) {
2198
+ const sizeMB = Math.round(declaredLength / 1024 / 1024);
2199
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2200
+ }
2154
2201
  const buffer = await response.body();
2202
+ if (buffer.byteLength > MAX_PDF_SIZE) {
2203
+ const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
2204
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2205
+ }
2155
2206
  await Promise.allSettled(pendingAddrChecks);
2156
2207
  if (poisonedError) throw poisonedError;
2157
2208
  await page.close();
2158
- return { contentType, buffer, jitter: 0 };
2209
+ return { contentType, bufferB64: buffer.toString("base64"), jitter: 0 };
2159
2210
  }
2160
2211
  let html = await page.content();
2161
2212
  const cfPatterns = ["_cf_chl_", "cdn-cgi/challenge-platform", "cf_chl_opt", "Just a moment...", "Checking your browser before accessing"];
2162
2213
  const hasCloudflare = cfPatterns.some((pattern) => html.includes(pattern));
2214
+ if (status >= 400 && !hasCloudflare) {
2215
+ throw new Error(`HTTP ${status}`);
2216
+ }
2163
2217
  if (hasCloudflare) {
2164
2218
  logToDebugFile2("WARN", `[Worker-${workerId2}] Cloudflare challenge detected for: ${url}`);
2165
2219
  try {
@@ -2184,6 +2238,10 @@ async function executeScrapeTask(_context, url, signal) {
2184
2238
  await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
2185
2239
  html = await page.content();
2186
2240
  }
2241
+ if (html.length > MAX_HTML_SIZE) {
2242
+ const sizeMB = Math.round(html.length / 1024 / 1024);
2243
+ throw new Error(`Browser HTML too large (${sizeMB}MB, max 25MB)`);
2244
+ }
2187
2245
  const jitter = process.env["PI_RESEARCH_MOCK_SCRAPE"] === "true" ? 0 : Math.floor(Math.random() * 1e3) + 500;
2188
2246
  if (jitter > 0) await new Promise((r) => setTimeout(r, jitter));
2189
2247
  const finalAddr = await finalMainFrameAddr;
@@ -186,8 +186,13 @@ export class WorkerPoolManager implements IService {
186
186
  const myEpoch = ++this.poolEpoch;
187
187
  this.pool = new FixedClusterPool(maxWorkers, workerPath, {
188
188
  env: browserEnv,
189
- // Prevent query leakage via process.argv in forked workers
190
- workerOptions: { execArgv: [] },
189
+ // Prevent query leakage via process.argv in forked workers: cluster
190
+ // pools fork through cluster.setupPrimary(opts.settings), and
191
+ // cluster.settings.args defaults to process.argv.slice(2) — without
192
+ // this, every worker re-exposes the full CLI (research query
193
+ // included) in `ps`. NB `workerOptions` is a worker_threads-only
194
+ // poolifier knob; it is ignored by cluster pools.
195
+ settings: { args: [] },
191
196
  errorHandler: (e: Error) => {
192
197
  // Ignore events from a pool that has since been replaced/destroyed — a
193
198
  // trailing error from an old pool's dying worker must not affect the live one.
@@ -76,6 +76,13 @@ export async function isEmbeddingLeaderAlive(
76
76
 
77
77
  let _embeddingInstance: IEmbedder | null = null;
78
78
  let _embeddingInitPromise: Promise<IEmbedder> | null = null;
79
+ // Config the in-flight init was started for. The join below must compare against
80
+ // these — the cached-instance path checks model/device before reuse, and the
81
+ // adoption paths refuse mismatched leaders, but a caller joining a promise built
82
+ // for a different config would silently receive (and stamp the cache with) the
83
+ // wrong-model embedder, defeating both guards.
84
+ let _initModel: string | null = null;
85
+ let _initDevice: string | null = null;
79
86
  let _cachedModel: string | null = null;
80
87
  let _cachedDevice: string | null = null;
81
88
 
@@ -130,8 +137,18 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
130
137
 
131
138
  // An internal retry (_attempt > 0) is re-entering from INSIDE the promise stored
132
139
  // here, so handing that promise back would make it await itself. Only a fresh
133
- // external call may join an in-flight init.
134
- if (_embeddingInitPromise && _attempt === 0) return _embeddingInitPromise;
140
+ // external call may join an in-flight init — and only one started for the SAME
141
+ // config: a mismatched joiner would get the wrong-model embedder (same-dimension
142
+ // models cross-embed silently). On mismatch, wait the in-flight init out (never
143
+ // race a second election in this process), then re-resolve for this config — the
144
+ // cached-instance check above disposes the mismatched instance.
145
+ if (_embeddingInitPromise && _attempt === 0) {
146
+ if (_initModel === cfg.EMBEDDING_MODEL && _initDevice === cfg.EMBEDDING_DEVICE) {
147
+ return _embeddingInitPromise;
148
+ }
149
+ await _embeddingInitPromise.catch(() => {});
150
+ return getEmbedder(cfg, _attempt);
151
+ }
135
152
 
136
153
  let p: Promise<IEmbedder>;
137
154
 
@@ -222,7 +239,12 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
222
239
  if (!(await isEmbeddingLeaderAlive(info, processLifecycle))) {
223
240
  // Candidate process died without cleaning up (or its PID was recycled by
224
241
  // an unrelated process — the start-time pairing catches that too).
225
- await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
242
+ // serverId-scoped: several waiters typically detect the death in the same
243
+ // poll tick; the first one's clear+claim can commit before this one's clear
244
+ // runs, and an unconditional delete here would deregister that NEW claimant
245
+ // (whose Phase-3 port publish is CAS-guarded and silently no-ops) — leaving
246
+ // two concurrent model inits and an invisible leader.
247
+ await stateManager.clearEmbeddingServer({ serverId: info.serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
226
248
  break;
227
249
  }
228
250
  // Re-check model identity every poll: the leader may have changed while
@@ -246,7 +268,10 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
246
268
  const secondCheck = await isPortListening(info.port);
247
269
  if (!secondCheck) {
248
270
  logger.warn(`[EmbeddingFactory] Registered port ${info.port} is unreachable after two checks — clearing stale state`);
249
- await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
271
+ // serverId-scoped: this snapshot is up to ~2.5s old (two probes + sleep);
272
+ // the entry may already belong to a new claimant. See the dead-leader
273
+ // clear above for the failure this prevents.
274
+ await stateManager.clearEmbeddingServer({ serverId: info.serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
250
275
  break;
251
276
  }
252
277
  }
@@ -282,8 +307,10 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
282
307
  try {
283
308
  port = await server.startServer();
284
309
  } catch (err) {
285
- // Init failed — clear our placeholder so others can try
286
- await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
310
+ // Init failed — clear our placeholder so others can try. serverId-scoped so a
311
+ // concurrent steal-and-reclaim (a waiter that judged us dead and claimed) is
312
+ // never deregistered by our own failure path.
313
+ await stateManager.clearEmbeddingServer({ serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
287
314
  throw err;
288
315
  }
289
316
 
@@ -307,6 +334,8 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
307
334
 
308
335
  p = init();
309
336
  _embeddingInitPromise = p;
337
+ _initModel = cfg.EMBEDDING_MODEL;
338
+ _initDevice = cfg.EMBEDDING_DEVICE;
310
339
  // init() already assigns _embeddingInstance before it resolves, so do NOT
311
340
  // re-assign it here. If clearEmbeddingInstance() ran (and disposed the server)
312
341
  // between init resolving and this microtask, re-assigning would resurrect the