@k2b/cloud 0.28.0 → 0.29.1

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 (112) hide show
  1. package/package.json +9 -6
  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 +259 -197
  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 +16 -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 +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. package/src/styles/utilities-navigation.css +41 -0
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import type { AccessSubject } from "../server";
3
+ import { resolveAssistantAudioModel } from "./assistant-models";
2
4
  import { createCloudAiTranscribeAudioTool } from "./audio-tool";
3
5
  import {
4
6
  CODE_RUNTIME_TOOL_NAMES,
@@ -158,7 +160,7 @@ export const createCloudAiCodeTools = () => [
158
160
  defineAiTool({
159
161
  name: "code_check",
160
162
  description:
161
- "Mandatory HTML app self-test: write files and steps.json (at most 20 main-flow steps), call code_check({id} OR {files}), look at EVERY screenshot with view_image, fix and check again, then code_open/code_present/code_publish. Runs desktop 1280×800 and phone 390×844 in opposite themes on separate throwaway copies of database, shared KV/files and only your own KV.user. Steps run in both views; reload keeps the copy. Match accessible role/name, label or text exactly, then case-insensitively, then by substring; ambiguous targets fail with candidates, placeholders are never names. Fill numbers with a dot. Upload file is an app-relative source path or chat file path/name. AI runs for real; HTTP, capability effects and approvals are unavailable; read-only granted capabilities run except in background turns. Returns passed, content hash (files, steps and tables), height, issues, calls, captured downloads, three PNG chat paths and an untrusted aria tree (4 KiB). Passing means not broken: inspect screenshots for cut-off, red, tight or doubled content. Self-test is a workflow guard, not a security mechanism. Aborting closes pages and discards copies. Bounded to 45 seconds, 1000 copied rows/16 MiB (schema only beyond), 64 downloads/250 MiB total/50 MiB per file.",
163
+ "Mandatory HTML app self-test: write files and steps.json (at most 20 main-flow steps, ending in the filled main state), call code_check({id} OR {files}), fix every error and layout warning, call view_image({path, prompt: review.prompt}) for EVERY path in review.paths (screenshots and PDFs), fix what it finds and check again, then code_open/code_present/code_publish. Runs desktop 1280×800 and phone 390×844 in opposite themes on separate throwaway copies of database, shared KV/files and only your own KV.user. Steps run in both views; reload keeps the copy. Match accessible role/name, label or text exactly, then case-insensitively, then by substring; ambiguous targets fail with candidates, placeholders are never names. Fill numbers with a dot. Upload file is an app-relative source path or chat file path/name. AI runs for real; HTTP, capability effects and approvals are unavailable; read-only granted capabilities run except in background turns. Returns passed, content hash (files, steps and tables), height, issues, calls, captured downloads, three whole-page PNG chat paths (up to 2000 px tall; cropped marks a longer page), review and an untrusted aria tree (4 KiB). Passing means not broken; misaligned rows, also in PDFs, and broken values only warn. Self-test is a workflow guard, not a security mechanism. Aborting closes pages and discards copies. Bounded to 45 seconds, 1000 copied rows/16 MiB (schema only beyond), 64 downloads/250 MiB total/50 MiB per file.",
162
164
  inputSchema: CodeCheckInput,
163
165
  outputSchema: z.json(),
164
166
  approval: "never",
@@ -198,6 +200,7 @@ export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
198
200
 
199
201
  export const createConfiguredDefaultCloudAiTools = async (config?: {
200
202
  firecrawlApiKey?: string | null;
203
+ accessSubject?: AccessSubject | null;
201
204
  fetch?: typeof fetch;
202
205
  allowedDataBoundaries?: AiDataBoundary[];
203
206
  }) => {
@@ -213,8 +216,12 @@ export const createConfiguredDefaultCloudAiTools = async (config?: {
213
216
  createCloudAiPresentTool(),
214
217
  createCloudAiCalculateTool(),
215
218
  createCloudAiViewImageTool(),
216
- createCloudAiTranscribeAudioTool(),
217
219
  ];
220
+ const audioModelConfigured = await resolveAssistantAudioModel(config?.accessSubject ?? null, config?.allowedDataBoundaries).then(
221
+ () => true,
222
+ () => false,
223
+ );
224
+ if (audioModelConfigured) tools.push(createCloudAiTranscribeAudioTool());
218
225
  const firecrawlConfigured =
219
226
  config && "firecrawlApiKey" in config ? Boolean(config.firecrawlApiKey?.trim()) : await isCloudAiFirecrawlConfigured();
220
227
  if (firecrawlConfigured) {
@@ -1,10 +1,12 @@
1
- import type { CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
1
+ import type { AssistantMessage, CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
2
2
  import { compact, nessi } from "@k2b/nessi";
3
3
  import { listCapabilities } from "../_internal/registry";
4
4
  import type { CapabilityActionReview } from "../contracts/capabilities";
5
5
  import type { AccessSubject, RequestActor } from "../server";
6
6
  import { createHelpReader } from "../services/help";
7
7
  import { logger } from "../services/logging";
8
+ import { getMandate } from "../services/mandates";
9
+ import type { MandatePolicyV1 } from "../services/mandates/policy";
8
10
  import { coreSettings } from "../services/settings/api";
9
11
  import { normalizeLocale } from "../shared/locale";
10
12
  import {
@@ -14,13 +16,13 @@ import {
14
16
  aiTurnAllowsRememberedApprovals,
15
17
  hasRememberedAiToolApproval,
16
18
  } from "./approvals";
17
- import { isAssistantChatTurn } from "./assistant-models";
19
+ import { aiChatAccessSubject, isAssistantChatTurn, resolveAssistantAudioModel } from "./assistant-models";
18
20
  import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
19
- import { createAiToolResolver, createRunToolStore } from "./capabilities";
21
+ import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
20
22
  import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
21
23
  import { aiChatMessages } from "./chat/messages";
22
24
  import { aiTurnErrorText } from "./chat/turn-error";
23
- import { aiChatTasks } from "./chat-tasks";
25
+ import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
24
26
  import { createCloudCompactFn } from "./compaction";
25
27
  import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
26
28
  import { aiFileStore } from "./files-store";
@@ -60,6 +62,7 @@ import { aiToolAudit } from "./tool-audit";
60
62
  import { acceptCanonicalToolNames } from "./tool-call-names";
61
63
  import { resolveAiToolResultMaxChars } from "./tool-result-budget";
62
64
  import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
65
+ import { AiTranscriptionError } from "./transcription";
63
66
  import {
64
67
  AiTurnFailure,
65
68
  type AiTurnFailureInfo,
@@ -68,6 +71,7 @@ import {
68
71
  aiTurnReasonFromThrown,
69
72
  rememberProviderErrors,
70
73
  } from "./turn-failure";
74
+ import { type AiTurnGuidance, loadAiTurnGuidance } from "./turn-guidance";
71
75
  import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
72
76
  import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
73
77
  import type {
@@ -91,10 +95,17 @@ const AI_COALESCE_MS = 25;
91
95
  const AI_COALESCE_MAX_CHARS = 512;
92
96
  const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
93
97
 
98
+ // Failed generation can contain calls that nessi deliberately never executes or resumes.
99
+ const isToolRound = (message: AssistantMessage): boolean =>
100
+ message.stopReason !== "error" &&
101
+ message.stopReason !== "interrupted" &&
102
+ message.stopReason !== "aborted" &&
103
+ message.content.some((block) => block.type === "tool_call");
104
+
94
105
  const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
95
106
  const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
96
107
  const rounds = messages.flatMap(({ message }) => {
97
- if (message.role !== "assistant") return [];
108
+ if (message.role !== "assistant" || !isToolRound(message)) return [];
98
109
  const callIds = message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : []));
99
110
  return callIds.length > 0 ? [callIds] : [];
100
111
  });
@@ -557,7 +568,10 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
557
568
  tools:
558
569
  source.kind === "default"
559
570
  ? [
560
- ...(await createConfiguredDefaultCloudAiTools()),
571
+ ...(await createConfiguredDefaultCloudAiTools({
572
+ accessSubject: aiChatAccessSubject(config.actor),
573
+ allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
574
+ })),
561
575
  // Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
562
576
  ...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
563
577
  (tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
@@ -855,6 +869,20 @@ export class AiTurnExecutor {
855
869
  (tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
856
870
  )
857
871
  : [];
872
+ let audioUnavailableInstruction: string | undefined;
873
+ if (
874
+ defaultToolSource &&
875
+ toolsSupported &&
876
+ [...(config.files?.attached ?? []), ...(config.files?.available ?? [])].some((file) => file.mediaType.startsWith("audio/")) &&
877
+ !sourceToolNames.includes("transcribe_audio")
878
+ ) {
879
+ try {
880
+ await resolveAssistantAudioModel(aiChatAccessSubject(config.actor), material.modelPolicy?.allowedDataBoundaries);
881
+ } catch (error) {
882
+ const reason = error instanceof AiTranscriptionError ? error.message : "Audio transcription is not available right now.";
883
+ audioUnavailableInstruction = `Audio files in this conversation cannot be transcribed: ${reason} Tell the user this reason when they ask about audio content.`;
884
+ }
885
+ }
858
886
  const offeredToolNames = new Set(activeTools.map((tool) => tool.def.name));
859
887
  // Built-ins that exist but this turn does not offer: client tools without their client, tools a
860
888
  // task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
@@ -963,6 +991,52 @@ export class AiTurnExecutor {
963
991
 
964
992
  const appliedSteers: AiTurnSteer[] = [];
965
993
 
994
+ let mandatePolicy: MandatePolicyV1 | null | undefined;
995
+ if (config.background && config.mandate) {
996
+ mandatePolicy = null;
997
+ try {
998
+ const mandate = await getMandate(config.mandate.id);
999
+ if (mandate && mandate.state === "active" && mandate.revision === config.mandate.revision) {
1000
+ mandatePolicy = mandate.policy;
1001
+ } else {
1002
+ backgroundError = "Scheduled task mandate is unavailable or changed; recreate or update the task.";
1003
+ }
1004
+ } catch {
1005
+ backgroundError = "Scheduled task mandate could not be loaded; retry or update the task.";
1006
+ }
1007
+ }
1008
+ if (backgroundError) {
1009
+ signal.removeEventListener("abort", onSignal);
1010
+ await this.finalize(
1011
+ conversationId,
1012
+ turnId,
1013
+ pipeline,
1014
+ "failed",
1015
+ { error: { code: "not_allowed" }, detail: backgroundError, message: backgroundError },
1016
+ "chat",
1017
+ promptLocale,
1018
+ );
1019
+ return;
1020
+ }
1021
+ const authorizeBackground =
1022
+ config.background && config.mandate
1023
+ ? async (entry: AiCapabilityCatalogEntry, args: unknown) => {
1024
+ try {
1025
+ await aiChatTasks.authorizeCapability({
1026
+ mandate: config.mandate!,
1027
+ appId: entry.appId,
1028
+ capabilityId: entry.operation.localId,
1029
+ kind: entry.kind,
1030
+ input: args,
1031
+ approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
1032
+ });
1033
+ } catch (error) {
1034
+ if (!(error instanceof AiChatTaskAuthorityError && error.code === "MANDATE_POLICY_DENIED"))
1035
+ backgroundError = error instanceof Error ? error.message : "Background capability access denied";
1036
+ throw error;
1037
+ }
1038
+ }
1039
+ : undefined;
966
1040
  const toolStore = config.background ? createRunToolStore(await aiConversations.getLoadedTools({ conversationId })) : aiConversations;
967
1041
  const tools = toolActor
968
1042
  ? createAiToolResolver({
@@ -970,6 +1044,7 @@ export class AiTurnExecutor {
970
1044
  actor: capabilityAuthority?.actor ?? toolActor,
971
1045
  staticTools: activeTools,
972
1046
  allowedTools,
1047
+ mandatePolicy,
973
1048
  unofferedTools,
974
1049
  runtimeContext: dynamicToolRuntimeContext,
975
1050
  store: toolStore,
@@ -995,25 +1070,7 @@ export class AiTurnExecutor {
995
1070
  }),
996
1071
  }
997
1072
  : {}),
998
- ...(config.background && config.mandate
999
- ? {
1000
- authorizeBackground: async (entry, args) => {
1001
- try {
1002
- await aiChatTasks.authorizeCapability({
1003
- mandate: config.mandate!,
1004
- appId: entry.appId,
1005
- capabilityId: entry.operation.localId,
1006
- kind: entry.kind,
1007
- input: args,
1008
- approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
1009
- });
1010
- } catch (error) {
1011
- backgroundError = error instanceof Error ? error.message : "Background capability access denied";
1012
- throw error;
1013
- }
1014
- },
1015
- }
1016
- : {}),
1073
+ authorizeBackground,
1017
1074
  onReview: (callId, review) => {
1018
1075
  capabilityActionReviews.set(callId, review);
1019
1076
  if (review.approvalScope) rememberableCapabilityApprovals.set(callId, review.approvalScope);
@@ -1022,16 +1079,7 @@ export class AiTurnExecutor {
1022
1079
  ? {
1023
1080
  execute: async (entry, args, context) => {
1024
1081
  try {
1025
- if (config.background && config.mandate) {
1026
- await aiChatTasks.authorizeCapability({
1027
- mandate: config.mandate,
1028
- appId: entry.appId,
1029
- capabilityId: entry.operation.localId,
1030
- kind: entry.kind,
1031
- input: args,
1032
- approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
1033
- });
1034
- }
1082
+ await authorizeBackground?.(entry, args);
1035
1083
  const result = await executeAiCapability({
1036
1084
  conversationId,
1037
1085
  turnId,
@@ -1101,12 +1149,43 @@ export class AiTurnExecutor {
1101
1149
  skillSubject,
1102
1150
  activeTools.some((tool) => tool.def.name === "load_skill"),
1103
1151
  );
1104
- const workingPlan = (await aiConversations.getConversation({ conversationId }))?.todoPlan;
1152
+ const chat = await aiConversations.getConversation({ conversationId });
1153
+ const workingPlan = chat?.todoPlan;
1154
+ const skillCatalogOffered = activeTools.some((tool) => tool.def.name === "load_skill");
1155
+ const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator");
1156
+ // Offer hints and the recent-work summary only guide a fresh turn a person follows; a resumed
1157
+ // attempt after an approval or a scheduled run gets neither.
1158
+ let guidance: AiTurnGuidance = {};
1159
+ if (isFresh && !config.background && defaultToolSource && user && chat) {
1160
+ try {
1161
+ guidance = await loadAiTurnGuidance(
1162
+ {
1163
+ chat,
1164
+ turnId,
1165
+ ownerUserId: user.id,
1166
+ input: config.input,
1167
+ instructions: [settings.globalInstructions, config.project?.instructions, memory?.text],
1168
+ skillOffers: skillCatalogOffered && skillCreatorAvailable && loadedSkills.length === 0,
1169
+ memoryOffers: memoryToolEnabled,
1170
+ hasAttachments: Boolean(config.files?.attached.length),
1171
+ },
1172
+ aiConversations,
1173
+ );
1174
+ } catch (error) {
1175
+ log.warn("AI turn guidance unavailable; continuing without it", {
1176
+ conversationId,
1177
+ turnId,
1178
+ error: error instanceof Error ? error.message : String(error),
1179
+ });
1180
+ }
1181
+ }
1105
1182
  const systemPrompt = composeAiSystemPrompt({
1106
1183
  globalInstructions: settings.globalInstructions,
1107
1184
  loadedSkills,
1108
1185
  turnInstructions: [
1109
1186
  material.systemPrompt,
1187
+ audioUnavailableInstruction,
1188
+ guidance.offerHint,
1110
1189
  workingPlan
1111
1190
  ? `Current working plan (new todo_write results supersede this):\n${JSON.stringify({ todos: workingPlan.todos })}`
1112
1191
  : undefined,
@@ -1122,7 +1201,7 @@ export class AiTurnExecutor {
1122
1201
  project: config.project,
1123
1202
  files: config.files,
1124
1203
  projectToolEnabled,
1125
- skills: activeTools.some((tool) => tool.def.name === "load_skill") ? skillCatalog.skills : undefined,
1204
+ skills: skillCatalogOffered ? skillCatalog.skills : undefined,
1126
1205
  omittedSkillCount: activeTools.some((tool) => tool.def.name === "search_skills") ? skillCatalog.omitted : 0,
1127
1206
  user,
1128
1207
  memoryEnabled: memoryActive,
@@ -1135,11 +1214,19 @@ export class AiTurnExecutor {
1135
1214
  timeZone,
1136
1215
  locale: promptLocale,
1137
1216
  interactive: !config.background,
1138
- skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
1217
+ skillCreatorAvailable,
1218
+ recentWork: guidance.recentWork,
1139
1219
  });
1140
1220
  // The turn policy counts the whole turn, including rounds that compaction archived.
1141
1221
  const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
1142
1222
  const turnBlocks = buildBlocksFromMessages(turnMessages);
1223
+ const providerNamesByCallId = new Map(
1224
+ turnMessages.flatMap(({ message }) =>
1225
+ message.role === "assistant"
1226
+ ? message.content.flatMap((part) => (part.type === "tool_call" ? [[part.id, part.name] as const] : []))
1227
+ : [],
1228
+ ),
1229
+ );
1143
1230
  const priorToolRounds = toolRoundState(turnMessages);
1144
1231
  const quotaSubject = accessSubjectForActor(material.actor);
1145
1232
  const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
@@ -1174,7 +1261,7 @@ export class AiTurnExecutor {
1174
1261
  runBudgetMs: claim.turn.runBudgetMs ?? null,
1175
1262
  finishedToolCalls: turnBlocks
1176
1263
  .slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
1177
- .flatMap((block) => (block.kind === "tool" ? [block] : [])),
1264
+ .flatMap((block) => (block.kind === "tool" ? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }] : [])),
1178
1265
  onDecision: (decision) =>
1179
1266
  decision.kind === "hint"
1180
1267
  ? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
@@ -1413,7 +1500,7 @@ export class AiTurnExecutor {
1413
1500
  result: event.result,
1414
1501
  isError: event.isError === true,
1415
1502
  });
1416
- } else if (event.type === "turn_end" && event.message.content.some((block) => block.type === "tool_call")) {
1503
+ } else if (event.type === "turn_end" && !abortController.signal.aborted && isToolRound(event.message)) {
1417
1504
  noteToolRound();
1418
1505
  } else if (event.type === "issue") {
1419
1506
  lastIssueMessage = event.issue.message;
@@ -38,3 +38,7 @@ export const guessAiMediaType = (path: string): string => {
38
38
  const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
39
39
  return MEDIA_TYPES[ext] ?? "application/octet-stream";
40
40
  };
41
+
42
+ /** Image classification includes filenames when clients send a generic media type. */
43
+ export const isAiImage = (path: string, mediaType: string): boolean =>
44
+ mediaType.startsWith("image/") || guessAiMediaType(path).startsWith("image/");
@@ -1,5 +1,7 @@
1
1
  import { type SQL, sql } from "bun";
2
+ import { stripImageMetadata } from "../services/image-metadata";
2
3
  import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
4
+ import { isAiImage } from "./file-media-type";
3
5
 
4
6
  export { guessAiMediaType } from "./file-media-type";
5
7
 
@@ -79,9 +81,11 @@ export const createUniqueAiFileInTransaction = async (
79
81
  throw new AiFileWriteError("STORAGE_FULL", `File exceeds the per-file limit of ${Math.floor(maxFile / (1024 * 1024))} MB.`);
80
82
  }
81
83
 
84
+ const bytes = isAiImage(input.path, input.mediaType ?? "application/octet-stream") ? stripImageMetadata(input.bytes) : input.bytes;
85
+
82
86
  await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
83
87
  const total = await aiConversationStoredBytes(tx, input.conversationId);
84
- if (total + input.bytes.byteLength > maxConversation) {
88
+ if (total + bytes.byteLength > maxConversation) {
85
89
  throw new AiFileWriteError("STORAGE_FULL", `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`);
86
90
  }
87
91
 
@@ -89,7 +93,7 @@ export const createUniqueAiFileInTransaction = async (
89
93
  const path = numberedAiFilePath(input.path, number);
90
94
  const rows = await tx<FileRow[]>`
91
95
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at)
92
- SELECT ${input.conversationId}, ${path}, ${input.bytes}, ${input.mediaType ?? "application/octet-stream"}, ${input.bytes.byteLength}, ${input.origin}, ${input.dictationRecordedAt ?? null}, now()
96
+ SELECT ${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, ${input.origin}, ${input.dictationRecordedAt ?? null}, now()
93
97
  WHERE NOT EXISTS (
94
98
  SELECT 1 FROM ai.files WHERE conversation_id = ${input.conversationId} AND normalize(path, NFC) = ${path.normalize("NFC")}
95
99
  )
@@ -191,7 +195,8 @@ export const aiFileStore = {
191
195
  producerCallKey: string;
192
196
  mediaType: string;
193
197
  }): Promise<AiFileStat> {
194
- if (input.bytes.byteLength > AI_FILES_MAX_FILE_BYTES_DEFAULT)
198
+ const bytes = isAiImage(input.path, input.mediaType) ? stripImageMetadata(input.bytes) : input.bytes;
199
+ if (bytes.byteLength > AI_FILES_MAX_FILE_BYTES_DEFAULT)
195
200
  throw new AiFileWriteError("STORAGE_FULL", "File exceeds the destination file limit; nothing was written.");
196
201
  if (!normalizeAiFilePath(input.path)) {
197
202
  throw new Error("Invalid tool artifact path or file size.");
@@ -210,18 +215,18 @@ export const aiFileStore = {
210
215
  Number(row.version) === 1 &&
211
216
  row.producer_call_key === input.producerCallKey &&
212
217
  row.media_type === input.mediaType &&
213
- Buffer.from(row.bytes).equals(Buffer.from(input.bytes))
218
+ Buffer.from(row.bytes).equals(Buffer.from(bytes))
214
219
  )
215
220
  return toStat(row);
216
221
  throw new AiFileWriteError("CONFLICT", "Destination file already exists. Choose another path; nothing was written.");
217
222
  }
218
223
  const total = await aiConversationStoredBytes(tx, input.conversationId);
219
- if (total + input.bytes.byteLength > AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT) {
224
+ if (total + bytes.byteLength > AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT) {
220
225
  throw new AiFileWriteError("STORAGE_FULL", "Conversation storage limit exceeded.");
221
226
  }
222
227
  const rows = await tx<FileRow[]>`
223
228
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, producer_call_key)
224
- VALUES (${input.conversationId}, ${path}, ${input.bytes}, ${input.mediaType}, ${input.bytes.byteLength}, 'assistant', ${input.producerCallKey})
229
+ VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType}, ${bytes.byteLength}, 'assistant', ${input.producerCallKey})
225
230
  RETURNING path, size, media_type, origin, dictation_recorded_at, updated_at, version
226
231
  `;
227
232
  return toStat(rows[0]!);
@@ -364,6 +369,8 @@ export const aiFileStore = {
364
369
  throw new AiFileWriteError("STORAGE_FULL", `File exceeds the per-file limit of ${Math.floor(maxFile / (1024 * 1024))} MB.`);
365
370
  }
366
371
 
372
+ const bytes = isAiImage(input.path, input.mediaType ?? "application/octet-stream") ? stripImageMetadata(input.bytes) : input.bytes;
373
+
367
374
  return sql.begin(async (tx) => {
368
375
  const [conversation] = await tx<
369
376
  { id: string; created_by_user_id: string; archived_at: Date | null }[]
@@ -384,7 +391,7 @@ export const aiFileStore = {
384
391
  if (version !== input.expectedVersion) throw new AiFileVersionConflict();
385
392
  }
386
393
  const otherBytes = await aiConversationStoredBytes(tx, input.conversationId, path);
387
- if (otherBytes + input.bytes.byteLength > maxConversation) {
394
+ if (otherBytes + bytes.byteLength > maxConversation) {
388
395
  throw new AiFileWriteError(
389
396
  "STORAGE_FULL",
390
397
  `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`,
@@ -394,7 +401,7 @@ export const aiFileStore = {
394
401
  if (input.allowUserOverwrite) {
395
402
  const written = await tx<{ id: string }[]>`
396
403
  UPDATE ai.files
397
- SET dictation_recorded_at = NULL, bytes = ${input.bytes}, media_type = ${input.mediaType ?? "application/octet-stream"}, size = ${input.bytes.byteLength}, updated_at = now(), version = version + 1
404
+ SET dictation_recorded_at = NULL, bytes = ${bytes}, media_type = ${input.mediaType ?? "application/octet-stream"}, size = ${bytes.byteLength}, updated_at = now(), version = version + 1
398
405
  WHERE conversation_id = ${input.conversationId} AND path = ${path} AND origin = 'user'
399
406
  RETURNING id
400
407
  `;
@@ -402,13 +409,13 @@ export const aiFileStore = {
402
409
  } else {
403
410
  await tx`
404
411
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
405
- VALUES (${input.conversationId}, ${path}, ${input.bytes}, ${input.mediaType ?? "application/octet-stream"}, ${input.bytes.byteLength}, 'user', now())
412
+ VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, 'user', now())
406
413
  `;
407
414
  }
408
415
  } else {
409
416
  const written = await tx<{ id: string }[]>`
410
417
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
411
- VALUES (${input.conversationId}, ${path}, ${input.bytes}, ${input.mediaType ?? "application/octet-stream"}, ${input.bytes.byteLength}, 'assistant', now())
418
+ VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, 'assistant', now())
412
419
  ON CONFLICT (conversation_id, path) DO UPDATE SET
413
420
  bytes = EXCLUDED.bytes,
414
421
  media_type = EXCLUDED.media_type,
package/src/ai/http.ts CHANGED
@@ -122,6 +122,15 @@ export type AiTurnContentPart = z.infer<typeof AiUserContentPartSchema>;
122
122
 
123
123
  export const AiClientToolIdSchema = z.enum(["local_bash", ...CODE_RUNTIME_TOOL_NAMES] satisfies [AiClientToolId, ...AiClientToolId[]]);
124
124
 
125
+ // Names use aiCapabilityToolName's provider grammar and 64-character limit. The 64-item cap matches allowedTools, with headroom
126
+ // for retired and not-yet-known names; 64 names × 64 ASCII characters bounds advertised name text to 4 KiB.
127
+ const AI_CLIENT_TOOL_MAX_ITEMS = 64;
128
+ const AiClientToolIdsSchema = z
129
+ .array(z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/))
130
+ .max(AI_CLIENT_TOOL_MAX_ITEMS)
131
+ .refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
132
+ .transform((ids) => ids.filter((id): id is AiClientToolId => AiClientToolIdSchema.safeParse(id).success));
133
+
125
134
  export const AiTurnInputSchema = z
126
135
  .object({
127
136
  message: z.string().trim().max(20000).optional(),
@@ -131,11 +140,7 @@ export const AiTurnInputSchema = z
131
140
  .max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1)
132
141
  .optional(),
133
142
  modelProfileId: z.string().trim().min(1).optional(),
134
- clientToolIds: z
135
- .array(AiClientToolIdSchema)
136
- .max(1 + CODE_RUNTIME_TOOL_NAMES.length)
137
- .refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
138
- .optional(),
143
+ clientToolIds: AiClientToolIdsSchema.optional(),
139
144
  })
140
145
  .refine((input) => Boolean(input.message?.trim() || input.content?.length), {
141
146
  message: "Message or content is required.",
@@ -156,11 +161,7 @@ export const AiSubmitConversationDraftInputSchema = z.object({
156
161
  queueId: z.uuid().optional(),
157
162
  draftRevision: z.number().int().min(1),
158
163
  modelProfileId: z.string().trim().min(1).optional(),
159
- clientToolIds: z
160
- .array(AiClientToolIdSchema)
161
- .max(1 + CODE_RUNTIME_TOOL_NAMES.length)
162
- .refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
163
- .optional(),
164
+ clientToolIds: AiClientToolIdsSchema.optional(),
164
165
  });
165
166
 
166
167
  export const AiSteerInputSchema = z.object({
package/src/ai/index.ts CHANGED
@@ -76,6 +76,7 @@ export {
76
76
  } from "./enrich";
77
77
  export { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
78
78
  export { aiConversationFileManifest } from "./file-context";
79
+ export { isAiImage } from "./file-media-type";
79
80
  export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
80
81
  export {
81
82
  AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
@@ -412,6 +412,7 @@ export const aiMemories = {
412
412
  * Apply one model proposal as an ownership-checked transaction. Background
413
413
  * learning may only mutate normal background memories; user, agent, and
414
414
  * pinned entries remain authoritative.
415
+ * The source chat must still be active; the row lock makes this atomic with archiving.
415
416
  */
416
417
  async applyBackgroundProposal(input: {
417
418
  userId: string;
@@ -437,6 +438,7 @@ export const aiMemories = {
437
438
  FROM ai.conversations c
438
439
  WHERE c.id = ${input.sourceConversationId}::uuid
439
440
  AND c.created_by_user_id = ${input.userId}::uuid
441
+ AND c.archived_at IS NULL
440
442
  AND (${input.sourceMessageId ?? null}::uuid IS NULL OR EXISTS (
441
443
  SELECT 1 FROM ai.messages m
442
444
  WHERE m.id = ${input.sourceMessageId ?? null}::uuid AND m.conversation_id = c.id