@lincoln504/pi-research 1.1.2 → 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 (119) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +2599 -1268
  3. package/dist/thread-worker.mjs +224 -147
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +20 -10
  6. package/docs/CHANGELOG.md +195 -17
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +114 -12
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cleanup/research-cleanup.ts +12 -4
  21. package/src/cli.ts +372 -86
  22. package/src/config.ts +134 -17
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  25. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  26. package/src/core/interfaces/planning-interfaces.ts +8 -0
  27. package/src/core/interfaces/service-names.ts +0 -1
  28. package/src/core/interfaces/state-manager-interfaces.ts +11 -3
  29. package/src/core/llm/agentic-repair.ts +14 -0
  30. package/src/core/pi-version.ts +136 -0
  31. package/src/core/planning-service.ts +43 -1
  32. package/src/core/service-initialization.ts +6 -14
  33. package/src/core/service-interfaces.ts +1 -1
  34. package/src/core/service-registry.ts +117 -8
  35. package/src/healthcheck/index.ts +7 -2
  36. package/src/index.ts +11 -18
  37. package/src/infrastructure/browser/browser-client.ts +80 -13
  38. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  39. package/src/infrastructure/browser/browser-server.ts +41 -12
  40. package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
  41. package/src/infrastructure/browser/cleanup-utils.ts +95 -9
  42. package/src/infrastructure/browser/client-agent.ts +6 -5
  43. package/src/infrastructure/browser/config.ts +14 -3
  44. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  45. package/src/infrastructure/browser/scheduler-factory.ts +44 -4
  46. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  47. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  48. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  49. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  50. package/src/infrastructure/browser/thread-worker.mjs +224 -147
  51. package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
  52. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  53. package/src/infrastructure/embedding/embedding-factory.ts +61 -15
  54. package/src/infrastructure/embedding/embedding-server.ts +105 -11
  55. package/src/infrastructure/file-lock-service.ts +295 -28
  56. package/src/infrastructure/knowledge-store-service.ts +48 -3
  57. package/src/infrastructure/process-lifecycle-service.ts +134 -34
  58. package/src/infrastructure/research-run-semaphore.ts +37 -3
  59. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  60. package/src/infrastructure/state/state-browser-api.ts +5 -2
  61. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  62. package/src/infrastructure/state/state-manager-service.ts +5 -5
  63. package/src/infrastructure/state/state-manager.ts +34 -23
  64. package/src/infrastructure/types/state-types.ts +9 -0
  65. package/src/knowledge/chunker.ts +88 -1
  66. package/src/knowledge/embedder-init.ts +1 -1
  67. package/src/knowledge/embedder.ts +188 -46
  68. package/src/knowledge/index.ts +1 -1
  69. package/src/knowledge/store.ts +101 -21
  70. package/src/knowledge/webgpu-viability.ts +9 -4
  71. package/src/knowledge/writer-queue.ts +88 -46
  72. package/src/logger.ts +5 -1
  73. package/src/observers/research-observer-impl.ts +31 -0
  74. package/src/orchestration/abort-utils.ts +60 -0
  75. package/src/orchestration/deep-research-orchestrator.ts +25 -4
  76. package/src/orchestration/headless-observer.ts +8 -0
  77. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  78. package/src/orchestration/research-orchestration-service.ts +65 -15
  79. package/src/orchestration/research-session-service.ts +37 -11
  80. package/src/orchestration/research-synthesis-service.ts +33 -11
  81. package/src/orchestration/researcher-executor.ts +100 -20
  82. package/src/orchestration/researcher.ts +39 -20
  83. package/src/orchestration/session-state.ts +11 -0
  84. package/src/sdk.ts +125 -19
  85. package/src/security/cisa-kev.ts +10 -3
  86. package/src/security/github-advisories.ts +18 -7
  87. package/src/security/index.ts +51 -30
  88. package/src/security/nvd-client.ts +24 -8
  89. package/src/security/osv-client.ts +14 -4
  90. package/src/skill-install/skill-installer.ts +153 -11
  91. package/src/stackexchange/index.ts +20 -5
  92. package/src/stackexchange/output/table.ts +7 -2
  93. package/src/stackexchange/rest-client.ts +41 -3
  94. package/src/tools/research-knowledge-search.ts +15 -0
  95. package/src/tools/research-tool-definition.ts +8 -2
  96. package/src/tools/scrape.ts +48 -25
  97. package/src/tools/search.ts +16 -2
  98. package/src/tools/security.ts +68 -7
  99. package/src/tui/research-health.ts +18 -2
  100. package/src/utils/atomic-replace.ts +101 -0
  101. package/src/utils/circuit-breaker.ts +9 -2
  102. package/src/utils/citation-utils.ts +3 -1
  103. package/src/utils/disk-space-checker.ts +11 -6
  104. package/src/utils/http-body.ts +176 -0
  105. package/src/utils/json-utils.ts +116 -76
  106. package/src/utils/llm-usage.ts +17 -1
  107. package/src/utils/log-rotation.ts +42 -10
  108. package/src/utils/log-utils.ts +40 -0
  109. package/src/utils/metrics-summary.ts +27 -9
  110. package/src/utils/stdio-capture.ts +63 -22
  111. package/src/utils/text-utils.ts +8 -1
  112. package/src/utils/tool-usage-tracker.ts +9 -2
  113. package/src/web-research/browser-search.ts +70 -5
  114. package/src/web-research/scraper-utils.ts +13 -0
  115. package/src/web-research/search.ts +25 -6
  116. package/src/web-research/types.ts +19 -4
  117. package/src/web-research/web-scraper.ts +72 -54
  118. package/src/youtube/transcript-client.ts +5 -1
  119. package/src/core/health-check-service.ts +0 -265
