@juspay/neurolink 12.21.2 → 12.22.1

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.
@@ -250,6 +250,28 @@ export declare function buildTurnStalledMessage(stallTimeoutMs: number, toolCall
250
250
  * short-circuit — stopReason "aborted").
251
251
  */
252
252
  export declare function buildAbortedTurnMessage(toolCallCount: number): string;
253
+ /**
254
+ * Pick the honest terminal message for a native-loop exit by its ACTUAL
255
+ * cause. The step-cap text is the fallback for genuine budget exhaustion
256
+ * only — time/stall/abort exits must never claim a step limit was reached
257
+ * (the 2026-07-03 "reached the 200-step limit" incident was a wall-clock
258
+ * abort wearing the cap message).
259
+ *
260
+ * Takes plain booleans rather than a turn clock so a loop whose wall-clock
261
+ * deadline is owned by something other than `createTurnClock` — AI Studio's
262
+ * pre-existing per-request timeout controller — can report through the same
263
+ * builders instead of growing a second set.
264
+ */
265
+ export declare function buildLoopExitMessage(params: {
266
+ timedOut: boolean;
267
+ stalled: boolean;
268
+ /** Turn duration so far, for the time-limit message. */
269
+ elapsedMs: number;
270
+ wasAborted: boolean;
271
+ stallTimeoutMs?: number;
272
+ maxSteps: number;
273
+ toolCallCount: number;
274
+ }): string;
253
275
  /**
254
276
  * Wrap-up nudge injected when the remaining turn time drops inside the
255
277
  * `wrapupTimeLeadMs` window — the time-budget twin of the soft step-budget
@@ -922,6 +922,30 @@ export function buildAbortedTurnMessage(toolCallCount) {
922
922
  : "";
923
923
  return `This turn was stopped before I could finish.${calls}`;
924
924
  }
925
+ /**
926
+ * Pick the honest terminal message for a native-loop exit by its ACTUAL
927
+ * cause. The step-cap text is the fallback for genuine budget exhaustion
928
+ * only — time/stall/abort exits must never claim a step limit was reached
929
+ * (the 2026-07-03 "reached the 200-step limit" incident was a wall-clock
930
+ * abort wearing the cap message).
931
+ *
932
+ * Takes plain booleans rather than a turn clock so a loop whose wall-clock
933
+ * deadline is owned by something other than `createTurnClock` — AI Studio's
934
+ * pre-existing per-request timeout controller — can report through the same
935
+ * builders instead of growing a second set.
936
+ */
937
+ export function buildLoopExitMessage(params) {
938
+ if (params.timedOut) {
939
+ return buildTurnTimeoutMessage(params.elapsedMs, params.toolCallCount);
940
+ }
941
+ if (params.stalled) {
942
+ return buildTurnStalledMessage(params.stallTimeoutMs ?? 0, params.toolCallCount);
943
+ }
944
+ if (params.wasAborted) {
945
+ return buildAbortedTurnMessage(params.toolCallCount);
946
+ }
947
+ return buildToolLoopCapMessage(params.maxSteps, params.toolCallCount);
948
+ }
925
949
  /**
926
950
  * Wrap-up nudge injected when the remaining turn time drops inside the
927
951
  * `wrapupTimeLeadMs` window — the time-budget twin of the soft step-budget
@@ -300,18 +300,9 @@ export declare class GoogleVertexProvider extends BaseProvider {
300
300
  */
301
301
  private emitGenerationEnd;
302
302
  /**
303
- * Emit a `turn:lifecycle` event on the SDK emitter (alongside
304
- * tool:start/tool:end) so loop conditions that previously only reached
305
- * process logs — step-cap, time-limit, stall, abort, tool timeouts,
306
- * malformed-call retries — are observable by consumers' own pipelines.
307
- */
308
- private emitTurnEvent;
309
- /**
310
- * Pick the honest terminal message for a native-loop exit by its ACTUAL
311
- * cause. The step-cap text is the fallback for genuine budget exhaustion
312
- * only — time/stall/abort exits must never claim a step limit was reached
313
- * (the 2026-07-03 "reached the 200-step limit" incident was a wall-clock
314
- * abort wearing the cap message).
303
+ * Turn-clock-shaped adapter over the shared {@link buildLoopExitMessage}.
304
+ * Every call site in this file holds a clock, so the mapping lives here
305
+ * rather than at ten call sites; the decision itself is the shared one.
315
306
  */
