@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.
@@ -87,7 +87,14 @@ export class SkillsManager {
87
87
  // name-find would resolve non-deterministically to the stale deprecated one
88
88
  // across store backends. Fall back to any match only when no active skill
89
89
  // carries the name.
90
- const entry = index.find((item) => item.name === idOrName && (item.status ?? "active") === "active") ?? index.find((item) => item.name === idOrName);
90
+ //
91
+ // Matched case-insensitively to agree with assertNameAvailable, which
92
+ // enforces uniqueness that way: names differing only in case cannot
93
+ // coexist, so a case-sensitive lookup could only fail to find a skill that
94
+ // is definitively there (`get("DEPLOY")` returned null for "deploy").
95
+ const wanted = idOrName.toLowerCase();
96
+ const entry = index.find((item) => item.name.toLowerCase() === wanted &&
97
+ (item.status ?? "active") === "active") ?? index.find((item) => item.name.toLowerCase() === wanted);
91
98
  return entry ? this.store.get(entry.id) : null;
92
99
  }
93
100
  /**
@@ -237,11 +244,23 @@ export class SkillsManager {
237
244
  }
238
245
  async assertNameAvailable(name, excludeId) {
239
246
  const index = await this.getIndex(true);
240
- const clash = index.find((item) => item.id !== excludeId &&
241
- item.name.toLowerCase() === name.toLowerCase() &&
242
- (item.status ?? "active") === "active");
247
+ // Deliberately NOT filtered to active (#1139). Soft-delete only flips
248
+ // status to "deprecated" — the entry stays in the index, so allowing a new
249
+ // skill to take the name left two entries sharing it. Every name-based
250
+ // lookup (CLI `skills show/delete <name>`, the skill_update/skill_delete
251
+ // tools, the `:id`-or-name REST routes) then had to guess which one the
252
+ // caller meant, and iteration order differs across store backends.
253
+ //
254
+ // A deprecated skill's name therefore stays reserved. Reusing it requires
255
+ // hard-deleting the old skill first, which is the explicit choice the
256
+ // ambiguity demands.
257
+ const clash = index.find((item) => item.id !== excludeId && item.name.toLowerCase() === name.toLowerCase());
243
258
  if (clash) {
244
- throw new Error(`A skill named "${name}" already exists`);
259
+ const suffix = (clash.status ?? "active") === "active"
260
+ ? ""
261
+ : ` (that name belongs to a deprecated skill, id ${clash.id}; ` +
262
+ `remove it before reusing the name)`;
263
+ throw new Error(`A skill named "${name}" already exists${suffix}`);
245
264
  }
246
265
  }
247
266
  }
@@ -200,6 +200,12 @@ export type CSVProcessorOptions = {
200
200
  * rather than hanging forever. Defaults: 30s for strings, 5min for files.
201
201
  */
202
202
  parseTimeoutMs?: number;
203
+ /**
204
+ * Skip blank / whitespace-only data rows (#373). Default `true`: blank lines
205
+ * are excluded from the returned content (including raw CSV text) and from
206
+ * `metadata.rowCount`. Set to `false` to preserve empty lines literally.
207
+ */
208
+ skipEmptyLines?: boolean;
203
209
  };
204
210
  /**
205
211
  * PDF API types for different providers
@@ -474,6 +480,10 @@ export type MultimodalPdfEntry = {
474
480
  password?: string;
475
481
  /** Per-page pixel ceiling for the image fallback (#260). */
476
482
  maxCanvasPixels?: number;
483
+ /** Render scale for the image fallback (#297). */
484
+ scale?: number;
485
+ /** Max pages converted by the image fallback (#297). */
486
+ maxPages?: number;
477
487
  };
478
488
  /** Result of PDF to image conversion. */
