@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.
@@ -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();
@@ -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, use the video's ACTUAL title and channel (both shown in this tool's output) as the description — e.g. \"'<exact title>' by <channel>\". Do NOT paraphrase or invent a title; the watch URL alone is not human-identifiable.",
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 decimal/octal IP representations.
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, decimal IP representations (2130706433),
165
- // AAAA-only hosts (::1, fc00::/7) and other hostname-based bypasses.
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
- const buffer = await response.arrayBuffer();
226
- const markdown = await extractPdfToMarkdown(new Uint8Array(buffer));
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 on the byte length before decoding.
236
- const bodyBytes = new Uint8Array(await response.arrayBuffer());
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);
@@ -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 so the report cites the real video title
125
- // and channel rather than a paraphrase (the watch URL is not identifiable).
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();