@k2b/cloud 0.28.0 → 0.29.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 (101) hide show
  1. package/package.json +7 -5
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +97 -17
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +12 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +16 -0
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-names.ts +157 -0
  19. package/src/ai/chat/turn-layout.ts +12 -0
  20. package/src/ai/chat/turn-view.tsx +15 -7
  21. package/src/ai/chat-tasks.ts +16 -3
  22. package/src/ai/code-mode-skill.ts +4 -4
  23. package/src/ai/compaction.ts +22 -3
  24. package/src/ai/default-tools.ts +9 -2
  25. package/src/ai/executor.ts +127 -40
  26. package/src/ai/file-media-type.ts +4 -0
  27. package/src/ai/files-store.ts +17 -10
  28. package/src/ai/http.ts +11 -10
  29. package/src/ai/index.ts +1 -0
  30. package/src/ai/memories.ts +2 -0
  31. package/src/ai/memory-learning.ts +59 -5
  32. package/src/ai/memory-workflow-evidence.ts +35 -10
  33. package/src/ai/projects-routes.ts +5 -1
  34. package/src/ai/projects.ts +5 -1
  35. package/src/ai/protocol.ts +34 -1
  36. package/src/ai/provider.ts +16 -1
  37. package/src/ai/quota-provider.ts +4 -1
  38. package/src/ai/routes.ts +20 -10
  39. package/src/ai/settings.ts +54 -17
  40. package/src/ai/skill-seeds.ts +11 -3
  41. package/src/ai/store.ts +56 -20
  42. package/src/ai/structured.ts +2 -1
  43. package/src/ai/system-prompt.ts +5 -1
  44. package/src/ai/transcription.ts +58 -13
  45. package/src/ai/turn-failure.ts +5 -1
  46. package/src/ai/turn-guidance.ts +505 -0
  47. package/src/ai/types.ts +1 -0
  48. package/src/ai/ui.tsx +1 -0
  49. package/src/api/admin-core-settings.ts +16 -1
  50. package/src/api/me.ts +42 -1
  51. package/src/browser/FileChooser.tsx +71 -295
  52. package/src/browser/FileSaver.tsx +407 -0
  53. package/src/browser/app-badges.ts +16 -0
  54. package/src/browser/choose-files.browser-harness.tsx +22 -6
  55. package/src/browser/choose-files.tsx +23 -36
  56. package/src/browser/file-chooser-messages.ts +64 -0
  57. package/src/browser/file-providers.ts +258 -8
  58. package/src/browser/files.tsx +26 -3
  59. package/src/browser/notifications.ts +37 -1
  60. package/src/browser/provider-folder.tsx +301 -0
  61. package/src/browser/provider-list.ts +39 -0
  62. package/src/browser/save-files.browser-harness.tsx +35 -0
  63. package/src/browser/save-files.tsx +107 -0
  64. package/src/contracts/app.ts +10 -0
  65. package/src/contracts/commands.ts +3 -1
  66. package/src/contracts/notification-group.ts +6 -0
  67. package/src/contracts/notification-types.ts +6 -0
  68. package/src/contracts/registry.ts +1 -0
  69. package/src/contracts/user-notifications.ts +74 -0
  70. package/src/server/middleware/request-logger.ts +11 -2
  71. package/src/services/accounts/avatar.ts +5 -2
  72. package/src/services/image-metadata.ts +438 -0
  73. package/src/services/index.ts +1 -0
  74. package/src/services/logging/redaction.ts +50 -0
  75. package/src/services/mandates/index.ts +4 -2
  76. package/src/services/mandates/policy.ts +20 -0
  77. package/src/services/notifications/browser.ts +48 -15
  78. package/src/services/notifications/dispatcher.ts +31 -1
  79. package/src/services/notifications/platform.ts +32 -4
  80. package/src/services/notifications/preview.ts +23 -0
  81. package/src/services/notifications/quiet.ts +153 -0
  82. package/src/services/notifications/user.ts +6 -0
  83. package/src/services/pdf/template-preview.ts +2 -1
  84. package/src/services/settings/app.ts +3 -0
  85. package/src/services/settings/store.ts +4 -1
  86. package/src/services/weather/geo.ts +6 -2
  87. package/src/shared/ai-platform-prompt.ts +2 -2
  88. package/src/shared/redirect.ts +2 -1
  89. package/src/shared/template-rendering.ts +120 -7
  90. package/src/ssr/AppBadge.tsx +30 -0
  91. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  92. package/src/ssr/IslandError.tsx +33 -0
  93. package/src/ssr/Layout.tsx +3 -0
  94. package/src/ssr/LayoutHelp.tsx +4 -2
  95. package/src/ssr/RailApps.island.tsx +9 -2
  96. package/src/ssr/app-badges.ts +93 -0
  97. package/src/ssr/rail-messages.ts +4 -0
  98. package/src/ssr/rail-navigation.ts +2 -0
  99. package/src/styles/effects.css +23 -0
  100. package/src/styles/file-chooser.css +25 -0
  101. package/src/styles/utilities-navigation.css +41 -0
