@juspay/neurolink 12.29.1 → 12.30.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +376 -374
  3. package/dist/cli/factories/commandFactory.js +2 -1
  4. package/dist/core/baseProvider.js +1 -0
  5. package/dist/core/modules/MessageBuilder.js +8 -0
  6. package/dist/processors/media/VideoProcessor.d.ts +56 -4
  7. package/dist/processors/media/VideoProcessor.js +263 -19
  8. package/dist/providers/catalog/deepseek.json +2 -1
  9. package/dist/providers/catalog/loader.js +3 -0
  10. package/dist/providers/catalog/provider-catalog.schema.json +4 -0
  11. package/dist/providers/catalog/schema.d.ts +1 -0
  12. package/dist/providers/catalog/schema.js +1 -0
  13. package/dist/providers/configuredOpenAICompat.d.ts +1 -0
  14. package/dist/providers/configuredOpenAICompat.js +3 -0
  15. package/dist/providers/openaiChatCompletionsBase.d.ts +8 -0
  16. package/dist/providers/openaiChatCompletionsBase.js +16 -2
  17. package/dist/providers/openaiChatCompletionsClient.d.ts +1 -1
  18. package/dist/providers/openaiChatCompletionsClient.js +13 -2
  19. package/dist/types/file.d.ts +20 -2
  20. package/dist/types/generate.d.ts +20 -26
  21. package/dist/types/openaiCompatible.d.ts +3 -0
  22. package/dist/types/processor.d.ts +43 -0
  23. package/dist/types/providerCatalog.d.ts +5 -0
  24. package/dist/types/providers.d.ts +3 -0
  25. package/dist/types/stream.d.ts +11 -11
  26. package/dist/utils/fileDetector.d.ts +15 -0
  27. package/dist/utils/fileDetector.js +40 -7
  28. package/dist/utils/mediaDuration.d.ts +19 -0
  29. package/dist/utils/mediaDuration.js +23 -0
  30. package/dist/utils/messageBuilder.js +4 -15
  31. package/dist/utils/multimodalOptionsBuilder.d.ts +2 -0
  32. package/dist/utils/multimodalOptionsBuilder.js +7 -0
  33. package/docs-site/static/search-index.json +6 -6
  34. package/package.json +3 -2
@@ -262,6 +262,16 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
262
262
  useStreamingWireForGenerate() {
263
263
  return false;
264
264
  }
