@k2b/cloud 0.25.0 → 0.27.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 (93) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/define-app.ts +8 -1
  4. package/src/_internal/process-identity.ts +7 -1
  5. package/src/_internal/registry-validation.ts +3 -0
  6. package/src/_internal/registry.ts +1 -0
  7. package/src/_internal/runtime-context.ts +1 -0
  8. package/src/access/GroupCoverage.tsx +175 -0
  9. package/src/access/PermissionEditor.tsx +119 -99
  10. package/src/access/messages.ts +30 -0
  11. package/src/ai/admin.ts +1 -0
  12. package/src/ai/approval-routes.ts +5 -5
  13. package/src/ai/browser-code-contracts.ts +14 -2
  14. package/src/ai/browser.ts +8 -1
  15. package/src/ai/capabilities.ts +115 -33
  16. package/src/ai/chat/blocks.tsx +86 -179
  17. package/src/ai/chat/builtin-tools.tsx +83 -48
  18. package/src/ai/chat/file-tools.tsx +4 -1
  19. package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
  20. package/src/ai/chat/message-actions.tsx +6 -2
  21. package/src/ai/chat/message-utils.ts +17 -14
  22. package/src/ai/chat/messages.ts +330 -2
  23. package/src/ai/chat/presentation.tsx +278 -104
  24. package/src/ai/chat/tool-groups.ts +55 -35
  25. package/src/ai/chat/turn-layout.ts +141 -0
  26. package/src/ai/chat/turn-view.tsx +644 -0
  27. package/src/ai/client/controller.ts +60 -31
  28. package/src/ai/client/file-source.ts +20 -3
  29. package/src/ai/client/projection.ts +42 -6
  30. package/src/ai/code-mode-skill.ts +27 -27
  31. package/src/ai/code-runtime-tools.ts +10 -1
  32. package/src/ai/code-source-contracts.ts +54 -4
  33. package/src/ai/code-source-tools.ts +10 -3
  34. package/src/ai/credentials.ts +17 -3
  35. package/src/ai/data-analysis-skill.ts +2 -2
  36. package/src/ai/default-tools.ts +2 -2
  37. package/src/ai/executor.ts +177 -80
  38. package/src/ai/file-context.ts +14 -2
  39. package/src/ai/file-tools.ts +17 -3
  40. package/src/ai/files-store.ts +134 -11
  41. package/src/ai/grids-skill.ts +2 -2
  42. package/src/ai/index.ts +7 -0
  43. package/src/ai/memories.ts +14 -0
  44. package/src/ai/migrate.ts +125 -0
  45. package/src/ai/model-request-settings.ts +98 -0
  46. package/src/ai/protocol.ts +26 -4
  47. package/src/ai/provider-fetch.ts +67 -15
  48. package/src/ai/provider-retry.ts +105 -0
  49. package/src/ai/provider.ts +7 -1
  50. package/src/ai/quota-provider.ts +16 -7
  51. package/src/ai/request-headers.ts +117 -0
  52. package/src/ai/routes.ts +34 -6
  53. package/src/ai/runtime.ts +1 -1
  54. package/src/ai/settings.ts +19 -2
  55. package/src/ai/skill-seeds.ts +31 -3
  56. package/src/ai/skills.ts +26 -0
  57. package/src/ai/solid.ts +1 -1
  58. package/src/ai/store.ts +202 -59
  59. package/src/ai/stream.ts +182 -37
  60. package/src/ai/structured.ts +20 -5
  61. package/src/ai/system-prompt.ts +25 -0
  62. package/src/ai/timeline.ts +9 -11
  63. package/src/ai/tool-call-names.ts +45 -0
  64. package/src/ai/turn-policy.ts +247 -0
  65. package/src/ai/turn-timing.ts +31 -3
  66. package/src/ai/types.ts +36 -5
  67. package/src/api/admin-ai-quotas.ts +36 -1
  68. package/src/api/admin-core-settings.ts +16 -23
  69. package/src/api/admin-outgoing-mail.ts +62 -0
  70. package/src/api/index.ts +2 -0
  71. package/src/cli/admin/ai-quotas.ts +70 -1
  72. package/src/cli/admin/index.ts +6 -0
  73. package/src/cli/admin/outgoing-mail.ts +118 -0
  74. package/src/contracts/app.ts +2 -0
  75. package/src/contracts/index.ts +1 -0
  76. package/src/contracts/outgoing-mail.ts +77 -0
  77. package/src/contracts/registry.ts +4 -0
  78. package/src/services/index.ts +3 -0
  79. package/src/services/notifications/email.ts +16 -26
  80. package/src/services/outgoing-mail/index.ts +19 -0
  81. package/src/services/outgoing-mail/store.ts +286 -0
  82. package/src/services/outgoing-mail/test-send.ts +40 -0
  83. package/src/services/outgoing-mail/transport.ts +13 -0
  84. package/src/services/settings/core-settings.ts +1 -38
  85. package/src/services/settings/store.ts +5 -1
  86. package/src/shared/ai-model-request-settings.ts +21 -0
  87. package/src/shared/ai-platform-prompt.ts +1 -1
  88. package/src/shared/ai-request-options.ts +185 -0
  89. package/src/shared/app-presentation.ts +10 -2
  90. package/src/ssr/admin-navigation.ts +1 -1
  91. package/src/ssr/platform-messages.ts +2 -0
  92. package/src/ssr/workspace-navigation.ts +7 -1
  93. package/src/styles/effects.css +69 -0
