@agent-native/core-corpus 0.201.0-nightly-20261005153421 → 0.201.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 (235) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/security/SKILL.md +9 -2
  3. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +14 -4
  4. package/corpus/templates/analytics/.agents/skills/storing-data/SKILL.md +12 -7
  5. package/corpus/templates/analytics/CHANGELOG.md +6 -0
  6. package/corpus/templates/analytics/README.md +2 -2
  7. package/corpus/templates/analytics/changelog/2026-10-02-events-with-a-very-long-name-app-or-page-path-no-longer-caus.md +6 -0
  8. package/corpus/templates/analytics/package.json +1 -0
  9. package/corpus/templates/analytics/server/handlers/first-party-analytics.ts +9 -19
  10. package/corpus/templates/analytics/server/handlers/session-replay.ts +34 -47
  11. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -6
  12. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +57 -36
  13. package/corpus/templates/analytics/server/lib/indexed-text.ts +53 -0
  14. package/corpus/templates/analytics/server/lib/request-errors.ts +73 -0
  15. package/corpus/templates/analytics/server/lib/session-event-index.ts +6 -16
  16. package/corpus/templates/analytics/server/lib/session-replay.ts +12 -2
  17. package/corpus/templates/analytics/server/plugins/auth.ts +2 -1
  18. package/corpus/templates/analytics/server/plugins/otel.ts +6 -0
  19. package/corpus/templates/assets/.agents/skills/security/SKILL.md +9 -2
  20. package/corpus/templates/assets/.agents/skills/storing-data/SKILL.md +12 -7
  21. package/corpus/templates/assets/CHANGELOG.md +7 -0
  22. package/corpus/templates/assets/README.md +2 -2
  23. package/corpus/templates/assets/package.json +1 -0
  24. package/corpus/templates/assets/server/plugins/auth.ts +2 -1
  25. package/corpus/templates/assets/server/plugins/otel.ts +6 -0
  26. package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +2 -1
  27. package/corpus/templates/brain/.agents/skills/brain/SKILL.md +5 -4
  28. package/corpus/templates/brain/.agents/skills/security/SKILL.md +9 -2
  29. package/corpus/templates/brain/.agents/skills/storing-data/SKILL.md +12 -7
  30. package/corpus/templates/brain/CHANGELOG.md +6 -0
  31. package/corpus/templates/brain/README.md +12 -8
  32. package/corpus/templates/brain/changelog/2026-10-05-private-slack-channels-are-searchable-org-wide.md +6 -0
  33. package/corpus/templates/brain/package.json +1 -0
  34. package/corpus/templates/brain/server/lib/audiences.ts +1 -98
  35. package/corpus/templates/brain/server/lib/connectors.ts +2 -166
  36. package/corpus/templates/brain/server/plugins/otel.ts +6 -0
  37. package/corpus/templates/calendar/.agents/skills/security/SKILL.md +9 -2
  38. package/corpus/templates/calendar/.agents/skills/storing-data/SKILL.md +12 -7
  39. package/corpus/templates/calendar/CHANGELOG.md +7 -0
  40. package/corpus/templates/calendar/README.md +2 -2
  41. package/corpus/templates/calendar/package.json +1 -0
  42. package/corpus/templates/calendar/server/plugins/auth.ts +2 -1
  43. package/corpus/templates/calendar/server/plugins/otel.ts +6 -0
  44. package/corpus/templates/chat/.agents/skills/security/SKILL.md +9 -2
  45. package/corpus/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
  46. package/corpus/templates/chat/README.md +2 -2
  47. package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +9 -0
  48. package/corpus/templates/clips/.agents/skills/security/SKILL.md +9 -2
  49. package/corpus/templates/clips/CHANGELOG.md +24 -0
  50. package/corpus/templates/clips/README.md +2 -2
  51. package/corpus/templates/clips/actions/lib/audio-chunk-list.ts +23 -0
  52. package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +187 -292
  53. package/corpus/templates/clips/actions/lib/chunked-transcription.ts +151 -0
  54. package/corpus/templates/clips/actions/lib/recording-audio-source.ts +191 -0
  55. package/corpus/templates/clips/actions/request-transcript.ts +81 -135
  56. package/corpus/templates/clips/actions/search-recordings.ts +19 -5
  57. package/corpus/templates/clips/app/components/clips-command-menu.tsx +5 -0
  58. package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -1
  59. package/corpus/templates/clips/app/components/library/search-bar.tsx +5 -0
  60. package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
  61. package/corpus/templates/clips/app/lib/local-recording-upload.ts +13 -6
  62. package/corpus/templates/clips/app/lib/recording-section.ts +1 -1
  63. package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +7 -5
  64. package/corpus/templates/clips/app/routes/record.tsx +16 -6
  65. package/corpus/templates/clips/changelog/2026-10-02-long-uploads-an-hour-or-more-now-transcribe-reliably-instead.md +6 -0
  66. package/corpus/templates/clips/changelog/2026-10-05-confirming-discard-recording-now-returns-you-to-your-library.md +6 -0
  67. package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recording-now-removes-its-failed-upload-card-fr.md +6 -0
  68. package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recovered-recording-no-longer-warns-that-it-s-o.md +6 -0
  69. package/corpus/templates/clips/changelog/2026-10-05-search-results-identify-recordings-in-trash-and-open-them-wi.md +6 -0
  70. package/corpus/templates/clips/package.json +1 -0
  71. package/corpus/templates/clips/server/lib/builder-cdn.ts +5 -0
  72. package/corpus/templates/clips/server/lib/builder-media-compression.ts +2 -2
  73. package/corpus/templates/clips/server/plugins/auth.ts +2 -1
  74. package/corpus/templates/clips/server/plugins/otel.ts +6 -0
  75. package/corpus/templates/clips/shared/transcript-failure.ts +5 -0
  76. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
  77. package/corpus/templates/content/.agents/skills/security/SKILL.md +9 -2
  78. package/corpus/templates/content/.agents/skills/storing-data/SKILL.md +12 -7
  79. package/corpus/templates/content/CHANGELOG.md +41 -0
  80. package/corpus/templates/content/README.md +2 -2
  81. package/corpus/templates/content/actions/_database-navigation.ts +523 -344
  82. package/corpus/templates/content/actions/_database-row-mutation.ts +44 -10
  83. package/corpus/templates/content/actions/_database-setup-discovery.ts +9 -3
  84. package/corpus/templates/content/actions/_database-utils.ts +299 -271
  85. package/corpus/templates/content/actions/_property-utils.ts +293 -190
  86. package/corpus/templates/content/actions/_relation-values.ts +3 -1
  87. package/corpus/templates/content/actions/get-content-database.ts +1 -0
  88. package/corpus/templates/content/actions/query-content-database-items.ts +32 -5
  89. package/corpus/templates/content/actions/set-document-property.ts +17 -11
  90. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +21 -6
  91. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +127 -43
  92. package/corpus/templates/content/app/components/editor/DocumentEditorSkeleton.tsx +159 -50
  93. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +12 -28
  94. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +6 -1
  95. package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +139 -70
  96. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +16 -1
  97. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +180 -75
  98. package/corpus/templates/content/app/components/editor/database/DatabaseViewSkeleton.tsx +76 -0
  99. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +60 -2
  100. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +41 -42
  101. package/corpus/templates/content/app/components/editor/document-editor-layout.ts +18 -0
  102. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +11 -3
  103. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +15 -7
  104. package/corpus/templates/content/app/components/editor/suggestions/model.ts +13 -0
  105. package/corpus/templates/content/app/components/editor/suggestions/read-only-blocks.ts +136 -0
  106. package/corpus/templates/content/app/components/editor/table-view.ts +73 -0
  107. package/corpus/templates/content/app/components/layout/ContentStartupShell.tsx +1 -1
  108. package/corpus/templates/content/app/components/layout/Layout.tsx +5 -1
  109. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +57 -16
  110. package/corpus/templates/content/app/components/sidebar/MovePageDialog.tsx +4 -4
  111. package/corpus/templates/content/app/components/sidebar/SidebarNavigationRow.tsx +34 -10
  112. package/corpus/templates/content/app/global.css +16 -8
  113. package/corpus/templates/content/app/hooks/content-action-refresh.ts +14 -13
  114. package/corpus/templates/content/app/hooks/use-content-database.ts +72 -9
  115. package/corpus/templates/content/app/hooks/use-content-personal-navigation.ts +7 -3
  116. package/corpus/templates/content/app/hooks/use-documents.ts +27 -0
  117. package/corpus/templates/content/app/i18n-data.ts +3 -2
  118. package/corpus/templates/content/app/lib/content-landing.ts +3 -31
  119. package/corpus/templates/content/app/lib/files-navigation.ts +182 -10
  120. package/corpus/templates/content/app/lib/files-root-hint.ts +20 -32
  121. package/corpus/templates/content/app/lib/page-startup-hints.ts +108 -0
  122. package/corpus/templates/content/app/lib/private-link-redirect.ts +25 -0
  123. package/corpus/templates/content/app/lib/sidebar-layout-hint.ts +34 -1
  124. package/corpus/templates/content/app/lib/startup-timing.ts +2 -0
  125. package/corpus/templates/content/app/root.tsx +3 -1
  126. package/corpus/templates/content/app/routes/_app.home.tsx +10 -19
  127. package/corpus/templates/content/app/routes/p.$id.tsx +23 -5
  128. package/corpus/templates/content/changelog/2026-10-02-a-page-link-you-cant-open-now-says-so-instead-of-opening-an.md +6 -0
  129. package/corpus/templates/content/changelog/2026-10-02-after-a-refresh-the-files-sidebar-shows-the-open-page-s-fold.md +6 -0
  130. package/corpus/templates/content/changelog/2026-10-02-large-collections-open-faster.md +6 -0
  131. package/corpus/templates/content/changelog/2026-10-02-pages-stay-inside-the-window-at-every-width-long-inline-code.md +6 -0
  132. package/corpus/templates/content/changelog/2026-10-02-pages-with-open-comments-and-collections-with-sorted-or-filt.md +6 -0
  133. package/corpus/templates/content/changelog/2026-10-02-suggest-edits-works-on-pages-written-by-agents-including-pag.md +6 -0
  134. package/corpus/templates/content/changelog/2026-10-02-while-you-suggest-edits-tables-and-other-blocks-that-suggest.md +6 -0
  135. package/corpus/templates/content/changelog/2026-10-05-collection-view-context-now-survives-quick-page-changes-when.md +6 -0
  136. package/corpus/templates/content/changelog/2026-10-05-collection-views-keep-their-saved-rows-accurate-during-refre.md +6 -0
  137. package/corpus/templates/content/changelog/2026-10-05-exact-saved-view-links-keep-collection-retry-available.md +6 -0
  138. package/corpus/templates/content/changelog/2026-10-05-expanding-a-files-folder-now-shares-one-navigation-read-acro.md +6 -0
  139. package/corpus/templates/content/changelog/2026-10-05-files-navigation-retries-temporary-failures-consistently.md +6 -0
  140. package/corpus/templates/content/changelog/2026-10-05-shared-page-owners-keep-edit-and-manage-controls-in-files-an.md +6 -0
  141. package/corpus/templates/content/changelog/2026-10-05-tagging-an-ai-in-a-comment-works-again-instead-of-failing-ri.md +6 -0
  142. package/corpus/templates/content/changelog/2026-10-05-the-home-loading-shell-keeps-the-layout-of-your-last-opened-.md +6 -0
  143. package/corpus/templates/content/changelog/2026-10-05-typing-and-a-space-or-inserting-a-toggle-or-callout-no-longe.md +6 -0
  144. package/corpus/templates/content/docs/product/capabilities/content.workspace.session-restore.md +4 -1
  145. package/corpus/templates/content/e2e/playwright.config.ts +1 -1
  146. package/corpus/templates/content/package.json +2 -1
  147. package/corpus/templates/content/scripts/seed-perf-database.mjs +600 -0
  148. package/corpus/templates/content/scripts/trace-startup.mjs +25 -3
  149. package/corpus/templates/content/server/db/index.ts +1 -1
  150. package/corpus/templates/content/server/lib/suggested-edits.ts +23 -26
  151. package/corpus/templates/content/server/plugins/auth.ts +2 -1
  152. package/corpus/templates/content/server/plugins/otel.ts +6 -0
  153. package/corpus/templates/content/shared/api.ts +17 -0
  154. package/corpus/templates/content/shared/suggestion-diff.ts +124 -38
  155. package/corpus/templates/content/shared/suggestion-formatting.ts +134 -3
  156. package/corpus/templates/crm/.agents/skills/security/SKILL.md +9 -2
  157. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +12 -7
  158. package/corpus/templates/crm/package.json +1 -0
  159. package/corpus/templates/crm/server/plugins/otel.ts +6 -0
  160. package/corpus/templates/design/.agents/skills/security/SKILL.md +9 -2
  161. package/corpus/templates/design/.agents/skills/storing-data/SKILL.md +12 -7
  162. package/corpus/templates/design/CHANGELOG.md +41 -0
  163. package/corpus/templates/design/README.md +2 -2
  164. package/corpus/templates/design/actions/list-designs.ts +11 -4
  165. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +110 -32
  166. package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +28 -0
  167. package/corpus/templates/design/app/components/design/multi-screen/types.ts +1 -0
  168. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +79 -0
  169. package/corpus/templates/design/app/components/layout/Header.tsx +2 -4
  170. package/corpus/templates/design/app/components/ui/tabs.tsx +59 -1
  171. package/corpus/templates/design/app/global.css +1 -34
  172. package/corpus/templates/design/app/i18n/en-US.ts +4 -0
  173. package/corpus/templates/design/app/lib/design-filter.ts +4 -2
  174. package/corpus/templates/design/app/pages/Index.tsx +91 -59
  175. package/corpus/templates/design/app/pages/design-editor/commands/cross-screen-element-drop.ts +58 -17
  176. package/corpus/templates/design/changelog/2026-10-01-filter-designs-by-owner.md +6 -0
  177. package/corpus/templates/design/changelog/2026-10-03-layers-dropped-into-visible-canvas-space-beyond-a-screen-now.md +6 -0
  178. package/corpus/templates/design/changelog/2026-10-03-visible-screen-overflow-no-longer-routes-drops-to-the-canvas.md +6 -0
  179. package/corpus/templates/design/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-designs.md +6 -0
  180. package/corpus/templates/design/package.json +1 -0
  181. package/corpus/templates/design/server/plugins/auth.ts +2 -1
  182. package/corpus/templates/design/server/plugins/otel.ts +6 -0
  183. package/corpus/templates/dispatch/.agents/skills/security/SKILL.md +9 -2
  184. package/corpus/templates/dispatch/.agents/skills/storing-data/SKILL.md +12 -7
  185. package/corpus/templates/dispatch/README.md +2 -2
  186. package/corpus/templates/dispatch/package.json +1 -0
  187. package/corpus/templates/dispatch/server/plugins/otel.ts +6 -0
  188. package/corpus/templates/dispatch/server/plugins/setup-dispatch.ts +2 -1
  189. package/corpus/templates/factory/.agents/skills/security/SKILL.md +9 -2
  190. package/corpus/templates/factory/.agents/skills/storing-data/SKILL.md +12 -7
  191. package/corpus/templates/factory/package.json +1 -0
  192. package/corpus/templates/factory/server/plugins/otel.ts +6 -0
  193. package/corpus/templates/forms/.agents/skills/security/SKILL.md +9 -2
  194. package/corpus/templates/forms/.agents/skills/storing-data/SKILL.md +12 -7
  195. package/corpus/templates/forms/README.md +2 -2
  196. package/corpus/templates/forms/package.json +1 -0
  197. package/corpus/templates/forms/server/plugins/auth.ts +2 -1
  198. package/corpus/templates/forms/server/plugins/otel.ts +6 -0
  199. package/corpus/templates/mail/.agents/skills/security/SKILL.md +9 -2
  200. package/corpus/templates/mail/.agents/skills/storing-data/SKILL.md +12 -7
  201. package/corpus/templates/mail/CHANGELOG.md +6 -0
  202. package/corpus/templates/mail/README.md +2 -2
  203. package/corpus/templates/mail/package.json +1 -0
  204. package/corpus/templates/mail/server/plugins/auth.ts +2 -1
  205. package/corpus/templates/mail/server/plugins/otel.ts +6 -0
  206. package/corpus/templates/plan/.agents/skills/security/SKILL.md +9 -2
  207. package/corpus/templates/plan/.agents/skills/storing-data/SKILL.md +12 -7
  208. package/corpus/templates/plan/CHANGELOG.md +1 -0
  209. package/corpus/templates/plan/README.md +2 -2
  210. package/corpus/templates/plan/package.json +1 -0
  211. package/corpus/templates/plan/server/plugins/auth.ts +2 -1
  212. package/corpus/templates/plan/server/plugins/otel.ts +6 -0
  213. package/corpus/templates/slides/.agents/skills/security/SKILL.md +9 -2
  214. package/corpus/templates/slides/.agents/skills/storing-data/SKILL.md +12 -7
  215. package/corpus/templates/slides/CHANGELOG.md +23 -0
  216. package/corpus/templates/slides/README.md +2 -2
  217. package/corpus/templates/slides/actions/list-decks.ts +11 -4
  218. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +44 -36
  219. package/corpus/templates/slides/app/components/home/SlidesHomeLibrary.tsx +79 -0
  220. package/corpus/templates/slides/app/components/layout/Header.tsx +2 -15
  221. package/corpus/templates/slides/app/components/ui/tabs.tsx +59 -0
  222. package/corpus/templates/slides/app/global.css +3 -54
  223. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  224. package/corpus/templates/slides/app/lib/deck-filter.ts +5 -2
  225. package/corpus/templates/slides/app/pages/Index.tsx +102 -88
  226. package/corpus/templates/slides/changelog/2026-10-01-filter-decks-by-owner.md +6 -0
  227. package/corpus/templates/slides/changelog/2026-10-02-hide-recent-until-decks-available.md +6 -0
  228. package/corpus/templates/slides/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-decks-a.md +6 -0
  229. package/corpus/templates/slides/package.json +1 -0
  230. package/corpus/templates/slides/server/handlers/request-auth-context.ts +8 -0
  231. package/corpus/templates/slides/server/plugins/auth.ts +2 -1
  232. package/corpus/templates/slides/server/plugins/otel.ts +6 -0
  233. package/corpus/templates/tasks/.agents/skills/security/SKILL.md +9 -2
  234. package/corpus/templates/tasks/.agents/skills/storing-data/SKILL.md +12 -7
  235. package/package.json +1 -1
