@lincoln504/pi-research 1.5.2 → 1.6.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.
@@ -28,9 +28,14 @@ const projectRoot = path.join(__dirname, '..');
28
28
  * coding-agent harnesses, so `npm uninstall` is symmetric with the in-app
29
29
  * installer (the /research-config "Install Skill in Coding Agents" action).
30
30
  * Reads the manifest written by the installer and removes ONLY entries we still
31
- * own (a symlink pointing into a pi-research skills dir, or a copy carrying our
32
- * package marker). Foreign directories are never touched. Best-effort; never
31
+ * own (a symlink pointing into a pi-research skill source dir, or a copy carrying
32
+ * our package marker). Foreign directories are never touched. Best-effort; never
33
33
  * throws (preuninstall must not fail the uninstall).
34
+ *
35
+ * The ownership regex below duplicates src/skill-install/skill-installer.ts's:
36
+ * preuninstall runs as a plain CJS script with no access to the built module.
37
+ * Both accept the current `agent-skill/` source dir AND the pre-1.5.3 `skills/`
38
+ * one, so a link created by an older version is still recognised and removed.
34
39
  */
35
40
  function removeInstalledSkills() {
36
41
  const manifestPath = path.join(os.homedir(), '.pi', 'research', 'installed-skills.json');
@@ -58,7 +63,7 @@ function removeInstalledSkills() {
58
63
  try {
59
64
  if (lst.isSymbolicLink()) {
60
65
  const dest = path.resolve(path.dirname(p), readlinkSync(p));
61
- owned = /[/\\]pi-research[/\\]skills[/\\]pi-research$/.test(dest);
66
+ owned = /[/\\]pi-research[/\\](?:agent-skill|skills)[/\\]pi-research$/.test(dest);
62
67
  } else {
63
68
  const md = readFileSync(path.join(p, 'SKILL.md'), 'utf-8');
64
69
  owned = md.includes('@lincoln504/pi-research');
@@ -10,7 +10,7 @@
10
10
  * Artifacts built:
11
11
  * - dist/thread-worker.mjs (browser worker, via build:worker)
12
12
  * - dist/cli.mjs (+ dist/prompts, dist/webgpu-probe.mjs) (pi-research CLI binary, via build:cli)
13
- * - skills/pi-research/scripts/run.mjs (skill launcher, via build:skill)
13
+ * - agent-skill/pi-research/scripts/run.mjs (skill launcher, via build:skill)
14
14
  */
15
15
 
16
16
  'use strict';
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * pi-research — standalone CLI
3
3
  *
4
4
  * A thin, dependency-free command line over the pi-research programmatic SDK
5
- * (src/sdk.ts). It lets any tool — Claude, Cursor, a shell script, CI — run
5
+ * (src/sdk.ts). It lets any tool — Claude, Codex, a shell script, CI — run
6
6
  * multi-agent web research without the pi CLI or TUI.
7
7
  *
8
8
  * pi-research research "<query>" [--depth N] [--model provider/id]
@@ -16,6 +16,7 @@ import { errorTracker } from '../../utils/error-tracker.ts';
16
16
  import { redactSecrets } from '../../utils/log-utils.ts';
17
17
  import { Utf8Body } from '../../utils/http-body.ts';
18
18
  import { getClientAgent } from './client-agent.ts';
19
+ import { isTransientSocketError } from './browser-error-utils.ts';
19
20
  import { getHealthCheckBudgetMs } from './config.ts';
20
21
  import type { NodeError } from '../../types/index.ts';
21
22
 
@@ -281,12 +282,29 @@ export class BrowserClient implements IScheduler {
281
282
  errorType = 'unknown';
282
283
  }
283
284
  const error = new Error(errorMsg);
284
- logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path} failed:`, errorMsg);
285
- errorTracker.trackError(error, {
286
- component: 'browser-manager',
287
- operation,
288
- errorType,
289
- });
285
+ // This is one ATTEMPT against the leader, not an outcome. runBrowserTask
286
+ // retries every transient socket failure — a leader handover (ECONNREFUSED
287
+ // against the departing leader's port) is the common case and recovers a
288
+ // few hundred milliseconds later. Logging ERROR and tracking here made every
289
+ // recovered handover look like a fault twice over: once in the ERROR log,
290
+ // and once more in the error tracker on top of the caller's own richer
291
+ // record (taskType + errorType) on the retry path. Observed 5 of 5 times in
292
+ // a real run log, each followed immediately by a successful recovery.
293
+ //
294
+ // A failure the caller will NOT retry still reports at ERROR here, and a
295
+ // retryable one that exhausts its budget still surfaces upstream with URL
296
+ // context (see web-scraper's isBenignScrapeFailure branch), so nothing that
297
+ // actually costs the user a result becomes invisible.
298
+ if (isTransientSocketError(error)) {
299
+ logger.debug(`[BrowserClient] Transient failure talking to http://127.0.0.1:${this.port}${path} (caller will retry): ${errorMsg}`);
300
+ } else {
301
+ logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path} failed:`, errorMsg);
302
+ errorTracker.trackError(error, {
303
+ component: 'browser-manager',
304
+ operation,
305
+ errorType,
306
+ });
307
+ }
290
308
  reject(error);
291
309
  });
292
310
  req.write(JSON.stringify(data));
@@ -124,6 +124,59 @@ export function isPoolShutdownError(error: unknown): boolean {
124
124
  );
125
125
  }
126
126
 
127
+ /** Playwright navigation failures whose cause is at the remote end — see below. */
128
+ const BENIGN_NAVIGATION_FAILURE =
129
+ /page\.goto: (?:Timeout \d+ *ms exceeded|net::|NS_ERROR_|SEC_ERROR_|MOZILLA_PKIX_ERROR_)/;
130
+
131
+ /**
132
+ * True when a scrape failure is an EXPECTED per-URL outcome rather than an engine
133
+ * fault. Scraping the open web routinely hits bot-protection interstitials, thin
134
+ * "stub" pages, HTTP 4xx/5xx, slow/unresponsive remotes, and (on quit) pool-drain
135
+ * races. None of these is a pi-research bug: the URL is already accounted for as
136
+ * "not scraped" (scrape_results_total{outcome=total_failure}) and surfaced to the
137
+ * user that way, and no operator action follows. Callers use this to log such
138
+ * failures at debug instead of error — keeping the ERROR log (and, via
139
+ * logger.error's error-tracker re-track, the diagnostic error count) focused on
140
+ * genuine faults. The messages matched here are produced by validateContent
141
+ * (`Fetch blocked:` / `Fetch returned stub:`), scrapeWithFetch (`HTTP <code>`)
142
+ * and extractPdfToMarkdown (`Could not extract content from PDF`, `PDF too
143
+ * large`) — a malformed/encrypted/scanned-image-only PDF, or one that simply
144
+ * exceeds the deliberate 100MB policy cap, is routine on the open web and
145
+ * just as non-actionable as a stub page or a 404; the 25MB HTML policy caps
146
+ * (`HTML response too large` from the fetch layer, `Browser HTML too large`
147
+ * from the browser layer) are the same deliberate-cap class; the remaining
148
+ * cases delegate to the shared browser-layer predicates. Deliberately does NOT
149
+ * match 'PDF extraction unavailable' (a pdf-oxide-wasm native-module load
150
+ * failure) — that is an infrastructure fault affecting every URL, not a
151
+ * routine per-PDF outcome, and must still surface at ERROR.
152
+ *
153
+ * Navigation failures reported by Playwright (`page.goto: …`) are the same class
154
+ * once — and only once — the reason is a network, TLS or timeout condition at the
155
+ * remote end: an expired/self-signed certificate (`SEC_ERROR_UNKNOWN_ISSUER` on a
156
+ * live university PDF host), a DNS or connection error (`net::ERR_…`,
157
+ * `NS_ERROR_…`), or a site too slow to answer inside the scrape budget. These were
158
+ * logged at ERROR by both the leader and the scraper. `page.goto` failures with any
159
+ * OTHER reason — notably `Target closed`, which is how a crashed or force-closed
160
+ * browser surfaces — are deliberately excluded and still report as faults.
161
+ */
162
+ export function isBenignScrapeFailure(error: unknown): boolean {
163
+ const msg = error instanceof Error ? error.message : String(error);
164
+ return (
165
+ msg.startsWith('Fetch blocked:') || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
166
+ msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
167
+ msg.startsWith('Could not extract content from PDF') || // malformed/encrypted/scanned-only PDF
168
+ msg.startsWith('PDF too large') || // exceeds the deliberate 100MB policy cap
169
+ msg.startsWith('Unsupported download:') || // attachment-served non-PDF (zip/xlsx/…) — nothing to extract
170
+ BENIGN_NAVIGATION_FAILURE.test(msg) || // remote TLS/DNS/timeout — the URL could not be loaded
171
+ msg.startsWith('HTML response too large') || // fetch-layer 25MB policy cap — same class as the PDF cap
172
+ msg.startsWith('Browser HTML too large') || // browser-layer 25MB policy cap
173
+ /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
174
+ isCloudflareBlockError(error) ||
175
+ isTaskTimeoutError(error) ||
176
+ isPoolShutdownError(error)
177
+ );
178
+ }
179
+
127
180
  // Session-scoped circuit breaker storage to support concurrent research runs
128
181
  const sessionCircuitBreakers = new Map<string, CircuitBreaker>();
129
182
  // Hard cap so a cleanup path that ever skips clearSessionCircuitBreaker (e.g. an earlier
@@ -3,7 +3,7 @@ import * as crypto from 'node:crypto';
3
3
  import { logger } from '../../logger.ts';
4
4
  import { Utf8Body } from '../../utils/http-body.ts';
5
5
  import type { SearchResult } from '../../web-research/types.ts';
6
- import { isCloudflareBlockError, isPoolShutdownError } from './browser-error-utils.ts';
6
+ import { isCloudflareBlockError, isPoolShutdownError, isBenignScrapeFailure } from './browser-error-utils.ts';
7
7
 
8
8
  export interface BrowserServerOptions {
9
9
  onSearch: (query: string, signal?: AbortSignal) => Promise<SearchResult[]>;
@@ -204,6 +204,14 @@ export class BrowserServer {
204
204
  // Pool destroyed mid-request during quit/SIGTERM teardown — expected,
205
205
  // not a server fault. DEBUG so a normal shutdown doesn't emit ERRORs.
206
206
  logger.debug('[BrowserServer] Request abandoned during shutdown:', error instanceof Error ? error.message : String(error));
207
+ } else if (isBenignScrapeFailure(error)) {
208
+ // Per-URL outcomes the CLIENT side already classifies as routine —
209
+ // an HTTP 4xx/5xx, a navigation timeout, an oversized PDF, a blocked
210
+ // fetch. The leader saw the same failure first and logged it at ERROR
211
+ // regardless, so every one of those outcomes still produced exactly
212
+ // one false ERROR per URL despite the client's classification. Same
213
+ // predicate as web-scraper.ts, so the two layers agree.
214
+ logger.debug('[BrowserServer] Expected per-URL scrape outcome:', error instanceof Error ? error.message.split('\n')[0] : String(error));
207
215
  } else {
208
216
  logger.error('[BrowserServer] Error handling request:', error);
209
217
  }
@@ -368,7 +368,10 @@ export async function initBrowser(): Promise<void> {
368
368
  * rather than left to leak.
369
369
  */
370
370
  export async function acquireTaskContext(): Promise<any> {
371
- const contextPromise = browser.newContext();
371
+ // acceptDownloads is explicit, not defaulted: executeScrapeTask recovers PDFs that a
372
+ // server sends as an attachment by reading the resulting Download, and that object
373
+ // only exists when downloads are accepted.
374
+ const contextPromise = browser.newContext({ acceptDownloads: true });
372
375
  contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
373
376
  let contextTimeoutId: NodeJS.Timeout | undefined;
374
377
  let contextRaceLost = false;
@@ -6,7 +6,7 @@
6
6
 
7
7
  let workerId: string = '';
8
8
 
9
- import { appendFileSync } from 'node:fs';
9
+ import { appendFileSync, promises as fsp } from 'node:fs';
10
10
  import { redactSecrets } from '../../utils/log-utils.ts';
11
11
  import { safeUnref } from '../../utils/safe-unref.ts';
12
12
  import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts';
@@ -405,6 +405,146 @@ function seedSubresourceVerdict(validatedUrl: string): void {
405
405
  }
406
406
  }
407
407
 
408
+ /**
409
+ * True when Firefox has discarded a response body it once held, so `response.body()`
410
+ * can never succeed for that response again. Distinct from every other body failure —
411
+ * a closed target, an aborted task — which must keep their original error.
412
+ */
413
+ function isEvictedBodyError(err: unknown): boolean {
414
+ const msg = err instanceof Error ? err.message : String(err);
415
+ return /was evicted|Response body is unavailable/i.test(msg);
416
+ }
417
+
418
+ /** Upper bound on the PDF re-fetch below. A 14MB PDF measured ~20s on a normal link. */
419
+ const PDF_REFETCH_TIMEOUT_MS = 90_000;
420
+
421
+ /**
422
+ * Re-request a PDF whose navigation body Firefox has already discarded.
423
+ *
424
+ * Uses the browser context's own request API rather than a bare fetch so the retry
425
+ * carries the same cookies, headers, proxy and TLS profile the navigation used — the
426
+ * reason this URL reached the stealth-browser fallback in the first place is usually
427
+ * that a plain fetch does not get served.
428
+ *
429
+ * SSRF: this is the one request in the scrape path that does NOT pass through
430
+ * `page.route` or fire `page.on('response')`, so neither the request-time DNS check
431
+ * nor the serverAddr rebinding backstop covers it. Three things stand in for them:
432
+ * the URL is re-validated here with the same DNS-aware check the route guard uses;
433
+ * it is the FINAL navigation URL, whose actually-connected IP the caller validated
434
+ * via `response.serverAddr()` seconds earlier; and redirects are refused outright, so
435
+ * the re-fetch cannot be walked to an unvalidated host. What remains is a TTL-0
436
+ * rebind landing inside the seconds between the navigation and this retry, on a host
437
+ * that already answered from a public address — strictly narrower than the
438
+ * subresource TOCTOU window the request-time check already accepts.
439
+ */
440
+ async function refetchPdfBody(
441
+ page: any,
442
+ url: string,
443
+ validateForWorker: (u: string) => Promise<unknown>,
444
+ signal?: AbortSignal,
445
+ ): Promise<Buffer> {
446
+ await validateForWorker(url);
447
+ if (signal?.aborted) throw new Error('Aborted');
448
+
449
+ // Raced against the task signal, not merely checked before starting. Playwright's
450
+ // request API takes no AbortSignal, and closing the page does not cancel it — so
451
+ // without this race a slow retry would hold the poolifier worker slot for up to
452
+ // PDF_REFETCH_TIMEOUT_MS past the deadline that was supposed to free it. The request
453
+ // itself is left to settle and be discarded; what matters is that the slot is not
454
+ // pinned waiting for it.
455
+ const requestPromise = page.context().request.get(url, {
456
+ maxRedirects: 0,
457
+ timeout: PDF_REFETCH_TIMEOUT_MS,
458
+ });
459
+ requestPromise.catch(() => {});
460
+ const response = await (signal
461
+ ? Promise.race([
462
+ requestPromise,
463
+ new Promise<never>((_, reject) => {
464
+ if (signal.aborted) return reject(new Error('Aborted'));
465
+ signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
466
+ }),
467
+ ])
468
+ : requestPromise);
469
+ const status = Number(response.status?.() ?? 0);
470
+ // A 3xx here means the server wants to send us somewhere maxRedirects:0 refused to
471
+ // follow — report it as the HTTP outcome it is rather than reading a redirect body.
472
+ if (!response.ok?.()) throw new Error(`HTTP ${status}`);
473
+
474
+ const declared = parseInt(String((await response.headersArray?.() ?? []).find?.((h: any) => String(h.name).toLowerCase() === 'content-length')?.value ?? ''), 10);
475
+ if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
476
+ throw new Error(`PDF too large (${Math.round(declared / 1024 / 1024)}MB, max 100MB)`);
477
+ }
478
+
479
+ const buffer: Buffer = await response.body();
480
+ if (buffer.byteLength > MAX_PDF_SIZE) {
481
+ throw new Error(`PDF too large (${Math.round(buffer.byteLength / 1024 / 1024)}MB, max 100MB)`);
482
+ }
483
+ logToDebugFile('DEBUG', `[Worker-${workerId}] Re-fetched evicted PDF body (${buffer.byteLength} bytes) for: ${url}`);
484
+ return buffer;
485
+ }
486
+
487
+ /**
488
+ * Finish a scrape from a Download the navigation turned into.
489
+ *
490
+ * A server that sends a PDF with `Content-Disposition: attachment` never produces a
491
+ * document for Playwright to inspect — Firefox hands the response to the download
492
+ * manager and `page.goto()` rejects with "Download is starting". The bytes have
493
+ * already arrived at that point, so the URL is recoverable; before this path existed
494
+ * every such source (vendor datasheets, standards-body PDFs, several journal hosts)
495
+ * was reported as an outright scrape failure.
496
+ *
497
+ * The SSRF guarantees are the ones the normal PDF branch relies on: the download's
498
+ * response passed through the same `page.on('response')` inspection, so settling
499
+ * `pendingAddrChecks` and re-checking the poison gate before returning bytes is
500
+ * equivalent here.
501
+ */
502
+ async function readPdfDownload(
503
+ download: any,
504
+ url: string,
505
+ page: any,
506
+ pendingAddrChecks: Array<Promise<void>>,
507
+ getPoisonedError: () => Error | null,
508
+ ): Promise<{ contentType: string; bufferB64: string; jitter: number }> {
509
+ const failure = await download.failure?.().catch(() => null);
510
+ if (failure) throw new Error(`Fetch blocked: download failed (${failure})`);
511
+
512
+ // Only PDFs are usable as binary content downstream; anything else (zip, csv, xlsx)
513
+ // is a routine unsupported-target outcome for this URL, not a fault.
514
+ const suggested = String(download.suggestedFilename?.() ?? '');
515
+ const downloadUrl = String(download.url?.() ?? url);
516
+ const looksLikePdf =
517
+ suggested.toLowerCase().endsWith('.pdf') || downloadUrl.toLowerCase().split('?')[0]!.endsWith('.pdf');
518
+ if (!looksLikePdf) {
519
+ throw new Error(`Unsupported download: ${suggested || downloadUrl} is not a PDF`);
520
+ }
521
+
522
+ const filePath = await download.path();
523
+ if (!filePath) throw new Error('Fetch blocked: download produced no readable file');
524
+
525
+ // Same 100MB policy cap the PDF branch enforces, checked from the file size so the
526
+ // bytes are never read into the worker at all when the file is over the limit.
527
+ const { size } = await fsp.stat(filePath);
528
+ if (size > MAX_PDF_SIZE) {
529
+ const sizeMB = Math.round(size / 1024 / 1024);
530
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
531
+ }
532
+
533
+ const buffer = await fsp.readFile(filePath);
534
+
535
+ await Promise.allSettled(pendingAddrChecks);
536
+ const poisoned = getPoisonedError();
537
+ if (poisoned) throw poisoned;
538
+
539
+ // Delete before the page closes: the context's download directory is removed on
540
+ // context close, but a worker's context is reused across tasks, so an undeleted
541
+ // file would accumulate for the life of the worker.
542
+ await download.delete?.().catch(() => {});
543
+ await page.close();
544
+ logToDebugFile('DEBUG', `[Worker-${workerId}] Recovered attachment-served PDF (${buffer.byteLength} bytes) for: ${url}`);
545
+ return { contentType: 'application/pdf', bufferB64: buffer.toString('base64'), jitter: 0 };
546
+ }
547
+
408
548
  /**
409
549
  * Execute a scrape task
410
550
  */
@@ -446,6 +586,14 @@ export async function executeScrapeTask(
446
586
  // page.close() produces.
447
587
  let oversizedPdfError: Error | null = null;
448
588
 
589
+ // Set when the navigation turns into a download instead of a document. A server that
590
+ // sends the PDF with Content-Disposition: attachment makes Firefox hand the response
591
+ // to the download manager, and page.goto() then rejects with "Download is starting" —
592
+ // the PDF branch below was never reached and the document was lost even though the
593
+ // bytes had already arrived. Captured here so the goto catch can finish the scrape
594
+ // from the downloaded file instead of failing the URL.
595
+ let pendingDownload: any = null;
596
+
449
597
  try {
450
598
  logToDebugFile('DEBUG', `[Worker-${workerId}] Starting scrape for: ${url}`);
451
599
 
@@ -589,8 +737,32 @@ export async function executeScrapeTask(
589
737
  }
590
738
  });
591
739
 
740
+ // A navigation the server marks as an attachment never becomes a document: Firefox
741
+ // routes it to the download manager and goto() rejects. Record the Download object
742
+ // so the goto catch below can read the bytes that already arrived.
743
+ page.on('download', (d: any) => { pendingDownload ??= d; });
744
+
592
745
  // High-fidelity wait: try domcontentloaded first for speed
593
- const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
746
+ let response;
747
+ try {
748
+ response = await page.goto(url, { waitUntil: 'domcontentloaded' });
749
+ } catch (navErr: unknown) {
750
+ // Only the download case is recoverable here; everything else (timeout, DNS,
751
+ // 'Target closed' from an abort/poison) must keep its original error.
752
+ const navMsg = navErr instanceof Error ? navErr.message : String(navErr);
753
+ const isDownloadNav = /download is starting/i.test(navMsg);
754
+ if (!isDownloadNav) throw navErr;
755
+ // The 'download' event and the goto rejection are not ordered relative to each
756
+ // other, so give the event a brief moment to land before giving up on it.
757
+ if (!pendingDownload) {
758
+ await Promise.race([
759
+ page.waitForEvent('download', { timeout: 5000 }).catch(() => null),
760
+ new Promise(r => setTimeout(r, 5000)),
761
+ ]);
762
+ }
763
+ if (!pendingDownload) throw navErr;
764
+ return await readPdfDownload(pendingDownload, url, page, pendingAddrChecks, () => poisonedError);
765
+ }
594
766
 
595
767
  // DNS-rebinding defense-in-depth. The page.route guard's validateForWorker resolves the
596
768
  // hostname independently of the browser's own connect-time resolution, so a TTL-0 rebind can
@@ -647,7 +819,18 @@ export async function executeScrapeTask(
647
819
  // decided this and be in the middle of closing the page — skip the (redundant,
648
820
  // about-to-fail) download attempt rather than race it.
649
821
  if (oversizedPdfError) throw oversizedPdfError;
650
- const buffer = await response.body();
822
+ let buffer: Buffer;
823
+ try {
824
+ buffer = await response.body();
825
+ } catch (bodyErr) {
826
+ // Firefox drops large navigation response bodies from its juggler-side cache,
827
+ // after which response.body() can only ever fail. Measured against a 13.9MB
828
+ // TU Delft lecture PDF: the body is unavailable whether it is requested at
829
+ // headers-received time or after the load settles, so this is not a race that
830
+ // asking earlier can win — the bytes are simply no longer held for us.
831
+ if (!isEvictedBodyError(bodyErr)) throw bodyErr;
832
+ buffer = await refetchPdfBody(page, finalUrl, validateForWorker, signal);
833
+ }
651
834
  // ...then the actual bytes: the header is advisory and absent on chunked
652
835
  // responses, so the byte count is the check that actually bounds memory.
653
836
  // Checked BEFORE toString('base64') doubles the allocation.
@@ -1516,11 +1516,25 @@ function isPoolShutdownError(error) {
1516
1516
  // same transient swap the "being reset" string covers, not a hard fault.
1517
1517
  err.message.includes("Cannot execute a task on not started pool"));
1518
1518
  }
1519
- var DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
1519
+ function isBenignScrapeFailure(error) {
1520
+ const msg = error instanceof Error ? error.message : String(error);
1521
+ return msg.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
1522
+ msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
1523
+ msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
1524
+ msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
1525
+ msg.startsWith("Unsupported download:") || // attachment-served non-PDF (zip/xlsx/…) — nothing to extract
1526
+ BENIGN_NAVIGATION_FAILURE.test(msg) || // remote TLS/DNS/timeout — the URL could not be loaded
1527
+ msg.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
1528
+ msg.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
1529
+ /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
1530
+ isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
1531
+ }
1532
+ var BENIGN_NAVIGATION_FAILURE, DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
1520
1533
  var init_browser_error_utils = __esm({
1521
1534
  "src/infrastructure/browser/browser-error-utils.ts"() {
1522
1535
  "use strict";
1523
1536
  init_circuit_breaker();
1537
+ BENIGN_NAVIGATION_FAILURE = /page\.goto: (?:Timeout \d+ *ms exceeded|net::|NS_ERROR_|SEC_ERROR_|MOZILLA_PKIX_ERROR_)/;
1524
1538
  DEFAULT_BREAKER_CONFIG = {
1525
1539
  // Raised from 3→8: parallel search bursts of 20 queries frequently produce
1526
1540
  // simultaneous timeouts on slow/CF-protected sites. A threshold of 3 caused
@@ -1565,17 +1579,6 @@ import * as dnsCb from "node:dns";
1565
1579
  import * as net from "node:net";
1566
1580
  import crypto from "node:crypto";
1567
1581
  import { NodeHtmlMarkdown } from "node-html-markdown";
1568
- function isBenignScrapeFailure(error) {
1569
- const msg = error instanceof Error ? error.message : String(error);
1570
- return msg.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
1571
- msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
1572
- msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
1573
- msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
1574
- msg.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
1575
- msg.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
1576
- /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
1577
- isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
1578
- }
1579
1582
  function getRandomUserAgent() {
1580
1583
  try {
1581
1584
  const index = crypto.randomInt(0, USER_AGENTS.length);
@@ -2014,7 +2017,7 @@ import { platform as platform2 } from "node:os";
2014
2017
 
2015
2018
  // src/infrastructure/browser/thread-worker-messaging.ts
2016
2019
  init_log_utils();
2017
- import { appendFileSync as appendFileSync2 } from "node:fs";
2020
+ import { appendFileSync as appendFileSync2, promises as fsp } from "node:fs";
2018
2021
 
2019
2022
  // src/utils/safe-unref.ts
2020
2023
  function safeUnref(timer) {
@@ -2286,6 +2289,66 @@ function seedSubresourceVerdict(validatedUrl) {
2286
2289
  } catch {
2287
2290
  }
2288
2291
  }
2292
+ function isEvictedBodyError(err) {
2293
+ const msg = err instanceof Error ? err.message : String(err);
2294
+ return /was evicted|Response body is unavailable/i.test(msg);
2295
+ }
2296
+ var PDF_REFETCH_TIMEOUT_MS = 9e4;
2297
+ async function refetchPdfBody(page, url, validateForWorker, signal) {
2298
+ await validateForWorker(url);
2299
+ if (signal?.aborted) throw new Error("Aborted");
2300
+ const requestPromise = page.context().request.get(url, {
2301
+ maxRedirects: 0,
2302
+ timeout: PDF_REFETCH_TIMEOUT_MS
2303
+ });
2304
+ requestPromise.catch(() => {
2305
+ });
2306
+ const response = await (signal ? Promise.race([
2307
+ requestPromise,
2308
+ new Promise((_, reject) => {
2309
+ if (signal.aborted) return reject(new Error("Aborted"));
2310
+ signal.addEventListener("abort", () => reject(new Error("Aborted")), { once: true });
2311
+ })
2312
+ ]) : requestPromise);
2313
+ const status = Number(response.status?.() ?? 0);
2314
+ if (!response.ok?.()) throw new Error(`HTTP ${status}`);
2315
+ const declared = parseInt(String((await response.headersArray?.() ?? []).find?.((h) => String(h.name).toLowerCase() === "content-length")?.value ?? ""), 10);
2316
+ if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
2317
+ throw new Error(`PDF too large (${Math.round(declared / 1024 / 1024)}MB, max 100MB)`);
2318
+ }
2319
+ const buffer = await response.body();
2320
+ if (buffer.byteLength > MAX_PDF_SIZE) {
2321
+ throw new Error(`PDF too large (${Math.round(buffer.byteLength / 1024 / 1024)}MB, max 100MB)`);
2322
+ }
2323
+ logToDebugFile2("DEBUG", `[Worker-${workerId2}] Re-fetched evicted PDF body (${buffer.byteLength} bytes) for: ${url}`);
2324
+ return buffer;
2325
+ }
2326
+ async function readPdfDownload(download, url, page, pendingAddrChecks, getPoisonedError) {
2327
+ const failure = await download.failure?.().catch(() => null);
2328
+ if (failure) throw new Error(`Fetch blocked: download failed (${failure})`);
2329
+ const suggested = String(download.suggestedFilename?.() ?? "");
2330
+ const downloadUrl = String(download.url?.() ?? url);
2331
+ const looksLikePdf = suggested.toLowerCase().endsWith(".pdf") || downloadUrl.toLowerCase().split("?")[0].endsWith(".pdf");
2332
+ if (!looksLikePdf) {
2333
+ throw new Error(`Unsupported download: ${suggested || downloadUrl} is not a PDF`);
2334
+ }
2335
+ const filePath = await download.path();
2336
+ if (!filePath) throw new Error("Fetch blocked: download produced no readable file");
2337
+ const { size } = await fsp.stat(filePath);
2338
+ if (size > MAX_PDF_SIZE) {
2339
+ const sizeMB = Math.round(size / 1024 / 1024);
2340
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2341
+ }
2342
+ const buffer = await fsp.readFile(filePath);
2343
+ await Promise.allSettled(pendingAddrChecks);
2344
+ const poisoned = getPoisonedError();
2345
+ if (poisoned) throw poisoned;
2346
+ await download.delete?.().catch(() => {
2347
+ });
2348
+ await page.close();
2349
+ logToDebugFile2("DEBUG", `[Worker-${workerId2}] Recovered attachment-served PDF (${buffer.byteLength} bytes) for: ${url}`);
2350
+ return { contentType: "application/pdf", bufferB64: buffer.toString("base64"), jitter: 0 };
2351
+ }
2289
2352
  async function executeScrapeTask(_context, url, signal) {
2290
2353
  const page = await createPageSafe(_context);
2291
2354
  const SCRAPE_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"], 15e3);
@@ -2302,6 +2365,7 @@ async function executeScrapeTask(_context, url, signal) {
2302
2365
  let poisonedError = null;
2303
2366
  const pendingAddrChecks = [];
2304
2367
  let oversizedPdfError = null;
2368
+ let pendingDownload = null;
2305
2369
  try {
2306
2370
  logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting scrape for: ${url}`);
2307
2371
  const { validateUrlForSSRF: validateForWorker, validateUrlForSSRFSync: validateForWorkerSync } = await Promise.resolve().then(() => (init_scraper_utils(), scraper_utils_exports));
@@ -2368,7 +2432,25 @@ async function executeScrapeTask(_context, url, signal) {
2368
2432
  } catch {
2369
2433
  }
2370
2434
  });
