@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
@@ -0,0 +1,244 @@
1
+ import { z } from "zod";
2
+ import { AI_SKILL_DESCRIPTION_MAX_CHARS, AI_SKILL_INSTRUCTIONS_MAX_CHARS, AI_SKILL_NAME_PATTERN } from "./skill-format";
3
+
4
+ /**
5
+ * Contracts of the client tools a local client such as `cld` runs on the user's computer, and of the context it
6
+ * sends with a turn. Cloud declares and validates them; the client executes them and enforces its own path rules.
7
+ */
8
+ export const AI_LOCAL_TOOL_NAMES = [
9
+ "local_list",
10
+ "local_read",
11
+ "local_upload",
12
+ "local_download",
13
+ "local_write",
14
+ "local_move",
15
+ "load_local_skill",
16
+ ] as const;
17
+ export type AiLocalToolName = (typeof AI_LOCAL_TOOL_NAMES)[number];
18
+
19
+ /** Longest absolute path a client reports, Linux PATH_MAX. */
20
+ export const AI_LOCAL_PATH_MAX_CHARS = 4096;
21
+ /** Entries per local_list page by default and at most, like code_files. */
22
+ export const AI_LOCAL_LIST_DEFAULT_LIMIT = 100;
23
+ export const AI_LOCAL_LIST_MAX_LIMIT = 1000;
24
+ /** Bytes per local_read call by default and at most, like read_file. */
25
+ export const AI_LOCAL_READ_DEFAULT_BYTES = 16 * 1024;
26
+ export const AI_LOCAL_READ_MAX_BYTES = 64 * 1024;
27
+ /** Text one local_write call writes, like write_file. */
28
+ export const AI_LOCAL_WRITE_MAX_BYTES = 100 * 1024;
29
+ /** Paths per upload or move batch, like the code-mode input paths and the 64-item request limits. */
30
+ export const AI_LOCAL_BATCH_MAX_ITEMS = 64;
31
+ /** AGENTS.md text one turn carries, the Codex default. */
32
+ export const AI_LOCAL_INSTRUCTIONS_MAX_BYTES = 32 * 1024;
33
+ /** Local Skills one turn lists, like the 64-item request limits. */
34
+ export const AI_LOCAL_SKILLS_MAX_ITEMS = 64;
35
+ /** Local Skills one message selects explicitly. */
36
+ export const AI_LOCAL_SELECTED_SKILLS_MAX_ITEMS = 4;
37
+ /** Files a loaded local Skill lists, like Cloud Skill references. */
38
+ export const AI_LOCAL_SKILL_FILES_MAX_ITEMS = 50;
39
+
40
+ const LocalPath = z.string().min(1).max(AI_LOCAL_PATH_MAX_CHARS);
41
+ const ChatPath = z.string().trim().min(1).max(500);
42
+ const SkillName = z.string().regex(AI_SKILL_NAME_PATTERN).max(64);
43
+ /** One line of client-reported text: control characters and line breaks never reach the prompt. */
44
+ const FactLine = (max: number) =>
45
+ z
46
+ .string()
47
+ .max(max)
48
+ .transform((value) => value.replace(/[\p{Cc}\p{Zl}\p{Zp}]+/gu, " ").trim())
49
+ .pipe(z.string().min(1));
50
+
51
+ /** A request the client did not run: the person said no, a rule refused it, or it failed. */
52
+ const LocalToolProblem = z.object({
53
+ status: z.enum(["denied", "failed"]),
54
+ error: z.string().max(2000),
55
+ });
56
+
57
+ export const CloudAiLocalListInputSchema = z.object({
58
+ path: LocalPath.default(".").describe("Folder to list, relative to the workspace."),
59
+ pattern: z
60
+ .string()
61
+ .trim()
62
+ .min(1)
63
+ .max(500)
64
+ .optional()
65
+ .describe("Glob such as **/*.pdf or 2026-09/*.{pdf,jpg}; matching ignores case. ** lists subfolders."),
66
+ recursive: z.boolean().default(false),
67
+ limit: z.number().int().min(1).max(AI_LOCAL_LIST_MAX_LIMIT).default(AI_LOCAL_LIST_DEFAULT_LIMIT),
68
+ after: LocalPath.optional().describe("nextAfter of the previous page."),
69
+ });
70
+ export const CloudAiLocalListOutputSchema = z.discriminatedUnion("status", [
71
+ z.object({
72
+ status: z.literal("completed"),
73
+ path: LocalPath,
74
+ entries: z
75
+ .array(
76
+ z.object({
77
+ path: LocalPath,
78
+ type: z.enum(["file", "directory", "other"]),
79
+ size: z.number().int().min(0),
80
+ modifiedAt: z.string().max(40),
81
+ }),
82
+ )
83
+ .max(AI_LOCAL_LIST_MAX_LIMIT),
84
+ nextAfter: LocalPath.nullable(),
85
+ unreadable: z.array(LocalPath).max(AI_LOCAL_LIST_MAX_LIMIT).optional(),
86
+ }),
87
+ LocalToolProblem,
88
+ ]);
89
+
90
+ export const CloudAiLocalReadInputSchema = z.object({
91
+ path: LocalPath,
92
+ offset: z.number().int().min(1).default(1).describe("First line to read."),
93
+ maxBytes: z.number().int().min(1).max(AI_LOCAL_READ_MAX_BYTES).default(AI_LOCAL_READ_DEFAULT_BYTES),
94
+ });
95
+ // Two completed shapes share their status, so this is a plain union rather than one keyed by status.
96
+ export const CloudAiLocalReadOutputSchema = z.union([
97
+ z.object({
98
+ status: z.literal("completed"),
99
+ kind: z.literal("text"),
100
+ path: LocalPath,
101
+ /** Lines prefixed with their number and a tab; short lines can make the numbers outweigh the text. */
102
+ content: z.string().max(AI_LOCAL_READ_MAX_BYTES * 10),
103
+ firstLine: z.number().int().min(1),
104
+ lastLine: z.number().int().min(0),
105
+ totalBytes: z.number().int().min(0),
106
+ nextOffset: z.number().int().min(1).nullable(),
107
+ }),
108
+ z.object({
109
+ status: z.literal("completed"),
110
+ kind: z.literal("uploaded"),
111
+ path: LocalPath,
112
+ chatPath: ChatPath,
113
+ mediaType: z.string().max(120),
114
+ size: z.number().int().min(0),
115
+ }),
116
+ LocalToolProblem,
117
+ ]);
118
+
119
+ export const CloudAiLocalUploadInputSchema = z.object({
120
+ paths: z.array(LocalPath).min(1).max(AI_LOCAL_BATCH_MAX_ITEMS),
121
+ directory: ChatPath.optional().describe("Chat folder for all files; by default each file keeps its folder from the workspace."),
122
+ });
123
+ export const CloudAiLocalUploadOutputSchema = z.discriminatedUnion("status", [
124
+ z.object({
125
+ status: z.literal("completed"),
126
+ files: z
127
+ .array(z.object({ path: LocalPath, chatPath: ChatPath, mediaType: z.string().max(120), size: z.number().int().min(0) }))
128
+ .max(AI_LOCAL_BATCH_MAX_ITEMS),
129
+ failed: z.array(z.object({ path: LocalPath, error: z.string().max(2000) })).max(AI_LOCAL_BATCH_MAX_ITEMS),
130
+ }),
131
+ LocalToolProblem,
132
+ ]);
133
+
134
+ export const CloudAiLocalDownloadInputSchema = z.object({
135
+ path: ChatPath.describe("Chat file to save."),
136
+ to: LocalPath.describe("Target file, relative to the workspace."),
137
+ replace: z.boolean().default(false),
138
+ });
139
+ export const CloudAiLocalDownloadOutputSchema = z.discriminatedUnion("status", [
140
+ z.object({ status: z.literal("completed"), path: LocalPath, size: z.number().int().min(0), replaced: z.boolean() }),
141
+ LocalToolProblem,
142
+ ]);
143
+
144
+ export const CloudAiLocalWriteInputSchema = z.object({
145
+ path: LocalPath,
146
+ content: z.string().refine((value) => new TextEncoder().encode(value).byteLength <= AI_LOCAL_WRITE_MAX_BYTES, "Content exceeds 100 KiB."),
147
+ mode: z.enum(["create", "append", "replace"]).default("create"),
148
+ });
149
+ export const CloudAiLocalWriteOutputSchema = z.discriminatedUnion("status", [
150
+ z.object({
151
+ status: z.literal("completed"),
152
+ path: LocalPath,
153
+ mode: z.enum(["create", "append", "replace"]),
154
+ size: z.number().int().min(0),
155
+ }),
156
+ LocalToolProblem,
157
+ ]);
158
+
159
+ const LocalMove = z.object({ from: LocalPath, to: LocalPath });
160
+ export const CloudAiLocalMoveInputSchema = z.object({
161
+ moves: z.array(LocalMove).min(1).max(AI_LOCAL_BATCH_MAX_ITEMS),
162
+ });
163
+ /** A move batch reports what already moved even when it stops, so the model can name or undo it. */
164
+ export const CloudAiLocalMoveOutputSchema = z.object({
165
+ status: z.enum(["completed", "denied", "failed"]),
166
+ moved: z.array(LocalMove).max(AI_LOCAL_BATCH_MAX_ITEMS),
167
+ conflicts: z.array(z.object({ to: LocalPath, reason: z.string().max(500) })).max(AI_LOCAL_BATCH_MAX_ITEMS),
168
+ error: z.string().max(2000).optional(),
169
+ });
170
+
171
+ export const CloudAiLoadLocalSkillInputSchema = z.object({ name: SkillName });
172
+ export const CloudAiLoadLocalSkillOutputSchema = z.discriminatedUnion("status", [
173
+ z.object({
174
+ status: z.literal("completed"),
175
+ name: SkillName,
176
+ directory: LocalPath,
177
+ instructions: z.string().max(AI_SKILL_INSTRUCTIONS_MAX_CHARS),
178
+ files: z.array(LocalPath).max(AI_LOCAL_SKILL_FILES_MAX_ITEMS),
179
+ }),
180
+ LocalToolProblem,
181
+ ]);
182
+
183
+ /** Facts about the local client a turn comes from; Cloud renders them into the "# Local client" prompt section. */
184
+ export const AiLocalClientSchema = z.object({
185
+ kind: z.enum(["cli", "acp"]),
186
+ /** The editor's ACP clientInfo.name. */
187
+ name: FactLine(120).optional(),
188
+ cwd: FactLine(AI_LOCAL_PATH_MAX_CHARS),
189
+ os: FactLine(120),
190
+ shell: FactLine(120),
191
+ localTools: z.enum(["ask", "off"]),
192
+ });
193
+
194
+ /** AGENTS.md files and local Skills the client found in its workspace for one turn. */
195
+ export const AiLocalContextSchema = z.object({
196
+ instructions: z
197
+ .array(z.object({ path: LocalPath, content: z.string() }))
198
+ .max(64)
199
+ .refine(
200
+ (files) =>
201
+ files.reduce((bytes, file) => bytes + new TextEncoder().encode(file.content).byteLength, 0) <= AI_LOCAL_INSTRUCTIONS_MAX_BYTES,
202
+ "AGENTS.md content exceeds 32 KiB.",
203
+ )
204
+ .default([]),
205
+ skills: z
206
+ .array(
207
+ z.object({
208
+ name: SkillName,
209
+ description: z.string().trim().min(1).max(AI_SKILL_DESCRIPTION_MAX_CHARS),
210
+ scope: z.enum(["project", "global"]),
211
+ manualOnly: z.boolean().optional(),
212
+ }),
213
+ )
214
+ .max(AI_LOCAL_SKILLS_MAX_ITEMS)
215
+ .refine((skills) => new Set(skills.map((skill) => skill.name)).size === skills.length, "Local Skill names must be unique.")
216
+ .default([]),
217
+ selectedSkills: z
218
+ .array(
219
+ z.object({
220
+ name: SkillName,
221
+ directory: LocalPath,
222
+ instructions: z.string().trim().min(1).max(AI_SKILL_INSTRUCTIONS_MAX_CHARS),
223
+ }),
224
+ )
225
+ .max(AI_LOCAL_SELECTED_SKILLS_MAX_ITEMS)
226
+ .default([]),
227
+ });
228
+
229
+ export type CloudAiLocalListInput = z.infer<typeof CloudAiLocalListInputSchema>;
230
+ export type CloudAiLocalListOutput = z.infer<typeof CloudAiLocalListOutputSchema>;
231
+ export type CloudAiLocalReadInput = z.infer<typeof CloudAiLocalReadInputSchema>;
232
+ export type CloudAiLocalReadOutput = z.infer<typeof CloudAiLocalReadOutputSchema>;
233
+ export type CloudAiLocalUploadInput = z.infer<typeof CloudAiLocalUploadInputSchema>;
234
+ export type CloudAiLocalUploadOutput = z.infer<typeof CloudAiLocalUploadOutputSchema>;
235
+ export type CloudAiLocalDownloadInput = z.infer<typeof CloudAiLocalDownloadInputSchema>;
236
+ export type CloudAiLocalDownloadOutput = z.infer<typeof CloudAiLocalDownloadOutputSchema>;
237
+ export type CloudAiLocalWriteInput = z.infer<typeof CloudAiLocalWriteInputSchema>;
238
+ export type CloudAiLocalWriteOutput = z.infer<typeof CloudAiLocalWriteOutputSchema>;
239
+ export type CloudAiLocalMoveInput = z.infer<typeof CloudAiLocalMoveInputSchema>;
240
+ export type CloudAiLocalMoveOutput = z.infer<typeof CloudAiLocalMoveOutputSchema>;
241
+ export type CloudAiLoadLocalSkillInput = z.infer<typeof CloudAiLoadLocalSkillInputSchema>;
242
+ export type CloudAiLoadLocalSkillOutput = z.infer<typeof CloudAiLoadLocalSkillOutputSchema>;
243
+ export type AiLocalClient = z.infer<typeof AiLocalClientSchema>;
244
+ export type AiLocalContext = z.infer<typeof AiLocalContextSchema>;
@@ -0,0 +1,144 @@
1
+ import type { NessiIssue, Provider, SessionStore, ToolCallBlock, ToolStreamIssue } from "@k2b/nessi";
2
+ import { AiTurnFailure } from "./turn-failure";
3
+ import type { AiStoredMessage } from "./types";
4
+
5
+ /** At most two automatic repairs per chat turn; user steering does not reset this budget. */
6
+ export const AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS = 2;
7
+
8
+ const excerpt = (text: string, maxChars: number): string => (text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`);
9
+
10
+ /** Never include argsText: code and other tool arguments can be very large. */
11
+ export const malformedToolCallMessage = (issue: ToolStreamIssue): string =>
12
+ `The tool call${issue.name ? ` to ${excerpt(issue.name, 120)}` : ""} could not be used: ${excerpt(issue.message, 512)} (${issue.reason}).`;
13
+
14
+ const REPAIR_REQUEST = "Re-send the call with its exact tool name and complete, valid, properly escaped JSON arguments.";
15
+
16
+ const buildStoredMalformedToolCallRepairText = (name: string): string => `The tool call to ${name} could not be used. ${REPAIR_REQUEST}`;
17
+
18
+ export const buildMalformedToolCallRepairText = (issue: ToolStreamIssue): string => `${malformedToolCallMessage(issue)} ${REPAIR_REQUEST}`;
19
+
20
+ // The prefix lets resumed turns count repairs independently of ordinary tool calls. The id is 34 characters of [a-z0-9_],
21
+ // within OpenAI's 40- and Anthropic's 64-character limits; stricter providers (Mistral's nine alphanumeric characters)
22
+ // map ids at the transport boundary (see provider.ts).
23
+ export const newMalformedToolCallId = (): string => `malformed_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`;
24
+
25
+ export const isMalformedToolCallId = (id: string): boolean => /^malformed_[0-9a-f]{24}$/.test(id);
26
+
27
+ export const malformedToolCallName = (issue: ToolStreamIssue): string =>
28
+ issue.name && /^[A-Za-z0-9_-]{1,64}$/.test(issue.name) ? issue.name : "unknown_tool";
29
+
30
+ // Partial responses (error/interrupted) count too: their stored error result requests the repair on resume, so each
31
+ // saved response with a malformed call spends one repair, as in the live run.
32
+ export const countMalformedToolCallRepairs = (messages: readonly Pick<AiStoredMessage, "message">[]): number =>
33
+ messages.filter(
34
+ ({ message }) =>
35
+ message.role === "assistant" && message.content.some((block) => block.type === "tool_call" && isMalformedToolCallId(block.id)),
36
+ ).length;
37
+
38
+ // Since 0.17.1, nessi also repairs malformed calls with its own user message and counter. Cloud's session store
39
+ // drops user messages nessi appends, and applying Cloud's repair through steering resets nessi's counter.
40
+ // Only Cloud's repair reaches the model and user. Executor unit tests pin Cloud's exhaustion; live integration tests
41
+ // pin the repair results sent to the model, absence of nessi's message in storage and requests, and Cloud's logged failure.
42
+ export const createMalformedToolCallRepair = (previousAttempts: number) => {
43
+ let attempts = previousAttempts;
44
+ let lastIssue: ToolStreamIssue | undefined;
45
+ // Malformed calls of the provider response in flight; pending marks a stored response that still awaits its repair.
46
+ let issues: (ToolStreamIssue & { callId: string })[] = [];
47
+ // A stored response beyond the budget means the run stopped before it could fail the turn.
48
+ let pending = previousAttempts > AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS;
49
+ return {
50
+ startProviderTurn: () => {
51
+ issues = [];
52
+ },
53
+ noteIssue: (issue: NessiIssue): NessiIssue => {
54
+ if (issue.kind !== "malformed_tool_call") return issue;
55
+ const rewritten = { ...issue, callId: newMalformedToolCallId() };
56
+ issues.push(rewritten);
57
+ lastIssue = rewritten;
58
+ return rewritten;
59
+ },
60
+ /** Hands the response's malformed calls to the store once and marks the repair as due. */
61
+ takeResponseIssues: () => {
62
+ const taken = issues;
63
+ issues = [];
64
+ if (taken.length > 0) pending = true;
65
+ return taken;
66
+ },
67
+ takeSteering: (): string | undefined => {
68
+ if (!pending) return undefined;
69
+ pending = false;
70
+ if (attempts >= AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS || !lastIssue) {
71
+ throw new AiTurnFailure(
72
+ "failed",
73
+ lastIssue
74
+ ? malformedToolCallMessage(lastIssue)
75
+ : "A stored response held a tool call that could not be used after the turn's repairs were spent.",
76
+ );
77
+ }
78
+ attempts++;
79
+ return buildMalformedToolCallRepairText(lastIssue);
80
+ },
81
+ };
82
+ };
83
+
84
+ export const withMalformedToolCallRepair = (provider: Provider, repair: ReturnType<typeof createMalformedToolCallRepair>): Provider => ({
85
+ ...provider,
86
+ stream: async function* (request) {
87
+ repair.startProviderTurn();
88
+ for await (const event of provider.stream(request)) {
89
+ yield event.type === "issue" ? { ...event, issue: repair.noteIssue(event.issue) } : event;
90
+ }
91
+ },
92
+ });
93
+
94
+ export const withMalformedToolCallResults = (
95
+ store: SessionStore,
96
+ repair: ReturnType<typeof createMalformedToolCallRepair>,
97
+ ): SessionStore => ({
98
+ ...store,
99
+ load: async () => {
100
+ const entries = await store.load();
101
+ const assistantIndex = entries.findLastIndex(({ message, kind }) => message.role === "assistant" && kind === "message");
102
+ const assistant = entries[assistantIndex]?.message;
103
+ const following = entries.slice(assistantIndex + 1);
104
+ if (assistant?.role !== "assistant" || following.some(({ message }) => message.role === "user")) return entries;
105
+ const resolved = new Set(following.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
106
+ const missing = assistant.content.filter(
107
+ (block): block is ToolCallBlock => block.type === "tool_call" && isMalformedToolCallId(block.id) && !resolved.has(block.id),
108
+ );
109
+ if (missing.length === 0) return entries;
110
+ for (const call of missing) {
111
+ await store.append({
112
+ role: "tool_result",
113
+ callId: call.id,
114
+ name: call.name,
115
+ isError: true,
116
+ result: buildStoredMalformedToolCallRepairText(call.name),
117
+ });
118
+ }
119
+ return store.load();
120
+ },
121
+ append: async (message, opts) => {
122
+ const issues = message.role === "assistant" && opts?.kind !== "summary" ? repair.takeResponseIssues() : [];
123
+ if (message.role !== "assistant" || issues.length === 0) {
124
+ await store.append(message, opts);
125
+ return;
126
+ }
127
+ const calls: ToolCallBlock[] = issues.map((issue) => ({
128
+ type: "tool_call",
129
+ id: issue.callId,
130
+ name: malformedToolCallName(issue),
131
+ args: {},
132
+ }));
133
+ await store.append({ ...message, content: [...message.content, ...calls] }, opts);
134
+ for (const issue of issues) {
135
+ await store.append({
136
+ role: "tool_result",
137
+ callId: issue.callId,
138
+ name: malformedToolCallName(issue),
139
+ result: buildMalformedToolCallRepairText(issue),
140
+ isError: true,
141
+ });
142
+ }
143
+ },
144
+ });
@@ -7,6 +7,7 @@ import {
7
7
  type RenderMarkdownToPdfOptions,
8
8
  renderMarkdownToPdf,
9
9
  } from "../services/pdf";
10
+ import { aiMissingChatFileError } from "./file-error";
10
11
  import { aiProjectFilePathFromMount } from "./file-mount";
11
12
  import { type AiFileContent, aiFileStore, normalizeAiFilePath } from "./files-store";
12
13
  import { withAiPdfConversionSlot } from "./pdf-conversions";
@@ -65,7 +66,7 @@ export const createCloudAiMarkdownToPdfTool = (dependencies: MarkdownPdfToolDepe
65
66
  if (!ctx.conversationId) throw new Error("The markdown_to_pdf tool needs a conversation context.");
66
67
  const sourcePath = conversationPath(input.path);
67
68
  const source = await read({ conversationId: ctx.conversationId, path: sourcePath });
68
- if (!source) throw new Error(`No such file: ${sourcePath}`);
69
+ if (!source) throw aiMissingChatFileError(sourcePath, ctx.localToolsEnabled);
69
70
  if (source.origin !== "assistant") {
70
71
  throw new Error(`Create an assistant-owned Markdown file with write_file before converting ${sourcePath}.`);
71
72
  }
package/src/ai/migrate.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { sql } from "bun";
2
+ import { CapabilityActionReviewSchema } from "../contracts/capabilities";
3
+ import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
2
4
  import { migrateAiMessageQueue } from "./message-queue";
3
5
  import { migrateAiModelAccess } from "./model-access-migrate";
4
6
  import { migrateAiQuotas } from "./quotas-migrate";
@@ -21,6 +23,8 @@ export const migrateCloudAi = async (): Promise<void> => {
21
23
  if (!outbox?.ready) {
22
24
  throw new Error("AI live updates write to events.outbox, which does not exist. Run Core's events migration before the AI migration.");
23
25
  }
26
+ // Approval requests reference auth.users, so they follow the check that Core's own migrations ran.
27
+ await migrateApprovals();
24
28
  await sql`CREATE SCHEMA IF NOT EXISTS ai`.simple();
25
29
  console.log(" ✓ ai schema");
26
30
 
@@ -640,7 +644,12 @@ export const migrateCloudAi = async (): Promise<void> => {
640
644
  PRIMARY KEY (turn_id, request_id)
641
645
  )`.simple();
642
646
 
647
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS approval_id UUID`.simple();
643
648
  await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS review JSONB`.simple();
649
+ // A code run asks for its nested HTTP requests and Actions; their remembered approval names a website or Action, not the run.
650
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS remember_tool_name TEXT`.simple();
651
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS allow_chat BOOLEAN NOT NULL DEFAULT FALSE`.simple();
652
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS consumed_inline BOOLEAN NOT NULL DEFAULT FALSE`.simple();
644
653
 
645
654
  await sql`
646
655
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_pending_actions_turn_call
@@ -653,6 +662,12 @@ export const migrateCloudAi = async (): Promise<void> => {
653
662
  WHERE status = 'pending'
654
663
  `.simple();
655
664
 
665
+ await sql`
666
+ CREATE INDEX IF NOT EXISTS idx_ai_pending_actions_approval
667
+ ON ai.pending_actions(approval_id)
668
+ WHERE approval_id IS NOT NULL
669
+ `.simple();
670
+
656
671
  await sql`
657
672
  DO $$
658
673
  BEGIN
@@ -700,13 +715,39 @@ export const migrateCloudAi = async (): Promise<void> => {
700
715
  FROM ai.tool_approval_preferences
701
716
  WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
702
717
  AND NOT (length(tool_name) = 64 AND tool_name ~ '__[0-9a-f]{12}$')
703
- ON CONFLICT (actor_user_id, tool_name, approval_scope) DO NOTHING
718
+ ON CONFLICT DO NOTHING
704
719
  `.simple();
705
720
  await sql`
706
721
  DELETE FROM ai.tool_approval_preferences
707
722
  WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
708
723
  `.simple();
709
724
 
725
+ // An approval limited to one chat ends with that chat. NULL means it applies everywhere.
726
+ await sql`
727
+ ALTER TABLE ai.tool_approval_preferences
728
+ ADD COLUMN IF NOT EXISTS conversation_id UUID REFERENCES ai.conversations(id) ON DELETE CASCADE
729
+ `.simple();
730
+ await sql`
731
+ CREATE UNIQUE INDEX IF NOT EXISTS ai_tool_approval_preferences_reach
732
+ ON ai.tool_approval_preferences(actor_user_id, tool_name, approval_scope, conversation_id) NULLS NOT DISTINCT
733
+ `.simple();
734
+ await sql`ALTER TABLE ai.tool_approval_preferences DROP CONSTRAINT IF EXISTS ai_tool_approval_preferences_unique`.simple();
735
+ await sql`
736
+ CREATE INDEX IF NOT EXISTS idx_ai_tool_approval_preferences_conversation
737
+ ON ai.tool_approval_preferences(conversation_id)
738
+ WHERE conversation_id IS NOT NULL
739
+ `.simple();
740
+ // Addresses a chat's web searches returned and the pages it read link to, normalized; web_extract and fetch_file
741
+ // read them without asking. Recorded when the tool runs, so a read never scans stored results.
742
+ await sql`
743
+ CREATE TABLE IF NOT EXISTS ai.web_addresses (
744
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
745
+ address TEXT NOT NULL,
746
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
747
+ PRIMARY KEY (conversation_id, address)
748
+ )
749
+ `.simple();
750
+
710
751
  await sql`
711
752
  CREATE TABLE IF NOT EXISTS ai.tool_calls (
712
753
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -724,9 +765,23 @@ export const migrateCloudAi = async (): Promise<void> => {
724
765
  rejected_at TIMESTAMPTZ,
725
766
  completed_at TIMESTAMPTZ,
726
767
  CONSTRAINT ai_tool_calls_status_check CHECK (status IN ('pending', 'running', 'waiting_for_approval', 'waiting_for_frontend', 'completed', 'failed', 'rejected')),
727
- CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_always', 'approved_by_preference', 'rejected'))
768
+ CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_for_chat', 'approved_always', 'approved_by_preference', 'rejected'))
728
769
  )
729
770
  `.simple();
771
+ await sql`
772
+ DO $$
773
+ BEGIN
774
+ IF NOT EXISTS (
775
+ SELECT 1 FROM pg_constraint
776
+ WHERE conname = 'ai_tool_calls_approval_state_check'
777
+ AND pg_get_constraintdef(oid) LIKE '%approved_for_chat%'
778
+ ) THEN
779
+ ALTER TABLE ai.tool_calls DROP CONSTRAINT IF EXISTS ai_tool_calls_approval_state_check;
780
+ ALTER TABLE ai.tool_calls ADD CONSTRAINT ai_tool_calls_approval_state_check
781
+ CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_for_chat', 'approved_always', 'approved_by_preference', 'rejected'));
782
+ END IF;
783
+ END $$
784
+ `.simple();
730
785
 
731
786
  await sql`
732
787
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_turn_call
@@ -738,6 +793,7 @@ export const migrateCloudAi = async (): Promise<void> => {
738
793
  ON ai.tool_calls(conversation_id, created_at DESC)
739
794
  `.simple();
740
795
 
796
+ await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS approval_decided_via TEXT`.simple();
741
797
  await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS idempotency_key TEXT`.simple();
742
798
 
743
799
  // Capability shape, outcome, and latency belong to capabilities.executions,
@@ -2525,5 +2581,67 @@ export const migrateCloudAi = async (): Promise<void> => {
2525
2581
  ` ! ${legacyPaths.files} conversation and ${legacyPaths.project_files} project file paths keep a non-NFC Unicode form because a normalized twin exists`,
2526
2582
  );
2527
2583
  }
2584
+ await backfillApprovalRequests();
2528
2585
  console.log(" ✓ ai conversation tables");
2529
2586
  };
2587
+
2588
+ const backfillApprovalRequests = async (): Promise<void> => {
2589
+ const decoded = (value: unknown): unknown => (typeof value === "string" ? JSON.parse(value) : value);
2590
+ while (true) {
2591
+ const count = await sql.begin(async (db) => {
2592
+ // Completed turns and ownerless chats cannot still ask a person; leave their legacy rows alone.
2593
+ const rows = await db<
2594
+ {
2595
+ id: string;
2596
+ turn_id: string;
2597
+ call_id: string;
2598
+ tool_name: string;
2599
+ approval_scope: string;
2600
+ message: string | null;
2601
+ review: unknown;
2602
+ args: unknown;
2603
+ allow_always: boolean;
2604
+ allow_chat: boolean;
2605
+ owner: string;
2606
+ title: string;
2607
+ expiry: Date;
2608
+ }[]
2609
+ >`SELECT a.*, c.created_by_user_id AS owner, c.title,
2610
+ COALESCE(t.deadline, a.created_at + interval '24 hours') AS expiry
2611
+ FROM ai.pending_actions a JOIN ai.turns t ON t.id=a.turn_id JOIN ai.conversations c ON c.id=a.conversation_id
2612
+ WHERE a.status='pending' AND a.kind IN ('approval','custom_approval') AND a.approval_id IS NULL
2613
+ AND c.created_by_user_id IS NOT NULL AND t.status='waiting_for_action'
2614
+ -- A running Assistant may already have asked again for this action; keep the request it saw.
2615
+ AND NOT EXISTS (SELECT 1 FROM approvals.requests r
2616
+ WHERE r.requester_kind='assistant_turn' AND r.requester_ref=a.turn_id::text || '/' || a.call_id)
2617
+ ORDER BY a.created_at, a.id LIMIT 200 FOR UPDATE OF a SKIP LOCKED`;
2618
+ for (const row of rows) {
2619
+ const review = CapabilityActionReviewSchema.safeParse(row.review == null ? null : decoded(row.review));
2620
+ // request computes both canonical digests; preserve the original wait deadline, including expiry.
2621
+ const record = await approvals.request(
2622
+ {
2623
+ ...approvalScopeFromToolName(row.tool_name),
2624
+ approvalScope: row.approval_scope,
2625
+ requester: { kind: "assistant_turn", ref: `${row.turn_id}/${row.call_id}`, appId: "assistant", title: row.title },
2626
+ subjectId: row.owner,
2627
+ approverUserId: row.owner,
2628
+ // Every turn that can ask allows remembering, so these flags hold what the call offers.
2629
+ assurance: approvalAssurance(row.allow_always || row.allow_chat),
2630
+ sentence: row.message ?? row.tool_name,
2631
+ review: review.success ? review.data : null,
2632
+ input: decoded(row.args),
2633
+ expiresInMs: Math.min(APPROVAL_MAX_WAIT_MS, row.expiry.getTime() - Date.now()),
2634
+ },
2635
+ db,
2636
+ );
2637
+ if (!record.ok) continue;
2638
+ await db`UPDATE approvals.requests SET expires_at=${row.expiry},
2639
+ status=CASE WHEN ${row.expiry} <= now() THEN 'expired' ELSE status END
2640
+ WHERE id=${record.id}::uuid AND status='pending'`;
2641
+ await db`UPDATE ai.pending_actions SET approval_id=${record.id}::uuid WHERE id=${row.id}::uuid`;
2642
+ }
2643
+ return rows.length;
2644
+ });
2645
+ if (!count) return;
2646
+ }
2647
+ };
@@ -0,0 +1,53 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ const catalog = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ openai_api_disabled: "The OpenAI-compatible API is turned off. Contact an administrator.",
8
+ invalid_api_key: "Use an active API key with the access “AI models only”. Create one in your Cloud profile.",
9
+ model_not_found: "This model is unavailable or you do not have access to it.",
10
+ invalid_request: "The request does not match the supported chat completion format.",
11
+ unsupported_content:
12
+ "Only text messages with system, developer, user or assistant roles are supported. Tool calls are not supported.",
13
+ unsupported_parameter: "This parameter is not supported. This API gives access to the models only.",
14
+ context_length_exceeded: "Your messages exceed this model's context window. Shorten them and try again.",
15
+ request_too_large: "The request body exceeds the limit derived from your available models' context windows.",
16
+ ai_disabled: "AI is turned off. Contact an administrator.",
17
+ model_access_denied: "You do not have access to this model. Contact an administrator.",
18
+ missing_provider_credential: "This model's provider is not configured. Contact an administrator.",
19
+ insufficient_quota:
20
+ "Your Assistant usage limit is reached, or your usage could not be measured. Wait for the reset or contact an administrator.",
21
+ too_many_concurrent_requests: "You have too many simultaneous requests. Wait for a request to finish and try again.",
22
+ rate_limit_exceeded: "Too many requests. Wait and try again.",
23
+ upstream_error: "The model provider could not complete your request. Try again later.",
24
+ internal_error: "Cloud could not process your request. Try again later.",
25
+ not_found: "This OpenAI-compatible API route does not exist.",
26
+ },
27
+ de: {
28
+ openai_api_disabled: "Die OpenAI-kompatible API ist ausgeschaltet. Wende dich an einen Administrator.",
29
+ invalid_api_key: "Verwende einen aktiven API-Schlüssel mit dem Zugriff „Nur KI-Modelle“. Du erstellst ihn in deinem Cloud-Profil.",
30
+ model_not_found: "Dieses Modell ist nicht verfügbar oder nicht für dich freigegeben.",
31
+ invalid_request: "Deine Anfrage entspricht nicht dem unterstützten Format für Chat-Vervollständigungen.",
32
+ unsupported_content:
33
+ "Nur Textnachrichten mit den Rollen system, developer, user oder assistant werden unterstützt. Werkzeugaufrufe werden nicht unterstützt.",
34
+ unsupported_parameter: "Dieser Parameter wird nicht unterstützt. Diese API gibt nur Zugriff auf die Modelle.",
35
+ context_length_exceeded: "Deine Nachrichten überschreiten das Kontextfenster dieses Modells. Kürze sie und versuche es erneut.",
36
+ request_too_large: "Deine Anfrage überschreitet die aus den Kontextfenstern deiner verfügbaren Modelle abgeleitete Größenbegrenzung.",
37
+ ai_disabled: "KI ist ausgeschaltet. Wende dich an einen Administrator.",
38
+ model_access_denied: "Dieses Modell ist nicht für dich freigegeben. Wende dich an einen Administrator.",
39
+ missing_provider_credential: "Der Anbieter dieses Modells ist nicht eingerichtet. Wende dich an einen Administrator.",
40
+ insufficient_quota:
41
+ "Dein Nutzungslimit im Assistenten ist erreicht, oder deine Nutzung konnte nicht gemessen werden. Warte auf das Zurücksetzen oder wende dich an einen Administrator.",
42
+ too_many_concurrent_requests: "Du hast zu viele gleichzeitige Anfragen. Warte, bis eine Anfrage beendet ist, und versuche es erneut.",
43
+ rate_limit_exceeded: "Zu viele Anfragen. Warte kurz und versuche es erneut.",
44
+ upstream_error: "Der Modellanbieter konnte deine Anfrage nicht abschließen. Versuche es später erneut.",
45
+ internal_error: "Cloud konnte deine Anfrage nicht verarbeiten. Versuche es später erneut.",
46
+ not_found: "Diese Route der OpenAI-kompatiblen API existiert nicht.",
47
+ },
48
+ },
49
+ });
50
+
51
+ export type OpenAiErrorCode = keyof ReturnType<typeof catalog.resolve>["t"];
52
+ export const openAiApiMessages = (locale: string) => catalog.resolve([locale]).t;
53
+ export const checkOpenAiApiMessages = () => catalog.check();