@@ -2,7 +2,7 @@ import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js";
2
2
  import { createStore, reconcile } from "solid-js/store";
3
3
  import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
4
4
  import { aiChatMessages } from "../chat/messages";
5
- import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
5
+ import { AI_TURN_LEASE_MS, type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
6
6
  import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
7
7
  import type {
8
8
  AiClientToolId,
@@ -201,6 +201,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
201
201
  const abortRequests = new Map<string, Promise<boolean>>();
202
202
  let stream: AiStreamHandle | null = null;
203
203
  let streamSession: AiStreamSession | null = null;
204
+ let silenceTimer: ReturnType<typeof setTimeout> | undefined;
204
205
  /** The chat error left by a stream that ended for good, until a stream opens again. */
205
206
  let streamError: string | null = null;
206
207
  let streamGeneration = 0;
@@ -294,8 +295,32 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
294
295
  void markConversationViewed(session.conversationId);
295
296
  };
296
297
 
298
+ const clearSilenceTimer = () => {
299
+ if (silenceTimer) clearTimeout(silenceTimer);
300
+ silenceTimer = undefined;
301
+ };
302
+
303
+ /**
304
+ * A running turn whose open stream delivers nothing for one worker lease either works silently or lost an update,
305
+ * such as its end. The stream then starts again from the turn's saved state. The wait starts with the stream's
306
+ * first event, so a slow first state is never cut off. A turn that waits for an action in this browser is not
307
+ * silent, and a stream that reconnects brings a fresh state anyway.
308
+ */
309
+ const watchSilence = (session: AiStreamSession) => {
310
+ clearSilenceTimer();
311
+ silenceTimer = setTimeout(() => {
312
+ silenceTimer = undefined;
313
+ if (!isCurrentStreamSession(streamSession, session)) return;
314
+ const status = streamStatus();
315
+ if (state.activeTurn?.status !== "running" || status === "connecting" || status === "reconnecting") return watchSilence(session);
316
+ closeStream();
317
+ openStream(session.conversationId);
318
+ }, AI_TURN_LEASE_MS);
319
+ };
320
+
297
321
  const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
298
322
  if (!isCurrentStreamSession(streamSession, session)) return;
323
+ watchSilence(session);
299
324
  const conversationId = session.conversationId;
300
325
  if (event.type === "turn_finished" && pendingSends()[conversationId]) {
301
326
  completedDuringSend.set(conversationId, event.turnId);
@@ -327,6 +352,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
327
352
  };
328
353
 
329
354
  const closeStream = () => {
355
+ clearSilenceTimer();
330
356
  stream?.close();
331
357
  stream = null;
332
358
  streamSession = null;
@@ -400,15 +426,15 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
400
426
  console.warn(`No frontend handler registered for AI tool "${name}" — leaving the action request pending.`);
401
427
  return false;
402
428
  }
429
+ let result: unknown;
403
430
  try {
404
- const result = await handler({ name, callId, args, turnId, conversationId });
405
- return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
431
+ result = await handler({ name, callId, args, turnId, conversationId });
406
432
  } catch (toolError) {
407
- return submitTurnActionForConversation(conversationId, turnId, callId, {
408
- type: "tool_result",
409
- result: { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" },
410
- });
433
+ result = { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" };
411
434
  }
435
+ const submitted = await submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
436
+ if (submitted) showActionResolved(conversationId, turnId, { type: "tool_result", callId, result });
437
+ return submitted;
412
438
  };
413
439
 
414
440
  // ---- conversation loading --------------------------------------------
@@ -1258,6 +1284,28 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1258
1284
  }
1259
1285
  };