2371
- const response = await page.goto(url, { waitUntil: "domcontentloaded" });
2435
+ page.on("download", (d) => {
2436
+ pendingDownload ??= d;
2437
+ });
2438
+ let response;
2439
+ try {
2440
+ response = await page.goto(url, { waitUntil: "domcontentloaded" });
2441
+ } catch (navErr) {
2442
+ const navMsg = navErr instanceof Error ? navErr.message : String(navErr);
2443
+ const isDownloadNav = /download is starting/i.test(navMsg);
2444
+ if (!isDownloadNav) throw navErr;
2445
+ if (!pendingDownload) {
2446
+ await Promise.race([
2447
+ page.waitForEvent("download", { timeout: 5e3 }).catch(() => null),
2448
+ new Promise((r) => setTimeout(r, 5e3))
2449
+ ]);
2450
+ }
2451
+ if (!pendingDownload) throw navErr;
2452
+ return await readPdfDownload(pendingDownload, url, page, pendingAddrChecks, () => poisonedError);
2453
+ }
2372
2454
  const serverAddr = await response?.serverAddr().catch(() => null);
2373
2455
  if (serverAddr?.ipAddress) {
2374
2456
  const ip = serverAddr.ipAddress;
@@ -2386,7 +2468,13 @@ async function executeScrapeTask(_context, url, signal) {
2386
2468
  throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2387
2469
  }
2388
2470
  if (oversizedPdfError) throw oversizedPdfError;
2389
- const buffer = await response.body();
2471
+ let buffer;
2472
+ try {
2473
+ buffer = await response.body();
2474
+ } catch (bodyErr) {
2475
+ if (!isEvictedBodyError(bodyErr)) throw bodyErr;
2476
+ buffer = await refetchPdfBody(page, finalUrl, validateForWorker, signal);
2477
+ }
2390
2478
  if (buffer.byteLength > MAX_PDF_SIZE) {
2391
2479
  const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
2392
2480
  throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
@@ -2749,7 +2837,7 @@ function logToDebugFile3(level, ...args) {
2749
2837
  return String(arg);
2750
2838
  }).join(" "))
