@k2b/cloud 0.40.0 → 0.42.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 (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -1,9 +1,9 @@
1
1
  import type { AssistantMessage, CompactEvent, LoopAggregate, Message, NessiLoop, OutboundEvent } from "@k2b/nessi";
2
2
  import { compact, nessi } from "@k2b/nessi";
3
3
  import { capabilityReviewRememberScope } from "../_internal/capability-reviews";
4
- import { listCapabilities } from "../_internal/registry";
4
+ import { getCapability, listCapabilities } from "../_internal/registry";
5
5
  import type { CapabilityActionReviewData } from "../contracts/capabilities";
6
- import { capabilityActionPolicy } from "../contracts/capabilities";
6
+ import { CapabilityActionReviewDataSchema, capabilityActionPolicy } from "../contracts/capabilities";
7
7
  import type { AccessSubject, RequestActor } from "../server";
8
8
  import { approvalAssurance, approvalScopeFromToolName, approvals } from "../services/approvals";
9
9
  import { createHelpReader } from "../services/help";
@@ -73,6 +73,7 @@ import {
73
73
  import { retryTransientProviderErrors } from "./provider-retry";
74
74
  import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
75
75
  import { collectConversationResourceObservations } from "./resource-refs";
76
+ import { compactCapabilityReview } from "./review-content";
76
77
  import { AiRunTimeout } from "./run-timeout";
77
78
  import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
78
79
  import { mergeAiSkillCatalog, selectAiSkillCatalog } from "./skill-catalog";
@@ -456,7 +457,12 @@ const approvalReviewForCallId = (
456
457
  * to (attempt, turn) so re-claimed attempts never collide, and (b) maintains
457
458
  * tool blocks keyed by callId, enriched with Cloud status/approval metadata.
458
459
  */
459
- const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowRememberedApprovals = true) => {
460
+ const createEventMapper = (
461
+ attempt: number,
462
+ seedBlocks: AiTurnBlock[],
463
+ allowRememberedApprovals = true,
464
+ location?: { conversationId: string; turnId: string },
465
+ ) => {
460
466
  const toolBlocks = new Map<string, Extract<AiTurnBlock, { kind: "tool" }>>();
461
467
  /** Website receipts by the call that shows them and their index; every later state of that call keeps them. */
462
468
  const receiptsByCall = new Map<string, Map<number, { method: string; url: string }>>();
@@ -586,7 +592,8 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
586
592
  return [setTool(event.callId, { name: event.name, args: event.args, status: "running" })];
587
593
  case "tool_action_request":
588
594
  const displayCallId = event.kind === "custom_approval" ? (customApprovalParentCallId(event.callId) ?? event.callId) : event.callId;
589
- const review = approvalReviewForCallId(approvalReviews, event.callId);
595
+ const rawReview = approvalReviewForCallId(approvalReviews, event.callId);
596
+ const review = rawReview && location ? compactCapabilityReview(rawReview, { ...location, callId: displayCallId }) : rawReview;
590
597
  const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
591
598
  const allowAlways = target
592
599
  ? target.always
@@ -919,9 +926,9 @@ export class AiTurnExecutor {
919
926
  const helpEnabled = toolActor !== null;
920
927
  const toolDiscoveryEnabled = toolActor !== null;
921
928
  const appToolsEnabled = toolActor !== null && defaultToolSource?.appTools === true;
922
- let capabilityAuthority: Awaited<ReturnType<typeof resolveAiCapabilityActor>> | null = null;
929
+ let toolAuthority: Awaited<ReturnType<typeof resolveAiCapabilityActor>> | null = null;
923
930
  try {
924
- capabilityAuthority = appToolsEnabled
931
+ toolAuthority = toolActor
925
932
  ? await resolveAiCapabilityActor({ conversationId, persistedActor: material.actor, store: aiConversations })
926
933
  : null;
927
934
  } catch (error) {
@@ -940,7 +947,10 @@ export class AiTurnExecutor {
940
947
  );
941
948
  return;
942
949
  }
943
- if (capabilityAuthority) material.actor = capabilityAuthority.actor;
950
+ if (toolAuthority) material.actor = toolAuthority.actor;
951
+ // Skills and Help need current authority even when application operations are disabled.
952
+ // Keep the appTools switch responsible for exposing and executing those operations.
953
+ const capabilityAuthority = appToolsEnabled ? toolAuthority : null;
944
954
 
945
955
  // Personalization applies to user-backed personal conversations.
946
956
  const user = aiActorUser(material.actor);
@@ -959,7 +969,9 @@ export class AiTurnExecutor {
959
969
  // executions must not gain an implicit database dependency or extra tools.
960
970
  const skillSubject =
961
971
  defaultToolSource && resolved.profile.capabilities.includes("tools") ? accessSubjectForActor(material.actor) : null;
962
- const availableSkills = skillSubject ? (await aiSkills.list(skillSubject)).filter((skill) => skill.enabled) : [];
972
+ const availableSkills = skillSubject
973
+ ? (await aiSkills.list(skillSubject, { authority: toolAuthority })).filter((skill) => skill.enabled)
974
+ : [];
963
975
  const localClient = !config.background && !config.mandate ? config.client : undefined;
964
976
  const localContext = localClient ? config.localContext : undefined;
965
977
  const localSkills = (localContext?.skills ?? []).map((skill) => ({
@@ -996,15 +1008,15 @@ export class AiTurnExecutor {
996
1008
  : undefined;
997
1009
  const skillFiles = skillSubject
998
1010
  ? {
999
- list: () => aiSkills.listTurnFiles(turnId, skillSubject),
1000
- read: (path: string) => aiSkills.readTurnFile(turnId, path, skillSubject),
1011
+ list: () => aiSkills.listTurnFiles(turnId, skillSubject, toolAuthority),
1012
+ read: (path: string) => aiSkills.readTurnFile(turnId, path, skillSubject, toolAuthority),
1001
1013
  }
1002
1014
  : undefined;
1003
1015
  const runtimeTools = [
1004
1016
  ...material.tools,
1005
1017
  ...(memoryActive ? [createCloudAiMemoryTool(query)] : []),
1006
- ...(skillSubject && availableSkills.length ? [createCloudAiLoadSkillTool(skillSubject)] : []),
1007
- ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject, localSkills)] : []),
1018
+ ...(skillSubject && availableSkills.length ? [createCloudAiLoadSkillTool(skillSubject, toolAuthority)] : []),
1019
+ ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject, localSkills, toolAuthority)] : []),
1008
1020
  ...(config.project && resolvedProjectId && projectSubject
1009
1021
  ? [
1010
1022
  createCloudAiSearchProjectTool(resolvedProjectId, projectSubject),
@@ -1055,6 +1067,8 @@ export class AiTurnExecutor {
1055
1067
  const approvalTargets = new Map<string, AiApprovalTarget>();
1056
1068
  const linkedApprovals = new Map<string, string>();
1057
1069
  const freshApprovalRequired = new Set<string>();
1070
+ const capabilityActionReviews = new Map<string, CapabilityActionReviewData>();
1071
+ const storedReviews = new Map<string, AiStoredActionReview>();
1058
1072
  const dynamicToolRuntimeContext = {
1059
1073
  turnId,
1060
1074
  reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
@@ -1096,6 +1110,13 @@ export class AiTurnExecutor {
1096
1110
  return "changed";
1097
1111
  },
1098
1112
  describeApproval: (approvalCallId: string, target: AiApprovalTarget) => approvalTargets.set(approvalCallId, target),
1113
+ describeReview: async (approvalCallId: string, review: CapabilityActionReviewData) => {
1114
+ capabilityActionReviews.set(approvalCallId, review);
1115
+ const callId = customApprovalParentCallId(approvalCallId) ?? approvalCallId;
1116
+ const stored = await recordAiToolReview({ conversationId, turnId, callId, review: storedActionReview(review) });
1117
+ storedReviews.set(callId, stored);
1118
+ await pipeline.setToolReview(callId, stored);
1119
+ },
1099
1120
  };
1100
1121
  const prepared = prepareAiTools({
1101
1122
  tools: activeTools,
@@ -1105,8 +1126,6 @@ export class AiTurnExecutor {
1105
1126
  });
1106
1127
  let backgroundError: string | null = null;
1107
1128
  const rememberableCapabilityApprovals = new Map<string, string>();
1108
- const capabilityActionReviews = new Map<string, CapabilityActionReviewData>();
1109
- const storedReviews = new Map<string, AiStoredActionReview>();
1110
1129
  const onCapabilityReview = async (
1111
1130
  callId: string,
1112
1131
  review: CapabilityActionReviewData,
@@ -1117,8 +1136,12 @@ export class AiTurnExecutor {
1117
1136
  const scope = capabilityReviewRememberScope(review, approval);
1118
1137
  if (scope) rememberableCapabilityApprovals.set(callId, scope);
1119
1138
  else rememberableCapabilityApprovals.delete(callId);
1120
- const stored = storedActionReview(review, capabilityActionPolicy(entry.app.manifest, entry.operation.localId));
1121
- await recordAiToolReview({ conversationId, turnId, callId, review: stored });
1139
+ const stored = await recordAiToolReview({
1140
+ conversationId,
1141
+ turnId,
1142
+ callId,
1143
+ review: storedActionReview(review, capabilityActionPolicy(entry.app.manifest, entry.operation.localId)),
1144
+ });
1122
1145
  storedReviews.set(callId, stored);
1123
1146
  await pipeline.setToolReview(callId, stored);
1124
1147
  };
@@ -1245,7 +1268,8 @@ export class AiTurnExecutor {
1245
1268
  const tools = toolActor
1246
1269
  ? createAiToolResolver({
1247
1270
  conversationId,
1248
- actor: capabilityAuthority?.actor ?? toolActor,
1271
+ actor: toolAuthority?.actor ?? toolActor,
1272
+ authority: toolAuthority,
1249
1273
  staticTools: activeTools,
1250
1274
  ...(activeTools.some((tool) => tool.def.name === "load_skill") ? { skillNames: availableSkills.map((skill) => skill.name) } : {}),
1251
1275
  allowedTools,
@@ -1464,6 +1488,7 @@ export class AiTurnExecutor {
1464
1488
  turnId,
1465
1489
  skillSubject,
1466
1490
  activeTools.some((tool) => tool.def.name === "load_skill"),
1491
+ toolAuthority,
1467
1492
  );
1468
1493
  const chat = await aiConversations.getConversation({ conversationId });
1469
1494
  const workingPlan = chat?.todoPlan;
@@ -1734,7 +1759,7 @@ export class AiTurnExecutor {
1734
1759
  approvalContext?: AiToolApprovalContext;
1735
1760
  allowRememberedApprovals: boolean;
1736
1761
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1737
- capabilityActionReviews: ReadonlyMap<string, CapabilityActionReviewData>;
1762
+ capabilityActionReviews: Map<string, CapabilityActionReviewData>;
1738
1763
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1739
1764
  linkedApprovals: ReadonlyMap<string, string>;
1740
1765
  freshApprovalRequired?: ReadonlySet<string>;
@@ -1919,7 +1944,7 @@ export class AiTurnExecutor {
1919
1944
  approvalContext?: AiToolApprovalContext;
1920
1945
  allowRememberedApprovals: boolean;
1921
1946
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1922
- capabilityActionReviews: ReadonlyMap<string, CapabilityActionReviewData>;
1947
+ capabilityActionReviews: Map<string, CapabilityActionReviewData>;
1923
1948
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1924
1949
  linkedApprovals: ReadonlyMap<string, string>;
1925
1950
  freshApprovalRequired?: ReadonlySet<string>;
@@ -2008,7 +2033,7 @@ export class AiTurnExecutor {
2008
2033
  }
2009
2034
  }
2010
2035
 
2011
- const review = event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined;
2036
+ let review = event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined;
2012
2037
  let approvalId: string | undefined;
2013
2038
  let presented = event;
2014
2039
  if (event.kind !== "client_tool") {
@@ -2023,6 +2048,24 @@ export class AiTurnExecutor {
2023
2048
  approvalId = linked;
2024
2049
  // The card shows the question the effect's own record holds, never the text the relay sent.
2025
2050
  presented = { ...event, message: record.sentence };
2051
+ // A later linked question without structured details replaces an earlier child's review too.
2052
+ review = record.details === null ? undefined : CapabilityActionReviewDataSchema.parse(record.details);
2053
+ if (review) capabilityActionReviews.set(event.callId, review);
2054
+ else capabilityActionReviews.delete(event.callId);
2055
+ const callId = event.kind === "custom_approval" ? (customApprovalParentCallId(event.callId) ?? event.callId) : event.callId;
2056
+ const capability = review && "sentence" in review && !review.ask ? await getCapability(record.appId).catch(() => null) : null;
2057
+ // Keep the original proposal on the existing tool-call message as well as the approval.
2058
+ // Its chat reference must remain readable after the approval has been consumed.
2059
+ const stored = await recordAiToolReview({
2060
+ conversationId,
2061
+ turnId,
2062
+ callId,
2063
+ review: storedActionReview(
2064
+ review ?? { message: record.sentence },
2065
+ capability ? capabilityActionPolicy(capability.manifest, record.action) : undefined,
2066
+ ),
2067
+ });
2068
+ await pipeline.setToolReview(callId, stored);
2026
2069
  } else {
2027
2070
  const record = await approvals.request({
2028
2071
  ...approvalScopeFromToolName(toolName),
@@ -2307,7 +2350,7 @@ class StreamPipeline {
2307
2350
  this.seq = input.startSeq;
2308
2351
  this.blocks = [];
2309
2352
  this.allowRememberedApprovals = input.allowRememberedApprovals;
2310
- this.mapper = createEventMapper(input.attempt, input.seedBlocks, input.allowRememberedApprovals);
2353
+ this.mapper = createEventMapper(input.attempt, input.seedBlocks, input.allowRememberedApprovals, input);
2311
2354
  }
2312
2355
 
2313
2356
  private ordered(run: () => Promise<void>): Promise<void> {
@@ -3,6 +3,7 @@ import { type RequestOptions as HttpsRequestOptions, request as httpsRequest } f
3
3
  import { z } from "zod";
4
4
  import { DOCUMENT_EXTRACTION_MAX_INPUT_BYTES, DOCUMENT_EXTRACTION_MAX_OUTPUT_BYTES } from "../services/document-extraction";
5
5
  import { type PublicNetworkAddress, resolvePublicNetworkAddresses } from "../services/network-security";
6
+ import { awaitNetworkOperation, readNetworkResponse, withNetworkDeadline } from "../services/network-transport";
6
7
  import { AI_FILES_MAX_FILE_BYTES_DEFAULT, aiFileStore, guessAiMediaType, normalizeAiFilePath } from "./files-store";
7
8
  import { authorizeWebRead } from "./firecrawl-tools";
8
9
  import { htmlToReadableMarkdown } from "./readable-html";
@@ -21,10 +22,11 @@ type PublicFileResponse = {
21
22
  type FetchFileDependencies = {
22
23
  resolve?: (hostname: string) => Promise<PublicNetworkAddress[]>;
23
24
  request?: RequestFactory;
25
+ timeoutMs?: number;
24
26
  requestPublicFile?: (
25
27
  url: URL,
26
28
  addresses: PublicNetworkAddress[],
27
- options: { maxBytes: number; signal?: AbortSignal },
29
+ options: { maxBytes: number; signal: AbortSignal },
28
30
  ) => Promise<PublicFileResponse>;
29
31
  };
30
32
 
@@ -72,7 +74,6 @@ export const buildPinnedFetchFileRequestOptions = (url: URL, addresses: PublicNe
72
74
  },
73
75
  servername: url.hostname,
74
76
  rejectUnauthorized: true,
75
- timeout: FETCH_FILE_REQUEST_TIMEOUT_MS,
76
77
  }));
77
78
  };
78
79
 
@@ -103,52 +104,40 @@ const httpFileError = (statusCode: number): Error => {
103
104
  const requestOneAddress = (
104
105
  request: RequestFactory,
105
106
  options: HttpsRequestOptions,
106
- input: { maxBytes: number; signal?: AbortSignal },
107
- ): Promise<PublicFileResponse> =>
108
- new Promise((resolve, reject) => {
109
- let settled = false;
110
- let responseReceived = false;
111
- let outgoing: ClientRequest;
112
- const abort = () => outgoing.destroy(new Error("File download was cancelled."));
113
- const finish = (result: { value?: PublicFileResponse; error?: Error }) => {
114
- if (settled) return;
115
- settled = true;
116
- input.signal?.removeEventListener("abort", abort);
117
- if (result.error) reject(responseReceived ? Object.assign(result.error, { responseReceived: true }) : result.error);
118
- else resolve(result.value!);
119
- };
120
- outgoing = request(options, (incoming) => {
107
+ input: { maxBytes: number; signal: AbortSignal },
108
+ ): Promise<PublicFileResponse> => {
109
+ let responseReceived = false;
110
+ return readNetworkResponse<PublicFileResponse>({
111
+ request,
112
+ options,
113
+ signal: input.signal,
114
+ read: (incoming, resolve, reject) => {
121
115
  responseReceived = true;
122
116
  const statusCode = incoming.statusCode ?? 0;
123
117
  if (statusCode >= 300 && statusCode < 400) {
124
- incoming.resume();
125
- finish({ value: { statusCode, headers: incoming.headers, bytes: new Uint8Array() } });
118
+ resolve({ statusCode, headers: incoming.headers, bytes: new Uint8Array() });
126
119
  return;
127
120
  }
128
121
  if (statusCode < 200 || statusCode >= 300) {
129
- incoming.resume();
130
- finish({ error: httpFileError(statusCode) });
122
+ reject(httpFileError(statusCode));
131
123
  return;
132
124
  }
133
125
  const contentEncoding = String(incoming.headers["content-encoding"] ?? "identity")
134
126
  .trim()
135
127
  .toLowerCase();
136
128
  if (contentEncoding !== "" && contentEncoding !== "identity") {
137
- incoming.destroy();
138
- finish({ error: responseError("Unsupported file response — The server compressed the download unexpectedly.") });
129
+ reject(responseError("Unsupported file response — The server compressed the download unexpectedly."));
139
130
  return;
140
131
  }
141
132
  let declared: number | null;
142
133
  try {
143
134
  declared = declaredContentLength(incoming.headers);
144
135
  } catch (error) {
145
- incoming.destroy();
146
- finish({ error: responseError(error instanceof Error ? error.message : "File response length is invalid.") });
136
+ reject(responseError(error instanceof Error ? error.message : "File response length is invalid."));
147
137
  return;
148
138
  }
149
139
  if (declared !== null && declared > input.maxBytes) {
150
- incoming.destroy();
151
- finish({ error: responseError(`File is too large — The download exceeds the ${fileLimitLabel(input.maxBytes)} limit.`) });
140
+ reject(responseError(`File is too large — The download exceeds the ${fileLimitLabel(input.maxBytes)} limit.`));
152
141
  return;
153
142
  }
154
143
 
@@ -158,36 +147,29 @@ const requestOneAddress = (
158
147
  const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
159
148
  size += bytes.byteLength;
160
149
  if (size > input.maxBytes) {
161
- incoming.destroy();
162
- finish({ error: responseError(`File is too large — The download exceeds the ${fileLimitLabel(input.maxBytes)} limit.`) });
150
+ reject(responseError(`File is too large — The download exceeds the ${fileLimitLabel(input.maxBytes)} limit.`));
163
151
  return;
164
152
  }
165
153
  chunks.push(bytes);
166
154
  });
167
155
  incoming.on("end", () => {
168
156
  if (declared !== null && size !== declared) {
169
- finish({ error: responseError("Incomplete file download — The received size did not match the server response.") });
157
+ reject(responseError("Incomplete file download — The received size did not match the server response."));
170
158
  return;
171
159
  }
172
160
  const body = Buffer.concat(chunks, size);
173
- finish({ value: { statusCode, headers: incoming.headers, bytes: new Uint8Array(body) } });
161
+ resolve({ statusCode, headers: incoming.headers, bytes: new Uint8Array(body) });
174
162
  });
175
- incoming.on("error", (error) => finish({ error }));
176
- });
177
- outgoing.on("timeout", () => outgoing.destroy(new Error("File server did not respond — Try again later.")));
178
- outgoing.on("error", (error) => finish({ error }));
179
- if (input.signal?.aborted) {
180
- abort();
181
- return;
182
- }
183
- input.signal?.addEventListener("abort", abort, { once: true });
184
- outgoing.end();
163
+ },
164
+ }).catch((error) => {
165
+ throw responseReceived ? Object.assign(error, { responseReceived: true }) : error;
185
166
  });
167
+ };
186
168
 
187
169
  const requestPublicFile = async (
188
170
  url: URL,
189
171
  addresses: PublicNetworkAddress[],
190
- options: { maxBytes: number; signal?: AbortSignal },
172
+ options: { maxBytes: number; signal: AbortSignal },
191
173
  request: RequestFactory = httpsRequest,
192
174
  ): Promise<PublicFileResponse> => {
193
175
  let lastError: unknown;
@@ -235,42 +217,51 @@ type WebsiteApprovedDownload = { origin: string; onRequest: (url: URL, hop: numb
235
217
  export const downloadPublicFile = async (
236
218
  rawUrl: string,
237
219
  options: FetchFileDependencies & { maxBytes?: number; signal?: AbortSignal; approvedWebsite?: WebsiteApprovedDownload } = {},
238
- ): Promise<{ bytes: Uint8Array; headers: IncomingHttpHeaders; url: URL }> => {
239
- const resolve = options.resolve ?? resolvePublicNetworkAddresses;
240
- const perform =
241
- options.requestPublicFile ??
242
- ((url: URL, addresses: PublicNetworkAddress[], input: { maxBytes: number; signal?: AbortSignal }) =>
243
- requestPublicFile(url, addresses, input, options.request));
244
- const maxBytes = options.maxBytes ?? AI_FILES_MAX_FILE_BYTES_DEFAULT;
245
- let url = publicHttpsUrl(rawUrl);
220
+ ): Promise<{ bytes: Uint8Array; headers: IncomingHttpHeaders; url: URL }> =>
221
+ withNetworkDeadline(
222
+ options.timeoutMs ?? FETCH_FILE_REQUEST_TIMEOUT_MS,
223
+ options.signal,
224
+ new Error("File server did not respond — Try again later."),
225
+ async (signal) => {
226
+ const resolve = options.resolve ?? resolvePublicNetworkAddresses;
227
+ const perform =
228
+ options.requestPublicFile ??
229
+ ((url: URL, addresses: PublicNetworkAddress[], input: { maxBytes: number; signal: AbortSignal }) =>
230
+ requestPublicFile(url, addresses, input, options.request));
231
+ const maxBytes = options.maxBytes ?? AI_FILES_MAX_FILE_BYTES_DEFAULT;
232
+ let url = publicHttpsUrl(rawUrl);
246
233
 
247
- for (let redirects = 0; redirects <= FETCH_FILE_MAX_REDIRECTS; redirects++) {
248
- if (options.approvedWebsite) {
249
- // A website approval covers its own origin only; an open redirect must not carry the address to another one.
250
- if (url.origin !== options.approvedWebsite.origin)
251
- throw new Error(
252
- `Redirect to another website — The file moved to ${url.toString()}, which this chat has not allowed. Download that address directly so the person can decide.`,
253
- );
254
- await options.approvedWebsite.onRequest(url, redirects);
255
- }
256
- const addresses = await resolveFileAddresses(resolve, url.hostname);
257
- const response = await perform(url, addresses, { maxBytes, signal: options.signal });
258
- if (response.statusCode >= 300 && response.statusCode < 400) {
259
- const location = responseLocation(response.headers);
260
- if (!location) throw new Error("Invalid file redirect — The server did not provide a destination.");
261
- if (redirects === FETCH_FILE_MAX_REDIRECTS) throw new Error("Too many file redirects — Use the final file URL instead.");
262
- try {
263
- url = publicHttpsUrl(new URL(location, url).toString());
264
- } catch (error) {
265
- if (error instanceof Error && error.message.includes(" — ")) throw error;
266
- throw new Error("Invalid file redirect — The server provided an invalid destination.");
234
+ for (let redirects = 0; redirects <= FETCH_FILE_MAX_REDIRECTS; redirects++) {
235
+ signal.throwIfAborted();
236
+ if (options.approvedWebsite) {
237
+ // A website approval covers its own origin only; an open redirect must not carry the address to another one.
238
+ if (url.origin !== options.approvedWebsite.origin)
239
+ throw new Error(
240
+ `Redirect to another website — The file moved to ${url.toString()}, which this chat has not allowed. Download that address directly so the person can decide.`,
241
+ );
242
+ await awaitNetworkOperation(options.approvedWebsite.onRequest(url, redirects), signal);
243
+ }
244
+ signal.throwIfAborted();
245
+ const addresses = await awaitNetworkOperation(resolveFileAddresses(resolve, url.hostname), signal);
246
+ signal.throwIfAborted();
247
+ const response = await awaitNetworkOperation(perform(url, addresses, { maxBytes, signal }), signal);
248
+ if (response.statusCode >= 300 && response.statusCode < 400) {
249
+ const location = responseLocation(response.headers);
250
+ if (!location) throw new Error("Invalid file redirect — The server did not provide a destination.");
251
+ if (redirects === FETCH_FILE_MAX_REDIRECTS) throw new Error("Too many file redirects — Use the final file URL instead.");
252
+ try {
253
+ url = publicHttpsUrl(new URL(location, url).toString());
254
+ } catch (error) {
255
+ if (error instanceof Error && error.message.includes(" — ")) throw error;
256
+ throw new Error("Invalid file redirect — The server provided an invalid destination.");
257
+ }
258
+ continue;
259
+ }
260
+ return { bytes: response.bytes, headers: response.headers, url };
267
261
  }
268
- continue;
269
- }
270
- return { bytes: response.bytes, headers: response.headers, url };
271
- }
272
- throw new Error("Too many file redirects — Use the final file URL instead.");
273
- };
262
+ throw new Error("Too many file redirects — Use the final file URL instead.");
263
+ },
264
+ );
274
265
 
275
266
  const safeFilename = (requested: string | undefined, url: URL): string => {
276
267
  let basename = requested;
@@ -1,10 +1,12 @@
1
1
  import { type ToolContext, truncateMiddle } from "@k2b/nessi";
2
2
  import { z } from "zod";
3
+ import type { CapabilityActionCallReview } from "../contracts/capabilities";
3
4
  import type { RequestActor } from "../server";
4
5
  import { coreSettings } from "../services";
5
6
  import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
6
7
  import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsWebsiteApprovals, aiWebsiteApprovalScope, findRememberedAiToolApproval } from "./approvals";
7
8
  import { aiConversations } from "./store";
9
+ import { cloudToolReview } from "./tool-sentences";
8
10
  import { defineAiTool } from "./tools";
9
11
  import type { AiApprovalTarget, AiWebsiteReceipt } from "./types";
10
12
  import { hasWebAddressProvenance, recordWebAddresses, webAddressesIn } from "./web-provenance";
@@ -280,7 +282,7 @@ export type WebReadContext = Pick<ToolContext, "requestApproval"> & {
280
282
  conversationId?: string;
281
283
  turnId?: string;
282
284
  locale?: string;
283
- requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
285
+ requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
284
286
  reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
285
287
  };
286
288
 
@@ -306,7 +308,18 @@ export const authorizeWebRead = async (kind: "page" | "file", url: string, ctx:
306
308
  return true;
307
309
  }
308
310
  const message = webReadApprovalMessage(kind, url, ctx.locale);
309
- const approved = ctx.requestApprovalFor ? await ctx.requestApprovalFor(message, target) : await ctx.requestApproval(message);
311
+ const review = cloudToolReview(kind === "page" ? "web_extract" : "fetch_file", { url }, ctx.locale ?? "en");
312
+ const question: CapabilityActionCallReview | undefined = review
313
+ ? {
314
+ ...review,
315
+ ask: "sends",
316
+ consequences: message
317
+ .split("\n")
318
+ .slice(1)
319
+ .map((text) => ({ text })),
320
+ }
321
+ : undefined;
322
+ const approved = ctx.requestApprovalFor ? await ctx.requestApprovalFor(message, target, question) : await ctx.requestApproval(message);
310
323
  if (!approved) throw new Error("The person did not allow reading this address. Use an address from their message or a search result.");
311
324
  return false;
312
325
  };
package/src/ai/http.ts CHANGED
@@ -70,6 +70,7 @@ export const AiToolPreloadSchema = z.union([
70
70
  ]);
71
71
 
72
72
  export const AiCreateConversationInputSchema = z.object({
73
+ requestId: z.uuid().optional(),
73
74
  title: z.string().trim().min(1).max(120).optional(),
74
75
  projectId: z.string().regex(AI_SHORT_ID_PATTERN).optional(),
75
76
  draft: AiInitialConversationDraftInputSchema.optional(),
package/src/ai/index.ts CHANGED
@@ -262,6 +262,7 @@ export {
262
262
  createCloudAiMemoryTool,
263
263
  } from "./memory-tool";
264
264
  export { migrateCloudAi } from "./migrate";
265
+ export { type AiOpenQuestion, type AiOpenQuestions, listAiOpenQuestions } from "./open-questions";
265
266
  export { personalAiModelPolicy } from "./personal-agent";
266
267
  export { type AiUserPrefs, aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
267
268
  export {
package/src/ai/migrate.ts CHANGED
@@ -47,6 +47,17 @@ export const migrateCloudAi = async (): Promise<void> => {
47
47
  )
48
48
  `.simple();
49
49
 
50
+ // Keep the request identity after deletion: retrying a lost response must not resurrect a chat.
51
+ await sql`
52
+ CREATE TABLE IF NOT EXISTS ai.conversation_creations (
53
+ owner_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
54
+ request_id UUID NOT NULL,
55
+ input_hash TEXT NOT NULL,
56
+ conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
57
+ PRIMARY KEY (owner_user_id, request_id)
58
+ )
59
+ `.simple();
60
+
50
61
  // AI chat ownership was unreleased and only backed local test data. Make the
51
62
  // product-model cut once, then keep only the global conversation contract.
52
63
  await sql`
@@ -2297,6 +2308,21 @@ export const migrateCloudAi = async (): Promise<void> => {
2297
2308
  AFTER UPDATE OF status, error, completed_at, cancel_requested_at ON ai.turns
2298
2309
  FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list,conversation-detail')
2299
2310
  `.simple();
2311
+ // Only questions for the person change the list of open questions; browser tool calls come and go per turn.
2312
+ await sql`DROP TRIGGER IF EXISTS ai_live_pending_actions_changed ON ai.pending_actions`.simple();
2313
+ await sql`DROP TRIGGER IF EXISTS ai_live_pending_actions_removed ON ai.pending_actions`.simple();
2314
+ await sql`
2315
+ CREATE TRIGGER ai_live_pending_actions_changed
2316
+ AFTER INSERT OR UPDATE OF status ON ai.pending_actions
2317
+ FOR EACH ROW WHEN (NEW.kind IN ('approval', 'custom_approval'))
2318
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')
2319
+ `.simple();
2320
+ await sql`
2321
+ CREATE TRIGGER ai_live_pending_actions_removed
2322
+ AFTER DELETE ON ai.pending_actions
2323
+ FOR EACH ROW WHEN (OLD.kind IN ('approval', 'custom_approval'))
2324
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')
2325
+ `.simple();
2300
2326
  await sql`DROP TRIGGER IF EXISTS ai_live_chat_tasks_changed ON ai.chat_tasks`.simple();
2301
2327
  await sql`
2302
2328
  CREATE TRIGGER ai_live_chat_tasks_changed AFTER INSERT OR UPDATE OR DELETE ON ai.chat_tasks
@@ -2642,6 +2668,8 @@ export const migrateCloudAi = async (): Promise<void> => {
2642
2668
  );
2643
2669
  }
2644
2670
  await backfillApprovalRequests();
2671
+ await sql`CREATE INDEX IF NOT EXISTS idx_ai_messages_submitted_draft
2672
+ ON ai.messages (conversation_id, (meta->>'submittedDraftRevision')) WHERE role = 'user'`.simple();
2645
2673
  console.log(" ✓ ai conversation tables");
2646
2674
  };
2647
2675