@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -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, runAiTranscription, type RunAiTranscriptionInput } 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,34 @@
1
+ import type { AiDraftContentPart } from "./types";
2
+
3
+ export const aiDraftPartLabel = (part: AiDraftContentPart): string => part.type === "text" ? part.text
4
+ : part.type === "resource" ? part.title ?? part.ref.id
5
+ : part.type === "project-file" ? part.path : part.path.split("/").at(-1) || part.path;
6
+
7
+ export const aiDraftText = (content: readonly AiDraftContentPart[]) => content.map(part =>
8
+ part.type === "text" || part.inline ? aiDraftPartLabel(part) : "").join("");
9
+
10
+ /** Queue text edits preserve untouched inline identities and ordinary attachments. */
11
+ export function editAiDraftText(content: readonly AiDraftContentPart[], text: string): AiDraftContentPart[] {
12
+ const before = aiDraftText(content);
13
+ let start = 0, oldEnd = before.length, newEnd = text.length;
14
+ while (start < oldEnd && start < newEnd && before[start] === text[start]) start++;
15
+ while (oldEnd > start && newEnd > start && before[oldEnd - 1] === text[newEnd - 1]) { oldEnd--; newEnd--; }
16
+ let position = 0;
17
+ const retained = content.flatMap(part => {
18
+ if (part.type === "text") { position += part.text.length; return []; }
19
+ if (!part.inline) return [];
20
+ const from = position; position += aiDraftPartLabel(part).length;
21
+ if (position <= start) return [{ from, end: position, part }];
22
+ if (from >= oldEnd) return [{ from: from + newEnd - oldEnd, end: position + newEnd - oldEnd, part }];
23
+ return [];
24
+ });
25
+ const result: AiDraftContentPart[] = [];
26
+ let offset = 0;
27
+ for (const item of retained) {
28
+ if (item.from > offset) result.push({ type: "text", text: text.slice(offset, item.from) });
29
+ result.push(item.part); offset = item.end;
30
+ }
31
+ if (offset < text.length) result.push({ type: "text", text: text.slice(offset) });
32
+ result.push(...content.filter(part => part.type !== "text" && !part.inline));
33
+ return result;
34
+ }
package/src/ai/enrich.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { AiBackgroundAdmissionError, AiBackgroundCostError } from "./inference-calls";
1
2
  import { z } from "zod";
2
3
  import { coreSettings } from "../services";
3
4
  import type { TraceContext } from "../services/logging";
@@ -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;