@k2b/cloud 0.39.0 → 0.41.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 (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -1,7 +1,9 @@
1
1
  import type { AssistantMessage, CompactEvent, LoopAggregate, Message, NessiLoop, OutboundEvent } from "@k2b/nessi";
2
2
  import { compact, nessi } from "@k2b/nessi";
3
+ import { capabilityReviewRememberScope } from "../_internal/capability-reviews";
3
4
  import { listCapabilities } from "../_internal/registry";
4
- import type { CapabilityActionReview } from "../contracts/capabilities";
5
+ import type { CapabilityActionReviewData } from "../contracts/capabilities";
6
+ import { capabilityActionPolicy } from "../contracts/capabilities";
5
7
  import type { AccessSubject, RequestActor } from "../server";
6
8
  import { approvalAssurance, approvalScopeFromToolName, approvals } from "../services/approvals";
7
9
  import { createHelpReader } from "../services/help";
@@ -10,6 +12,7 @@ import { getMandate } from "../services/mandates";
10
12
  import type { MandatePolicyV1 } from "../services/mandates/policy";
11
13
  import { coreSettings } from "../services/settings/api";
12
14
  import { normalizeLocale } from "../shared/locale";
15
+ import { type AiActionCheck, type AiStoredActionReview, storedActionReview } from "./action-review";
13
16
  import {
14
17
  AI_WEBSITE_APPROVAL_TOOL,
15
18
  type AiToolApprovalContext,
@@ -19,20 +22,23 @@ import {
19
22
  hasRememberedAiToolApproval,
20
23
  } from "./approvals";
21
24
  import { aiChatAccessSubject, isAssistantChatTurn, resolveAssistantAudioModel } from "./assistant-models";
25
+ import { authorizeBackgroundActionWith } from "./background-review";
22
26
  import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
23
- import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
24
- import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
27
+ import { type AiCapabilityCatalogEntry, buildAiCapabilityCatalog, createAiToolResolver, createRunToolStore } from "./capabilities";
28
+ import {
29
+ AiCapabilityExecutionError,
30
+ aiCapabilityCheckResult,
31
+ checkAiCapability,
32
+ executeAiCapability,
33
+ resolveAiCapabilityActor,
34
+ reviewAiCapability,
35
+ } from "./capability-execution";
25
36
  import { aiChatMessages } from "./chat/messages";
26
37
  import { isFailedTool } from "./chat/tool-groups";
27
38
  import { aiTurnErrorText } from "./chat/turn-error";
28
39
  import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
29
40
  import { createCloudCompactFn } from "./compaction";
30
- import {
31
- createCloudAiCodeTools,
32
- createCloudAiLocalBashTool,
33
- createCloudAiLocalTools,
34
- createConfiguredDefaultCloudAiTools,
35
- } from "./default-tools";
41
+ import { createAiTurnCodeAndClientTools, createConfiguredDefaultCloudAiTools } from "./default-tools";
36
42
  import { aiFileStore } from "./files-store";
37
43
  import { AI_LOCAL_TOOL_NAMES } from "./local-tool-contracts";
38
44
  import {
@@ -72,7 +78,15 @@ import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
72
78
  import { mergeAiSkillCatalog, selectAiSkillCatalog } from "./skill-catalog";
73
79
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
74
80
  import { aiSkills } from "./skills";
75
- import { aiConversations, recordAiWebsiteReceipts } from "./store";
81
+ import {
82
+ AI_TOOL_CHECK_STALE_MS,
83
+ aiConversations,
84
+ claimAiToolCheck,
85
+ readAiToolCheck,
86
+ recordAiToolReview,
87
+ recordAiWebsiteReceipts,
88
+ settleAiToolCheck,
89
+ } from "./store";
76
90
  import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
77
91
  import { composeAiSystemPrompt } from "./system-prompt";
78
92
  import { aiToolAudit } from "./tool-audit";
@@ -427,9 +441,9 @@ type ToolBlockPatch = Partial<Extract<AiTurnBlock, { kind: "tool" }>> & { name?:
427
441
  const customApprovalParentCallId = (callId: string): string | undefined => /^(.*)-approval-\d+$/.exec(callId)?.[1];
428
442
 
429
443
  const approvalReviewForCallId = (
430
- reviews: ReadonlyMap<string, CapabilityActionReview>,
444
+ reviews: ReadonlyMap<string, CapabilityActionReviewData>,
431
445
  callId: string,
432
- ): CapabilityActionReview | undefined => {
446
+ ): CapabilityActionReviewData | undefined => {
433
447
  const direct = reviews.get(callId);
434
448
  if (direct) return direct;
435
449
  const parentCallId = customApprovalParentCallId(callId);
@@ -479,9 +493,11 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
479
493
  const setCanonicalNames = (items: Map<string, string>) => {
480
494
  canonicalNames = items;
481
495
  };
482
- let approvalReviews = new Map<string, CapabilityActionReview>();
483
- const setApprovalReviews = (items: Map<string, CapabilityActionReview>) => {
496
+ let approvalReviews = new Map<string, CapabilityActionReviewData>();
497
+ let capabilityScopes: ReadonlyMap<string, string> = new Map();
498
+ const setApprovalReviews = (items: Map<string, CapabilityActionReviewData>, scopes: ReadonlyMap<string, string> = new Map()) => {
484
499
  approvalReviews = items;
500
+ capabilityScopes = scopes;
485
501
  };
486
502
  let approvalPolicies: PreparedAiTools["approvalPolicies"] = new Map();
487
503
  const setApprovalPolicies = (items: PreparedAiTools["approvalPolicies"]) => {
@@ -505,7 +521,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
505
521
  const streamKinds = new Map<string, "text" | "thinking">();
506
522
 
507
523
  const setTool = (callId: string, patch: ToolBlockPatch, displayCallId = callId): BlockOp => {
508
- const existing = toolBlocks.get(callId);
524
+ const existing = toolBlocks.get(callId) ?? toolBlocks.get(displayCallId);
509
525
  const rawName = patch.name ?? existing?.name ?? "tool";
510
526
  const name = canonicalNames.get(rawName) ?? rawName;
511
527
  const approval = patch.clearApproval ? undefined : "approval" in patch ? patch.approval : existing?.approval;
@@ -520,6 +536,8 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
520
536
  result: "result" in patch ? patch.result : existing?.result,
521
537
  isError: "isError" in patch ? patch.isError : existing?.isError,
522
538
  approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false, allowChat: false } : approval,
539
+ review: patch.review ?? existing?.review,
540
+ check: patch.check ?? existing?.check,
523
541
  frontendMode: patch.frontendMode ?? existing?.frontendMode,
524
542
  presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
525
543
  ...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
@@ -572,7 +590,8 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
572
590
  const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
573
591
  const allowAlways = target
574
592
  ? target.always
575
- : review?.approvalScope !== undefined || aiToolAllowsAlways(approvalPolicies.get(event.name));
593
+ : capabilityScopes.has(customApprovalParentCallId(event.callId) ?? event.callId) ||
594
+ aiToolAllowsAlways(approvalPolicies.get(event.name));
576
595
  return [
577
596
  setTool(
578
597
  event.callId,
@@ -651,6 +670,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
651
670
 
652
671
  return {
653
672
  translate,
673
+ setTool,
654
674
  storedMessage,
655
675
  compaction,
656
676
  setFrontendModes,
@@ -690,10 +710,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
690
710
  accessSubject: aiChatAccessSubject(config.actor),
691
711
  allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
692
712
  })),
693
- // Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
694
- ...[createCloudAiLocalBashTool(), ...createCloudAiLocalTools(), ...createCloudAiCodeTools()].filter(
695
- (tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
696
- ),
713
+ ...createAiTurnCodeAndClientTools(config),
697
714
  ]
698
715
  : [],
699
716
  toolApprovalContext: config.toolApprovalContext,
@@ -997,11 +1014,7 @@ export class AiTurnExecutor {
997
1014
  ];
998
1015
  const toolsSupported = resolved.profile.capabilities.includes("tools");
999
1016
  const allowed = allowedTools === null ? null : new Set(allowedTools);
1000
- const activeTools = toolsSupported
1001
- ? runtimeTools.filter(
1002
- (tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
1003
- )
1004
- : [];
1017
+ const activeTools = toolsSupported ? runtimeTools.filter((tool) => !allowed || allowed.has(tool.def.name)) : [];
1005
1018
  let audioUnavailableInstruction: string | undefined;
1006
1019
  if (
1007
1020
  defaultToolSource &&
@@ -1042,6 +1055,8 @@ export class AiTurnExecutor {
1042
1055
  const approvalTargets = new Map<string, AiApprovalTarget>();
1043
1056
  const linkedApprovals = new Map<string, string>();
1044
1057
  const freshApprovalRequired = new Set<string>();
1058
+ const capabilityActionReviews = new Map<string, CapabilityActionReviewData>();
1059
+ const storedReviews = new Map<string, AiStoredActionReview>();
1045
1060
  const dynamicToolRuntimeContext = {
1046
1061
  turnId,
1047
1062
  reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
@@ -1083,6 +1098,14 @@ export class AiTurnExecutor {
1083
1098
  return "changed";
1084
1099
  },
1085
1100
  describeApproval: (approvalCallId: string, target: AiApprovalTarget) => approvalTargets.set(approvalCallId, target),
1101
+ describeReview: async (approvalCallId: string, review: CapabilityActionReviewData) => {
1102
+ capabilityActionReviews.set(approvalCallId, review);
1103
+ const callId = customApprovalParentCallId(approvalCallId) ?? approvalCallId;
1104
+ const stored = storedActionReview(review);
1105
+ await recordAiToolReview({ conversationId, turnId, callId, review: stored });
1106
+ storedReviews.set(callId, stored);
1107
+ await pipeline.setToolReview(callId, stored);
1108
+ },
1086
1109
  };
1087
1110
  const prepared = prepareAiTools({
1088
1111
  tools: activeTools,
@@ -1092,7 +1115,21 @@ export class AiTurnExecutor {
1092
1115
  });
1093
1116
  let backgroundError: string | null = null;
1094
1117
  const rememberableCapabilityApprovals = new Map<string, string>();
1095
- const capabilityActionReviews = new Map<string, CapabilityActionReview>();
1118
+ const onCapabilityReview = async (
1119
+ callId: string,
1120
+ review: CapabilityActionReviewData,
1121
+ approval: "none" | "rememberable" | undefined,
1122
+ entry: AiCapabilityCatalogEntry,
1123
+ ) => {
1124
+ capabilityActionReviews.set(callId, review);
1125
+ const scope = capabilityReviewRememberScope(review, approval);
1126
+ if (scope) rememberableCapabilityApprovals.set(callId, scope);
1127
+ else rememberableCapabilityApprovals.delete(callId);
1128
+ const stored = storedActionReview(review, capabilityActionPolicy(entry.app.manifest, entry.operation.localId));
1129
+ await recordAiToolReview({ conversationId, turnId, callId, review: stored });
1130
+ storedReviews.set(callId, stored);
1131
+ await pipeline.setToolReview(callId, stored);
1132
+ };
1096
1133
  pipeline.setApprovalTargets(approvalTargets);
1097
1134
  pipeline.setFrontendModes(prepared.frontendModes);
1098
1135
  pipeline.setCanonicalNames(prepared.canonicalNames);
@@ -1101,7 +1138,7 @@ export class AiTurnExecutor {
1101
1138
  const rejectedToolCallIds = new Set<string>();
1102
1139
  const approvedToolCallIds = new Set<string>();
1103
1140
  pipeline.setPresentations(toolPresentations);
1104
- pipeline.setApprovalReviews(capabilityActionReviews);
1141
+ pipeline.setApprovalReviews(capabilityActionReviews, rememberableCapabilityApprovals);
1105
1142
  let turnInput = config.input;
1106
1143
  try {
1107
1144
  if (resolved.profile.capabilities.includes("vision") && config.files?.attached.some((file) => isAiImageMediaType(file.mediaType))) {
@@ -1192,7 +1229,7 @@ export class AiTurnExecutor {
1192
1229
  );
1193
1230
  return;
1194
1231
  }
1195
- const authorizeBackground =
1232
+ const authorizeBackgroundMandate =
1196
1233
  config.background && config.mandate
1197
1234
  ? async (entry: AiCapabilityCatalogEntry, args: unknown) => {
1198
1235
  try {
@@ -1202,6 +1239,7 @@ export class AiTurnExecutor {
1202
1239
  capabilityId: entry.operation.localId,
1203
1240
  kind: entry.kind,
1204
1241
  input: args,
1242
+ policy: capabilityActionPolicy(entry.app.manifest, entry.operation.localId),
1205
1243
  approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
1206
1244
  });
1207
1245
  } catch (error) {
@@ -1245,16 +1283,51 @@ export class AiTurnExecutor {
1245
1283
  }),
1246
1284
  }
1247
1285
  : {}),
1248
- authorizeBackground,
1249
- onReview: (callId, review) => {
1250
- capabilityActionReviews.set(callId, review);
1251
- if (review.approvalScope) rememberableCapabilityApprovals.set(callId, review.approvalScope);
1252
- },
1286
+ authorizeBackground: authorizeBackgroundMandate
1287
+ ? async (entry, args, context) => {
1288
+ const review = await authorizeBackgroundActionWith(
1289
+ {
1290
+ turnId,
1291
+ mandate: config.mandate!,
1292
+ appId: entry.appId,
1293
+ capabilityId: entry.operation.localId,
1294
+ kind: entry.kind,
1295
+ args,
1296
+ policy: capabilityActionPolicy(entry.app.manifest, entry.operation.localId),
1297
+ approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
1298
+ hasReview: entry.kind === "action" && "review" in entry.operation && entry.operation.review === true,
1299
+ action: entry.display.title,
1300
+ review: () =>
1301
+ capabilityAuthority
1302
+ ? reviewAiCapability({
1303
+ conversationId,
1304
+ authority: capabilityAuthority,
1305
+ mandate: config.mandate,
1306
+ locale: promptLocale,
1307
+ entry,
1308
+ args,
1309
+ context,
1310
+ })
1311
+ : Promise.resolve(null),
1312
+ signal: context.signal,
1313
+ },
1314
+ () => authorizeBackgroundMandate(entry, args),
1315
+ );
1316
+ if (review && context.callId)
1317
+ await onCapabilityReview(
1318
+ context.callId,
1319
+ review,
1320
+ "approval" in entry.operation ? entry.operation.approval : undefined,
1321
+ entry,
1322
+ );
1323
+ }
1324
+ : undefined,
1325
+ onReview: onCapabilityReview,
1253
1326
  ...(capabilityAuthority
1254
1327
  ? {
1255
1328
  execute: async (entry, args, context) => {
1256
1329
  try {
1257
- await authorizeBackground?.(entry, args);
1330
+ await authorizeBackgroundMandate?.(entry, args);
1258
1331
  const result = await executeAiCapability({
1259
1332
  conversationId,
1260
1333
  turnId,
@@ -1268,9 +1341,78 @@ export class AiTurnExecutor {
1268
1341
  entry,
1269
1342
  args,
1270
1343
  context,
1344
+ }).catch(async (error: unknown) => {
1345
+ const review = context.callId ? storedReviews.get(context.callId) : undefined;
1346
+ if (entry.kind !== "action" || !(error instanceof AiCapabilityExecutionError)) throw error;
1347
+ // Only an answer from the app about the request itself (a 4xx) proves that nothing happened. A 5xx,
1348
+ // a timeout, or a cancellation leaves the outcome open only once Cloud sent the request to the app;
1349
+ // before that, nothing ran and the error stays as it is. The idempotency codes speak of an earlier
1350
+ // attempt with the same key, so they leave it open even when this one sent nothing.
1351
+ const uncertain =
1352
+ ["ACTION_OUTCOME_UNKNOWN", "IDEMPOTENCY_UNCERTAIN", "IDEMPOTENCY_IN_PROGRESS"].includes(error.code) ||
1353
+ (error.dispatched && (error.status >= 500 || error.status === 499));
1354
+ if (!uncertain) throw error;
1355
+ const unknown =
1356
+ error.code === "ACTION_OUTCOME_UNKNOWN"
1357
+ ? error
1358
+ : new AiCapabilityExecutionError(
1359
+ "ACTION_OUTCOME_UNKNOWN",
1360
+ error.status,
1361
+ `The outcome is unknown after ${error.code}; do not retry automatically.`,
1362
+ error.dispatched,
1363
+ );
1364
+ if (!review || !("sentence" in review) || !review.check || !context.callId) throw unknown;
1365
+ let check: AiActionCheck;
1366
+ try {
1367
+ // Durable first: the claim stores `checking` before the Query runs. If this worker stops, the
1368
+ // claim goes stale and the person can check again; while it holds, no re-check runs beside it.
1369
+ const target = { conversationId, turnId, callId: context.callId };
1370
+ const claimedAt = new Date().toISOString();
1371
+ const claimed = await claimAiToolCheck({
1372
+ ...target,
1373
+ at: claimedAt,
1374
+ staleBefore: new Date(Date.now() - AI_TOOL_CHECK_STALE_MS).toISOString(),
1375
+ first: true,
1376
+ });
1377
+ if (!claimed) {
1378
+ // A person's check holds or settled the call already; its stored result is the outcome.
1379
+ const stored = await readAiToolCheck(target);
1380
+ const known = stored && stored.state !== "checking" ? stored : { state: "pending" as const };
1381
+ check = known;
1382
+ } else {
1383
+ await pipeline.setToolCheck(context.callId, { state: "checking" });
1384
+ const answered = await checkAiCapability({
1385
+ conversationId,
1386
+ turnId,
1387
+ authority: capabilityAuthority!,
1388
+ mandate: config.mandate,
1389
+ // The receipt joins the review's sentence, so it is asked for in the review's language.
1390
+ locale: review.locale,
1391
+ entry,
1392
+ check: review.check,
1393
+ catalog: buildAiCapabilityCatalog([entry.app], promptLocale),
1394
+ context,
1395
+ });
1396
+ // A claim that went stale and was taken over loses to the newer check's stored result.
1397
+ check = (await settleAiToolCheck({ ...target, claimedAt, check: answered }))
1398
+ ? answered
1399
+ : ((await readAiToolCheck(target)) ?? answered);
1400
+ }
1401
+ await pipeline.setToolCheck(context.callId, check);
1402
+ } catch (recoveryError) {
1403
+ log.warn("AI Action outcome recovery failed", {
1404
+ code: "action_outcome_recovery_failed",
1405
+ turnId,
1406
+ callId: context.callId,
1407
+ error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
1408
+ });
1409
+ throw unknown;
1410
+ }
1411
+ return aiCapabilityCheckResult(check);
1271
1412
  });
1272
1413
  const resources = collectConversationResourceObservations(args, result);
1273
1414
  if (resources.length) {
1415
+ // Bookkeeping after a call that ran never turns its outcome into a failure: the effect happened.
1274
1416
  await Promise.all([
1275
1417
  indexConversationResources({ conversationId, turnId, callId: context.callId, resources }),
1276
1418
  recordMemoryWorkflowEvidence({
@@ -1280,7 +1422,14 @@ export class AiTurnExecutor {
1280
1422
  capabilityId: entry.name,
1281
1423
  resources,
1282
1424
  }),
1283
- ]);
1425
+ ]).catch((error: unknown) =>
1426
+ log.warn("AI capability bookkeeping failed after the call ran", {
1427
+ code: "capability_bookkeeping_failed",
1428
+ turnId,
1429
+ callId: context.callId,
1430
+ error: error instanceof Error ? error.message : String(error),
1431
+ }),
1432
+ );
1284
1433
  }
1285
1434
  return result;
1286
1435
  } catch (error) {
@@ -1593,7 +1742,7 @@ export class AiTurnExecutor {
1593
1742
  approvalContext?: AiToolApprovalContext;
1594
1743
  allowRememberedApprovals: boolean;
1595
1744
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1596
- capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1745
+ capabilityActionReviews: ReadonlyMap<string, CapabilityActionReviewData>;
1597
1746
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1598
1747
  linkedApprovals: ReadonlyMap<string, string>;
1599
1748
  freshApprovalRequired?: ReadonlySet<string>;
@@ -1778,7 +1927,7 @@ export class AiTurnExecutor {
1778
1927
  approvalContext?: AiToolApprovalContext;
1779
1928
  allowRememberedApprovals: boolean;
1780
1929
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1781
- capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1930
+ capabilityActionReviews: ReadonlyMap<string, CapabilityActionReviewData>;
1782
1931
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1783
1932
  linkedApprovals: ReadonlyMap<string, string>;
1784
1933
  freshApprovalRequired?: ReadonlySet<string>;
@@ -2207,8 +2356,8 @@ class StreamPipeline {
2207
2356
  this.mapper.setCanonicalNames(names);
2208
2357
  }
2209
2358
 
2210
- setApprovalReviews(reviews: Map<string, CapabilityActionReview>): void {
2211
- this.mapper.setApprovalReviews(reviews);
2359
+ setApprovalReviews(reviews: Map<string, CapabilityActionReviewData>, scopes: ReadonlyMap<string, string>): void {
2360
+ this.mapper.setApprovalReviews(reviews, scopes);
2212
2361
  }
2213
2362
 
2214
2363
  setApprovalPolicies(policies: PreparedAiTools["approvalPolicies"]): void {
@@ -2290,6 +2439,16 @@ class StreamPipeline {
2290
2439
  await this.maybeSnapshot();
2291
2440
  }
2292
2441
 
2442
+ async setToolReview(callId: string, review: AiStoredActionReview): Promise<void> {
2443
+ await this.emitOp(this.mapper.setTool(callId, { review }));
2444
+ await this.maybeSnapshot();
2445
+ }
2446
+
2447
+ async setToolCheck(callId: string, check: AiActionCheck): Promise<void> {
2448
+ await this.emitOp(this.mapper.setTool(callId, { check, status: "running" }));
2449
+ await this.maybeSnapshot();
2450
+ }
2451
+
2293
2452
  async reportToolProgress(callId: string, progress: string): Promise<void> {
2294
2453
  const block = this.blocks.find((block) => block.kind === "tool" && block.callId === callId);
2295
2454
  if (!block || block.kind !== "tool" || block.progress === progress) return;
@@ -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/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  export { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
23
23
  export { type AiAttachmentRef, aiAttachmentMarker, formatAiFileSize, parseAiAttachmentMarkers } from "./attachments";
24
24
  export { CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema, createCloudAiTranscribeAudioTool } from "./audio-tool";
25
+ export { AiBackgroundApprovalRequiredError, authorizeBackgroundAction } from "./background-review";
25
26
  export { CODE_RUNTIME_TOOL_NAMES, CodeResourceId, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
26
27
  export { aiCapabilityId, aiCapabilityToolName } from "./capabilities";
27
28
  export { getAiChatQuotas } from "./chat-quotas";
@@ -261,6 +262,7 @@ export {
261
262
  createCloudAiMemoryTool,
262
263
  } from "./memory-tool";
263
264
  export { migrateCloudAi } from "./migrate";
265
+ export { type AiOpenQuestion, type AiOpenQuestions, listAiOpenQuestions } from "./open-questions";
264
266
  export { personalAiModelPolicy } from "./personal-agent";
265
267
  export { type AiUserPrefs, aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
266
268
  export {
@@ -419,6 +421,7 @@ export type {
419
421
  AiProjectPromptSnapshot,
420
422
  AiProviderId,
421
423
  AiPublicModelProfile,
424
+ AiRecentResource,
422
425
  AiResolvedModel,
423
426
  AiRuntimeTool,
424
427
  AiSettingsError,
package/src/ai/migrate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { sql } from "bun";
2
- import { CapabilityActionReviewSchema } from "../contracts/capabilities";
2
+ import { CapabilityActionReviewDataSchema } from "../contracts/capabilities";
3
3
  import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
4
4
  import { migrateChatTaskReasons } from "./chat-task-reasons-migrate";
5
5
  import { migrateAiMessageQueue } from "./message-queue";
@@ -404,6 +404,15 @@ export const migrateCloudAi = async (): Promise<void> => {
404
404
  ON ai.messages(conversation_id, loop_id, seq ASC)
405
405
  WHERE loop_id IS NOT NULL
406
406
  `.simple();
407
+ // "Recent" in the slash menu reads the newest messages a person sent in each of their latest chats. Without it the
408
+ // read walks every Assistant and tool row of those chats. The predicate matches the store query word for word.
409
+ await sql`
410
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_sent_by_person
411
+ ON ai.messages(conversation_id, seq DESC) INCLUDE (created_at, id)
412
+ WHERE role = 'user' AND kind = 'message'
413
+ AND NOT (COALESCE(meta, '{}'::jsonb) ? 'scheduledTask')
414
+ AND NOT (COALESCE(meta, '{}'::jsonb) ? 'agentMessage')
415
+ `.simple();
407
416
 
408
417
  // Optional pg_textsearch ranking. Native weighted FTS above is always the
409
418
  // correctness path; missing extension support must never block startup.
@@ -2288,6 +2297,21 @@ export const migrateCloudAi = async (): Promise<void> => {
2288
2297
  AFTER UPDATE OF status, error, completed_at, cancel_requested_at ON ai.turns
2289
2298
  FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list,conversation-detail')
2290
2299
  `.simple();
2300
+ // Only questions for the person change the list of open questions; browser tool calls come and go per turn.
2301
+ await sql`DROP TRIGGER IF EXISTS ai_live_pending_actions_changed ON ai.pending_actions`.simple();
2302
+ await sql`DROP TRIGGER IF EXISTS ai_live_pending_actions_removed ON ai.pending_actions`.simple();
2303
+ await sql`
2304
+ CREATE TRIGGER ai_live_pending_actions_changed
2305
+ AFTER INSERT OR UPDATE OF status ON ai.pending_actions
2306
+ FOR EACH ROW WHEN (NEW.kind IN ('approval', 'custom_approval'))
2307
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')
2308
+ `.simple();
2309
+ await sql`
2310
+ CREATE TRIGGER ai_live_pending_actions_removed
2311
+ AFTER DELETE ON ai.pending_actions
2312
+ FOR EACH ROW WHEN (OLD.kind IN ('approval', 'custom_approval'))
2313
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')
2314
+ `.simple();
2291
2315
  await sql`DROP TRIGGER IF EXISTS ai_live_chat_tasks_changed ON ai.chat_tasks`.simple();
2292
2316
  await sql`
2293
2317
  CREATE TRIGGER ai_live_chat_tasks_changed AFTER INSERT OR UPDATE OR DELETE ON ai.chat_tasks
@@ -2667,7 +2691,7 @@ const backfillApprovalRequests = async (): Promise<void> => {
2667
2691
  WHERE r.requester_kind='assistant_turn' AND r.requester_ref=a.turn_id::text || '/' || a.call_id)
2668
2692
  ORDER BY a.created_at, a.id LIMIT 200 FOR UPDATE OF a SKIP LOCKED`;
2669
2693
  for (const row of rows) {
2670
- const review = CapabilityActionReviewSchema.safeParse(row.review == null ? null : decoded(row.review));
2694
+ const review = CapabilityActionReviewDataSchema.safeParse(row.review == null ? null : decoded(row.review));
2671
2695
  // request computes both canonical digests; preserve the original wait deadline, including expiry.
2672
2696
  const record = await approvals.request(
2673
2697
  {