@lincoln504/pi-research 1.0.3 → 1.0.5
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 +1 -1
- package/dist/cli.mjs +256 -51
- package/dist/prompts/researcher.md +1 -1
- package/docs/CONFIGURATION.md +2 -2
- package/docs/KNOWLEDGE-STORE.md +5 -3
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/skills/pi-research/references/configuration.md +2 -2
- package/src/cli.ts +78 -31
- package/src/core/llm/model-registry-factory.ts +17 -6
- package/src/knowledge/store.ts +17 -7
- package/src/orchestration/deep-research-orchestrator.ts +38 -12
- package/src/orchestration/headless-observer.ts +45 -0
- package/src/orchestration/quick-research-orchestrator.ts +5 -4
- package/src/orchestration/research-orchestration-service.ts +3 -2
- package/src/orchestration/research-synthesis-service.ts +11 -3
- package/src/orchestration/researcher-executor.ts +1 -1
- package/src/orchestration/session-state.ts +33 -1
- package/src/prompts/researcher.md +1 -1
- package/src/sdk.ts +20 -1
- package/src/security/cisa-kev.ts +3 -0
- package/src/security/github-advisories.ts +5 -0
- package/src/security/nvd-client.ts +3 -0
- package/src/security/osv-client.ts +3 -0
- package/src/tools/youtube-transcript.ts +6 -2
- package/src/utils/shared-links.ts +26 -0
- package/src/web-research/scraper-utils.ts +10 -3
- package/src/web-research/web-scraper.ts +95 -4
- package/src/youtube/index.ts +7 -3
- package/src/youtube/transcript-client.ts +2 -0
|
@@ -26,6 +26,8 @@ const MAX_STEERING_MESSAGE_LENGTH = 2000;
|
|
|
26
26
|
interface PiSessionState {
|
|
27
27
|
/** Map of research run ID → array of failed researcher IDs */
|
|
28
28
|
failures: Map<string, string[]>;
|
|
29
|
+
/** Map of research run ID → (researcher ID → first failure reason) */
|
|
30
|
+
failureReasons: Map<string, Map<string, string>>;
|
|
29
31
|
/** Ordered list of research run IDs for TUI stacking (Index 0 = oldest/bottom-most) */
|
|
30
32
|
order: string[];
|
|
31
33
|
/** Registry of panel states for each research run */
|
|
@@ -70,6 +72,7 @@ function getPiState(piSessionId: string | undefined): PiSessionState {
|
|
|
70
72
|
if (!state) {
|
|
71
73
|
state = {
|
|
72
74
|
failures: new Map(),
|
|
75
|
+
failureReasons: new Map(),
|
|
73
76
|
order: [],
|
|
74
77
|
panels: new Map(),
|
|
75
78
|
aborts: new Map(),
|
|
@@ -392,6 +395,7 @@ export function refreshAllSessions(piSessionId: string | undefined): void {
|
|
|
392
395
|
for (const id of state.order) {
|
|
393
396
|
if (!validIds.includes(id)) {
|
|
394
397
|
state.failures.delete(id);
|
|
398
|
+
state.failureReasons.delete(id);
|
|
395
399
|
state.panels.delete(id);
|
|
396
400
|
}
|
|
397
401
|
}
|
|
@@ -454,6 +458,7 @@ export function startResearchSession(piSessionId: string | undefined, customRese
|
|
|
454
458
|
const sid = normalizeSessionId(piSessionId);
|
|
455
459
|
const state = getPiState(sid);
|
|
456
460
|
state.failures.set(researchId, []);
|
|
461
|
+
state.failureReasons.set(researchId, new Map());
|
|
457
462
|
return researchId;
|
|
458
463
|
}
|
|
459
464
|
|
|
@@ -501,6 +506,7 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
501
506
|
if (!state) return;
|
|
502
507
|
|
|
503
508
|
state.failures.delete(researchId);
|
|
509
|
+
state.failureReasons.delete(researchId);
|
|
504
510
|
state.panels.delete(researchId);
|
|
505
511
|
state.aborts.delete(researchId);
|
|
506
512
|
|
|
@@ -525,6 +531,7 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
525
531
|
} else {
|
|
526
532
|
// Clear research-specific state but preserve the session and steering
|
|
527
533
|
state.failures.clear();
|
|
534
|
+
state.failureReasons.clear();
|
|
528
535
|
state.aborts.clear();
|
|
529
536
|
}
|
|
530
537
|
}
|
|
@@ -563,12 +570,28 @@ export function isBottomMostSession(piSessionId: string | undefined, researchId:
|
|
|
563
570
|
/**
|
|
564
571
|
* Record a researcher failure
|
|
565
572
|
*/
|
|
566
|
-
export function recordResearcherFailure(piSessionId: string | undefined, researchId: string, researcherId: string): void {
|
|
573
|
+
export function recordResearcherFailure(piSessionId: string | undefined, researchId: string, researcherId: string, reason?: string): void {
|
|
567
574
|
const sid = normalizeSessionId(piSessionId);
|
|
568
575
|
const state = getPiState(sid);
|
|
569
576
|
const failures = state.failures.get(researchId) || [];
|
|
570
577
|
failures.push(researcherId);
|
|
571
578
|
state.failures.set(researchId, failures);
|
|
579
|
+
if (reason) {
|
|
580
|
+
const reasons = state.failureReasons.get(researchId) ?? new Map<string, string>();
|
|
581
|
+
// Keep the FIRST reason per researcher — the root cause; retries repeat it.
|
|
582
|
+
if (!reasons.has(researcherId)) reasons.set(researcherId, reason);
|
|
583
|
+
state.failureReasons.set(researchId, reasons);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Get the recorded failure reason per failed researcher (first failure wins).
|
|
589
|
+
*/
|
|
590
|
+
export function getResearcherFailureReasons(piSessionId: string | undefined, researchId: string): Record<string, string> {
|
|
591
|
+
const sid = normalizeSessionId(piSessionId);
|
|
592
|
+
const state = getPiState(sid);
|
|
593
|
+
const reasons = state.failureReasons.get(researchId);
|
|
594
|
+
return reasons ? Object.fromEntries(reasons) : {};
|
|
572
595
|
}
|
|
573
596
|
|
|
574
597
|
/**
|
|
@@ -610,9 +633,18 @@ export function getResearchStopMessage(piSessionId: string | undefined, research
|
|
|
610
633
|
// search engine blocking" here mis-sends the user to debug their network; the
|
|
611
634
|
// readiness probes do not even run a search, so a failure is not evidence of bot
|
|
612
635
|
// blocking.
|
|
636
|
+
// Lead with the RECORDED errors when we have them — the actual root cause
|
|
637
|
+
// (e.g. a provider 429) beats generic guidance every time.
|
|
638
|
+
const state = getPiState(sid);
|
|
639
|
+
const reasons = state.failureReasons.get(researchId);
|
|
640
|
+
const recorded = reasons && reasons.size > 0
|
|
641
|
+
? ['Recorded errors:', ...[...reasons].map(([id, r]) => `• researcher ${id}: ${r}`), '']
|
|
642
|
+
: [];
|
|
643
|
+
|
|
613
644
|
return [
|
|
614
645
|
`Research stopped: ${count} researcher(s) did not return a usable report: ${failed.join(', ')}.`,
|
|
615
646
|
'',
|
|
647
|
+
...recorded,
|
|
616
648
|
'Most likely cause — the research model could not produce grounded results:',
|
|
617
649
|
'• Try a more capable research model. Very small or "thinking-only" models often',
|
|
618
650
|
' emit no final text after their tool calls, which fails every researcher.',
|
|
@@ -58,7 +58,7 @@ Write your report immediately after scraping is complete, or as soon as a tool r
|
|
|
58
58
|
## Guidelines
|
|
59
59
|
|
|
60
60
|
- **Available Tools**:
|
|
61
|
-
- `scrape`: Fetch and read web pages (primary tool). Focus your energy here.
|
|
61
|
+
- `scrape`: Fetch and read web pages (primary tool). Focus your energy here. Do NOT scrape YouTube video links (watch / youtu.be / shorts) — a watch page returns YouTube's app shell, not the video's content; read videos with `youtube_transcript`. Scraping a YouTube channel or playlist page to DISCOVER video links is fine.
|
|
62
62
|
- `youtube_transcript`: Read the captions/transcript of YouTube videos from your source list — it is the `scrape` for video. ONE call only, batching up to a few of the most relevant YouTube links. Cite these as `Source: YouTube Transcript`.
|
|
63
63
|
- `stackexchange`: Use ONLY for Stack Overflow or Stack Exchange URLs.
|
|
64
64
|
- `security_search`: Query NVD, CVE, OSV, CISA databases.
|
package/src/sdk.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type {
|
|
|
30
30
|
import type { Model } from '@earendil-works/pi-ai';
|
|
31
31
|
import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
32
32
|
import { logger, createLogger, setLogger } from './logger.ts';
|
|
33
|
-
import { getConfig, createConfig, validateConfig, type Config } from './config.ts';
|
|
33
|
+
import { getConfig, createConfig, validateConfig, resetConfig, type Config } from './config.ts';
|
|
34
34
|
import { metrics, MetricsRegistry, runWithRunRegistry } from './utils/metrics.ts';
|
|
35
35
|
import type { IMetricsSnapshot, RunSummary } from './utils/metrics.ts';
|
|
36
36
|
import { extractRunStats, type ResearchStats } from './utils/metrics-summary.ts';
|
|
@@ -236,6 +236,19 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
236
236
|
globalApiKey = options.apiKey || process.env['PI_RESEARCH_API_KEY'];
|
|
237
237
|
let parsedProvider = options.provider || process.env['PI_RESEARCH_PROVIDER'];
|
|
238
238
|
|
|
239
|
+
// An EXPLICIT model option must govern the entire run — coordinator (via
|
|
240
|
+
// ctx.model) AND researchers/synthesis (which resolve through
|
|
241
|
+
// RESEARCH_MODEL, where config would otherwise outrank the option and split
|
|
242
|
+
// the run across two models, with the report metadata naming the wrong one).
|
|
243
|
+
// Pin the config to the explicit choice. No-op when the option was itself
|
|
244
|
+
// seeded from the config (the CLI does that).
|
|
245
|
+
if (options.model) {
|
|
246
|
+
const explicit = typeof options.model === 'string'
|
|
247
|
+
? options.model
|
|
248
|
+
: `${(options.model as Model<any>).provider}/${(options.model as Model<any>).id}`;
|
|
249
|
+
globalConfig = { ...globalConfig, RESEARCH_MODEL: explicit };
|
|
250
|
+
}
|
|
251
|
+
|
|
239
252
|
// Infer provider from the effective model if not explicitly provided. The
|
|
240
253
|
// effective model is the explicit option first, else the configured
|
|
241
254
|
// RESEARCH_MODEL — the same precedence the resolve below uses.
|
|
@@ -820,6 +833,12 @@ async function _doShutdown(): Promise<void> {
|
|
|
820
833
|
_lastResearchId = null;
|
|
821
834
|
_lastRunSummary = null;
|
|
822
835
|
_lastErrorReport = null;
|
|
836
|
+
// The module-level config cache survives the globals above. Without this, a
|
|
837
|
+
// shutdown → env/config-file change → re-init cycle in one process silently
|
|
838
|
+
// reuses the first init's config. (The CLI/skill are unaffected — they pass
|
|
839
|
+
// ignoreGlobalConfig and their own config — but the SDK contract is that a
|
|
840
|
+
// fresh init reads fresh config.)
|
|
841
|
+
resetConfig();
|
|
823
842
|
|
|
824
843
|
// Remove signal handlers — caller is shutting down cooperatively.
|
|
825
844
|
_removeSignalHandlers();
|
package/src/security/cisa-kev.ts
CHANGED
|
@@ -122,6 +122,9 @@ async function fetchWithRetryImpl(url: string, options: RequestInit, signal?: Ab
|
|
|
122
122
|
async () => {
|
|
123
123
|
const response = await fetch(url, options);
|
|
124
124
|
if (!response.ok) {
|
|
125
|
+
// Drain before throwing: an unconsumed error body pins its socket until
|
|
126
|
+
// GC, and this path runs on every retry of a failed sweep.
|
|
127
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
125
128
|
const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as Error & { status?: number };
|
|
126
129
|
error.status = response.status;
|
|
127
130
|
metrics.increment('cisa_kev_errors_total', 1, { endpoint, status_code: response.status.toString() });
|
|
@@ -116,6 +116,9 @@ export async function searchGitHubAdvisories(
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
if (!resp.ok) {
|
|
119
|
+
// Drain before throwing: an unconsumed error body pins its socket until
|
|
120
|
+
// GC, and the 403/5xx branches below are retried with backoff.
|
|
121
|
+
void resp.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
119
122
|
if (resp.status === 404) {
|
|
120
123
|
throw new Error(`Repository "${owner}/${name}" not found or no access to security advisories.`);
|
|
121
124
|
}
|
|
@@ -206,6 +209,8 @@ export async function searchGitHubAdvisories(
|
|
|
206
209
|
});
|
|
207
210
|
|
|
208
211
|
if (!resp.ok) {
|
|
212
|
+
// Same drain-before-throw as the repo-advisories fetch above.
|
|
213
|
+
void resp.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
209
214
|
if (resp.status === 404) {
|
|
210
215
|
throw new Error('Advisory not found (HTTP 404)');
|
|
211
216
|
}
|
|
@@ -327,6 +327,9 @@ function handleFetchError(error: unknown): never {
|
|
|
327
327
|
|
|
328
328
|
function handleResponseStatus(response: Response): void {
|
|
329
329
|
if (!response.ok) {
|
|
330
|
+
// Drain before throwing: an unconsumed error body pins its socket until GC,
|
|
331
|
+
// and this path runs on every retry of a rate-limited/5xx sweep.
|
|
332
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
330
333
|
if (response.status === 429) {
|
|
331
334
|
throw new Error('NVD API rate limit exceeded (HTTP 429). Retrying with backoff...');
|
|
332
335
|
}
|
|
@@ -44,6 +44,9 @@ async function fetchWithRetry(
|
|
|
44
44
|
async () => {
|
|
45
45
|
const response = await fetch(url, options);
|
|
46
46
|
if (!response.ok) {
|
|
47
|
+
// Drain before throwing: an unconsumed error body pins its socket until
|
|
48
|
+
// GC, and this path runs on every retry of a rate-limited/5xx sweep.
|
|
49
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
47
50
|
const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as Error & { status?: number };
|
|
48
51
|
error.status = response.status;
|
|
49
52
|
metrics.increment('osv_errors_total', 1, { endpoint, status_code: response.status.toString() });
|
|
@@ -20,7 +20,7 @@ import { Value } from 'typebox/value';
|
|
|
20
20
|
import { youtubeTranscriptCommand } from '../youtube/index.ts';
|
|
21
21
|
import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
|
|
22
22
|
import type { SystemResearchState } from '../orchestration/deep-research-types.ts';
|
|
23
|
-
import { cacheScrapedContent, registerScrapedLinks, registerResearcherScrapes } from '../utils/shared-links.ts';
|
|
23
|
+
import { cacheScrapedContent, registerScrapedLinks, registerTranscribedLinks, registerResearcherScrapes } from '../utils/shared-links.ts';
|
|
24
24
|
import { type Config, getConfig } from '../config.ts';
|
|
25
25
|
import { logger } from '../logger.ts';
|
|
26
26
|
import { metrics } from '../utils/metrics.ts';
|
|
@@ -64,7 +64,7 @@ export function createYoutubeTranscriptTool(options: {
|
|
|
64
64
|
'You may call this tool only ONCE, so choose the videos carefully before calling.',
|
|
65
65
|
'Pass full watch URLs, youtu.be links, or shorts/embed/live links. Non-YouTube links are ignored.',
|
|
66
66
|
'Videos without captions, or that are private/age-restricted, are reported as unavailable — the rest still succeed.',
|
|
67
|
-
"When you cite a YouTube source in CITED LINKS,
|
|
67
|
+
"When you cite a YouTube source in CITED LINKS, the entry's link MUST be the video's watch URL (shown in this tool's output) — a title-only entry loses the source from the final report. Put the video's ACTUAL title and channel in the Description — e.g. \"'<exact title>' by <channel>\". Do NOT paraphrase or invent a title.",
|
|
68
68
|
],
|
|
69
69
|
parameters: YoutubeParamsSchema,
|
|
70
70
|
executionMode: 'parallel',
|
|
@@ -139,6 +139,10 @@ export function createYoutubeTranscriptTool(options: {
|
|
|
139
139
|
|
|
140
140
|
if (researchId && cachedUrls.length > 0) {
|
|
141
141
|
registerScrapedLinks(researchId, cachedUrls);
|
|
142
|
+
// Record the provenance subset: if the researcher's CITED LINKS block is
|
|
143
|
+
// unparseable, the synthesis fallback rebuilds sources from the link pool
|
|
144
|
+
// and must label these "YouTube Transcript", not "Scrape".
|
|
145
|
+
registerTranscribedLinks(researchId, cachedUrls);
|
|
142
146
|
options.updateGlobalLinks?.(cachedUrls);
|
|
143
147
|
if (options.researcherId) {
|
|
144
148
|
registerResearcherScrapes(researchId, options.researcherId, cachedUrls);
|
|
@@ -17,6 +17,11 @@ const sessionLinks = new Map<string, Set<string>>();
|
|
|
17
17
|
const sessionScrapedContent = new Map<string, Map<string, string>>();
|
|
18
18
|
// Per-researcher scrape tracking: researchId → researcherId → Set<normalizedUrl>
|
|
19
19
|
const researcherScrapes = new Map<string, Map<string, Set<string>>>();
|
|
20
|
+
// URLs whose content came from a YouTube TRANSCRIPT (not a page scrape). The
|
|
21
|
+
// transcript tool registers its watch URLs in the same sessionLinks pool so the
|
|
22
|
+
// post-round writer and citation fallback see them; this subset lets the
|
|
23
|
+
// fallback label them "YouTube Transcript" instead of "Scrape".
|
|
24
|
+
const transcribedUrls = new Map<string, Set<string>>();
|
|
20
25
|
|
|
21
26
|
// FIX (Issue 12): Track creation timestamps for orphaned-entry cleanup.
|
|
22
27
|
const sessionTimestamps = new Map<string, number>();
|
|
@@ -38,6 +43,7 @@ function startCleanupTimer(): void {
|
|
|
38
43
|
sessionScrapedContent.delete(researchId);
|
|
39
44
|
sessionTimestamps.delete(researchId);
|
|
40
45
|
researcherScrapes.delete(researchId);
|
|
46
|
+
transcribedUrls.delete(researchId);
|
|
41
47
|
cleaned++;
|
|
42
48
|
}
|
|
43
49
|
}
|
|
@@ -111,6 +117,24 @@ export function getScrapedLinks(researchId: string): string[] {
|
|
|
111
117
|
return Array.from(sessionLinks.get(researchId) || []);
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Mark links as sourced from a YouTube transcript (call IN ADDITION to
|
|
122
|
+
* registerScrapedLinks — this only records the provenance subset).
|
|
123
|
+
*/
|
|
124
|
+
export function registerTranscribedLinks(researchId: string, links: string[]) {
|
|
125
|
+
if (!transcribedUrls.has(researchId)) {
|
|
126
|
+
transcribedUrls.set(researchId, new Set());
|
|
127
|
+
}
|
|
128
|
+
sessionTimestamps.set(researchId, Date.now()); // last-activity TTL refresh (parity with registerScrapedLinks)
|
|
129
|
+
const pool = transcribedUrls.get(researchId)!;
|
|
130
|
+
links.forEach(l => pool.add(normalizeUrl(l)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a URL's content came from a YouTube transcript in this session. */
|
|
134
|
+
export function isTranscribedLink(researchId: string, url: string): boolean {
|
|
135
|
+
return transcribedUrls.get(researchId)?.has(normalizeUrl(url)) ?? false;
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
/**
|
|
115
139
|
* Deduplicate a list of candidate URLs against already scraped links.
|
|
116
140
|
*/
|
|
@@ -140,6 +164,7 @@ export function cleanupSharedLinks(researchId: string) {
|
|
|
140
164
|
sessionScrapedContent.delete(researchId);
|
|
141
165
|
sessionTimestamps.delete(researchId);
|
|
142
166
|
researcherScrapes.delete(researchId);
|
|
167
|
+
transcribedUrls.delete(researchId);
|
|
143
168
|
logger.debug(`[Shared Links] Cleaned up session: ${researchId}`);
|
|
144
169
|
}
|
|
145
170
|
|
|
@@ -156,6 +181,7 @@ export function clearAllSharedLinks(): void {
|
|
|
156
181
|
sessionScrapedContent.clear();
|
|
157
182
|
sessionTimestamps.clear();
|
|
158
183
|
researcherScrapes.clear();
|
|
184
|
+
transcribedUrls.clear();
|
|
159
185
|
logger.debug('[Shared Links] All global state cleared');
|
|
160
186
|
}
|
|
161
187
|
|
|
@@ -150,7 +150,10 @@ export function validateUrlForSSRFSync(url: string): boolean {
|
|
|
150
150
|
*
|
|
151
151
|
* FIX (#8): In addition to hostname pattern checks, resolves the hostname
|
|
152
152
|
* via DNS and rejects any address that resolves to a private/reserved IP.
|
|
153
|
-
* This defends against DNS rebinding and
|
|
153
|
+
* This defends against DNS rebinding and AAAA-only private hosts. (Decimal/
|
|
154
|
+
* octal/hex IP encodings are handled earlier: WHATWG URL parsing normalizes
|
|
155
|
+
* them to dotted-quad, so the sync IP-literal gate blocks them — see the
|
|
156
|
+
* comment on the DNS pass below.)
|
|
154
157
|
*/
|
|
155
158
|
export async function validateUrlForSSRF(url: string): Promise<void> {
|
|
156
159
|
// Synchronous protocol/localhost/internal-pattern/IP-literal screen first.
|
|
@@ -161,8 +164,12 @@ export async function validateUrlForSSRF(url: string): Promise<void> {
|
|
|
161
164
|
const hostname = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
162
165
|
|
|
163
166
|
// Resolve hostname via DNS (both IPv4 and IPv6) and check all resulting addresses.
|
|
164
|
-
// This catches DNS rebinding,
|
|
165
|
-
//
|
|
167
|
+
// This catches DNS rebinding, AAAA-only hosts (::1, fc00::/7) and other
|
|
168
|
+
// hostname-based bypasses. (It does NOT catch numeric-encoding forms like
|
|
169
|
+
// decimal 2130706433 — dns.resolve4/6 reject numeric hosts with EBADNAME — but
|
|
170
|
+
// it doesn't need to: WHATWG URL parsing normalizes decimal/octal/hex IPv4
|
|
171
|
+
// forms to dotted-quad before this function ever sees them, so the IP-literal
|
|
172
|
+
// gate in validateUrlForSSRFSync blocks them. Pinned by a regression test.)
|
|
166
173
|
const [v4Results, v6Results] = await Promise.allSettled([
|
|
167
174
|
dns.resolve4(hostname),
|
|
168
175
|
dns.resolve6(hostname),
|
|
@@ -140,6 +140,57 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** Thrown by readBodyCapped when a streamed body crosses its byte cap. */
|
|
144
|
+
class BodyTooLargeError extends Error {
|
|
145
|
+
constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
|
|
146
|
+
super(`Response body exceeded ${maxBytes} byte cap`);
|
|
147
|
+
this.name = 'BodyTooLargeError';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
|
|
153
|
+
* over-limit body aborts the moment the cap is crossed. The Content-Length
|
|
154
|
+
* pre-screen above is advisory only: a chunked (or lying) response carries no
|
|
155
|
+
* usable length header, and `response.arrayBuffer()` would otherwise buffer the
|
|
156
|
+
* entire body — potentially hundreds of MB from a hostile server — into the
|
|
157
|
+
* main process before any size check ran.
|
|
158
|
+
*
|
|
159
|
+
* A body-less response (test doubles, some mocks) falls back to arrayBuffer();
|
|
160
|
+
* callers keep their post-read size checks as the belt for that path.
|
|
161
|
+
*/
|
|
162
|
+
async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
|
|
163
|
+
const body = response.body;
|
|
164
|
+
// Also tolerate non-standard doubles that expose a body without getReader.
|
|
165
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
166
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const reader = body.getReader();
|
|
170
|
+
const chunks: Uint8Array[] = [];
|
|
171
|
+
let total = 0;
|
|
172
|
+
try {
|
|
173
|
+
for (;;) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) break;
|
|
176
|
+
if (value) {
|
|
177
|
+
total += value.byteLength;
|
|
178
|
+
if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
|
|
179
|
+
chunks.push(value);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
// Over-cap or downstream throw: release the connection instead of leaving
|
|
184
|
+
// the socket open (and the server still sending) until GC. No-op after a
|
|
185
|
+
// clean done=true read.
|
|
186
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
187
|
+
}
|
|
188
|
+
const out = new Uint8Array(total);
|
|
189
|
+
let offset = 0;
|
|
190
|
+
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
143
194
|
async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<ScrapeLayerResult> {
|
|
144
195
|
await validateUrlForSSRF(url);
|
|
145
196
|
|
|
@@ -189,6 +240,9 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
189
240
|
if (!location) break; // No Location header — treat as final response
|
|
190
241
|
const resolved = new URL(location, currentUrl).href;
|
|
191
242
|
await validateUrlForSSRF(resolved); // Block SSRF targets in redirects
|
|
243
|
+
// Drain the redirect response before following: an unconsumed body pins
|
|
244
|
+
// its socket (undici keeps it open until read/cancelled or GC).
|
|
245
|
+
try { await response.body?.cancel(); } catch { /* best-effort */ }
|
|
192
246
|
currentUrl = resolved;
|
|
193
247
|
continue;
|
|
194
248
|
}
|
|
@@ -197,6 +251,8 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
197
251
|
|
|
198
252
|
if (!response.ok) {
|
|
199
253
|
metrics.increment('scrape_errors_total', 1, { error_type: 'http_error', status_code: String(response.status) });
|
|
254
|
+
// Same socket-pinning concern as the redirect drain above.
|
|
255
|
+
try { await response.body?.cancel(); } catch { /* best-effort */ }
|
|
200
256
|
throw new Error(`HTTP ${response.status}`);
|
|
201
257
|
}
|
|
202
258
|
|
|
@@ -211,19 +267,34 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
211
267
|
const sizeMB = Math.round(size / 1024 / 1024);
|
|
212
268
|
logger.warn(`[Scrapers] PDF too large (Content-Length: ${sizeMB}MB, max 100MB), skipping`);
|
|
213
269
|
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
|
|
270
|
+
// Same socket-pinning concern as the redirect/!ok drains above — and this
|
|
271
|
+
// throw fires precisely on the LARGEST responses.
|
|
272
|
+
try { await response.body?.cancel(); } catch { /* best-effort */ }
|
|
214
273
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
215
274
|
}
|
|
216
275
|
} else if (size > MAX_HTML_SIZE) {
|
|
217
276
|
const sizeMB = Math.round(size / 1024 / 1024);
|
|
218
277
|
logger.warn(`[Scrapers] HTML response too large (Content-Length: ${sizeMB}MB, max 25MB), skipping`);
|
|
219
278
|
metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: contentType.split(';')[0] || 'unknown' });
|
|
279
|
+
try { await response.body?.cancel(); } catch { /* best-effort */ }
|
|
220
280
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
|
|
221
281
|
}
|
|
222
282
|
}
|
|
223
283
|
|
|
224
284
|
if (contentType.includes('application/pdf') || url.toLowerCase().endsWith('.pdf')) {
|
|
225
|
-
|
|
226
|
-
|
|
285
|
+
let pdfBytes: Uint8Array;
|
|
286
|
+
try {
|
|
287
|
+
pdfBytes = await readBodyCapped(response, MAX_PDF_SIZE);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
if (e instanceof BodyTooLargeError) {
|
|
290
|
+
const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
|
|
291
|
+
logger.warn(`[Scrapers] PDF too large (streamed ${sizeMB}MB, max 100MB), skipping`);
|
|
292
|
+
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
|
|
293
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`, { cause: e });
|
|
294
|
+
}
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
297
|
+
const markdown = await extractPdfToMarkdown(pdfBytes);
|
|
227
298
|
validateContent('', markdown, url);
|
|
228
299
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', content_type: 'pdf', status: 'success' });
|
|
229
300
|
metrics.observe('scrape_latency_ms', fetchDuration, { layer: 'fetch', content_type: 'pdf', status: 'success' });
|
|
@@ -232,8 +303,28 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
232
303
|
|
|
233
304
|
// Read the raw bytes and decode with the DECLARED charset. response.text() blindly UTF-8-decodes,
|
|
234
305
|
// which mangles Shift_JIS/GBK/EUC-KR/ISO-8859-1 pages into mojibake that then gets cached + cited.
|
|
235
|
-
// Size-check
|
|
236
|
-
|
|
306
|
+
// Size-capped while streaming; the post-read check below is the belt for the
|
|
307
|
+
// body-less fallback path inside readBodyCapped.
|
|
308
|
+
let bodyBytes: Uint8Array;
|
|
309
|
+
try {
|
|
310
|
+
bodyBytes = await readBodyCapped(response, MAX_HTML_SIZE);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (e instanceof BodyTooLargeError) {
|
|
313
|
+
const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
|
|
314
|
+
logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
|
|
315
|
+
metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: 'html' });
|
|
316
|
+
errorTracker.trackError(new Error(`HTML response too large (${sizeMB}MB, max 25MB)`), {
|
|
317
|
+
component: 'scrapers',
|
|
318
|
+
operation: 'fetch',
|
|
319
|
+
url,
|
|
320
|
+
domain: extractDomain(url),
|
|
321
|
+
layer: 'fetch',
|
|
322
|
+
contentType: 'html',
|
|
323
|
+
});
|
|
324
|
+
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
|
|
325
|
+
}
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
237
328
|
|
|
238
329
|
if (bodyBytes.length > MAX_HTML_SIZE) {
|
|
239
330
|
const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
|
package/src/youtube/index.ts
CHANGED
|
@@ -121,10 +121,14 @@ function formatResults(results: VideoTranscript[], rejected: string[]): string {
|
|
|
121
121
|
if (r.lang) meta.push(`Language: ${r.lang}`);
|
|
122
122
|
if (typeof r.charCount === 'number') meta.push(`${r.charCount.toLocaleString()} chars`);
|
|
123
123
|
if (meta.length) md += `**${meta.join(' · ')}**\n`;
|
|
124
|
-
// Exact, copy-ready citation string
|
|
125
|
-
//
|
|
124
|
+
// Exact, copy-ready citation string. It MUST lead with the watch URL: a
|
|
125
|
+
// title-only citation cannot be matched to a source by the synthesis
|
|
126
|
+
// citation pipeline (parseCitations extracts URLs), so the transcript's
|
|
127
|
+
// provenance would silently drop out of the final report's CITED LINKS.
|
|
128
|
+
// The title/channel follows so the report names the real video, not a
|
|
129
|
+
// paraphrase (the watch URL alone is not human-identifiable).
|
|
126
130
|
if (r.title) {
|
|
127
|
-
md += `**Cite as:** '${r.title}'${r.author ? ` by ${r.author}` : ''}\n`;
|
|
131
|
+
md += `**Cite as:** ${r.url} — '${r.title}'${r.author ? ` by ${r.author}` : ''}\n`;
|
|
128
132
|
}
|
|
129
133
|
md += `\n${r.text}\n\n---\n\n`;
|
|
130
134
|
}
|
|
@@ -251,6 +251,8 @@ async function fetchOne(
|
|
|
251
251
|
// 404 (removed) returns HTML that parseJson3 yields '' for, which would
|
|
252
252
|
// be misreported below as a bot-protection/PoToken failure.
|
|
253
253
|
if (!response.ok) {
|
|
254
|
+
// Drain before throwing so the error body doesn't pin its socket.
|
|
255
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
254
256
|
throw new Error(`HTTP ${response.status} from timedtext endpoint`);
|
|
255
257
|
}
|
|
256
258
|
const body = await response.text();
|