@k2b/cloud 0.30.0 → 0.31.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 (76) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +14 -4
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/approval-routes.ts +40 -4
  8. package/src/ai/approvals.ts +68 -10
  9. package/src/ai/chat/blocks.tsx +53 -32
  10. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  11. package/src/ai/chat/message-actions.tsx +3 -1
  12. package/src/ai/chat/messages.ts +18 -0
  13. package/src/ai/chat/tool-groups.ts +66 -12
  14. package/src/ai/chat/tool-names.ts +4 -2
  15. package/src/ai/chat/turn-layout.ts +8 -0
  16. package/src/ai/chat/turn-view.tsx +94 -25
  17. package/src/ai/chat/web-tools.tsx +95 -1
  18. package/src/ai/client/controller.ts +6 -3
  19. package/src/ai/code-runtime-tools.ts +50 -17
  20. package/src/ai/code-source-contracts.ts +25 -21
  21. package/src/ai/code-source-tools.ts +3 -1
  22. package/src/ai/executor.ts +170 -14
  23. package/src/ai/fetch-file-tool.ts +41 -4
  24. package/src/ai/firecrawl-tools.ts +87 -4
  25. package/src/ai/index.ts +8 -0
  26. package/src/ai/migrate.ts +113 -2
  27. package/src/ai/protocol.ts +22 -1
  28. package/src/ai/routes.ts +11 -0
  29. package/src/ai/runtime.ts +46 -7
  30. package/src/ai/store.ts +66 -1
  31. package/src/ai/tool-audit.ts +11 -1
  32. package/src/ai/tools.ts +91 -3
  33. package/src/ai/types.ts +39 -0
  34. package/src/ai/web-provenance.ts +103 -0
  35. package/src/api/capabilities.ts +3 -2
  36. package/src/api/widgets.ts +36 -5
  37. package/src/browser/CloudResourceSearch.tsx +1 -1
  38. package/src/browser/command-shortcuts.ts +21 -6
  39. package/src/browser/spotlight-position.ts +4 -1
  40. package/src/browser/widgets.ts +7 -5
  41. package/src/capabilities/executions.ts +3 -0
  42. package/src/cli/commands.ts +20 -0
  43. package/src/cli/index.ts +16 -0
  44. package/src/cli/plugin.ts +30 -0
  45. package/src/contracts/app.ts +34 -2
  46. package/src/contracts/registry.ts +9 -1
  47. package/src/contracts/widgets.ts +47 -2
  48. package/src/desktop/solid.tsx +6 -7
  49. package/src/server/index.ts +1 -0
  50. package/src/server/widget-request.ts +27 -0
  51. package/src/services/approvals/index.ts +225 -0
  52. package/src/services/approvals/migrate.ts +33 -0
  53. package/src/shared/app-presentation.ts +11 -0
  54. package/src/shared/email-html.ts +43 -8
  55. package/src/ssr/AdminLayout.tsx +4 -7
  56. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  57. package/src/ssr/Layout.tsx +38 -14
  58. package/src/ssr/LayoutFooter.tsx +1 -1
  59. package/src/ssr/LayoutHeader.tsx +17 -20
  60. package/src/ssr/LayoutHelp.tsx +1 -1
  61. package/src/ssr/LayoutRail.tsx +1 -2
  62. package/src/ssr/PageTrail.tsx +23 -0
  63. package/src/ssr/PageTrailRow.island.tsx +20 -0
  64. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  65. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  66. package/src/ssr/islands/index.ts +1 -0
  67. package/src/ssr/layout-runtime.ts +60 -0
  68. package/src/ssr/mobile-menu-history.ts +1 -1
  69. package/src/ssr/page-trail-runtime.ts +313 -0
  70. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  71. package/src/ssr/page-trail.ts +59 -0
  72. package/src/ssr/platform-messages.ts +4 -0
  73. package/src/styles/effects.css +21 -4
  74. package/src/styles/global.css +35 -2
  75. package/src/styles/utilities-layout.css +24 -1
  76. package/src/styles/utilities-navigation.css +6 -18
