@juspay/neurolink 10.10.8 → 10.10.10

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.
@@ -180,6 +180,37 @@ export function assertValidCsvRow(row, rowNumber) {
180
180
  throw ErrorFactory.csvRowInvalid(`[CSVProcessor] Invalid CSV row ${rowNumber}: expected a string-keyed object with string values, got ${Array.isArray(row) ? "array" : typeof row}`, rowNumber);
181
181
  }
182
182
  }
183
+ /**
184
+ * True when a parsed CSV row is blank: no keys, or every value is empty /
185
+ * whitespace-only (#373). Shared by the structured post-filter and by
186
+ * `streamParse` so blank rows do not consume `maxRows`.
187
+ */
188
+ export function isBlankCsvDataRow(row) {
189
+ const keys = Object.keys(row);
190
+ if (keys.length === 0) {
191
+ return true;
192
+ }
193
+ return Object.values(row).every((val) => val === "" || (typeof val === "string" && val.trim() === ""));
194
+ }
195
+ /**
196
+ * Raw-line counterpart of {@link isBlankCsvDataRow} (#373): a fully blank /
197
+ * whitespace line, or a delimiter-only line such as `,,` / ` , ` whose
198
+ * fields are all empty after a CSV-aware split.
199
+ */
200
+ export function isBlankCsvRawLine(line, delimiter) {
201
+ if (line.trim() === "") {
202
+ return true;
203
+ }
204
+ const fields = splitCSVFields(line, delimiter);
205
+ if (fields.length === 0) {
206
+ return true;
207
+ }
208
+ const row = Object.create(null);
209
+ for (let i = 0; i < fields.length; i++) {
210
+ row[`c${i}`] = fields[i];
211
+ }
212
+ return isBlankCsvDataRow(row);
213
+ }
183
214
  // ============================================================================
184
215
  // Parse Error Context (#375)
185
216
  // ============================================================================
@@ -624,18 +655,41 @@ function isMetadataLine(lines) {
624
655
  }
625
656
  const firstLine = lines[0].trim();
626
657
  const secondLine = lines[1].trim();
658
+ // Excel's explicit-delimiter preamble is metadata regardless of what follows.
627
659
  if (firstLine.match(/^sep=/i)) {
628
660
  return true;
629
661
  }
662
+ // #373: this check has to come BEFORE the comma-count heuristics, not after.
663
+ // Both of them read a higher comma count on line 2 as "line 1 was a preamble"
664
+ // — but a delimiter-only row (`,,`) is a blank DATA row, and its commas are
665
+ // structure, not fields. Ordered the other way, a legitimate single-column
666
+ // header followed by a blank row (`name` / `,,`) tripped the zero-vs-nonzero
667
+ // rule and stripMetadataLine() deleted the real header, leaving unnamed
668
+ // columns. Verified against `name\n,,\nalice\nbob`.
669
+ if (isDelimiterOnlyOrBlankLine(secondLine)) {
670
+ return false;
671
+ }
630
672
  const firstCommaCount = countUnquotedCommas(firstLine);
631
673
  const secondCommaCount = countUnquotedCommas(secondLine);
674
+ // A title/preamble line carries no delimiters while the header below it does.
632
675
  if (firstCommaCount === 0 && secondCommaCount > 0) {
633
676
  return true;
634
677
  }
635
- if (secondCommaCount > 0 && firstCommaCount !== secondCommaCount) {
678
+ // Differing field counts mean line 1 is not part of the same table as line 2.
679
+ return secondCommaCount > 0 && firstCommaCount !== secondCommaCount;
680
+ }
681
+ /**
682
+ * True when `line` is empty/whitespace or contains only common CSV
683
+ * delimiters (comma / tab / semicolon / pipe) and whitespace — i.e. a
684
+ * delimiter-only blank row like `,,` or ` ; ; `. Used before the
685
+ * delimiter is known (metadata detection).
686
+ */
687
+ function isDelimiterOnlyOrBlankLine(line) {
688
+ const trimmed = line.trim();
689
+ if (trimmed === "") {
636
690
  return true;
637
691
  }
638
- return false;
692
+ return /^[\s,;\t|]+$/.test(trimmed) && /[,;\t|]/.test(trimmed);
639
693
  }
640
694
  /**
641
695
  * Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
@@ -737,13 +791,14 @@ export class CSVProcessor {
737
791
  * @returns Formatted CSV data ready for LLM (JSON or Markdown)
738
792
  */
