@lincoln504/pi-research 1.0.10 → 1.0.12
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/dist/cli.mjs +444 -58
- package/dist/thread-worker.mjs +39 -1
- package/docs/CHANGELOG.md +26 -0
- package/package.json +5 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/core/interfaces/service-names.ts +2 -0
- package/src/infrastructure/browser/browser-client.ts +19 -1
- package/src/infrastructure/browser/browser-error-utils.ts +24 -1
- package/src/infrastructure/browser/browser-server.ts +70 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +19 -3
- package/src/infrastructure/browser/scheduler-factory.ts +1 -1
- package/src/infrastructure/browser/thread-worker.mjs +39 -1
- package/src/infrastructure/process-lifecycle-service.ts +21 -3
- package/src/infrastructure/research-run-semaphore.ts +290 -0
- package/src/infrastructure/service-initialization.ts +20 -0
- package/src/orchestration/deep-research-orchestrator.ts +11 -0
- package/src/orchestration/research-orchestration-service.ts +35 -0
- package/src/orchestration/research-synthesis-service.ts +171 -15
- package/src/web-research/retry-utils.ts +9 -3
- package/src/web-research/scraper-utils.ts +43 -0
- package/src/web-research/web-scraper.ts +67 -40
|
@@ -39,9 +39,15 @@ export interface RetryOptions {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Sleep for `ms`, resolving early (rejecting with an AbortError) if `signal`
|
|
42
|
-
* aborts. The timer is unref'd so it never keeps the event loop alive.
|
|
42
|
+
* aborts. The timer is unref'd by default so it never keeps the event loop alive.
|
|
43
|
+
*
|
|
44
|
+
* Pass `keepAlive` when the sleep is *foreground* work — i.e. the caller is
|
|
45
|
+
* mid-operation and the operation must still complete after the delay. An unref'd
|
|
46
|
+
* timer is only safe while some other referenced handle exists; if the delay is the
|
|
47
|
+
* sole pending handle, Node drains the loop and exits mid-operation, silently
|
|
48
|
+
* truncating the work instead of resuming it.
|
|
43
49
|
*/
|
|
44
|
-
export function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
50
|
+
export function abortableDelay(ms: number, signal?: AbortSignal, keepAlive = false): Promise<void> {
|
|
45
51
|
return new Promise<void>((resolve, reject) => {
|
|
46
52
|
if (signal?.aborted) {
|
|
47
53
|
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
|
@@ -51,7 +57,7 @@ export function abortableDelay(ms: number, signal?: AbortSignal): Promise<void>
|
|
|
51
57
|
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
|
52
58
|
resolve();
|
|
53
59
|
}, ms);
|
|
54
|
-
(timeoutId as TimeoutHandle).unref?.();
|
|
60
|
+
if (!keepAlive) (timeoutId as TimeoutHandle).unref?.();
|
|
55
61
|
const onAbort = signal
|
|
56
62
|
? () => {
|
|
57
63
|
clearTimeout(timeoutId as unknown as ReturnType<typeof setTimeout>);
|
|
@@ -264,6 +264,49 @@ export async function getSsrfSafeDispatcher(): Promise<unknown | null> {
|
|
|
264
264
|
return cachedSsrfDispatcher as unknown | null;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Render an error for diagnostics, surfacing the swallowed underlying cause.
|
|
269
|
+
*
|
|
270
|
+
* Node's `fetch()`/undici wraps the real transport reason (ECONNRESET, ENOTFOUND,
|
|
271
|
+
* a TLS error, UND_ERR_*, an AbortError) under `error.cause`, and the top-level
|
|
272
|
+
* object is almost always the uninformative `TypeError: fetch failed`. Logging
|
|
273
|
+
* only `String(err)` (or `err.message`) therefore records "fetch failed" and
|
|
274
|
+
* discards the actual reason — which made the production scrape-failure spike
|
|
275
|
+
* (212+ sub-5ms failures) impossible to diagnose from logs. This walks the
|
|
276
|
+
* `cause` chain (bounded, mirroring `isTransientError`) and folds in any Node /
|
|
277
|
+
* undici `.code`, so each "fetch failed" log line is self-diagnosing.
|
|
278
|
+
*
|
|
279
|
+
* Defensive against throwing getters and non-Error values: never throws.
|
|
280
|
+
*/
|
|
281
|
+
export function formatErrorWithCause(err: unknown): string {
|
|
282
|
+
try {
|
|
283
|
+
const parts: string[] = [];
|
|
284
|
+
let current: unknown = err;
|
|
285
|
+
for (let depth = 0; current !== null && current !== undefined && depth < 5; depth++) {
|
|
286
|
+
let segment: string;
|
|
287
|
+
if (current instanceof Error) {
|
|
288
|
+
segment = current.message || current.name;
|
|
289
|
+
const code = (current as { code?: unknown }).code;
|
|
290
|
+
if (typeof code === 'string' && code && !segment.includes(code)) {
|
|
291
|
+
segment = `${segment} [${code}]`;
|
|
292
|
+
}
|
|
293
|
+
} else if (typeof current === 'string') {
|
|
294
|
+
segment = current;
|
|
295
|
+
} else {
|
|
296
|
+
segment = String(current);
|
|
297
|
+
}
|
|
298
|
+
if (segment && !parts.includes(segment)) parts.push(segment);
|
|
299
|
+
const next = (current as { cause?: unknown }).cause;
|
|
300
|
+
if (next === current) break; // defensive: self-referential cause
|
|
301
|
+
current = next;
|
|
302
|
+
}
|
|
303
|
+
if (parts.length === 0) return String(err);
|
|
304
|
+
return parts.length === 1 ? parts[0]! : parts.join(' ← ');
|
|
305
|
+
} catch {
|
|
306
|
+
return String(err);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
267
310
|
/**
|
|
268
311
|
* Check if an IPv4 address is private/reserved/loopback.
|
|
269
312
|
*/
|
|
@@ -23,12 +23,16 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
FETCH_LAYER_TIMEOUT,
|
|
25
25
|
} from './types.ts';
|
|
26
|
-
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, isBenignScrapeFailure, } from './scraper-utils.ts';
|
|
26
|
+
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
|
|
27
|
+
import { isTransientError, abortableDelay } from './retry-utils.ts';
|
|
27
28
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
28
29
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
29
30
|
import type { ServiceContainer } from '../core/service-registry.ts';
|
|
30
31
|
import { cacheScrapedContent } from '../utils/shared-links.ts';
|
|
31
32
|
|
|
33
|
+
/** Backoff before the fetch layer's single transient retry. */
|
|
34
|
+
const FETCH_RETRY_DELAY_MS = 250;
|
|
35
|
+
|
|
32
36
|
let playwrightAvailable: boolean = false;
|
|
33
37
|
let markdownConverterPromise: Promise<(html: string) => Promise<string>> | null = null;
|
|
34
38
|
|
|
@@ -191,6 +195,25 @@ async function readBodyCapped(response: Response, maxBytes: number): Promise<Uin
|
|
|
191
195
|
return out;
|
|
192
196
|
}
|
|
193
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Record a fetch-layer failure exactly once per URL.
|
|
200
|
+
*
|
|
201
|
+
* Kept out of {@link scrapeWithFetch} because that function is retried: tracking
|
|
202
|
+
* inside it would file one transient failure as two, and the fetch layer's error
|
|
203
|
+
* counts are precisely what production incidents are diagnosed from.
|
|
204
|
+
*/
|
|
205
|
+
function trackFetchLayerFailure(url: string, error: unknown): void {
|
|
206
|
+
const isSsrf = error instanceof Error && error.message.includes('not allowed');
|
|
207
|
+
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
208
|
+
component: 'scrapers',
|
|
209
|
+
operation: 'fetch',
|
|
210
|
+
url,
|
|
211
|
+
domain: extractDomain(url),
|
|
212
|
+
layer: 'fetch',
|
|
213
|
+
...(isSsrf ? { errorType: 'ssrf_blocked' } : {}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
194
217
|
async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<ScrapeLayerResult> {
|
|
195
218
|
await validateUrlForSSRF(url);
|
|
196
219
|
|
|
@@ -313,14 +336,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
313
336
|
const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
|
|
314
337
|
logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
|
|
315
338
|
metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: 'html' });
|
|
316
|
-
|
|
317
|
-
component: 'scrapers',
|
|
318
|
-
operation: 'fetch',
|
|
319
|
-
url,
|
|
320
|
-
domain: extractDomain(url),
|
|
321
|
-
layer: 'fetch',
|
|
322
|
-
contentType: 'html',
|
|
323
|
-
});
|
|
339
|
+
// Not tracked here — scrapeSingle tracks the fetch layer's final error once.
|
|
324
340
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
|
|
325
341
|
}
|
|
326
342
|
throw e;
|
|
@@ -330,14 +346,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
330
346
|
const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
|
|
331
347
|
logger.warn(`[Scrapers] HTML response too large (actual: ${sizeMB}MB, max 25MB), skipping`);
|
|
332
348
|
metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: 'html' });
|
|
333
|
-
|
|
334
|
-
component: 'scrapers',
|
|
335
|
-
operation: 'fetch',
|
|
336
|
-
url,
|
|
337
|
-
domain: extractDomain(url),
|
|
338
|
-
layer: 'fetch',
|
|
339
|
-
contentType: 'html',
|
|
340
|
-
});
|
|
349
|
+
// Not tracked here — scrapeSingle tracks the fetch layer's final error once.
|
|
341
350
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
|
|
342
351
|
}
|
|
343
352
|
|
|
@@ -356,27 +365,17 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
356
365
|
metrics.observe('scrape_latency_ms', fetchDuration, { layer: 'fetch', content_type: 'html', status: 'success' });
|
|
357
366
|
return { source: 'fetch', layer: 'fetch', markdown };
|
|
358
367
|
} catch (error) {
|
|
368
|
+
// Attempt-scoped metrics stay here (they count fetch operations, and a retried
|
|
369
|
+
// attempt IS a second operation). Error TRACKING does not: scrapeSingle wraps this
|
|
370
|
+
// call in a retry, and tracking per attempt would record one failure twice —
|
|
371
|
+
// inflating exactly the error counts these numbers are read to diagnose.
|
|
372
|
+
// scrapeSingle calls trackFetchLayerFailure once with the final error.
|
|
359
373
|
if (error instanceof Error && error.message.includes('not allowed')) {
|
|
360
374
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
361
375
|
metrics.observe('scrape_latency_ms', Date.now() - fetchStart, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
362
|
-
errorTracker.trackError(error, {
|
|
363
|
-
component: 'scrapers',
|
|
364
|
-
operation: 'fetch',
|
|
365
|
-
url,
|
|
366
|
-
domain: extractDomain(url),
|
|
367
|
-
layer: 'fetch',
|
|
368
|
-
errorType: 'ssrf_blocked',
|
|
369
|
-
});
|
|
370
376
|
throw error;
|
|
371
377
|
}
|
|
372
378
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'error' });
|
|
373
|
-
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
374
|
-
component: 'scrapers',
|
|
375
|
-
operation: 'fetch',
|
|
376
|
-
url,
|
|
377
|
-
domain: extractDomain(url),
|
|
378
|
-
layer: 'fetch',
|
|
379
|
-
});
|
|
380
379
|
throw error;
|
|
381
380
|
} finally {
|
|
382
381
|
clearTimeout(timeoutId);
|
|
@@ -482,7 +481,35 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
482
481
|
|
|
483
482
|
const start = Date.now();
|
|
484
483
|
try {
|
|
485
|
-
|
|
484
|
+
// One cheap transient retry before falling back to the (10–30× slower) stealth
|
|
485
|
+
// browser. The fetch layer is the only scrape layer with no retry; a transient
|
|
486
|
+
// socket/DNS blip (ECONNRESET, ENOTFOUND, UND_ERR_*, a bare `fetch failed`)
|
|
487
|
+
// otherwise costs a full browser render. Gated on isTransientError, so SSRF
|
|
488
|
+
// rejections, size caps and 4xx rethrow immediately; the outer catch still owns
|
|
489
|
+
// the browser fallback on genuine exhaustion.
|
|
490
|
+
//
|
|
491
|
+
// Deliberately NOT retryWithBackoff: it logs every retry at WARN, and a
|
|
492
|
+
// fetch-layer miss is an expected, benign event with a fallback behind it —
|
|
493
|
+
// the same reason isBenignScrapeFailure keeps these out of ERROR. A burst of
|
|
494
|
+
// blocked scrapes would otherwise emit one WARN per URL.
|
|
495
|
+
const res = await (async () => {
|
|
496
|
+
try {
|
|
497
|
+
return await scrapeWithFetch(url, signal);
|
|
498
|
+
} catch (firstErr) {
|
|
499
|
+
if (!isTransientError(firstErr) || signal?.aborted) throw firstErr;
|
|
500
|
+
logger.debug(`[Scrapers] fetch transient failure for ${url}, retrying once: ${formatErrorWithCause(firstErr)}`);
|
|
501
|
+
metrics.increment('scrape_fetch_retries_total', 1, { layer: 'fetch' });
|
|
502
|
+
try {
|
|
503
|
+
// keepAlive: the retry is foreground work. An unref'd backoff is only safe
|
|
504
|
+
// while some other handle is referenced; a lone scrape would otherwise let
|
|
505
|
+
// the process exit during the 250ms gap and never make the second attempt.
|
|
506
|
+
await abortableDelay(FETCH_RETRY_DELAY_MS, signal, true);
|
|
507
|
+
} catch {
|
|
508
|
+
throw firstErr; // aborted during backoff — surface the real cause
|
|
509
|
+
}
|
|
510
|
+
return await scrapeWithFetch(url, signal);
|
|
511
|
+
}
|
|
512
|
+
})();
|
|
486
513
|
const duration = Date.now() - start;
|
|
487
514
|
logger.log(`[Scrapers] fetch success for ${url} in ${duration}ms`);
|
|
488
515
|
metrics.increment('scrape_results_total', 1, { outcome: 'fetch_success' });
|
|
@@ -494,10 +521,10 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
494
521
|
return { ...res, url, success: true };
|
|
495
522
|
} catch (e1) {
|
|
496
523
|
const fetchDuration = Date.now() - start;
|
|
497
|
-
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
|
|
524
|
+
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
|
|
525
|
+
// Tracked here rather than inside scrapeWithFetch, which is retried above —
|
|
526
|
+
// one transient failure must file one error, not one per attempt.
|
|
527
|
+
trackFetchLayerFailure(url, e1);
|
|
501
528
|
|
|
502
529
|
if (playwrightAvailable) {
|
|
503
530
|
try {
|
|
@@ -524,18 +551,18 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
524
551
|
// at ERROR with its stack. Either way, scrapeWithStealthBrowser's own catch
|
|
525
552
|
// already tracked the error with layer context, so it is not re-tracked here.
|
|
526
553
|
if (isBenignScrapeFailure(e2)) {
|
|
527
|
-
logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${
|
|
554
|
+
logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${formatErrorWithCause(e2)}`);
|
|
528
555
|
} else {
|
|
529
556
|
logger.error(`[Scrapers] Browser fallback failed for ${url} in ${totalDuration}ms:`, e2);
|
|
530
557
|
}
|
|
531
558
|
metrics.increment('scrape_errors_total', 1, { error_type: 'fallback_failed', layer: 'playwright' });
|
|
532
559
|
metrics.increment('scrape_results_total', 1, { outcome: 'total_failure' });
|
|
533
|
-
return { url, success: false, error:
|
|
560
|
+
return { url, success: false, error: formatErrorWithCause(e2), markdown: '' };
|
|
534
561
|
}
|
|
535
562
|
}
|
|
536
563
|
metrics.increment('scrape_errors_total', 1, { error_type: 'no_fallback_available', layer: 'fetch' });
|
|
537
564
|
metrics.increment('scrape_results_total', 1, { outcome: 'total_failure' });
|
|
538
|
-
return { url, success: false, error:
|
|
565
|
+
return { url, success: false, error: formatErrorWithCause(e1), markdown: '' };
|
|
539
566
|
}
|
|
540
567
|
}
|
|
541
568
|
|