@@ -11,6 +11,7 @@ import {
11
11
  type PendingAiVfsFile,
12
12
  readImageFile,
13
13
  readVfsFile,
14
+ VFS_FILE_MAX_BYTES,
14
15
  } from "./message-utils";
15
16
 
16
17
  export type { AiComposerAttachment } from "./message-utils";
@@ -37,6 +38,13 @@ export type AiComposerFileResult = {
37
38
 
38
39
  export const aiComposerFileAccept = FILE_INPUT_ACCEPT;
39
40
 
41
+ /**
42
+ * The `maxBytes` for `chooseFiles` while a composer holds `attached` attachments. Files from Cloud apps are read into
43
+ * the browser before `readAiComposerFiles` checks them, so together they stay within what the draft can still take:
44
+ * each free slot at most the largest file the composer attaches. 0 when every slot is taken.
45
+ */
46
+ export const aiComposerChooseMaxBytes = (attached: number): number => Math.max(0, MAX_ATTACHMENTS - attached) * VFS_FILE_MAX_BYTES;
47
+
40
48
  export const aiChatModelOptions = (profiles: readonly AiPublicModelProfile[]): ChatModelOption[] =>
41
49
  profiles.map((profile) => ({
42
50
  id: profile.id,
@@ -10,14 +10,17 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
10
10
  * The live chat timeline for a browser test, folded from wire events like the
11
11
  * controller does. `window.emit(event)` applies one event,
12
12
  * `window.steer(text)` appends a pending steer as the controller does,
13
- * `window.reconnect(on)` sets whether the stream reconnects, and
14
- * `window.continued` lists the messages that **Continue** sent.
13
+ * `window.reconnect(on)` sets whether the stream reconnects,
14
+ * `window.continued` lists the messages that **Continue** sent, and
15
+ * `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
15
16
  */
16
17
  const [state, setState] = createStore(emptyProjection());
17
18
  const [reconnecting, setReconnecting] = createSignal(false);
18
19
  const continued: string[] = [];
20
+ const opened: string[] = [];
19
21
  Object.assign(window, {
20
22
  continued,
23
+ opened,
21
24
  reconnect: (on: boolean) => setReconnecting(on),
22
25
  emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
23
26
  steer: (text: string) =>
@@ -32,7 +35,13 @@ root.style.cssText = "display: flex; flex-direction: column; height: 100dvh; max
32
35
  document.body.append(root);
33
36
  render(
34
37
  () => (
35
- <AiChatActionsProvider actions={{ onContinueTurn: (message) => void continued.push(message) }}>
38
+ <AiChatActionsProvider
39
+ actions={{
40
+ onContinueTurn: (message) => void continued.push(message),
41
+ fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
42
+ onOpenFile: (path) => void opened.push(path),
43
+ }}
44
+ >
36
45
  {(() => {
37
46
  const items = createAiChatTimeline({
38
47
  messages: createMemo(() => visibleMessages(state)),
@@ -218,7 +218,7 @@ const openAssistantResponseInfo = (entries: AiStoredMessage[], locale: string) =
218
218
  {(tool) => (
219
219
  <li>
220
220
  <i class={aiToolIcon(tool.name)} aria-hidden="true" />
221
- {displayToolName(tool.name)}
221
+ {displayToolName(tool.name, locale)}
222
222
  <Show when={tool.count > 1}>
223
223
  <span class="text-dimmed tabular-nums">×{tool.count}</span>
224
224
  </Show>
@@ -3,12 +3,14 @@ import { fileIcons } from "@k2b/stdlib";
3
3
  import { formatBytes as sharedFormatBytes } from "../../shared/format";
4
4
  import { type AiAttachmentRef, parseAiAttachmentMarkers } from "../attachments";
5
5
  import { AI_AUDIO_EXTENSIONS } from "../audio-format";
6
+ import { aiProjectFilePathFromMount, aiSkillFilePathFromMount } from "../file-mount";
6
7
  import { AI_IMAGE_INPUT_MAX_BYTES, AI_TURN_ATTACHMENT_MAX_ITEMS } from "../limits";
7
8
  import { type AiResourceMarker, parseAiResourceMarker } from "../resource-markers";
8
9
  import { assistantVisibleTextFromMessage } from "../timeline";
9
10
  import type { AiStoredMessage, AiUserContentPart } from "../types";
10
11
  import { AI_IMAGE_MEDIA_TYPES, isAiImageMediaType } from "../types";
11
12
  import { aiChatMessages } from "./messages";
13
+ import { aiBuiltinToolName } from "./tool-names";
12
14
 
13
15
  type AssistantToolResultMessage = Extract<Message, { role: "tool_result" }>;
14
16
 
@@ -144,8 +146,9 @@ export const describeResponseEnd = (entry: Pick<AiStoredMessage, "loopDoneReason
144
146
  interrupted: t.infoEndInterrupted,
145
147
  error: t.infoEndFailed,
146
148
  };
149
+ const stopped = entry.stopReason && ["error", "aborted", "interrupted"].includes(entry.stopReason);
147
150
  const parts = [
148
- entry.loopDoneReason ? run[entry.loopDoneReason] : null,
151
+ entry.loopDoneReason && !(entry.loopDoneReason === "stop" && stopped) ? run[entry.loopDoneReason] : null,
149
152
  entry.stopReason ? (Object.hasOwn(message, entry.stopReason) ? message[entry.stopReason] : entry.stopReason) : null,
150
153
  ].filter((part): part is string => Boolean(part));
151
154
  return [...new Set(parts)].join(" · ") || t.infoUnknown;
@@ -312,11 +315,10 @@ export const isSurveyToolName = (name: string) => name === "survey" || name ===
312
315
 
313
316
  export const isTextEditorToolName = (name: string) => name === "text_editor" || name === "cloud_text_editor";
314
317
 
315
- export const displayToolName = (name: string) => {
316
- if (isCardToolName(name)) return "card";
317
- if (isSurveyToolName(name)) return "survey";
318
- if (isTextEditorToolName(name)) return "text editor";
319
- if (name === "local_bash") return "Local Bash";
318
+ /** A tool's name for people: the localized name of a built-in tool, otherwise its name in words. */
319
+ export const displayToolName = (name: string, locale: string) => {
320
+ const builtin = aiBuiltinToolName(name, locale);
321
+ if (builtin) return builtin;
320
322
  const words = name.split("_").filter(Boolean).join(" ");
321
323
  return `${words.slice(0, 1).toUpperCase()}${words.slice(1)}`;
322
324
  };
@@ -340,6 +342,7 @@ const BUILT_IN_TOOL_ICONS = new Map<string, string>([
340
342
  ["web_search", "ti ti-search"],
341
343
  ["web_extract", "ti ti-world-download"],
342
344
  ["view_image", "ti ti-photo-spark"],
345
+ ["code_check", "ti ti-checklist"],
343
346
  ["memory", "ti ti-brain"],
344
347
  ["search_project", "ti ti-folder-search"],
345
348
  ["read_project_knowledge", "ti ti-notebook"],
@@ -353,6 +356,25 @@ const BUILT_IN_TOOL_ICONS = new Map<string, string>([
353
356
  ["read_cloud_resource", "ti ti-ai-gateway"],
354
357
  ]);
355
358
 
359
+ /** What an app check found: errors stop it from passing, warnings are counted separately. */
360
+ export type AiCodeCheckOutcome = { passed: boolean; errors: number; warnings: number };
361
+
362
+ export const codeCheckOutcome = (name: string, result: unknown): AiCodeCheckOutcome | null => {
363
+ if (name !== "code_check" || !isRecord(result) || typeof result.passed !== "boolean") return null;
364
+ const issues = Array.isArray(result.issues) ? result.issues.filter(isRecord) : [];
365
+ const warnings = issues.filter((issue) => issue.severity === "warning").length;
366
+ return { passed: result.passed, errors: issues.length - warnings, warnings };
367
+ };
368
+
369
+ /** The chat image a view_image call looked at. Project and skill files are not chat files and have no preview here. */
370
+ export const viewedChatImage = (name: string, result: unknown): { path: string; description: string } | null => {
371
+ if (name !== "view_image" || !isRecord(result)) return null;
372
+ const { path, mediaType, description } = result;
373
+ if (typeof path !== "string" || typeof mediaType !== "string" || !isAiImageMediaType(mediaType)) return null;
374
+ if (aiProjectFilePathFromMount(path) !== null || aiSkillFilePathFromMount(path) !== null) return null;
375
+ return { path, description: typeof description === "string" ? description.trim() : "" };
376
+ };
377
+
356
378
  export const aiToolIcon = (name: string, appIcon?: string | null): string => {
357
379
  if (appIcon) return appIcon;
358
380
  return BUILT_IN_TOOL_ICONS.get(name) ?? (/__(?:query|action)__/.test(name) ? "ti ti-ai-gateway" : "ti ti-tool");
@@ -25,6 +25,7 @@ const messages = i18n.define({
25
25
  turnErrorTitle: "The answer was interrupted.",
26
26
  turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
27
27
  if (code === "model_unavailable") return "The model service did not answer.";
28
+ if (code === "provider_stopped") return "The model provider ended this answer early.";
28
29
  if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
29
30
  if (code === "context_full") return "This chat is too long for the model. Start a new chat to continue; this one stays as it is.";
30
31
  if (code === "time_limit")
@@ -71,6 +72,7 @@ const messages = i18n.define({
71
72
  stepWritingCode: "Writing code",
72
73
  stepRunningCode: "Running code",
73
74
  stepCheckingApp: "Checking the app",
75
+ stepCheckingRun: "Checking the run",
74
76
  stepCode: "Working with code",
75
77
  stepPlan: "Updating the working plan",
76
78
  stepPdf: "Creating a PDF",
@@ -87,6 +89,11 @@ const messages = i18n.define({
87
89
  stepFailed: "failed",
88
90
  stepRejected: "rejected",
89
91
  stepWaiting: "waiting",
92
+ checkPassed: "passed",
93
+ checkNotPassed: "not passed",
94
+ checkFindings: ({ count }: { count: number }) => (count === 1 ? "1 finding" : `${count} findings`),
95
+ checkWarnings: ({ count }: { count: number }) => (count === 1 ? "1 warning" : `${count} warnings`),
96
+ openImage: ({ name }: { name: string }) => `Open image ${name}`,
90
97
  steered: "Conversation steered",
91
98
  reasoning: "Reasoning",
92
99
  groupPlan: "Updated working plan",
@@ -286,6 +293,7 @@ const messages = i18n.define({
286
293
  `${count === 1 ? "1 message" : `${count} messages`} summarized · ${date}`,
287
294
  compactedHint: "From here on, the model sees this summary instead of the messages above.",
288
295
  compactedEmpty: "No visible content",
296
+ malformedImage: "This image could not be read. Export it again or choose another file.",
289
297
  },
290
298
  de: {
291
299
  backgroundRun: "Hintergrundlauf",
@@ -309,6 +317,7 @@ const messages = i18n.define({
309
317
  turnErrorTitle: "Die Antwort wurde abgebrochen.",
310
318
  turnErrorReason: ({ code, limitMinutes }) => {
311
319
  if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
320
+ if (code === "provider_stopped") return "Der KI-Anbieter hat diese Antwort vorzeitig beendet.";
312
321
  if (code === "quota_exhausted")
313
322
  return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
314
323
  if (code === "context_full")
@@ -361,6 +370,7 @@ const messages = i18n.define({
361
370
  stepWritingCode: "Schreibt Code",
362
371
  stepRunningCode: "Führt Code aus",
363
372
  stepCheckingApp: "Prüft die App",
373
+ stepCheckingRun: "Prüft den Lauf",
364
374
  stepCode: "Arbeitet mit Code",
365
375
  stepPlan: "Aktualisiert den Arbeitsplan",
366
376
  stepPdf: "Erstellt ein PDF",
@@ -377,6 +387,11 @@ const messages = i18n.define({
377
387
  stepFailed: "fehlgeschlagen",
378
388
  stepRejected: "abgelehnt",
379
389
  stepWaiting: "wartet",
390
+ checkPassed: "bestanden",
391
+ checkNotPassed: "nicht bestanden",
392
+ checkFindings: ({ count }) => (count === 1 ? "1 Befund" : `${count} Befunde`),
393
+ checkWarnings: ({ count }) => (count === 1 ? "1 Warnung" : `${count} Warnungen`),
394
+ openImage: ({ name }) => `Bild ${name} öffnen`,
380
395
  steered: "Richtung geändert",
381
396
  reasoning: "Überlegungen",
382
397
  groupPlan: "Arbeitsplan aktualisiert",
@@ -575,6 +590,7 @@ const messages = i18n.define({
575
590
  compactedCount: ({ count, date }) => `${count === 1 ? "1 Nachricht" : `${count} Nachrichten`} zusammengefasst · ${date}`,
576
591
  compactedHint: "Ab hier sieht das Modell diese Zusammenfassung statt der Nachrichten darüber.",
577
592
  compactedEmpty: "Kein sichtbarer Inhalt",
593
+ malformedImage: "Dieses Bild konnte nicht gelesen werden. Exportiere es erneut oder wähle eine andere Datei.",
578
594
  },
579
595
  },
580
596
  });
@@ -20,7 +20,7 @@ import { type AiChatActions, AiChatActionsProvider, createAssistantMessageAction
20
20
  import { isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
21
21
  import { aiChatMessages } from "./messages";
22
22
  import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
23
- import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, waitsForUser } from "./turn-layout";
23
+ import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, storedPhase, waitsForUser } from "./turn-layout";
24
24
  import { TurnNavigator } from "./turn-navigator";
25
25
  import { activeTimelineSeq } from "./turn-navigator-utils";
26
26
  import { type AiTurnDuration, type AiTurnSegment, AiTurnView } from "./turn-view";
@@ -102,13 +102,6 @@ const leadingSteerOpener = (blocks: readonly AssistantBlock[]): string | null =>
102
102
  return opener;
103
103
  };
104
104
 
105
- /** History phase of a finished loop, from how its loop ended. */
106
- const storedPhase = (entries: readonly AiStoredMessage[]): AiTurnPhase => {
107
- const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
108
- if (!reason || reason === "stop") return "completed";
109
- return reason === "aborted" ? "stopped" : "failed";
110
- };
111
-
112
105
  /** Responses with a work line, rich places, or a failure notice span the message column so their right edges align. */
113
106
  const isWideLayout = (layout: AiTurnLayout, error?: AiTurnError) =>
114
107
  layout.showWork || layout.results.length > 0 || layout.actions.length > 0 || Boolean(error);
@@ -0,0 +1,157 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ /**
4
+ * People-facing names of Cloud's built-in Assistant tools, keyed by tool name. Step rows, receipts, and approvals show
5
+ * these; a capability tool brings its own translated title instead. A tool test fails when a built-in tool has no entry.
6
+ */
7
+ const toolNames = i18n.define({
8
+ baseLocale: "en",
9
+ messages: {
10
+ en: {
11
+ calculate: "Calculate",
12
+ card: "Card",
13
+ survey: "Survey",
14
+ text_editor: "Text editor",
15
+ local_bash: "Local Bash",
16
+ todo_write: "Working plan",
17
+ memory: "Memory",
18
+ list_files: "List files",
19
+ read_file: "Read file",
20
+ write_file: "Write file",
21
+ fetch_file: "Download file",
22
+ present: "Deliver file",
23
+ markdown_to_pdf: "Markdown to PDF",
24
+ html_to_pdf: "HTML to PDF",
25
+ view_image: "View image",
26
+ transcribe_audio: "Transcribe audio",
27
+ web_search: "Web search",
28
+ web_extract: "Read web page",
29
+ search_tools: "Search tools",
30
+ load_tools: "Load tools",
31
+ list_apps: "List apps",
32
+ search_help: "Search help",
33
+ read_help: "Read help",
34
+ search_project: "Search project",
35
+ read_project_knowledge: "Read project knowledge",
36
+ read_cloud_resource: "Read Cloud item",
37
+ load_skill: "Load skill",
38
+ search_skills: "Search skills",
39
+ code_run: "Run code",
40
+ code_action: "Run app action",
41
+ code_inspect: "Inspect run",
42
+ code_stop: "Stop run",
43
+ code_open: "Open app",
44
+ code_export: "Save run output",
45
+ code_present: "Show app in chat",
46
+ code_check: "App check",
47
+ code_secret: "Enter secret",
48
+ code_database: "App tables",
49
+ code_files: "List stored files",
50
+ code_file_stat: "File details",
51
+ code_file_copy: "Copy file",
52
+ code_database_export: "Export app database",
53
+ code_manage_read: "Read app details",
54
+ code_delete: "Delete app",
55
+ code_database_read: "Read app database",
56
+ code_database_clear: "Clear app data",
57
+ code_database_reset: "Reset app database",
58
+ code_storage_list: "List app storage",
59
+ code_storage_delete: "Delete from app storage",
60
+ code_access_read: "Read app access",
61
+ code_access_change: "Change app access",
62
+ code_actions: "List app actions",
63
+ code_sql: "Query app database",
64
+ code_versions: "List app versions",
65
+ code_list: "Find apps",
66
+ code_read: "Read app source",
67
+ code_history: "App history",
68
+ code_fork: "Copy app",
69
+ code_publish: "Publish app",
70
+ code_unpublish: "Withdraw app",
71
+ code_restore: "Restore app version",
72
+ code_update: "Update app details",
73
+ code_create: "Create app",
74
+ code_write: "Save app files",
75
+ code_remove: "Remove app file",
76
+ },
77
+ de: {
78
+ calculate: "Rechnen",
79
+ card: "Karte",
80
+ survey: "Umfrage",
81
+ text_editor: "Texteditor",
82
+ local_bash: "Lokale Bash",
83
+ todo_write: "Arbeitsplan",
84
+ memory: "Gedächtnis",
85
+ list_files: "Dateien auflisten",
86
+ read_file: "Datei lesen",
87
+ write_file: "Datei schreiben",
88
+ fetch_file: "Datei herunterladen",
89
+ present: "Datei bereitstellen",
90
+ markdown_to_pdf: "Markdown als PDF",
91
+ html_to_pdf: "HTML als PDF",
92
+ view_image: "Bild ansehen",
93
+ transcribe_audio: "Audio transkribieren",
94
+ web_search: "Websuche",
95
+ web_extract: "Webseite lesen",
96
+ search_tools: "Werkzeuge suchen",
97
+ load_tools: "Werkzeuge laden",
98
+ list_apps: "Apps auflisten",
99
+ search_help: "Hilfe durchsuchen",
100
+ read_help: "Hilfe lesen",
101
+ search_project: "Projekt durchsuchen",
102
+ read_project_knowledge: "Projektwissen lesen",
103
+ read_cloud_resource: "Cloud-Eintrag lesen",
104
+ load_skill: "Skill laden",
105
+ search_skills: "Skills suchen",
106
+ code_run: "Code ausführen",
107
+ code_action: "App-Aktion ausführen",
108
+ code_inspect: "Lauf prüfen",
109
+ code_stop: "Lauf beenden",
110
+ code_open: "App öffnen",
111
+ code_export: "Laufergebnis speichern",
112
+ code_present: "App im Chat zeigen",
113
+ code_check: "App-Prüfung",
114
+ code_secret: "Geheimnis eingeben",
115
+ code_database: "App-Tabellen",
116
+ code_files: "Gespeicherte Dateien auflisten",
117
+ code_file_stat: "Dateidetails",
118
+ code_file_copy: "Datei kopieren",
119
+ code_database_export: "App-Datenbank exportieren",
120
+ code_manage_read: "App-Details lesen",
121
+ code_delete: "App löschen",
122
+ code_database_read: "App-Datenbank lesen",
123
+ code_database_clear: "App-Daten leeren",
124
+ code_database_reset: "App-Datenbank zurücksetzen",
125
+ code_storage_list: "App-Speicher auflisten",
126
+ code_storage_delete: "Aus App-Speicher löschen",
127
+ code_access_read: "App-Zugriff lesen",
128
+ code_access_change: "App-Zugriff ändern",
129
+ code_actions: "App-Aktionen auflisten",
130
+ code_sql: "App-Datenbank abfragen",
131
+ code_versions: "App-Versionen auflisten",
132
+ code_list: "Apps suchen",
133
+ code_read: "App-Quelltext lesen",
134
+ code_history: "App-Verlauf",
135
+ code_fork: "App kopieren",
136
+ code_publish: "App veröffentlichen",
137
+ code_unpublish: "App zurückziehen",
138
+ code_restore: "App-Version wiederherstellen",
139
+ code_update: "App-Details ändern",
140
+ code_create: "App erstellen",
141
+ code_write: "App-Dateien speichern",
142
+ code_remove: "App-Datei entfernen",
143
+ },
144
+ },
145
+ });
146
+
147
+ /** Older chats call the visual tools by their `cloud_` names. */
148
+ const ALIASES: Record<string, string> = { cloud_card: "card", cloud_survey: "survey", cloud_text_editor: "text_editor" };
149
+
150
+ /** The localized name of a built-in tool, or undefined for a tool Cloud does not ship. */
151
+ export const aiBuiltinToolName = (name: string, locale: string): string | undefined => {
152
+ const t: Record<string, string> = toolNames.resolve([locale]).t;
153
+ const key = ALIASES[name] ?? name;
154
+ return Object.hasOwn(t, key) ? t[key] : undefined;
155
+ };
156
+
157
+ export const checkAiToolNames = () => toolNames.check();
@@ -1,4 +1,5 @@
1
1
  import { type AiTurnBlock, isRenderableTurnBlock } from "../protocol";
2
+ import type { AiStoredMessage } from "../types";
2
3
  import { hasCapabilityTable } from "./capability-result";
3
4
  import { isCardToolName, isRecord, isSurveyToolName, isTextEditorToolName } from "./message-utils";
4
5
  import { isFailedTool } from "./tool-groups";
@@ -139,3 +140,14 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
139
140
  waitingFor,
140
141
  };
141
142
  }
143
+
144
+ /** The loop ending is authoritative; partial messages also carry their own failed or interrupted ending. */
145
+ export const storedPhase = (entries: readonly Pick<AiStoredMessage, "loopDoneReason" | "stopReason">[]): AiTurnPhase => {
146
+ const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
147
+ if (reason === "aborted") return "stopped";
148
+ if (reason && reason !== "stop") return "failed";
149
+ const stop = entries.findLast((entry) => entry.stopReason)?.stopReason;
150
+ if (stop === "error") return "failed";
151
+ if (stop === "interrupted" || stop === "aborted") return "stopped";
152
+ return "completed";
153
+ };
@@ -127,7 +127,8 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
127
127
  if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
128
128
  return withTarget(t.stepWritingCode);
129
129
  if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
130
- if (name === "code_inspect") return t.stepCheckingApp;
130
+ if (name === "code_check") return t.stepCheckingApp;
131
+ if (name === "code_inspect") return t.stepCheckingRun;
131
132
  if (name.startsWith("code_")) return t.stepCode;
132
133
  if (["markdown_to_pdf", "html_to_pdf"].includes(name)) return withTarget(t.stepPdf);
133
134
  if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(name)) return t.stepLoading;
@@ -369,6 +370,7 @@ function ResultLinks(props: { links: Record<string, unknown>[] }) {
369
370
 
370
371
  /** The summary and links of a table result, unless its receipt in place 4 already shows them. */
371
372
  function CapabilityResultRow(props: { block: ToolBlock }) {
373
+ const locale = useLocale();
372
374
  const block = () => props.block;
373
375
  const summary = () => resultSummary(block());
374
376
  const links = () => resultLinks(block());
@@ -377,7 +379,7 @@ function CapabilityResultRow(props: { block: ToolBlock }) {
377
379
  <Chat.Activity
378
380
  icon={aiToolIcon(block().name, block().presentation?.appIcon)}
379
381
  accent={block().presentation?.appAccent}
380
- label={summary() || (block().presentation?.title ?? displayToolName(block().name))}
382
+ label={summary() || (block().presentation?.title ?? displayToolName(block().name, locale()))}
381
383
  trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
382
384
  />
383
385
  </Show>
@@ -385,6 +387,7 @@ function CapabilityResultRow(props: { block: ToolBlock }) {
385
387
  }
386
388
 
387
389
  function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; receipt: Accessor<boolean> }) {
390
+ const locale = useLocale();
388
391
  const actions = useAiChatActions();
389
392
  const block = () => props.result()?.block;
390
393
  return (
@@ -414,7 +417,10 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
414
417
  <Show when={!props.receipt()}>
415
418
  <CapabilityResultRow block={current()} />
416
419
  </Show>
417
- <CapabilityTablePreview result={current().result} label={current().presentation?.title ?? displayToolName(current().name)} />
420
+ <CapabilityTablePreview
421
+ result={current().result}
422
+ label={current().presentation?.title ?? displayToolName(current().name, locale())}
423
+ />
418
424
  </Match>
419
425
  </Switch>
420
426
  )}
@@ -426,7 +432,7 @@ function AiReceipt(props: { action: AiTurnAction; stopped: boolean }) {
426
432
  const locale = useLocale();
427
433
  const t = () => aiChatMessages(locale());
428
434
  const block = () => props.action.block;
429
- const title = () => block().presentation?.title ?? displayToolName(block().name);
435
+ const title = () => block().presentation?.title ?? displayToolName(block().name, locale());
430
436
  const links = () => (props.action.state === "done" ? resultLinks(block()) : []);
431
437
  const label = () => {
432
438
  const state = props.action.state;
@@ -465,7 +471,9 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
465
471
  const current = action();
466
472
  if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
467
473
  announce(
468
- aiChatMessages(locale()).approvalNeeded({ title: current.block.presentation?.title ?? displayToolName(current.block.name) }),
474
+ aiChatMessages(locale()).approvalNeeded({
475
+ title: current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
476
+ }),
469
477
  );
470
478
  }),
471
479
  );
@@ -500,7 +508,7 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
500
508
  <Switch
501
509
  fallback={
502
510
  <Chat.Activity
503
- label={current().block.presentation?.title ?? displayToolName(current().block.name)}
511
+ label={current().block.presentation?.title ?? displayToolName(current().block.name, locale())}
504
512
  icon={aiToolIcon(current().block.name)}
505
513
  busy={current().block.status === "awaiting_client"}
506
514
  />
@@ -516,7 +524,7 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
516
524
  </Match>
517
525
  <Match when={current().state === "running"}>
518
526
  <Chat.Activity
519
- label={current().block.presentation?.title ?? displayToolName(current().block.name)}
527
+ label={current().block.presentation?.title ?? displayToolName(current().block.name, locale())}
520
528
  icon={aiToolIcon(current().block.name, current().block.presentation?.appIcon)}
521
529
  accent={current().block.presentation?.appAccent}
522
530
  busy
@@ -4,6 +4,7 @@ import { getCapability } from "../_internal/registry";
4
4
  import { logger } from "../services/logging";
5
5
  import {
6
6
  createMandate,
7
+ MANDATE_POLICY_DENIED_MESSAGE,
7
8
  type Mandate,
8
9
  type MandatePolicyV1,
9
10
  MandatePolicyV1Schema,
@@ -88,7 +89,14 @@ export class AiChatTaskIdempotencyConflictError extends Error {
88
89
  }
89
90
 
90
91
  class AiChatTaskCreateRace extends Error {}
91
- class AiChatTaskAuthorityError extends Error {}
92
+ export class AiChatTaskAuthorityError extends Error {
93
+ constructor(
94
+ message: string,
95
+ readonly code?: string,
96
+ ) {
97
+ super(message);
98
+ }
99
+ }
92
100
  const log = logger("ai:chat-tasks");
93
101
 
94
102
  type OccurrenceRow = {
@@ -497,10 +505,15 @@ export const aiChatTasks = {
497
505
  input: input.input,
498
506
  capabilityApproval: input.approval,
499
507
  });
500
- if (!result.ok)
508
+ if (!result.ok) {
509
+ const policyDenied = result.error.code === "FORBIDDEN" && result.error.message === MANDATE_POLICY_DENIED_MESSAGE;
501
510
  throw new AiChatTaskAuthorityError(
502
- `Background task cannot call ${input.appId}.${input.capabilityId}: ${result.error.message}. Check the task grants and fixed inputs in the normal chat; this run cannot expand them.`,
511
+ policyDenied
512
+ ? `This task's grants do not allow ${input.appId}.${input.capabilityId} with these inputs; continue with the granted tools or explain what is missing.`
513
+ : `Background task cannot call ${input.appId}.${input.capabilityId}: ${result.error.message}. Check the task grants and fixed inputs in the normal chat; this run cannot expand them.`,
514
+ policyDenied ? "MANDATE_POLICY_DENIED" : result.error.code,
503
515
  );
516
+ }
504
517
  },
505
518
  list: async (input: {
506
519
  userId: string;