@larkup/workspace-server 0.0.0-stage → 0.2.1

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 (180) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +2 -2
  3. package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
  4. package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
  5. package/dist-types/app/api/chat/route.d.ts +3 -0
  6. package/dist-types/app/api/chat/route.d.ts.map +1 -0
  7. package/dist-types/app/api/chat/tools.d.ts +67 -0
  8. package/dist-types/app/api/chat/tools.d.ts.map +1 -0
  9. package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
  10. package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
  11. package/dist-types/app/api/config/verify/route.d.ts +3 -0
  12. package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
  13. package/dist-types/app/api/describe-image/route.d.ts +4 -0
  14. package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
  15. package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
  16. package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
  17. package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
  18. package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
  19. package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
  20. package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
  21. package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
  22. package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
  23. package/dist-types/app/api/document/sign/route.d.ts +2 -0
  24. package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
  25. package/dist-types/app/api/files/remote/route.d.ts +5 -0
  26. package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
  27. package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
  28. package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
  29. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
  30. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
  31. package/dist-types/app/api/mcp/route.d.ts +4 -0
  32. package/dist-types/app/api/mcp/route.d.ts.map +1 -0
  33. package/dist-types/app/api/parse-file/route.d.ts +6 -0
  34. package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
  35. package/dist-types/app/api/pdf-page/route.d.ts +4 -0
  36. package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
  37. package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
  38. package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
  39. package/dist-types/app/api/search/bing/route.d.ts +5 -0
  40. package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
  41. package/dist-types/app/api/search/brave/route.d.ts +5 -0
  42. package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
  43. package/dist-types/app/api/search/exa/route.d.ts +5 -0
  44. package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
  45. package/dist-types/app/api/search/google/route.d.ts +30 -0
  46. package/dist-types/app/api/search/google/route.d.ts.map +1 -0
  47. package/dist-types/app/api/search/route.d.ts +7 -0
  48. package/dist-types/app/api/search/route.d.ts.map +1 -0
  49. package/dist-types/app/api/search/tavily/route.d.ts +5 -0
  50. package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
  51. package/dist-types/app/api/search/verify/route.d.ts +4 -0
  52. package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
  53. package/dist-types/app/api/tabular/route.d.ts +9 -0
  54. package/dist-types/app/api/tabular/route.d.ts.map +1 -0
  55. package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
  56. package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
  57. package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
  58. package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
  59. package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
  60. package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
  61. package/dist-types/lib/chat/image-description.d.ts +10 -0
  62. package/dist-types/lib/chat/image-description.d.ts.map +1 -0
  63. package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
  64. package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
  65. package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
  66. package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
  67. package/dist-types/lib/chat/message-input.d.ts +8 -0
  68. package/dist-types/lib/chat/message-input.d.ts.map +1 -0
  69. package/dist-types/lib/chat/model-provider.d.ts +23 -0
  70. package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
  71. package/dist-types/lib/chat/response-style.d.ts +2 -0
  72. package/dist-types/lib/chat/response-style.d.ts.map +1 -0
  73. package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
  74. package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
  75. package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
  76. package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
  77. package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
  78. package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
  79. package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
  80. package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
  81. package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
  82. package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
  83. package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
  84. package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
  85. package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
  86. package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
  87. package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
  88. package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
  89. package/dist-types/lib/chat/tool-context.d.ts +72 -0
  90. package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
  91. package/dist-types/lib/chat/tool-registry.d.ts +3 -0
  92. package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
  93. package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
  94. package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
  95. package/dist-types/lib/chat/visual-routing.d.ts +53 -0
  96. package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
  97. package/dist-types/lib/enterprise-client.d.ts +31 -0
  98. package/dist-types/lib/enterprise-client.d.ts.map +1 -0
  99. package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
  100. package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
  101. package/dist-types/lib/media/knowledge.d.ts +47 -0
  102. package/dist-types/lib/media/knowledge.d.ts.map +1 -0
  103. package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
  104. package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
  105. package/dist-types/lib/media/source-identity.d.ts +12 -0
  106. package/dist-types/lib/media/source-identity.d.ts.map +1 -0
  107. package/dist-types/lib/media/source-utils.d.ts +157 -0
  108. package/dist-types/lib/media/source-utils.d.ts.map +1 -0
  109. package/dist-types/lib/media/video/quick-look.d.ts +60 -0
  110. package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
  111. package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
  112. package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
  113. package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
  114. package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
  115. package/dist-types/lib/remote-files.d.ts +12 -0
  116. package/dist-types/lib/remote-files.d.ts.map +1 -0
  117. package/dist-types/lib/search/provider-verification.d.ts +5 -0
  118. package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
  119. package/dist-types/runtime-host.d.ts +20 -0
  120. package/dist-types/runtime-host.d.ts.map +1 -0
  121. package/package.json +338 -3
  122. package/src/app/api/chat/feedback/route.ts +77 -0
  123. package/src/app/api/chat/route.ts +2277 -0
  124. package/src/app/api/chat/tools.ts +3563 -0
  125. package/src/app/api/config/test-embedding/route.ts +39 -0
  126. package/src/app/api/config/verify/route.ts +309 -0
  127. package/src/app/api/describe-image/route.ts +108 -0
  128. package/src/app/api/doc-editor/edit/route.ts +87 -0
  129. package/src/app/api/doc-editor/export/route.ts +32 -0
  130. package/src/app/api/doc-editor/parse/route.ts +74 -0
  131. package/src/app/api/doc-editor/restore/route.ts +70 -0
  132. package/src/app/api/document/sign/route.ts +55 -0
  133. package/src/app/api/files/remote/route.ts +32 -0
  134. package/src/app/api/mcp/[connectionId]/route.ts +29 -0
  135. package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
  136. package/src/app/api/mcp/route.ts +22 -0
  137. package/src/app/api/parse-file/route.ts +86 -0
  138. package/src/app/api/pdf-page/route.ts +40 -0
  139. package/src/app/api/proxy/verify/route.ts +78 -0
  140. package/src/app/api/search/bing/route.ts +86 -0
  141. package/src/app/api/search/brave/route.ts +88 -0
  142. package/src/app/api/search/exa/route.ts +100 -0
  143. package/src/app/api/search/google/route.ts +147 -0
  144. package/src/app/api/search/route.ts +28 -0
  145. package/src/app/api/search/tavily/route.ts +93 -0
  146. package/src/app/api/search/verify/route.ts +27 -0
  147. package/src/app/api/tabular/route.ts +68 -0
  148. package/src/lib/chat/assistant-source-scope.ts +32 -0
  149. package/src/lib/chat/conversation-memory.ts +621 -0
  150. package/src/lib/chat/gateway-fallbacks.ts +12 -0
  151. package/src/lib/chat/image-description.ts +21 -0
  152. package/src/lib/chat/media-retrieval-routing.ts +37 -0
  153. package/src/lib/chat/media-source-routing.ts +100 -0
  154. package/src/lib/chat/message-input.ts +48 -0
  155. package/src/lib/chat/model-provider.ts +157 -0
  156. package/src/lib/chat/response-style.ts +3 -0
  157. package/src/lib/chat/retrieval-ranking.ts +126 -0
  158. package/src/lib/chat/retrieval-routing.ts +221 -0
  159. package/src/lib/chat/tabular-dataset-routing.ts +93 -0
  160. package/src/lib/chat/tabular-query-plan.ts +195 -0
  161. package/src/lib/chat/tabular-routing.ts +122 -0
  162. package/src/lib/chat/tabular-sandbox-files.ts +78 -0
  163. package/src/lib/chat/tabular-visualization.ts +165 -0
  164. package/src/lib/chat/tool-call-repair.ts +84 -0
  165. package/src/lib/chat/tool-context.ts +1222 -0
  166. package/src/lib/chat/tool-registry.ts +6 -0
  167. package/src/lib/chat/video-rag-routing.ts +149 -0
  168. package/src/lib/chat/visual-routing.ts +225 -0
  169. package/src/lib/enterprise-client.ts +96 -0
  170. package/src/lib/marketplace/tool-runtime-config.ts +155 -0
  171. package/src/lib/media/knowledge.ts +489 -0
  172. package/src/lib/media/pdf-inspection.ts +280 -0
  173. package/src/lib/media/source-identity.ts +50 -0
  174. package/src/lib/media/source-utils.ts +1156 -0
  175. package/src/lib/media/video/quick-look.ts +368 -0
  176. package/src/lib/media/video-intelligence-adapter.ts +1946 -0
  177. package/src/lib/media/video-intelligence-connection.ts +151 -0
  178. package/src/lib/remote-files.ts +182 -0
  179. package/src/lib/search/provider-verification.ts +95 -0
  180. package/src/runtime-host.ts +31 -0