@@ -0,0 +1,151 @@
1
+ import { normalizeProviderTranscript } from "./provider-transcript.js";
2
+
3
+ const CHUNK_TRANSCRIPTION_CONCURRENCY = 3;
4
+
5
+ export interface TimedChunk {
6
+ startMs: number;
7
+ durationMs: number;
8
+ }
9
+
10
+ export interface ProviderTranscriptResult {
11
+ text: string;
12
+ language?: string | null;
13
+ segments?: Array<{
14
+ startMs: number;
15
+ endMs: number;
16
+ text: string;
17
+ speakerLabel?: string;
18
+ }>;
19
+ }
20
+
21
+ interface JoinedTranscriptSegment {
22
+ startMs: number;
23
+ endMs: number;
24
+ text: string;
25
+ speaker?: string;
26
+ }
27
+
28
+ interface JoinedTranscript {
29
+ fullText: string;
30
+ language: string | null;
31
+ segments: JoinedTranscriptSegment[];
32
+ }
33
+
34
+ export class ChunkTranscriptionError extends Error {
35
+ readonly chunkIndex: number;
36
+ readonly chunkCount: number;
37
+ readonly cause: unknown;
38
+
39
+ constructor(chunkIndex: number, chunkCount: number, cause: unknown) {
40
+ const reason = cause instanceof Error ? cause.message : String(cause);
41
+ super(
42
+ `Transcribing part ${chunkIndex + 1} of ${chunkCount} failed: ${reason}`,
43
+ );
44
+ this.name = "ChunkTranscriptionError";
45
+ this.chunkIndex = chunkIndex;
46
+ this.chunkCount = chunkCount;
47
+ this.cause = cause;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Transcribes every chunk, at most `concurrency` at a time, retrying each
53
+ * chunk once when `isRetryable` accepts its error. Any chunk that still fails
54
+ * fails the whole run: a transcript missing a part must never be saved as if
55
+ * it were complete. A single-chunk run rethrows the provider error unchanged.
56
+ */
57
+ export async function transcribeAudioChunks<C extends TimedChunk>({
58
+ chunks,
59
+ transcribe,
60
+ isRetryable,
61
+ concurrency = CHUNK_TRANSCRIPTION_CONCURRENCY,
62
+ }: {
63
+ chunks: C[];
64
+ transcribe: (chunk: C, index: number) => Promise<ProviderTranscriptResult>;
65
+ isRetryable: (err: unknown) => boolean;
66
+ concurrency?: number;
67
+ }): Promise<ProviderTranscriptResult[]> {
68
+ const results = new Array<ProviderTranscriptResult>(chunks.length);
69
+ let next = 0;
70
+ let failure: { index: number; error: unknown } | null = null;
71
+
72
+ const transcribeWithRetry = async (index: number) => {
73
+ try {
74
+ return await transcribe(chunks[index], index);
75
+ } catch (err) {
76
+ if (!isRetryable(err)) throw err;
77
+ return transcribe(chunks[index], index);
78
+ }
79
+ };
80
+
81
+ const worker = async () => {
82
+ while (!failure && next < chunks.length) {
83
+ const index = next++;
84
+ try {
85
+ results[index] = await transcribeWithRetry(index);
86
+ } catch (error) {
87
+ failure ??= { index, error };
88
+ }
89
+ }
90
+ };
91
+
92
+ await Promise.all(
93
+ Array.from({ length: Math.min(concurrency, chunks.length) }, worker),
94
+ );
95
+
96
+ if (failure) {
97
+ const { index, error } = failure;
98
+ if (chunks.length === 1) throw error;
99
+ throw new ChunkTranscriptionError(index, chunks.length, error);
100
+ }
101
+ return results;
102
+ }
103
+
104
+ function clamp(value: number, min: number, max: number): number {
105
+ return Math.min(Math.max(value, min), max);
106
+ }
107
+
108
+ /**
109
+ * Stitches per-chunk results into one transcript on the recording timeline.
110
+ * Provider timestamps drift past the end of long audio, so each segment is
111
+ * clamped to its own chunk before the chunk's offset is added.
112
+ */
113
+ export function joinChunkTranscripts(
114
+ chunks: TimedChunk[],
115
+ results: ProviderTranscriptResult[],
116
+ ): JoinedTranscript {
117
+ const texts: string[] = [];
118
+ const segments: JoinedTranscriptSegment[] = [];
119
+ let language: string | null = null;
120
+
121
+ chunks.forEach((chunk, index) => {
122
+ const result = results[index];
123
+ const chunkSegments = (result.segments ?? [])
124
+ .map((segment) => {
125
+ const startMs = clamp(segment.startMs, 0, chunk.durationMs);
126
+ const endMs = clamp(segment.endMs, startMs, chunk.durationMs);
127
+ const speaker = segment.speakerLabel?.trim();
128
+ return {
129
+ startMs: chunk.startMs + startMs,
130
+ endMs: chunk.startMs + endMs,
131
+ text: segment.text.trim(),
132
+ ...(speaker ? { speaker } : {}),
133
+ };
134
+ })
135
+ .filter((segment) => segment.text)
136
+ .sort((a, b) => a.startMs - b.startMs);
137
+
138
+ const normalized = normalizeProviderTranscript(result.text, chunkSegments);
139
+ if (normalized.fullText) {
140
+ texts.push(normalized.fullText);
141
+ language ??= result.language?.trim() || null;
142
+ }
143
+ segments.push(...normalized.segments);
144
+ });
145
+
146
+ return {
147
+ fullText: texts.join("\n\n"),
148
+ language,
149
+ segments,
150
+ };
151
+ }
@@ -0,0 +1,191 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Readable, Transform } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import type { ReadableStream as NodeReadableStream } from "node:stream/web";
8
+
9
+ import { readAppState } from "@agent-native/core/application-state";
10
+ import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
11
+ import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
12
+
13
+ import { isBuilderCdnHost } from "../../server/lib/builder-cdn.js";
14
+ import {
15
+ AudioOnlyExtractionError,
16
+ extractAudioOnlyWithFfmpeg,
17
+ type AudioExtractor,
18
+ type AudioOnlyTranscriptionMedia,
19
+ } from "./audio-only-transcription.js";
20
+
21
+ interface RecordingAudioTimeouts {
22
+ downloadMs: number;
23
+ extractionMs: number;
24
+ }
25
+
26
+ type MediaFetcher = (url: string, init: RequestInit) => Promise<Response>;
27
+
28
+ type RecordingMediaTarget =
29
+ | { kind: "local-blob" }
30
+ | { kind: "stream"; url: string; origin: "app" | "builder-cdn" }
31
+ | { kind: "download"; url: string };
32
+
33
+ function appOrigin(): string {
34
+ const port = process.env.NITRO_PORT || process.env.PORT || "3000";
35
+ return (
36
+ process.env.PUBLIC_URL ??
37
+ process.env.NITRO_PUBLIC_URL ??
38
+ `http://localhost:${port}`
39
+ );
40
+ }
41
+
42
+ // Only URLs the server controls may be streamed by ffmpeg directly: ffmpeg
43
+ // follows redirects and resolves hosts without the SSRF guard, so any other
44
+ // URL is downloaded through ssrfSafeFetch instead.
45
+ export function resolveRecordingMediaTarget(
46
+ videoUrl: string,
47
+ ): RecordingMediaTarget {
48
+ if (
49
+ videoUrl.startsWith("/api/video/") ||
50
+ (videoUrl.startsWith("/api/uploads/") && videoUrl.endsWith("/blob"))
51
+ ) {
52
+ return { kind: "local-blob" };
53
+ }
54
+ if (videoUrl.startsWith("/") && !videoUrl.startsWith("//")) {
55
+ return { kind: "stream", url: `${appOrigin()}${videoUrl}`, origin: "app" };
56
+ }
57
+ let parsed: URL;
58
+ try {
59
+ parsed = new URL(videoUrl);
60
+ } catch {
61
+ return { kind: "download", url: videoUrl };
62
+ }
63
+ if (parsed.protocol === "https:" && isBuilderCdnHost(parsed.hostname)) {
64
+ return { kind: "stream", url: parsed.toString(), origin: "builder-cdn" };
65
+ }
66
+ return { kind: "download", url: videoUrl };
67
+ }
68
+
69
+ const safeFetch: MediaFetcher = (url, init) =>
70
+ ssrfSafeFetch(url, init, { maxRedirects: 3 });
71
+
72
+ function tooLargeError(bytes: number): Error {
73
+ return new Error(
74
+ `Recording media is too large to transcribe (${bytes} bytes, max ${MAX_UPLOAD_BYTES}).`,
75
+ );
76
+ }
77
+
78
+ async function withTempDir<T>(work: (dir: string) => Promise<T>): Promise<T> {
79
+ const dir = await mkdtemp(join(tmpdir(), "clips-transcription-source-"));
80
+ try {
81
+ return await work(dir);
82
+ } finally {
83
+ await rm(dir, { recursive: true, force: true }).catch((err) => {
84
+ console.warn(
85
+ `[clips] failed to remove transcription temp dir ${dir}:`,
86
+ (err as Error)?.message ?? String(err),
87
+ );
88
+ });
89
+ }
90
+ }
91
+
92
+ async function downloadToFile(
93
+ url: string,
94
+ path: string,
95
+ fetcher: MediaFetcher,
96
+ timeoutMs: number,
97
+ ): Promise<void> {
98
+ const res = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
99
+ if (!res.ok) {
100
+ // guard:allow-bare-error — invariant: request-transcript records media preparation errors as a failed transcript.
101
+ throw new Error(
102
+ `Failed to fetch videoUrl: HTTP ${res.status} ${res.statusText}`,
103
+ );
104
+ }
105
+ const declaredBytes = Number(res.headers.get("content-length") ?? "0");
106
+ if (declaredBytes > MAX_UPLOAD_BYTES) throw tooLargeError(declaredBytes);
107
+ // guard:allow-bare-error — invariant: request-transcript catches media preparation errors and records them as a failed transcript.
108
+ if (!res.body) throw new Error("Failed to fetch videoUrl: empty body");
109
+
110
+ let receivedBytes = 0;
111
+ const limit = new Transform({
112
+ transform(chunk: Buffer, _encoding, callback) {
113
+ receivedBytes += chunk.length;
114
+ if (receivedBytes > MAX_UPLOAD_BYTES) {
115
+ callback(tooLargeError(receivedBytes));
116
+ return;
117
+ }
118
+ callback(null, chunk);
119
+ },
120
+ });
121
+ await pipeline(
122
+ Readable.fromWeb(res.body as unknown as NodeReadableStream),
123
+ limit,
124
+ createWriteStream(path),
125
+ );
126
+ }
127
+
128
+ export async function prepareRecordingTranscriptionAudio({
129
+ recordingId,
130
+ videoUrl,
131
+ timeouts,
132
+ extractor = extractAudioOnlyWithFfmpeg,
133
+ fetchers = { app: fetch, safe: safeFetch },
134
+ }: {
135
+ recordingId: string;
136
+ videoUrl: string;
137
+ timeouts: RecordingAudioTimeouts;
138
+ extractor?: AudioExtractor;
139
+ fetchers?: { app: MediaFetcher; safe: MediaFetcher };
140
+ }): Promise<AudioOnlyTranscriptionMedia> {
141
+ const extractFromFile = (write: (path: string) => Promise<void>) =>
142
+ withTempDir(async (dir) => {
143
+ const path = join(dir, "input");
144
+ await write(path);
145
+ return extractor({
146
+ source: { kind: "file", path },
147
+ timeoutMs: timeouts.extractionMs,
148
+ });
149
+ });
150
+ const downloadAndExtract = (url: string, fetcher: MediaFetcher) =>
151
+ extractFromFile((path) =>
152
+ downloadToFile(url, path, fetcher, timeouts.downloadMs),
153
+ );
154
+
155
+ const target = resolveRecordingMediaTarget(videoUrl);
156
+ switch (target.kind) {
157
+ case "local-blob":
158
+ return extractFromFile(async (path) => {
159
+ const stash = await readAppState(`recording-blob-${recordingId}`);
160
+ const b64 = typeof stash?.data === "string" ? stash.data : null;
161
+ // guard:allow-bare-error — invariant: request-transcript catches media preparation errors and records them as a failed transcript.
162
+ if (!b64) throw new Error("recording-blob app-state missing");
163
+ await writeFile(path, Buffer.from(b64, "base64"));
164
+ });
165
+ case "download":
166
+ return downloadAndExtract(target.url, fetchers.safe);
167
+ case "stream":
168
+ try {
169
+ return await extractor({
170
+ source: { kind: "url", url: target.url },
171
+ timeoutMs: timeouts.downloadMs + timeouts.extractionMs,
172
+ });
173
+ } catch (err) {
174
+ // A stream can fail where a full download succeeds, e.g. a server
175
+ // without range support for an MP4 whose index sits at the end.
176
+ if (
177
+ !(err instanceof AudioOnlyExtractionError) ||
178
+ err.code !== "EXTRACTION_FAILED"
179
+ ) {
180
+ throw err;
181
+ }
182
+ console.warn(
183
+ `[clips] streaming audio extraction failed for ${recordingId}; retrying from a downloaded copy: ${err.message}`,
184
+ );
185
+ return downloadAndExtract(
186
+ target.url,
187
+ target.origin === "app" ? fetchers.app : fetchers.safe,
188
+ );
189
+ }
190
+ }
191
+ }
@@ -1,10 +1,6 @@
1
1
  import { defineAction } from "@agent-native/core/action";
2
2
  import type { ActionRunContext } from "@agent-native/core/action";
3
- import {
4
- readAppState,
5
- writeAppState,
6
- } from "@agent-native/core/application-state";
7
- import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
3
+ import { writeAppState } from "@agent-native/core/application-state";
8
4
  import { resolveHasBuilderGatewayCredential } from "@agent-native/core/server";
9
5
  import { assertAccess } from "@agent-native/core/sharing";
10
6
  import { track } from "@agent-native/core/tracking";
@@ -37,25 +33,28 @@ import { PENDING_TRANSCRIPT_HEARTBEAT_MS } from "../shared/transcript-status.js"
37
33
  import {
38
34
  AudioOnlyExtractionError,
39
35
  assertAudioHasAudibleSignal,
36
+ audioExtractionTimeoutMs,
40
37
  isNoExtractableAudioError,
41
38
  isTransientExtractionError,
42
- prepareAudioOnlyTranscriptionMedia,
43
39
  type AudioOnlyTranscriptionMedia,
44
40
  } from "./lib/audio-only-transcription.js";
45
41
  import {
46
42
  clearBuilderCreditsExhausted,
47
43
  noteBuilderCreditsExhausted,
48
44
  } from "./lib/builder-credits-state.js";
45
+ import {
46
+ ChunkTranscriptionError,
47
+ joinChunkTranscripts,
48
+ transcribeAudioChunks,
49
+ } from "./lib/chunked-transcription.js";
49
50
  import { finalizeEndedMeetingsForRecording } from "./lib/finalize-ended-meetings.js";
50
51
  import {
51
52
  fetchLoomTranscript,
52
53
  loomTranscriptUnavailableMessage,
53
54
  } from "./lib/loom-transcript.js";
54
55
  import { isLoomRecording } from "./lib/native-media.js";
55
- import {
56
- isLikelyMismatchedTranscriptLanguage,
57
- normalizeProviderTranscript,
58
- } from "./lib/provider-transcript.js";
56
+ import { isLikelyMismatchedTranscriptLanguage } from "./lib/provider-transcript.js";
57
+ import { prepareRecordingTranscriptionAudio } from "./lib/recording-audio-source.js";
59
58
  import regenerateSummary from "./regenerate-summary.js";
60
59
  import regenerateTitle from "./regenerate-title.js";
61
60
 
@@ -158,6 +157,24 @@ export function builderTranscriptionTimeoutMs(
158
157
  );
159
158
  }
