@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.
@@ -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,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
- const response = await page.goto(url, { waitUntil: "domcontentloaded" });
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
- const buffer = await response.body();
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((fsp) => fsp.appendFile(logFile, `${JSON.stringify(entry)}
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;
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,69 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.1] - 2026-08-23
9
+
10
+ A report that contained no analysis was being delivered as a completed run, because
11
+ the engine's own repair step padded it out. Fixing that exposed a second way into the
12
+ same failure, and a review of the machinery around it turned up a third defect.
13
+
14
+ ### Fixed
15
+
16
+ - **A synthesis with no analysis in it reached users looking like a finished report.** The completion guard was `if (!result.trim())` — a lone title line passes it, and `ensureCitedLinks` then appends the rebuilt source list underneath. Two runs in the retained logs did exactly that: 131 and 147 characters of prose delivered as 6.8KB and 9.9KB documents with a heading, a full citation list, and not one sentence between them, both reported as complete. The engine's own repair machinery was what disguised the failure. A synthesis is now checked for analysis by two independent signals — prose volume measured BEFORE the citation block, and whether any sentence ends at all — and the response is **additive**: the model's own text is kept, the researchers' reports are appended below it, and a notice states what happened. That is the part that matters. Because a false positive can only ever add material, never discard it, the threshold can sit well clear of real reports (800 characters, against a smallest observed legitimate report of 1056) rather than hugging the two failures out of fear of destroying a good run. Quick mode gets the structural signal only: it has no researcher reports to fall back on, so the sole available response is to flag the report — and telling a reader their correct one-sentence answer is untrustworthy is worse than missing a thin one. Its notice says the run produced no substantive write-up and that the cited sources are still accurate (`src/orchestration/deep-research-orchestrator.ts`, `src/orchestration/quick-research-orchestrator.ts`, `src/utils/text-utils.ts`, `src/constants.ts`).
17
+ - **The same failure had a second route in, through the raw JSON envelope.** When upstream JSON parsing fails, the synthesis arrives as the whole envelope string rather than the extracted markdown, and an unwrap ran afterwards to recover it. Both completion checks therefore measured the envelope — long, well punctuated, and healthy-looking whatever it wrapped — and the unwrap then replaced it with the title or the empty string it was hiding, with nothing left to catch either. An envelope wrapping empty content produced a citation list with no document above it. The unwrap now runs first, so every check judges the report itself (`src/orchestration/deep-research-orchestrator.ts`).
18
+ - **The structural signal assumed Latin punctuation.** It looked for `.!?` plus the CJK full stops, so a correct report written with a danda, an Arabic full stop or an Ethiopic full stop ended no sentence as far as the check was concerned. In quick mode, where that signal runs alone, every such report would have been flagged as thin (`src/utils/text-utils.ts`).
19
+ - **A time-of-check/time-of-use gap in the attachment-served PDF path.** The 100MB cap was measured with `fsp.stat(filePath)` and the bytes then read with `fsp.readFile(filePath)` — two separate lookups of a path the browser process is still writing to, so the file that was measured need not be the file that was read (CodeQL `js/file-system-race`, alert #266). The size is now taken from an open handle and the bytes read back through that same handle, which cannot be swapped underneath, and the descriptor is released on both the accept and the over-cap reject (`src/infrastructure/browser/thread-worker-messaging.ts`).
20
+
21
+ ### Changed
22
+
23
+ - **A quick report no longer opens by narrating itself.** Live runs delivered documents whose first line was "I have gathered extensive material… Let me now synthesize my findings into a comprehensive report." and "I have gathered sufficient material from multiple authoritative sources. I will now synthesize the full report." Quick mode has no synthesis step behind it to absorb that, so it landed as the reader's first sentence. The researcher prompt now forbids it — and, because that instruction was measurably not obeyed (it still appeared in 2 of 3 live runs after the prompt change), quick mode also removes the line. Removing text is destructive, so every condition on it is a required narrowing rather than a score: the segment must be a single line, separated from the report by a blank line, first-person AND about the act of reporting, and must leave a report behind. A report that opens as the format requires — with a topic title — matches none of them. The strip runs BEFORE the analysis check above, so a title-only report cannot pass that check on the strength of the narration's full stop, and each removal is counted in `research_report_preamble_stripped_total` (`src/prompts/researcher.md`, `src/utils/text-utils.ts`, `src/orchestration/quick-research-orchestrator.ts`).
24
+
25
+ ### Removed
26
+
27
+ - **The GitHub Release job added in 1.6.0 is gone; tags remain the only release marker.** Cutting a Release per tag was not wanted — the tag plus `docs/CHANGELOG.md` is the intended record — so the job was removed and the one Release it created (v1.6.0) was deleted. The tag itself is untouched, and nothing about publishing changes: the release workflow is still CI-gate → tarball test → npm publish (`.github/workflows/release.yml`).
28
+
29
+ ### Verified
30
+
31
+ - Full gates green: 2917 unit tests across 230 files, type-check over source and tests, lint, dependency-cruiser, and the integration suite (77 passed, 26 skipped for live network).
32
+ - Live runs across all four front-ends: CLI quick and depth-1 (15.5KB and 16.5KB reports), the agent skill launcher through its installed link (11.8KB, opening straight at the report), the pi extension, and the SDK (12.1KB, clean shutdown). Every report was replayed through both guards afterwards: the one real preamble was removed, and neither analysis check fired on any of them.
33
+
34
+ ## [1.6.0] - 2026-08-22
35
+
36
+ Driven by log forensics rather than by the test suite: every entry below was found
37
+ by reading what the engine actually reported in production, and each fix was
38
+ re-measured against the exact URL, error line or counter that exposed it.
39
+
40
+ The starting observation was that the ERROR log had stopped meaning anything. Across
41
+ the retained run logs, **all 26 ERROR lines were either a condition the engine had
42
+ already recovered from or a routine per-URL outcome** — a blocked page, a slow site,
43
+ an untrusted certificate. Not one described an unrecovered engine fault. Two classes
44
+ account for all of them: content the scraper had already fetched and then dropped,
45
+ and recovered conditions reported as terminal failures.
46
+
47
+ ### Fixed
48
+
49
+ - **PDF sources the browser had already fetched were being discarded two different ways, both now recovered.** A server sending the PDF with `Content-Disposition: attachment` makes Firefox route the response to the download manager, so no document is produced and `page.goto()` rejects with "Download is starting" — the PDF branch was never reached even though the bytes had arrived. Separately, Firefox discards large navigation response bodies from its juggler-side cache, after which `response.body()` fails with "Response body … was evicted!". Both recur across the run logs, and both hit exactly the sources research most wants — vendor datasheets, standards-body documents, journal and university PDFs. The download is now read back from disk, and an evicted body is re-requested through the browser context's own request API — which carries the same cookies, headers, proxy and TLS profile as the navigation, the reason the URL reached the stealth-browser fallback at all. Verified live against the two URLs that failed: a Frontiers article PDF now yields 106k characters of text and a 13.9MB TU Delft lecture PDF 27k, both previously total failures. The re-fetch was measured before it was written: asking for the body earlier does not help, because the eviction happens when the browser drops the bytes, not when we ask for them — a first attempt built on that assumption was discarded after a direct camoufox probe disproved it (`src/infrastructure/browser/thread-worker-messaging.ts`, `src/infrastructure/browser/thread-worker-browser.ts`).
50
+ - **A knowledge-store write that was retried and succeeded no longer reports as a failed write.** `addDocumentsToStore` logged ERROR and incremented `knowledge_store_add_documents_total{status=error}` before rethrowing — but its caller catches exactly that throw, reconnects to a fresh embedder and retries, so the write usually completed. All 8 occurrences across the retained logs are immediately followed by the reconnect WARN — 8 of 8 recovered, none lost — and every one of them counted the same batch twice (once error, once success). The Lance commit-conflict retry fed the same path, so cross-process write contention did it too. Attempt-level accounting now stays in the attempt and outcome accounting moved to `KnowledgeStore.addDocuments`, which is the only layer that knows whether a write was actually abandoned — exactly one counter increment per call, and an ERROR only when the batch is genuinely lost (`src/knowledge/store-operations.ts`, `src/knowledge/store.ts`).
51
+ - **The browser leader logged every routine per-URL scrape outcome as a server fault.** `BrowserServer`'s handler classified Cloudflare challenges and pool-drain races but sent everything else to ERROR, including the HTTP 4xx/5xx, navigation timeouts and over-cap PDFs that the client side already classifies as expected. The leader saw each failure first, so the client's careful classification never prevented the ERROR — one false fault per affected URL. Both layers now share the same predicate (`src/infrastructure/browser/browser-server.ts`).
52
+ - **A leader handover was reported as a terminal error by the layer that was about to retry it.** `BrowserClient` logged ERROR and recorded a tracked error for any socket failure, including the ECONNREFUSED a follower gets against a leader that has already exited. `runBrowserTask` retries that within a second: 5 of 5 such lines in the log were followed immediately by the recovery WARN, and each was counted twice in the diagnostic error report — once here without context, once by the caller with it. A failure the caller will not retry still reports at ERROR, and a retryable one that exhausts its budget still surfaces upstream with URL context (`src/infrastructure/browser/browser-client.ts`).
53
+ - **Remote TLS, DNS and navigation-timeout failures are now classified as the per-URL outcomes they are.** `page.goto: SEC_ERROR_UNKNOWN_ISSUER` (a live university PDF host with an untrusted certificate) and `page.goto: Timeout 15000ms exceeded` reached the ERROR log and the diagnostic error count from both the leader and the scraper, though neither is actionable and the URL is already tallied as not scraped. `page.goto: Target closed` — how a crashed or force-closed browser surfaces — is deliberately excluded and still reports as a fault (`src/web-research/scraper-utils.ts`).
54
+
55
+ - **The unit suite was writing its deliberate failure fixtures into the user’s real shared log.** `unit-env.ts` redirects `PI_RESEARCH_LOG_PATH` before `src/logger.ts` is evaluated, but the global logger is built lazily and resolves its path at CONSTRUCTION — so a suite whose `beforeEach` deletes every `PI_RESEARCH_*` variable (config.test.ts did) left the first logger of the run binding to the production default instead. Measured at ~7KB per run appended to `/tmp/pi-research.log`, including a `{corrupt json` registry fixture whose ERROR line reads exactly like a live failure and cost real time during this release’s own forensic pass. The setup now constructs the logger while the redirect is still set, config.test.ts keeps the redirect out of its scrub, and a test pins the guarantee; whole-suite pollution measured at 0 bytes after the fix (`test/setup/unit.ts`, `test/unit/config.test.ts`, `test/unit/logger-test-isolation.test.ts`).
56
+
57
+ ### Changed
58
+
59
+ - **`bgutils-js` upgraded 3.2.0 → 4.0.3**, the YouTube PoToken stack's most upstream-brittle dependency and the one pin with no written rationale. v4 is a hard break: the barrel entry is gone (the package now exposes only `/botguard`, `/webpo` and `/utils` subpaths, so the previous bare import resolves to nothing), `BG.Challenge.create` became a free `getChallenge()` whose config drops `globalObj`/`identifier` and renames `fetch` to `fetchFunction`, and `BotGuardClient`'s `globalObj` became `globalObject`. All three renames fail silently rather than loudly — a wrong `globalObject` leaves the VM with no global to attach to and the minter factory returns a non-function — so each is now pinned by a test that asserts the field names v4 actually reads, and the whole flow was re-validated live: a real PoToken minted against Google's attestation endpoint and a real transcript fetched. Staying on v3 meant sitting on an unmaintained line for the one subsystem that breaks whenever YouTube changes BotGuard, with the failure mode being a silent empty transcript body (`src/youtube/potoken.ts`).
60
+ - **`jsdom` deliberately stays at 29.1.1.** jsdom 30 narrows its supported Node range to `^22.22.2 || ^24.15.0 || >=26.0.0`, which excludes both part of this package's own `>=22.19.0` floor and Node 25 entirely; 29's `^20.19.0 || ^22.13.0 || >=24.0.0` covers the whole supported surface. Attempted, measured against the engine ranges, reverted — the pin now has the same recorded rationale as the others (`package.json`).
61
+
62
+ ### Added
63
+
64
+ - **A full SDK API reference.** `src/sdk.ts` exports 24 symbols; the documentation named 7. `runResearchDetailed`, `scrapeUrl`, `verifyUrl`, `exportKnowledge`, `getSessionMetrics`, `logRunErrorSummary` and the four `getLast*` telemetry accessors were all public and all undiscoverable short of reading the source. Every export now has a signature, a description and — where it differs — a note that it is reachable only through the `/sdk` subpath. A test keeps the two in sync (`docs/SDK.md`, `test/unit/sdk/sdk-docs-surface.test.ts`).
65
+ - **A contract test for the pi-host `ResourceLoader` adapter**, which had 0% coverage and was mocked out in the one test that imported it. This is the seam whose drift under pi 0.81.0 aborted *every* researcher session with "Research produced no report"; the defense against a repeat was a comment. The test runs the real adapter against the installed host: the registration lists the host iterates must be iterable, each resource getter must return the shape the host destructures, and the adapter must implement every public accessor the host's own loader exposes — so a future pi that extends the contract fails here instead of at runtime with a green suite (`test/unit/utils/make-resource-loader.test.ts`).
66
+
67
+ ### Documentation
68
+
69
+ - README no longer frames read-only operation as a trust caveat. Being unable to run commands or write anything is the correct shape for an agent whose whole job is reading untrusted web pages: prompt injection picked up mid-run has nothing to act on. The cost line also states plainly that LLM tokens are the only cost, and the page reads less in the second person throughout.
70
+
8
71
  ## [1.5.3] - 2026-08-22
9
72
 
10
73
  ### Fixed
package/docs/SDK.md CHANGED
@@ -75,15 +75,8 @@ await shutdownResearchSDK();
75
75
 
76
76
  `initResearchSDK` must run before any research call. Auth resolves from
77
77
  `options.apiKey` + `options.provider`, else `process.env.PI_RESEARCH_API_KEY` /
78
- `PI_RESEARCH_PROVIDER`, else pi's `~/.pi/agent/auth.json`. Other exports include
79
- `runResearchDetailed`, `searchKnowledge`, `scrapeUrl`, `getResearchHealth`,
80
- `getLastRunStats`, and `getSessionMetrics`, plus `exportKnowledge` (write the
81
- knowledge store to a web-consumable JSON file) and the post-run telemetry accessors
82
- `getLastRunMetrics`, `getLastRunSummary`, `getLastErrorReport`, and
83
- `getLastResearcherOutcome` (planned/launched/succeeded/failed researcher counts plus
84
- per-researcher failure reasons for the most recent run — lets a caller tell a thin
85
- report caused by a sparse topic apart from one where most researchers failed). Both
86
- `@lincoln504/pi-research` and `@lincoln504/pi-research/sdk` export these symbols.
78
+ `PI_RESEARCH_PROVIDER`, else pi's `~/.pi/agent/auth.json`. The five calls above are the
79
+ common path; [API reference](#api-reference) below lists every export with its signature.
87
80
 
88
81
  > Concurrency: a single initialized SDK instance runs one research call at a time.
89
82
  > Overlapping `runDeepResearch`/`runQuickResearch` calls on the same instance throw
@@ -156,6 +149,76 @@ that run, not the browser pool, LanceDB handles or worker processes.
156
149
  The SDK does not write report files. Report export is a front-end concern — the pi
157
150
  extension and the CLI / agent skill do it when `PI_RESEARCH_REPORT_EXPORT_ENABLED=true`.
158
151
 
152
+ ### API reference
153
+
154
+ Everything below is exported from both `@lincoln504/pi-research` and
155
+ `@lincoln504/pi-research/sdk`, except the two marked *sdk subpath only* — the package
156
+ entry point deliberately does not re-publish them. Every call except `repairJson` and
157
+ `getSDKContainer` requires `initResearchSDK()` first and throws `SDK not initialized`
158
+ otherwise.
159
+
160
+ **Lifecycle**
161
+
162
+ | Export | Signature | Notes |
163
+ |---|---|---|
164
+ | `initResearchSDK` | `(options?: ResearchSDKOptions) => Promise<void>` | Registers services. No-ops if already initialized; waits out an in-flight shutdown. |
165
+ | `shutdownResearchSDK` | `() => Promise<void>` | Required. Drains the writer queue, closes LanceDB, kills worker processes. Clears every `getLast*` accessor. |
166
+ | `getSDKContainer` | `() => ServiceContainer \| null` | *sdk subpath only.* Internal/test surface — the live service container, or `null` before init. Not covered by semver; the package entry point does not export it. |
167
+
168
+ **Research**
169
+
170
+ | Export | Signature | Notes |
171
+ |---|---|---|
172
+ | `runDeepResearch` | `(query, options?, signal?) => Promise<string>` | Depth 1–3. Returns the Markdown report. `options` is `ResearchOptions` minus the fields the SDK owns (`ctx`, `query`, `model`, `sessionId`, `researchId`). |
173
+ | `runQuickResearch` | `(query, options?, signal?) => Promise<string>` | Depth 0. Same as above with `depth` fixed and therefore not accepted. |
174
+ | `runResearchDetailed` | `(query, options?, signal?) => Promise<ResearchRunResult>` | Same run as `runDeepResearch`, returning `{ report, sessionId, runId, metrics, stats, reports }` instead of the bare string. |
175
+ | `getResearchReports` | `(researchId?) => Promise<Map<string, string>>` | Per-researcher reports keyed by researcher id. Defaults to the most recent run; empty map if there has not been one. |
176
+
177
+ **Web access**
178
+
179
+ | Export | Signature | Notes |
180
+ |---|---|---|
181
+ | `scrapeUrl` | `(url, signal?) => Promise<ScrapeResult>` | One URL through the full pipeline (SSRF gate → fetch or stealth browser → PDF extraction → Markdown). |
182
+ | `verifyUrl` | `(url, signal?) => Promise<boolean>` | Reachability only, no content returned. Resolves `false` rather than throwing on a blocked or dead URL. |
183
+
184
+ **Knowledge store**
185
+
186
+ | Export | Signature | Notes |
187
+ |---|---|---|
188
+ | `searchKnowledge` | `(queries: string[], signal?) => Promise<KnowledgeSearchResult>` | `{ text, found: 'yes' \| 'maybe' \| 'no', documentsSearched, citations }`. Resolves `found: 'no'` when the store is disabled, empty, or unavailable — it does not throw. |
189
+ | `exportKnowledge` | `(outputPath: string) => Promise<void>` | Writes the store to a web-consumable JSON file. |
190
+
191
+ **Post-run telemetry** — all reflect the most recent completed run, are `null` until one completes, and are cleared by `shutdownResearchSDK()`.
192
+
193
+ | Export | Signature | Notes |
194
+ |---|---|---|
195
+ | `getLastRunStats` | `() => ResearchStats \| null` | Headline counts (searches, scrapes, tokens, cost) derived from the run snapshot. |
196
+ | `getLastRunMetrics` | `() => IMetricsSnapshot \| null` | The raw counters/gauges/histograms for that run. |
197
+ | `getLastRunSummary` | `() => RunSummary \| null` | `{ runId, startedAt, completedAt, durationMs, status, snapshot }`. |
198
+ | `getLastErrorReport` | `() => ErrorReport \| null` | Aggregated errors — totals, patterns, by-domain, by-type. Lets an unattended caller see what failed without parsing logs. |
199
+ | `getLastResearcherOutcome` | `() => ResearcherOutcome \| null` | `{ planned, launched, succeeded, failed, failureReasons }`. Distinguishes a thin report caused by a sparse topic from one where most researchers failed. |
200
+ | `getSessionMetrics` | `() => IMetricsSnapshot` | Cumulative across every run since init, rather than per-run. |
201
+ | `logRunErrorSummary` | `(report, depthLabel, status) => void` | *sdk subpath only.* Emits one compact, secret-redacted line for a run's tracked errors. No-op when the report is null or empty. |
202
+
203
+ **Health and utilities**
204
+
205
+ | Export | Signature | Notes |
206
+ |---|---|---|
207
+ | `getResearchHealth` | `(opts?: { force?: boolean }) => Promise<HealthReport>` | Runs every registered check. `force` bypasses the cached result. |
208
+ | `repairJson` | `(json: string) => string` | Repairs truncated/malformed model JSON. Pure; usable before init. |
209
+
210
+ **Types** — `ResearchSDKOptions`, `ResearchRunResult`, `KnowledgeSearchResult`, `ResearcherOutcome`,
211
+ `ResearchOptions`, `ResearchObserver`, `IMetricsSnapshot`, `IMetricHistogram`, `RunSummary`, and
212
+ `ResearchStats`.
213
+
214
+ **Also on the package entry point**, for callers that want the orchestrators directly rather
215
+ than through the SDK wrapper: `DeepResearchOrchestrator`, `QuickResearchOrchestrator`,
216
+ `HeadlessObserver` (an observer that prints progress to stdout), `ServiceNames`,
217
+ `shutdownManager`, `extractRunStats`, `normalizeUrl`, the config accessors
218
+ `getConfig` / `setConfig` / `resetConfig` / `validateConfig`, and the team-size constants.
219
+ These are lower-level than the SDK functions above and assume you manage the service
220
+ container yourself.
221
+
159
222
  ### Init options
160
223
 
161
224
  | Option | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.5.3",
3
+ "version": "1.6.1",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
@@ -118,7 +118,7 @@
118
118
  "@kreuzberg/html-to-markdown-node": "^3.5.5",
119
119
  "@lancedb/lancedb": "0.37.1",
120
120
  "apache-arrow": "21.1.0",
121
- "bgutils-js": "^3.2.0",
121
+ "bgutils-js": "^4.0.3",
122
122
  "camoufox-js": "^0.10.2",
123
123
  "esbuild": "^0.28.1",
124
124
  "impit": "0.13.0",
package/src/constants.ts CHANGED
@@ -201,6 +201,36 @@ export const MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
201
201
  */
202
202
  export const DEFAULT_EXCLUDED_TOOLS: readonly string[] = ['grep'];
203
203
 
204
+ /**
205
+ * Volume signal for {@link isAnalysisFreeSynthesis}: prose below this, measured BEFORE
206
+ * the CITED LINKS section, is treated as a synthesis containing no analysis.
207
+ *
208
+ * Calibrated against 41 real reports from the run logs rather than guessed. The two
209
+ * broken ones — each a lone title line, delivered as a "complete" 6.8KB and 9.9KB
210
+ * document because the engine's own citation rebuild padded them out — measured 131 and
211
+ * 147 characters. The shortest LEGITIMATE report measured 1056.
212
+ *
213
+ * Set at 800 rather than hugging the failures, because the response to tripping it is
214
+ * ADDITIVE: the model's own text is kept and the researcher reports are appended below
215
+ * it. A false positive therefore costs some duplication, never a lost report, so the
216
+ * boundary is placed to catch marginal cases rather than to avoid them. It is also only
217
+ * one of two independent signals — a synthesis with no sentence-ending punctuation is
218
+ * caught at any length, so a long title would not slip through on volume alone.
219
+ */
220
+ export const MIN_SYNTHESIS_PROSE_CHARS = 800;
221
+
222
+ /** Flags a quick-mode report that contains no analysis, where there is no fallback to add. */
223
+ export const QUICK_THIN_REPORT_NOTICE =
224
+ '_Note: this run did not produce a substantive write-up. The sources below were ' +
225
+ 'retrieved and are cited accurately, but the summary above should not be treated as ' +
226
+ 'a complete answer — consider re-running, or running at a deeper level._';
227
+
228
+ /** Explains an additive fallback in the delivered report, so the reader is never misled. */
229
+ export const SYNTHESIS_FALLBACK_NOTICE =
230
+ '_Note: the synthesis step did not produce a usable summary for this run, so the ' +
231
+ 'individual researcher reports are included below in full. The sources cited at the ' +
232
+ 'end of this document are unaffected._';
233
+
204
234
  /**
205
235
  * The complete set of excludable research tool names — the vocabulary
206
236
  * `--exclude-tools` / `excludeTools` / PI_RESEARCH_DISABLED_TOOLS accept.
@@ -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;