@juspay/neurolink 10.10.6 → 10.10.8

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 (61) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +37 -8
  3. package/dist/browser/neurolink.min.js +399 -399
  4. package/dist/cli/factories/commandFactory.js +8 -4
  5. package/dist/constants/contextWindows.js +10 -1
  6. package/dist/context/anthropicLoopGuard.d.ts +1 -0
  7. package/dist/context/anthropicLoopGuard.js +30 -12
  8. package/dist/context/contextCompactor.js +19 -0
  9. package/dist/context/geminiLoopGuard.d.ts +54 -0
  10. package/dist/context/geminiLoopGuard.js +140 -0
  11. package/dist/core/redisConversationMemoryManager.d.ts +27 -0
  12. package/dist/core/redisConversationMemoryManager.js +146 -25
  13. package/dist/lib/constants/contextWindows.js +10 -1
  14. package/dist/lib/context/anthropicLoopGuard.d.ts +1 -0
  15. package/dist/lib/context/anthropicLoopGuard.js +30 -12
  16. package/dist/lib/context/contextCompactor.js +19 -0
  17. package/dist/lib/context/geminiLoopGuard.d.ts +54 -0
  18. package/dist/lib/context/geminiLoopGuard.js +141 -0
  19. package/dist/lib/core/redisConversationMemoryManager.d.ts +27 -0
  20. package/dist/lib/core/redisConversationMemoryManager.js +146 -25
  21. package/dist/lib/processors/media/VideoProcessor.d.ts +13 -3
  22. package/dist/lib/processors/media/VideoProcessor.js +53 -12
  23. package/dist/lib/providers/googleAiStudio/client.d.ts +0 -31
  24. package/dist/lib/providers/googleAiStudio/client.js +118 -1
  25. package/dist/lib/providers/googleNativeGemini3/utils.d.ts +9 -0
  26. package/dist/lib/providers/googleNativeGemini3/utils.js +12 -0
  27. package/dist/lib/providers/googleVertex/client.d.ts +0 -45
  28. package/dist/lib/providers/googleVertex/client.js +201 -21
  29. package/dist/lib/types/context.d.ts +9 -0
  30. package/dist/lib/types/file.d.ts +37 -0
  31. package/dist/lib/types/generate.d.ts +4 -0
  32. package/dist/lib/types/stream.d.ts +4 -0
  33. package/dist/lib/utils/errorHandling.d.ts +21 -0
  34. package/dist/lib/utils/errorHandling.js +53 -0
  35. package/dist/lib/utils/fileDetector.js +9 -6
  36. package/dist/lib/utils/messageBuilder.js +111 -36
  37. package/dist/lib/utils/pdfProcessor.d.ts +11 -0
  38. package/dist/lib/utils/pdfProcessor.js +17 -0
  39. package/dist/lib/utils/redis.d.ts +60 -1
  40. package/dist/lib/utils/redis.js +143 -12
  41. package/dist/processors/media/VideoProcessor.d.ts +13 -3
  42. package/dist/processors/media/VideoProcessor.js +53 -12
  43. package/dist/providers/googleAiStudio/client.d.ts +0 -31
  44. package/dist/providers/googleAiStudio/client.js +118 -1
  45. package/dist/providers/googleNativeGemini3/utils.d.ts +9 -0
  46. package/dist/providers/googleNativeGemini3/utils.js +12 -0
  47. package/dist/providers/googleVertex/client.d.ts +0 -45
  48. package/dist/providers/googleVertex/client.js +201 -21
  49. package/dist/types/context.d.ts +9 -0
  50. package/dist/types/file.d.ts +37 -0
  51. package/dist/types/generate.d.ts +4 -0
  52. package/dist/types/stream.d.ts +4 -0
  53. package/dist/utils/errorHandling.d.ts +21 -0
  54. package/dist/utils/errorHandling.js +53 -0
  55. package/dist/utils/fileDetector.js +9 -6
  56. package/dist/utils/messageBuilder.js +111 -36
  57. package/dist/utils/pdfProcessor.d.ts +11 -0
  58. package/dist/utils/pdfProcessor.js +17 -0
  59. package/dist/utils/redis.d.ts +60 -1
  60. package/dist/utils/redis.js +143 -12
  61. package/package.json +3 -1
