@k2b/cloud 0.29.1 → 0.31.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 (136) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/app-roles.ts +14 -0
  4. package/src/_internal/capabilities.ts +113 -5
  5. package/src/_internal/capability-sentences.ts +413 -0
  6. package/src/_internal/define-app.ts +24 -5
  7. package/src/_internal/registry-validation.ts +5 -0
  8. package/src/_internal/registry.ts +50 -6
  9. package/src/_internal/widget-declarations.ts +53 -0
  10. package/src/access/PermissionEditor.tsx +43 -24
  11. package/src/ai/app-skill-store.ts +298 -0
  12. package/src/ai/app-skills.ts +105 -0
  13. package/src/ai/approval-routes.ts +40 -4
  14. package/src/ai/approvals.ts +68 -10
  15. package/src/ai/browser.ts +10 -0
  16. package/src/ai/capabilities.ts +68 -9
  17. package/src/ai/chart-block.ts +380 -0
  18. package/src/ai/chat/action-sentences.ts +49 -0
  19. package/src/ai/chat/blocks.tsx +97 -40
  20. package/src/ai/chat/chart-block.tsx +79 -0
  21. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  22. package/src/ai/chat/message-actions.tsx +3 -1
  23. package/src/ai/chat/message-utils.ts +3 -0
  24. package/src/ai/chat/messages.ts +26 -2
  25. package/src/ai/chat/tool-groups.ts +66 -12
  26. package/src/ai/chat/tool-names.ts +6 -2
  27. package/src/ai/chat/turn-layout.ts +10 -2
  28. package/src/ai/chat/turn-view.tsx +123 -36
  29. package/src/ai/chat/web-tools.tsx +95 -1
  30. package/src/ai/client/controller.ts +6 -3
  31. package/src/ai/code-runtime-tools.ts +109 -61
  32. package/src/ai/code-source-contracts.ts +25 -21
  33. package/src/ai/code-source-tools.ts +3 -1
  34. package/src/ai/default-tool-contracts.ts +198 -0
  35. package/src/ai/default-tools.ts +25 -1
  36. package/src/ai/executor.ts +189 -35
  37. package/src/ai/fetch-file-tool.ts +41 -4
  38. package/src/ai/firecrawl-tools.ts +87 -4
  39. package/src/ai/index.ts +17 -1
  40. package/src/ai/migrate.ts +172 -14
  41. package/src/ai/protocol.ts +22 -1
  42. package/src/ai/routes.ts +11 -0
  43. package/src/ai/runtime-tools.ts +1 -0
  44. package/src/ai/runtime.ts +46 -7
  45. package/src/ai/skill-content.ts +54 -0
  46. package/src/ai/skills-routes.ts +22 -13
  47. package/src/ai/skills.ts +177 -231
  48. package/src/ai/store.ts +66 -1
  49. package/src/ai/tool-audit.ts +11 -1
  50. package/src/ai/tool-sentences.ts +38 -0
  51. package/src/ai/tools.ts +91 -3
  52. package/src/ai/types.ts +47 -1
  53. package/src/ai/web-provenance.ts +103 -0
  54. package/src/api/admin-ai-skills.ts +35 -16
  55. package/src/api/capabilities.ts +3 -2
  56. package/src/api/fanout.ts +128 -0
  57. package/src/api/help.ts +8 -3
  58. package/src/api/mcp.ts +4 -1
  59. package/src/api/search.ts +31 -106
  60. package/src/api/widgets.ts +291 -101
  61. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  62. package/src/browser/CloudResourceSearch.tsx +1 -1
  63. package/src/browser/command-shortcuts.ts +21 -6
  64. package/src/browser/ndjson.ts +34 -0
  65. package/src/browser/reload.ts +46 -0
  66. package/src/browser/search-stream.ts +4 -26
  67. package/src/browser/spotlight-position.ts +4 -1
  68. package/src/browser/widgets.ts +30 -0
  69. package/src/capabilities/executions.ts +3 -0
  70. package/src/capabilities/server.ts +20 -2
  71. package/src/cli/access.ts +48 -14
  72. package/src/cli/admin/ai-skills.ts +59 -39
  73. package/src/cli/commands.ts +20 -0
  74. package/src/cli/index.ts +16 -0
  75. package/src/cli/plugin.ts +30 -0
  76. package/src/contracts/announcement-cookie.ts +62 -0
  77. package/src/contracts/announcements.ts +14 -52
  78. package/src/contracts/app.ts +34 -2
  79. package/src/contracts/capabilities.ts +24 -0
  80. package/src/contracts/pwa-paths.ts +7 -2
  81. package/src/contracts/pwa.ts +2 -7
  82. package/src/contracts/registry.ts +10 -1
  83. package/src/contracts/shared.ts +5 -0
  84. package/src/contracts/widgets.ts +67 -2
  85. package/src/desktop/solid.tsx +6 -7
  86. package/src/index.ts +1 -0
  87. package/src/server/help.ts +8 -1
  88. package/src/server/index.ts +1 -0
  89. package/src/server/widget-request.ts +27 -0
  90. package/src/services/accounts/users.ts +15 -0
  91. package/src/services/approvals/index.ts +225 -0
  92. package/src/services/approvals/migrate.ts +33 -0
  93. package/src/services/help/index.ts +18 -5
  94. package/src/services/help/types.ts +3 -2
  95. package/src/services/identity/constants.ts +2 -0
  96. package/src/services/identity/key-ring.ts +123 -42
  97. package/src/services/identity/runtime-config.ts +13 -2
  98. package/src/shared/app-presentation.ts +11 -0
  99. package/src/shared/email-html.ts +43 -8
  100. package/src/ssr/AdminLayout.tsx +4 -7
  101. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  102. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  103. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  104. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  105. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  106. package/src/ssr/Layout.tsx +38 -14
  107. package/src/ssr/LayoutFooter.tsx +1 -1
  108. package/src/ssr/LayoutHeader.tsx +17 -20
  109. package/src/ssr/LayoutHelp.tsx +1 -1
  110. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  111. package/src/ssr/LayoutRail.tsx +1 -2
  112. package/src/ssr/PageTrail.tsx +23 -0
  113. package/src/ssr/PageTrailRow.island.tsx +20 -0
  114. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  115. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  116. package/src/ssr/PwaRuntime.island.tsx +1 -1
  117. package/src/ssr/app-navigation.ts +5 -7
  118. package/src/ssr/global-search-host.tsx +55 -0
  119. package/src/ssr/help.ts +3 -3
  120. package/src/ssr/islands/index.ts +1 -0
  121. package/src/ssr/layout-runtime.ts +60 -0
  122. package/src/ssr/mobile-menu-history.ts +1 -1
  123. package/src/ssr/page-trail-runtime.ts +313 -0
  124. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  125. package/src/ssr/page-trail.ts +59 -0
  126. package/src/ssr/platform-messages.ts +4 -0
  127. package/src/ssr/rail-context.ts +4 -3
  128. package/src/ssr/runtime.ts +14 -1
  129. package/src/styles/effects.css +21 -4
  130. package/src/styles/global.css +35 -2
  131. package/src/styles/utilities-layout.css +24 -1
  132. package/src/styles/utilities-navigation.css +6 -18
  133. package/src/ai/code-mode-skill.ts +0 -114
  134. package/src/ai/data-analysis-skill.ts +0 -10
  135. package/src/ai/grids-skill.ts +0 -158
  136. package/src/ai/skill-seeds.ts +0 -560