1260
1286
 
1287
+ /**
1288
+ * Shows an action the server accepted as resolved, so the turn runs on before the stream confirms it. A turn that
1289
+ * runs is watched for silence again, which heals a confirmation that never arrives.
1290
+ */
1291
+ const showActionResolved = (
1292
+ conversationId: string,
1293
+ turnId: string,
1294
+ action: { type: "approval_response"; callId: string; approved: boolean } | { type: "tool_result"; callId: string; result: unknown },
1295
+ ) => {
1296
+ if (!isActiveConversation(conversationId) || state.activeTurn?.turnId !== turnId) return;
1297
+ const waitingStatus = action.type === "approval_response" ? "awaiting_approval" : "awaiting_client";
1298
+ setState("activeTurn", (current) => {
1299
+ if (!current || current.turnId !== turnId) return current;
1300
+ const pending = current.blocks.some(
1301
+ (block) => block.kind === "tool" && block.callId === action.callId && block.status === waitingStatus,
1302
+ );
1303
+ if (!pending) return current;
1304
+ return reconcileActiveTurnActions(current, [{ callId: action.callId, resolvedEvent: action }]);
1305
+ });
1306
+ cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1307
+ };
1308
+
1261
1309
  const submitTurnAction = (
1262
1310
  turnId: string,
1263
1311
  callId: string,
@@ -1277,18 +1325,9 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1277
1325
  approved: input.approved,
1278
1326
  remember: input.remember,
1279
1327
  });
1280
- if (!submitted || !isActiveConversation(conversationId) || state.activeTurn?.turnId !== request.turnId) return submitted;
1281
- const action = { type: "approval_response" as const, callId: request.callId, approved: input.approved };
1282
- setState("activeTurn", (current) => {
1283
- if (!current || current.turnId !== request.turnId) return current;
1284
- const pending = current.blocks.some(
1285
- (block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_approval",
1286
- );
1287
- if (!pending) return current;
1288
- return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
1289
- });
1290
- cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1291
- return true;
1328
+ if (submitted)
1329
+ showActionResolved(conversationId, request.turnId, { type: "approval_response", callId: request.callId, approved: input.approved });
1330
+ return submitted;
1292
1331
  };
1293
1332
 
1294
1333
  const submitFrontendToolResult = async (request: { turnId: string; callId: string }, result: unknown) => {
@@ -1299,18 +1338,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1299
1338
  type: "tool_result",
1300
1339
  result,
1301
1340
  });
1302
- if (!submitted || !isActiveConversation(conversationId) || state.activeTurn?.turnId !== request.turnId) return submitted;
1303
- const action = { type: "tool_result" as const, callId: request.callId, result };
1304
- setState("activeTurn", (current) => {
1305
- if (!current || current.turnId !== request.turnId) return current;
1306
- const pending = current.blocks.some(
1307
- (block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_client",
1308
- );
1309
- if (!pending) return current;
1310
- return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
1311
- });
1312
- cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1313
- return true;
1341
+ if (submitted) showActionResolved(conversationId, request.turnId, { type: "tool_result", callId: request.callId, result });
1342
+ return submitted;
1314
1343
  };
1315
1344
 
