@k2b/cloud 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (349) hide show
  1. package/README.md +2 -2
  2. package/package.json +22 -11
  3. package/scripts/README.md +10 -0
  4. package/scripts/browser-performance.ts +14 -0
  5. package/scripts/build-pdf-renderer.ts +39 -0
  6. package/scripts/build.ts +20 -35
  7. package/scripts/preload.ts +16 -12
  8. package/scripts/sync-recovery-smoke.ts +22 -6
  9. package/src/_internal/app-presentation.ts +14 -2
  10. package/src/_internal/build-metadata.ts +8 -1
  11. package/src/_internal/capabilities.ts +131 -7
  12. package/src/_internal/capability-streams.ts +81 -0
  13. package/src/_internal/define-app.ts +80 -37
  14. package/src/_internal/heartbeat.ts +4 -0
  15. package/src/_internal/help-catalog.ts +27 -172
  16. package/src/_internal/help.ts +13 -76
  17. package/src/_internal/page-responses.ts +2 -0
  18. package/src/_internal/postgres-application-name.ts +30 -0
  19. package/src/_internal/process-identity.ts +15 -0
  20. package/src/_internal/process-sync.ts +1 -1
  21. package/src/_internal/readiness.ts +4 -1
  22. package/src/_internal/registry-validation.ts +29 -25
  23. package/src/_internal/registry.ts +2 -53
  24. package/src/_internal/runtime-context.ts +5 -16
  25. package/src/_internal/server-timing.ts +13 -0
  26. package/src/_internal/status-preserving-ssr.ts +18 -6
  27. package/src/_internal/web-vitals-asset.ts +10 -0
  28. package/src/access/PermissionEditor.tsx +18 -151
  29. package/src/access/PrincipalPicker.tsx +110 -0
  30. package/src/access/ResourceApiKeys.tsx +1 -1
  31. package/src/access/ui.ts +1 -0
  32. package/src/ai/admin.ts +14 -13
  33. package/src/ai/assistant-models.ts +12 -1
  34. package/src/ai/audio-format.ts +34 -0
  35. package/src/ai/audio-tool.ts +90 -0
  36. package/src/ai/browser-code-contracts.ts +201 -0
  37. package/src/ai/browser.ts +4 -0
  38. package/src/ai/capabilities.ts +55 -36
  39. package/src/ai/capability-execution.ts +47 -12
  40. package/src/ai/chat/blocks.tsx +229 -90
  41. package/src/ai/chat/builtin-tools.tsx +2 -4
  42. package/src/ai/chat/capability-result.ts +15 -0
  43. package/src/ai/chat/capability-table.tsx +2 -10
  44. package/src/ai/chat/composer-adapter.ts +83 -3
  45. package/src/ai/chat/message-actions.tsx +8 -7
  46. package/src/ai/chat/message-utils.ts +25 -1
  47. package/src/ai/chat/messages.ts +2 -0
  48. package/src/ai/chat/presentation.tsx +31 -47
  49. package/src/ai/chat/primitives.tsx +43 -1
  50. package/src/ai/chat/tool-disclosure.tsx +21 -2
  51. package/src/ai/chat/tool-groups.ts +58 -0
  52. package/src/ai/chat-quotas.ts +25 -0
  53. package/src/ai/chat-task-contracts.ts +14 -10
  54. package/src/ai/chat-tasks.ts +297 -43
  55. package/src/ai/client/controller.ts +165 -39
  56. package/src/ai/client/file-source.ts +12 -6
  57. package/src/ai/client/live-connection.ts +1 -0
  58. package/src/ai/client/projection.ts +29 -11
  59. package/src/ai/client/transport.ts +6 -0
  60. package/src/ai/code-capability-routes.ts +105 -0
  61. package/src/ai/code-capability-transport.ts +39 -0
  62. package/src/ai/code-execution.ts +53 -0
  63. package/src/ai/code-mode-skill.ts +118 -0
  64. package/src/ai/code-runtime-tools.ts +179 -0
  65. package/src/ai/code-source-contracts.ts +270 -0
  66. package/src/ai/code-source-tools.ts +188 -0
  67. package/src/ai/compaction.ts +27 -6
  68. package/src/ai/data-analysis-skill.ts +10 -0
  69. package/src/ai/default-tools.ts +99 -3
  70. package/src/ai/dictation-runtime.ts +171 -0
  71. package/src/ai/dictations.ts +188 -0
  72. package/src/ai/draft-content.ts +49 -0
  73. package/src/ai/enrich.ts +6 -0
  74. package/src/ai/executor.ts +189 -33
  75. package/src/ai/fetch-file-tool.ts +2 -2
  76. package/src/ai/file-content-version.ts +27 -0
  77. package/src/ai/file-context.ts +2 -1
  78. package/src/ai/file-media-type.ts +12 -0
  79. package/src/ai/file-reference-contracts.ts +15 -0
  80. package/src/ai/file-tools.ts +8 -1
  81. package/src/ai/files-store.ts +212 -71
  82. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  83. package/src/ai/grids-skill.ts +42 -10
  84. package/src/ai/http.ts +26 -8
  85. package/src/ai/index.ts +26 -3
  86. package/src/ai/inference-calls.ts +197 -0
  87. package/src/ai/live-events.ts +5 -1
  88. package/src/ai/live.ts +1 -1
  89. package/src/ai/markdown-pdf-tool.ts +2 -2
  90. package/src/ai/memories.ts +30 -27
  91. package/src/ai/memory-learning-runs.ts +1 -1
  92. package/src/ai/memory-learning.ts +64 -32
  93. package/src/ai/memory-tool.ts +1 -5
  94. package/src/ai/memory-workflow-evidence.ts +9 -4
  95. package/src/ai/message-queue.ts +178 -0
  96. package/src/ai/migrate.ts +189 -11
  97. package/src/ai/model-pricing.ts +43 -0
  98. package/src/ai/pdf-render-worker.ts +75 -0
  99. package/src/ai/pdf-render.fixture.ts +28 -0
  100. package/src/ai/pdf-render.ts +77 -0
  101. package/src/ai/projects-routes.ts +18 -43
  102. package/src/ai/projects.ts +33 -14
  103. package/src/ai/protocol.ts +3 -0
  104. package/src/ai/provider.ts +1 -8
  105. package/src/ai/quota-provider.ts +192 -0
  106. package/src/ai/quota-report.ts +152 -0
  107. package/src/ai/quotas-migrate.ts +45 -0
  108. package/src/ai/quotas.ts +168 -0
  109. package/src/ai/routes.ts +241 -11
  110. package/src/ai/run-timeout.ts +12 -0
  111. package/src/ai/runtime-tools.ts +7 -5
  112. package/src/ai/runtime.ts +74 -17
  113. package/src/ai/settings.ts +36 -8
  114. package/src/ai/short-id.ts +4 -5
  115. package/src/ai/skill-catalog.ts +0 -10
  116. package/src/ai/skill-search.ts +58 -0
  117. package/src/ai/skill-seeds.ts +114 -20
  118. package/src/ai/skill-tool.ts +21 -5
  119. package/src/ai/skills-routes.ts +49 -4
  120. package/src/ai/skills.ts +358 -163
  121. package/src/ai/store.ts +416 -201
  122. package/src/ai/stream.ts +5 -0
  123. package/src/ai/structured-runs.ts +4 -3
  124. package/src/ai/structured.ts +12 -1
  125. package/src/ai/system-prompt.ts +14 -2
  126. package/src/ai/task-contracts.ts +84 -0
  127. package/src/ai/task-execution.ts +92 -0
  128. package/src/ai/timeline.ts +2 -5
  129. package/src/ai/todo-contracts.ts +32 -0
  130. package/src/ai/todo-tool.ts +19 -0
  131. package/src/ai/tool-audit.ts +30 -40
  132. package/src/ai/tools.ts +6 -0
  133. package/src/ai/transcription.ts +202 -0
  134. package/src/ai/turn-timing.ts +114 -0
  135. package/src/ai/types.ts +58 -10
  136. package/src/ai/ui.tsx +3 -0
  137. package/src/ai/usage.ts +118 -118
  138. package/src/ai/vision-tool.ts +60 -10
  139. package/src/api/admin-ai-projects.ts +2 -2
  140. package/src/api/admin-ai-quotas.ts +80 -0
  141. package/src/api/admin-ai-skills.ts +48 -5
  142. package/src/api/admin-ai-usage.ts +2 -2
  143. package/src/api/admin-core-settings.ts +25 -0
  144. package/src/api/admin-identity.ts +91 -51
  145. package/src/api/admin-linux-identities.ts +10 -4
  146. package/src/api/admin-rail.ts +58 -0
  147. package/src/api/announcements.ts +14 -0
  148. package/src/api/app-approval.ts +2 -2
  149. package/src/api/auth.ts +1 -1
  150. package/src/api/capabilities.ts +324 -43
  151. package/src/api/capability-streams.ts +262 -0
  152. package/src/api/help.ts +8 -44
  153. package/src/api/index.ts +8 -2
  154. package/src/api/mcp.ts +45 -34
  155. package/src/api/me-web-vitals.ts +35 -0
  156. package/src/api/me.ts +2 -0
  157. package/src/api/search/schemas.ts +26 -15
  158. package/src/api/search.bench.ts +1 -1
  159. package/src/api/search.ts +54 -17
  160. package/src/browser/CloudResourceSearch.tsx +640 -702
  161. package/src/browser/app-approval-vault.ts +19 -7
  162. package/src/browser/command-bridge.ts +109 -0
  163. package/src/browser/command-shortcuts.ts +76 -0
  164. package/src/browser/commands.ts +67 -0
  165. package/src/browser/navigation-search.ts +48 -0
  166. package/src/browser/notifications.ts +1 -1
  167. package/src/browser/resource-picker.tsx +5 -18
  168. package/src/browser/resource-search-dialog.ts +9 -0
  169. package/src/browser/resource-search-input.ts +46 -0
  170. package/src/browser/resource-search-messages.ts +76 -40
  171. package/src/browser/resource-search.ts +7 -0
  172. package/src/browser/search-bridge.ts +102 -0
  173. package/src/browser/search-commands.ts +59 -0
  174. package/src/browser/search.ts +13 -0
  175. package/src/browser/spotlight-position.ts +208 -0
  176. package/src/browser/testing.ts +6 -0
  177. package/src/browser/web-vitals.ts +25 -0
  178. package/src/capabilities/claims.ts +179 -0
  179. package/src/capabilities/client.ts +34 -0
  180. package/src/capabilities/command-link.ts +16 -0
  181. package/src/capabilities/executions.ts +298 -0
  182. package/src/capabilities/migrate.ts +81 -0
  183. package/src/capabilities/server.ts +116 -35
  184. package/src/capabilities/stream-body.ts +16 -0
  185. package/src/capabilities/streams.ts +28 -0
  186. package/src/cli/admin/ai-quotas.ts +161 -0
  187. package/src/cli/admin/ai-skills.ts +64 -0
  188. package/src/cli/admin/ai-usage.ts +53 -30
  189. package/src/cli/admin/app-credentials.ts +77 -0
  190. package/src/cli/admin/data.ts +17 -0
  191. package/src/cli/admin/index.ts +10 -1
  192. package/src/cli/admin/instance.ts +2 -2
  193. package/src/cli/admin/linux.ts +4 -2
  194. package/src/cli/admin/sync.ts +1 -1
  195. package/src/cli/capabilities.ts +42 -0
  196. package/src/cli/capability-streams.ts +25 -0
  197. package/src/cli/index.ts +3 -2
  198. package/src/config/define-env.ts +97 -0
  199. package/src/config/env.ts +147 -51
  200. package/src/config/index.ts +3 -1
  201. package/src/contracts/app.ts +23 -3
  202. package/src/contracts/capabilities.ts +75 -5
  203. package/src/contracts/capability-streams.ts +46 -0
  204. package/src/contracts/commands.ts +49 -0
  205. package/src/contracts/index.ts +5 -2
  206. package/src/contracts/rail-admin.ts +38 -0
  207. package/src/contracts/rail-preferences.ts +16 -0
  208. package/src/contracts/registry.ts +5 -27
  209. package/src/contracts/settings-types.ts +1 -1
  210. package/src/contracts/shared.ts +18 -5
  211. package/src/contracts/web-vitals.ts +27 -0
  212. package/src/desktop/index.ts +1 -0
  213. package/src/desktop/solid.tsx +1 -1
  214. package/src/index.ts +8 -6
  215. package/src/server/index.ts +3 -0
  216. package/src/server/middleware/auth.ts +3 -2
  217. package/src/server/middleware/route-template.ts +9 -3
  218. package/src/server/middleware/runtime.ts +2 -1
  219. package/src/server/middleware/settings.ts +17 -11
  220. package/src/server/ratelimit.ts +1 -1
  221. package/src/server/services/access-revision.ts +10 -0
  222. package/src/server/services/access.ts +3 -1
  223. package/src/server/services/index.ts +1 -0
  224. package/src/services/account-category-policy.ts +9 -0
  225. package/src/services/account-lifecycle/audit.ts +1 -1
  226. package/src/services/accounts/app.ts +12 -2
  227. package/src/services/accounts/email-write.ts +23 -0
  228. package/src/services/accounts/groups.ts +15 -4
  229. package/src/services/accounts/identities.ts +249 -0
  230. package/src/services/accounts/identity-reconciliation.ts +87 -0
  231. package/src/services/accounts/ipa-data.ts +2 -2
  232. package/src/services/accounts/local-groups.ts +2 -2
  233. package/src/services/accounts/notification-sender.ts +12 -2
  234. package/src/services/accounts/posix.ts +45 -29
  235. package/src/services/accounts/users.ts +2 -2
  236. package/src/services/announcements/index.ts +69 -8
  237. package/src/services/app-approval.ts +11 -10
  238. package/src/services/audit/index.ts +1 -1
  239. package/src/services/cache-fill.ts +35 -0
  240. package/src/services/help/index.ts +109 -0
  241. package/src/services/help/maintenance.ts +25 -0
  242. package/src/services/help/store.ts +180 -0
  243. package/src/services/help/types.ts +46 -0
  244. package/src/services/identity/invocation-actor.ts +2 -2
  245. package/src/services/identity/key-config.ts +8 -5
  246. package/src/services/identity/key-ring.ts +1 -1
  247. package/src/services/identity/runtime-config.ts +6 -5
  248. package/src/services/index.ts +15 -0
  249. package/src/services/ipa/posix.ts +1 -1
  250. package/src/services/ipa/sync.ts +1 -1
  251. package/src/services/ipa/users.ts +268 -221
  252. package/src/services/legal-consent.ts +1 -1
  253. package/src/services/logging/index.ts +15 -13
  254. package/src/services/logging/trace.ts +2 -1
  255. package/src/services/logging/web-vitals.ts +81 -0
  256. package/src/services/mandates/index.ts +21 -4
  257. package/src/services/mandates/policy.ts +165 -2
  258. package/src/services/notifications/batches.ts +125 -57
  259. package/src/services/notifications/browser.ts +12 -0
  260. package/src/services/notifications/index.ts +0 -2
  261. package/src/services/notifications/platform.ts +0 -4
  262. package/src/services/oauth-tokens.ts +3 -3
  263. package/src/services/pdf/gotenberg.ts +131 -72
  264. package/src/services/pdf/index.ts +11 -8
  265. package/src/services/providers/local/users.ts +25 -16
  266. package/src/services/public-http.ts +101 -0
  267. package/src/services/rail-shortcuts.ts +95 -0
  268. package/src/services/rail-snapshot.ts +53 -0
  269. package/src/services/request-cache-redis.ts +29 -0
  270. package/src/services/session/index.ts +15 -2
  271. package/src/services/session/user.ts +62 -37
  272. package/src/services/settings/app.ts +5 -1
  273. package/src/services/settings/core-settings.ts +26 -35
  274. package/src/services/settings/defaults.ts +5 -0
  275. package/src/services/settings/index.ts +3 -3
  276. package/src/services/settings/store.ts +58 -84
  277. package/src/services/webauthn.ts +1 -1
  278. package/src/shared/ai-costs.ts +42 -0
  279. package/src/shared/ai-platform-prompt.ts +1 -1
  280. package/src/shared/ai-quotas.ts +136 -0
  281. package/src/shared/ai-usage.ts +6 -16
  282. package/src/shared/app-presentation.ts +21 -15
  283. package/src/shared/capability-messages.ts +14 -0
  284. package/src/shared/help.ts +6 -42
  285. package/src/shared/index.ts +3 -0
  286. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  287. package/src/ssr/AdminLayout.tsx +7 -5
  288. package/src/ssr/AdminSidebar.tsx +30 -16
  289. package/src/ssr/AppLaunchpad.island.tsx +33 -136
  290. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  291. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  292. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  293. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  294. package/src/ssr/Layout.tsx +17 -6
  295. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  296. package/src/ssr/LayoutFooter.tsx +1 -1
  297. package/src/ssr/LayoutHeader.tsx +46 -14
  298. package/src/ssr/LayoutHelp.tsx +22 -23
  299. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  300. package/src/ssr/LayoutRail.tsx +17 -9
  301. package/src/ssr/MinimalLayout.tsx +1 -1
  302. package/src/ssr/MobileNavigation.tsx +71 -0
  303. package/src/ssr/MobileProfileActions.tsx +49 -0
  304. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  305. package/src/ssr/RailApps.island.tsx +2 -2
  306. package/src/ssr/RailEditor.tsx +17 -2
  307. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  308. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  309. package/src/ssr/admin-navigation.ts +11 -1
  310. package/src/ssr/help.ts +29 -0
  311. package/src/ssr/islands/SearchBar.tsx +67 -0
  312. package/src/ssr/islands/index.ts +3 -1
  313. package/src/ssr/layout-context.ts +2 -2
  314. package/src/ssr/layout-help-search.ts +15 -0
  315. package/src/ssr/mobile-menu-history.ts +70 -0
  316. package/src/ssr/platform-messages.ts +16 -8
  317. package/src/ssr/profile-actions.ts +42 -0
  318. package/src/ssr/rail-context.ts +8 -4
  319. package/src/ssr/rail-messages.ts +2 -0
  320. package/src/ssr/rail-navigation.ts +3 -3
  321. package/src/ssr/workspace-navigation.ts +75 -0
  322. package/src/styles/effects.css +6 -6
  323. package/src/styles/global.css +54 -0
  324. package/src/styles/input.css +43 -41
  325. package/src/styles/resource-search.css +505 -0
  326. package/src/styles/utilities-feedback.css +13 -0
  327. package/src/styles/utilities-layout.css +1 -5
  328. package/src/styles/utilities-navigation.css +3 -39
  329. package/src/types/ambient.d.ts +5 -0
  330. package/src/workflows/ai/index.ts +2 -2
  331. package/src/workflows/ai/runtime.ts +19 -90
  332. package/src/workflows/ai/store.ts +2 -0
  333. package/src/workflows/ai/types.ts +6 -82
  334. package/src/workflows/ai-actions.ts +16 -13
  335. package/src/workflows/definition.ts +10 -5
  336. package/src/workflows/store/actions.ts +44 -20
  337. package/src/workflows/store/index.ts +4 -2
  338. package/src/workflows/store/runs.ts +36 -0
  339. package/src/workflows/store/worker-pool.ts +55 -0
  340. package/src/workflows/store/worker-runtime.ts +90 -0
  341. package/src/ai/kit-skill.ts +0 -22
  342. package/src/ai/usage-migrate.ts +0 -81
  343. package/src/browser/notification-target.ts +0 -27
  344. package/src/contracts/notification-live.ts +0 -66
  345. package/src/server/services/freeipa/test-certificates.ts +0 -44
  346. package/src/services/notifications/live.ts +0 -50
  347. package/src/services/session/test-fixture.ts +0 -18
  348. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  349. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -89,6 +89,23 @@ const findLoopSplitIndex = (entries: StoreEntry[], keepLoops: number): number =>