265
+ /**
266
+ * When true, an assistant turn's reasoning goes back to the vendor as
267
+ * `reasoning_content` on every later request of the conversation. DeepSeek
268
+ * documents this as required once tools are in play and answers 400
269
+ * without it. Default false: strict OpenAI-compatible backends reject a
270
+ * field they don't know. Read per request, never during construction.
271
+ */
272
+ replayReasoningContent() {
273
+ return false;
274
+ }
265
275
  /**
266
276
  * Hook to adjust the fully-built wire request body before it is sent, on
267
277
  * both the streaming and non-streaming paths. Default identity. Override for
@@ -542,6 +552,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
542
552
  const resolveWireMaxTokens = this.resolveWireMaxTokens.bind(this);
543
553
  const suppressResponseFormatWithTools = this.suppressResponseFormatWithTools.bind(this);
544
554
  const useStreamingWireForGenerate = this.useStreamingWireForGenerate.bind(this);
555
+ const replayReasoningContent = this.replayReasoningContent.bind(this);
545
556
  const getTimeoutForOptions = (opts) => this.getTimeout((opts ?? {}));
546
557
  return {
547
558
  specificationVersion: "v3",
@@ -555,7 +566,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
555
566
  const wireNameMaps = buildWireToolNameMaps((options.tools ?? [])
556
567
  .filter((t) => t.type === "function")
557
568
  .map((t) => t.name));
558
- const baseMessages = messageBuilderToOpenAI(options.prompt, wireNameMaps?.toWire);
569
+ const baseMessages = messageBuilderToOpenAI(options.prompt, wireNameMaps?.toWire, replayReasoningContent());
559
570
  const hasTools = Array.isArray(options.tools) && options.tools.length > 0;
560
571
  const responseFormat = options.responseFormat &&
561
572
  !(hasTools && suppressResponseFormatWithTools())
@@ -1657,7 +1668,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1657
1668
  const transformedPrompt = Array.isArray(params?.prompt)
1658
1669
  ? params.prompt
1659
1670
  : promptMessages;
1660
- const conversation = messageBuilderToOpenAI(transformedPrompt, wireNameMaps?.toWire);
1671
+ const conversation = messageBuilderToOpenAI(transformedPrompt, wireNameMaps?.toWire, this.replayReasoningContent());
1661
1672
  loopConversation = conversation;
1662
1673
  const sampled = {
1663
1674
  ...options,
@@ -2171,6 +2182,9 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2171
2182
  role: "assistant",
2172
2183
  content: stepResult.text.length > 0 ? stepResult.text : null,
2173
2184
  tool_calls: toolCallsForMessage,
2185
+ ...(this.replayReasoningContent() && stepResult.reasoning
2186
+ ? { reasoning_content: stepResult.reasoning }
2187
+ : {}),
2174
2188
  });
2175
2189
  for (const [, t] of stepResult.toolCalls) {
2176
2190
  const startedAt = new Date();
@@ -48,7 +48,7 @@ export declare const stringifyToolInput: (input: unknown) => string;
48
48
  export declare const stringifyToolOutput: (output: unknown) => string;
49
49
  export declare const imageDataToURL: (data: unknown, mediaType?: string) => string | undefined;
50
50
  export declare const convertContentForOpenAI: (content: unknown) => string | OpenAICompatMessageContent[];
51
- export declare const messageBuilderToOpenAI: (messages: ReadonlyArray<OpenAICompatMessage>, toolNameToWire?: Map<string, string>) => OpenAICompatChatMessage[];
51
+ export declare const messageBuilderToOpenAI: (messages: ReadonlyArray<OpenAICompatMessage>, toolNameToWire?: Map<string, string>, replayReasoning?: boolean) => OpenAICompatChatMessage[];
52
52
  export declare const buildToolsForOpenAI: (tools: Record<string, Tool>, toolNameToWire?: Map<string, string>) => OpenAICompatChatTool[] | undefined;
53
53
  export declare const v3ToolsToOpenAI: (tools: OpenAICompatV3CallTools | undefined, toolNameToWire?: Map<string, string>) => OpenAICompatChatTool[] | undefined;
54
54
  export declare const v3ToolChoiceToOpenAI: (choice: OpenAICompatV3CallToolChoice, toolNameToWire?: Map<string, string>) => OpenAICompatToolChoiceWire | undefined;
@@ -96,6 +96,11 @@ export const estimateWireTokens = (messages, tools, provider) => {
96
96
  if (toolCalls) {
97
97
  total += estimateTokens(safeStringify(toolCalls), provider);
98
98
  }
99
+ const reasoning = message
100
+ .reasoning_content;
101
+ if (reasoning) {
102
+ total += estimateTokens(reasoning, provider);
103
+ }
99
104
  }
100
105
  if (tools && tools.length > 0) {
101
106
  total += estimateTokens(safeStringify(tools), provider);
@@ -242,7 +247,7 @@ export const convertContentForOpenAI = (content) => {
242
247
  }
243
248
  return out;
244
249
  };
245
- export const messageBuilderToOpenAI = (messages, toolNameToWire) => {
250
+ export const messageBuilderToOpenAI = (messages, toolNameToWire, replayReasoning = false) => {
246
251
  const out = [];
247
252
  for (const msg of messages) {
248
253
  switch (msg.role) {
@@ -264,10 +269,14 @@ export const messageBuilderToOpenAI = (messages, toolNameToWire) => {
264
269
  const parts = Array.isArray(msg.content) ? msg.content : [msg.content];
265
270
  const text = [];
266
271
  const toolCalls = [];
272
+ const reasoning = [];
267
273
  for (const part of parts) {
268
274
  if (part && typeof part === "object") {
269
275
  const p = part;
270
- if (p.type === "text") {
276
+ if (p.type === "reasoning") {
277
+ reasoning.push(part.text ?? "");
278
+ }
279
+ else if (p.type === "text") {
271
280
  text.push({
272
281
  type: "text",
273
282
  text: part.text ?? "",
@@ -297,10 +306,12 @@ export const messageBuilderToOpenAI = (messages, toolNameToWire) => {
297
306
  : text.length === 1 && text[0].type === "text"
298
307
  ? text[0].text
299
308
  : text;
309
+ const reasoningContent = replayReasoning ? reasoning.join("") : "";
300
310
  out.push({
301
311
  role: "assistant",
302
312
  content: flat,
303
313
  ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
314
+ ...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
304
315
  });
305
316
  break;
306
317
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * File detection and processing types for unified file handling
3
3
  */