@@ -1,8 +1,10 @@
1
- import { Chat, ScrollArea } from "@k2b/ui";
1
+ import { Button, Chat, ScrollArea, useLocale } from "@k2b/ui";
2
2
  import { createSignal, For, Show } from "solid-js";
3
3
  import { formatAiFileSize } from "../attachments";
4
4
  import type { AiTurnBlock } from "../protocol";
5
+ import { useAiChatActions } from "./message-actions";
5
6
  import { isRecord } from "./message-utils";
7
+ import { aiChatMessages } from "./messages";
6
8
  import { AiToolActivity } from "./tool-disclosure";
7
9
 
8
10
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -200,3 +202,95 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
200
202
  </Show>
201
203
  );
202
204
  }
205
+
206
+ type WebsiteReceipt = { method: string; url: string };
207
+
208
+ /**
209
+ * Requests a website approval for this chat let through without asking, from a code run or a page read. The call
210
+ * reports them as they go out, so they are there whether it succeeded, failed, or its turn stopped.
211
+ */
212
+ export const websiteReceipts = (block: ToolBlock): WebsiteReceipt[] => block.receipts ?? [];
213
+
214
+ const originOf = (url: string): string => {
215
+ try {
216
+ return new URL(url).origin;
217
+ } catch {
218
+ return url;
219
+ }
220
+ };
221
+
222
+ /**
223
+ * The receipt of every request a website approval let through: one row per website with each full URL, query
224
+ * included, and a revoke action that makes the next request ask again.
225
+ */
226
+ export function WebsiteReceipts(props: { block: ToolBlock }) {
227
+ const actions = useAiChatActions();
228
+ const locale = useLocale();
229
+ const t = () => aiChatMessages(locale());
230
+ const groups = () => {
231
+ const byOrigin = new Map<string, WebsiteReceipt[]>();
232
+ for (const receipt of websiteReceipts(props.block)) {
233
+ const origin = originOf(receipt.url);
234
+ byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), receipt]);
235
+ }
236
+ return [...byOrigin].map(([origin, receipts]) => ({ origin, receipts }));
237
+ };
238
+ return (
239
+ <For each={groups()}>
240
+ {(group) => {
241
+ const [state, setState] = createSignal<"idle" | "busy" | "revoked" | "failed">("idle");
242
+ const host = () => domainOf(group.origin);
243
+ const revoke = async () => {
244
+ if (!actions.onRevokeWebsite || state() === "busy") return;
245
+ setState("busy");
246
+ try {
247
+ await actions.onRevokeWebsite(group.origin);
248
+ setState("revoked");
249
+ } catch {
250
+ setState("failed");
251
+ }
252
+ };
253
+ return (
254
+ <Chat.Activity
255
+ icon="ti ti-world-check"
256
+ label={host()}
257
+ description={
258
+ state() === "revoked"
259
+ ? t().websiteRevoked
260
+ : state() === "failed"
261
+ ? t().revokeFailed
262
+ : t().websiteAllowedForChat({ count: group.receipts.length })
263
+ }
264
+ defaultOpen
265
+ bodyInset={false}
266
+ trailing={
267
+ // The button keeps its place and label once revoked, so nothing moves; the description says it is revoked.
268
+ <Show when={actions.onRevokeWebsite}>
269
+ <Button
270
+ size="xs"
271
+ variant="ghost"
272
+ loading={state() === "busy"}
273
+ disabled={state() === "revoked"}
274
+ aria-label={t().revokeWebsiteFor({ host: host() })}
275
+ onClick={() => void revoke()}
276
+ >
277
+ {t().revoke}
278
+ </Button>
279
+ </Show>
280
+ }
281
+ >
282
+ <ul class="flex w-full min-w-0 flex-col gap-0.5 rounded-md bg-zinc-100/70 px-2 py-1.5 text-xs [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
283
+ <For each={group.receipts}>
284
+ {(receipt) => (
285
+ <li class="min-w-0 break-all font-mono text-[11px] text-secondary">
286
+ <span class="font-semibold text-primary">{receipt.method}</span> {receipt.url}
287
+ </li>
288
+ )}
289
+ </For>
290
+ </ul>
291
+ </Chat.Activity>
292
+ );
293
+ }}
294
+ </For>
295
+ );
296
+ }
@@ -1284,7 +1284,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1284
1284
  conversationId: string,
