@k2b/cloud 0.31.0 → 0.33.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 (173) hide show
  1. package/package.json +8 -6
  2. package/src/_internal/define-app.ts +20 -9
  3. package/src/_internal/page-responses.ts +5 -1
  4. package/src/ai/app-skills.ts +1 -7
  5. package/src/ai/audio-tool.ts +2 -1
  6. package/src/ai/browser.ts +109 -0
  7. package/src/ai/capabilities.ts +6 -2
  8. package/src/ai/chart-block.ts +431 -81
  9. package/src/ai/chat/action-sentences.ts +13 -7
  10. package/src/ai/chat/blocks.tsx +11 -7
  11. package/src/ai/chat/builtin-tools.tsx +9 -7
  12. package/src/ai/chat/chart-block.tsx +218 -44
  13. package/src/ai/chat/explorer-block.tsx +276 -0
  14. package/src/ai/chat/file-tools.tsx +40 -1
  15. package/src/ai/chat/message-actions.tsx +6 -0
  16. package/src/ai/chat/message-utils.ts +7 -0
  17. package/src/ai/chat/messages.ts +16 -0
  18. package/src/ai/chat/presentation.tsx +13 -3
  19. package/src/ai/chat/reference-text.tsx +34 -0
  20. package/src/ai/chat/tool-groups.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +14 -0
  22. package/src/ai/chat/turn-view.tsx +17 -7
  23. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  24. package/src/ai/chat/view-block.tsx +417 -0
  25. package/src/ai/chat/visual-tools.tsx +32 -53
  26. package/src/ai/client-tool-results.ts +9 -0
  27. package/src/ai/code-source-contracts.ts +17 -2
  28. package/src/ai/code-source-tools.ts +1 -0
  29. package/src/ai/default-tool-contracts.ts +334 -91
  30. package/src/ai/default-tools.ts +89 -13
  31. package/src/ai/executor.ts +173 -31
  32. package/src/ai/file-error.ts +2 -0
  33. package/src/ai/file-tools.ts +27 -7
  34. package/src/ai/files-store.ts +60 -0
  35. package/src/ai/html-pdf-tool.ts +3 -2
  36. package/src/ai/http.ts +37 -12
  37. package/src/ai/index.ts +90 -0
  38. package/src/ai/local-tool-contracts.ts +244 -0
  39. package/src/ai/malformed-tool-call-repair.ts +144 -0
  40. package/src/ai/markdown-pdf-tool.ts +2 -1
  41. package/src/ai/memories.ts +48 -1
  42. package/src/ai/memory-learning.ts +17 -6
  43. package/src/ai/memory-tool.ts +1 -0
  44. package/src/ai/migrate.ts +15 -0
  45. package/src/ai/openai-api-messages.ts +53 -0
  46. package/src/ai/openai-api-slots.ts +40 -0
  47. package/src/ai/openai-api.ts +383 -0
  48. package/src/ai/protocol.ts +6 -2
  49. package/src/ai/provider.ts +17 -4
  50. package/src/ai/request-context.ts +33 -0
  51. package/src/ai/routes.ts +96 -57
  52. package/src/ai/runtime.ts +48 -12
  53. package/src/ai/settings.ts +5 -0
  54. package/src/ai/skill-catalog.ts +20 -5
  55. package/src/ai/skill-format.ts +17 -7
  56. package/src/ai/skill-tool.ts +15 -5
  57. package/src/ai/skills-routes.ts +20 -6
  58. package/src/ai/skills.ts +26 -7
  59. package/src/ai/store.ts +25 -0
  60. package/src/ai/system-prompt.ts +69 -10
  61. package/src/ai/timeline.ts +3 -1
  62. package/src/ai/tools.ts +3 -0
  63. package/src/ai/types.ts +9 -0
  64. package/src/ai/view-block.ts +941 -0
  65. package/src/ai/vision-tool.ts +2 -1
  66. package/src/api/admin-identity.ts +6 -1
  67. package/src/api/app-approval-actions.ts +36 -0
  68. package/src/api/app-approval.ts +10 -2
  69. package/src/api/approvals-loader.ts +15 -0
  70. package/src/api/approvals.ts +120 -0
  71. package/src/api/index.ts +3 -0
  72. package/src/api/me.ts +12 -3
  73. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  74. package/src/approvals/Approvals.tsx +621 -0
  75. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  76. package/src/approvals/messages.ts +95 -0
  77. package/src/approvals/ui.ts +8 -0
  78. package/src/browser/app-approval.ts +32 -0
  79. package/src/browser/choose-files.tsx +26 -13
  80. package/src/browser/commands.ts +13 -4
  81. package/src/browser/file-provider-error.ts +11 -0
  82. package/src/browser/file-providers.ts +3 -12
  83. package/src/browser/files.tsx +1 -2
  84. package/src/browser/live-connection.ts +2 -3
  85. package/src/browser/navigation-search.ts +4 -1
  86. package/src/browser/provider-list.ts +2 -1
  87. package/src/browser/save-files.tsx +13 -2
  88. package/src/cli/account.ts +21 -2
  89. package/src/cli/commands.ts +4 -2
  90. package/src/contracts/app-approval.ts +120 -0
  91. package/src/contracts/approval-paths.ts +4 -0
  92. package/src/contracts/approvals.ts +81 -0
  93. package/src/contracts/command-path.ts +17 -0
  94. package/src/contracts/commands.ts +5 -15
  95. package/src/contracts/shared.ts +5 -0
  96. package/src/events/live-protocol.ts +2 -1
  97. package/src/events/live-server-message.ts +40 -0
  98. package/src/events/testing.ts +88 -0
  99. package/src/server/index.ts +2 -0
  100. package/src/server/middleware/auth.ts +11 -1
  101. package/src/server/request-timeout.ts +22 -0
  102. package/src/server/services/access.ts +4 -3
  103. package/src/services/app-approval.ts +103 -15
  104. package/src/services/approvals/cursor.ts +15 -0
  105. package/src/services/approvals/device.ts +112 -0
  106. package/src/services/approvals/index.ts +27 -11
  107. package/src/services/approvals/list.ts +120 -0
  108. package/src/services/approvals/migrate.ts +9 -1
  109. package/src/services/identity/models-only-auth.ts +25 -0
  110. package/src/services/notifications/email-frame.ts +16 -3
  111. package/src/services/outbox.ts +13 -6
  112. package/src/services/service-account-credentials.ts +22 -10
  113. package/src/services/settings/core-settings.ts +27 -0
  114. package/src/shared/ai-platform-prompt.ts +29 -3
  115. package/src/shared/email-html.ts +4 -0
  116. package/src/shared/index.ts +6 -1
  117. package/src/shared/markdown/extensions/code.ts +14 -0
  118. package/src/shared/markdown/index.ts +4 -4
  119. package/src/shared/theme.ts +41 -20
  120. package/src/ssr/AnnouncementBanners.tsx +30 -0
  121. package/src/ssr/AppBadge.tsx +4 -4
  122. package/src/ssr/AppLaunchpadPanel.tsx +6 -6
  123. package/src/ssr/CloudShell.island.tsx +91 -0
  124. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  125. package/src/ssr/Layout.tsx +56 -85
  126. package/src/ssr/LayoutHeader.tsx +31 -77
  127. package/src/ssr/LayoutHelp.tsx +2 -57
  128. package/src/ssr/LayoutHelpTrigger.tsx +51 -0
  129. package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
  130. package/src/ssr/LayoutRail.tsx +15 -69
  131. package/src/ssr/MinimalLayout.tsx +2 -3
  132. package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
  133. package/src/ssr/MobileNavigation.tsx +13 -8
  134. package/src/ssr/MobileProfileActions.tsx +10 -7
  135. package/src/ssr/PageTrailRow.tsx +14 -0
  136. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
  137. package/src/ssr/PwaLayout.tsx +0 -2
  138. package/src/ssr/PwaRuntime.island.tsx +1 -1
  139. package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
  140. package/src/ssr/ShellRuntime.island.tsx +170 -0
  141. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  142. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  143. package/src/ssr/announcements-dialog.tsx +66 -0
  144. package/src/ssr/app-badges.ts +50 -6
  145. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  146. package/src/ssr/busy-action.ts +19 -0
  147. package/src/ssr/islands/index.ts +9 -6
  148. package/src/ssr/launchpad-surface.tsx +15 -0
  149. package/src/ssr/layout-help-registry.tsx +60 -0
  150. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  151. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  152. package/src/ssr/preference-controller.ts +30 -21
  153. package/src/ssr/profile-actions.ts +26 -15
  154. package/src/ssr/profile-preferences-messages.ts +8 -4
  155. package/src/ssr/rail-context.ts +8 -12
  156. package/src/ssr/rail-navigation.ts +5 -3
  157. package/src/ssr/runtime.ts +6 -1
  158. package/src/ssr/shell-data.tsx +48 -0
  159. package/src/ssr/timezone-cookie.ts +22 -0
  160. package/src/ssr/workspace-navigation.ts +25 -2
  161. package/src/styles/approvals.css +390 -0
  162. package/src/styles/global.css +22 -7
  163. package/src/styles/utilities-navigation.css +13 -0
  164. package/src/workflows/language/compiler.ts +1 -1
  165. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  166. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  167. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  168. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  169. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  170. package/src/ssr/PageTrailRow.island.tsx +0 -20
  171. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  172. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  173. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
