@lincoln504/pi-research 1.1.2 → 1.2.0

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.
Files changed (74) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +1287 -540
  3. package/dist/thread-worker.mjs +30 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +81 -1
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +50 -10
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cli.ts +149 -21
  21. package/src/config.ts +56 -15
  22. package/src/constants.ts +31 -0
  23. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  24. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  25. package/src/core/pi-version.ts +136 -0
  26. package/src/core/service-registry.ts +117 -8
  27. package/src/index.ts +11 -18
  28. package/src/infrastructure/browser/browser-client.ts +16 -4
  29. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  30. package/src/infrastructure/browser/browser-server.ts +17 -5
  31. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  32. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  33. package/src/infrastructure/browser/client-agent.ts +4 -3
  34. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  35. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  36. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  37. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  38. package/src/infrastructure/browser/thread-worker.mjs +30 -11
  39. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  40. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  41. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  42. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  43. package/src/infrastructure/file-lock-service.ts +223 -26
  44. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  45. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  46. package/src/infrastructure/state/state-manager-service.ts +1 -1
  47. package/src/infrastructure/state/state-manager.ts +19 -20
  48. package/src/infrastructure/types/state-types.ts +9 -0
  49. package/src/knowledge/embedder-init.ts +1 -1
  50. package/src/knowledge/embedder.ts +52 -3
  51. package/src/knowledge/index.ts +1 -1
  52. package/src/knowledge/store.ts +35 -9
  53. package/src/knowledge/webgpu-viability.ts +9 -4
  54. package/src/knowledge/writer-queue.ts +88 -46
  55. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  56. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  57. package/src/orchestration/research-orchestration-service.ts +2 -4
  58. package/src/orchestration/research-synthesis-service.ts +33 -11
  59. package/src/security/cisa-kev.ts +10 -3
  60. package/src/security/github-advisories.ts +18 -7
  61. package/src/security/nvd-client.ts +17 -6
  62. package/src/security/osv-client.ts +14 -4
  63. package/src/skill-install/skill-installer.ts +135 -9
  64. package/src/stackexchange/rest-client.ts +6 -2
  65. package/src/tools/research-knowledge-search.ts +15 -0
  66. package/src/tools/search.ts +6 -1
  67. package/src/utils/atomic-replace.ts +101 -0
  68. package/src/utils/citation-utils.ts +3 -1
  69. package/src/utils/http-body.ts +176 -0
  70. package/src/web-research/browser-search.ts +50 -4
  71. package/src/web-research/search.ts +20 -5
  72. package/src/web-research/types.ts +17 -4
  73. package/src/web-research/web-scraper.ts +7 -50
  74. package/src/youtube/transcript-client.ts +5 -1
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Capped response-body reading.
3
+ *
4
+ * `response.json()` and `response.text()` buffer the WHOLE body into the main
5
+ * process before anything can inspect its size — the byte count is only knowable
6
+ * after the memory has already been committed. A hostile or merely broken upstream
7
+ * can therefore drive this process to an OOM kill with a single reply, and because
8
+ * a `Content-Length` header is advisory (absent on chunked encoding, and freely
9
+ * falsifiable) no pre-screen closes that hole. Streaming chunk-by-chunk and
10
+ * aborting the instant the cap is crossed is the only check that actually bounds
11
+ * the allocation.
12
+ *
13
+ * The scraper has read HTML and PDF this way for some time; these helpers exist so
14
+ * the JSON API clients share that one implementation rather than each growing a
15
+ * near-copy of it.
16
+ */
17
+
18
+ /** Thrown by {@link readBodyCapped} when a streamed body crosses its byte cap. */
19
+ export class BodyTooLargeError extends Error {
20
+ constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
21
+ super(`Response body exceeded ${maxBytes} byte cap`);
22
+ this.name = 'BodyTooLargeError';
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Default cap for a JSON API response.
28
+ *
29
+ * Every endpoint behind these clients answers in kilobytes for a normal query, so
30
+ * 32MB is orders of magnitude of headroom for legitimate traffic while still
31
+ * bounding a runaway body to something a Node heap absorbs without dying. Bulk
32
+ * endpoints that legitimately return more (a full vulnerability catalogue, a
33
+ * maximum-size CVE page) pass their own larger cap explicitly.
34
+ */
35
+ export const MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
36
+
37
+ /**
38
+ * Cap for bulk catalogue downloads (CISA KEV's full feed, a 2000-item NVD page).
39
+ * These are legitimately tens of megabytes and grow over time, so they get their
40
+ * own ceiling rather than forcing the shared default up for everyone.
41
+ */
42
+ export const MAX_BULK_JSON_BODY_BYTES = 96 * 1024 * 1024;
43
+
44
+ /**
45
+ * Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
46
+ * over-limit body aborts the moment the cap is crossed.
47
+ *
48
+ * A body-less response (test doubles, some mocks) falls back to `arrayBuffer()`;
49
+ * callers that had a post-read size check keep it as the belt for that path.
50
+ */
51
+ export async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
52
+ const body = response.body;
53
+ // Also tolerate non-standard doubles that expose a body without getReader.
54
+ if (!body || typeof body.getReader !== 'function') {
55
+ return new Uint8Array(await response.arrayBuffer());
56
+ }
57
+
58
+ const reader = body.getReader();
59
+ const chunks: Uint8Array[] = [];
60
+ let total = 0;
61
+ try {
62
+ for (;;) {
63
+ const { done, value } = await reader.read();
64
+ if (done) break;
65
+ if (value) {
66
+ total += value.byteLength;
67
+ if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
68
+ chunks.push(value);
69
+ }
70
+ }
71
+ } finally {
72
+ // Over-cap or downstream throw: release the connection instead of leaving
73
+ // the socket open (and the server still sending) until GC. No-op after a
74
+ // clean done=true read.
75
+ try { await reader.cancel(); } catch { /* already closed */ }
76
+ }
77
+ const out = new Uint8Array(total);
78
+ let offset = 0;
79
+ for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Whether this response can actually be read as a stream — i.e. whether the cap
85
+ * can be enforced at all.
86
+ *
87
+ * A real `Response` from `fetch()` always exposes a `ReadableStream` body except
88
+ * for the null-body statuses (204/205/304), which have nothing to cap. So in
89
+ * production this is true exactly when there are bytes to bound, and the capped
90
+ * path is never quietly skipped. It returns false only for the null-body statuses
91
+ * and for hand-rolled test doubles, which implement whichever of
92
+ * `arrayBuffer()`/`text()`/`json()` they need and no stream at all.
93
+ */
94
+ function hasStreamBody(response: Response): boolean {
95
+ const body = response.body;
96
+ return !!body && typeof body.getReader === 'function';
97
+ }
98
+
99
+ /**
100
+ * Capped replacement for `response.text()`.
101
+ *
102
+ * Decodes UTF-8 non-fatally, matching `Response.text()` — a malformed byte becomes
103
+ * U+FFFD rather than throwing, so swapping this in cannot turn a body that used to
104
+ * parse into an exception.
105
+ */
106
+ export async function readTextCapped(
107
+ response: Response,
108
+ maxBytes: number = MAX_JSON_BODY_BYTES,
109
+ ): Promise<string> {
110
+ if (hasStreamBody(response)) {
111
+ return new TextDecoder('utf-8').decode(await readBodyCapped(response, maxBytes));
112
+ }
113
+ // Double/null-body ladder — see hasStreamBody for why this is unreachable for a
114
+ // real body-bearing response.
115
+ if (typeof response.arrayBuffer === 'function') {
116
+ return new TextDecoder('utf-8').decode(new Uint8Array(await response.arrayBuffer()));
117
+ }
118
+ return response.text();
119
+ }
120
+
121
+ /**
122
+ * Capped replacement for `response.json()`.
123
+ *
124
+ * Throws {@link BodyTooLargeError} for an over-cap body and a `SyntaxError` for a
125
+ * malformed one, so callers that already distinguish "malformed JSON" from other
126
+ * failures keep working: the size failure is a distinct, catchable type rather
127
+ * than something that masquerades as a parse error.
128
+ */
129
+ export async function readJsonCapped<T = unknown>(
130
+ response: Response,
131
+ maxBytes: number = MAX_JSON_BODY_BYTES,
132
+ ): Promise<T> {
133
+ if (hasStreamBody(response) || typeof response.arrayBuffer === 'function' || typeof response.text === 'function') {
134
+ return JSON.parse(await readTextCapped(response, maxBytes)) as T;
135
+ }
136
+ // A double that implements json() alone.
137
+ return response.json() as Promise<T>;
138
+ }
139
+
140
+ /**
141
+ * Accumulator for a Node `IncomingMessage` body that decodes UTF-8 exactly ONCE,
142
+ * at the end.
143
+ *
144
+ * The obvious `body += chunk` is silently lossy. `+=` stringifies each Buffer on
145
+ * its own, so a multi-byte character straddling a chunk boundary is decoded as two
146
+ * independent fragments and both halves become U+FFFD. Chunk boundaries fall
147
+ * wherever the socket happens to split, so the damage scales with payload size and
148
+ * lands only on non-ASCII text — measured at ~15 mangled characters in a 560KB CJK
149
+ * body. Nothing throws: `JSON.parse` still succeeds, so the corruption reaches the
150
+ * report and the knowledge store unannounced.
151
+ *
152
+ * Buffering the chunks and concatenating before a single decode is what makes a
153
+ * split sequence whole again.
154
+ *
155
+ * `byteLength` counts real bytes, which is also the honest unit for a payload cap —
156
+ * the old `body.length` measured UTF-16 code units of an already-corrupted string.
157
+ */
158
+ export class Utf8Body {
159
+ private readonly chunks: Buffer[] = [];
160
+ private bytes = 0;
161
+
162
+ /** Total bytes accumulated so far. */
163
+ get byteLength(): number {
164
+ return this.bytes;
165
+ }
166
+
167
+ push(chunk: Buffer): void {
168
+ this.chunks.push(chunk);
169
+ this.bytes += chunk.byteLength;
170
+ }
171
+
172
+ /** Concatenate and decode. Safe to call once the stream has ended. */
173
+ toString(): string {
174
+ return Buffer.concat(this.chunks).toString('utf8');
175
+ }
176
+ }
@@ -9,7 +9,7 @@ import { getMaxWorkers } from '../infrastructure/browser/config.ts';
9
9
  import { logger } from '../logger.ts';
10
10
  import { safeUnref } from '../utils/safe-unref.ts';
11
11
  import { normalizeUrl } from '../utils/url-utils.ts';
12
- import type { SearchResult } from './types.ts';
12
+ import type { SearchResult, QueryFailure } from './types.ts';
13
13
  import type { Config } from '../config.ts';
14
14
  import { metrics } from '../utils/metrics.ts';
15
15
  import { getServiceContainer } from '../core/service-registry.ts';
@@ -21,11 +21,20 @@ import type { ServiceContainer } from '../core/service-registry.ts';
21
21
  * Each worker maintains its own "warm" browser process.
22
22
  */
23
23
  export async function performSearch(
24
- queries: string[],
24
+ queries: string[],
25
25
  config?: Config,
26
26
  signal?: AbortSignal,
27
27
  onProgress?: (links: number) => void,
28
- container: ServiceContainer = getServiceContainer()
28
+ container: ServiceContainer = getServiceContainer(),
29
+ /**
30
+ * Optional sink for per-query FAILURES (timeout / worker error). Populated as
31
+ * an out-param rather than folded into the return value because the returned
32
+ * map's `query -> results` shape is what every caller consumes; a query absent
33
+ * from this map produced zero results with no failure, i.e. the search really
34
+ * did come back empty. Without this the caller cannot tell those apart and
35
+ * reports every empty query as a too-narrow query.
36
+ */
37
+ failures?: Map<string, QueryFailure>,
29
38
  ): Promise<Map<string, SearchResult[]>> {
30
39
  const startTime = Date.now();
31
40
  const resultMap = new Map<string, SearchResult[]>();
@@ -78,7 +87,12 @@ export async function performSearch(
78
87
  // (post-dequeue) deadline.
79
88
  const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
80
89
 
81
- const filteredQueries = queries.filter(q => q.trim());
90
+ // Deduplicated: resultMap and the failures map are both keyed by the query
91
+ // string, so two tasks for the same string race last-writer-wins — instance A
92
+ // delivering results, then instance B timing out, discarded A's results and
93
+ // marked the query failed. One task per distinct string closes that (and stops
94
+ // paying for the duplicate browser work).
95
+ const filteredQueries = [...new Set(queries.filter(q => q.trim()))];
82
96
  let timeoutCount = 0;
83
97
  let errorCount = 0;
84
98
  // Keep one representative worker error so total-failure surfaces the real
@@ -146,11 +160,31 @@ export async function performSearch(
146
160
 
147
161
  if (isTimeout) {
148
162
  logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms — likely starved waiting for a free browser worker under concurrent load, or a slow/blocked search provider: "${query}"`);
163
+ failures?.set(query, {
164
+ type: 'timeout',
165
+ message: `Search timed out after ${QUERY_TIMEOUT_MS}ms before returning anything. This says nothing about the query — the browser pool was saturated or the search provider was slow/blocked. Retrying the same query later is reasonable.`,
166
+ });
149
167
  } else {
150
168
  const msg = error instanceof Error ? error.message : String(error);
151
169
  if (msg !== 'Aborted') {
152
170
  if (!sampleWorkerError) sampleWorkerError = msg;
153
171
  logger.error(`[Search] Worker failed for "${query}": ${msg}`);
172
+ failures?.set(query, {
173
+ type: 'service_unavailable',
174
+ message: `The search backend failed for this query (${msg}). This is an infrastructure failure, not a signal about the query itself — do not rewrite the query in response to it.`,
175
+ });
176
+ } else if (!signal?.aborted) {
177
+ // A worker-side 'Aborted' with no caller abort (a pool drain or
178
+ // leader handover cancelling the task) is an infrastructure
179
+ // interruption. Without an entry here the query fell through to
180
+ // the empty_results default — "the query may be too narrow" —
181
+ // the exact misattribution this map exists to prevent. The
182
+ // caller-abort case stays unrecorded: the post-loop signal
183
+ // check reports the cancellation itself.
184
+ failures?.set(query, {
185
+ type: 'service_unavailable',
186
+ message: 'The search task was cancelled on the worker side (pool drain or leader handover) before completing. This says nothing about the query — do not rewrite it; retrying the same query later is reasonable.',
187
+ });
154
188
  }
155
189
  }
156
190
  resultMap.set(query, []);
@@ -162,6 +196,18 @@ export async function performSearch(
162
196
 
163
197
  await Promise.all(searchTasks);
164
198
 
199
+ // A cancelled run is not a broken one. Every query aborts with zero results,
200
+ // which walks straight into the total-failure branch below and reports
201
+ // "Browser workers may be unavailable, DuckDuckGo is unreachable, or the
202
+ // system is under extreme load" — an infrastructure outage the user never had.
203
+ // They pressed Ctrl-C. Check this BEFORE the outage heuristic, and surface the
204
+ // codebase's standard abort signature so the callers that already special-case
205
+ // it (task-execution-service, the orchestrator, the CLI classifier) see a
206
+ // cancellation rather than a failure.
207
+ if (signal?.aborted) {
208
+ throw new Error('Aborted');
209
+ }
210
+
165
211
  // Detect total failure: if every valid query returned empty, the worker pool is likely dead.
166
212
  const totalResults = Array.from(resultMap.values()).reduce((sum, r) => sum + r.length, 0);
167
213
  if (totalResults === 0 && filteredQueries.length > 0) {
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { logger } from '../logger.ts';
8
8
  import { performSearch } from './browser-search.ts';
9
- import type { QueryResultWithError } from './types.ts';
9
+ import type { QueryResultWithError, QueryFailure } from './types.ts';
10
10
  import type { Config } from '../config.ts';
11
11
  import { metrics } from '../utils/metrics.ts';
12
12
  import { getServiceContainer } from '../core/service-registry.ts';
@@ -39,7 +39,10 @@ export async function search(
39
39
  const searchStart = Date.now();
40
40
 
41
41
  try {
42
- const resultMap = await performSearch(queries, config, signal, onProgress, container);
42
+ // Per-query failures (timeout / dead worker) come back separately from the
43
+ // results so an empty result set can be attributed correctly below.
44
+ const failures = new Map<string, QueryFailure>();
45
+ const resultMap = await performSearch(queries, config, signal, onProgress, container, failures);
43
46
  const searchDuration = Date.now() - searchStart;
44
47
  metrics.observe('search_latency_ms', searchDuration);
45
48
 
@@ -56,7 +59,12 @@ export async function search(
56
59
  successfulQueries++;
57
60
  metrics.observe('search_results_per_query', qResults.length);
58
61
  } else {
59
- result.error = {
62
+ // Only call it an empty result when the search actually completed and
63
+ // returned nothing. A recorded failure (timeout, dead worker, breaker
64
+ // fast-fail) carries its own message — telling the researcher "the query
65
+ // may be too narrow" for an infrastructure fault makes it rewrite a
66
+ // perfectly good query instead of retrying.
67
+ result.error = failures.get(q) ?? {
60
68
  type: 'empty_results',
61
69
  message: 'Browser-based search returned no relevant results for this query (the query may be too narrow, or the site returned nothing usable).'
62
70
  };
@@ -73,11 +81,18 @@ export async function search(
73
81
  return results;
74
82
  } catch (error) {
75
83
  const searchDuration = Date.now() - searchStart;
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ // A cancelled run must propagate as a cancellation. Swallowing it into
86
+ // per-query `unknown` errors would let the orchestrator carry on and
87
+ // synthesize from nothing, then report a failure the user never had. Checked
88
+ // BEFORE the error metrics: a user cancelling runs is not a failing search
89
+ // backend, and counting each Esc as queries.length errors painted one.
90
+ if (signal?.aborted || message === 'Aborted') {
91
+ throw error;
92
+ }
76
93
  metrics.observe('search_latency_ms', searchDuration, { status: 'error' });
77
94
  metrics.increment('search_queries_total', queries.length, { status: 'error' });
78
95
  metrics.increment('search_errors_total', 1, { error_type: 'search_failed' });
79
-
80
- const message = error instanceof Error ? error.message : String(error);
81
96
  // Re-throw total search failure so the orchestrator can surface a clear error
82
97
  // rather than silently producing an empty synthesis with zero links.
83
98
  if (message.includes('Search completely failed')) {
@@ -12,10 +12,23 @@ export type { SearchResult, ScrapeResult };
12
12
  export interface QueryResultWithError {
13
13
  query: string;
14
14
  results: SearchResult[];
15
- error?: {
16
- type: 'empty_results' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
17
- message: string;
18
- };
15
+ error?: QueryFailure;
16
+ }
17
+
18
+ /**
19
+ * Why one query yielded nothing.
20
+ *
21
+ * The distinction matters to the *researcher agent*, which reads these messages
22
+ * and decides what to do next: `empty_results` is genuine feedback about the
23
+ * query (rewrite it, broaden it), while a timeout or a dead worker says nothing
24
+ * at all about the query and should prompt a retry instead. Collapsing the two —
25
+ * reporting every zero-result query as "the query may be too narrow" — sent the
26
+ * agent off rewriting perfectly good queries whenever the browser pool was the
27
+ * thing that failed.
28
+ */
29
+ export interface QueryFailure {
30
+ type: 'empty_results' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
31
+ message: string;
19
32
  }
20
33
 
21
34
  // Scraper types
@@ -26,6 +26,7 @@ import {
26
26
  import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
27
27
  import { isTransientError, abortableDelay } from './retry-utils.ts';
28
28
  import { safeUnref } from '../utils/safe-unref.ts';
29
+ import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
29
30
  import { getServiceContainer } from '../core/service-registry.ts';
30
31
  import type { ServiceContainer } from '../core/service-registry.ts';
31
32
  import { cacheScrapedContent } from '../utils/shared-links.ts';
@@ -144,56 +145,12 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
144
145
  }
145
146
  }
146
147
 
147
- /** Thrown by readBodyCapped when a streamed body crosses its byte cap. */
148
- class BodyTooLargeError extends Error {
149
- constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
150
- super(`Response body exceeded ${maxBytes} byte cap`);
151
- this.name = 'BodyTooLargeError';
152
- }
153
- }
154
-
155
- /**
156
- * Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
157
- * over-limit body aborts the moment the cap is crossed. The Content-Length
158
- * pre-screen above is advisory only: a chunked (or lying) response carries no
159
- * usable length header, and `response.arrayBuffer()` would otherwise buffer the
160
- * entire body — potentially hundreds of MB from a hostile server — into the
161
- * main process before any size check ran.
162
- *
163
- * A body-less response (test doubles, some mocks) falls back to arrayBuffer();
164
- * callers keep their post-read size checks as the belt for that path.
165
- */
166
- async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
167
- const body = response.body;
168
- // Also tolerate non-standard doubles that expose a body without getReader.
169
- if (!body || typeof body.getReader !== 'function') {
170
- return new Uint8Array(await response.arrayBuffer());
171
- }
172
-
173
- const reader = body.getReader();
174
- const chunks: Uint8Array[] = [];
175
- let total = 0;
176
- try {
177
- for (;;) {
178
- const { done, value } = await reader.read();
179
- if (done) break;
180
- if (value) {
181
- total += value.byteLength;
182
- if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
183
- chunks.push(value);
184
- }
185
- }
186
- } finally {
187
- // Over-cap or downstream throw: release the connection instead of leaving
188
- // the socket open (and the server still sending) until GC. No-op after a
189
- // clean done=true read.
190
- try { await reader.cancel(); } catch { /* already closed */ }
191
- }
192
- const out = new Uint8Array(total);
193
- let offset = 0;
194
- for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
195
- return out;
196
- }
148
+ // readBodyCapped / BodyTooLargeError live in utils/http-body.ts — the scraper
149
+ // pioneered the streaming cap, and the JSON API clients now share that one
150
+ // implementation instead of each carrying a near-copy. The Content-Length
151
+ // pre-screen above stays advisory only: chunked responses carry no usable length
152
+ // header and the header is freely falsifiable, so the streamed cap is the check
153
+ // that actually bounds the allocation.
197
154
 
198
155
  /**
199
156
  * Record a fetch-layer failure exactly once per URL.
@@ -21,6 +21,7 @@ import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-resea
21
21
  import { CircuitBreaker } from '../utils/circuit-breaker.ts';
22
22
  import { logger } from '../logger.ts';
23
23
  import { metrics } from '../utils/metrics.ts';
24
+ import { readTextCapped } from '../utils/http-body.ts';
24
25
  import { mintPoTokens } from './potoken.ts';
25
26
  import { watchUrl } from './video-id.ts';
26
27
 
@@ -255,7 +256,10 @@ async function fetchOne(
255
256
  void response.body?.cancel()?.catch(() => { /* best-effort */ });
256
257
  throw new Error(`HTTP ${response.status} from timedtext endpoint`);
257
258
  }
258
- const body = await response.text();
259
+ // A timedtext document is kilobytes; the cap only bounds a hostile or
260
+ // malfunctioning response, which this path is directly exposed to because
261
+ // base_url comes out of a parsed getInfo() payload.
262
+ const body = await readTextCapped(response);
259
263
  const text = parseJson3(body);
260
264
 
261
265
  // Empty-body guard: HTTP 200 + empty body == silent PoToken failure.