@@ -39,6 +39,9 @@ export declare const ERROR_CODES: {
39
39
  readonly FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED";
40
40
  readonly CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED";
41
41
  readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
42
+ readonly PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED: "PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED";
43
+ readonly PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED: "PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED";
44
+ readonly PDF_PAGE_COUNT_UNVERIFIABLE: "PDF_PAGE_COUNT_UNVERIFIABLE";
42
45
  readonly PDF_PASSWORD_REQUIRED: "PDF_PASSWORD_REQUIRED";
43
46
  readonly PDF_INCORRECT_PASSWORD: "PDF_INCORRECT_PASSWORD";
44
47
  readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
@@ -203,6 +206,24 @@ export declare class ErrorFactory {
203
206
  * Create a PDF page limit exceeded error
204
207
  */
205
208
  static pdfPageLimitExceeded(estimatedPages: number, maxPages: number, provider: string): NeuroLinkError;
209
+ /**
210
+ * The combined page count across every PDF in one request exceeds what the
211
+ * provider accepts (#309). Distinct from `pdfPageLimitExceeded`, which is
212
+ * per-file — here each document can be individually legal.
213
+ */
214
+ static pdfAggregatePageLimitExceeded(fileCount: number, totalPages: number, maxPages: number, provider: string): NeuroLinkError;
215
+ /**
216
+ * The combined byte size across every PDF in one request exceeds what the
217
+ * provider accepts (#309).
218
+ */
219
+ static pdfAggregateSizeLimitExceeded(fileCount: number, totalMB: number, maxSizeMB: number, provider: string): NeuroLinkError;
220
+ /**
221
+ * A PDF supplied through the untrusted `input.content` surface could not be
222
+ * parsed for a page count (#309). Caller-supplied `metadata.pages` is not
223
+ * authoritative there, so an unreadable document is rejected rather than
224
+ * admitted with an assumed count of zero.
225
+ */
226
+ static pdfPageCountUnverifiable(filenames: string[], provider: string): NeuroLinkError;
206
227
  /**
207
228
  * The PDF is encrypted and no password was supplied (#258).
208
229
  */
@@ -52,6 +52,9 @@ export const ERROR_CODES = {
52
52
  CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED",
53
53
  // PDF validation errors
54
54
  PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
55
+ PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED: "PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED",
56
+ PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED: "PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED",
57
+ PDF_PAGE_COUNT_UNVERIFIABLE: "PDF_PAGE_COUNT_UNVERIFIABLE",
55
58
  PDF_PASSWORD_REQUIRED: "PDF_PASSWORD_REQUIRED",
56
59
  PDF_INCORRECT_PASSWORD: "PDF_INCORRECT_PASSWORD",
57
60
  // Rate limiter errors
@@ -557,6 +560,56 @@ export class ErrorFactory {
557
560
  },
558
561
  });
559
562
  }
563
+ /**
564
+ * The combined page count across every PDF in one request exceeds what the
565
+ * provider accepts (#309). Distinct from `pdfPageLimitExceeded`, which is
566
+ * per-file — here each document can be individually legal.
567
+ */
568
+ static pdfAggregatePageLimitExceeded(fileCount, totalPages, maxPages, provider) {
569
+ return new NeuroLinkError({
570
+ code: ERROR_CODES.PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED,
571
+ message: `[PDF] Combined page count across ${fileCount} PDF(s) (${totalPages}) exceeds the ` +
572
+ `${maxPages}-page limit for ${provider}. ` +
573
+ `Split the request or reduce the number of PDFs.`,
574
+ category: ErrorCategory.VALIDATION,
575
+ severity: ErrorSeverity.MEDIUM,
576
+ retriable: false,
577
+ context: { fileCount, totalPages, maxPages, provider },
578
+ });
579
+ }
580
+ /**
581
+ * The combined byte size across every PDF in one request exceeds what the
582
+ * provider accepts (#309).
583
+ */
584
+ static pdfAggregateSizeLimitExceeded(fileCount, totalMB, maxSizeMB, provider) {
585
+ return new NeuroLinkError({
586
+ code: ERROR_CODES.PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED,
587
+ message: `[PDF] Combined size across ${fileCount} PDF(s) (${totalMB.toFixed(2)}MB) exceeds the ` +
588
+ `${maxSizeMB}MB limit for ${provider}.`,
589
+ category: ErrorCategory.VALIDATION,
590
+ severity: ErrorSeverity.MEDIUM,
591
+ retriable: false,
592
+ context: { fileCount, totalMB, maxSizeMB, provider },
593
+ });
594
+ }
595
+ /**
596
+ * A PDF supplied through the untrusted `input.content` surface could not be
597
+ * parsed for a page count (#309). Caller-supplied `metadata.pages` is not
598
+ * authoritative there, so an unreadable document is rejected rather than
599
+ * admitted with an assumed count of zero.
600
+ */
601
+ static pdfPageCountUnverifiable(filenames, provider) {
602
+ return new NeuroLinkError({
603
+ code: ERROR_CODES.PDF_PAGE_COUNT_UNVERIFIABLE,
604
+ message: `[PDF] Cannot verify the page count for ${filenames.length} PDF(s) supplied via ` +
605
+ `input.content (${filenames.join(", ")}). Provide readable PDFs, or submit them ` +
606
+ `via input.pdfFiles where page counts are derived during detection.`,
607
+ category: ErrorCategory.VALIDATION,
608
+ severity: ErrorSeverity.MEDIUM,
609
+ retriable: false,
610
+ context: { filenames, provider },
611
+ });
612
+ }
560
613
  /**
561
614
  * The PDF is encrypted and no password was supplied (#258).
562
615
  */