316
307
  private buildLoopExitMessage;
317
308
  protected formatProviderError(error: unknown): Error;
@@ -32,7 +32,7 @@ import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } fr
32
32
  import { createNativeThinkingConfig } from "../../utils/thinkingConfig.js";
33
33
  import { TimeoutError, withTimeout } from "../../utils/async/index.js";
34
34
  import { parseTimeout } from "../../utils/timeout.js";
35
- import { appendStepText, buildAbortedTurnMessage, buildContextCapMessage, buildDedupedEngineTools, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createContextGuard, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
35
+ import { appendStepText, buildContextCapMessage, buildDedupedEngineTools, buildLoopExitMessage, buildToolLoopCapMessage, buildWrapupNudgeText, createContextGuard, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
36
36
  import { createGeminiLoopAdapter } from "../../core/geminiLoopAdapter.js";
37
37
  import { runAgenticLoop } from "../../core/loopEngine.js";
38
38
  import { createAnthropicLoopAdapter } from "../anthropic/loopAdapter.js";
@@ -5697,41 +5697,22 @@ export class GoogleVertexProvider extends BaseProvider {
5697
5697
  }
5698
5698
  }
5699
5699
  /**
5700
- * Emit a `turn:lifecycle` event on the SDK emitter (alongside
5701
- * tool:start/tool:end) so loop conditions that previously only reached
5702
- * process logs — step-cap, time-limit, stall, abort, tool timeouts,
5703
- * malformed-call retries — are observable by consumers' own pipelines.
5704
- */
5705
- emitTurnEvent(payload) {
5706
- try {
5707
- this.neurolink?.getEventEmitter()?.emit("turn:lifecycle", {
5708
- provider: this.providerName,
5709
- timestamp: Date.now(),
5710
- ...payload,
5711
- });
5712
- }
5713
- catch {
5714
- /* listener errors are non-fatal */
5715
- }
5716
- }
5717
- /**
5718
- * Pick the honest terminal message for a native-loop exit by its ACTUAL
5719
- * cause. The step-cap text is the fallback for genuine budget exhaustion
5720
- * only — time/stall/abort exits must never claim a step limit was reached
5721
- * (the 2026-07-03 "reached the 200-step limit" incident was a wall-clock
5722
- * abort wearing the cap message).
5700
+ * Turn-clock-shaped adapter over the shared {@link buildLoopExitMessage}.
5701
+ * Every call site in this file holds a clock, so the mapping lives here
5702
+ * rather than at ten call sites; the decision itself is the shared one.
5723
5703
  */
5724
5704
  buildLoopExitMessage(params) {
5725
- if (params.turnClock.timedOut) {
5726
- return buildTurnTimeoutMessage(params.turnClock.elapsedMs(), params.toolCallCount);
5727
- }
5728
- if (params.turnClock.stalled) {
5729
- return buildTurnStalledMessage(params.stallTimeoutMs ?? 0, params.toolCallCount);
5730
- }
5731
- if (params.wasAborted) {
5732
- return buildAbortedTurnMessage(params.toolCallCount);
5733
- }
5734
- return buildToolLoopCapMessage(params.maxSteps, params.toolCallCount);
5705
+ return buildLoopExitMessage({
5706
+ timedOut: params.turnClock.timedOut,
5707
+ stalled: params.turnClock.stalled,
5708
+ elapsedMs: params.turnClock.elapsedMs(),
5709
+ wasAborted: params.wasAborted,
5710
+ ...(params.stallTimeoutMs !== undefined
5711
+ ? { stallTimeoutMs: params.stallTimeoutMs }
5712
+ : {}),
5713
+ maxSteps: params.maxSteps,
5714
+ toolCallCount: params.toolCallCount,
5715
+ });
5735
5716
  }
5736
5717
  formatProviderError(error) {
5737
5718
  const errorRecord = error;
@@ -37,6 +37,10 @@ export declare const buildWireToolNameMaps: (names: readonly string[], reservedW
37
37
  * tool definitions). Used by the per-request max_tokens fit against a
38
38
  * RUNTIME-DISCOVERED context window — deliberately the same char-based
39
39
  * estimator the budget pipeline uses, so both layers agree.
40
+ *
41
+ * Image parts are billed at the flat IMAGE_TOKEN_ESTIMATE, never by the length
42
+ * of their base64 data URL: a PDF rendered to 10 page images otherwise
43
+ * stringifies to ~1.2M "tokens" and is rejected before it is ever sent.
40
44
  */
41
45
  export declare const estimateWireTokens: (messages: ReadonlyArray<OpenAICompatChatMessage>, tools: OpenAICompatChatTool[] | undefined, provider?: string) => number;
42
46
  export declare const safeStringify: (value: unknown) => string;
@@ -17,7 +17,7 @@ import { createParser } from "eventsource-parser";
17
17
  import { sanitizeToolName } from "../mcp/toolConverter.js";
18
18
  import { resolveSamplingParams } from "../models/modelRegistry.js";
19
19
  import { convertZodToJsonSchema, normalizeWireToolSchema, } from "../utils/schemaConversion.js";
20
- import { estimateTokens, TOKENS_PER_MESSAGE, } from "../utils/tokenEstimation.js";
20
+ import { estimateTokens, IMAGE_TOKEN_ESTIMATE, TOKENS_PER_MESSAGE, } from "../utils/tokenEstimation.js";
21
21
  export const stripTrailingSlash = (s) => s.replace(/\/+$/, "");
22
22
  // OpenAI-compatible wire tool names: first char letter/underscore (some
23
23
  // chat templates treat a leading digit or hyphen as invalid identifiers),
@@ -68,14 +68,29 @@ export const buildWireToolNameMaps = (names, reservedWireNames) => {
68
68
  * tool definitions). Used by the per-request max_tokens fit against a
69
69
  * RUNTIME-DISCOVERED context window — deliberately the same char-based
70
70
  * estimator the budget pipeline uses, so both layers agree.
71
+ *
72
+ * Image parts are billed at the flat IMAGE_TOKEN_ESTIMATE, never by the length
73
+ * of their base64 data URL: a PDF rendered to 10 page images otherwise
74
+ * stringifies to ~1.2M "tokens" and is rejected before it is ever sent.
71
75
  */
72
76
  export const estimateWireTokens = (messages, tools, provider) => {
73
77
  let total = 0;
74
78
  for (const message of messages) {
75
- const content = typeof message.content === "string"
76
- ? message.content
77
- : safeStringify(message.content);
78
- total += estimateTokens(content, provider) + TOKENS_PER_MESSAGE;
79
+ if (Array.isArray(message.content)) {
80
+ for (const part of message.content) {
81
+ total +=
82
+ part.type === "image_url"
83
+ ? IMAGE_TOKEN_ESTIMATE
84
+ : estimateTokens(part.text, provider);
85
+ }
86
+ total += TOKENS_PER_MESSAGE;
87
+ }
88
+ else {
89
+ const content = typeof message.content === "string"
90
+ ? message.content
91
+ : safeStringify(message.content);
92
+ total += estimateTokens(content, provider) + TOKENS_PER_MESSAGE;
93
+ }
79
94
  // tool_calls only exists on the assistant variant of the message union.
80
95
  const toolCalls = message.tool_calls;
81
96
  if (toolCalls) {
@@ -552,6 +552,19 @@ export type PDFImagePage = {
552
552
  /** Populated when this page failed to render (#294). */
553
553
  error?: string;
554
554
  };
555
+ /**
556
+ * A PDF opened for page rendering. Rendering goes through pdf-parse so the
557
+ * process loads exactly one pdfjs-dist copy — a second copy fails pdfjs's
558
+ * API-vs-Worker version check for whichever library loads second.
559
+ */
560
+ export type PDFRenderDocument = {
561
+ /** Total pages in the document. */
562
+ length: number;
563
+ /** Render a 1-based page to a PNG buffer. */
564
+ getPage: (pageNumber: number) => Promise<Buffer>;
565
+ /** Release pdfjs resources held by the document. */
566
+ destroy: () => Promise<void>;
567
+ };
555
568
  /**
556
569
  * A single PDF queued for multimodal message building, normalised from either
557
570
  * submission surface — `input.pdfFiles` or `input.content` with `type: "pdf"`
@@ -5,7 +5,10 @@
5
5
  * - Native PDF support for providers that accept PDF directly (Google AI, Vertex, OpenAI, Anthropic, Bedrock)
6
6
  * - PDF → Image conversion for providers that don't support native PDF (Azure, Mistral, Ollama)
7
7
  *
8
- * The conversion uses pdf-to-img package (MuPDF-based) for high-quality conversion.
8
+ * Page counting, text extraction and page rendering all go through pdf-parse,
9
+ * so the process only ever loads pdf-parse's pdfjs-dist. pdfjs keeps its Node
10
+ * worker in a process-wide global, and a second pdfjs version fails its
11
+ * API-vs-Worker version check against whichever copy loaded first.
9
12
  */
10
13
  import type { FileProcessingResult, PDFProcessorOptions, PDFProviderConfig, PDFImageConversionOptions, PDFImageConversionResult, PDFImagePage } from "../types/index.js";
11
14
  export declare class PDFProcessor {
@@ -40,6 +43,21 @@ export declare class PDFProcessor {
40
43
  * falls back to the regex estimate.
41
44
  */
42
45
  private static getAccuratePageCount;
46
+ /**
47
+ * Font and CMap directories of the pdfjs-dist copy pdf-parse itself loads,
48
+ * so non-embedded standard fonts and CJK text render instead of dropping
49
+ * out. Resolved from pdf-parse's location, never our own, to stay on that
50
+ * single copy. Undefined when resolution fails; rendering still works, with
51
+ * pdfjs falling back to its built-in font substitution.
52
+ */
53
+ private static pdfjsAssetDirs;
54
+ private static resolvePdfjsAssetDirs;
55
+ /**
56
+ * Open a PDF for page-by-page PNG rendering through pdf-parse. Rejects with
57
+ * pdf-parse's PasswordException for encrypted PDFs (#258), which the callers
58
+ * map to typed errors.
59
+ */
60
+ private static openForRendering;
43
61
  static estimateTokens(pageCount: number, mode?: "text-only" | "visual"): number;
44
62
  /**
45
63
  * Estimate the largest page's rendered pixel count from the PDF's MediaBox
@@ -96,7 +114,7 @@ export declare class PDFProcessor {
96
114
  *
97
115
  * NOT a wrapper of/over {@link convertToImages}, despite the similar
98
116
  * contract — the two are independent, parallel implementations (each does
99
- * its own `pdf-to-img` import, downscale calculation, and page loop)
117
+ * its own document open, downscale calculation, and page loop)
100
118
  * rather than one delegating to the other. Keep behavior changes (page
101
119
  * isolation, downscale, password handling) in sync across both by hand.
102
120
  */
@@ -113,7 +131,7 @@ export declare class PDFProcessor {
113
131
  * Check if PDF to image conversion is available
114
132
  * Useful for feature detection
115
133
  *
116
- * @returns true if pdf-to-img package is available
134
+ * @returns true if the pdf-parse renderer is available
117
135
  */
118
136
  static isImageConversionAvailable(): Promise<boolean>;
119
137
  /**
@@ -5,8 +5,13 @@
5
5
  * - Native PDF support for providers that accept PDF directly (Google AI, Vertex, OpenAI, Anthropic, Bedrock)
6
6
  * - PDF → Image conversion for providers that don't support native PDF (Azure, Mistral, Ollama)
7
7
  *
8
- * The conversion uses pdf-to-img package (MuPDF-based) for high-quality conversion.
8
+ * Page counting, text extraction and page rendering all go through pdf-parse,
9
+ * so the process only ever loads pdf-parse's pdfjs-dist. pdfjs keeps its Node
10
+ * worker in a process-wide global, and a second pdfjs version fails its
11
+ * API-vs-Worker version check against whichever copy loaded first.
9
12
  */
13
+ import { createRequire } from "node:module";
14
+ import path from "node:path";
10
15
  import { PDF_LIMITS } from "../core/constants.js";
11
16
  import { ErrorFactory } from "./errorHandling.js";
12
17
  import { logger } from "./logger.js";
@@ -297,6 +302,76 @@ export class PDFProcessor {
297
302
  return null;
298
303
  }
299
304
  }
305
+ /**
306
+ * Font and CMap directories of the pdfjs-dist copy pdf-parse itself loads,
307
+ * so non-embedded standard fonts and CJK text render instead of dropping
308
+ * out. Resolved from pdf-parse's location, never our own, to stay on that
309
+ * single copy. Undefined when resolution fails; rendering still works, with
310
+ * pdfjs falling back to its built-in font substitution.
311
+ */
312
+ static pdfjsAssetDirs;
313
+ static resolvePdfjsAssetDirs() {
314
+ if (PDFProcessor.pdfjsAssetDirs === undefined) {
315
+ try {
316
+ const pdfParseEntry = createRequire(import.meta.url).resolve("pdf-parse");
317
+ const pdfjsRoot = path.dirname(createRequire(pdfParseEntry).resolve("pdfjs-dist/package.json"));
318
+ PDFProcessor.pdfjsAssetDirs = {
319
+ standardFontDataUrl: path.join(pdfjsRoot, "standard_fonts") + path.sep,
320
+ cMapUrl: path.join(pdfjsRoot, "cmaps") + path.sep,
321
+ };
322
+ }
323
+ catch (error) {
324
+ logger.debug(`[PDF→Image] pdfjs font/CMap directories unavailable: ${error instanceof Error ? error.message : String(error)}`);
325
+ PDFProcessor.pdfjsAssetDirs = null;
326
+ }
327
+ }
328
+ return PDFProcessor.pdfjsAssetDirs ?? undefined;
329
+ }
330
+ /**
331
+ * Open a PDF for page-by-page PNG rendering through pdf-parse. Rejects with
332
+ * pdf-parse's PasswordException for encrypted PDFs (#258), which the callers
333
+ * map to typed errors.
334
+ */
335
+ static async openForRendering(pdfBuffer, scale, password) {
336
+ const { PDFParse } = await import("pdf-parse");
337
+ const parser = new PDFParse({
338
+ data: new Uint8Array(pdfBuffer),
339
+ isEvalSupported: false,
340
+ cMapPacked: true,
341
+ ...PDFProcessor.resolvePdfjsAssetDirs(),
342
+ ...(password ? { password } : {}),
343
+ });
344
+ try {
345
+ const { total } = await parser.getInfo();
346
+ return {
347
+ length: total,
348
+ getPage: async (pageNumber) => {
349
+ const shot = await parser.getScreenshot({
350
+ partial: [pageNumber],
351
+ scale,
352
+ imageBuffer: true,
353
+ imageDataUrl: false,
354
+ });
355
+ const page = shot.pages[0];
356
+ if (!page || page.data.length === 0) {
357
+ throw new Error(`page ${pageNumber} rendered no image data`);
358
+ }
359
+ return Buffer.from(page.data);
360
+ },
361
+ destroy: async () => {
362
+ await parser.destroy().catch(() => {
363
+ /* cleanup - ignore destroy errors */
364
+ });
365
+ },
366
+ };
367
+ }
368
+ catch (error) {
369
+ await parser.destroy().catch(() => {
370
+ /* cleanup - ignore destroy errors */
371
+ });
372
+ throw error;
373
+ }
374
+ }
300
375
  static estimateTokens(pageCount, mode = "visual") {
301
376
  if (mode === "text-only") {
302
377
  return Math.ceil((pageCount / 3) * 1000);
@@ -395,16 +470,14 @@ export class PDFProcessor {
395
470
  estimatedMemoryMB,
396
471
  sizeMB: Number(sizeMB.toFixed(2)),
397
472
  });
473
+ let document;
398
474
  try {
399
- // Dynamic import to avoid loading MuPDF binaries until needed
400
- const pdfToImgModule = await import("pdf-to-img");
401
- const pdf = pdfToImgModule.pdf;
402
475
  logger.debug("[PDF→Image] Starting PDF to image conversion", {
403
476
  bufferSize: pdfBuffer.length,
404
477
  scale,
405
478
  maxPages: maxPages || "all",
406
479
  });
407
- // #260: pre-flight page-size check WITHOUT rendering. pdf-to-img applies
480
+ // #260: pre-flight page-size check WITHOUT rendering. The renderer applies
408
481
  // `scale` uniformly with no per-page hook and no pixel guard, so a very
409
482
  // large page (e.g. an architectural drawing with a huge MediaBox) can
410
483
  // allocate gigabytes of canvas. Read the largest MediaBox from the PDF
@@ -416,7 +489,7 @@ export class PDFProcessor {
416
489
  const downscale = Math.sqrt(maxCanvasPixels / largestPixels);
417
490
  // Floor the result: an astronomically large (but now finite, see
418
491
  // `largestPagePixels`) pixel estimate would otherwise push `downscale`
419
- // — and therefore `effectiveScale` — toward 0, handing `pdf-to-img` a
492
+ // — and therefore `effectiveScale` — toward 0, handing the renderer a
420
493
  // degenerate viewport instead of a small-but-renderable page.
421
494
  effectiveScale = Math.max(PDF_LIMITS.MIN_EFFECTIVE_SCALE, scale * downscale);
422
495
  // Recompute the ratio actually applied (may differ from `downscale`
@@ -431,14 +504,9 @@ export class PDFProcessor {
431
504
  logger.warn(`[PDF→Image] ⚠️ ${msg}`);
432
505
  warnings.push(msg);
433
506
  }
434
- // Create PDF document (password forwarded for encrypted PDFs, #258).
435
- // pdf-to-img resolves `.length` (numPages) synchronously here and exposes
436
- // `.getPage(n)`, so we drive an indexed loop rather than the async
437
- // iterator — that lets one bad page be isolated instead of aborting all.
438
- const document = await pdf(pdfBuffer, {
439
- scale: effectiveScale,
440
- ...(password ? { password } : {}),
441
- });
507
+ // Password forwarded for encrypted PDFs (#258). An indexed getPage()
508
+ // loop lets one bad page be isolated instead of aborting all.
509
+ document = await PDFProcessor.openForRendering(pdfBuffer, effectiveScale, password);
442
510
  const totalPages = document.length;
443
511
  // #294: convert page-by-page with per-page isolation. A single page whose
444
512
  // canvas render throws (e.g. a degenerate MediaBox) no longer discards
@@ -518,6 +586,9 @@ export class PDFProcessor {
518
586
  cause: error,
519
587
  });
520
588
  }
589
+ finally {
590
+ await document?.destroy();
591
+ }
521
592
  }
522
593
  /**
523
594
  * Shared input validation for the image-conversion paths. Returns the PDF
@@ -568,7 +639,7 @@ export class PDFProcessor {
568
639
  *
569
640
  * NOT a wrapper of/over {@link convertToImages}, despite the similar
570
641
  * contract — the two are independent, parallel implementations (each does
571
- * its own `pdf-to-img` import, downscale calculation, and page loop)
642
+ * its own document open, downscale calculation, and page loop)
572
643
  * rather than one delegating to the other. Keep behavior changes (page
573
644
  * isolation, downscale, password handling) in sync across both by hand.
574
645
  */
@@ -581,8 +652,6 @@ export class PDFProcessor {
581
652
  maxCanvasPixels,
582
653
  maxPages,
583
654
  });
584
- const pdfToImgModule = await import("pdf-to-img");
585
- const pdf = pdfToImgModule.pdf;
586
655
  // #260: uniform downscale so the largest page stays under maxCanvasPixels.
587
656
  let effectiveScale = scale;
588
657
  const largestPixels = PDFProcessor.largestPagePixels(pdfBuffer, scale);
@@ -596,10 +665,7 @@ export class PDFProcessor {
596
665
  // being yielded as a page error.
597
666
  let document;
598
667
  try {
599
- document = await pdf(pdfBuffer, {
600
- scale: effectiveScale,
601
- ...(password ? { password } : {}),
602
- });
668
+ document = await PDFProcessor.openForRendering(pdfBuffer, effectiveScale, password);
603
669
  }
604
670
  catch (error) {
605
671
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -617,33 +683,43 @@ export class PDFProcessor {
617
683
  }
618
684
  const totalPages = document.length;
619
685
  let converted = 0;
620
- for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
621
- if (maxPages !== undefined && pageNum - 1 >= maxPages) {
622
- break;
623
- }
624
- try {
625
- const page = await document.getPage(pageNum);
626
- const base64Image = page.toString("base64");
627
- converted++;
628
- if (onProgress) {
629
- await onProgress({
630
- pagesConverted: converted,
631
- totalPages,
632
- elapsedMs: Date.now() - startTime,
633
- });
686
+ try {
687
+ for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
688
+ if (maxPages !== undefined && pageNum - 1 >= maxPages) {
689
+ break;
690
+ }
691
+ try {
692
+ const page = await document.getPage(pageNum);
693
+ const base64Image = page.toString("base64");
694
+ converted++;
695
+ if (onProgress) {
696
+ await onProgress({
697
+ pagesConverted: converted,
698
+ totalPages,
699
+ elapsedMs: Date.now() - startTime,
700
+ });
701
+ }
702
+ yield {
703
+ pageIndex: pageNum,
704
+ image: base64Image,
705
+ imageSizeBytes: page.length,
706
+ };
707
+ }
708
+ catch (pageError) {
709
+ const msg = pageError instanceof Error ? pageError.message : String(pageError);
710
+ logger.warn(`[PDF→Image] ⚠️ page ${pageNum} failed to render (stream): ${msg}`);
711
+ yield {
712
+ pageIndex: pageNum,
713
+ image: "",
714
+ imageSizeBytes: 0,
715
+ error: msg,
716
+ };
634
717
  }
635
- yield {
636
- pageIndex: pageNum,
637
- image: base64Image,
638
- imageSizeBytes: page.length,
639
- };
640
- }
641
- catch (pageError) {
642
- const msg = pageError instanceof Error ? pageError.message : String(pageError);
643
- logger.warn(`[PDF→Image] ⚠️ page ${pageNum} failed to render (stream): ${msg}`);
644
- yield { pageIndex: pageNum, image: "", imageSizeBytes: 0, error: msg };
645
718
  }
646
719
  }
720
+ finally {
721
+ await document.destroy();
722
+ }
647
723
  }
648
724
  /**
649
725
  * Convert a PDF file path to an array of base64 PNG images
@@ -661,11 +737,11 @@ export class PDFProcessor {
661
737
  * Check if PDF to image conversion is available
662
738
  * Useful for feature detection
663
739
  *
664
- * @returns true if pdf-to-img package is available
740
+ * @returns true if the pdf-parse renderer is available
665
741
  */
666
742
  static async isImageConversionAvailable() {
667
743
  try {
668
- await import("pdf-to-img");
744
+ await import("pdf-parse");
669
745
  return true;
670
746
  }
671
747
  catch {
@@ -683,7 +759,7 @@ export class PDFProcessor {
683
759
  static estimateConversionMemoryUsage(pdfSizeBytes, pageCount, scale = PDF_LIMITS.DEFAULT_SCALE) {
684
760
  // Rough estimation:
685
761
  // - Each page at scale 2 produces ~1-3MB PNG
686
- // - MuPDF needs ~2x PDF size for processing
762
+ // - pdfjs needs ~2x PDF size for processing
687
763
  // - Output images need ~2MB per page on average
688
764
  const pdfProcessingMB = (pdfSizeBytes / (1024 * 1024)) * 2;
689
765
  const outputImagesMB = pageCount * 2 * scale;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared string-distance helpers for "did you mean?" suggestions.
3
+ *
4
+ * Extracted from toolCallRepair.ts, which had the only implementation, so
5
+ * provider-name validation can reuse it rather than carry a second copy.
6
+ */
7
+ /**
8
+ * Longest string the distance primitive will compare.
9
+ *
10
+ * Exported so every call site can apply the same limit *before* calling
11
+ * `levenshtein` — skipping an over-limit operand entirely, rather than
12
+ * comparing a truncated prefix of it. Truncating silently merged any two
13
+ * strings that happened to share their first `MAX_COMPARABLE_LENGTH`
14
+ * characters into distance 0, which let a direct MCP tool-name lookup
15
+ * (`resolveToolName`) resolve to the wrong tool for a long, colliding name.
16
+ */
17
+ export declare const MAX_COMPARABLE_LENGTH = 128;
18
+ /**
19
+ * Compute Levenshtein edit distance between two strings.
20
+ * Uses the iterative matrix approach — O(m*n) time, O(min(m,n)) space.
21
+ *
22
+ * Throws rather than truncating when either input exceeds
23
+ * `MAX_COMPARABLE_LENGTH`: silently comparing truncated prefixes produces a
24
+ * *wrong* distance (two distinct strings sharing a long prefix collapse to
25
+ * distance 0) instead of no distance, which is worse than refusing outright.
26
+ * Callers that want fuzzy matching over long input must apply the length
27
+ * check themselves and skip the comparison — see `suggestClosest` below and
28
+ * `resolveToolName`/`rankToolNameCandidates` in toolCallRepair.ts.
29
+ */
30
+ export declare function levenshtein(a: string, b: string): number;
31
+ /**
32
+ * Candidates within `maxDistance` edits of `input`, closest first.
33
+ *
34
+ * Ties break on the candidate's own order in `candidates`, so a caller that
35
+ * passes canonical names before aliases gets the canonical one suggested.
36
+ * Comparison is case-insensitive; the returned strings keep their original
37
+ * casing so they can be pasted back verbatim.
38
+ *
39
+ * `input` longer than `MAX_COMPARABLE_LENGTH` is rejected outright (no
40
+ * suggestions), and any individual candidate over the same limit is skipped
41
+ * rather than compared — never truncated. Truncating either side risks
42
+ * exactly the false distance-0 match `levenshtein`'s guard rejects: two
43
+ * distinct strings sharing a long-enough prefix would otherwise look
44
+ * identical and "suggest" the wrong one.
45
+ *
46
+ * @param input - The string the user actually typed
47
+ * @param candidates - Valid values, most-preferred first
48
+ * @param options.maxDistance - Edit budget (default 3)
49
+ * @param options.limit - Most suggestions to return (default 3)
50
+ */
51
+ export declare function suggestClosest(input: string, candidates: readonly string[], options?: {
52
+ maxDistance?: number;
53
+ limit?: number;
54
+ }): string[];