@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
@@ -1,20 +1,21 @@
1
- import { sql } from "bun";
2
1
  import { createHash } from "node:crypto";
2
+ import { sql } from "bun";
3
3
  import { z } from "zod";
4
4
  import { CloudResourceRefSchema } from "../contracts/capabilities";
5
5
  import { coreSettings } from "../services";
6
6
  import { logger } from "../services/logging";
7
7
  import { normalizeLocale } from "../shared/locale";
8
8
  import { parseAiAttachmentMarkers } from "./attachments";
9
+ import { AiBackgroundAdmissionError, AiBackgroundCostError } from "./inference-calls";
10
+ import { type AiBackgroundMemoryProposal, aiMemories } from "./memories";
9
11
  import { type AiMemoryLearningChange, aiMemoryLearningRuns } from "./memory-learning-runs";
10
12
  import {
13
+ type AiMemoryWorkflowEvidence,
14
+ type AiMemoryWorkflowPattern,
11
15
  listAiPendingWorkflowPatterns,
12
16
  listAiTurnWorkflowEvidence,
13
17
  markAiWorkflowPatternReviewed,
14
- type AiMemoryWorkflowEvidence,
15
- type AiMemoryWorkflowPattern,
16
18
  } from "./memory-workflow-evidence";
17
- import { type AiBackgroundMemoryProposal, aiMemories } from "./memories";
18
19
  import { aiConversations } from "./store";
19
20
  import type { RunAiStructuredInput, RunAiStructuredResult } from "./structured";
20
21
  import { resolveAiBackgroundModel, runAiStructured } from "./structured";
@@ -217,13 +218,7 @@ export const buildMemoryLearningTranscript = (messages: AiStoredMessage[]): stri
217
218
  messages
