@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
@@ -31,6 +31,13 @@ type FileRow = {
31
31
  type FileContentRow = FileRow & { bytes: Uint8Array };
32
32
  export type AiFileContent = AiFileStat & { bytes: Uint8Array };
33
33
 
34
+ /** `LIKE` pattern for every path below a folder; the folder name itself matches literally. */
35
+ const pathsBelow = (folder: string): string => `${folder.endsWith("/") ? folder : `${folder}/`}`.replace(/[\\%_]/gu, "\\$&") + "%";
36
+
37
+ /** Working files of a conversation live below this folder; the assistant keeps intermediate steps there. */
38
+ export const AI_WORKING_FILES_FOLDER = "/temp/";
39
+ export const isAiWorkingFilePath = (path: string): boolean => path.startsWith(AI_WORKING_FILES_FOLDER);
40
+
34
41
  const iso = (value: Date | string): string => (value instanceof Date ? value.toISOString() : new Date(value).toISOString());
35
42
 
36
43
  const toStat = (row: FileRow): AiFileStat => ({
@@ -246,7 +253,7 @@ export const aiFileStore = {
246
253
  if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000))
247
254
  throw new Error("Invalid file page limit");
248
255
  const prefix = input.prefix ?? "/";
249
- const pattern = `${prefix.endsWith("/") ? prefix : `${prefix}/`}%`;
256
+ const pattern = pathsBelow(prefix);
250
257
  const rows = await sql<FileRow[]>`
251
258
  SELECT path, size, media_type, origin, dictation_recorded_at, updated_at, version
252
259
  FROM ai.files
@@ -467,20 +474,17 @@ export const aiFileStore = {
467
474
  return sql.begin(async (tx) => {
468
475
  await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
469
476
  const path = await storedAiFilePath(tx, input.conversationId, input.path);
470
- if (input.recursive) {
471
- const pattern = `${path.endsWith("/") ? path : `${path}/`}%`;
472
- const rows = await tx<{ id: string }[]>`
473
- DELETE FROM ai.files
474
- WHERE conversation_id = ${input.conversationId} AND (path = ${path} OR path LIKE ${pattern})
475
- RETURNING id
476
- `;
477
- return rows.length;
478
- }
477
+ const pattern = input.recursive ? pathsBelow(path) : null;
479
478
  const rows = await tx<{ id: string }[]>`
480
479
  DELETE FROM ai.files
481
- WHERE conversation_id = ${input.conversationId} AND path = ${path}
480
+ WHERE conversation_id = ${input.conversationId} AND (path = ${path} OR path LIKE ${pattern})
482
481
  RETURNING id
483
482
  `;
483
+ // A result is the file it delivered. Once that file is gone, another file at the same path is not that result.
484
+ await tx`
485
+ DELETE FROM ai.conversation_sources
486
+ WHERE conversation_id = ${input.conversationId} AND kind = 'result' AND (source_key = ${path} OR source_key LIKE ${pattern})
487
+ `;
484
488
  return rows.length;
485
489
  });
486
490
  },
@@ -498,6 +502,19 @@ export const aiFileStore = {
498
502
  `;
499
503
  if (target[0]) return "conflict" as const;
500
504
  await tx`UPDATE ai.files SET path = ${input.to}, updated_at = now(), version = version + 1 WHERE id = ${source[0].id}::uuid`;
505
+ // A delivered result is the file, not its old name. A result row left at the new name belonged to a file that
506
+ // is gone and must not attach to this one; the moved file's own delivery, if any, moves there.
507
+ await tx`
508
+ DELETE FROM ai.conversation_sources
509
+ WHERE conversation_id = ${input.conversationId} AND kind = 'result' AND source_key = ${input.to}
510
+ `;
511
+ // A title that was only the old file name follows the new one; a title the assistant chose stays.
512
+ await tx`
513
+ UPDATE ai.conversation_sources
514
+ SET source_key = ${input.to},
515
+ title = CASE WHEN title = ${from.slice(from.lastIndexOf("/") + 1)} THEN ${input.to.slice(input.to.lastIndexOf("/") + 1)} ELSE title END
516
+ WHERE conversation_id = ${input.conversationId} AND kind = 'result' AND source_key = ${from}
517
+ `;
501
518
  return "renamed" as const;
502
519
  });
503
520
  },
@@ -534,6 +551,112 @@ export const aiFileStore = {
534
551
  },
535
552
  };
536
553
 