89
89
  return -1;
90
90
  };
91
91
 
92
+ /** Preserve recent complete rounds, retaining one when the first loop has only two. */
93
+ const findRoundSplitIndex = (entries: StoreEntry[]): number => {
94
+ const rounds = entries.flatMap((entry, index) => (entry.message.role === "assistant" && entry.kind === "message" ? [index] : []));
95
+ if (!rounds.length) return -1;
96
+ const candidate = rounds.length === 1 ? entries.length : rounds[rounds.length - (rounds.length > 2 ? 2 : 1)]!;
97
+ const pending = new Set<string>();
98
+ let safe = -1;
99
+ for (let index = 0; index < candidate; index++) {
100
+ const message = entries[index]!.message;
101
+ if (message.role === "assistant") {
102
+ for (const block of message.content) if (block.type === "tool_call") pending.add(block.id);
103
+ } else if (message.role === "tool_result") pending.delete(message.callId);
104
+ if (pending.size === 0) safe = index + 1;
105
+ }
106
+ return safe;
107
+ };
108
+
92
109
  const buildCompactionPrompt = (additionalInstructions: string, source: string) => ({
93
110
  systemPrompt: buildAiTaskPrompt({
94
111
  baseInstructions: DEFAULT_COMPACTION_PROMPT,
@@ -113,13 +130,16 @@ export const createCloudCompactFn = (input: {
113
130
  keepRecentLoops?: number;
114
131
  }): CompactFn => {
115
132
  return (ctx) => {
116
- if (!ctx.force && (typeof ctx.fillRatio !== "number" || ctx.fillRatio < COMPACTION_FILL_RATIO)) return null;
133
+ const window = ctx.provider.contextWindow;
134
+ const reserve = input.maxOutputTokens ?? 0;
135
+ const fillRatio =
136
+ typeof ctx.fillRatio === "number" && window && window > reserve ? (ctx.fillRatio * window) / (window - reserve) : ctx.fillRatio;
137
+ if (!ctx.force && (typeof fillRatio !== "number" || fillRatio < COMPACTION_FILL_RATIO)) return null;
117
138
 
118
139
  const totalLoops = countConversationLoops(ctx.entries);
119
- const keepLoops = input.keepRecentLoops ?? keepLoopsForFillRatio(ctx.fillRatio, totalLoops);
120
- if (totalLoops <= keepLoops) return null;
121
-
122
- const splitIndex = findLoopSplitIndex(ctx.entries, keepLoops);
140
+ const keepLoops = input.keepRecentLoops ?? keepLoopsForFillRatio(fillRatio, totalLoops);
141
+ const loopSplit = totalLoops > keepLoops ? findLoopSplitIndex(ctx.entries, keepLoops) : -1;
142
+ const splitIndex = loopSplit > 0 ? loopSplit : findRoundSplitIndex(ctx.entries);
123
143
  if (splitIndex < 1) return null;
124
144
 
125
145
  const sourceEntries = ctx.entries.slice(0, splitIndex);
@@ -162,6 +182,7 @@ export const createCloudCompactFn = (input: {
162
182
 
163
183
  await aiConversations.compactMessages({
164
184
  conversationId: input.conversationId,
185
+ turnId: input.turnId,
165
186
  checkpointSeq: checkpoint.seq,
166
187
  modelProfileId: input.modelProfileId,
167
188
  summary: {
@@ -179,4 +200,4 @@ export const createCloudCompactFn = (input: {
179
200
  };
180
201
  };
181
202
 
182
- export const __compactionTest = { countConversationLoops, findLoopSplitIndex, keepLoopsForFillRatio };
203
+ export const __compactionTest = { countConversationLoops, findLoopSplitIndex, findRoundSplitIndex, keepLoopsForFillRatio };
@@ -0,0 +1,10 @@
1
+ // Generated by packages/assistant/scripts/generate-code-mode-skill.ts.
2
+ import type { AiSkillTemplate } from "./skills";
3
+ export const ASSISTANT_DATA_ANALYSIS_SKILL = {
4
+ "key": "assistant:data-analysis",
5
+ "version": 6,
6
+ "name": "assistant-data-analysis",
7
+ "description": "Analyze source data, explain metrics and comparisons, and build evidence-backed reports or interactive dashboards in Assistant Code Mode. Use for multi-step analysis, data exploration, and dashboards; a simple chart only needs the Code Mode charts reference.",
8
+ "instructions": "# Analyze data and deliver an inspectable result\n\nStart with the question the reader needs to answer. Choose a direct answer,\nan interactive visualization in this chat, an exported file, or a reusable Studio app accordingly.\nFor a one-time visual analysis, prefer a chat visualization; filters and buttons\ndo not by themselves require a Studio App.\nLoad `assistant-code-mode` for execution and read its `references/analytics.md`\nfor the built-in UI. Loading this skill does not install a library or grant access.\n\n## Keep a working plan\n\nFor work with several real steps, use `todo_write` to keep a short chat plan.\nReplace the full `todos` list each time; give each item a stable `id`, actionable\n`content`, and `status` (`pending`, `in_progress`, `completed`, or `cancelled`).\nAt most one step is active. Update as work changes, including user corrections;\nmark a step completed only after doing and checking it. Preserve exact commands\nwhen they matter. Skip this tool for a simple calculation or conversational reply.\n\nFor analysis, useful steps are reconcile source data, build the analysis, and\nverify totals, filters and conclusions. A rendered dashboard alone is not proof\nthat its numbers are correct. Keep blocked source work open and explain why.\n\n## Establish the data\n\nIdentify the actual source, unit of observation, time window, timezone, and\nlatest complete period. Read files or discover the relevant Cloud capabilities\nbefore selecting fields. External APIs use `http.fetch`; credentials are entered\nonly through the trusted `code_secret` dialog.\n\nInspect a bounded sample, missing values, duplicate keys, types, and coverage.\nReconcile totals and join cardinalities before drawing conclusions. Keep raw\nnumbers separate from display formatting. Distinguish zero from unavailable data;\nstate exclusions and denominator choices. Do not substitute fixture data for a\nblocked source unless the user requested a mockup.\n\nKeep the source query or transformation in the saved source or an accompanying\nfile so another run can reproduce the result. Preserve the source identity and\nretrieval timestamp, without credentials or credential-bearing URLs.\n\n## Build one consistent analysis\n\nDerive charts, metrics, and tables from the same reviewed data. Aggregate large\ninputs before crossing the UI bridge. Chart Explorer rows identify selectable\nentities with stable string keys; derived bins and groups need explicit mark\nmappings. Shared keys across charts mean the same entity, not equal values.\n\nChoose the simplest chart that answers the question. Use tables for exact\nrecords, bars for category comparisons, lines for ordered trends, and distributions\nfor spread. State units, comparison windows, and whether a percent is a fraction\nor an already scaled number. Do not imply causation from correlation.\n\nLead reports with the finding, then supporting evidence and limitations.\nLead dashboards with the important measurements, then trends and diagnostic\nbreakdowns. Use shared filters only when they affect all claimed views. Keep the\ninitial view useful without interaction. Avoid unrelated metrics added merely to\nfill a grid.\n\nAttach source context to Explorer data: `mode`, `asOf`, `sources`, and relevant\n`status`/`note`. A timestamp records when the data was retrieved; it does not prove\nthat the upstream source is complete. Label partial or fixture data visibly.\n\n## Validate before delivery\n\nRun the actual source with `code_run`. Inspect the result; use `code_interact`\nwith structured UI events to test filters, chart/table switching, selection,\nempty results, and recovery from a failed load. Reconcile displayed values with\nthe reviewed totals and check that filters describe the data actually displayed.\nA successful schema validation does not establish analytical correctness.\n\nValidate the actual presented source (the saved revision for Apps), rather than pasting its formulas into a\nsecond test script. Keep an independent expectation from the input data: row\ncounts, unmatched joins, totals and representative boundary cases. For targets,\nstate their grain (for example month × region) and aggregate each target once;\nmultiple selected regions must sum their distinct targets. Compare inspected raw\nKPI values and plotted series with independent expectations. A formatted value\nmatching after rounding does not validate the raw ratio. Never round fractions\nbefore passing them to percent-formatted controls. Preserve precision\nuntil display formatting. Test reset, one/multiple/all selections, empty results,\nand complete versus partial periods. A newly generated timestamp is not source\nfreshness: keep the real retrieval or file-snapshot timestamp stable.\n\nFor a Studio App data snapshot, export the validated dataset with `files.save` and\n`code_export`, then copy its exact path/version into the resource with\n`code_write({id,expectedRevision,files:[{path:\"data.json\",fromFile:reference}]})`.\nObtain the exact reference with `code_file_stat`; importing private files into\nApp source receives fresh review.\nImport that file in the app. Never rebuild a truncated dataset by copying tool\noutput. Keep transformations and source identity alongside the snapshot.\n\nHuman approval and uncertain HTTP outcomes follow the Code Mode HTTP contract.\nDo not replay an external mutation to refresh a chart. Separate local filtering\nfrom external loading; an Apply button can avoid a request for every slider move.\n\n## Save, share, and hand off\n\nFor chat visualizations, test `code_run` and relevant `code_interact` controls,\nthen deliver with `code_present({runId,title})`. A successful run alone is not\nvisible to the user. Retain source identity, reviewed input data and their real\nretrieval timestamp. Put writes and external reloads behind explicit buttons;\nopening an old result must not repeat earlier actions. The user can download the\ncurrent view as PDF/HTML or an individual chart as SVG.\n\nFor reusable Studio Apps, reuse one Cloud resource for later revisions of the same report or dashboard.\nSave its source, test that revision, and use the normal Code Mode publication\nworkflow when publication is requested. Publishing a version and granting reader\naccess are separate operations. Preserve existing access; a dashboard request\ndoes not authorize broadening it. Personal secrets are never copied to readers.\n\nA published source version is not automatically a frozen data snapshot. A frozen\nreport must retain the reviewed data explicitly in its authorized resource or\noutput file. A live app must implement its loader and display the retrieval time;\nloading once is not continuous monitoring. No background refresh exists unless\nimplemented through an appropriate supported workflow.\n\nPresent the chat visualization, resource link or exported file, the data timestamp, and material\ncoverage limitations. Say whether it is a snapshot or reloads from its sources.\nIf publication fails, retain the tested resource and report the failed stage;\ndo not claim success or create a different public destination. Sites-specific\nhosting, access policies, and editor storage are not part of this Cloud workflow.",
9
+ "extraFrontmatter": {}
10
+ } satisfies AiSkillTemplate;
@@ -1,3 +1,20 @@
1
+ import { z } from "zod";
2
+ import { createCloudAiTranscribeAudioTool } from "./audio-tool";
3
+ import {
4
+ CODE_RUNTIME_TOOL_NAMES,
5
+ CodeActionInput,
6
+ CodeExportInput,
7
+ CodeInspectInput,
8
+ CodeInteractInput,
9
+ CodeOpenInput,
10
+ CodePresentInput,
11
+ CodeRunInput,
12
+ CodeSecretInput,
13
+ CodeStopInput,
14
+ } from "./browser-code-contracts";
15
+ import { runManagedCodeTool } from "./code-runtime-tools";
16
+ import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
17
+ import { createCodeSourceTools } from "./code-source-tools";
1
18
  import {
2
19
  CloudAiCardInputSchema,
3
20
  CloudAiCardOutputSchema,
@@ -8,6 +25,7 @@ import {
8
25
  CloudAiTextEditorInputSchema,
9
26
  CloudAiTextEditorOutputSchema,
10
27
  } from "./default-tool-contracts";
28
+ import { createCloudAiFetchFileTool } from "./fetch-file-tool";
11
29
  import {
12
30
  createCloudAiCalculateTool,
13
31
  createCloudAiListFilesTool,
@@ -15,9 +33,9 @@ import {
15
33
  createCloudAiReadFileTool,
16
34
  createCloudAiWriteFileTool,
17
35
  } from "./file-tools";
18
- import { createCloudAiFetchFileTool } from "./fetch-file-tool";
19
36
  import { createCloudAiWebExtractTool, createCloudAiWebSearchTool, isCloudAiFirecrawlConfigured } from "./firecrawl-tools";
20
37
  import { createCloudAiMarkdownToPdfTool } from "./markdown-pdf-tool";
38
+ import { createAiTodoTool } from "./todo-tool";
21
39
  import { defineAiTool } from "./tools";
22
40
  import type { AiDataBoundary, AiRuntimeTool } from "./types";
23
41
  import { createCloudAiViewImageTool } from "./vision-tool";
@@ -87,10 +105,86 @@ export const createCloudAiLocalBashTool = () =>
87
105
  "use local_bash only when work on the user's local CLI computer is necessary; every command requires local confirmation and its result must be checked.",
88
106
  }).client();
89
107
 
90
- export const createDefaultCloudAiTools = () => [createCloudAiSurveyTool(), createCloudAiTextEditorTool()];
108
+ export const createCloudAiCodeTools = () => [
109
+ defineAiTool({
110
+ name: "code_secret",
111
+ description:
112
+ "Open a trusted Secret input dialog. The user enters the value directly into encrypted Assistant storage; only configured/name returns. Never ask for a credential in chat, survey, app controls or code_interact. Personal secrets are scoped to this chat or resource and bound to the exact HTTPS origin, header and prefix. Use secret(name,{prefix}) in http.fetch headers; load assistant-code-mode for HTTP details. Web UI required for secret entry; stored secrets also work from CLI.",
113
+ inputSchema: CodeSecretInput,
114
+ outputSchema: z.object({ configured: z.boolean(), name: z.string() }),
115
+ approval: "never",
116
+ }).client(),
117
+ defineAiTool({
118
+ name: "code_action",
119
+ description:
120
+ "Call one published Studio App action using its exact discovered name, publication and input schema. Discover with code_actions first. Use permission is sufficient; draft and management access are not granted. Runtime effects and approvals are real. Never repeat an uncertain mutation blindly.",
121
+ inputSchema: CodeActionInput,
122
+ outputSchema: z.json(),
123
+ approval: "never",
124
+ }).server(runManagedCodeTool("code_action")),
125
+ defineAiTool({
126
+ name: "code_run",
127
+ promptHint:
128
+ "For file analysis, data transformations or combining Cloud data, run a short one-off script with code_run. Load assistant-code-mode for its runtime APIs; no saved app is required.",
129
+ description:
130
+ "Run a saved resource id OR one-off code in the isolated worker. Optional resourceId binds one-off code to existing app data (Manage required), without editing its source. Includes capabilities.run(name,input) to chain Cloud capabilities in JavaScript; load assistant-code-mode for its runtime APIs. Scripts accept current chat inputPaths; app test runs use them only as explicit picker fixtures. Returns output, logs and UI state for agent inspection only; use code_present to show a one-off visualization to the user. Local test storage is temporary; shared data, database writes and capability effects are real and keep normal permissions and approvals.",
131
+ inputSchema: CodeRunInput,
132
+ outputSchema: z.json(),
133
+ approval: "never",
134
+ }).server(runManagedCodeTool("code_run")),
135
+ defineAiTool({
136
+ name: "code_inspect",
137
+ description:
138
+ "Inspect a test run: errors, logs, output, controls and pending modal. Use nodeId to inspect table rows or list items; follow pagination only as needed.",
139
+ inputSchema: CodeInspectInput,
140
+ outputSchema: z.json(),
141
+ approval: "never",
142
+ }).server(runManagedCodeTool("code_inspect")),
143
+ defineAiTool({
144
+ name: "code_interact",
145
+ description:
146
+ "Operate a control or answer a pending modal using its exact snapshot ID. Buttons need only id; controls use event:{type:change,value:...} or event:{type:select,key:...}. Use answer for modal replies, or answer:null to cancel. Copy an interactions example from code_inspect; never send JSON as a string. Use steps:[{id,event?},...] for up to three known sequential interactions; stops on errors, modals or background work. Returns one resulting state with completedSteps and nextStep.",
147
+ inputSchema: CodeInteractInput,
148
+ outputSchema: z.json(),
149
+ approval: "never",
150
+ }).server(runManagedCodeTool("code_interact")),
151
+ defineAiTool({
152
+ name: "code_stop",
153
+ description: "Stop and release an isolated test run.",
154
+ inputSchema: CodeStopInput,
155
+ outputSchema: z.json(),
156
+ approval: "never",
157
+ }).server(runManagedCodeTool("code_stop")),
158
+ defineAiTool({
159
+ name: "code_open",
160
+ description: "Show the app beside the chat without starting it. For one-off calculations, return the result instead of opening an app.",
161
+ inputSchema: CodeOpenInput,
162
+ outputSchema: z.json(),
163
+ approval: "never",
164
+ }).client(),
165
+ defineAiTool({
166
+ name: "code_present",
167
+ description:
168
+ "Present a successful one-off run as a persistent interactive visualization in this chat. Requires code_run with code, without resourceId or saved app id. Saves source, selected inputs and UI preview. Users can activate controls and download the current view. Test runs are not visible until this succeeds. For reusable apps use code_open instead.",
169
+ inputSchema: CodePresentInput,
170
+ outputSchema: z.json(),
171
+ approval: "never",
172
+ }).server(runManagedCodeTool("code_present")),
173
+ defineAiTool({
174
+ name: "code_export",
175
+ description: "Copy a captured output file from a test run into the chat. Returns a path for normal file tools and presentation.",
176
+ inputSchema: CodeExportInput,
177
+ outputSchema: z.json(),
178
+ approval: "never",
179
+ }).server(runManagedCodeTool("code_export")),
180
+ ];
181
+
182
+ export const createDefaultCloudAiTools = () => [createAiTodoTool(), createCloudAiSurveyTool(), createCloudAiTextEditorTool()];
91
183
 
92
184
  /** Built-ins advertised through discovery and loaded only when needed. */
93
- export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set([
185
+ export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
186
+ ...CODE_RUNTIME_TOOL_NAMES,
187
+ ...Object.keys(CODE_SOURCE_TOOLS),
94
188
  "survey",
95
189
  "text_editor",
96
190
  "list_files",
@@ -108,6 +202,7 @@ export const createConfiguredDefaultCloudAiTools = async (config?: {
108
202
  }) => {
109
203
  const tools: AiRuntimeTool[] = [
110
204
  ...createDefaultCloudAiTools(),
205
+ ...createCodeSourceTools(),
111
206
  createCloudAiListFilesTool(),
112
207
  createCloudAiReadFileTool(),
113
208
  createCloudAiFetchFileTool(),
@@ -116,6 +211,7 @@ export const createConfiguredDefaultCloudAiTools = async (config?: {
116
211
  createCloudAiPresentTool(),
117
212
  createCloudAiCalculateTool(),
118
213
  createCloudAiViewImageTool(),
214
+ createCloudAiTranscribeAudioTool(),
119
215
  ];
120
216
  const firecrawlConfigured =
121
217
  config && "firecrawlApiKey" in config ? Boolean(config.firecrawlApiKey?.trim()) : await isCloudAiFirecrawlConfigured();
@@ -0,0 +1,171 @@
1
+ import { sql } from "bun";
2
+ import { lazySync } from "../_internal/process-sync";
3
+ import { logger, trace } from "../services/logging";
4
+ import { superviseRuntimeTask } from "../services/runtime-lifecycle";
5
+ import { aiModelAccess } from "./model-access";
6
+ import { aiProjects } from "./projects";
7
+ import { AiTranscriptionError, describeTranscriptionFailure, type RunAiTranscriptionInput, runAiTranscription } from "./transcription";
8
+
9
+ const log = logger("ai:dictations");
10
+ const queue = lazySync((sync) =>
11
+ sync.queue<{ id: string }>({ id: "cloud-ai-dictations", owner: "cloud", delivery: { ackWaitMs: 45_000, maxAttempts: 50 } }),
12
+ );
13
+ export const enqueueAiDictation = async (shortId: string): Promise<void> => {
14
+ const [task] = await sql<
15
+ { id: string }[]
16
+ >`SELECT id FROM ai.dictations WHERE short_id = ${shortId} AND status = 'queued' AND disposition = 'pending'`;
17
+ if (task) await queue().send({ data: { id: task.id }, orderingKey: task.id });
18
+ };
19
+ const MAX_ATTEMPTS = 3; // Same automatic attempt budget as workflow inference.
20
+ const LEASE_MS = 45_000;
21
+
22
+ type ClaimedDictation = {
23
+ id: string;
24
+ conversation_id: string;
25
+ user_id: string;
26
+ source_path: string;
27
+ source_bytes: Uint8Array;
28
+ language: string | null;
29
+ model_profile_id: string;
30
+ attempts: number;
31
+ };
32
+
33
+ /** Nessi 0.12 exposes normalized messages, not typed HTTP errors. Fail closed for unknown errors. */
34
+ export const isRetryableDictationError = (error: unknown): boolean => {
35
+ if (error instanceof AiTranscriptionError) return error.retryable;
36
+ if (!(error instanceof Error)) return false;
37
+ return (
38
+ /^(openai|openai-compatible) (408|409|425|429|5\d\d): /.test(error.message) ||
39
+ /^(openai|openai-compatible) connection failed: /.test(error.message)
40
+ );
41
+ };
42
+
43
+ export const processAiDictation = async (
44
+ id: string,
45
+ signal: AbortSignal,
46
+ run: (input: RunAiTranscriptionInput) => Promise<{ text: string; modelProfileId: string }> = runAiTranscription,
47
+ ): Promise<void> => {
48
+ const token = crypto.randomUUID();
49
+ const [task] = await sql<ClaimedDictation[]>`
50
+ UPDATE ai.dictations SET status = 'running', lease_token = ${token}, lease_until = now() + ${LEASE_MS} * interval '1 millisecond', attempts = attempts + 1, updated_at = now()
51
+ WHERE id = ${id} AND disposition = 'pending' AND source_bytes IS NOT NULL AND attempts < ${MAX_ATTEMPTS}
52
+ AND ((status = 'queued' AND next_attempt_at <= now()) OR (status = 'running' AND lease_until < now()))
53
+ RETURNING id, conversation_id, user_id, source_path, source_bytes, language, model_profile_id, attempts
54
+ `;
55
+ if (!task) return;
56
+ const span = await trace.start({
57
+ name: "ai.dictation",
58
+ source: "ai:dictations",
59
+ category: "ai",
60
+ attributes: { dictationId: id, conversationId: task.conversation_id, modelProfileId: task.model_profile_id, attempt: task.attempts },
61
+ });
62
+ let failure: AiTranscriptionError | undefined;
63
+ const canceled = new AbortController();
64
+ const combined = AbortSignal.any([signal, canceled.signal]);
65
+ let checking = false;
66
+ const heartbeat = async () => {
67
+ if (checking) return;
68
+ checking = true;
69
+ try {
70
+ const [row] = await sql`UPDATE ai.dictations d SET lease_until = now() + ${LEASE_MS} * interval '1 millisecond'
71
+ FROM ai.conversations c WHERE d.id = ${id} AND d.lease_token = ${token} AND d.status = 'running' AND d.disposition = 'pending'
72
+ AND c.id = d.conversation_id AND c.created_by_user_id = d.user_id AND c.archived_at IS NULL RETURNING d.id`;
73
+ if (!row) canceled.abort();
74
+ } catch {
75
+ canceled.abort();
76
+ } finally {
77
+ checking = false;
78
+ }
79
+ };
80
+ const timer = setInterval(() => void heartbeat(), 3_000);
81
+ try {
82
+ const [conversation] = await sql<
83
+ { project_id: string | null }[]
84
+ >`SELECT project_id FROM ai.conversations WHERE id = ${task.conversation_id} AND created_by_user_id = ${task.user_id} AND archived_at IS NULL`;
85
+ if (!conversation) throw new Error("Conversation unavailable.");
86
+ const subject = { type: "user" as const, userId: task.user_id };
87
+ await aiModelAccess.assertAllowed(task.model_profile_id, subject);
88
+ if (conversation.project_id && !(await aiProjects.snapshot(conversation.project_id, subject)))
89
+ throw new Error("Project access unavailable.");
90
+ const result = await run({
91
+ task: "dictation",
92
+ traceParent: span,
93
+ file: new Blob([new Uint8Array(task.source_bytes)]),
94
+ filename: task.source_path,
95
+ requestedModelId: task.model_profile_id,
96
+ language: task.language ?? undefined,
97
+ signal: combined,
98
+ attribution: { userId: task.user_id, conversationId: task.conversation_id },
99
+ });
100
+ combined.throwIfAborted();
101
+ // Same text payload ceiling as the existing file-write endpoint.
102
+ if (result.text.length > 12_000_000) throw new Error("Transcript exceeds the text file limit.");
103
+ await sql`UPDATE ai.dictations SET status = 'succeeded', result = ${result.text}, source_bytes = NULL, lease_token = NULL, lease_until = NULL, error_code = NULL, updated_at = now()
104
+ WHERE id = ${id} AND lease_token = ${token} AND status = 'running' AND disposition = 'pending'`;
105
+ } catch (error) {
106
+ failure = describeTranscriptionFailure(error, "configuration", combined.aborted);
107
+ const interrupted = signal.aborted;
108
+ const retry = !canceled.signal.aborted && task.attempts < MAX_ATTEMPTS && (interrupted || isRetryableDictationError(error));
109
+ log[combined.aborted ? "info" : retry ? "warn" : "error"](failure.message, {
110
+ dictationId: id,
111
+ traceId: span.traceId,
112
+ modelProfileId: task.model_profile_id,
113
+ attempt: task.attempts,
114
+ retry,
115
+ errorCode: failure.code,
116
+ });
117
+ await sql`UPDATE ai.dictations SET status = ${retry ? "queued" : "failed"}, lease_token = NULL, lease_until = NULL,
118
+ error_code = ${retry ? null : failure.code}, next_attempt_at = now() + ${task.attempts * 2} * interval '1 second', updated_at = now()
119
+ WHERE id = ${id} AND lease_token = ${token} AND status = 'running' AND disposition = 'pending'`;
120
+ } finally {
121
+ clearInterval(timer);
122
+ await trace.end({ context: span, status: failure ? "error" : "ok", statusMessage: failure?.message });
123
+ }
124
+ };
125
+
126
+ /** DB recovery is authoritative, including a crash after upload commit but before enqueue. */
127
+ export const sweepAiDictations = async (): Promise<void> => {
128
+ await sql`UPDATE ai.dictations SET status = 'failed', error_code = 'attempts_exhausted', lease_token = NULL, lease_until = NULL, updated_at = now()
129
+ WHERE disposition = 'pending' AND attempts >= ${MAX_ATTEMPTS} AND (status = 'queued' OR (status = 'running' AND lease_until < now()))`;
130
+ const tasks = await sql<{ id: string }[]>`SELECT id FROM ai.dictations WHERE disposition = 'pending'
131
+ AND ((status = 'queued' AND next_attempt_at <= now()) OR (status = 'running' AND lease_until < now())) ORDER BY updated_at LIMIT 100`;
132
+ for (const task of tasks) await queue().send({ data: { id: task.id }, orderingKey: task.id });
133
+ };
134
+
135
+ export const startAiDictationRuntime = (): (() => void) => {
136
+ const controller = new AbortController();
137
+ void superviseRuntimeTask({
138
+ name: "AI dictation worker",
139
+ signal: controller.signal,
140
+ run: async (signal) => {
141
+ const worker = await queue().process({ concurrency: 2, signal }, (message) => processAiDictation(message.data.id, message.signal));
142
+ try {
143
+ await new Promise<void>((resolve) => {
144
+ if (signal.aborted) resolve();
145
+ else signal.addEventListener("abort", () => resolve(), { once: true });
146
+ });
147
+ } finally {
148
+ worker.stop();
149
+ }
150
+ },
151
+ onError: () => log.warn("Dictation worker unavailable; retrying"),
152
+ });
153
+ let sweeping = false;
154
+ const sweep = async () => {
155
+ if (sweeping || controller.signal.aborted) return;
156
+ sweeping = true;
157
+ try {
158
+ await sweepAiDictations();
159
+ } catch {
160
+ log.warn("Dictation recovery unavailable; retrying");
161
+ } finally {
162
+ sweeping = false;
163
+ }
164
+ };
165
+ void sweep();
166
+ const timer = setInterval(() => void sweep(), 15_000);
167
+ return () => {
168
+ controller.abort();
169
+ clearInterval(timer);
170
+ };
171
+ };
@@ -0,0 +1,188 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+ import { inspectAiAudio } from "./audio-format";
4
+ import { createUniqueAiFileInTransaction } from "./files-store";
5
+ import { AiSaveConversationDraftInputSchema } from "./http";
6
+ import { withAiShortIdForDb } from "./short-id";
7
+ import { saveAiDraftInTransaction } from "./store";
8
+ import type { AiResolvedAudioModel } from "./transcription";
9
+
10
+ export const AiDictationStartSchema = z.object({
11
+ operationId: z.uuid(),
12
+ language: z
13
+ .string()
14
+ .regex(/^[a-z]{2}$/)
15
+ .optional(),
16
+ });
17
+ export const AiDictationListSchema = z.object({ after: z.uuid().optional(), limit: z.coerce.number().int().min(1).max(50).default(20) });
18
+ export type AiDictationStatus = "queued" | "running" | "succeeded" | "failed" | "canceled";
19
+ export type AiDictationDisposition = "pending" | "applied" | "discarded";
20
+ export type AiDictation = {
21
+ id: string;
22
+ sourcePath: string;
23
+ operationId: string;
24
+ status: AiDictationStatus;
25
+ disposition: AiDictationDisposition;
26
+ errorCode: string | null;
27
+ createdAt: string;
28
+ };
29
+ type DictationRow = {
30
+ id: string;
31
+ short_id: string;
32
+ operation_id: string;
33
+ source_path: string;
34
+ status: AiDictationStatus;
35
+ disposition: AiDictationDisposition;
36
+ error_code: string | null;
37
+ created_at: Date;
38
+ result: string | null;
39
+ request_hash: string;
40
+ };
41
+ const publicDictation = (row: DictationRow): AiDictation => ({
42
+ id: row.short_id,
43
+ sourcePath: row.source_path,
44
+ operationId: row.operation_id,
45
+ status: row.status,
46
+ disposition: row.disposition,
47
+ errorCode: row.error_code,
48
+ createdAt: row.created_at.toISOString(),
49
+ });
50
+ export class AiDictationConflict extends Error {}
51
+
52
+ const lockConversation = async (tx: typeof sql, conversationId: string, userId: string) => {
53
+ const [row] =
54
+ await tx`SELECT id FROM ai.conversations WHERE id = ${conversationId} AND created_by_user_id = ${userId} AND archived_at IS NULL FOR UPDATE`;
55
+ if (!row) throw new Error("Conversation is unavailable.");
56
+ };
57
+
58
+ export const aiDictations = {
59
+ async start(input: {
60
+ conversationId: string;
61
+ userId: string;
62
+ operationId: string;
63
+ bytes: Uint8Array;
64
+ language?: string;
65
+ resolveModel: () => Promise<AiResolvedAudioModel>;
66
+ }): Promise<AiDictation> {
67
+ AiDictationStartSchema.parse(input);
68
+ const format = inspectAiAudio(input.bytes);
69
+ const hash = new Bun.CryptoHasher("sha256")
70
+ .update(input.bytes)
71
+ .update(JSON.stringify({ language: input.language ?? null }))
72
+ .digest("hex");
73
+ return sql.begin(async (tx) => {
74
+ await lockConversation(tx, input.conversationId, input.userId);
75
+ const [canceled] = await tx`SELECT operation_id FROM ai.dictation_cancellations
76
+ WHERE conversation_id = ${input.conversationId} AND user_id = ${input.userId} AND operation_id = ${input.operationId}`;
77
+ if (canceled) throw new AiDictationConflict("This recording operation was discarded.");
78
+ const [previous] = await tx<
79
+ DictationRow[]
80
+ >`SELECT * FROM ai.dictations WHERE conversation_id = ${input.conversationId} AND user_id = ${input.userId} AND operation_id = ${input.operationId}`;
81
+ if (previous) {
82
+ if (previous.request_hash !== hash)
83
+ throw new AiDictationConflict("This recording operation already has different audio or options.");
84
+ return publicDictation(previous);
85
+ }
86
+ const [pending] =
87
+ await tx`SELECT id FROM ai.dictations WHERE conversation_id = ${input.conversationId} AND disposition = 'pending' LIMIT 1`;
88
+ if (pending) throw new AiDictationConflict("Finish or discard the pending dictation before starting another.");
89
+ const model = await input.resolveModel();
90
+ const row = await withAiShortIdForDb(tx, "ai_dictations_short_id_key", async (db, shortId) => {
91
+ const [created] = await db<DictationRow[]>`
92
+ INSERT INTO ai.dictations (short_id, conversation_id, user_id, operation_id, request_hash, source_path, source_bytes, media_type, model_profile_id, language)
93
+ VALUES (${shortId}, ${input.conversationId}, ${input.userId}, ${input.operationId}, ${hash}, ${`/dictation-${shortId}.${format.extension}`}, ${input.bytes}, ${format.mediaType}, ${model.profile.id}, ${input.language ?? null}) RETURNING *
94
+ `;
95
+ return created!;
96
+ });
97
+ // The inserted snapshot is already counted by the normal file-write quota.
98
+ const file = await createUniqueAiFileInTransaction(tx, {
99
+ conversationId: input.conversationId,
100
+ path: row.source_path,
101
+ bytes: input.bytes,
102
+ mediaType: format.mediaType,
103
+ origin: "user",
104
+ dictationRecordedAt: new Date(row.created_at).toISOString(),
105
+ });
106
+ await tx`UPDATE ai.dictations SET source_path = ${file.path} WHERE id = ${row.id}`;
107
+ return publicDictation({ ...row, source_path: file.path });
108
+ });
109
+ },
110
+ async list(input: { conversationId: string; userId: string; after?: string; limit?: number }) {
111
+ const limit = Math.min(50, Math.max(1, input.limit ?? 20));
112
+ // Cursor is opaque to the UI but validated as a UUID at the API boundary.
113
+ const rows = await sql<DictationRow[]>`
114
+ SELECT d.id, d.short_id, d.operation_id, d.source_path, d.status, d.disposition, d.error_code, d.created_at
115
+ FROM ai.dictations d JOIN ai.conversations c ON c.id = d.conversation_id
116
+ WHERE d.conversation_id = ${input.conversationId} AND c.created_by_user_id = ${input.userId} AND c.archived_at IS NULL
117
+ AND d.disposition = 'pending' AND (${input.after ?? null}::uuid IS NULL OR d.id > ${input.after ?? null}::uuid)
118
+ ORDER BY d.id LIMIT ${limit + 1}
119
+ `;
120
+ return { items: rows.slice(0, limit).map(publicDictation), nextCursor: rows.length > limit ? rows[limit - 1]!.id : null };
121
+ },
122
+ async get(input: { conversationId: string; userId: string; id: string }) {
123
+ const [row] = await sql<DictationRow[]>`
124
+ SELECT d.* FROM ai.dictations d JOIN ai.conversations c ON c.id = d.conversation_id
125
+ WHERE d.conversation_id = ${input.conversationId} AND c.created_by_user_id = ${input.userId} AND c.archived_at IS NULL AND d.short_id = ${input.id}
126
+ `;
127
+ return row ? { ...publicDictation(row), text: row.result } : null;
128
+ },
129
+ async discard(input: { conversationId: string; userId: string; id: string }) {
130
+ return sql.begin(async (tx) => {
131
+ await lockConversation(tx, input.conversationId, input.userId);
132
+ await tx`UPDATE ai.dictations SET disposition = 'discarded', status = CASE WHEN status IN ('queued', 'running') THEN 'canceled' ELSE status END,
133
+ source_bytes = NULL, lease_token = NULL, lease_until = NULL, updated_at = now()
134
+ WHERE conversation_id = ${input.conversationId} AND user_id = ${input.userId} AND short_id = ${input.id} AND disposition = 'pending'`;
135
+ });
136
+ },
137
+ async discardOperation(input: { conversationId: string; userId: string; operationId: string }) {
138
+ z.uuid().parse(input.operationId);
139
+ return sql.begin(async (tx) => {
140
+ // Same lock as start/apply: the marker also fences a start that has not arrived yet.
141
+ await lockConversation(tx, input.conversationId, input.userId);
142
+ const [row] = await tx<DictationRow[]>`SELECT * FROM ai.dictations
143
+ WHERE conversation_id = ${input.conversationId} AND user_id = ${input.userId} AND operation_id = ${input.operationId}`;
144
+ if (row?.disposition === "applied") return { disposition: "applied" as const };
145
+ await tx`INSERT INTO ai.dictation_cancellations (conversation_id, user_id, operation_id)
146
+ VALUES (${input.conversationId}, ${input.userId}, ${input.operationId}) ON CONFLICT DO NOTHING`;
147
+ await tx`UPDATE ai.dictations SET disposition = 'discarded', status = 'canceled',
148
+ source_bytes = NULL, lease_token = NULL, lease_until = NULL, updated_at = now()
149
+ WHERE conversation_id = ${input.conversationId} AND user_id = ${input.userId} AND operation_id = ${input.operationId}
150
+ AND disposition = 'pending'`;
151
+ return { disposition: "discarded" as const };
152
+ });
153
+ },
154
+ async retry(input: { conversationId: string; userId: string; id: string }) {
155
+ return sql.begin(async (tx) => {
156
+ await lockConversation(tx, input.conversationId, input.userId);
157
+ await tx`UPDATE ai.dictations SET status = 'queued', attempts = 0, error_code = NULL, next_attempt_at = now(), updated_at = now()
158
+ WHERE conversation_id = ${input.conversationId} AND user_id = ${input.userId} AND short_id = ${input.id}
159
+ AND status = 'failed' AND disposition = 'pending' AND source_bytes IS NOT NULL`;
160
+ });
161
+ },
162
+ async apply(input: { conversationId: string; userId: string; id: string } & z.infer<typeof AiSaveConversationDraftInputSchema>) {
163
+ AiSaveConversationDraftInputSchema.parse(input);
164
+ return sql.begin(async (tx) => {
165
+ await lockConversation(tx, input.conversationId, input.userId);
166
+ const [row] = await tx<
167
+ DictationRow[]
168
+ >`SELECT * FROM ai.dictations WHERE conversation_id = ${input.conversationId} AND user_id = ${input.userId} AND short_id = ${input.id} FOR UPDATE`;
169
+ if (!row) throw new Error("Dictation is unavailable.");
170
+ if (row.disposition !== "pending") return { disposition: row.disposition, draft: null };
171
+ if (row.status !== "succeeded" || row.result === null) throw new AiDictationConflict("Dictation is not ready.");
172
+ const text = [...input.content.filter((part) => part.type === "text").map((part) => part.text), row.result]
173
+ .filter(Boolean)
174
+ .join("\n\n");
175
+ const content = [...(text ? [{ type: "text" as const, text }] : []), ...input.content.filter((part) => part.type !== "text")];
176
+ AiSaveConversationDraftInputSchema.parse({ expectedRevision: input.expectedRevision, content });
177
+ const saved = await saveAiDraftInTransaction(tx, {
178
+ conversationId: input.conversationId,
179
+ ownerUserId: input.userId,
180
+ expectedRevision: input.expectedRevision,
181
+ content,
182
+ });
183
+ if (!saved.ok) throw new AiDictationConflict("The conversation draft changed. Review the saved draft before inserting.");
184
+ await tx`UPDATE ai.dictations SET disposition = 'applied', updated_at = now() WHERE id = ${row.id}`;
185
+ return { disposition: "applied" as const, draft: saved.draft };
186
+ });
187
+ },
188
+ };
@@ -0,0 +1,49 @@
1
+ import type { AiDraftContentPart } from "./types";
2
+
3
+ export const aiDraftPartLabel = (part: AiDraftContentPart): string =>
4
+ part.type === "text"
5
+ ? part.text
6
+ : part.type === "resource"
7
+ ? (part.title ?? part.ref.id)
8
+ : part.type === "project-file"
9
+ ? part.path
10
+ : part.path.split("/").at(-1) || part.path;
11
+
12
+ export const aiDraftText = (content: readonly AiDraftContentPart[]) =>
13
+ content.map((part) => (part.type === "text" || part.inline ? aiDraftPartLabel(part) : "")).join("");
14
+
15
+ /** Queue text edits preserve untouched inline identities and ordinary attachments. */
16
+ export function editAiDraftText(content: readonly AiDraftContentPart[], text: string): AiDraftContentPart[] {
17
+ const before = aiDraftText(content);
18
+ let start = 0,
19
+ oldEnd = before.length,
20
+ newEnd = text.length;
21
+ while (start < oldEnd && start < newEnd && before[start] === text[start]) start++;
22
+ while (oldEnd > start && newEnd > start && before[oldEnd - 1] === text[newEnd - 1]) {
23
+ oldEnd--;
24
+ newEnd--;
25
+ }
26
+ let position = 0;
27
+ const retained = content.flatMap((part) => {
28
+ if (part.type === "text") {
29
+ position += part.text.length;
30
+ return [];
31
+ }
32
+ if (!part.inline) return [];
33
+ const from = position;
34
+ position += aiDraftPartLabel(part).length;
35
+ if (position <= start) return [{ from, end: position, part }];
36
+ if (from >= oldEnd) return [{ from: from + newEnd - oldEnd, end: position + newEnd - oldEnd, part }];
37
+ return [];
38
+ });
39
+ const result: AiDraftContentPart[] = [];
40
+ let offset = 0;
41
+ for (const item of retained) {
42
+ if (item.from > offset) result.push({ type: "text", text: text.slice(offset, item.from) });
43
+ result.push(item.part);
44
+ offset = item.end;
45
+ }
46
+ if (offset < text.length) result.push({ type: "text", text: text.slice(offset) });
47
+ result.push(...content.filter((part) => part.type !== "text" && !part.inline));
48
+ return result;
49
+ }
package/src/ai/enrich.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { coreSettings } from "../services";
3
3
  import type { TraceContext } from "../services/logging";
4
4
  import { logger, trace } from "../services/logging";
5
+ import { AiBackgroundAdmissionError, AiBackgroundCostError } from "./inference-calls";
5
6
  import { aiConversations } from "./store";
6
7
  import type { RunAiStructuredInput, RunAiStructuredResult } from "./structured";
7
8
  import { resolveAiBackgroundModel, runAiStructured } from "./structured";
@@ -257,6 +258,11 @@ export const enrichDirtyAiConversations = async (input: {
257
258
  try {
258
259
  await enrichOne(conversation, span);
259
260
  } catch (error) {
261
+ if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) {
262
+ summary.skipped += 1;
263
+ await store.recordEnrichmentRun({ conversationId: conversation.id, status: "skipped", trigger });
264
+ break;
265
+ }
260
266
  // Stays dirty; the failure marker backs it off so one poison chat
261
267
  // cannot burn a model call every single slot.
262
268
  summary.failed += 1;