1285
1285
  turnId: string,
1286
1286
  callId: string,
1287
- action: { type: "approval_response"; approved: boolean; remember?: "always" } | { type: "tool_result"; result: unknown },
1287
+ action: { type: "approval_response"; approved: boolean; remember?: "chat" | "always" } | { type: "tool_result"; result: unknown },
1288
1288
  ) => {
1289
1289
  if (isActiveConversation(conversationId) && runStatus() === "stopping") return false;
1290
1290
  try {
@@ -1325,7 +1325,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1325
1325
  const submitTurnAction = (
1326
1326
  turnId: string,
1327
1327
  callId: string,
1328
- action: { type: "approval_response"; approved: boolean; remember?: "always" } | { type: "tool_result"; result: unknown },
1328
+ action: { type: "approval_response"; approved: boolean; remember?: "chat" | "always" } | { type: "tool_result"; result: unknown },
1329
1329
  ) => {
1330
1330
  const conversationId = activeConversationId();
1331
1331
  if (!conversationId) return Promise.resolve(false);
@@ -1333,7 +1333,10 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1333
1333
  return submitTurnActionForConversation(conversationId, turnId, callId, action);
1334
1334
  };
1335
1335
 
1336
- const respondToApproval = async (request: { turnId: string; callId: string }, input: { approved: boolean; remember?: "always" }) => {
1336
+ const respondToApproval = async (
1337
+ request: { turnId: string; callId: string },
1338
+ input: { approved: boolean; remember?: "chat" | "always" },
1339
+ ) => {
1337
1340
  const conversationId = activeConversationId();
1338
1341
  if (!conversationId) return false;
1339
1342
  const submitted = await submitTurnAction(request.turnId, request.callId, {
@@ -14,6 +14,7 @@ import { resolveAiCapabilityActor } from "./capability-execution";
14
14
  import { CODE_CAPABILITY_TOKEN_HEADER, codeCapabilityOperation } from "./code-capability-transport";
15
15
  import { authorizeCodeExecution } from "./code-execution";
16
16
  import { aiConversations } from "./store";
17
+ import type { AiApprovalTarget, AiWebsiteReceipt } from "./types";
17
18
 
18
19
  const log = logger("ai:code-runtime");
19
20
  const ISSUANCE_TIMEOUT_MS = 5_000;
@@ -24,7 +25,20 @@ const Reply = z.object({
24
25
  status: z.enum(["running", "busy", "done", "lost"]),
25
26
  phase: z.enum(["starting", "running", "busy", "waiting_for_user"]).optional(),
26
27
  result: z.unknown().optional(),
27
- approvals: z.array(z.object({ id: z.uuid(), message: z.string(), decision: z.boolean().nullable() })),
28
+ approvals: z.array(
29
+ z.object({
30
+ id: z.uuid(),
31
+ approvalId: z.uuid().optional(),
32
+ message: z.string(),
33
+ decision: z.boolean().nullable(),
34
+ /** What the person may remember: the website or Action, derived by the host from the stored request. */
35
+ remember: z.object({ toolName: z.string(), approvalScope: z.string(), always: z.boolean() }).optional(),
36
+ }),
37
+ ),
38
+ /** The next requests a website approval for this chat let through without asking, after `receiptsAfter`. */
39
+ receipts: z.array(z.object({ method: z.string(), url: z.string() })).default([]),
40
+ /** More receipts follow; the answer then leaves out the result until the last one is read. */
41
+ moreReceipts: z.boolean().default(false),
28
42
  }),
29
43
  });
30
44
  type Context = ToolContext & {
@@ -34,6 +48,9 @@ type Context = ToolContext & {
34
48
  locale?: string;
35
49
  timeZone?: string;
36
50
  reportProgress?: (message: string) => Promise<void>;
51
+ /** Relays a decision; the managed host consumes the linked platform approval before execution. */
52
+ requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
53
+ reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
37
54
  };
38
55
 
39
56
  /** The tab only renders progress and approvals; server calls own all execution and resumption. */
@@ -101,7 +118,7 @@ export const runManagedCodeTool =
101
118
  token && token.claims.exp * 1000 - Date.now() >= 10_000 ? token : undefined;
102
119
  let signed: Awaited<ReturnType<typeof signInvocationToken>> | undefined;
103
120
  let callback: Awaited<ReturnType<typeof signInvocationToken>> | undefined;
104
- const request = async (decision?: { id: string; approved: boolean }) => {
121
+ const request = async ({ decision, receiptsAfter }: { decision?: { id: string; approved: boolean }; receiptsAfter: number }) => {
105
122
  context.signal.throwIfAborted();
106
123
  await authorizeCodeExecution(context.conversationId!, context.turnId!, actor.user.id);
107
124
  signed = usable(signed) ?? (await issue({ targetAppId: "assistant", callingAppId: "core", operation: `tool:${name}` }));
@@ -127,7 +144,7 @@ export const runManagedCodeTool =
127
144
  signal: context.signal,
128
145
  body: JSON.stringify({
129
146
  conversationId: context.conversationId,
130
- input: { turnId: context.turnId, callId: context.callId, name, args, ...(decision ? { decision } : {}) },
147
+ input: { turnId: context.turnId, callId: context.callId, name, args, receiptsAfter, ...(decision ? { decision } : {}) },
131
148
  }),
132
149
  });
133
150
  const body = await readBoundedJson(response, 256 * 1024);
@@ -135,29 +152,41 @@ export const runManagedCodeTool =
135
152
  throw new Error("Code host request failed; inspect the existing call before starting another execution");
136
153
  return Reply.parse(body.data).data;
137
154
  };
155
+ const backgroundApproval = async (): Promise<boolean> => {
156
+ throw new Error("Background code cannot request interactive approval. Update task grants in the normal chat.");
157
+ };
138
158
  return waitForManagedCodeCall(
139
159
  request,
140
- runConfig.background
141
- ? {
142
- ...context,
143
- requestApproval: async () => {
144
- throw new Error("Background code cannot request interactive approval. Update task grants in the normal chat.");
145
- },
146
- }
147
- : context,
160
+ runConfig.background ? { ...context, requestApproval: backgroundApproval, requestRelayedApproval: backgroundApproval } : context,
148
161
  );
149
162
  };
150
163
 
151
- /** Ordered reviews are replayed through Nessi before consuming a durable result. */
164
+ /**
165
+ * Ordered reviews are replayed through Nessi before consuming a durable result. Each request a website approval let
166
+ * through is reported as a receipt as soon as the host names it, so the chat shows it with its full URL whatever the
167
+ * run's outcome. Receipts arrive page by page after the ones already reported.
168
+ */
152
169
  export async function waitForManagedCodeCall(
153
- request: (decision?: { id: string; approved: boolean }) => Promise<z.infer<typeof Reply>["data"]>,
154
- context: Pick<ToolContext, "signal" | "requestApproval"> & { locale?: string; reportProgress?: (message: string) => Promise<void> },
170
+ request: (input: { decision?: { id: string; approved: boolean }; receiptsAfter: number }) => Promise<z.input<typeof Reply>["data"]>,
171
+ context: Pick<ToolContext, "signal" | "requestApproval"> & {
172
+ locale?: string;
173
+ reportProgress?: (message: string) => Promise<void>;
174
+ /** Relays a decision; the managed host consumes the linked platform approval before execution. */
175
+ requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
176
+ reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
177
+ },
155
178
  ): Promise<z.infer<ReturnType<typeof z.json>>> {
156
179
  const seen = new Set<string>();
157
180
  let lastPhase: string | undefined;
181
+ let receiptsAfter = 0;
158
182
  while (true) {
159
183
  context.signal.throwIfAborted();
160
- const state = await request();
184
+ const state = Reply.shape.data.parse(await request({ receiptsAfter }));
185
+ if (state.receipts.length) {
186
+ await context.reportWebsiteReceipts?.(state.receipts.map((receipt, index) => ({ index: receiptsAfter + index, ...receipt })));
187
+ receiptsAfter += state.receipts.length;
188
+ }
189
+ if (state.moreReceipts) continue;
161
190
  if (state.phase && state.phase !== lastPhase) {
162
191
  lastPhase = state.phase;
163
192
  const labels = context.locale?.startsWith("de")
@@ -179,9 +208,13 @@ export async function waitForManagedCodeCall(
179
208
  // Skipping already resolved reviews would shift Nessi's child action IDs.
180
209
  for (const approval of state.approvals) {
181
210
  if (seen.has(approval.id)) continue;
182
- const approved = await context.requestApproval(approval.message);
211
+ if (!context.requestRelayedApproval) throw new Error("Managed code approval requires a relay context");
212
+ const approved = await context.requestRelayedApproval(approval.message, {
213
+ approvalId: approval.approvalId,
214
+ target: approval.remember,
215
+ });
183
216
  seen.add(approval.id);
184
- if (approval.decision === null) await request({ id: approval.id, approved });
217
+ if (approval.decision === null) await request({ decision: { id: approval.id, approved }, receiptsAfter });
185
218
  }
186
219
  if (state.status === "done") {
187
220
  // Nessi records a thrown error as a failed tool result the model can act on.
@@ -12,15 +12,6 @@ const ArtifactPath = z
12
12
  .max(180)
13
13
  .regex(/^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)*$/)
14
14
  .refine((path) => path.split("/").every((part) => part !== "." && part !== ".."));
15
- const ArtifactFile = z
16
- .object({
17
- path: ArtifactPath,
18
- content: z
19
- .string()
20
- .max(1024 * 1024)
21
- .describe("Complete source file content; read existing content before replacing it."),
22
- })
23
- .strict();
24
15
  const Icon = z
25
16
  .string()
26
17
  .regex(/^ti ti-[a-z0-9-]+$/)
@@ -39,15 +30,6 @@ const DatabaseSql = z.object({
39
30
  params: z.array(z.json()).max(1000).default([]),
40
31
  });
41
32
 
42
- const CodeWriteInput = Id.extend({
43
- expectedRevision: z.number().int().positive(),
44
- entry: ArtifactPath.optional().describe("index.html for an app with an interface, or the script that code_run executes."),
45
- files: z
46
- .array(z.union([ArtifactFile, z.object({ path: ArtifactPath, fromFile: AiFileReference }).strict()]))
47
- .min(1)
48
- .max(64),
49
- });
50
-
51
33
  const DbName = z
52
34
  .string()
53
35
  .regex(/^[A-Za-z][A-Za-z0-9_]*$/)
@@ -305,11 +287,33 @@ export const CODE_SOURCE_TOOLS = {
305
287
  "Create a private reusable App: an index.html interface with optional style.css and app.js, published actions and optional persistence. Starts with a minimal index.html. For one-off analysis, use code_run with code instead. Returns id and entry path; write source with code_write. Optional icon uses the complete class, e.g. ti ti-chart-bar; omit it when unsure. Does not run or share anything.",
306
288
  input: ArtifactCreate.pick({ title: true, description: true, icon: true }),
307
289
  },
290
+ // Source text stays top-level because some providers corrupt strings nested in arrays.
291
+ // Imports carry only references, so they keep an array and one review per batch.
308
292
  code_write: {
309
293
  description:
310
- "Atomically save complete source files against expectedRevision from code_read/create. Other files stay unchanged. Returns revision and compiler diagnostics; never runs. fromFile imports one exact chat/Project/App file reference as UTF-8 source without printing bytes. File imports receive fresh review because source may be shared or published. Use code_file_stat first; source file and bundle byte limits apply.",
311
- review: (input: unknown) => CodeWriteInput.parse(input).files.some((file) => "fromFile" in file),
312
- input: CodeWriteInput,
294
+ "Save one complete source file against expectedRevision from code_read, code_create or the previous write. Other files stay unchanged. Write a multi-file app with one call per file and wait for each result, passing its returned revision as the next expectedRevision; calls sent together in one response share a stale revision, so all but the first fail with CONFLICT. Returns revision, the lines and length of every file, and compiler diagnostics for the saved state; an import of a file not yet written is reported as missing until it is saved. Never runs. Copy exact chat, Project or App files with code_import.",
295
+ input: Id.extend({
296
+ expectedRevision: z.number().int().positive().describe("Revision from code_read, code_create or the previous write."),
297
+ path: ArtifactPath.describe("Relative path of the file to save."),
298
+ content: z
299
+ .string()
300
+ .max(1024 * 1024)
301
+ .describe("Complete file content as plain text; read existing content before replacing it."),
302
+ entry: ArtifactPath.optional().describe("index.html for an app with an interface, or the script that code_run executes."),
303
+ }),
304
+ },
305
+ code_import: {
306
+ description:
307
+ "Atomically import exact chat, Project or App file references as UTF-8 source files against expectedRevision, without printing their bytes. One fresh review covers the whole batch because source may be shared or published. Use code_file_stat first; source file and bundle byte limits apply. Returns revision, the lines and length of every file, imported and diagnostics. Write source text with code_write.",
308
+ review: true,
309
+ input: Id.extend({
310
+ expectedRevision: z.number().int().positive(),
311
+ entry: ArtifactPath.optional().describe("index.html for an app with an interface, or the script that code_run executes."),
312
+ files: z
313
+ .array(z.object({ path: ArtifactPath, fromFile: AiFileReference }).strict())
314
+ .min(1)
315
+ .max(64),
316
+ }),
313
317
  },
314
318
  code_remove: {
315
319
  description:
@@ -21,6 +21,7 @@ import { defineAiTool } from "./tools";
21
21
  const WRITE_TOOLS = new Set([
22
22
  "code_create",
23
23
  "code_write",
24
+ "code_import",
24
25
  "code_remove",
25
26
  "code_update",
26
27
  "code_fork",
@@ -131,7 +132,7 @@ export function createCodeSourceTool(name: CodeSourceToolName) {
131
132
  if (!response.ok) throw new Error("Assistant tool request failed.");
132
133
  return { data: reply.data.data, status: response.status };
133
134
  };
134
- if ("review" in definition && (typeof definition.review === "function" ? definition.review(input) : definition.review)) {
135
+ if ("review" in definition && definition.review) {
135
136
  const preview = z.object({ data: z.object({ message: z.string().min(1) }) }).parse((await send(true)).data).data;
136
137
  if (!authorizeDatabase && !(await context.requestApproval(preview.message)))
137
138
  throw new Error("The user declined this change. No mutation was sent.");
@@ -191,5 +192,6 @@ export const createCodeSourceTools = () => [
191
192
  createCodeSourceTool("code_update"),
192
193
  createCodeSourceTool("code_create"),
193
194
  createCodeSourceTool("code_write"),
195
+ createCodeSourceTool("code_import"),
194
196
  createCodeSourceTool("code_remove"),
195
197
  ];