4
+ import type { ImageWithAltText } from "./multimodal.js";
4
5
  /**
5
6
  * Supported file types for multimodal input
6
7
  */
@@ -276,8 +277,15 @@ export type FileProcessingResult = {
276
277
  type: FileType;
277
278
  content: string | Buffer;
278
279
  mimeType: string;
279
- /** Additional images extracted from the file (e.g., video keyframes, audio cover art) */
280
- images?: Array<Buffer | string>;
280
+ /**
281
+ * Additional images extracted from the file (e.g., video keyframes, audio
282
+ * cover art).
283
+ *
284
+ * An entry may carry alt text, which is how a video keyframe states the
285
+ * timestamp it was sampled at. Bare bytes still work — the field was
286
+ * widened, not changed.
287
+ */
288
+ images?: Array<Buffer | string | ImageWithAltText>;
281
289
  metadata: {
282
290
  confidence: number;
283
291
  size?: number;
@@ -549,6 +557,16 @@ export type VideoProcessorOptions = {
549
557
  quality?: number;
550
558
  /** Frame encoding. Defaults to jpeg. */
551
559
  format?: "jpeg" | "png";
560
+ /**
561
+ * Transcribe the clip's spoken audio (#433). Off by default: it costs an
562
+ * ffmpeg pass plus a Whisper call, and most attached video is silent
563
+ * screen capture.
564
+ *
565
+ * Best-effort — a clip with no audio track, no `OPENAI_API_KEY`, or a
566
+ * failed call still processes, and the reason lands in
567
+ * `ProcessedVideo.transcriptionSkippedReason`.
568
+ */
569
+ transcribeAudio?: boolean;
552
570
  };
553
571
  /**
554
572
  * Office processor options for Word, PowerPoint, and Excel documents
@@ -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, FileWithMetadata, MultimodalAudioEntry, MultimodalVideoEntry, VisionImageOutputFormat } from "./file.js";
21
+ import type { CSVProcessorOptions, VideoProcessorOptions, 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";
@@ -180,16 +180,15 @@ export type GenerateOptions = {
180
180
  */
181
181
  outputFormat?: VisionImageOutputFormat;
182
182
  };
183
- videoOptions?: {
184
- /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
185
- frames?: number;
186
- /** Frame encoder quality, clamped to 1-100. Default 80. */
187
- quality?: number;
188
- /** Frame encoding. Default jpeg. */
189
- format?: "jpeg" | "png";
190
- /** Not implemented yet (#433) — warns rather than silently doing nothing. */
191
- transcribeAudio?: boolean;
192
- };
183
+ /**
184
+ * Video processing options — keyframe budget, encoder settings, and
185
+ * whether to transcribe the clip's spoken audio.
186
+ *
187
+ * The canonical shape, rather than a third structurally-identical copy of
188
+ * it: `StreamOptions` and `TextGenerationOptions` declare the same field,
189
+ * and the inline copies had already drifted apart in what they documented.
190
+ */
191
+ videoOptions?: VideoProcessorOptions;
193
192
  /**
194
193
  * Text-to-Speech (TTS) configuration
195
194
  *
@@ -1428,6 +1427,16 @@ export type TextGenerationOptions = {
1428
1427
  expectedOutcome?: string;
1429
1428
  evaluationCriteria?: string[];
1430
1429
  csvOptions?: CSVProcessorOptions;
1430
+ /**
1431
+ * Video processing options (#478, #433).
1432
+ *
1433
+ * Reconstructed option objects have to carry this the same way they carry
1434
+ * `csvOptions` and `pdfOptions`: the message builder hands it to the
1435
+ * detector, which hands it to `VideoProcessor`, and anything that rebuilds
1436
+ * the options along the way and omits it restores the defaults without
1437
+ * saying so.
1438
+ */
1439
+ videoOptions?: VideoProcessorOptions;
1431
1440
  /** PDF processing options (#258). */
1432
1441
  pdfOptions?: {
1433
1442
  /** Password for an encrypted PDF (image-conversion fallback path). */
@@ -1453,21 +1462,6 @@ export type TextGenerationOptions = {
1453
1462
  */
1454
1463
  outputFormat?: VisionImageOutputFormat;
1455
1464
  };
1456
- /**
1457
- * Video processing options (mirrors `GenerateOptions.videoOptions`). Never
1458
- * declared here before, so `buildGenerateTextOptions` had nowhere to
1459
- * forward the caller's setting even once the allowlist itself named it.
1460
- */
1461
- videoOptions?: {
1462
- /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
1463
- frames?: number;
1464
- /** Frame encoder quality, clamped to 1-100. Default 80. */
1465
- quality?: number;
1466
- /** Frame encoding. Default jpeg. */
1467
- format?: "jpeg" | "png";
1468
- /** Not implemented yet (#433) — warns rather than silently doing nothing. */
1469
- transcribeAudio?: boolean;
1470
- };
1471
1465
  enableSummarization?: boolean;
1472
1466
  /**
1473
1467
  * Skip injecting tool schemas into the system prompt.
@@ -34,6 +34,9 @@ export type OpenAICompatChatMessage = {
34
34
  role: "assistant";
35
35
  content?: string | OpenAICompatMessageContent[] | null;
36
36
  tool_calls?: OpenAICompatToolCallWire[];
37
+ /** The turn's reasoning, sent back only where the catalog opts in
38
+ * (DeepSeek's replayReasoningContent quirk). */
39
+ reasoning_content?: string;
37
40
  } | {
38
41
  role: "tool";
39
42
  content: string;
@@ -711,6 +711,20 @@ export type ProcessedAudio = ProcessedFileBase & {
711
711
  transcriptionSkippedReason?: string;
712
712
  coverArt?: Buffer;
713
713
  };
714
+ /**
715
+ * One extracted keyframe and the moment it came from.
716
+ *
717
+ * Internal to extraction: `ProcessedVideo` splits this back into the parallel
718
+ * `keyframes` / `keyframeTimestampsSec` arrays its existing consumers expect.
719
+ * Keeping the pair together while frames are being read and encoded is what
720
+ * makes a dropped frame impossible to mislabel — the alternative is
721
+ * reconstructing timestamps from an interval after the fact, which is wrong
722
+ * for every frame following a failed encode.
723
+ */
724
+ export type VideoKeyframe = {
725
+ readonly buffer: Buffer;
726
+ readonly timestampSec: number;
727
+ };
714
728
  /**
715
729
  * Processed video result.
716
730
  * Extends ProcessedFileBase with video-specific fields including metadata,
@@ -720,6 +734,19 @@ export type ProcessedAudio = ProcessedFileBase & {
720
734
  export type ProcessedVideo = ProcessedFileBase & {
721
735
  textContent: string;
722
736
  keyframes: Buffer[];
737
+ /**
738
+ * When each entry of `keyframes` was sampled, in seconds from the start of
739
+ * the clip. Same length, same order, ascending.
740
+ *
741
+ * A parallel array rather than a richer `keyframes` element type: that
742
+ * field is read by `fileReferenceRegistry` and by the detector as a plain
743
+ * `Buffer[]`, and reshaping it would break them for a value they do not
744
+ * need. Extraction can also drop an individual frame when its encode
745
+ * fails, so the timestamps are recorded as frames are kept — never
746
+ * reconstructed from an interval, which would silently mislabel every
747
+ * frame after a dropped one.
748
+ */
749
+ keyframeTimestampsSec: number[];
723
750
  metadata: {
724
751
  duration: number;
725
752
  durationFormatted: string;
@@ -735,6 +762,22 @@ export type ProcessedVideo = ProcessedFileBase & {
735
762
  fileSize: number;
736
763
  };
737
764
  subtitleText?: string;
765
+ /**
766
+ * Speech transcribed from the clip's audio track, when
767
+ * `VideoProcessorOptions.transcribeAudio` asked for it and it worked.
768
+ *
769
+ * Distinct from `subtitleText`, which is an embedded subtitle stream the
770
+ * file already carried. A recording can have one, both or neither.
771
+ */
772
+ transcript?: string;
773
+ hasTranscript: boolean;
774
+ /**
775
+ * Why no transcript was produced. Follows `ProcessedAudio`'s precedent:
776
+ * "nobody asked", "there is no audio track", "no API key", "the call
777
+ * failed" and "nobody was speaking" are five different situations that
778
+ * otherwise all present as an absent transcript.
779
+ */
780
+ transcriptionSkippedReason?: string;
738
781
  hasKeyframes: boolean;
739
782
  frameCount: number;
740
783
  };
@@ -73,6 +73,11 @@ export type CatalogQuirks = {
73
73
  * ConfiguredOpenAICompatProvider so `generate({ schema })` keeps working
74
74
  * (mirrors the pre-catalog `supportsStructuredOutputs: false` behavior). */
75
75
  responseFormatDowngrade?: "json-schema-to-json-object";
76
+ /** Vendor wants each assistant turn's `reasoning_content` sent back on
77
+ * every later request of the conversation (DeepSeek documents a 400
78
+ * without it once tools are in play). ConfiguredOpenAICompatProvider
79
+ * turns on the replay; every other provider leaves the field off. */
80
+ replayReasoningContent?: boolean;
76
81
  };
77
82
  export type CatalogBillingPolicy = "free-tier" | "free-with-card" | "no-free-tier";
78
83
  export type CatalogSetup = {
@@ -798,6 +798,9 @@ export type OpenAICompatCatalogEntry = {
798
798
  * `response_format: { type: "json_schema" }` but accepts
799
799
  * `{ type: "json_object" }`. */
800
800
  responseFormatDowngrade?: "json-schema-to-json-object";
801
+ /** See CatalogQuirks.replayReasoningContent — a vendor that wants each
802
+ * assistant turn's `reasoning_content` sent back on later requests. */
803
+ replayReasoningContent?: boolean;
801
804
  };
802
805
  /** The subset of OpenAICompatCatalogEntry that resolveOpenAICompatConfig()
803
806
  * needs — lets call sites pass a minimal object without the full catalog
@@ -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, VisionImageOutputFormat } from "./file.js";
19
+ import type { CSVProcessorOptions, FileWithMetadata, VideoProcessorOptions, VisionImageOutputFormat } 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";
@@ -356,6 +356,16 @@ export type StreamOptions = {
356
356
  };
357
357
  };
358
358
  csvOptions?: CSVProcessorOptions;
359
+ /**
360
+ * Video processing options (#478, #433).
361
+ *
362
+ * Reconstructed option objects have to carry this the same way they carry
363
+ * `csvOptions` and `pdfOptions`: the message builder hands it to the
364
+ * detector, which hands it to `VideoProcessor`, and anything that rebuilds
365
+ * the options along the way and omits it restores the defaults without
366
+ * saying so.
367
+ */
368
+ videoOptions?: VideoProcessorOptions;
359
369
  /** PDF processing options (#258). */
360
370
  pdfOptions?: {
361
371
  /** Password for an encrypted PDF (image-conversion fallback path). */
@@ -386,16 +396,6 @@ export type StreamOptions = {
386
396
  */
387
397
  outputFormat?: VisionImageOutputFormat;
388
398
  };
389
- videoOptions?: {
390
- /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
391
- frames?: number;
392
- /** Frame encoder quality, clamped to 1-100. Default 80. */
393
- quality?: number;
394
- /** Frame encoding. Default jpeg. */
395
- format?: "jpeg" | "png";
396
- /** Not implemented yet (#433) — warns rather than silently doing nothing. */
397
- transcribeAudio?: boolean;
398
- };
399
399
  /**
400
400
  * Text-to-Speech (TTS) configuration for streaming
401
401
  *
@@ -114,6 +114,21 @@ export declare class FileDetector {
114
114
  /**
115
115
  * SDK-8: Format an informative placeholder when a file processor fails.
116
116
  * Instead of bare "[Video file: name]" strings, include size, format, and
117
+ * Describe one keyframe well enough for a model to place it in time.
118
+ *
119
+ * The clip's total length rides along because the eighth frame of a
120
+ * recording means nothing without knowing how long the recording is.
121
+ *
122
+ * Explicit units rather than a clock — see `formatKeyframeTimestamp`. An
123
+ * earlier draft used "0:03" here and the model reported the frame as
124
+ * appearing at "3:00".
125
+ *
126
+ * A timestamp can be missing if extraction and its schedule ever disagree
127
+ * in length. That should not happen, but an unlabelled frame is better
128
+ * than one labelled "NaN".
129
+ */
130
+ private static describeKeyframe;
131
+ /**
117
132
  * the reason for failure so the LLM can acknowledge the attachment.
118
133
  */
119
134
  private static formatInformativePlaceholder;
@@ -37,6 +37,7 @@ import { withTimeout } from "./errorHandling.js";
37
37
  import { normalizeUrlForCache, redactUrlForError, sanitizeErrorCause, } from "./logSanitize.js";
38
38
  import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
39
39
  import { PDFProcessor } from "./pdfProcessor.js";
40
+ import { formatKeyframeTimestamp } from "./mediaDuration.js";
40
41
  /**
41
42
  * Short-TTL cache of URL → Content-Type (#323). A URL is commonly detected more
42
43
  * than once (repeated multimodal prompts reuse the same asset URL); caching the
@@ -797,6 +798,26 @@ export class FileDetector {
797
798
  /**
798
799
  * SDK-8: Format an informative placeholder when a file processor fails.
799
800
  * Instead of bare "[Video file: name]" strings, include size, format, and
801
+ * Describe one keyframe well enough for a model to place it in time.
802
+ *
803
+ * The clip's total length rides along because the eighth frame of a
804
+ * recording means nothing without knowing how long the recording is.
805
+ *
806
+ * Explicit units rather than a clock — see `formatKeyframeTimestamp`. An
807
+ * earlier draft used "0:03" here and the model reported the frame as
808
+ * appearing at "3:00".
809
+ *
810
+ * A timestamp can be missing if extraction and its schedule ever disagree
811
+ * in length. That should not happen, but an unlabelled frame is better
812
+ * than one labelled "NaN".
813
+ */
814
+ static describeKeyframe(timestampSec, durationFormatted) {
815
+ if (typeof timestampSec !== "number" || !Number.isFinite(timestampSec)) {
816
+ return undefined;
817
+ }
818
+ return `Video keyframe at ${formatKeyframeTimestamp(timestampSec)} into a ${durationFormatted} video`;
819
+ }
820
+ /**
800
821
  * the reason for failure so the LLM can acknowledge the attachment.
801
822
  */
802
823
  static formatInformativePlaceholder(typeName, filename, content, detection, error) {
@@ -1120,25 +1141,37 @@ export class FileDetector {
1120
1141
  // the processor; previously these stopped at the CLI layer.
1121
1142
  videoOptions);
1122
1143
  if (videoResult.success && videoResult.data) {
1144
+ // Bound to a local because the keyframe map below is a closure, and
1145
+ // TypeScript drops the `videoResult.data` narrowing across one.
1146
+ const video = videoResult.data;
1123
1147
  return {
1124
1148
  type: "video",
1125
- content: videoResult.data.textContent ||
1149
+ content: video.textContent ||
1126
1150
  FileDetector.formatInformativePlaceholder("Video", videoFilename, content, detection),
1127
1151
  mimeType: detection.mimeType,
1128
- images: videoResult.data.keyframes && videoResult.data.keyframes.length > 0
1129
- ? videoResult.data.keyframes
1152
+ // Each keyframe carries the moment it was sampled at. Alt text is
1153
+ // the only per-image channel that survives to the model — the
1154
+ // message builder folds it into the prompt as
1155
+ // "[Image N: <alt>]" — so without it the frames arrive as an
1156
+ // unlabelled pile and "what happens at 0:30?" has no answer even
1157
+ // though the frame at 0:30 is right there.
1158
+ images: video.keyframes && video.keyframes.length > 0
1159
+ ? video.keyframes.map((frame, index) => ({
1160
+ data: frame,
1161
+ altText: FileDetector.describeKeyframe(video.keyframeTimestampsSec[index], video.metadata.durationFormatted),
1162
+ }))
1130
1163
  : undefined,
1131
1164
  metadata: {
1132
1165
  ...detection.metadata,
1133
- frameCount: videoResult.data.frameCount,
1134
- hasKeyframes: videoResult.data.hasKeyframes,
1166
+ frameCount: video.frameCount,
1167
+ hasKeyframes: video.hasKeyframes,
1135
1168
  // Carried so native delivery can apply its duration ceiling
1136
1169
  // without opening the container a second time. The processor
1137
1170
  // reports 0 when nothing could probe it; that is "unknown", not a
1138
1171
  // zero-length clip, so it is left off rather than passed through
1139
1172
  // as a number a ceiling check would happily accept.
1140
- ...(videoResult.data.metadata.duration > 0
1141
- ? { durationSec: videoResult.data.metadata.duration }
1173
+ ...(video.metadata.duration > 0
1174
+ ? { durationSec: video.metadata.duration }
1142
1175
  : {}),
1143
1176
  },
1144
1177
  };
@@ -21,3 +21,22 @@
21
21
  * fabricated number would be worse than an obvious zero.
22
22
  */
23
23
  export declare function formatMediaDuration(seconds: number): string;
24
+ /**
25
+ * Format the moment a video keyframe was sampled at — "3.00s",
26
+ * "1234.00s (20m 34s)".
27
+ *
28
+ * Deliberately not the clock form, for the reason in this module's header:
29
+ * the string is read by a language model, and "0:03" is ambiguous. That is
30
+ * not theoretical here — an early draft labelled frames "0:03" and the model
31
+ * reported the green frame as appearing at "3:00".
32
+ *
33
+ * Deliberately not `formatMediaDuration` alone either. That rounds to whole
34
+ * seconds, and an explicit `videoOptions.frames` spreads frames evenly rather
35
+ * than on the duration tier: sixteen frames across a 3.6s clip are 0.225s
36
+ * apart, and every one of them would render as "0s". The precise value is
37
+ * therefore always present, with the readable form added only past a minute,
38
+ * where "1234.00s" stops meaning anything to a reader.
39
+ *
40
+ * Non-finite and negative inputs clamp to zero rather than emitting "NaNs".
41
+ */
42
+ export declare function formatKeyframeTimestamp(seconds: number): string;
@@ -42,3 +42,26 @@ export function formatMediaDuration(seconds) {
42
42
  }
43
43
  return parts.join(" ");
44
44
  }
45
+ /**
46
+ * Format the moment a video keyframe was sampled at — "3.00s",
47
+ * "1234.00s (20m 34s)".
48
+ *
49
+ * Deliberately not the clock form, for the reason in this module's header:
50
+ * the string is read by a language model, and "0:03" is ambiguous. That is
51
+ * not theoretical here — an early draft labelled frames "0:03" and the model
52
+ * reported the green frame as appearing at "3:00".
53
+ *
54
+ * Deliberately not `formatMediaDuration` alone either. That rounds to whole
55
+ * seconds, and an explicit `videoOptions.frames` spreads frames evenly rather
56
+ * than on the duration tier: sixteen frames across a 3.6s clip are 0.225s
57
+ * apart, and every one of them would render as "0s". The precise value is
58
+ * therefore always present, with the readable form added only past a minute,
59
+ * where "1234.00s" stops meaning anything to a reader.
60
+ *
61
+ * Non-finite and negative inputs clamp to zero rather than emitting "NaNs".
62
+ */
63
+ export function formatKeyframeTimestamp(seconds) {
64
+ const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
65
+ const precise = `${safe.toFixed(2)}s`;
66
+ return safe >= 60 ? `${precise} (${formatMediaDuration(safe)})` : precise;
67
+ }
@@ -832,20 +832,6 @@ export function mergeMediaFileAliases(input) {
832
832
  input.audioFiles = undefined;
833
833
  input.videoFiles = undefined;
834
834
  }
835
- /**
836
- * #478: `transcribeAudio` (CLI `--transcribe-audio`) is accepted by the options
837
- * surface but no video-audio transcription exists yet — VideoProcessor extracts
838
- * keyframes and embedded subtitle tracks only, and the transcription step is
839
- * still open as #433. Say so once per request rather than letting the caller
840
- * believe a transcript was produced and silently omitted.
841
- */
842
- function warnIfVideoTranscriptionRequested(videoOptions) {
843
- if (videoOptions?.transcribeAudio) {
844
- logger.warn("[NEUROLINK] Video audio transcription was requested but is not implemented yet " +
845
- "(tracked as #433). Keyframes and any embedded subtitle tracks are still extracted; " +
846
- "spoken audio will not be transcribed.");
847
- }
848
- }
849
835
  /**
850
836
  * Process the unified files array with auto-detection.
851
837
  * Handles lazy file registration, full processing, and preview injection.
@@ -916,6 +902,10 @@ async function detectFileForUnifiedArray(file, options, provider, genericFileMax
916
902
  frames: options.videoOptions.frames,
917
903
  quality: options.videoOptions.quality,
918
904
  format: options.videoOptions.format,
905
+ // #433: `--transcribe-audio` stopped here until now — the flag was
906
+ // accepted, warned about, and never handed to the processor that
907
+ // would have to act on it.
908
+ transcribeAudio: options.videoOptions.transcribeAudio,
919
909
  }
920
910
  : undefined,
921
911
  provider: provider,
@@ -952,7 +942,6 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
952
942
  }
953
943
  const totalFiles = pending.length;
954
944
  const files = pending;
955
- warnIfVideoTranscriptionRequested(options.videoOptions);
956
945
  return withSpan({
957
946
  name: "neurolink.file.process_all",
958
947
  tracer: tracers.file,
@@ -15,6 +15,7 @@ import type { StreamOptions } from "../types/index.js";
15
15
  * - input.audioFiles: Audio files (Buffer | string paths)
16
16
  * - input.videoFiles: Video files (Buffer | string paths)
17
17
  * - csvOptions: CSV parsing options
18
+ * - videoOptions: Video frame/quality/format/transcription options
18
19
  * - systemPrompt: System-level instructions
19
20
  * - conversationMessages: Chat history
20
21
  * - temperature: Model temperature (0-1)
@@ -64,6 +65,7 @@ export declare function buildMultimodalOptions(options: StreamOptions, providerN
64
65
  imageOptions: {
65
66
  outputFormat?: import("../index.js").VisionImageOutputFormat;
66
67
  } | undefined;
68
+ videoOptions: import("../index.js").VideoProcessorOptions | undefined;
67
69
  systemPrompt: string | undefined;
68
70
  conversationHistory: import("../index.js").ChatMessage[] | undefined;
69
71
  provider: string;
@@ -14,6 +14,7 @@
14
14
  * - input.audioFiles: Audio files (Buffer | string paths)
15
15
  * - input.videoFiles: Video files (Buffer | string paths)
16
16
  * - csvOptions: CSV parsing options
17
+ * - videoOptions: Video frame/quality/format/transcription options
17
18
  * - systemPrompt: System-level instructions
18
19
  * - conversationMessages: Chat history
19
20
  * - temperature: Model temperature (0-1)
@@ -60,6 +61,12 @@ export function buildMultimodalOptions(options, providerName, modelName) {
60
61
  csvOptions: options.csvOptions,
61
62
  pdfOptions: options.pdfOptions,
62
63
  imageOptions: options.imageOptions,
64
+ // Same whitelist hazard as above, one level up: `videoFiles` reaching
65
+ // the processor is not enough on its own — without `videoOptions` too,
66
+ // the frame/quality/format/transcribeAudio knobs a caller set on
67
+ // `StreamOptions` never reach VideoProcessor on the Bedrock branch, and
68
+ // it silently falls back to the duration-tier default.
69
+ videoOptions: options.videoOptions,
63
70
  systemPrompt: options.systemPrompt,
64
71
  conversationHistory: options.conversationMessages,
65
72
  provider: providerName,