1316
1345
  if (options.initialConversationId) {
@@ -30,7 +30,14 @@ const bytesToBase64 = (bytes: Uint8Array): string => {
30
30
  return btoa(binary);
31
31
  };
32
32
 
33
- export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource & { listFiles(): Promise<AiFileStat[]> } => {
33
+ export type ConversationFileSource = FileSource & {
34
+ /** Files below `prefix` (default `/`); with `page`, at most `page.limit` files by path after `page.after`. */
35
+ listFiles(prefix?: string, page?: { limit: number; after?: string }): Promise<AiFileStat[]>;
36
+ /** Delete a folder with everything below it. */
37
+ removeFolder(path: string): Promise<void>;
38
+ };
39
+
40
+ export const conversationFileSource = (baseUrl: string, conversationId: string): ConversationFileSource => {
34
41
  const filesUrl = (suffix = "", params?: Record<string, string>) => {
35
42
  const query = params ? `?${new URLSearchParams(params).toString()}` : "";
36
43
  return `${baseUrl}/conversations/${conversationId}/files${suffix}${query}`;
@@ -45,8 +52,14 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
45
52
  return (await response.json()) as T;
46
53
  };
47
54
 
48
- const listFiles = async (): Promise<AiFileStat[]> =>
49
- (await request<{ files: AiFileStat[] }>(filesUrl(), { method: "GET" }, "Failed to load files")).files;
55
+ const listFiles = async (prefix?: string, page?: { limit: number; after?: string }): Promise<AiFileStat[]> => {
56
+ const params: Record<string, string> = {
57
+ ...(prefix ? { prefix } : {}),
58
+ ...(page ? { limit: String(page.limit), ...(page.after ? { after: page.after } : {}) } : {}),
59
+ };
60
+ const url = Object.keys(params).length ? filesUrl("", params) : filesUrl();
61
+ return (await request<{ files: AiFileStat[] }>(url, { method: "GET" }, "Failed to load files")).files;
62
+ };
50
63
 
51
64
  return {
52
65
  previewPreferencesKey: "assistant.csv-preview",
@@ -80,6 +93,10 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
80
93
  await request(filesUrl("", { path }), { method: "DELETE" }, "Failed to delete file");
81
94
  },
82
95
 
96
+ async removeFolder(path) {
97
+ await request(filesUrl("", { path, recursive: "true" }), { method: "DELETE" }, "Failed to delete folder");
98
+ },
99
+
83
100
  async rename(from, to) {
84
101
  await request(filesUrl("/rename"), { method: "POST", body: JSON.stringify({ from, to }) }, "Failed to rename file");
85
102
  },
@@ -9,6 +9,17 @@ export type AiActiveTurn = {
9
9
  status: "running" | "waiting_for_action";
10
10
  blocks: AiTurnBlock[];
11
11
  modelProfileId: string | null;
12
+ /**
13
+ * A model call failed transiently and waits for its retry. Live only: the next turn event ends it, and so does a
14
+ * snapshot that has moved past the retry.
15
+ */
16
+ providerRetry?: boolean;
17
+ /** Server start of the turn, known from a state snapshot. */
18
+ createdAt?: string;
19
+ /** Time already spent waiting for answered user actions, from the last state snapshot. */
20
+ actionWaitMs?: number;
21
+ /** Start of the user action the turn waited for at the last state snapshot. */
22
+ waitingSince?: string | null;
12
23
  };
13
24
 
14
25
  export type AiChatProjection = {
@@ -90,7 +101,9 @@ export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiv
90
101
  const pendingSteers = previous.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed");
91
102
  const known = new Set(incoming.blocks.map((block) => block.id));
92
103
  const blocks = [...incoming.blocks, ...pendingSteers.filter((block) => !known.has(block.id))];
93
- return { ...incoming, blocks, status: deriveStatus(blocks) };
104
+ // A snapshot never carries a provider retry. One at the retry's own position has seen nothing after it, so the wait goes on.
105
+ const retry = previous.providerRetry && incoming.seq === previous.seq ? { providerRetry: true } : {};
106
+ return { ...turnTiming(previous), ...incoming, ...retry, blocks, status: deriveStatus(blocks) };
94
107
  };
95
108
 
96
109
  export const reconcileActiveTurnActions = (
@@ -112,9 +125,22 @@ export const activeTurnFromSnapshot = (snapshot: AiTurnSnapshot | null): AiActiv
112
125
  status: snapshot.status === "waiting_for_action" ? "waiting_for_action" : "running",
113
126
  blocks: normalizeCustomApprovalBlocks(snapshot.blocks),
114
127
  modelProfileId: snapshot.modelProfileId,
128
+ createdAt: snapshot.createdAt,
129
+ ...(snapshot.actionWaitMs !== undefined ? { actionWaitMs: snapshot.actionWaitMs } : {}),
130
+ ...(snapshot.waitingSince !== undefined ? { waitingSince: snapshot.waitingSince } : {}),
115
131
  };
116
132
  };
117
133
 
134
+ /** Snapshot timing survives later attempts of the same turn, which carry no timing of their own. */
135
+ const turnTiming = (turn: AiActiveTurn | null | undefined): Pick<AiActiveTurn, "createdAt" | "actionWaitMs" | "waitingSince"> =>
136
+ turn
137
+ ? {
138
+ ...(turn.createdAt !== undefined ? { createdAt: turn.createdAt } : {}),
139
+ ...(turn.actionWaitMs !== undefined ? { actionWaitMs: turn.actionWaitMs } : {}),
140
+ ...(turn.waitingSince !== undefined ? { waitingSince: turn.waitingSince } : {}),
141
+ }
142
+ : {};
143
+
118
144
  /**
119
145
  * Fold one stream event into the projection. Pure and total: the client and any
120
146
  * test converge on the same state for the same ordered event sequence.
@@ -125,6 +151,7 @@ export const activeTurnFromSnapshot = (snapshot: AiTurnSnapshot | null): AiActiv
125
151
  * stale attempts are ignored. Older senders without a baseline reset to only
126
152
  * locally pending steering blocks.
127
153
  * - block events apply only when strictly newer than the active turn's cursor.
154
+ * - `provider_retry` marks the active turn until its next event.
128
155
  * - `turn_finished` folds the turn's persisted messages in and clears the active turn.
129
156
  */
130
157
  export const reduceProjection = (state: AiChatProjection, event: AiStreamEvent): AiChatProjection => {
@@ -146,6 +173,8 @@ export const reduceProjection = (state: AiChatProjection, event: AiStreamEvent):
146
173
  return reduceWireEvent(state, event);
147
174
  };
148
175
 
176
+ const withoutProviderRetry = ({ providerRetry: _, ...turn }: AiActiveTurn): AiActiveTurn => turn;
177
+
149
178
  export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): AiChatProjection => {
150
179
  const active = state.activeTurn;
151
180
 
@@ -163,6 +192,7 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
163
192
  status: deriveStatus(blocks),
164
193
  blocks,
165
194
  modelProfileId: event.modelProfileId,
195
+ ...turnTiming(active?.turnId === event.turnId ? active : null),
166
196
  },
167
197
  };
