@k2b/cloud 0.27.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 (155) 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/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
package/src/ai/routes.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  err,
11
11
  fail,
12
12
  getLocale,
13
+ getTimeZone,
13
14
  ok,
14
15
  type RequestActor,
15
16
  rateLimit,
@@ -17,12 +18,15 @@ import {
17
18
  v,
18
19
  } from "../server";
19
20
  import { isRequestCredentialCurrent } from "../server/middleware/auth";
21
+ import { ImageMetadataError } from "../services/image-metadata";
20
22
  import { logger } from "../services/logging";
21
23
  import { coreSettings } from "../services/settings/api";
24
+ import { readThemeFromCookieHeader } from "../shared/theme";
22
25
  import type { AiToolApprovalContext } from "./approvals";
23
- import { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
26
+ import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
24
27
  import { AI_AUDIO_MAX_BYTES } from "./audio-format";
25
28
  import { buildAiCapabilityCatalog } from "./capabilities";
29
+ import { aiChatMessages } from "./chat/messages";
26
30
  import { getAiChatQuotas } from "./chat-quotas";
27
31
  import { createConfiguredDefaultCloudAiTools } from "./default-tools";
28
32
  import { enqueueAiDictation } from "./dictation-runtime";
@@ -56,7 +60,6 @@ import {
56
60
  queuedMessageAccepted,
57
61
  updateQueuedMessage,
58
62
  } from "./message-queue";
59
- import { aiModelAccess } from "./model-access";
60
63
  import { personalAiModelPolicy } from "./personal-agent";
61
64
  import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
62
65
  import { aiProjects } from "./projects";
@@ -82,7 +85,6 @@ import { aiConversations } from "./store";
82
85
  import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
83
86
  import { composeAiSystemPrompt } from "./system-prompt";
84
87
  import { aiToolPromptHints } from "./tools";
85
- import { resolveAiAudioModel } from "./transcription";
86
88
  import type { AiConversation, AiModelPolicy, AiTurn } from "./types";
87
89
 
88
90
  /** Everything a resolved, authorized request needs to run against the shared runtime. */
@@ -418,7 +420,10 @@ export const aiRoutes = (() => {
418
420
  const skillCatalog = selectAiSkillCatalog(availableSkills, previewProfile?.contextWindow ?? 0, "");
419
421
  const tools = toolsSupported
420
422
  ? [
421
- ...(await createConfiguredDefaultCloudAiTools()),
423
+ ...(await createConfiguredDefaultCloudAiTools({
424
+ accessSubject: c.get("accessSubject"),
425
+ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
426
+ })),
422
427
  ...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
423
428
  ...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
424
429
  ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
@@ -524,7 +529,14 @@ export const aiRoutes = (() => {
524
529
  return respond(c, fail(err.internal("The live capability catalog is unavailable.")));
525
530
  }
526
531
  }
527
- const builtInNames = new Set((await createConfiguredDefaultCloudAiTools()).map((tool) => tool.def.name));
532
+ const builtInNames = new Set(
533
+ (
534
+ await createConfiguredDefaultCloudAiTools({
535
+ accessSubject: c.get("accessSubject"),
536
+ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
537
+ })
538
+ ).map((tool) => tool.def.name),
539
+ );
528
540
  const preloadTools = (body.preloadTools ?? []).map((requested) => {
529
541
  if ("name" in requested) return builtInNames.has(requested.name) ? requested.name : null;
530
542
  return (
@@ -884,6 +896,8 @@ export const aiRoutes = (() => {
884
896
  userMessage: message,
885
897
  actor: ctx.actor,
886
898
  locale: getLocale(c),
899
+ theme: readThemeFromCookieHeader(c.req.header("cookie")),
900
+ timeZone: getTimeZone(c),
887
901
  requestedModelId: body.modelProfileId ?? project?.defaultModelProfileId ?? undefined,
888
902
  modelPolicy: ctx.modelPolicy,
889
903
  project: project ?? undefined,
@@ -991,6 +1005,8 @@ export const aiRoutes = (() => {
991
1005
  userMessage: message,
992
1006
  actor: ctx.actor,
993
1007
  locale: getLocale(c),
1008
+ theme: readThemeFromCookieHeader(c.req.header("cookie")),
1009
+ timeZone: getTimeZone(c),
994
1010
  requestedModelId,
995
1011
  modelPolicy: ctx.modelPolicy,
996
1012
  systemPrompt,
@@ -1172,11 +1188,7 @@ export const aiRoutes = (() => {
1172
1188
  conversationId: conversation.id,
1173
1189
  userId: ctx.ownerUserId,
1174
1190
  bytes: new Uint8Array(await file.arrayBuffer()),
1175
- resolveModel: async () => {
1176
- const model = await resolveAiAudioModel({ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries });
1177
- await aiModelAccess.assertAllowed(model.profile.id, c.get("accessSubject"));
1178
- return model;
1179
- },
1191
+ resolveModel: () => resolveAssistantAudioModel(c.get("accessSubject"), ctx.modelPolicy.allowedDataBoundaries),
1180
1192
  });
1181
1193
  await enqueueAiDictation(dictation.id).catch(() => undefined); // The DB sweep heals failed delivery.
1182
1194
  return respond(c, ok(dictation));
@@ -1306,6 +1318,8 @@ export const aiRoutes = (() => {
1306
1318
  });
1307
1319
  return respond(c, ok({ file: stat }));
1308
1320
  } catch (error) {
1321
+ if (error instanceof ImageMetadataError)
1322
+ return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
1309
1323
  logger("ai:files").warn("Conversation upload failed", {
1310
1324
  code: "file_upload_failed",
1311
1325
  conversationId: conversation.id,
@@ -1366,6 +1380,8 @@ export const aiRoutes = (() => {
1366
1380
  allowUserOverwrite: existing?.origin === "user",
1367
1381
  });
1368
1382
  } catch (error) {
1383
+ if (error instanceof ImageMetadataError)
1384
+ return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
1369
1385
  return respond(c, fail(err.badInput(error instanceof Error ? error.message : "Failed to write file")));
1370
1386
  }
1371
1387
  return respond(c, ok({ file: await aiFileStore.stat({ conversationId: conversation.id, path }) }));
@@ -1,12 +1,11 @@
1
+ import type { AiTurnError } from "./types";
2
+
1
3
  /** Preserve why execution stopped instead of reporting an operator deadline as a user abort. */
2
4
  export class AiRunTimeout extends Error {
3
5
  constructor(readonly budgetMs: number | null) {
4
6
  super("AI_RUN_TIMEOUT");
5
7
  }
6
- messageFor(locale?: string): string {
7
- const minutes = this.budgetMs ? this.budgetMs / 60_000 : null;
8
- return locale?.startsWith("de")
9
- ? `Laufzeitlimit${minutes ? ` von ${minutes} Minuten` : ""} erreicht. Du kannst die Aufgabe mit einer neuen Nachricht fortsetzen.`
10
- : `Run time limit${minutes ? ` of ${minutes} minutes` : ""} reached. You can continue the task with a new message.`;
8
+ turnError(): AiTurnError {
9
+ return this.budgetMs ? { code: "time_limit", limitMinutes: Math.round(this.budgetMs / 60_000) } : { code: "time_limit" };
11
10
  }
12
11
  }
package/src/ai/runtime.ts CHANGED
@@ -94,6 +94,8 @@ export type SubmitAiChatTurnInput = {
94
94
  userMessage: Message;
95
95
  actor?: RequestActor;
96
96
  locale?: string;
97
+ theme?: "light" | "dark";
98
+ timeZone?: string;
97
99
  modelPolicy?: AiModelPolicy;
98
100
  requestedModelId?: string;
99
101
  /** Optional instructions that apply only to this turn. */
@@ -155,6 +157,8 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
155
157
  chatId: input.chatId,
156
158
  actor: input.actor,
157
159
  ...(input.locale ? { locale: input.locale } : {}),
160
+ ...(input.theme ? { theme: input.theme } : {}),
161
+ ...(input.timeZone ? { timeZone: input.timeZone } : {}),
158
162
  modelPolicy: input.modelPolicy,
159
163
  requestedModelId,
160
164
  systemPrompt: input.systemPrompt,
@@ -190,6 +194,8 @@ export const deliverAiInterChatMessage = async (input: {
190
194
  chatId?: string;
191
195
  actor: RequestActor;
192
196
  locale?: string;
197
+ theme?: "light" | "dark";
198
+ timeZone?: string;
193
199
  modelPolicy?: AiModelPolicy;
194
200
  systemPrompt?: string;
195
201
  project?: AiChatTurnRunConfig["project"];
@@ -209,6 +215,8 @@ export const deliverAiInterChatMessage = async (input: {
209
215
  chatId: input.chatId,
210
216
  actor: input.actor,
211
217
  ...(input.locale ? { locale: input.locale } : {}),
218
+ ...(input.theme ? { theme: input.theme } : {}),
219
+ ...(input.timeZone ? { timeZone: input.timeZone } : {}),
212
220
  modelPolicy: input.modelPolicy,
213
221
  systemPrompt: input.systemPrompt,
214
222
  project: input.project,
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { coreSettings } from "../services";
3
+ import { stripImageDataUrlMetadata } from "../services/image-metadata";
3
4
  import { AiModelPricingSchema } from "../shared/ai-costs";
4
5
  import {
5
6
  AI_REQUEST_HEADERS_PROVIDER_ERROR,
@@ -152,6 +153,18 @@ const normalizeProfile = (raw: z.infer<typeof ModelProfileSchema>): AiModelProfi
152
153
  };
153
154
  };
154
155
 
156
+ /** Sanitize every submitted model logo, including unchanged images from stored profiles. */
157
+ export const prepareAiModelProfileImages = (rawJson: string): string => {
158
+ const raw: unknown = JSON.parse(rawJson);
159
+ if (!Array.isArray(raw)) return rawJson;
160
+ return JSON.stringify(
161
+ raw.map((profile: unknown) => {
162
+ if (typeof profile !== "object" || profile === null || !("image" in profile) || typeof profile.image !== "string") return profile;
163
+ return { ...profile, image: stripImageDataUrlMetadata(profile.image) };
164
+ }),
165
+ );
166
+ };
167
+
155
168
  export const parseAiModelProfiles = (rawJson: string): { profiles: AiModelProfile[]; error?: AiSettingsError } => {
156
169
  let raw: unknown;
157
170
  try {
@@ -468,13 +481,18 @@ export const selectAiModelProfile = (
468
481
  const modelId = resolvePolicyModelId(state, policy, requestedModelId);
469
482
  const profile = state.profiles.find((candidate) => candidate.id === modelId);
470
483
  if (!profile || !matchesPolicy(profile, policy)) {
471
- throw Object.assign(new Error(`AI model "${modelId}" is not allowed for this chat.`), {
472
- aiError: {
473
- code: "model_policy_mismatch",
474
- message: `AI model "${modelId}" is not allowed for this chat.`,
475
- fields: { modelProfileId: "Choose an allowed enabled model profile." },
476
- } satisfies AiSettingsError,
477
- });
484
+ throw Object.assign(
485
+ new Error(
486
+ `AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
487
+ ),
488
+ {
489
+ aiError: {
490
+ code: "model_policy_mismatch",
491
+ message: `AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
492
+ fields: { modelProfileId: "Choose an allowed enabled model profile." },
493
+ } satisfies AiSettingsError,
494
+ },
495
+ );
478
496
  }
479
497
  return profile;
480
498
  };
@@ -497,8 +515,19 @@ const visionModelPolicy = (modelId: string, allowedDataBoundaries?: AiDataBounda
497
515
  export const resolveAiVisionModel = async (allowedDataBoundaries?: AiDataBoundary[]): Promise<AiResolvedModel> => {
498
516
  const state = await readAiSettingsState();
499
517
  if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
518
+ if (!state.enabled) {
519
+ throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
520
+ aiError: {
521
+ code: "ai_disabled",
522
+ message: "AI is disabled. An administrator can enable AI in the AI settings.",
523
+ } satisfies AiSettingsError,
524
+ });
525
+ }
500
526
  const modelId = (state.visionModelId ?? "").trim();
501
- if (!modelId) throw new Error("No vision model is configured.");
527
+ if (!modelId)
528
+ throw new Error(
529
+ "Image inspection is not set up: no vision model is configured. An administrator can configure a vision model in the AI settings.",
530
+ );
502
531
  return resolveAiModelFromState(state, visionModelPolicy(modelId, allowedDataBoundaries));
503
532
  };
504
533
 
@@ -520,8 +549,11 @@ export const resolveAiModelFromState = async (
520
549
  ): Promise<AiResolvedModel> => {
521
550
  if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
522
551
  if (!state.enabled) {
523
- throw Object.assign(new Error("AI is disabled."), {
524
- aiError: { code: "ai_disabled", message: "AI is disabled." } satisfies AiSettingsError,
552
+ throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
553
+ aiError: {
554
+ code: "ai_disabled",
555
+ message: "AI is disabled. An administrator can enable AI in the AI settings.",
556
+ } satisfies AiSettingsError,
525
557
  });
526
558
  }
527
559
 
@@ -529,13 +561,18 @@ export const resolveAiModelFromState = async (
529
561
 
530
562
  const credential = providerSupportsCredential(profile.provider) ? await getAiCredential(profile.id) : null;
531
563
  if (providerRequiresCredential(profile.provider) && !credential?.trim()) {
532
- throw Object.assign(new Error(`AI model "${profile.id}" is missing provider credentials.`), {
533
- aiError: {
534
- code: "missing_provider_credential",
535
- message: `AI model "${profile.id}" is missing provider credentials.`,
536
- fields: { "ai.model_profiles_json": "Enter the provider API key on this model profile." },
537
- } satisfies AiSettingsError,
538
- });
564
+ throw Object.assign(
565
+ new Error(
566
+ `AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
567
+ ),
568
+ {
569
+ aiError: {
570
+ code: "missing_provider_credential",
571
+ message: `AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
572
+ fields: { "ai.model_profiles_json": "Enter the provider API key on this model profile." },
573
+ } satisfies AiSettingsError,
574
+ },
575
+ );
539
576
  }
540
577
 
541
578
  const headers = providerSupportsRequestHeaders(profile.provider) ? await getAiRequestHeaders(profile.id) : {};
@@ -223,6 +223,8 @@ Use these defaults unless the user asks otherwise or a more specific loaded Skil
223
223
  - For text lookup across Spaces, use \`spaces.item.search\`. For overdue, assigned or inactive tasks use \`spaces.task.focus\`; filter at the server instead of enumerating every Space.
224
224
  - For a date-bounded calendar use \`spaces.event.agenda\`. Follow every cursor, including after an empty page: pagination groups series with their overrides. Collect all pages and sort by startsAt for a chronological agenda. Use returned occurrences; do not expand recurrence rules yourself or equate a series anchor with the next occurrence.
225
225
  - Select a writable Space through \`spaces.space.browse\`. A known Space can be listed directly with \`spaces.task.list\` or \`spaces.event.list\`. Read \`spaces.space.read\` only when column/tag IDs or configuration are needed.
226
+ - List entries show at most three assignees and tags: when \`assigneeCount\` is larger than the number of \`assignees\`, state the total (for example "3 of 11") or read the item before naming everyone. \`relationsTruncated\` signals that a relation preview is partial.
227
+ - Use each entry's \`overdue\` to call a task overdue; a completed task is never overdue, so never infer it from \`deadline\` alone.
226
228
  - Read a selected \`spaces.item.read\` before changing its content or deleting it. Use a task for work and an event only with explicit valid start and end. Select assignees from \`spaces.space.assignee.list\`, never inferred names or invented IDs.
227
229
 
228
230
  ## Make focused changes
@@ -235,6 +237,12 @@ Use these defaults unless the user asks otherwise or a more specific loaded Skil
235
237
  - Use \`spaces.item.reference.find\` to find existing items for a source resource. Link on creation through references rather than a redundant second mutation. For an existing item use \`spaces.item.reference.add\`; use \`spaces.item.link-candidate.search\` when a writable target is unknown.
236
238
  - Use \`spaces.event.create\` for interactive creation. Never blindly retry an uncertain mutation.
237
239
 
240
+ ## Templates
241
+
242
+ - When the user names a template or the Space has one for the request, find it with \`spaces.template.list\`; pass the person's IANA timeZone when known. Each template carries its date rule and the next proposed local dates.
243
+ - Create from it with \`spaces.task.create\` or \`spaces.event.create\` and \`templateId\`. Set \`date\` to the proposal the user means ("next Wednesday" is the matching proposal) or to an explicit date; without \`date\` the first proposal applies. Give only the fields the user wants to change; the template fills the rest, including its checklist.
244
+ - Never guess a date from a vague request; if the proposals do not settle it, ask. Managing templates (\`spaces.template.create\`, \`spaces.template.update\`, \`spaces.template.delete\`) needs Space admin access.
245
+
238
246
  ## Calendar mail and cross-app work
239
247
 
240
248
  Before importing, responding to or preparing emailed calendar invitations, read /skills/cloud-spaces/references/calendar-mail.md. Preparation, attaching to a draft and commit are distinct from sending.
@@ -257,6 +265,7 @@ Use these defaults unless the user asks otherwise or a more specific loaded Skil
257
265
 
258
266
  - For a saved place, use location search or list and pass the returned location ID to \`weather.forecast.current\` for current conditions or \`weather.forecast.get\` for hourly and daily outlooks.
259
267
  - For an unsaved German city, use \`weather.city.search\`, choose an unambiguous candidate, and pass its coordinates directly to a forecast. Save it with \`weather.location.create\` only when the user asks.
268
+ - If city search is unavailable, use known coordinates with \`weather.forecast.current\` or \`weather.forecast.get\` and \`source.kind = "coordinates"\`; an administrator can configure the Geo API URL in Weather settings.
260
269
  - If multiple city candidates remain plausible, ask which one instead of choosing silently. Delete a saved location only when explicitly requested.
261
270
 
262
271
  ## Reporting defaults
@@ -475,11 +484,10 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
475
484
  references: [{ path: "references/query-tasks.md", content: CLOUD_GRIDS_QUERY_REFERENCE }],
476
485
  },
477
486
  {
478
- version: 2,
487
+ version: 3,
479
488
  key: "assistant:cloud-assistant",
480
489
  name: "cloud-assistant",
481
- description:
482
- "Use for work involving Cloud Assistant itself: finding or reading earlier conversations, recovering resources used in chats, messaging another conversation, or creating and managing reminders and recurring scheduled chat work.",
490
+ description: `Use for work involving Cloud Assistant itself: finding or reading earlier conversations, recovering resources used in chats, messaging another conversation, or creating and managing reminders and recurring scheduled chat work. Also use when a request refers to earlier work, such as "like last time", "as last week", or "the report you made me".`,
483
491
  instructions: CLOUD_ASSISTANT_INSTRUCTIONS,
484
492
  },
485
493
  {
@@ -524,16 +532,16 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
524
532
  instructions: CLOUD_CONTACTS_INSTRUCTIONS,
525
533
  },
526
534
  {
527
- version: 1,
535
+ version: 4,
528
536
  key: "spaces:cloud-spaces",
529
537
  name: "cloud-spaces",
530
538
  description:
531
- "Use for work involving Cloud Spaces: finding, reading, creating, or updating tasks and events, assignees, blockers, comments, tags, linked Cloud resources, and calendar invitations. Load it whenever a request involves a Space, task, work item, deadline, event, calendar entry, or shared work queue.",
539
+ "Use for work involving Cloud Spaces: finding, reading, creating, or updating tasks and events, templates, assignees, blockers, comments, tags, linked Cloud resources, and calendar invitations. Load it whenever a request involves a Space, task, work item, deadline, event, calendar entry, or shared work queue.",
532
540
  instructions: CLOUD_SPACES_INSTRUCTIONS,
533
541
  references: [{ path: "references/calendar-mail.md", content: CLOUD_SPACES_CALENDAR_REFERENCE }],
534
542
  },
535
543
  {
536
- version: 1,
544
+ version: 2,
537
545
  key: "weather:cloud-weather",
538
546
  name: "cloud-weather",
539
547
  description:
package/src/ai/store.ts CHANGED
@@ -4,8 +4,10 @@ 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 { aiTurnErrorText } from "./chat/turn-error";
7
8
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
8
9
  import type { AiTurnBlock } from "./protocol";
10
+ import { AiRunTimeout } from "./run-timeout";
9
11
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
10
12
  import { parseAiTodoPlan } from "./todo-contracts";
11
13
  import { activeTurnWaits } from "./turn-timing";
@@ -29,6 +31,7 @@ import type {
29
31
  AiToolPresentation,
30
32
  AiTurn,
31
33
  AiTurnClaim,
34
+ AiTurnError,
32
35
  AiTurnRunConfig,
33
36
  AiTurnStatus,
34
37
  AiTurnSteer,
@@ -112,6 +115,7 @@ type ConversationRow = {
112
115
  latest_browser_pending?: boolean;
113
116
  latest_turn_error?: string | null;
114
117
  latest_turn_completed_at?: Date | string | null;
118
+ latest_turn_short_id?: string | null;
115
119
  enrich_fail_count: number | null;
116
120
  project_id: string | null;
117
121
  draft_content: unknown;
@@ -552,6 +556,7 @@ const rowToConversation = (row: ConversationRow): AiConversation => ({
552
556
  lastUsedAt: iso(row.last_used_at),
553
557
  runStatus: conversationRunStatus(row.latest_turn_status, row.latest_browser_pending),
554
558
  runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
559
+ runTurnId: row.latest_turn_short_id ?? null,
555
560
  unreadCompletion:
556
561
  Boolean(
557
562
  row.background_received_at &&
@@ -723,10 +728,10 @@ const loadConversationSummary = async (input: {
723
728
  latest.status AS latest_turn_status,
724
729
  ${browserWorkPending()} AS latest_browser_pending,
725
730
  latest.error AS latest_turn_error,
726
- latest.completed_at AS latest_turn_completed_at
731
+ latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
727
732
  FROM ai.conversations conversation
728
733
  LEFT JOIN LATERAL (
729
- SELECT status, error, completed_at, live_blocks
734
+ SELECT short_id, status, error, completed_at, live_blocks
730
735
  FROM ai.turns
731
736
  WHERE conversation_id = conversation.id AND NOT COALESCE(run_config ? 'background', false)
732
737
  ORDER BY created_at DESC, id DESC
@@ -784,13 +789,30 @@ const messageSearchText = (message: Message): string => {
784
789
  * time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
785
790
  * never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
786
791
  * limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
792
+ * A failure's reason goes to the turn's last message, also when that is the user's own, so history can say why the
793
+ * turn failed and what to do next.
787
794
  */
788
795
  const recordTurnEnd = async (
789
796
  db: typeof sql,
790
- input: { conversationId: string; turnId: string; reason: "aborted" | "error" },
797
+ input: { conversationId: string; turnId: string; reason: "aborted" | "error"; turnError?: AiTurnError | null },
791
798
  ): Promise<void> => {
792
799
  const replaces = input.reason === "error" ? "aborted" : null;
793
800
  for (const table of ["ai.messages", "ai.task_messages"]) {
801
+ if (input.turnError) {
802
+ await db`
803
+ UPDATE ${db(table)}
804
+ SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object('turnError', ${JSON.stringify(input.turnError)}::text::jsonb)
805
+ WHERE id = (
806
+ SELECT id
807
+ FROM ${db(table)}
808
+ WHERE conversation_id = ${input.conversationId}
809
+ AND loop_id = ${input.turnId}::text
810
+ AND kind = 'message'
811
+ ORDER BY seq DESC
812
+ LIMIT 1
813
+ )
814
+ `;
815
+ }
794
816
  await db`
795
817
  UPDATE ${db(table)}
796
818
  SET loop_done_reason = ${input.reason}
@@ -915,7 +937,7 @@ const insertMessageLocked = async (
915
937
  // 'auto' is reserved for enrichment-set titles.
916
938
  await db`
917
939
  UPDATE ai.conversations
918
- SET title = ${title}, updated_at = now()
940
+ SET title = CASE WHEN title_source = 'default' THEN ${title} ELSE title END, updated_at = now()
919
941
  WHERE id = ${input.conversationId}
920
942
  `;
921
943
  } else {
@@ -1184,10 +1206,10 @@ export const aiConversations: AiConversationService = {
1184
1206
  latest.status AS latest_turn_status,
1185
1207
  ${browserWorkPending()} AS latest_browser_pending,
1186
1208
  latest.error AS latest_turn_error,
1187
- latest.completed_at AS latest_turn_completed_at
1209
+ latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
1188
1210
  FROM ai.conversations conversation
1189
1211
  LEFT JOIN LATERAL (
1190
- SELECT status, error, completed_at, live_blocks
1212
+ SELECT short_id, status, error, completed_at, live_blocks
1191
1213
  FROM ai.turns
1192
1214
  WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
1193
1215
  ORDER BY created_at DESC, id DESC
@@ -1240,12 +1262,12 @@ export const aiConversations: AiConversationService = {
1240
1262
  SELECT conversation.*, ${effectiveDone()} AS is_done,
1241
1263
  EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
1242
1264
  latest.status AS latest_turn_status, ${browserWorkPending()} AS latest_browser_pending,
1243
- latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at,
1265
+ latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id,
1244
1266
  jsonb_build_object('completed', COALESCE(progress.completed,0), 'total', COALESCE(progress.total,0),
1245
1267
  'step', progress.step, 'tool', ai.sidebar_tool_label(latest.live_blocks)->>'label') AS activity
1246
1268
  FROM ai.conversations conversation
1247
1269
  LEFT JOIN LATERAL (
1248
- SELECT status, error, completed_at, live_blocks FROM ai.turns
1270
+ SELECT short_id, status, error, completed_at, live_blocks FROM ai.turns
1249
1271
  WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id ORDER BY created_at DESC, id DESC LIMIT 1
1250
1272
  ) latest ON TRUE
1251
1273
  LEFT JOIN LATERAL (
@@ -1287,10 +1309,10 @@ export const aiConversations: AiConversationService = {
1287
1309
  latest.status AS latest_turn_status,
1288
1310
  ${browserWorkPending()} AS latest_browser_pending,
1289
1311
  latest.error AS latest_turn_error,
1290
- latest.completed_at AS latest_turn_completed_at
1312
+ latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
1291
1313
  FROM ai.conversations conversation
1292
1314
  LEFT JOIN LATERAL (
1293
- SELECT status, error, completed_at, live_blocks
1315
+ SELECT short_id, status, error, completed_at, live_blocks
1294
1316
  FROM ai.turns
1295
1317
  WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
1296
1318
  ORDER BY created_at DESC, id DESC
@@ -1926,32 +1948,68 @@ export const aiConversations: AiConversationService = {
1926
1948
  },
1927
1949
 
1928
1950
  archiveConversation: async (input) => {
1951
+ // Archiving considers pending learning work once, just like learning being
1952
+ // off at completion. Restoring cannot backfill these turns or receipts.
1929
1953
  const rows = await sql<{ id: string }[]>`
1930
- UPDATE ai.conversations
1931
- SET archived_at = now(), pinned_at = NULL
1932
- WHERE id = ${input.conversationId}
1933
- AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1934
- AND archived_at IS NULL
1935
- AND NOT EXISTS (
1936
- SELECT 1
1937
- FROM ai.turns
1938
- WHERE conversation_id = ai.conversations.id
1939
- AND status IN ('queued', 'running', 'waiting_for_action')
1940
- )
1941
- AND NOT EXISTS (SELECT 1 FROM ai.queued_messages WHERE conversation_id = ai.conversations.id AND status IN ('pending','failed'))
1942
- RETURNING id
1954
+ WITH archived AS (
1955
+ UPDATE ai.conversations
1956
+ SET archived_at = now(), pinned_at = NULL
1957
+ WHERE id = ${input.conversationId}
1958
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1959
+ AND archived_at IS NULL
1960
+ AND NOT EXISTS (
1961
+ SELECT 1
1962
+ FROM ai.turns
1963
+ WHERE conversation_id = ai.conversations.id
1964
+ AND status IN ('queued', 'running', 'waiting_for_action')
1965
+ )
1966
+ AND NOT EXISTS (SELECT 1 FROM ai.queued_messages WHERE conversation_id = ai.conversations.id AND status IN ('pending','failed'))
1967
+ RETURNING id
1968
+ ), considered_turns AS (
1969
+ UPDATE ai.turns
1970
+ SET memory_learned_at = completed_at,
1971
+ memory_learn_failed_at = NULL,
1972
+ memory_learn_fail_count = 0
1973
+ WHERE conversation_id IN (SELECT id FROM archived)
1974
+ AND status = 'completed'
1975
+ AND memory_learned_at IS NULL
1976
+ ), reviewed_evidence AS (
1977
+ UPDATE ai.memory_workflow_evidence
1978
+ SET reviewed_at = now()
1979
+ WHERE conversation_id IN (SELECT id FROM archived)
1980
+ AND reviewed_at IS NULL
1981
+ )
1982
+ SELECT id FROM archived
1943
1983
  `;
1944
1984
  return Boolean(rows[0]);
1945
1985
  },
1946
1986
 
1947
1987
  restoreConversation: async (input) => {
1988
+ // Restore never backfills: everything completed beforehand counts as considered,
1989
+ // including chats archived before archiving retired pending learning work.
1948
1990
  const rows = await sql<{ id: string }[]>`
1949
- UPDATE ai.conversations
1950
- SET archived_at = NULL
1951
- WHERE id = ${input.conversationId}
1952
- AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1953
- AND archived_at IS NOT NULL
1954
- RETURNING id
1991
+ WITH restored AS (
1992
+ UPDATE ai.conversations
1993
+ SET archived_at = NULL
1994
+ WHERE id = ${input.conversationId}
1995
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1996
+ AND archived_at IS NOT NULL
1997
+ RETURNING id
1998
+ ), considered_turns AS (
1999
+ UPDATE ai.turns
2000
+ SET memory_learned_at = completed_at,
2001
+ memory_learn_failed_at = NULL,
2002
+ memory_learn_fail_count = 0
2003
+ WHERE conversation_id IN (SELECT id FROM restored)
2004
+ AND status = 'completed'
2005
+ AND memory_learned_at IS NULL
2006
+ ), reviewed_evidence AS (
2007
+ UPDATE ai.memory_workflow_evidence
2008
+ SET reviewed_at = now()
2009
+ WHERE conversation_id IN (SELECT id FROM restored)
2010
+ AND reviewed_at IS NULL
2011
+ )
2012
+ SELECT id FROM restored
1955
2013
  `;
1956
2014
  return rows[0] ? loadConversationSummary(input) : null;
1957
2015
  },
@@ -2957,6 +3015,7 @@ export const aiConversations: AiConversationService = {
2957
3015
  conversationId: input.conversationId,
2958
3016
  turnId: input.turnId,
2959
3017
  reason: input.status === "aborted" ? "aborted" : "error",
3018
+ turnError: input.status === "failed" ? (input.turnError ?? { code: "failed" }) : null,
2960
3019
  });
2961
3020
  await tx`
2962
3021
  UPDATE ai.turn_steers
@@ -2977,11 +3036,12 @@ export const aiConversations: AiConversationService = {
2977
3036
  // 1) Finalize turns that exhausted actual recovery attempts. Resuming a
2978
3037
  // resolved user/frontend action is normal progress and does not consume
2979
3038
  // this budget.
3039
+ // The sweep does not know a turn's language; the chat words the recorded reason in the reader's.
2980
3040
  const exhaustedRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
2981
3041
  UPDATE ai.turns
2982
3042
  SET status = 'failed',
2983
3043
  completed_at = now(),
2984
- error = 'AI turn exhausted its recovery attempts.',
3044
+ error = ${aiTurnErrorText({ code: "interrupted" }, "en")},
2985
3045
  lease_owner = NULL,
2986
3046
  lease_expires_at = NULL,
2987
3047
  live_blocks = NULL
@@ -3029,11 +3089,13 @@ export const aiConversations: AiConversationService = {
3029
3089
  `;
3030
3090
 
3031
3091
  // 2) Finalize over-budget turns without a live lease.
3032
- const budgetRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
3092
+ const budgetRows = await sql<
3093
+ { id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string; run_budget_ms: number | null }[]
3094
+ >`
3033
3095
  UPDATE ai.turns
3034
3096
  SET status = 'failed',
3035
3097
  completed_at = now(),
3036
- error = 'Run time limit reached. You can continue the task in a new message.',
3098
+ error = ${aiTurnErrorText({ code: "time_limit" }, "en")},
3037
3099
  lease_owner = NULL,
3038
3100
  lease_expires_at = NULL,
3039
3101
  live_blocks = NULL
@@ -3047,8 +3109,12 @@ export const aiConversations: AiConversationService = {
3047
3109
  AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now())
3048
3110
  LIMIT ${limit}
3049
3111
  )
3050
- RETURNING id, conversation_id, error, attempt, live_seq
3112
+ RETURNING id, conversation_id, error, attempt, live_seq, run_budget_ms
3051
3113
  `;
3114
+ const turnErrors = new Map<string, AiTurnError>([
3115
+ ...exhaustedRows.map((row): [string, AiTurnError] => [row.id, { code: "interrupted" }]),
3116
+ ...budgetRows.map((row): [string, AiTurnError] => [row.id, new AiRunTimeout(Number(row.run_budget_ms) || null).turnError()]),
3117
+ ]);
3052
3118
  result.failed = [...exhaustedRows, ...budgetRows].map((row) => ({
3053
3119
  conversationId: row.conversation_id,
3054
3120
  turnId: row.id,
@@ -3087,10 +3153,12 @@ export const aiConversations: AiConversationService = {
3087
3153
  // History tells a stop from a turn that failed or whose wait expired, as it does for turns that end on their own.
3088
3154
  const stopped = new Set(abortedRows.filter((row) => row.stopped).map((row) => row.id));
3089
3155
  for (const finalized of [...result.failed, ...result.aborted]) {
3156
+ const stop = stopped.has(finalized.turnId);
3090
3157
  await recordTurnEnd(sql, {
3091
3158
  conversationId: finalized.conversationId,
3092
3159
  turnId: finalized.turnId,
3093
- reason: stopped.has(finalized.turnId) ? "aborted" : "error",
3160
+ reason: stop ? "aborted" : "error",
3161
+ turnError: stop ? null : (turnErrors.get(finalized.turnId) ?? { code: "wait_expired" }),
3094
3162
  });
3095
3163
  await sql`
3096
3164
  UPDATE ai.pending_actions
@@ -5,6 +5,7 @@ import type { AccessSubject } from "../server";
5
5
  import { coreSettings } from "../services";
6
6
  import type { TraceContext } from "../services/logging";
7
7
  import { trace } from "../services/logging";
8
+ import { taskReasoningEffort } from "./provider";
8
9
  import { inferenceProvider } from "./quota-provider";
9
10
  import { resolveAiModel } from "./settings";
10
11
  import { type AiUsageAttribution, safelyRecordStructuredRun } from "./structured-runs";
@@ -131,7 +132,7 @@ export const runAiStructured = async <TOutput extends z.ZodType>(
131
132
  outputName: input.outputName,
132
133
  temperature: input.temperature ?? 0,
133
134
  maxOutputTokens: input.maxOutputTokens ?? resolved.profile.maxOutputTokens,
134
- disableReasoning: true,
135
+ reasoningEffort: taskReasoningEffort(resolved.provider),
135
136
  signal: input.signal,
136
137
  });
137
138
  const durationMs = Date.now() - startedAt;