@lincoln504/pi-research 1.1.2 → 1.3.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.
- package/README.md +3 -3
- package/dist/cli.mjs +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +114 -12
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- package/src/core/health-check-service.ts +0 -265
|
@@ -33,6 +33,26 @@ const sessionCaptureStates = new Map<string, boolean>();
|
|
|
33
33
|
let unkeyedCaptureDepth = 0;
|
|
34
34
|
let isAnyLoggerCapturingOutput = false;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Active captures in creation order. Restoration MUST be LIFO: each capture saves
|
|
38
|
+
* the currently-installed writers/console/fs.writeSync (and the current FD-2
|
|
39
|
+
* target) as its "originals", so a frame that finishes while a later frame is
|
|
40
|
+
* still active must NOT restore — it would re-install the still-active frame's
|
|
41
|
+
* view of the world later, when that frame finally exits, re-pointing stderr,
|
|
42
|
+
* stdout, console.* and OS-level FD 2 at a dead capture's log-file plumbing for
|
|
43
|
+
* the remainder of the process. Keyed captures nest by design (a run's capture
|
|
44
|
+
* around the embedding server's), and nothing orders their exits — a cancelled
|
|
45
|
+
* run's outer capture routinely ends while the embedder-init capture is mid
|
|
46
|
+
* flight. So: an out-of-order finisher only marks its frame retired; the frame
|
|
47
|
+
* on top of the stack performs the actual restores, unwinding every already-
|
|
48
|
+
* retired frame beneath it in strict reverse order.
|
|
49
|
+
*/
|
|
50
|
+
interface CaptureFrame {
|
|
51
|
+
retired: boolean;
|
|
52
|
+
restore: () => void;
|
|
53
|
+
}
|
|
54
|
+
const captureStack: CaptureFrame[] = [];
|
|
55
|
+
|
|
36
56
|
/**
|
|
37
57
|
* Recompute the global "is anything capturing" flag from BOTH the keyed sessions and
|
|
38
58
|
* the unkeyed-capture depth, so neither scheme can clobber the other's state.
|
|
@@ -220,7 +240,9 @@ export async function captureStdio<T>(
|
|
|
220
240
|
if (shouldRedirectStderr(stat)) {
|
|
221
241
|
savedFd2 = fs.openSync('/dev/fd/2', 'a');
|
|
222
242
|
fs.closeSync(2);
|
|
223
|
-
|
|
243
|
+
// 0o600 applies only when this open CREATES the file — keeps the log
|
|
244
|
+
// owner-only in a world-traversable tmpdir if rotation removed it.
|
|
245
|
+
const newFd = fs.openSync(logFile, 'a', 0o600);
|
|
224
246
|
if (newFd === 2) {
|
|
225
247
|
fd2Redirected = true;
|
|
226
248
|
} else {
|
|
@@ -371,20 +393,41 @@ export async function captureStdio<T>(
|
|
|
371
393
|
}
|
|
372
394
|
} catch (_e) { /* ignore */ }
|
|
373
395
|
|
|
396
|
+
const frame: CaptureFrame = {
|
|
397
|
+
retired: false,
|
|
398
|
+
restore: () => {
|
|
399
|
+
process.stderr.write = originalStderrWrite;
|
|
400
|
+
process.stdout.write = originalStdoutWrite;
|
|
401
|
+
try {
|
|
402
|
+
fs.writeSync = originalFsWriteSync;
|
|
403
|
+
} catch { /* ignore */ }
|
|
404
|
+
console.log = originalConsole.log;
|
|
405
|
+
console.info = originalConsole.info;
|
|
406
|
+
console.warn = originalConsole.warn;
|
|
407
|
+
console.error = originalConsole.error;
|
|
408
|
+
console.debug = originalConsole.debug;
|
|
409
|
+
|
|
410
|
+
// Restore FD 2 to what it was when THIS frame started (for a nested frame
|
|
411
|
+
// that is the enclosing frame's log-file redirect; the enclosing frame's
|
|
412
|
+
// own restore, which always runs after this one in the unwind, then puts
|
|
413
|
+
// the true terminal FD back).
|
|
414
|
+
if (fd2Redirected && savedFd2 >= 0) {
|
|
415
|
+
try {
|
|
416
|
+
fs.closeSync(2);
|
|
417
|
+
const r = fs.openSync(`/dev/fd/${savedFd2}`, 'a');
|
|
418
|
+
if (r !== 2 && r >= 0) {
|
|
419
|
+
// Didn't recover FD 2 — leave restored FD open
|
|
420
|
+
}
|
|
421
|
+
try { fs.closeSync(savedFd2); } catch { /* ignore */ }
|
|
422
|
+
} catch { /* ignore restore errors */ }
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
captureStack.push(frame);
|
|
427
|
+
|
|
374
428
|
try {
|
|
375
429
|
return await task();
|
|
376
430
|
} finally {
|
|
377
|
-
// Restore everything
|
|
378
|
-
process.stderr.write = originalStderrWrite;
|
|
379
|
-
process.stdout.write = originalStdoutWrite;
|
|
380
|
-
try {
|
|
381
|
-
fs.writeSync = originalFsWriteSync;
|
|
382
|
-
} catch { /* ignore */ }
|
|
383
|
-
console.log = originalConsole.log;
|
|
384
|
-
console.info = originalConsole.info;
|
|
385
|
-
console.warn = originalConsole.warn;
|
|
386
|
-
console.error = originalConsole.error;
|
|
387
|
-
console.debug = originalConsole.debug;
|
|
388
431
|
if (sessionId) {
|
|
389
432
|
setSessionCapturing(sessionId, false);
|
|
390
433
|
} else {
|
|
@@ -392,16 +435,14 @@ export async function captureStdio<T>(
|
|
|
392
435
|
recomputeGlobalCapturing();
|
|
393
436
|
}
|
|
394
437
|
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
try { fs.closeSync(savedFd2); } catch { /* ignore */ }
|
|
404
|
-
} catch { /* ignore restore errors */ }
|
|
438
|
+
// LIFO unwind (see captureStack): only the top frame restores; an
|
|
439
|
+
// out-of-order finisher parks as retired and is unwound — in reverse
|
|
440
|
+
// order — when everything above it has finished. Until then its patches
|
|
441
|
+
// stay installed, which merely extends capture a little; restoring early
|
|
442
|
+
// would divert stdio permanently.
|
|
443
|
+
frame.retired = true;
|
|
444
|
+
while (captureStack.length > 0 && captureStack[captureStack.length - 1]!.retired) {
|
|
445
|
+
captureStack.pop()!.restore();
|
|
405
446
|
}
|
|
406
447
|
}
|
|
407
448
|
}
|
package/src/utils/text-utils.ts
CHANGED
|
@@ -76,7 +76,14 @@ export function truncateWithMarker(content: string, maxChars: number): string {
|
|
|
76
76
|
const makeMarker = (kept: number) => `\n\n[content truncated: showing ${kept} of ${total} chars]`;
|
|
77
77
|
// Reserve marker room using the longest possible marker (kept ≤ total, so the
|
|
78
78
|
// digit count of `kept` never exceeds that of `total`).
|
|
79
|
-
|
|
79
|
+
let keep = Math.max(0, maxChars - makeMarker(total).length);
|
|
80
|
+
// slice() counts UTF-16 code units, so the cut can land between the halves of a
|
|
81
|
+
// surrogate pair — a lone high surrogate in the body is invalid text that some
|
|
82
|
+
// providers reject (400) when it reaches a JSON payload. Backing off one unit
|
|
83
|
+
// restores well-formed text. The whitespace-boundary path below cannot split a
|
|
84
|
+
// pair: it cuts immediately before a BMP whitespace character.
|
|
85
|
+
const lastKeptUnit = content.charCodeAt(keep - 1);
|
|
86
|
+
if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
|
|
80
87
|
let body = content.slice(0, keep);
|
|
81
88
|
// Prefer a clean boundary near the cut point.
|
|
82
89
|
const boundary = Math.max(body.lastIndexOf('\n'), body.lastIndexOf(' '));
|
|
@@ -125,13 +125,20 @@ export class ToolUsageTracker {
|
|
|
125
125
|
|
|
126
126
|
const toolLimit = this.limits[toolName as keyof ToolLimits];
|
|
127
127
|
const catLimit = usage.limit;
|
|
128
|
+
// Decide by which limit actually tripped, computed exactly as recordCall
|
|
129
|
+
// computes it: a first-ever `search` blocked by the shared gathering cap must
|
|
130
|
+
// NOT claim "you have already used your one search call" — the agent would
|
|
131
|
+
// wrongly believe it had searched. The per-tool wording is only correct when
|
|
132
|
+
// the per-tool counter itself is at its limit.
|
|
133
|
+
const toolCount = usage.toolCounts.get(toolName) || 0;
|
|
134
|
+
const toolLimitTripped = toolLimit !== undefined && toolCount >= toolLimit;
|
|
128
135
|
|
|
129
|
-
if (toolName === 'search' && toolLimit === 1) {
|
|
136
|
+
if (toolName === 'search' && toolLimit === 1 && toolLimitTripped) {
|
|
130
137
|
return `SEARCH LIMIT REACHED: You have already used your one search call. ` +
|
|
131
138
|
`Proceed to scraping: use the scrape tool for full deep-dives into your best search results.`;
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
if (toolName === 'youtube_transcript' && toolLimit === 1) {
|
|
141
|
+
if (toolName === 'youtube_transcript' && toolLimit === 1 && toolLimitTripped) {
|
|
135
142
|
return `YOUTUBE TRANSCRIPT LIMIT REACHED: You have already used your one youtube_transcript call. ` +
|
|
136
143
|
`Continue with your other gathering tools (search, scrape) and proceed to synthesis.`;
|
|
137
144
|
}
|
|
@@ -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,28 @@ 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 / cross-query
|
|
31
|
+
* dedup). Populated as an out-param rather than folded into the return value
|
|
32
|
+
* because the returned map's `query -> results` shape is what every caller
|
|
33
|
+
* consumes; a query absent from this map produced zero results with no
|
|
34
|
+
* failure, i.e. the search really did come back empty. Without this the
|
|
35
|
+
* caller cannot tell those apart and reports every empty query as a
|
|
36
|
+
* too-narrow query.
|
|
37
|
+
*/
|
|
38
|
+
failures?: Map<string, QueryFailure>,
|
|
39
|
+
/**
|
|
40
|
+
* Session/research id keying the per-session circuit breaker inside
|
|
41
|
+
* runWorkerSearch — mirroring the scrape path, which threads it via
|
|
42
|
+
* BrowserTask.sessionId. Callers without a session (undefined) share the
|
|
43
|
+
* global breaker, as before.
|
|
44
|
+
*/
|
|
45
|
+
sessionId?: string,
|
|
29
46
|
): Promise<Map<string, SearchResult[]>> {
|
|
30
47
|
const startTime = Date.now();
|
|
31
48
|
const resultMap = new Map<string, SearchResult[]>();
|
|
@@ -78,7 +95,12 @@ export async function performSearch(
|
|
|
78
95
|
// (post-dequeue) deadline.
|
|
79
96
|
const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
|
|
80
97
|
|
|
81
|
-
|
|
98
|
+
// Deduplicated: resultMap and the failures map are both keyed by the query
|
|
99
|
+
// string, so two tasks for the same string race last-writer-wins — instance A
|
|
100
|
+
// delivering results, then instance B timing out, discarded A's results and
|
|
101
|
+
// marked the query failed. One task per distinct string closes that (and stops
|
|
102
|
+
// paying for the duplicate browser work).
|
|
103
|
+
const filteredQueries = [...new Set(queries.filter(q => q.trim()))];
|
|
82
104
|
let timeoutCount = 0;
|
|
83
105
|
let errorCount = 0;
|
|
84
106
|
// Keep one representative worker error so total-failure surfaces the real
|
|
@@ -102,7 +124,7 @@ export async function performSearch(
|
|
|
102
124
|
? AbortSignal.any([signal, timeoutController.signal])
|
|
103
125
|
: timeoutController.signal;
|
|
104
126
|
|
|
105
|
-
const results = await runWorkerSearch(query, config, querySignal, 1,
|
|
127
|
+
const results = await runWorkerSearch(query, config, querySignal, 1, sessionId, container);
|
|
106
128
|
const queryDuration = Date.now() - queryStartTime;
|
|
107
129
|
metrics.observe('browser_search_query_duration_ms', queryDuration);
|
|
108
130
|
|
|
@@ -128,6 +150,17 @@ export async function performSearch(
|
|
|
128
150
|
uniqueResults.push(r);
|
|
129
151
|
}
|
|
130
152
|
}
|
|
153
|
+
if (uniqueResults.length === 0) {
|
|
154
|
+
// Every raw result deduplicated away against URLs earlier
|
|
155
|
+
// queries in this batch already returned. Without an entry
|
|
156
|
+
// here the query fell through to the empty_results default —
|
|
157
|
+
// "the query may be too narrow" — sending the researcher off
|
|
158
|
+
// rewriting a query that actually worked.
|
|
159
|
+
failures?.set(query, {
|
|
160
|
+
type: 'all_duplicates',
|
|
161
|
+
message: `All ${results.length} results for this query duplicated URLs already returned by earlier queries in this batch. The query succeeded but surfaced nothing new — do not rewrite it; try a different angle only if you still need additional sources.`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
131
164
|
resultMap.set(query, uniqueResults);
|
|
132
165
|
} else {
|
|
133
166
|
metrics.increment('browser_search_queries_total', 1, { status: 'no_results' });
|
|
@@ -146,11 +179,31 @@ export async function performSearch(
|
|
|
146
179
|
|
|
147
180
|
if (isTimeout) {
|
|
148
181
|
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}"`);
|
|
182
|
+
failures?.set(query, {
|
|
183
|
+
type: 'timeout',
|
|
184
|
+
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.`,
|
|
185
|
+
});
|
|
149
186
|
} else {
|
|
150
187
|
const msg = error instanceof Error ? error.message : String(error);
|
|
151
188
|
if (msg !== 'Aborted') {
|
|
152
189
|
if (!sampleWorkerError) sampleWorkerError = msg;
|
|
153
190
|
logger.error(`[Search] Worker failed for "${query}": ${msg}`);
|
|
191
|
+
failures?.set(query, {
|
|
192
|
+
type: 'service_unavailable',
|
|
193
|
+
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.`,
|
|
194
|
+
});
|
|
195
|
+
} else if (!signal?.aborted) {
|
|
196
|
+
// A worker-side 'Aborted' with no caller abort (a pool drain or
|
|
197
|
+
// leader handover cancelling the task) is an infrastructure
|
|
198
|
+
// interruption. Without an entry here the query fell through to
|
|
199
|
+
// the empty_results default — "the query may be too narrow" —
|
|
200
|
+
// the exact misattribution this map exists to prevent. The
|
|
201
|
+
// caller-abort case stays unrecorded: the post-loop signal
|
|
202
|
+
// check reports the cancellation itself.
|
|
203
|
+
failures?.set(query, {
|
|
204
|
+
type: 'service_unavailable',
|
|
205
|
+
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.',
|
|
206
|
+
});
|
|
154
207
|
}
|
|
155
208
|
}
|
|
156
209
|
resultMap.set(query, []);
|
|
@@ -162,6 +215,18 @@ export async function performSearch(
|
|
|
162
215
|
|
|
163
216
|
await Promise.all(searchTasks);
|
|
164
217
|
|
|
218
|
+
// A cancelled run is not a broken one. Every query aborts with zero results,
|
|
219
|
+
// which walks straight into the total-failure branch below and reports
|
|
220
|
+
// "Browser workers may be unavailable, DuckDuckGo is unreachable, or the
|
|
221
|
+
// system is under extreme load" — an infrastructure outage the user never had.
|
|
222
|
+
// They pressed Ctrl-C. Check this BEFORE the outage heuristic, and surface the
|
|
223
|
+
// codebase's standard abort signature so the callers that already special-case
|
|
224
|
+
// it (task-execution-service, the orchestrator, the CLI classifier) see a
|
|
225
|
+
// cancellation rather than a failure.
|
|
226
|
+
if (signal?.aborted) {
|
|
227
|
+
throw new Error('Aborted');
|
|
228
|
+
}
|
|
229
|
+
|
|
165
230
|
// Detect total failure: if every valid query returned empty, the worker pool is likely dead.
|
|
166
231
|
const totalResults = Array.from(resultMap.values()).reduce((sum, r) => sum + r.length, 0);
|
|
167
232
|
if (totalResults === 0 && filteredQueries.length > 0) {
|
|
@@ -343,6 +343,11 @@ function isPrivateIpv6(ip: string): boolean {
|
|
|
343
343
|
// Link-local (fe80::/10)
|
|
344
344
|
if (normalized.startsWith('fe8') || normalized.startsWith('fe9') ||
|
|
345
345
|
normalized.startsWith('fea') || normalized.startsWith('feb')) return true;
|
|
346
|
+
// Deprecated site-local (fec0::/10, RFC 3879, deprecated 2004). No OS
|
|
347
|
+
// auto-routes to it today, but it's still squarely "private/reserved"
|
|
348
|
+
// address space — the sibling range immediately above this one.
|
|
349
|
+
if (normalized.startsWith('fec') || normalized.startsWith('fed') ||
|
|
350
|
+
normalized.startsWith('fee') || normalized.startsWith('fef')) return true;
|
|
346
351
|
// IPv4-mapped ::ffff:x.x.x.x — check the embedded IPv4 part. The mapped tail
|
|
347
352
|
// can arrive dotted-decimal (::ffff:127.0.0.1) OR hex (::ffff:7f00:1) — Node's
|
|
348
353
|
// URL parser normalizes literals to the hex form — and the prefix can be
|
|
@@ -357,6 +362,14 @@ function isPrivateIpv6(ip: string): boolean {
|
|
|
357
362
|
// legitimate scrape target, so block the whole ranges rather than decode them.
|
|
358
363
|
if (normalized.startsWith('2002:')) return true;
|
|
359
364
|
if (/^2001:0{0,4}:/.test(normalized) || normalized.startsWith('2001::')) return true;
|
|
365
|
+
// NAT64 well-known prefix (64:ff9b::/96, RFC 6052) also tunnels an IPv4
|
|
366
|
+
// address, and unlike the transitional ranges above it's actively deployed
|
|
367
|
+
// today (464XLAT on IPv6-only mobile/enterprise networks). A host whose
|
|
368
|
+
// outbound path traverses a NAT64 gateway gets this transparently
|
|
369
|
+
// translated to a direct connection to the embedded IPv4 (e.g. the cloud
|
|
370
|
+
// metadata service) — block the whole prefix regardless of what it wraps,
|
|
371
|
+
// same rationale as the 6to4/Teredo ranges just above.
|
|
372
|
+
if (normalized.startsWith('64:ff9b:')) return true;
|
|
360
373
|
// All-zeros (unspecified address)
|
|
361
374
|
if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
|
|
362
375
|
return false;
|
|
@@ -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';
|
|
@@ -23,6 +23,9 @@ import type { ServiceContainer } from '../core/service-registry.ts';
|
|
|
23
23
|
* @param config - Optional configuration override
|
|
24
24
|
* @param signal - Optional AbortSignal
|
|
25
25
|
* @param onProgress - Optional callback with cumulative link count across completed queries
|
|
26
|
+
* @param container - Service container for scheduler resolution
|
|
27
|
+
* @param sessionId - Research/session id keying the per-session circuit breaker
|
|
28
|
+
* (mirrors the scrape path); undefined callers share the global breaker
|
|
26
29
|
* @returns Promise<QueryResultWithError[]> - Array of search results with error information
|
|
27
30
|
*/
|
|
28
31
|
export async function search(
|
|
@@ -30,7 +33,8 @@ export async function search(
|
|
|
30
33
|
config?: Config,
|
|
31
34
|
signal?: AbortSignal,
|
|
32
35
|
onProgress?: (links: number) => void,
|
|
33
|
-
container: ServiceContainer = getServiceContainer()
|
|
36
|
+
container: ServiceContainer = getServiceContainer(),
|
|
37
|
+
sessionId?: string
|
|
34
38
|
): Promise<QueryResultWithError[]> {
|
|
35
39
|
if (queries.length === 0) return [];
|
|
36
40
|
|
|
@@ -39,7 +43,10 @@ export async function search(
|
|
|
39
43
|
const searchStart = Date.now();
|
|
40
44
|
|
|
41
45
|
try {
|
|
42
|
-
|
|
46
|
+
// Per-query failures (timeout / dead worker) come back separately from the
|
|
47
|
+
// results so an empty result set can be attributed correctly below.
|
|
48
|
+
const failures = new Map<string, QueryFailure>();
|
|
49
|
+
const resultMap = await performSearch(queries, config, signal, onProgress, container, failures, sessionId);
|
|
43
50
|
const searchDuration = Date.now() - searchStart;
|
|
44
51
|
metrics.observe('search_latency_ms', searchDuration);
|
|
45
52
|
|
|
@@ -56,7 +63,12 @@ export async function search(
|
|
|
56
63
|
successfulQueries++;
|
|
57
64
|
metrics.observe('search_results_per_query', qResults.length);
|
|
58
65
|
} else {
|
|
59
|
-
result
|
|
66
|
+
// Only call it an empty result when the search actually completed and
|
|
67
|
+
// returned nothing. A recorded failure (timeout, dead worker, breaker
|
|
68
|
+
// fast-fail) carries its own message — telling the researcher "the query
|
|
69
|
+
// may be too narrow" for an infrastructure fault makes it rewrite a
|
|
70
|
+
// perfectly good query instead of retrying.
|
|
71
|
+
result.error = failures.get(q) ?? {
|
|
60
72
|
type: 'empty_results',
|
|
61
73
|
message: 'Browser-based search returned no relevant results for this query (the query may be too narrow, or the site returned nothing usable).'
|
|
62
74
|
};
|
|
@@ -73,11 +85,18 @@ export async function search(
|
|
|
73
85
|
return results;
|
|
74
86
|
} catch (error) {
|
|
75
87
|
const searchDuration = Date.now() - searchStart;
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
// A cancelled run must propagate as a cancellation. Swallowing it into
|
|
90
|
+
// per-query `unknown` errors would let the orchestrator carry on and
|
|
91
|
+
// synthesize from nothing, then report a failure the user never had. Checked
|
|
92
|
+
// BEFORE the error metrics: a user cancelling runs is not a failing search
|
|
93
|
+
// backend, and counting each Esc as queries.length errors painted one.
|
|
94
|
+
if (signal?.aborted || message === 'Aborted') {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
76
97
|
metrics.observe('search_latency_ms', searchDuration, { status: 'error' });
|
|
77
98
|
metrics.increment('search_queries_total', queries.length, { status: 'error' });
|
|
78
99
|
metrics.increment('search_errors_total', 1, { error_type: 'search_failed' });
|
|
79
|
-
|
|
80
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
81
100
|
// Re-throw total search failure so the orchestrator can surface a clear error
|
|
82
101
|
// rather than silently producing an empty synthesis with zero links.
|
|
83
102
|
if (message.includes('Search completely failed')) {
|
|
@@ -12,10 +12,25 @@ export type { SearchResult, ScrapeResult };
|
|
|
12
12
|
export interface QueryResultWithError {
|
|
13
13
|
query: string;
|
|
14
14
|
results: SearchResult[];
|
|
15
|
-
error?:
|
|
16
|
-
|
|
17
|
-
|
|
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. `all_duplicates` is the third case: the query SUCCEEDED,
|
|
28
|
+
* but every result deduplicated away against URLs earlier queries in the batch
|
|
29
|
+
* already returned — coverage feedback, not query-quality feedback.
|
|
30
|
+
*/
|
|
31
|
+
export interface QueryFailure {
|
|
32
|
+
type: 'empty_results' | 'all_duplicates' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
|
|
33
|
+
message: string;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
// 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';
|
|
@@ -79,7 +80,11 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
|
79
80
|
const sizeMB = Math.round(bytes.length / 1024 / 1024);
|
|
80
81
|
logger.warn(`[Scrapers] PDF too large (${sizeMB}MB, max 100MB), skipping extraction`);
|
|
81
82
|
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
|
|
82
|
-
|
|
83
|
+
// THROW, never an in-band "*Error: ...*" string: a returned banner flowed
|
|
84
|
+
// into validateContent as if it were page content, and a verbose one
|
|
85
|
+
// (≥50 words / ≥200 chars) cleared the stub gate — the failure banner was
|
|
86
|
+
// then cached and cited as a successful scrape.
|
|
87
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
const pdfExtractionStart = Date.now();
|
|
@@ -113,7 +118,9 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
|
113
118
|
contentType: 'pdf',
|
|
114
119
|
errorType: 'extraction_failed',
|
|
115
120
|
});
|
|
116
|
-
|
|
121
|
+
// See the size branch above: an extraction failure must FAIL the scrape,
|
|
122
|
+
// not travel in-band as pseudo-content.
|
|
123
|
+
throw new Error(`Could not extract content from PDF (${msg})`, { cause: e });
|
|
117
124
|
}
|
|
118
125
|
}
|
|
119
126
|
|
|
@@ -144,56 +151,12 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
|
144
151
|
}
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
}
|
|
154
|
+
// readBodyCapped / BodyTooLargeError live in utils/http-body.ts — the scraper
|
|
155
|
+
// pioneered the streaming cap, and the JSON API clients now share that one
|
|
156
|
+
// implementation instead of each carrying a near-copy. The Content-Length
|
|
157
|
+
// pre-screen above stays advisory only: chunked responses carry no usable length
|
|
158
|
+
// header and the header is freely falsifiable, so the streamed cap is the check
|
|
159
|
+
// that actually bounds the allocation.
|
|
197
160
|
|
|
198
161
|
/**
|
|
199
162
|
* Record a fetch-layer failure exactly once per URL.
|
|
@@ -397,8 +360,30 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
|
|
|
397
360
|
const result = await runBrowserTask<any>({ url: _url, sessionId }, 'scrape', config, signal, 1, container);
|
|
398
361
|
const browserDuration = Date.now() - browserStart;
|
|
399
362
|
|
|
400
|
-
|
|
401
|
-
|
|
363
|
+
// PDF bytes travel base64-encoded (bufferB64): the worker result crosses the
|
|
364
|
+
// cluster-IPC JSON hop (and, for followers, a second JSON hop through the
|
|
365
|
+
// leader's browser-server), which turns a raw Buffer into
|
|
366
|
+
// {type:'Buffer',data:[...]} — new Uint8Array(that) is ZERO bytes, so the
|
|
367
|
+
// extraction ran on nothing and its error string was cached as a "success".
|
|
368
|
+
// The JSON-roundtripped legacy shape is still decoded so a mixed-version
|
|
369
|
+
// leader/follower pair keeps working.
|
|
370
|
+
const legacyBuffer = result.buffer;
|
|
371
|
+
const pdfBytes: Buffer | null =
|
|
372
|
+
typeof result.bufferB64 === 'string'
|
|
373
|
+
? Buffer.from(result.bufferB64, 'base64')
|
|
374
|
+
: Array.isArray(legacyBuffer?.data)
|
|
375
|
+
? Buffer.from(legacyBuffer.data)
|
|
376
|
+
: legacyBuffer instanceof Uint8Array
|
|
377
|
+
? Buffer.from(legacyBuffer)
|
|
378
|
+
: null;
|
|
379
|
+
|
|
380
|
+
if (pdfBytes !== null) {
|
|
381
|
+
const markdown = await extractPdfToMarkdown(pdfBytes);
|
|
382
|
+
// Mirror the fetch layer's PDF branch: extraction failures THROW from
|
|
383
|
+
// extractPdfToMarkdown, and validateContent still fails a nominally
|
|
384
|
+
// successful extraction that produced no real content — neither may be
|
|
385
|
+
// recorded (and cached) as a success.
|
|
386
|
+
validateContent('', markdown, _url);
|
|
402
387
|
metrics.increment('scrape_operations_total', 1, { layer: 'playwright', content_type: 'pdf', status: 'success' });
|
|
403
388
|
metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', content_type: 'pdf', status: 'success' });
|
|
404
389
|
return { source: 'playwright', layer: 'playwright+camoufox', markdown };
|
|
@@ -429,6 +414,10 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
|
|
|
429
414
|
metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', content_type: 'html', status: 'success' });
|
|
430
415
|
return { source: 'playwright', layer: 'playwright+camoufox', markdown };
|
|
431
416
|
} catch (error) {
|
|
417
|
+
// A caller cancellation is not a scrape failure: no layer error metrics and
|
|
418
|
+
// no tracker entry — the search path applies the same 'Aborted' special case
|
|
419
|
+
// end-to-end (see browser-search.ts). scrapeSingle classifies it above us.
|
|
420
|
+
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw error;
|
|
432
421
|
const browserDuration = Date.now() - browserStart;
|
|
433
422
|
metrics.increment('scrape_operations_total', 1, { layer: 'playwright', status: 'error' });
|
|
434
423
|
metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', status: 'error' });
|
|
@@ -444,6 +433,15 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
|
|
|
444
433
|
}
|
|
445
434
|
|
|
446
435
|
export async function scrapeSingle(url: string, signal?: AbortSignal, config?: Config, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<ScrapeResult> {
|
|
436
|
+
// Cancellation short-circuit: after a user abort each remaining URL would
|
|
437
|
+
// otherwise still pay for SSRF DNS validation, a doomed fetch, and a browser
|
|
438
|
+
// fallback — all logged/counted as failures the user never had. 'Aborted' is
|
|
439
|
+
// the codebase's recognized cancellation signature (see browser-search.ts);
|
|
440
|
+
// deliberately no total_failure tally, no tracker entry, no ERROR log.
|
|
441
|
+
if (signal?.aborted) {
|
|
442
|
+
return { url, success: false, error: 'Aborted', markdown: '' };
|
|
443
|
+
}
|
|
444
|
+
|
|
447
445
|
// Guard against an array accidentally stringified and passed as a single url (JSON arrays start
|
|
448
446
|
// with '['). Do NOT reject brackets ANYWHERE: valid URLs legitimately contain them in query
|
|
449
447
|
// strings (e.g. ?ids[]=1) and IPv6 literals (http://[::1]/) — the old includes('[') check dropped
|
|
@@ -520,6 +518,13 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
520
518
|
|
|
521
519
|
return { ...res, url, success: true };
|
|
522
520
|
} catch (e1) {
|
|
521
|
+
// Cancelled mid-fetch: not a fetch-layer failure, and no reason to pay for
|
|
522
|
+
// the browser fallback — classify as the recognized cancellation (no ERROR
|
|
523
|
+
// log, no tracker entry, no total_failure tally).
|
|
524
|
+
if (signal?.aborted || (e1 instanceof Error && e1.message === 'Aborted')) {
|
|
525
|
+
logger.debug(`[Scrapers] Scrape cancelled for ${url}`);
|
|
526
|
+
return { url, success: false, error: 'Aborted', markdown: '' };
|
|
527
|
+
}
|
|
523
528
|
const fetchDuration = Date.now() - start;
|
|
524
529
|
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
|
|
525
530
|
// Tracked here rather than inside scrapeWithFetch, which is retried above —
|
|
@@ -542,6 +547,14 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
542
547
|
|
|
543
548
|
return { ...res, url, success: true };
|
|
544
549
|
} catch (e2) {
|
|
550
|
+
// See the fetch-layer catch above: a cancellation surfacing from the
|
|
551
|
+
// browser layer (runBrowserTask throws exactly 'Aborted' on a caller
|
|
552
|
+
// abort) is not a failure — it must not reach the ERROR log, the error
|
|
553
|
+
// tracker, or the total_failure tally.
|
|
554
|
+
if (signal?.aborted || (e2 instanceof Error && e2.message === 'Aborted')) {
|
|
555
|
+
logger.debug(`[Scrapers] Browser scrape cancelled for ${url}`);
|
|
556
|
+
return { url, success: false, error: 'Aborted', markdown: '' };
|
|
557
|
+
}
|
|
545
558
|
const totalDuration = Date.now() - start;
|
|
546
559
|
// A blocked/stubbed/HTTP-errored/slow/pool-draining URL is an expected
|
|
547
560
|
// scraping outcome, already surfaced to the user as "not scraped" (the
|
|
@@ -573,6 +586,11 @@ export async function scrape(urls: string[], maxConcurrency = 5, signal?: AbortS
|
|
|
573
586
|
|
|
574
587
|
const results: ScrapeResult[] = [];
|
|
575
588
|
for (let i = 0; i < urls.length; i += maxConcurrency) {
|
|
589
|
+
// Stop dispatching once the caller has cancelled; URLs already in flight
|
|
590
|
+
// settle via scrapeSingle's own cancellation classification. Without this,
|
|
591
|
+
// every remaining batch still ran its full fetch + browser-fallback cycle
|
|
592
|
+
// after a user cancel.
|
|
593
|
+
if (signal?.aborted) break;
|
|
576
594
|
const batch = urls.slice(i, i + maxConcurrency);
|
|
577
595
|
const batchRes = await Promise.all(
|
|
578
596
|
batch.map(async 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
|
-
|
|
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.
|