@@ -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
  }
@@ -9,7 +9,7 @@ import { getMaxWorkers } from '../infrastructure/browser/config.ts';
9
9
  import { logger } from '../logger.ts';
10
10
  import { safeUnref } from '../utils/safe-unref.ts';
11
11
  import { normalizeUrl } from '../utils/url-utils.ts';
12
- import type { SearchResult } from './types.ts';
12
+ import type { SearchResult, QueryFailure } from './types.ts';
13
13
  import type { Config } from '../config.ts';
14
14
  import { metrics } from '../utils/metrics.ts';
15
15
  import { getServiceContainer } from '../core/service-registry.ts';
@@ -21,11 +21,28 @@ import type { ServiceContainer } from '../core/service-registry.ts';
21
21
  * Each worker maintains its own "warm" browser process.
22
22
  */
23
23
  export async function performSearch(
24
- queries: string[],
24
+ queries: string[],
25
25
  config?: Config,
26
26
  signal?: AbortSignal,
27
27
  onProgress?: (links: number) => void,
28
- container: ServiceContainer = getServiceContainer()
28
+ container: ServiceContainer = getServiceContainer(),
29
+ /**
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.
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,
29
46
  ): Promise<Map<string, SearchResult[]>> {
30
47
  const startTime = Date.now();
31
48
  const resultMap = new Map<string, SearchResult[]>();
@@ -78,7 +95,12 @@ export async function performSearch(
78
95
  // (post-dequeue) deadline.
79
96
  const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
80
97
 
81
- const filteredQueries = queries.filter(q => q.trim());
98
+ // Deduplicated: resultMap and the failures map are both keyed by the query
99
+ // string, so two tasks for the same string race last-writer-wins — instance A
100
+ // delivering results, then instance B timing out, discarded A's results and
101
+ // marked the query failed. One task per distinct string closes that (and stops
102
+ // paying for the duplicate browser work).
103
+ const filteredQueries = [...new Set(queries.filter(q => q.trim()))];
82
104
  let timeoutCount = 0;
83
105
  let errorCount = 0;
84
106
  // Keep one representative worker error so total-failure surfaces the real
@@ -102,7 +124,7 @@ export async function performSearch(
102
124
  ? AbortSignal.any([signal, timeoutController.signal])
103
125
  : timeoutController.signal;
104
126
 
105
- const results = await runWorkerSearch(query, config, querySignal, 1, undefined, container);
127
+ const results = await runWorkerSearch(query, config, querySignal, 1, sessionId, container);
106
128
  const queryDuration = Date.now() - queryStartTime;
107
129
  metrics.observe('browser_search_query_duration_ms', queryDuration);
108
130
 
@@ -128,6 +150,17 @@ export async function performSearch(
128
150
  uniqueResults.push(r);
129
151
  }
130
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
+ }
131
164
  resultMap.set(query, uniqueResults);
132
165
  } else {
133
166
  metrics.increment('browser_search_queries_total', 1, { status: 'no_results' });
@@ -146,11 +179,31 @@ export async function performSearch(
146
179
 
147
180
  if (isTimeout) {
148
181
  logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms — likely starved waiting for a free browser worker under concurrent load, or a slow/blocked search provider: "${query}"`);
182
+ failures?.set(query, {
183
+ type: 'timeout',
184
+ message: `Search timed out after ${QUERY_TIMEOUT_MS}ms before returning anything. This says nothing about the query — the browser pool was saturated or the search provider was slow/blocked. Retrying the same query later is reasonable.`,
185
+ });
149
186
  } else {
150
187
  const msg = error instanceof Error ? error.message : String(error);
151
188
  if (msg !== 'Aborted') {
152
189
  if (!sampleWorkerError) sampleWorkerError = msg;
153
190
  logger.error(`[Search] Worker failed for "${query}": ${msg}`);
191
+ failures?.set(query, {
192
+ type: 'service_unavailable',
193
+ message: `The search backend failed for this query (${msg}). This is an infrastructure failure, not a signal about the query itself — do not rewrite the query in response to it.`,
194
+ });
195
+ } else if (!signal?.aborted) {
196
+ // A worker-side 'Aborted' with no caller abort (a pool drain or
197
+ // leader handover cancelling the task) is an infrastructure
198
+ // interruption. Without an entry here the query fell through to
199
+ // the empty_results default — "the query may be too narrow" —
200
+ // the exact misattribution this map exists to prevent. The
201
+ // caller-abort case stays unrecorded: the post-loop signal
202
+ // check reports the cancellation itself.
203
+ failures?.set(query, {
204
+ type: 'service_unavailable',
205
+ message: 'The search task was cancelled on the worker side (pool drain or leader handover) before completing. This says nothing about the query — do not rewrite it; retrying the same query later is reasonable.',
206
+ });
154
207
  }
155
208
  }
156
209
  resultMap.set(query, []);
@@ -162,6 +215,18 @@ export async function performSearch(
162
215
 
163
216
  await Promise.all(searchTasks);
164
217
 
218
+ // A cancelled run is not a broken one. Every query aborts with zero results,
219
+ // which walks straight into the total-failure branch below and reports
220
+ // "Browser workers may be unavailable, DuckDuckGo is unreachable, or the
221
+ // system is under extreme load" — an infrastructure outage the user never had.
222
+ // They pressed Ctrl-C. Check this BEFORE the outage heuristic, and surface the
223
+ // codebase's standard abort signature so the callers that already special-case
224
+ // it (task-execution-service, the orchestrator, the CLI classifier) see a
225
+ // cancellation rather than a failure.
226
+ if (signal?.aborted) {
227
+ throw new Error('Aborted');
228
+ }
229
+
165
230
  // Detect total failure: if every valid query returned empty, the worker pool is likely dead.
166
231
  const totalResults = Array.from(resultMap.values()).reduce((sum, r) => sum + r.length, 0);
167
232
  if (totalResults === 0 && filteredQueries.length > 0) {
@@ -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;
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { logger } from '../logger.ts';
8
8
  import { performSearch } from './browser-search.ts';
9
- import type { QueryResultWithError } from './types.ts';
9
+ import type { QueryResultWithError, QueryFailure } from './types.ts';
10
10
  import type { Config } from '../config.ts';
11
11
  import { metrics } from '../utils/metrics.ts';
12
12
  import { getServiceContainer } from '../core/service-registry.ts';
@@ -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
 
@@ -39,7 +43,10 @@ export async function search(
39
43
  const searchStart = Date.now();
40
44
 
41
45
  try {
42
- const resultMap = await performSearch(queries, config, signal, onProgress, container);
46
+ // Per-query failures (timeout / dead worker) come back separately from the
47
+ // results so an empty result set can be attributed correctly below.
48
+ const failures = new Map<string, QueryFailure>();
49
+ const resultMap = await performSearch(queries, config, signal, onProgress, container, failures, sessionId);
43
50
  const searchDuration = Date.now() - searchStart;
44
51
  metrics.observe('search_latency_ms', searchDuration);
45
52
 
@@ -56,7 +63,12 @@ export async function search(
56
63
  successfulQueries++;
57
64
  metrics.observe('search_results_per_query', qResults.length);
58
65
  } else {
59
- result.error = {
66
+ // Only call it an empty result when the search actually completed and
67
+ // returned nothing. A recorded failure (timeout, dead worker, breaker
68
+ // fast-fail) carries its own message — telling the researcher "the query
69
+ // may be too narrow" for an infrastructure fault makes it rewrite a
70
+ // perfectly good query instead of retrying.
71
+ result.error = failures.get(q) ?? {
60
72
  type: 'empty_results',
61
73
  message: 'Browser-based search returned no relevant results for this query (the query may be too narrow, or the site returned nothing usable).'
62
74
  };
@@ -73,11 +85,18 @@ export async function search(
73
85
  return results;
74
86
  } catch (error) {
75
87
  const searchDuration = Date.now() - searchStart;
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ // A cancelled run must propagate as a cancellation. Swallowing it into
90
+ // per-query `unknown` errors would let the orchestrator carry on and
91
+ // synthesize from nothing, then report a failure the user never had. Checked
92
+ // BEFORE the error metrics: a user cancelling runs is not a failing search
93
+ // backend, and counting each Esc as queries.length errors painted one.
94
+ if (signal?.aborted || message === 'Aborted') {
95
+ throw error;
96
+ }
76
97
  metrics.observe('search_latency_ms', searchDuration, { status: 'error' });
77
98
  metrics.increment('search_queries_total', queries.length, { status: 'error' });
78
99
  metrics.increment('search_errors_total', 1, { error_type: 'search_failed' });
79
-
80
- const message = error instanceof Error ? error.message : String(error);
81
100
  // Re-throw total search failure so the orchestrator can surface a clear error
82
101
  // rather than silently producing an empty synthesis with zero links.
83
102
  if (message.includes('Search completely failed')) {
@@ -12,10 +12,25 @@ export type { SearchResult, ScrapeResult };
12
12
  export interface QueryResultWithError {
13
13
  query: string;
14
14
  results: SearchResult[];
15
- error?: {
16
- type: 'empty_results' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
17
- message: string;
18
- };
15
+ error?: QueryFailure;
16
+ }
17
+
18
+ /**
19
+ * Why one query yielded nothing.
20
+ *
21
+ * The distinction matters to the *researcher agent*, which reads these messages
22
+ * and decides what to do next: `empty_results` is genuine feedback about the
23
+ * query (rewrite it, broaden it), while a timeout or a dead worker says nothing
24
+ * at all about the query and should prompt a retry instead. Collapsing the two —
25
+ * reporting every zero-result query as "the query may be too narrow" — sent the
26
+ * agent off rewriting perfectly good queries whenever the browser pool was the
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.
30
+ */
31
+ export interface QueryFailure {
32
+ type: 'empty_results' | 'all_duplicates' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
33
+ message: string;
19
34
  }
20
35
 
21
36
  // Scraper types
@@ -26,6 +26,7 @@ import {
26
26
  import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
27
27
  import { isTransientError, abortableDelay } from './retry-utils.ts';
28
28
  import { safeUnref } from '../utils/safe-unref.ts';
29
+ import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
29
30
  import { getServiceContainer } from '../core/service-registry.ts';
30
31
  import type { ServiceContainer } from '../core/service-registry.ts';
31
32
  import { cacheScrapedContent } from '../utils/shared-links.ts';
@@ -79,7 +80,11 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
79
80
  const sizeMB = Math.round(bytes.length / 1024 / 1024);
80
81
  logger.warn(`[Scrapers] PDF too large (${sizeMB}MB, max 100MB), skipping extraction`);
81
82
  metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
82
- 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)`);
83
88
  }
84
89
 
85
90
  const pdfExtractionStart = Date.now();
@@ -113,7 +118,9 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
113
118
  contentType: 'pdf',
114
119
  errorType: 'extraction_failed',
115
120
  });
116
- 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 });
117
124
  }
118
125
  }
119
126
 
@@ -144,56 +151,12 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
144
151
  }
145
152
  }
146
153
 
147
- /** Thrown by readBodyCapped when a streamed body crosses its byte cap. */
148
- class BodyTooLargeError extends Error {
149
- constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
150
- super(`Response body exceeded ${maxBytes} byte cap`);
151
- this.name = 'BodyTooLargeError';
152
- }
153
- }
154
-
155
- /**
156
- * Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
157
- * over-limit body aborts the moment the cap is crossed. The Content-Length
158
- * pre-screen above is advisory only: a chunked (or lying) response carries no
159
- * usable length header, and `response.arrayBuffer()` would otherwise buffer the
160
- * entire body — potentially hundreds of MB from a hostile server — into the
161
- * main process before any size check ran.
162
- *
163
- * A body-less response (test doubles, some mocks) falls back to arrayBuffer();
164
- * callers keep their post-read size checks as the belt for that path.
165
- */
166
- async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
167
- const body = response.body;
168
- // Also tolerate non-standard doubles that expose a body without getReader.
169
- if (!body || typeof body.getReader !== 'function') {
170
- return new Uint8Array(await response.arrayBuffer());
171
- }
172
-
173
- const reader = body.getReader();
174
- const chunks: Uint8Array[] = [];
175
- let total = 0;
176
- try {
177
- for (;;) {
178
- const { done, value } = await reader.read();
179
- if (done) break;
180
- if (value) {
181
- total += value.byteLength;
182
- if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
183
- chunks.push(value);
184
- }
185
- }
186
- } finally {
187
- // Over-cap or downstream throw: release the connection instead of leaving
188
- // the socket open (and the server still sending) until GC. No-op after a
189
- // clean done=true read.
190
- try { await reader.cancel(); } catch { /* already closed */ }
191
- }
192
- const out = new Uint8Array(total);
193
- let offset = 0;
194
- for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
195
- return out;
196
- }
154
+ // readBodyCapped / BodyTooLargeError live in utils/http-body.ts — the scraper
155
+ // pioneered the streaming cap, and the JSON API clients now share that one
156
+ // implementation instead of each carrying a near-copy. The Content-Length
157
+ // pre-screen above stays advisory only: chunked responses carry no usable length
158
+ // header and the header is freely falsifiable, so the streamed cap is the check
159
+ // that actually bounds the allocation.
197
160
 
198
161
  /**
199
162
  * Record a fetch-layer failure exactly once per URL.
@@ -397,8 +360,30 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
397
360
  const result = await runBrowserTask<any>({ url: _url, sessionId }, 'scrape', config, signal, 1, container);
398
361
  const browserDuration = Date.now() - browserStart;
399
362
 
400
- if (result.buffer) {
401
- 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);
402
387
  metrics.increment('scrape_operations_total', 1, { layer: 'playwright', content_type: 'pdf', status: 'success' });
403
388
  metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', content_type: 'pdf', status: 'success' });
404
389
  return { source: 'playwright', layer: 'playwright+camoufox', markdown };
@@ -429,6 +414,10 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
429
414
  metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', content_type: 'html', status: 'success' });
430
415
  return { source: 'playwright', layer: 'playwright+camoufox', markdown };
431
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;
432
421
  const browserDuration = Date.now() - browserStart;
433
422
  metrics.increment('scrape_operations_total', 1, { layer: 'playwright', status: 'error' });
434
423
  metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', status: 'error' });
@@ -444,6 +433,15 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
444
433
  }
445
434
 
446
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
+
447
445
  // Guard against an array accidentally stringified and passed as a single url (JSON arrays start
448
446
  // with '['). Do NOT reject brackets ANYWHERE: valid URLs legitimately contain them in query
449
447
  // strings (e.g. ?ids[]=1) and IPv6 literals (http://[::1]/) — the old includes('[') check dropped
@@ -520,6 +518,13 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
520
518
 
521
519
  return { ...res, url, success: true };
522
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
+ }
523
528
  const fetchDuration = Date.now() - start;
524
529
  logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
525
530
  // Tracked here rather than inside scrapeWithFetch, which is retried above —
@@ -542,6 +547,14 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
542
547
 
543
548
  return { ...res, url, success: true };
544
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
+ }
545
558
  const totalDuration = Date.now() - start;
546
559
  // A blocked/stubbed/HTTP-errored/slow/pool-draining URL is an expected
547
560
  // scraping outcome, already surfaced to the user as "not scraped" (the
@@ -573,6 +586,11 @@ export async function scrape(urls: string[], maxConcurrency = 5, signal?: AbortS
573
586
 
574
587
  const results: ScrapeResult[] = [];
575
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;
576
594
  const batch = urls.slice(i, i + maxConcurrency);
577
595
  const batchRes = await Promise.all(
578
596
  batch.map(async url => {
@@ -21,6 +21,7 @@ import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-resea
21
21
  import { CircuitBreaker } from '../utils/circuit-breaker.ts';
22
22
  import { logger } from '../logger.ts';
23
23
  import { metrics } from '../utils/metrics.ts';
24
+ import { readTextCapped } from '../utils/http-body.ts';
24
25
  import { mintPoTokens } from './potoken.ts';
25
26
  import { watchUrl } from './video-id.ts';
26
27
 
@@ -255,7 +256,10 @@ async function fetchOne(
255
256
  void response.body?.cancel()?.catch(() => { /* best-effort */ });
256
257
  throw new Error(`HTTP ${response.status} from timedtext endpoint`);
257
258
  }
258
- const body = await response.text();
259
+ // A timedtext document is kilobytes; the cap only bounds a hostile or
260
+ // malfunctioning response, which this path is directly exposed to because
261
+ // base_url comes out of a parsed getInfo() payload.
262
+ const body = await readTextCapped(response);
259
263
  const text = parseJson3(body);
260
264
 
261
265
  // Empty-body guard: HTTP 200 + empty body == silent PoToken failure.