739
793
  static async process(content, options) {
740
- const { maxRows: rawMaxRows = 1000, formatStyle = "raw", includeHeaders = true, sampleDataFormat = "json", extension = null, encoding, sanitizeColumnNames = false, columnNameCase = "snake_case", parseTimeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS, } = options || {};
794
+ const { maxRows: rawMaxRows = 1000, formatStyle = "raw", includeHeaders = true, sampleDataFormat = "json", extension = null, encoding, sanitizeColumnNames = false, columnNameCase = "snake_case", parseTimeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS, skipEmptyLines = true, } = options || {};
741
795
  const maxRows = Math.max(1, Math.min(10000, rawMaxRows));
742
796
  logger.debug("[CSVProcessor] Starting CSV processing", {
743
797
  contentSize: content.length,
744
798
  formatStyle,
745
799
  maxRows,
746
800
  includeHeaders,
801
+ skipEmptyLines,
747
802
  });
748
803
  // #362: detect the encoding (BOM → chardet → UTF-8 fallback) or honor the
749
804
  // caller's override, instead of the previous hard-coded UTF-8 decode.
@@ -764,7 +819,15 @@ export class CSVProcessor {
764
819
  }
765
820
  // dataLines already has the metadata line (if any) stripped.
766
821
  const csvLines = dataLines;
767
- const limitedLines = csvLines.slice(0, 1 + maxRows); // header + data rows
822
+ const headerLine = csvLines[0] ?? "";
823
+ const rawDataLines = csvLines.slice(1);
824
+ // #373: drop blank AND delimiter-only data lines (e.g. `,,`) so raw
825
+ // content matches structured `isBlankCsvDataRow` semantics.
826
+ const effectiveDataLines = skipEmptyLines
827
+ ? rawDataLines.filter((line) => !isBlankCsvRawLine(line, delimiter))
828
+ : rawDataLines;
829
+ const limitedDataLines = effectiveDataLines.slice(0, maxRows);
830
+ const limitedLines = csvLines.length === 0 ? [] : [headerLine, ...limitedDataLines];
768
831
  // #378: opt-in — rewrite the literal header line with sanitized,
769
832
  // deduped identifiers so the raw CSV text shown to the LLM has clean
770
833
  // column names too. (Splits on commas to match the existing raw-branch
@@ -780,12 +843,10 @@ export class CSVProcessor {
780
843
  // #1199: quote-aware, delimiter-aware split (matches the
781
844
  // sanitizeColumnNames branch above and #1192's detected delimiter).
782
845
  const headerFields = splitCSVFields(limitedLines[0] || "", delimiter);
783
- const rowCount = limitedLines
784
- .slice(1)
785
- .filter((line) => line.trim() !== "").length;
786
- const originalRowCount = csvLines
787
- .slice(1)
788
- .filter((line) => line.trim() !== "").length;
846
+ // When preserving empties, blank lines count as rows; when skipping,
847
+ // only non-empty data lines do.
848
+ const rowCount = limitedDataLines.length;
849
+ const originalRowCount = effectiveDataLines.length;
789
850
  const wasTruncated = rowCount < originalRowCount;
790
851
  if (wasTruncated) {
791
852
  logger.warn(`[CSVProcessor] CSV data truncated: showing ${rowCount} of ${originalRowCount} rows (limit: ${maxRows})`);
@@ -802,17 +863,16 @@ export class CSVProcessor {
802
863
  truncated: wasTruncated,
803
864
  });
804
865
  // Parse a sample for enhanced metadata analysis (raw format still
805
- // benefits from column analysis). Reuse the already-split, already
806
- // metadata-stripped `dataLines` and the already-detected `delimiter`
807
- // via the shared streamParse() core instead of routing `limitedCSV`
808
- // back through parseCSVStringWithMeta(), which would re-split/re-join
809
- // it and re-run a redundant BOM/metadata/delimiter-detection pass.
866
+ // benefits from column analysis). Feed the already-filtered limited
867
+ // lines and honor skipEmptyRows so blank/delimiter-only rows do not
868
+ // consume the sample limit (#373).
810
869
  const sampleRows = Math.min(rowCount, 500);
811
- const { rows: sampleForAnalysis, timedOut: rawTimedOut } = await this.streamParse(Readable.from([dataLines.slice(0, 1 + sampleRows).join("\n")]), undefined, {
870
+ const { rows: sampleForAnalysis, timedOut: rawTimedOut } = await this.streamParse(Readable.from([limitedLines.join("\n")]), undefined, {
812
871
  maxRows: sampleRows,
813
872
  skipLines: 0,
814
873
  timeoutMs: parseTimeoutMs,
815
874
  delimiter,
875
+ skipEmptyRows: skipEmptyLines,
816
876
  });
817
877
  const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(sampleForAnalysis);
818
878
  // Log data quality summary
@@ -856,51 +916,83 @@ export class CSVProcessor {
856
916
  // `delimiter`) — reuse both via the shared streamParse() core instead of
857
917
  // routing csvString back through parseCSVStringWithMeta(), which would
858
918
  // re-split it and re-run metadata/delimiter detection a second time.
859
- const { rows, timedOut: structuredTimedOut } = await this.streamParse(Readable.from([dataLines.join("\n")]), undefined, { maxRows, skipLines: 0, timeoutMs: parseTimeoutMs, delimiter });
860
- // Filter out empty rows (empty objects or rows with only whitespace values from blank lines)
919
+ const { rows, timedOut: structuredTimedOut, headers: parsedHeaders, } = await this.streamParse(Readable.from([dataLines.join("\n")]), undefined, {
920
+ maxRows,
921
+ skipLines: 0,
922
+ timeoutMs: parseTimeoutMs,
923
+ delimiter,
924
+ // Count only non-blank rows toward maxRows when skipping empties
925
+ // (#373), so maxRows: 2 still yields two real data rows if blanks
926
+ // appear first.
927
+ skipEmptyRows: skipEmptyLines,
928
+ });
929
+ // Filter out empty rows (empty objects or rows with only whitespace values
930
+ // from blank lines). #373: `skipEmptyLines` (default true) controls this;
931
+ // set false to preserve blank-line rows in structured output.
861
932
  const filteredRows = rows.filter((row) => {
862
933
  if (!row || typeof row !== "object") {
863
934
  return false;
864
935
  }
865
- const keys = Object.keys(row);
866
- if (keys.length === 0) {
867
- return false;
936
+ if (!skipEmptyLines) {
937
+ return true;
868
938
  }
869
- // Check if all values are empty or whitespace-only
870
- return !Object.values(row).every((val) => val === "" || (typeof val === "string" && val.trim() === ""));
939
+ return !isBlankCsvDataRow(row);
871
940
  });
941
+ // Schema must come from parser headers or the first non-blank data row,
942
+ // never from a preserved leading blank row (#373 review).
943
+ const schemaRow = filteredRows.find((row) => row && typeof row === "object" && !isBlankCsvDataRow(row));
944
+ const schemaHeaders = (parsedHeaders && parsedHeaders.length > 0
945
+ ? [...parsedHeaders]
946
+ : undefined) ??
947
+ (schemaRow ? Object.keys(schemaRow) : undefined) ??
948
+ (filteredRows[0] ? Object.keys(filteredRows[0]) : []) ??
949
+ [];
872
950
  // #378: opt-in — remap each row's keys from original → sanitized identifiers.
951
+ // Always project onto schemaHeaders first so preserved blank rows still
952
+ // carry the real column keys for Markdown/JSON (#373 review).
873
953
  let structuredColumnNameMapping;
874
954
  const sanitizedToOriginal = new Map();
875
- let nonEmptyRows = filteredRows;
876
- if (sanitizeColumnNames && filteredRows.length > 0) {
877
- const origHeaders = Object.keys(filteredRows[0]);
955
+ let columnNames = schemaHeaders;
956
+ let nonEmptyRows = schemaHeaders.length === 0
957
+ ? filteredRows
958
+ : filteredRows.map((row) => {
959
+ const out = Object.create(null);
960
+ for (const h of schemaHeaders) {
961
+ out[h] = typeof row[h] === "string" ? row[h] : "";
962
+ }
963
+ return out;
964
+ });
965
+ if (sanitizeColumnNames && schemaHeaders.length > 0) {
966
+ const origHeaders = schemaHeaders;
878
967
  const { sanitized, mapping } = buildColumnNameMapping(origHeaders, columnNameCase);
879
968
  origHeaders.forEach((original, i) => {
880
969
  if (original !== sanitized[i]) {
881
970
  sanitizedToOriginal.set(sanitized[i], original);
882
971
  }
883
972
  });
884
- nonEmptyRows = filteredRows.map((row) => {
973
+ nonEmptyRows = nonEmptyRows.map((row) => {
885
974
  // Defense-in-depth: a null-prototype target means an attacker-chosen
886
975
  // header literally named "__proto__"/"constructor"/"prototype" can
887
976
  // only ever create a harmless own property, never touch
888
977
  // Object.prototype (#1199).
889
978
  const out = Object.create(null);
890
979
  origHeaders.forEach((h, i) => {
891
- out[sanitized[i]] = row[h];
980
+ out[sanitized[i]] = row[h] ?? "";
892
981
  });
893
982
  return out;
894
983
  });
895
984
  structuredColumnNameMapping = mapping.length > 0 ? mapping : undefined;
985
+ columnNames = sanitized;
896
986
  }
897
987
  // Extract metadata from parsed results
898
988
  const rowCount = nonEmptyRows.length;
899
- const columnNames = nonEmptyRows.length > 0 ? Object.keys(nonEmptyRows[0]) : [];
900
989
  const columnCount = columnNames.length;
901
990
  const hasEmptyColumns = columnNames.some((col) => !col || col.trim() === "");
902
- const sampleRows = nonEmptyRows.slice(0, 3);
903
- const sampleData = this.formatSampleData(sampleRows, sampleDataFormat, includeHeaders);
991
+ // Sample / analysis should prefer non-blank rows so a preserved leading
992
+ // blank does not poison type detection or formatSampleData.
993
+ const rowsForAnalysis = nonEmptyRows.filter((row) => !isBlankCsvDataRow(row));
994
+ const sampleRows = (rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows).slice(0, 3);
995
+ const sampleData = this.formatSampleData(sampleRows, sampleDataFormat, includeHeaders, columnNames);
904
996
  if (hasEmptyColumns) {
905
997
  logger.warn("[CSVProcessor] CSV contains empty or blank column headers", {
906
998
  columnNames,
@@ -909,8 +1001,9 @@ export class CSVProcessor {
909
1001
  if (rowCount === 0) {
910
1002
  logger.warn("[CSVProcessor] CSV file contains no data rows");
911
1003
  }
912
- // Perform enhanced column analysis
913
- const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(nonEmptyRows);
1004
+ // Perform enhanced column analysis on non-blank rows so preserved
1005
+ // leading blanks do not yield an empty schema (#373 review).
1006
+ const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows);
914
1007
  // #378: carry the pre-sanitization header on each renamed column.
915
1008
  if (sanitizedToOriginal.size > 0) {
916
1009
  for (const col of columnMetadata) {
@@ -929,7 +1022,7 @@ export class CSVProcessor {
929
1022
  }
930
1023
  // Format parsed data
931
1024
  logger.debug(`[CSVProcessor] Converting ${rowCount} rows to ${formatStyle} format`);
932
- const formatted = this.formatForLLM(nonEmptyRows, formatStyle, includeHeaders);
1025
+ const formatted = this.formatForLLM(nonEmptyRows, formatStyle, includeHeaders, columnNames);
933
1026
  logger.info("[CSVProcessor] ✅ Processed CSV file", {
934
1027
  formatStyle,
935
1028
  rowCount,
@@ -954,7 +1047,7 @@ export class CSVProcessor {
954
1047
  columnMetadata,
955
1048
  dataQualityWarnings,
956
1049
  dataQualityScore,
957
- hasHeaders: detectHasHeaders(columnNames, nonEmptyRows),
1050
+ hasHeaders: detectHasHeaders(columnNames, rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows),
958
1051
  detectedDelimiter: delimiter,
959
1052
  detectedEncoding,
960
1053
  encodingConfidence,
@@ -1240,7 +1333,7 @@ export class CSVProcessor {
1240
1333
  finish(() => {
1241
1334
  logger.warn(`[CSVProcessor] Parse timed out after ${Date.now() - startTime}ms with ${rows.length} partial row(s)`);
1242
1335
  abort();
1243
- resolve({ rows, timedOut: true });
1336
+ resolve({ rows, timedOut: true, headers: capturedHeaders });
1244
1337
  });
1245
1338
  }, opts.timeoutMs);
1246
1339
  if (typeof timer.unref === "function") {
@@ -1277,6 +1370,10 @@ export class CSVProcessor {
1277
1370
  });
1278
1371
  return;
1279
1372
  }
1373
+ // #373: blank rows must not consume maxRows when skipping empties.
1374
+ if (opts.skipEmptyRows && isBlankCsvDataRow(row)) {
1375
+ return;
1376
+ }
1280
1377
  lastRowColumnCount = Object.keys(row).length;
1281
1378
  rows.push(row);
1282
1379
  count++;
@@ -1284,14 +1381,14 @@ export class CSVProcessor {
1284
1381
  logger.debug(`[CSVProcessor] Reached row limit ${opts.maxRows}, stopping parse`);
1285
1382
  finish(() => {
1286
1383
  abort();
1287
- resolve({ rows, timedOut: false });
1384
+ resolve({ rows, timedOut: false, headers: capturedHeaders });
1288
1385
  });
1289
1386
  }
1290
1387
  })
1291
1388
  .on("end", () => {
1292
1389
  finish(() => {
1293
1390
  logger.debug(`[CSVProcessor] Parsing complete: ${rows.length} rows parsed`);
1294
- resolve({ rows, timedOut: false });
1391
+ resolve({ rows, timedOut: false, headers: capturedHeaders });
1295
1392
  });
1296
1393
  })
1297
1394
  .on("error", (error) => {
@@ -1315,25 +1412,36 @@ export class CSVProcessor {
1315
1412
  /**
1316
1413
  * Format parsed CSV data for LLM consumption
1317
1414
  * Only used for JSON and Markdown formats (raw format handled separately)
1415
+ *
1416
+ * @param columnNames - Optional schema headers (#373). When preserved blank
1417
+ * rows lead `rows`, Markdown must still use the real column names.
1318
1418
  */
1319
- static formatForLLM(rows, formatStyle, includeHeaders) {
1419
+ static formatForLLM(rows, formatStyle, includeHeaders, columnNames) {
1320
1420
  if (rows.length === 0) {
1321
1421
  return "CSV file is empty or contains no data.";
1322
1422
  }
1323
1423
  if (formatStyle === "json") {
1324
1424
  return JSON.stringify(rows, null, 2);
1325
1425
  }
1326
- return this.toMarkdownTable(rows, includeHeaders);
1426
+ return this.toMarkdownTable(rows, includeHeaders, columnNames);
1327
1427
  }
1328
1428
  /**
1329
1429
  * Format as markdown table
1330
1430
  * Best for small datasets (<100 rows)
1431
+ *
1432
+ * @param columnNames - Optional explicit headers (#373). Falls back to
1433
+ * `Object.keys(rows[0])` when omitted.
1331
1434
  */
1332
- static toMarkdownTable(rows, includeHeaders) {
1435
+ static toMarkdownTable(rows, includeHeaders, columnNames) {
1333
1436
  if (rows.length === 0) {
1334
1437
  return "CSV file is empty or contains no data.";
1335
1438
  }
1336
- const headers = Object.keys(rows[0]);
1439
+ const headers = columnNames && columnNames.length > 0
1440
+ ? columnNames
1441
+ : Object.keys(rows[0] ?? {});
1442
+ if (headers.length === 0) {
1443
+ return "CSV file is empty or contains no data.";
1444
+ }
1337
1445
  // Escape backslashes, pipes, and sanitize newlines to keep rows intact
1338
1446
  const escapePipe = (str) => str
1339
1447
  .replace(/\\/g, "\\\\")
@@ -1358,9 +1466,10 @@ export class CSVProcessor {
1358
1466
  * @param sampleRows - Array of sample row objects
1359
1467
  * @param format - Output format for sample data
1360
1468
  * @param includeHeaders - Whether to include headers in CSV/markdown formats
1469
+ * @param columnNames - Optional schema headers for csv/markdown (#373)
1361
1470
  * @returns Formatted sample data as string or array
1362
1471
  */
1363
- static formatSampleData(sampleRows, format, includeHeaders) {
1472
+ static formatSampleData(sampleRows, format, includeHeaders, columnNames) {
1364
1473
  if (sampleRows.length === 0) {
1365
1474
  return format === "object" ? [] : "No data rows";
1366
1475
  }
@@ -1370,9 +1479,9 @@ export class CSVProcessor {
1370
1479
  case "json":
1371
1480
  return JSON.stringify(sampleRows, null, 2);
1372
1481
  case "csv":
1373
- return this.toCSVString(sampleRows, includeHeaders);
1482
+ return this.toCSVString(sampleRows, includeHeaders, columnNames);
1374
1483
  case "markdown":
1375
- return this.toMarkdownTable(sampleRows, includeHeaders);
1484
+ return this.toMarkdownTable(sampleRows, includeHeaders, columnNames);
1376
1485
  default:
1377
1486
  return sampleRows;
1378
1487
  }
@@ -1382,13 +1491,19 @@ export class CSVProcessor {
1382
1491
  *
1383
1492
  * @param rows - Array of row objects
1384
1493
  * @param includeHeaders - Whether to include header row
1494
+ * @param columnNames - Optional explicit headers (#373)
1385
1495
  * @returns CSV formatted string
1386
1496
  */
1387
- static toCSVString(rows, includeHeaders) {
1497
+ static toCSVString(rows, includeHeaders, columnNames) {
1388
1498
  if (rows.length === 0) {
1389
1499
  return "";
1390
1500
  }
1391
- const headers = Object.keys(rows[0]);
1501
+ const headers = columnNames && columnNames.length > 0
1502
+ ? columnNames
1503
+ : Object.keys(rows[0] ?? {});
1504
+ if (headers.length === 0) {
1505
+ return "";
1506
+ }
1392
1507
  // Escape CSV values (wrap in quotes if contains comma, quote, or newline)
1393
1508
  const escapeCSV = (value) => {
1394
1509
  if (value.includes(",") ||
@@ -4,6 +4,7 @@ import { getGlobalDispatcher, interceptors, request } from "undici";
4
4
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
5
5
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
6
6
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
7
+ import { PDF_LIMITS } from "../core/constants.js";
7
8
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
8
9
  import { isCSVContent, SIZE_TIER_THRESHOLDS } from "../types/index.js";
9
10
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
@@ -1150,6 +1151,10 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
1150
1151
  // #260: carry the per-page canvas-pixel ceiling so the caller can
1151
1152
  // raise (or lower) the memory guard for the image-fallback render.
1152
1153
  maxCanvasPixels: options.pdfOptions?.maxCanvasPixels,
1154
+ // #297: render scale / page ceiling, so the lowered default is
1155
+ // actually reachable and callers can trade sharpness for memory.
1156
+ scale: options.pdfOptions?.scale,
1157
+ maxPages: options.pdfOptions?.maxPages,
1153
1158
  });
1154
1159
  logger.info(`[PDF] ✅ Queued for multimodal: ${filename} (${result.metadata?.estimatedPages ?? "unknown"} pages)`);
1155
1160
  }
@@ -1458,6 +1463,8 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
1458
1463
  // guard as the `input.pdfFiles` path (see `processExplicitPdfFiles`).
1459
1464
  password: pdfOptions?.password,
1460
1465
  maxCanvasPixels: pdfOptions?.maxCanvasPixels,
1466
+ scale: pdfOptions?.scale,
1467
+ maxPages: pdfOptions?.maxPages,
1461
1468
  }));
1462
1469
  // #309: same aggregate ceiling as `input.pdfFiles`. Without this, moving an
1463
1470
  // over-limit payload from `input.pdfFiles` to `input.content` skipped the
@@ -1809,7 +1816,10 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1809
1816
  /**
1810
1817
  * Convert multimodal content (images + PDFs) to provider format
1811
1818
  */
1812
- async function convertMultimodalToProviderFormat(text, images, pdfFiles, provider, model) {
1819
+ async function convertMultimodalToProviderFormat(text, images,
1820
+ // The canonical entry shape (#309) rather than a fourth copy of it inline —
1821
+ // which is what let the render knobs stop short of this function.
1822
+ pdfFiles, provider, model) {
1813
1823
  const content = [
1814
1824
  { type: "text", text },
1815
1825
  ];
@@ -1843,14 +1853,41 @@ async function convertMultimodalToProviderFormat(text, images, pdfFiles, provide
1843
1853
  logger.info(`[PDF→Image] Provider ${provider} doesn't support native PDF. Converting ${pdfFiles.length} PDF(s) to images...`);
1844
1854
  for (const pdf of pdfFiles) {
1845
1855
  try {
1856
+ const effectiveMaxPages = pdf.maxPages ?? PDF_LIMITS.DEFAULT_MAX_PAGES;
1846
1857
  const conversionResult = await PDFImageConverter.convertToImages(pdf.buffer, {
1847
- scale: 2.0, // High quality for OCR/analysis
1848
- maxPages: 20, // Limit pages to prevent token overflow
1858
+ // #297: this is the only PDF→image call the product actually makes,
1859
+ // and it used to hardcode scale 2.0 — silently overriding the
1860
+ // lowered PDF_LIMITS.DEFAULT_SCALE and keeping the memory cost the
1861
+ // issue reports (a 100-page render at 2.0 is ~776MB; 1.5 is ~44%
1862
+ // fewer pixels per page). Callers can raise it back per request.
1863
+ scale: pdf.scale ?? PDF_LIMITS.DEFAULT_SCALE,
1864
+ // Page ceiling guards token overflow; also now caller-adjustable
1865
+ // rather than a constant nothing could reach.
1866
+ maxPages: effectiveMaxPages,
1849
1867
  ...(pdf.password ? { password: pdf.password } : {}), // #258
1850
1868
  ...(pdf.maxCanvasPixels
1851
1869
  ? { maxCanvasPixels: pdf.maxCanvasPixels }
1852
1870
  : {}), // #260
1853
1871
  });
1872
+ // The renderer stops at maxPages, so a longer document is silently
1873
+ // truncated — say so rather than letting the model answer from a
1874
+ // partial document as though it had the whole thing.
1875
+ //
1876
+ // Keyed on the cap being reached, not on pdf.pageCount: that field is
1877
+ // null whenever `input.content` omits `metadata.pages`, which is the
1878
+ // common case, so a page-count comparison would simply never fire
1879
+ // there. Reaching the cap is also unambiguous — a short count caused by
1880
+ // per-page render failures (#294 isolates those into `errors`) would
1881
+ // otherwise be misreported as a maxPages truncation.
1882
+ if (conversionResult.pageCount >= effectiveMaxPages) {
1883
+ logger.warn(`[PDF→Image] ${safeBasename(pdf.filename)} hit the ${effectiveMaxPages}-page ` +
1884
+ `conversion limit. Any pages beyond that were not sent — the model may be ` +
1885
+ `answering from a partial document. Raise pdfOptions.maxPages or split the file.`);
1886
+ }
1887
+ if (conversionResult.errors && conversionResult.errors.length > 0) {
1888
+ logger.warn(`[PDF→Image] ${safeBasename(pdf.filename)}: ${conversionResult.errors.length} page(s) ` +
1889
+ `failed to render and were omitted (page ${conversionResult.errors.map((e) => e.page).join(", ")}).`);
1890
+ }
1854
1891
  logger.info(`[PDF→Image] ✅ Converted ${pdf.filename}: ${conversionResult.pageCount} page(s) → images`);
1855
1892
  // Add each page as an ImagePart (raw base64, not data: URI — see SSRF note above)
1856
1893
  conversionResult.images.forEach((base64Image, pageIndex) => {
@@ -58,6 +58,8 @@ export declare function buildMultimodalOptions(options: StreamOptions, providerN
58
58
  pdfOptions: {
59
59
  password?: string;
60
60
  maxCanvasPixels?: number;
61
+ scale?: number;
62
+ maxPages?: number;
61
63
  } | undefined;
62
64
  systemPrompt: string | undefined;
63
65
  conversationHistory: import("../types/conversation.js").ChatMessage[] | undefined;
@@ -377,6 +377,7 @@ export class PDFProcessor {
377
377
  format,
378
378
  scale,
379
379
  maxCanvasPixels,
380
+ maxPages,
380
381
  });
381
382
  logger.debug("[PDF→Image] ✅ PDF validation passed", {
382
383
  bufferSize: pdfBuffer.length,
@@ -535,6 +536,14 @@ export class PDFProcessor {
535
536
  if (!Number.isFinite(opts.maxCanvasPixels) || opts.maxCanvasPixels <= 0) {
536
537
  throw new Error(`Invalid maxCanvasPixels: ${opts.maxCanvasPixels}. Must be a finite number greater than 0.`);
537
538
  }
539
+ // #297: maxPages became caller-controlled, and an unvalidated 0/-1/NaN
540
+ // silently converts nothing, surfacing later as a misleading
541
+ // "PDF has 0 pages" from deep inside the renderer. Reject it here where
542
+ // the message can still name the offending option.
543
+ if (opts.maxPages !== undefined &&
544
+ (!Number.isInteger(opts.maxPages) || opts.maxPages < 1)) {
545
+ throw new Error(`Invalid maxPages: ${opts.maxPages}. Must be a whole number of at least 1.`);
546
+ }
538
547
  if (!pdfBuffer || pdfBuffer.length < 5) {
539
548
  throw new Error("Invalid PDF: Buffer is too small or empty. " +
540
549
  "A valid PDF must be at least 5 bytes (PDF header).");
@@ -570,6 +579,7 @@ export class PDFProcessor {
570
579
  format,
571
580
  scale,
572
581
  maxCanvasPixels,
582
+ maxPages,
573
583
  });
574
584
  const pdfToImgModule = await import("pdf-to-img");
575
585
  const pdf = pdfToImgModule.pdf;
@@ -41,6 +41,7 @@ import { SIZE_LIMITS_MB } from "../config/index.js";
41
41
  import { FileErrorCode } from "../errors/index.js";
42
42
  import { withTimeout } from "../../utils/timeout.js";
43
43
  import { formatMediaDuration } from "../../utils/mediaDuration.js";
44
+ import { logger } from "../../utils/logger.js";
44
45
  import { tryImport } from "../../utils/tryImport.js";
45
46
  let _musicMetadata = null;
46
47
  async function loadMusicMetadata() {
@@ -264,6 +265,11 @@ export class AudioProcessor extends BaseFileProcessor {
264
265
  transcript: transcriptionResult.transcript,
265
266
  hasTranscript: transcriptionResult.hasTranscript,
266
267
  transcriptionProvider: transcriptionResult.transcriptionProvider,
268
+ ...(transcriptionResult.transcriptionSkippedReason
269
+ ? {
270
+ transcriptionSkippedReason: transcriptionResult.transcriptionSkippedReason,
271
+ }
272
+ : {}),
267
273
  coverArt: coverArt ?? undefined,
268
274
  buffer,
269
275
  mimetype: fileInfo.mimetype || "audio/mpeg",
@@ -316,20 +322,25 @@ export class AudioProcessor extends BaseFileProcessor {
316
322
  * @returns Transcription result with transcript text, or empty result
317
323
  */
318
324
  async attemptTranscription(buffer, filename, mimetype) {
319
- const emptyResult = {
320
- transcript: undefined,
321
- hasTranscript: false,
322
- transcriptionProvider: undefined,
325
+ const skipped = (reason) => {
326
+ logger.warn(`[AudioProcessor] No transcript for ${filename}: ${reason}. ` +
327
+ `The model will receive metadata only.`);
328
+ return {
329
+ transcript: undefined,
330
+ hasTranscript: false,
331
+ transcriptionProvider: undefined,
332
+ transcriptionSkippedReason: reason,
333
+ };
323
334
  };
324
335
  // Check if OPENAI_API_KEY is available
325
336
  const apiKey = process.env.OPENAI_API_KEY;
326
337
  if (!apiKey) {
327
- return emptyResult;
338
+ return skipped("OPENAI_API_KEY is not set, and Whisper is the only transcription backend wired up");
328
339
  }
329
340
  // Check file size (Whisper limit is 25MB)
330
341
  const fileSizeMB = buffer.length / (1024 * 1024);
331
342
  if (fileSizeMB > AUDIO_CONFIG.WHISPER_MAX_SIZE_MB) {
332
- return emptyResult;
343
+ return skipped(`file is ${fileSizeMB.toFixed(1)}MB, over Whisper's ${AUDIO_CONFIG.WHISPER_MAX_SIZE_MB}MB limit — split or compress it`);
333
344
  }
334
345
  // Check if file format is supported by Whisper
335
346
  const ext = filename.split(".").pop()?.toLowerCase();
@@ -343,7 +354,7 @@ export class AudioProcessor extends BaseFileProcessor {
343
354
  mimetype.startsWith("audio/ogg") ||
344
355
  mimetype.startsWith("audio/x-m4a"));
345
356
  if (!isFormatSupported && !isMimeSupported) {
346
- return emptyResult;
357
+ return skipped(`format is not one Whisper accepts (extension "${ext ?? "none"}", mimetype "${mimetype ?? "none"}"); supported: ${AUDIO_CONFIG.WHISPER_SUPPORTED_FORMATS.join(", ")}`);
347
358
  }
348
359
  try {
349
360
  // Dynamic imports to avoid loading these modules when transcription is not needed
@@ -351,26 +362,33 @@ export class AudioProcessor extends BaseFileProcessor {
351
362
  const openai = createOpenAI({ apiKey });
352
363
  const model = openai.transcription("whisper-1");
353
364
  // Wrap in withTimeout — large audio files can take a while, but a
354
- // stalled request shouldn't block the processor forever. The outer
355
- // catch swallows any error (transcription is best-effort), so a
356
- // TimeoutError ends up in the same fallback path as other failures.
365
+ // stalled request shouldn't block the processor forever. A TimeoutError
366
+ // lands in the same handler as other failures below, which reports it as
367
+ // the reason rather than discarding it.
357
368
  const result = await withTimeout(experimental_transcribe({
358
369
  model,
359
370
  audio: buffer,
360
371
  }), AUDIO_CONFIG.TRANSCRIPTION_TIMEOUT_MS, "openai-whisper", "generate");
361
372
  if (result.text && result.text.trim().length > 0) {
373
+ logger.debug(`[AudioProcessor] Transcribed ${filename} via openai-whisper (${result.text.trim().length} chars)`);
362
374
  return {
363
375
  transcript: result.text.trim(),
364
376
  hasTranscript: true,
365
377
  transcriptionProvider: "openai-whisper",
378
+ transcriptionSkippedReason: undefined,
366
379
  };
367
380
  }
368
- return emptyResult;
381
+ // A successful call that returned nothing is a legitimate outcome
382
+ // (silence, music, no speech) — distinct from a failure.
383
+ return skipped("Whisper returned an empty transcript for this audio");
369
384
  }
370
- catch {
371
- // Transcription is best-effort — never fail the entire processing pipeline
372
- // Common failures: rate limiting, network issues, unsupported audio encoding
373
- return emptyResult;
385
+ catch (error) {
386
+ // Transcription stays best-effort — a failure must never kill the whole
387
+ // processing pipeline. But discarding the error outright, as this block
388
+ // used to, made a bad API key, a rate limit and a network blip all look
389
+ // identical to "this file has no speech in it".
390
+ const message = error instanceof Error ? error.message : String(error);
391
+ return skipped(`transcription request failed — ${message}`);
374
392
  }
375
393
  }
376
394
  // ===========================================================================