@juspay/neurolink 9.94.4 → 9.94.6

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.
@@ -15,6 +15,7 @@
15
15
  * jina_ (Jina), fish- (Fish Audio)
16
16
  * - Generic key=value pairs: api_key=…, access_token: …, secret_key=…
17
17
  */
18
+ import { basename, resolve as resolvePath } from "path";
18
19
  const TOKEN_PREFIXES = [
19
20
  "sk",
20
21
  "pk",
@@ -156,12 +157,161 @@ export function safeDebugSerialize(value, maxLen = 10_000) {
156
157
  * for diagnostics. Use this for any `baseURL`/endpoint a caller may have
157
158
  * supplied with inline credentials. The match is global, so every `//…@`
158
159
  * authority in the string is redacted (e.g. a proxy chain or a URL embedded
159
- * in a query parameter), not just the first.
160
+ * in a query parameter), not just the first. IPv6 literal hosts (`//u:p@[::1]/x`)
161
+ * and percent-encoded userinfo (`//u:p%40x@host/x`) are handled naturally —
162
+ * neither `[`/`]` nor `%` stop either pass below, so the bracketed host or
163
+ * encoded byte just rides along as part of the (non-redacted) remainder.
164
+ *
165
+ * This is best-effort defense-in-depth for free-form logging, deliberately
166
+ * regex-based so it can scrub *multiple* embedded authorities out of
167
+ * arbitrary text (a real URL parser only ever parses one URL at a time).
168
+ * It is NOT the primary protection for user-facing errors — that's
169
+ * {@link redactUrlForError}, which parses the single input via `new URL()`
170
+ * and reconstructs from protocol+host+pathname, never touching userinfo at
171
+ * all. Prefer that function whenever the input is known to be exactly one
172
+ * URL.
173
+ *
174
+ * Two passes, deliberately:
175
+ * 1. Well-formed authorities — credentials with no raw `/` — bounded at the
176
+ * authority's first `/` (or string end). Greedy backtracking naturally
177
+ * absorbs multiple `@` segments within one authority (e.g. `//a@b@host`
178
+ * → `//***@host`) while leaving a second, separate `//...@` authority
179
+ * elsewhere in the string (e.g. one embedded in a query parameter)
180
+ * untouched, because pass 1's own exclusion of `/` stops it at the first
181
+ * `/`, before it can ever reach a nested authority.
182
+ * 2. Malformed authorities whose credentials contain an unescaped `/`
183
+ * (e.g. `//user:sec/ret@host/path`) — pass 1 can't find an `@` before
184
+ * hitting that `/`, so this pass allows `/` and redacts up to the last
185
+ * remaining `@`. It stops before any *nested* `//` (via a negative
186
+ * lookahead) rather than excluding a specific character like `*` —
187
+ * excluding a literal character is what let a password containing both
188
+ * `/` and `*` (e.g. `//user:pa*ss/word@host/path`, legal under RFC 3986)
189
+ * slip through both passes entirely in an earlier version of this
190
+ * function.
160
191
  *
161
192
  * @param url - The URL (or URL-shaped string) to redact.
162
193
  */
163
194
  export function redactUrlCredentials(url) {
164
- return url.replace(/\/\/[^/@]+@/g, "//***@");
195
+ const wellFormed = url.replace(/\/\/[^/\s"'<>]*@/g, "//***@");
196
+ return wellFormed.replace(/\/\/(?:(?!\/\/)[^\s"'<>])*@/g, "//***@");
197
+ }
198
+ /**
199
+ * Reduce a URL to its origin + pathname for safe inclusion in error messages
200
+ * and logs, dropping the query string and fragment — where presigned-URL
201
+ * signatures, SAS tokens, and other secrets commonly live — while keeping
202
+ * enough of the URL (scheme, host, path) for the error to stay diagnostic.
203
+ *
204
+ * Falls back to a best-effort redacted string (query/fragment and
205
+ * `user:pass@` stripped) when the input isn't a parseable absolute URL.
206
+ * Either way the result is capped at 200 chars so a very long host/path
207
+ * can't blow up the error message.
208
+ *
209
+ * @param url - The URL (or URL-shaped string) to redact.
210
+ */
211
+ export function redactUrlForError(url) {
212
+ const truncate = (s) => s.length > 200 ? `${s.slice(0, 200)}…` : s;
213
+ try {
214
+ const parsed = new URL(url);
215
+ // Reconstruct explicitly from protocol + host + pathname — never
216
+ // parsed.username/parsed.password, and never parsed.href (which
217
+ // re-serializes any embedded userinfo) — so a `user:pass@host` in the
218
+ // input can't leak into a diagnostic error message.
219
+ return truncate(`${parsed.protocol}//${parsed.host}${parsed.pathname}`);
220
+ }
221
+ catch {
222
+ // Unparseable input: `new URL()` gave us nothing to work with, so
223
+ // best-effort drop the query/fragment (where secrets like tokens live)
224
+ // and strip any `//user:pass@` segment before falling back to the raw
225
+ // string — never echo the input completely unredacted.
226
+ const withoutQueryOrFragment = url.split(/[?#]/)[0];
227
+ return truncate(redactUrlCredentials(withoutQueryOrFragment));
228
+ }
229
+ }
230
+ /**
231
+ * Scrub any embedded absolute URLs out of free-form text — typically the
232
+ * `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
233
+ * undici's `request()`, or Node's resolver, all of which frequently embed
234
+ * the full request URL (query string, credentials, and all) verbatim.
235
+ * Interpolating that message raw into a thrown or logged error would leak a
236
+ * presigned URL's token even after the explicit URL argument was already
237
+ * redacted via {@link redactUrlForError}. Finds each `scheme://…` run in the
238
+ * text and replaces it with its redacted form.
239
+ *
240
+ * @param text - Free-form text (typically `Error.message`) to scrub.
241
+ */
242
+ export function redactUrlsInText(text) {
243
+ return text.replace(/[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"'<>)]+/g, (match) => redactUrlForError(match));
244
+ }
245
+ /**
246
+ * Redact every occurrence of `filePath` from an error message, checking both
247
+ * the path exactly as given and its `path.resolve()`'d form.
248
+ *
249
+ * Node's own fs errors (ENOENT/EACCES) embed the exact string passed to the
250
+ * fs call, so a plain substring match usually suffices — but wrapped or
251
+ * lower-level errors sometimes normalize the path first (relative→absolute,
252
+ * trailing-slash variations), which an exact-match-only redaction would miss.
253
+ * This is defense-in-depth, not a guarantee: a `realpath`-resolved (symlink-
254
+ * following) variant can still differ from both forms and would slip through.
255
+ *
256
+ * @param message - The error message to scrub.
257
+ * @param filePath - The path to redact (both as given and resolved).
258
+ */
259
+ export function redactPathFromMessage(message, filePath) {
260
+ const safeName = basename(filePath);
261
+ // Longest-first: the resolved (absolute) form always contains the relative
262
+ // form as a substring, so redacting the relative form first would only
263
+ // strip the trailing segment of an absolute path and leave its parent
264
+ // directory behind (e.g. "/Users/me/project/foo/bar.png" redacting
265
+ // "foo/bar.png" first leaves "/Users/me/project/bar.png"). Redacting the
266
+ // longer resolved variant first removes the whole absolute path in one
267
+ // pass, so the shorter relative pass below has nothing left to match.
268
+ return message
269
+ .split(resolvePath(filePath))
270
+ .join(safeName)
271
+ .split(filePath)
272
+ .join(safeName);
273
+ }
274
+ /**
275
+ * Build a sanitized copy of a network/URL/file error, safe to attach as
276
+ * `Error.cause` on a rethrow.
277
+ *
278
+ * Redacting only the OUTER thrown error's message isn't enough: if the RAW
279
+ * underlying error is attached as `cause`, anything that walks the cause
280
+ * chain (cause-aware logging, telemetry spans) can still recover an
281
+ * unredacted URL/path/secret via `error.cause.message`. This returns a NEW
282
+ * `Error` whose message has been through {@link redactUrlsInText} — and,
283
+ * when `options.filePath` is supplied, {@link redactPathFromMessage} too, so
284
+ * a filesystem error's ENOENT/EACCES text doesn't leak the host's directory
285
+ * layout via the cause chain the way the outer message already avoids.
286
+ * `.name` and `.code` (`NodeJS.ErrnoException`) are copied over so retry
287
+ * classification (e.g. `isRetryableNetworkError`) still works when it
288
+ * inspects the cause. The raw original is never referenced by the result.
289
+ *
290
+ * Handles non-`Error` thrown values too (a raw string/object can just as
291
+ * easily carry an unredacted URL or path) by running their `String()` form
292
+ * through the same redaction before wrapping.
293
+ *
294
+ * @param error - The raw underlying error (or thrown value) to sanitize.
295
+ * @param options - `filePath`: a known filesystem path to redact to its
296
+ * basename, in addition to URL redaction.
297
+ */
298
+ export function sanitizeErrorCause(error, options) {
299
+ const redact = (text) => {
300
+ const urlSafe = redactUrlsInText(text);
301
+ return options?.filePath
302
+ ? redactPathFromMessage(urlSafe, options.filePath)
303
+ : urlSafe;
304
+ };
305
+ if (!(error instanceof Error)) {
306
+ return new Error(redact(String(error)));
307
+ }
308
+ const sanitized = new Error(redact(error.message));
309
+ sanitized.name = error.name;
310
+ const code = error.code;
311
+ if (code !== undefined) {
312
+ sanitized.code = code;
313
+ }
314
+ return sanitized;
165
315
  }
166
316
  /**
167
317
  * Recursively sanitize a record/array, returning a structurally identical
@@ -5,12 +5,12 @@ import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerIma
5
5
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
6
6
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
7
7
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
8
- import { SIZE_TIER_THRESHOLDS } from "../types/index.js";
8
+ import { isCSVContent, SIZE_TIER_THRESHOLDS } from "../types/index.js";
9
9
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
10
- import { NeuroLinkError } from "./errorHandling.js";
10
+ import { ErrorFactory, NeuroLinkError, withTimeout } from "./errorHandling.js";
11
11
  import { FileDetector } from "./fileDetector.js";
12
12
  import { getImageCache } from "./imageCache.js";
13
- import { ImageProcessor } from "./imageProcessor.js";
13
+ import { ImageProcessor, imageUtils } from "./imageProcessor.js";
14
14
  import { logger } from "./logger.js";
15
15
  import { PDFImageConverter, PDFProcessor } from "./pdfProcessor.js";
16
16
  import { urlDownloadRateLimiter } from "./rateLimiter.js";
@@ -860,7 +860,11 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
860
860
  }
861
861
  catch (error) {
862
862
  const errMsg = error instanceof Error ? error.message : String(error);
863
- logger.error(`[NEUROLINK] File skipped/failed: ${filename}reason: ${errMsg}`);
863
+ // #273: don't silently drop a failed filelog, then throw so the
864
+ // caller learns the file couldn't be processed (matches the explicit
865
+ // pdf/csv paths' fail-loud behavior).
866
+ logger.error(`[NEUROLINK] File processing failed: ${filename} — reason: ${errMsg}`);
867
+ throw ErrorFactory.fileProcessingFailed(filename, error instanceof Error ? error : new Error(errMsg));
864
868
  }
865
869
  }
866
870
  span.setAttribute(ATTR.FILE_INCLUDED_COUNT, includedCount);
@@ -935,10 +939,12 @@ async function processExplicitCsvFiles(options) {
935
939
  logger.info(`[CSV] ✅ Processed: ${filename}`);
936
940
  }
937
941
  catch (error) {
938
- logger.error(`[CSV] ❌ Failed:`, error);
939
942
  const filename = extractFilename(csvFile, i);
940
- options.input.text += `\n\n## CSV Data Error: Failed to process "${filename}"`;
941
- options.input.text += `\nReason: ${error instanceof Error ? error.message : "Unknown error"}`;
943
+ const errMsg = error instanceof Error ? error.message : String(error);
944
+ // #273: fail loud instead of embedding the error into the prompt text
945
+ // (which the model would then "analyze"). Log, then throw.
946
+ logger.error(`[CSV] ❌ Failed to process ${filename}: ${errMsg}`);
947
+ throw ErrorFactory.csvProcessingFailed(filename, error instanceof Error ? error : new Error(errMsg));
942
948
  }
943
949
  }
944
950
  }
@@ -1067,6 +1073,18 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1067
1073
  // local const so TypeScript sees the definite (non-optional) type in the
1068
1074
  // rest of this function, avoiding 60+ "possibly undefined" errors.
1069
1075
  const inp = options.input;
1076
+ // #284: audioFiles/videoFiles have no dedicated processor — route them
1077
+ // through the same auto-detecting `files` pipeline that already
1078
+ // understands "audio"/"video" FileDetector results (see
1079
+ // appendDetectedFileResult), instead of silently dropping them once
1080
+ // detectMultimodal() routes an audio/video-only request here.
1081
+ if (inp.audioFiles?.length || inp.videoFiles?.length) {
1082
+ inp.files = [
1083
+ ...(inp.files || []),
1084
+ ...(inp.audioFiles || []),
1085
+ ...(inp.videoFiles || []),
1086
+ ];
1087
+ }
1070
1088
  // Compute provider-specific max PDF size once for consistent validation
1071
1089
  const pdfConfig = PDFProcessor.getProviderConfig(provider);
1072
1090
  const maxSize = pdfConfig
@@ -1088,6 +1106,13 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1088
1106
  const hasPDFs = pdfFiles.length > 0;
1089
1107
  // If no images or PDFs, use standard message building and convert to MultimodalChatMessage[]
1090
1108
  if (!hasImages && !hasPDFs) {
1109
+ // #289: CSV content[] items don't need vision, so they never reach the
1110
+ // multimodal converter below — process them into the prompt text here
1111
+ // (otherwise a `content: [{type:"csv"}]`-only request silently drops it).
1112
+ const csvContentItems = inp.content?.filter(isCSVContent) ?? [];
1113
+ if (csvContentItems.length > 0) {
1114
+ inp.text = await appendCsvContentToText(csvContentItems, inp.text ?? "");
1115
+ }
1091
1116
  if (inp.csvFiles) {
1092
1117
  inp.csvFiles = [];
1093
1118
  }
@@ -1216,6 +1241,47 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1216
1241
  throw error;
1217
1242
  }
1218
1243
  }
1244
+ /**
1245
+ * Timeout for detecting/parsing an in-memory CSV `content[]` buffer (#325
1246
+ * review, round 2). Bounds a stalled detector rather than blocking the
1247
+ * request indefinitely.
1248
+ */
1249
+ const CSV_CONTENT_DETECTION_TIMEOUT_MS = 30_000;
1250
+ /**
1251
+ * #289: process CSV `content[]` items into appended prompt text. CSV is
1252
+ * delivered to the model as text (like the explicit `csvFiles` path), so this
1253
+ * is shared by both the no-vision gate and the multimodal converter.
1254
+ */
1255
+ async function appendCsvContentToText(csvItems, baseText) {
1256
+ let text = baseText;
1257
+ for (const csv of csvItems) {
1258
+ const raw = csv.data;
1259
+ if (raw === undefined) {
1260
+ continue;
1261
+ }
1262
+ // #325: raw CSV text (e.g. "a,b\n1,2") is not base64 — decoding it as
1263
+ // base64 silently corrupts the content. Only treat the string as base64
1264
+ // when it actually validates as such; otherwise treat it as literal
1265
+ // UTF-8 CSV text, matching how Buffer inputs are already handled as-is.
1266
+ const buffer = typeof raw === "string"
1267
+ ? imageUtils.isValidBase64(raw)
1268
+ ? Buffer.from(raw, "base64")
1269
+ : Buffer.from(raw, "utf-8")
1270
+ : raw;
1271
+ const name = csv.metadata?.filename || "data.csv";
1272
+ // #325 review (round 2): a stalled/hung detector must not block the
1273
+ // request indefinitely — wrap with the project's standard withTimeout.
1274
+ const result = await withTimeout(FileDetector.detectAndProcess(buffer, {
1275
+ allowedTypes: ["csv"],
1276
+ csvOptions: {
1277
+ maxRows: csv.metadata?.maxRows,
1278
+ formatStyle: csv.metadata?.formatStyle,
1279
+ },
1280
+ }), CSV_CONTENT_DETECTION_TIMEOUT_MS, new Error(`Timed out processing CSV content "${name}" after ${CSV_CONTENT_DETECTION_TIMEOUT_MS}ms`));
1281
+ text += `${text ? "\n\n" : ""}## CSV Data from ${name}\n${result.content}`;
1282
+ }
1283
+ return text;
1284
+ }
1219
1285
  /**
1220
1286
  * Convert advanced content format to provider-specific format
1221
1287
  */
@@ -1223,14 +1289,19 @@ async function convertContentToProviderFormat(content, provider, _model) {
1223
1289
  const textContent = content.find((c) => c.type === "text");
1224
1290
  const imageContent = content.filter((c) => c.type === "image");
1225
1291
  const pdfContent = content.filter((c) => c.type === "pdf");
1292
+ const csvContent = content.filter(isCSVContent);
1226
1293
  // Allow empty text when multimodal content is present (enables image-only or PDF-only queries)
1227
- const text = textContent?.text || "";
1294
+ let text = textContent?.text || "";
1295
+ // #289: CSV content[] items were silently dropped — fold each into the text.
1296
+ if (csvContent.length > 0) {
1297
+ text = await appendCsvContentToText(csvContent, text);
1298
+ }
1228
1299
  const hasMultimodal = imageContent.length > 0 || pdfContent.length > 0;
1229
1300
  // Validate that we have at least some content
1230
1301
  if (!hasMultimodal && !text) {
1231
1302
  throw new Error("Content must include either text or multimodal content");
1232
1303
  }
1233
- // Text-only case
1304
+ // Text-only case (CSV has already been folded into `text`)
1234
1305
  if (imageContent.length === 0 && pdfContent.length === 0) {
1235
1306
  return text;
1236
1307
  }
@@ -53,6 +53,7 @@ export type GenerateOptions = {
53
53
  images?: Array<Buffer | string | ImageWithAltText>;
54
54
  csvFiles?: Array<Buffer | string>;
55
55
  pdfFiles?: Array<Buffer | string>;
56
+ audioFiles?: Array<Buffer | string>;
56
57
  videoFiles?: Array<Buffer | string>;
57
58
  files?: Array<Buffer | string | FileWithMetadata>;
58
59
  content?: Content[];
@@ -419,15 +419,46 @@ export type MultimodalInput = {
419
419
  segments?: DirectorSegment[];
420
420
  };
421
421
  /**
422
- * Content format for multimodal messages (used internally)
423
- * Compatible with Vercel AI SDK message format
422
+ * Content format for multimodal messages (used internally).
423
+ *
424
+ * #325: the loose `[key: string]: unknown` index signature has been replaced
425
+ * with the concrete fields the codebase actually reads/writes across the
426
+ * text / image / file / tool-call / tool-result shapes. This keeps the broad
427
+ * structural compatibility the internal pipeline relies on (a single object
428
+ * type, not a strict discriminated union that would force narrowing at every
429
+ * consumer) while removing the "any key is allowed" hole that let typos and
430
+ * unrelated keys through unchecked.
424
431
  */
425
432
  export type MessageContent = {
426
433
  type: string;
434
+ /** Text content (`type: "text"`). */
427
435
  text?: string;
436
+ /** Base64 / data-URI image (`type: "image"`). */
428
437
  image?: string;
438
+ /** MIME type for image/file parts. */
429
439
  mimeType?: string;
430
- [key: string]: unknown;
440
+ /** Raw file bytes or base64 (`type: "file"`/document parts). */
441
+ data?: string | Buffer;
442
+ /** File name for document/file parts. */
443
+ name?: string;
444
+ /** File name (alias used by some file parts). */
445
+ filename?: string;
446
+ /** Tool-call identifier (`type: "tool-call"`/`"tool-result"`). */
447
+ toolCallId?: string;
448
+ /** Tool name (`type: "tool-call"`/`"tool-result"`). */
449
+ toolName?: string;
450
+ /** Tool-call arguments (`type: "tool-call"`). */
451
+ args?: Record<string, unknown>;
452
+ /** Tool-result payload (`type: "tool-result"`). */
453
+ result?: unknown;
454
+ /** Whether a tool-result represents an error (`type: "tool-result"`). */
455
+ isError?: boolean;
456
+ /**
457
+ * Provider-specific per-block options (e.g. Anthropic cache_control).
458
+ * Read as `item.providerOptions` when converting `MessageContent[]` to
459
+ * `ModelMessage[]` in `MessageBuilder.ts`.
460
+ */
461
+ providerOptions?: Record<string, unknown>;
431
462
  };
432
463
  /**
433
464
  * Extended chat message for multimodal support (internal use)
@@ -193,6 +193,7 @@ export type StreamOptions = {
193
193
  images?: Array<Buffer | string | ImageWithAltText>;
194
194
  csvFiles?: Array<Buffer | string>;
195
195
  pdfFiles?: Array<Buffer | string>;
196
+ audioFiles?: Array<Buffer | string>;
196
197
  videoFiles?: Array<Buffer | string>;
197
198
  files?: Array<Buffer | string | FileWithMetadata>;
198
199
  content?: Content[];
@@ -35,6 +35,9 @@ export declare const ERROR_CODES: {
35
35
  readonly IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL";
36
36
  readonly INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT";
37
37
  readonly INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE";
38
+ readonly IMAGE_BUFFER_INVALID: "IMAGE_BUFFER_INVALID";
39
+ readonly FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED";
40
+ readonly CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED";
38
41
  readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
39
42
  readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
40
43
  readonly RATE_LIMITER_QUEUE_TIMEOUT: "RATE_LIMITER_QUEUE_TIMEOUT";
@@ -216,6 +219,13 @@ export declare class ErrorFactory {
216
219
  * Create an invalid image format error
217
220
  */
218
221
  static invalidImageFormat(): NeuroLinkError;
222
+ /**
223
+ * Create an image buffer validation error: empty, undersized, or a
224
+ * truncated buffer detected by `ImageProcessor.validateBufferNotEmpty()`.
225
+ * Takes the fully-formed message so call sites keep their specific
226
+ * byte-count detail instead of a fixed generic message.
227
+ */
228
+ static imageBufferInvalid(message: string): NeuroLinkError;
219
229
  /**
220
230
  * Create a rate limiter queue full error
221
231
  */
@@ -287,6 +297,18 @@ export declare class ErrorFactory {
287
297
  * generic `Error` instances.
288
298
  */
289
299
  static proxyWorkerLifecycle(message: string, context?: Record<string, unknown>): NeuroLinkError;
300
+ /**
301
+ * Create a generic file-processing-failed error (e.g. an unrecognized or
302
+ * corrupt file in `processUnifiedFilesArray`). Preserves the original error
303
+ * as `originalError` (stack + message copied onto the new error's context).
304
+ */
305
+ static fileProcessingFailed(filename: string, originalError: Error): NeuroLinkError;
306
+ /**
307
+ * Create a generic CSV-processing-failed error (explicit `csvFiles` path,
308
+ * distinct from `fileProcessingFailed` so callers can classify CSV-specific
309
+ * failures separately). Preserves the original error as `originalError`.
310
+ */
311
+ static csvProcessingFailed(filename: string, originalError: Error): NeuroLinkError;
290
312
  }
291
313
  /**
292
314
  * Timeout wrapper for async operations
@@ -46,6 +46,10 @@ export const ERROR_CODES = {
46
46
  IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL",
47
47
  INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT",
48
48
  INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE",
49
+ IMAGE_BUFFER_INVALID: "IMAGE_BUFFER_INVALID",
50
+ // Generic file/CSV processing errors
51
+ FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED",
52
+ CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED",
49
53
  // PDF validation errors
50
54
  PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
51
55
  // Rate limiter errors
@@ -627,6 +631,22 @@ export class ErrorFactory {
627
631
  },
628
632
  });
629
633
  }
634
+ /**
635
+ * Create an image buffer validation error: empty, undersized, or a
636
+ * truncated buffer detected by `ImageProcessor.validateBufferNotEmpty()`.
637
+ * Takes the fully-formed message so call sites keep their specific
638
+ * byte-count detail instead of a fixed generic message.
639
+ */
640
+ static imageBufferInvalid(message) {
641
+ return new NeuroLinkError({
642
+ code: ERROR_CODES.IMAGE_BUFFER_INVALID,
643
+ message,
644
+ category: ErrorCategory.VALIDATION,
645
+ severity: ErrorSeverity.MEDIUM,
646
+ retriable: false,
647
+ context: { field: "input.images" },
648
+ });
649
+ }
630
650
  // ============================================================================
631
651
  // RATE LIMITER ERRORS
632
652
  // ============================================================================
@@ -924,6 +944,41 @@ export class ErrorFactory {
924
944
  context: context || {},
925
945
  });
926
946
  }
947
+ // ============================================================================
948
+ // GENERIC FILE / CSV PROCESSING ERRORS
949
+ // ============================================================================
950
+ /**
951
+ * Create a generic file-processing-failed error (e.g. an unrecognized or
952
+ * corrupt file in `processUnifiedFilesArray`). Preserves the original error
953
+ * as `originalError` (stack + message copied onto the new error's context).
954
+ */
955
+ static fileProcessingFailed(filename, originalError) {
956
+ return new NeuroLinkError({
957
+ code: ERROR_CODES.FILE_PROCESSING_FAILED,
958
+ message: `Failed to process file "${filename}": ${originalError.message}`,
959
+ category: ErrorCategory.EXECUTION,
960
+ severity: ErrorSeverity.HIGH,
961
+ retriable: false,
962
+ context: { filename },
963
+ originalError,
964
+ });
965
+ }
966
+ /**
967
+ * Create a generic CSV-processing-failed error (explicit `csvFiles` path,
968
+ * distinct from `fileProcessingFailed` so callers can classify CSV-specific
969
+ * failures separately). Preserves the original error as `originalError`.
970
+ */
971
+ static csvProcessingFailed(filename, originalError) {
972
+ return new NeuroLinkError({
973
+ code: ERROR_CODES.CSV_PROCESSING_FAILED,
974
+ message: `Failed to process CSV file "${filename}": ${originalError.message}`,
975
+ category: ErrorCategory.EXECUTION,
976
+ severity: ErrorSeverity.HIGH,
977
+ retriable: false,
978
+ context: { filename },
979
+ originalError,
980
+ });
981
+ }
927
982
  }
928
983
  /**
929
984
  * Timeout wrapper for async operations