@larkup/workspace-server 0.0.0-stage → 0.2.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 (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,489 @@
1
+ import { generateText } from 'ai';
2
+ import { getModelsByType } from '@larkup/core/models-cache';
3
+ import type { NewDocumentInput } from '@larkup/core/documents-store';
4
+ import type { RagConfig } from '@larkup/core/types';
5
+ import { sanitizeGeneratedOutput } from '@larkup/marketplace/output';
6
+ import { createChatModel, resolveConfiguredChatModel } from '../chat/model-provider';
7
+
8
+ export interface MediaEvidenceSegment {
9
+ text: string;
10
+ transcript?: string;
11
+ visualContext?: string;
12
+ startSecs: number;
13
+ endSecs: number;
14
+ sequence: number;
15
+ }
16
+
17
+ interface SummaryOptions {
18
+ title: string;
19
+ mediaType: 'video' | 'audio';
20
+ durationSecs: number;
21
+ segments: MediaEvidenceSegment[];
22
+ config: RagConfig;
23
+ onProgress?: (completed: number, total: number, message: string) => void | Promise<void>;
24
+ }
25
+
26
+ interface MediaDocumentOptions {
27
+ assetId: string;
28
+ title: string;
29
+ mediaType: 'video' | 'audio';
30
+ localUrl: string;
31
+ originalUrl?: string;
32
+ durationSecs: number;
33
+ summary: string;
34
+ segments: MediaEvidenceSegment[];
35
+ fileName: string;
36
+ mimeType: string;
37
+ transcriptSource: string;
38
+ transcriptProvider?: string;
39
+ transcriptLanguage?: string;
40
+ knowledgeRevisionId?: string;
41
+ videoRuntimeScope?: 'local' | 'cloud';
42
+ }
43
+
44
+ const SYNTHESIS_BATCH_CHARS = 14_000;
45
+ const FINAL_SYNTHESIS_CHARS = 32_000;
46
+
47
+ export async function createMediaKnowledgeSummary(options: SummaryOptions): Promise<string> {
48
+ const batches = batchSegments(options.segments, SYNTHESIS_BATCH_CHARS);
49
+ const models = await getModelsByType('language');
50
+ const resolved = resolveConfiguredChatModel(options.config, models);
51
+ const model = createChatModel(
52
+ resolved.provider,
53
+ resolved.modelId,
54
+ resolved.apiKey,
55
+ options.config.customChatModels,
56
+ ) as any;
57
+
58
+ const batchNotes: string[] = [];
59
+ let completedCalls = 0;
60
+ let previousBatchTail = '';
61
+ for (let index = 0; index < batches.length; index++) {
62
+ await options.onProgress?.(
63
+ completedCalls,
64
+ batches.length + 1,
65
+ `Reviewing evidence section ${index + 1} of ${batches.length}...`,
66
+ );
67
+ const prompt = buildBatchPrompt(
68
+ options,
69
+ batches[index],
70
+ index,
71
+ batches.length,
72
+ previousBatchTail,
73
+ );
74
+ const note = await generateAndTrack(model, prompt, resolved, options.mediaType);
75
+ batchNotes.push(note);
76
+ previousBatchTail = note.slice(-400);
77
+ completedCalls++;
78
+ await options.onProgress?.(
79
+ completedCalls,
80
+ Math.max(completedCalls + 1, batches.length + 1),
81
+ `Reviewed evidence section ${index + 1} of ${batches.length}.`,
82
+ );
83
+ }
84
+
85
+ let reducedNotes = batchNotes;
86
+ let reductionLevel = 0;
87
+ while (reducedNotes.join('\n\n').length > FINAL_SYNTHESIS_CHARS) {
88
+ reductionLevel++;
89
+ const groups = batchTexts(reducedNotes, SYNTHESIS_BATCH_CHARS);
90
+ const nextLevel: string[] = [];
91
+ for (let index = 0; index < groups.length; index++) {
92
+ const estimatedTotal = completedCalls + (groups.length - index) + 1;
93
+ await options.onProgress?.(
94
+ completedCalls,
95
+ estimatedTotal,
96
+ `Connecting note group ${index + 1} of ${groups.length}...`,
97
+ );
98
+ nextLevel.push(
99
+ await generateAndTrack(
100
+ model,
101
+ buildReductionPrompt(options, groups[index], reductionLevel, index, groups.length),
102
+ resolved,
103
+ options.mediaType,
104
+ ),
105
+ );
106
+ completedCalls++;
107
+ }
108
+ reducedNotes = nextLevel;
109
+ }
110
+
111
+ await options.onProgress?.(
112
+ completedCalls,
113
+ completedCalls + 1,
114
+ 'Connecting people, events, decisions, and outcomes...',
115
+ );
116
+ const finalPrompt = buildFinalPrompt(options, reducedNotes);
117
+ const summary = await generateAndTrack(model, finalPrompt, resolved, options.mediaType, 2_000);
118
+ completedCalls++;
119
+ await options.onProgress?.(completedCalls, completedCalls, 'Created searchable media notes.');
120
+ return summary.trim();
121
+ }
122
+
123
+ export function createFallbackMediaSummary(
124
+ _title: string,
125
+ _mediaType: 'video' | 'audio',
126
+ segments: MediaEvidenceSegment[],
127
+ ): string {
128
+ const selected = [
129
+ ...segments.slice(0, 2),
130
+ ...segments.slice(
131
+ Math.max(2, Math.floor(segments.length / 2) - 1),
132
+ Math.floor(segments.length / 2) + 1,
133
+ ),
134
+ ...segments.slice(-3),
135
+ ].filter(
136
+ (segment, index, all) =>
137
+ all.findIndex((candidate) => candidate.sequence === segment.sequence) === index,
138
+ );
139
+ return selected.map(formatSegmentForPrompt).join('\n\n');
140
+ }
141
+
142
+ export function buildMediaDocumentInputs(options: MediaDocumentOptions): NewDocumentInput[] {
143
+ const boundedSegments = options.segments.map((segment) => {
144
+ const startSecs = Math.max(0, Math.min(options.durationSecs, segment.startSecs));
145
+ const endSecs = Math.max(startSecs, Math.min(options.durationSecs, segment.endSecs));
146
+ return { ...segment, startSecs, endSecs };
147
+ });
148
+ const boundedOptions = { ...options, segments: boundedSegments };
149
+ const baseMetadata = {
150
+ mediaAssetId: options.assetId,
151
+ mediaType: options.mediaType,
152
+ fileName: options.fileName,
153
+ mimeType: options.mimeType,
154
+ mediaUrl: options.localUrl,
155
+ originalUrl: options.originalUrl,
156
+ durationSecs: options.durationSecs,
157
+ transcriptSource: options.transcriptSource,
158
+ transcriptProvider: options.transcriptProvider,
159
+ transcriptLanguage: options.transcriptLanguage,
160
+ knowledgeRevisionId: options.knowledgeRevisionId,
161
+ videoRuntimeScope: options.videoRuntimeScope,
162
+ };
163
+ const baseUrl = options.originalUrl || options.localUrl;
164
+ return [
165
+ {
166
+ title: options.title,
167
+ content: [options.summary].join('\n\n'),
168
+ source: 'media',
169
+ url: baseUrl,
170
+ metadata: {
171
+ ...baseMetadata,
172
+ contentKind: `${options.mediaType}-summary`,
173
+ isMediaSummary: true,
174
+ segmentCount: boundedSegments.length,
175
+ },
176
+ },
177
+ ...buildTemporalChapterInputs(boundedOptions, baseMetadata, baseUrl),
178
+ ...boundedSegments.map((segment) => ({
179
+ title: `${options.title} — ${formatTime(segment.startSecs)}`,
180
+ content: [segment.text].join('\n\n'),
181
+ source: 'media' as const,
182
+ url: timestampMediaUrl(baseUrl, segment.startSecs),
183
+ metadata: {
184
+ ...baseMetadata,
185
+ contentKind:
186
+ options.mediaType === 'video' ? 'multimodal-segment' : 'audio-transcript-segment',
187
+ isMediaSummary: false,
188
+ sequence: segment.sequence,
189
+ startSecs: segment.startSecs,
190
+ endSecs: segment.endSecs,
191
+ evidenceKinds: [
192
+ ...(segment.transcript?.trim() ? ['speech'] : []),
193
+ ...(segment.visualContext?.trim() ? ['visual', 'ocr'] : []),
194
+ ],
195
+ },
196
+ })),
197
+ ];
198
+ }
199
+
200
+ /**
201
+ * The vector index mirrors the temporal hierarchy: one root summary, bounded
202
+ * time-frame chapter summaries, then source-linked fine-grained segments.
203
+ * Chapter documents are retrieval aids only; their child evidence remains the
204
+ * authority for a factual answer.
205
+ */
206
+ function buildTemporalChapterInputs(
207
+ options: MediaDocumentOptions,
208
+ baseMetadata: Record<string, unknown>,
209
+ baseUrl: string,
210
+ ): NewDocumentInput[] {
211
+ const windowSecs =
212
+ options.durationSecs <= 30 * 60
213
+ ? 5 * 60
214
+ : options.durationSecs <= 4 * 60 * 60
215
+ ? 15 * 60
216
+ : 30 * 60;
217
+ const chapters = new Map<number, MediaEvidenceSegment[]>();
218
+ for (const segment of options.segments) {
219
+ const index = Math.floor(segment.startSecs / windowSecs);
220
+ const group = chapters.get(index) ?? [];
221
+ group.push(segment);
222
+ chapters.set(index, group);
223
+ }
224
+ return [...chapters.entries()].map(([index, segments]) => {
225
+ const startSecs = index * windowSecs;
226
+ const endSecs = Math.min(
227
+ options.durationSecs,
228
+ Math.max(...segments.map((segment) => segment.endSecs)),
229
+ );
230
+ return {
231
+ title: `${options.title} — ${formatTime(startSecs)}`,
232
+ content: [
233
+ ...segments.map(
234
+ (segment) =>
235
+ `[${formatTime(segment.startSecs)}–${formatTime(segment.endSecs)}] ${segment.text.slice(
236
+ 0,
237
+ 1_200,
238
+ )}`,
239
+ ),
240
+ ].join('\n\n'),
241
+ source: 'media' as const,
242
+ url: timestampMediaUrl(baseUrl, startSecs),
243
+ metadata: {
244
+ ...baseMetadata,
245
+ contentKind: 'media-chapter',
246
+ isMediaSummary: false,
247
+ isTemporalSummary: true,
248
+ chapterIndex: index,
249
+ startSecs,
250
+ endSecs,
251
+ segmentCount: segments.length,
252
+ },
253
+ };
254
+ });
255
+ }
256
+
257
+ export function timestampMediaUrl(url: string, startSecs: number): string {
258
+ const seconds = Math.max(0, Math.floor(startSecs));
259
+ try {
260
+ const parsed = new URL(url);
261
+ if (
262
+ /(^|\.)youtube\.com$|(^|\.)youtu\.be$/.test(parsed.hostname) ||
263
+ parsed.searchParams.has('v')
264
+ ) {
265
+ parsed.searchParams.set('t', `${seconds}s`);
266
+ return parsed.toString();
267
+ }
268
+ } catch {}
269
+ return `${url.split('#')[0]}#t=${seconds}`;
270
+ }
271
+
272
+ export function normalizeMediaCitationRange(
273
+ mediaType: 'image' | 'video' | 'audio',
274
+ durationSecs?: number,
275
+ startSecs?: number,
276
+ endSecs?: number,
277
+ ): { startSecs?: number; endSecs?: number } {
278
+ if (mediaType === 'image' || startSecs === undefined || !Number.isFinite(startSecs)) {
279
+ return {};
280
+ }
281
+
282
+ const duration =
283
+ durationSecs !== undefined && Number.isFinite(durationSecs)
284
+ ? Math.max(0, durationSecs)
285
+ : Number.POSITIVE_INFINITY;
286
+ const start = Math.min(Math.max(0, startSecs), duration);
287
+ const end =
288
+ endSecs !== undefined && Number.isFinite(endSecs)
289
+ ? Math.min(Math.max(start, endSecs), duration)
290
+ : undefined;
291
+
292
+ return { startSecs: start, endSecs: end };
293
+ }
294
+
295
+ export function formatTime(seconds: number): string {
296
+ const safeSeconds = Math.max(0, Math.floor(seconds));
297
+ const hours = Math.floor(safeSeconds / 3600);
298
+ const minutes = Math.floor((safeSeconds % 3600) / 60);
299
+ const secs = safeSeconds % 60;
300
+ return hours > 0
301
+ ? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
302
+ : `${minutes}:${String(secs).padStart(2, '0')}`;
303
+ }
304
+
305
+ export function queryAwareExcerpt(
306
+ text: string,
307
+ query: string,
308
+ maxChars: number,
309
+ preferEnd = false,
310
+ ): string {
311
+ if (text.length <= maxChars) return text;
312
+ const terms =
313
+ query
314
+ .normalize('NFKC')
315
+ .toLocaleLowerCase()
316
+ .match(/[\p{Letter}\p{Number}][\p{Letter}\p{Mark}\p{Number}_-]{2,}/gu) ?? [];
317
+ const normalizedText = text.normalize('NFKC').toLocaleLowerCase();
318
+ const matches = [...new Set(terms)]
319
+ .map((term) => normalizedText.indexOf(term))
320
+ .filter((index) => index >= 0);
321
+ if (matches.length > 0) {
322
+ const center = Math.min(...matches);
323
+ const start = Math.max(0, Math.min(text.length - maxChars, center - Math.floor(maxChars / 3)));
324
+ return `${start > 0 ? '…' : ''}${text.slice(start, start + maxChars)}${
325
+ start + maxChars < text.length ? '…' : ''
326
+ }`;
327
+ }
328
+ return preferEnd ? `…${text.slice(-maxChars)}` : `${text.slice(0, maxChars)}…`;
329
+ }
330
+
331
+ function batchSegments(
332
+ segments: MediaEvidenceSegment[],
333
+ maxChars: number,
334
+ ): MediaEvidenceSegment[][] {
335
+ const batches: MediaEvidenceSegment[][] = [];
336
+ let current: MediaEvidenceSegment[] = [];
337
+ let currentChars = 0;
338
+ for (const originalSegment of segments) {
339
+ const textParts = splitText(originalSegment.text, maxChars);
340
+ for (let partIndex = 0; partIndex < textParts.length; partIndex++) {
341
+ const segment = {
342
+ ...originalSegment,
343
+ text:
344
+ textParts.length === 1
345
+ ? textParts[partIndex]
346
+ : `${textParts[partIndex]}\n[Continuation ${partIndex + 1} of ${
347
+ textParts.length
348
+ } for this timestamp range]`,
349
+ };
350
+ if (current.length > 0 && currentChars + segment.text.length > maxChars) {
351
+ batches.push(current);
352
+ current = [];
353
+ currentChars = 0;
354
+ }
355
+ current.push(segment);
356
+ currentChars += segment.text.length;
357
+ }
358
+ }
359
+ if (current.length > 0) batches.push(current);
360
+ return batches;
361
+ }
362
+
363
+ function splitText(text: string, maxChars: number): string[] {
364
+ if (text.length <= maxChars) return [text];
365
+ const parts: string[] = [];
366
+ let offset = 0;
367
+ while (offset < text.length) {
368
+ let end = Math.min(text.length, offset + maxChars);
369
+ if (end < text.length) {
370
+ const boundary = text.lastIndexOf('\n', end);
371
+ if (boundary > offset + maxChars / 2) end = boundary;
372
+ }
373
+ parts.push(text.slice(offset, end));
374
+ offset = end;
375
+ }
376
+ return parts;
377
+ }
378
+
379
+ function batchTexts(texts: string[], maxChars: number): string[][] {
380
+ const groups: string[][] = [];
381
+ let current: string[] = [];
382
+ let currentChars = 0;
383
+ for (const text of texts.flatMap((value) => splitText(value, maxChars))) {
384
+ if (current.length > 0 && currentChars + text.length > maxChars) {
385
+ groups.push(current);
386
+ current = [];
387
+ currentChars = 0;
388
+ }
389
+ current.push(text);
390
+ currentChars += text.length;
391
+ }
392
+ if (current.length > 0) groups.push(current);
393
+ return groups;
394
+ }
395
+
396
+ function buildBatchPrompt(
397
+ options: SummaryOptions,
398
+ segments: MediaEvidenceSegment[],
399
+ index: number,
400
+ count: number,
401
+ previousBatchTail = '',
402
+ ): string {
403
+ const stateContext = previousBatchTail
404
+ ? `\nState from previous section: ${previousBatchTail}\nBuild on this context. Note what changed, progressed, or resolved since then.\n`
405
+ : '';
406
+ return `You are carefully watching section ${index + 1} of ${count} from a ${
407
+ options.mediaType
408
+ } titled "${options.title}".${stateContext}
409
+
410
+ Write compact factual viewing notes that remain useful for later questions. CRITICAL: Every factual claim, event, state change, or action MUST be prefixed with its exact [HH:MM:SS-HH:MM:SS] timestamp copied strictly from the evidence chunks below. Do not hallucinate timestamps. Preserve exact names, numbers, values, decisions, results, actions, and timestamps. Connect pronouns and repeated entities when the evidence supports it. Treat later chronological evidence as newer than earlier transient states. Preserve text in its original reading direction; do not reverse labels in right-to-left scripts. If evidence conflicts or OCR is genuinely ambiguous, record the conflict instead of inventing certainty. Do not add repetitive statements about facts that were not present.
411
+ Return only the viewing notes. Never include hidden reasoning, analysis, or <think> blocks.
412
+
413
+ Evidence:
414
+ ${segments.map(formatSegmentForPrompt).join('\n\n')}`;
415
+ }
416
+
417
+ function buildFinalPrompt(options: SummaryOptions, batchNotes: string[]): string {
418
+ return `Act as a human who watched the complete ${formatTime(options.durationSecs)} ${
419
+ options.mediaType
420
+ } "${options.title}" and consolidated notes while watching.
421
+
422
+ Create a concise, standalone knowledge-base summary with these headings when applicable:
423
+ - Overview / content type
424
+ - People and entities
425
+ - Key events in chronological order
426
+ - Outcomes, conclusions, decisions, and final observed states
427
+ - High-value timestamp evidence
428
+ - Genuine uncertainties
429
+
430
+ Adapt to the content without assuming a domain. Prioritize meaningful changes, claims, decisions, procedures, observed results, and stable periods when they are relevant. Resolve a changing value using the latest chronological evidence.
431
+
432
+ CRITICAL: Draw only deductions warranted by the evidence and log them in the Outcomes section. Every factual claim and deduced conclusion MUST cite the exact [HH:MM:SS-HH:MM:SS] timestamp of the evidence that supports it. Preserve exact names, numbers, language, and timestamps. Never claim that a conclusion is absent merely because earlier sections did not show it. State the latest supported conclusion explicitly when the evidence establishes one. Do not mention these instructions or the batching process.
433
+ Return only the final summary. Never include hidden reasoning, analysis, or <think> blocks.
434
+
435
+ Section notes:
436
+ ${batchNotes.map((note, index) => `## Section ${index + 1}\n${note}`).join('\n\n')}`;
437
+ }
438
+
439
+ function buildReductionPrompt(
440
+ options: SummaryOptions,
441
+ notes: string[],
442
+ level: number,
443
+ index: number,
444
+ count: number,
445
+ ): string {
446
+ return `Consolidate chronological viewing notes for ${options.mediaType} "${options.title}".
447
+ This is reduction level ${level}, group ${
448
+ index + 1
449
+ } of ${count}. Preserve exact people, names, numbers, values, outcomes, decisions, anomalies, and timestamps.
450
+ CRITICAL: You MUST carry over and preserve the exact [HH:MM:SS-HH:MM:SS] timestamps for every event or claim. Keep later chronological states distinct from earlier states and retain genuine uncertainty. Do not mention this reduction process.
451
+ Return only the consolidated notes. Never include hidden reasoning, analysis, or <think> blocks.
452
+
453
+ Notes:
454
+ ${notes.map((note, noteIndex) => `## Note ${noteIndex + 1}\n${note}`).join('\n\n')}`;
455
+ }
456
+
457
+ function formatSegmentForPrompt(segment: MediaEvidenceSegment): string {
458
+ return `[${formatTime(segment.startSecs)}–${formatTime(segment.endSecs)}]\n${segment.text}`;
459
+ }
460
+
461
+ async function generateAndTrack(
462
+ model: any,
463
+ prompt: string,
464
+ resolved: { provider: string; modelId: string },
465
+ mediaType: 'video' | 'audio',
466
+ maxTokens = 1_400,
467
+ ): Promise<string> {
468
+ const { text, usage } = await generateText({
469
+ model,
470
+ prompt,
471
+ maxOutputTokens: maxTokens,
472
+ temperature: 0,
473
+ });
474
+ const { estimateCost, trackUsageEvent } = await import('@larkup/core/analytics-store');
475
+ void trackUsageEvent({
476
+ type: 'media_processing',
477
+ mediaType,
478
+ modelId: resolved.modelId,
479
+ provider: resolved.provider,
480
+ promptTokens: usage.inputTokens ?? 0,
481
+ completionTokens: usage.outputTokens ?? 0,
482
+ totalTokens: usage.totalTokens ?? 0,
483
+ estimatedCost: estimateCost(resolved.modelId, usage.inputTokens ?? 0, usage.outputTokens ?? 0),
484
+ timestamp: new Date().toISOString(),
485
+ }).catch((error) => console.error('Failed to record media synthesis usage:', error));
486
+ const sanitized = sanitizeGeneratedOutput(text);
487
+ if (!sanitized) throw new Error('The media synthesis model returned no final answer.');
488
+ return sanitized;
489
+ }