@lincoln504/pi-research 1.6.5 → 1.6.7

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 (64) hide show
  1. package/README.md +14 -9
  2. package/agent-skill/pi-research/README.md +5 -4
  3. package/agent-skill/pi-research/SKILL.md +1 -1
  4. package/dist/cli.mjs +616 -153
  5. package/dist/pdf-extract-worker.mjs +1289 -0
  6. package/dist/prompts/research-tool-usage.md +2 -1
  7. package/dist/prompts/researcher.md +1 -1
  8. package/dist/prompts/system-coordinator.md +2 -2
  9. package/dist/prompts/system-lead-router.md +1 -1
  10. package/docs/AGENT-SKILL.md +6 -6
  11. package/docs/ARCHITECTURE.md +24 -22
  12. package/docs/CHANGELOG.md +73 -0
  13. package/docs/CONFIGURATION.md +3 -2
  14. package/docs/SDK.md +7 -12
  15. package/package.json +8 -5
  16. package/scripts/build.cjs +35 -0
  17. package/scripts/setup.cjs +12 -10
  18. package/src/cleanup/research-cleanup.ts +11 -5
  19. package/src/cli.ts +7 -5
  20. package/src/config.ts +9 -3
  21. package/src/core/interfaces/knowledge-interfaces.ts +8 -0
  22. package/src/core/interfaces/orchestration-interfaces.ts +1 -1
  23. package/src/core/interfaces/research-plan-types.ts +6 -0
  24. package/src/core/llm/agentic-repair.ts +40 -3
  25. package/src/core/llm/llm-utils.ts +93 -5
  26. package/src/core/llm/model-registry-factory.ts +2 -15
  27. package/src/core/pi-version.ts +1 -1
  28. package/src/core/planning-service.ts +110 -15
  29. package/src/core/planning-utils.ts +3 -0
  30. package/src/core/service-registry.ts +8 -6
  31. package/src/index.ts +8 -5
  32. package/src/infrastructure/browser/config.ts +6 -7
  33. package/src/infrastructure/browser/ensure-browser.ts +14 -7
  34. package/src/infrastructure/browser/thread-worker-browser.ts +18 -19
  35. package/src/knowledge/chunker.ts +22 -4
  36. package/src/knowledge/embedder-init.ts +41 -1
  37. package/src/knowledge/embedder-utils.ts +7 -1
  38. package/src/knowledge/store.ts +68 -1
  39. package/src/knowledge/transformers-loader.ts +58 -1
  40. package/src/observers/research-observer-impl.ts +4 -1
  41. package/src/orchestration/deep-research-orchestrator.ts +3 -3
  42. package/src/orchestration/headless-observer.ts +33 -0
  43. package/src/orchestration/quick-research-orchestrator.ts +17 -3
  44. package/src/orchestration/research-orchestration-service.ts +27 -4
  45. package/src/orchestration/session-state.ts +36 -7
  46. package/src/prompts/research-tool-usage.md +2 -1
  47. package/src/prompts/researcher.md +1 -1
  48. package/src/prompts/system-coordinator.md +2 -2
  49. package/src/prompts/system-lead-router.md +1 -1
  50. package/src/sdk.ts +12 -4
  51. package/src/tools/research-knowledge-search.ts +23 -7
  52. package/src/tools/research-tool-definition.ts +1 -1
  53. package/src/tools/scrape.ts +12 -9
  54. package/src/tools/search.ts +12 -6
  55. package/src/tools/security.ts +6 -1
  56. package/src/tools/stackexchange.ts +6 -1
  57. package/src/utils/cancellation.ts +34 -1
  58. package/src/utils/llm-usage.ts +8 -0
  59. package/src/utils/log-utils.ts +1 -1
  60. package/src/web-research/browser-search.ts +6 -5
  61. package/src/web-research/pdf-extract-worker.mjs +1289 -0
  62. package/src/web-research/pdf-extract-worker.ts +42 -0
  63. package/src/web-research/pdf-extraction.ts +392 -0
  64. 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,