package/src/ai/store.ts CHANGED
@@ -4,6 +4,7 @@ import { type SQL, sql } from "bun";
4
4
  import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
5
5
  import { logger } from "../services/logging";
6
6
  import { toPgTextArray } from "../services/postgres";
7
+ import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
7
8
  import { aiTurnErrorText } from "./chat/turn-error";
8
9
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
9
10
  import type { AiTurnBlock } from "./protocol";
@@ -36,6 +37,7 @@ import type {
36
37
  AiTurnStatus,
37
38
  AiTurnSteer,
38
39
  AiTurnSweepResult,
40
+ AiWebsiteReceipt,
39
41
  } from "./types";
40
42
 
41
43
  /** A queued turn this old without a claim is considered lost and re-enqueued by the sweep. */
@@ -191,6 +193,7 @@ type TurnRow = {
191
193
  };
192
194
 
193
195
  type PendingActionRow = {
196
+ approval_id: string | null;
194
197
  turn_id: string;
195
198
  conversation_id: string;
196
199
  call_id: string;
@@ -202,6 +205,8 @@ type PendingActionRow = {
202
205
  review: unknown | null;
203
206
  approval_scope: string;
204
207
  allow_always: boolean;
208
+ allow_chat: boolean | null;
209
+ remember_tool_name: string | null;
205
210
  frontend_mode: AiFrontendToolMode | null;
206
211
  resolved_event: unknown | null;
207
212
  };
@@ -678,9 +683,12 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
678
683
  message: row.message ?? undefined,
679
684
  review: parseCapabilityActionReview(row.review),
680
685
  allowAlways: row.allow_always,
686
+ allowChat: row.allow_always || row.allow_chat === true,
687
+ ...(row.remember_tool_name === AI_WEBSITE_APPROVAL_TOOL ? { website: row.approval_scope } : {}),
681
688
  };
682
689
 