168
198
  }
@@ -172,20 +202,26 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
172
202
  return { ...state, messages: mergeMessages(state.messages, event.messages ?? []), activeTurn: null };
173
203
  }
174
204
 
205
+ if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
206
+
207
+ if (event.type === "provider_retry") {
208
+ return { ...state, activeTurn: { ...active, seq: event.seq, attempt: event.attempt, providerRetry: true } };
209
+ }
210
+
175
211
  if (event.type === "message_saved") {
176
- if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
177
212
  return {
178
213
  ...state,
179
214
  messages: mergeMessages(state.messages, [event.message]),
180
- activeTurn: { ...active, seq: event.seq, attempt: event.attempt },
215
+ activeTurn: { ...withoutProviderRetry(active), seq: event.seq, attempt: event.attempt },
181
216
  };
182
217
  }
183
218
 
184
219
  // block_set / block_delta
185
- if (!active || active.turnId !== event.turnId) return state;
186
- if (!isNewerWireEvent(event, active)) return state;
187
220
  const blocks = applyWireEventToBlocks(active.blocks, event);
188
- return { ...state, activeTurn: { ...active, attempt: event.attempt, seq: event.seq, blocks, status: deriveStatus(blocks) } };
221
+ return {
222
+ ...state,
223
+ activeTurn: { ...withoutProviderRetry(active), attempt: event.attempt, seq: event.seq, blocks, status: deriveStatus(blocks) },
224
+ };
189
225
  };
190
226
 
191
227
  /** Assistant/tool messages of the active turn are represented by live blocks; hide them. */