@juspay/neurolink 12.30.1 → 12.31.0

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.
@@ -4,4 +4,4 @@ import type { OptionSchema, TextGenerationOptions } from "../../types/index.js";
4
4
  * This object provides metadata for validation and help text in the CLI loop.
5
5
  * It is derived from the main TextGenerationOptions interface to ensure consistency.
6
6
  */
7
- export declare const textGenerationOptionsSchema: Record<keyof Omit<TextGenerationOptions, "prompt" | "input" | "schema" | "tools" | "context" | "conversationHistory" | "conversationMessages" | "conversationMemoryConfig" | "originalPrompt" | "middleware" | "expectedOutcome" | "evaluationCriteria" | "region" | "csvOptions" | "pdfOptions" | "imageOptions" | "videoOptions" | "tts" | "stt" | "thinkingConfig" | "requestId" | "fileRegistry" | "abortSignal" | "toolFilter" | "excludeTools" | "toolChoice" | "prepareStep" | "credentials" | "onFinish" | "onError" | "processors" | "piiDetection" | "responseValidation" | "inputValidation" | "toolExecutionCapture" | "toolExecutionRecorder" | "toolRoots">, OptionSchema>;
7
+ export declare const textGenerationOptionsSchema: Record<keyof Omit<TextGenerationOptions, "prompt" | "input" | "schema" | "tools" | "context" | "conversationHistory" | "conversationMessages" | "conversationMemoryConfig" | "originalPrompt" | "middleware" | "expectedOutcome" | "evaluationCriteria" | "region" | "csvOptions" | "pdfOptions" | "imageOptions" | "videoOptions" | "officeOptions" | "tts" | "stt" | "thinkingConfig" | "requestId" | "fileRegistry" | "abortSignal" | "toolFilter" | "excludeTools" | "toolChoice" | "prepareStep" | "credentials" | "onFinish" | "onError" | "processors" | "piiDetection" | "responseValidation" | "inputValidation" | "toolExecutionCapture" | "toolExecutionRecorder" | "toolRoots">, OptionSchema>;
@@ -937,6 +937,7 @@ export class BaseProvider {
937
937
  csvOptions: options.csvOptions,
938
938
  pdfOptions: options.pdfOptions,
939
939
  videoOptions: options.videoOptions,
940
+ officeOptions: options.officeOptions,
940
941
  // Forward abort, tool filtering, and timeout options to prevent
941
942
  // silent bypass when falling back from real streaming to fake streaming
942
943
  abortSignal: options.abortSignal,
@@ -109,6 +109,11 @@ export class MessageBuilder {
109
109
  // transcription flag are read downstream by the detector, and
110
110
  // a reconstruction that omits them silently restores defaults.
111
111
  videoOptions: options.videoOptions,
112
+ // This object is rebuilt field by field, so anything not named
113
+ // here never reaches the multimodal builder — the same way
114
+ // audioFiles/videoFiles went missing in #284. Office options
115
+ // steer XLSX sheet selection and rendering.
116
+ officeOptions: options.officeOptions,
112
117
  provider: options.provider,
113
118
  model: options.model,
114
119
  temperature: options.temperature,
@@ -244,6 +249,11 @@ export class MessageBuilder {
244
249
  // transcription flag are read downstream by the detector, and
245
250
  // a reconstruction that omits them silently restores defaults.
246
251
  videoOptions: options.videoOptions,
252
+ // This object is rebuilt field by field, so anything not named
253
+ // here never reaches the multimodal builder — the same way
254
+ // audioFiles/videoFiles went missing in #284. Office options
255
+ // steer XLSX sheet selection and rendering.
256
+ officeOptions: options.officeOptions,
247
257
  provider: options.provider,
248
258
  model: options.model,
249
259
  temperature: options.temperature,
package/dist/neurolink.js CHANGED
@@ -4284,6 +4284,7 @@ Current user's request: ${currentInput}`;
4284
4284
  // caller's video settings have ever reached a provider -- frames and
4285
4285
  // quality included, not just the native-video flags added alongside.
4286
4286
  videoOptions: options.videoOptions,
4287
+ officeOptions: options.officeOptions,
4287
4288
  region: options.region,
4288
4289
  tts: options.tts,
4289
4290
  stt: options.stt,
@@ -36,7 +36,7 @@
36
36
  * ```
37
37
  */
38
38
  import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
39
- import type { FileInfo, ProcessOptions, ProcessedExcel, ProcessorFileProcessingResult } from "../../types/index.js";
39
+ import type { ExcelWorksheet, FileInfo, OfficeProcessorOptions, ProcessOptions, ProcessedExcel, ProcessorFileProcessingResult } from "../../types/index.js";
40
40
  /**
41
41
  * Excel Processor - handles .xlsx and .xls files.
42
42
  * Uses exceljs library for parsing with support for large files.
@@ -136,6 +136,18 @@ export declare class ExcelProcessor extends BaseFileProcessor<ProcessedExcel> {
136
136
  */
137
137
  extractSheetRange(buffer: Buffer, sheet?: string | number, rowStart?: number, rowEnd?: number, columns?: string[]): Promise<string>;
138
138
  }
139
+ /**
140
+ * Render processed worksheets into the text handed to the model.
141
+ *
142
+ * @param worksheets - Worksheets extracted from the workbook
143
+ * @param totalRows - Total row count across all extracted worksheets
144
+ * @param options - Office options; `sheetName` selects a single sheet and
145
+ * `formatStyle` chooses the rendering (default `raw`, the tab-separated
146
+ * preview that predates the option)
147
+ * @returns Prompt-ready text, or a message naming the available sheets when
148
+ * `sheetName` matches none of them
149
+ */
150
+ export declare function formatWorksheetsForPrompt(worksheets: ExcelWorksheet[], totalRows: number, options?: OfficeProcessorOptions): string;
139
151
  /**
140
152
  * Singleton Excel processor instance.
141
153
  * Use this for standard Excel processing operations.
@@ -39,6 +39,7 @@ import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
39
39
  import { SIZE_LIMITS } from "../config/index.js";
40
40
  import { FileErrorCode } from "../errors/index.js";
41
41
  import { tryImport } from "../../utils/tryImport.js";
42
+ import { dedupeColumnNames } from "../../utils/csvProcessor.js";
42
43
  let _exceljs = null;
43
44
  async function loadExcelJS() {
44
45
  if (_exceljs) {
@@ -482,6 +483,123 @@ export class ExcelProcessor extends BaseFileProcessor {
482
483
  }
483
484
  }
484
485
  // =============================================================================
486
+ // PROMPT FORMATTING
487
+ // =============================================================================
488
+ /** Rows beyond this per sheet are summarised rather than listed. */
489
+ const PROMPT_PREVIEW_ROWS = 20;
490
+ /** RFC 4180: quote a field that contains a comma, quote or newline. */
491
+ function csvEscape(value) {
492
+ return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
493
+ }
494
+ /** A pipe inside a cell would otherwise split it into two columns. */
495
+ function markdownEscape(value) {
496
+ return value
497
+ .replace(/\\/g, "\\\\")
498
+ .replace(/\|/g, "\\|")
499
+ .replace(/\r?\n/g, " ");
500
+ }
501
+ function cellText(value) {
502
+ return String(value ?? "");
503
+ }
504
+ function formatSheetRows(sheet, formatStyle) {
505
+ const previewRows = sheet.rows.slice(0, PROMPT_PREVIEW_ROWS);
506
+ if (previewRows.length === 0) {
507
+ return "";
508
+ }
509
+ switch (formatStyle) {
510
+ case "markdown": {
511
+ // `rows[0]` IS the header row (that is where `headers` came from), so
512
+ // the body always starts at index 1 — emitting row 0 again would
513
+ // duplicate the header underneath the separator.
514
+ const header = sheet.headers.length > 0 ? sheet.headers : previewRows[0].map(cellText);
515
+ const body = previewRows.slice(1);
516
+ const columnCount = Math.max(header.length, ...body.map((row) => row.length), 1);
517
+ const pad = (cells) => Array.from({ length: columnCount }, (_, i) => markdownEscape(cells[i] ?? ""));
518
+ return [
519
+ `| ${pad(header).join(" | ")} |`,
520
+ `| ${Array.from({ length: columnCount }, () => "---").join(" | ")} |`,
521
+ ...body.map((row) => `| ${pad(row.map(cellText)).join(" | ")} |`),
522
+ ].join("\n");
523
+ }
524
+ case "csv":
525
+ return previewRows
526
+ .map((row) => row.map((cell) => csvEscape(cellText(cell))).join(","))
527
+ .join("\n");
528
+ case "json": {
529
+ const rawHeader = sheet.headers.length > 0 ? sheet.headers : previewRows[0].map(cellText);
530
+ const body = previewRows.slice(1);
531
+ // A data row's own populated-cell count can exceed the header's — row
532
+ // extraction sizes each row on its own last populated cell, not a
533
+ // workbook-wide column count, so a header truncated by a merged cell
534
+ // (or a value landing in a column the header left blank) is a normal
535
+ // spreadsheet shape. "markdown" already sizes on the widest row for
536
+ // exactly this reason; do the same here instead of zipping strictly to
537
+ // header.length, which silently dropped every cell past it.
538
+ const columnCount = Math.max(rawHeader.length, ...body.map((row) => row.length), 1);
539
+ const extendedHeader = Array.from({ length: columnCount }, (_, i) => rawHeader[i] ?? `Column ${i + 1}`);
540
+ // Object.fromEntries silently drops a column when two header cells are
541
+ // literally the same string (a copy-pasted header, a merged-and-split
542
+ // sheet) — the later column's value overwrites the earlier one under
543
+ // the shared key. Dedupe first so every column keeps its own key.
544
+ const header = dedupeColumnNames(extendedHeader);
545
+ const objects = body.map((row) => Object.fromEntries(header.map((key, i) => [key, row[i] ?? null])));
546
+ return JSON.stringify(objects, null, 2);
547
+ }
548
+ case "raw":
549
+ default:
550
+ // Bug-for-bug with the pre-option behaviour: the header line is printed
551
+ // and then `rows` is printed in full, so row 1 appears twice. Callers
552
+ // that want a clean table ask for "markdown" or "csv".
553
+ return [
554
+ sheet.headers.join("\t"),
555
+ ...previewRows.map((row) => row.map(cellText).join("\t")),
556
+ ].join("\n");
557
+ }
558
+ }
559
+ /**
560
+ * Render processed worksheets into the text handed to the model.
561
+ *
562
+ * @param worksheets - Worksheets extracted from the workbook
563
+ * @param totalRows - Total row count across all extracted worksheets
564
+ * @param options - Office options; `sheetName` selects a single sheet and
565
+ * `formatStyle` chooses the rendering (default `raw`, the tab-separated
566
+ * preview that predates the option)
567
+ * @returns Prompt-ready text, or a message naming the available sheets when
568
+ * `sheetName` matches none of them
569
+ */
570
+ export function formatWorksheetsForPrompt(worksheets, totalRows, options) {
571
+ const requested = options?.sheetName;
572
+ let selected = worksheets;
573
+ if (requested !== undefined) {
574
+ selected = worksheets.filter((sheet) => sheet.name === requested);
575
+ if (selected.length === 0) {
576
+ const available = worksheets.map((sheet) => sheet.name).join(", ");
577
+ return `Sheet "${requested}" not found. Available sheets: ${available || "(none)"}`;
578
+ }
579
+ }
580
+ const formatStyle = options?.formatStyle ?? "raw";
581
+ const selectedRows = selected.reduce((sum, sheet) => sum + sheet.rowCount, 0);
582
+ const scope = requested !== undefined
583
+ ? `Spreadsheet: sheet "${requested}" of ${worksheets.length}, ${selectedRows} rows`
584
+ : `Spreadsheet: ${worksheets.length} sheet(s), ${totalRows} total rows`;
585
+ let text = `${scope}\n`;
586
+ for (const sheet of selected) {
587
+ text += `\n### Sheet: ${sheet.name}\n`;
588
+ text += `Columns (${sheet.columnCount}): ${sheet.headers.join(", ")}\n`;
589
+ text += `Rows: ${sheet.rowCount}\n`;
590
+ const rendered = formatSheetRows(sheet, formatStyle);
591
+ if (!rendered) {
592
+ continue;
593
+ }
594
+ text += `\nData:\n${rendered}\n`;
595
+ const remaining = sheet.rowCount - PROMPT_PREVIEW_ROWS;
596
+ if (remaining > 0) {
597
+ text += `... (${remaining} more rows)\n`;
598
+ }
599
+ }
600
+ return text;
601
+ }
602
+ // =============================================================================
485
603
  // SINGLETON INSTANCE
486
604
  // =============================================================================
487
605
  /**
@@ -35,6 +35,7 @@ import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
35
35
  import { SIZE_LIMITS } from "../config/index.js";
36
36
  import { FileErrorCode } from "../errors/index.js";
37
37
  import { tryImport } from "../../utils/tryImport.js";
38
+ import { htmlToMarkdown } from "../../utils/htmlToMarkdown.js";
38
39
  let _mammoth = null;
39
40
  async function loadMammoth() {
40
41
  if (_mammoth) {
@@ -64,6 +65,22 @@ const SUPPORTED_WORD_EXTENSIONS = [".docx", ".doc"];
64
65
  * Word documents can be larger due to embedded images and complex formatting
65
66
  */
66
67
  const WORD_TIMEOUT_MS = 60000;
68
+ /**
69
+ * Derive the document metrics from the extracted plain text.
70
+ *
71
+ * Counted off `textContent` rather than the markdown so the numbers describe
72
+ * the document's prose, not the syntax added to represent it.
73
+ */
74
+ function measureText(textContent) {
75
+ const trimmed = textContent.trim();
76
+ return {
77
+ wordCount: trimmed ? trimmed.split(/\s+/).length : 0,
78
+ paragraphCount: textContent
79
+ .split(/\n+/)
80
+ .filter((line) => line.trim().length > 0).length,
81
+ characterCount: textContent.length,
82
+ };
83
+ }
67
84
  // =============================================================================
68
85
  // WORD PROCESSOR CLASS
69
86
  // =============================================================================
@@ -144,7 +161,9 @@ export class WordProcessor extends BaseFileProcessor {
144
161
  return {
145
162
  textContent: "",
146
163
  htmlContent: "",
164
+ markdownContent: "",
147
165
  warnings: [],
166
+ ...measureText(""),
148
167
  buffer,
149
168
  mimetype: fileInfo.mimetype ||
150
169
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -267,7 +286,9 @@ export class WordProcessor extends BaseFileProcessor {
267
286
  filename: this.getFilename(fileInfo),
268
287
  textContent,
269
288
  htmlContent,
289
+ markdownContent: htmlToMarkdown(htmlContent),
270
290
  warnings,
291
+ ...measureText(textContent),
271
292
  },
272
293
  };
273
294
  }
@@ -611,6 +611,21 @@ export type OfficeProcessorOptions = {
611
611
  processAllSheets?: boolean;
612
612
  /** For presentations (pptx only): whether to include slide notes */
613
613
  includeSlideNotes?: boolean;
614
+ /**
615
+ * For spreadsheets (xlsx only): restrict processing to the named sheet.
616
+ * When the workbook has no sheet by that name the result says so and lists
617
+ * the names it does have, rather than silently falling back to every sheet.
618
+ * Omit to process every sheet (the default).
619
+ */
620
+ sheetName?: string;
621
+ /**
622
+ * For spreadsheets (xlsx only): how sheet data is rendered into the prompt.
623
+ * - `raw` (default): tab-separated preview — the long-standing behaviour
624
+ * - `csv`: comma-separated values with RFC 4180 quoting
625
+ * - `markdown`: a markdown table per sheet
626
+ * - `json`: an array of row objects keyed by the header row
627
+ */
628
+ formatStyle?: "raw" | "markdown" | "json" | "csv";
614
629
  };
615
630
  /**
616
631
  * File detector options
@@ -18,7 +18,7 @@ import type { AvatarOptions, AvatarResult } from "./avatar.js";
18
18
  import type { MusicOptions, MusicResult } from "./music.js";
19
19
  import type { StandardRecord, ValidationSchema, ZodUnknownSchema } from "./aliases.js";
20
20
  import type { NeurolinkCredentials } from "./providers.js";
21
- import type { CSVProcessorOptions, VideoProcessorOptions, FileWithMetadata, MultimodalAudioEntry, MultimodalVideoEntry, VisionImageOutputFormat } from "./file.js";
21
+ import type { CSVProcessorOptions, VideoProcessorOptions, OfficeProcessorOptions, FileWithMetadata, MultimodalAudioEntry, MultimodalVideoEntry, VisionImageOutputFormat } from "./file.js";
22
22
  import type { WorkflowConfig } from "./workflow.js";
23
23
  import type { Schema, Tool, ToolChoice } from "./tools.js";
24
24
  import type { StepResult, LanguageModel } from "./providers.js";
@@ -150,6 +150,11 @@ export type GenerateOptions = {
150
150
  music?: MusicOptions;
151
151
  };
152
152
  csvOptions?: CSVProcessorOptions;
153
+ /**
154
+ * Office document processing options. Currently consumed by the XLSX path
155
+ * (`sheetName`, `formatStyle`); see OfficeProcessorOptions.
156
+ */
157
+ officeOptions?: OfficeProcessorOptions;
153
158
  /** PDF processing options (#258). */
154
159
  pdfOptions?: {
155
160
  /** Password for an encrypted PDF (image-conversion fallback path). */
@@ -1437,6 +1442,11 @@ export type TextGenerationOptions = {
1437
1442
  * saying so.
1438
1443
  */
1439
1444
  videoOptions?: VideoProcessorOptions;
1445
+ /**
1446
+ * Office document processing options. Currently consumed by the XLSX path
1447
+ * (`sheetName`, `formatStyle`); see OfficeProcessorOptions.
1448
+ */
1449
+ officeOptions?: OfficeProcessorOptions;
1440
1450
  /** PDF processing options (#258). */
1441
1451
  pdfOptions?: {
1442
1452
  /** Password for an encrypted PDF (image-conversion fallback path). */
@@ -639,8 +639,19 @@ export type ProcessedWord = ProcessedFileBase & {
639
639
  textContent: string;
640
640
  /** HTML representation of the Word document */
641
641
  htmlContent: string;
642
+ /**
643
+ * Markdown rendering of `htmlContent`, preserving the structure that plain
644
+ * text loses: headings, ordered/unordered lists and tables.
645
+ */
646
+ markdownContent: string;
642
647
  /** Warnings from mammoth extraction (e.g., unsupported elements) */
643
648
  warnings: string[];
649
+ /** Whitespace-delimited word count of `textContent`. */
650
+ wordCount: number;
651
+ /** Count of non-empty text blocks in `textContent`. */
652
+ paragraphCount: number;
653
+ /** Character count of `textContent`, including whitespace. */
654
+ characterCount: number;
644
655
  };
645
656
  /**
646
657
  * Processed RTF document result.
@@ -16,7 +16,7 @@ import type { AIModelProviderConfig, NeurolinkCredentials } from "./providers.js
16
16
  import type { TTSChunk, TTSOptions, TTSResult } from "./tts.js";
17
17
  import type { STTOptions, STTResult } from "./stt.js";
18
18
  import type { StandardRecord, ValidationSchema } from "./aliases.js";
19
- import type { CSVProcessorOptions, FileWithMetadata, VideoProcessorOptions, VisionImageOutputFormat } from "./file.js";
19
+ import type { CSVProcessorOptions, FileWithMetadata, VideoProcessorOptions, VisionImageOutputFormat, OfficeProcessorOptions } from "./file.js";
20
20
  import type { WorkflowConfig } from "./workflow.js";
21
21
  import type { LanguageModel, StepResult } from "./providers.js";
22
22
  import type { Tool, ToolChoice } from "./tools.js";
@@ -366,6 +366,11 @@ export type StreamOptions = {
366
366
  * saying so.
367
367
  */
368
368
  videoOptions?: VideoProcessorOptions;
369
+ /**
370
+ * Office document processing options. Currently consumed by the XLSX path
371
+ * (`sheetName`, `formatStyle`); see OfficeProcessorOptions.
372
+ */
373
+ officeOptions?: OfficeProcessorOptions;
369
374
  /** PDF processing options (#258). */
370
375
  pdfOptions?: {
371
376
  /** Password for an encrypted PDF (image-conversion fallback path). */
@@ -321,3 +321,22 @@ export type ScalarRecoveryDecision = {
321
321
  } | {
322
322
  kind: "not-json";
323
323
  };
324
+ /**
325
+ * An element in the parsed HTML tree used for Markdown conversion.
326
+ */
327
+ export type MarkdownHtmlElementNode = {
328
+ kind: "element";
329
+ /** Lower-cased tag name. */
330
+ tag: string;
331
+ /** Lower-cased attribute names mapped to their decoded values. */
332
+ attrs: Record<string, string>;
333
+ children: MarkdownHtmlNode[];
334
+ };
335
+ /**
336
+ * A node in the parsed HTML tree used for Markdown conversion: either a text
337
+ * run or an element.
338
+ */
339
+ export type MarkdownHtmlNode = {
340
+ kind: "text";
341
+ value: string;
342
+ } | MarkdownHtmlElementNode;
@@ -341,14 +341,14 @@ export class FileDetector {
341
341
  logger.warn(`[FileDetector] All fallback parsing failed for type "${detection.type}". ` +
342
342
  `Attempted: ${options.allowedTypes.join(", ")}. Falling through to universal handler.`);
343
343
  const csvOptions = options?.csvOptions;
344
- const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
344
+ const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions, options?.officeOptions);
345
345
  FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
346
346
  return result;
347
347
  }
348
348
  const content = loadedUrl?.content ??
349
349
  (await FileDetector.loadContent(input, detection, options));
350
350
  const csvOptions = options?.csvOptions;
351
- const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
351
+ const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions, options?.officeOptions);
352
352
  FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
353
353
  return result;
354
354
  });
@@ -1063,7 +1063,7 @@ export class FileDetector {
1063
1063
  /**
1064
1064
  * Route to appropriate processor
1065
1065
  */
1066
- static async processFile(content, detection, options, provider, videoOptions) {
1066
+ static async processFile(content, detection, options, provider, videoOptions, officeOptions) {
1067
1067
  switch (detection.type) {
1068
1068
  case "csv":
1069
1069
  // Pass original extension through to CSV processor; if detection has none,
@@ -1087,7 +1087,7 @@ export class FileDetector {
1087
1087
  case "archive":
1088
1088
  return await FileDetector.processArchiveFile(content, detection);
1089
1089
  case "xlsx":
1090
- return await FileDetector.processXlsxFile(content, detection);
1090
+ return await FileDetector.processXlsxFile(content, detection, officeOptions);
1091
1091
  case "docx":
1092
1092
  return await FileDetector.processDocxFile(content, detection);
1093
1093
  case "pptx":
@@ -1283,7 +1283,7 @@ export class FileDetector {
1283
1283
  /**
1284
1284
  * Process Excel/OpenDocument spreadsheet file via ExcelProcessor or OpenDocumentProcessor
1285
1285
  */
1286
- static async processXlsxFile(content, detection) {
1286
+ static async processXlsxFile(content, detection, officeOptions) {
1287
1287
  const xlsxFilename = detection.metadata.filename || "spreadsheet";
1288
1288
  try {
1289
1289
  const ext = detection.extension?.toLowerCase();
@@ -1308,7 +1308,7 @@ export class FileDetector {
1308
1308
  }
1309
1309
  }
1310
1310
  else {
1311
- const { excelProcessor } = await import("../processors/document/ExcelProcessor.js");
1311
+ const { excelProcessor, formatWorksheetsForPrompt } = await import("../processors/document/ExcelProcessor.js");
1312
1312
  const xlsxResult = await excelProcessor.processFile({
1313
1313
  id: xlsxFilename,
1314
1314
  name: xlsxFilename,
@@ -1318,30 +1318,9 @@ export class FileDetector {
1318
1318
  buffer: content,
1319
1319
  });
1320
1320
  if (xlsxResult.success && xlsxResult.data) {
1321
- // Build text content from worksheets
1322
- const sheets = xlsxResult.data.worksheets || [];
1323
- let textContent = `Spreadsheet: ${sheets.length} sheet(s), ${xlsxResult.data.totalRows} total rows\n`;
1324
- for (const sheet of sheets) {
1325
- textContent += `\n### Sheet: ${sheet.name}\n`;
1326
- textContent += `Columns (${sheet.columnCount}): ${sheet.headers.join(", ")}\n`;
1327
- textContent += `Rows: ${sheet.rowCount}\n`;
1328
- // Include first rows as sample data
1329
- const sampleRows = sheet.rows.slice(0, 20);
1330
- const rowText = sampleRows
1331
- .map((row) => row.map((c) => String(c ?? "")).join("\t"))
1332
- .join("\n");
1333
- if (!rowText) {
1334
- continue;
1335
- }
1336
- textContent += `\nData:\n${sheet.headers.join("\t")}\n${rowText}\n`;
1337
- const remaining = sheet.rowCount - 20;
1338
- if (remaining > 0) {
1339
- textContent += `... (${remaining} more rows)\n`;
1340
- }
1341
- }
1342
1321
  return {
1343
1322
  type: "xlsx",
1344
- content: textContent,
1323
+ content: formatWorksheetsForPrompt(xlsxResult.data.worksheets || [], xlsxResult.data.totalRows, officeOptions),
1345
1324
  mimeType: detection.mimeType,
1346
1325
  metadata: detection.metadata,
1347
1326
  };
@@ -1374,13 +1353,18 @@ export class FileDetector {
1374
1353
  try {
1375
1354
  if (ext === "odt") {
1376
1355
  const { openDocumentProcessor } = await import("../processors/document/OpenDocumentProcessor.js");
1377
- const odtResult = await openDocumentProcessor.processFile({
1356
+ // Bounded per the project's async-timeout guideline, matching the ODP
1357
+ // branch of processPptxFile below: this unzips and parses
1358
+ // attacker-supplied bytes, and a stalled parse would otherwise hold
1359
+ // the request open with no ceiling. On timeout the throw lands in
1360
+ // this block's existing catch, which degrades to the placeholder.
1361
+ const odtResult = await withTimeout(openDocumentProcessor.processFile({
1378
1362
  id: docxFilename,
1379
1363
  name: docxFilename,
1380
1364
  mimetype: detection.mimeType || "application/vnd.oasis.opendocument.text",
1381
1365
  size: content.length,
1382
1366
  buffer: content,
1383
- });
1367
+ }), FileDetector.DEFAULT_DOCUMENT_TIMEOUT);
1384
1368
  if (odtResult.success && odtResult.data) {
1385
1369
  return {
1386
1370
  type: "docx",
@@ -1393,13 +1377,14 @@ export class FileDetector {
1393
1377
  }
1394
1378
  else if (ext === "rtf") {
1395
1379
  const { rtfProcessor } = await import("../processors/document/RtfProcessor.js");
1396
- const rtfResult = await rtfProcessor.processFile({
1380
+ // Bounded for the same reason as the odt branch above.
1381
+ const rtfResult = await withTimeout(rtfProcessor.processFile({
1397
1382
  id: docxFilename,
1398
1383
  name: docxFilename,
1399
1384
  mimetype: detection.mimeType || "application/rtf",
1400
1385
  size: content.length,
1401
1386
  buffer: content,
1402
- });
1387
+ }), FileDetector.DEFAULT_DOCUMENT_TIMEOUT);
1403
1388
  if (rtfResult.success && rtfResult.data) {
1404
1389
  return {
1405
1390
  type: "docx",
@@ -1412,19 +1397,29 @@ export class FileDetector {
1412
1397
  }
1413
1398
  else {
1414
1399
  const { wordProcessor } = await import("../processors/document/WordProcessor.js");
1415
- const docxResult = await wordProcessor.processFile({
1400
+ // Bounded for the same reason as the odt branch above. This call now
1401
+ // also runs htmlToMarkdown's recursive HTML-to-markdown rendering
1402
+ // over mammoth's output, so it is real additional work inside what
1403
+ // was already an unbounded call.
1404
+ const docxResult = await withTimeout(wordProcessor.processFile({
1416
1405
  id: docxFilename,
1417
1406
  name: docxFilename,
1418
1407
  mimetype: detection.mimeType ||
1419
1408
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1420
1409
  size: content.length,
1421
1410
  buffer: content,
1422
- });
1411
+ }), FileDetector.DEFAULT_DOCUMENT_TIMEOUT);
1423
1412
  if (docxResult.success && docxResult.data) {
1413
+ // Prefer the markdown rendering: it keeps the headings, lists and
1414
+ // tables that the plain-text extraction flattens away. Plain text
1415
+ // remains the fallback for a document with no convertible structure.
1416
+ const { markdownContent, textContent, wordCount, paragraphCount, characterCount, } = docxResult.data;
1417
+ const body = markdownContent || textContent;
1424
1418
  return {
1425
1419
  type: "docx",
1426
- content: docxResult.data.textContent ||
1427
- FileDetector.formatInformativePlaceholder("Document", docxFilename, content, detection),
1420
+ content: body
1421
+ ? `Document: ${wordCount} words, ${paragraphCount} paragraphs, ${characterCount} characters\n\n${body}`
1422
+ : FileDetector.formatInformativePlaceholder("Document", docxFilename, content, detection),
1428
1423
  mimeType: detection.mimeType,
1429
1424
  metadata: detection.metadata,
1430
1425
  };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * HTML to Markdown conversion for document processors.
3
+ *
4
+ * Scoped deliberately to the small, well-formed HTML subset that document
5
+ * converters emit (mammoth for DOCX): headings, paragraphs, emphasis, links,
6
+ * images, lists, block quotes, preformatted text and tables. It is not a
7
+ * general-purpose web-page converter, which is why the repository gains no
8
+ * dependency for it.
9
+ *
10
+ * @module utils/htmlToMarkdown
11
+ */
12
+ /**
13
+ * Convert an HTML fragment to Markdown.
14
+ *
15
+ * @param html - HTML produced by a document converter
16
+ * @returns Markdown text, or an empty string when the fragment has no content
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * htmlToMarkdown("<h1>Title</h1><p>Body</p>"); // "# Title\n\nBody"
21
+ * ```
22
+ */
23
+ export declare function htmlToMarkdown(html: string): string;