160
159
 
160
+ // The stored size may describe the original upload or its compressed copy,
161
+ // depending on whether compression finished, so budget for the larger of it
162
+ // and a duration-based estimate.
163
+ export function estimatedRecordingMediaBytes(
164
+ videoSizeBytes: number | null | undefined,
165
+ durationMs: number | null | undefined,
166
+ ): number {
167
+ const sizeBytes =
168
+ videoSizeBytes && Number.isFinite(videoSizeBytes) && videoSizeBytes > 0
169
+ ? videoSizeBytes
170
+ : 0;
171
+ const durationBytes =
172
+ durationMs && Number.isFinite(durationMs) && durationMs > 0
173
+ ? Math.ceil(durationMs / 60_000) * ESTIMATED_VIDEO_BYTES_PER_MINUTE
174
+ : 0;
175
+ return Math.max(sizeBytes, durationBytes);
176
+ }
177
+
161
178
  export function recordingMediaFetchTimeoutMs(
162
179
  videoSizeBytes: number | null | undefined,
163
180
  durationMs: number | null | undefined,
@@ -172,12 +189,10 @@ export function recordingMediaFetchTimeoutMs(
172
189
  );
173
190
  }
174
191
 
175
- const estimatedBytes =
176
- videoSizeBytes && Number.isFinite(videoSizeBytes) && videoSizeBytes > 0
177
- ? videoSizeBytes
178
- : durationMs && Number.isFinite(durationMs) && durationMs > 0
179
- ? Math.ceil(durationMs / 60_000) * ESTIMATED_VIDEO_BYTES_PER_MINUTE
180
- : 0;
192
+ const estimatedBytes = estimatedRecordingMediaBytes(
193
+ videoSizeBytes,
194
+ durationMs,
195
+ );
181
196
  if (!estimatedBytes) return MEDIA_FETCH_MIN_TIMEOUT_MS;