@@ -344,13 +344,13 @@ export class FileDetector {
344
344
  logger.warn(`[FileDetector] All fallback parsing failed for type "${detection.type}". ` +
345
345
  `Attempted: ${options.allowedTypes.join(", ")}. Falling through to universal handler.`);
346
346
  const csvOptions = options?.csvOptions;
347
- const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider);
347
+ const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
348
348
  FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
349
349
  return result;
350
350
  }
351
351
  const content = await FileDetector.loadContent(input, detection, options);
352
352
  const csvOptions = options?.csvOptions;
353
- const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider);
353
+ const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
354
354
  FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
355
355
  return result;
356
356
  });
@@ -967,7 +967,7 @@ export class FileDetector {
967
967
  /**
968
968
  * Route to appropriate processor
969
969
  */
970
- static async processFile(content, detection, options, provider) {
970
+ static async processFile(content, detection, options, provider, videoOptions) {
971
971
  switch (detection.type) {
972
972
  case "csv":
973
973
  // Pass original extension through to CSV processor; if detection has none,
@@ -985,7 +985,7 @@ export class FileDetector {
985
985
  // AI providers don't support SVG as image format, so we extract text content
986
986
  return await FileDetector.processSvgAsText(content, detection);
987
987
  case "video":
988
- return await FileDetector.processVideoFile(content, detection);
988
+ return await FileDetector.processVideoFile(content, detection, videoOptions);
989
989
  case "audio":
990
990
  return await FileDetector.processAudioFile(content, detection);
991
991
  case "archive":
@@ -1031,7 +1031,7 @@ export class FileDetector {
1031
1031
  /**
1032
1032
  * Process video file: extract metadata, keyframes, and subtitles via VideoProcessor
1033
1033
  */
1034
- static async processVideoFile(content, detection) {
1034
+ static async processVideoFile(content, detection, videoOptions) {
1035
1035
  const videoFilename = detection.metadata.filename || "video";
1036
1036
  try {
1037
1037
  const videoResult = await (await getVideoProcessor()).processFile({
@@ -1040,7 +1040,10 @@ export class FileDetector {
1040
1040
  mimetype: detection.mimeType || "video/mp4",
1041
1041
  size: content.length,
1042
1042
  buffer: content,
1043
- });
1043
+ },
1044
+ // #478: carry the caller's keyframe budget/quality/format through to
1045
+ // the processor; previously these stopped at the CLI layer.
1046
+ videoOptions);
1044
1047
  if (videoResult.success && videoResult.data) {
1045
1048
  return {
1046
1049
  type: "video",
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
2
  import { readFile as readFileAsync, stat as statAsync } from "fs/promises";
3
- import { basename } from "path";
4
3
  import { getGlobalDispatcher, interceptors, request } from "undici";
5
4
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
6
5
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
@@ -809,6 +808,20 @@ export function mergeMediaFileAliases(input) {
809
808
  input.audioFiles = undefined;
810
809
  input.videoFiles = undefined;
811
810
  }
811
+ /**
812
+ * #478: `transcribeAudio` (CLI `--transcribe-audio`) is accepted by the options
813
+ * surface but no video-audio transcription exists yet — VideoProcessor extracts
814
+ * keyframes and embedded subtitle tracks only, and the transcription step is
815
+ * still open as #433. Say so once per request rather than letting the caller
816
+ * believe a transcript was produced and silently omitted.
817
+ */
818
+ function warnIfVideoTranscriptionRequested(videoOptions) {
819
+ if (videoOptions?.transcribeAudio) {
820
+ logger.warn("[NEUROLINK] Video audio transcription was requested but is not implemented yet " +
821
+ "(tracked as #433). Keyframes and any embedded subtitle tracks are still extracted; " +
822
+ "spoken audio will not be transcribed.");
823
+ }
824
+ }
812
825
  /**
813
826
  * Process the unified files array with auto-detection.
814
827
  * Handles lazy file registration, full processing, and preview injection.
@@ -825,6 +838,7 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
825
838
  }
826
839
  const totalFiles = options.input.files.length;
827
840
  const files = options.input.files;
841
+ warnIfVideoTranscriptionRequested(options.videoOptions);
828
842
  return withSpan({
829
843
  name: "neurolink.file.process_all",
830
844
  tracer: tracers.file,
@@ -882,6 +896,15 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
882
896
  "unknown",
883
897
  ],
884
898
  csvOptions: options.csvOptions,
899
+ // #478: videos arrive through this unified `files` path, so this is
900
+ // where the CLI's frame/quality/format request has to be handed on.
901
+ videoOptions: options.videoOptions
902
+ ? {
903
+ frames: options.videoOptions.frames,
904
+ quality: options.videoOptions.quality,
905
+ format: options.videoOptions.format,
906
+ }
907
+ : undefined,
885
908
  provider: provider,
886
909
  mimetypeHint: fileMimetypeHint,
887
910
  });
@@ -1020,6 +1043,83 @@ function enforcePostProcessingBudget(options, provider, model) {
1020
1043
  `budget=${contextWindow.toLocaleString()} tokens, ` +
1021
1044
  `utilization=${contextWindow > 0 ? ((totalContentTokens / contextWindow) * 100).toFixed(1) : "N/A"}%`);
1022
1045
  }
1046
+ /**
1047
+ * #309: enforce the provider's page/size ceilings across ALL PDFs in a request,
1048
+ * not just per-file. N files each just under the single-file limit can still
1049
+ * blow past it in aggregate (e.g. three 40-page PDFs → 120 pages for a
1050
+ * 100-page API).
1051
+ *
1052
+ * Shared by both PDF submission surfaces: `input.pdfFiles` (via
1053
+ * `processExplicitPdfFiles`) and `input.content` with `type: "pdf"` (via
1054
+ * `convertContentToProviderFormat`). The latter previously built its own
1055
+ * `pdfFiles` array and reached the provider without ever calling this guard,
1056
+ * so the limit was bypassable by moving the same payload to `input.content`.
1057
+ */
1058
+ /**
1059
+ * Basename that strips BOTH separators regardless of host platform.
1060
+ *
1061
+ * `path.basename` only understands the host's separator, so on a POSIX server
1062
+ * a Windows-style filename (`C:\Users\alice\q3-merger.pdf`) comes back
1063
+ * completely unchanged — defeating the point of trimming it before it reaches
1064
+ * a log line, since caller-controlled paths can carry usernames and internal
1065
+ * directory structure.
1066
+ */
1067
+ function safeBasename(filename) {
1068
+ const lastSeparator = Math.max(filename.lastIndexOf("/"), filename.lastIndexOf("\\"));
1069
+ const trimmed = lastSeparator === -1 ? filename : filename.slice(lastSeparator + 1);
1070
+ return trimmed || "<unnamed file>";
1071
+ }
1072
+ async function enforceAggregatePdfLimits(pdfFiles, provider, { trustSuppliedPageCounts }) {
1073
+ const aggregateConfig = PDFProcessor.getProviderConfig(provider);
1074
+ // Only an empty set is exempt. A single PDF must still be checked: on the
1075
+ // `input.content` path nothing else validates it (that path never goes
1076
+ // through FileDetector.detectAndProcess / PDFProcessor.process), so bailing
1077
+ // at length <= 1 let one 200-page document through untouched.
1078
+ if (!aggregateConfig || pdfFiles.length === 0) {
1079
+ return;
1080
+ }
1081
+ // Byte total is free to compute — enforce it BEFORE parsing anything, so an
1082
+ // oversized request is rejected without first spending parser CPU/memory on
1083
+ // every document in it.
1084
+ const totalMB = pdfFiles.reduce((sum, f) => sum + f.buffer.length, 0) / (1024 * 1024);
1085
+ if (totalMB > aggregateConfig.maxSizeMB) {
1086
+ throw ErrorFactory.pdfAggregateSizeLimitExceeded(pdfFiles.length, totalMB, aggregateConfig.maxSizeMB, provider);
1087
+ }
1088
+ // `trustSuppliedPageCounts` is the difference between the two surfaces.
1089
+ // On `input.pdfFiles` the count comes from FileDetector's own detection, so
1090
+ // it is authoritative. On `input.content` it is `metadata.pages` — plain
1091
+ // caller input — and trusting it lets a request declare `pages: 1` for each
1092
+ // of three 40-page PDFs and sail past the ceiling. There, the count is
1093
+ // always re-derived from the bytes and the supplied value is ignored.
1094
+ const pageCounts = await Promise.all(pdfFiles.map(async (f) => trustSuppliedPageCounts && typeof f.pageCount === "number"
1095
+ ? f.pageCount
1096
+ : await PDFProcessor.resolvePageCount(f.buffer)));
1097
+ // Filenames are caller-controlled and may be full paths carrying
1098
+ // PII/internal directory segments — surface only the basename, stripping
1099
+ // both separators so a Windows path is trimmed on a POSIX host too.
1100
+ const unknownFileNames = pdfFiles
1101
+ .filter((_, i) => typeof pageCounts[i] !== "number")
1102
+ .map((f) => (f.filename ? safeBasename(f.filename) : "<unnamed file>"));
1103
+ const totalPages = pageCounts.reduce((sum, p) => sum + (typeof p === "number" ? p : 0), 0);
1104
+ if (unknownFileNames.length > 0) {
1105
+ if (!trustSuppliedPageCounts) {
1106
+ // Untrusted surface: an unreadable count is indistinguishable from an
1107
+ // evasion attempt, and counting it as zero is precisely the hole. Fail
1108
+ // closed rather than admit an unverifiable document.
1109
+ throw ErrorFactory.pdfPageCountUnverifiable(unknownFileNames, provider);
1110
+ }
1111
+ // Trusted surface: detection already vetted these, so one unreadable
1112
+ // count must not fail an otherwise valid request — but it must not be
1113
+ // silent either, since the known sum may undercount the true total.
1114
+ logger.warn(`[PDF] Aggregate page-limit check across ${pdfFiles.length} PDFs could only be ` +
1115
+ `partially verified: ${unknownFileNames.length} file(s) have an unknown ` +
1116
+ `page count (${unknownFileNames.join(", ")}), so the known total (${totalPages}) may ` +
1117
+ `undercount the true combined page count.`);
1118
+ }
1119
+ if (totalPages > aggregateConfig.maxPages) {
1120
+ throw ErrorFactory.pdfAggregatePageLimitExceeded(pdfFiles.length, totalPages, aggregateConfig.maxPages, provider);
1121
+ }
1122
+ }
1023
1123
  /**
1024
1124
  * Process explicit PDF files and return structured PDF entries for multimodal processing.
1025
1125
  */
@@ -1059,41 +1159,10 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
1059
1159
  throw error;
1060
1160
  }
1061
1161
  }
1062
- // #309: enforce the provider's page/size ceilings across ALL PDFs, not just
1063
- // per-file. N files each just under the single-file limit can still blow past
1064
- // it in aggregate (e.g. three 40-page PDFs → 120 pages for a 100-page API).
1065
- const aggregateConfig = PDFProcessor.getProviderConfig(provider);
1066
- if (aggregateConfig && pdfFiles.length > 1) {
1067
- // A null pageCount (accurate count unavailable — see
1068
- // PDFProcessor.getAccuratePageCount) is treated as 0 in the sum below,
1069
- // which can undercount the aggregate and let a combined request over
1070
- // the provider's page limit slip through silently. Enforcement still
1071
- // runs against the known sum — a PDF with an unknown count must not
1072
- // fail the request outright — but the gap itself must not be silent.
1073
- const unknownPageCountFiles = pdfFiles.filter((f) => f.pageCount === null || f.pageCount === undefined);
1074
- const totalPages = pdfFiles.reduce((sum, f) => sum + (f.pageCount ?? 0), 0);
1075
- const totalMB = pdfFiles.reduce((sum, f) => sum + f.buffer.length, 0) / (1024 * 1024);
1076
- if (unknownPageCountFiles.length > 0) {
1077
- // Filenames are caller-controlled and may be full paths carrying
1078
- // PII/internal directory segments — log only the basename.
1079
- const unknownFileNames = unknownPageCountFiles
1080
- .map((f) => (f.filename ? basename(f.filename) : "<unnamed file>"))
1081
- .join(", ");
1082
- logger.warn(`[PDF] Aggregate page-limit check across ${pdfFiles.length} PDFs could only be ` +
1083
- `partially verified: ${unknownPageCountFiles.length} file(s) have an unknown ` +
1084
- `page count (${unknownFileNames}), so the known total (${totalPages}) may ` +
1085
- `undercount the true combined page count.`);
1086
- }
1087
- if (totalPages > aggregateConfig.maxPages) {
1088
- throw new Error(`[PDF] Combined page count across ${pdfFiles.length} PDFs (${totalPages}) exceeds the ` +
1089
- `${aggregateConfig.maxPages}-page limit for ${provider}. ` +
1090
- `Split the request or reduce the number of PDFs.`);
1091
- }
1092
- if (totalMB > aggregateConfig.maxSizeMB) {
1093
- throw new Error(`[PDF] Combined size across ${pdfFiles.length} PDFs (${totalMB.toFixed(2)}MB) exceeds the ` +
1094
- `${aggregateConfig.maxSizeMB}MB limit for ${provider}.`);
1095
- }
1096
- }
1162
+ // Counts here come from FileDetector's detection, so they are authoritative.
1163
+ await enforceAggregatePdfLimits(pdfFiles, provider, {
1164
+ trustSuppliedPageCounts: true,
1165
+ });
1097
1166
  return pdfFiles;
1098
1167
  }
1099
1168
  /**
@@ -1390,6 +1459,12 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
1390
1459
  password: pdfOptions?.password,
1391
1460
  maxCanvasPixels: pdfOptions?.maxCanvasPixels,
1392
1461
  }));
1462
+ // #309: same aggregate ceiling as `input.pdfFiles`. Without this, moving an
1463
+ // over-limit payload from `input.pdfFiles` to `input.content` skipped the
1464
+ // check entirely and the request went straight to the provider.
1465
+ await enforceAggregatePdfLimits(pdfFiles, provider, {
1466
+ trustSuppliedPageCounts: false,
1467
+ });
1393
1468
  return await convertMultimodalToProviderFormat(text, images, pdfFiles, provider, _model);
1394
1469
  }
1395
1470
  /**
@@ -18,6 +18,17 @@ export declare class PDFProcessor {
18
18
  */
19
19
  static supportsNativePDF(provider: string): boolean;
20
20
  static getProviderConfig(provider: string): PDFProviderConfig | null;
21
+ /**
22
+ * Best-effort page count for a PDF whose count the caller did not supply
23
+ * (#309). Mirrors what `process()` derives for the `input.pdfFiles` path:
24
+ * the accurate pdfjs count when the document parses, otherwise the header
25
+ * regex estimate. Returns null when neither can determine a count.
26
+ *
27
+ * Exists so the aggregate page-limit guard can enforce against PDFs handed
28
+ * in via `input.content`, where `metadata.pages` is optional and routinely
29
+ * omitted — without it, an absent count silently sums as zero.
30
+ */
31
+ static resolvePageCount(buffer: Buffer): Promise<number | null>;
21
32
  private static isValidPDF;
22
33
  private static extractBasicMetadata;
23
34
  /**
@@ -212,6 +212,23 @@ export class PDFProcessor {
212
212
  static getProviderConfig(provider) {
213
213
  return PDF_PROVIDER_CONFIGS[provider] || null;
214
214
  }
215
+ /**
216
+ * Best-effort page count for a PDF whose count the caller did not supply
217
+ * (#309). Mirrors what `process()` derives for the `input.pdfFiles` path:
218
+ * the accurate pdfjs count when the document parses, otherwise the header
219
+ * regex estimate. Returns null when neither can determine a count.
220
+ *
221
+ * Exists so the aggregate page-limit guard can enforce against PDFs handed
222
+ * in via `input.content`, where `metadata.pages` is optional and routinely
223
+ * omitted — without it, an absent count silently sums as zero.
224
+ */
225
+ static async resolvePageCount(buffer) {
226
+ const accurate = await PDFProcessor.getAccuratePageCount(buffer);
227
+ if (accurate !== null) {
228
+ return accurate;
229
+ }
230
+ return PDFProcessor.extractBasicMetadata(buffer).estimatedPages;
231
+ }
215
232
  static isValidPDF(buffer) {
216
233
  if (buffer.length < 5) {
217
234
  return false;
@@ -2,7 +2,7 @@
2
2
  * Redis Utilities for NeuroLink
3
3
  * Helper functions for Redis storage operations
4
4
  */
5
- import type { RedisClient, RedisConversationObject, RedisStorageConfig } from "../types/index.js";
5
+ import type { ChatMessage, RedisClient, RedisConversationObject, RedisStorageConfig } from "../types/index.js";
6
6
  /**
7
7
  * Get a pooled Redis connection. Multiple callers with the same host:port:db
8
8
  * share a single connection, reducing connection count.
@@ -36,6 +36,28 @@ export declare function getUserSessionsKey(config: Required<RedisStorageConfig>,
36
36
  * Serializes conversation object for Redis storage
37
37
  */
38
38
  export declare function serializeConversation(conversation: RedisConversationObject): string;
39
+ /**
40
+ * True for a complete `ChatMessage` — `id` included, because callers keying
41
+ * summary pointers and condensation groups off it are entitled to find one.
42
+ */
43
+ export declare function isStoredChatMessage(value: unknown): value is ChatMessage;
44
+ /**
45
+ * Coerce a stored entry into a complete `ChatMessage`, or `undefined` when its
46
+ * shape is unusable.
47
+ *
48
+ * Shared by BOTH read paths on purpose. Inline blobs have always been
49
+ * validated; once messages moved into the companion LIST the split read
50
+ * bypassed that check, so `null`, a number or a bare string survived
51
+ * `JSON.parse` and reached callers that dereference `.role`, `.content` and
52
+ * `.metadata`. The two storage formats must offer the same read guarantee.
53
+ *
54
+ * `id` is a separate matter: it is required on `ChatMessage`, but no read path
55
+ * has ever enforced it, so history written before it existed carries none.
56
+ * Rejecting those records would empty otherwise-healthy sessions, so a missing
57
+ * id is backfilled rather than fatal — prefixed, so a synthesized id is never
58
+ * mistaken for one an existing pointer could reference.
59
+ */
60
+ export declare function normalizeStoredMessage(value: unknown): ChatMessage | undefined;
39
61
  /**
40
62
  * Deserializes conversation object from Redis storage
41
63
  */
@@ -58,3 +80,40 @@ export declare function scanKeys(client: RedisClient, pattern: string, batchSize
58
80
  * Get normalized Redis configuration with defaults
59
81
  */
60
82
  export declare function getNormalizedConfig(config: RedisStorageConfig): Required<RedisStorageConfig>;
83
+ /**
84
+ * Suffix of the companion LIST key holding a session's messages.
85
+ *
86
+ * Every `storeConversationTurn` used to re-serialize and SET the ENTIRE
87
+ * conversation, so per-turn write cost grew with history size. Measured
88
+ * against local Redis: 200 turns of 2KB messages stayed flat at 2ms, but 400
89
+ * turns of 20KB messages (~16MB blob) went 2ms -> 61ms, a 30x degradation on
90
+ * exactly the agentic tool-output profile. Splitting messages into an
91
+ * append-only LIST makes the per-turn write O(1) in conversation size.
92
+ */
93
+ export declare const MESSAGES_KEY_SUFFIX = ":msgs";
94
+ /**
95
+ * Marker on a stored blob meaning "messages live in the companion LIST".
96
+ * A blob WITHOUT it is a legacy record whose inline `messages` array is
97
+ * authoritative — it is read as-is and converted on its next write, which is
98
+ * what makes this migration backward compatible.
99
+ */
100
+ export declare const MESSAGES_IN_LIST_MARKER = "__nlMessagesInList";
101
+ /** Redis key holding a session's messages as an append-only LIST. */
102
+ export declare function getSessionMessagesKey(config: Required<RedisStorageConfig>, sessionId: string, userId?: string): string;
103
+ /**
104
+ * True for a companion messages LIST key.
105
+ *
106
+ * Scan-then-GET paths (`getStats`, session listing) match `${keyPrefix}*`, so
107
+ * they now also see these LIST keys — and `GET` on a LIST raises WRONGTYPE.
108
+ * They must filter with this, exactly as they already skip `:sessions` index
109
+ * keys.
110
+ */
111
+ export declare function isSessionMessagesKey(key: string): boolean;
112
+ /** Serialize the conversation WITHOUT its messages, flagged for split reads. */
113
+ export declare function serializeConversationMetadata(conversation: RedisConversationObject): string;
114
+ /** True when a deserialized blob's messages live in the companion LIST. */
115
+ export declare function usesSplitMessageStorage(conversation: RedisConversationObject | null | undefined): boolean;
116
+ /** Parse LRANGE entries, skipping any single entry that is not usable. */
117
+ export declare function parseStoredMessages(entries: string[]): ChatMessage[];
118
+ /** Encode messages for RPUSH. */
119
+ export declare function encodeStoredMessages(messages: ChatMessage[]): string[];
@@ -2,6 +2,7 @@
2
2
  * Redis Utilities for NeuroLink
3
3
  * Helper functions for Redis storage operations
4
4
  */
5
+ import { randomUUID } from "crypto";
5
6
  import { createClient } from "redis";
6
7
  import { logger } from "./logger.js";
7
8
  const SESSION_ONLY_PREFIX = "session-only:";
@@ -198,6 +199,65 @@ export function serializeConversation(conversation) {
198
199
  throw error;
199
200
  }
200
201
  }
202
+ /** The exact role set `ChatMessage` allows. */
203
+ const STORED_MESSAGE_ROLES = new Set([
204
+ "user",
205
+ "assistant",
206
+ "system",
207
+ "tool_call",
208
+ "tool_result",
209
+ ]);
210
+ function isStoredMessageRole(role) {
211
+ return typeof role === "string" && STORED_MESSAGE_ROLES.has(role);
212
+ }
213
+ /**
214
+ * True for a complete `ChatMessage` — `id` included, because callers keying
215
+ * summary pointers and condensation groups off it are entitled to find one.
216
+ */
217
+ export function isStoredChatMessage(value) {
218
+ if (typeof value !== "object" || value === null) {
219
+ return false;
220
+ }
221
+ const candidate = value;
222
+ return (typeof candidate.id === "string" &&
223
+ typeof candidate.content === "string" &&
224
+ isStoredMessageRole(candidate.role));
225
+ }
226
+ /**
227
+ * Coerce a stored entry into a complete `ChatMessage`, or `undefined` when its
228
+ * shape is unusable.
229
+ *
230
+ * Shared by BOTH read paths on purpose. Inline blobs have always been
231
+ * validated; once messages moved into the companion LIST the split read
232
+ * bypassed that check, so `null`, a number or a bare string survived
233
+ * `JSON.parse` and reached callers that dereference `.role`, `.content` and
234
+ * `.metadata`. The two storage formats must offer the same read guarantee.
235
+ *
236
+ * `id` is a separate matter: it is required on `ChatMessage`, but no read path
237
+ * has ever enforced it, so history written before it existed carries none.
238
+ * Rejecting those records would empty otherwise-healthy sessions, so a missing
239
+ * id is backfilled rather than fatal — prefixed, so a synthesized id is never
240
+ * mistaken for one an existing pointer could reference.
241
+ */
242
+ export function normalizeStoredMessage(value) {
243
+ if (isStoredChatMessage(value)) {
244
+ return value;
245
+ }
246
+ if (typeof value !== "object" || value === null) {
247
+ return undefined;
248
+ }
249
+ const candidate = value;
250
+ if (typeof candidate.content !== "string" ||
251
+ !isStoredMessageRole(candidate.role)) {
252
+ return undefined;
253
+ }
254
+ return {
255
+ ...candidate,
256
+ id: `legacy-${randomUUID()}`,
257
+ role: candidate.role,
258
+ content: candidate.content,
259
+ };
260
+ }
201
261
  /**
202
262
  * Deserializes conversation object from Redis storage
203
263
  */
@@ -235,18 +295,8 @@ export function deserializeConversation(data) {
235
295
  return null;
236
296
  }
237
297
  // Validate each message in the messages array
238
- const isValidHistory = conversation.messages.every((m) => typeof m === "object" &&
239
- m !== null &&
240
- "role" in m &&
241
- "content" in m &&
242
- typeof m.role === "string" &&
243
- typeof m.content === "string" &&
244
- (m.role === "user" ||
245
- m.role === "assistant" ||
246
- m.role === "system" ||
247
- m.role === "tool_call" ||
248
- m.role === "tool_result"));
249
- if (!isValidHistory) {
298
+ const normalizedMessages = conversation.messages.map(normalizeStoredMessage);
299
+ if (normalizedMessages.some((message) => message === undefined)) {
250
300
  logger.warn("[redisUtils] Invalid messages structure", {
251
301
  messageCount: conversation.messages.length,
252
302
  firstMessage: conversation.messages.length > 0
@@ -255,6 +305,7 @@ export function deserializeConversation(data) {
255
305
  });
256
306
  return null;
257
307
  }
308
+ conversation.messages = normalizedMessages.filter((message) => message !== undefined);
258
309
  logger.debug("[redisUtils] Conversation deserialized successfully", {
259
310
  sessionId: conversation.sessionId,
260
311
  userId: conversation.userId,
@@ -396,4 +447,84 @@ export function getNormalizedConfig(config) {
396
447
  },
397
448
  };
398
449
  }
450
+ // ---------------------------------------------------------------------------
451
+ // Split message storage
452
+ // ---------------------------------------------------------------------------
453
+ /**
454
+ * Suffix of the companion LIST key holding a session's messages.
455
+ *
456
+ * Every `storeConversationTurn` used to re-serialize and SET the ENTIRE
457
+ * conversation, so per-turn write cost grew with history size. Measured
458
+ * against local Redis: 200 turns of 2KB messages stayed flat at 2ms, but 400
459
+ * turns of 20KB messages (~16MB blob) went 2ms -> 61ms, a 30x degradation on
460
+ * exactly the agentic tool-output profile. Splitting messages into an
461
+ * append-only LIST makes the per-turn write O(1) in conversation size.
462
+ */
463
+ export const MESSAGES_KEY_SUFFIX = ":msgs";
464
+ /**
465
+ * Marker on a stored blob meaning "messages live in the companion LIST".
466
+ * A blob WITHOUT it is a legacy record whose inline `messages` array is
467
+ * authoritative — it is read as-is and converted on its next write, which is
468
+ * what makes this migration backward compatible.
469
+ */
470
+ export const MESSAGES_IN_LIST_MARKER = "__nlMessagesInList";
471
+ /** Redis key holding a session's messages as an append-only LIST. */
472
+ export function getSessionMessagesKey(config, sessionId, userId) {
473
+ return `${getSessionKey(config, sessionId, userId)}${MESSAGES_KEY_SUFFIX}`;
474
+ }
475
+ /**
476
+ * True for a companion messages LIST key.
477
+ *
478
+ * Scan-then-GET paths (`getStats`, session listing) match `${keyPrefix}*`, so
479
+ * they now also see these LIST keys — and `GET` on a LIST raises WRONGTYPE.
480
+ * They must filter with this, exactly as they already skip `:sessions` index
481
+ * keys.
482
+ */
483
+ export function isSessionMessagesKey(key) {
484
+ return key.endsWith(MESSAGES_KEY_SUFFIX);
485
+ }
486
+ /** Serialize the conversation WITHOUT its messages, flagged for split reads. */
487
+ export function serializeConversationMetadata(conversation) {
488
+ return JSON.stringify({
489
+ ...conversation,
490
+ messages: [],
491
+ [MESSAGES_IN_LIST_MARKER]: true,
492
+ });
493
+ }
494
+ /** True when a deserialized blob's messages live in the companion LIST. */
495
+ export function usesSplitMessageStorage(conversation) {
496
+ if (!conversation) {
497
+ return false;
498
+ }
499
+ const record = conversation;
500
+ return record[MESSAGES_IN_LIST_MARKER] === true;
501
+ }
502
+ /** Parse LRANGE entries, skipping any single entry that is not usable. */
503
+ export function parseStoredMessages(entries) {
504
+ const messages = [];
505
+ for (const entry of entries) {
506
+ try {
507
+ // `JSON.parse` succeeds for `null`, numbers and bare strings, so the
508
+ // catch below cannot filter them — the shape has to be checked.
509
+ const parsed = JSON.parse(entry);
510
+ const message = normalizeStoredMessage(parsed);
511
+ if (!message) {
512
+ logger.warn("[redisUtils] Skipping stored message with invalid shape");
513
+ continue;
514
+ }
515
+ messages.push(message);
516
+ }
517
+ catch (error) {
518
+ // One corrupt entry must not destroy a whole session's history.
519
+ logger.warn("[redisUtils] Skipping unparseable stored message", {
520
+ error: error instanceof Error ? error.message : String(error),
521
+ });
522
+ }
523
+ }
524
+ return messages;
525
+ }
526
+ /** Encode messages for RPUSH. */
527
+ export function encodeStoredMessages(messages) {
528
+ return messages.map((message) => JSON.stringify(message));
529
+ }
399
530
  //# sourceMappingURL=redis.js.map