@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
@@ -18,6 +18,7 @@ import { runManagedCodeTool } from "./code-runtime-tools";
18
18
  import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
19
19
  import { createCodeSourceTools } from "./code-source-tools";
20
20
  import {
21
+ CLOUD_AI_GROUP_MAX_CHARTS,
21
22
  CloudAiCardInputSchema,
22
23
  CloudAiCardOutputSchema,
23
24
  CloudAiChartInputSchema,
@@ -39,6 +40,22 @@ import {
39
40
  } from "./file-tools";
40
41
  import { createCloudAiWebExtractTool, createCloudAiWebSearchTool, isCloudAiFirecrawlConfigured } from "./firecrawl-tools";
41
42
  import { createCloudAiHtmlToPdfTool } from "./html-pdf-tool";
43
+ import {
44
+ CloudAiLoadLocalSkillInputSchema,
45
+ CloudAiLoadLocalSkillOutputSchema,
46
+ CloudAiLocalDownloadInputSchema,
47
+ CloudAiLocalDownloadOutputSchema,
48
+ CloudAiLocalListInputSchema,
49
+ CloudAiLocalListOutputSchema,
50
+ CloudAiLocalMoveInputSchema,
51
+ CloudAiLocalMoveOutputSchema,
52
+ CloudAiLocalReadInputSchema,
53
+ CloudAiLocalReadOutputSchema,
54
+ CloudAiLocalUploadInputSchema,
55
+ CloudAiLocalUploadOutputSchema,
56
+ CloudAiLocalWriteInputSchema,
57
+ CloudAiLocalWriteOutputSchema,
58
+ } from "./local-tool-contracts";
42
59
  import { createCloudAiMarkdownToPdfTool } from "./markdown-pdf-tool";
43
60
  import { createAiTodoTool } from "./todo-tool";
44
61
  import { defineAiTool } from "./tools";
@@ -72,23 +89,22 @@ export const createCloudAiCardTool = () =>
72
89
  defineAiTool({
73
90
  name: "card",
74
91
  description:
75
- "Render one compact visual highlight card in the chat. Use it only for a single status, metric, KPI, or short result. Use normal markdown for tables, lists, comparisons, and longer explanations. Keep all fields flat; do not pass arrays or nested objects.",
92
+ "Show one highlight figure you already have, such as a status, metric or short result, as a stat tile in the chat. Keep the value to a short figure or status and the caption to one line. Several figures computed in code_run go through cloud.view.stats in the run, not several card calls; tables, lists and longer explanations stay Markdown. Keep all fields flat; do not pass arrays or nested objects.",
76
93
  inputSchema: CloudAiCardInputSchema,
77
94
  outputSchema: CloudAiCardOutputSchema,
78
95
  approval: "never",
79
- promptHint: "show one compact highlight card (metric, KPI, status) — not for tables, lists, or long text.",
96
+ promptHint: "show one highlight figure already known; several computed figures use cloud.view.stats in code_run.",
80
97
  }).clientView();
81
98
 
82
99
  export const createCloudAiChartTool = () =>
83
100
  defineAiTool({
84
101
  name: "chart",
85
- description:
86
- "Show one chart in the chat from data you already have: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge or sparkline. Cloud draws it with the renderer of cloud.chart() in Studio apps and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Aggregate first. Compute derived numbers with calculate or code_run before charting them. Say what the chart shows in title and put the unit, period or source in subtitle. The chart has no state, filters or actions: for interaction show an app in the chat with code_present, and build a saved Studio App only when data must be kept or the app reused. After the chart, state the finding instead of repeating its values.",
102
+ description: `Show one chart in the chat from a few values you already have that did not come from a code run, such as figures from the conversation, a web page or a small capability result: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge, sparkline, boxplot, heatmap, barGauge or stat. Several related measures go into one call as a group of small charts (charts, at most ${CLOUD_AI_GROUP_MAX_CHARTS}); linkX shares the x cursor between line charts with the same kind of x. There is no second y axis: compare measures with different units as a group. Cloud draws it with the renderer of cloud.chart() and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Values computed in code_run never go here: the script shows them itself with cloud.view.chart, cloud.view.group, cloud.view.table, cloud.view.stats, cloud.view.details or cloud.view.explorer, and you present the returned path. Aggregate first. Say what the chart shows in title and put the unit, period or source in subtitle; formats are data ({ style: "percent" } takes fractions). The chart has no state, filters or actions: filters, steps and comparison over computed rows come from cloud.view.explorer in code_run; anything else the person interacts with, such as a calculator, converter or form, is a Studio app built with code_create. After the chart, state the finding instead of repeating its values.`,
87
103
  inputSchema: CloudAiChartInputSchema,
88
104
  outputSchema: CloudAiChartOutputSchema,
89
105
  approval: "never",
90
106
  promptHint:
91
- "show data you already have as a chart in the chat, without code. For interaction use a chat app (code_present); for kept data or reuse, a saved Studio App.",
107
+ "show a few values you already have (not from a code run) as a chart in the chat. Code results use cloud.view.* and present; filters, steps and comparison use cloud.view.explorer in code_run; an app or tool to interact with is a Studio app (code_create).",
92
108
  }).clientView();
93
109
 
94
110
  export const createCloudAiSurveyTool = () =>
@@ -118,14 +134,73 @@ export const createCloudAiLocalBashTool = () =>
118
134
  defineAiTool({
119
135
  name: "local_bash",
120
136
  description:
121
- "Run one Bash command on the user's local CLI computer after the CLI shows the exact command and the user confirms it. Use only when local computer interaction is necessary. The command runs from the CLI's startup directory as the current OS user. Inspect the returned status, exit code, stdout, and stderr before continuing.",
137
+ "Run one Bash command in the workspace folder on the user's computer, as the user's OS account. Use it only when no file or Cloud tool fits; the client may ask the user first, and a run does not prove that the user saw the command.",
122
138
  inputSchema: CloudAiLocalBashInputSchema,
123
139
  outputSchema: CloudAiLocalBashOutputSchema,
124
140
  approval: "never",
125
- promptHint:
126
- "use local_bash only when work on the user's local CLI computer is necessary; every command requires local confirmation and its result must be checked.",
141
+ promptHint: "use local_bash only when no local file tool or Cloud tool fits; check status, exit code, and output.",
127
142
  }).client();
128
143
 
144
+ /** File tools a local client runs in the user's workspace folder; the client applies its path rules and questions. */
145
+ export const createCloudAiLocalTools = () => [
146
+ defineAiTool({
147
+ name: "local_list",
148
+ description:
149
+ "List files and folders in the user's local workspace, optionally recursive or filtered by a case-insensitive glob such as **/*.pdf, in pages of up to 1000 entries with size and modification time. Folders outside the workspace ask the user first.",
150
+ inputSchema: CloudAiLocalListInputSchema,
151
+ outputSchema: CloudAiLocalListOutputSchema,
152
+ approval: "never",
153
+ }).client(),
154
+ defineAiTool({
155
+ name: "local_read",
156
+ description:
157
+ "Read a local text file with line numbers, 16 KiB by default and 64 KiB at most per call; continue with nextOffset. PDFs, images, and office or spreadsheet files are uploaded to this chat instead, and the result names the chat path for read_file or view_image.",
158
+ inputSchema: CloudAiLocalReadInputSchema,
159
+ outputSchema: CloudAiLocalReadOutputSchema,
160
+ approval: "never",
161
+ }).client(),
162
+ defineAiTool({
163
+ name: "local_upload",
164
+ description:
165
+ "Upload up to 64 local files to this chat and return their chat paths, so Cloud tools such as read_file, view_image, and code mode can use them. Uploads stay in the chat until it is deleted; each file can have up to 50 MB.",
166
+ inputSchema: CloudAiLocalUploadInputSchema,
167
+ outputSchema: CloudAiLocalUploadOutputSchema,
168
+ approval: "never",
169
+ }).client(),
170
+ defineAiTool({
171
+ name: "local_download",
172
+ description:
173
+ "Save a chat file, text or binary, into the local workspace and create missing folders. It never overwrites an existing file unless replace is true, which asks the user.",
174
+ inputSchema: CloudAiLocalDownloadInputSchema,
175
+ outputSchema: CloudAiLocalDownloadOutputSchema,
176
+ approval: "never",
177
+ }).client(),
178
+ defineAiTool({
179
+ name: "local_write",
180
+ description:
181
+ "Write a local text file of up to 100 KiB: create makes a new file and fails if one exists, append adds to an existing file, and replace overwrites one. Append and replace ask the user.",
182
+ inputSchema: CloudAiLocalWriteInputSchema,
183
+ outputSchema: CloudAiLocalWriteOutputSchema,
184
+ approval: "never",
185
+ }).client(),
186
+ defineAiTool({
187
+ name: "local_move",
188
+ description:
189
+ "Rename or move up to 64 files or folders inside the workspace in one batch, which the user confirms once as a list. It never overwrites: if a target exists, nothing moves and the result names the conflicts.",
190
+ inputSchema: CloudAiLocalMoveInputSchema,
191
+ outputSchema: CloudAiLocalMoveOutputSchema,
192
+ approval: "never",
193
+ }).client(),
194
+ defineAiTool({
195
+ name: "load_local_skill",
196
+ description:
197
+ "Load a Skill marked [local] in the Skills section by its exact name. Returns its instructions, folder, and file list; read referenced files with local_read.",
198
+ inputSchema: CloudAiLoadLocalSkillInputSchema,
199
+ outputSchema: CloudAiLoadLocalSkillOutputSchema,
200
+ approval: "never",
201
+ }).client(),
202
+ ];
203
+
129
204
  export const createCloudAiCodeTools = () => [
130
205
  defineAiTool({
131
206
  name: "code_secret",
@@ -146,16 +221,17 @@ export const createCloudAiCodeTools = () => [
146
221
  defineAiTool({
147
222
  name: "code_run",
148
223
  promptHint:
149
- "For file analysis, data transformations or combining Cloud data, run a short one-off script with code_run. Load assistant-code-mode for its runtime APIs; no saved app is required.",
224
+ "For file analysis, data transformations or combining Cloud data, run a short one-off script with code_run; its computed views come from cloud.view.* in the script and present. Load assistant-code-mode for its runtime APIs; no saved app is required.",
150
225
  description:
151
- "Run one-off script code OR a saved script resource in the isolated worker. Optional resourceId binds one-off code to existing app data (Manage required), without editing its source. Includes cloud.capabilities.run(name,input) to chain Cloud capabilities in JavaScript; load assistant-code-mode for its runtime APIs. Scripts read explicit chat inputPaths. Returns output, logs and captured files for agent inspection only. Apps with an index.html interface do not run here: show them with code_open or code_present. Runs use real shared and personal data; database writes and capability effects keep normal permissions and approvals.",
226
+ "Run one-off script code OR a saved script resource in the isolated worker. Optional resourceId binds one-off code to existing app data (Manage required), without editing its source. Includes cloud.capabilities.run(name,input) to chain Cloud capabilities in JavaScript; load assistant-code-mode for its runtime APIs. Scripts read explicit chat inputPaths. Returns output, logs, captured files and view references for agent inspection only. Show computed views with cloud.view.chart, cloud.view.group, cloud.view.table, cloud.view.stats, cloud.view.details or cloud.view.explorer in the script, then present({path}) each returned view; never retype run values into chart arguments, files or Markdown. Apps with an index.html interface do not run here: show them with code_open or code_present. Runs use real shared and personal data; database writes and capability effects keep normal permissions and approvals.",
152
227
  inputSchema: CodeRunInput,
153
228
  outputSchema: z.json(),
154
229
  approval: "never",
155
230
  }).server(runManagedCodeTool("code_run")),
156
231
  defineAiTool({
157
232
  name: "code_inspect",
158
- description: "Inspect a script run: status, progress, errors, logs, output and captured files. Use waitMs to wait for background work.",
233
+ description:
234
+ "Inspect a script run: status, progress, errors, logs, output, captured files and view references. Use waitMs to wait for background work.",
159
235
  inputSchema: CodeInspectInput,
160
236
  outputSchema: z.json(),
161
237
  approval: "never",
@@ -170,7 +246,7 @@ export const createCloudAiCodeTools = () => [
170
246
  defineAiTool({
171
247
  name: "code_open",
172
248
  description:
173
- "Show the app beside the chat without starting it; HTML apps require a passing code_check for the current files and tables. For one-off calculations, return the result instead of opening an app.",
249
+ "Show a saved Studio app beside the chat without starting it; HTML apps require a passing code_check for the current files and tables. When the person only wants one calculated result, answer it instead of building an app.",
174
250
  inputSchema: CodeOpenInput,
175
251
  outputSchema: z.json(),
176
252
  approval: "never",
@@ -186,7 +262,7 @@ export const createCloudAiCodeTools = () => [
186
262
  defineAiTool({
187
263
  name: "code_present",
188
264
  description:
189
- "Show an HTML app as a card in this chat after a passing code_check for exactly these files and table definitions. Pass files (index.html plus optional style.css and app.js) for a one-off app without saved data, or the id of a saved app to run it live with its data. The person starts the card with a click. Static problems such as CDN scripts, inline handlers or a missing index.html are rejected before anything is saved. For a reusable app beside the chat use code_open.",
265
+ "Show a saved Studio app as a card in this chat: pass its id after a passing code_check for its current files and tables; it runs live with its data, and the person starts the card with a click. Every app, calculator, converter, form or tool the person asks for is a Studio app created with code_create; code_open shows it beside the chat instead. files (index.html plus optional style.css and app.js) instead of id show a throwaway card that is not saved and does not appear in Studio: only when the person explicitly asks for a preview they do not want to keep, after a passing code_check for exactly these files. Static problems such as CDN scripts, inline handlers or a missing index.html are rejected before anything is saved.",
190
266
  inputSchema: CodePresentInput,
191
267
  outputSchema: z.json(),
192
268
  approval: "never",
@@ -1,4 +1,4 @@
1
- import type { AssistantMessage, CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
1
+ import type { AssistantMessage, CompactEvent, LoopAggregate, Message, 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";
@@ -26,8 +26,21 @@ import { aiChatMessages } from "./chat/messages";
26
26
  import { aiTurnErrorText } from "./chat/turn-error";
27
27
  import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
28
28
  import { createCloudCompactFn } from "./compaction";
29
- import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
29
+ import {
30
+ createCloudAiCodeTools,
31
+ createCloudAiLocalBashTool,
32
+ createCloudAiLocalTools,
33
+ createConfiguredDefaultCloudAiTools,
34
+ } from "./default-tools";
30
35
  import { aiFileStore } from "./files-store";
36
+ import { AI_LOCAL_TOOL_NAMES } from "./local-tool-contracts";
37
+ import {
38
+ countMalformedToolCallRepairs,
39
+ createMalformedToolCallRepair,
40
+ isMalformedToolCallId,
41
+ withMalformedToolCallRepair,
42
+ withMalformedToolCallResults,
43
+ } from "./malformed-tool-call-repair";
31
44
  import { aiMemories } from "./memories";
32
45
  import { createCloudAiMemoryTool } from "./memory-tool";
33
46
  import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
@@ -42,6 +55,7 @@ import {
42
55
  type AiWireEvent,
43
56
  applyWireEventToBlocks,
44
57
  buildBlocksFromMessages,
58
+ canonicalToolName,
45
59
  compactionBlockId,
46
60
  reconcileResolvedTurnActions,
47
61
  steerAppliedBlockId,
@@ -54,7 +68,7 @@ import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
54
68
  import { collectConversationResourceObservations } from "./resource-refs";
55
69
  import { AiRunTimeout } from "./run-timeout";
56
70
  import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
57
- import { selectAiSkillCatalog } from "./skill-catalog";
71
+ import { mergeAiSkillCatalog, selectAiSkillCatalog } from "./skill-catalog";
58
72
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
59
73
  import { aiSkills } from "./skills";
60
74
  import { aiConversations, recordAiWebsiteReceipts } from "./store";
@@ -100,17 +114,18 @@ const AI_COALESCE_MAX_CHARS = 512;
100
114
  const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
101
115
 
102
116
  // Failed generation can contain calls that nessi deliberately never executes or resumes.
117
+ // Cloud's repair calls are skipped too: the live run never counts them.
103
118
  const isToolRound = (message: AssistantMessage): boolean =>
104
119
  message.stopReason !== "error" &&
105
120
  message.stopReason !== "interrupted" &&
106
121
  message.stopReason !== "aborted" &&
107
- message.content.some((block) => block.type === "tool_call");
122
+ message.content.some((block) => block.type === "tool_call" && !isMalformedToolCallId(block.id));
108
123
 
109
124
  const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
110
125
  const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
111
126
  const rounds = messages.flatMap(({ message }) => {
112
127
  if (message.role !== "assistant" || !isToolRound(message)) return [];
113
- const callIds = message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : []));
128
+ const callIds = message.content.flatMap((block) => (block.type === "tool_call" && !isMalformedToolCallId(block.id) ? [block.id] : []));
114
129
  return callIds.length > 0 ? [callIds] : [];
115
130
  });
116
131
  return {
@@ -271,6 +286,7 @@ export type ExecutorConfig = {
271
286
  validateTurn?: typeof validateAiTurnRequest;
272
287
  /** Runs after the durable turn state and final wire event are flushed. */
273
288
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
289
+ onApprovalRequested?: (event: { approvalId: string }) => Promise<void>;
274
290
  /** Waits before retrying a transient provider failure without Retry-After; tests shorten them. */
275
291
  providerRetryDelaysMs?: readonly number[];
276
292
  };
@@ -278,6 +294,29 @@ export type ExecutorConfig = {
278
294
  type ResolvedModel = Awaited<ReturnType<typeof resolveAiModel>>;
279
295
  type ValidatedTurn = { settings: Awaited<ReturnType<typeof validateAiTurnRequest>>["settings"]; resolved: ResolvedModel };
280
296
 
297
+ // One entry per steering text returned to nessi, including repairs that have no user steer to acknowledge.
298
+ const createChatSteering =
299
+ (input: {
300
+ repair: ReturnType<typeof createMalformedToolCallRepair>;
301
+ appliedSteers: (AiTurnSteer | null)[];
302
+ takePendingSteers: () => Promise<AiTurnSteer[]>;
303
+ noteSteering: () => void;
304
+ }) =>
305
+ async ({ signal }: { signal: AbortSignal }) => {
306
+ if (signal.aborted) return undefined;
307
+ // Exhaustion fails before consuming any pending user input.
308
+ const repairText = input.repair.takeSteering();
309
+ const steers = await input.takePendingSteers();
310
+ input.appliedSteers.push(...steers);
311
+ if (steers.length > 0) input.noteSteering();
312
+ const texts = steers.map((steer) => steer.text);
313
+ if (repairText) {
314
+ texts.push(repairText);
315
+ input.appliedSteers.push(null);
316
+ }
317
+ return texts.length > 0 ? texts : undefined;
318
+ };
319
+
281
320
  // ---------------------------------------------------------------------------
282
321
  // Baseline rebuild — reconstruct the full active-turn view from persisted rounds
283
322
  // ---------------------------------------------------------------------------
@@ -465,6 +504,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
465
504
  name,
466
505
  args: "args" in patch ? patch.args : existing?.args,
467
506
  status: patch.status ?? existing?.status ?? "running",
507
+ at: patch.at ?? existing?.at,
468
508
  result: "result" in patch ? patch.result : existing?.result,
469
509
  isError: "isError" in patch ? patch.isError : existing?.isError,
470
510
  approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false, allowChat: false } : approval,
@@ -548,6 +588,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
548
588
  setTool(event.callId, {
549
589
  name: event.name,
550
590
  status: rejectedCallIds.has(event.callId) ? "rejected" : event.isError ? "failed" : "completed",
591
+ at: new Date().toISOString(),
551
592
  result: event.result,
552
593
  isError: Boolean(event.isError),
553
594
  clearApproval: true,
@@ -559,7 +600,15 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
559
600
  if (callId && toolBlocks.has(callId)) {
560
601
  const existing = toolBlocks.get(callId);
561
602
  if (existing && existing.status !== "completed" && existing.status !== "failed") {
562
- return [setTool(callId, { status: "failed", result: event.issue.message, isError: true, clearApproval: true })];
603
+ return [
604
+ setTool(callId, {
605
+ status: "failed",
606
+ at: new Date().toISOString(),
607
+ result: event.issue.message,
608
+ isError: true,
609
+ clearApproval: true,
610
+ }),
611
+ ];
563
612
  }
564
613
  }
565
614
  return [];
@@ -573,8 +622,24 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
573
622
  }
574
623
  };
575
624
 
625
+ const storedMessage = (message: Message, at: string): BlockOp[] =>
626
+ message.role === "tool_result" && isMalformedToolCallId(message.callId)
627
+ ? [
628
+ setTool(message.callId, {
629
+ name: canonicalToolName(message.name),
630
+ args: {},
631
+ status: "failed",
632
+ at,
633
+ result: message.result,
634
+ isError: true,
635
+ clearApproval: true,
636
+ }),
637
+ ]
638
+ : [];
639
+
576
640
  return {
577
641
  translate,
642
+ storedMessage,
578
643
  compaction,
579
644
  setFrontendModes,
580
645
  setPresentations,
@@ -614,7 +679,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
614
679
  allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
615
680
  })),
616
681
  // Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
617
- ...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
682
+ ...[createCloudAiLocalBashTool(), ...createCloudAiLocalTools(), ...createCloudAiCodeTools()].filter(
618
683
  (tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
619
684
  ),
620
685
  ]
@@ -866,7 +931,22 @@ export class AiTurnExecutor {
866
931
  const skillSubject =
867
932
  defaultToolSource && resolved.profile.capabilities.includes("tools") ? accessSubjectForActor(material.actor) : null;
868
933
  const availableSkills = skillSubject ? (await aiSkills.list(skillSubject)).filter((skill) => skill.enabled) : [];
869
- const skillCatalog = selectAiSkillCatalog(availableSkills, resolved.provider.contextWindow ?? 0, query);
934
+ const localClient = !config.background && !config.mandate ? config.client : undefined;
935
+ const localContext = localClient ? config.localContext : undefined;
936
+ const localSkills = (localContext?.skills ?? []).map((skill) => ({
937
+ name: skill.name,
938
+ description: skill.description,
939
+ manualOnly: skill.manualOnly,
940
+ source: "local" as const,
941
+ }));
942
+ const skillCatalog = selectAiSkillCatalog(
943
+ mergeAiSkillCatalog(
944
+ availableSkills.map(({ name, description, manualOnly }) => ({ name, description, manualOnly })),
945
+ localSkills,
946
+ ),
947
+ resolved.provider.contextWindow ?? 0,
948
+ query,
949
+ );
870
950
  const projectFiles =
871
951
  project && resolvedProjectId && projectSubject
872
952
  ? {
@@ -895,7 +975,7 @@ export class AiTurnExecutor {
895
975
  ...material.tools,
896
976
  ...(memoryActive ? [createCloudAiMemoryTool(query)] : []),
897
977
  ...(skillSubject && availableSkills.length ? [createCloudAiLoadSkillTool(skillSubject)] : []),
898
- ...(skillSubject && skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject)] : []),
978
+ ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject, localSkills)] : []),
899
979
  ...(config.project && resolvedProjectId && projectSubject
900
980
  ? [
901
981
  createCloudAiSearchProjectTool(resolvedProjectId, projectSubject),
@@ -928,7 +1008,13 @@ export class AiTurnExecutor {
928
1008
  // Built-ins that exist but this turn does not offer: client tools without their client, tools a
929
1009
  // task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
930
1010
  const unofferedTools = [
931
- ...new Set([...sourceToolNames, ...runtimeTools.map((tool) => tool.def.name), ...CODE_RUNTIME_TOOL_NAMES, "local_bash"]),
1011
+ ...new Set([
1012
+ ...sourceToolNames,
1013
+ ...runtimeTools.map((tool) => tool.def.name),
1014
+ ...CODE_RUNTIME_TOOL_NAMES,
1015
+ "local_bash",
1016
+ ...AI_LOCAL_TOOL_NAMES,
1017
+ ]),
932
1018
  ].filter((name) => !offeredToolNames.has(name));
933
1019
  const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
934
1020
  const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
@@ -948,6 +1034,7 @@ export class AiTurnExecutor {
948
1034
  turnId,
949
1035
  reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
950
1036
  reportWebsiteReceipts: (callId: string, receipts: AiWebsiteReceipt[]) => pipeline.reportWebsiteReceipts(callId, receipts),
1037
+ localToolsEnabled: activeTools.some((tool) => tool.def.name === "local_upload"),
951
1038
  attachedFilePaths: new Set(config.files?.attached.map((file) => file.path) ?? []),
952
1039
  allowedDataBoundaries: material.modelPolicy?.allowedDataBoundaries,
953
1040
  projectFiles,
@@ -1064,7 +1151,7 @@ export class AiTurnExecutor {
1064
1151
  await pipeline.emitBaseline();
1065
1152
  }
1066
1153
 
1067
- const appliedSteers: AiTurnSteer[] = [];
1154
+ const appliedSteers: (AiTurnSteer | null)[] = [];
1068
1155
 
1069
1156
  let mandatePolicy: MandatePolicyV1 | null | undefined;
1070
1157
  if (config.background && config.mandate) {
@@ -1118,6 +1205,7 @@ export class AiTurnExecutor {
1118
1205
  conversationId,
1119
1206
  actor: capabilityAuthority?.actor ?? toolActor,
1120
1207
  staticTools: activeTools,
1208
+ ...(activeTools.some((tool) => tool.def.name === "load_skill") ? { skillNames: availableSkills.map((skill) => skill.name) } : {}),
1121
1209
  allowedTools,
1122
1210
  mandatePolicy,
1123
1211
  unofferedTools,
@@ -1226,8 +1314,8 @@ export class AiTurnExecutor {
1226
1314
  );
1227
1315
  const chat = await aiConversations.getConversation({ conversationId });
1228
1316
  const workingPlan = chat?.todoPlan;
1229
- const skillCatalogOffered = activeTools.some((tool) => tool.def.name === "load_skill");
1230
- const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator");
1317
+ const skillCatalogOffered = activeTools.some((tool) => ["load_skill", "load_local_skill"].includes(tool.def.name));
1318
+ const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator" && !skill.manualOnly);
1231
1319
  // Offer hints and the recent-work summary only guide a fresh turn a person follows; a resumed
1232
1320
  // attempt after an approval or a scheduled run gets neither.
1233
1321
  let guidance: AiTurnGuidance = {};
@@ -1256,7 +1344,16 @@ export class AiTurnExecutor {
1256
1344
  }
1257
1345
  const systemPrompt = composeAiSystemPrompt({
1258
1346
  globalInstructions: settings.globalInstructions,
1259
- loadedSkills,
1347
+ loadedSkills: [...loadedSkills, ...(localContext?.selectedSkills ?? []).map((skill) => ({ ...skill, source: "local" as const }))],
1348
+ localClient: localClient
1349
+ ? {
1350
+ client: localClient,
1351
+ cloudUrl: (await coreSettings.get<string>("app.url")) || "",
1352
+ instructions: settings.localClientInstructions,
1353
+ }
1354
+ : undefined,
1355
+ workspaceInstructions: localContext?.instructions,
1356
+ localSkillsAvailable: skillCatalogOffered && localSkills.some((skill) => !skill.manualOnly),
1260
1357
  turnInstructions: [
1261
1358
  material.systemPrompt,
1262
1359
  audioUnavailableInstruction,
@@ -1292,8 +1389,9 @@ export class AiTurnExecutor {
1292
1389
  skillCreatorAvailable,
1293
1390
  recentWork: guidance.recentWork,
1294
1391
  });
1295
- // The turn policy counts the whole turn, including rounds that compaction archived.
1392
+ // The turn policy and repair budget count the whole turn, including rounds that compaction archived.
1296
1393
  const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
1394
+ const malformedToolCallRepair = createMalformedToolCallRepair(countMalformedToolCallRepairs(turnMessages));
1297
1395
  const turnBlocks = buildBlocksFromMessages(turnMessages);
1298
1396
  const providerNamesByCallId = new Map(
1299
1397
  turnMessages.flatMap(({ message }) =>
@@ -1336,7 +1434,11 @@ export class AiTurnExecutor {
1336
1434
  runBudgetMs: claim.turn.runBudgetMs ?? null,
1337
1435
  finishedToolCalls: turnBlocks
1338
1436
  .slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
1339
- .flatMap((block) => (block.kind === "tool" ? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }] : [])),
1437
+ .flatMap((block) =>
1438
+ block.kind === "tool" && !isMalformedToolCallId(block.callId)
1439
+ ? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }]
1440
+ : [],
1441
+ ),
1340
1442
  onDecision: (decision) =>
1341
1443
  decision.kind === "hint"
1342
1444
  ? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
@@ -1346,21 +1448,20 @@ export class AiTurnExecutor {
1346
1448
  agentId: "cloud",
1347
1449
  loopId: turnId,
1348
1450
  ...(isFresh ? { input: turnInput } : {}),
1349
- provider: turnPolicy.provider,
1451
+ provider: withMalformedToolCallRepair(turnPolicy.provider, malformedToolCallRepair),
1350
1452
  systemPrompt,
1351
- store,
1352
- steering: async ({ signal: steeringSignal }) => {
1353
- if (steeringSignal.aborted) return undefined;
1354
- const steers = await aiConversations.takePendingTurnSteers({
1355
- conversationId,
1356
- turnId,
1357
- leaseOwner: this.config.leaseOwner,
1358
- });
1359
- appliedSteers.push(...steers);
1360
- if (steers.length === 0) return undefined;
1361
- turnPolicy.noteSteering();
1362
- return steers.map((steer) => steer.text);
1363
- },
1453
+ store: withMalformedToolCallResults(store, malformedToolCallRepair),
1454
+ steering: createChatSteering({
1455
+ repair: malformedToolCallRepair,
1456
+ appliedSteers,
1457
+ takePendingSteers: () =>
1458
+ aiConversations.takePendingTurnSteers({
1459
+ conversationId,
1460
+ turnId,
1461
+ leaseOwner: this.config.leaseOwner,
1462
+ }),
1463
+ noteSteering: turnPolicy.noteSteering,
1464
+ }),
1364
1465
  tools: async () => {
1365
1466
  try {
1366
1467
  return await turnPolicy.tools();
@@ -1482,7 +1583,7 @@ export class AiTurnExecutor {
1482
1583
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1483
1584
  linkedApprovals: ReadonlyMap<string, string>;
1484
1585
  freshApprovalRequired?: ReadonlySet<string>;
1485
- appliedSteers: AiTurnSteer[];
1586
+ appliedSteers: (AiTurnSteer | null)[];
1486
1587
  noteToolRound: () => void;
1487
1588
  noteToolCall: (call: AiTurnPolicyToolCall) => void;
1488
1589
  /**
@@ -1532,6 +1633,7 @@ export class AiTurnExecutor {
1532
1633
  linkedApprovals: input.linkedApprovals,
1533
1634
  freshApprovalRequired: input.freshApprovalRequired,
1534
1635
  onBackgroundBlocked: input.onBackgroundBlocked,
1636
+ signal: abortController.signal,
1535
1637
  });
1536
1638
  if (suspended) {
1537
1639
  abortController.abort();
@@ -1638,6 +1740,7 @@ export class AiTurnExecutor {
1638
1740
  linkedApprovals: ReadonlyMap<string, string>;
1639
1741
  freshApprovalRequired?: ReadonlySet<string>;
1640
1742
  onBackgroundBlocked?: (message: string) => void;
1743
+ signal: AbortSignal;
1641
1744
  }): Promise<boolean> {
1642
1745
  const {
1643
1746
  event,
@@ -1790,6 +1893,32 @@ export class AiTurnExecutor {
1790
1893
  }
1791
1894
 
1792
1895
  await pipeline.apply(presented);
1896
+ if (
1897
+ event.kind === "client_tool" &&
1898
+ frontendMode === "client" &&
1899
+ (toolName === "local_bash" || AI_LOCAL_TOOL_NAMES.some((name) => name === toolName))
1900
+ ) {
1901
+ await pipeline.flush();
1902
+ // One wait stays inside a heartbeat interval, keeping lease renewal and abort detection on their cadence.
1903
+ const deadline = performance.now() + this.config.heartbeatMs / 2;
1904
+ while (!input.signal.aborted && performance.now() < deadline) {
1905
+ const action = await aiConversations.getPendingTurnAction({ conversationId, turnId, callId: event.callId });
1906
+ if (action?.status === "resolved" && action.resolvedEvent) {
1907
+ await aiConversations.markPendingTurnActionConsumedInline({ conversationId, turnId, callId: event.callId });
1908
+ loop.push(action.resolvedEvent);
1909
+ return false;
1910
+ }
1911
+ await new Promise<void>((resolve) => {
1912
+ const done = () => {
1913
+ clearTimeout(timer);
1914
+ input.signal.removeEventListener("abort", done);
1915
+ resolve();
1916
+ };
1917
+ const timer = setTimeout(done, 10);
1918
+ input.signal.addEventListener("abort", done, { once: true });
1919
+ });
1920
+ }
1921
+ }
1793
1922
  const suspended = await aiConversations.suspendTurn({
1794
1923
  conversationId,
1795
1924
  turnId,
@@ -1799,6 +1928,16 @@ export class AiTurnExecutor {
1799
1928
  waitingBudgetMs: AI_ACTION_BUDGET_MS,
1800
1929
  });
1801
1930
  await pipeline.flush().catch(() => undefined);
1931
+ if (suspended && approvalId && this.config.onApprovalRequested) {
1932
+ await this.config.onApprovalRequested({ approvalId }).catch((error) => {
1933
+ log.warn("AI approval requested hook failed", {
1934
+ conversationId,
1935
+ turnId,
1936
+ approvalId,
1937
+ error: error instanceof Error ? error.message : "AI approval requested hook failed",
1938
+ });
1939
+ });
1940
+ }
1802
1941
  return suspended;
1803
1942
  }
1804
1943
 
@@ -2055,6 +2194,8 @@ class StreamPipeline {
2055
2194
  }
2056
2195
 
2057
2196
  async emitMessage(message: AiStoredMessage): Promise<void> {
2197
+ // A malformed call shows once its error result is saved, at the end of its response as in history.
2198
+ for (const op of this.mapper.storedMessage(message.message, message.createdAt)) await this.emitOp(op);
2058
2199
  const seq = this.nextSeq();
2059
2200
  await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
2060
2201
  await this.maybeSnapshot();
@@ -2196,6 +2337,7 @@ class StreamPipeline {
2196
2337
  }
2197
2338
 
2198
2339
  export const __aiExecutorTest = {
2340
+ createChatSteering,
2199
2341
  indexConversationToolSource,
2200
2342
  StreamPipeline,
2201
2343
  createEventMapper,
@@ -0,0 +1,2 @@
1
+ export const aiMissingChatFileError = (path: string, localToolsEnabled?: boolean, message = `No such file: ${path}`): Error =>
2
+ new Error(`${message}${localToolsEnabled ? " This is a chat path. Upload local files with local_upload first." : ""}`);