683
690
  const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRecord => ({
691
+ approvalId: row.approval_id ?? undefined,
684
692
  turnId: row.turn_id,
685
693
  conversationId: row.conversation_id,
686
694
  callId: row.call_id,
@@ -692,6 +700,8 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
692
700
  review: parseCapabilityActionReview(row.review),
693
701
  approvalScope: row.approval_scope,
694
702
  allowAlways: row.allow_always,
703
+ allowChat: row.allow_always || row.allow_chat === true,
704
+ ...(row.remember_tool_name ? { rememberToolName: row.remember_tool_name } : {}),
695
705
  frontendMode: row.frontend_mode ?? undefined,
696
706
  resolvedEvent: row.resolved_event ? parseJsonValue<InboundEvent>(row.resolved_event) : null,
697
707
  });
@@ -784,6 +794,18 @@ const messageSearchText = (message: Message): string => {
784
794
  return text.slice(0, SEARCH_TEXT_MAX_CHARS);
785
795
  };
786
796
 
797
+ /**
798
+ * An ended turn uses no further approval: cancel its open questions and the turn's own approvals that no call used.
799
+ * A Studio request linked from the turn stays as decided, because the code run uses it when the call runs there.
800
+ */
801
+ const cancelTurnApprovals = async (db: typeof sql, turnId: string): Promise<void> => {
802
+ await db`
803
+ UPDATE approvals.requests SET status = 'cancelled'
804
+ WHERE id IN (SELECT approval_id FROM ai.pending_actions WHERE turn_id = ${turnId} AND approval_id IS NOT NULL)
805
+ AND (status = 'pending' OR (status = 'approved' AND requester_kind = 'assistant_turn'))
806
+ `;
807
+ };
808
+
787
809
  /**
788
810
  * Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
789
811
  * time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
@@ -878,6 +900,35 @@ const recordTurnEnd = async (
878
900
  }
879
901
  };
880
902
 
903
+ /**
904
+ * Records requests a website approval let through on the assistant message that holds the tool call, as each one
905
+ * goes out, so the chat keeps every receipt whether the call succeeds, fails, or its turn stops. Website approvals
906
+ * apply only to interactive chats, whose messages live in `ai.messages`. A receipt with a known index replaces itself.
907
+ */
908
+ export const recordAiWebsiteReceipts = async (input: {
909
+ conversationId: string;
910
+ turnId: string;
911
+ callId: string;
912
+ receipts: AiWebsiteReceipt[];
913
+ }): Promise<void> => {
914
+ if (!input.receipts.length) return;
915
+ const receipts = Object.fromEntries(input.receipts.map(({ index, method, url }) => [String(index), { method, url }]));
916
+ await sql`
917
+ UPDATE ai.messages
918
+ SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
919
+ 'websiteReceipts',
920
+ COALESCE(meta->'websiteReceipts', '{}'::jsonb) || jsonb_build_object(
921
+ ${input.callId}::text,
922
+ COALESCE(meta->'websiteReceipts'->${input.callId}::text, '{}'::jsonb) || (${JSON.stringify(receipts)}::text)::jsonb
923
+ )
924
+ )
925
+ WHERE conversation_id = ${input.conversationId}::uuid
926
+ AND loop_id = ${input.turnId}::text
927
+ AND role = 'assistant'
928
+ AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
929
+ `;
930
+ };
931
+
881
932
  /** Insert a message inside an open conversation-lock transaction and bump the conversation. */
