@k2b/cloud 0.30.0 → 0.32.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 (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
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,
@@ -18,17 +18,18 @@ import {
18
18
  v,
19
19
  } from "../server";
20
20
  import { isRequestCredentialCurrent } from "../server/middleware/auth";
21
+ import { approvalDecisionChannel } from "../services/approvals";
21
22
  import { ImageMetadataError } from "../services/image-metadata";
22
23
  import { logger } from "../services/logging";
23
24
  import { coreSettings } from "../services/settings/api";
24
25
  import { readThemeFromCookieHeader } from "../shared/theme";
25
- import type { AiToolApprovalContext } from "./approvals";
26
26
  import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
27
27
  import { AI_AUDIO_MAX_BYTES } from "./audio-format";
28
28
  import { buildAiCapabilityCatalog } from "./capabilities";
29
29
  import { aiChatMessages } from "./chat/messages";
30
30
  import { getAiChatQuotas } from "./chat-quotas";
31
- 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";
32
33
  import { enqueueAiDictation } from "./dictation-runtime";
33
34
  import { AiDictationConflict, AiDictationListSchema, AiDictationStartSchema, aiDictations } from "./dictations";
34
35
  import { aiProjectFilePathFromMount } from "./file-mount";
@@ -48,6 +49,7 @@ import {
48
49
  toAiActionFailureResponse,
49
50
  toAiErrorResponse,
50
51
  } from "./http";
52
+ import { AI_LOCAL_TOOL_NAMES, AiLocalClientSchema } from "./local-tool-contracts";
51
53
  import { aiMaintenanceJobs } from "./maintenance";
52
54
  import { AI_MEMORY_CONTENT_MAX_CHARS, aiMemories } from "./memories";
53
55
  import { aiMemoryLearningRuns } from "./memory-learning-runs";
@@ -60,10 +62,10 @@ import {
60
62
  queuedMessageAccepted,
61
63
  updateQueuedMessage,
62
64
  } from "./message-queue";
63
- import { personalAiModelPolicy } from "./personal-agent";
64
65
  import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
65
66
  import { aiProjects } from "./projects";
66
67
  import { projectPublicAiStoredMessages, publicAiStoredMessages } from "./public-projection";
68
+ import { type AiChatRequestContext, resolveAiChatRequestContext as resolveContext } from "./request-context";
67
69
  import { aiResourceMarker } from "./resource-markers";
68
70
  import { isConversationResourceCursor } from "./resource-refs";
69
71
  import {
@@ -85,29 +87,7 @@ import { aiConversations } from "./store";
85
87
  import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
86
88
  import { composeAiSystemPrompt } from "./system-prompt";
87
89
  import { aiToolPromptHints } from "./tools";
88
- import type { AiConversation, AiModelPolicy, AiTurn } from "./types";
89
-
90
- /** Everything a resolved, authorized request needs to run against the shared runtime. */
91
- type AiChatRequestContext = {
92
- actor: RequestActor;
93
- ownerUserId: string;
94
- modelPolicy: AiModelPolicy;
95
- toolApprovalContext: AiToolApprovalContext;
96
- };
97
-
98
- const actorUser = (actor: RequestActor) => (actor.kind === "user" ? actor.user : actor.delegatedUser);
99
-
100
- const resolveContext = async (c: Context<AuthContext>): Promise<AiChatRequestContext | ApiErrorResponse> => {
101
- const actor = c.get("actor");
102
- const user = actorUser(actor);
103
- if (!user) return respond(c, fail(err.forbidden("AI conversations require a user-backed actor.")));
104
- return {
105
- actor,
106
- ownerUserId: user.id,
107
- modelPolicy: personalAiModelPolicy,
108
- toolApprovalContext: { actorUserId: user.id },
109
- };
110
- };
90
+ import type { AiConversation, AiTurn } from "./types";
111
91
 
112
92
  const retryInstruction = (mode: "retry" | "details" | "concise"): string | null => {
113
93
  if (mode === "details") return "Answer the user's request again with more detail and specificity.";
@@ -326,7 +306,11 @@ export const aiRoutes = (() => {
326
306
  .use(rateLimit())
327
307
  .use("*", auth.requireRole("authenticated"))
328
308
  .get("/status", async (c) => {
329
- 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 } }));
330
314
  })
331
315
  .get("/quotas", async (c) => {
332
316
  c.header("Cache-Control", "no-store");
@@ -402,6 +386,21 @@ export const aiRoutes = (() => {
402
386
  const user = aiActorUser(ctx.actor);
403
387
  if (!user) return respond(c, fail(err.forbidden("AI preferences require a user context.")));
404
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;
405
404
  const prefs = await aiUserPrefs.get(user.id);
406
405
  const memoryEnabled = Boolean(prefs?.memoryEnabled);
407
406
  const memory = memoryEnabled ? await aiMemories.selectHot(user.id, "") : null;
@@ -417,13 +416,18 @@ export const aiRoutes = (() => {
417
416
  }
418
417
  const toolsSupported = Boolean(previewProfile?.capabilities.includes("tools"));
419
418
  const availableSkills = toolsSupported ? (await aiSkills.list(c.get("accessSubject"))).filter((skill) => skill.enabled) : [];
420
- 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
+ );
421
424
  const tools = toolsSupported
422
425
  ? [
423
426
  ...(await createConfiguredDefaultCloudAiTools({
424
427
  accessSubject: c.get("accessSubject"),
425
428
  allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
426
429
  })),
430
+ ...(client?.localTools === "ask" ? [createCloudAiLocalBashTool(), ...createCloudAiLocalTools()] : []),
427
431
  ...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
428
432
  ...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
429
433
  ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
@@ -434,6 +438,9 @@ export const aiRoutes = (() => {
434
438
  const promptLocale = getLocale(c);
435
439
  const prompt = composeAiSystemPrompt({
436
440
  globalInstructions: state.globalInstructions,
441
+ localClient: client
442
+ ? { client, cloudUrl: (await coreSettings.get<string>("app.url")) || "", instructions: state.localClientInstructions }
443
+ : undefined,
437
444
  user,
438
445
  memoryEnabled,
439
446
  memoryToolEnabled,
@@ -446,7 +453,7 @@ export const aiRoutes = (() => {
446
453
  memory: memory?.text,
447
454
  timeZone,
448
455
  locale: promptLocale,
449
- skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
456
+ skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator" && !skill.manualOnly),
450
457
  });
451
458
  return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
452
459
  })
@@ -890,6 +897,7 @@ export const aiRoutes = (() => {
890
897
  try {
891
898
  const submission = {
892
899
  assistantChat: true as const,
900
+ ...(ctx.signedInSession ? { signedInSession: true as const } : {}),
893
901
  conversationId: conversation.id,
894
902
  chatId: conversation.shortId,
895
903
  input,
@@ -902,6 +910,8 @@ export const aiRoutes = (() => {
902
910
  modelPolicy: ctx.modelPolicy,
903
911
  project: project ?? undefined,
904
912
  clientToolIds: body.clientToolIds,
913
+ client: body.client,
914
+ localContext: body.localContext,
905
915
  toolSource: { kind: "default" as const, appTools: true },
906
916
  toolApprovalContext: ctx.toolApprovalContext,
907
917
  expectedDraftRevision: body.draftRevision,
@@ -999,6 +1009,7 @@ export const aiRoutes = (() => {
999
1009
  }
1000
1010
  const result = await submitAiChatTurn({
1001
1011
  assistantChat: true,
1012
+ ...(ctx.signedInSession ? { signedInSession: true as const } : {}),
1002
1013
  conversationId: conversation.id,
1003
1014
  chatId: conversation.shortId,
1004
1015
  input,
@@ -1016,7 +1027,20 @@ export const aiRoutes = (() => {
1016
1027
  truncateFromSeq: target.seq,
1017
1028
  fileSnapshot: originalRunConfig?.kind !== "compact" ? originalRunConfig?.files : undefined,
1018
1029
  retrySourceTurnId: target.loopId ?? undefined,
1019
- 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,
1020
1044
  });
1021
1045
  rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
1022
1046
  return respond(
@@ -1108,24 +1132,49 @@ export const aiRoutes = (() => {
1108
1132
  await abortAiTurn({ conversationId: conversation.id, turnId: turn.id });
1109
1133
  return respond(c, ok({ ok: true }));
1110
1134
  })
1111
- .post("/conversations/:conversationId/turns/:turnId/actions/:callId", v("json", AiTurnActionSchema), async (c) => {
1112
- const ctx = await resolveContext(c);
1113
- if (ctx instanceof Response) return ctx;
1114
- const conversation = await loadConversation(c, ctx);
1115
- if (!conversation) return notFound(c);
1116
- const turn = await loadTurn(conversation, c.req.param("turnId"));
1117
- const callId = c.req.param("callId");
1118
- if (!turn || !callId) return notFound(c);
1119
- const result = await submitAiTurnAction({
1120
- conversationId: conversation.id,
1121
- turnId: turn.id,
1122
- callId,
1123
- action: c.req.valid("json"),
1124
- toolApprovalContext: ctx.toolApprovalContext,
1125
- });
1126
- if (!result.ok) return toAiActionFailureResponse(c, result);
1127
- return respond(c, ok({ ok: true }));
1128
- })
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
+ )
1129
1178
  .get("/conversations/:conversationId/pending-actions/:turnId", async (c) => {
1130
1179
  const ctx = await resolveContext(c);
1131
1180
  if (ctx instanceof Response) return ctx;
@@ -1342,6 +1391,7 @@ export const aiRoutes = (() => {
1342
1391
  "Content-Type": stored.mediaType || "application/octet-stream",
1343
1392
  "Content-Disposition": `attachment; filename="${filename}"`,
1344
1393
  "Content-Length": String(stored.size),
1394
+ "X-Cloud-Ai-File-Origin": stored.origin,
1345
1395
  "Cache-Control": "private, no-store",
1346
1396
  });
1347
1397
  })
package/src/ai/runtime.ts CHANGED
@@ -4,14 +4,16 @@ import type { QueueMessage } from "@k2b/sync";
4
4
  import { z } from "zod";
5
5
  import { lazySync } from "../_internal/process-sync";
6
6
  import type { RequestActor } from "../server";
7
+ import { type ApprovalDecisionChannel, approvals } from "../services/approvals";
7
8
  import { logger } from "../services/logging";
8
9
  import { superviseRuntimeTask } from "../services/runtime-lifecycle";
9
10
  import { coreSettings } from "../services/settings/api";
10
- import { type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
11
+ import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
11
12
  import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
12
13
  import { startAiDictationRuntime } from "./dictation-runtime";
13
- import { AiTurnExecutor } from "./executor";
14
+ import { AiTurnExecutor, type ExecutorConfig } from "./executor";
14
15
  import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
16
+ import type { AiLocalClient, AiLocalContext } from "./local-tool-contracts";
15
17
  import { drainQueuedMessages } from "./message-queue";
16
18
  import { AI_TURN_LEASE_MS } from "./protocol";
17
19
  import { aiQuotas } from "./quotas";
@@ -87,6 +89,8 @@ export const enqueueExistingAiTurn = (input: AiTurnJob): Promise<unknown> => enq
87
89
  export type SubmitAiChatTurnInput = {
88
90
  /** Only interactive Assistant HTTP handlers set this server-owned marker. */
89
91
  assistantChat?: true;
92
+ /** Only a handler that saw a signed-in browser session sets this server-owned marker; see `AiChatTurnRunConfig`. */
93
+ signedInSession?: true;
90
94
  conversationId: string;
91
95
  /** Stable public ID exposed as runtime context, not instructions. */
92
96
  chatId?: string;
@@ -102,6 +106,8 @@ export type SubmitAiChatTurnInput = {
102
106
  systemPrompt?: string;
103
107
  project?: AiChatTurnRunConfig["project"];
104
108
  clientToolIds?: AiClientToolId[];
109
+ client?: AiLocalClient;
110
+ localContext?: AiLocalContext;
105
111
  toolSource?: AiTurnToolSource;
106
112
  canInspectAttachedImages?: boolean;
107
113
  toolApprovalContext?: AiToolApprovalContext;
@@ -116,6 +122,7 @@ export type SubmitAiChatTurnInput = {
116
122
  };
117
123
 
118
124
  export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
125
+ if (input.localContext && !input.client) throw new Error("Local context requires a local client.");
119
126
  if (input.clientToolIds?.length && input.toolSource?.kind !== "default") {
120
127
  throw new Error("Optional client tools require the default tool source.");
121
128
  }
@@ -153,6 +160,7 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
153
160
  ),
154
161
  ],
155
162
  ...(input.assistantChat ? { assistantChat: true } : {}),
163
+ ...(input.signedInSession ? { signedInSession: true } : {}),
156
164
  input: canonicalInput,
157
165
  chatId: input.chatId,
158
166
  actor: input.actor,
@@ -166,6 +174,8 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
166
174
  files,
167
175
  canInspectAttachedImages,
168
176
  clientToolIds: input.clientToolIds,
177
+ client: input.client,
178
+ localContext: input.localContext,
169
179
  toolSource: input.toolSource ?? { kind: "none" },
170
180
  toolApprovalContext: input.toolApprovalContext,
171
181
  };
@@ -297,7 +307,7 @@ export const abortAiTurn = async (input: { conversationId: string; turnId: strin
297
307
  // ---------------------------------------------------------------------------
298
308
 
299
309
  export const AiTurnActionSchema = z.discriminatedUnion("type", [
300
- z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.literal("always").optional() }),
310
+ z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.enum(["chat", "always"]).optional() }),
301
311
  z.object({ type: z.literal("tool_result"), result: z.unknown() }),
302
312
  ]);
303
313
  export type AiTurnActionInput = z.infer<typeof AiTurnActionSchema>;
@@ -312,7 +322,7 @@ export const listPendingAiTurnActions = async (input: { conversationId: string;
312
322
  const [actions, config] = await Promise.all([aiConversations.listPendingTurnActions(input), aiConversations.getTurnRunConfig(input)]);
313
323
  return aiTurnAllowsRememberedApprovals(config)
314
324
  ? actions
315
- : actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false } : action));
325
+ : actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false, allowChat: false } : action));
316
326
  };
317
327
 
318
328
  export const submitAiTurnAction = async (input: {
@@ -320,8 +330,11 @@ export const submitAiTurnAction = async (input: {
320
330
  turnId: string;
321
331
  callId: string;
322
332
  action: AiTurnActionInput;
333
+ decidedBy: { userId: string; via: ApprovalDecisionChannel; deviceId?: string };
323
334
  toolApprovalContext?: AiToolApprovalContext;
324
- }): Promise<{ ok: true } | { ok: false; status: 400 | 404 | 409; message: string }> => {
335
+ /** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
336
+ signedInSession?: boolean;
337
+ }): Promise<{ ok: true } | { ok: false; status: 400 | 403 | 404 | 409; message: string }> => {
325
338
  const pending = await aiConversations.getPendingTurnAction(input);
326
339
  if (!pending) return { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
327
340
  if (pending.status === "resolved") {
@@ -338,18 +351,52 @@ export const submitAiTurnAction = async (input: {
338
351
 
339
352
  if (input.action.type === "approval_response") {
340
353
  if (pending.kind === "client_tool") return { ok: false, status: 400, message: "Frontend tool requests require a tool result." };
341
- if (input.action.remember === "always") {
354
+ const remember = input.action.remember;
355
+ if (remember) {
342
356
  const config = await aiConversations.getTurnRunConfig(input);
343
- if (!input.action.approved || !pending.allowAlways || !input.toolApprovalContext || !aiTurnAllowsRememberedApprovals(config)) {
357
+ const toolName = pending.rememberToolName ?? pending.name;
358
+ const allowed = remember === "always" ? pending.allowAlways : (pending.allowChat ?? pending.allowAlways);
359
+ if (
360
+ !input.action.approved ||
361
+ !allowed ||
362
+ !input.toolApprovalContext ||
363
+ !aiTurnAllowsRememberedApprovals(config) ||
364
+ // A website is remembered only by a person in a signed-in session: `cld`, an API key, or another delegated
365
+ // credential can approve this one request, but cannot let the next ones through.
366
+ (toolName === AI_WEBSITE_APPROVAL_TOOL && (remember !== "chat" || !input.signedInSession))
367
+ ) {
344
368
  return { ok: false, status: 400, message: "This approval cannot be remembered." };
345
369
  }
346
- await rememberAiToolApproval(input.toolApprovalContext, { toolName: pending.name, approvalScope: pending.approvalScope });
370
+ }
371
+ if (pending.approvalId) {
372
+ const decision = await approvals.decide(pending.approvalId, { approved: input.action.approved }, input.decidedBy);
373
+ if (!decision.ok)
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." };
379
+ }
380
+ if (remember && input.toolApprovalContext) {
381
+ const toolName = pending.rememberToolName ?? pending.name;
382
+ await rememberAiToolApproval(input.toolApprovalContext, {
383
+ toolName,
384
+ approvalScope: pending.approvalScope,
385
+ conversationId: remember === "chat" ? input.conversationId : null,
386
+ });
347
387
  }
348
388
  await aiToolAudit
349
389
  .noteApprovalResolved({
350
390
  turnId: input.turnId,
351
391
  callId: input.callId,
352
- approvalState: input.action.approved ? (input.action.remember === "always" ? "approved_always" : "approved_once") : "rejected",
392
+ decidedVia: input.decidedBy.via,
393
+ approvalState: input.action.approved
394
+ ? remember === "always"
395
+ ? "approved_always"
396
+ : remember === "chat"
397
+ ? "approved_for_chat"
398
+ : "approved_once"
399
+ : "rejected",
353
400
  })
354
401
  .catch(() => undefined);
355
402
 
@@ -386,11 +433,14 @@ export const submitAiTurnAction = async (input: {
386
433
  // Worker + sweep lifecycle
387
434
  // ---------------------------------------------------------------------------
388
435
 
436
+ type ApprovalRequestedListener = NonNullable<ExecutorConfig["onApprovalRequested"]>;
437
+
389
438
  const runClaimedTurn = async (
390
439
  job: AiTurnJob,
391
440
  signal: AbortSignal,
392
441
  leaseOwner: string,
393
442
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
443
+ onApprovalRequested?: ApprovalRequestedListener,
394
444
  ): Promise<void> => {
395
445
  const timeoutMinutes = await coreSettings.get<number>("ai.turn_timeout_minutes");
396
446
  const runBudgetMs = Math.max(0, Number(timeoutMinutes ?? 30)) * 60_000;
@@ -419,6 +469,7 @@ const runClaimedTurn = async (
419
469
  heartbeatMs: AI_TURN_HEARTBEAT_MS,
420
470
  enqueueContinuation: (input) => enqueueAiTurn(input).then(() => undefined),
421
471
  onTurnFinalized,
472
+ onApprovalRequested,
422
473
  });
423
474
  await executor.run({ conversationId: job.conversationId, turnId: job.turnId, claim, signal });
424
475
  };
@@ -427,6 +478,7 @@ const processMessage = async (
427
478
  message: Pick<QueueMessage<AiTurnJob>, "messageId" | "data" | "heartbeat">,
428
479
  signal: AbortSignal,
429
480
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
481
+ onApprovalRequested?: ApprovalRequestedListener,
430
482
  ): Promise<void> => {
431
483
  const leaseOwner = `${AI_WORKER_ID}:${message.messageId}:${crypto.randomUUID()}`;
432
484
  let heartbeatFailed = false;
@@ -449,7 +501,7 @@ const processMessage = async (
449
501
  }, AI_TURN_HEARTBEAT_MS);
450
502
  if (typeof touch === "object" && "unref" in touch) touch.unref();
451
503
  try {
452
- await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized);
504
+ await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized, onApprovalRequested);
453
505
  } catch (error) {
454
506
  log.error("AI turn worker error", {
455
507
  conversationId: message.data.conversationId,
@@ -511,6 +563,7 @@ export const sweepAiRuntime = async (onTurnFinalized?: (event: AiTurnFinalizedEv
511
563
 
512
564
  type AiRuntimeState = {
513
565
  listeners: Set<(event: AiTurnFinalizedEvent) => Promise<void>>;
566
+ approvalListeners: Set<ApprovalRequestedListener>;
514
567
  stop: () => void;
515
568
  };
516
569
 
@@ -518,19 +571,29 @@ let running: AiRuntimeState | null = null;
518
571
  let refCount = 0;
519
572
 
520
573
  export const startAiRuntime = (
521
- input: { concurrency?: number; onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void> } = {},
574
+ input: {
575
+ concurrency?: number;
576
+ onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
577
+ onApprovalRequested?: ApprovalRequestedListener;
578
+ } = {},
522
579
  ): (() => void) => {
523
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;
524
584
  if (running) {
525
585
  refCount += 1;
526
586
  if (listener) running.listeners.add(listener);
527
- return releaseAiRuntime(listener);
587
+ if (approvalListener) running.approvalListeners.add(approvalListener);
588
+ return releaseAiRuntime(listener, approvalListener);
528
589
  }
529
590
 
530
591
  const concurrency = Math.min(Math.max(Math.floor(input.concurrency ?? AI_TURN_WORKER_CONCURRENCY), 1), 64);
531
592
  const controller = new AbortController();
532
593
  const listeners = new Set<(event: AiTurnFinalizedEvent) => Promise<void>>();
533
594
  if (listener) listeners.add(listener);
595
+ const approvalListeners = new Set<ApprovalRequestedListener>();
596
+ if (approvalListener) approvalListeners.add(approvalListener);
534
597
  refCount = 1;
535
598
  const dispatchTurnFinalized = async (event: AiTurnFinalizedEvent): Promise<void> => {
536
599
  const results = await Promise.allSettled([...listeners].map((notify) => notify(event)));
@@ -538,12 +601,18 @@ export const startAiRuntime = (
538
601
  if (errors.length > 0) throw new AggregateError(errors, `${errors.length} AI turn finalized listener(s) failed`);
539
602
  };
540
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
+
541
610
  void superviseRuntimeTask({
542
611
  name: "AI turn worker",
543
612
  signal: controller.signal,
544
613
  run: async (signal) => {
545
614
  const worker = await aiTurnQueue().process({ concurrency, signal }, (message) =>
546
- processMessage(message, message.signal, dispatchTurnFinalized),
615
+ processMessage(message, message.signal, dispatchTurnFinalized, dispatchApprovalRequested),
547
616
  );
548
617
  try {
549
618
  await new Promise<void>((resolve) => {
@@ -573,22 +642,27 @@ export const startAiRuntime = (
573
642
  const stopDictations = startAiDictationRuntime();
574
643
  const state: AiRuntimeState = {
575
644
  listeners,
645
+ approvalListeners,
576
646
  stop: () => {
577
647
  if (running !== state) return;
578
648
  controller.abort();
579
649
  stopDictations();
580
650
  clearInterval(sweepTimer);
581
651
  listeners.clear();
652
+ approvalListeners.clear();
582
653
  running = null;
583
654
  refCount = 0;
584
655
  },
585
656
  };
586
657
  running = state;
587
658
  log.info("AI runtime started", { workerId: AI_WORKER_ID, concurrency });
588
- return releaseAiRuntime(listener);
659
+ return releaseAiRuntime(listener, approvalListener);
589
660
  };
590
661
 
591
- 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) => {
592
666
  let released = false;
593
667
  return () => {
594
668
  if (released) return;
@@ -596,6 +670,7 @@ const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<vo
596
670
  const state = running;
597
671
  if (!state) return;
598
672
  if (listener) state.listeners.delete(listener);
673
+ if (approvalListener) state.approvalListeners.delete(approvalListener);
599
674
  refCount = Math.max(0, refCount - 1);
600
675
  if (refCount === 0) state.stop();
601
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);