218
219
  .flatMap((stored) => {
219
220
  const message = stored.message;
220
- if (
221
- stored.kind !== "message" ||
222
- message.role !== "user" ||
223
- stored.meta?.agentMessage ||
224
- stored.meta?.scheduledTask
225
- )
226
- return [];
221
+ if (stored.kind !== "message" || message.role !== "user" || stored.meta?.agentMessage || stored.meta?.scheduledTask) return [];
227
222
  return message.content.flatMap((part) => {
228
223
  if (typeof part === "string") return part.trim() ? [part.trim()] : [];
229
224
  if (part.type !== "text") return [];
@@ -305,7 +300,15 @@ const estimatedTokens = (systemPrompt: string, taskInput: string): number =>
305
300
 
306
301
  const workflowEvidenceKey = (pattern: AiMemoryWorkflowPattern): string =>
307
302
  createHash("sha256")
308
- .update(JSON.stringify([pattern.userId, pattern.capabilityId, pattern.resourceRef.type, pattern.resourceRef.id, ...[...pattern.turnIds].sort()]))
303
+ .update(
304
+ JSON.stringify([
305
+ pattern.userId,
306
+ pattern.capabilityId,
307
+ pattern.resourceRef.type,
308
+ pattern.resourceRef.id,
309
+ ...[...pattern.turnIds].sort(),
310
+ ]),
311
+ )
309
312
  .digest("hex");
310
313
 
311
314
  const toProposal = (change: z.infer<typeof AiMemoryLearningChangeSchema>): AiBackgroundMemoryProposal => ({
@@ -329,7 +332,8 @@ const applyChanges = async (
329
332
  !evidence.actions.some(
330
333
  (action) => action.resourceRef.type === change.resourceRef?.type && action.resourceRef.id === change.resourceRef.id,
331
334
  )
332
- ) continue;
335
+ )
336
+ continue;
333
337
  const results = await aiMemories.applyBackgroundProposal({
334
338
  userId: evidence.candidate.userId,
335
339
  sourceConversationId: evidence.candidate.conversationId,
@@ -376,9 +380,7 @@ const workflowPatternInput = async (
376
380
  return {
377
381
  source: evidence[0] ?? null,
378
382
  mutableMemoryIds: new Set(
379
- selected.memories
380
- .filter((memory) => memory.source === "background" && memory.priority === "normal")
381
- .map((memory) => memory.shortId),
383
+ selected.memories.filter((memory) => memory.source === "background" && memory.priority === "normal").map((memory) => memory.shortId),
382
384
  ),
383
385
  input: [
384
386
  `CAPABILITY: ${pattern.capabilityId}`,
@@ -395,7 +397,8 @@ const workflowPatternInput = async (
395
397
  const usageNumbers = (usage: RunAiStructuredResult<z.ZodType>["usage"], reserved: number) => {
396
398
  const inputTokens = usage?.input;
397
399
  const outputTokens = usage?.output;
398
- const total = usage?.total ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
400
+ const total =
401
+ usage?.total ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
399
402
  return { inputTokens, outputTokens, accountedTokens: total ?? reserved };
400
403
  };
401
404
 
@@ -414,8 +417,7 @@ export const learnAiMemoriesFromPrivateChats = async (
414
417
  }
415
418
 
416
419
  const configuredBudget =
417
- input.deps?.monthlyTokenBudget ??
418
- Number(await coreSettings.get<number>(AI_MEMORY_LEARNING_MONTHLY_TOKEN_BUDGET_SETTING_KEY));
420
+ input.deps?.monthlyTokenBudget ?? Number(await coreSettings.get<number>(AI_MEMORY_LEARNING_MONTHLY_TOKEN_BUDGET_SETTING_KEY));
419
421
  const monthlyTokenBudget =
420
422
  Number.isFinite(configuredBudget) && configuredBudget > 0
421
423
  ? Math.floor(configuredBudget)
@@ -432,9 +434,7 @@ export const learnAiMemoriesFromPrivateChats = async (
432
434
  let defaultLocale: Promise<string> | undefined;
433
435
  const resolveLearningLocale = (locale?: string | null): Promise<string> => {
434
436
  if (locale) return Promise.resolve(normalizeLocale(locale));
435
- defaultLocale ??= (input.deps?.readDefaultLocale ?? (() => coreSettings.get<string>("app.locale")))().then(
436
- normalizeLocale,
437
- );
437
+ defaultLocale ??= (input.deps?.readDefaultLocale ?? (() => coreSettings.get<string>("app.locale")))().then(normalizeLocale);
438
438
  return defaultLocale;
439
439
  };
440
440
 
@@ -514,6 +514,18 @@ export const learnAiMemoriesFromPrivateChats = async (
514
514
  if (status === "skipped") summary.skipped += 1;
515
515
  processedCandidates += 1;
516
516
  } catch (error) {
517
+ if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) {
518
+ summary.skipped += 1;
519
+ if (runId)
520
+ await aiMemoryLearningRuns.finish({
521
+ runId,
522
+ status: "skipped",
523
+ durationMs: Date.now() - startedAt,
524
+ changes: [],
525
+ accountedTokens: 0,
526
+ });
527
+ return summary;
528
+ }
517
529
  summary.failed += 1;
518
530
  processedCandidates += 1;
519
531
  await markFailed(candidate).catch(() => undefined);
@@ -564,7 +576,11 @@ export const learnAiMemoriesFromPrivateChats = async (
564
576
  if (!runId) continue;
565
577
  const result = await structured({
566
578
  task: "memory-learn-workflow",
567
- attribution: { userId: pattern.userId, conversationId: context.source.candidate.conversationId, turnId: context.source.candidate.turnId },
579
+ attribution: {
580
+ userId: pattern.userId,
581
+ conversationId: context.source.candidate.conversationId,
582
+ turnId: context.source.candidate.turnId,
583
+ },
568
584
  appId: "ai",
569
585
  systemPrompt: workflowPrompt,
570
586
  input: context.input,
@@ -578,15 +594,19 @@ export const learnAiMemoriesFromPrivateChats = async (
578
594
  if (result.output.workflow && (await learningEnabled(pattern.userId))) {
579
595
  const memoryIds = result.output.workflow.memoryIds;
580
596
  const action = memoryIds.length > 1 ? "merge" : memoryIds.length === 1 ? "replace" : "add";
581
- changes = await applyChanges(context.source, [
582
- {
583
- action,
584
- kind: "workflow",
585
- content: result.output.workflow.content,
586
- memoryIds,
587
- resourceRef: pattern.resourceRef,
588
- },
589
- ], context.mutableMemoryIds);
597
+ changes = await applyChanges(
598
+ context.source,
599
+ [
600
+ {
601
+ action,
602
+ kind: "workflow",
603
+ content: result.output.workflow.content,
604
+ memoryIds,
605
+ resourceRef: pattern.resourceRef,
606
+ },
607
+ ],
608
+ context.mutableMemoryIds,
609
+ );
590
610
  }
591
611
  await markAiWorkflowPatternReviewed(pattern);
592
612
  await aiMemoryLearningRuns.finish({
@@ -600,6 +620,18 @@ export const learnAiMemoriesFromPrivateChats = async (
600
620
  summary.updated += changes.filter((change) => change.action === "updated" || change.action === "merged").length;
601
621
  if (changes.length === 0) summary.skipped += 1;
602
622
  } catch (error) {
623
+ if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) {
624
+ summary.skipped += 1;
625
+ if (runId)
626
+ await aiMemoryLearningRuns.finish({
627
+ runId,
628
+ status: "skipped",
629
+ durationMs: Date.now() - startedAt,
630
+ changes: [],
631
+ accountedTokens: 0,
632
+ });
633
+ return summary;
634
+ }
603
635
  summary.failed += 1;
604
636
  if (runId) {
605
637
  await aiMemoryLearningRuns
@@ -108,11 +108,7 @@ export const createCloudAiMemoryTool = (evidence = "") =>
108
108
  }
109
109
 
110
110
  const memory = await aiMemories.getByShortId(userId, input.id);
111
- if (
112
- memory &&
113
- !memoryMutationSupportedByEvidence(evidence, input.id) &&
114
- !memoryMutationSupportedByEvidence(evidence, memory.content)
115
- ) {
111
+ if (memory && !memoryMutationSupportedByEvidence(evidence, input.id) && !memoryMutationSupportedByEvidence(evidence, memory.content)) {
116
112
  return { ok: false, message: "A memory can only be forgotten when the user identifies it in this turn." };
117
113
  }
118
114
  if (!memory || !(await aiMemories.deleteByShortId(userId, input.id))) return { ok: false, message: "Memory not found." };
@@ -37,10 +37,15 @@ export const recordAiMemoryWorkflowEvidence = async (input: {
37
37
  input.resources.flatMap((resource) => {
38
38
  const parsed = CloudResourceRefSchema.safeParse(resource.ref);
39
39
  if (!parsed.success) return [];
40
- return [[`${parsed.data.type}\0${parsed.data.id}`, {
41
- ref: parsed.data,
42
- ...(resource.title ? { title: resource.title.trim().slice(0, MAX_RESOURCE_TITLE_CHARS) } : {}),
43
- }] as const];
40
+ return [
41
+ [
42
+ `${parsed.data.type}\0${parsed.data.id}`,
43
+ {
44
+ ref: parsed.data,
45
+ ...(resource.title ? { title: resource.title.trim().slice(0, MAX_RESOURCE_TITLE_CHARS) } : {}),
46
+ },
47
+ ] as const,
48
+ ];
44
49
  }),
45
50
  ).values(),
46
51
  ].slice(0, MAX_RECEIPT_RESOURCES);
@@ -0,0 +1,178 @@
1
+ import { sql } from "bun";
2
+ import { logger } from "../services/logging";
3
+ import { aiChatAccessSubject, isAssistantChatTurn } from "./assistant-models";
4
+ import { editAiDraftText } from "./draft-content";
5
+ import { canonicalizeAiConversationAttachments } from "./file-context";
6
+ import { AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT } from "./files-store";
7
+ import { aiInputToUserMessage, aiTurnInputToContent } from "./http";
8
+ import { AiQuotaError, aiQuotas } from "./quotas";
9
+ import { aiResourceMarker } from "./resource-markers";
10
+ import { aiConversations } from "./store";
11
+ import type { AiConversationService, AiDraftContentPart } from "./types";
12
+
13
+ const log = logger("ai:message-queue");
14
+ export const AI_MESSAGE_QUEUE_LIMIT = 32;
15
+ export class AiMessageQueueConflict extends Error {}
16
+
17
+ type Submission = Parameters<AiConversationService["submitChatTurn"]>[0];
18
+
19
+ export const migrateAiMessageQueue = async () => {
20
+ await sql`CREATE TABLE IF NOT EXISTS ai.queued_messages (
21
+ id uuid PRIMARY KEY, conversation_id uuid NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
22
+ position bigint GENERATED ALWAYS AS IDENTITY, submission jsonb NOT NULL, content jsonb NOT NULL,
23
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','failed','submitted','cancelled')),
24
+ error text, created_at timestamptz NOT NULL DEFAULT now()
25
+ )`.simple();
26
+ await sql`ALTER TABLE ai.queued_messages ADD COLUMN IF NOT EXISTS quota_checked_at TIMESTAMPTZ`.simple();
27
+ await sql`CREATE INDEX IF NOT EXISTS ai_queued_messages_pending ON ai.queued_messages(conversation_id, position) WHERE status IN ('pending','failed')`.simple();
28
+ await sql`CREATE TABLE IF NOT EXISTS ai.queued_message_files (
29
+ message_id uuid NOT NULL REFERENCES ai.queued_messages(id) ON DELETE CASCADE,
30
+ path text NOT NULL, bytes bytea NOT NULL, media_type text NOT NULL, size bigint NOT NULL,
31
+ origin text NOT NULL, dictation_recorded_at timestamptz, updated_at timestamptz NOT NULL, version bigint NOT NULL,
32
+ PRIMARY KEY(message_id, path)
33
+ )`.simple();
34
+ await sql`DROP TRIGGER IF EXISTS ai_live_queued_messages_changed ON ai.queued_messages`.simple();
35
+ await sql`CREATE TRIGGER ai_live_queued_messages_changed AFTER INSERT OR DELETE ON ai.queued_messages
36
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-detail,conversation-list')`.simple();
37
+ await sql`DROP TRIGGER IF EXISTS ai_live_queued_messages_updated ON ai.queued_messages`.simple();
38
+ await sql`CREATE TRIGGER ai_live_queued_messages_updated AFTER UPDATE ON ai.queued_messages
39
+ FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.error IS DISTINCT FROM NEW.error OR OLD.content IS DISTINCT FROM NEW.content)
40
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-detail,conversation-list')`.simple();
41
+ };
42
+
43
+ export const listQueuedMessages = async (conversationId: string) => {
44
+ const rows = await sql<{ id: string; content: AiDraftContentPart[]; error: string | null }[]>`
45
+ SELECT id, content, error FROM ai.queued_messages
46
+ WHERE conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') ORDER BY position`;
47
+ return rows.map((row) => ({ id: row.id, content: row.content, failed: Boolean(row.error) }));
48
+ };
49
+
50
+ export const queuedMessageAccepted = async (conversationId: string, id: string) => {
51
+ const [row] = await sql`SELECT id FROM ai.queued_messages WHERE id=${id}::uuid AND conversation_id=${conversationId}::uuid`;
52
+ return Boolean(row);
53
+ };
54
+
55
+ export const enqueueChatMessage = async (id: string, input: Submission, content: AiDraftContentPart[]) =>
56
+ sql.begin(async (tx) => {
57
+ const [conversation] = await tx<{ draft_revision: number; project_id: string | null }[]>`
58
+ SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId}::uuid AND archived_at IS NULL FOR UPDATE`;
59
+ if (!conversation) throw new AiMessageQueueConflict("Conversation not found.");
60
+ const [existing] =
61
+ await tx`SELECT id FROM ai.queued_messages WHERE id = ${id}::uuid AND conversation_id = ${input.conversationId}::uuid`;
62
+ if (existing) return;
63
+ if (Number(conversation.draft_revision) !== input.expectedDraftRevision)
64
+ throw new AiMessageQueueConflict("Conversation draft changed before the message was queued.");
65
+ if (conversation.project_id !== input.expectedProjectId)
66
+ throw new AiMessageQueueConflict("Conversation Project changed before the message was queued.");
67
+ const [count] = await tx<
68
+ { count: number }[]
69
+ >`SELECT count(*)::int AS count FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed')`;
70
+ if (count!.count >= AI_MESSAGE_QUEUE_LIMIT) throw new AiMessageQueueConflict("The message queue is full (32 messages).");
71
+ const [size] = await tx<
72
+ { size: number }[]
73
+ >`SELECT COALESCE(sum(f.size),0)::float8 AS size FROM ai.queued_message_files f JOIN ai.queued_messages q ON q.id = f.message_id WHERE q.conversation_id = ${input.conversationId}::uuid`;
74
+ if (
75
+ Number(size!.size) + (input.runConfig.files?.attached ?? []).reduce((sum, file) => sum + file.size, 0) >
76
+ AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT
77
+ )
78
+ throw new AiMessageQueueConflict("Queued attachments exceed the conversation file budget.");
79
+ const { expectedDraftRevision, ...submission } = input;
80
+ await tx`INSERT INTO ai.queued_messages(id, conversation_id, submission, content) VALUES (${id}::uuid, ${input.conversationId}::uuid, (${JSON.stringify(submission)}::text)::jsonb, (${JSON.stringify(content)}::text)::jsonb)`;
81
+ for (const file of input.runConfig.files?.attached ?? []) {
82
+ const copied =
83
+ await tx`INSERT INTO ai.queued_message_files(message_id,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version)
84
+ SELECT ${id}::uuid,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version FROM ai.files
85
+ WHERE conversation_id = ${input.conversationId}::uuid AND path = ${file.path} AND version = ${file.version ?? -1} RETURNING path`;
86
+ if (!copied.length) throw new AiMessageQueueConflict("An attached file changed before the message was queued.");
87
+ }
88
+ await tx`UPDATE ai.conversations SET draft_content = '[]'::jsonb, draft_revision = draft_revision + 1, draft_updated_at = now(), last_used_at = now(), done = CASE WHEN done IS TRUE THEN NULL ELSE done END WHERE id = ${input.conversationId}::uuid`;
89
+ });
90
+
91
+ /** Cancellation and retry share the conversation lock with promotion. */
92
+ export const updateQueuedMessage = async (conversationId: string, id: string, action: "cancel" | "retry") =>
93
+ sql.begin(async (tx) => {
94
+ await tx`SELECT id FROM ai.conversations WHERE id = ${conversationId}::uuid FOR UPDATE`;
95
+ const changed = await tx`UPDATE ai.queued_messages SET status = ${action === "cancel" ? "cancelled" : "pending"}, error = NULL
96
+ WHERE id = ${id}::uuid AND conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') RETURNING id`;
97
+ if (action === "cancel" && changed.length) {
98
+ await tx`DELETE FROM ai.queued_message_files WHERE message_id = ${id}::uuid`;
99
+ await tx`UPDATE ai.queued_messages SET submission = '{}'::jsonb, content = '[]'::jsonb WHERE id = ${id}::uuid`;
100
+ }
101
+ return Boolean(changed.length);
102
+ });
103
+
104
+ /** Edit only queued text; immutable attachments and the independent composer draft stay intact. */
105
+ export const editQueuedMessage = async (conversationId: string, id: string, text: string) =>
106
+ sql.begin(async (tx) => {
107
+ await tx`SELECT id FROM ai.conversations WHERE id = ${conversationId}::uuid FOR UPDATE`;
108
+ const [row] = await tx<{ submission: Submission; content: AiDraftContentPart[] }[]>`SELECT submission, content FROM ai.queued_messages
109
+ WHERE id = ${id}::uuid AND conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') FOR UPDATE`;
110
+ if (!row) return false;
111
+ const content = editAiDraftText(row.content, text);
112
+ const parts = content.map((part) =>
113
+ part.type === "text"
114
+ ? part
115
+ : part.type === "project-file"
116
+ ? { type: "text" as const, text: `Project file: ${part.path}` }
117
+ : part.type === "file"
118
+ ? { type: "attachment" as const, path: part.path, mediaType: part.mediaType, size: part.size }
119
+ : { type: "text" as const, text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }) },
120
+ );
121
+ const { message } = aiInputToUserMessage(aiTurnInputToContent({ content: parts }));
122
+ const userMessage = canonicalizeAiConversationAttachments(message, row.submission.runConfig.files);
123
+ if (userMessage.role !== "user") throw new Error("Queued message must be a user message.");
124
+ const submission = {
125
+ ...row.submission,
126
+ userMessage,
127
+ runConfig: {
128
+ ...row.submission.runConfig,
129
+ input: userMessage.content,
130
+ selectedSkillIds: [
131
+ ...new Set(content.flatMap((part) => (part.type === "resource" && part.ref.type === "core.ai.skill" ? [part.ref.id] : []))),
132
+ ],
133
+ },
134
+ };
135
+ await tx`UPDATE ai.queued_messages SET submission = (${JSON.stringify(submission)}::text)::jsonb,
136
+ content = (${JSON.stringify(content)}::text)::jsonb WHERE id = ${id}::uuid`;
137
+ return true;
138
+ });
139
+
140
+ /** Existing turn creation owns execution and recovery; the DB lock owns FIFO and deduplication. */
141
+ export const drainQueuedMessages = async (
142
+ enqueue: (job: { conversationId: string; turnId: string }) => Promise<unknown>,
143
+ conversationId?: string,
144
+ ) => {
145
+ const rows = await sql<{ id: string; conversation_id: string; submission: Submission }[]>`
146
+ SELECT q.id, q.conversation_id, q.submission FROM ai.queued_messages q
147
+ JOIN ai.conversations c ON c.id = q.conversation_id
148
+ WHERE (q.status = 'pending' OR (q.status = 'failed' AND q.error IN ('quota_exhausted','quota_usage_unknown'))) AND c.archived_at IS NULL AND c.created_by_user_id IS NOT NULL
149
+ AND (${conversationId ?? null}::uuid IS NULL OR q.conversation_id = ${conversationId ?? null}::uuid)
150
+ AND NOT EXISTS (SELECT 1 FROM ai.queued_messages earlier WHERE earlier.conversation_id = q.conversation_id AND earlier.position < q.position AND earlier.status IN ('pending','failed'))
151
+ AND NOT EXISTS (SELECT 1 FROM ai.turns t WHERE t.conversation_id = q.conversation_id AND NOT COALESCE(t.run_config ? 'background', false) AND t.status IN ('queued','running','waiting_for_action'))
152
+ ORDER BY q.quota_checked_at NULLS FIRST, q.position LIMIT ${AI_MESSAGE_QUEUE_LIMIT}`;
153
+ for (const row of rows) {
154
+ try {
155
+ const quotaSubject = aiChatAccessSubject(row.submission.runConfig.actor);
156
+ if (isAssistantChatTurn(row.submission.runConfig) && quotaSubject)
157
+ await aiQuotas.assertAllowed(quotaSubject, row.submission.modelProfileId);
158
+ await sql`UPDATE ai.queued_messages SET status='pending', error=NULL WHERE id=${row.id}::uuid AND status='failed' AND error IN ('quota_exhausted','quota_usage_unknown')`;
159
+ const result = await aiConversations.submitChatTurn({ ...row.submission, queuedMessageId: row.id });
160
+ await enqueue({ conversationId: row.conversation_id, turnId: result.turn.id });
161
+ } catch (error) {
162
+ if (error instanceof AiQuotaError) {
163
+ await sql`UPDATE ai.queued_messages SET status='failed', error=${error.code}, quota_checked_at=clock_timestamp() WHERE id=${row.id}::uuid AND status IN ('pending','failed')`;
164
+ continue;
165
+ }
166
+ // Another dispatcher may have consumed it or a new foreground turn may have won.
167
+ const reason = error instanceof Error ? error.message : String(error);
168
+ if (reason === "Queued message is no longer ready." || reason.includes("idx_ai_turns_one_active_per_conversation")) continue;
169
+ await sql`UPDATE ai.queued_messages SET status = 'failed', error = 'dispatch_failed' WHERE id = ${row.id}::uuid AND status = 'pending'`;
170
+ log.error("Queued message dispatch failed", {
171
+ conversationId: row.conversation_id,
172
+ messageId: row.id,
173
+ code: "queue_dispatch_failed",
174
+ errorType: error instanceof Error ? error.name : "unknown",
175
+ });
176
+ }
177
+ }
178
+ };