@lincoln504/pi-research 1.5.3 → 1.6.1
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.
- package/README.md +11 -11
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +204 -132
- package/dist/prompts/researcher.md +2 -0
- package/dist/thread-worker.mjs +112 -17
- package/docs/CHANGELOG.md +63 -0
- package/docs/SDK.md +72 -9
- package/package.json +2 -2
- package/src/constants.ts +30 -0
- package/src/infrastructure/browser/browser-client.ts +24 -6
- package/src/infrastructure/browser/browser-error-utils.ts +53 -0
- package/src/infrastructure/browser/browser-server.ts +9 -1
- package/src/infrastructure/browser/thread-worker-browser.ts +4 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +196 -3
- package/src/infrastructure/browser/thread-worker.mjs +112 -17
- package/src/knowledge/store-operations.ts +18 -7
- package/src/knowledge/store.ts +21 -3
- package/src/orchestration/deep-research-orchestrator.ts +37 -11
- package/src/orchestration/quick-research-orchestrator.ts +40 -2
- package/src/orchestration/research-synthesis-service.ts +6 -1
- package/src/prompts/researcher.md +2 -0
- package/src/utils/text-utils.ts +99 -0
- package/src/web-research/scraper-utils.ts +4 -36
- package/src/youtube/potoken.ts +47 -34
|
@@ -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,156 @@ 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
|
+
//
|
|
528
|
+
// The size is measured on an OPEN HANDLE and the bytes are read back through that
|
|
529
|
+
// same handle, so the check and the read cannot land on two different files: a
|
|
530
|
+
// stat-by-path followed by a read-by-path is a time-of-check/time-of-use gap, and
|
|
531
|
+
// the download directory is a path the browser process is still writing to.
|
|
532
|
+
const handle = await fsp.open(filePath, 'r');
|
|
533
|
+
let buffer: Buffer;
|
|
534
|
+
try {
|
|
535
|
+
const { size } = await handle.stat();
|
|
536
|
+
if (size > MAX_PDF_SIZE) {
|
|
537
|
+
const sizeMB = Math.round(size / 1024 / 1024);
|
|
538
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
539
|
+
}
|
|
540
|
+
buffer = await handle.readFile();
|
|
541
|
+
} finally {
|
|
542
|
+
await handle.close().catch(() => {});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
await Promise.allSettled(pendingAddrChecks);
|
|
546
|
+
const poisoned = getPoisonedError();
|
|
547
|
+
if (poisoned) throw poisoned;
|
|
548
|
+
|
|
549
|
+
// Delete before the page closes: the context's download directory is removed on
|
|
550
|
+
// context close, but a worker's context is reused across tasks, so an undeleted
|
|
551
|
+
// file would accumulate for the life of the worker.
|
|
552
|
+
await download.delete?.().catch(() => {});
|
|
553
|
+
await page.close();
|
|
554
|
+
logToDebugFile('DEBUG', `[Worker-${workerId}] Recovered attachment-served PDF (${buffer.byteLength} bytes) for: ${url}`);
|
|
555
|
+
return { contentType: 'application/pdf', bufferB64: buffer.toString('base64'), jitter: 0 };
|
|
556
|
+
}
|
|
557
|
+
|
|
408
558
|
/**
|
|
409
559
|
* Execute a scrape task
|
|
410
560
|
*/
|
|
@@ -446,6 +596,14 @@ export async function executeScrapeTask(
|
|
|
446
596
|
// page.close() produces.
|
|
447
597
|
let oversizedPdfError: Error | null = null;
|
|
448
598
|
|
|
599
|
+
// Set when the navigation turns into a download instead of a document. A server that
|
|
600
|
+
// sends the PDF with Content-Disposition: attachment makes Firefox hand the response
|
|
601
|
+
// to the download manager, and page.goto() then rejects with "Download is starting" —
|
|
602
|
+
// the PDF branch below was never reached and the document was lost even though the
|
|
603
|
+
// bytes had already arrived. Captured here so the goto catch can finish the scrape
|
|
604
|
+
// from the downloaded file instead of failing the URL.
|
|
605
|
+
let pendingDownload: any = null;
|
|
606
|
+
|
|
449
607
|
try {
|
|
450
608
|
logToDebugFile('DEBUG', `[Worker-${workerId}] Starting scrape for: ${url}`);
|
|
451
609
|
|
|
@@ -589,8 +747,32 @@ export async function executeScrapeTask(
|
|
|
589
747
|
}
|
|
590
748
|
});
|
|
591
749
|
|
|
750
|
+
// A navigation the server marks as an attachment never becomes a document: Firefox
|
|
751
|
+
// routes it to the download manager and goto() rejects. Record the Download object
|
|
752
|
+
// so the goto catch below can read the bytes that already arrived.
|
|
753
|
+
page.on('download', (d: any) => { pendingDownload ??= d; });
|
|
754
|
+
|
|
592
755
|
// High-fidelity wait: try domcontentloaded first for speed
|
|
593
|
-
|
|
756
|
+
let response;
|
|
757
|
+
try {
|
|
758
|
+
response = await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
759
|
+
} catch (navErr: unknown) {
|
|
760
|
+
// Only the download case is recoverable here; everything else (timeout, DNS,
|
|
761
|
+
// 'Target closed' from an abort/poison) must keep its original error.
|
|
762
|
+
const navMsg = navErr instanceof Error ? navErr.message : String(navErr);
|
|
763
|
+
const isDownloadNav = /download is starting/i.test(navMsg);
|
|
764
|
+
if (!isDownloadNav) throw navErr;
|
|
765
|
+
// The 'download' event and the goto rejection are not ordered relative to each
|
|
766
|
+
// other, so give the event a brief moment to land before giving up on it.
|
|
767
|
+
if (!pendingDownload) {
|
|
768
|
+
await Promise.race([
|
|
769
|
+
page.waitForEvent('download', { timeout: 5000 }).catch(() => null),
|
|
770
|
+
new Promise(r => setTimeout(r, 5000)),
|
|
771
|
+
]);
|
|
772
|
+
}
|
|
773
|
+
if (!pendingDownload) throw navErr;
|
|
774
|
+
return await readPdfDownload(pendingDownload, url, page, pendingAddrChecks, () => poisonedError);
|
|
775
|
+
}
|
|
594
776
|
|
|
595
777
|
// DNS-rebinding defense-in-depth. The page.route guard's validateForWorker resolves the
|
|
596
778
|
// hostname independently of the browser's own connect-time resolution, so a TTL-0 rebind can
|
|
@@ -647,7 +829,18 @@ export async function executeScrapeTask(
|
|
|
647
829
|
// decided this and be in the middle of closing the page — skip the (redundant,
|
|
648
830
|
// about-to-fail) download attempt rather than race it.
|
|
649
831
|
if (oversizedPdfError) throw oversizedPdfError;
|
|
650
|
-
|
|
832
|
+
let buffer: Buffer;
|
|
833
|
+
try {
|
|
834
|
+
buffer = await response.body();
|
|
835
|
+
} catch (bodyErr) {
|
|
836
|
+
// Firefox drops large navigation response bodies from its juggler-side cache,
|
|
837
|
+
// after which response.body() can only ever fail. Measured against a 13.9MB
|
|
838
|
+
// TU Delft lecture PDF: the body is unavailable whether it is requested at
|
|
839
|
+
// headers-received time or after the load settles, so this is not a race that
|
|
840
|
+
// asking earlier can win — the bytes are simply no longer held for us.
|
|
841
|
+
if (!isEvictedBodyError(bodyErr)) throw bodyErr;
|
|
842
|
+
buffer = await refetchPdfBody(page, finalUrl, validateForWorker, signal);
|
|
843
|
+
}
|
|
651
844
|
// ...then the actual bytes: the header is advisory and absent on chunked
|
|
652
845
|
// responses, so the byte count is the check that actually bounds memory.
|
|
653
846
|
// 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
|
-
|
|
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,73 @@ 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 handle = await fsp.open(filePath, "r");
|
|
2338
|
+
let buffer;
|
|
2339
|
+
try {
|
|
2340
|
+
const { size } = await handle.stat();
|
|
2341
|
+
if (size > MAX_PDF_SIZE) {
|
|
2342
|
+
const sizeMB = Math.round(size / 1024 / 1024);
|
|
2343
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
2344
|
+
}
|
|
2345
|
+
buffer = await handle.readFile();
|
|
2346
|
+
} finally {
|
|
2347
|
+
await handle.close().catch(() => {
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
await Promise.allSettled(pendingAddrChecks);
|
|
2351
|
+
const poisoned = getPoisonedError();
|
|
2352
|
+
if (poisoned) throw poisoned;
|
|
2353
|
+
await download.delete?.().catch(() => {
|
|
2354
|
+
});
|
|
2355
|
+
await page.close();
|
|
2356
|
+
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Recovered attachment-served PDF (${buffer.byteLength} bytes) for: ${url}`);
|
|
2357
|
+
return { contentType: "application/pdf", bufferB64: buffer.toString("base64"), jitter: 0 };
|
|
2358
|
+
}
|
|
2289
2359
|
async function executeScrapeTask(_context, url, signal) {
|
|
2290
2360
|
const page = await createPageSafe(_context);
|
|
2291
2361
|
const SCRAPE_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"], 15e3);
|
|
@@ -2302,6 +2372,7 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2302
2372
|
let poisonedError = null;
|
|
2303
2373
|
const pendingAddrChecks = [];
|
|
2304
2374
|
let oversizedPdfError = null;
|
|
2375
|
+
let pendingDownload = null;
|
|
2305
2376
|
try {
|
|
2306
2377
|
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting scrape for: ${url}`);
|
|
2307
2378
|
const { validateUrlForSSRF: validateForWorker, validateUrlForSSRFSync: validateForWorkerSync } = await Promise.resolve().then(() => (init_scraper_utils(), scraper_utils_exports));
|
|
@@ -2368,7 +2439,25 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2368
2439
|
} catch {
|
|
2369
2440
|
}
|
|
2370
2441
|
});
|
|
2371
|
-
|
|
2442
|
+
page.on("download", (d) => {
|
|
2443
|
+
pendingDownload ??= d;
|
|
2444
|
+
});
|
|
2445
|
+
let response;
|
|
2446
|
+
try {
|
|
2447
|
+
response = await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
2448
|
+
} catch (navErr) {
|
|
2449
|
+
const navMsg = navErr instanceof Error ? navErr.message : String(navErr);
|
|
2450
|
+
const isDownloadNav = /download is starting/i.test(navMsg);
|
|
2451
|
+
if (!isDownloadNav) throw navErr;
|
|
2452
|
+
if (!pendingDownload) {
|
|
2453
|
+
await Promise.race([
|
|
2454
|
+
page.waitForEvent("download", { timeout: 5e3 }).catch(() => null),
|
|
2455
|
+
new Promise((r) => setTimeout(r, 5e3))
|
|
2456
|
+
]);
|
|
2457
|
+
}
|
|
2458
|
+
if (!pendingDownload) throw navErr;
|
|
2459
|
+
return await readPdfDownload(pendingDownload, url, page, pendingAddrChecks, () => poisonedError);
|
|
2460
|
+
}
|
|
2372
2461
|
const serverAddr = await response?.serverAddr().catch(() => null);
|
|
2373
2462
|
if (serverAddr?.ipAddress) {
|
|
2374
2463
|
const ip = serverAddr.ipAddress;
|
|
@@ -2386,7 +2475,13 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2386
2475
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
2387
2476
|
}
|
|
2388
2477
|
if (oversizedPdfError) throw oversizedPdfError;
|
|
2389
|
-
|
|
2478
|
+
let buffer;
|
|
2479
|
+
try {
|
|
2480
|
+
buffer = await response.body();
|
|
2481
|
+
} catch (bodyErr) {
|
|
2482
|
+
if (!isEvictedBodyError(bodyErr)) throw bodyErr;
|
|
2483
|
+
buffer = await refetchPdfBody(page, finalUrl, validateForWorker, signal);
|
|
2484
|
+
}
|
|
2390
2485
|
if (buffer.byteLength > MAX_PDF_SIZE) {
|
|
2391
2486
|
const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
|
|
2392
2487
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
@@ -2749,7 +2844,7 @@ function logToDebugFile3(level, ...args) {
|
|
|
2749
2844
|
return String(arg);
|
|
2750
2845
|
}).join(" "))
|
|
2751
2846
|
};
|
|
2752
|
-
import("node:fs/promises").then((
|
|
2847
|
+
import("node:fs/promises").then((fsp2) => fsp2.appendFile(logFile, `${JSON.stringify(entry)}
|
|
2753
2848
|
`)).catch(() => {
|
|
2754
2849
|
});
|
|
2755
2850
|
} catch {
|
|
@@ -2888,7 +2983,7 @@ async function initBrowser() {
|
|
|
2888
2983
|
return initPromise;
|
|
2889
2984
|
}
|
|
2890
2985
|
async function acquireTaskContext() {
|
|
2891
|
-
const contextPromise = browser.newContext();
|
|
2986
|
+
const contextPromise = browser.newContext({ acceptDownloads: true });
|
|
2892
2987
|
contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
2893
2988
|
let contextTimeoutId;
|
|
2894
2989
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
}
|
package/src/knowledge/store.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { MigrationStrategy, MigrationResult } from './migration.ts';
|
|
|
23
23
|
import { metrics } from '../utils/metrics.ts';
|
|
24
24
|
import { createStoreTable, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
|
|
25
25
|
import { addDocumentsToStore, assertValidEmbeddingVectors, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
|
|
26
|
+
import type { AddDocumentsOutcome } from './store-operations.ts';
|
|
26
27
|
import { isEmbedderUnreachable } from './embedder-utils.ts';
|
|
27
28
|
import { ServiceLifecycle } from '../core/service-registry.ts';
|
|
28
29
|
import { ServiceNames } from '../core/service-interfaces.ts';
|
|
@@ -713,13 +714,14 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
713
714
|
// Now check isClosing after registering
|
|
714
715
|
if (this.isClosing) {
|
|
715
716
|
logger.warn(`[store] Skipping addDocuments — store is closing`);
|
|
717
|
+
metrics.increment('knowledge_store_add_documents_total', 1, { status: 'ignored_closing' });
|
|
716
718
|
return;
|
|
717
719
|
}
|
|
718
720
|
|
|
719
721
|
let table = await this.getFreshTable();
|
|
720
722
|
const workspace = this.getWorkspace();
|
|
721
723
|
|
|
722
|
-
await this.withEmbedderReconnect(async (embedder) => {
|
|
724
|
+
const outcome = await this.withEmbedderReconnect<AddDocumentsOutcome>(async (embedder) => {
|
|
723
725
|
let retryCount = 0;
|
|
724
726
|
const MAX_RETRIES = 5;
|
|
725
727
|
const BASE_DELAY = 100;
|
|
@@ -727,7 +729,7 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
727
729
|
while (retryCount <= MAX_RETRIES) {
|
|
728
730
|
try {
|
|
729
731
|
const isGlobal = this.options.knowledgeMode === 'global';
|
|
730
|
-
|
|
732
|
+
return await addDocumentsToStore(
|
|
731
733
|
table,
|
|
732
734
|
docs,
|
|
733
735
|
embedder,
|
|
@@ -735,7 +737,6 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
735
737
|
workspace,
|
|
736
738
|
isGlobal
|
|
737
739
|
);
|
|
738
|
-
return;
|
|
739
740
|
} catch (err) {
|
|
740
741
|
// Detect commit conflicts typical of cross-process write contention
|
|
741
742
|
if (isLanceCommitConflict(err)) {
|
|
@@ -758,7 +759,24 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
758
759
|
throw err;
|
|
759
760
|
}
|
|
760
761
|
}
|
|
762
|
+
// Unreachable: the loop only exits via return or throw. Present so the
|
|
763
|
+
// outcome type stays non-optional for the accounting below.
|
|
764
|
+
throw new Error('[store] addDocuments retry loop exited without result');
|
|
761
765
|
});
|
|
766
|
+
|
|
767
|
+
// Exactly one outcome per logical addDocuments call. Attempts that failed and
|
|
768
|
+
// were then recovered by the reconnect/conflict retries are not counted here
|
|
769
|
+
// and are not logged at ERROR — only a write that is actually abandoned is.
|
|
770
|
+
// An empty batch is a no-op and is not counted at all, as before.
|
|
771
|
+
if (outcome !== 'empty') {
|
|
772
|
+
metrics.increment('knowledge_store_add_documents_total', 1, {
|
|
773
|
+
status: outcome === 'ignored_closing' ? 'ignored_closing' : 'success',
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
} catch (err) {
|
|
777
|
+
metrics.increment('knowledge_store_add_documents_total', 1, { status: 'error' });
|
|
778
|
+
logger.error('[store] Failed to add documents:', err);
|
|
779
|
+
throw err;
|
|
762
780
|
} finally {
|
|
763
781
|
this.activeWrites.delete(writePromise);
|
|
764
782
|
writeResolve!();
|
|
@@ -9,7 +9,8 @@ import { type Model } from '@earendil-works/pi-ai';
|
|
|
9
9
|
import { logger } from '../logger.ts';
|
|
10
10
|
import { metrics } from '../utils/metrics.ts';
|
|
11
11
|
import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
|
|
12
|
-
import { MAX_EXTRA_ROUNDS_WITH_STEERING, resolveExcludedTools } from '../constants.ts';
|
|
12
|
+
import { MAX_EXTRA_ROUNDS_WITH_STEERING, MIN_SYNTHESIS_PROSE_CHARS, SYNTHESIS_FALLBACK_NOTICE, resolveExcludedTools } from '../constants.ts';
|
|
13
|
+
import { synthesisProseBody, isAnalysisFreeSynthesis } from '../utils/text-utils.ts';
|
|
13
14
|
import {
|
|
14
15
|
getMaxRounds,
|
|
15
16
|
} from '../core/planning-utils.ts';
|
|
@@ -594,6 +595,19 @@ export class DeepResearchOrchestrator {
|
|
|
594
595
|
|
|
595
596
|
let result = finalReport.content || '';
|
|
596
597
|
|
|
598
|
+
// Unwrap FIRST, before anything judges the content. If the LLM returned the full
|
|
599
|
+
// JSON envelope as its response text and JSON parsing failed upstream,
|
|
600
|
+
// finalReport.content is the raw JSON string rather than the extracted markdown —
|
|
601
|
+
// and every check below would then be measuring the envelope instead of the report.
|
|
602
|
+
// An envelope wrapping an empty or title-only content read as a long, well-punctuated
|
|
603
|
+
// document, so neither guard fired and the unwrapped nothing went straight out.
|
|
604
|
+
try {
|
|
605
|
+
const parsed: unknown = JSON.parse(result);
|
|
606
|
+
if (parsed && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>)['content'] === 'string') {
|
|
607
|
+
result = (parsed as Record<string, unknown>)['content'] as string;
|
|
608
|
+
}
|
|
609
|
+
} catch { /* result is not JSON — use as-is */ }
|
|
610
|
+
|
|
597
611
|
if (!result.trim()) {
|
|
598
612
|
if (hasReports) {
|
|
599
613
|
// Content is empty but reports exist — build a fallback synthesis.
|
|
@@ -603,18 +617,30 @@ export class DeepResearchOrchestrator {
|
|
|
603
617
|
// No reports, no recorded failures, and nothing to ship either.
|
|
604
618
|
throw new Error('Research produced no report — no source material was collected.');
|
|
605
619
|
}
|
|
620
|
+
} else if (hasReports && isAnalysisFreeSynthesis(result, MIN_SYNTHESIS_PROSE_CHARS)) {
|
|
621
|
+
// The synthesis LLM returned something, but nothing that constitutes analysis —
|
|
622
|
+
// twice in the run logs it was a lone title line. `!result.trim()` passes on
|
|
623
|
+
// that, and ensureCitedLinks then appends the rebuilt source list underneath, so
|
|
624
|
+
// a 131-character failure reached the user as a 6.8KB document with a heading,
|
|
625
|
+
// citations, and not one sentence between them, reported as a successful run.
|
|
626
|
+
//
|
|
627
|
+
// The response is deliberately ADDITIVE rather than a replacement. Whatever the
|
|
628
|
+
// model did write is kept, the researchers' own reports are appended below it,
|
|
629
|
+
// and a notice states plainly what happened. That matters more than the
|
|
630
|
+
// detector's precision: because a false positive can only ever ADD material,
|
|
631
|
+
// never discard it, the threshold can sit far away from real reports instead of
|
|
632
|
+
// hugging them out of fear of destroying a legitimate one. A missed detection
|
|
633
|
+
// costs a thin report; a false detection costs a few paragraphs of duplication.
|
|
634
|
+
const proseChars = synthesisProseBody(result).length;
|
|
635
|
+
logger.warn(`[DeepOrchestrator] LLM synthesis for ${researchId} carried no analysis (${proseChars} char(s) of prose, min ${MIN_SYNTHESIS_PROSE_CHARS}) — appending ${synthesisCheckService.getReportCount(researchId)} researcher report(s) below it`);
|
|
636
|
+
metrics.increment('research_synthesis_analysis_free_total', 1, { mode: 'deep' });
|
|
637
|
+
result = [
|
|
638
|
+
result.trim(),
|
|
639
|
+
SYNTHESIS_FALLBACK_NOTICE,
|
|
640
|
+
synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound),
|
|
641
|
+
].join('\n\n');
|
|
606
642
|
}
|
|
607
643
|
|
|
608
|
-
// Guard: if the LLM returned the full JSON envelope as its response text and JSON
|
|
609
|
-
// parsing failed upstream, finalReport.content may be the raw JSON string rather
|
|
610
|
-
// than the extracted markdown. Unwrap it here so docs never show the raw JSON.
|
|
611
|
-
try {
|
|
612
|
-
const parsed: unknown = JSON.parse(result);
|
|
613
|
-
if (parsed && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>)['content'] === 'string') {
|
|
614
|
-
result = (parsed as Record<string, unknown>)['content'] as string;
|
|
615
|
-
}
|
|
616
|
-
} catch { /* result is not JSON — use as-is */ }
|
|
617
|
-
|
|
618
644
|
// Final post-processing: Ensure CITED LINKS section is accurate and consistent
|
|
619
645
|
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
620
646
|
result = synthesisService.ensureCitedLinks(researchId, result);
|