182
197
 
183
198
  const fiftyMbUnits = Math.ceil(estimatedBytes / (50 * 1024 * 1024));
@@ -301,78 +316,6 @@ function rootCause(err: Error): Error {
301
316
  return cause instanceof Error ? rootCause(cause) : err;
302
317
  }
303
318
 
304
- function recordingFallbackMimeType(
305
- rec: Pick<RecordingMediaRow, "videoFormat">,
306
- ): string {
307
- return rec.videoFormat === "mp4" ? "video/mp4" : "video/webm";
308
- }
309
-
310
- function pickSourceMimeType(
311
- actual: string | null | undefined,
312
- fallback: string,
313
- ): string {
314
- const base = (actual ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
315
- if (!base || base === "application/octet-stream") return fallback;
316
- return actual ?? fallback;
317
- }
318
-
319
- async function loadRecordingMediaBlob({
320
- recordingId,
321
- videoUrl,
322
- fallbackMimeType,
323
- timeoutMs,
324
- }: {
325
- recordingId: string;
326
- videoUrl: string;
327
- fallbackMimeType: string;
328
- timeoutMs: number;
329
- }): Promise<{ blob: Blob; sourceMimeType: string }> {
330
- const isLocalBlob =
331
- videoUrl.startsWith("/api/video/") ||
332
- (videoUrl.startsWith("/api/uploads/") && videoUrl.endsWith("/blob"));
333
- if (isLocalBlob) {
334
- const stash = await readAppState(`recording-blob-${recordingId}`);
335
- const b64 = typeof stash?.data === "string" ? stash.data : null;
336
- if (!b64) throw new Error("recording-blob app-state missing");
337
- const bytes = Buffer.from(b64, "base64");
338
- const mime =
339
- typeof stash?.mimeType === "string" ? stash.mimeType : fallbackMimeType;
340
- return {
341
- blob: new Blob([bytes], { type: mime }),
342
- sourceMimeType: pickSourceMimeType(mime, fallbackMimeType),
343
- };
344
- }
345
-
346
- let resolvedVideoUrl = videoUrl;
347
- const isAppRelativeUrl =
348
- resolvedVideoUrl.startsWith("/") && !resolvedVideoUrl.startsWith("//");
349
- if (isAppRelativeUrl) {
350
- const port = process.env.NITRO_PORT || process.env.PORT || "3000";
351
- const origin =
352
- process.env.PUBLIC_URL ??
353
- process.env.NITRO_PUBLIC_URL ??
354
- `http://localhost:${port}`;
355
- resolvedVideoUrl = `${origin}${resolvedVideoUrl}`;
356
- }
357
- const vidRes = isAppRelativeUrl
358
- ? await fetch(resolvedVideoUrl, { signal: AbortSignal.timeout(timeoutMs) })
359
- : await ssrfSafeFetch(
360
- resolvedVideoUrl,
361
- { signal: AbortSignal.timeout(timeoutMs) },
362
- { maxRedirects: 3 },
363
- );
364
- if (!vidRes.ok) {
365
- throw new Error(
366
- `Failed to fetch videoUrl: HTTP ${vidRes.status} ${vidRes.statusText}`,
367
- );
368
- }
369
- const blob = await vidRes.blob();
370
- return {
371
- blob,
372
- sourceMimeType: pickSourceMimeType(blob.type, fallbackMimeType),
373
- };
374
- }
375
-
376
319
  function isRecentlyPendingTranscript(transcript: {
377
320
  status: string | null;
378
321
  updatedAt: string | null;
@@ -1069,8 +1012,8 @@ const requestTranscriptAction = defineAction({
1069
1012
  const now = new Date().toISOString();
1070
1013
 
1071
1014
  let builderError: string | null = null;
1015
+ let builderFailureCode: TranscriptFailureCode = "CLOUD_FAILED";
1072
1016
  let audioMediaPromise: Promise<AudioOnlyTranscriptionMedia> | null = null;
1073
- let audioSignalPromise: Promise<void> | null = null;
1074
1017
 
1075
1018
  const getAudioMedia = (
1076
1019
  rec: RecordingMediaRow,
@@ -1083,31 +1026,21 @@ const requestTranscriptAction = defineAction({
1083
1026
  transcriptFailureMessage("NO_AUDIO_SAVED"),
1084
1027
  );
1085
1028
  }
1086
- audioMediaPromise ??= (async () => {
1087
- const fallbackMimeType = recordingFallbackMimeType(rec);
1088
- const media = await loadRecordingMediaBlob({
1089
- recordingId: args.recordingId,
1090
- videoUrl,
1091
- fallbackMimeType,
1092
- timeoutMs: recordingMediaFetchTimeoutMs(
1029
+ audioMediaPromise ??= prepareRecordingTranscriptionAudio({
1030
+ recordingId: args.recordingId,
1031
+ videoUrl,
1032
+ timeouts: {
1033
+ downloadMs: recordingMediaFetchTimeoutMs(
1093
1034
  rec.videoSizeBytes,
1094
1035
  rec.durationMs,
1095
1036
  ),
1096
- });
1097
- return prepareAudioOnlyTranscriptionMedia({
1098
- blob: media.blob,
1099
- recordingId: args.recordingId,
1100
- sourceMimeType: media.sourceMimeType,
1101
- });
1102
- })();
1037
+ extractionMs: audioExtractionTimeoutMs(
1038
+ estimatedRecordingMediaBytes(rec.videoSizeBytes, rec.durationMs),
1039
+ ),
1040
+ },
1041
+ });
1103
1042
  return audioMediaPromise;
1104
1043
  };
1105
- const ensureAudioHasSignal = (
1106
- media: AudioOnlyTranscriptionMedia,
1107
- ): Promise<void> => {
1108
- audioSignalPromise ??= assertAudioHasAudibleSignal(media);
1109
- return audioSignalPromise;
1110
- };
1111
1044
 
1112
1045
  const [existingNativeTranscript] = await db
1113
1046
  .select({
@@ -1231,13 +1164,14 @@ const requestTranscriptAction = defineAction({
1231
1164
  }
1232
1165
 
1233
1166
  let audioMedia: AudioOnlyTranscriptionMedia;
1167
+ const extractionStartedAt = Date.now();
1234
1168
  try {
1235
1169
  audioMedia = await withPendingTranscriptHeartbeat(
1236
1170
  db,
1237
1171
  args.recordingId,
1238
1172
  async () => {
1239
1173
  const media = await getAudioMedia(rec);
1240
- await ensureAudioHasSignal(media);
1174
+ assertAudioHasAudibleSignal(media);
1241
1175
  return media;
1242
1176
  },
1243
1177
  );
@@ -1252,35 +1186,39 @@ const requestTranscriptAction = defineAction({
1252
1186
  });
1253
1187
  }
1254
1188
 
1189
+ console.log("[clips] transcription audio prepared", {
1190
+ recordingId: args.recordingId,
1191
+ chunks: audioMedia.chunks.length,
1192
+ audioBytes: audioMedia.chunks.reduce(
1193
+ (total, chunk) => total + chunk.audioBytes.byteLength,
1194
+ 0,
1195
+ ),
1196
+ elapsedMs: Date.now() - extractionStartedAt,
1197
+ });
1198
+
1255
1199
  try {
1256
1200
  const startedAt = Date.now();
1257
- const builderResult = await withPendingTranscriptHeartbeat(
1201
+ const chunkResults = await withPendingTranscriptHeartbeat(
1258
1202
  db,
1259
1203
  args.recordingId,
1260
1204
  () =>
1261
- transcribeWithBuilderModelFallback({
1262
- audioBytes: audioMedia.audioBytes,
1263
- mimeType: audioMedia.mimeType,
1264
- diarize: true,
1265
- instructions: SPEECH_ONLY_TRANSCRIPTION_INSTRUCTIONS,
1266
- timeoutMs: builderTranscriptionTimeoutMs(rec.durationMs),
1205
+ transcribeAudioChunks({
1206
+ chunks: audioMedia.chunks,
1207
+ isRetryable: isTransientTranscriptionError,
1208
+ transcribe: (chunk) =>
1209
+ transcribeWithBuilderModelFallback({
1210
+ audioBytes: chunk.audioBytes,
1211
+ mimeType: audioMedia.mimeType,
1212
+ diarize: true,
1213
+ instructions: SPEECH_ONLY_TRANSCRIPTION_INSTRUCTIONS,
1214
+ timeoutMs: builderTranscriptionTimeoutMs(chunk.durationMs),
1215
+ }),
1267
1216
  }),
1268
1217
  );
1269
1218
 
1270
- const segments = (builderResult.segments ?? [])
1271
- .map((s) => {
1272
- const speaker = s.speakerLabel?.trim();
1273
- return {
1274
- startMs: s.startMs,
1275
- endMs: s.endMs,
1276
- text: s.text.trim(),
1277
- ...(speaker ? { speaker } : {}),
1278
- };
1279
- })
1280
- .filter((segment) => segment.text);
1281
- const normalizedTranscript = normalizeProviderTranscript(
1282
- builderResult.text,
1283
- segments,
1219
+ const normalizedTranscript = joinChunkTranscripts(
1220
+ audioMedia.chunks,
1221
+ chunkResults,
1284
1222
  );
1285
1223
  const fullText = normalizedTranscript.fullText;
1286
1224
 
@@ -1309,7 +1247,7 @@ const requestTranscriptAction = defineAction({
1309
1247
  ownerEmail,
1310
1248
  status: "ready",
1311
1249
  failureReason: null,
1312
- language: builderResult.language ?? "en",
1250
+ language: normalizedTranscript.language ?? "en",
1313
1251
  segmentsJson: JSON.stringify(normalizedTranscript.segments),
1314
1252
  fullText,
1315
1253
  now,
@@ -1363,7 +1301,7 @@ const requestTranscriptAction = defineAction({
1363
1301
 
1364
1302
  const elapsedMs = Date.now() - startedAt;
1365
1303
  console.log(
1366
- `Transcribed recording ${args.recordingId} via builder in ${elapsedMs}ms (${normalizedTranscript.segments.length} segments)`,
1304
+ `Transcribed recording ${args.recordingId} via builder in ${elapsedMs}ms (${audioMedia.chunks.length} parts, ${normalizedTranscript.segments.length} segments)`,
1367
1305
  );
1368
1306
  return {
1369
1307
  recordingId: args.recordingId,
@@ -1375,6 +1313,8 @@ const requestTranscriptAction = defineAction({
1375
1313
  } catch (err) {
1376
1314
  const reason = (err as Error).message;
1377
1315
  const details = serializeError(err);
1316
+ const chunkErr = err instanceof ChunkTranscriptionError ? err : null;
1317
+ if (chunkErr) builderFailureCode = "CHUNK_FAILED";
1378
1318
  if (isBuilderCreditsExhaustedMessage(reason)) {
1379
1319
  await noteBuilderCreditsExhausted({
1380
1320
  source: "transcription",
@@ -1401,6 +1341,12 @@ const requestTranscriptAction = defineAction({
1401
1341
  provider: BUILDER_GEMINI_TRANSCRIPTION_MODEL,
1402
1342
  failureReason: reason,
1403
1343
  details,
1344
+ ...(chunkErr
1345
+ ? {
1346
+ chunkIndex: chunkErr.chunkIndex,
1347
+ chunkCount: chunkErr.chunkCount,
1348
+ }
1349
+ : {}),
1404
1350
  });
1405
1351
  }
1406
1352
  }
@@ -1424,14 +1370,14 @@ const requestTranscriptAction = defineAction({
1424
1370
  ownerEmail,
1425
1371
  status: "failed",
1426
1372
  failureReason: reason,
1427
- failureCode: builderError ? "CLOUD_FAILED" : "CLOUD_UNCONFIGURED",
1373
+ failureCode: builderError ? builderFailureCode : "CLOUD_UNCONFIGURED",
1428
1374
  now,
1429
1375
  ...(cloudTransient ? { retryCount: cloudNextRetryCount } : {}),
1430
1376
  });
1431
1377
  track(
1432
1378
  "recording_transcription_failed",
1433
1379
  {
1434
- failure_code: builderError ? "CLOUD_FAILED" : "CLOUD_UNCONFIGURED",
1380
+ failure_code: builderError ? builderFailureCode : "CLOUD_UNCONFIGURED",
1435
1381
  stage: "transcription",
1436
1382
  retryable: cloudTransient,
1437
1383
  output_id: args.recordingId,