package/src/ai/routes.ts CHANGED
@@ -2,9 +2,9 @@ import { type Context, Hono } from "hono";
2
2
  import { bodyLimit } from "hono/body-limit";
3
3
  import { z } from "zod";
4
4
  import { listCapabilities } from "../_internal/registry";
5
+ import { APPROVAL_CLIENT_HEADER } from "../contracts/approvals";
5
6
  import {
6
7
  type AccessSubject,
7
- type ApiErrorResponse,
8
8
  type AuthContext,
9
9
  auth,
10
10
  err,
@@ -23,13 +23,13 @@ import { ImageMetadataError } from "../services/image-metadata";
23
23
  import { logger } from "../services/logging";
24
24
  import { coreSettings } from "../services/settings/api";
25
25
  import { readThemeFromCookieHeader } from "../shared/theme";
26
- import type { AiToolApprovalContext } from "./approvals";
27
26
  import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
28
27
  import { AI_AUDIO_MAX_BYTES } from "./audio-format";
29
28
  import { buildAiCapabilityCatalog } from "./capabilities";
30
29
  import { aiChatMessages } from "./chat/messages";
31
30
  import { getAiChatQuotas } from "./chat-quotas";
32
- import { createConfiguredDefaultCloudAiTools } from "./default-tools";
31
+ import { AI_CLIENT_TOOL_ACTION_MAX_BYTES, aiClientToolOutputSchemas } from "./client-tool-results";
32
+ import { createCloudAiLocalBashTool, createCloudAiLocalTools, createConfiguredDefaultCloudAiTools } from "./default-tools";
33
33
  import { enqueueAiDictation } from "./dictation-runtime";
34
34
  import { AiDictationConflict, AiDictationListSchema, AiDictationStartSchema, aiDictations } from "./dictations";
35
35
  import { aiProjectFilePathFromMount } from "./file-mount";
@@ -49,6 +49,7 @@ import {
49
49
  toAiActionFailureResponse,
50
50
  toAiErrorResponse,
51
51
  } from "./http";
52
+ import { AI_LOCAL_TOOL_NAMES, AiLocalClientSchema } from "./local-tool-contracts";
52
53
  import { aiMaintenanceJobs } from "./maintenance";
53
54
  import { AI_MEMORY_CONTENT_MAX_CHARS, aiMemories } from "./memories";
54
55
  import { aiMemoryLearningRuns } from "./memory-learning-runs";
@@ -61,10 +62,10 @@ import {
61
62
  queuedMessageAccepted,
62
63
  updateQueuedMessage,
63
64
  } from "./message-queue";
64
- import { personalAiModelPolicy } from "./personal-agent";
65
65
  import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
66
66
  import { aiProjects } from "./projects";
67
67
  import { projectPublicAiStoredMessages, publicAiStoredMessages } from "./public-projection";
68
+ import { type AiChatRequestContext, resolveAiChatRequestContext as resolveContext } from "./request-context";
68
69
  import { aiResourceMarker } from "./resource-markers";
69
70
  import { isConversationResourceCursor } from "./resource-refs";
70
71
  import {
@@ -86,32 +87,7 @@ import { aiConversations } from "./store";
86
87
  import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
87
88
  import { composeAiSystemPrompt } from "./system-prompt";
88
89
  import { aiToolPromptHints } from "./tools";
89
- import type { AiConversation, AiModelPolicy, AiTurn } from "./types";
90
-
91
- /** Everything a resolved, authorized request needs to run against the shared runtime. */
92
- type AiChatRequestContext = {
93
- actor: RequestActor;
94
- ownerUserId: string;
95
- modelPolicy: AiModelPolicy;
96
- toolApprovalContext: AiToolApprovalContext;
97
- /** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
98
- signedInSession: boolean;
99
- };
100
-
101
- const actorUser = (actor: RequestActor) => (actor.kind === "user" ? actor.user : actor.delegatedUser);
102
-
103
- const resolveContext = async (c: Context<AuthContext>): Promise<AiChatRequestContext | ApiErrorResponse> => {
104
- const actor = c.get("actor");
105
- const user = actorUser(actor);
106
- if (!user) return respond(c, fail(err.forbidden("AI conversations require a user-backed actor.")));
107
- return {
108
- actor,
109
- ownerUserId: user.id,
110
- modelPolicy: personalAiModelPolicy,
111
- toolApprovalContext: { actorUserId: user.id },
112
- signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
113
- };
114
- };
90
+ import type { AiConversation, AiTurn } from "./types";
115
91
 
116
92
  const retryInstruction = (mode: "retry" | "details" | "concise"): string | null => {
117
93
  if (mode === "details") return "Answer the user's request again with more detail and specificity.";
@@ -330,7 +306,11 @@ export const aiRoutes = (() => {
330
306
  .use(rateLimit())
331
307
  .use("*", auth.requireRole("authenticated"))
332
308
  .get("/status", async (c) => {
333
- return respond(c, ok(await assistantAiSettingsState(c.get("accessSubject"))));
309
+ const [status, enabled] = await Promise.all([
310
+ assistantAiSettingsState(c.get("accessSubject")),
311
+ coreSettings.get<boolean>("ai.openai_api_enabled"),
312
+ ]);
313
+ return respond(c, ok({ ...status, openaiApi: { enabled } }));
334
314
  })
335
315
  .get("/quotas", async (c) => {
336
316
  c.header("Cache-Control", "no-store");
@@ -406,6 +386,21 @@ export const aiRoutes = (() => {
406
386
  const user = aiActorUser(ctx.actor);
407
387
  if (!user) return respond(c, fail(err.forbidden("AI preferences require a user context.")));
408
388
 
389
+ const clientKind = c.req.query("clientKind");
390
+ const parsedClient =
391
+ clientKind === undefined
392
+ ? undefined
393
+ : AiLocalClientSchema.safeParse({
394
+ kind: clientKind,
395
+ name: c.req.query("clientName"),
396
+ cwd: c.req.query("cwd"),
397
+ os: c.req.query("os"),
398
+ shell: c.req.query("shell"),
399
+ localTools: c.req.query("localTools"),
400
+ });
401
+ if (parsedClient && !parsedClient.success)
402
+ return respond(c, fail(err.badInput(`Invalid local client: ${parsedClient.error.issues[0]?.message}`)));
403
+ const client = parsedClient?.success ? parsedClient.data : undefined;
409
404
  const prefs = await aiUserPrefs.get(user.id);
410
405
  const memoryEnabled = Boolean(prefs?.memoryEnabled);
411
406
  const memory = memoryEnabled ? await aiMemories.selectHot(user.id, "") : null;
@@ -421,13 +416,18 @@ export const aiRoutes = (() => {
421
416
  }
422
417
  const toolsSupported = Boolean(previewProfile?.capabilities.includes("tools"));
423
418
  const availableSkills = toolsSupported ? (await aiSkills.list(c.get("accessSubject"))).filter((skill) => skill.enabled) : [];
424
- const skillCatalog = selectAiSkillCatalog(availableSkills, previewProfile?.contextWindow ?? 0, "");
419
+ const skillCatalog = selectAiSkillCatalog(
420
+ availableSkills.filter((skill) => !skill.manualOnly).map(({ name, description }) => ({ name, description })),
421
+ previewProfile?.contextWindow ?? 0,
422
+ "",
423
+ );
425
424
  const tools = toolsSupported
426
425
  ? [
427
426
  ...(await createConfiguredDefaultCloudAiTools({
428
427
  accessSubject: c.get("accessSubject"),
429
428
  allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
430
429
  })),
430
+ ...(client?.localTools === "ask" ? [createCloudAiLocalBashTool(), ...createCloudAiLocalTools()] : []),
431
431
  ...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
432
432
  ...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
433
433
  ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
@@ -438,6 +438,9 @@ export const aiRoutes = (() => {
438
438
  const promptLocale = getLocale(c);
439
439
  const prompt = composeAiSystemPrompt({
440
440
  globalInstructions: state.globalInstructions,
441
+ localClient: client
442
+ ? { client, cloudUrl: (await coreSettings.get<string>("app.url")) || "", instructions: state.localClientInstructions }
443
+ : undefined,
441
444
  user,
442
445
  memoryEnabled,
443
446
  memoryToolEnabled,
@@ -450,7 +453,7 @@ export const aiRoutes = (() => {
450
453
  memory: memory?.text,
451
454
  timeZone,
452
455
  locale: promptLocale,
453
- skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
456
+ skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator" && !skill.manualOnly),
454
457
  });
455
458
  return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
456
459
  })
@@ -907,6 +910,8 @@ export const aiRoutes = (() => {
907
910
  modelPolicy: ctx.modelPolicy,
908
911
  project: project ?? undefined,
909
912
  clientToolIds: body.clientToolIds,
913
+ client: body.client,
914
+ localContext: body.localContext,
910
915
  toolSource: { kind: "default" as const, appTools: true },
911
916
  toolApprovalContext: ctx.toolApprovalContext,
912
917
  expectedDraftRevision: body.draftRevision,
@@ -1022,7 +1027,20 @@ export const aiRoutes = (() => {
1022
1027
  truncateFromSeq: target.seq,
1023
1028
  fileSnapshot: originalRunConfig?.kind !== "compact" ? originalRunConfig?.files : undefined,
1024
1029
  retrySourceTurnId: target.loopId ?? undefined,
1025
- clientToolIds: originalRunConfig?.kind !== "compact" ? originalRunConfig?.clientToolIds : undefined,
1030
+ // Local context and tools belong to the retrying client, not the original workspace.
1031
+ clientToolIds:
1032
+ body.clientToolIds ??
1033
+ (originalRunConfig?.kind !== "compact"
1034
+ ? originalRunConfig?.clientToolIds?.filter((id) => id !== "local_bash" && !AI_LOCAL_TOOL_NAMES.some((name) => name === id))
1035
+ : undefined),
1036
+ client: body.client,
1037
+ localContext: body.client
1038
+ ? {
1039
+ instructions: body.localContext?.instructions ?? [],
1040
+ skills: body.localContext?.skills ?? [],
1041
+ selectedSkills: originalRunConfig?.kind !== "compact" ? (originalRunConfig?.localContext?.selectedSkills ?? []) : [],
1042
+ }
1043
+ : undefined,
1026
1044
  });
1027
1045
  rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
1028
1046
  return respond(
@@ -1114,29 +1132,49 @@ export const aiRoutes = (() => {
1114
1132
  await abortAiTurn({ conversationId: conversation.id, turnId: turn.id });
1115
1133
  return respond(c, ok({ ok: true }));
1116
1134
  })
1117
- .post("/conversations/:conversationId/turns/:turnId/actions/:callId", v("json", AiTurnActionSchema), async (c) => {
1118
- const ctx = await resolveContext(c);
1119
- if (ctx instanceof Response) return ctx;
1120
- const conversation = await loadConversation(c, ctx);
1121
- if (!conversation) return notFound(c);
1122
- const turn = await loadTurn(conversation, c.req.param("turnId"));
1123
- const callId = c.req.param("callId");
1124
- if (!turn || !callId) return notFound(c);
1125
- const result = await submitAiTurnAction({
1126
- conversationId: conversation.id,
1127
- turnId: turn.id,
1128
- callId,
1129
- action: c.req.valid("json"),
1130
- decidedBy: {
1131
- userId: ctx.ownerUserId,
1132
- via: approvalDecisionChannel({ credentialKind: c.get("credentialKind"), actor: ctx.actor }),
1133
- },
1134
- toolApprovalContext: ctx.toolApprovalContext,
1135
- signedInSession: ctx.signedInSession,
1136
- });
1137
- if (!result.ok) return toAiActionFailureResponse(c, result);
1138
- return respond(c, ok({ ok: true }));
1139
- })
1135
+ .post(
1136
+ "/conversations/:conversationId/turns/:turnId/actions/:callId",
1137
+ bodyLimit({ maxSize: AI_CLIENT_TOOL_ACTION_MAX_BYTES }),
1138
+ v("json", AiTurnActionSchema),
1139
+ async (c) => {
1140
+ const ctx = await resolveContext(c);
1141
+ if (ctx instanceof Response) return ctx;
1142
+ const conversation = await loadConversation(c, ctx);
1143
+ if (!conversation) return notFound(c);
1144
+ const turn = await loadTurn(conversation, c.req.param("turnId"));
1145
+ const callId = c.req.param("callId");
1146
+ if (!turn || !callId) return notFound(c);
1147
+ const action = c.req.valid("json");
1148
+ if (action.type === "tool_result") {
1149
+ const pending = await aiConversations.getPendingTurnAction({ conversationId: conversation.id, turnId: turn.id, callId });
1150
+ const schema = pending?.kind === "client_tool" ? aiClientToolOutputSchemas.get(pending.name) : undefined;
1151
+ if (schema) {
1152
+ const parsed = schema.safeParse(action.result);
1153
+ if (!parsed.success)
1154
+ return respond(c, fail(err.badInput(`Invalid ${pending?.name} result: ${parsed.error.issues[0]?.message}`)));
1155
+ action.result = parsed.data;
1156
+ }
1157
+ }
1158
+ const result = await submitAiTurnAction({
1159
+ conversationId: conversation.id,
1160
+ turnId: turn.id,
1161
+ callId,
1162
+ action,
1163
+ decidedBy: {
1164
+ userId: ctx.ownerUserId,
1165
+ via: approvalDecisionChannel({
1166
+ credentialKind: c.get("credentialKind"),
1167
+ actor: ctx.actor,
1168
+ client: c.req.header(APPROVAL_CLIENT_HEADER),
1169
+ }),
1170
+ },
1171
+ toolApprovalContext: ctx.toolApprovalContext,
1172
+ signedInSession: ctx.signedInSession,
1173
+ });
1174
+ if (!result.ok) return toAiActionFailureResponse(c, result);
1175
+ return respond(c, ok({ ok: true }));
1176
+ },
1177
+ )
1140
1178
  .get("/conversations/:conversationId/pending-actions/:turnId", async (c) => {
1141
1179
  const ctx = await resolveContext(c);
1142
1180
  if (ctx instanceof Response) return ctx;
@@ -1353,6 +1391,7 @@ export const aiRoutes = (() => {
1353
1391
  "Content-Type": stored.mediaType || "application/octet-stream",
1354
1392
  "Content-Disposition": `attachment; filename="${filename}"`,
1355
1393
  "Content-Length": String(stored.size),
1394
+ "X-Cloud-Ai-File-Origin": stored.origin,
1356
1395
  "Cache-Control": "private, no-store",
1357
1396
  });
1358
1397
  })
package/src/ai/runtime.ts CHANGED
@@ -11,8 +11,9 @@ import { coreSettings } from "../services/settings/api";
11
11
  import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
12
12
  import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
13
13
  import { startAiDictationRuntime } from "./dictation-runtime";
14
- import { AiTurnExecutor } from "./executor";
14
+ import { AiTurnExecutor, type ExecutorConfig } from "./executor";
15
15
  import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
16
+ import type { AiLocalClient, AiLocalContext } from "./local-tool-contracts";
16
17
  import { drainQueuedMessages } from "./message-queue";
17
18
  import { AI_TURN_LEASE_MS } from "./protocol";
18
19
  import { aiQuotas } from "./quotas";
@@ -105,6 +106,8 @@ export type SubmitAiChatTurnInput = {
105
106
  systemPrompt?: string;
106
107
  project?: AiChatTurnRunConfig["project"];
107
108
  clientToolIds?: AiClientToolId[];
109
+ client?: AiLocalClient;
110
+ localContext?: AiLocalContext;
108
111
  toolSource?: AiTurnToolSource;
109
112
  canInspectAttachedImages?: boolean;
110
113
  toolApprovalContext?: AiToolApprovalContext;
@@ -119,6 +122,7 @@ export type SubmitAiChatTurnInput = {
119
122
  };
120
123
 
121
124
  export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
125
+ if (input.localContext && !input.client) throw new Error("Local context requires a local client.");
122
126
  if (input.clientToolIds?.length && input.toolSource?.kind !== "default") {
123
127
  throw new Error("Optional client tools require the default tool source.");
124
128
  }
@@ -170,6 +174,8 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
170
174
  files,
171
175
  canInspectAttachedImages,
172
176
  clientToolIds: input.clientToolIds,
177
+ client: input.client,
178
+ localContext: input.localContext,
173
179
  toolSource: input.toolSource ?? { kind: "none" },
174
180
  toolApprovalContext: input.toolApprovalContext,
175
181
  };
@@ -324,11 +330,11 @@ export const submitAiTurnAction = async (input: {
324
330
  turnId: string;
325
331
  callId: string;
326
332
  action: AiTurnActionInput;
327
- decidedBy: { userId: string; via: ApprovalDecisionChannel };
333
+ decidedBy: { userId: string; via: ApprovalDecisionChannel; deviceId?: string };
328
334
  toolApprovalContext?: AiToolApprovalContext;
329
335
  /** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
330
336
  signedInSession?: boolean;
331
- }): Promise<{ ok: true } | { ok: false; status: 400 | 404 | 409; message: string }> => {
337
+ }): Promise<{ ok: true } | { ok: false; status: 400 | 403 | 404 | 409; message: string }> => {
332
338
  const pending = await aiConversations.getPendingTurnAction(input);
333
339
  if (!pending) return { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
334
340
  if (pending.status === "resolved") {
@@ -365,9 +371,11 @@ export const submitAiTurnAction = async (input: {
365
371
  if (pending.approvalId) {
366
372
  const decision = await approvals.decide(pending.approvalId, { approved: input.action.approved }, input.decidedBy);
367
373
  if (!decision.ok)
368
- return decision.code === "ALREADY_DECIDED"
369
- ? { ok: false, status: 409, message: "AI action was already resolved with a different response." }
370
- : { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
374
+ return decision.code === "REJECT_ONLY"
375
+ ? { ok: false, status: 403, message: "Approve this request in Cloud. An editor can only reject it." }
376
+ : decision.code === "ALREADY_DECIDED"
377
+ ? { ok: false, status: 409, message: "AI action was already resolved with a different response." }
378
+ : { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
371
379
  }
372
380
  if (remember && input.toolApprovalContext) {
373
381
  const toolName = pending.rememberToolName ?? pending.name;
@@ -425,11 +433,14 @@ export const submitAiTurnAction = async (input: {
425
433
  // Worker + sweep lifecycle
426
434
  // ---------------------------------------------------------------------------
427
435
 
436
+ type ApprovalRequestedListener = NonNullable<ExecutorConfig["onApprovalRequested"]>;
437
+
428
438
  const runClaimedTurn = async (
429
439
  job: AiTurnJob,
430
440
  signal: AbortSignal,
431
441
  leaseOwner: string,
432
442
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
443
+ onApprovalRequested?: ApprovalRequestedListener,
433
444
  ): Promise<void> => {
434
445
  const timeoutMinutes = await coreSettings.get<number>("ai.turn_timeout_minutes");
435
446
  const runBudgetMs = Math.max(0, Number(timeoutMinutes ?? 30)) * 60_000;
@@ -458,6 +469,7 @@ const runClaimedTurn = async (
458
469
  heartbeatMs: AI_TURN_HEARTBEAT_MS,
459
470
  enqueueContinuation: (input) => enqueueAiTurn(input).then(() => undefined),
460
471
  onTurnFinalized,
472
+ onApprovalRequested,
461
473
  });
462
474
  await executor.run({ conversationId: job.conversationId, turnId: job.turnId, claim, signal });
463
475
  };
@@ -466,6 +478,7 @@ const processMessage = async (
466
478
  message: Pick<QueueMessage<AiTurnJob>, "messageId" | "data" | "heartbeat">,
467
479
  signal: AbortSignal,
468
480
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
481
+ onApprovalRequested?: ApprovalRequestedListener,
469
482
  ): Promise<void> => {
470
483
  const leaseOwner = `${AI_WORKER_ID}:${message.messageId}:${crypto.randomUUID()}`;
471
484
  let heartbeatFailed = false;
@@ -488,7 +501,7 @@ const processMessage = async (
488
501
  }, AI_TURN_HEARTBEAT_MS);
489
502
  if (typeof touch === "object" && "unref" in touch) touch.unref();
490
503
  try {
491
- await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized);
504
+ await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized, onApprovalRequested);
492
505
  } catch (error) {
493
506
  log.error("AI turn worker error", {
494
507
  conversationId: message.data.conversationId,
@@ -550,6 +563,7 @@ export const sweepAiRuntime = async (onTurnFinalized?: (event: AiTurnFinalizedEv
550
563
 
551
564
  type AiRuntimeState = {
552
565
  listeners: Set<(event: AiTurnFinalizedEvent) => Promise<void>>;
566
+ approvalListeners: Set<ApprovalRequestedListener>;
553
567
  stop: () => void;
554
568
  };
555
569
 
@@ -557,19 +571,29 @@ let running: AiRuntimeState | null = null;
557
571
  let refCount = 0;
558
572
 
559
573
  export const startAiRuntime = (
560
- input: { concurrency?: number; onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void> } = {},
574
+ input: {
575
+ concurrency?: number;
576
+ onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
577
+ onApprovalRequested?: ApprovalRequestedListener;
578
+ } = {},
561
579
  ): (() => void) => {
562
580
  const listener = input.onTurnFinalized ? (event: AiTurnFinalizedEvent) => input.onTurnFinalized!(event) : null;
581
+ const approvalListener: ApprovalRequestedListener | null = input.onApprovalRequested
582
+ ? (event) => input.onApprovalRequested!(event)
583
+ : null;
563
584
  if (running) {
564
585
  refCount += 1;
565
586
  if (listener) running.listeners.add(listener);
566
- return releaseAiRuntime(listener);
587
+ if (approvalListener) running.approvalListeners.add(approvalListener);
588
+ return releaseAiRuntime(listener, approvalListener);
567
589
  }
568
590
 
569
591
  const concurrency = Math.min(Math.max(Math.floor(input.concurrency ?? AI_TURN_WORKER_CONCURRENCY), 1), 64);
570
592
  const controller = new AbortController();
571
593
  const listeners = new Set<(event: AiTurnFinalizedEvent) => Promise<void>>();
572
594
  if (listener) listeners.add(listener);
595
+ const approvalListeners = new Set<ApprovalRequestedListener>();
596
+ if (approvalListener) approvalListeners.add(approvalListener);
573
597
  refCount = 1;
574
598
  const dispatchTurnFinalized = async (event: AiTurnFinalizedEvent): Promise<void> => {
575
599
  const results = await Promise.allSettled([...listeners].map((notify) => notify(event)));
@@ -577,12 +601,18 @@ export const startAiRuntime = (
577
601
  if (errors.length > 0) throw new AggregateError(errors, `${errors.length} AI turn finalized listener(s) failed`);
578
602
  };
579
603
 
604
+ const dispatchApprovalRequested: ApprovalRequestedListener = async (event) => {
605
+ const results = await Promise.allSettled([...approvalListeners].map((notify) => notify(event)));
606
+ const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected").map((result) => result.reason);
607
+ if (errors.length) throw new AggregateError(errors, `${errors.length} AI approval requested listener(s) failed`);
608
+ };
609
+
580
610
  void superviseRuntimeTask({
581
611
  name: "AI turn worker",
582
612
  signal: controller.signal,
583
613
  run: async (signal) => {
584
614
  const worker = await aiTurnQueue().process({ concurrency, signal }, (message) =>
585
- processMessage(message, message.signal, dispatchTurnFinalized),
615
+ processMessage(message, message.signal, dispatchTurnFinalized, dispatchApprovalRequested),
586
616
  );
587
617
  try {
588
618
  await new Promise<void>((resolve) => {
@@ -612,22 +642,27 @@ export const startAiRuntime = (
612
642
  const stopDictations = startAiDictationRuntime();
613
643
  const state: AiRuntimeState = {
614
644
  listeners,
645
+ approvalListeners,
615
646
  stop: () => {
616
647
  if (running !== state) return;
617
648
  controller.abort();
618
649
  stopDictations();
619
650
  clearInterval(sweepTimer);
620
651
  listeners.clear();
652
+ approvalListeners.clear();
621
653
  running = null;
622
654
  refCount = 0;
623
655
  },
624
656
  };
625
657
  running = state;
626
658
  log.info("AI runtime started", { workerId: AI_WORKER_ID, concurrency });
627
- return releaseAiRuntime(listener);
659
+ return releaseAiRuntime(listener, approvalListener);
628
660
  };
629
661
 
630
- const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<void>) | null): (() => void) => {
662
+ const releaseAiRuntime = (
663
+ listener: ((event: AiTurnFinalizedEvent) => Promise<void>) | null,
664
+ approvalListener: ApprovalRequestedListener | null,
665
+ ): (() => void) => {
631
666
  let released = false;
632
667
  return () => {
633
668
  if (released) return;
@@ -635,6 +670,7 @@ const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<vo
635
670
  const state = running;
636
671
  if (!state) return;
637
672
  if (listener) state.listeners.delete(listener);
673
+ if (approvalListener) state.approvalListeners.delete(approvalListener);
638
674
  refCount = Math.max(0, refCount - 1);
639
675
  if (refCount === 0) state.stop();
640
676
  };
@@ -315,6 +315,7 @@ export const resolveAiSettingsStateFromRaw = async (input: {
315
315
  visionModelId?: string;
316
316
  profilesJson: string;
317
317
  globalInstructions?: string;
318
+ localClientInstructions?: string;
318
319
  compactionInstructions?: string;
319
320
  maxToolResultChars?: unknown;
320
321
  firecrawlApiKey?: string;
@@ -327,6 +328,7 @@ export const resolveAiSettingsStateFromRaw = async (input: {
327
328
  defaultModelId: input.defaultModelId ?? "",
328
329
  visionModelId: input.visionModelId ?? "",
329
330
  globalInstructions: input.globalInstructions ?? "",
331
+ localClientInstructions: input.localClientInstructions ?? "",
330
332
  compactionInstructions: input.compactionInstructions ?? "",
331
333
  maxToolResultChars: normalizeMaxToolResultChars(input.maxToolResultChars),
332
334
  firecrawlConfigured: Boolean(input.firecrawlApiKey?.trim()),
@@ -417,6 +419,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
417
419
  visionModelId,
418
420
  profilesJson,
419
421
  globalInstructions,
422
+ localClientInstructions,
420
423
  compactionInstructions,
421
424
  maxToolResultChars,
422
425
  firecrawlApiKey,
@@ -426,6 +429,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
426
429
  coreSettings.get<string>("ai.vision_model_id"),
427
430
  coreSettings.get<string>("ai.model_profiles_json"),
428
431
  coreSettings.get<string>("ai.global_instructions"),
432
+ coreSettings.get<string>("ai.local_client_instructions"),
429
433
  coreSettings.get<string>("ai.compaction_instructions"),
430
434
  coreSettings.get<number>("ai.max_tool_result_chars"),
431
435
  coreSettings.get<string>(AI_FIRECRAWL_API_KEY_SETTING_KEY),
@@ -446,6 +450,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
446
450
  visionModelId: visionModelId ?? "",
447
451
  profilesJson: profilesJson ?? "[]",
448
452
  globalInstructions: globalInstructions ?? "",
453
+ localClientInstructions: localClientInstructions ?? "",
449
454
  compactionInstructions: compactionInstructions ?? "",
450
455
  maxToolResultChars,
451
456
  firecrawlApiKey: firecrawlApiKey ?? "",
@@ -4,9 +4,18 @@ import type { AiSkillSummary } from "./skills";
4
4
  export const AI_SKILL_CATALOG_MAX_CHARS = 8_000;
5
5
  const AI_SKILL_CATALOG_MIN_CHARS = AI_SKILL_NAME_MAX_CHARS + AI_SKILL_DESCRIPTION_MAX_CHARS + 8;
6
6
 
7
- export type AiSkillCatalogEntry = Pick<AiSkillSummary, "name" | "description">;
7
+ export type AiSkillCatalogEntry = Pick<AiSkillSummary, "name" | "description"> & { source?: "local"; manualOnly?: boolean };
8
8
 
9
- const catalogLine = (skill: AiSkillCatalogEntry): string => `- ${skill.name}: ${skill.description.replace(/\s+/g, " ").trim()}`;
9
+ export const mergeAiSkillCatalog = (
10
+ cloud: readonly AiSkillCatalogEntry[],
11
+ local: readonly AiSkillCatalogEntry[],
12
+ ): AiSkillCatalogEntry[] => {
13
+ const localNames = new Set(local.map((skill) => skill.name));
14
+ return [...cloud.filter((skill) => !skill.manualOnly && !localNames.has(skill.name)), ...local.filter((skill) => !skill.manualOnly)];
15
+ };
16
+
17
+ const catalogLine = (skill: AiSkillCatalogEntry): string =>
18
+ `- ${skill.name}${skill.source === "local" ? " [local]" : ""}: ${skill.description.replace(/\s+/g, " ").trim()}`;
10
19
 
11
20
  const terms = (value: string): string[] => [
12
21
  ...new Set(
@@ -30,7 +39,13 @@ const relevance = (skill: AiSkillCatalogEntry, query: string): number => {
30
39
  return score;
31
40
  };
32
41
 
33
- const ranked = (skills: readonly AiSkillCatalogEntry[], query: string): AiSkillCatalogEntry[] =>
42
+ export const searchAiSkillCatalog = (skills: readonly AiSkillCatalogEntry[], query: string): AiSkillCatalogEntry[] =>
43
+ rankAiSkillCatalog(
44
+ skills.filter((skill) => relevance(skill, query) > 0),
45
+ query,
46
+ );
47
+
48
+ export const rankAiSkillCatalog = (skills: readonly AiSkillCatalogEntry[], query: string): AiSkillCatalogEntry[] =>
34
49
  skills
35
50
  .map((skill, index) => ({ skill, index, score: relevance(skill, query) }))
36
51
  .sort((left, right) => right.score - left.score || left.index - right.index)
@@ -46,12 +61,12 @@ export const selectAiSkillCatalog = (
46
61
  AI_SKILL_CATALOG_MAX_CHARS,
47
62
  Math.max(AI_SKILL_CATALOG_MIN_CHARS, contextWindow > 0 ? Math.floor(contextWindow * 0.02 * 4) : AI_SKILL_CATALOG_MAX_CHARS),
48
63
  );
49
- const normalized = skills.map((skill) => ({ name: skill.name, description: skill.description.replace(/\s+/g, " ").trim() }));
64
+ const normalized = skills.map((skill) => ({ ...skill, description: skill.description.replace(/\s+/g, " ").trim() }));
50
65
  if (normalized.map(catalogLine).join("\n").length <= budget) return { skills: normalized, omitted: 0 };
51
66
 
52
67
  const selected: AiSkillCatalogEntry[] = [];
53
68
  let used = 0;
54
- for (const skill of ranked(normalized, query)) {
69
+ for (const skill of rankAiSkillCatalog(normalized, query)) {
55
70
  const size = catalogLine(skill).length + (selected.length ? 1 : 0);
56
71
  if (used + size > budget) continue;
57
72
  selected.push(skill);
@@ -1,4 +1,5 @@
1
1
  import { parseDocument, stringify } from "yaml";
2
+ import { AI_SKILL_FILE_MOUNT } from "./file-mount";
2
3
 
3
4
  export const AI_SKILL_NAME_MAX_CHARS = 64;
4
5
  export const AI_SKILL_DESCRIPTION_MAX_CHARS = 1024;
@@ -26,7 +27,11 @@ export type AiSkillDocument = {
26
27
  extraFrontmatter: AiSkillExtraFrontmatter;
27
28
  };
28
29
 
29
- const OPTIONAL_FRONTMATTER_FIELDS = new Set(["license", "compatibility", "metadata", "allowed-tools"]);
30
+ export const isAiSkillManualOnly = (extraFrontmatter: AiSkillExtraFrontmatter): boolean =>
31
+ extraFrontmatter["disable-model-invocation"] === true;
32
+
33
+ export const mountAiSkillInstructionLinks = (name: string, instructions: string): string =>
34
+ instructions.replace(/\]\((references\/[a-z0-9][a-z0-9._-]*\.md)\)/g, (_, path: string) => `](${AI_SKILL_FILE_MOUNT}/${name}/${path})`);
30
35
 
31
36
  const assertJsonValue = (value: unknown, path: string): void => {
32
37
  if (value === null || typeof value === "string" || typeof value === "boolean") return;
@@ -35,7 +40,7 @@ const assertJsonValue = (value: unknown, path: string): void => {
35
40
  value.forEach((item, index) => assertJsonValue(item, `${path}[${index}]`));
36
41
  return;
37
42
  }
38
- if (typeof value === "object") {
43
+ if (typeof value === "object" && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
39
44
  for (const [key, child] of Object.entries(value)) assertJsonValue(child, `${path}.${key}`);
40
45
  return;
41
46
  }
@@ -73,8 +78,8 @@ export const validateAiSkillInstructions = (value: string): string => {
73
78
 
74
79
  export const validateAiSkillExtraFrontmatter = (value: AiSkillExtraFrontmatter = {}): AiSkillExtraFrontmatter => {
75
80
  for (const key of Object.keys(value)) {
76
- if (!OPTIONAL_FRONTMATTER_FIELDS.has(key)) {
77
- throw new Error(`Unsupported SKILL.md frontmatter field "${key}".`);
81
+ if (key === "name" || key === "description") {
82
+ throw new Error(`SKILL.md frontmatter field "${key}" must not appear in extraFrontmatter; it would collide on export.`);
78
83
  }
79
84
  }
80
85
  assertJsonValue(value, "Skill frontmatter");
@@ -109,7 +114,8 @@ export const validateAiSkillReferences = (references: readonly AiSkillReferenceI
109
114
  });
110
115
  };
111
116
 
112
- export const parseAiSkillMarkdown = (source: string): AiSkillDocument => {
117
+ /** Split SKILL.md into its frontmatter fields and body without validating either; local clients apply their own rules. */
118
+ export const splitAiSkillMarkdown = (source: string): { fields: Record<string, unknown>; body: string } => {
113
119
  const normalized = source.replace(/\r\n/g, "\n");
114
120
  if (!normalized.startsWith("---\n")) throw new Error("SKILL.md must start with YAML frontmatter.");
115
121
  const end = normalized.indexOf("\n---\n", 4);
@@ -120,7 +126,11 @@ export const parseAiSkillMarkdown = (source: string): AiSkillDocument => {
120
126
  if (!frontmatter || typeof frontmatter !== "object" || Array.isArray(frontmatter)) {
121
127
  throw new Error("SKILL.md frontmatter must be a YAML object.");
122
128
  }
123
- const fields = { ...(frontmatter as Record<string, unknown>) };
129
+ return { fields: { ...(frontmatter as Record<string, unknown>) }, body: normalized.slice(end + 5) };
130
+ };
131
+
132
+ export const parseAiSkillMarkdown = (source: string): AiSkillDocument => {
133
+ const { fields, body } = splitAiSkillMarkdown(source);
124
134
  if (typeof fields.name !== "string") throw new Error("SKILL.md frontmatter requires a string name.");
125
135
  if (typeof fields.description !== "string") throw new Error("SKILL.md frontmatter requires a string description.");
126
136
  const name = validateAiSkillName(fields.name);
@@ -130,7 +140,7 @@ export const parseAiSkillMarkdown = (source: string): AiSkillDocument => {
130
140
  return {
131
141
  name,
132
142
  description,
133
- instructions: validateAiSkillInstructions(normalized.slice(end + 5)),
143
+ instructions: validateAiSkillInstructions(body),
134
144
  extraFrontmatter: validateAiSkillExtraFrontmatter(fields),
135
145
  };
136
146
  };