882
933
  const insertMessageLocked = async (
883
934
  input: {
@@ -3017,6 +3068,7 @@ export const aiConversations: AiConversationService = {
3017
3068
  WHERE turn_id = ${input.turnId}
3018
3069
  AND status = 'pending'
3019
3070
  `;
3071
+ await cancelTurnApprovals(tx, input.turnId);
3020
3072
  if (input.status !== "completed") {
3021
3073
  await recordTurnEnd(tx, {
3022
3074
  conversationId: input.conversationId,
@@ -3173,6 +3225,7 @@ export const aiConversations: AiConversationService = {
3173
3225
  WHERE turn_id = ${finalized.turnId}
3174
3226
  AND status = 'pending'
3175
3227
  `;
3228
+ await cancelTurnApprovals(sql, finalized.turnId);
3176
3229
  await sql`
3177
3230
  UPDATE ai.turn_steers
3178
3231
  SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
@@ -3261,6 +3314,7 @@ export const aiConversations: AiConversationService = {
3261
3314
  savePendingTurnAction: async (input) => {
3262
3315
  await sql`
3263
3316
  INSERT INTO ai.pending_actions (
3317
+ approval_id,
3264
3318
  turn_id,
3265
3319
  conversation_id,
3266
3320
  call_id,
@@ -3271,12 +3325,15 @@ export const aiConversations: AiConversationService = {
3271
3325
  review,
3272
3326
  approval_scope,
3273
3327
  allow_always,
3328
+ allow_chat,
3329
+ remember_tool_name,
3274
3330
  frontend_mode,
3275
3331
  status,
3276
3332
  resolved_event,
3277
3333
  resolved_at
3278
3334
  )
3279
3335
  VALUES (
3336
+ ${input.approvalId ?? null}::uuid,
3280
3337
  ${input.turnId},
3281
3338
  ${input.conversationId},
3282
3339
  ${input.callId},
@@ -3287,6 +3344,8 @@ export const aiConversations: AiConversationService = {
3287
3344
  ${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
3288
3345
  ${input.approvalScope},
3289
3346
  ${input.allowAlways},
3347
+ ${input.allowChat ?? input.allowAlways},
3348
+ ${input.rememberToolName ?? null},
3290
3349
  ${input.frontendMode ?? null},
3291
3350
  ${input.resolvedEvent ? "resolved" : "pending"},
3292
3351
  ${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::text::jsonb,
@@ -3294,6 +3353,7 @@ export const aiConversations: AiConversationService = {
3294
3353
  )
3295
3354
  ON CONFLICT (turn_id, call_id)
3296
3355
  DO UPDATE SET
3356
+ approval_id = EXCLUDED.approval_id,
3297
3357
  kind = EXCLUDED.kind,
3298
3358
  tool_name = EXCLUDED.tool_name,
3299
3359
  args = EXCLUDED.args,
@@ -3301,6 +3361,8 @@ export const aiConversations: AiConversationService = {
3301
3361
  review = EXCLUDED.review,
3302
3362
  approval_scope = EXCLUDED.approval_scope,
3303
3363
  allow_always = EXCLUDED.allow_always,
3364
+ allow_chat = EXCLUDED.allow_chat,
3365
+ remember_tool_name = EXCLUDED.remember_tool_name,
3304
3366
  frontend_mode = EXCLUDED.frontend_mode
3305
3367
  `;
3306
3368
  },
@@ -3370,12 +3432,15 @@ export const aiConversations: AiConversationService = {
3370
3432
 
3371
3433
  clearPendingTurnActions: async (input) => {
3372
3434
  await sql`
3373
- UPDATE ai.pending_actions
3435
+ WITH aborted AS (UPDATE ai.pending_actions
3374
3436
  SET status = 'aborted',
3375
3437
  resolved_at = COALESCE(resolved_at, now())
3376
3438
  WHERE conversation_id = ${input.conversationId}
3377
3439
  AND turn_id = ${input.turnId}
3378
3440
  AND status = 'pending'
3441
+ RETURNING approval_id)
3442
+ UPDATE approvals.requests SET status='cancelled'
3443
+ WHERE id IN (SELECT approval_id FROM aborted WHERE approval_id IS NOT NULL) AND status='pending'
3379
3444
  `;
3380
3445
  },
3381
3446
 
@@ -1,4 +1,5 @@
1
1
  import { sql } from "bun";
2
+ import type { ApprovalDecisionChannel } from "../services/approvals";
2
3
 
3
4
  /**
4
5
  * AI-specific tool-call state only: approval, turn linkage, and the request id
@@ -8,7 +9,14 @@ import { sql } from "bun";
8
9
  */
9
10
  export type AiToolCallLocation = "server" | "client" | "client_view" | "client_interaction";
10
11
 
11
- export type AiToolApprovalState = "not_required" | "waiting" | "approved_once" | "approved_always" | "approved_by_preference" | "rejected";
12
+ export type AiToolApprovalState =
13
+ | "not_required"
14
+ | "waiting"
15
+ | "approved_once"
16
+ | "approved_for_chat"
17
+ | "approved_always"
18
+ | "approved_by_preference"
19
+ | "rejected";
12
20
 
13
21
  export const aiToolAudit = {
14
22
  /** Resolve the current server-recorded invocation, never a model-supplied chat id. */
@@ -140,12 +148,14 @@ export const aiToolAudit = {
140
148
  turnId: string;
141
149
  callId: string;
142
150
  approvalState: Exclude<AiToolApprovalState, "not_required" | "waiting">;
151
+ decidedVia?: ApprovalDecisionChannel;
143
152
  }): Promise<void> => {
144
153
  const approved = input.approvalState !== "rejected";
145
154
  await sql`
146
155
  UPDATE ai.tool_calls
147
156
  SET
148
157
  approval_state = ${input.approvalState},
158
+ approval_decided_via = ${input.decidedVia ?? null},
149
159
  status = CASE WHEN ${approved} THEN status ELSE 'rejected' END,
150
160
  approved_at = CASE WHEN ${approved} THEN now() ELSE approved_at END,
151
161
  rejected_at = CASE WHEN ${approved} THEN rejected_at ELSE now() END
@@ -0,0 +1,38 @@
1
+ import type { CapabilityPresentationCatalog } from "../contracts/capabilities";
2
+
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" },
18
+ },
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
+ },
36
+ },
37
+ },
38
+ } as const satisfies CapabilityPresentationCatalog;
package/src/ai/tools.ts CHANGED
@@ -4,6 +4,7 @@ import type { z } from "zod";
4
4
  import type { RequestActor } from "../server";
5
5
  import { aiToolNeedsApproval } from "./approvals";
6
6
  import type {
7
+ AiApprovalTarget,
7
8
  AiDataBoundary,
8
9
  AiFrontendToolMode,
9
10
  AiProjectFileToolSource,
@@ -13,6 +14,7 @@ import type {
13
14
  AiToolApprovalPolicy,
14
15
  AiToolDefinition,
15
16
  AiToolRuntime,
17
+ AiWebsiteReceipt,
16
18
  } from "./types";
17
19
 
18
20
  export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType>(config: {
@@ -60,6 +62,12 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
60
62
  timeZone?: string;
61
63
  /** Localized short status; never include payloads or secrets. */
62
64
  reportProgress?: (message: string) => Promise<void>;
65
+ /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
66
+ requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
67
+ /** Relays a decision for an effect another platform component consumes and executes. */
68
+ requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
69
+ /** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
70
+ reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
63
71
  },
64
72
  ) => Promise<z.infer<TOutput>>,
65
73
  ): AiToolRuntime<TInput, TOutput> {
@@ -106,15 +114,93 @@ export type AiToolPreparationContext = {
106
114
  locale?: string;
107
115
  timeZone?: string;
108
116
  reportToolProgress?: (callId: string, message: string) => Promise<void>;
117
+ /** Uses the approved snapshot immediately before the tool executes its effect. */
118
+ consumeApproval?: (approvalCallId: string, input: unknown) => Promise<AiApprovalUse>;
119
+ /** Links a relayed decision to the record the effect owner will consume. */
120
+ linkApproval?: (approvalCallId: string, approvalId: string) => void;
121
+ /** Hears what a nested approval asks for before the approval is requested. */
122
+ describeApproval?: (approvalCallId: string, target: AiApprovalTarget) => void;
123
+ reportWebsiteReceipts?: (callId: string, receipts: AiWebsiteReceipt[]) => Promise<void>;
109
124
  };
110
125
 
126
+ /**
127
+ * What using an approval found: `approved` lets the effect run, `changed` means the approved snapshot no longer
128
+ * matches and asks again, and `used` means an earlier run of the call already used it, so the call must not run again.
129
+ */
130
+ export type AiApprovalUse = "approved" | "changed" | "used";
131
+
132
+ const approvalCallIdPattern = /^(.*)-approval-(\d+)$/;
133
+
134
+ /**
135
+ * Whether `askedCallIds` holds a question of the same tool call that Nessi asked after `approvalCallId`, the call's
136
+ * own approval or one of its numbered questions. A turn that continues after such a question runs the tool from its
137
+ * start again, so the approvals before it are used again by the same call.
138
+ */
139
+ export const aiApprovalAskedLater = (approvalCallId: string, askedCallIds: Iterable<string>): boolean => {
140
+ const own = approvalCallIdPattern.exec(approvalCallId);
141
+ const callId = own ? own[1] : approvalCallId,
142
+ index = own ? Number(own[2]) : -1;
143
+ for (const asked of askedCallIds) {
144
+ const later = approvalCallIdPattern.exec(asked);
145
+ if (later && later[1] === callId && Number(later[2]) > index) return true;
146
+ }
147
+ return false;
148
+ };
149
+
150
+ const approvalUsedError = () =>
151
+ new Error("This approval was already used, so Cloud does not run the call again. Check its outcome before you call again.");
152
+
111
153
  export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRuntimeTool[] }): PreparedAiTools => {
112
154
  const canonicalNames = new Map<string, string>();
113
155
  const approvalPolicies = new Map<string, AiToolApprovalPolicy>();
114
156
  const frontendModes = new Map<string, AiFrontendToolMode>();
115
157
 
158
+ const approvalContext = async (toolInput: unknown, ctx: ToolContext, needsApproval: boolean) => {
159
+ const callId = ctx.callId;
160
+ const use = (approvalCallId: string): Promise<AiApprovalUse> =>
161
+ input.consumeApproval ? input.consumeApproval(approvalCallId, toolInput) : Promise.resolve("approved");
162
+ if (needsApproval && input.consumeApproval) {
163
+ const used = callId ? await use(callId) : "changed";
164
+ if (used === "used") throw approvalUsedError();
165
+ if (used === "changed") throw new Error("This approval no longer matches the call. Ask again.");
166
+ }
167
+ // Nessi numbers the approvals of one execution in request order; count them to name each one's call ID.
168
+ let approvals = 0;
169
+ const requestApproval = async (message: string, target?: AiApprovalTarget): Promise<boolean> => {
170
+ while (true) {
171
+ const approvalCallId = `${callId}-approval-${approvals++}`;
172
+ if (target) input.describeApproval?.(approvalCallId, target);
173
+ if (!(await ctx.requestApproval(message))) return false;
174
+ const used = await use(approvalCallId);
175
+ if (used === "approved") return true;
176
+ if (used === "used") throw approvalUsedError();
177
+ // A changed snapshot requires a new human decision under the next Nessi action ID.
178
+ }
179
+ };
180
+ return {
181
+ ...ctx,
182
+ requestApproval,
183
+ requestApprovalFor: (message: string, target: AiApprovalTarget) => requestApproval(message, target),
184
+ requestRelayedApproval: async (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => {
185
+ const approvalCallId = `${callId}-approval-${approvals++}`;
186
+ if (options.approvalId) input.linkApproval?.(approvalCallId, options.approvalId);
187
+ if (options.target) input.describeApproval?.(approvalCallId, options.target);
188
+ if (!(await ctx.requestApproval(message))) return false;
189
+ // The effect's own record is used where it runs. Without one, as for a website request from code, the relay
190
+ // that hands over the decision uses the chat's record; a decision it cannot use is not handed over as approval.
191
+ return options.approvalId !== undefined || (await use(approvalCallId)) === "approved";
192
+ },
193
+ };
194
+ };
195
+
116
196
  const tools = (input.tools ?? []).map((tool): Tool => {
117
- if (!isCloudAiTool(tool)) return tool;
197
+ if (!isCloudAiTool(tool)) {
198
+ if (tool.kind !== "server") return tool;
199
+ return {
200
+ ...tool,
201
+ execute: async (toolInput, ctx) => tool.execute(toolInput, await approvalContext(toolInput, ctx, tool.def.needsApproval === true)),
202
+ };
203
+ }
118
204
 
119
205
  canonicalNames.set(tool.def.name, tool.def.canonicalName ?? tool.def.name);
120
206
  approvalPolicies.set(tool.def.name, tool.def.approval);
@@ -130,12 +216,12 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
130
216
  });
131
217
 
132
218
  if (tool.location === "server") {
133
- return nessiTool.server((toolInput, ctx) => {
219
+ return nessiTool.server(async (toolInput, ctx) => {
134
220
  if (!input.actor) throw new Error(`AI server tool "${tool.def.name}" requires a request actor.`);
135
221
  const report = input.reportToolProgress;
136
222
  const callId = ctx.callId;
137
223
  return tool.run(toolInput, {
138
- ...ctx,
224
+ ...(await approvalContext(toolInput, ctx, aiToolNeedsApproval(tool.def.approval))),
139
225
  actor: input.actor,
140
226
  conversationId: input.conversationId,
141
227
  turnId: input.turnId,
@@ -147,6 +233,8 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
147
233
  locale: input.locale,
148
234
  timeZone: input.timeZone,
149
235
  reportProgress: callId && report ? (message) => report(callId, message) : undefined,
236
+ reportWebsiteReceipts:
237
+ callId && input.reportWebsiteReceipts ? (receipts) => input.reportWebsiteReceipts!(callId, receipts) : undefined,
150
238
  });
151
239
  });
152
240
  }
package/src/ai/types.ts CHANGED
@@ -12,7 +12,8 @@ import type {
12
12
  } from "@k2b/nessi";
13
13
  import type { Usage } from "@k2b/nessi/ai";
14
14
  import type { z } from "zod";
15
- import type { CapabilityActionReview, CloudResourceRef } from "../contracts/capabilities";
15
+ import type { CapabilitySentenceField } from "../_internal/capability-sentences";
16
+ import type { CapabilityActionReview, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
16
17
  import type { RequestActor } from "../server";
17
18
  import type { AiTurnBlock } from "./protocol";
18
19
 
@@ -302,6 +303,8 @@ export type AiStoredMessage = {
302
303
  toolPresentations?: Record<string, AiToolPresentation>;
303
304
  /** How the person decided an approval; `expired` when the turn ended while it still waited. */
304
305
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
306
+ /** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
307
+ websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
305
308
  /** Why the turn failed, on the last message of its loop. The chat words it in the reader's language. */
306
309
  turnError?: AiTurnError;
307
310
  } | null;
@@ -419,6 +422,10 @@ export type AiPendingTurnAction =
419
422
  message?: string;
420
423
  review?: CapabilityActionReview;
421
424
  allowAlways: boolean;
425
+ /** Whether the approval can be remembered for this chat. */
426
+ allowChat: boolean;
427
+ /** The website origin a chat approval would allow, for an HTTP request a code run makes. */
428
+ website?: string;
422
429
  }
423
430
  | {
424
431
  type: "frontend_tool";
@@ -440,6 +447,7 @@ export type AiTurnAbortRequest =
440
447
  };
441
448
 
442
449
  export type AiPendingTurnActionRecord = {
450
+ approvalId?: string;
443
451
  turnId: string;
444
452
  conversationId: string;
445
453
  callId: string;
@@ -451,6 +459,10 @@ export type AiPendingTurnActionRecord = {
451
459
  review?: CapabilityActionReview;
452
460
  approvalScope: string;
453
461
  allowAlways: boolean;
462
+ /** Whether the approval can be remembered for this chat; defaults to `allowAlways`. */
463
+ allowChat?: boolean;
464
+ /** Remembered-approval name when it differs from `name`: the website or Action a code run asks for. */
465
+ rememberToolName?: string;
454
466
  frontendMode?: AiFrontendToolMode;
455
467
  resolvedEvent: InboundEvent | null;
456
468
  };
@@ -606,6 +618,11 @@ export type AiChatTurnRunConfig = {
606
618
  selectedSkillIds?: string[];
607
619
  /** Server-owned marker: model grants apply only to interactive Assistant chat. */
608
620
  assistantChat?: true;
621
+ /**
622
+ * Server-owned marker: a person started this turn in a signed-in browser session, not through `cld`, an API key,
623
+ * a delegated account, or a schedule. Only such a turn uses website approvals.
624
+ */
625
+ signedInSession?: true;
609
626
  kind?: "chat";
610
627
  input: Input;
611
628
  /** Stable public ID exposed as runtime context, not instructions. */
@@ -1015,6 +1032,23 @@ export type AiAccessResult<TAccess = unknown> = {
1015
1032
 
1016
1033
  export type AiToolApprovalPolicy = "never" | "once" | "always" | { kind: "user-configurable"; default: "once" | "always"; scope?: string };
1017
1034
 
1035
+ /**
1036
+ * What a server tool asks a person to approve when the approval can be remembered: a website or an Action, not the
1037
+ * tool itself. A target can always be remembered for its chat.
1038
+ */
1039
+ export type AiApprovalTarget = {
1040
+ toolName: string;
1041
+ approvalScope: string;
1042
+ /** Whether the person may also remember it everywhere. */
1043
+ always: boolean;
1044
+ };
1045
+
1046
+ /**
1047
+ * A request a remembered website approval let through without asking. The chat shows it with its full URL and a revoke
1048
+ * action; `index` orders the receipts of one tool call, and a receipt reported again with the same index replaces it.
1049
+ */
1050
+ export type AiWebsiteReceipt = { index: number; method: string; url: string };
1051
+
1018
1052
  export type AiFrontendToolMode = "client" | "client_view" | "client_interaction";
1019
1053
 
1020
1054
  export type AiCapabilityToolPresentation = {
@@ -1025,6 +1059,12 @@ export type AiCapabilityToolPresentation = {
1025
1059
  appAccent?: string;
1026
1060
  title: string;
1027
1061
  capabilityKind: "query" | "action";
1062
+ /** Actions: the app's sentences in the locale of the turn, worded for each call by the chat. */
1063
+ sentences?: CapabilityActionSentences;
1064
+ /** Actions: field formats for the sentences and labelled fields for the generic sentence, in schema order. */
1065
+ fields?: CapabilitySentenceField[];
1066
+ /** Actions: Cloud offered the model `approvalReason` for this call, so the chat may show it as the model's reason. */
1067
+ approvalReason?: true;
1028
1068
  };
1029
1069
 
1030
1070
  export type AiToolPresentation = AiCapabilityToolPresentation;
@@ -1064,6 +1104,12 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
1064
1104
  timeZone?: string;
1065
1105
  /** Localized short status; never include payloads or secrets. */
1066
1106
  reportProgress?: (message: string) => Promise<void>;
1107
+ /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
1108
+ requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
1109
+ /** Relays a decision for an effect another platform component consumes and executes. */
1110
+ requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
1111
+ /** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
1112
+ reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
1067
1113
  },
1068
1114
  ): Promise<z.infer<TOutput>>;
1069
1115
  }
@@ -0,0 +1,103 @@
1
+ import { domainToUnicode } from "node:url";
2
+ import { sql } from "bun";
3
+ import { toPgTextArray } from "../services/postgres";
4
+
5
+ /** The longest address web_extract and fetch_file accept; a longer one can never be read, so it is never recorded. */
6
+ const WEB_ADDRESS_MAX_CHARS = 2_000;
7
+
8
+ const URL_TOKEN = /https?:\/\/[^\s<>"'`\\^{}|[\]]+/gi;
9
+ /**
10
+ * An address typed without its scheme, such as `example.org/news`. It must start the word: a host inside another
11
+ * address, an email address, or a longer host name is not a typed address of its own.
12
+ */
13
+ const BARE_TOKEN =
14
+ /(?<![\w@.\-/:%+~#=?&])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9](?::\d{1,5})?(?:\/[^\s<>"'`\\^{}|[\]]*)?/gi;
15
+
16
+ /** Drops punctuation that ends the sentence around an address, and a closing bracket the address did not open. */
17
+ const trimToken = (token: string): string => {
18
+ let end = token.length;
19
+ while (end > 0) {
20
+ const last = token[end - 1]!;
21
+ if (".,;:!?*'\"".includes(last)) end--;
22
+ else if (last === ")" && token.slice(0, end).split("(").length <= token.slice(0, end).split(")").length - 1) end--;
23
+ else break;
24
+ }
25
+ return token.slice(0, end);
26
+ };
27
+
28
+ /** The forms of one address that count as the same read: as written, without its fragment, and over HTTPS. */
29
+ const addressForms = (raw: string, add: (address: string) => void) => {
30
+ let url: URL;
31
+ try {
32
+ url = new URL(raw);
33
+ } catch {
34
+ return;
35
+ }
36
+ if (url.protocol !== "https:" && url.protocol !== "http:") return;
37
+ const forms = [url.href];
38
+ if (url.hash) forms.push(url.href.slice(0, -url.hash.length));
39
+ // Upgrading to HTTPS carries no data the address did not already carry.
40
+ if (url.protocol === "http:") forms.push(...forms.map((form) => `https:${form.slice("http:".length)}`));
41
+ for (const form of forms) if (form.length <= WEB_ADDRESS_MAX_CHARS) add(form);
42
+ };
43
+
44
+ /**
45
+ * Every whole address in a text, normalized like a URL the tools read. Only complete tokens count, never a part of a
46
+ * longer address; with `typed`, an address written without its scheme counts too, as a person often types it.
47
+ */
48
+ export const webAddressesIn = (text: string, options: { typed?: boolean } = {}): Set<string> => {
49
+ const addresses = new Set<string>();
50
+ const add = (address: string) => addresses.add(address);
51
+ const rest = text.replace(URL_TOKEN, (token) => {
52
+ addressForms(trimToken(token), add);
53
+ return " ";
54
+ });
55
+ if (options.typed)
56
+ for (const [token] of rest.matchAll(BARE_TOKEN)) {
57
+ const bare = trimToken(token);
58
+ addressForms(`https://${bare}`, add);
59
+ addressForms(`http://${bare}`, add);
60
+ }
61
+ return addresses;
62
+ };
63
+
64
+ /** Records addresses a web search returned or a page read in this chat links to; later reads of them do not ask. */
65
+ export const recordWebAddresses = async (conversationId: string, addresses: Iterable<string>): Promise<void> => {
66
+ const list = [...addresses];
67
+ if (!list.length) return;
68
+ await sql`
69
+ INSERT INTO ai.web_addresses (conversation_id, address)
70
+ SELECT ${conversationId}::uuid, address FROM unnest(${toPgTextArray(list)}::text[]) address
71
+ ON CONFLICT DO NOTHING
72
+ `;
73
+ };
74
+
75
+ /**
76
+ * Whether the chat itself supplied this address: the person wrote it, a web search returned it, or a page read
77
+ * earlier links to it. A message another chat sent counts as neither. Anything else, such as an address the model
78
+ * assembled from other data, has no provenance. `url` is the normalized address the tool reads.
79
+ */
80
+ export const hasWebAddressProvenance = async (conversationId: string, url: string): Promise<boolean> => {
81
+ const [recorded] = await sql<{ found: boolean }[]>`
82
+ SELECT EXISTS (SELECT 1 FROM ai.web_addresses WHERE conversation_id = ${conversationId}::uuid AND address = ${url}) AS found
83
+ `;
84
+ if (recorded?.found) return true;
85
+ // Only the person's own messages that name the host are read, never tool results.
86
+ const { hostname } = new URL(url);
87
+ const hosts = [...new Set([hostname, domainToUnicode(hostname) || hostname])];
88
+ const rows = await sql<{ text: string }[]>`
89
+ SELECT text FROM (
90
+ SELECT message, meta FROM ai.messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
91
+ UNION ALL
92
+ SELECT message, meta FROM ai.task_messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
93
+ ) entry
94
+ CROSS JOIN LATERAL (
95
+ SELECT string_agg(CASE WHEN jsonb_typeof(part) = 'string' THEN part #>> '{}' ELSE part->>'text' END, chr(10)) AS text
96
+ FROM jsonb_array_elements(CASE WHEN jsonb_typeof(entry.message->'content') = 'array' THEN entry.message->'content' ELSE '[]'::jsonb END) part
97
+ WHERE jsonb_typeof(part) = 'string' OR part->>'type' = 'text'
98
+ ) content
99
+ WHERE NOT COALESCE(entry.meta ? 'agentMessage', false)
100
+ AND EXISTS (SELECT 1 FROM unnest(${toPgTextArray(hosts)}::text[]) host WHERE strpos(lower(content.text), host) > 0)
101
+ `;
102
+ return rows.some((row) => webAddressesIn(row.text, { typed: true }).has(url));
103
+ };