@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
package/src/ai/store.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
2
2
  import type { Usage } from "@k2b/nessi/ai";
3
3
  import { type SQL, sql } from "bun";
4
- import { type CapabilityActionReviewData, CapabilityActionReviewDataSchema } from "../contracts/capabilities";
4
+ import { capabilityRequestHash } from "../capabilities/claims";
5
+ import { restoreReviewSnapshot, serializeReviewSnapshot } from "../capabilities/review-storage";
6
+ import { type CapabilityActionReviewPresentation, CapabilityActionReviewPresentationSchema } from "../contracts/capabilities";
5
7
  import { logger } from "../services/logging";
6
8
  import { toPgTextArray } from "../services/postgres";
7
9
  import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck, type AiStoredActionReview } from "./action-review";
@@ -14,6 +16,13 @@ import { aiTurnErrorText } from "./chat/turn-error";
14
16
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
15
17
  import type { AiTurnBlock } from "./protocol";
16
18
  import { collectRecentSentResourceRefs, RECENT_SENT_CONVERSATION_SCAN_LIMIT, RECENT_SENT_MESSAGE_SCAN_LIMIT } from "./recent-sent-refs";
19
+ import {
20
+ compactCapabilityReview,
21
+ compactReviewMapSql,
22
+ compactReviewMetaSql,
23
+ compactStoredReview,
24
+ storedReviewWithContent,
25
+ } from "./review-content";
17
26
  import { AiRunTimeout } from "./run-timeout";
18
27
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
19
28
  import { parseAiTodoPlan } from "./todo-contracts";
@@ -530,9 +539,9 @@ const parseJsonValue = <T>(value: unknown): T => {
530
539
  return value as T;
531
540
  };
532
541
 
