@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
@@ -27,8 +27,19 @@ export class LogRotation {
27
27
  try {
28
28
  if (fs.existsSync(logFile)) {
29
29
  fs.unlinkSync(logFile);
30
+ // Same rationale as the post-rotation recreate in rotateLogFile: the
31
+ // unlink discarded the 0o600 file, and a umask-default recreate by the
32
+ // next append would drop the owner-only posture.
33
+ try {
34
+ fs.closeSync(fs.openSync(logFile, 'a', 0o600));
35
+ } catch { /* best-effort — the next append recreates the file */ }
36
+ // And, as there, re-stamp the mode: a concurrent append landing between
37
+ // the unlink and the open makes the creation-time 0o600 a no-op.
38
+ try {
39
+ fs.chmodSync(logFile, 0o600);
40
+ } catch { /* best-effort */ }
30
41
  }
31
-
42
+
32
43
  const files = fs.readdirSync(logDir);
33
44
  const baseName = path.basename(logFile);
34
45
  const archives = files.filter(f => f.startsWith(baseName) && f !== baseName);
@@ -63,19 +74,40 @@ export class LogRotation {
63
74
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
64
75
  const archivePath = `${logFile}.${timestamp}`;
65
76
 
66
- // Rename current log file to archive
77
+ // Rename current log file to archive. The archive name is timestamped, so
78
+ // the target never pre-exists — a rename failure means the SOURCE is held
79
+ // open (Windows sharing semantics). A copy+unlink fallback here would
80
+ // destroy appends the OTHER processes writing this consolidated log (main,
81
+ // embedding server, browser workers) land between the copy and the unlink.
82
+ // Skipping the rotation is the data-preserving choice: the file transiently
83
+ // exceeds the cap until a later check succeeds (accepted by the throttling
84
+ // contract in checkAndRotate).
67
85
  try {
68
86
  fs.renameSync(logFile, archivePath);
69
87
  } catch (renameErr) {
70
- // fs.renameSync fails on Windows if target exists (NTFS). Fall back to copy+delete.
71
- if (process.platform === 'win32') {
72
- fs.copyFileSync(logFile, archivePath);
73
- fs.unlinkSync(logFile);
74
- } else {
75
- throw renameErr;
76
- }
88
+ this.logger.log('[Logger] Log rotation skipped (rename failed; file likely in use):', renameErr);
89
+ return;
77
90
  }
78
-
91
+
92
+ // Rotation moved the 0o600 file aside; without an explicit recreate the next
93
+ // append materializes it at the umask default (typically 0644) in a
94
+ // world-traversable tmpdir, silently dropping the owner-only posture Logger
95
+ // establishes at construction. Mode applies only at creation, so this is
96
+ // harmless where a file already exists or mode semantics differ (win32).
97
+ try {
98
+ fs.closeSync(fs.openSync(logFile, 'a', 0o600));
99
+ } catch { /* best-effort — the next append recreates the file */ }
100
+ // The mode above applies only if THIS open created the file. Several
101
+ // processes append to this consolidated log, so a concurrent async append
102
+ // can recreate it at umask perms between the rename and the open, turning
103
+ // the 0o600 into a no-op — stamp the mode unconditionally to close that
104
+ // window for good. ENOENT (nothing recreated it and the open failed) is
105
+ // tolerable: best-effort posture, same as the recreate itself.
106
+ try {
107
+ fs.chmodSync(logFile, 0o600);
108
+ } catch { /* best-effort */ }
109
+
110
+
79
111
  // Clean up old archives
80
112
  try {
81
113
  const files = fs.readdirSync(logDir);
@@ -86,6 +86,11 @@ const MAX_LOG_MESSAGE_LENGTH = 10_000;
86
86
  const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
87
87
  // Credentials embedded in a URL userinfo component: scheme://user:pass@host
88
88
  const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
89
+ // Cookie / Set-Cookie headers. The KV value class below stops at whitespace, so
90
+ // "Cookie: lang=en; SID=secret123" would mask only the first pair and leave every
91
+ // later one exposed. A cookie string is a ;-and-space separated list of secrets,
92
+ // not a single token — mask the ENTIRE header value to end-of-line.
93
+ const COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
89
94
  // key=value / key: "value" pairs whose key names a secret.
90
95
  const SENSITIVE_KV_PATTERN =
91
96
  /\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;
@@ -137,6 +142,9 @@ export function redactSecrets(message: string): string {
137
142
  // Before the KV pass, for the same reason as Basic above: the KV value class
138
143
  // stops at the space after "Bearer", leaving an opaque token exposed.
139
144
  out = out.replace(BEARER_AUTH_PATTERN, 'Bearer [REDACTED]');
145
+ // Whole-header cookie masking before the KV pass, which would otherwise stop
146
+ // at the first whitespace and leave every subsequent pair in the clear.
147
+ out = out.replace(COOKIE_HEADER_PATTERN, (_m, key: string, sep: string) => `${key}${sep}[REDACTED]`);
140
148
  out = out.replace(SENSITIVE_KV_PATTERN, (_m, key: string, sep: string) => `${key}${sep}[REDACTED]`);
141
149
  out = out.replace(KNOWN_TOKEN_PATTERN, '[REDACTED]');
142
150
  out = out.replace(PROVIDER_TOKEN_PATTERN, '[REDACTED]');
@@ -144,6 +152,38 @@ export function redactSecrets(message: string): string {
144
152
  return out;
145
153
  }
146
154
 
155
+ // Every ESC-initiated terminal sequence — not just the colour/style CSI subset
156
+ // ANSI_PATTERN strips. Alternatives, tried in order at each ESC:
157
+ // 1. CSI with parameter (incl. private-mode `?`), intermediate, and final
158
+ // bytes per ECMA-48 — catches \x1b[?25l (hide cursor) and kin.
159
+ // 2. OSC to BEL or ST (or truncated) — catches \x1b]0;title\x07 retitling.
160
+ // 3. DCS/SOS/PM/APC to ST (or truncated).
161
+ // 4. Bare ESC plus at most one byte — the fallback that also swallows a
162
+ // trailing/truncated ESC rather than leaking it.
163
+ // (Built from strings, so no-control-regex has nothing to flag here.)
164
+ const TERMINAL_ESCAPE_PATTERN = new RegExp(
165
+ '\\x1b\\[[0-?]*[ -/]*[@-~]?' +
166
+ '|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?' +
167
+ '|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?' +
168
+ '|\\x1b.?',
169
+ 'g',
170
+ );
171
+
172
+ /**
173
+ * Strip terminal escape sequences and residual control bytes from a line bound
174
+ * for the CONSOLE sink. redactSecrets' ANSI_PATTERN removes only plain CSI
175
+ * colour/style sequences, so private-mode CSI (\x1b[?25l), OSC window-retitling
176
+ * (\x1b]0;…\x07), and DCS/SOS/PM/APC embedded in untrusted content would
177
+ * otherwise reach the user's terminal verbatim under PI_RESEARCH_CONSOLE_LOG.
178
+ * The console sink keeps its own explicit CR/LF→"" replace (the form CodeQL
179
+ * recognizes as the log-injection sanitizer); this handles everything else,
180
+ * spacing out any leftover C0/DEL byte (BEL, backspace, …) like
181
+ * neutralizeControlChars does.
182
+ */
183
+ export function stripTerminalEscapes(message: string): string {
184
+ return message.replace(TERMINAL_ESCAPE_PATTERN, '').replace(CONTROL_CHARS_PATTERN, ' ');
185
+ }
186
+
147
187
  /**
148
188
  * Neutralize control characters (newlines, carriage returns, etc.) so that
149
189
  * untrusted content cannot forge or corrupt a raw console log line
@@ -137,9 +137,17 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
137
137
  const browserSearchQueries = sumCounter(counters, 'browser_search_queries_total');
138
138
  const searchQueries = sumCounter(counters, 'search_queries_total') + browserSearchQueries;
139
139
 
140
- // URLs discovered via search
141
- const urlsDiscovered = sumCounter(counters, 'browser_search_results_total') +
142
- sumCounter(counters, 'browser_search_unique_urls_total');
140
+ // URLs discovered via search. browser_search_unique_urls_total is the
141
+ // cross-query deduped subset of the SAME batch counted by
142
+ // browser_search_results_total (raw per-query results), so summing both
143
+ // near-doubles the figure. The unique counter IS the discovered count; the
144
+ // raw counter is only a fallback for a search that threw before completing
145
+ // the batch (the unique count is emitted after cross-query dedup, so such a
146
+ // run never emits it).
147
+ const uniqueUrlsDiscovered = sumCounter(counters, 'browser_search_unique_urls_total');
148
+ const urlsDiscovered = uniqueUrlsDiscovered > 0
149
+ ? uniqueUrlsDiscovered
150
+ : sumCounter(counters, 'browser_search_results_total');
143
151
 
144
152
  // URLs scraped — outcomes from scrape_results_total
145
153
  const fetchSuccess = getLabeledCounter(counters, 'scrape_results_total', { outcome: 'fetch_success' });
@@ -178,12 +186,22 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
178
186
  // Cost — sum all label variants
179
187
  const cost = sumCounter(counters, 'llm_cost_total');
180
188
 
181
- // Duration from histogram
182
- const durationMs = histograms['research_session_duration_ms{mode="deep",complexity="1",status="success"}']?.max ||
183
- histograms['research_session_duration_ms{mode="deep",complexity="2",status="success"}']?.max ||
184
- histograms['research_session_duration_ms{mode="deep",complexity="3",status="success"}']?.max ||
185
- histograms['research_session_duration_ms{mode="quick",complexity="0",status="success"}']?.max ||
186
- 0;
189
+ // Duration from histogram. The registry serializes label keys in ALPHABETICAL
190
+ // order (MetricsRegistry.serializeLabels), so the real key is
191
+ // `{complexity="…",mode="…",status="…"}` — a lookup that hardcodes any other
192
+ // label order can never match. Complexity is also emitted as String(complexity)
193
+ // with no fixed value set. Scan by metric-name prefix + success status instead,
194
+ // taking the largest observed max across matching label variants.
195
+ let durationMs = 0;
196
+ for (const key of Object.keys(histograms)) {
197
+ if (
198
+ key.startsWith('research_session_duration_ms{') &&
199
+ key.includes('status="success"')
200
+ ) {
201
+ const max = histograms[key]?.max ?? 0;
202
+ if (max > durationMs) durationMs = max;
203
+ }
204
+ }
187
205
 
188
206
  // Rounds completed — evaluator runs give us the round count
189
207
  const roundsCompleted = sumCounter(counters, 'evaluator_runs_total');
@@ -33,6 +33,26 @@ const sessionCaptureStates = new Map<string, boolean>();
33
33
  let unkeyedCaptureDepth = 0;
34
34
  let isAnyLoggerCapturingOutput = false;
35
35
 
36
+ /**
37
+ * Active captures in creation order. Restoration MUST be LIFO: each capture saves
38
+ * the currently-installed writers/console/fs.writeSync (and the current FD-2
39
+ * target) as its "originals", so a frame that finishes while a later frame is
40
+ * still active must NOT restore — it would re-install the still-active frame's
41
+ * view of the world later, when that frame finally exits, re-pointing stderr,
42
+ * stdout, console.* and OS-level FD 2 at a dead capture's log-file plumbing for
43
+ * the remainder of the process. Keyed captures nest by design (a run's capture
44
+ * around the embedding server's), and nothing orders their exits — a cancelled
45
+ * run's outer capture routinely ends while the embedder-init capture is mid
46
+ * flight. So: an out-of-order finisher only marks its frame retired; the frame
47
+ * on top of the stack performs the actual restores, unwinding every already-
48
+ * retired frame beneath it in strict reverse order.
49
+ */
50
+ interface CaptureFrame {
51
+ retired: boolean;
52
+ restore: () => void;
53
+ }
54
+ const captureStack: CaptureFrame[] = [];
55
+
36
56
  /**
37
57
  * Recompute the global "is anything capturing" flag from BOTH the keyed sessions and
38
58
  * the unkeyed-capture depth, so neither scheme can clobber the other's state.
@@ -220,7 +240,9 @@ export async function captureStdio<T>(
220
240
  if (shouldRedirectStderr(stat)) {
221
241
  savedFd2 = fs.openSync('/dev/fd/2', 'a');
222
242
  fs.closeSync(2);
223
- const newFd = fs.openSync(logFile, 'a');
243
+ // 0o600 applies only when this open CREATES the file — keeps the log
244
+ // owner-only in a world-traversable tmpdir if rotation removed it.
245
+ const newFd = fs.openSync(logFile, 'a', 0o600);
224
246
  if (newFd === 2) {
225
247
  fd2Redirected = true;
226
248
  } else {
@@ -371,20 +393,41 @@ export async function captureStdio<T>(
371
393
  }
372
394
  } catch (_e) { /* ignore */ }
373
395
 
396
+ const frame: CaptureFrame = {
397
+ retired: false,
398
+ restore: () => {
399
+ process.stderr.write = originalStderrWrite;
400
+ process.stdout.write = originalStdoutWrite;
401
+ try {
402
+ fs.writeSync = originalFsWriteSync;
403
+ } catch { /* ignore */ }
404
+ console.log = originalConsole.log;
405
+ console.info = originalConsole.info;
406
+ console.warn = originalConsole.warn;
407
+ console.error = originalConsole.error;
408
+ console.debug = originalConsole.debug;
409
+
410
+ // Restore FD 2 to what it was when THIS frame started (for a nested frame
411
+ // that is the enclosing frame's log-file redirect; the enclosing frame's
412
+ // own restore, which always runs after this one in the unwind, then puts
413
+ // the true terminal FD back).
414
+ if (fd2Redirected && savedFd2 >= 0) {
415
+ try {
416
+ fs.closeSync(2);
417
+ const r = fs.openSync(`/dev/fd/${savedFd2}`, 'a');
418
+ if (r !== 2 && r >= 0) {
419
+ // Didn't recover FD 2 — leave restored FD open
420
+ }
421
+ try { fs.closeSync(savedFd2); } catch { /* ignore */ }
422
+ } catch { /* ignore restore errors */ }
423
+ }
424
+ },
425
+ };
426
+ captureStack.push(frame);
427
+
374
428
  try {
375
429
  return await task();
376
430
  } finally {
377
- // Restore everything
378
- process.stderr.write = originalStderrWrite;
379
- process.stdout.write = originalStdoutWrite;
380
- try {
381
- fs.writeSync = originalFsWriteSync;
382
- } catch { /* ignore */ }
383
- console.log = originalConsole.log;
384
- console.info = originalConsole.info;
385
- console.warn = originalConsole.warn;
386
- console.error = originalConsole.error;
387
- console.debug = originalConsole.debug;
388
431
  if (sessionId) {
389
432
  setSessionCapturing(sessionId, false);
390
433
  } else {
@@ -392,16 +435,14 @@ export async function captureStdio<T>(
392
435
  recomputeGlobalCapturing();
393
436
  }
394
437
 
395
- // Restore FD 2
396
- if (fd2Redirected && savedFd2 >= 0) {
397
- try {
398
- fs.closeSync(2);
399
- const r = fs.openSync(`/dev/fd/${savedFd2}`, 'a');
400
- if (r !== 2 && r >= 0) {
401
- // Didn't recover FD 2 — leave restored FD open
402
- }
403
- try { fs.closeSync(savedFd2); } catch { /* ignore */ }
404
- } catch { /* ignore restore errors */ }
438
+ // LIFO unwind (see captureStack): only the top frame restores; an
439
+ // out-of-order finisher parks as retired and is unwound — in reverse
440
+ // order — when everything above it has finished. Until then its patches
441
+ // stay installed, which merely extends capture a little; restoring early
442
+ // would divert stdio permanently.
443
+ frame.retired = true;
444
+ while (captureStack.length > 0 && captureStack[captureStack.length - 1]!.retired) {
445
+ captureStack.pop()!.restore();
405
446
  }
406
447
  }
407
448
  }
@@ -76,7 +76,14 @@ export function truncateWithMarker(content: string, maxChars: number): string {
76
76
  const makeMarker = (kept: number) => `\n\n[content truncated: showing ${kept} of ${total} chars]`;
77
77
  // Reserve marker room using the longest possible marker (kept ≤ total, so the
78
78
  // digit count of `kept` never exceeds that of `total`).
79
- const keep = Math.max(0, maxChars - makeMarker(total).length);
79
+ let keep = Math.max(0, maxChars - makeMarker(total).length);
80
+ // slice() counts UTF-16 code units, so the cut can land between the halves of a
81
+ // surrogate pair — a lone high surrogate in the body is invalid text that some
82
+ // providers reject (400) when it reaches a JSON payload. Backing off one unit
83
+ // restores well-formed text. The whitespace-boundary path below cannot split a
84
+ // pair: it cuts immediately before a BMP whitespace character.
85
+ const lastKeptUnit = content.charCodeAt(keep - 1);
86
+ if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
80
87
  let body = content.slice(0, keep);
81
88
  // Prefer a clean boundary near the cut point.
82
89
  const boundary = Math.max(body.lastIndexOf('\n'), body.lastIndexOf(' '));
@@ -125,13 +125,20 @@ export class ToolUsageTracker {
125
125
 
126
126
  const toolLimit = this.limits[toolName as keyof ToolLimits];
127
127
  const catLimit = usage.limit;
128
+ // Decide by which limit actually tripped, computed exactly as recordCall
129
+ // computes it: a first-ever `search` blocked by the shared gathering cap must
130
+ // NOT claim "you have already used your one search call" — the agent would
131
+ // wrongly believe it had searched. The per-tool wording is only correct when
132
+ // the per-tool counter itself is at its limit.
133
+ const toolCount = usage.toolCounts.get(toolName) || 0;
134
+ const toolLimitTripped = toolLimit !== undefined && toolCount >= toolLimit;
128
135
 
129
- if (toolName === 'search' && toolLimit === 1) {
136
+ if (toolName === 'search' && toolLimit === 1 && toolLimitTripped) {
130
137
  return `SEARCH LIMIT REACHED: You have already used your one search call. ` +
131
138
  `Proceed to scraping: use the scrape tool for full deep-dives into your best search results.`;
132
139
  }
133
140
 
134
- if (toolName === 'youtube_transcript' && toolLimit === 1) {
141
+ if (toolName === 'youtube_transcript' && toolLimit === 1 && toolLimitTripped) {
135
142
  return `YOUTUBE TRANSCRIPT LIMIT REACHED: You have already used your one youtube_transcript call. ` +
136
143
  `Continue with your other gathering tools (search, scrape) and proceed to synthesis.`;
137
144
  }
@@ -27,14 +27,22 @@ export async function performSearch(
27
27
  onProgress?: (links: number) => void,
28
28
  container: ServiceContainer = getServiceContainer(),
29
29
  /**
30
- * Optional sink for per-query FAILURES (timeout / worker error). Populated as
31
- * an out-param rather than folded into the return value because the returned
32
- * map's `query -> results` shape is what every caller consumes; a query absent
33
- * from this map produced zero results with no failure, i.e. the search really
34
- * did come back empty. Without this the caller cannot tell those apart and
35
- * reports every empty query as a too-narrow query.
30
+ * Optional sink for per-query FAILURES (timeout / worker error / cross-query
31
+ * dedup). Populated as an out-param rather than folded into the return value
32
+ * because the returned map's `query -> results` shape is what every caller
33
+ * consumes; a query absent from this map produced zero results with no
34
+ * failure, i.e. the search really did come back empty. Without this the
35
+ * caller cannot tell those apart and reports every empty query as a
36
+ * too-narrow query.
36
37
  */
37
38
  failures?: Map<string, QueryFailure>,
39
+ /**
40
+ * Session/research id keying the per-session circuit breaker inside
41
+ * runWorkerSearch — mirroring the scrape path, which threads it via
42
+ * BrowserTask.sessionId. Callers without a session (undefined) share the
43
+ * global breaker, as before.
44
+ */
45
+ sessionId?: string,
38
46
  ): Promise<Map<string, SearchResult[]>> {
39
47
  const startTime = Date.now();
40
48
  const resultMap = new Map<string, SearchResult[]>();
@@ -116,7 +124,7 @@ export async function performSearch(
116
124
  ? AbortSignal.any([signal, timeoutController.signal])
117
125
  : timeoutController.signal;
118
126
 
119
- const results = await runWorkerSearch(query, config, querySignal, 1, undefined, container);
127
+ const results = await runWorkerSearch(query, config, querySignal, 1, sessionId, container);
120
128
  const queryDuration = Date.now() - queryStartTime;
121
129
  metrics.observe('browser_search_query_duration_ms', queryDuration);
122
130
 
@@ -142,6 +150,17 @@ export async function performSearch(
142
150
  uniqueResults.push(r);
143
151
  }
144
152
  }
153
+ if (uniqueResults.length === 0) {
154
+ // Every raw result deduplicated away against URLs earlier
155
+ // queries in this batch already returned. Without an entry
156
+ // here the query fell through to the empty_results default —
157
+ // "the query may be too narrow" — sending the researcher off
158
+ // rewriting a query that actually worked.
159
+ failures?.set(query, {
160
+ type: 'all_duplicates',
161
+ message: `All ${results.length} results for this query duplicated URLs already returned by earlier queries in this batch. The query succeeded but surfaced nothing new — do not rewrite it; try a different angle only if you still need additional sources.`,
162
+ });
163
+ }
145
164
  resultMap.set(query, uniqueResults);
146
165
  } else {
147
166
  metrics.increment('browser_search_queries_total', 1, { status: 'no_results' });
@@ -343,6 +343,11 @@ function isPrivateIpv6(ip: string): boolean {
343
343
  // Link-local (fe80::/10)
344
344
  if (normalized.startsWith('fe8') || normalized.startsWith('fe9') ||
345
345
  normalized.startsWith('fea') || normalized.startsWith('feb')) return true;
346
+ // Deprecated site-local (fec0::/10, RFC 3879, deprecated 2004). No OS
347
+ // auto-routes to it today, but it's still squarely "private/reserved"
348
+ // address space — the sibling range immediately above this one.
349
+ if (normalized.startsWith('fec') || normalized.startsWith('fed') ||
350
+ normalized.startsWith('fee') || normalized.startsWith('fef')) return true;
346
351
  // IPv4-mapped ::ffff:x.x.x.x — check the embedded IPv4 part. The mapped tail
347
352
  // can arrive dotted-decimal (::ffff:127.0.0.1) OR hex (::ffff:7f00:1) — Node's
348
353
  // URL parser normalizes literals to the hex form — and the prefix can be
@@ -357,6 +362,14 @@ function isPrivateIpv6(ip: string): boolean {
357
362
  // legitimate scrape target, so block the whole ranges rather than decode them.
358
363
  if (normalized.startsWith('2002:')) return true;
359
364
  if (/^2001:0{0,4}:/.test(normalized) || normalized.startsWith('2001::')) return true;
365
+ // NAT64 well-known prefix (64:ff9b::/96, RFC 6052) also tunnels an IPv4
366
+ // address, and unlike the transitional ranges above it's actively deployed
367
+ // today (464XLAT on IPv6-only mobile/enterprise networks). A host whose
368
+ // outbound path traverses a NAT64 gateway gets this transparently
369
+ // translated to a direct connection to the embedded IPv4 (e.g. the cloud
370
+ // metadata service) — block the whole prefix regardless of what it wraps,
371
+ // same rationale as the 6to4/Teredo ranges just above.
372
+ if (normalized.startsWith('64:ff9b:')) return true;
360
373
  // All-zeros (unspecified address)
361
374
  if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
362
375
  return false;
@@ -23,6 +23,9 @@ import type { ServiceContainer } from '../core/service-registry.ts';
23
23
  * @param config - Optional configuration override
24
24
  * @param signal - Optional AbortSignal
25
25
  * @param onProgress - Optional callback with cumulative link count across completed queries
26
+ * @param container - Service container for scheduler resolution
27
+ * @param sessionId - Research/session id keying the per-session circuit breaker
28
+ * (mirrors the scrape path); undefined callers share the global breaker
26
29
  * @returns Promise<QueryResultWithError[]> - Array of search results with error information
27
30
  */
28
31
  export async function search(
@@ -30,7 +33,8 @@ export async function search(
30
33
  config?: Config,
31
34
  signal?: AbortSignal,
32
35
  onProgress?: (links: number) => void,
33
- container: ServiceContainer = getServiceContainer()
36
+ container: ServiceContainer = getServiceContainer(),
37
+ sessionId?: string
34
38
  ): Promise<QueryResultWithError[]> {
35
39
  if (queries.length === 0) return [];
36
40
 
@@ -42,7 +46,7 @@ export async function search(
42
46
  // Per-query failures (timeout / dead worker) come back separately from the
43
47
  // results so an empty result set can be attributed correctly below.
44
48
  const failures = new Map<string, QueryFailure>();
45
- const resultMap = await performSearch(queries, config, signal, onProgress, container, failures);
49
+ const resultMap = await performSearch(queries, config, signal, onProgress, container, failures, sessionId);
46
50
  const searchDuration = Date.now() - searchStart;
47
51
  metrics.observe('search_latency_ms', searchDuration);
48
52
 
@@ -24,10 +24,12 @@ export interface QueryResultWithError {
24
24
  * at all about the query and should prompt a retry instead. Collapsing the two —
25
25
  * reporting every zero-result query as "the query may be too narrow" — sent the
26
26
  * agent off rewriting perfectly good queries whenever the browser pool was the
27
- * thing that failed.
27
+ * thing that failed. `all_duplicates` is the third case: the query SUCCEEDED,
28
+ * but every result deduplicated away against URLs earlier queries in the batch
29
+ * already returned — coverage feedback, not query-quality feedback.
28
30
  */
29
31
  export interface QueryFailure {
30
- type: 'empty_results' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
32
+ type: 'empty_results' | 'all_duplicates' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
31
33
  message: string;
32
34
  }
33
35
 
@@ -80,7 +80,11 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
80
80
  const sizeMB = Math.round(bytes.length / 1024 / 1024);
81
81
  logger.warn(`[Scrapers] PDF too large (${sizeMB}MB, max 100MB), skipping extraction`);
82
82
  metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
83
- return `*Error: PDF too large (${sizeMB}MB, max 100MB).*`;
83
+ // THROW, never an in-band "*Error: ...*" string: a returned banner flowed
84
+ // into validateContent as if it were page content, and a verbose one
85
+ // (≥50 words / ≥200 chars) cleared the stub gate — the failure banner was
86
+ // then cached and cited as a successful scrape.
87
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
84
88
  }
85
89
 
86
90
  const pdfExtractionStart = Date.now();
@@ -114,7 +118,9 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
114
118
  contentType: 'pdf',
115
119
  errorType: 'extraction_failed',
116
120
  });
117
- return `*Error: Could not extract content from PDF (${msg}).*`;
121
+ // See the size branch above: an extraction failure must FAIL the scrape,
122
+ // not travel in-band as pseudo-content.
123
+ throw new Error(`Could not extract content from PDF (${msg})`, { cause: e });
118
124
  }
119
125
  }
120
126
 
@@ -354,8 +360,30 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
354
360
  const result = await runBrowserTask<any>({ url: _url, sessionId }, 'scrape', config, signal, 1, container);
355
361
  const browserDuration = Date.now() - browserStart;
356
362
 
357
- if (result.buffer) {
358
- const markdown = await extractPdfToMarkdown(new Uint8Array(result.buffer));
363
+ // PDF bytes travel base64-encoded (bufferB64): the worker result crosses the
364
+ // cluster-IPC JSON hop (and, for followers, a second JSON hop through the
365
+ // leader's browser-server), which turns a raw Buffer into
366
+ // {type:'Buffer',data:[...]} — new Uint8Array(that) is ZERO bytes, so the
367
+ // extraction ran on nothing and its error string was cached as a "success".
368
+ // The JSON-roundtripped legacy shape is still decoded so a mixed-version
369
+ // leader/follower pair keeps working.
370
+ const legacyBuffer = result.buffer;
371
+ const pdfBytes: Buffer | null =
372
+ typeof result.bufferB64 === 'string'
373
+ ? Buffer.from(result.bufferB64, 'base64')
374
+ : Array.isArray(legacyBuffer?.data)
375
+ ? Buffer.from(legacyBuffer.data)
376
+ : legacyBuffer instanceof Uint8Array
377
+ ? Buffer.from(legacyBuffer)
378
+ : null;
379
+
380
+ if (pdfBytes !== null) {
381
+ const markdown = await extractPdfToMarkdown(pdfBytes);
382
+ // Mirror the fetch layer's PDF branch: extraction failures THROW from
383
+ // extractPdfToMarkdown, and validateContent still fails a nominally
384
+ // successful extraction that produced no real content — neither may be
385
+ // recorded (and cached) as a success.
386
+ validateContent('', markdown, _url);
359
387
  metrics.increment('scrape_operations_total', 1, { layer: 'playwright', content_type: 'pdf', status: 'success' });
360
388
  metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', content_type: 'pdf', status: 'success' });
361
389
  return { source: 'playwright', layer: 'playwright+camoufox', markdown };
@@ -386,6 +414,10 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
386
414
  metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', content_type: 'html', status: 'success' });
387
415
  return { source: 'playwright', layer: 'playwright+camoufox', markdown };
388
416
  } catch (error) {
417
+ // A caller cancellation is not a scrape failure: no layer error metrics and
418
+ // no tracker entry — the search path applies the same 'Aborted' special case
419
+ // end-to-end (see browser-search.ts). scrapeSingle classifies it above us.
420
+ if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw error;
389
421
  const browserDuration = Date.now() - browserStart;
390
422
  metrics.increment('scrape_operations_total', 1, { layer: 'playwright', status: 'error' });
391
423
  metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', status: 'error' });
@@ -401,6 +433,15 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
401
433
  }
402
434
 
403
435
  export async function scrapeSingle(url: string, signal?: AbortSignal, config?: Config, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<ScrapeResult> {
436
+ // Cancellation short-circuit: after a user abort each remaining URL would
437
+ // otherwise still pay for SSRF DNS validation, a doomed fetch, and a browser
438
+ // fallback — all logged/counted as failures the user never had. 'Aborted' is
439
+ // the codebase's recognized cancellation signature (see browser-search.ts);
440
+ // deliberately no total_failure tally, no tracker entry, no ERROR log.
441
+ if (signal?.aborted) {
442
+ return { url, success: false, error: 'Aborted', markdown: '' };
443
+ }
444
+
404
445
  // Guard against an array accidentally stringified and passed as a single url (JSON arrays start
405
446
  // with '['). Do NOT reject brackets ANYWHERE: valid URLs legitimately contain them in query
406
447
  // strings (e.g. ?ids[]=1) and IPv6 literals (http://[::1]/) — the old includes('[') check dropped
@@ -477,6 +518,13 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
477
518
 
478
519
  return { ...res, url, success: true };
479
520
  } catch (e1) {
521
+ // Cancelled mid-fetch: not a fetch-layer failure, and no reason to pay for
522
+ // the browser fallback — classify as the recognized cancellation (no ERROR
523
+ // log, no tracker entry, no total_failure tally).
524
+ if (signal?.aborted || (e1 instanceof Error && e1.message === 'Aborted')) {
525
+ logger.debug(`[Scrapers] Scrape cancelled for ${url}`);
526
+ return { url, success: false, error: 'Aborted', markdown: '' };
527
+ }
480
528
  const fetchDuration = Date.now() - start;
481
529
  logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
482
530
  // Tracked here rather than inside scrapeWithFetch, which is retried above —
@@ -499,6 +547,14 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
499
547
 
500
548
  return { ...res, url, success: true };
501
549
  } catch (e2) {
550
+ // See the fetch-layer catch above: a cancellation surfacing from the
551
+ // browser layer (runBrowserTask throws exactly 'Aborted' on a caller
552
+ // abort) is not a failure — it must not reach the ERROR log, the error
553
+ // tracker, or the total_failure tally.
554
+ if (signal?.aborted || (e2 instanceof Error && e2.message === 'Aborted')) {
555
+ logger.debug(`[Scrapers] Browser scrape cancelled for ${url}`);
556
+ return { url, success: false, error: 'Aborted', markdown: '' };
557
+ }
502
558
  const totalDuration = Date.now() - start;
503
559
  // A blocked/stubbed/HTTP-errored/slow/pool-draining URL is an expected
504
560
  // scraping outcome, already surfaced to the user as "not scraped" (the
@@ -530,6 +586,11 @@ export async function scrape(urls: string[], maxConcurrency = 5, signal?: AbortS
530
586
 
531
587
  const results: ScrapeResult[] = [];
532
588
  for (let i = 0; i < urls.length; i += maxConcurrency) {
589
+ // Stop dispatching once the caller has cancelled; URLs already in flight
590
+ // settle via scrapeSingle's own cancellation classification. Without this,
591
+ // every remaining batch still ran its full fetch + browser-fallback cycle
592
+ // after a user cancel.
593
+ if (signal?.aborted) break;
533
594
  const batch = urls.slice(i, i + maxConcurrency);
534
595
  const batchRes = await Promise.all(
535
596
  batch.map(async url => {