479
489
  export type PDFImageConversionResult = {
@@ -133,6 +133,17 @@ export type GenerateOptions = {
133
133
  password?: string;
134
134
  /** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
135
135
  maxCanvasPixels?: number;
136
+ /**
137
+ * Render scale for the image fallback used by providers without native PDF
138
+ * support (#297). Higher is sharper but costs roughly the square in memory
139
+ * and tokens. Range 0.1-10; defaults to PDF_LIMITS.DEFAULT_SCALE (1.5).
140
+ */
141
+ scale?: number;
142
+ /**
143
+ * Max pages converted by the image fallback (#297). Pages beyond this are
144
+ * not sent to the model at all. Defaults to PDF_LIMITS.DEFAULT_MAX_PAGES (20).
145
+ */
146
+ maxPages?: number;
136
147
  };
137
148
  videoOptions?: {
138
149
  /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
@@ -1265,6 +1276,10 @@ export type TextGenerationOptions = {
1265
1276
  password?: string;
1266
1277
  /** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
1267
1278
  maxCanvasPixels?: number;
1279
+ /** Render scale for the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_SCALE. */
1280
+ scale?: number;
1281
+ /** Max pages converted by the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_MAX_PAGES. */
1282
+ maxPages?: number;
1268
1283
  };
1269
1284
  enableSummarization?: boolean;
1270
1285
  /**
@@ -703,6 +703,12 @@ export type ProcessedAudio = ProcessedFileBase & {
703
703
  transcript?: string;
704
704
  hasTranscript: boolean;
705
705
  transcriptionProvider?: string;
706
+ /**
707
+ * Why transcription produced nothing, when it did (#416). Absent on success.
708
+ * Lets a caller distinguish "this audio has no speech" from "the transcription
709
+ * backend was never reachable", which previously looked identical.
710
+ */
711
+ transcriptionSkippedReason?: string;
706
712
  coverArt?: Buffer;
707
713
  };
708
714
  /**
@@ -225,6 +225,17 @@ export type StreamOptions = {
225
225
  password?: string;
226
226
  /** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
227
227
  maxCanvasPixels?: number;
228
+ /**
229
+ * Render scale for the image fallback used by providers without native PDF
230
+ * support (#297). Higher is sharper but costs roughly the square in memory
231
+ * and tokens. Range 0.1-10; defaults to PDF_LIMITS.DEFAULT_SCALE (1.5).
232
+ */
233
+ scale?: number;
234
+ /**
235
+ * Max pages converted by the image fallback (#297). Pages beyond this are
236
+ * not sent to the model at all. Defaults to PDF_LIMITS.DEFAULT_MAX_PAGES (20).
237
+ */
238
+ maxPages?: number;
228
239
  };
229
240
  videoOptions?: {
230
241
  /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
@@ -30,6 +30,18 @@ export declare function isValidCsvRow(row: unknown): row is CSVRow;
30
30
  * match) when a row violates the invariant.
31
31
  */
32
32
  export declare function assertValidCsvRow(row: unknown, rowNumber: number): asserts row is CSVRow;
33
+ /**
34
+ * True when a parsed CSV row is blank: no keys, or every value is empty /
35
+ * whitespace-only (#373). Shared by the structured post-filter and by
36
+ * `streamParse` so blank rows do not consume `maxRows`.
37
+ */
38
+ export declare function isBlankCsvDataRow(row: CSVRow): boolean;
39
+ /**
40
+ * Raw-line counterpart of {@link isBlankCsvDataRow} (#373): a fully blank /
41
+ * whitespace line, or a delimiter-only line such as `,,` / ` , ` whose
42
+ * fields are all empty after a CSV-aware split.
43
+ */
44
+ export declare function isBlankCsvRawLine(line: string, delimiter: string): boolean;
33
45
  /**
34
46
  * Detect if first line is CSV metadata (not actual data/headers)
35
47
  * Common patterns:
@@ -174,11 +186,17 @@ export declare class CSVProcessor {
174
186
  /**
175
187
  * Format parsed CSV data for LLM consumption
176
188
  * Only used for JSON and Markdown formats (raw format handled separately)
189
+ *
190
+ * @param columnNames - Optional schema headers (#373). When preserved blank
191
+ * rows lead `rows`, Markdown must still use the real column names.
177
192
  */
178
193
  private static formatForLLM;
179
194
  /**
180
195
  * Format as markdown table
181
196
  * Best for small datasets (<100 rows)
197
+ *
198
+ * @param columnNames - Optional explicit headers (#373). Falls back to
199
+ * `Object.keys(rows[0])` when omitted.
182
200
  */
183
201
  private static toMarkdownTable;
184
202
  /**
@@ -187,6 +205,7 @@ export declare class CSVProcessor {
187
205
  * @param sampleRows - Array of sample row objects
188
206
  * @param format - Output format for sample data
189
207
  * @param includeHeaders - Whether to include headers in CSV/markdown formats
208
+ * @param columnNames - Optional schema headers for csv/markdown (#373)
190
209
  * @returns Formatted sample data as string or array
191
210
  */
192
211
  private static formatSampleData;
@@ -195,6 +214,7 @@ export declare class CSVProcessor {
195
214
  *
196
215
  * @param rows - Array of row objects
197
216
  * @param includeHeaders - Whether to include header row
217
+ * @param columnNames - Optional explicit headers (#373)
198
218
  * @returns CSV formatted string
199
219
  */
200
220
  private static toCSVString;
@@ -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(",") ||