533
- const parseCapabilityActionReview = (value: unknown): CapabilityActionReviewData | undefined => {
542
+ const parseCapabilityActionReview = (value: unknown): CapabilityActionReviewPresentation | undefined => {
534
543
  if (value === null || value === undefined) return undefined;
535
- const parsed = CapabilityActionReviewDataSchema.safeParse(parseJsonValue(value));
544
+ const parsed = CapabilityActionReviewPresentationSchema.safeParse(restoreReviewSnapshot(parseJsonValue(value)));
536
545
  return parsed.success ? parsed.data : undefined;
537
546
  };
538
547
 
@@ -664,6 +673,14 @@ const intValue = (value: number | string | null | undefined): number => Math.max
664
673
 
665
674
  const rowToMessage = (row: MessageRow): AiStoredMessage => {
666
675
  const message = parseJsonValue<Message>(row.message);
676
+ const meta = row.meta ? parseJsonValue<AiStoredMessage["meta"]>(row.meta) : null;
677
+ if (meta?.toolReviews)
678
+ meta.toolReviews = Object.fromEntries(
679
+ Object.entries(meta.toolReviews).map(([callId, review]) => [
680
+ callId,
681
+ compactStoredReview(review, { conversationId: row.conversation_id, turnId: row.loop_id ?? "", callId }),
682
+ ]),
683
+ );
667
684
  return {
668
685
  id: row.id,
669
686
  shortId: row.short_id,
@@ -679,7 +696,7 @@ const rowToMessage = (row: MessageRow): AiStoredMessage => {
679
696
  loopAggregate: row.loop_aggregate ? parseJsonValue<LoopAggregate>(row.loop_aggregate) : null,
680
697
  loopDoneReason: row.loop_done_reason,
681
698
  compactedAt: row.compacted_at ? iso(row.compacted_at) : null,
682
- meta: row.meta ? parseJsonValue<AiStoredMessage["meta"]>(row.meta) : null,
699
+ meta,
683
700
  feedback:
684
701
  Number(row.feedback_rating) === 1 || Number(row.feedback_rating) === -1
685
702
  ? {
@@ -733,7 +750,16 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
733
750
  name: row.tool_name,
734
751
  args: row.args,
735
752
  message: row.message ?? undefined,
736
- review: parseCapabilityActionReview(row.review),
753
+ review: (() => {
754
+ const review = parseCapabilityActionReview(row.review);
755
+ return review
756
+ ? compactCapabilityReview(review, {
757
+ conversationId: row.conversation_id,
758
+ turnId: row.turn_id,
759
+ callId: row.kind === "custom_approval" ? row.call_id.replace(/-approval-[0-9]+$/, "") : row.call_id,
760
+ })
761
+ : undefined;
762
+ })(),
737
763
  allowAlways: row.allow_always,
738
764
  allowChat: row.allow_always || row.allow_chat === true,
739
765
  ...(row.remember_tool_name === AI_WEBSITE_APPROVAL_TOOL ? { website: row.approval_scope } : {}),
@@ -749,7 +775,16 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
749
775
  name: row.tool_name,
750
776
  args: row.args,
751
777
  message: row.message ?? undefined,
752
- review: parseCapabilityActionReview(row.review),
778
+ review: (() => {
779
+ const review = parseCapabilityActionReview(row.review);
780
+ return review
781
+ ? compactCapabilityReview(review, {
782
+ conversationId: row.conversation_id,
783
+ turnId: row.turn_id,
784
+ callId: row.kind === "custom_approval" ? row.call_id.replace(/-approval-[0-9]+$/, "") : row.call_id,
785
+ })
786
+ : undefined;
787
+ })(),
753
788
  approvalScope: row.approval_scope,
754
789
  allowAlways: row.allow_always,
755
790
  allowChat: row.allow_always || row.allow_chat === true,
@@ -994,7 +1029,7 @@ const recordAiToolMeta = async (
994
1029
  UPDATE ${db(table)}
995
1030
  SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
996
1031
  ${key}::text, COALESCE(meta->${key}::text, '{}'::jsonb) ||
997
- jsonb_build_object(${input.callId}::text, ${JSON.stringify(value)}::text::jsonb)
1032
+ jsonb_build_object(${input.callId}::text, ${key === "toolReviews" ? serializeReviewSnapshot(value) : JSON.stringify(value)}::text::jsonb)
998
1033
  )
999
1034
  WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
1000
1035
  AND role = 'assistant'
@@ -1005,8 +1040,16 @@ const recordAiToolMeta = async (
1005
1040
  if (!written) throw new Error("AI tool call message is unavailable.");
1006
1041
  };
1007
1042
 
1008
- export const recordAiToolReview = (input: { conversationId: string; turnId: string; callId: string; review: AiStoredActionReview }) =>
1009
- recordAiToolMeta(input, "toolReviews", input.review);
1043
+ export const recordAiToolReview = async (input: {
1044
+ conversationId: string;
1045
+ turnId: string;
1046
+ callId: string;
1047
+ review: AiStoredActionReview;
1048
+ }): Promise<AiStoredActionReview> => {
1049
+ const stored = storedReviewWithContent(input.review, input);
1050
+ await recordAiToolMeta(input, "toolReviews", stored);
1051
+ return compactStoredReview(stored, input);
1052
+ };
1010
1053
 
1011
1054
  export const recordAiToolCheck = (input: { conversationId: string; turnId: string; callId: string; check: AiActionCheck }, db = sql) =>
1012
1055
  recordAiToolMeta(input, "toolChecks", input.check, db);
@@ -1319,11 +1362,45 @@ export const saveAiDraftInTransaction = async (
1319
1362
  };
1320
1363
  };
1321
1364
 
1365
+ export class AiConversationCreationError extends Error {
1366
+ constructor(readonly reason: "conflict" | "unavailable") {
1367
+ super(
1368
+ reason === "conflict"
1369
+ ? "This creation request was already used with different input."
1370
+ : "The conversation from this request is no longer available.",
1371
+ );
1372
+ }
1373
+ }
1374
+
1322
1375
  export const aiConversations: AiConversationService = {
1376
+ getDraftSubmission: async ({ conversationId, ownerUserId, revision }) => {
1377
+ const [row] = await sql<{ message_id: string; turn_id: string | null }[]>`
1378
+ SELECT message.short_id AS message_id, turn.short_id AS turn_id
1379
+ FROM ai.messages message
1380
+ JOIN ai.conversations conversation ON conversation.id = message.conversation_id
1381
+ LEFT JOIN ai.turns turn ON turn.id::text = message.loop_id AND turn.conversation_id = conversation.id
1382
+ WHERE conversation.id = ${conversationId}::uuid AND conversation.created_by_user_id = ${ownerUserId}::uuid
1383
+ AND conversation.archived_at IS NULL AND message.role = 'user'
1384
+ AND message.meta->>'submittedDraftRevision' = ${String(revision)}
1385
+ LIMIT 1
1386
+ `;
1387
+ return row ? { messageId: row.message_id, turnId: row.turn_id } : null;
1388
+ },
1323
1389
  createConversation: async (input) => {
1324
- const rows = await withAiShortId(
1325
- "idx_ai_conversations_short_id",
1326
- (shortId) => sql<ConversationRow[]>`
1390
+ const normalized = {
1391
+ title: input.title?.trim() || "New chat",
1392
+ description: input.description?.trim() ?? "",
1393
+ projectId: input.projectId ?? null,
1394
+ draft: input.draft ?? [],
1395
+ preloadTools: input.preloadTools ?? [],
1396
+ allowedTools: input.allowedTools ?? null,
1397
+ launchedByAppId: input.launchedByAppId?.trim() || null,
1398
+ };
1399
+ const insert = async (db: SQL) => {
1400
+ const rows = await withAiShortIdForDb(
1401
+ db,
1402
+ "idx_ai_conversations_short_id",
1403
+ (attempt, shortId) => attempt<ConversationRow[]>`
1327
1404
  INSERT INTO ai.conversations (
1328
1405
  short_id,
1329
1406
  title,
@@ -1339,8 +1416,8 @@ export const aiConversations: AiConversationService = {
1339
1416
  )
1340
1417
  SELECT
1341
1418
  ${shortId},
1342
- ${input.title?.trim() || "New chat"},
1343
- ${input.description?.trim() ?? ""},
1419
+ ${normalized.title},
1420
+ ${normalized.description},
1344
1421
  ${input.projectId ?? null}::uuid,
1345
1422
  ${JSON.stringify(input.draft ?? [])}::text::jsonb,
1346
1423
  ${input.draft?.length ? 1 : 0},
@@ -1352,10 +1429,38 @@ export const aiConversations: AiConversationService = {
1352
1429
  WHERE ${input.projectId ?? null}::uuid IS NULL
1353
1430
  OR EXISTS (SELECT 1 FROM ai.projects project WHERE project.id = ${input.projectId ?? null}::uuid)
1354
1431
  RETURNING *
1355
- `,
1356
- );
1357
- if (!rows[0]) throw new Error("Project does not exist.");
1358
- return rowToConversation(rows[0]);
1432
+ `,
1433
+ );
1434
+ if (!rows[0]) throw new Error("Project does not exist.");
1435
+ return rowToConversation(rows[0]);
1436
+ };
1437
+ if (!input.requestId) return insert(sql);
1438
+ const hash = capabilityRequestHash(normalized);
1439
+ return sql.begin(async (tx) => {
1440
+ const reserved = await tx<{ request_id: string }[]>`
1441
+ INSERT INTO ai.conversation_creations (owner_user_id, request_id, input_hash)
1442
+ VALUES (${input.ownerUserId}::uuid, ${input.requestId}::uuid, ${hash})
1443
+ ON CONFLICT (owner_user_id, request_id) DO NOTHING RETURNING request_id
1444
+ `;
1445
+ const [claim] = await tx<{ input_hash: string; conversation_id: string | null }[]>`
1446
+ SELECT input_hash, conversation_id FROM ai.conversation_creations
1447
+ WHERE owner_user_id = ${input.ownerUserId}::uuid AND request_id = ${input.requestId}::uuid
1448
+ FOR UPDATE
1449
+ `;
1450
+ if (!claim || claim.input_hash !== hash) throw new AiConversationCreationError("conflict");
1451
+ if (!reserved.length) {
1452
+ const [existing] = await tx<ConversationRow[]>`
1453
+ SELECT * FROM ai.conversations WHERE id = ${claim.conversation_id}::uuid
1454
+ AND created_by_user_id = ${input.ownerUserId}::uuid AND archived_at IS NULL
1455
+ `;
1456
+ if (!existing) throw new AiConversationCreationError("unavailable");
1457
+ return rowToConversation(existing);
1458
+ }
1459
+ const conversation = await insert(tx);
1460
+ await tx`UPDATE ai.conversation_creations SET conversation_id = ${conversation.id}::uuid
1461
+ WHERE owner_user_id = ${input.ownerUserId}::uuid AND request_id = ${input.requestId}::uuid`;
1462
+ return conversation;
1463
+ });
1359
1464
  },
1360
1465
 
1361
1466
  forkConversation: async (input) => {
@@ -2442,7 +2547,10 @@ export const aiConversations: AiConversationService = {
2442
2547
  // summaries are hidden. The active summary sorts after archived rows
2443
2548
  // sharing its checkpoint seq, marking where the model context begins.
2444
2549
  const rows = await sql<MessageRow[]>`
2445
- SELECT *
2550
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2551
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2552
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2553
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
2446
2554
  FROM ai.messages
2447
2555
  WHERE conversation_id = ${input.conversationId}
2448
2556
  AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
@@ -2467,7 +2575,10 @@ export const aiConversations: AiConversationService = {
2467
2575
  ORDER BY seq DESC
2468
2576
  LIMIT ${limit}
2469
2577
  )
2470
- SELECT *
2578
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2579
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2580
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2581
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
2471
2582
  FROM ai.messages
2472
2583
  WHERE conversation_id = ${input.conversationId}
2473
2584
  AND seq IN (SELECT seq FROM page_seqs)
@@ -2496,7 +2607,10 @@ export const aiConversations: AiConversationService = {
2496
2607
  const pattern = `%${query.toLowerCase()}%`;
2497
2608
  const rows = await sql<(MessageRow & { page_rank: number | string })[]>`
2498
2609
  WITH matching AS (
2499
- SELECT *, DENSE_RANK() OVER (ORDER BY seq DESC) AS page_rank
2610
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2611
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2612
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2613
+ ${compactReviewMetaSql(todoMetaJson())} AS meta, DENSE_RANK() OVER (ORDER BY seq DESC) AS page_rank
2500
2614
  FROM ai.messages
2501
2615
  WHERE conversation_id = ${input.conversationId}::uuid
2502
2616
  AND role <> 'tool_result'
@@ -2620,7 +2734,10 @@ export const aiConversations: AiConversationService = {
2620
2734
 
2621
2735
  listContextMessages: async (input) => {
2622
2736
  const rows = await sql<MessageRow[]>`
2623
- SELECT *
2737
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2738
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2739
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2740
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
2624
2741
  FROM ai.messages
2625
2742
  WHERE conversation_id = ${input.conversationId}
2626
2743
  AND compacted_at IS NULL
@@ -2631,7 +2748,10 @@ export const aiConversations: AiConversationService = {
2631
2748
 
2632
2749
  listTurnMessages: async (input) => {
2633
2750
  const rows = await sql<MessageRow[]>`
2634
- SELECT *
2751
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2752
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2753
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2754
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
2635
2755
  FROM (SELECT * FROM ai.messages UNION ALL SELECT * FROM ai.task_messages) messages
2636
2756
  WHERE conversation_id = ${input.conversationId}
2637
2757
  AND loop_id = ${input.loopId}
@@ -3042,7 +3162,7 @@ export const aiConversations: AiConversationService = {
3042
3162
  const [waits, metadata] = await Promise.all([
3043
3163
  activeTurnWaits(rows[0].id),
3044
3164
  sql<{ tool_reviews: Record<string, AiStoredActionReview> | null; tool_checks: Record<string, AiActionCheck> | null }[]>`
3045
- SELECT meta->'toolReviews' AS tool_reviews, meta->'toolChecks' AS tool_checks
3165
+ SELECT ${compactReviewMapSql(sql`meta->'toolReviews'`)} AS tool_reviews, meta->'toolChecks' AS tool_checks
3046
3166
  FROM (
3047
3167
  SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
3048
3168
  UNION ALL
@@ -3062,7 +3182,8 @@ export const aiConversations: AiConversationService = {
3062
3182
  turn: rowToTurn(rows[0]),
3063
3183
  liveBlocks: rowToLiveBlocks(rows[0]).map((block) => {
3064
3184
  if (block.kind !== "tool") return block;
3065
- const review = reviews.get(block.callId);
3185
+ const snapshot = reviews.get(block.callId);
3186
+ const review = snapshot ? compactStoredReview(snapshot, { ...input, turnId: rows[0]!.id, callId: block.callId }) : undefined;
3066
3187
  const check = checks.get(block.callId);
3067
3188
  return { ...block, ...(review ? { review } : {}), ...(check ? { check } : {}) };
3068
3189
  }),
@@ -3546,7 +3667,7 @@ export const aiConversations: AiConversationService = {
3546
3667
  ${input.name},
3547
3668
  ${JSON.stringify(input.args ?? null)}::text::jsonb,
3548
3669
  ${input.message ?? null},
3549
- ${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
3670
+ ${input.review ? serializeReviewSnapshot("sentence" in input.review ? storedReviewWithContent(input.review, input) : input.review) : null}::text::jsonb,
3550
3671
  ${input.approvalScope},
3551
3672
  ${input.allowAlways},
3552
3673
  ${input.allowChat ?? input.allowAlways},
@@ -3574,7 +3695,8 @@ export const aiConversations: AiConversationService = {
3574
3695
 
3575
3696
  listPendingTurnActions: async (input) => {
3576
3697
  const rows = await sql<PendingActionRow[]>`
3577
- SELECT *
3698
+ SELECT approval_id, turn_id, conversation_id, call_id, kind, status, tool_name, args, message,
3699
+ review - 'content' AS review, approval_scope, allow_always, allow_chat, remember_tool_name, frontend_mode, resolved_event
3578
3700
  FROM ai.pending_actions
3579
3701
  WHERE conversation_id = ${input.conversationId}
3580
3702
  AND turn_id = ${input.turnId}
@@ -3586,7 +3708,8 @@ export const aiConversations: AiConversationService = {
3586
3708
 
3587
3709
  getPendingTurnAction: async (input) => {
3588
3710
  const rows = await sql<PendingActionRow[]>`
3589
- SELECT *
3711
+ SELECT approval_id, turn_id, conversation_id, call_id, kind, status, tool_name, args, message,
3712
+ review - 'content' AS review, approval_scope, allow_always, allow_chat, remember_tool_name, frontend_mode, resolved_event
3590
3713
  FROM ai.pending_actions
3591
3714
  WHERE conversation_id = ${input.conversationId}
3592
3715
  AND turn_id = ${input.turnId}
@@ -3607,9 +3730,34 @@ export const aiConversations: AiConversationService = {
3607
3730
  return row ? rowToPendingActionRecord(row) : null;
3608
3731
  },
3609
3732
 
3733
+ listTurnToolReviews: async (input) => {
3734
+ // Only the review metadata, never the messages themselves, which can carry large tool results.
3735
+ const rows = await sql<{ tool_reviews: Record<string, AiStoredActionReview> | null }[]>`
3736
+ SELECT ${compactReviewMapSql(sql`meta->'toolReviews'`)} AS tool_reviews
3737
+ FROM (
3738
+ SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
3739
+ UNION ALL
3740
+ SELECT meta, seq, conversation_id, loop_id, role FROM ai.task_messages
3741
+ ) messages
3742
+ WHERE conversation_id = ${input.conversationId}
3743
+ AND loop_id = ${input.turnId}
3744
+ AND role = 'assistant'
3745
+ AND meta ? 'toolReviews'
3746
+ ORDER BY seq ASC
3747
+ `;
3748
+ return new Map(
3749
+ rows.flatMap((row) =>
3750
+ Object.entries(row.tool_reviews ?? {}).map(
3751
+ ([callId, review]) => [callId, compactStoredReview(review, { ...input, callId })] as const,
3752
+ ),
3753
+ ),
3754
+ );
3755
+ },
3756
+
3610
3757
  listPendingActionRecords: async (input) => {
3611
3758
  const rows = await sql<PendingActionRow[]>`
3612
- SELECT *
3759
+ SELECT approval_id, turn_id, conversation_id, call_id, kind, status, tool_name, args, message,
3760
+ review - 'content' AS review, approval_scope, allow_always, allow_chat, remember_tool_name, frontend_mode, resolved_event
3613
3761
  FROM ai.pending_actions
3614
3762
  WHERE conversation_id = ${input.conversationId}
3615
3763
  AND turn_id = ${input.turnId}
@@ -3621,7 +3769,8 @@ export const aiConversations: AiConversationService = {
3621
3769
 
3622
3770
  listResolvedPendingActions: async (input) => {
3623
3771
  const rows = await sql<PendingActionRow[]>`
3624
- SELECT *
3772
+ SELECT approval_id, turn_id, conversation_id, call_id, kind, status, tool_name, args, message,
3773
+ review - 'content' AS review, approval_scope, allow_always, allow_chat, remember_tool_name, frontend_mode, resolved_event
3625
3774
  FROM ai.pending_actions
3626
3775
  WHERE conversation_id = ${input.conversationId}
3627
3776
  AND turn_id = ${input.turnId}
@@ -3785,7 +3934,11 @@ export const aiConversations: AiConversationService = {
3785
3934
  const rows = input.turnId
3786
3935
  ? (
3787
3936
  await sql<MessageRow[]>`
3788
- SELECT * FROM ai.messages
3937
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
3938
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
3939
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
3940
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
3941
+ FROM ai.messages
3789
3942
  WHERE conversation_id = ${input.conversationId}::uuid AND compacted_at IS NULL
3790
3943
  AND ${visibleToTurn(input.turnId)}
3791
3944
  ORDER BY seq ASC
@@ -0,0 +1,36 @@
1
+ import { actionFixtures } from "./chat/action-question.fixtures";
2
+ import { type AiOpenQuestion, openQuestionFromRow } from "./open-questions";
3
+ import type { AiTurnBlock } from "./protocol";
4
+
5
+ /**
6
+ * Demo Assistant questions for host tests and screenshots, in German and English, with fixed times. The demo data may
7
+ * change in any release: assert on what your host renders from it, not on its wording.
8
+ */
9
+ export const aiQuestionFixtures = (locale: "de" | "en"): Record<string, { block: Extract<AiTurnBlock, { kind: "tool" }> }> =>
10
+ actionFixtures(locale);
11
+
12
+ /** A waiting question as `listAiOpenQuestions` presents it from the chat's tool block. */
13
+ export const aiOpenQuestionFixture = (
14
+ input: {
15
+ id: string;
16
+ conversationId: string;
17
+ conversationTitle: string | null;
18
+ createdAt: string;
19
+ block: Extract<AiTurnBlock, { kind: "tool" }>;
20
+ },
21
+ context: { locale: string; timeZone: string },
22
+ ): AiOpenQuestion =>
23
+ openQuestionFromRow(
24
+ {
25
+ id: input.id,
26
+ conversationId: input.conversationId,
27
+ conversationTitle: input.conversationTitle,
28
+ displayCallId: input.block.callId,
29
+ appId: input.block.presentation?.kind === "capability" ? input.block.presentation.appId : "core",
30
+ sentence: "",
31
+ toolName: input.block.name,
32
+ createdAt: new Date(input.createdAt),
33
+ block: input.block,
34
+ },
35
+ context,
36
+ );
@@ -1,38 +1,156 @@
1
- import type { CapabilityPresentationCatalog } from "../contracts/capabilities";
1
+ import { actionReview } from "../capabilities/reviews";
2
+ import type { CapabilityActionCallReview } from "../contracts/capabilities";
3
+ import { CodeActionInput } from "./browser-code-contracts";
2
4
 
3
- /**
4
- * Sentences for Cloud's own Assistant tools that act on something, in the catalog shape every app uses for
5
- * its Actions and keyed by tool name. The chat words these calls through the same renderer as app Actions;
6
- * a test checks every placeholder against the tool's input schema. A tool that only reads or does several
7
- * things, such as `memory` searching, adding, or forgetting, keeps its title with what it acted on instead.
8
- */
9
- export const CLOUD_AI_TOOL_PRESENTATION = {
10
- baseLocale: "en",
11
- sentences: {
12
- // Code asks for approvals of what it does inside the run, so a rejection or a stop does not mean the code did not run.
13
- code_run: { approval: "Run code", done: "Ran code" },
14
- code_action: { approval: "Run app action {input.action}", done: "Ran app action {input.action}" },
15
- web_extract: { approval: "Read {input.url}", done: "Read {input.url}", notRun: "{input.url} not read" },
16
- fetch_file: { approval: "Download {input.url}", done: "Downloaded {input.url}", notRun: "{input.url} not downloaded" },
17
- write_file: { approval: "Write {input.path}", done: "Wrote {input.path}", notRun: "{input.path} not written" },
5
+ // Like the catalog these forms replace, code runs name no "not run" form: code asks about what it does inside the run,
6
+ // so a declined or stopped question does not mean the code did not run. No tool names a "rejected" form, so a declined
7
+ // call keeps Cloud's own words.
8
+ const forms = {
9
+ en: {
10
+ code_run: {
11
+ action: "Run code",
12
+ running: "Running code",
13
+ done: "Ran code",
14
+ confirm: "Run code",
15
+ decline: "Do not run",
16
+ },
17
+ code_action: {
18
+ action: "Run app action {action}",
19
+ running: "Running app action {action}",
20
+ done: "Ran app action {action}",
21
+ confirm: "Run action",
22
+ decline: "Do not run",
23
+ },
24
+ web_extract: {
25
+ action: "Read page on {site}",
26
+ running: "Reading page on {site}",
27
+ done: "Read page on {site}",
28
+ confirm: "Read page",
29
+ decline: "Do not read",
30
+ notRun: "Page on {site} not read",
31
+ },
32
+ fetch_file: {
33
+ action: "Download file from {site}",
34
+ running: "Downloading file from {site}",
35
+ done: "Downloaded file from {site}",
36
+ confirm: "Download file",
37
+ decline: "Do not download",
38
+ notRun: "File from {site} not downloaded",
39
+ },
40
+ write_file: {
41
+ action: "Write {file}",
42
+ running: "Writing {file}",
43
+ done: "Wrote {file}",
44
+ confirm: "Write file",
45
+ decline: "Do not write",
46
+ notRun: "{file} not written",
47
+ },
18
48
  },
19
- translations: {
20
- de: {
21
- actions: {
22
- code_run: { sentences: { approval: "Code ausführen", done: "Code ausgeführt" } },
23
- code_action: { sentences: { approval: "App-Aktion {input.action} ausführen", done: "App-Aktion {input.action} ausgeführt" } },
24
- web_extract: { sentences: { approval: "{input.url} lesen", done: "{input.url} gelesen", notRun: "{input.url} nicht gelesen" } },
25
- fetch_file: {
26
- sentences: {
27
- approval: "{input.url} herunterladen",
28
- done: "{input.url} heruntergeladen",
29
- notRun: "{input.url} nicht heruntergeladen",
30
- },
31
- },
32
- write_file: {
33
- sentences: { approval: "{input.path} schreiben", done: "{input.path} geschrieben", notRun: "{input.path} nicht geschrieben" },
34
- },
35
- },
49
+ de: {
50
+ code_run: {
51
+ action: "Code ausführen",
52
+ running: "Code wird ausgeführt",
53
+ done: "Code ausgeführt",
54
+ confirm: "Code ausführen",
55
+ decline: "Nicht ausführen",
56
+ },
57
+ code_action: {
58
+ action: "App-Aktion {action} ausführen",
59
+ running: "App-Aktion {action} wird ausgeführt",
60
+ done: "App-Aktion {action} ausgeführt",
61
+ confirm: "Aktion ausführen",
62
+ decline: "Nicht ausführen",
63
+ },
64
+ web_extract: {
65
+ action: "Seite auf {site} lesen",
66
+ running: "Seite auf {site} wird gelesen",
67
+ done: "Seite auf {site} gelesen",
68
+ confirm: "Seite lesen",
69
+ decline: "Nicht lesen",
70
+ notRun: "Seite auf {site} nicht gelesen",
71
+ },
72
+ fetch_file: {
73
+ action: "Datei von {site} herunterladen",
74
+ running: "Datei von {site} wird heruntergeladen",
75
+ done: "Datei von {site} heruntergeladen",
76
+ confirm: "Datei herunterladen",
77
+ decline: "Nicht herunterladen",
78
+ notRun: "Datei von {site} nicht heruntergeladen",
79
+ },
80
+ write_file: {
81
+ action: "{file} schreiben",
82
+ running: "{file} wird geschrieben",
83
+ done: "{file} geschrieben",
84
+ confirm: "Datei schreiben",
85
+ decline: "Nicht schreiben",
86
+ notRun: "{file} nicht geschrieben",
36
87
  },
37
88
  },
38
- } as const satisfies CapabilityPresentationCatalog;
89
+ } as const;
90
+
91
+ /** The bound of a `fields` preview value. */
92
+ const ADDRESS_MAX = 2000;
93
+ const readableAddress = (address: string): string | null => {
94
+ try {
95
+ return decodeURI(address);
96
+ } catch {
97
+ return null;
98
+ }
99
+ };
100
+
101
+ const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
102
+
103
+ /** Cloud tools use the same per-call review contract as application Actions. Safe for historical, incomplete inputs. */
104
+ export const cloudToolReview = (name: string, args: unknown, locale: string): CapabilityActionCallReview | null => {
105
+ if (!isRecord(args)) return null;
106
+ const language = /^de(?:-|$)/i.test(locale) ? "de" : "en";
107
+ const input = args;
108
+ const sentence = forms[language];
109
+ try {
110
+ if (name === "code_run") {
111
+ return actionReview({ locale: language, sentence: sentence.code_run }, {});
112
+ }
113
+ if (name === "code_action") {
114
+ const parsed = CodeActionInput.shape.action.safeParse(input.action);
115
+ if (!parsed.success) return null;
116
+ return actionReview({ locale: language, sentence: sentence.code_action }, { action: { kind: "text", value: parsed.data } });
117
+ }
118
+ if (name === "web_extract" || name === "fetch_file") {
119
+ if (typeof input.url !== "string" || !input.url.trim()) return null;
120
+ const href = input.url;
121
+ const url = new URL(href);
122
+ if (!url.host) return null;
123
+ const label = language === "de" ? "Adresse" : "Address";
124
+ const review = (rows: { label: string; value: string }[]) =>
125
+ actionReview(
126
+ { locale: language, sentence: sentence[name] },
127
+ // A place title holds 200 characters; the address rows keep the whole host.
128
+ { site: { kind: "place", title: url.host.length > 200 ? `${url.host.slice(0, 99)}…${url.host.slice(-100)}` : url.host } },
129
+ { preview: { kind: "fields", rows } },
130
+ );
131
+ // One address row holds at most 2,000 characters. An address whose encoded form is longer shows as written;
132
+ // if even that does not fit, the encoded address continues over numbered rows, so every character stays visible.
133
+ for (const address of [href, readableAddress(href)])
134
+ if (address && address.length <= ADDRESS_MAX) {
135
+ try {
136
+ return review([{ label, value: address }]);
137
+ } catch {
138
+ // Try the next form.
139
+ }
140
+ }
141
+ const parts = Array.from({ length: Math.ceil(href.length / ADDRESS_MAX) }, (_, index) =>
142
+ href.slice(index * ADDRESS_MAX, (index + 1) * ADDRESS_MAX),
143
+ );
144
+ return review(parts.map((value, index) => ({ label: `${label} ${index + 1}/${parts.length}`, value })));
145
+ }
146
+ if (name === "write_file") {
147
+ if (typeof input.path !== "string" || !input.path.trim()) return null;
148
+ const file = input.path.trim().split("/").at(-1);
149
+ if (!file) return null;
150
+ return actionReview({ locale: language, sentence: sentence.write_file }, { file: { kind: "object", title: file.slice(0, 200) } });
151
+ }
152
+ } catch {
153
+ // Historical calls can predate the current bounds or carry partial input; they keep their generic tool title.
154
+ }
155
+ return null;
156
+ };