@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,3563 @@
1
+ import { workspaceServerUrl, workspaceServerFetch as fetch } from '../../../runtime-host';
2
+ import { workspaceHost } from '@larkup/core/workspace-host';
3
+ import { createHash } from 'node:crypto';
4
+ import { jsonSchema, tool } from 'ai';
5
+ import { z } from 'zod';
6
+ import type {
7
+ AgentToolDefinition,
8
+ AgentToolExecutionContext,
9
+ AgentToolHandler,
10
+ } from '@larkup/marketplace/extension';
11
+ import { readConfig } from '@larkup/core/config-store';
12
+ import { createAdapter } from '@larkup/vector-stores/factory';
13
+ import { embedQuery } from '@larkup/core/indexing/embedder';
14
+ import { runWithProject } from '@larkup/core/project-store';
15
+ import {
16
+ getTabularDataset,
17
+ listTabularDatasets,
18
+ queryTabular,
19
+ resolveTabularDatasetGroups,
20
+ } from '@larkup/core/tabular-store';
21
+ import {
22
+ getCorpusDocuments,
23
+ exportCorpusAsCSV,
24
+ exportCorpusAsJSONL,
25
+ type CorpusFilter,
26
+ } from '@larkup/core/corpus-retriever';
27
+ import { workspaceSandbox } from '../../../runtime-host';
28
+ import type { SandboxBackend, SandboxFile } from '@larkup/sandbox/types';
29
+ import { applyFieldEdits, applyContentEdits } from '@larkup/tool-doc-editor';
30
+ import { loadTool } from '@larkup/marketplace/loader';
31
+ import { getInstalledTools } from '@larkup/marketplace/installer';
32
+ import { readDocuments } from '@larkup/core/documents-store';
33
+ import { readGroups } from '@larkup/core/groups-store';
34
+ import { readMediaAssets } from '@larkup/core/media-store';
35
+ import {
36
+ filterDocumentsAvailableToAssistant,
37
+ filterGroupOwnedDataAvailableToAssistant,
38
+ filterMediaAssetsAvailableToAssistant,
39
+ } from '@larkup/core/assistant-data-scope';
40
+ import {
41
+ searchVideoKnowledge,
42
+ videoKnowledgeRetrievalCapabilities,
43
+ } from '@larkup/core/video-knowledge/retrieval';
44
+ import {
45
+ aggregateVideoKnowledge,
46
+ scanVideoKnowledge,
47
+ } from '@larkup/core/video-knowledge/query-engine';
48
+ import { planVideoInvestigation as buildVideoInvestigationPlan } from '@larkup/core/video-knowledge/investigation';
49
+ import { verifyMediaEvidence } from '@larkup/core/video-knowledge/verification';
50
+ import {
51
+ planVideoQuestion,
52
+ type VideoInvestigationDirective,
53
+ } from '@larkup/core/video-knowledge/query-planner';
54
+ import { decideInspection, LIMITS } from '@larkup/core/video-knowledge/inspection-policy';
55
+ import { chunkTimeRange } from '@larkup/core/video-knowledge/inspection-chunking';
56
+ import { expandInvestigationRange } from '@larkup/core/video-knowledge/range-expansion';
57
+ import { queryVideoEmbeddings } from '@larkup/core/video-knowledge/video-embedding-index';
58
+ import {
59
+ searchSemanticEvidence,
60
+ semanticScoresByEvidenceId,
61
+ } from '@larkup/core/video-knowledge/evidence-semantic-index';
62
+ import {
63
+ formatTimecode,
64
+ fuseFocusRanges,
65
+ type FocusSignal,
66
+ } from '@larkup/core/video-knowledge/focus-ranges';
67
+ import { trackUsageEvent } from '@larkup/core/analytics-store';
68
+ import { cacheImageAnalysis, getCachedImageAnalysis } from '@larkup/core/image-analysis-cache';
69
+ import {
70
+ normalizeMediaCitationRange,
71
+ queryAwareExcerpt,
72
+ timestampMediaUrl,
73
+ } from '../../../lib/media/knowledge';
74
+ import { hasVideoIntelligenceCapacity } from '../../../lib/media/video-intelligence-adapter';
75
+ import {
76
+ isUsableFinding,
77
+ quickLook,
78
+ resolveQuickLookSource,
79
+ type QuickLookFinding,
80
+ } from '../../../lib/media/video/quick-look';
81
+ import {
82
+ buildQueryTermMatchers,
83
+ countMatchesIn,
84
+ rankKnowledgeHits,
85
+ } from '../../../lib/chat/retrieval-ranking';
86
+ import {
87
+ activeMediaFollowUpResult,
88
+ clearlyTitleMatchedMediaAsset,
89
+ clearlyUnavailableTitleMatchedMediaAsset,
90
+ explicitlySelectedMediaAsset,
91
+ shouldKeepActiveMediaSource,
92
+ } from '../../../lib/chat/media-source-routing';
93
+ import { createTabularVisualization } from '../../../lib/chat/tabular-visualization';
94
+ import { normalizeChartConfigWithEvidence } from '@larkup/core/chart-config';
95
+ import { createDataExport, selectDataExportSource } from '@larkup/core/data-export';
96
+ import { assistantSourceScopeFingerprint } from '../../../lib/chat/assistant-source-scope';
97
+ import { inferTabularPlan } from '../../../lib/chat/tabular-query-plan';
98
+ import {
99
+ resolveTabularDatasetForQuestion,
100
+ selectTabularDatasetForQuestion,
101
+ } from '../../../lib/chat/tabular-dataset-routing';
102
+ import {
103
+ createTabularSandboxFiles,
104
+ remapLegacyMultiDatasetReads,
105
+ } from '../../../lib/chat/tabular-sandbox-files';
106
+ import { leadingMediaAssetId } from '../../../lib/chat/media-retrieval-routing';
107
+ import { findIndexedImageSource } from '../../../lib/chat/visual-routing';
108
+ import { indexedVideoEvidenceIsSufficient } from '../../../lib/chat/video-rag-routing';
109
+ import {
110
+ inspectStoredPdf,
111
+ readStoredPdfBytes,
112
+ renderStoredPdfPage,
113
+ } from '../../../lib/media/pdf-inspection';
114
+ import {
115
+ canonicalMediaSourceUrl,
116
+ newestEquivalentMediaAsset,
117
+ } from '../../../lib/media/source-identity';
118
+ import {
119
+ isDocumentAvailableInVideoRuntime,
120
+ videoRuntimeScopeFromConfig,
121
+ type VideoRuntimeScope,
122
+ } from '@larkup/core/video-runtime-scope';
123
+ import {
124
+ executeEnterpriseTool,
125
+ getEnterpriseTools,
126
+ trackEnterpriseToolUsage,
127
+ } from '../../../lib/enterprise-client';
128
+
129
+ const sandboxAvailabilityCache = new Map<string, { expiresAt: number; ready: boolean }>();
130
+ const hostedSandboxAvailabilityCaches = new WeakMap<object, typeof sandboxAvailabilityCache>();
131
+
132
+ /** A compact, typed value captured from the source (for example a displayed
133
+ * state, total, or counter). Unlike free-form OCR, it can be ranked and
134
+ * cited without mistaking a clock or identifier for the answer. */
135
+ /**
136
+ * Ceiling on all automatic source re-inspection for one chat turn. Past this,
137
+ * the answer is composed from the evidence already gathered rather than
138
+ * holding the turn open for another cold GPU worker.
139
+ */
140
+ const CHAT_INSPECTION_BUDGET_MS = 150_000;
141
+ /**
142
+ * Ceiling on the bounded re-watch. Its windows run together, so this bounds the
143
+ * whole step rather than each window, and it is deliberately far below
144
+ * `CHAT_INSPECTION_BUDGET_MS`: this path exists to answer while someone waits.
145
+ */
146
+ const QUICK_LOOK_BUDGET_MS = 60_000;
147
+ /** Semantic ranking enriches RAG but must never hold an interactive lookup hostage. */
148
+ const CHAT_SEMANTIC_LOOKUP_BUDGET_MS = 2_500;
149
+
150
+ function structuredStateFromEvidence(
151
+ evidence: any,
152
+ ): { subject: string; property: string; value: string | number } | undefined {
153
+ const payload = evidence?.payload;
154
+ const text = payload && typeof payload === 'object' ? payload.text : undefined;
155
+ if (!text || typeof text !== 'object' || Array.isArray(text)) return undefined;
156
+ if (
157
+ typeof text.subject !== 'string' ||
158
+ typeof text.property !== 'string' ||
159
+ (typeof text.value !== 'string' && typeof text.value !== 'number')
160
+ ) {
161
+ return undefined;
162
+ }
163
+ return { subject: text.subject, property: text.property, value: text.value };
164
+ }
165
+
166
+ function evidenceText(payload: unknown): string {
167
+ if (payload && typeof payload === 'object' && 'text' in payload) {
168
+ const text = (payload as { text?: unknown }).text;
169
+ return typeof text === 'string' ? text : JSON.stringify(text);
170
+ }
171
+ return JSON.stringify(payload) ?? '';
172
+ }
173
+
174
+ /**
175
+ * Do not offer an unavailable sandbox to the model: a factual answer should
176
+ * still use retrieval or tabular data when the local runtime is unavailable.
177
+ */
178
+ async function isSandboxReady(config: any): Promise<boolean> {
179
+ const host = workspaceHost();
180
+ let availabilityCache = sandboxAvailabilityCache;
181
+ if (host) {
182
+ // Managed hosts can resolve different credentials behind identical public config.
183
+ availabilityCache = hostedSandboxAvailabilityCaches.get(host) ?? new Map();
184
+ hostedSandboxAvailabilityCaches.set(host, availabilityCache);
185
+ }
186
+ const { backend, credentials } = resolveSandboxConfig(config);
187
+ const cacheKey =
188
+ backend +
189
+ ':' +
190
+ createHash('sha256')
191
+ .update(JSON.stringify(credentials ?? {}))
192
+ .digest('hex');
193
+ const cached = availabilityCache.get(cacheKey);
194
+ if (cached && cached.expiresAt > Date.now()) return cached.ready;
195
+
196
+ try {
197
+ const health = await workspaceSandbox({ backend, credentials }).healthCheck();
198
+ const ready = health.status === 'ready';
199
+ availabilityCache.set(cacheKey, { ready, expiresAt: Date.now() + 30_000 });
200
+ return ready;
201
+ } catch {
202
+ availabilityCache.set(cacheKey, { ready: false, expiresAt: Date.now() + 30_000 });
203
+ return false;
204
+ }
205
+ }
206
+
207
+ function documentEditModelOutput({ output }: { output: any }) {
208
+ return {
209
+ type: 'json' as const,
210
+ value: output?.success
211
+ ? {
212
+ success: true,
213
+ action: output.action,
214
+ sessionId: output.sessionId,
215
+ updatedFields: output.updatedFields ?? [],
216
+ fileName: output.fileName,
217
+ totalPages: output.totalPages,
218
+ message:
219
+ 'The document was updated successfully. Do not repeat the edit unless requested.',
220
+ }
221
+ : {
222
+ success: false,
223
+ error: output?.error ?? 'Document update failed',
224
+ },
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Re-watches candidate windows of an indexed source to settle the current
230
+ * question, and reports only what that reading actually established.
231
+ *
232
+ * Speech already recorded for each window travels with it: a reader that can
233
+ * both see the frames and read what was said over them resolves an identity or
234
+ * a change that neither modality settles alone.
235
+ */
236
+ async function reWatchSource(input: {
237
+ asset: { id: string; storageUri: string; fileName: string; durationSecs?: number };
238
+ question: string;
239
+ ranges: Array<{ startSecs: number; endSecs: number; label?: string }>;
240
+ subjectName?: string;
241
+ knownEntities?: string[];
242
+ mediaAssetId: string;
243
+ maxWaitMs?: number;
244
+ }): Promise<{ findings: QuickLookFinding[]; elapsedMs: number } | undefined> {
245
+ const startedAt = Date.now();
246
+ let release: (() => Promise<void>) | undefined;
247
+ try {
248
+ const looks = input.ranges.map((range) => ({
249
+ startSecs: Math.max(0, range.startSecs),
250
+ endSecs: Math.min(
251
+ input.asset.durationSecs ?? range.endSecs,
252
+ Math.max(range.startSecs + 5, Math.min(range.endSecs, range.startSecs + 45)),
253
+ ),
254
+ lookingFor: [
255
+ input.subjectName
256
+ ? `Which visible person is "${input.subjectName}", and what the question asks about them.`
257
+ : range.label,
258
+ input.knownEntities?.length
259
+ ? `Known source identities to ground from visible labels or aligned speech, never assume: ${input.knownEntities.join(', ')}.`
260
+ : '',
261
+ ]
262
+ .filter(Boolean)
263
+ .join(' '),
264
+ }));
265
+ const speech = await Promise.all(
266
+ looks.map((range) =>
267
+ searchVideoKnowledge(input.mediaAssetId, '', 12, {
268
+ modalities: ['transcript'],
269
+ minimumRangeDistanceSecs: 0,
270
+ videoDurationSecs: input.asset.durationSecs,
271
+ timeRange: range,
272
+ }).catch(() => []),
273
+ ),
274
+ );
275
+ const transcriptByStart = new Map(
276
+ looks.map((range, index) => [
277
+ range.startSecs,
278
+ speech[index]
279
+ .sort((a, b) => a.evidence.timeRange.startSecs - b.evidence.timeRange.startSecs)
280
+ .map((hit) => evidenceText(hit.evidence.payload))
281
+ .filter(Boolean)
282
+ .join(' ')
283
+ .slice(0, 4_000),
284
+ ]),
285
+ );
286
+ const source = await resolveQuickLookSource(input.asset.storageUri, input.asset.fileName);
287
+ release = source.cleanup;
288
+ const findings = await quickLook({
289
+ mediaPath: source.mediaPath,
290
+ question: input.question,
291
+ ranges: looks,
292
+ transcriptFor: (range) => transcriptByStart.get(range.startSecs) || undefined,
293
+ signal: AbortSignal.timeout(
294
+ Math.max(1_000, Math.min(QUICK_LOOK_BUDGET_MS, input.maxWaitMs ?? QUICK_LOOK_BUDGET_MS)),
295
+ ),
296
+ });
297
+ const usable = findings.filter(isUsableFinding);
298
+ return usable.length > 0 ? { findings: usable, elapsedMs: Date.now() - startedAt } : undefined;
299
+ } catch (error) {
300
+ console.warn('[chat] bounded re-watch of the source failed:', error);
301
+ return undefined;
302
+ } finally {
303
+ await release?.();
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Retrieves documents from the knowledge base
309
+ */
310
+ function isMediaAssetAvailableInRuntime(
311
+ asset: { videoRuntimeScope?: unknown },
312
+ activeScope: VideoRuntimeScope,
313
+ ) {
314
+ return asset.videoRuntimeScope !== 'local' && asset.videoRuntimeScope !== 'cloud'
315
+ ? true
316
+ : asset.videoRuntimeScope === activeScope;
317
+ }
318
+
319
+ function uniqueCompletedMediaFallback(
320
+ query: string,
321
+ activeScope: VideoRuntimeScope,
322
+ mediaAssets: Awaited<ReturnType<typeof readMediaAssets>>,
323
+ groups: Awaited<ReturnType<typeof readGroups>>,
324
+ ) {
325
+ const assets = filterMediaAssetsAvailableToAssistant(mediaAssets, groups).filter(
326
+ (asset) =>
327
+ asset.processingStatus === 'completed' &&
328
+ Boolean(asset.activeVideoKnowledgeRevisionId) &&
329
+ isMediaAssetAvailableInRuntime(asset, activeScope) &&
330
+ (asset.type === 'video' || asset.type === 'audio'),
331
+ );
332
+ const allCompletedAssets = mediaAssets.filter(
333
+ (asset) =>
334
+ asset.processingStatus === 'completed' &&
335
+ Boolean(asset.activeVideoKnowledgeRevisionId) &&
336
+ isMediaAssetAvailableInRuntime(asset, activeScope) &&
337
+ (asset.type === 'video' || asset.type === 'audio'),
338
+ );
339
+ const unavailableTitleMatch = clearlyUnavailableTitleMatchedMediaAsset(
340
+ query,
341
+ assets,
342
+ allCompletedAssets,
343
+ );
344
+ if (unavailableTitleMatch) {
345
+ return {
346
+ query,
347
+ hits: [],
348
+ error: `The named media source "${unavailableTitleMatch.fileName}" is not available to Assistant chat. Enable its data group under Data → Groups, then ask again.`,
349
+ };
350
+ }
351
+ // A retrieval miss must not strand a single, unambiguous active media
352
+ // source. With several sources the host deliberately stays neutral and
353
+ // lets ordinary retrieval disambiguate rather than guessing the asset.
354
+ if (assets.length === 1) {
355
+ return {
356
+ query,
357
+ hits: [],
358
+ videoEvidence: {
359
+ mediaAssetId: assets[0].id,
360
+ retrievalFallback: 'single-active-media-source',
361
+ },
362
+ };
363
+ }
364
+ // A vector index can be temporarily unavailable while a completed video
365
+ // remains perfectly usable through its evidence store. Recover an asset
366
+ // only when its user-visible title has a clear lexical lead; otherwise
367
+ // preserve the neutral no-result behavior for genuinely ambiguous media.
368
+ const titleMatch = clearlyTitleMatchedMediaAsset(query, assets);
369
+ if (!titleMatch) return { query, hits: [] };
370
+ return {
371
+ query,
372
+ hits: [],
373
+ videoEvidence: {
374
+ mediaAssetId: titleMatch.id,
375
+ retrievalFallback: 'title-matched-media-source',
376
+ },
377
+ };
378
+ }
379
+
380
+ export async function queryKnowledgeBase(query: string, topK: number, projectId: string | null) {
381
+ const doRetrieve = async () => {
382
+ const config = await readConfig();
383
+ const activeVideoRuntimeScope = videoRuntimeScopeFromConfig(config);
384
+ // Retrieve a small diverse pool. Sending a very large candidate set into
385
+ // the answer model noticeably delays the first streamed token without
386
+ // helping the user-facing source list.
387
+ // Keep a wider candidate pool internal, then return only the compact,
388
+ // diverse evidence set to the model. This substantially improves recall
389
+ // for exact labels in visual PDFs without inflating prompt size.
390
+ const candidateCount = Math.min(Math.max(topK * 16, 64), 100);
391
+ // Document status is more precise than the last index-run summary: a later
392
+ // partial run can be marked failed even though earlier uploaded and scraped
393
+ // documents were successfully indexed and remain queryable.
394
+ const [allDocuments, groups, mediaAssets] = await Promise.all([
395
+ readDocuments(),
396
+ readGroups(),
397
+ readMediaAssets(),
398
+ ]);
399
+ const documents = filterDocumentsAvailableToAssistant(allDocuments, groups).filter((document) =>
400
+ isDocumentAvailableInVideoRuntime(document, activeVideoRuntimeScope),
401
+ );
402
+ const availableMediaAssets = filterMediaAssetsAvailableToAssistant(mediaAssets, groups);
403
+ const activeMediaAssets = availableMediaAssets.filter(
404
+ (asset) =>
405
+ asset.processingStatus === 'completed' &&
406
+ Boolean(asset.activeVideoKnowledgeRevisionId) &&
407
+ isMediaAssetAvailableInRuntime(asset, activeVideoRuntimeScope) &&
408
+ (asset.type === 'video' || asset.type === 'audio'),
409
+ );
410
+ const directTitleMatchedAsset = explicitlySelectedMediaAsset(query, activeMediaAssets);
411
+ const selectedMediaAsset = directTitleMatchedAsset
412
+ ? newestEquivalentMediaAsset(directTitleMatchedAsset, availableMediaAssets)
413
+ : undefined;
414
+ if (!documents.some((document) => document.status === 'indexed'))
415
+ return uniqueCompletedMediaFallback(query, activeVideoRuntimeScope, mediaAssets, groups);
416
+
417
+ try {
418
+ const host = workspaceHost();
419
+ const vector = host ? [] : await embedQuery(config, query);
420
+ const adapter = host
421
+ ? {
422
+ query: (_vector: number[], count: number, text: string) =>
423
+ host.queryKnowledge(text, count),
424
+ }
425
+ : await createAdapter(config);
426
+ const indexedDocumentIds = new Set(documents.map((document) => document.id));
427
+ const hits = (await adapter.query(vector, candidateCount, query)).filter((hit: any) =>
428
+ indexedDocumentIds.has(hit.documentId),
429
+ );
430
+ // Pure vector similarity can leave out a document whose title is an
431
+ // obvious literal match for the query -- a meta-referential question
432
+ // ("what happened in my test video?") has little semantic overlap
433
+ // with the video's actual transcript content, so a document titled
434
+ // test-video.mp4 can miss the top-100 candidate pool entirely even
435
+ // though "test video" names it almost verbatim. Reranking can only
436
+ // reorder what's already in the pool, so supplement it here instead.
437
+ const queryMatchers = buildQueryTermMatchers(query);
438
+ const requiredTitleMatches = Math.min(2, queryMatchers.length);
439
+ if (requiredTitleMatches > 0) {
440
+ const presentDocumentIds = new Set(hits.map((hit: any) => hit.documentId));
441
+ const titleMatched = documents
442
+ .filter(
443
+ (document) => document.status === 'indexed' && !presentDocumentIds.has(document.id),
444
+ )
445
+ .map((document) => ({ document, matches: countMatchesIn(queryMatchers, document.title) }))
446
+ .filter(({ matches }) => matches >= requiredTitleMatches)
447
+ .sort((left, right) => right.matches - left.matches)
448
+ .slice(0, 8)
449
+ .map(({ document }) => ({
450
+ id: document.id,
451
+ documentId: document.id,
452
+ score: 0,
453
+ title: document.title,
454
+ text: document.content.slice(0, 1_200),
455
+ url: document.url,
456
+ metadata: document.metadata,
457
+ }));
458
+ if (titleMatched.length > 0) hits.push(...titleMatched);
459
+ }
460
+ const formatted = await formatKnowledgeHits(
461
+ query,
462
+ hits,
463
+ topK,
464
+ documents,
465
+ availableMediaAssets,
466
+ );
467
+ if (formatted.hits.length === 0)
468
+ return uniqueCompletedMediaFallback(query, activeVideoRuntimeScope, mediaAssets, groups);
469
+ // A vector row created before the evidence-first media revision may
470
+ // carry only a document id. Resolve it against the active media source
471
+ // here, so the generic chat router can force whichever installed
472
+ // evidence-query action owns that source before a model answers.
473
+ const resolvedMediaAssetId =
474
+ selectedMediaAsset?.id ??
475
+ (await findEvidenceFirstVideoAssetId(formatted, activeVideoRuntimeScope));
476
+ const resolvedMediaAsset = resolvedMediaAssetId
477
+ ? availableMediaAssets.find((asset) => asset.id === resolvedMediaAssetId)
478
+ : undefined;
479
+ const mediaAssetId = resolvedMediaAsset
480
+ ? newestEquivalentMediaAsset(resolvedMediaAsset, availableMediaAssets).id
481
+ : resolvedMediaAssetId;
482
+ // Once retrieval has selected an active evidence-first media source,
483
+ // its tool is the authority for this turn. Keeping secondary clips in
484
+ // the answer context can both confuse source attribution and spend the
485
+ // answer model's budget on unrelated recordings.
486
+ const authoritativeMediaAsset = selectedMediaAsset ?? resolvedMediaAsset;
487
+ const scopedHits = authoritativeMediaAsset
488
+ ? formatted.hits.filter(
489
+ (hit: any) =>
490
+ hit.metadata?.mediaAssetId === authoritativeMediaAsset.id ||
491
+ authoritativeMediaAsset.documentIds.includes(String(hit.documentId ?? '')),
492
+ )
493
+ : formatted.hits;
494
+ const authoritativeHits = mediaAssetId
495
+ ? scopedHits.map((hit: any) =>
496
+ hit.metadata?.mediaAssetId
497
+ ? { ...hit, metadata: { ...hit.metadata, mediaAssetId } }
498
+ : hit,
499
+ )
500
+ : scopedHits;
501
+ return mediaAssetId
502
+ ? {
503
+ ...formatted,
504
+ hits: authoritativeHits,
505
+ videoEvidence: { mediaAssetId, retrievalFallback: 'active-media-source' },
506
+ }
507
+ : formatted;
508
+ } catch (error) {
509
+ // A retrieval outage must not become a model/tool failure that exposes
510
+ // implementation details. The chat policy handles an empty result with
511
+ // its normal, user-facing uncertainty response.
512
+ console.error('[chat] knowledge-base retrieval failed:', error);
513
+ return uniqueCompletedMediaFallback(query, activeVideoRuntimeScope, mediaAssets, groups);
514
+ }
515
+ };
516
+
517
+ return projectId ? runWithProject(projectId, doRetrieve) : doRetrieve();
518
+ }
519
+
520
+ async function formatKnowledgeHits(
521
+ query: string,
522
+ rawHits: any[],
523
+ topK: number,
524
+ indexedDocuments?: Awaited<ReturnType<typeof readDocuments>>,
525
+ indexedMediaAssets?: Awaited<ReturnType<typeof readMediaAssets>>,
526
+ ) {
527
+ const [storedDocuments, storedMediaAssets] = await Promise.all([
528
+ readDocuments(),
529
+ readMediaAssets(),
530
+ ]);
531
+ const documents = indexedDocuments ?? storedDocuments;
532
+ const mediaAssets = indexedMediaAssets ?? storedMediaAssets;
533
+ const documentsById = new Map(documents.map((document) => [document.id, document]));
534
+ const activeMediaDocumentIds = new Set(mediaAssets.flatMap((asset) => asset.documentIds));
535
+ const hydrated = rankKnowledgeHits(
536
+ query,
537
+ rawHits.map((hit) => {
538
+ const document = documentsById.get(hit.documentId);
539
+ return {
540
+ ...hit,
541
+ title: hit.title || document?.title || 'Untitled',
542
+ url: hit.url || document?.url || '',
543
+ text: hit.text || document?.content || '',
544
+ metadata: { ...document?.metadata, ...hit.metadata },
545
+ };
546
+ }),
547
+ );
548
+
549
+ const selected: any[] = [];
550
+ const hitsPerDocument = new Map<string, number>();
551
+ const selectedEvidenceSources = new Set<string>();
552
+ for (const hit of hydrated) {
553
+ // Never resurrect a vector whose document was deleted or which is no
554
+ // longer indexed. This matters after a mixed scrape/upload index retry.
555
+ if (
556
+ !documentsById.has(hit.documentId) ||
557
+ documentsById.get(hit.documentId)?.status !== 'indexed'
558
+ ) {
559
+ continue;
560
+ }
561
+ if (
562
+ hit.metadata?.mediaAssetId &&
563
+ (!documentsById.has(hit.documentId) || !activeMediaDocumentIds.has(hit.documentId))
564
+ ) {
565
+ continue;
566
+ }
567
+ // A re-upload or re-index can leave several assets for the same source.
568
+ // Return one active evidence source per canonical upload so a generic
569
+ // evidence action is never dispatched in parallel for duplicate copies.
570
+ const mediaAsset = hit.metadata?.mediaAssetId
571
+ ? mediaAssets.find((asset) => asset.id === hit.metadata.mediaAssetId)
572
+ : undefined;
573
+ const evidenceSourceKey = mediaAsset?.activeVideoKnowledgeRevisionId
574
+ ? mediaAsset.originalUrl || mediaAsset.storageUri || mediaAsset.id
575
+ : undefined;
576
+ if (evidenceSourceKey && selectedEvidenceSources.has(evidenceSourceKey)) continue;
577
+ const seen = hitsPerDocument.get(hit.documentId) ?? 0;
578
+ const isTimestampedMedia =
579
+ hit.metadata?.isMediaSummary === true ||
580
+ hit.metadata?.contentKind === 'multimodal-segment' ||
581
+ hit.metadata?.contentKind === 'audio-transcript-segment';
582
+ // One strong hit per timestamped evidence document leaves room for other
583
+ // moments instead of returning eight chunks from the same summary.
584
+ if (seen >= (isTimestampedMedia ? 1 : 3)) continue;
585
+ selected.push(hit);
586
+ if (evidenceSourceKey) selectedEvidenceSources.add(evidenceSourceKey);
587
+ hitsPerDocument.set(hit.documentId, seen + 1);
588
+ if (selected.length >= topK) break;
589
+ }
590
+ const primaryMediaAssetId = selected.find((hit) => hit.metadata?.mediaAssetId)?.metadata
591
+ .mediaAssetId;
592
+ const mediaDocuments = primaryMediaAssetId
593
+ ? documents.filter(
594
+ (document) =>
595
+ document.status === 'indexed' &&
596
+ document.metadata?.mediaAssetId === primaryMediaAssetId &&
597
+ activeMediaDocumentIds.has(document.id),
598
+ )
599
+ : [];
600
+ const timestampedDocuments = mediaDocuments
601
+ .filter((document) => Number.isFinite(Number(document.metadata?.endSecs)))
602
+ .sort(
603
+ (left, right) => Number(left.metadata?.endSecs ?? 0) - Number(right.metadata?.endSecs ?? 0),
604
+ );
605
+ const mediaDuration = Math.max(
606
+ 0,
607
+ ...timestampedDocuments.map((document) =>
608
+ Number(document.metadata?.durationSecs ?? document.metadata?.endSecs ?? 0),
609
+ ),
610
+ );
611
+ const trailingHorizonSecs = Math.max(10 * 60, Math.min(30 * 60, mediaDuration * 0.2));
612
+ const trailingDocuments = timestampedDocuments.filter(
613
+ (document) => Number(document.metadata?.endSecs ?? 0) >= mediaDuration - trailingHorizonSecs,
614
+ );
615
+ const timestampedEnding = [
616
+ ...new Map(trailingDocuments.slice(-4).map((document) => [document.id, document])).values(),
617
+ ].sort(
618
+ (left, right) => Number(left.metadata?.endSecs ?? 0) - Number(right.metadata?.endSecs ?? 0),
619
+ );
620
+ const mediaSummary = mediaDocuments.find(
621
+ (document) => document.metadata?.isMediaSummary === true,
622
+ );
623
+ const legacyWholeTimeline =
624
+ timestampedEnding.length === 0
625
+ ? mediaDocuments.find((document) => document.metadata?.contentKind === 'multimodal-timeline')
626
+ : undefined;
627
+ // A compact ending context is returned for every primary media match. This
628
+ // avoids depending on a brittle language-specific keyword gate for questions
629
+ // whose answer is only established near the end of a source.
630
+ const endingContext = primaryMediaAssetId
631
+ ? [
632
+ ...(mediaSummary
633
+ ? [
634
+ {
635
+ role: 'summary',
636
+ title: mediaSummary.title,
637
+ url: mediaSummary.url,
638
+ text: queryAwareExcerpt(mediaSummary.content, query, 6_000),
639
+ startSecs: undefined,
640
+ endSecs: undefined,
641
+ },
642
+ ]
643
+ : []),
644
+ ...timestampedEnding.map((document) => ({
645
+ role: 'ending',
646
+ title: document.title,
647
+ url: document.url,
648
+ text: queryAwareExcerpt(document.content, query, 3_200, true),
649
+ startSecs: document.metadata?.startSecs,
650
+ endSecs: document.metadata?.endSecs,
651
+ })),
652
+ ...(legacyWholeTimeline
653
+ ? [
654
+ {
655
+ role: 'ending',
656
+ title: legacyWholeTimeline.title,
657
+ url: legacyWholeTimeline.url,
658
+ // Legacy media was one giant document. Its ending is the tail,
659
+ // never the beginning.
660
+ text: queryAwareExcerpt(legacyWholeTimeline.content, query, 4_000, true),
661
+ startSecs: undefined,
662
+ endSecs: legacyWholeTimeline.metadata?.durationSecs,
663
+ },
664
+ ]
665
+ : []),
666
+ ]
667
+ : undefined;
668
+
669
+ return {
670
+ query,
671
+ hits: selected.map((hit, hitIndex) => {
672
+ // Text chunks inherit their source-document ID but not necessarily the
673
+ // document-level PDF image list. Recover it here so a question that
674
+ // retrieves a routine name in text can still inspect or preview the
675
+ // corresponding schema image.
676
+ const sourceDocument = documents.find((document) => document.id === hit.documentId);
677
+ const relatedDocumentImages = sourceDocument
678
+ ? documents
679
+ .filter(
680
+ (document) =>
681
+ document.status === 'indexed' &&
682
+ document.metadata?.isImage === true &&
683
+ document.metadata?.originalFile === sourceDocument.title,
684
+ )
685
+ .map((document) => ({
686
+ imageUrl: document.metadata?.imageUrl,
687
+ pageNumber: document.metadata?.pageNumber,
688
+ index: document.metadata?.index,
689
+ description: document.metadata?.description,
690
+ }))
691
+ .filter((image) => typeof image.imageUrl === 'string' && image.imageUrl.length > 0)
692
+ : [];
693
+ const sequence = Number(hit.metadata?.sequence);
694
+ const startSecs = Number(hit.metadata?.startSecs);
695
+ const activeVideoAsset = hit.metadata?.mediaAssetId
696
+ ? mediaAssets.find((asset) => asset.id === hit.metadata.mediaAssetId)
697
+ : undefined;
698
+ const usesEvidenceFirstVideoKnowledge = Boolean(
699
+ activeVideoAsset?.activeVideoKnowledgeRevisionId &&
700
+ (activeVideoAsset.type === 'video' || activeVideoAsset.type === 'audio'),
701
+ );
702
+ const timelineContext =
703
+ hit.metadata?.mediaAssetId && !usesEvidenceFirstVideoKnowledge
704
+ ? documents
705
+ .filter((document) => {
706
+ const candidateSequence = Number(document.metadata?.sequence);
707
+ const candidateStart = Number(document.metadata?.startSecs);
708
+ return (
709
+ document.status === 'indexed' &&
710
+ activeMediaDocumentIds.has(document.id) &&
711
+ document.metadata?.mediaAssetId === hit.metadata.mediaAssetId &&
712
+ Number.isFinite(sequence) &&
713
+ Number.isFinite(candidateSequence) &&
714
+ Math.abs(candidateSequence - sequence) <= 1 &&
715
+ // Sequence is the authoritative adjacency marker. Fixed
716
+ // 90-second distance broke wider adaptive windows used for
717
+ // multi-hour camera and screen recordings.
718
+ (!Number.isFinite(startSecs) ||
719
+ !Number.isFinite(candidateStart) ||
720
+ Math.abs(candidateStart - startSecs) <=
721
+ Math.max(
722
+ 90,
723
+ Number(hit.metadata?.endSecs ?? startSecs) - startSecs,
724
+ Number(document.metadata?.endSecs ?? candidateStart) - candidateStart,
725
+ ))
726
+ );
727
+ })
728
+ .sort(
729
+ (left, right) =>
730
+ Number(left.metadata?.startSecs ?? 0) - Number(right.metadata?.startSecs ?? 0),
731
+ )
732
+ .map((document) => ({
733
+ role:
734
+ Number(document.metadata?.sequence) < sequence
735
+ ? 'before'
736
+ : Number(document.metadata?.sequence) > sequence
737
+ ? 'after'
738
+ : 'matched',
739
+ title: document.title,
740
+ url: document.url,
741
+ text: queryAwareExcerpt(document.content, query, 3_200),
742
+ startSecs: document.metadata?.startSecs,
743
+ endSecs: document.metadata?.endSecs,
744
+ }))
745
+ : undefined;
746
+
747
+ return {
748
+ documentId: hit.documentId,
749
+ title: hit.title ?? 'Untitled',
750
+ url: hit.url ?? '',
751
+ score: Number((hit.score ?? 0).toFixed(3)),
752
+ // An active Video Knowledge revision replaces this disposable vector
753
+ // projection as the factual source. Do not leak the old timeline
754
+ // excerpt to the model: it can otherwise answer from a broad/dummy
755
+ // window without running the evidence verification tool.
756
+ text: usesEvidenceFirstVideoKnowledge
757
+ ? 'Verified media evidence is available. Use the installed evidence-query action with this mediaAssetId before answering factual questions about this media.'
758
+ : queryAwareExcerpt(hit.text ?? '', query, 1_600),
759
+ // UI-only preview of the exact indexed chunk that matched. The model's
760
+ // compact tool context intentionally ignores this field and answers
761
+ // video questions from active evidence instead, while the user can
762
+ // still inspect what the search actually found.
763
+ context: queryAwareExcerpt(hit.text ?? sourceDocument?.content ?? '', query, 900),
764
+ // PDF extraction stores its verified upload URLs on the source
765
+ // document, rather than creating media-library assets. Expose those
766
+ // URLs explicitly so the presentation tool can validate and render
767
+ // them in chat.
768
+ // A visual chunk is a precise match. Keep it ahead of the parent
769
+ // document's full image catalog so a request to preview a retrieved
770
+ // diagram cannot silently fall back to image zero (often a cover).
771
+ images:
772
+ (hit.metadata?.isImage && hit.metadata?.imageUrl
773
+ ? [
774
+ {
775
+ imageUrl: hit.metadata.imageUrl,
776
+ pageNumber: hit.metadata.pageNumber,
777
+ index: hit.metadata.index,
778
+ description: hit.metadata.description,
779
+ },
780
+ ]
781
+ : undefined) ??
782
+ hit.metadata?.images ??
783
+ sourceDocument?.metadata?.images ??
784
+ (relatedDocumentImages.length > 0 ? relatedDocumentImages : undefined),
785
+ metadata: {
786
+ ...hit.metadata,
787
+ usesEvidenceFirstVideoKnowledge,
788
+ },
789
+ timelineContext,
790
+ endingContext:
791
+ hitIndex === 0 && !usesEvidenceFirstVideoKnowledge ? endingContext : undefined,
792
+ };
793
+ }),
794
+ };
795
+ }
796
+
797
+ /**
798
+ * Vector rows created before Video Knowledge can be missing the derived
799
+ * `usesEvidenceFirstVideoKnowledge` flag. Resolve against the source of
800
+ * truth instead of allowing that stale projection to downgrade a video
801
+ * question into ordinary RAG.
802
+ */
803
+ async function findEvidenceFirstVideoAssetId(
804
+ retrieval: { hits?: any[] },
805
+ activeScope: VideoRuntimeScope,
806
+ projectId?: string,
807
+ ) {
808
+ const readAssetsAndGroups = async () => Promise.all([readMediaAssets(), readGroups()] as const);
809
+ const [assets, groups] = projectId
810
+ ? await runWithProject(projectId, readAssetsAndGroups)
811
+ : await readAssetsAndGroups();
812
+ const activeVideoAssets = filterMediaAssetsAvailableToAssistant(assets, groups).filter(
813
+ (asset) =>
814
+ asset.processingStatus === 'completed' &&
815
+ Boolean(asset.activeVideoKnowledgeRevisionId) &&
816
+ isMediaAssetAvailableInRuntime(asset, activeScope) &&
817
+ (asset.type === 'video' || asset.type === 'audio'),
818
+ );
819
+ const assetsById = new Map(activeVideoAssets.map((asset) => [asset.id, asset]));
820
+ const assetIdByDocumentId = new Map(
821
+ activeVideoAssets.flatMap((asset) =>
822
+ asset.documentIds.map((documentId) => [documentId, asset.id] as const),
823
+ ),
824
+ );
825
+ // Re-indexing/import retries can leave more than one completed asset for a
826
+ // source. The most recently updated completed asset is authoritative: it is
827
+ // the one the user most recently indexed, and choosing it is independent of
828
+ // what kind of question happens to be asked. Seek/tracking URL variants are
829
+ // grouped by canonicalMediaSourceUrl above.
830
+ const preferredAssetByOriginalUrl = new Map<string, Promise<string>>();
831
+ const preferEvidenceQuality = async (assetId: string) => {
832
+ const originalUrl = canonicalMediaSourceUrl(assetsById.get(assetId)?.originalUrl ?? '');
833
+ if (!originalUrl) return assetId;
834
+ const existing = preferredAssetByOriginalUrl.get(originalUrl);
835
+ if (existing) return existing;
836
+ const candidates = activeVideoAssets.filter(
837
+ (candidate) => canonicalMediaSourceUrl(candidate.originalUrl ?? '') === originalUrl,
838
+ );
839
+ const selection = Promise.resolve(
840
+ candidates.sort(
841
+ (left, right) =>
842
+ Date.parse(right.updatedAt ?? right.createdAt ?? '') -
843
+ Date.parse(left.updatedAt ?? left.createdAt ?? ''),
844
+ )[0]?.id ?? assetId,
845
+ );
846
+ preferredAssetByOriginalUrl.set(originalUrl, selection);
847
+ return selection;
848
+ };
849
+
850
+ const assetId = leadingMediaAssetId(retrieval, new Set(assetsById.keys()), assetIdByDocumentId);
851
+ return assetId ? preferEvidenceQuality(assetId) : undefined;
852
+ }
853
+
854
+ /** Builds the SandboxManager config from the workspace's configured default sandbox provider. */
855
+ function resolveSandboxConfig(config: any): {
856
+ backend: SandboxBackend;
857
+ credentials?: Record<string, string>;
858
+ } {
859
+ const backend = (config?.defaultSandboxProvider as SandboxBackend) || 'local';
860
+ return { backend, credentials: config?.sandboxProviderConfigs?.[backend] };
861
+ }
862
+
863
+ export async function getChatTools(context: {
864
+ projectId?: string;
865
+ docSessionId?: string;
866
+ config?: any;
867
+ requestText?: string;
868
+ /** Compact source context used only to disambiguate the user's immediate follow-up retrieval. */
869
+ contextualKnowledgeQuery?: string;
870
+ /** Public origin of the incoming chat request, used for local media URLs. */
871
+ origin?: string;
872
+ /** Media source from the immediately preceding evidence-backed turn. */
873
+ preferredMediaAssetId?: string;
874
+ /** Exact bounded result from the immediately preceding answer, for direct export follow-ups. */
875
+ recentExportTable?: {
876
+ columns: string[];
877
+ rows: Array<Record<string, unknown>>;
878
+ };
879
+ /** Verified rows from the preceding visible table, for a direct chart follow-up. */
880
+ recentVisualizationTable?: {
881
+ columns: string[];
882
+ rows: Array<Record<string, unknown>>;
883
+ };
884
+ /** Conversational answer fallback when no structured table preceded the export request. */
885
+ recentAnswerText?: string;
886
+ }) {
887
+ const {
888
+ projectId,
889
+ docSessionId,
890
+ config,
891
+ origin,
892
+ requestText,
893
+ preferredMediaAssetId,
894
+ contextualKnowledgeQuery,
895
+ recentExportTable,
896
+ recentVisualizationTable,
897
+ recentAnswerText,
898
+ } = context;
899
+ // Installed tools are shared, while their connection/runtime selection is
900
+ // project-scoped. Resolve that configuration once before constructing any
901
+ // dynamic client so Local and Cloud tools always receive the active
902
+ // project's settings rather than the install-time defaults.
903
+ const activeProjectConfig = projectId
904
+ ? await runWithProject(projectId, () => readConfig())
905
+ : config;
906
+ const activeVideoRuntimeScope = videoRuntimeScopeFromConfig(activeProjectConfig);
907
+ let currentTurnExportTable:
908
+ { columns: string[]; rows: Array<Record<string, unknown>> } | undefined;
909
+ const inActiveProject = <T>(operation: () => Promise<T>): Promise<T> =>
910
+ projectId ? runWithProject(projectId, operation) : operation();
911
+ // Read this scope at tool-execution time instead of capturing it when the
912
+ // chat request starts. A group can be moved or disabled while a request is
913
+ // in flight, and residual vectors/evidence must not bridge that change.
914
+ const readAssistantSourceScope = () =>
915
+ inActiveProject(async () => {
916
+ const [allDocuments, groups, allMediaAssets] = await Promise.all([
917
+ readDocuments(),
918
+ readGroups(),
919
+ readMediaAssets(),
920
+ ]);
921
+ return {
922
+ groups,
923
+ documents: filterDocumentsAvailableToAssistant(allDocuments, groups),
924
+ mediaAssets: filterMediaAssetsAvailableToAssistant(allMediaAssets, groups),
925
+ };
926
+ });
927
+ const readAssistantTabularDatasets = () =>
928
+ inActiveProject(async () => {
929
+ const [datasets, groups, documents] = await Promise.all([
930
+ listTabularDatasets(),
931
+ readGroups(),
932
+ readDocuments(),
933
+ ]);
934
+ return filterGroupOwnedDataAvailableToAssistant(
935
+ resolveTabularDatasetGroups(datasets, documents),
936
+ groups,
937
+ );
938
+ });
939
+ const readAssistantSourceFingerprint = async () => {
940
+ const [{ documents }, currentConfig] = await Promise.all([
941
+ readAssistantSourceScope(),
942
+ inActiveProject(() => readConfig()),
943
+ ]);
944
+ return assistantSourceScopeFingerprint(documents, {
945
+ configUpdatedAt: currentConfig.updatedAt,
946
+ });
947
+ };
948
+ const scopedAsset = async (mediaAssetId: string) => {
949
+ const { mediaAssets, documents } = await readAssistantSourceScope();
950
+ const asset = mediaAssets.find((candidate) => candidate.id === mediaAssetId);
951
+ if (!asset || !isMediaAssetAvailableInRuntime(asset, activeVideoRuntimeScope)) {
952
+ return undefined;
953
+ }
954
+ const sourceLanguage = documents
955
+ .filter((document) => document.metadata?.mediaAssetId === mediaAssetId)
956
+ .map((document) => document.metadata?.transcriptLanguage)
957
+ .find((value): value is string => typeof value === 'string' && value.trim().length > 0);
958
+ return sourceLanguage ? { ...asset, sourceLanguage: sourceLanguage.trim() } : asset;
959
+ };
960
+ const authoritativeMediaAssetId = async (mediaAssetId: string) => {
961
+ const { mediaAssets } = await readAssistantSourceScope();
962
+ const requested = mediaAssets.find((asset) => asset.id === mediaAssetId);
963
+ return requested ? newestEquivalentMediaAsset(requested, mediaAssets).id : mediaAssetId;
964
+ };
965
+ const indexedMediaContext = async (mediaAssetId: string) => {
966
+ if (!(await scopedAsset(mediaAssetId))) return '';
967
+ const read = async () => {
968
+ const observations = await searchVideoKnowledge(mediaAssetId, '', 2_000, {
969
+ modalities: ['computed', 'visual'],
970
+ minimumRangeDistanceSecs: 0,
971
+ }).catch(() => []);
972
+ const preferred =
973
+ observations
974
+ .filter((hit) => {
975
+ const text = evidenceText(hit.evidence.payload).trim();
976
+ return (
977
+ /^(?:Reconciled|Indexed) overview:/i.test(text) &&
978
+ !/bounded (?:interactive )?inspection/i.test(text)
979
+ );
980
+ })
981
+ .sort(
982
+ (left, right) =>
983
+ evidenceText(right.evidence.payload).length -
984
+ evidenceText(left.evidence.payload).length,
985
+ )[0] ??
986
+ observations.find((hit) => {
987
+ const text = evidenceText(hit.evidence.payload).trim();
988
+ return (
989
+ hit.evidence.modality === 'visual' &&
990
+ text.length >= 80 &&
991
+ !text.startsWith('{') &&
992
+ !/^Detected objects:/i.test(text)
993
+ );
994
+ });
995
+ return evidenceText(preferred?.evidence.payload)
996
+ .split(/\nClaim question:/i, 1)[0]
997
+ .trim()
998
+ .slice(0, 900);
999
+ };
1000
+ return projectId ? runWithProject(projectId, read) : read();
1001
+ };
1002
+ const scopedDocument = async (documentId: string) => {
1003
+ const { documents } = await readAssistantSourceScope();
1004
+ return documents.find(
1005
+ (document) => document.id === documentId && document.status === 'indexed',
1006
+ );
1007
+ };
1008
+ const pdfPagePreviewUrl = (documentId: string, pageNumber: number) => {
1009
+ const query = new URLSearchParams({ documentId, page: String(pageNumber) });
1010
+ if (projectId) query.set('projectId', projectId);
1011
+ return workspaceServerUrl(`/api/pdf-page?${query.toString()}`);
1012
+ };
1013
+ // Embeddings and vector providers are the host's concern, so the host
1014
+ // supplies the semantic ranking signal and the installed tool stays
1015
+ // responsible for retrieval strategy. Without it, a question asked in one
1016
+ // language scores zero against a source recorded in another and the ranked
1017
+ // lookup returns whatever happened to share a character with the question.
1018
+ // Concurrent modality searches share one scoring pass. The entry is removed
1019
+ // as soon as that pass completes: an inspection can append evidence and
1020
+ // activate a new manifest, so a later wave must score the new index.
1021
+ const semanticScorePasses = new Map<string, Promise<Map<string, number> | undefined>>();
1022
+ const semanticScoresFor = async (mediaAssetId: string, query: string) => {
1023
+ const key = `${mediaAssetId}\u0000${query}`;
1024
+ const active = semanticScorePasses.get(key);
1025
+ if (active) return active;
1026
+ const pass = searchSemanticEvidence(mediaAssetId, query, {
1027
+ topK: 60,
1028
+ abortSignal: AbortSignal.timeout(CHAT_SEMANTIC_LOOKUP_BUDGET_MS),
1029
+ })
1030
+ .then((hits) => (hits.length > 0 ? semanticScoresByEvidenceId(hits) : undefined))
1031
+ .catch(() => undefined);
1032
+ semanticScorePasses.set(key, pass);
1033
+ try {
1034
+ return await pass;
1035
+ } finally {
1036
+ if (semanticScorePasses.get(key) === pass) semanticScorePasses.delete(key);
1037
+ }
1038
+ };
1039
+
1040
+ // The host only routes a source handle to an installed capability. The
1041
+ // tool remains responsible for retrieval, inspection, and grounding.
1042
+ const mediaEvidence = {
1043
+ getAsset: scopedAsset,
1044
+ planQuestion: (question: string, directive: VideoInvestigationDirective) =>
1045
+ planVideoQuestion(question, directive),
1046
+ planInvestigation: async (
1047
+ mediaAssetId: string,
1048
+ question: string,
1049
+ directive: VideoInvestigationDirective,
1050
+ ) => {
1051
+ if (!(await scopedAsset(mediaAssetId))) return undefined;
1052
+ const plan = () => buildVideoInvestigationPlan(mediaAssetId, question, directive);
1053
+ return projectId ? runWithProject(projectId, plan) : plan();
1054
+ },
1055
+ search: async (
1056
+ mediaAssetId: string,
1057
+ query: string,
1058
+ limit: number,
1059
+ options: Parameters<typeof searchVideoKnowledge>[3] = {},
1060
+ ) => {
1061
+ if (!(await scopedAsset(mediaAssetId))) return [];
1062
+ const search = async () =>
1063
+ searchVideoKnowledge(mediaAssetId, query, limit, {
1064
+ ...options,
1065
+ semanticScores: options.semanticScores ?? (await semanticScoresFor(mediaAssetId, query)),
1066
+ });
1067
+ return projectId ? runWithProject(projectId, search) : search();
1068
+ },
1069
+ // Complete-source questions must not use semantic rank as their coverage
1070
+ // mechanism. The Core engine pages immutable active evidence in time order
1071
+ // and stores aggregate results outside the model context.
1072
+ scan: async (
1073
+ mediaAssetId: string,
1074
+ input: {
1075
+ kind: 'source-inventory' | 'all-evidence';
1076
+ cursor?: number;
1077
+ limit?: number;
1078
+ modalities?: Array<'transcript' | 'ocr' | 'visual' | 'computed'>;
1079
+ },
1080
+ ) => {
1081
+ if (!(await scopedAsset(mediaAssetId))) return undefined;
1082
+ const scan = () => scanVideoKnowledge(mediaAssetId, input);
1083
+ return projectId ? runWithProject(projectId, scan) : scan();
1084
+ },
1085
+ aggregate: async (mediaAssetId: string) => {
1086
+ if (!(await scopedAsset(mediaAssetId))) return undefined;
1087
+ const aggregate = () => aggregateVideoKnowledge(mediaAssetId);
1088
+ return projectId ? runWithProject(projectId, aggregate) : aggregate();
1089
+ },
1090
+ // Cloud projects have an explicit normal inspection API backed by their
1091
+ // managed runtime. Do not silently fall back to a local multimodal call
1092
+ // with the chat model, which may not even accept image inputs.
1093
+ ...(activeVideoRuntimeScope === 'local'
1094
+ ? {
1095
+ reWatch: async (
1096
+ mediaAssetId: string,
1097
+ question: string,
1098
+ ranges: Array<{ startSecs: number; endSecs: number; lookingFor?: string }>,
1099
+ options?: { maxWaitMs?: number; knownEntities?: string[] },
1100
+ ) => {
1101
+ const asset = await scopedAsset(mediaAssetId);
1102
+ if (!asset || asset.type !== 'video' || asset.processingStatus !== 'completed')
1103
+ return [];
1104
+ const run = async () => {
1105
+ const outcome = await reWatchSource({
1106
+ asset,
1107
+ question,
1108
+ mediaAssetId,
1109
+ ranges: ranges.map((range) => ({ ...range, label: range.lookingFor })),
1110
+ maxWaitMs: options?.maxWaitMs,
1111
+ knownEntities: options?.knownEntities,
1112
+ });
1113
+ return (outcome?.findings ?? []).map((finding) => ({
1114
+ range: finding.range,
1115
+ at: finding.at,
1116
+ found: finding.found,
1117
+ read: finding.read,
1118
+ confidence: finding.confidence,
1119
+ settlesQuestion: finding.settlesQuestion,
1120
+ }));
1121
+ };
1122
+ return projectId ? runWithProject(projectId, run) : run();
1123
+ },
1124
+ }
1125
+ : {}),
1126
+ /**
1127
+ * Ranked windows worth looking at, fused from every timestamped signal the
1128
+ * host holds. An installed tool decides what to do with them; this only
1129
+ * says where the agreement is.
1130
+ */
1131
+ locate: async (
1132
+ mediaAssetId: string,
1133
+ query: string,
1134
+ options: { maxRanges?: number; maxWindowSecs?: number } = {},
1135
+ ) => {
1136
+ if (!(await scopedAsset(mediaAssetId))) return [];
1137
+ const run = async () => {
1138
+ const [semanticEvidence, clipMatches] = await Promise.all([
1139
+ searchSemanticEvidence(mediaAssetId, query, {
1140
+ topK: 12,
1141
+ abortSignal: AbortSignal.timeout(CHAT_SEMANTIC_LOOKUP_BUDGET_MS),
1142
+ }).catch(() => []),
1143
+ queryVideoEmbeddings(mediaAssetId, query, 6).catch(() => []),
1144
+ ]);
1145
+ return fuseFocusRanges(
1146
+ [
1147
+ ...semanticEvidence.map((hit) => ({
1148
+ kind: 'semantic' as const,
1149
+ startSecs: hit.startSecs,
1150
+ endSecs: hit.endSecs,
1151
+ score: hit.score,
1152
+ })),
1153
+ ...clipMatches.map((match) => ({
1154
+ kind: 'clip-embedding' as const,
1155
+ startSecs: match.startSecs,
1156
+ endSecs: match.endSecs,
1157
+ score: match.score,
1158
+ })),
1159
+ ],
1160
+ options,
1161
+ );
1162
+ };
1163
+ return projectId ? runWithProject(projectId, run) : run();
1164
+ },
1165
+ };
1166
+ const sandboxReady = await isSandboxReady(config);
1167
+ const builtInTools: Record<string, any> = {
1168
+ searchKnowledgeBase: tool({
1169
+ description:
1170
+ "HIGHEST PRIORITY TOOL — Search the user's private RAG knowledge base. ALWAYS use this FIRST before webSearch or any other tool when the user asks a question. Search once with a focused query. If a result has a mediaAssetId and the question concerns media, call an installed evidence-query action with that exact id before answering. DO NOT call presentMedia automatically; only call it when the user explicitly asks to preview a moment.",
1171
+ inputSchema: z.object({
1172
+ query: z.string().describe('The search query for the knowledge base.'),
1173
+ }),
1174
+ execute: async ({ query }) => {
1175
+ const continuingAsset = preferredMediaAssetId
1176
+ ? await mediaEvidence.getAsset(preferredMediaAssetId)
1177
+ : undefined;
1178
+ if (continuingAsset?.processingStatus === 'completed') {
1179
+ const completedMedia = (await readAssistantSourceScope()).mediaAssets.filter(
1180
+ (asset) =>
1181
+ asset.processingStatus === 'completed' &&
1182
+ Boolean(asset.activeVideoKnowledgeRevisionId) &&
1183
+ isMediaAssetAvailableInRuntime(asset, activeVideoRuntimeScope) &&
1184
+ (asset.type === 'video' || asset.type === 'audio'),
1185
+ );
1186
+ // Continuing on an unambiguous source keeps ordinary follow-ups
1187
+ // fast. With several indexed videos, however, search again unless
1188
+ // the user clearly names the current one; otherwise the first clip
1189
+ // mentioned in a chat silently monopolizes every later question.
1190
+ if (shouldKeepActiveMediaSource(query, continuingAsset, completedMedia)) {
1191
+ return activeMediaFollowUpResult(
1192
+ query,
1193
+ continuingAsset,
1194
+ await indexedMediaContext(continuingAsset.id),
1195
+ );
1196
+ }
1197
+ }
1198
+ // RAG is a locator, never an answer cache. The chat route turns a
1199
+ // matched mediaAssetId into a separate, visible evidence-query tool
1200
+ // call. That action can then fall back to bounded live analysis.
1201
+ // The deterministic preflight calls this action with the user's exact
1202
+ // wording. Preserve that wording in the UI, but use the compact prior
1203
+ // topic to resolve a reference such as "what will it be about?" before
1204
+ // vector retrieval. Model-authored, already-specific search queries
1205
+ // are left untouched.
1206
+ const retrievalQuery =
1207
+ contextualKnowledgeQuery && query.trim() === requestText?.trim()
1208
+ ? contextualKnowledgeQuery
1209
+ : query;
1210
+ const fingerprintBeforeSearch = await readAssistantSourceFingerprint();
1211
+ const retrieval = await queryKnowledgeBase(retrievalQuery, 4, projectId ?? null);
1212
+ const fingerprintAfterSearch = await readAssistantSourceFingerprint();
1213
+ return {
1214
+ ...retrieval,
1215
+ ...(retrievalQuery === query ? {} : { query }),
1216
+ ...(fingerprintBeforeSearch === fingerprintAfterSearch
1217
+ ? { sourceScopeFingerprint: fingerprintAfterSearch }
1218
+ : {}),
1219
+ };
1220
+ },
1221
+ }),
1222
+
1223
+ inspectPdfPages: tool({
1224
+ description:
1225
+ 'Inspect pages from the original indexed PDF locally. Use the exact documentId returned by searchKnowledgeBase when the answer may depend on a PDF page that was not visual-indexed. This ranks nearby pages from local page text, extracts bounded page text and tables, and returns validated page-preview URLs. It never sends the PDF outside this workspace.',
1226
+ inputSchema: z.object({
1227
+ documentId: z.string().describe('Exact documentId returned by searchKnowledgeBase.'),
1228
+ question: z.string().describe('The focused user question used to select nearby PDF pages.'),
1229
+ pageNumbers: z
1230
+ .array(z.number().int().positive())
1231
+ .max(3)
1232
+ .optional()
1233
+ .describe('Optional exact PDF page numbers when the evidence already identifies them.'),
1234
+ }),
1235
+ execute: async ({ documentId, question, pageNumbers }) => {
1236
+ try {
1237
+ const document = await scopedDocument(documentId);
1238
+ if (!document) return { success: false, error: 'That PDF document is not available.' };
1239
+ const result = await inspectStoredPdf(document, question, pageNumbers, (pageNumber) =>
1240
+ pdfPagePreviewUrl(document.id, pageNumber),
1241
+ );
1242
+ return {
1243
+ success: true,
1244
+ documentId: document.id,
1245
+ title: document.title,
1246
+ ...result,
1247
+ ui: {
1248
+ kind: 'card',
1249
+ title: `Inspected ${document.title}`,
1250
+ facts: [
1251
+ {
1252
+ label: 'Pages selected',
1253
+ value: result.pages.map((page) => String(page.pageNumber)).join(', '),
1254
+ },
1255
+ { label: 'Total pages', value: String(result.totalPages) },
1256
+ ],
1257
+ },
1258
+ };
1259
+ } catch (error: any) {
1260
+ return { success: false, error: error.message ?? 'Could not inspect the PDF locally.' };
1261
+ }
1262
+ },
1263
+ }),
1264
+
1265
+ analyzePdfPages: tool({
1266
+ description:
1267
+ 'Analyze locally rendered pages from the original indexed PDF. Use after inspectPdfPages when the answer depends on a diagram, chart, image, equation layout, or other visual content. Pass the exact documentId and the selected page numbers. The source PDF stays local; only the bounded rendered pages are sent to the configured vision capability when one is enabled.',
1268
+ inputSchema: z.object({
1269
+ documentId: z.string().describe('Exact documentId returned by inspectPdfPages.'),
1270
+ pageNumbers: z
1271
+ .array(z.number().int().positive())
1272
+ .max(3)
1273
+ .optional()
1274
+ .describe(
1275
+ 'Optional one to three page numbers returned by inspectPdfPages. Omit to reuse the local question-based selection.',
1276
+ ),
1277
+ prompt: z.string().describe('Precisely what to read or verify in the rendered PDF pages.'),
1278
+ }),
1279
+ execute: async ({ documentId, pageNumbers, prompt }) => {
1280
+ try {
1281
+ const document = await scopedDocument(documentId);
1282
+ if (!document) return { success: false, error: 'That PDF document is not available.' };
1283
+ const inspection = await inspectStoredPdf(document, prompt, pageNumbers, (pageNumber) =>
1284
+ pdfPagePreviewUrl(document.id, pageNumber),
1285
+ );
1286
+ const pages = await Promise.all(
1287
+ inspection.pages.map(async (page) => {
1288
+ const rendered = await renderStoredPdfPage(document, page.pageNumber);
1289
+ const response = await fetch(
1290
+ new URL(
1291
+ workspaceServerUrl(
1292
+ `/api/describe-image${
1293
+ projectId ? `?serverId=${encodeURIComponent(projectId)}` : ''
1294
+ }`,
1295
+ ),
1296
+ origin ?? 'http://127.0.0.1:4567',
1297
+ ),
1298
+ {
1299
+ method: 'POST',
1300
+ headers: { 'Content-Type': 'application/json' },
1301
+ body: JSON.stringify({ base64: rendered.data.toString('base64'), prompt }),
1302
+ },
1303
+ );
1304
+ const data = await response.json().catch(() => ({}));
1305
+ return {
1306
+ ...page,
1307
+ analysis: response.ok ? data.description : undefined,
1308
+ error: response.ok
1309
+ ? undefined
1310
+ : (data.error ?? 'Visual analysis failed for this page.'),
1311
+ };
1312
+ }),
1313
+ );
1314
+ return {
1315
+ success: pages.some((page) => Boolean(page.analysis)),
1316
+ documentId: document.id,
1317
+ title: document.title,
1318
+ totalPages: inspection.totalPages,
1319
+ pages,
1320
+ ui: {
1321
+ kind: 'card',
1322
+ title: `Analyzed pages from ${document.title}`,
1323
+ facts: [
1324
+ { label: 'Pages', value: pages.map((page) => String(page.pageNumber)).join(', ') },
1325
+ ],
1326
+ },
1327
+ };
1328
+ } catch (error: any) {
1329
+ return { success: false, error: error.message ?? 'Could not analyze PDF pages.' };
1330
+ }
1331
+ },
1332
+ }),
1333
+
1334
+ queryVideoKnowledge: tool({
1335
+ description:
1336
+ 'Use this for every factual question about an indexed video or audio asset after searchKnowledgeBase identifies its mediaAssetId. It performs a fresh hierarchical investigation (chapters → scenes → events/states → active source evidence) on every call, returns seekable ranges, and highlights unresolved conflicts. Treat only returned active evidence as source truth. Use it for simple, temporal, comparative, exact-text, counting, and outcome questions before answering; do not infer a claim beyond these records. When verification says evidence is insufficient or inspection is required, inspect the bounded candidate range and query again before saying the answer is unknown.',
1337
+ inputSchema: z.object({
1338
+ mediaAssetId: z.string().describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
1339
+ query: z.string().describe('The user’s focused question or sub-question about this media.'),
1340
+ investigation: z
1341
+ .object({
1342
+ scope: z.enum(['focused', 'temporal', 'source']),
1343
+ goal: z.enum(['answer', 'compare', 'trace', 'enumerate', 'synthesize']),
1344
+ evidence: z
1345
+ .array(z.enum(['speech', 'visible-text', 'visual', 'computed']))
1346
+ .max(4)
1347
+ .optional(),
1348
+ recordSet: z
1349
+ .enum(['all', 'source-authored', 'source-questions', 'observed'])
1350
+ .optional(),
1351
+ timeRange: z
1352
+ .object({ startSecs: z.number().min(0), endSecs: z.number().min(0) })
1353
+ .optional(),
1354
+ })
1355
+ .describe(
1356
+ 'Required language-model interpretation of the evidence scope and reasoning operation.',
1357
+ ),
1358
+ limit: z.number().int().min(1).max(12).optional(),
1359
+ }),
1360
+ execute: async ({ mediaAssetId, query, investigation, limit }, { toolCallId }) => {
1361
+ mediaAssetId = await authoritativeMediaAssetId(mediaAssetId);
1362
+ const run = async (allowAutomaticInspection = true): Promise<any> => {
1363
+ const asset = await scopedAsset(mediaAssetId);
1364
+ if (!asset || asset.processingStatus !== 'completed') {
1365
+ return { success: false, error: 'That indexed media asset is no longer available.' };
1366
+ }
1367
+ const plan = planVideoQuestion(query, investigation);
1368
+ const sourceWideVisualEvidenceRequested =
1369
+ plan.investigation?.scope === 'source' &&
1370
+ (!plan.investigation.evidence ||
1371
+ plan.investigation.evidence.some(
1372
+ (modality) => modality === 'visual' || modality === 'computed',
1373
+ ));
1374
+ // These four locators are independent and each costs a network round
1375
+ // trip or a full state scan. Running them concurrently is most of the
1376
+ // difference between a chat answer that feels immediate and one that
1377
+ // reads as hung before any evidence has even been ranked.
1378
+ const [
1379
+ investigationPlan,
1380
+ semantic,
1381
+ visualMatches,
1382
+ semanticEvidence,
1383
+ sourceWideVisualOverviewEvidence,
1384
+ ] = await Promise.all([
1385
+ buildVideoInvestigationPlan(mediaAssetId, query, investigation),
1386
+ // The regular RAG retriever owns embeddings/vector providers. Join
1387
+ // its media projection hits back to active Core evidence so video
1388
+ // answers get hybrid semantic + lexical retrieval without treating
1389
+ // vector documents as source truth.
1390
+ queryKnowledgeBase(query, 24, null),
1391
+ // Video-clip embeddings catch visual actions no caption/OCR/
1392
+ // transcript ever put into words. There is no existing evidence
1393
+ // record to join these to (the embedding IS the signal), so they
1394
+ // surface as candidate ranges for the agent to inspect via
1395
+ // watch_original/read_evidence, not as evidence themselves.
1396
+ queryVideoEmbeddings(mediaAssetId, query, 6),
1397
+ // Evidence-granular semantic retrieval. The corpus above matches
1398
+ // chapter-sized documents, which locates a fifteen-minute span
1399
+ // rather than a moment, and lexical scoring finds nothing at all
1400
+ // when the question and the source are in different languages.
1401
+ // This ranks the individual readings at their own timestamps.
1402
+ searchSemanticEvidence(mediaAssetId, query, { topK: 60 }),
1403
+ sourceWideVisualEvidenceRequested
1404
+ ? searchVideoKnowledge(mediaAssetId, '', 2_000, {
1405
+ modalities: ['visual', 'computed'],
1406
+ minimumRangeDistanceSecs: 0,
1407
+ videoDurationSecs: asset.durationSecs,
1408
+ }).then((candidates) => {
1409
+ const sourceWideMoments = candidates.filter((candidate) => {
1410
+ const range = candidate.evidence.timeRange;
1411
+ const span = range.endSecs - range.startSecs;
1412
+ return span >= 15 && span <= 90;
1413
+ });
1414
+ // This source-wide directive is a structural retrieval
1415
+ // decision, independent of language, genre, subject, or
1416
+ // claim-specific vocabulary.
1417
+ const stable = sourceWideMoments.filter(
1418
+ (candidate) => candidate.evidence.timeRange.startSecs >= 60,
1419
+ );
1420
+ const pool = stable.length > 0 ? stable : sourceWideMoments;
1421
+ const selected: typeof pool = [];
1422
+ for (const candidate of pool.sort(
1423
+ (left, right) =>
1424
+ left.evidence.timeRange.startSecs - right.evidence.timeRange.startSecs,
1425
+ )) {
1426
+ if (
1427
+ selected.some(
1428
+ (existing) =>
1429
+ Math.abs(
1430
+ existing.evidence.timeRange.startSecs -
1431
+ candidate.evidence.timeRange.startSecs,
1432
+ ) < 20,
1433
+ )
1434
+ )
1435
+ continue;
1436
+ selected.push(candidate);
1437
+ if (selected.length >= 4) break;
1438
+ }
1439
+ return selected;
1440
+ })
1441
+ : Promise.resolve([]),
1442
+ ]);
1443
+ const semanticDocumentIds = semantic.hits
1444
+ .filter((hit: any) => hit.metadata?.mediaAssetId === mediaAssetId)
1445
+ .map((hit: any) => String(hit.documentId));
1446
+ const semanticScores = semanticEvidence.length
1447
+ ? semanticScoresByEvidenceId(semanticEvidence)
1448
+ : undefined;
1449
+ let hits = await searchVideoKnowledge(mediaAssetId, query, limit ?? 8, {
1450
+ modalities: plan.modalities,
1451
+ minimumRangeDistanceSecs: 2,
1452
+ semanticDocumentIds,
1453
+ semanticScores,
1454
+ queryPlan: plan,
1455
+ videoDurationSecs: asset.durationSecs,
1456
+ });
1457
+ // A question about a named person cannot be answered by describing
1458
+ // the scene generically. Ground the name to a transcript mention
1459
+ // before falling back to whole-scene evidence.
1460
+ const subjectMentions = plan.subjectName
1461
+ ? await searchVideoKnowledge(mediaAssetId, plan.subjectName, 3, {
1462
+ modalities: ['transcript'],
1463
+ minimumRangeDistanceSecs: 4,
1464
+ videoDurationSecs: asset.durationSecs,
1465
+ })
1466
+ : [];
1467
+ const subjectRange = subjectMentions[0]
1468
+ ? {
1469
+ // Identity is usually established over an exchange rather
1470
+ // than a single spoken token. Keep enough context on both
1471
+ // sides for a vision model to correlate who is addressed,
1472
+ // speaking, or camera-focused, while retaining a bounded
1473
+ // single-pass inspection.
1474
+ startSecs: Math.max(0, subjectMentions[0].evidence.timeRange.startSecs - 15),
1475
+ endSecs: subjectMentions[0].evidence.timeRange.endSecs + 15,
1476
+ }
1477
+ : undefined;
1478
+ const overlapsSubjectRange = (hit: (typeof hits)[number]) =>
1479
+ Boolean(
1480
+ subjectRange &&
1481
+ hit.evidence.timeRange.startSecs < subjectRange.endSecs &&
1482
+ hit.evidence.timeRange.endSecs > subjectRange.startSecs,
1483
+ );
1484
+ // Every video question receives a compact tail view. It lets the
1485
+ // agent reason about an ending/state transition when relevant,
1486
+ // without encoding a sports, meeting, surveillance, or language
1487
+ // specific workflow in the runtime.
1488
+ const tailRange = asset.durationSecs
1489
+ ? {
1490
+ // Keep the terminal view large enough to contain a complete
1491
+ // closing exchange/result. It matches the one-minute
1492
+ // interactive inspection cap, so a likely outcome can be
1493
+ // verified in one sparse semantic worker pass.
1494
+ startSecs: Math.max(0, asset.durationSecs - LIMITS.durationSecs),
1495
+ endSecs: asset.durationSecs,
1496
+ }
1497
+ : undefined;
1498
+ // How a source concludes is rarely confined to its final minute -- a
1499
+ // result is announced, shown, and discussed, and then the recording
1500
+ // runs on through reactions and sign-off. Reading the closing *phase*
1501
+ // rather than the closing minute is what makes a concluding state
1502
+ // findable at all; `tailRange` stays narrow because it also sizes a
1503
+ // bounded re-decode, which this does not.
1504
+ const closingRange = asset.durationSecs
1505
+ ? {
1506
+ startSecs: Math.max(
1507
+ 0,
1508
+ asset.durationSecs -
1509
+ Math.min(900, Math.max(LIMITS.durationSecs, asset.durationSecs * 0.2)),
1510
+ ),
1511
+ endSecs: asset.durationSecs,
1512
+ }
1513
+ : undefined;
1514
+ // Two views of the closing phase, because they fail in opposite ways.
1515
+ // Descriptive readings state a concluding value plainly but are
1516
+ // sparse; speech is dense but, on conversational audio, is mostly
1517
+ // fragments. Ranking them together lets whichever is noisier for this
1518
+ // source crowd the other out, so each is retrieved to its own quota.
1519
+ const [closingDescriptive, closingAny] = closingRange
1520
+ ? await Promise.all([
1521
+ searchVideoKnowledge(mediaAssetId, query, 8, {
1522
+ modalities: ['visual', 'computed'],
1523
+ minimumRangeDistanceSecs: 0,
1524
+ semanticScores,
1525
+ queryPlan: plan,
1526
+ videoDurationSecs: asset.durationSecs,
1527
+ timeRange: closingRange,
1528
+ }),
1529
+ searchVideoKnowledge(mediaAssetId, query, 6, {
1530
+ modalities: plan.modalities,
1531
+ minimumRangeDistanceSecs: 0,
1532
+ semanticScores,
1533
+ queryPlan: plan,
1534
+ videoDurationSecs: asset.durationSecs,
1535
+ timeRange: closingRange,
1536
+ }),
1537
+ ])
1538
+ : [[], []];
1539
+ const closingEvidence = [
1540
+ ...closingDescriptive,
1541
+ ...closingAny.filter(
1542
+ (candidate) =>
1543
+ !closingDescriptive.some(
1544
+ (existing) => existing.evidence.id === candidate.evidence.id,
1545
+ ),
1546
+ ),
1547
+ ];
1548
+ // Lexical OCR often outranks a VLM observation on an empty tail
1549
+ // lookup. Outcome evidence must still include the bounded visual
1550
+ // interpretation of that terminal range, otherwise chat silently
1551
+ // falls back to text fragments despite a completed GPU analysis.
1552
+ const terminalSemanticEvidence =
1553
+ closingRange && plan.kinds.includes('outcome')
1554
+ ? await searchVideoKnowledge(mediaAssetId, query, 4, {
1555
+ modalities: ['visual', 'computed'],
1556
+ minimumRangeDistanceSecs: 0,
1557
+ semanticScores,
1558
+ queryPlan: plan,
1559
+ videoDurationSecs: asset.durationSecs,
1560
+ timeRange: closingRange,
1561
+ })
1562
+ : [];
1563
+ if (terminalSemanticEvidence.length > 0) {
1564
+ const terminalEvidenceIds = new Set(
1565
+ terminalSemanticEvidence.map((hit) => hit.evidence.id),
1566
+ );
1567
+ hits = [
1568
+ ...terminalSemanticEvidence,
1569
+ ...hits.filter((hit) => !terminalEvidenceIds.has(hit.evidence.id)),
1570
+ ].slice(0, limit ?? 8);
1571
+ }
1572
+ const closingEvidenceWithSemantics = [
1573
+ ...closingEvidence,
1574
+ ...terminalSemanticEvidence.filter(
1575
+ (candidate) =>
1576
+ !closingEvidence.some((existing) => existing.evidence.id === candidate.evidence.id),
1577
+ ),
1578
+ ];
1579
+ // Typed state evidence records short text that recurred on screen,
1580
+ // kept distinct from free-form OCR. It locates where a display was
1581
+ // present and when it changed, for any question about how a source
1582
+ // ends up; it never establishes what that text refers to.
1583
+ const structuredOutcomeHit = plan.kinds.includes('outcome')
1584
+ ? hits.find((hit) => structuredStateFromEvidence(hit.evidence))
1585
+ : undefined;
1586
+ const structuredOutcomeState = structuredOutcomeHit
1587
+ ? structuredStateFromEvidence(structuredOutcomeHit.evidence)
1588
+ : undefined;
1589
+ // A named-person appearance answer must be anchored to a visual
1590
+ // observation from the range where that identity was grounded.
1591
+ // Otherwise a generic scene caption can silently attach another
1592
+ // person's clothing or action to the requested name.
1593
+ if (plan.subjectName && subjectRange) {
1594
+ const groundedVisualHits = hits.filter(
1595
+ (hit) => hit.evidence.modality === 'visual' && overlapsSubjectRange(hit),
1596
+ );
1597
+ hits = groundedVisualHits;
1598
+ }
1599
+ const verification = await verifyMediaEvidence({
1600
+ mediaAssetId,
1601
+ evidenceIds: hits.map((hit) => hit.evidence.id),
1602
+ requiresFramePrecision: /\b(exact|precisely|frame|at\s+\d{1,2}:\d{2})\b/i.test(query),
1603
+ });
1604
+ const requiresCorroboration = plan.kinds.some((kind) =>
1605
+ [
1606
+ 'outcome',
1607
+ 'state-change',
1608
+ 'comparison',
1609
+ 'counting',
1610
+ 'computation',
1611
+ 'person-attribute',
1612
+ ].includes(kind),
1613
+ );
1614
+ // Where to look is decided by fusing every timestamped signal
1615
+ // retrieval produced -- semantic evidence, cross-modal clip vectors,
1616
+ // lexical hits, the indexed hierarchy, and the source's own ending --
1617
+ // rather than by a fixed rule per claim kind. A conclusion is often
1618
+ // stated before the last minute of a recording, and the moment that
1619
+ // answers a question is frequently nowhere near the top-ranked
1620
+ // chapter, so agreement between independent signals locates it far
1621
+ // more reliably than any single one of them.
1622
+ const focusSignals: FocusSignal[] = [
1623
+ ...sourceWideVisualOverviewEvidence.slice(0, 3).map((hit) => ({
1624
+ kind: 'lexical' as const,
1625
+ startSecs: hit.evidence.timeRange.startSecs,
1626
+ endSecs: hit.evidence.timeRange.endSecs,
1627
+ score: 2,
1628
+ label: 'source-wide visual reading',
1629
+ })),
1630
+ ...semanticEvidence
1631
+ .filter((hit) => hit.endSecs - hit.startSecs <= 180)
1632
+ .slice(0, 12)
1633
+ .map((hit) => ({
1634
+ kind: 'semantic' as const,
1635
+ startSecs: hit.startSecs,
1636
+ endSecs: hit.endSecs,
1637
+ score: hit.score,
1638
+ })),
1639
+ ...visualMatches.map((match) => ({
1640
+ kind: 'clip-embedding' as const,
1641
+ startSecs: match.startSecs,
1642
+ endSecs: match.endSecs,
1643
+ score: match.score,
1644
+ })),
1645
+ ...hits
1646
+ .filter(
1647
+ (hit) => hit.evidence.timeRange.endSecs - hit.evidence.timeRange.startSecs <= 180,
1648
+ )
1649
+ .slice(0, 8)
1650
+ .map((hit) => ({
1651
+ kind: 'lexical' as const,
1652
+ startSecs: hit.evidence.timeRange.startSecs,
1653
+ endSecs: hit.evidence.timeRange.endSecs,
1654
+ score: hit.score,
1655
+ })),
1656
+ ...(investigationPlan?.candidateRanges ?? []).slice(0, 6).map((range, position) => ({
1657
+ kind: 'hierarchy' as const,
1658
+ startSecs: range.startSecs,
1659
+ endSecs: range.endSecs,
1660
+ score: 1 - position * 0.15,
1661
+ label: range.reason,
1662
+ })),
1663
+ // How a source concludes is a prior, never an instruction: it only
1664
+ // ever competes with the other signals for a question whose answer
1665
+ // is a resolution or a final state. Where in the closing phase to
1666
+ // look is itself decided by relevance -- the best semantic match
1667
+ // inside that phase -- rather than by pointing blindly at the last
1668
+ // minute, which is usually sign-off rather than substance.
1669
+ ...(closingRange &&
1670
+ (plan.kinds.includes('outcome') || plan.kinds.includes('state-change'))
1671
+ ? (() => {
1672
+ const closing = semanticEvidence
1673
+ .filter(
1674
+ (hit) =>
1675
+ hit.startSecs <= closingRange.endSecs &&
1676
+ hit.endSecs >= closingRange.startSecs,
1677
+ )
1678
+ .slice(0, 3);
1679
+ return closing.length > 0
1680
+ ? closing.map((hit) => ({
1681
+ kind: 'ending' as const,
1682
+ startSecs: hit.startSecs,
1683
+ endSecs: hit.endSecs,
1684
+ score: hit.score,
1685
+ }))
1686
+ : [
1687
+ {
1688
+ kind: 'ending' as const,
1689
+ startSecs: tailRange?.startSecs ?? closingRange.startSecs,
1690
+ endSecs: closingRange.endSecs,
1691
+ score: 1,
1692
+ },
1693
+ ];
1694
+ })()
1695
+ : []),
1696
+ ];
1697
+ const allFocusRanges = fuseFocusRanges(focusSignals, {
1698
+ maxRanges: 8,
1699
+ maxWindowSecs: LIMITS.durationSecs,
1700
+ });
1701
+ // A recurring on-screen state looks identical to a semantic ranker
1702
+ // wherever it occurs, so a mid-source reading of it can outrank the
1703
+ // concluding one on similarity alone. When the question is explicitly
1704
+ // about how the source ended, time is the only thing that separates
1705
+ // them: keep the closing phase in front, and fall back to the full
1706
+ // list only when nothing there matched at all.
1707
+ const closingFocusRanges =
1708
+ closingRange && plan.kinds.includes('outcome')
1709
+ ? allFocusRanges.filter(
1710
+ (range) =>
1711
+ range.startSecs <= closingRange.endSecs &&
1712
+ range.endSecs >= closingRange.startSecs,
1713
+ )
1714
+ : [];
1715
+ const focusRanges = (
1716
+ closingFocusRanges.length > 0
1717
+ ? [
1718
+ ...closingFocusRanges,
1719
+ ...allFocusRanges.filter((range) => !closingFocusRanges.includes(range)),
1720
+ ]
1721
+ : allFocusRanges
1722
+ ).slice(0, 5);
1723
+ // A named-person claim is still verified from where that person is
1724
+ // named or addressed -- identity grounding outranks topical signal.
1725
+ const requestedRange = subjectRange ?? focusRanges[0] ?? tailRange;
1726
+ const recommendedInspection = requestedRange
1727
+ ? {
1728
+ startSecs: requestedRange.startSecs,
1729
+ endSecs: Math.min(
1730
+ requestedRange.endSecs,
1731
+ requestedRange.startSecs +
1732
+ (plan.kinds.includes('outcome') ? LIMITS.durationSecs : 30),
1733
+ ),
1734
+ purpose: plan.kinds.includes('counting')
1735
+ ? 'count'
1736
+ : plan.kinds.includes('exact-ocr')
1737
+ ? 'high-res-ocr'
1738
+ : plan.kinds.includes('comparison') || plan.kinds.includes('state-change')
1739
+ ? 'compare'
1740
+ : 'verify-visual',
1741
+ ...(plan.subjectName
1742
+ ? {
1743
+ focusHint: `Multiple people may be visible in this range. Identify which visible person is "${plan.subjectName}" (named or addressed near this moment) and describe that specific person, not the scene in general.`,
1744
+ }
1745
+ : {}),
1746
+ }
1747
+ : undefined;
1748
+ // Outcome claims need a short semantic read of the ending, not just
1749
+ // an object/OCR anchor that happens to overlap it. A whole-video
1750
+ // summary is navigation context only: it cannot establish what
1751
+ // happened at the end. Counts, computed answers, comparisons, and
1752
+ // state changes always need deliberate bounded coverage too.
1753
+ const hasTerminalSemanticEvidence = closingEvidenceWithSemantics.some(
1754
+ (hit) =>
1755
+ hit.evidence.modality === 'visual' &&
1756
+ hit.evidence.confidence.score >= 0.5 &&
1757
+ hit.evidence.timeRange.endSecs - hit.evidence.timeRange.startSecs <= 180 &&
1758
+ hit.evidence.confidence.uncertaintyReasons.some((reason) =>
1759
+ reason.includes('Semantic VLM interpretation'),
1760
+ ),
1761
+ );
1762
+ const hasTerminalDirectVerdict = closingEvidenceWithSemantics.some(
1763
+ (hit) =>
1764
+ hit.evidence.modality === 'visual' &&
1765
+ /Claim verdict:\s*direct/i.test(evidenceText(hit.evidence.payload)),
1766
+ );
1767
+ // Re-decoding the source is the single slowest thing this tool can
1768
+ // do. The shared gate evaluates indexed provenance, confidence,
1769
+ // temporal coverage, and locator agreement before any live read.
1770
+ const topFocusRange = focusRanges[0];
1771
+ const sufficiencyCandidates = [
1772
+ ...hits,
1773
+ ...closingEvidenceWithSemantics.filter(
1774
+ (candidate) =>
1775
+ !hits.some((existing) => existing.evidence.id === candidate.evidence.id),
1776
+ ),
1777
+ ];
1778
+ const indexAlreadyAnswers = indexedVideoEvidenceIsSufficient({
1779
+ verificationStatus: verification.status,
1780
+ questionKinds: plan.kinds,
1781
+ evidence: sufficiencyCandidates.map((hit) => ({
1782
+ modality: hit.evidence.modality,
1783
+ text: evidenceText(hit.evidence.payload),
1784
+ confidenceScore: hit.evidence.confidence.score,
1785
+ startSecs: hit.evidence.timeRange.startSecs,
1786
+ endSecs: hit.evidence.timeRange.endSecs,
1787
+ conflict: hit.conflict,
1788
+ })),
1789
+ focusSources: topFocusRange?.sources,
1790
+ durationSecs: asset.durationSecs,
1791
+ hierarchyRanges: investigationPlan?.coverage?.representedRanges,
1792
+ });
1793
+ const requiresFreshInspection =
1794
+ !indexAlreadyAnswers &&
1795
+ (verification.status !== 'supported' ||
1796
+ plan.kinds.some((kind) =>
1797
+ [
1798
+ 'state-change',
1799
+ 'comparison',
1800
+ 'counting',
1801
+ 'computation',
1802
+ 'person-attribute',
1803
+ ].includes(kind),
1804
+ ) ||
1805
+ (plan.kinds.includes('outcome') &&
1806
+ (!hasTerminalSemanticEvidence || !hasTerminalDirectVerdict)));
1807
+ // Device entitlements are evaluated by the managed cloud control
1808
+ // plane. An unlimited plan may inspect the bounded range directly;
1809
+ // every other installation keeps the conservative browser budget.
1810
+ const videoRuntimeConfig = activeProjectConfig?.toolConfigs?.['video-intelligence'];
1811
+ const usesManagedCloudRuntime =
1812
+ (videoRuntimeConfig?.runtimeMode ?? 'managed-cloud') === 'managed-cloud';
1813
+ // A RAG-complete answer must stay entirely local to the index. Even
1814
+ // checking remote inspection capacity adds avoidable chat latency
1815
+ // and makes the fast path look like analysis was involved.
1816
+ const hasManagedCloudCapacity =
1817
+ usesManagedCloudRuntime && requiresFreshInspection && recommendedInspection
1818
+ ? await hasVideoIntelligenceCapacity().catch(() => false)
1819
+ : false;
1820
+ const inspectionDecision = hasManagedCloudCapacity
1821
+ ? {
1822
+ decision:
1823
+ requiresCorroboration && requiresFreshInspection
1824
+ ? ('required' as const)
1825
+ : ('optional' as const),
1826
+ reason: 'managed-cloud-entitlement',
1827
+ committed: {
1828
+ durationSecs: recommendedInspection
1829
+ ? recommendedInspection.endSecs - recommendedInspection.startSecs
1830
+ : 30,
1831
+ bytes: 64 * 1024 * 1024,
1832
+ sandboxSeconds: 30,
1833
+ spendUsd: 0,
1834
+ },
1835
+ }
1836
+ : decideInspection({
1837
+ required:
1838
+ (requiresCorroboration && requiresFreshInspection) ||
1839
+ (plan.requiresInspectionWhenInsufficient &&
1840
+ ['insufficient', 'needs_inspection'].includes(verification.status)) ||
1841
+ false,
1842
+ plausibleRange: Boolean(recommendedInspection),
1843
+ estimate: {
1844
+ // A wide recommendedInspection (up to 180s for an outcome
1845
+ // claim) is never dispatched as one request -- it's
1846
+ // chunked into <=LIMITS.durationSecs pieces below. Budget
1847
+ // against one chunk's real cost, not the full range, or
1848
+ // this always trips exceedsPerBundle and gets downgraded
1849
+ // to background-refinement before a single chunk ever runs.
1850
+ durationSecs: recommendedInspection
1851
+ ? Math.min(
1852
+ recommendedInspection.endSecs - recommendedInspection.startSecs,
1853
+ LIMITS.durationSecs,
1854
+ )
1855
+ : 30,
1856
+ bytes: 64 * 1024 * 1024,
1857
+ sandboxSeconds: 30,
1858
+ spendUsd: 0,
1859
+ lowerResolutionProbability: hits.length > 0 ? 0.65 : 0,
1860
+ },
1861
+ budget: {
1862
+ remainingDurationSecs: 180,
1863
+ remainingBytes: 1024 * 1024 * 1024,
1864
+ remainingSandboxSeconds: 600,
1865
+ remainingSpendUsd: 0.5,
1866
+ usedBundleRuns: 0,
1867
+ },
1868
+ });
1869
+ let autoInspection:
1870
+ | {
1871
+ status: 'completed' | 'failed';
1872
+ range: { startSecs: number; endSecs: number };
1873
+ purpose: string;
1874
+ evidenceCount?: number;
1875
+ chunksInspected?: number;
1876
+ error?: string;
1877
+ rule?: string;
1878
+ }
1879
+ | undefined;
1880
+ // Before paying for a dispatched re-index, re-watch the candidate
1881
+ // windows directly. Sampling frames off the source and reading them
1882
+ // in one request settles most questions in seconds, where the
1883
+ // dispatched path spends minutes on a cold worker and routinely
1884
+ // outlives the turn -- which is what makes an answerable question
1885
+ // come back as "the video does not show this".
1886
+ const rewatch =
1887
+ allowAutomaticInspection && requiresFreshInspection && recommendedInspection
1888
+ ? await reWatchSource({
1889
+ asset,
1890
+ question: query,
1891
+ ranges:
1892
+ sourceWideVisualOverviewEvidence.length > 0
1893
+ ? sourceWideVisualOverviewEvidence.slice(0, 1).map((hit) => ({
1894
+ startSecs: hit.evidence.timeRange.startSecs,
1895
+ endSecs: hit.evidence.timeRange.endSecs,
1896
+ label: 'source-wide visual reading',
1897
+ }))
1898
+ : (focusRanges.length > 0 ? focusRanges : [recommendedInspection]).slice(
1899
+ 0,
1900
+ 3,
1901
+ ),
1902
+ subjectName: plan.subjectName,
1903
+ mediaAssetId,
1904
+ })
1905
+ : undefined;
1906
+ // A re-watch reads the source; it does not add to the index. So there
1907
+ // is nothing to re-retrieve, and the reading simply travels with the
1908
+ // evidence already gathered below.
1909
+ const settledByReWatch = Boolean(rewatch?.findings.length);
1910
+ if (
1911
+ !settledByReWatch &&
1912
+ allowAutomaticInspection &&
1913
+ inspectionDecision.decision === 'required' &&
1914
+ recommendedInspection &&
1915
+ origin
1916
+ ) {
1917
+ const inspectionUrl = new URL(workspaceServerUrl('/api/media/inspect'), origin);
1918
+ if (projectId) inspectionUrl.searchParams.set('projectId', projectId);
1919
+ // A single request wider than LIMITS.durationSecs always gets
1920
+ // downgraded to background-refinement by decideInspection (see
1921
+ // inspection-policy.ts) instead of running now -- an outcome
1922
+ // question's recommended range can be up to 180s, so it must be
1923
+ // dispatched as sequential <=LIMITS.durationSecs chunks, not one
1924
+ // call, or the automatic inspection silently never happens.
1925
+ // Ordered by whatever signal already exists (a visual-embedding
1926
+ // match scores higher relevance to the actual question than a
1927
+ // blind guess), tail-first as the fallback tiebreak since a
1928
+ // concluding state is often near the end: stop as soon as
1929
+ // a chunk actually resolves the claim instead of always
1930
+ // spending the full budget. This is claim/signal-driven, not
1931
+ // tied to any particular language, domain, or video.
1932
+ const chunkSignalScore = (chunk: { startSecs: number; endSecs: number }) =>
1933
+ visualMatches.reduce(
1934
+ (sum, match) =>
1935
+ sum +
1936
+ (match.startSecs < chunk.endSecs && match.endSecs > chunk.startSecs
1937
+ ? match.score
1938
+ : 0),
1939
+ 0,
1940
+ );
1941
+ const chunks = chunkTimeRange(
1942
+ recommendedInspection.startSecs,
1943
+ recommendedInspection.endSecs,
1944
+ LIMITS.durationSecs,
1945
+ ).sort(
1946
+ (a, b) => chunkSignalScore(b) - chunkSignalScore(a) || b.startSecs - a.startSecs,
1947
+ );
1948
+ let evidenceCount = 0;
1949
+ let chunksCompleted = 0;
1950
+ let refined: any;
1951
+ let chunkError: string | undefined;
1952
+ let workerTimedOut = false;
1953
+ // Chunks are signal-ordered, not necessarily chronological, so
1954
+ // the covered span has to be tracked explicitly rather than
1955
+ // read off however many were visited from one end.
1956
+ let coveredStartSecs = recommendedInspection.endSecs;
1957
+ let coveredEndSecs = recommendedInspection.startSecs;
1958
+ // A person is watching this answer arrive. Each chunk may wait out
1959
+ // a cold worker, so without a ceiling across the whole loop a
1960
+ // multi-chunk range can hold one chat turn open for many minutes.
1961
+ // Spend the budget on the best-ranked chunks and then answer from
1962
+ // what was gathered, rather than making the wait unbounded.
1963
+ const inspectionDeadline = Date.now() + CHAT_INSPECTION_BUDGET_MS;
1964
+ let budgetExhausted = false;
1965
+ for (const chunk of chunks) {
1966
+ if (Date.now() >= inspectionDeadline) {
1967
+ budgetExhausted = true;
1968
+ break;
1969
+ }
1970
+ try {
1971
+ const response = await fetch(inspectionUrl, {
1972
+ method: 'POST',
1973
+ headers: { 'Content-Type': 'application/json' },
1974
+ body: JSON.stringify({
1975
+ mediaAssetId,
1976
+ startSecs: chunk.startSecs,
1977
+ endSecs: chunk.endSecs,
1978
+ purpose: recommendedInspection.purpose,
1979
+ queryId: `chat-verification:${mediaAssetId}:${query}:${chunk.startSecs}`.slice(
1980
+ 0,
1981
+ 128,
1982
+ ),
1983
+ question: recommendedInspection.focusHint
1984
+ ? `${query}\n\n${recommendedInspection.focusHint}`
1985
+ : query,
1986
+ ...(plan.subjectName ? { knownEntities: [plan.subjectName] } : {}),
1987
+ maxFrames: 24,
1988
+ toolCallId,
1989
+ // Never let one chunk consume the whole interactive budget.
1990
+ maxWaitMs: Math.max(30_000, inspectionDeadline - Date.now()),
1991
+ }),
1992
+ });
1993
+ const inspectionResult = await response.json().catch(() => ({}));
1994
+ if (!response.ok) {
1995
+ chunkError =
1996
+ typeof inspectionResult.error === 'string'
1997
+ ? inspectionResult.error
1998
+ : `Inspection returned HTTP ${response.status}.`;
1999
+ // The analysis service itself didn't respond -- trying more
2000
+ // chunks just means more multi-minute waits for the same
2001
+ // outcome, so stop immediately rather than after every
2002
+ // remaining chunk times out too.
2003
+ if (response.status === 504 || inspectionResult.workerTimeout) {
2004
+ workerTimedOut = true;
2005
+ }
2006
+ break;
2007
+ }
2008
+ chunksCompleted += 1;
2009
+ coveredStartSecs = Math.min(coveredStartSecs, chunk.startSecs);
2010
+ coveredEndSecs = Math.max(coveredEndSecs, chunk.endSecs);
2011
+ evidenceCount += Array.isArray(inspectionResult.evidence)
2012
+ ? inspectionResult.evidence.length
2013
+ : 0;
2014
+ refined = await run(false);
2015
+ if (!refined?.claimVerification?.requiresCorroboration) break;
2016
+ } catch (error) {
2017
+ chunkError = error instanceof Error ? error.message : 'Cloud inspection failed.';
2018
+ break;
2019
+ }
2020
+ }
2021
+ if (chunksCompleted > 0) {
2022
+ // Every available chunk was actually inspected (not deferred,
2023
+ // not timed out) and the claim still isn't fully corroborated
2024
+ // -- more bounded inspection of this same range won't change
2025
+ // that. Rather than leave requiresCorroboration true forever
2026
+ // (which reads to the model as "keep trying" indefinitely,
2027
+ // producing either an infinite tool-call loop or a refusal to
2028
+ // answer), tell it plainly that the search budget for this
2029
+ // claim is exhausted and a best-effort, confidence-qualified
2030
+ // answer from the gathered evidence is expected now.
2031
+ // Exhausted either by running out of candidate chunks, or by a
2032
+ // chunk failing partway through (chunkError) -- either way, at
2033
+ // least one real inspection already ran and no further
2034
+ // automatic one will follow, so the same instruction applies.
2035
+ const exhausted =
2036
+ (chunksCompleted >= chunks.length || Boolean(chunkError) || budgetExhausted) &&
2037
+ refined?.claimVerification?.requiresCorroboration;
2038
+ return {
2039
+ ...refined,
2040
+ ...(exhausted
2041
+ ? {
2042
+ claimVerification: {
2043
+ ...refined.claimVerification,
2044
+ requiresCorroboration: false,
2045
+ rule: 'Every available bounded range for this claim has now been inspected; no further inspection of this range is possible. Answer from the gathered evidence, stating your confidence level and citing the specific evidence (or its absence) rather than declining to answer or claiming the video does not show it.',
2046
+ },
2047
+ }
2048
+ : {}),
2049
+ autoInspection: {
2050
+ status: 'completed' as const,
2051
+ range: { startSecs: coveredStartSecs, endSecs: coveredEndSecs },
2052
+ purpose: recommendedInspection.purpose,
2053
+ evidenceCount,
2054
+ chunksInspected: chunksCompleted,
2055
+ },
2056
+ };
2057
+ }
2058
+ autoInspection = {
2059
+ status: 'failed',
2060
+ range: {
2061
+ startSecs: recommendedInspection.startSecs,
2062
+ endSecs: recommendedInspection.endSecs,
2063
+ },
2064
+ purpose: recommendedInspection.purpose,
2065
+ ...(workerTimedOut
2066
+ ? {
2067
+ rule: "The video analysis service didn't respond -- this range was never actually checked. Tell the user the check couldn't run right now; never phrase this as the video not showing the answer.",
2068
+ }
2069
+ : {}),
2070
+ error: chunkError ?? 'Cloud inspection failed.',
2071
+ };
2072
+ }
2073
+ void trackUsageEvent({
2074
+ type: 'media_processing',
2075
+ mediaOperation: 'investigation',
2076
+ mediaAssetId,
2077
+ queryKind: plan.kinds.join(','),
2078
+ evidenceCount: hits.length,
2079
+ timestamp: new Date().toISOString(),
2080
+ });
2081
+ const sourceUrl = workspaceServerUrl(
2082
+ `/api/media/${asset.id}${
2083
+ projectId ? `?projectId=${encodeURIComponent(projectId)}` : ''
2084
+ }`,
2085
+ );
2086
+ const buildSeekUrl = (startSecs: number): string | undefined => {
2087
+ if (asset.originalUrl) {
2088
+ try {
2089
+ const url = new URL(asset.originalUrl);
2090
+ if (url.hostname.includes('youtube.com') || url.hostname.includes('youtu.be')) {
2091
+ url.searchParams.set('t', String(Math.floor(startSecs)));
2092
+ return url.toString();
2093
+ }
2094
+ } catch {}
2095
+ }
2096
+ return `${sourceUrl}${sourceUrl.includes('?') ? '&' : '?'}t=${startSecs}`;
2097
+ };
2098
+ const modalityLabel = (modality: string) =>
2099
+ modality === 'transcript'
2100
+ ? 'Speech'
2101
+ : modality === 'ocr'
2102
+ ? 'On-screen text'
2103
+ : modality === 'visual'
2104
+ ? 'Visual'
2105
+ : modality === 'audio-event'
2106
+ ? 'Audio'
2107
+ : modality === 'computed'
2108
+ ? 'Timeline'
2109
+ : 'Evidence';
2110
+ // Renders through the host's generic output.ui.kind contract
2111
+ // (message-item.tsx / chat-citations.tsx) -- the same mechanism
2112
+ // any marketplace tool's result can use, nothing video-specific
2113
+ // hardcoded on the chat-UI side. A clip is only offered when
2114
+ // verification actually supports the claim being cited.
2115
+ const primarySupportingHit =
2116
+ plan.subjectName && subjectRange
2117
+ ? hits.find(
2118
+ (hit) =>
2119
+ hit.evidence.modality === 'visual' &&
2120
+ overlapsSubjectRange(hit) &&
2121
+ !hit.conflict &&
2122
+ Number.isFinite(hit.evidence.timeRange.startSecs),
2123
+ )
2124
+ : hits.find(
2125
+ (hit) => !hit.conflict && Number.isFinite(hit.evidence.timeRange.startSecs),
2126
+ );
2127
+ const ui = {
2128
+ kind: 'citations' as const,
2129
+ title: asset.fileName,
2130
+ fileName: asset.fileName,
2131
+ mediaType: 'video' as const,
2132
+ ...(verification.status === 'supported' && primarySupportingHit
2133
+ ? {
2134
+ mediaUrl: sourceUrl,
2135
+ sourceUrl: asset.originalUrl || sourceUrl,
2136
+ primaryTimestampSecs: primarySupportingHit.evidence.timeRange.startSecs,
2137
+ primaryEndSecs: primarySupportingHit.evidence.timeRange.endSecs,
2138
+ }
2139
+ : {}),
2140
+ items: hits.map((hit) => ({
2141
+ label: modalityLabel(hit.evidence.modality),
2142
+ detail: evidenceText(hit.evidence.payload),
2143
+ timestampSecs: hit.evidence.timeRange.startSecs,
2144
+ seekUrl: buildSeekUrl(hit.evidence.timeRange.startSecs),
2145
+ conflicted: hit.conflict,
2146
+ })),
2147
+ };
2148
+ const result = {
2149
+ success: true,
2150
+ mediaAssetId: asset.id,
2151
+ fileName: asset.fileName,
2152
+ sourceUrl,
2153
+ originalUrl: asset.originalUrl,
2154
+ ui,
2155
+ verification,
2156
+ plan,
2157
+ // The full hierarchy is chapter and scene summaries running to
2158
+ // tens of thousands of characters. Streaming that into every
2159
+ // answer crowds out the evidence itself and slows the turn down
2160
+ // for no gain, so what ships here is a navigable map: timecodes
2161
+ // and titles only. `planVideoInvestigation` still returns the
2162
+ // detail when the model deliberately asks for it.
2163
+ ...(investigationPlan
2164
+ ? {
2165
+ timeline: {
2166
+ strategy: investigationPlan.strategy,
2167
+ coverage: investigationPlan.coverage,
2168
+ chapters: investigationPlan.timeline.map((chapter) => ({
2169
+ at: `${formatTimecode(chapter.startSecs)}–${formatTimecode(chapter.endSecs)}`,
2170
+ startSecs: chapter.startSecs,
2171
+ endSecs: chapter.endSecs,
2172
+ title: chapter.title,
2173
+ })),
2174
+ rule: 'A map of the source for navigation only. It never establishes a fact; call read_evidence on a range to read what is actually recorded there.',
2175
+ },
2176
+ }
2177
+ : {}),
2178
+ // The ranked answer to "where should I look?", fused from every
2179
+ // timestamped signal. This is what the model should navigate by.
2180
+ ...(focusRanges.length > 0
2181
+ ? {
2182
+ focusRanges: {
2183
+ ranges: focusRanges.map((range) => ({
2184
+ at: `${formatTimecode(range.startSecs)}–${formatTimecode(range.endSecs)}`,
2185
+ startSecs: range.startSecs,
2186
+ endSecs: range.endSecs,
2187
+ score: range.score,
2188
+ agreedBy: range.sources,
2189
+ ...(range.label ? { label: range.label } : {}),
2190
+ })),
2191
+ rule: 'Ranked by how strongly independent retrieval signals agree that the answer is in the window, not by evidence content. When the evidence below is not enough, call read_evidence on the top range first, and watch_original only if reading it still leaves the question open.',
2192
+ },
2193
+ }
2194
+ : {}),
2195
+ ...(plan.subjectName
2196
+ ? {
2197
+ entityGrounding: subjectRange
2198
+ ? {
2199
+ resolved: true,
2200
+ subjectName: plan.subjectName,
2201
+ range: subjectRange,
2202
+ rule: "Describe this named person only from visual evidence overlapping the grounded range. Do not assign another visible person's clothing, action, or appearance based on a generic scene caption.",
2203
+ }
2204
+ : {
2205
+ resolved: false,
2206
+ subjectName: plan.subjectName,
2207
+ rule: `No transcript mention of "${plan.subjectName}" was found, so no visible person could be linked to that name. Say plainly that this person couldn't be identified in the video rather than describing the scene generically as if it answered the question.`,
2208
+ },
2209
+ }
2210
+ : {}),
2211
+ ...(closingRange
2212
+ ? {
2213
+ claimVerification: {
2214
+ // The window `closingEvidence` was actually drawn from.
2215
+ // Reporting the narrower inspection-sized tail here made
2216
+ // the model treat evidence from the closing phase as
2217
+ // out-of-range and discard it.
2218
+ closingRange: {
2219
+ ...closingRange,
2220
+ at: `${formatTimecode(closingRange.startSecs)}–${formatTimecode(
2221
+ closingRange.endSecs,
2222
+ )}`,
2223
+ },
2224
+ claimKinds: plan.kinds,
2225
+ // The source itself was just read for this question, so
2226
+ // asking for corroboration would only send the turn back
2227
+ // for evidence it already has.
2228
+ requiresCorroboration:
2229
+ requiresCorroboration && requiresFreshInspection && !settledByReWatch,
2230
+ ...(recommendedInspection && !settledByReWatch
2231
+ ? { recommendedInspection }
2232
+ : {}),
2233
+ rule: settledByReWatch
2234
+ ? 'The source was re-watched for this question; answer from what the readings in directObservation established, and cite their timestamps.'
2235
+ : structuredOutcomeState
2236
+ ? 'A structured source state establishes only its displayed value. Do not assign the value to an identity, owner, or category unless evidence from the same source range explicitly identifies that mapping. Match the evidence range to the claim being made; never promote a local observation into a broader conclusion.'
2237
+ : 'Match the evidence range to the claim being made. When requiresCorroboration is true, run the recommended bounded inspection before answering, then query this tool again. Never promote a local observation into a broader conclusion.',
2238
+ closingEvidence: closingEvidenceWithSemantics.map((hit) => ({
2239
+ evidenceId: hit.evidence.id,
2240
+ modality: hit.evidence.modality,
2241
+ at: formatTimecode(hit.evidence.timeRange.startSecs),
2242
+ text: evidenceText(hit.evidence.payload),
2243
+ startSecs: hit.evidence.timeRange.startSecs,
2244
+ endSecs: hit.evidence.timeRange.endSecs,
2245
+ confidence: hit.evidence.confidence,
2246
+ })),
2247
+ },
2248
+ }
2249
+ : {}),
2250
+ ...(structuredOutcomeHit && structuredOutcomeState
2251
+ ? {
2252
+ structuredOutcomeState: {
2253
+ ...structuredOutcomeState,
2254
+ startSecs: structuredOutcomeHit.evidence.timeRange.startSecs,
2255
+ endSecs: structuredOutcomeHit.evidence.timeRange.endSecs,
2256
+ confidence: structuredOutcomeHit.evidence.confidence,
2257
+ rule: 'This is a compact value read from the source. It establishes the displayed value, but not the identity or meaning of either side unless that mapping is also present in the cited evidence.',
2258
+ },
2259
+ }
2260
+ : {}),
2261
+ retrievalCapabilities: videoKnowledgeRetrievalCapabilities(),
2262
+ inspectionDecision,
2263
+ ...(rewatch
2264
+ ? {
2265
+ directObservation: {
2266
+ findings: rewatch.findings,
2267
+ elapsedMs: rewatch.elapsedMs,
2268
+ rule:
2269
+ 'These are direct readings taken by re-watching the source just now, for ' +
2270
+ 'this question. Answer from them and cite the timestamps they came from, ' +
2271
+ 'and do not report that the source fails to show something a reading here ' +
2272
+ 'establishes. Weigh them against each other and against the indexed ' +
2273
+ 'evidence rather than taking each at face value: a reading is strongest ' +
2274
+ 'where several readings and the index agree. A lone reading that ' +
2275
+ 'contradicts both the other readings and the index -- naming someone or ' +
2276
+ 'something that appears nowhere else -- is a misreading, whatever ' +
2277
+ 'confidence it carries; leave it out rather than reporting it.',
2278
+ },
2279
+ }
2280
+ : {}),
2281
+ ...(autoInspection ? { autoInspection } : {}),
2282
+ ...(visualMatches.length > 0
2283
+ ? {
2284
+ visualMatches: {
2285
+ matches: visualMatches,
2286
+ rule: 'Ranked by visual similarity only, not evidence. If the transcript/OCR/caption evidence above does not answer the question, call watch_original (inspectVideoKnowledge) or read_evidence on the highest-scoring range here before answering.',
2287
+ },
2288
+ }
2289
+ : {}),
2290
+ evidence: hits.map((hit) => {
2291
+ const rawText = evidenceText(hit.evidence.payload);
2292
+ return {
2293
+ evidenceId: hit.evidence.id,
2294
+ modality: hit.evidence.modality,
2295
+ text: rawText,
2296
+ startSecs: hit.evidence.timeRange.startSecs,
2297
+ endSecs: hit.evidence.timeRange.endSecs,
2298
+ precision: hit.evidence.timeRange.precision,
2299
+ confidence: hit.evidence.confidence,
2300
+ conflicted: hit.conflict,
2301
+ };
2302
+ }),
2303
+ };
2304
+ // Video questions deliberately do not reuse answer or miss memory.
2305
+ // A later turn always starts from current indexed evidence and may
2306
+ // request a fresh bounded analysis when that evidence is incomplete.
2307
+ return result;
2308
+ };
2309
+ return projectId ? runWithProject(projectId, run) : run();
2310
+ },
2311
+ }),
2312
+
2313
+ planVideoInvestigation: tool({
2314
+ description:
2315
+ 'Plan a complex video investigation before source inspection. It searches the active time hierarchy (chapters, scenes, events, states) and returns the best candidate ranges. Use this when queryVideoKnowledge has weak or no direct evidence, or when the question requires before/after reasoning, tracking, counting, or a chain of events. Then inspect only the returned range(s), never the entire video.',
2316
+ inputSchema: z.object({
2317
+ mediaAssetId: z.string().describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
2318
+ question: z.string().describe('The full user question, including any temporal conditions.'),
2319
+ investigation: z.object({
2320
+ scope: z.enum(['focused', 'temporal', 'source']),
2321
+ goal: z.enum(['answer', 'compare', 'trace', 'enumerate', 'synthesize']),
2322
+ evidence: z
2323
+ .array(z.enum(['speech', 'visible-text', 'visual', 'computed']))
2324
+ .max(4)
2325
+ .optional(),
2326
+ recordSet: z.enum(['all', 'source-authored', 'source-questions', 'observed']).optional(),
2327
+ timeRange: z
2328
+ .object({ startSecs: z.number().min(0), endSecs: z.number().min(0) })
2329
+ .optional(),
2330
+ }),
2331
+ }),
2332
+ execute: async ({ mediaAssetId, question, investigation }) => {
2333
+ const run = async () => {
2334
+ const asset = await scopedAsset(mediaAssetId);
2335
+ if (!asset || asset.processingStatus !== 'completed') {
2336
+ return { success: false, error: 'That indexed media asset is no longer available.' };
2337
+ }
2338
+ const plan = await buildVideoInvestigationPlan(mediaAssetId, question, investigation);
2339
+ if (!plan)
2340
+ return { success: false, error: 'No active video knowledge revision is available.' };
2341
+ void trackUsageEvent({
2342
+ type: 'media_processing',
2343
+ mediaOperation: 'investigation',
2344
+ mediaAssetId,
2345
+ queryKind: 'planner',
2346
+ timestamp: new Date().toISOString(),
2347
+ });
2348
+ return { success: true, ...plan };
2349
+ };
2350
+ return projectId ? runWithProject(projectId, run) : run();
2351
+ },
2352
+ }),
2353
+
2354
+ read_evidence: tool({
2355
+ description:
2356
+ 'Read every recorded piece of evidence (transcript, OCR, visual, computed) whose timestamp falls inside a specific window, in chronological order — no relevance ranking or query terms. Use this once queryVideoKnowledge or planVideoInvestigation has narrowed to a candidate range and you need the full, literal record of what is known there, not a search over it.',
2357
+ inputSchema: z.object({
2358
+ mediaAssetId: z.string().describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
2359
+ startSecs: z.number().nonnegative(),
2360
+ endSecs: z.number().nonnegative(),
2361
+ limit: z.number().int().min(1).max(50).optional(),
2362
+ }),
2363
+ execute: async ({ mediaAssetId, startSecs, endSecs, limit }) => {
2364
+ const run = async () => {
2365
+ const asset = await scopedAsset(mediaAssetId);
2366
+ if (!asset || asset.processingStatus !== 'completed') {
2367
+ return { success: false, error: 'That indexed media asset is no longer available.' };
2368
+ }
2369
+ const hits = await searchVideoKnowledge(mediaAssetId, '', limit ?? 30, {
2370
+ minimumRangeDistanceSecs: 0,
2371
+ videoDurationSecs: asset.durationSecs,
2372
+ timeRange: { startSecs, endSecs },
2373
+ });
2374
+ const chronological = [...hits].sort(
2375
+ (a, b) => a.evidence.timeRange.startSecs - b.evidence.timeRange.startSecs,
2376
+ );
2377
+ return {
2378
+ success: true,
2379
+ mediaAssetId: asset.id,
2380
+ range: { startSecs, endSecs },
2381
+ evidence: chronological.map((hit) => {
2382
+ const rawText =
2383
+ typeof hit.evidence.payload === 'object' &&
2384
+ hit.evidence.payload &&
2385
+ 'text' in hit.evidence.payload
2386
+ ? String((hit.evidence.payload as { text?: unknown }).text ?? '')
2387
+ : JSON.stringify(hit.evidence.payload);
2388
+ return {
2389
+ evidenceId: hit.evidence.id,
2390
+ modality: hit.evidence.modality,
2391
+ text: rawText,
2392
+ startSecs: hit.evidence.timeRange.startSecs,
2393
+ endSecs: hit.evidence.timeRange.endSecs,
2394
+ precision: hit.evidence.timeRange.precision,
2395
+ confidence: hit.evidence.confidence,
2396
+ conflicted: hit.conflict,
2397
+ };
2398
+ }),
2399
+ };
2400
+ };
2401
+ return projectId ? runWithProject(projectId, run) : run();
2402
+ },
2403
+ }),
2404
+
2405
+ expand_range: tool({
2406
+ description:
2407
+ 'Widen a candidate time range for further investigation. Prefers the smallest indexed scene or chapter that fully encloses the given range (a real structural boundary in the video) over an arbitrary pad, falling back to a fixed pad only when nothing indexed encloses it yet. Use this when read_evidence or queryVideoKnowledge found too little inside a range to answer, before calling watch_original (inspectVideoKnowledge) on the widened range.',
2408
+ inputSchema: z.object({
2409
+ mediaAssetId: z.string().describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
2410
+ startSecs: z.number().nonnegative(),
2411
+ endSecs: z.number().nonnegative(),
2412
+ }),
2413
+ execute: async ({ mediaAssetId, startSecs, endSecs }) => {
2414
+ const run = async () => {
2415
+ const asset = await scopedAsset(mediaAssetId);
2416
+ if (!asset || asset.processingStatus !== 'completed') {
2417
+ return { success: false, error: 'That indexed media asset is no longer available.' };
2418
+ }
2419
+ const expanded = await expandInvestigationRange(
2420
+ mediaAssetId,
2421
+ { startSecs, endSecs },
2422
+ asset.durationSecs,
2423
+ );
2424
+ if (!expanded) {
2425
+ return { success: false, error: 'No active video knowledge revision is available.' };
2426
+ }
2427
+ return { success: true, ...expanded };
2428
+ };
2429
+ return projectId ? runWithProject(projectId, run) : run();
2430
+ },
2431
+ }),
2432
+
2433
+ presentMedia: tool({
2434
+ description:
2435
+ 'Embed one indexed image, video segment, or audio segment as a compact citation in the chat. Use the exact mediaAssetId for Media-library assets, or the exact images[].imageUrl returned by searchKnowledgeBase for an image extracted from a PDF. Call this when the user asks to see, watch, play, or hear the media. Set extractFrame=true with a specific startSecs to show "what was on screen at X:XX" as a single frame preview instead of a video player. Prefer one best citation and do not call this for every search hit.',
2436
+ inputSchema: z.object({
2437
+ assetId: z
2438
+ .string()
2439
+ .optional()
2440
+ .describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
2441
+ imageUrl: z
2442
+ .string()
2443
+ .optional()
2444
+ .describe(
2445
+ 'Exact images[].imageUrl returned by searchKnowledgeBase for an extracted PDF image.',
2446
+ ),
2447
+ documentId: z
2448
+ .string()
2449
+ .optional()
2450
+ .describe(
2451
+ 'Exact PDF documentId returned by searchKnowledgeBase for an on-demand page preview.',
2452
+ ),
2453
+ pageNumber: z
2454
+ .number()
2455
+ .int()
2456
+ .positive()
2457
+ .optional()
2458
+ .describe('Preferred PDF page number to render when documentId is provided.'),
2459
+ question: z
2460
+ .string()
2461
+ .optional()
2462
+ .describe(
2463
+ 'Optional user intent used to choose the nearest PDF page when pageNumber is omitted.',
2464
+ ),
2465
+ startSecs: z
2466
+ .number()
2467
+ .nonnegative()
2468
+ .optional()
2469
+ .describe('Exact segment start time returned by searchKnowledgeBase.'),
2470
+ endSecs: z
2471
+ .number()
2472
+ .nonnegative()
2473
+ .optional()
2474
+ .describe('Exact segment end time returned by searchKnowledgeBase.'),
2475
+ extractFrame: z
2476
+ .boolean()
2477
+ .optional()
2478
+ .describe(
2479
+ 'When true, extract and show a single frame at startSecs instead of embedding the video player. Use this to answer "show me what was on screen at X:XX".',
2480
+ ),
2481
+ }),
2482
+ execute: async ({
2483
+ assetId,
2484
+ imageUrl,
2485
+ documentId,
2486
+ pageNumber,
2487
+ question,
2488
+ startSecs,
2489
+ endSecs,
2490
+ extractFrame,
2491
+ }) => {
2492
+ const resolvePresentation = async () => {
2493
+ if (documentId) {
2494
+ const document = await scopedDocument(documentId);
2495
+ if (!document) return { success: false, error: 'That PDF document is not available.' };
2496
+ const selectedPage =
2497
+ pageNumber ??
2498
+ (await inspectStoredPdf(document, question ?? '', undefined)).pages[0]?.pageNumber;
2499
+ if (!selectedPage)
2500
+ return { success: false, error: 'No renderable PDF page was found.' };
2501
+ await renderStoredPdfPage(document, selectedPage, 960);
2502
+ const mediaUrl = pdfPagePreviewUrl(document.id, selectedPage);
2503
+ return {
2504
+ success: true,
2505
+ assetId: `pdf-page-${document.id}-${selectedPage}`,
2506
+ mediaType: 'image' as const,
2507
+ fileName: `${document.title} — Page ${selectedPage}`,
2508
+ mediaUrl,
2509
+ sourceUrl: document.url || mediaUrl,
2510
+ };
2511
+ }
2512
+ if (imageUrl) {
2513
+ const { documents } = await readAssistantSourceScope();
2514
+ const indexedImage = findIndexedImageSource(documents, imageUrl);
2515
+ if (!indexedImage) {
2516
+ return {
2517
+ success: false,
2518
+ error: 'That extracted document image is no longer available.',
2519
+ };
2520
+ }
2521
+ const { source, image } = indexedImage;
2522
+ return {
2523
+ success: true,
2524
+ // The preview uses mediaUrl when supplied; this stable sentinel
2525
+ // only satisfies its shared media citation shape.
2526
+ assetId: `document-image-${image.index ?? 0}`,
2527
+ mediaType: 'image' as const,
2528
+ fileName: `${source?.title ?? 'Document'} — Image ${(image.index ?? 0) + 1}`,
2529
+ mediaUrl: imageUrl,
2530
+ sourceUrl: source?.url || imageUrl,
2531
+ };
2532
+ }
2533
+
2534
+ if (!assetId) {
2535
+ return { success: false, error: 'Choose a media asset or extracted document image.' };
2536
+ }
2537
+ const asset = await scopedAsset(assetId);
2538
+ if (!asset || asset.processingStatus !== 'completed') {
2539
+ return {
2540
+ success: false,
2541
+ error: 'That indexed media asset is not available in the selected runtime.',
2542
+ };
2543
+ }
2544
+
2545
+ const range = normalizeMediaCitationRange(
2546
+ asset.type,
2547
+ asset.durationSecs,
2548
+ startSecs,
2549
+ endSecs,
2550
+ );
2551
+ const serverQuery = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
2552
+ const mediaUrl = workspaceServerUrl(`/api/media/${asset.id}${serverQuery}`);
2553
+ const sourceUrl =
2554
+ range.startSecs !== undefined
2555
+ ? timestampMediaUrl(asset.originalUrl || mediaUrl, range.startSecs)
2556
+ : asset.originalUrl || mediaUrl;
2557
+
2558
+ // Frame preview: extract a single frame at the timestamp instead
2559
+ // of embedding the full video player. This answers "show me what
2560
+ // was on screen at X:XX" with a precise image.
2561
+ let framePreviewUrl: string | undefined;
2562
+ if (extractFrame && asset.type === 'video' && range.startSecs !== undefined) {
2563
+ const frameQuery = new URLSearchParams();
2564
+ frameQuery.set('t', String(range.startSecs));
2565
+ if (projectId) frameQuery.set('projectId', projectId);
2566
+ framePreviewUrl = workspaceServerUrl(
2567
+ `/api/media/${asset.id}/frame?${frameQuery.toString()}`,
2568
+ );
2569
+ }
2570
+
2571
+ return {
2572
+ success: true,
2573
+ assetId: asset.id,
2574
+ mediaType: framePreviewUrl ? ('frame-preview' as const) : asset.type,
2575
+ fileName: asset.fileName,
2576
+ mediaUrl: framePreviewUrl || mediaUrl,
2577
+ sourceUrl,
2578
+ startSecs: range.startSecs,
2579
+ endSecs: range.endSecs,
2580
+ framePreviewUrl,
2581
+ };
2582
+ };
2583
+
2584
+ return projectId ? runWithProject(projectId, resolvePresentation) : resolvePresentation();
2585
+ },
2586
+ }),
2587
+
2588
+ queryTabularData: tool({
2589
+ description:
2590
+ 'Query a tabular dataset (CSV/Excel/JSON) for exact values, filtering, grouping, and aggregations. Use this when the user asks about specific data values, comparisons, averages, sums, counts, highest/lowest, or any numerical question about uploaded data.',
2591
+ inputSchema: z.object({
2592
+ datasetId: z
2593
+ .string()
2594
+ .describe(
2595
+ 'The ID of the dataset to query. Get this from the available datasets list in the system prompt.',
2596
+ ),
2597
+ filters: z
2598
+ .array(
2599
+ z.object({
2600
+ column: z.string(),
2601
+ op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'in']),
2602
+ value: z.any(),
2603
+ }),
2604
+ )
2605
+ .optional()
2606
+ .describe(
2607
+ 'Filters to apply to the data. This array must contain only filter objects; limit and columns are separate top-level arguments.',
2608
+ ),
2609
+ groupBy: z.array(z.string()).optional().describe('Columns to group by for aggregations.'),
2610
+ aggregations: z
2611
+ .array(
2612
+ z.object({
2613
+ column: z.string(),
2614
+ op: z.enum(['sum', 'avg', 'count', 'countDistinct', 'min', 'max', 'median']),
2615
+ }),
2616
+ )
2617
+ .optional()
2618
+ .describe(
2619
+ 'Aggregation operations to perform. Use countDistinct when the user asks for unique or distinct values.',
2620
+ ),
2621
+ sortBy: z.string().optional().describe('Column to sort results by.'),
2622
+ sortOrder: z.enum(['asc', 'desc']).optional().describe('Sort direction.'),
2623
+ limit: z.number().optional().describe('Max number of rows to return.'),
2624
+ columns: z.array(z.string()).optional().describe('Specific columns to include in results.'),
2625
+ timeBucket: z
2626
+ .object({
2627
+ column: z.string(),
2628
+ grain: z.enum(['month', 'quarter', 'year']),
2629
+ })
2630
+ .optional()
2631
+ .describe('Optional calendar grouping for a date column.'),
2632
+ }),
2633
+ execute: async (params) => {
2634
+ try {
2635
+ const availableDatasets = await readAssistantTabularDatasets();
2636
+ const resolvedDatasetId = resolveTabularDatasetForQuestion(
2637
+ requestText,
2638
+ availableDatasets,
2639
+ params.datasetId,
2640
+ );
2641
+ if (!availableDatasets.some((dataset) => dataset.id === resolvedDatasetId)) {
2642
+ throw new Error('Dataset not found');
2643
+ }
2644
+ const dataset = await inActiveProject(() => getTabularDataset(resolvedDatasetId));
2645
+ if (!dataset) throw new Error('Dataset not found');
2646
+ const plan = inferTabularPlan(requestText, dataset, {
2647
+ ...params,
2648
+ datasetId: resolvedDatasetId,
2649
+ });
2650
+ const result = await queryTabular(plan.request);
2651
+ if (result.columns.length && result.rows.length) {
2652
+ currentTurnExportTable = { columns: result.columns, rows: result.rows };
2653
+ }
2654
+ const visualization = createTabularVisualization(requestText, result);
2655
+ if (visualization && plan.chartTitle) visualization.title = plan.chartTitle;
2656
+ return visualization ? { ...result, visualization } : result;
2657
+ } catch (err: any) {
2658
+ return {
2659
+ columns: [],
2660
+ rows: [],
2661
+ totalRows: 0,
2662
+ error: err.message ?? 'Query failed',
2663
+ };
2664
+ }
2665
+ },
2666
+ }),
2667
+
2668
+ createDataExport: tool({
2669
+ description:
2670
+ 'Create a real downloadable Excel, CSV, or PDF file from answer data already established in this conversation. Use this whenever the user asks to export, save, download, or convert an answer/result to one of these formats. Never write a Markdown download link. Omit columns and rows whenever a data query ran in this turn or the request is a direct follow-up: the host reuses the complete authoritative result. Supply rows only when no structured data result exists.',
2671
+ inputSchema: z.object({
2672
+ format: z.enum(['xlsx', 'csv', 'pdf']).describe('The requested file format.'),
2673
+ title: z.string().max(100).optional().describe('A short human-readable file title.'),
2674
+ fileName: z
2675
+ .string()
2676
+ .max(100)
2677
+ .optional()
2678
+ .describe('Optional file name without relying on a Markdown link.'),
2679
+ columns: z
2680
+ .array(z.string())
2681
+ .max(50)
2682
+ .optional()
2683
+ .describe('Columns from a result obtained in this same turn.'),
2684
+ rows: z
2685
+ .array(z.looseObject({}))
2686
+ .max(500)
2687
+ .optional()
2688
+ .describe('Rows from a result obtained in this same turn.'),
2689
+ }),
2690
+ execute: async ({ format, title, fileName, columns, rows }) => {
2691
+ try {
2692
+ const source = selectDataExportSource({
2693
+ currentTurnTable: currentTurnExportTable,
2694
+ explicitTable: columns?.length && rows?.length ? { columns, rows } : undefined,
2695
+ recentTable: recentExportTable,
2696
+ recentAnswerText,
2697
+ });
2698
+ if (!source) throw new Error('There is no completed answer available to export yet.');
2699
+ return await createDataExport({
2700
+ format,
2701
+ title: title || 'Larkup answer',
2702
+ fileName,
2703
+ columns: source.columns,
2704
+ rows: source.rows,
2705
+ });
2706
+ } catch (error) {
2707
+ return {
2708
+ success: false,
2709
+ error: error instanceof Error ? error.message : 'The export could not be created.',
2710
+ };
2711
+ }
2712
+ },
2713
+ }),
2714
+
2715
+ generateVisualization: tool({
2716
+ description:
2717
+ 'Generate an interactive chart visualization only from verified tabular rows already returned by a data tool in this turn or shown in the immediately preceding table. Copy those exact rows to data; charts without matching table evidence are rejected. xAxisKey and every series.dataKey must exactly match fields in those rows; never use placeholder names such as EMPTY, null, or undefined. Supply clear human-readable xAxisLabel and yAxisLabel values, including the unit when known. The UI renders the result automatically, so after calling it only summarize the finding in prose.',
2718
+ inputSchema: z.object({
2719
+ chartType: z
2720
+ .enum(['bar', 'area', 'line', 'pie', 'scatter', 'radar'])
2721
+ .describe('The type of chart to create.'),
2722
+ title: z.string().describe('Chart title.'),
2723
+ subtitle: z.string().optional().describe('Chart subtitle for additional context.'),
2724
+ data: z
2725
+ .array(z.looseObject({}))
2726
+ .describe(
2727
+ "Array of data points to plot. You MUST provide the data here. Example: [{'name': 'North', 'value': 3206}, {'name': 'East', 'value': 2492}]",
2728
+ ),
2729
+ xAxisKey: z
2730
+ .string()
2731
+ .describe("The key in data objects to use for the X axis (usually 'name')."),
2732
+ series: z
2733
+ .array(
2734
+ z.object({
2735
+ dataKey: z.string().describe('The key in data objects for this series values.'),
2736
+ label: z.string().optional().describe('Display label for this series in the legend.'),
2737
+ color: z
2738
+ .string()
2739
+ .optional()
2740
+ .describe('Color for this series (hex). If omitted, theme colors are used.'),
2741
+ }),
2742
+ )
2743
+ .describe('Data series to plot.'),
2744
+ colors: z
2745
+ .array(z.string())
2746
+ .optional()
2747
+ .describe(
2748
+ 'Array of hex colors to use for the chart palette. The AI should decide and pick elegant, classy colors that contrast beautifully against a soft, muted background. Make it dynamic and appropriate for the data.',
2749
+ ),
2750
+ stacked: z.boolean().optional().describe('Whether to stack bar/area charts.'),
2751
+ showLegend: z.boolean().optional().default(true).describe('Whether to show the legend.'),
2752
+ xAxisLabel: z
2753
+ .string()
2754
+ .optional()
2755
+ .describe('Human-readable X-axis name, including its unit when relevant.'),
2756
+ yAxisLabel: z
2757
+ .string()
2758
+ .optional()
2759
+ .describe('Human-readable Y-axis name, including its unit when relevant.'),
2760
+ }),
2761
+ execute: async (config) => {
2762
+ // Chart arguments are model-authored. Require every plotted value to
2763
+ // match authoritative table rows so unrelated image/text retrieval can
2764
+ // never be turned into a plausible-looking invented chart.
2765
+ return normalizeChartConfigWithEvidence(
2766
+ config,
2767
+ currentTurnExportTable?.rows ?? recentVisualizationTable?.rows,
2768
+ );
2769
+ },
2770
+ }),
2771
+
2772
+ executeAnalysis: tool({
2773
+ description:
2774
+ 'Execute Python code in a secure sandbox for deep data analysis. Use this for complex statistical computations (correlations, regressions, clustering), data transformations, or when creating custom matplotlib visualizations. The code has access to pandas, numpy, matplotlib, scipy, scikit-learn, and seaborn. Always print results to stdout and use plt.show() for charts. The sandbox already configures headless plotting and its cache directories: do not set MPLBACKEND, MPLCONFIGDIR, or XDG_CACHE_HOME in analysis code.',
2775
+ inputSchema: z.object({
2776
+ code: z
2777
+ .string()
2778
+ .describe(
2779
+ 'Python code to execute. Has pandas (2.0+), numpy, matplotlib, scipy, sklearn, seaborn available. ALWAYS use "ME" instead of "M" for resample frequency. Print results and use plt.show() for charts. Plot configuration is already prepared by the sandbox; do not set MPLBACKEND, MPLCONFIGDIR, or XDG_CACHE_HOME.',
2780
+ ),
2781
+ datasetId: z
2782
+ .string()
2783
+ .optional()
2784
+ .describe(
2785
+ "For one dataset, its CSV is available as 'data.csv' in the working directory.",
2786
+ ),
2787
+ datasetIds: z
2788
+ .array(z.string())
2789
+ .min(1)
2790
+ .optional()
2791
+ .describe(
2792
+ "For a correlation, comparison, or join across related files or worksheets, provide every required dataset ID. Their CSV filenames and schemas are listed in 'datasets.json' in the working directory.",
2793
+ ),
2794
+ pdfDocumentId: z
2795
+ .string()
2796
+ .optional()
2797
+ .describe(
2798
+ "Optional exact PDF documentId. Its original local file will be mounted as 'source.pdf' for pypdf/Pillow analysis.",
2799
+ ),
2800
+ }),
2801
+ execute: async ({ code, datasetId, datasetIds, pdfDocumentId }) => {
2802
+ try {
2803
+ const sandboxManager = workspaceSandbox(resolveSandboxConfig(config));
2804
+ const files: SandboxFile[] = [];
2805
+
2806
+ const requestedDatasetIds = [
2807
+ ...new Set([...(datasetIds ?? []), ...(datasetId ? [datasetId] : [])]),
2808
+ ];
2809
+ const availableDatasets = await readAssistantTabularDatasets();
2810
+ // Some providers emit valid analysis code but omit an optional
2811
+ // datasetId. When the request maps unambiguously to one indexed
2812
+ // schema, stage it automatically so `data.csv` remains a reliable
2813
+ // single-sheet contract. Ambiguous workbooks still require the
2814
+ // model to make an explicit selection.
2815
+ if (requestedDatasetIds.length === 0) {
2816
+ const automaticDatasetId = selectTabularDatasetForQuestion(
2817
+ requestText,
2818
+ availableDatasets,
2819
+ );
2820
+ if (automaticDatasetId) requestedDatasetIds.push(automaticDatasetId);
2821
+ }
2822
+ if (requestedDatasetIds.length > 0) {
2823
+ const availableDatasetIds = new Set(availableDatasets.map((dataset) => dataset.id));
2824
+ const unavailableDatasetId = requestedDatasetIds.find(
2825
+ (requestedId) => !availableDatasetIds.has(requestedId),
2826
+ );
2827
+ if (unavailableDatasetId) {
2828
+ throw new Error(`Tabular dataset is not available: ${unavailableDatasetId}`);
2829
+ }
2830
+ const datasets = await inActiveProject(() =>
2831
+ Promise.all(requestedDatasetIds.map(getTabularDataset)),
2832
+ );
2833
+ const missingDatasetId = requestedDatasetIds.find((_, index) => !datasets[index]);
2834
+ if (missingDatasetId) {
2835
+ throw new Error(`Tabular dataset is not available: ${missingDatasetId}`);
2836
+ }
2837
+ files.push(
2838
+ ...createTabularSandboxFiles(datasets as NonNullable<(typeof datasets)[number]>[])
2839
+ .files,
2840
+ );
2841
+ }
2842
+
2843
+ if (pdfDocumentId) {
2844
+ const document = await scopedDocument(pdfDocumentId);
2845
+ if (!document) throw new Error('That PDF document is not available.');
2846
+ const bytes = await readStoredPdfBytes(document);
2847
+ files.push({ name: 'source.pdf', content: bytes.toString('base64'), isBase64: true });
2848
+ }
2849
+
2850
+ let finalCode = code;
2851
+ // Clean up any markdown blocks LLMs might mistakenly wrap the code in
2852
+ finalCode = finalCode.replace(/^```python\s*\n?/m, '');
2853
+ finalCode = finalCode.replace(/^```\s*\n?/m, '');
2854
+ finalCode = finalCode.replace(/```\s*$/m, '');
2855
+ // Generated notebook snippets occasionally use `os` only for
2856
+ // plotting-cache setup. The execution wrapper already configures
2857
+ // those paths, but importing os here makes such harmless snippets
2858
+ // robust without changing the requested analysis.
2859
+ if (
2860
+ /\bos\.(?:environ|makedirs|path)\b/.test(finalCode) &&
2861
+ !/^\s*(?:import|from)\s+os\b/m.test(finalCode)
2862
+ ) {
2863
+ finalCode = `import os\n${finalCode}`;
2864
+ }
2865
+
2866
+ let result = await sandboxManager.execute({
2867
+ code: finalCode,
2868
+ language: 'python',
2869
+ files,
2870
+ timeout: 30_000,
2871
+ });
2872
+
2873
+ // Auto-retry once if code crashes
2874
+ if (result.exitCode !== 0) {
2875
+ if (
2876
+ requestedDatasetIds.length > 1 &&
2877
+ /(?:no such file|data\.csv)/i.test(result.stderr)
2878
+ ) {
2879
+ finalCode = remapLegacyMultiDatasetReads(finalCode, requestedDatasetIds.length);
2880
+ }
2881
+ // Apply regex patches for common LLM mistakes
2882
+ finalCode = finalCode.replace(/resample\((['"])M(['"])\)/g, 'resample($1ME$2)');
2883
+ finalCode = finalCode.replace(/resample\((['"])Q(['"])\)/g, 'resample($1QE$2)');
2884
+ finalCode = finalCode.replace(/resample\((['"])Y(['"])\)/g, 'resample($1YE$2)');
2885
+ finalCode = finalCode.replace(/freq=(['"])M(['"])/g, 'freq=$1ME$2');
2886
+
2887
+ result = await sandboxManager.execute({
2888
+ code: finalCode,
2889
+ language: 'python',
2890
+ files,
2891
+ timeout: 30_000,
2892
+ });
2893
+ }
2894
+
2895
+ return {
2896
+ stdout: result.stdout.slice(0, 5000),
2897
+ stderr: result.stderr.slice(0, 2000),
2898
+ exitCode: result.exitCode,
2899
+ artifacts: result.artifacts.map((a) => ({
2900
+ name: a.name,
2901
+ mimeType: a.mimeType,
2902
+ data: a.data.slice(0, 500_000), // cap at around 375KB base64
2903
+ })),
2904
+ executionTimeMs: result.executionTimeMs,
2905
+ };
2906
+ } catch (err: any) {
2907
+ return {
2908
+ stdout: '',
2909
+ stderr: err.message ?? 'Sandbox execution failed',
2910
+ exitCode: 1,
2911
+ artifacts: [],
2912
+ executionTimeMs: 0,
2913
+ };
2914
+ }
2915
+ },
2916
+ }),
2917
+
2918
+ analyzeImageDeeply: tool({
2919
+ description:
2920
+ 'Use this tool when you need to answer a detailed question about an image (like a diagram, chart, or schema) found in the knowledge base. Pass the exact imageUrl found in the document metadata, along with specific instructions on what you need to extract from it.',
2921
+ inputSchema: z.object({
2922
+ imageUrl: z
2923
+ .string()
2924
+ .describe('The URL of the image to analyze (from metadata.images.imageUrl)'),
2925
+ prompt: z
2926
+ .string()
2927
+ .describe(
2928
+ 'Specific instructions on what to extract or analyze from this image. Be as detailed as possible to get the best result.',
2929
+ ),
2930
+ }),
2931
+ execute: async ({ imageUrl, prompt }) => {
2932
+ const analyzeIndexedImage = async () => {
2933
+ try {
2934
+ // A model may only inspect an image that is actually part of the
2935
+ // active corpus. This prevents guessed URLs from becoming a chat
2936
+ // preview or an image-analysis request.
2937
+ const { documents } = await readAssistantSourceScope();
2938
+ const isIndexedImage = documents.some(
2939
+ (document) =>
2940
+ document.status === 'indexed' &&
2941
+ (document.metadata?.imageUrl === imageUrl ||
2942
+ (Array.isArray(document.metadata?.images) &&
2943
+ document.metadata.images.some((image: any) => image?.imageUrl === imageUrl))),
2944
+ );
2945
+ if (!isIndexedImage) {
2946
+ return { error: 'That image is not available in the indexed material.' };
2947
+ }
2948
+
2949
+ const cached = await getCachedImageAnalysis(imageUrl, prompt);
2950
+ if (cached) return { analysis: cached, cached: true };
2951
+
2952
+ const fetchUrl = imageUrl.startsWith('/')
2953
+ ? new URL(
2954
+ imageUrl,
2955
+ context.origin ?? `http://127.0.0.1:${process.env.PORT || 3000}`,
2956
+ ).toString()
2957
+ : imageUrl;
2958
+ const res = await fetch(fetchUrl);
2959
+ if (!res.ok) throw new Error(`Failed to fetch image: ${res.statusText}`);
2960
+ const base64 = Buffer.from(await res.arrayBuffer()).toString('base64');
2961
+
2962
+ const descRes = await fetch(
2963
+ new URL(
2964
+ workspaceServerUrl('/api/describe-image'),
2965
+ context.origin ?? `http://127.0.0.1:${process.env.PORT || 3000}`,
2966
+ ).toString(),
2967
+ {
2968
+ method: 'POST',
2969
+ headers: { 'Content-Type': 'application/json' },
2970
+ body: JSON.stringify({ base64, prompt }),
2971
+ },
2972
+ );
2973
+
2974
+ if (!descRes.ok) throw new Error(`Vision API error: ${descRes.statusText}`);
2975
+ const data = await descRes.json();
2976
+ await cacheImageAnalysis(imageUrl, prompt, data.description);
2977
+ return { analysis: data.description, cached: false };
2978
+ } catch (err: any) {
2979
+ return { error: `Failed to analyze image: ${err.message}` };
2980
+ }
2981
+ };
2982
+
2983
+ return projectId ? runWithProject(projectId, analyzeIndexedImage) : analyzeIndexedImage();
2984
+ },
2985
+ }),
2986
+
2987
+ getIndexedData: tool({
2988
+ description:
2989
+ 'Get structured access to ALL indexed source documents. Use this for counting, listing, filtering, and overview questions about the knowledge base. Returns document metadata (title, source type, status, custom metadata fields, dates). Use this when the user asks "how many documents?", "list all X", "show progress", "what sources?", or any structural question about the corpus.',
2990
+ inputSchema: z.object({
2991
+ filter: z
2992
+ .object({
2993
+ source: z
2994
+ .enum(['text', 'files', 'website', 'media', 'integrations'])
2995
+ .optional()
2996
+ .describe('Filter by document source type.'),
2997
+ status: z
2998
+ .enum(['indexed', 'unindexed'])
2999
+ .optional()
3000
+ .describe('Filter by indexing status.'),
3001
+ metadataKey: z.string().optional().describe('Filter by metadata key existence.'),
3002
+ metadataValue: z
3003
+ .string()
3004
+ .optional()
3005
+ .describe('Filter by metadata key=value (requires metadataKey).'),
3006
+ titleContains: z.string().optional().describe('Case-insensitive text search in title.'),
3007
+ createdAfter: z
3008
+ .string()
3009
+ .optional()
3010
+ .describe('Only docs created on or after this ISO date.'),
3011
+ createdBefore: z
3012
+ .string()
3013
+ .optional()
3014
+ .describe('Only docs created on or before this ISO date.'),
3015
+ })
3016
+ .optional()
3017
+ .describe('Optional filters to narrow down documents.'),
3018
+ limit: z
3019
+ .number()
3020
+ .optional()
3021
+ .default(200)
3022
+ .describe('Max number of document summaries to return (default 200).'),
3023
+ offset: z.number().optional().default(0).describe('Pagination offset.'),
3024
+ includeContent: z
3025
+ .boolean()
3026
+ .optional()
3027
+ .default(false)
3028
+ .describe(
3029
+ 'Whether to include truncated document content. Default false for performance. Set true only when you need the actual text.',
3030
+ ),
3031
+ }),
3032
+ execute: async ({ filter, limit, offset, includeContent }) => {
3033
+ try {
3034
+ const maxLimit = includeContent ? 20 : 100;
3035
+ const actualLimit = Math.min(limit ?? (includeContent ? 20 : 100), maxLimit);
3036
+ const { documents } = await readAssistantSourceScope();
3037
+
3038
+ return await getCorpusDocuments(
3039
+ {
3040
+ ...(filter as CorpusFilter | undefined),
3041
+ documentIds: documents.map((document) => document.id),
3042
+ },
3043
+ actualLimit,
3044
+ offset ?? 0,
3045
+ includeContent ?? false,
3046
+ );
3047
+ } catch (err: any) {
3048
+ return {
3049
+ documents: [],
3050
+ total: 0,
3051
+ summary: {
3052
+ totalDocuments: 0,
3053
+ bySource: {},
3054
+ byStatus: {},
3055
+ topMetadataKeys: [],
3056
+ totalCharacters: 0,
3057
+ dateRange: null,
3058
+ },
3059
+ error: err.message ?? 'Failed to retrieve corpus data',
3060
+ };
3061
+ }
3062
+ },
3063
+ }),
3064
+
3065
+ analyzeCorpusWithCode: tool({
3066
+ description:
3067
+ 'Run Python code in a sandbox with the FULL indexed corpus available as a file. Use this for complex analysis of documents: parsing fields from content, computing progress percentages, grouping by patterns, detecting duplicates, generating charts from corpus data, or any question that requires programmatic processing of hundreds/thousands of documents. The corpus is available as "corpus.csv" (or "corpus.jsonl" if format is jsonl). CSV columns: id, title, source, url, charCount, status, createdAt, content, plus any metadata_* columns.',
3068
+ inputSchema: z.object({
3069
+ code: z
3070
+ .string()
3071
+ .describe(
3072
+ 'Python code to execute. The corpus file is in the working directory. Use pandas to load it: df = pd.read_csv("corpus.csv") or for JSONL: df = pd.read_json("corpus.jsonl", lines=True). Has pandas (2.0+), numpy, matplotlib, scipy, sklearn, seaborn available. ALWAYS use "ME" instead of "M" for resample frequency. Print results and use plt.show() for charts.',
3073
+ ),
3074
+ format: z
3075
+ .enum(['csv', 'jsonl'])
3076
+ .optional()
3077
+ .default('csv')
3078
+ .describe('Format of the corpus file. Default is CSV.'),
3079
+ }),
3080
+ execute: async ({ code, format }) => {
3081
+ try {
3082
+ const sandboxManager = workspaceSandbox(resolveSandboxConfig(config));
3083
+ const { documents } = await readAssistantSourceScope();
3084
+ const assistantCorpusFilter: CorpusFilter = {
3085
+ documentIds: documents.map((document) => document.id),
3086
+ };
3087
+
3088
+ // Export corpus in the requested format
3089
+ const corpusData =
3090
+ format === 'jsonl'
3091
+ ? await exportCorpusAsJSONL(assistantCorpusFilter)
3092
+ : await exportCorpusAsCSV(assistantCorpusFilter);
3093
+
3094
+ if (!corpusData) {
3095
+ return {
3096
+ stdout: 'No documents found in the corpus.',
3097
+ stderr: '',
3098
+ exitCode: 0,
3099
+ artifacts: [],
3100
+ executionTimeMs: 0,
3101
+ };
3102
+ }
3103
+
3104
+ const fileName = format === 'jsonl' ? 'corpus.jsonl' : 'corpus.csv';
3105
+ const files = [{ name: fileName, content: corpusData }];
3106
+
3107
+ let finalCode = code;
3108
+ let result = await sandboxManager.execute({
3109
+ code: finalCode,
3110
+ language: 'python',
3111
+ files,
3112
+ timeout: 30_000,
3113
+ });
3114
+
3115
+ // Auto-retry once if code crashes
3116
+ if (result.exitCode !== 0) {
3117
+ // Apply regex patches for common LLM mistakes
3118
+ finalCode = finalCode.replace(/resample\((['"])M(['"])\)/g, 'resample($1ME$2)');
3119
+ finalCode = finalCode.replace(/resample\((['"])Q(['"])\)/g, 'resample($1QE$2)');
3120
+ finalCode = finalCode.replace(/resample\((['"])Y(['"])\)/g, 'resample($1YE$2)');
3121
+ finalCode = finalCode.replace(/freq=(['"])M(['"])/g, 'freq=$1ME$2');
3122
+
3123
+ result = await sandboxManager.execute({
3124
+ code: finalCode,
3125
+ language: 'python',
3126
+ files,
3127
+ timeout: 30_000,
3128
+ });
3129
+ }
3130
+
3131
+ return {
3132
+ stdout: result.stdout.slice(0, 5000),
3133
+ stderr: result.stderr.slice(0, 2000),
3134
+ exitCode: result.exitCode,
3135
+ artifacts: result.artifacts.map((a) => ({
3136
+ name: a.name,
3137
+ mimeType: a.mimeType,
3138
+ data: a.data.slice(0, 500_000),
3139
+ })),
3140
+ executionTimeMs: result.executionTimeMs,
3141
+ };
3142
+ } catch (err: any) {
3143
+ return {
3144
+ stdout: '',
3145
+ stderr: err.message ?? 'Corpus analysis failed',
3146
+ exitCode: 1,
3147
+ artifacts: [],
3148
+ executionTimeMs: 0,
3149
+ };
3150
+ }
3151
+ },
3152
+ }),
3153
+
3154
+ fillDocumentForm: tool({
3155
+ description:
3156
+ 'Fill form fields, text inputs, or checkboxes in the currently active document. Use this when the user asks to "fill the form", "add data to fields", or "fill with dummy data". You should use the searchKnowledgeBase tool to find answers to form questions if you do not know them. The available fields are provided in your system prompt.',
3157
+ inputSchema: z.object({
3158
+ edits: z
3159
+ .array(
3160
+ z.object({
3161
+ fieldId: z
3162
+ .string()
3163
+ .describe('The ID of the field to fill (from the active document context)'),
3164
+ value: z.string().describe('The value to set for the field'),
3165
+ }),
3166
+ )
3167
+ .describe('List of fields to fill and their new values'),
3168
+ }),
3169
+ execute: async ({ edits }) => {
3170
+ if (!docSessionId)
3171
+ return {
3172
+ success: false,
3173
+ error:
3174
+ 'Document not loaded yet. Please wait a moment or ask the user to click the document to open it in the canvas before filling it.',
3175
+ };
3176
+
3177
+ try {
3178
+ const fieldEdits = edits.map((edit) => ({
3179
+ ...edit,
3180
+ type: 'fill' as const,
3181
+ }));
3182
+ const result = await applyFieldEdits(docSessionId, fieldEdits);
3183
+ if (!result.success) {
3184
+ return { success: false, error: result.error };
3185
+ }
3186
+
3187
+ return {
3188
+ success: true,
3189
+ action: 'fill_document',
3190
+ sessionId: docSessionId,
3191
+ edits,
3192
+ updatedFields: result.updatedFields,
3193
+ fileName: result.parsed.fileName,
3194
+ mimeType: result.parsed.mimeType,
3195
+ fields: result.parsed.fields,
3196
+ pages: result.parsed.pages.map((p) => ({
3197
+ index: p.index,
3198
+ text: p.text.slice(0, 2000),
3199
+ html: p.html,
3200
+ fields: p.fields,
3201
+ dimensions: p.dimensions,
3202
+ })),
3203
+ totalPages: result.parsed.totalPages,
3204
+ fileBase64: result.fileBase64,
3205
+ };
3206
+ } catch (err: any) {
3207
+ return { success: false, error: err.message };
3208
+ }
3209
+ },
3210
+ toModelOutput: documentEditModelOutput,
3211
+ }),
3212
+
3213
+ editDocument: tool({
3214
+ description:
3215
+ 'Edit the content (text) of the currently active document. Use this when the user asks to modify paragraphs, rewrite text, or change slides (not form fields).',
3216
+ inputSchema: z.object({
3217
+ edits: z
3218
+ .array(
3219
+ z.object({
3220
+ pageIndex: z.number().default(0).describe('Page or slide index (0-based)'),
3221
+ type: z.enum(['replace_text', 'insert_text']).describe('Type of edit'),
3222
+ search: z.string().optional().describe('Text to search for (if replacing)'),
3223
+ text: z.string().describe('Replacement text or text to insert'),
3224
+ }),
3225
+ )
3226
+ .describe('List of content edits to apply'),
3227
+ }),
3228
+ execute: async ({ edits }) => {
3229
+ if (!docSessionId)
3230
+ return {
3231
+ success: false,
3232
+ error:
3233
+ 'Document not loaded yet. Please wait a moment or ask the user to click the document to open it in the canvas before editing it.',
3234
+ };
3235
+
3236
+ try {
3237
+ const result = await applyContentEdits(docSessionId, edits);
3238
+ if (!result.success) {
3239
+ return { success: false, error: result.error };
3240
+ }
3241
+
3242
+ return {
3243
+ success: true,
3244
+ action: 'edit_document',
3245
+ sessionId: docSessionId,
3246
+ edits,
3247
+ updatedFields: result.updatedFields,
3248
+ fileName: result.parsed.fileName,
3249
+ mimeType: result.parsed.mimeType,
3250
+ pages: result.parsed.pages.map((p) => ({
3251
+ index: p.index,
3252
+ text: p.text.slice(0, 2000),
3253
+ html: p.html,
3254
+ fields: p.fields,
3255
+ dimensions: p.dimensions,
3256
+ })),
3257
+ totalPages: result.parsed.totalPages,
3258
+ fileBase64: result.fileBase64,
3259
+ };
3260
+ } catch (err: any) {
3261
+ return { success: false, error: err.message };
3262
+ }
3263
+ },
3264
+ toModelOutput: documentEditModelOutput,
3265
+ }),
3266
+
3267
+ requestDocumentSignature: tool({
3268
+ description:
3269
+ 'STRICTLY ONLY use this when the user explicitly asks to "sign" the currently active document or asks for a "signature". CRITICAL: Do NOT use this tool for filling forms, answering questions, or adding dummy data. CRITICAL: NEVER call this tool more than once per user request. Yields a UI widget for the user to confirm the placement and provide their signature.',
3270
+ inputSchema: z.object({
3271
+ detectedLocations: z
3272
+ .array(
3273
+ z.object({
3274
+ pageIndex: z.number(),
3275
+ context: z.string(),
3276
+ }),
3277
+ )
3278
+ .optional()
3279
+ .describe('Optional. The detected signature locations in the document.'),
3280
+ }),
3281
+ }),
3282
+
3283
+ webSearch: tool({
3284
+ description:
3285
+ 'Search the public internet for current information, news, or facts. Use it first for clearly current/public requests, or once after an irrelevant local search. If it fails or returns no useful results, searchKnowledgeBase is available once as a fallback. Never repeat either search tool in the same turn.',
3286
+ inputSchema: z.object({
3287
+ query: z.string().describe('The search query.'),
3288
+ }),
3289
+ execute: async ({ query }) => {
3290
+ const knowledgeBaseFallback = async (error: string) => {
3291
+ try {
3292
+ const knowledge = await queryKnowledgeBase(query, 5, projectId ?? null);
3293
+ return {
3294
+ error,
3295
+ results: [],
3296
+ fallback: {
3297
+ source: 'knowledge-base',
3298
+ attemptedBecause: 'Public web search failed or returned no results.',
3299
+ ...knowledge,
3300
+ },
3301
+ };
3302
+ } catch (fallbackError: any) {
3303
+ return {
3304
+ error,
3305
+ results: [],
3306
+ fallback: {
3307
+ source: 'knowledge-base',
3308
+ error: fallbackError?.message || 'Knowledge-base fallback failed.',
3309
+ hits: [],
3310
+ },
3311
+ };
3312
+ }
3313
+ };
3314
+ const provider = config?.webSearchProvider || 'tavily';
3315
+ const req = new Request('http://localhost/api/search', {
3316
+ method: 'POST',
3317
+ body: JSON.stringify({ query }),
3318
+ });
3319
+
3320
+ let res: any;
3321
+ try {
3322
+ switch (provider) {
3323
+ case 'google':
3324
+ case 'serper':
3325
+ const { POST: searchGoogle } = await import('../search/google/route');
3326
+ res = await searchGoogle(req);
3327
+ break;
3328
+ case 'tavily':
3329
+ const { POST: searchTavily } = await import('../search/tavily/route');
3330
+ res = await searchTavily(req);
3331
+ break;
3332
+ case 'brave':
3333
+ const { POST: searchBrave } = await import('../search/brave/route');
3334
+ res = await searchBrave(req);
3335
+ break;
3336
+ case 'bing':
3337
+ const { POST: searchBing } = await import('../search/bing/route');
3338
+ res = await searchBing(req);
3339
+ break;
3340
+ case 'exa':
3341
+ const { POST: searchExa } = await import('../search/exa/route');
3342
+ res = await searchExa(req);
3343
+ break;
3344
+ case 'local':
3345
+ default:
3346
+ const { POST: searchLocal } = await import('../search/route');
3347
+ res = await searchLocal(req);
3348
+ break;
3349
+ }
3350
+
3351
+ if (res.status >= 400) {
3352
+ const err = await res.json();
3353
+ return knowledgeBaseFallback(err.error || 'Search failed');
3354
+ }
3355
+
3356
+ const data = await res.json();
3357
+ const items = data.items || data.results || [];
3358
+ if (items.length === 0) {
3359
+ return knowledgeBaseFallback('Public search returned no results.');
3360
+ }
3361
+ return {
3362
+ results: items.slice(0, 10).map((i: any) => ({
3363
+ title: i.title,
3364
+ url: i.url,
3365
+ snippet: i.description || i.snippet || '',
3366
+ })),
3367
+ };
3368
+ } catch (e: any) {
3369
+ return knowledgeBaseFallback(e.message || 'Search failed');
3370
+ }
3371
+ },
3372
+ }),
3373
+ };
3374
+
3375
+ const enabledTools = config?.enabledTools || [];
3376
+ const finalTools: Record<string, any> = {};
3377
+
3378
+ // Core chat capabilities below remain available by their generic category.
3379
+ // Optional first-party actions must be explicitly enabled; installed
3380
+ // Marketplace tools retain the historical "all when unset" behavior.
3381
+ const isBuiltInEnabled = (id: string) => enabledTools.includes(id);
3382
+ const isMarketplaceEnabled = (id: string) =>
3383
+ enabledTools.length === 0 || enabledTools.includes(id);
3384
+
3385
+ // 1. Add enabled built-in tools
3386
+ for (const [id, toolDef] of Object.entries(builtInTools)) {
3387
+ if (id === 'webSearch') continue; // Handled separately
3388
+ if ((id === 'executeAnalysis' || id === 'analyzeCorpusWithCode') && !sandboxReady) continue;
3389
+
3390
+ if (
3391
+ isBuiltInEnabled(id) ||
3392
+ [
3393
+ // Retrieval is core chat behavior, not an optional marketplace action.
3394
+ // Keeping it available prevents an enabled web-search configuration
3395
+ // from silently removing the user's local knowledge base.
3396
+ 'searchKnowledgeBase',
3397
+ // Data analysis and visualizations are part of the first-party chat
3398
+ // experience. Keep them available even when a legacy enabled-tools
3399
+ // configuration only lists marketplace tools.
3400
+ 'queryTabularData',
3401
+ 'createDataExport',
3402
+ 'generateVisualization',
3403
+ 'executeAnalysis',
3404
+ // PDF/image hits can require one visual pass after retrieval. This is
3405
+ // available only to a follow-up tool step, never auto-run on chat.
3406
+ 'inspectPdfPages',
3407
+ 'analyzePdfPages',
3408
+ 'analyzeImageDeeply',
3409
+ 'presentMedia',
3410
+ 'fillDocumentForm',
3411
+ 'editDocument',
3412
+ 'requestDocumentSignature',
3413
+ ].includes(id)
3414
+ ) {
3415
+ finalTools[id] = toolDef;
3416
+ }
3417
+ }
3418
+
3419
+ // Add webSearch tool if explicitly enabled globally
3420
+ if (config?.webSearchEnabled === true) {
3421
+ finalTools['webSearch'] = builtInTools['webSearch'];
3422
+ }
3423
+
3424
+ // 2. Add enabled marketplace tools, generically — for ANY tool implementing
3425
+ // the ToolExtension v1 + AGENT_TOOL(S) contract (packages/marketplace's
3426
+ // `defineAgentTool`), a new tool becomes chat-callable purely by publishing
3427
+ // a manifest and an AGENT_TOOL export. Nothing here may name a specific
3428
+ // tool's id, schema, or behavior.
3429
+ const dynamicToolNames: string[] = [];
3430
+ const dynamicToolWorkflows: Record<string, NonNullable<AgentToolDefinition['workflow']>> = {};
3431
+ const dynamicToolEvidenceInputs: Record<
3432
+ string,
3433
+ NonNullable<AgentToolDefinition['evidenceInput']>
3434
+ > = {};
3435
+ const promptFragments: string[] = [];
3436
+
3437
+ try {
3438
+ const hostedTools = workspaceHost()?.tools;
3439
+ const installed = hostedTools ?? (workspaceHost() ? [] : await getInstalledTools());
3440
+ for (const t of installed) {
3441
+ if (!isMarketplaceEnabled(t.id)) continue;
3442
+ const hosted = hostedTools?.find((candidate) => candidate.id === t.id);
3443
+ const mod = hosted?.module ?? (await loadTool<Record<string, unknown>>(t.id));
3444
+ if (!mod) continue;
3445
+
3446
+ const extension = (mod.TOOL_EXTENSION ?? mod.default) as
3447
+ { id?: string; apiVersion?: string; createClient?: (ctx: unknown) => unknown } | undefined;
3448
+ const agentDefs: AgentToolDefinition[] = Array.isArray(mod.AGENT_TOOLS)
3449
+ ? (mod.AGENT_TOOLS as AgentToolDefinition[])
3450
+ : mod.AGENT_TOOL
3451
+ ? [mod.AGENT_TOOL as AgentToolDefinition]
3452
+ : [];
3453
+
3454
+ if (
3455
+ extension?.apiVersion === '1' &&
3456
+ typeof extension.createClient === 'function' &&
3457
+ agentDefs.length > 0
3458
+ ) {
3459
+ const toolContext = {
3460
+ config: {
3461
+ ...(t.config ?? {}),
3462
+ ...(activeProjectConfig?.toolConfigs?.[t.id] ?? {}),
3463
+ },
3464
+ fetch: hosted
3465
+ ? (input: RequestInfo | URL, init?: RequestInit) => {
3466
+ const raw =
3467
+ typeof input === 'string'
3468
+ ? input
3469
+ : input instanceof URL
3470
+ ? input.toString()
3471
+ : input.url;
3472
+ const source = new URL(raw, origin ?? 'http://workspace.internal');
3473
+ const routed = workspaceServerUrl(`${source.pathname}${source.search}`);
3474
+ return fetch(new URL(routed, source.origin), init);
3475
+ }
3476
+ : globalThis.fetch,
3477
+ trackUsage: (event: { meter: string; quantity: number; unit: string }) =>
3478
+ trackEnterpriseToolUsage(config, t.id, event),
3479
+ };
3480
+ const client = extension.createClient(toolContext) as Record<string, AgentToolHandler>;
3481
+ for (const def of agentDefs) {
3482
+ if (!isValidAgentToolDefinition(def) || typeof client[def.method] !== 'function') {
3483
+ console.warn(`[marketplace] Skipping invalid agent action from "${t.id}".`);
3484
+ continue;
3485
+ }
3486
+ // Marketplace packages cannot replace first-party tools or each
3487
+ // other. A collision is a package error, not an override mechanism.
3488
+ if (finalTools[def.name]) {
3489
+ console.warn(
3490
+ `[marketplace] Skipping colliding agent action "${def.name}" from "${t.id}".`,
3491
+ );
3492
+ continue;
3493
+ }
3494
+ finalTools[def.name] = tool({
3495
+ description: def.description,
3496
+ inputSchema: jsonSchema(def.parameters as Parameters<typeof jsonSchema>[0]),
3497
+ execute: (input: unknown, options: { toolCallId: string }) =>
3498
+ client[def.method](input, {
3499
+ origin,
3500
+ projectId,
3501
+ toolCallId: options.toolCallId,
3502
+ mediaEvidence,
3503
+ } satisfies AgentToolExecutionContext),
3504
+ });
3505
+ dynamicToolNames.push(def.name);
3506
+ if (def.workflow) dynamicToolWorkflows[def.name] = def.workflow;
3507
+ if (def.evidenceInput) dynamicToolEvidenceInputs[def.name] = def.evidenceInput;
3508
+ if (def.systemPromptFragment) promptFragments.push(def.systemPromptFragment);
3509
+ }
3510
+ continue;
3511
+ }
3512
+
3513
+ // Legacy shapes some marketplace tools still export directly.
3514
+ if (typeof mod.default === 'function') {
3515
+ finalTools[t.id] = (mod.default as (ctx: unknown) => unknown)(context);
3516
+ dynamicToolNames.push(t.id);
3517
+ } else if (typeof mod.tool === 'function') {
3518
+ finalTools[t.id] = (mod.tool as (ctx: unknown) => unknown)(context);
3519
+ dynamicToolNames.push(t.id);
3520
+ }
3521
+ }
3522
+ } catch (err) {
3523
+ console.error('[marketplace] Failed to load marketplace tools for chat:', err);
3524
+ }
3525
+
3526
+ // Enterprise tools never touch the public Marketplace catalog. Their
3527
+ // descriptors and handlers stay in the private EE dashboard, and only an
3528
+ // enrolled client with an explicitly installed tool receives them here.
3529
+ // (Generic already: `enterpriseTool.id`/`.description` always come from
3530
+ // whatever the EE server currently returns, never a hardcoded tool.)
3531
+ for (const enterpriseTool of await getEnterpriseTools(config)) {
3532
+ finalTools[enterpriseTool.id] = tool({
3533
+ description: `${enterpriseTool.description} Use this only when it directly helps the Enterprise user's request.`,
3534
+ inputSchema: z.object({
3535
+ query: z.string().describe('The focused request for this private tool.'),
3536
+ }),
3537
+ execute: async ({ query }) => executeEnterpriseTool(config, enterpriseTool.id, { query }),
3538
+ });
3539
+ dynamicToolNames.push(enterpriseTool.id);
3540
+ }
3541
+
3542
+ return {
3543
+ tools: finalTools,
3544
+ promptFragments,
3545
+ dynamicToolNames,
3546
+ dynamicToolWorkflows,
3547
+ dynamicToolEvidenceInputs,
3548
+ };
3549
+ }
3550
+
3551
+ function isValidAgentToolDefinition(definition: AgentToolDefinition): boolean {
3552
+ return (
3553
+ typeof definition.name === 'string' &&
3554
+ /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(definition.name) &&
3555
+ typeof definition.description === 'string' &&
3556
+ definition.description.length > 0 &&
3557
+ typeof definition.method === 'string' &&
3558
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(definition.method) &&
3559
+ typeof definition.parameters === 'object' &&
3560
+ definition.parameters !== null &&
3561
+ !Array.isArray(definition.parameters)
3562
+ );
3563
+ }