554
+ export type AiConversationWorkingGroup = {
555
+ /** The first folder below `/temp/`; null for files directly in `/temp/`. */
556
+ folder: string | null;
557
+ count: number;
558
+ bytes: number;
559
+ updatedAt: string;
560
+ /** Number of files per media type. */
561
+ mediaTypes: Record<string, number>;
562
+ };
563
+
564
+ export type AiConversationFileOverview = {
565
+ /** Uploads and other files outside `/temp/`, newest first. */
566
+ files: AiFileStat[];
567
+ fileCount: number;
568
+ /** Working files below `/temp/`, one group per first subfolder, most recently changed first. */
569
+ groups: AiConversationWorkingGroup[];
570
+ groupCount: number;
571
+ /** Working files directly in `/temp/`, outside any group folder, newest first. */
572
+ looseWorkingFiles: AiFileStat[];
573
+ workingCount: number;
574
+ workingBytes: number;
575
+ /** Storage the conversation uses against its limit, dictation recordings included. */
576
+ usedBytes: number;
577
+ maxBytes: number;
578
+ };
579
+
580
+ /**
581
+ * A bounded view of a conversation's files for interfaces: files outside the working folder by name, working files
582
+ * as groups with counts. Load the files of a group with `listAiConversationFiles` and its folder as prefix.
583
+ * Authorized services may expose this after resolving the conversation owner.
584
+ */
585
+ export const loadAiConversationFileOverview = async (
586
+ conversationId: string,
587
+ limits: { files: number; groups: number; looseWorkingFiles: number } = { files: 500, groups: 200, looseWorkingFiles: 100 },
588
+ ): Promise<AiConversationFileOverview> => {
589
+ const working = pathsBelow(AI_WORKING_FILES_FOLDER);
590
+ const [files, [counts], groups, looseWorkingFiles, usedBytes] = await Promise.all([
591
+ sql<FileRow[]>`
592
+ SELECT path, size, media_type, origin, dictation_recorded_at, updated_at, version
593
+ FROM ai.files
594
+ WHERE conversation_id = ${conversationId} AND path NOT LIKE ${working}
595
+ ORDER BY updated_at DESC, path ASC
596
+ LIMIT ${limits.files}
597
+ `,
598
+ sql<{ files: number | string; working: number | string; working_bytes: number | string }[]>`
599
+ SELECT count(*) FILTER (WHERE path NOT LIKE ${working}) AS files,
600
+ count(*) FILTER (WHERE path LIKE ${working}) AS working,
601
+ COALESCE(sum(size) FILTER (WHERE path LIKE ${working}), 0) AS working_bytes
602
+ FROM ai.files
603
+ WHERE conversation_id = ${conversationId}
604
+ `,
605
+ sql<
606
+ {
607
+ folder: string | null;
608
+ count: number | string;
609
+ bytes: number | string;
610
+ updated_at: Date | string;
611
+ media_types: Record<string, number | string>;
612
+ group_count: number | string;
613
+ }[]
614
+ >`
615
+ WITH working AS (
616
+ SELECT CASE WHEN path ~ '^/temp/[^/]+/' THEN split_part(path, '/', 3) END AS folder, media_type, size, updated_at
617
+ FROM ai.files
618
+ WHERE conversation_id = ${conversationId} AND path LIKE ${working}
619
+ ),
620
+ by_type AS (
621
+ SELECT folder, media_type, count(*) AS files, sum(size) AS bytes, max(updated_at) AS updated_at
622
+ FROM working
623
+ GROUP BY folder, media_type
624
+ )
625
+ SELECT folder, sum(files) AS count, sum(bytes) AS bytes, max(updated_at) AS updated_at,
626
+ jsonb_object_agg(media_type, files) AS media_types, count(*) OVER () AS group_count
627
+ FROM by_type
628
+ GROUP BY folder
629
+ ORDER BY max(updated_at) DESC, folder ASC NULLS LAST
630
+ LIMIT ${limits.groups}
631
+ `,
632
+ sql<FileRow[]>`
633
+ SELECT path, size, media_type, origin, dictation_recorded_at, updated_at, version
634
+ FROM ai.files
635
+ WHERE conversation_id = ${conversationId} AND path LIKE ${working} AND path !~ '^/temp/[^/]+/'
636
+ ORDER BY updated_at DESC, path ASC
637
+ LIMIT ${limits.looseWorkingFiles}
638
+ `,
639
+ aiConversationStoredBytes(sql, conversationId),
640
+ ]);
641
+ return {
642
+ files: files.map(toStat),
643
+ fileCount: Number(counts?.files ?? 0),
644
+ groups: groups.map((group) => ({
645
+ folder: group.folder,
646
+ count: Number(group.count),
647
+ bytes: Number(group.bytes),
648
+ updatedAt: iso(group.updated_at),
649
+ mediaTypes: Object.fromEntries(Object.entries(group.media_types).map(([type, count]) => [type, Number(count)])),
650
+ })),
651
+ groupCount: Number(groups[0]?.group_count ?? 0),
652
+ looseWorkingFiles: looseWorkingFiles.map(toStat),
653
+ workingCount: Number(counts?.working ?? 0),
654
+ workingBytes: Number(counts?.working_bytes ?? 0),
655
+ usedBytes,
656
+ maxBytes: AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
657
+ };
658
+ };
659
+
537
660
  /** Authorized services may expose this read after resolving the conversation owner. */