2751
2839
  };
2752
- import("node:fs/promises").then((fsp) => fsp.appendFile(logFile, `${JSON.stringify(entry)}
2840
+ import("node:fs/promises").then((fsp2) => fsp2.appendFile(logFile, `${JSON.stringify(entry)}
2753
2841
  `)).catch(() => {
2754
2842
  });
2755
2843
  } catch {
@@ -2888,7 +2976,7 @@ async function initBrowser() {
2888
2976
  return initPromise;
2889
2977
  }
2890
2978
  async function acquireTaskContext() {
2891
- const contextPromise = browser.newContext();
2979
+ const contextPromise = browser.newContext({ acceptDownloads: true });
2892
2980
  contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
2893
2981
  let contextTimeoutId;
2894
2982
  let contextRaceLost = false;
@@ -13,6 +13,13 @@ import { logger } from '../logger.ts';
13
13
  import { metrics } from '../utils/metrics.ts';
14
14
  import { StoreDocument } from '../core/interfaces/knowledge-interfaces.ts';
15
15
 
16
+ /**
17
+ * What a single add attempt actually did. Returned rather than logged/counted here
18
+ * so the caller — which owns the retry loop — records exactly one outcome per
19
+ * logical addDocuments call.
20
+ */
21
+ export type AddDocumentsOutcome = 'written' | 'empty' | 'ignored_closing';
22
+
16
23
  /**
17
24
  * Guard against a broken embedder silently writing corrupt vectors: a
18
25
  * wrong-count, empty, ragged, or NaN/Inf-poisoned result would otherwise land
@@ -69,12 +76,11 @@ export async function addDocumentsToStore(
69
76
  isClosing: () => boolean,
70
77
  workspace: string,
71
78
  isGlobal: boolean
72
- ): Promise<void> {
73
- if (docs.length === 0) return;
79
+ ): Promise<AddDocumentsOutcome> {
80
+ if (docs.length === 0) return 'empty';
74
81
  if (isClosing()) {
75
82
  logger.warn('[store] Ignoring addDocuments during close');
76
- metrics.increment('knowledge_store_add_documents_total', 1, { status: 'ignored_closing' });
77
- return;
83
+ return 'ignored_closing';
78
84
  }
79
85
 
80
86
  const startTime = Date.now();
@@ -98,14 +104,19 @@ export async function addDocumentsToStore(
98
104
  await table.add(data);
99
105
  const duration = Date.now() - startTime;
100
106
  metrics.observe('knowledge_store_add_documents_duration_ms', duration);
101
- metrics.increment('knowledge_store_add_documents_total', 1, { status: 'success' });
102
107
  metrics.increment('knowledge_store_chunks_added_total', docs.length);
103
108
  logger.log(`[store] Added ${docs.length} chunk(s) for ${docs[0]?.url} [workspace=${workspace}, global=${isGlobal}]`);
109
+ return 'written';
104
110
  } catch (err) {
105
111
  const duration = Date.now() - startTime;
106
112
  metrics.observe('knowledge_store_add_documents_duration_ms', duration, { status: 'error' });
107
- metrics.increment('knowledge_store_add_documents_total', 1, { status: 'error' });
108
- logger.error('[store] Failed to add documents:', err);
113
+ // Attempt-level failure only. The caller retries embedder-unreachable and Lance
114
+ // commit-conflict failures and usually succeeds, so logging ERROR and counting a
115
+ // terminal outcome here reported lost writes that were not lost, and double-counted
116
+ // the batch in knowledge_store_add_documents_total (once error, once success).
117
+ // Outcome accounting belongs to KnowledgeStore.addDocuments, which is the only
118
+ // layer that knows whether the write was ultimately abandoned.
119
+ logger.debug(`[store] add attempt failed after ${duration}ms: ${err instanceof Error ? err.message : String(err)}`);
109
120
  throw err;
110
121
  }
111
122
  }