@lincoln504/pi-research 1.6.5 → 1.6.6
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 +13 -9
- package/agent-skill/pi-research/README.md +5 -4
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +587 -149
- package/dist/pdf-extract-worker.mjs +1289 -0
- package/dist/prompts/research-tool-usage.md +2 -1
- package/dist/prompts/researcher.md +1 -1
- package/dist/prompts/system-coordinator.md +2 -2
- package/dist/prompts/system-lead-router.md +1 -1
- package/docs/AGENT-SKILL.md +6 -6
- package/docs/ARCHITECTURE.md +24 -22
- package/docs/CHANGELOG.md +54 -0
- package/docs/CONFIGURATION.md +3 -2
- package/docs/SDK.md +7 -12
- package/package.json +7 -4
- package/scripts/build.cjs +35 -0
- package/scripts/setup.cjs +12 -10
- package/src/cleanup/research-cleanup.ts +11 -5
- package/src/cli.ts +7 -5
- package/src/config.ts +9 -3
- package/src/core/interfaces/knowledge-interfaces.ts +8 -0
- package/src/core/interfaces/orchestration-interfaces.ts +1 -1
- package/src/core/interfaces/research-plan-types.ts +6 -0
- package/src/core/llm/agentic-repair.ts +40 -3
- package/src/core/llm/llm-utils.ts +93 -5
- package/src/core/llm/model-registry-factory.ts +2 -15
- package/src/core/pi-ai-skew.ts +351 -0
- package/src/core/pi-version.ts +1 -1
- package/src/core/planning-service.ts +110 -15
- package/src/core/planning-utils.ts +3 -0
- package/src/core/service-registry.ts +8 -6
- package/src/index.ts +14 -0
- package/src/infrastructure/browser/config.ts +6 -7
- package/src/infrastructure/browser/ensure-browser.ts +14 -7
- package/src/infrastructure/browser/thread-worker-browser.ts +18 -19
- package/src/knowledge/chunker.ts +22 -4
- package/src/knowledge/embedder-init.ts +41 -1
- package/src/knowledge/store.ts +68 -1
- package/src/observers/research-observer-impl.ts +4 -1
- package/src/orchestration/deep-research-orchestrator.ts +3 -3
- package/src/orchestration/headless-observer.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +17 -3
- package/src/orchestration/research-orchestration-service.ts +27 -4
- package/src/orchestration/session-state.ts +36 -7
- package/src/prompts/research-tool-usage.md +2 -1
- package/src/prompts/researcher.md +1 -1
- package/src/prompts/system-coordinator.md +2 -2
- package/src/prompts/system-lead-router.md +1 -1
- package/src/sdk.ts +12 -4
- package/src/tools/research-knowledge-search.ts +23 -7
- package/src/tools/research-tool-definition.ts +1 -1
- package/src/tools/scrape.ts +12 -9
- package/src/tools/search.ts +12 -6
- package/src/tools/security.ts +6 -1
- package/src/tools/stackexchange.ts +6 -1
- package/src/utils/cancellation.ts +34 -1
- package/src/utils/llm-usage.ts +8 -0
- package/src/utils/log-utils.ts +1 -1
- package/src/web-research/browser-search.ts +6 -5
- package/src/web-research/pdf-extract-worker.mjs +1289 -0
- package/src/web-research/pdf-extract-worker.ts +42 -0
- package/src/web-research/pdf-extraction.ts +392 -0
- package/src/web-research/web-scraper.ts +3 -111
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF extraction worker entry — bundled by scripts/build.cjs into
|
|
3
|
+
* pdf-extract-worker.mjs and spawned by pdf-extraction.ts.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately dumb: receive {id, bytes}, run the SAME parsePdfCore the
|
|
6
|
+
* in-process path runs (one code path, no behavioral drift), post the
|
|
7
|
+
* classified result back. All metrics/logging stay parent-side so both paths
|
|
8
|
+
* emit identically.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parentPort } from 'node:worker_threads';
|
|
12
|
+
import { parsePdfCore, PdfExtractError } from './pdf-extraction.ts';
|
|
13
|
+
|
|
14
|
+
interface WorkerRequest {
|
|
15
|
+
id: number;
|
|
16
|
+
bytes: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (parentPort !== null) {
|
|
20
|
+
const port = parentPort;
|
|
21
|
+
port.on('message', (msg: WorkerRequest) => {
|
|
22
|
+
void (async () => {
|
|
23
|
+
try {
|
|
24
|
+
const result = await parsePdfCore(msg.bytes);
|
|
25
|
+
port.postMessage({ id: msg.id, ok: true, markdown: result.markdown, pageCount: result.pageCount });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof PdfExtractError) {
|
|
28
|
+
// Worker-side structured clone drops Error causes — the kind and the
|
|
29
|
+
// message (the load-bearing classification strings) always survive.
|
|
30
|
+
port.postMessage({ id: msg.id, ok: false, kind: e.kind, message: e.message });
|
|
31
|
+
} else {
|
|
32
|
+
port.postMessage({
|
|
33
|
+
id: msg.id,
|
|
34
|
+
ok: false,
|
|
35
|
+
kind: 'extraction_failed',
|
|
36
|
+
message: e instanceof Error ? e.message : String(e),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF extraction — off the main thread when possible, identical when not.
|
|
3
|
+
*
|
|
4
|
+
* Relocated verbatim from web-scraper.ts (which previously ran the whole
|
|
5
|
+
* parse inline) to fix its own recorded TODO(hardening, medium): the
|
|
6
|
+
* pdf-oxide-wasm parse is irreducibly synchronous (wasm-bindgen, no async
|
|
7
|
+
* API), so a large/complex PDF blocks the MAIN process's event loop for
|
|
8
|
+
* seconds — starving every concurrent scrape, the TUI render loop, and
|
|
9
|
+
* timers.
|
|
10
|
+
*
|
|
11
|
+
* The parse now runs in a dedicated worker_threads Worker (NOT the browser
|
|
12
|
+
* pool: cluster IPC would JSON-serialize the bytes — the exact hazard behind
|
|
13
|
+
* the bufferB64 round-trip — a pool worker eagerly boots Firefox before any
|
|
14
|
+
* task, and poolifier never settles in-flight execute() promises on worker
|
|
15
|
+
* death). postMessage transfers the byte buffer zero-copy after a defensive
|
|
16
|
+
* slice(); a wedged worker is terminate()d on timeout — the only way to
|
|
17
|
+
* interrupt a sync wasm call — and the parse retried once in-process.
|
|
18
|
+
*
|
|
19
|
+
* Behavioral contract (byte-identical to the web-scraper original):
|
|
20
|
+
* - error STRINGS and their classification kinds are load-bearing:
|
|
21
|
+
* isBenignScrapeFailure distinguishes 'Could not extract content from
|
|
22
|
+
* PDF (…)' (benign, per-URL) from 'PDF extraction unavailable
|
|
23
|
+
* (pdf-oxide-wasm failed to load: …)' (infrastructure, NOT benign);
|
|
24
|
+
* - metrics: scrape_pdf_conversion_ms / scrape_pdf_conversions_total{status,
|
|
25
|
+
* pages} on success, scrape_pdf_errors_total{error_type} per failure kind;
|
|
26
|
+
* - log levels: warn (size), error (native module), debug (extraction);
|
|
27
|
+
* - WasmPdfDocument.free() runs on every exit path (native memory is not
|
|
28
|
+
* GC-reclaimed).
|
|
29
|
+
*
|
|
30
|
+
* The worker bundle (pdf-extract-worker.mjs) is built by scripts/build.cjs
|
|
31
|
+
* next to this file; when absent (unbuilt dev tree, exotic install) the
|
|
32
|
+
* in-process path runs unchanged — bounded by MAX_PDF_SIZE and the scrape
|
|
33
|
+
* task's deadline, exactly like before. PI_RESEARCH_PDF_WORKER=off disables
|
|
34
|
+
* the worker path outright (also the vitest default, so the module-level
|
|
35
|
+
* vi.mock('pdf-oxide-wasm') seams keep reaching the parse).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { existsSync } from 'node:fs';
|
|
39
|
+
import { dirname, join } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { Worker } from 'node:worker_threads';
|
|
42
|
+
|
|
43
|
+
// ESM-safe module directory. Bare __dirname is undefined in the bundled
|
|
44
|
+
// dist/cli.mjs (esbuild format: 'esm') and in jiti-loaded ESM — before this
|
|
45
|
+
// fix the packaged CLI threw ReferenceError on the first PDF and every
|
|
46
|
+
// extraction failed. Same pattern as worker-pool-manager.ts.
|
|
47
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
import { logger } from '../logger.ts';
|
|
49
|
+
import { metrics } from '../utils/metrics.ts';
|
|
50
|
+
import { errorTracker } from '../utils/error-tracker.ts';
|
|
51
|
+
import { MAX_PDF_SIZE } from './scraper-types.ts';
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Classified errors — the classification contract survives the worker hop.
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
export type PdfExtractErrorKind = 'size_exceeded' | 'native_module_unavailable' | 'extraction_failed';
|
|
58
|
+
|
|
59
|
+
export class PdfExtractError extends Error {
|
|
60
|
+
readonly kind: PdfExtractErrorKind;
|
|
61
|
+
constructor(kind: PdfExtractErrorKind, message: string, options?: { cause?: unknown }) {
|
|
62
|
+
super(message, options);
|
|
63
|
+
this.name = 'PdfExtractError';
|
|
64
|
+
this.kind = kind;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// In-process parse core (also the worker's payload — one code path).
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
interface PdfModule {
|
|
73
|
+
WasmPdfDocument: new (bytes: Uint8Array) => {
|
|
74
|
+
pageCount(): number;
|
|
75
|
+
toMarkdownAll(): string;
|
|
76
|
+
toMarkdown(page: number): string;
|
|
77
|
+
free(): void;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let pdfModulePromise: Promise<PdfModule> | null = null;
|
|
82
|
+
|
|
83
|
+
/** Cached dynamic import — vitest's vi.mock('pdf-oxide-wasm') seam keeps working. */
|
|
84
|
+
function loadPdfModule(): Promise<PdfModule> {
|
|
85
|
+
if (pdfModulePromise === null) {
|
|
86
|
+
pdfModulePromise = import('pdf-oxide-wasm') as Promise<PdfModule>;
|
|
87
|
+
}
|
|
88
|
+
return pdfModulePromise;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface PdfParseResult {
|
|
92
|
+
markdown: string;
|
|
93
|
+
pageCount: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The parse itself: size guard, module load (caught SEPARATELY from parse —
|
|
98
|
+
* an infrastructure fault is not a per-PDF outcome), doc.free() on every
|
|
99
|
+
* exit, toMarkdownAll with per-page fallback. Throws PdfExtractError with
|
|
100
|
+
* the historical message strings; no metrics/logging here so the caller can
|
|
101
|
+
* emit them once regardless of which context did the work.
|
|
102
|
+
*/
|
|
103
|
+
export async function parsePdfCore(bytes: Uint8Array): Promise<PdfParseResult> {
|
|
104
|
+
if (bytes.length > MAX_PDF_SIZE) {
|
|
105
|
+
const sizeMB = Math.round(bytes.length / 1024 / 1024);
|
|
106
|
+
// THROW, never an in-band "*Error: ...*" string: a returned banner flowed
|
|
107
|
+
// into validateContent as if it were page content (see git history).
|
|
108
|
+
throw new PdfExtractError('size_exceeded', `PDF too large (${sizeMB}MB, max 100MB)`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let WasmPdfDocument: PdfModule['WasmPdfDocument'];
|
|
112
|
+
try {
|
|
113
|
+
({ WasmPdfDocument } = await loadPdfModule());
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
116
|
+
// Deliberately does NOT start with 'Could not extract content from PDF' —
|
|
117
|
+
// isBenignScrapeFailure must NOT classify this as routine.
|
|
118
|
+
throw new PdfExtractError(
|
|
119
|
+
'native_module_unavailable',
|
|
120
|
+
`PDF extraction unavailable (pdf-oxide-wasm failed to load: ${msg})`,
|
|
121
|
+
{ cause: e },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const doc = new WasmPdfDocument(bytes);
|
|
127
|
+
// free() is native/WASM-side memory, not GC-reclaimed — must run on every
|
|
128
|
+
// exit, including when toMarkdownAll() AND the per-page fallback throw.
|
|
129
|
+
try {
|
|
130
|
+
const pageCount = doc.pageCount();
|
|
131
|
+
let markdown = `# PDF Document\n\n**Pages:** ${pageCount}\n\n`;
|
|
132
|
+
try {
|
|
133
|
+
markdown += doc.toMarkdownAll();
|
|
134
|
+
} catch {
|
|
135
|
+
for (let i = 0; i < pageCount; i++) {
|
|
136
|
+
markdown += `## Page ${i + 1}\n\n${doc.toMarkdown(i)}\n\n`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { markdown, pageCount };
|
|
140
|
+
} finally {
|
|
141
|
+
doc.free();
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
145
|
+
// See size branch: a failure must FAIL the scrape, not travel in-band.
|
|
146
|
+
throw new PdfExtractError('extraction_failed', `Could not extract content from PDF (${msg})`, { cause: e });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// Worker transport.
|
|
152
|
+
// ============================================================================
|
|
153
|
+
|
|
154
|
+
const WORKER_FILENAME = 'pdf-extract-worker.mjs';
|
|
155
|
+
/** Sync wasm cannot be cancelled; terminate() after this and retry in-process. */
|
|
156
|
+
const PDF_WORKER_TIMEOUT_MS = 30_000;
|
|
157
|
+
|
|
158
|
+
interface PendingRequest {
|
|
159
|
+
resolve: (r: PdfParseResult) => void;
|
|
160
|
+
reject: (e: Error) => void;
|
|
161
|
+
timer: ReturnType<typeof setTimeout>;
|
|
162
|
+
/** The Worker instance serving this request — timeout teardown must target
|
|
163
|
+
* THIS worker, never whatever is current when the timer fires. */
|
|
164
|
+
worker: Worker;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let pdfWorker: Worker | null = null;
|
|
168
|
+
let nextRequestId = 1;
|
|
169
|
+
const pendingRequests = new Map<number, PendingRequest>();
|
|
170
|
+
let warnedWorkerUnavailable = false;
|
|
171
|
+
|
|
172
|
+
type WorkerInvoke = (bytes: Uint8Array) => Promise<PdfParseResult>;
|
|
173
|
+
|
|
174
|
+
function resolveWorkerPath(): string | null {
|
|
175
|
+
const candidate = join(MODULE_DIR, WORKER_FILENAME);
|
|
176
|
+
return existsSync(candidate) ? candidate : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True when the worker bundle is present next to this module and the off
|
|
180
|
+
* kill-switch is not set. Exported for the regression test that pins the
|
|
181
|
+
* ESM-safe resolution (bare __dirname would throw here in dist/cli.mjs). */
|
|
182
|
+
export function pdfWorkerEnabled(): boolean {
|
|
183
|
+
if (process.env['PI_RESEARCH_PDF_WORKER'] === 'off') return false;
|
|
184
|
+
return resolveWorkerPath() !== null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function failAllPending(reason: string): void {
|
|
188
|
+
for (const [id, pending] of pendingRequests) {
|
|
189
|
+
clearTimeout(pending.timer);
|
|
190
|
+
pendingRequests.delete(id);
|
|
191
|
+
pending.reject(new Error(`PDF worker ${reason}`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getOrCreatePdfWorker(): Worker | null {
|
|
196
|
+
if (pdfWorker !== null) return pdfWorker;
|
|
197
|
+
const workerPath = resolveWorkerPath();
|
|
198
|
+
if (workerPath === null) {
|
|
199
|
+
if (!warnedWorkerUnavailable) {
|
|
200
|
+
warnedWorkerUnavailable = true;
|
|
201
|
+
logger.warn(
|
|
202
|
+
`[Scrapers] PDF worker bundle absent (${WORKER_FILENAME}) — PDF extraction stays on the main thread this session. ` +
|
|
203
|
+
`Run the build step (npm install / prepare) to regenerate it.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
let worker: Worker;
|
|
209
|
+
try {
|
|
210
|
+
worker = new Worker(workerPath);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
213
|
+
if (!warnedWorkerUnavailable) {
|
|
214
|
+
warnedWorkerUnavailable = true;
|
|
215
|
+
logger.warn(`[Scrapers] PDF worker failed to spawn (${msg}) — falling back to in-process extraction.`);
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
pdfWorker = worker;
|
|
220
|
+
// An idle worker must never keep the process alive at shutdown; there is no
|
|
221
|
+
// browser process to reap and no cleanup ordering to respect (unlike the
|
|
222
|
+
// browser pool), so unref replaces dispose wiring entirely.
|
|
223
|
+
try {
|
|
224
|
+
worker.unref();
|
|
225
|
+
} catch {
|
|
226
|
+
// unref() on a terminating worker can throw on some Node versions — the
|
|
227
|
+
// exit handler below already clears our reference.
|
|
228
|
+
}
|
|
229
|
+
worker.on('message', (msg: { id: number; ok: boolean; markdown?: string; pageCount?: number; kind?: PdfExtractErrorKind; message?: string }) => {
|
|
230
|
+
const pending = pendingRequests.get(msg.id);
|
|
231
|
+
if (pending === undefined) return;
|
|
232
|
+
pendingRequests.delete(msg.id);
|
|
233
|
+
clearTimeout(pending.timer);
|
|
234
|
+
if (msg.ok) {
|
|
235
|
+
pending.resolve({ markdown: msg.markdown ?? '', pageCount: msg.pageCount ?? 0 });
|
|
236
|
+
} else {
|
|
237
|
+
pending.reject(new PdfExtractError(msg.kind ?? 'extraction_failed', msg.message ?? 'PDF worker failed'));
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
// Crash/exit handlers act only when the dying worker is the CURRENT one: a
|
|
241
|
+
// terminate()-then-respawn sequence can leave the old worker's exit event
|
|
242
|
+
// arriving after a fresh worker was created — without this identity check a
|
|
243
|
+
// stale exit would null the global and fail the NEW worker's requests.
|
|
244
|
+
worker.on('error', (e) => {
|
|
245
|
+
if (pdfWorker === worker) {
|
|
246
|
+
pdfWorker = null;
|
|
247
|
+
failAllPending(`crashed: ${e instanceof Error ? e.message : String(e)}`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
worker.on('exit', (code) => {
|
|
251
|
+
if (pdfWorker === worker) {
|
|
252
|
+
pdfWorker = null;
|
|
253
|
+
failAllPending(`exited (code ${code})`);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
return worker;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function extractViaWorkerImpl(bytes: Uint8Array): Promise<PdfParseResult> {
|
|
260
|
+
const worker = getOrCreatePdfWorker();
|
|
261
|
+
if (worker === null) {
|
|
262
|
+
// Absent/spawn-failed worker is NOT a transport failure of a started
|
|
263
|
+
// parse: report it as a plain rejection so the caller falls back cleanly.
|
|
264
|
+
return Promise.reject(new Error('PDF worker unavailable'));
|
|
265
|
+
}
|
|
266
|
+
const id = nextRequestId++;
|
|
267
|
+
// Defensive copy: the transfer neuters the buffer it moves, and while both
|
|
268
|
+
// current call sites never touch pdfBytes after the await, a future caller
|
|
269
|
+
// must not be able to hand itself a detached ArrayBuffer by accident. A
|
|
270
|
+
// one-shot copy of ≤100MB is tens of milliseconds — correctness first.
|
|
271
|
+
const transferable = bytes.slice();
|
|
272
|
+
return new Promise<PdfParseResult>((resolve, reject) => {
|
|
273
|
+
const timer = setTimeout(() => {
|
|
274
|
+
// The worker instance serving THIS request — terminate that one, never
|
|
275
|
+
// whatever happens to be current when the timer fires.
|
|
276
|
+
const doomed = pendingRequests.get(id)?.worker ?? null;
|
|
277
|
+
// Drop THIS request first so failAllPending below (which rejects every
|
|
278
|
+
// REMAINING pending request and clears their timers) cannot double-fire
|
|
279
|
+
// on it — siblings fail fast instead of hanging out their own 30s.
|
|
280
|
+
pendingRequests.delete(id);
|
|
281
|
+
// A sync wasm parse wedged past the deadline can only be stopped by
|
|
282
|
+
// terminating its thread. The NEXT PDF spawns a fresh worker.
|
|
283
|
+
pdfWorker = null;
|
|
284
|
+
failAllPending(`timed out after ${PDF_WORKER_TIMEOUT_MS}ms`);
|
|
285
|
+
doomed?.terminate().catch(() => undefined);
|
|
286
|
+
reject(new Error(`PDF worker timed out after ${PDF_WORKER_TIMEOUT_MS}ms`));
|
|
287
|
+
}, PDF_WORKER_TIMEOUT_MS);
|
|
288
|
+
pendingRequests.set(id, { resolve, reject, timer, worker });
|
|
289
|
+
try {
|
|
290
|
+
worker.postMessage({ id, bytes: transferable }, [transferable.buffer]);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
// Synchronous transport failure: this request falls back in-process.
|
|
293
|
+
// Clear the armed timer and the entry, else the timer fires later and
|
|
294
|
+
// terminates whatever worker is CURRENT (an unrelated in-flight parse).
|
|
295
|
+
pendingRequests.delete(id);
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
reject(new Error(`PDF worker transport failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Test seam: the orchestration below accepts an injected transport. */
|
|
303
|
+
const workerInvoker: WorkerInvoke = extractViaWorkerImpl;
|
|
304
|
+
|
|
305
|
+
export function disposePdfWorkerPool(): void {
|
|
306
|
+
pdfWorker?.terminate().catch(() => undefined);
|
|
307
|
+
pdfWorker = null;
|
|
308
|
+
failAllPending('disposed');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============================================================================
|
|
312
|
+
// Orchestration — the public surface web-scraper used to define inline.
|
|
313
|
+
// ============================================================================
|
|
314
|
+
|
|
315
|
+
function emitFailure(e: PdfExtractError): void {
|
|
316
|
+
if (e.kind === 'size_exceeded') {
|
|
317
|
+
logger.warn(`[Scrapers] ${e.message}, skipping extraction`);
|
|
318
|
+
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (e.kind === 'native_module_unavailable') {
|
|
322
|
+
logger.error(`[Scrapers] pdf-oxide-wasm failed to load — PDF extraction is unavailable (not just for this URL): ${e.cause instanceof Error ? e.cause.message : e.message}`);
|
|
323
|
+
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'native_module_unavailable' });
|
|
324
|
+
errorTracker.trackError(e.cause instanceof Error ? e.cause : e.message, {
|
|
325
|
+
component: 'scrapers',
|
|
326
|
+
operation: 'pdf-extract',
|
|
327
|
+
contentType: 'pdf',
|
|
328
|
+
errorType: 'native_module_unavailable',
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// debug, not error: a malformed/encrypted/scanned-image-only PDF is an
|
|
333
|
+
// expected, non-actionable per-URL outcome (see isBenignScrapeFailure).
|
|
334
|
+
logger.debug(`[Scrapers] PDF extraction failed: ${e.cause instanceof Error ? e.cause.message : e.message}`);
|
|
335
|
+
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'extraction_failed' });
|
|
336
|
+
errorTracker.trackError(e.cause instanceof Error ? e.cause : e.message, {
|
|
337
|
+
component: 'scrapers',
|
|
338
|
+
operation: 'pdf-extract',
|
|
339
|
+
contentType: 'pdf',
|
|
340
|
+
errorType: 'extraction_failed',
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function extractPdfToMarkdown(
|
|
345
|
+
bytes: Uint8Array,
|
|
346
|
+
opts: { invokeWorker?: WorkerInvoke } = {},
|
|
347
|
+
): Promise<string> {
|
|
348
|
+
const start = Date.now();
|
|
349
|
+
const invoke = opts.invokeWorker ?? workerInvoker;
|
|
350
|
+
|
|
351
|
+
let result: PdfParseResult;
|
|
352
|
+
if (pdfWorkerEnabled()) {
|
|
353
|
+
try {
|
|
354
|
+
result = await invoke(bytes);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
// A CLASSIFIED error is a real answer from inside the worker (size,
|
|
357
|
+
// module, parse): report it, do not redo the work in-process.
|
|
358
|
+
if (e instanceof PdfExtractError) {
|
|
359
|
+
emitFailure(e);
|
|
360
|
+
throw e;
|
|
361
|
+
}
|
|
362
|
+
// Transport failure (spawn/timeout/crash): one bounded in-process
|
|
363
|
+
// retry — today's behavior, still capped by MAX_PDF_SIZE and the
|
|
364
|
+
// scrape deadline.
|
|
365
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
366
|
+
logger.warn(`[Scrapers] PDF worker path failed (${msg}); retrying extraction in-process`);
|
|
367
|
+
try {
|
|
368
|
+
result = await parsePdfCore(bytes);
|
|
369
|
+
} catch (e2) {
|
|
370
|
+
if (e2 instanceof PdfExtractError) {
|
|
371
|
+
emitFailure(e2);
|
|
372
|
+
throw e2;
|
|
373
|
+
}
|
|
374
|
+
throw e2;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
try {
|
|
379
|
+
result = await parsePdfCore(bytes);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
if (e instanceof PdfExtractError) {
|
|
382
|
+
emitFailure(e);
|
|
383
|
+
throw e;
|
|
384
|
+
}
|
|
385
|
+
throw e;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
metrics.observe('scrape_pdf_conversion_ms', Date.now() - start);
|
|
390
|
+
metrics.increment('scrape_pdf_conversions_total', 1, { status: 'success', pages: String(result.pageCount) });
|
|
391
|
+
return result.markdown;
|
|
392
|
+
}
|
|
@@ -30,6 +30,9 @@ import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
|
30
30
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
31
31
|
import type { ServiceContainer } from '../core/service-registry.ts';
|
|
32
32
|
import { cacheScrapedContent } from '../utils/shared-links.ts';
|
|
33
|
+
// PDF extraction now lives in pdf-extraction.ts (worker-thread offload with an
|
|
34
|
+
// identical in-process fallback); same export name and contract as before.
|
|
35
|
+
import { extractPdfToMarkdown } from './pdf-extraction.ts';
|
|
33
36
|
|
|
34
37
|
/** Backoff before the fetch layer's single transient retry. */
|
|
35
38
|
const FETCH_RETRY_DELAY_MS = 250;
|
|
@@ -75,117 +78,6 @@ async function convertToMarkdown(html: string): Promise<string> {
|
|
|
75
78
|
return converter(html);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
// TODO(hardening, medium): this synchronous WASM parse runs on the MAIN
|
|
79
|
-
// process's event loop — a large/complex PDF can block it for seconds,
|
|
80
|
-
// starving every concurrent scrape, the TUI render loop, and timers. The
|
|
81
|
-
// correct fix is a worker_threads-based extraction (mirroring the browser
|
|
82
|
-
// pool's CPU isolation pattern), but it is deliberately NOT done in this
|
|
83
|
-
// pass: pdf-oxide-wasm is dynamically imported here and unit tests mock the
|
|
84
|
-
// module via vitest, which cannot reach into a real worker thread — moving
|
|
85
|
-
// the parse requires a companion test-seam redesign first. Bounded today by
|
|
86
|
-
// MAX_PDF_SIZE (100MB) and the scrape task's own deadline.
|
|
87
|
-
async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
88
|
-
if (bytes.length > MAX_PDF_SIZE) {
|
|
89
|
-
const sizeMB = Math.round(bytes.length / 1024 / 1024);
|
|
90
|
-
logger.warn(`[Scrapers] PDF too large (${sizeMB}MB, max 100MB), skipping extraction`);
|
|
91
|
-
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
|
|
92
|
-
// THROW, never an in-band "*Error: ...*" string: a returned banner flowed
|
|
93
|
-
// into validateContent as if it were page content, and a verbose one
|
|
94
|
-
// (≥50 words / ≥200 chars) cleared the stub gate — the failure banner was
|
|
95
|
-
// then cached and cited as a successful scrape.
|
|
96
|
-
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const pdfExtractionStart = Date.now();
|
|
100
|
-
|
|
101
|
-
// The native module load is caught SEPARATELY from the parse/extraction
|
|
102
|
-
// logic below: a failure here (missing prebuilt binary for this platform,
|
|
103
|
-
// corrupted install) is an INFRASTRUCTURE fault — PDF extraction is broken
|
|
104
|
-
// for every URL, not just this one — not the routine, expected outcome of
|
|
105
|
-
// a malformed/encrypted/scanned-image-only PDF. Sharing one catch with the
|
|
106
|
-
// parse logic used to log both at `debug` and lump both under
|
|
107
|
-
// 'extraction_failed', so a broken install produced an endless stream of
|
|
108
|
-
// silent per-PDF "failures" instead of ever surfacing the real cause.
|
|
109
|
-
let WasmPdfDocument: (new (bytes: Uint8Array) => {
|
|
110
|
-
pageCount(): number;
|
|
111
|
-
toMarkdownAll(): string;
|
|
112
|
-
toMarkdown(page: number): string;
|
|
113
|
-
free(): void;
|
|
114
|
-
});
|
|
115
|
-
try {
|
|
116
|
-
({ WasmPdfDocument } = await import('pdf-oxide-wasm'));
|
|
117
|
-
} catch (e) {
|
|
118
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
119
|
-
logger.error(`[Scrapers] pdf-oxide-wasm failed to load — PDF extraction is unavailable (not just for this URL): ${msg}`);
|
|
120
|
-
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'native_module_unavailable' });
|
|
121
|
-
errorTracker.trackError(e instanceof Error ? e : String(e), {
|
|
122
|
-
component: 'scrapers',
|
|
123
|
-
operation: 'pdf-extract',
|
|
124
|
-
contentType: 'pdf',
|
|
125
|
-
errorType: 'native_module_unavailable',
|
|
126
|
-
});
|
|
127
|
-
// Deliberately does NOT start with 'Could not extract content from PDF' —
|
|
128
|
-
// isBenignScrapeFailure must NOT classify this as the routine per-PDF
|
|
129
|
-
// outcome it is for the parse-failure branch below.
|
|
130
|
-
throw new Error(`PDF extraction unavailable (pdf-oxide-wasm failed to load: ${msg})`, { cause: e });
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
const doc = new WasmPdfDocument(bytes);
|
|
135
|
-
// free() is native/WASM-side memory, not GC-reclaimed — it must run on
|
|
136
|
-
// every exit, including the case toMarkdownAll() AND the per-page fallback
|
|
137
|
-
// both throw (a malformed/encrypted PDF the codebase already treats as
|
|
138
|
-
// routine on the open web, so this path fires regularly). It used to sit
|
|
139
|
-
// after the fallback loop, reachable only when nothing below it threw —
|
|
140
|
-
// the one case that needed the fallback landing squarely on the one case
|
|
141
|
-
// that skipped the free(), leaking one WasmPdfDocument per occurrence in a
|
|
142
|
-
// browser-server process that stays warm across many research sessions.
|
|
143
|
-
try {
|
|
144
|
-
const pageCount = doc.pageCount();
|
|
145
|
-
|
|
146
|
-
let markdown = `# PDF Document\n\n**Pages:** ${pageCount}\n\n`;
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
markdown += doc.toMarkdownAll();
|
|
150
|
-
} catch {
|
|
151
|
-
for (let i = 0; i < pageCount; i++) {
|
|
152
|
-
markdown += `## Page ${i + 1}\n\n${doc.toMarkdown(i)}\n\n`;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const pdfExtractionDuration = Date.now() - pdfExtractionStart;
|
|
157
|
-
metrics.observe('scrape_pdf_conversion_ms', pdfExtractionDuration);
|
|
158
|
-
metrics.increment('scrape_pdf_conversions_total', 1, { status: 'success', pages: String(pageCount) });
|
|
159
|
-
return markdown;
|
|
160
|
-
} finally {
|
|
161
|
-
doc.free();
|
|
162
|
-
}
|
|
163
|
-
} catch (e) {
|
|
164
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
165
|
-
// debug, not error: a malformed/encrypted/scanned-image-only PDF is an
|
|
166
|
-
// expected, non-actionable per-URL outcome, not a genuine fault — the same
|
|
167
|
-
// reason isBenignScrapeFailure (scraper-utils.ts) keeps stub/blocked/HTTP-error
|
|
168
|
-
// content out of ERROR. This is called from both the fetch layer (where a
|
|
169
|
-
// browser-layer fallback always follows — the fetch layer's own catch at line
|
|
170
|
-
// ~529 already logs ANY of its failures at debug for that reason) and the
|
|
171
|
-
// browser layer (the last resort — isBenignScrapeFailure now recognizes this
|
|
172
|
-
// message so that terminal call site's own classification stays consistent
|
|
173
|
-
// too). Logging ERROR here, before either caller gets a chance to classify,
|
|
174
|
-
// pre-empted both of those and always alarmed regardless.
|
|
175
|
-
logger.debug(`[Scrapers] PDF extraction failed: ${msg}`);
|
|
176
|
-
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'extraction_failed' });
|
|
177
|
-
errorTracker.trackError(e instanceof Error ? e : String(e), {
|
|
178
|
-
component: 'scrapers',
|
|
179
|
-
operation: 'pdf-extract',
|
|
180
|
-
contentType: 'pdf',
|
|
181
|
-
errorType: 'extraction_failed',
|
|
182
|
-
});
|
|
183
|
-
// See the size branch above: an extraction failure must FAIL the scrape,
|
|
184
|
-
// not travel in-band as pseudo-content.
|
|
185
|
-
throw new Error(`Could not extract content from PDF (${msg})`, { cause: e });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
81
|
/**
|
|
190
82
|
* Decode an HTML response body using its declared charset. response.text() blindly UTF-8-decodes,
|
|
191
83
|
* corrupting non-UTF-8 pages (Shift_JIS/GBK/EUC-KR/ISO-8859-1). Prefer the Content-Type charset,
|