538
661
  export const listAiConversationFiles = (
539
662
  conversationId: string,
@@ -153,6 +153,6 @@ A left join preserves unmatched readable source records. A relation with many ta
153
153
 
154
154
  ## Read stored document files
155
155
 
156
- For download links, use the downloadUrl returned by document.list/read/create verbatim; each artifacts[] entry carries its own downloadUrl. Every format (PDF, CSV, JSON, XML, renderer artifacts) uses the same links. They require the user's current Cloud session and are not a public share. Studio/code tools needing file bytes use document.content.read plus capabilities.streams.read; do not fetch invented paths.
156
+ For download links, use the downloadUrl returned by document.list/read/create verbatim; each artifacts[] entry carries its own downloadUrl. Every format (PDF, CSV, JSON, XML, renderer artifacts) uses the same links. They require the user's current Cloud session and are not a public share. Studio/code tools needing file bytes use document.content.read plus cloud.capabilities.streams.read; do not fetch invented paths.
157
157
 
158
- Use \`grids.document.read\` for metadata and available artifact keys. When the task needs actual PDF, XML or CSV bytes, use \`grids.document.content.read\` with the document ID and optional artifactKey; omission selects the primary artifact. In code mode, obtain the stream via capabilities.run in the current run and pass it to capabilities.streams.read to receive a File. Read XML/CSV as text or use an available PDF processor. A binary download alone does not extract PDF text or prove that you inspected its contents. Prefer GQL for structured data analysis. This is a read, not document issuance, a public share or sending a file. Respect the 50 MiB code-mode payload budget and 250 MiB total transfer budget; larger stored artifacts require a supported HTTP/CLI transfer. Streams expire, require current permissions and cannot be reused across turns or in mandate-backed background tasks. Never invent stream references or export capabilities.`;
158
+ Use \`grids.document.read\` for metadata and available artifact keys. When the task needs actual PDF, XML or CSV bytes, use \`grids.document.content.read\` with the document ID and optional artifactKey; omission selects the primary artifact. In code mode, obtain the stream via cloud.capabilities.run in the current run and pass it to cloud.capabilities.streams.read to receive a File. Read XML/CSV as text or use an available PDF processor. A binary download alone does not extract PDF text or prove that you inspected its contents. Prefer GQL for structured data analysis. This is a read, not document issuance, a public share or sending a file. Respect the 50 MiB code-mode payload budget and 250 MiB total transfer budget; larger stored artifacts require a supported HTTP/CLI transfer. Streams expire, require current permissions and cannot be reused across turns or in mandate-backed background tasks. Never invent stream references or export capabilities.`;
package/src/ai/index.ts CHANGED
@@ -75,14 +75,20 @@ export {
75
75
  shouldApplyEnrichedTitle,
76
76
  } from "./enrich";
77
77
  export { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
78
+ export { aiConversationFileManifest } from "./file-context";
78
79
  export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
79
80
  export {
80
81
  AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
81
82
  AI_FILES_MAX_FILE_BYTES_DEFAULT,
83
+ AI_WORKING_FILES_FOLDER,
84
+ type AiConversationFileOverview,
85
+ type AiConversationWorkingGroup,
82
86
  type AiFileStat,
83
87
  createAiConversationArtifact,
84
88
  guessAiMediaType,
89
+ isAiWorkingFilePath,
85
90
  listAiConversationFiles,
91
+ loadAiConversationFileOverview,
86
92
  normalizeAiFilePath,
87
93
  readAiConversationFile,
88
94
  writeAiConversationFile,
@@ -245,6 +251,7 @@ export {
245
251
  validateAiSkillReferences,
246
252
  } from "./skill-format";
247
253
  export {
254
+ type AiConversationSkillUse,
248
255
  type AiLoadedSkillSnapshot,
249
256
  type AiSkill,
250
257
  type AiSkillAccess,
@@ -206,6 +206,20 @@ export const formatAiMemories = (
206
206
  export const aiMemories = {
207
207
  list: search,
208
208
 
209
+ /** Active memories saved from one of the user's chats, newest first. */
210
+ async listFromConversation(userId: string, conversationId: string, limit = 50): Promise<AiMemory[]> {
211
+ const rows = await sql<MemoryRow[]>`
212
+ SELECT id, short_id, user_id, kind, content, priority, source, source_conversation_id, source_message_id,
213
+ resource_type, resource_id, created_at, updated_at
214
+ FROM ai.memories
215
+ WHERE user_id = ${userId}::uuid AND source_conversation_id = ${conversationId}::uuid
216
+ AND deleted_at IS NULL AND superseded_by_id IS NULL
217
+ ORDER BY updated_at DESC, id ASC
218
+ LIMIT ${Math.min(Math.max(Math.floor(limit), 1), 100)}
219
+ `;
220
+ return rows.map(toMemory);
221
+ },
222
+
209
223
  async resolveSourceConversationShortIds(userId: string, conversationIds: string[]): Promise<Map<string, string>> {
210
224
  if (conversationIds.length === 0) return new Map();
211
225
  const rows = await sql<{ id: string; short_id: string }[]>`
package/src/ai/migrate.ts CHANGED
@@ -393,6 +393,12 @@ export const migrateCloudAi = async (): Promise<void> => {
393
393
  ON ai.messages(conversation_id, loop_id, seq ASC)
394
394
  WHERE compacted_at IS NULL AND loop_id IS NOT NULL
395
395
  `.simple();
396
+ // Chat positions of sources and results, also in turns that compaction archived; archived messages stay visible.
397
+ await sql`
398
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation_loop_seq
399
+ ON ai.messages(conversation_id, loop_id, seq ASC)
400
+ WHERE loop_id IS NOT NULL
401
+ `.simple();
396
402
 
397
403
  // Optional pg_textsearch ranking. Native weighted FTS above is always the
398
404
  // correctness path; missing extension support must never block startup.
@@ -559,6 +565,13 @@ export const migrateCloudAi = async (): Promise<void> => {
559
565
  ON ai.conversation_sources(conversation_id, last_seen_at DESC, kind, source_key)
560
566
  `.simple();
561
567
 
568
+ // A delivered result may be a Cloud resource, for example an app opened with code_open.
569
+ await sql`
570
+ ALTER TABLE ai.conversation_sources
571
+ ADD COLUMN IF NOT EXISTS resource_type TEXT,
572
+ ADD COLUMN IF NOT EXISTS resource_id TEXT
573
+ `.simple();
574
+
562
575
  await sql`
563
576
  CREATE TABLE IF NOT EXISTS ai.turn_steers (
564
577
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1272,6 +1285,109 @@ export const migrateCloudAi = async (): Promise<void> => {
1272
1285
  END $$
1273
1286
  `.simple();
1274
1287
 
1288
+ // Deliveries become `result` sources. The kind check widens once, and then the deliveries already stored in chat
1289
+ // history are indexed once, so existing chats show their results from the start; this needs ai.files above. The
1290
+ // widened check stays NOT VALID until that backfill committed: validating it marks the backfill done, and a start
1291
+ // that stopped in between repeats it. The table is locked exclusively only while the check changes; the scan of the
1292
+ // history leaves it readable.
1293
+ const [kindCheck] = await sql<{ definition: string; validated: boolean }[]>`
1294
+ SELECT pg_get_constraintdef(oid) AS definition, convalidated AS validated FROM pg_constraint
1295
+ WHERE conname = 'ai_conversation_sources_kind_check' AND conrelid = 'ai.conversation_sources'::regclass
1296
+ `;
1297
+ const resultsAllowed = kindCheck?.definition.includes("'result'") === true;
1298
+ if (!resultsAllowed)
1299
+ await sql.begin(async (tx) => {
1300
+ await tx`ALTER TABLE ai.conversation_sources DROP CONSTRAINT IF EXISTS ai_conversation_sources_kind_check`.simple();
1301
+ await tx`
1302
+ ALTER TABLE ai.conversation_sources
1303
+ ADD CONSTRAINT ai_conversation_sources_kind_check CHECK (kind IN ('result', 'web', 'activity')) NOT VALID
1304
+ `.simple();
1305
+ });
1306
+ if (!resultsAllowed || !kindCheck?.validated) {
1307
+ await sql.begin(async (tx) => {
1308
+ // History is not news: the backfill must not queue one live event per indexed row.
1309
+ const liveTrigger = (state: "ENABLE" | "DISABLE") =>
1310
+ tx.unsafe(`
1311
+ DO $$ BEGIN
1312
+ IF EXISTS (
1313
+ SELECT 1 FROM pg_trigger
1314
+ WHERE tgname = 'ai_live_conversation_sources_changed' AND tgrelid = 'ai.conversation_sources'::regclass
1315
+ ) THEN
1316
+ ALTER TABLE ai.conversation_sources ${state} TRIGGER ai_live_conversation_sources_changed;
1317
+ END IF;
1318
+ END $$
1319
+ `);
1320
+ await liveTrigger("DISABLE");
1321
+ await tx`
1322
+ WITH results AS (
1323
+ SELECT message.conversation_id, message.loop_id, message.seq, message.created_at,
1324
+ CASE WHEN jsonb_typeof(message.message) = 'string' THEN (message.message #>> '{}')::jsonb ELSE message.message END AS payload
1325
+ FROM ai.messages message
1326
+ WHERE message.role = 'tool_result' AND message.kind = 'message'
1327
+ ),
1328
+ delivered AS (
1329
+ SELECT result.*, result.payload ->> 'callId' AS call_id, result.payload ->> 'name' AS tool_name,
1330
+ result.payload -> 'result' AS output,
1331
+ (SELECT block -> 'args'
1332
+ FROM ai.messages call,
1333
+ jsonb_array_elements(CASE WHEN jsonb_typeof(call.message) = 'string' THEN (call.message #>> '{}')::jsonb ELSE call.message END -> 'content') block
1334
+ WHERE call.conversation_id = result.conversation_id AND call.loop_id = result.loop_id AND call.role = 'assistant'
1335
+ AND block ->> 'type' = 'tool_call' AND block ->> 'id' = result.payload ->> 'callId'
1336
+ LIMIT 1) AS args
1337
+ FROM results result
1338
+ WHERE result.payload ->> 'name' IN ('present', 'code_open', 'code_present')
1339
+ AND COALESCE(result.payload ->> 'isError', 'false') <> 'true'
1340
+ AND jsonb_typeof(result.payload -> 'result') = 'object'
1341
+ -- Browser tools report a failure as a result with an error.
1342
+ AND NOT (result.payload -> 'result' ? 'error')
1343
+ ),
1344
+ indexed AS (
1345
+ SELECT DISTINCT ON (delivered.conversation_id, source_key)
1346
+ delivered.conversation_id, source_key, title, preview, icon, resource_type, resource_id,
1347
+ delivered.loop_id, delivered.call_id, delivered.created_at
1348
+ FROM delivered,
1349
+ LATERAL (
1350
+ SELECT
1351
+ CASE delivered.tool_name
1352
+ WHEN 'present' THEN delivered.output ->> 'path'
1353
+ WHEN 'code_open' THEN 'assistant.artifact:' || (delivered.args ->> 'id')
1354
+ ELSE 'code_present:' || delivered.call_id
1355
+ END AS source_key,
1356
+ CASE delivered.tool_name
1357
+ WHEN 'present' THEN COALESCE(NULLIF(btrim(delivered.args ->> 'title'), ''), regexp_replace(delivered.output ->> 'path', '^.*/', ''))
1358
+ WHEN 'code_open' THEN 'Studio app'
1359
+ ELSE COALESCE(NULLIF(btrim(delivered.args ->> 'title'), ''), NULLIF(btrim(delivered.output ->> 'title'), ''), 'Visualization')
1360
+ END AS title,
1361
+ CASE WHEN delivered.tool_name = 'present' THEN NULLIF(btrim(delivered.args ->> 'description'), '') END AS preview,
1362
+ CASE delivered.tool_name WHEN 'present' THEN 'ti ti-file' WHEN 'code_open' THEN 'ti ti-app-window' ELSE 'ti ti-chart-dots' END AS icon,
1363
+ CASE WHEN delivered.tool_name = 'code_open' THEN 'assistant.artifact' END AS resource_type,
1364
+ CASE WHEN delivered.tool_name = 'code_open' THEN delivered.args ->> 'id' END AS resource_id
1365
+ ) shape
1366
+ WHERE (delivered.tool_name <> 'present' OR delivered.output ->> 'path' LIKE '/%')
1367
+ -- A result is the file it delivered: a file deleted since then is not one, and its path may hold another.
1368
+ AND (delivered.tool_name <> 'present' OR EXISTS (
1369
+ SELECT 1 FROM ai.files file
1370
+ WHERE file.conversation_id = delivered.conversation_id AND file.path = delivered.output ->> 'path'
1371
+ ))
1372
+ AND (delivered.tool_name <> 'code_open' OR NULLIF(delivered.args ->> 'id', '') IS NOT NULL)
1373
+ AND (delivered.tool_name <> 'code_present' OR delivered.output ? 'presentationId')
1374
+ ORDER BY delivered.conversation_id, source_key, delivered.seq DESC
1375
+ )
1376
+ INSERT INTO ai.conversation_sources (
1377
+ conversation_id, kind, source_key, title, preview, icon, resource_type, resource_id,
1378
+ source_turn_id, source_call_id, first_seen_at, last_seen_at
1379
+ )
1380
+ SELECT indexed.conversation_id, 'result', indexed.source_key, indexed.title, indexed.preview, indexed.icon,
1381
+ indexed.resource_type, indexed.resource_id, turn.id, indexed.call_id, indexed.created_at, indexed.created_at
1382
+ FROM indexed
1383
+ LEFT JOIN ai.turns turn ON turn.id::text = indexed.loop_id AND turn.conversation_id = indexed.conversation_id
1384
+ ON CONFLICT (conversation_id, kind, source_key) DO NOTHING
1385
+ `.simple();
1386
+ await liveTrigger("ENABLE");
1387
+ });
1388
+ await sql`ALTER TABLE ai.conversation_sources VALIDATE CONSTRAINT ai_conversation_sources_kind_check`.simple();
1389
+ }
1390
+
1275
1391
  // Inline image parts were an alpha transport detail. Move their bytes into
1276
1392
  // the authorized conversation file store and leave only stable references in
1277
1393
  // message history. Re-running is idempotent because the path is derived from
@@ -2209,6 +2325,15 @@ export const migrateCloudAi = async (): Promise<void> => {
2209
2325
  )
2210
2326
  `.simple();
2211
2327
 
2328
+ await sql`
2329
+ CREATE TABLE IF NOT EXISTS ai.model_request_headers (
2330
+ profile_id TEXT PRIMARY KEY,
2331
+ secret TEXT NOT NULL,
2332
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2333
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
2334
+ )
2335
+ `.simple();
2336
+
2212
2337
  // One-time move of keys that were stored inside the profiles setting. Once
2213
2338
  // the blob holds no apiKey the split yields nothing and this is a no-op, so
2214
2339
  // it is safe to re-run. Dynamic imports keep the module free of store deps.
@@ -0,0 +1,98 @@
1
+ import { sql } from "bun";
2
+ import { coreSettings } from "../services";
3
+ import { audit } from "../services/audit";
4
+ import { invalidateSettingsCache, set as setSetting } from "../services/settings";
5
+ import { decryptValue } from "../services/settings/crypto";
6
+ import {
7
+ type AiModelRequestSettings,
8
+ type AiModelRequestSettingsUpdate,
9
+ AiModelRequestSettingsUpdateSchema,
10
+ } from "../shared/ai-model-request-settings";
11
+ import { AiQuotaError } from "./quotas";
12
+ import { getAiRequestHeaders, planAiProfileRequestHeaders, storeAiRequestHeaderPlan } from "./request-headers";
13
+ import { parseAiModelProfiles } from "./settings";
14
+ import type { AiModelProfile } from "./types";
15
+
16
+ const key = "ai.model_profiles_json";
17
+ export class AiModelRequestSettingsInvalid extends Error {}
18
+
19
+ const readSnapshot = async (id: string, fallback: string, db: typeof sql, lock = false) => {
20
+ const rows = lock
21
+ ? await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key} FOR UPDATE`
22
+ : await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key}`;
23
+ const raw = rows[0] ? await decryptValue(rows[0].value) : fallback;
24
+ if (typeof raw !== "string") throw new AiModelRequestSettingsInvalid("Invalid model configuration.");
25
+ const parsed = parseAiModelProfiles(raw);
26
+ if (parsed.error) throw new AiModelRequestSettingsInvalid(parsed.error.fields?.[key] ?? parsed.error.message);
27
+ const profile = parsed.profiles.find((profile) => profile.id === id);
28
+ if (!profile) throw new AiQuotaError("quota_conflict", "Model no longer exists.");
29
+ const [headerRow] = await db<{ secret: string }[]>`SELECT secret FROM ai.model_request_headers WHERE profile_id=${id}`;
30
+ // Hash ciphertext, never secret values. Any settings/header change invalidates
31
+ // the snapshot, including replacement of a value under an unchanged name.
32
+ const revision = new Bun.CryptoHasher("sha256").update(JSON.stringify([rows[0]?.value ?? raw, headerRow?.secret ?? null])).digest("hex");
33
+ const requestHeaderNames = Object.keys(await getAiRequestHeaders(id, db)).sort();
34
+ return { profiles: parsed.profiles, profile, settings: present(profile, requestHeaderNames, revision) };
35
+ };
36
+ const present = (profile: AiModelProfile, requestHeaderNames: string[], revision: string): AiModelRequestSettings => ({
37
+ id: profile.id,
38
+ reasoningEffort: profile.reasoningEffort ?? null,
39
+ extraBody: profile.extraBody ?? null,
40
+ requestHeaderNames,
41
+ revision,
42
+ });
43
+
44
+ export const getAiModelRequestSettings = async (id: string): Promise<AiModelRequestSettings> =>
45
+ (await readSnapshot(id, await coreSettings.get<string>(key), sql)).settings;
46
+
47
+ /** One transaction changes the non-secret profile and encrypted headers; audit contains names only. */
48
+ export const setAiModelRequestSettings = async (
49
+ id: string,
50
+ input: AiModelRequestSettingsUpdate,
51
+ actorId: string,
52
+ ): Promise<AiModelRequestSettings> => {
53
+ const data = AiModelRequestSettingsUpdateSchema.parse(input);
54
+ const fallback = await coreSettings.get<string>(key);
55
+ const result = await sql.begin(async (db) => {
56
+ const current = await readSnapshot(id, fallback, db, true);
57
+ if (current.settings.revision !== data.expected)
58
+ throw new AiQuotaError("quota_conflict", "Model request settings changed. Read them again before updating.");
59
+ const nextProfile = { ...current.profile };
60
+ if (Object.hasOwn(data, "reasoningEffort")) {
61
+ if (data.reasoningEffort) nextProfile.reasoningEffort = data.reasoningEffort;
62
+ else delete nextProfile.reasoningEffort;
63
+ }
64
+ if (Object.hasOwn(data, "extraBody")) {
65
+ if (data.extraBody) nextProfile.extraBody = data.extraBody;
66
+ else delete nextProfile.extraBody;
67
+ }
68
+ const validated = parseAiModelProfiles(JSON.stringify([nextProfile]));
69
+ if (validated.error) throw new AiModelRequestSettingsInvalid(validated.error.fields?.[key] ?? validated.error.message);
70
+ const headers = data.clearRequestHeaders ? {} : await getAiRequestHeaders(id, db);
71
+ const plan = planAiProfileRequestHeaders({
72
+ currentProfiles: [current.profile],
73
+ nextProfiles: [nextProfile],
74
+ existingNames: { [id]: Object.keys(headers) },
75
+ submitted: data.requestHeaders === undefined ? [] : [{ profileId: id, patch: data.requestHeaders }],
76
+ });
77
+ if (plan.error) throw new AiModelRequestSettingsInvalid(plan.error);
78
+ // This endpoint owns one profile only. Never prune the other profiles.
79
+ const keepOthers = current.profiles.filter((profile) => profile.id !== id).map((profile) => profile.id);
80
+ plan.keepHeaderProfileIds = [...keepOthers, ...(data.clearRequestHeaders ? [] : plan.keepHeaderProfileIds)];
81
+ await storeAiRequestHeaderPlan(plan, db);
82
+ await setSetting(key, JSON.stringify(current.profiles.map((profile) => (profile.id === id ? validated.profiles[0] : profile))), db);
83
+ const saved = (await readSnapshot(id, fallback, db)).settings;
84
+ await audit.record(
85
+ {
86
+ action: "ai.model.request-settings",
87
+ outcome: "allowed",
88
+ actor: { userId: actorId },
89
+ target: { type: "ai-model", id },
90
+ metadata: { reasoningEffort: saved.reasoningEffort, extraBody: saved.extraBody, requestHeaderNames: saved.requestHeaderNames },
91
+ },
92
+ db,
93
+ );
94
+ return saved;
95
+ });
96
+ await invalidateSettingsCache([key]);
97
+ return result;
98
+ };
@@ -19,6 +19,12 @@ import type { AiConversation, AiFrontendToolMode, AiStoredMessage, AiToolPresent
19
19
 
20
20
  export const AI_WIRE_VERSION = 1;
21
21
 
22
+ /**
23
+ * Lease of a turn worker. A running turn whose worker is gone is claimed again after one lease, so a running turn
24
+ * that sends nothing for this long either works silently or lost an update; a client then reloads the turn's state.
25
+ */
26
+ export const AI_TURN_LEASE_MS = 45_000;
27
+
22
28
  export type AiToolBlockStatus = "running" | "awaiting_approval" | "awaiting_client" | "completed" | "failed" | "rejected";
23
29
 
24
30
  export type AiTurnBlock =
@@ -39,6 +45,8 @@ export type AiTurnBlock =
39
45
  isError?: boolean;
40
46
  /** Present while status is awaiting_approval. */
41
47
  approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
48
+ /** The user approved this call in the chat; the decided approval stays visible as a receipt. */
49
+ approved?: boolean;
42
50
  /** Present for frontend tools. */
43
51
  frontendMode?: AiFrontendToolMode;
44
52
  /** Saved Cloud-owned display snapshot for capability calls. */
@@ -67,6 +75,8 @@ export type AiWireEvent =
67
75
  | (AiWireEventBase & { type: "message_saved"; message: AiStoredMessage })
68
76
  | (AiWireEventBase & { type: "block_set"; block: AiTurnBlock })
69
77
  | (AiWireEventBase & { type: "block_delta"; blockId: string; blockKind: "text" | "thinking"; delta: string })
78
+ /** A model call failed transiently and waits for its retry. Transient: the next event of the turn ends it. */
79
+ | (AiWireEventBase & { type: "provider_retry" })
70
80
  | (AiWireEventBase & {
71
81
  type: "turn_finished";
72
82
  status: AiTurnFinishedStatus;
@@ -85,6 +95,10 @@ export type AiTurnSnapshot = {
85
95
  blocks: AiTurnBlock[];
86
96
  modelProfileId: string | null;
87
97
  createdAt: string;
98
+ /** Time the turn already waited for answered user actions such as approvals. Absent from older servers. */
99
+ actionWaitMs?: number;
100
+ /** Start of the user action the turn waits for now, or null while it works. Absent from older servers. */
101
+ waitingSince?: string | null;
88
102
  };
89
103
 
90
104
  /** Full projection seed sent as the first SSE event on every (re)connect. */
@@ -151,7 +165,12 @@ export const reconcileResolvedTurnActions = (
151
165
  const event = resolvedByCallId.get(block.callId);
152
166
  if (!event || event.callId !== block.callId) return block;
153
167
  if (block.status === "awaiting_approval" && event.type === "approval_response") {
154
- return { ...block, status: event.approved ? "running" : "rejected", approval: undefined };
168
+ return {
169
+ ...block,
170
+ status: event.approved ? "running" : "rejected",
171
+ approval: undefined,
172
+ ...(event.approved ? { approved: true } : {}),
173
+ };
155
174
  }
156
175
  if (block.status === "awaiting_client" && event.type === "tool_result") {
157
176
  return { ...block, status: "completed", result: event.result, isError: false };
@@ -172,7 +191,7 @@ export const buildBlocksFromMessages = (
172
191
  meta?: {
173
192
  steerId?: string;
174
193
  toolPresentations?: Record<string, AiToolPresentation>;
175
- toolOutcomes?: Record<string, "rejected">;
194
+ toolOutcomes?: Record<string, "rejected" | "approved">;
176
195
  } | null;
177
196
  }[],
178
197
  ): AiTurnBlock[] => {
@@ -196,6 +215,8 @@ export const buildBlocksFromMessages = (
196
215
  args: block.args,
197
216
  status: "running",
198
217
  presentation: meta?.toolPresentations?.[block.id],
218
+ // A turn that ended before an approved call returned records the approval on the call's message.
219
+ ...(meta?.toolOutcomes?.[block.id] === "approved" ? { approved: true } : {}),
199
220
  });
200
221
  }
201
222
  });
@@ -212,12 +233,13 @@ export const buildBlocksFromMessages = (
212
233
  const at = toolIndex.get(message.callId);
213
234
  const existing = at !== undefined ? blocks[at] : undefined;
214
235
  if (existing?.kind === "tool") {
215
- const rejected = meta?.toolOutcomes?.[message.callId] === "rejected";
236
+ const outcome = meta?.toolOutcomes?.[message.callId];
216
237
  blocks[at!] = {
217
238
  ...existing,
218
- status: rejected ? "rejected" : message.isError ? "failed" : "completed",
239
+ status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
219
240
  result: message.result,
220
241
  isError: message.isError,
242
+ ...(outcome === "approved" ? { approved: true } : {}),
221
243
  };
222
244
  }
223
245
  }