@@ -0,0 +1,1946 @@
1
+ import { workspaceHost } from '@larkup/core/workspace-host';
2
+ import { openAsBlob } from 'node:fs';
3
+ import { getInstalledTool } from '@larkup/marketplace/installer';
4
+ import { loadToolExtension } from '@larkup/marketplace/extension';
5
+ import type { MediaAsset, MediaPipelineStage } from '@larkup/core/types';
6
+ import { trackUsageEvent } from '@larkup/core/analytics-store';
7
+ import type { OfflineKnowledgeEvidenceInput } from '@larkup/core/video-knowledge/knowledge-builder';
8
+ import type { MediaEvidenceSegment } from './knowledge';
9
+ import { resolveVideoIntelligenceConnection } from './video-intelligence-connection';
10
+ import {
11
+ reconcileVideoIntelligenceCapacity,
12
+ type VideoIntelligenceUsageSnapshot,
13
+ } from '@larkup/core/video-intelligence-capacity';
14
+
15
+ /**
16
+ * Distinguishes "the GPU provider never picked this job up in time" from
17
+ * every other failure -- callers use this to tell the model/user the
18
+ * analysis service didn't respond, instead of implying the video itself
19
+ * doesn't show the answer.
20
+ */
21
+ export class VideoWorkerTimeoutError extends Error {
22
+ constructor(public readonly lastJobStatus: string) {
23
+ super(`The video analysis worker did not respond in time (last status: ${lastJobStatus}).`);
24
+ this.name = 'VideoWorkerTimeoutError';
25
+ }
26
+ }
27
+
28
+ /**
29
+ * A terminal managed-worker failure has already released its concurrency and
30
+ * source-minute reservation, so a fresh worker can safely retry it. Keep this
31
+ * deliberately narrow: transport errors may leave the original job running,
32
+ * while configuration, quota, and evidence errors need user action instead.
33
+ *
34
+ * Safe to retry:
35
+ * - Larkup Cloud wrapper messages (previous-generation worker responses)
36
+ * - Model API overload / capacity errors from the upstream vision/LLM gateway
37
+ * - Stall-timeout: the job was explicitly cancelled before throwing, so no
38
+ * duplicate run is left alive on the worker
39
+ * - Source ingestion failures where the worker dropped the job before ingesting
40
+ * (status === 'failed' at the prepare stage, not a user-data error)
41
+ */
42
+ export function isRetryableManagedVideoWorkerFailure(error: unknown): boolean {
43
+ const message = error instanceof Error ? error.message : String(error ?? '');
44
+ if (error instanceof VideoWorkerTimeoutError) return true;
45
+ return (
46
+ // Legacy Larkup Cloud wrapper messages
47
+ message.includes('Larkup Cloud could not complete this video') ||
48
+ message.includes('Larkup Cloud completed without a result') ||
49
+ // Modal GPU worker: upstream model capacity error
50
+ message.includes('model API is currently overloaded') ||
51
+ message.includes('may experience intermittent errors') ||
52
+ // Stall-timeout: job was cancelled before throwing — safe to resubmit
53
+ message.includes('stopped reporting progress for') ||
54
+ // Generic transient source ingestion drop (before any user data is processed)
55
+ message.includes('Video indexing ended with status failed')
56
+ );
57
+ }
58
+
59
+ interface VideoClient {
60
+ health(): Promise<unknown>;
61
+ getUsage(): Promise<{
62
+ sourceMinutesLimit: number | null;
63
+ sourceMinutesUsed: number;
64
+ activeJobs: number;
65
+ concurrentJobsLimit: number;
66
+ activeJobIds?: string[];
67
+ }>;
68
+ provisionDeviceAccess(installationId: string): Promise<{
69
+ apiKey: string;
70
+ entitlement: {
71
+ plan: string;
72
+ sourceMinutesPerMonth: number | null;
73
+ maxConcurrentJobs: number;
74
+ };
75
+ }>;
76
+ upload(file: Blob, fileName: string): Promise<{ uploadId: string }>;
77
+ submitJob(request: Record<string, unknown>): Promise<VideoJob>;
78
+ getJob(jobId: string): Promise<VideoJob>;
79
+ acknowledgeJobResult(jobId: string): Promise<{ status: string }>;
80
+ cancelJob(jobId: string): Promise<VideoJob>;
81
+ purgeJobData?: (jobId: string) => Promise<void>;
82
+ cancelOnlyActiveJob?: () => Promise<{ status: string; alreadyStopped?: boolean }>;
83
+ submitMaterialization?: (request: {
84
+ sourceUrl: string;
85
+ fileName?: string;
86
+ durationSecs?: number;
87
+ }) => Promise<VideoMaterializationJob>;
88
+ getMaterialization?: (jobId: string) => Promise<VideoMaterializationJob>;
89
+ acknowledgeMaterialization?: (jobId: string) => Promise<{ status: string }>;
90
+ cancelMaterialization?: (jobId: string) => Promise<VideoMaterializationJob>;
91
+ }
92
+
93
+ interface VideoMaterializationJob {
94
+ id: string;
95
+ status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
96
+ progress: {
97
+ stage: string;
98
+ percent: number;
99
+ message: string;
100
+ current?: number;
101
+ total?: number;
102
+ unit?: string;
103
+ elapsedSeconds?: number;
104
+ estimatedRemainingSeconds?: number;
105
+ };
106
+ source: {
107
+ url: string;
108
+ fileName: string;
109
+ mimeType: string;
110
+ sizeBytes: number;
111
+ durationSecs: number;
112
+ } | null;
113
+ error: string | null;
114
+ }
115
+
116
+ /**
117
+ * The control plane owns the source-minute allowance. A managed entitlement
118
+ * with remaining capacity may run its bounded inspection directly; a second
119
+ * host-side budget must not turn a valid paid allowance into an unnecessary
120
+ * browser approval flow.
121
+ */
122
+ export async function hasVideoIntelligenceCapacity(): Promise<boolean> {
123
+ if (workspaceHost()) return false;
124
+ const installed = await getInstalledTool('video-intelligence');
125
+ if (!installed) return false;
126
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
127
+ if (!extension) return false;
128
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
129
+ const client = extension.createClient({ config, fetch: globalThis.fetch });
130
+ const usage = await client.getUsage();
131
+ return (
132
+ usage.concurrentJobsLimit > 0 &&
133
+ usage.activeJobs < usage.concurrentJobsLimit &&
134
+ (usage.sourceMinutesLimit === null || usage.sourceMinutesUsed < usage.sourceMinutesLimit)
135
+ );
136
+ }
137
+
138
+ export async function getVideoIntelligenceUsage(): Promise<{
139
+ sourceMinutesLimit: number | null;
140
+ sourceMinutesUsed: number;
141
+ activeJobs: number;
142
+ concurrentJobsLimit: number;
143
+ activeJobIds?: string[];
144
+ }> {
145
+ const installed = await getInstalledTool('video-intelligence');
146
+ if (!installed) throw new Error('Video Intelligence is not installed.');
147
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
148
+ if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
149
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
150
+ return extension.createClient({ config, fetch: globalThis.fetch }).getUsage();
151
+ }
152
+
153
+ export async function getReconciledVideoIntelligenceUsage(
154
+ assets: MediaAsset[],
155
+ ): Promise<VideoIntelligenceUsageSnapshot> {
156
+ const installed = await getInstalledTool('video-intelligence');
157
+ if (!installed) throw new Error('Video Intelligence is not installed.');
158
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
159
+ if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
160
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
161
+ const client = extension.createClient({ config, fetch: globalThis.fetch });
162
+ return reconcileVideoIntelligenceCapacity({
163
+ assets,
164
+ getUsage: () => client.getUsage(),
165
+ getJob: (jobId) => client.getJob(jobId),
166
+ cancelJob: (jobId) => client.cancelJob(jobId),
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Checks credentials and a video-capable model before a source is uploaded,
172
+ * downloaded, or queued. This keeps configuration mistakes out of long-running
173
+ * media jobs and gives the settings screen a useful, actionable error.
174
+ */
175
+ export async function validateVideoIntelligenceConfiguration(): Promise<void> {
176
+ const installed = await getInstalledTool('video-intelligence');
177
+ if (!installed) throw new Error('Install Video Intelligence before indexing this video.');
178
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
179
+ if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
180
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
181
+ assertVideoIntelligenceConfiguration(config);
182
+ }
183
+
184
+ /**
185
+ * Uses the configured managed service only as a short-lived remote-source
186
+ * bridge. The host must immediately copy the returned URL into its own
187
+ * canonical storage, then acknowledge it so the managed copy is deleted.
188
+ */
189
+ export async function materializeInstalledRemoteVideo(input: {
190
+ sourceUrl: string;
191
+ fileName?: string;
192
+ durationSecs?: number;
193
+ maxWaitMs?: number;
194
+ assertStillActive?: () => Promise<void>;
195
+ reportProgress?: (progress: VideoMaterializationJob['progress']) => Promise<void>;
196
+ }): Promise<{ jobId: string; source: NonNullable<VideoMaterializationJob['source']> }> {
197
+ const installed = await getInstalledTool('video-intelligence');
198
+ if (!installed) throw new Error('Install Video Intelligence before importing this video.');
199
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
200
+ if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
201
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
202
+ const context = { config, fetch: globalThis.fetch };
203
+ await extension.ensureRuntime?.(context);
204
+ const client = extension.createClient(context);
205
+ if (
206
+ !client.submitMaterialization ||
207
+ !client.getMaterialization ||
208
+ !client.acknowledgeMaterialization ||
209
+ !client.cancelMaterialization
210
+ ) {
211
+ throw new Error('Update Video Intelligence before importing a remote video.');
212
+ }
213
+ await client.health();
214
+ await input.assertStillActive?.();
215
+ let job = await client.submitMaterialization({
216
+ sourceUrl: input.sourceUrl,
217
+ ...(input.fileName ? { fileName: input.fileName } : {}),
218
+ ...(Number.isFinite(input.durationSecs) && Number(input.durationSecs) > 0
219
+ ? { durationSecs: Number(input.durationSecs) }
220
+ : {}),
221
+ });
222
+ const deadline = Date.now() + (input.maxWaitMs ?? 2 * 60 * 60_000);
223
+ while (job.status === 'queued' || job.status === 'running') {
224
+ if (Date.now() >= deadline) {
225
+ await client.cancelMaterialization(job.id).catch(() => undefined);
226
+ throw new VideoWorkerTimeoutError(job.status);
227
+ }
228
+ await input.assertStillActive?.().catch(async (error) => {
229
+ await client.cancelMaterialization!(job.id).catch(() => undefined);
230
+ throw error;
231
+ });
232
+ await input.reportProgress?.(job.progress);
233
+ await new Promise((resolve) => setTimeout(resolve, 2_500));
234
+ job = await client.getMaterialization(job.id);
235
+ }
236
+ if (job.status !== 'completed' || !job.source) {
237
+ throw new Error(job.error || `Remote video import ended with status ${job.status}.`);
238
+ }
239
+ return { jobId: job.id, source: job.source };
240
+ }
241
+
242
+ export async function acknowledgeInstalledRemoteVideoMaterialization(jobId: string): Promise<void> {
243
+ const installed = await getInstalledTool('video-intelligence');
244
+ if (!installed) return;
245
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
246
+ if (!extension) return;
247
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
248
+ const client = extension.createClient({ config, fetch: globalThis.fetch });
249
+ await client.acknowledgeMaterialization?.(jobId);
250
+ }
251
+
252
+ /** Cancels the managed job before its MediaAsset is deleted. */
253
+ export async function cancelVideoIntelligenceJob(jobId: string): Promise<void> {
254
+ const installed = await getInstalledTool('video-intelligence');
255
+ if (!installed) return;
256
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
257
+ if (!extension) return;
258
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
259
+ const client = extension.createClient({ config, fetch: globalThis.fetch });
260
+ const job = await client.getJob(jobId);
261
+ if (job.status === 'queued' || job.status === 'running') await client.cancelJob(jobId);
262
+ }
263
+
264
+ /** Removes the local runtime's retained source/result after its host asset is deleted. */
265
+ export async function purgeLocalVideoIntelligenceJobData(jobId: string): Promise<void> {
266
+ const installed = await getInstalledTool('video-intelligence');
267
+ if (!installed) return;
268
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
269
+ if (!extension) return;
270
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
271
+ if (runtimeModeFor(config) !== 'local') return;
272
+ const client = extension.createClient({ config, fetch: globalThis.fetch });
273
+ if (typeof client.purgeJobData !== 'function') return;
274
+ await client.purgeJobData(jobId);
275
+ }
276
+
277
+ /** Stops exactly one orphaned cloud job. The service rejects multiple jobs. */
278
+ export async function cancelOnlyActiveVideoIntelligenceJob(): Promise<void> {
279
+ const installed = await getInstalledTool('video-intelligence');
280
+ if (!installed) throw new Error('Video Intelligence is not installed.');
281
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
282
+ if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
283
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
284
+ const client = extension.createClient({ config, fetch: globalThis.fetch });
285
+ if (typeof client.cancelOnlyActiveJob !== 'function') {
286
+ throw new Error('Update Video Intelligence before stopping an orphaned cloud job.');
287
+ }
288
+ await client.cancelOnlyActiveJob();
289
+ }
290
+
291
+ interface VideoJob {
292
+ id: string;
293
+ status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
294
+ progress: {
295
+ stage: string;
296
+ percent: number;
297
+ message: string;
298
+ stagePercent?: number;
299
+ sequence?: number;
300
+ elapsedSeconds?: number;
301
+ estimatedRemainingSeconds?: number;
302
+ current?: number;
303
+ total?: number;
304
+ unit?: string;
305
+ };
306
+ result: VideoEvidence | null;
307
+ error: string | null;
308
+ }
309
+
310
+ interface CloudProgressCounter {
311
+ current: number;
312
+ total: number;
313
+ }
314
+
315
+ interface CloudProgressCounters {
316
+ captions?: CloudProgressCounter;
317
+ searchClips?: CloudProgressCounter;
318
+ combined?: CloudProgressCounter;
319
+ }
320
+
321
+ interface VideoEvidence {
322
+ durationMs: number;
323
+ video: { width: number; height: number; fps: number };
324
+ transcript: Array<{ startMs: number; endMs: number; text: string }>;
325
+ visualObservations: Array<{
326
+ timeMs: number;
327
+ objects: Array<{ label: string; trackId: number; confidence: number }>;
328
+ ocr: Array<{ text: string; confidence: number }>;
329
+ }>;
330
+ tracks: Array<{
331
+ trackId: number;
332
+ label: string;
333
+ startMs: number;
334
+ endMs: number;
335
+ observations: number;
336
+ confidence?: number;
337
+ }>;
338
+ anonymousPresenceLedger?: {
339
+ method: 'object-detection-anonymous-tracking';
340
+ sampledFrames: number;
341
+ sampleIntervalSecs?: number;
342
+ tracks: Array<{
343
+ trackId: number;
344
+ label: string;
345
+ startMs: number;
346
+ endMs: number;
347
+ observations: number;
348
+ confidence?: number;
349
+ timestampsMs: number[];
350
+ }>;
351
+ labels: Array<{
352
+ label: string;
353
+ distinctTrackCount: number;
354
+ maximumSimultaneous: number;
355
+ simultaneousTimestampsMs: number[];
356
+ }>;
357
+ };
358
+ recurringOverlayText?: Array<{
359
+ text: string;
360
+ firstSeenMs: number;
361
+ lastSeenMs: number;
362
+ observations: number;
363
+ timestampsMs?: number[];
364
+ confidence: number;
365
+ }>;
366
+ semanticObservations?: Array<{
367
+ startMs: number;
368
+ endMs: number;
369
+ text: string;
370
+ confidence: number;
371
+ }>;
372
+ semanticDiagnostics?: {
373
+ attempted?: boolean;
374
+ error?: string | null;
375
+ };
376
+ agentDiagnostics?: {
377
+ attempted?: boolean;
378
+ provider?: string;
379
+ model?: string;
380
+ requests?: number;
381
+ latencyMs?: number;
382
+ fallback?: boolean;
383
+ error?: string | null;
384
+ promptTokens?: number;
385
+ completionTokens?: number;
386
+ };
387
+ processingDiagnostics?: {
388
+ estimatedTotalSeconds?: number;
389
+ elapsedSeconds?: number;
390
+ estimateErrorSeconds?: number;
391
+ };
392
+ transcriptionDiagnostics?: {
393
+ provider?: string | null;
394
+ fallbackProvider?: string | null;
395
+ fallbackUsed?: boolean;
396
+ chunkCount?: number;
397
+ completedChunks?: number;
398
+ chunkErrors?: number;
399
+ error?: string | null;
400
+ };
401
+ knowledgeSummary?: {
402
+ overview?: string;
403
+ participants?: Array<{
404
+ name: string;
405
+ role: string;
406
+ evidence: Array<{ startMs: number; endMs: number }>;
407
+ }>;
408
+ visibleSubjects?: Array<{
409
+ identity: string;
410
+ identityBasis: 'source-named' | 'source-described' | 'unresolved';
411
+ appearances: Array<{
412
+ startMs: number;
413
+ endMs: number;
414
+ confidence: 'direct' | 'partial';
415
+ }>;
416
+ }>;
417
+ stateHistory?: Array<{
418
+ startMs: number;
419
+ endMs: number;
420
+ state: string;
421
+ confidence: 'direct' | 'partial';
422
+ }>;
423
+ keyEvents?: Array<{
424
+ startMs: number;
425
+ endMs: number;
426
+ event: string;
427
+ confidence: 'direct' | 'partial';
428
+ }>;
429
+ narrative?: Array<{
430
+ startMs: number;
431
+ endMs: number;
432
+ text: string;
433
+ confidence: 'direct' | 'partial';
434
+ }>;
435
+ context?: Array<{
436
+ fact: string;
437
+ evidence: Array<{ startMs: number; endMs: number }>;
438
+ }>;
439
+ sourceItems?: Array<{
440
+ kind: 'question' | 'clue' | 'heading' | 'slide-item' | 'board-item' | 'list-item';
441
+ channel: 'spoken' | 'visible';
442
+ questionRole?: 'primary' | 'interactional' | 'rhetorical' | 'not-question';
443
+ text: string;
444
+ answer: string;
445
+ respondent?: string;
446
+ startMs: number;
447
+ endMs: number;
448
+ }>;
449
+ sourceActivityStructures?: Array<{
450
+ kind: 'grid' | 'sequence' | 'collection';
451
+ label: string;
452
+ dimensions: Array<{ label: string; values: string[] }>;
453
+ promptCount?: number;
454
+ startMs: number;
455
+ endMs: number;
456
+ }>;
457
+ sourceTaskInstances?: Array<{
458
+ id: string;
459
+ prompt: string;
460
+ channel: 'spoken' | 'visible';
461
+ answer: string;
462
+ respondent?: string;
463
+ startMs: number;
464
+ endMs: number;
465
+ }>;
466
+ sourceInventoryCoverage?: {
467
+ complete: boolean;
468
+ totalWindows: number;
469
+ processedWindows: number;
470
+ reason?: string;
471
+ };
472
+ uncertainties?: string[];
473
+ };
474
+ coverage?: {
475
+ requested?: 'fast' | 'balanced' | 'thorough';
476
+ sourceFrames?: number;
477
+ decodedFrames?: number;
478
+ analyzedFrames?: number;
479
+ semanticClips?: number;
480
+ };
481
+ videoEmbeddings?: Array<{
482
+ clipId: string;
483
+ startMs: number;
484
+ endMs: number;
485
+ vector: number[];
486
+ dimensions: number;
487
+ provider: string;
488
+ }>;
489
+ videoEmbeddingDiagnostics?: {
490
+ attempted?: boolean;
491
+ provider?: string;
492
+ error?: string | null;
493
+ };
494
+ detectedLanguage?: string;
495
+ answeringGuide: {
496
+ goal?: string;
497
+ importantEntities: string[];
498
+ questionsToPrepareFor: string[];
499
+ instruction: string;
500
+ };
501
+ }
502
+
503
+ export function createVideoIntelligenceSubmitRequest(input: {
504
+ source: Record<string, unknown>;
505
+ brief: Record<string, unknown>;
506
+ config?: Record<string, unknown>;
507
+ }): Record<string, unknown> {
508
+ return {
509
+ source: input.source,
510
+ brief: input.brief,
511
+ ...(input.config && runtimeModeFor(input.config) !== 'local'
512
+ ? { modelConfiguration: resolveVideoJobModelConfiguration(input.config) }
513
+ : {}),
514
+ };
515
+ }
516
+
517
+ type ReportStage = (
518
+ stage: MediaPipelineStage,
519
+ patch: {
520
+ status: 'running' | 'completed';
521
+ percent?: number;
522
+ current?: number;
523
+ total?: number;
524
+ unit?: string;
525
+ sequence?: number;
526
+ elapsedSeconds?: number;
527
+ estimatedRemainingSeconds?: number;
528
+ message: string;
529
+ startedAt?: string;
530
+ /**
531
+ * The dispatched job's own status, when known. 'queued' means the GPU
532
+ * worker hasn't picked the job up yet (cold start) -- distinct from
533
+ * `status` above, which is about this reporting call's own stage, not
534
+ * the underlying job.
535
+ */
536
+ jobStatus?: 'queued' | 'running';
537
+ },
538
+ ) => Promise<void>;
539
+
540
+ export async function runInstalledVideoIntelligence(input: {
541
+ asset: MediaAsset;
542
+ mediaPath: string;
543
+ reportStage: ReportStage;
544
+ briefOverride?: Record<string, unknown>;
545
+ /** Bounded cloud inspection reserves only the requested source range. */
546
+ sourceDurationSecs?: number;
547
+ /**
548
+ * A presigned HTTPS URL to an already-durable canonical copy (the web
549
+ * app's own S3StorageProvider). When set, this skips openAsBlob + the
550
+ * client.upload() multipart round-trip entirely -- the control plane
551
+ * dispatches the GPU worker straight at this URL, which is what lets
552
+ * watch_original re-inspect a video long after its original upload
553
+ * expired without ever re-transferring the file through this process.
554
+ */
555
+ sourceUrl?: string;
556
+ /** Persists the managed job ID immediately so deletion can cancel it. */
557
+ onJobSubmitted?: (jobId: string) => Promise<void>;
558
+ /** Prevents a removed asset from submitting a new managed job. */
559
+ assertStillActive?: () => Promise<void>;
560
+ /**
561
+ * How long to wait for the dispatched job before giving up. Full-video
562
+ * indexing runs unattended, so it keeps the long default; a chat-triggered
563
+ * bounded inspection has a live user waiting on a response and must fail
564
+ * fast (with a clear timeout error the caller can distinguish from "the
565
+ * video doesn't show this") if the GPU provider never picks the job up,
566
+ * rather than hang for hours.
567
+ */
568
+ maxWaitMs?: number;
569
+ }): Promise<{
570
+ evidence: VideoEvidence;
571
+ segments: MediaEvidenceSegment[];
572
+ }> {
573
+ const installed = await getInstalledTool('video-intelligence');
574
+ if (!installed) throw new Error('Install Video Intelligence (New) before indexing this video.');
575
+ const extension = await loadToolExtension<VideoClient>('video-intelligence');
576
+ if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
577
+ const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
578
+ assertVideoIntelligenceConfiguration(config);
579
+ const context = { config, fetch: globalThis.fetch };
580
+ await input.reportStage('extract', {
581
+ status: 'running',
582
+ percent: 1,
583
+ message: 'Connecting to the configured Video Intelligence runtime...',
584
+ });
585
+ const client = extension.createClient(context);
586
+ try {
587
+ await extension.ensureRuntime?.(context);
588
+ await client.health();
589
+ } catch (error) {
590
+ throw new Error(
591
+ `Video Intelligence connection check failed: ${
592
+ error instanceof Error ? error.message : 'unknown error'
593
+ }`,
594
+ );
595
+ }
596
+ const uploaded = input.sourceUrl
597
+ ? null
598
+ : await client.upload(
599
+ await openAsBlob(input.mediaPath, { type: input.asset.mimeType }),
600
+ input.asset.fileName,
601
+ );
602
+ const brief: Record<string, unknown> = enforceManagedSemanticBrief(config, {
603
+ ...createAssetVideoIndexingBrief(input.asset),
604
+ ...(input.briefOverride ?? {}),
605
+ // Managed indexing has a semantic reader for normal visual understanding.
606
+ // Local indexing is offline-only, so it must retain OCR and detection as
607
+ // the source of visual evidence rather than producing a speech-only index.
608
+ skipHeavyOperators: shouldSkipHeavyVideoOperators(config, input.briefOverride),
609
+ // Fast Cloud coverage is explicitly a visual overview. Avoid making its
610
+ // completion wait on a full Whisper pass, which can dominate a short
611
+ // video after a worker cold start; balanced/deep indexing still includes
612
+ // timestamped speech by default.
613
+ ...(shouldSkipCloudTranscription(config, input.briefOverride, input.asset)
614
+ ? { skipTranscription: true }
615
+ : {}),
616
+ // Fast is an evidence-backed overview: semantic captions are the source
617
+ // for chat, while video-vector embeddings are an optional secondary
618
+ // retrieval index. Do not make a user wait for that expensive remote
619
+ // index (or its provider queue) before a Fast video is searchable.
620
+ ...(shouldSkipCloudVideoEmbeddings(config, input.briefOverride, input.asset)
621
+ ? { skipVideoEmbeddings: true }
622
+ : {}),
623
+ });
624
+ // Normalizing an unbounded source through the worker's single-range path
625
+ // guarantees an H.264 analysis file. Some browser codecs transcribe fine
626
+ // but OpenCV cannot yield frames after a stream-copy materialization, which
627
+ // otherwise silently drops all OCR/object/VLM evidence from a full index.
628
+ const effectiveDurationSecs = input.sourceDurationSecs ?? input.asset.durationSecs;
629
+ if (
630
+ (!Array.isArray(brief.importantRanges) || brief.importantRanges.length === 0) &&
631
+ Number.isFinite(effectiveDurationSecs) &&
632
+ Number(effectiveDurationSecs) > 0
633
+ ) {
634
+ brief.importantRanges = [
635
+ { startSecs: 0, endSecs: Number(effectiveDurationSecs), note: 'full-index-normalization' },
636
+ ];
637
+ }
638
+ await input.assertStillActive?.();
639
+ let job: VideoJob;
640
+ try {
641
+ job = await client.submitJob(
642
+ createVideoIntelligenceSubmitRequest({
643
+ source: {
644
+ ...(input.sourceUrl ? { url: input.sourceUrl } : { uploadId: uploaded!.uploadId }),
645
+ fileName: input.asset.fileName,
646
+ mimeType: input.asset.mimeType,
647
+ // `durationSecs` is the billable bounded clip duration for a live
648
+ // inspection. The worker still needs the original timeline length
649
+ // to distinguish a 0:00–0:20 inspection from an entire 20-second
650
+ // source, otherwise it downloads and plans the full recording.
651
+ ...(Number.isFinite(input.asset.durationSecs) && Number(input.asset.durationSecs) > 0
652
+ ? { timelineDurationSecs: Number(input.asset.durationSecs) }
653
+ : {}),
654
+ durationSecs: effectiveDurationSecs,
655
+ },
656
+ brief,
657
+ config,
658
+ }),
659
+ );
660
+ } catch (error) {
661
+ throw new Error(
662
+ `Video Intelligence job submission failed: ${
663
+ error instanceof Error ? error.message : 'unknown error'
664
+ }`,
665
+ );
666
+ }
667
+ try {
668
+ await input.onJobSubmitted?.(job.id);
669
+ } catch (error) {
670
+ await client.cancelJob(job.id).catch(() => undefined);
671
+ throw error;
672
+ }
673
+ const maxWaitMs = input.maxWaitMs ?? 6 * 60 * 60_000;
674
+ // Queuing and execution are distinct provider phases. A worker can become
675
+ // available near the end of a cold-start allowance; do not cancel a job
676
+ // that has already started processing solely because it spent time queued.
677
+ let deadline = Date.now() + maxWaitMs;
678
+ let previousJobStatus = job.status;
679
+ let reportedCloudStage = '';
680
+ let cloudStageStartedAt = Date.now();
681
+ let lastRemoteProgressSignature = '';
682
+ let lastRemoteProgressAt = Date.now();
683
+ const runningStallTimeoutMs = Math.min(maxWaitMs, 3 * 60_000);
684
+ while (job.status === 'queued' || job.status === 'running') {
685
+ if (Date.now() >= deadline) {
686
+ await client.cancelJob(job.id).catch(() => undefined);
687
+ throw new VideoWorkerTimeoutError(job.status);
688
+ }
689
+ const remoteProgressSignature = JSON.stringify({
690
+ status: job.status,
691
+ stage: job.progress.stage,
692
+ percent: job.progress.percent,
693
+ stagePercent: job.progress.stagePercent,
694
+ sequence: job.progress.sequence,
695
+ current: job.progress.current,
696
+ total: job.progress.total,
697
+ message: job.progress.message,
698
+ });
699
+ const hasFreshRemoteProgress = remoteProgressSignature !== lastRemoteProgressSignature;
700
+ if (hasFreshRemoteProgress) {
701
+ lastRemoteProgressSignature = remoteProgressSignature;
702
+ lastRemoteProgressAt = Date.now();
703
+ } else if (
704
+ job.status === 'running' &&
705
+ Date.now() - lastRemoteProgressAt >= runningStallTimeoutMs
706
+ ) {
707
+ await client.cancelJob(job.id).catch(() => undefined);
708
+ throw new Error(
709
+ 'The video analysis worker stopped reporting progress for 3 minutes. The job was cancelled safely; retry to use a fresh worker.',
710
+ );
711
+ }
712
+ if (job.progress.stage !== reportedCloudStage) {
713
+ await completePreviousCloudStage(reportedCloudStage, input.reportStage);
714
+ reportedCloudStage = job.progress.stage;
715
+ cloudStageStartedAt = Date.now();
716
+ }
717
+ if (hasFreshRemoteProgress) {
718
+ const progress = cloudStageProgress(job.progress);
719
+ await input.reportStage(mapStage(job.progress.stage), {
720
+ status: 'running',
721
+ jobStatus: job.status === 'queued' ? 'queued' : 'running',
722
+ ...progress,
723
+ // The semantic-reader and embedding counters begin after the local
724
+ // decode/detect work. Reset the clock here so its ETA is based on the
725
+ // actual remote work, not the entire video job.
726
+ ...(job.progress.stage === 'synthesize'
727
+ ? { startedAt: new Date(cloudStageStartedAt).toISOString() }
728
+ : {}),
729
+ message: job.progress.message,
730
+ });
731
+ }
732
+ // Worker telemetry arrives every few seconds. Poll below the managed
733
+ // control-plane rate limit while keeping every heartbeat visible.
734
+ await new Promise((resolve) => setTimeout(resolve, 2_500));
735
+ job = await client.getJob(job.id);
736
+ if (job.status === 'running' && previousJobStatus !== 'running') {
737
+ deadline = Date.now() + maxWaitMs;
738
+ }
739
+ previousJobStatus = job.status;
740
+ }
741
+ if (job.status !== 'completed' || !job.result) {
742
+ throw new Error(job.error || `Video indexing ended with status ${job.status}.`);
743
+ }
744
+ const semanticDiagnostics = job.result.semanticDiagnostics;
745
+ const semanticFailure = shouldRequireSemanticVideoEvidence(
746
+ config,
747
+ semanticDiagnostics,
748
+ job.result.semanticObservations,
749
+ );
750
+ if (semanticFailure) {
751
+ const detail = semanticDiagnostics?.error
752
+ ? `: ${semanticDiagnostics.error}`
753
+ : !semanticDiagnostics
754
+ ? ': the Larkup Cloud worker returned an incomplete semantic-evidence result. Retry after updating the Cloud worker.'
755
+ : '';
756
+ throw new Error(`Video semantic analysis did not return validated evidence${detail}`);
757
+ }
758
+ const agent = job.result.agentDiagnostics;
759
+ if (agent?.attempted && agent.model) {
760
+ const promptTokens = agent.promptTokens ?? 0;
761
+ const completionTokens = agent.completionTokens ?? 0;
762
+ void trackUsageEvent({
763
+ type: 'media_processing',
764
+ mediaType: 'video',
765
+ mediaOperation: 'extraction',
766
+ modelId: agent.model,
767
+ provider: agent.provider,
768
+ promptTokens,
769
+ completionTokens,
770
+ totalTokens: promptTokens + completionTokens,
771
+ latencyMs: agent.latencyMs,
772
+ durationSecs: job.result.durationMs / 1_000,
773
+ frameCount: job.result.coverage?.analyzedFrames,
774
+ observationCount:
775
+ job.result.visualObservations.length + (job.result.semanticObservations?.length ?? 0),
776
+ runtime: runtimeModeFor(config) === 'local' ? 'local' : 'remote',
777
+ timestamp: new Date().toISOString(),
778
+ });
779
+ }
780
+ // The control plane retains source/result data only until this explicit
781
+ // acknowledgement. Do this only after validation, so a failed evidence
782
+ // check remains diagnosable and can be recovered instead of deleting the
783
+ // worker's result before the user sees the reason.
784
+ // Installed marketplace extensions are hot-loaded. Older still-supported
785
+ // clients acknowledge from getJob(), while newer ones expose the explicit
786
+ // acknowledgement method above. Support both during the rollout.
787
+ if (typeof client.acknowledgeJobResult === 'function') {
788
+ await client.acknowledgeJobResult(job.id).catch(() => undefined);
789
+ }
790
+ await input.reportStage('vision', {
791
+ status: 'completed',
792
+ percent: 100,
793
+ message: `Analyzed ${job.result.visualObservations.length} timestamped visual observations.`,
794
+ });
795
+ await input.reportStage('transcribe', {
796
+ status: 'completed',
797
+ percent: 100,
798
+ message: `Transcribed ${job.result.transcript.length} timestamped speech sections.`,
799
+ });
800
+ await input.reportStage('extract', {
801
+ status: 'completed',
802
+ percent: 100,
803
+ message: 'Video evidence selected.',
804
+ });
805
+ return { evidence: job.result, segments: evidenceToSegments(job.result) };
806
+ }
807
+
808
+ export function shouldRequireSemanticVideoEvidence(
809
+ config: Record<string, unknown>,
810
+ diagnostics: VideoEvidence['semanticDiagnostics'],
811
+ observations: VideoEvidence['semanticObservations'] = [],
812
+ ): boolean {
813
+ const hasUsableEvidence = observations.some((observation) => observation.text.trim());
814
+ return runtimeModeFor(config) !== 'local' && (!diagnostics?.attempted || !hasUsableEvidence);
815
+ }
816
+
817
+ /** Managed indexes must contain actual visual-language evidence, not speech/OCR alone. */
818
+ export function enforceManagedSemanticBrief(
819
+ config: Record<string, unknown>,
820
+ brief: Record<string, unknown>,
821
+ ): Record<string, unknown> {
822
+ return runtimeModeFor(config) === 'local' ? brief : { ...brief, requireSemanticVision: true };
823
+ }
824
+
825
+ export function shouldSkipHeavyVideoOperators(
826
+ _config: Record<string, unknown>,
827
+ briefOverride?: Record<string, unknown>,
828
+ ): boolean {
829
+ if (typeof briefOverride?.skipHeavyOperators === 'boolean') {
830
+ return briefOverride.skipHeavyOperators;
831
+ }
832
+ return false;
833
+ }
834
+
835
+ export function shouldSkipCloudTranscription(
836
+ _config: Record<string, unknown>,
837
+ briefOverride: Record<string, unknown> | undefined,
838
+ _asset: MediaAsset,
839
+ ): boolean {
840
+ return briefOverride?.skipTranscription === true;
841
+ }
842
+
843
+ export function shouldSkipCloudVideoEmbeddings(
844
+ _config: Record<string, unknown>,
845
+ briefOverride: Record<string, unknown> | undefined,
846
+ _asset: MediaAsset,
847
+ ): boolean {
848
+ return briefOverride?.skipVideoEmbeddings === true;
849
+ }
850
+
851
+ export function assertVideoIntelligenceConfiguration(config: Record<string, unknown>): void {
852
+ const models = resolveVideoJobModelConfiguration(config);
853
+ if (!/gemini.*(?:flash|vision)|qwen.*vl|gpt-4o|gpt-4\.1/i.test(models.vision.model)) {
854
+ throw new Error(
855
+ `Before indexing starts, choose a video-capable vision model. "${models.vision.model}" is not supported for video analysis.`,
856
+ );
857
+ }
858
+ }
859
+
860
+ export function resolveVideoJobModelConfiguration(config: Record<string, unknown>) {
861
+ const value = (candidate: unknown) =>
862
+ typeof candidate === 'string' && candidate.trim() ? candidate.trim() : undefined;
863
+
864
+ const supportedVisionProviders = new Set(['vercel_ai_gateway', 'google', 'openai']);
865
+ const supportedBrainProviders = new Set([
866
+ 'vercel_ai_gateway',
867
+ 'google',
868
+ 'openai',
869
+ 'deepseek',
870
+ 'mistral',
871
+ 'cohere',
872
+ 'anthropic',
873
+ ]);
874
+
875
+ const brainOverride = value(config.videoAgentProvider);
876
+ const globalBrainProvider = value(config.larkupAgentProvider);
877
+ const brainProvider =
878
+ brainOverride && brainOverride !== 'auto'
879
+ ? brainOverride
880
+ : (globalBrainProvider ?? 'vercel_ai_gateway');
881
+
882
+ const rawBrainApiKey =
883
+ (brainOverride && brainOverride !== 'auto' ? value(config.videoAgentApiKey) : undefined) ??
884
+ (globalBrainProvider === brainProvider ? value(config.larkupAgentApiKey) : undefined);
885
+
886
+ const visionOverride = value(config.videoVisionProvider);
887
+ const globalVisionProvider = value(config.larkupVisionProvider);
888
+ const visionProvider =
889
+ visionOverride && visionOverride !== 'auto'
890
+ ? visionOverride
891
+ : (globalVisionProvider ??
892
+ (supportedVisionProviders.has(brainProvider) ? brainProvider : 'vercel_ai_gateway'));
893
+
894
+ const visionApiKey =
895
+ (visionOverride && visionOverride !== 'auto' ? value(config.videoVisionApiKey) : undefined) ??
896
+ (globalVisionProvider === visionProvider ? value(config.larkupVisionApiKey) : undefined) ??
897
+ value(config.visionGatewayApiKey) ??
898
+ (visionProvider === brainProvider ? rawBrainApiKey : undefined);
899
+
900
+ const visionModelOverride = value(config.semanticVisionModel);
901
+ const visionModel =
902
+ (visionModelOverride && visionModelOverride !== 'auto' ? visionModelOverride : undefined) ??
903
+ (globalVisionProvider === visionProvider ? value(config.larkupVisionModel) : undefined) ??
904
+ (visionProvider === 'google'
905
+ ? 'google/gemini-3.6-flash'
906
+ : visionProvider === 'openai'
907
+ ? 'openai/gpt-4o-mini'
908
+ : 'google/gemini-3.6-flash');
909
+
910
+ const brainApiKey =
911
+ rawBrainApiKey ?? (brainProvider === visionProvider ? visionApiKey : undefined);
912
+ const brainModelOverride = value(config.agentModel);
913
+ const brainModel =
914
+ (brainModelOverride && brainModelOverride !== 'auto' ? brainModelOverride : undefined) ??
915
+ (globalBrainProvider === brainProvider ? value(config.larkupAgentModel) : undefined) ??
916
+ (brainProvider === 'google' ? 'google/gemini-3.5-flash-lite' : 'openai/gpt-5-mini');
917
+
918
+ const audioProvider = value(config.audioProvider) ?? 'deepgram';
919
+ const audioApiKey =
920
+ value(config.audioApiKey) ?? (audioProvider === brainProvider ? rawBrainApiKey : undefined);
921
+ const audioModel = {
922
+ openai: 'whisper-1',
923
+ groq: 'whisper-large-v3-turbo',
924
+ deepgram: 'nova-3',
925
+ elevenlabs: 'scribe_v2',
926
+ }[audioProvider] as string | undefined;
927
+
928
+ if (!supportedVisionProviders.has(visionProvider)) {
929
+ throw new Error(
930
+ `Video vision cannot use "${visionProvider}". Choose Google, OpenAI, or Vercel AI Gateway under Settings → AI Models → Vision Model.`,
931
+ );
932
+ }
933
+ if (!supportedBrainProviders.has(brainProvider)) {
934
+ throw new Error(`Agent / tool-brain provider "${brainProvider}" is not supported.`);
935
+ }
936
+ if (!new Set(['openai', 'groq', 'deepgram', 'elevenlabs']).has(audioProvider)) {
937
+ throw new Error(`Audio provider "${audioProvider}" is not supported.`);
938
+ }
939
+ if (!visionApiKey) {
940
+ throw new Error(
941
+ 'Before indexing starts, configure a vision provider and API key in Video Intelligence or under Settings → AI Models → Vision Model. Text-only providers such as DeepSeek need a separate vision provider. The video was not uploaded or downloaded.',
942
+ );
943
+ }
944
+ if (!brainApiKey) {
945
+ throw new Error(
946
+ 'Before indexing starts, add the selected agent provider API key in Video Intelligence or under Settings → AI Models. The video was not uploaded or downloaded.',
947
+ );
948
+ }
949
+ if (!audioApiKey) {
950
+ throw new Error(
951
+ 'Before indexing starts, add the selected audio provider API key in Video Intelligence settings. The video was not uploaded or downloaded.',
952
+ );
953
+ }
954
+ if (!audioModel) {
955
+ throw new Error(`Audio provider "${audioProvider}" has no automatic transcription model.`);
956
+ }
957
+ return {
958
+ audio: { provider: audioProvider, apiKey: audioApiKey!, model: audioModel! },
959
+ brain: { provider: brainProvider, apiKey: brainApiKey!, model: brainModel },
960
+ vision: { provider: visionProvider, apiKey: visionApiKey!, model: visionModel },
961
+ };
962
+ }
963
+
964
+ function runtimeModeFor(config: Record<string, unknown>): string {
965
+ const value = typeof config.runtimeMode === 'string' ? config.runtimeMode : '';
966
+ if (value === 'local' || value === 'local-docker' || value === 'local-process') return 'local';
967
+ return value === 'custom-remote' ? 'custom-remote' : 'managed-cloud';
968
+ }
969
+
970
+ /** Pull all independently-completed remote units out of the worker message. */
971
+ function cloudProgressCounters(message: string): CloudProgressCounters {
972
+ const counters = [
973
+ ...message.matchAll(
974
+ /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+(captions|described|indexed|search clips|clips)\b/gi,
975
+ ),
976
+ ]
977
+ .map((match) => ({
978
+ current: Number(match[1].replaceAll(',', '')),
979
+ total: Number(match[2].replaceAll(',', '')),
980
+ kind: match[3].toLowerCase(),
981
+ }))
982
+ .filter((counter) => Number.isFinite(counter.current) && Number.isFinite(counter.total));
983
+ const combined = counters.reduce<CloudProgressCounter | undefined>(
984
+ (combined, counter) => ({
985
+ current: (combined?.current ?? 0) + Math.min(counter.current, counter.total),
986
+ total: (combined?.total ?? 0) + counter.total,
987
+ }),
988
+ undefined,
989
+ );
990
+ const captions = counters.find(
991
+ (counter) => counter.kind === 'captions' || counter.kind === 'described',
992
+ );
993
+ const searchClips = counters.find(
994
+ (counter) =>
995
+ counter.kind === 'search clips' || counter.kind === 'clips' || counter.kind === 'indexed',
996
+ );
997
+ return {
998
+ captions: captions && { current: captions.current, total: captions.total },
999
+ searchClips: searchClips && { current: searchClips.current, total: searchClips.total },
1000
+ combined,
1001
+ };
1002
+ }
1003
+
1004
+ /**
1005
+ * Translate the worker's overall percent into the mapped UI step's own band,
1006
+ * preferring its true completed/total clip units whenever it reports them.
1007
+ */
1008
+ export function cloudStageProgress(progress: VideoJob['progress']): {
1009
+ percent: number;
1010
+ current?: number;
1011
+ total?: number;
1012
+ unit?: string;
1013
+ sequence?: number;
1014
+ elapsedSeconds?: number;
1015
+ estimatedRemainingSeconds?: number;
1016
+ } {
1017
+ const runtimeDetails = {
1018
+ ...(Number.isFinite(progress.sequence) ? { sequence: progress.sequence } : {}),
1019
+ ...(Number.isFinite(progress.elapsedSeconds)
1020
+ ? { elapsedSeconds: progress.elapsedSeconds }
1021
+ : {}),
1022
+ ...(Number.isFinite(progress.estimatedRemainingSeconds)
1023
+ ? { estimatedRemainingSeconds: progress.estimatedRemainingSeconds }
1024
+ : {}),
1025
+ };
1026
+ // Unlike a queue placeholder, this is FFmpeg's measured timestamp while
1027
+ // the Cloud worker copies/normalizes the source. Keep it as a normal,
1028
+ // determinate step so a long source never appears stuck at 5%.
1029
+ if (progress.stage === 'prepare') {
1030
+ return {
1031
+ percent: cloudOverallToStagePercent('prepare', progress.percent, progress.stagePercent),
1032
+ ...(Number.isFinite(progress.current) ? { current: progress.current } : {}),
1033
+ ...(Number.isFinite(progress.total) ? { total: progress.total } : {}),
1034
+ ...(progress.unit ? { unit: progress.unit } : {}),
1035
+ ...runtimeDetails,
1036
+ };
1037
+ }
1038
+ if (progress.stage === 'synthesize') {
1039
+ const counters = cloudProgressCounters(progress.message);
1040
+ if (counters.combined && counters.combined.total > 0) {
1041
+ const etaCounter =
1042
+ counters.captions && counters.captions.current < counters.captions.total
1043
+ ? counters.captions
1044
+ : counters.searchClips;
1045
+ return {
1046
+ percent: cloudOverallToStagePercent(
1047
+ progress.stage,
1048
+ progress.percent,
1049
+ progress.stagePercent,
1050
+ ),
1051
+ current: etaCounter?.current ?? counters.combined.current,
1052
+ total: etaCounter?.total ?? counters.combined.total,
1053
+ unit: etaCounter === counters.captions ? 'captions' : 'search clips',
1054
+ ...runtimeDetails,
1055
+ };
1056
+ }
1057
+ return {
1058
+ percent: cloudOverallToStagePercent(progress.stage, progress.percent, progress.stagePercent),
1059
+ ...runtimeDetails,
1060
+ };
1061
+ }
1062
+ return {
1063
+ percent: cloudOverallToStagePercent(progress.stage, progress.percent, progress.stagePercent),
1064
+ ...(Number.isFinite(progress.current) ? { current: progress.current } : {}),
1065
+ ...(Number.isFinite(progress.total) ? { total: progress.total } : {}),
1066
+ ...(progress.unit ? { unit: progress.unit } : {}),
1067
+ ...runtimeDetails,
1068
+ };
1069
+ }
1070
+
1071
+ /**
1072
+ * Convert the worker's overall percent into progress within the mapped UI
1073
+ * step, from the phase table the worker itself reports against. The worker
1074
+ * already advances that percent smoothly and monotonically, so there is
1075
+ * nothing left for the host to smooth or guess at.
1076
+ */
1077
+ export function cloudOverallToStagePercent(
1078
+ stage: string,
1079
+ overallPercent: number,
1080
+ stagePercent?: number,
1081
+ ): number {
1082
+ // The runtime knows which slice of its bar the current stage owns, so it
1083
+ // reports how far through that stage it is. Taking it at its word keeps a
1084
+ // per-step bar correct through any change to the pipeline's shape; the
1085
+ // fallback only covers a runtime old enough not to send it.
1086
+ if (typeof stagePercent === 'number' && Number.isFinite(stagePercent)) {
1087
+ return Math.max(0, Math.min(99, Math.round(stagePercent)));
1088
+ }
1089
+ // Preparation runs before the pipeline and reports its own share directly.
1090
+ if (stage === 'prepare') return Math.min(99, Math.round((overallPercent / 25) * 100));
1091
+ return Math.max(0, Math.min(99, Math.round(overallPercent)));
1092
+ }
1093
+
1094
+ /**
1095
+ * Preserve source-level cloud observations for the investigation engine. The
1096
+ * 30-second RAG segments above are useful navigation projections, but they
1097
+ * must never be the only evidence used for an exact score or count.
1098
+ */
1099
+ export function evidenceToKnowledgeInputs(evidence: VideoEvidence) {
1100
+ const visualObservations = [
1101
+ ...evidence.visualObservations.flatMap((observation) => {
1102
+ if (!observation.objects.length) return [];
1103
+ const timestampSecs = observation.timeMs / 1_000;
1104
+ const objects = observation.objects.map((object) => ({
1105
+ label: object.label,
1106
+ trackId: object.trackId,
1107
+ confidence: object.confidence,
1108
+ }));
1109
+ return [
1110
+ {
1111
+ startSecs: timestampSecs,
1112
+ endSecs: timestampSecs,
1113
+ observations: [
1114
+ {
1115
+ kind: 'object' as const,
1116
+ value: `Detected objects: ${objects
1117
+ .map((object) => `${object.label} (track ${object.trackId})`)
1118
+ .join(', ')}`,
1119
+ frameTimestamps: [timestampSecs],
1120
+ confidence:
1121
+ objects.reduce((total, object) => total + object.confidence, 0) /
1122
+ Math.max(1, objects.length),
1123
+ uncertaintyReasons: ['Object labels are produced by the video detector.'],
1124
+ },
1125
+ ],
1126
+ },
1127
+ ];
1128
+ }),
1129
+ ...(evidence.recurringOverlayText ?? []).map((overlay) => ({
1130
+ startSecs: overlay.firstSeenMs / 1_000,
1131
+ endSecs: overlay.lastSeenMs / 1_000,
1132
+ observations: [
1133
+ {
1134
+ kind: 'state' as const,
1135
+ // Recurring on-screen text says a display was present over a span
1136
+ // and when it changed. The distinct schema keeps it a locator: what
1137
+ // the text means is established by reading the frames.
1138
+ value: {
1139
+ subject: 'on-screen-text',
1140
+ property: 'recurring-overlay',
1141
+ value: overlay.text,
1142
+ observations: overlay.observations,
1143
+ firstSeenMs: overlay.firstSeenMs,
1144
+ lastSeenMs: overlay.lastSeenMs,
1145
+ },
1146
+ frameTimestamps: (overlay.timestampsMs ?? [overlay.firstSeenMs]).map(
1147
+ (timestamp) => timestamp / 1_000,
1148
+ ),
1149
+ confidence: overlay.confidence,
1150
+ uncertaintyReasons: [
1151
+ 'Recurring on-screen text read by OCR. Use it to locate a source range for visual verification; it does not establish what the text refers to.',
1152
+ ],
1153
+ },
1154
+ ],
1155
+ })),
1156
+ ...(evidence.semanticObservations ?? [])
1157
+ .filter((observation) => observation.text.trim())
1158
+ .map((observation) => ({
1159
+ startSecs: observation.startMs / 1_000,
1160
+ endSecs: observation.endMs / 1_000,
1161
+ observations: [
1162
+ {
1163
+ kind: 'action' as const,
1164
+ value: normalizeVisibleSubjectProtocolTimestamps(
1165
+ observation.text,
1166
+ observation.startMs,
1167
+ observation.endMs,
1168
+ ),
1169
+ frameTimestamps: [observation.startMs / 1_000, observation.endMs / 1_000],
1170
+ confidence: Math.min(1, Math.max(0, observation.confidence)),
1171
+ uncertaintyReasons: [
1172
+ 'Semantic VLM interpretation is grounded in a bounded sequence of cloud-analyzed frames.',
1173
+ ],
1174
+ },
1175
+ ],
1176
+ })),
1177
+ ];
1178
+ const ocrEvidence = evidence.visualObservations.flatMap((observation) => {
1179
+ const timestampSecs = observation.timeMs / 1_000;
1180
+ return observation.ocr
1181
+ .filter((line) => line.text.trim() && line.confidence >= 0.35)
1182
+ .map((line) => ({
1183
+ modality: 'ocr' as const,
1184
+ timeRange: {
1185
+ startSecs: timestampSecs,
1186
+ endSecs: timestampSecs,
1187
+ precision: 'estimated' as const,
1188
+ },
1189
+ payload: {
1190
+ text: line.text,
1191
+ blocks: [{ text: line.text, confidence: line.confidence }],
1192
+ },
1193
+ source: { kind: 'provider' as const, provider: 'video-intelligence-ocr' },
1194
+ confidence: {
1195
+ score: Math.min(1, Math.max(0, line.confidence)),
1196
+ source: 'provider' as const,
1197
+ calibrationStatus: 'uncalibrated' as const,
1198
+ uncertaintyReasons: ['OCR derived from a timestamped cloud analysis frame.'],
1199
+ },
1200
+ observation: { kind: 'ocr' as const, value: { text: line.text } },
1201
+ }));
1202
+ });
1203
+ return {
1204
+ transcriptChunks: evidence.transcript.map((segment) => ({
1205
+ text: segment.text,
1206
+ startSecs: segment.startMs / 1_000,
1207
+ endSecs: segment.endMs / 1_000,
1208
+ })),
1209
+ visualObservations,
1210
+ ocrEvidence,
1211
+ reconciledEvidence: reconciledAccount(evidence),
1212
+ };
1213
+ }
1214
+
1215
+ /**
1216
+ * A bounded worker decodes an excerpt as a new media stream, while its
1217
+ * enclosing semantic observation is anchored to the original source. Some
1218
+ * vision providers consequently return `Visible subject` protocol times
1219
+ * relative to that excerpt. Canonicalize only that typed protocol back to the
1220
+ * enclosing source interval before it is persisted; ordinary prose and
1221
+ * already-source-absolute protocol values remain unchanged.
1222
+ */
1223
+ function normalizeVisibleSubjectProtocolTimestamps(
1224
+ text: string,
1225
+ observationStartMs: number,
1226
+ observationEndMs: number,
1227
+ ): string {
1228
+ if (!text.includes('Visible subject: ')) return text;
1229
+ const startMs = Math.max(0, Math.floor(observationStartMs));
1230
+ const endMs = Math.max(startMs, Math.floor(observationEndMs));
1231
+ const spanMs = endMs - startMs;
1232
+ return text
1233
+ .split(/(\r?\n)/u)
1234
+ .map((line) => {
1235
+ const prefix = 'Visible subject: ';
1236
+ if (!line.startsWith(prefix)) return line;
1237
+ try {
1238
+ const subject = JSON.parse(line.slice(prefix.length)) as Record<string, unknown>;
1239
+ const subjectStartMs = Number(subject.startMs);
1240
+ const subjectEndMs = Number(subject.endMs);
1241
+ if (
1242
+ !Number.isFinite(subjectStartMs) ||
1243
+ !Number.isFinite(subjectEndMs) ||
1244
+ subjectEndMs < subjectStartMs ||
1245
+ (subjectStartMs >= startMs && subjectEndMs <= endMs)
1246
+ ) {
1247
+ return line;
1248
+ }
1249
+ // Relative timestamps may have a tiny negative value when the first
1250
+ // sampled frame precedes the decoded excerpt's zero point. Values far
1251
+ // beyond the excerpt are neither reliable relative times nor safe to
1252
+ // reinterpret, so leave them for the protocol validator to reject.
1253
+ if (subjectStartMs < -1_000 || subjectEndMs > spanMs + 1_000) return line;
1254
+ return (
1255
+ prefix +
1256
+ JSON.stringify({
1257
+ ...subject,
1258
+ startMs: Math.max(startMs, Math.min(endMs, Math.round(startMs + subjectStartMs))),
1259
+ endMs: Math.max(
1260
+ startMs,
1261
+ Math.min(endMs, Math.round(startMs + Math.max(subjectStartMs, subjectEndMs))),
1262
+ ),
1263
+ })
1264
+ );
1265
+ } catch {
1266
+ return line;
1267
+ }
1268
+ })
1269
+ .join('');
1270
+ }
1271
+
1272
+ /** Converts a bounded cloud re-analysis result into immutable refinement evidence. */
1273
+ export function evidenceToRefinementInputs(
1274
+ evidence: VideoEvidence,
1275
+ ): OfflineKnowledgeEvidenceInput[] {
1276
+ const inputs = evidenceToKnowledgeInputs(evidence);
1277
+ const transcript: OfflineKnowledgeEvidenceInput[] = inputs.transcriptChunks.map((chunk) => ({
1278
+ modality: 'transcript',
1279
+ timeRange: { startSecs: chunk.startSecs, endSecs: chunk.endSecs, precision: 'segment' },
1280
+ payload: { text: chunk.text },
1281
+ source: { kind: 'provider', provider: 'video-intelligence-stt' },
1282
+ confidence: {
1283
+ score: 0.8,
1284
+ source: 'provider',
1285
+ calibrationStatus: 'uncalibrated',
1286
+ uncertaintyReasons: ['Timestamped transcript returned by bounded cloud analysis.'],
1287
+ },
1288
+ observation: { kind: 'speech', value: { text: chunk.text } },
1289
+ }));
1290
+ const visual: OfflineKnowledgeEvidenceInput[] = inputs.visualObservations.flatMap((sequence) =>
1291
+ (sequence.observations ?? []).map((observation) => ({
1292
+ modality: 'visual' as const,
1293
+ timeRange: {
1294
+ startSecs: sequence.startSecs,
1295
+ endSecs: sequence.endSecs,
1296
+ precision: 'estimated' as const,
1297
+ },
1298
+ payload: { text: observation.value },
1299
+ source: { kind: 'provider' as const, provider: 'video-intelligence-vision' },
1300
+ confidence: {
1301
+ score: observation.confidence,
1302
+ source: 'provider' as const,
1303
+ calibrationStatus: 'uncalibrated' as const,
1304
+ uncertaintyReasons: observation.uncertaintyReasons,
1305
+ },
1306
+ observation: { kind: observation.kind, value: observation.value },
1307
+ })),
1308
+ );
1309
+ const trackedPeople = evidence.tracks.filter((track) => track.label.toLowerCase() === 'person');
1310
+ const tracking: OfflineKnowledgeEvidenceInput[] = trackedPeople.length
1311
+ ? [
1312
+ {
1313
+ modality: 'computed',
1314
+ timeRange: {
1315
+ startSecs: Math.min(...trackedPeople.map((track) => track.startMs / 1_000)),
1316
+ endSecs: Math.max(...trackedPeople.map((track) => track.endMs / 1_000)),
1317
+ precision: 'estimated',
1318
+ },
1319
+ payload: {
1320
+ method: 'video-intelligence-anonymous-person-tracking',
1321
+ tracks: trackedPeople.map((track) => ({
1322
+ id: track.trackId,
1323
+ startSecs: track.startMs / 1_000,
1324
+ endSecs: track.endMs / 1_000,
1325
+ observations: track.observations,
1326
+ confidence: track.confidence ?? null,
1327
+ })),
1328
+ },
1329
+ source: { kind: 'provider', provider: 'video-intelligence-tracking' },
1330
+ confidence: {
1331
+ score:
1332
+ trackedPeople.reduce((total, track) => total + (track.confidence ?? 0.65), 0) /
1333
+ trackedPeople.length,
1334
+ source: 'provider',
1335
+ calibrationStatus: 'uncalibrated',
1336
+ uncertaintyReasons: [
1337
+ 'This is an anonymous track count within the inspected range, not person identity.',
1338
+ 'Occlusion and re-entry can cause an undercount or duplicate track.',
1339
+ ],
1340
+ },
1341
+ observation: {
1342
+ kind: 'computed',
1343
+ value: {
1344
+ count: trackedPeople.length,
1345
+ label: 'person',
1346
+ method: 'anonymous-bounded-tracking',
1347
+ },
1348
+ },
1349
+ },
1350
+ ]
1351
+ : [];
1352
+ const anonymousPresence: OfflineKnowledgeEvidenceInput[] = evidence.anonymousPresenceLedger
1353
+ ? [
1354
+ {
1355
+ modality: 'computed',
1356
+ timeRange: {
1357
+ startSecs: 0,
1358
+ endSecs: evidence.durationMs / 1_000,
1359
+ precision: 'estimated',
1360
+ },
1361
+ payload: {
1362
+ text:
1363
+ 'Anonymous presence ledger from every indexed detection sample. It reports concurrent ' +
1364
+ 'subjects and continuous anonymous tracks, but does not claim that tracks across edits ' +
1365
+ 'belong to the same identity.',
1366
+ ...evidence.anonymousPresenceLedger,
1367
+ },
1368
+ source: { kind: 'provider', provider: 'video-intelligence-tracking' },
1369
+ confidence: {
1370
+ score: 0.72,
1371
+ source: 'provider',
1372
+ calibrationStatus: 'uncalibrated',
1373
+ uncertaintyReasons: [
1374
+ 'The ledger measures detections at indexed sample times, not every decoded frame.',
1375
+ 'Anonymous tracks can split when a subject is occluded, exits, re-enters, or a cut occurs.',
1376
+ ],
1377
+ },
1378
+ observation: {
1379
+ kind: 'computed',
1380
+ value: evidence.anonymousPresenceLedger,
1381
+ },
1382
+ },
1383
+ ]
1384
+ : [];
1385
+ return [
1386
+ ...transcript,
1387
+ ...visual,
1388
+ ...inputs.ocrEvidence,
1389
+ ...tracking,
1390
+ ...anonymousPresence,
1391
+ ...reconciledAccount(evidence),
1392
+ ];
1393
+ }
1394
+
1395
+ /**
1396
+ * The index's own reconciled account of the source, as retrievable evidence.
1397
+ *
1398
+ * Individual readings of the same moment disagree -- one clip reads a display
1399
+ * one way and the next reads it the other. The runtime already resolves that:
1400
+ * it cross-checks every claim against the rest of the timeline and keeps only
1401
+ * what survives. Without this, retrieval only ever sees the raw readings and
1402
+ * can surface the one that lost, so the answer contradicts the index that
1403
+ * produced it. Each item carries the span it was drawn from and the
1404
+ * uncertainties the check left open.
1405
+ */
1406
+ function reconciledAccount(evidence: VideoEvidence): OfflineKnowledgeEvidenceInput[] {
1407
+ const summary = evidence.knowledgeSummary;
1408
+ if (!summary) return [];
1409
+ const durationSecs = evidence.durationMs / 1_000;
1410
+ const entry = (
1411
+ startSecs: number,
1412
+ endSecs: number,
1413
+ text: string,
1414
+ confidence: number,
1415
+ ): OfflineKnowledgeEvidenceInput => ({
1416
+ modality: 'computed',
1417
+ timeRange: {
1418
+ startSecs: Math.max(0, Math.min(durationSecs, startSecs)),
1419
+ endSecs: Math.max(0, Math.min(durationSecs, Math.max(startSecs, endSecs))),
1420
+ precision: 'estimated',
1421
+ },
1422
+ payload: { text },
1423
+ source: { kind: 'provider', provider: 'video-intelligence-index' },
1424
+ confidence: {
1425
+ score: confidence,
1426
+ source: 'heuristic',
1427
+ calibrationStatus: 'uncalibrated',
1428
+ uncertaintyReasons: [
1429
+ 'The index reconciled this across the whole timeline; it is not a single reading.',
1430
+ ...(summary.uncertainties ?? []).slice(0, 4),
1431
+ ],
1432
+ },
1433
+ observation: { kind: 'computed', value: { text } },
1434
+ });
1435
+ const score = (confidence: 'direct' | 'partial') => (confidence === 'direct' ? 0.82 : 0.6);
1436
+ return [
1437
+ ...(summary.overview
1438
+ ? [entry(0, durationSecs, `Reconciled overview: ${summary.overview}`, 0.75)]
1439
+ : []),
1440
+ ...(summary.stateHistory ?? []).map((state) =>
1441
+ entry(
1442
+ state.startMs / 1_000,
1443
+ state.endMs / 1_000,
1444
+ `Reconciled state: ${state.state}`,
1445
+ score(state.confidence),
1446
+ ),
1447
+ ),
1448
+ ...(summary.keyEvents ?? []).map((event) =>
1449
+ entry(
1450
+ event.startMs / 1_000,
1451
+ event.endMs / 1_000,
1452
+ `Reconciled event: ${event.event}`,
1453
+ score(event.confidence),
1454
+ ),
1455
+ ),
1456
+ ...(summary.narrative ?? []).map((note) =>
1457
+ entry(
1458
+ note.startMs / 1_000,
1459
+ note.endMs / 1_000,
1460
+ `Chronological note: ${note.text}`,
1461
+ score(note.confidence),
1462
+ ),
1463
+ ),
1464
+ ...(summary.participants ?? []).flatMap((participant) =>
1465
+ participant.evidence
1466
+ .slice(0, 1)
1467
+ .map((range) =>
1468
+ entry(
1469
+ range.startMs / 1_000,
1470
+ range.endMs / 1_000,
1471
+ `Reconciled participant: ${participant.name} — ${participant.role}`,
1472
+ 0.7,
1473
+ ),
1474
+ ),
1475
+ ),
1476
+ ...(summary.visibleSubjects ?? []).flatMap((subject) => {
1477
+ const appearances = subject.appearances
1478
+ .filter((appearance) => appearance.endMs >= appearance.startMs)
1479
+ .slice(0, 256);
1480
+ if (!appearances.length) return [];
1481
+ const startMs = Math.min(...appearances.map((appearance) => appearance.startMs));
1482
+ const endMs = Math.max(...appearances.map((appearance) => appearance.endMs));
1483
+ const observedDurationMs = appearances.reduce(
1484
+ (total, appearance) => total + appearance.endMs - appearance.startMs,
1485
+ 0,
1486
+ );
1487
+ const direct = appearances.every((appearance) => appearance.confidence === 'direct');
1488
+ return [
1489
+ entry(
1490
+ startMs / 1_000,
1491
+ endMs / 1_000,
1492
+ `Reconciled visible subject: ${subject.identity}\nIdentity basis: ${subject.identityBasis}\n` +
1493
+ `Observed appearances: ${appearances
1494
+ .map(
1495
+ (appearance) =>
1496
+ `${appearance.startMs}-${appearance.endMs}ms (${appearance.confidence})`,
1497
+ )
1498
+ .join('; ')}\nObserved visible duration: ${observedDurationMs}ms`,
1499
+ direct ? 0.8 : 0.6,
1500
+ ),
1501
+ ];
1502
+ }),
1503
+ ...(summary.context ?? []).flatMap((context) =>
1504
+ context.evidence
1505
+ .slice(0, 1)
1506
+ .map((range) =>
1507
+ entry(
1508
+ range.startMs / 1_000,
1509
+ range.endMs / 1_000,
1510
+ `Reconciled context: ${context.fact}`,
1511
+ 0.7,
1512
+ ),
1513
+ ),
1514
+ ),
1515
+ ...(summary.sourceInventoryCoverage
1516
+ ? [
1517
+ entry(
1518
+ 0,
1519
+ durationSecs,
1520
+ `Source inventory coverage: ${
1521
+ summary.sourceInventoryCoverage.complete ? 'complete' : 'partial'
1522
+ }${
1523
+ summary.sourceInventoryCoverage.reason
1524
+ ? ` (${summary.sourceInventoryCoverage.reason})`
1525
+ : ''
1526
+ }`,
1527
+ summary.sourceInventoryCoverage.complete ? 0.9 : 0.5,
1528
+ ),
1529
+ ]
1530
+ : []),
1531
+ ...(summary.sourceItems ?? []).map((item) =>
1532
+ entry(
1533
+ item.startMs / 1_000,
1534
+ item.endMs / 1_000,
1535
+ item.kind === 'question'
1536
+ ? `Source question (${item.channel}${
1537
+ item.questionRole ? `, ${item.questionRole}` : ''
1538
+ }): ${item.text}${
1539
+ item.answer ? `\nSource answer: ${item.answer}` : ''
1540
+ }${item.respondent ? `\nSource respondent: ${item.respondent}` : ''}`
1541
+ : `Source item (${item.kind}, ${item.channel}): ${item.text}`,
1542
+ 0.82,
1543
+ ),
1544
+ ),
1545
+ ...(summary.sourceTaskInstances ?? []).map((task) =>
1546
+ entry(
1547
+ task.startMs / 1_000,
1548
+ task.endMs / 1_000,
1549
+ `Source question (${task.channel}, primary): ${task.prompt}\nSource task id: ${task.id}${
1550
+ task.answer ? `\nSource answer: ${task.answer}` : ''
1551
+ }${task.respondent ? `\nSource respondent: ${task.respondent}` : ''}`,
1552
+ 0.82,
1553
+ ),
1554
+ ),
1555
+ ...(summary.sourceActivityStructures ?? []).map((structure) =>
1556
+ entry(
1557
+ structure.startMs / 1_000,
1558
+ structure.endMs / 1_000,
1559
+ `Source structure (${structure.kind}): ${
1560
+ structure.label ||
1561
+ structure.dimensions
1562
+ .map((dimension) => `${dimension.label}: ${dimension.values.join(', ')}`)
1563
+ .join(' | ')
1564
+ }\nSource dimensions: ${structure.dimensions
1565
+ .map((dimension) => `${dimension.label} = ${dimension.values.join(', ')}`)
1566
+ .join(' | ')}${
1567
+ structure.promptCount === undefined
1568
+ ? ''
1569
+ : `\nSource prompt slots: ${structure.promptCount}`
1570
+ }`,
1571
+ 0.82,
1572
+ ),
1573
+ ),
1574
+ ];
1575
+ }
1576
+
1577
+ /**
1578
+ * Resolve the uploader's free-form focus once, then use it everywhere that
1579
+ * creates or audits a video index. It is deliberately opaque text: neither
1580
+ * the host nor the worker infers a genre or applies subject-specific rules.
1581
+ */
1582
+ export function resolveVideoIndexingHint(asset: MediaAsset): string | undefined {
1583
+ const candidate = asset.toolInputs?.['video-intelligence'];
1584
+ const input =
1585
+ candidate && typeof candidate === 'object' ? (candidate as Record<string, unknown>) : {};
1586
+ const surfaceGoal = typeof input.goal === 'string' ? input.goal.trim() : '';
1587
+ const persistedInstructions = asset.indexingInstructions?.trim() || '';
1588
+ const hint = surfaceGoal || persistedInstructions;
1589
+ return hint ? hint.slice(0, 4_000) : undefined;
1590
+ }
1591
+
1592
+ export function createAssetVideoIndexingBrief(asset: MediaAsset): Record<string, unknown> {
1593
+ const candidate = asset.toolInputs?.['video-intelligence'];
1594
+ const input =
1595
+ candidate && typeof candidate === 'object' ? (candidate as Record<string, unknown>) : {};
1596
+ const rawIndexingMode = String(input.indexingMode);
1597
+ const indexingMode = ['fast', 'balanced', 'thorough'].includes(rawIndexingMode)
1598
+ ? rawIndexingMode
1599
+ : 'balanced';
1600
+ return {
1601
+ goal: resolveVideoIndexingHint(asset),
1602
+ contentType:
1603
+ typeof input.contentType === 'string' && input.contentType.trim()
1604
+ ? input.contentType.trim().slice(0, 120)
1605
+ : 'general',
1606
+ knownEntities: stringList(input.knownEntities, 50),
1607
+ expectedQuestions: stringList(input.expectedQuestions, 20),
1608
+ language:
1609
+ typeof input.language === 'string' && input.language.trim() && input.language !== 'auto'
1610
+ ? input.language.slice(0, 32)
1611
+ : (inferLanguageHintFromTitle(asset.fileName) ?? 'auto'),
1612
+ importantRanges: Array.isArray(input.importantRanges) ? input.importantRanges.slice(0, 20) : [],
1613
+ indexingMode,
1614
+ processingAuthorityConfirmed: input.processingAuthorityConfirmed === true,
1615
+ retainSourceHours: retainSourceHours(input.retainSourceHours),
1616
+ };
1617
+ }
1618
+
1619
+ /**
1620
+ * Infer only languages whose writing system makes the hint high-confidence.
1621
+ * Latin titles stay automatic because their script is shared by too many
1622
+ * languages. This avoids a second AI request and remains content-agnostic.
1623
+ */
1624
+ export function inferLanguageHintFromTitle(title: string): string | undefined {
1625
+ const count = (pattern: RegExp) => title.match(pattern)?.length ?? 0;
1626
+ const arabic = count(/\p{Script=Arabic}/gu);
1627
+ const cyrillic = count(/\p{Script=Cyrillic}/gu);
1628
+ const candidates: Array<[string, number]> = [
1629
+ [/[ٹڈڑںھ]/u.test(title) ? 'ur' : /[گچپژ]/u.test(title) ? 'fa' : 'ar', arabic],
1630
+ [/[іїєґ]/iu.test(title) ? 'uk' : 'ru', cyrillic],
1631
+ ['he', count(/\p{Script=Hebrew}/gu)],
1632
+ ['ko', count(/\p{Script=Hangul}/gu)],
1633
+ ['ja', count(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu)],
1634
+ ['th', count(/\p{Script=Thai}/gu)],
1635
+ ['el', count(/\p{Script=Greek}/gu)],
1636
+ ['hi', count(/\p{Script=Devanagari}/gu)],
1637
+ ];
1638
+ const [language, characters] = candidates.sort((left, right) => right[1] - left[1])[0] ?? [];
1639
+ return characters >= 3 ? language : undefined;
1640
+ }
1641
+
1642
+ /**
1643
+ * A bounded cloud re-analysis (`watch_original` / inspection-policy's
1644
+ * required/optional decisions) reseeks the original source video, so it
1645
+ * cannot work once the source is deleted. Defaults to 72 hours -- long
1646
+ * enough for an active chat session to still verify uncertain evidence,
1647
+ * short enough to bound retained-video exposure -- and stays overridable
1648
+ * per install, clamped to the runtime brief's [0, 720] schema range.
1649
+ */
1650
+ function retainSourceHours(value: unknown): number {
1651
+ const configured = Number(value);
1652
+ if (!Number.isFinite(configured)) return 72;
1653
+ return Math.max(0, Math.min(720, Math.round(configured)));
1654
+ }
1655
+
1656
+ function stringList(value: unknown, limit: number): string[] {
1657
+ if (!Array.isArray(value)) return [];
1658
+ return [
1659
+ ...new Set(
1660
+ value
1661
+ .filter((item): item is string => typeof item === 'string')
1662
+ .map((item) => item.trim())
1663
+ .filter(Boolean),
1664
+ ),
1665
+ ].slice(0, limit);
1666
+ }
1667
+
1668
+ function mapStage(stage: string): MediaPipelineStage {
1669
+ if (stage === 'prepare') return 'prepare';
1670
+ if (stage === 'transcribe') return 'transcribe';
1671
+ // The worker's final phase is distinct from visual frame analysis. Preserve
1672
+ // it so the UI can truthfully say it is finishing rather than implying that
1673
+ // it is still selecting frames.
1674
+ if (stage === 'synthesize') return 'synthesize';
1675
+ if (stage === 'complete') return 'vision';
1676
+ if (stage === 'detect' || stage === 'ocr') return 'vision';
1677
+ return 'extract';
1678
+ }
1679
+
1680
+ async function completePreviousCloudStage(stage: string, reportStage: ReportStage): Promise<void> {
1681
+ if (stage === 'prepare') {
1682
+ await reportStage('prepare', {
1683
+ status: 'completed',
1684
+ percent: 100,
1685
+ message: 'Cloud video ready.',
1686
+ });
1687
+ }
1688
+ if (stage === 'transcribe') {
1689
+ await reportStage('transcribe', {
1690
+ status: 'completed',
1691
+ percent: 100,
1692
+ message: 'Speech timeline ready.',
1693
+ });
1694
+ }
1695
+ if (stage === 'decode') {
1696
+ await reportStage('extract', {
1697
+ status: 'completed',
1698
+ percent: 100,
1699
+ message: 'Video evidence selected.',
1700
+ });
1701
+ }
1702
+ }
1703
+
1704
+ export function evidenceToSegments(evidence: VideoEvidence): MediaEvidenceSegment[] {
1705
+ const buckets = new Map<
1706
+ number,
1707
+ { speech: string[]; story: string[]; visual: string[]; startSecs: number; endSecs: number }
1708
+ >();
1709
+ const bucketFor = (seconds: number) => {
1710
+ const index = Math.floor(seconds / 30);
1711
+ const existing = buckets.get(index);
1712
+ if (existing) return existing;
1713
+ const created = {
1714
+ speech: [] as string[],
1715
+ story: [] as string[],
1716
+ visual: [] as string[],
1717
+ startSecs: index * 30,
1718
+ endSecs: Math.min((index + 1) * 30, evidence.durationMs / 1_000),
1719
+ };
1720
+ buckets.set(index, created);
1721
+ return created;
1722
+ };
1723
+ for (const transcript of evidence.transcript) {
1724
+ bucketFor(transcript.startMs / 1_000).speech.push(transcript.text);
1725
+ }
1726
+ // A detector's class list ("person, chair, couch") and a raw OCR dump say
1727
+ // nothing a reader can use, but they are numerous enough to dominate every
1728
+ // retrieved excerpt and to be what a user sees when they open the indexed
1729
+ // text. Both remain queryable as their own evidence records
1730
+ // (`evidenceToKnowledgeInputs`); they just no longer pass for notes. Only
1731
+ // confidently-read on-screen text survives here, because an exact label or
1732
+ // readout is often the thing a later question turns on.
1733
+ for (const observation of evidence.visualObservations) {
1734
+ const visibleText = observation.ocr
1735
+ .filter((line) => line.confidence >= 0.75 && line.text.trim().length >= 2)
1736
+ .map((line) => line.text.trim());
1737
+ if (visibleText.length) {
1738
+ bucketFor(observation.timeMs / 1_000).visual.push(
1739
+ `On screen: ${[...new Set(visibleText)].join(' | ')}`,
1740
+ );
1741
+ }
1742
+ }
1743
+ for (const observation of evidence.semanticObservations ?? []) {
1744
+ const interpretation = observation.text.trim();
1745
+ if (!interpretation) continue;
1746
+ const bucket = bucketFor(observation.startMs / 1_000);
1747
+ bucket.endSecs = Math.max(
1748
+ bucket.endSecs,
1749
+ Math.min(evidence.durationMs / 1_000, observation.endMs / 1_000),
1750
+ );
1751
+ bucket.visual.push(interpretation);
1752
+ }
1753
+ const summary = evidence.knowledgeSummary;
1754
+ if (summary?.overview) bucketFor(0).visual.push(`Indexed overview: ${summary.overview}`);
1755
+ for (const participant of summary?.participants ?? []) {
1756
+ for (const source of participant.evidence) {
1757
+ bucketFor(source.startMs / 1_000).visual.push(
1758
+ `Indexed participant: ${participant.name} — ${participant.role}.`,
1759
+ );
1760
+ }
1761
+ }
1762
+ for (const state of summary?.stateHistory ?? []) {
1763
+ bucketFor(state.startMs / 1_000).visual.push(
1764
+ `Indexed state (${state.confidence}): ${state.state}`,
1765
+ );
1766
+ }
1767
+ for (const event of summary?.keyEvents ?? []) {
1768
+ bucketFor(event.startMs / 1_000).visual.push(
1769
+ `Indexed event (${event.confidence}): ${event.event}`,
1770
+ );
1771
+ }
1772
+ for (const note of summary?.narrative ?? []) {
1773
+ bucketFor(note.startMs / 1_000).story.push(note.text);
1774
+ }
1775
+ for (const context of summary?.context ?? []) {
1776
+ for (const source of context.evidence) {
1777
+ bucketFor(source.startMs / 1_000).visual.push(`Indexed context: ${context.fact}`);
1778
+ }
1779
+ }
1780
+ for (const item of summary?.sourceItems ?? []) {
1781
+ bucketFor(item.startMs / 1_000).visual.push(
1782
+ item.kind === 'question'
1783
+ ? `Source question (${item.channel}${
1784
+ item.questionRole ? `, ${item.questionRole}` : ''
1785
+ }): ${item.text}${
1786
+ item.answer ? `\nSource answer: ${item.answer}` : ''
1787
+ }${item.respondent ? `\nSource respondent: ${item.respondent}` : ''}`
1788
+ : `Source item (${item.kind}, ${item.channel}): ${item.text}`,
1789
+ );
1790
+ }
1791
+ for (const task of summary?.sourceTaskInstances ?? []) {
1792
+ bucketFor(task.startMs / 1_000).visual.push(
1793
+ `Source question (${task.channel}, primary): ${task.prompt}\nSource task id: ${task.id}${
1794
+ task.answer ? `\nSource answer: ${task.answer}` : ''
1795
+ }${task.respondent ? `\nSource respondent: ${task.respondent}` : ''}`,
1796
+ );
1797
+ }
1798
+ for (const structure of summary?.sourceActivityStructures ?? []) {
1799
+ bucketFor(structure.startMs / 1_000).visual.push(
1800
+ `Source structure (${structure.kind}): ${
1801
+ structure.label ||
1802
+ structure.dimensions
1803
+ .map((dimension) => `${dimension.label}: ${dimension.values.join(', ')}`)
1804
+ .join(' | ')
1805
+ }\nSource dimensions: ${structure.dimensions
1806
+ .map((dimension) => `${dimension.label} = ${dimension.values.join(', ')}`)
1807
+ .join(' | ')}${
1808
+ structure.promptCount === undefined ? '' : `\nSource prompt slots: ${structure.promptCount}`
1809
+ }`,
1810
+ );
1811
+ }
1812
+ if (summary?.sourceInventoryCoverage) {
1813
+ bucketFor(0).visual.push(
1814
+ `Source inventory coverage: ${
1815
+ summary.sourceInventoryCoverage.complete ? 'complete' : 'partial'
1816
+ }${
1817
+ summary.sourceInventoryCoverage.reason ? ` (${summary.sourceInventoryCoverage.reason})` : ''
1818
+ }`,
1819
+ );
1820
+ }
1821
+ const guide = [
1822
+ evidence.answeringGuide.goal ? `User indexing goal: ${evidence.answeringGuide.goal}` : '',
1823
+ evidence.answeringGuide.importantEntities.length
1824
+ ? `Important entities: ${evidence.answeringGuide.importantEntities.join(', ')}`
1825
+ : '',
1826
+ evidence.answeringGuide.questionsToPrepareFor.length
1827
+ ? `Expected questions: ${evidence.answeringGuide.questionsToPrepareFor.join(' | ')}`
1828
+ : '',
1829
+ ]
1830
+ .filter(Boolean)
1831
+ .join('\n');
1832
+ return [...buckets.entries()]
1833
+ .sort(([left], [right]) => left - right)
1834
+ .map(([, bucket], sequence) => {
1835
+ const transcript = bucket.speech.join(' ').trim();
1836
+ // Lead with the cross-scene account a person would remember; keep raw
1837
+ // per-clip readings underneath it as source detail.
1838
+ const visualContext = [...new Set([...bucket.story, ...bucket.visual])].join('\n');
1839
+ const guidance = sequence === 0 && guide ? `${guide}\n` : '';
1840
+ return {
1841
+ text: `${guidance}${transcript ? `Speech: ${transcript}\n` : ''}${visualContext}`.trim(),
1842
+ transcript,
1843
+ visualContext,
1844
+ startSecs: bucket.startSecs,
1845
+ endSecs: bucket.endSecs,
1846
+ sequence,
1847
+ };
1848
+ })
1849
+ .filter((segment) => segment.text.length > 0);
1850
+ }
1851
+
1852
+ /** Render the runtime's already-audited account without paying for a second LLM pass. */
1853
+ export function formatVideoKnowledgeSummary(evidence: VideoEvidence): string | null {
1854
+ const summary = evidence.knowledgeSummary;
1855
+ if (!summary?.overview) return null;
1856
+ const time = (milliseconds: number) => {
1857
+ const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
1858
+ const hours = Math.floor(seconds / 3_600);
1859
+ const minutes = Math.floor((seconds % 3_600) / 60);
1860
+ const remainder = seconds % 60;
1861
+ return hours > 0
1862
+ ? `${hours}:${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}`
1863
+ : `${minutes}:${remainder.toString().padStart(2, '0')}`;
1864
+ };
1865
+ // The runtime already writes every value in the recording's language.
1866
+ // Keep this renderer language-neutral instead of surrounding those values
1867
+ // with English headings that make an Arabic (or other-language) index look
1868
+ // translated in the Data view and bias cross-language retrieval.
1869
+ const lines = [summary.overview];
1870
+ if (summary.participants?.length) {
1871
+ lines.push(
1872
+ ...summary.participants.map((item) => {
1873
+ const anchor = item.evidence[0];
1874
+ return `- ${anchor ? `[${time(anchor.startMs)}] ` : ''}${item.name} — ${item.role}`;
1875
+ }),
1876
+ );
1877
+ }
1878
+ const timeline = [
1879
+ ...(summary.stateHistory ?? []).map((item) => ({
1880
+ startMs: item.startMs,
1881
+ endMs: item.endMs,
1882
+ text: item.state,
1883
+ })),
1884
+ ...(summary.keyEvents ?? []).map((item) => ({
1885
+ startMs: item.startMs,
1886
+ endMs: item.endMs,
1887
+ text: item.event,
1888
+ })),
1889
+ ].sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
1890
+ if (timeline.length) {
1891
+ lines.push(
1892
+ ...timeline.map((item) => `- [${time(item.startMs)}–${time(item.endMs)}] ${item.text}`),
1893
+ );
1894
+ }
1895
+ if (summary.narrative?.length) {
1896
+ lines.push(
1897
+ ...summary.narrative.map(
1898
+ (item) => `- [${time(item.startMs)}–${time(item.endMs)}] ${item.text}`,
1899
+ ),
1900
+ );
1901
+ }
1902
+ if (summary.context?.length) {
1903
+ lines.push(
1904
+ ...summary.context.map((item) => {
1905
+ const anchor = item.evidence[0];
1906
+ return `- ${anchor ? `[${time(anchor.startMs)}] ` : ''}${item.fact}`;
1907
+ }),
1908
+ );
1909
+ }
1910
+ if (summary.sourceItems?.length) {
1911
+ lines.push(
1912
+ ...summary.sourceItems.map(
1913
+ (item) =>
1914
+ `- [${time(item.startMs)}–${time(item.endMs)}] ${item.text}${
1915
+ item.answer ? ` — ${item.answer}` : ''
1916
+ }${item.respondent ? ` — ${item.respondent}` : ''}`,
1917
+ ),
1918
+ );
1919
+ }
1920
+ if (summary.sourceActivityStructures?.length) {
1921
+ lines.push(
1922
+ ...summary.sourceActivityStructures.map(
1923
+ (structure) =>
1924
+ `- [${time(structure.startMs)}–${time(structure.endMs)}] ${
1925
+ structure.label || '—'
1926
+ } (${structure.dimensions
1927
+ .map((dimension) => `${dimension.label} = ${dimension.values.join(', ')}`)
1928
+ .join(' | ')})`,
1929
+ ),
1930
+ );
1931
+ }
1932
+ if (summary.sourceTaskInstances?.length) {
1933
+ lines.push(
1934
+ ...summary.sourceTaskInstances.map(
1935
+ (task) =>
1936
+ `- [${time(task.startMs)}–${time(task.endMs)}] ${task.prompt}${
1937
+ task.answer ? ` — ${task.answer}` : ''
1938
+ }${task.respondent ? ` — ${task.respondent}` : ''}`,
1939
+ ),
1940
+ );
1941
+ }
1942
+ if (summary.uncertainties?.length) {
1943
+ lines.push(...summary.uncertainties.map((item) => `- ${item}`));
1944
+ }
1945
+ return lines.join('\n');
1946
+ }