@k2b/cloud 0.26.0 → 0.28.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 (121) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/define-app.ts +8 -1
  3. package/src/_internal/process-identity.ts +7 -1
  4. package/src/_internal/registry-validation.ts +3 -0
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/ai/admin.ts +1 -0
  7. package/src/ai/browser-code-contracts.ts +46 -64
  8. package/src/ai/browser.ts +9 -1
  9. package/src/ai/capabilities.ts +82 -22
  10. package/src/ai/chat/blocks.tsx +16 -7
  11. package/src/ai/chat/builtin-tools.tsx +31 -23
  12. package/src/ai/chat/file-tools.tsx +4 -1
  13. package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
  14. package/src/ai/chat/message-actions.tsx +123 -101
  15. package/src/ai/chat/message-utils.ts +30 -1
  16. package/src/ai/chat/messages.ts +206 -2
  17. package/src/ai/chat/presentation.tsx +94 -28
  18. package/src/ai/chat/tool-groups.ts +1 -1
  19. package/src/ai/chat/turn-error.ts +21 -0
  20. package/src/ai/chat/turn-view.tsx +102 -16
  21. package/src/ai/chat/user-message.tsx +19 -14
  22. package/src/ai/chat/visual-tools.tsx +1 -1
  23. package/src/ai/client/controller.ts +83 -37
  24. package/src/ai/client/file-source.ts +20 -3
  25. package/src/ai/client/projection.ts +7 -2
  26. package/src/ai/code-mode-skill.ts +32 -36
  27. package/src/ai/code-runtime-tools.ts +14 -1
  28. package/src/ai/code-source-contracts.ts +56 -6
  29. package/src/ai/code-source-tools.ts +10 -3
  30. package/src/ai/credentials.ts +17 -3
  31. package/src/ai/data-analysis-skill.ts +3 -3
  32. package/src/ai/default-tools.ts +15 -16
  33. package/src/ai/executor.ts +281 -141
  34. package/src/ai/file-context.ts +14 -2
  35. package/src/ai/file-tools.ts +17 -3
  36. package/src/ai/files-store.ts +134 -11
  37. package/src/ai/grids-skill.ts +2 -2
  38. package/src/ai/index.ts +9 -0
  39. package/src/ai/memories.ts +14 -0
  40. package/src/ai/migrate.ts +125 -0
  41. package/src/ai/model-request-settings.ts +98 -0
  42. package/src/ai/open-tool-calls.ts +87 -0
  43. package/src/ai/protocol.ts +6 -0
  44. package/src/ai/provider.ts +7 -1
  45. package/src/ai/quota-provider.ts +2 -2
  46. package/src/ai/request-headers.ts +117 -0
  47. package/src/ai/routes.ts +40 -6
  48. package/src/ai/run-timeout.ts +4 -5
  49. package/src/ai/runtime.ts +9 -1
  50. package/src/ai/settings.ts +19 -2
  51. package/src/ai/skill-seeds.ts +35 -7
  52. package/src/ai/skills.ts +26 -0
  53. package/src/ai/solid.ts +1 -1
  54. package/src/ai/store.ts +155 -71
  55. package/src/ai/stream.ts +180 -37
  56. package/src/ai/structured.ts +20 -5
  57. package/src/ai/system-prompt.ts +8 -0
  58. package/src/ai/tool-call-names.ts +45 -0
  59. package/src/ai/turn-failure.ts +100 -0
  60. package/src/ai/turn-policy.ts +248 -0
  61. package/src/ai/types.ts +52 -4
  62. package/src/api/admin-ai-quotas.ts +36 -1
  63. package/src/api/admin-core-settings.ts +16 -23
  64. package/src/api/admin-outgoing-mail.ts +242 -0
  65. package/src/api/index.ts +2 -0
  66. package/src/browser/FileChooser.tsx +11 -0
  67. package/src/browser/file-chooser-messages.ts +2 -0
  68. package/src/cli/admin/ai-quotas.ts +70 -1
  69. package/src/cli/admin/index.ts +8 -0
  70. package/src/cli/admin/notifications.ts +6 -0
  71. package/src/cli/admin/outgoing-mail.ts +215 -0
  72. package/src/contracts/app.ts +2 -0
  73. package/src/contracts/index.ts +1 -0
  74. package/src/contracts/outgoing-mail.ts +265 -0
  75. package/src/contracts/registry.ts +2 -0
  76. package/src/services/help/store.ts +2 -1
  77. package/src/services/index.ts +13 -0
  78. package/src/services/notifications/batches.ts +139 -79
  79. package/src/services/notifications/channels.ts +33 -13
  80. package/src/services/notifications/dispatcher.ts +57 -5
  81. package/src/services/notifications/email-frame.fixture.html +51 -0
  82. package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
  83. package/src/services/notifications/email-mail.ts +101 -0
  84. package/src/services/notifications/index.ts +49 -36
  85. package/src/services/notifications/observability.ts +9 -1
  86. package/src/services/notifications/platform.ts +1 -1
  87. package/src/services/notifications/runtime.ts +9 -3
  88. package/src/services/outgoing-mail/admin.ts +84 -0
  89. package/src/services/outgoing-mail/attachments.ts +135 -0
  90. package/src/services/outgoing-mail/bulk.ts +11 -0
  91. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  92. package/src/services/outgoing-mail/drain.ts +60 -0
  93. package/src/services/outgoing-mail/enqueue.ts +180 -0
  94. package/src/services/outgoing-mail/index.ts +112 -0
  95. package/src/services/outgoing-mail/message.ts +14 -0
  96. package/src/services/outgoing-mail/messages.ts +431 -0
  97. package/src/services/outgoing-mail/retention.ts +48 -0
  98. package/src/services/outgoing-mail/runtime.ts +72 -0
  99. package/src/services/outgoing-mail/send.ts +130 -0
  100. package/src/services/outgoing-mail/store.ts +397 -0
  101. package/src/services/outgoing-mail/sync.ts +39 -0
  102. package/src/services/outgoing-mail/test-send.ts +40 -0
  103. package/src/services/outgoing-mail/transport.ts +17 -0
  104. package/src/services/pdf/markdown.ts +22 -4
  105. package/src/services/postgres.ts +15 -0
  106. package/src/services/settings/core-settings.ts +19 -38
  107. package/src/services/settings/store.ts +5 -1
  108. package/src/shared/ai-model-request-settings.ts +21 -0
  109. package/src/shared/ai-platform-prompt.ts +49 -5
  110. package/src/shared/ai-request-options.ts +185 -0
  111. package/src/shared/markdown/extensions/links.ts +28 -20
  112. package/src/shared/markdown/index.ts +14 -5
  113. package/src/shared/markdown/shared.ts +0 -7
  114. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  115. package/src/ssr/admin-navigation.ts +1 -1
  116. package/src/ssr/platform-messages.ts +3 -1
  117. package/src/ssr/workspace-navigation.ts +7 -1
  118. package/src/styles/effects.css +15 -17
  119. package/src/styles/tokens.css +2 -0
  120. package/src/styles/utilities-markdown-editor.css +4 -39
  121. package/src/styles/utilities-markdown-table.css +14 -17
package/src/ai/solid.ts CHANGED
@@ -6,7 +6,7 @@ export {
6
6
  type CreateAiChatControllerOptions,
7
7
  createAiChatController,
8
8
  } from "./client/controller";
9
- export { conversationFileSource } from "./client/file-source";
9
+ export { type ConversationFileSource, conversationFileSource } from "./client/file-source";
10
10
  export {
11
11
  type AiActiveTurn,
12
12
  type AiChatProjection,
package/src/ai/store.ts CHANGED
@@ -4,8 +4,10 @@ import { type SQL, sql } from "bun";
4
4
  import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
5
5
  import { logger } from "../services/logging";
6
6
  import { toPgTextArray } from "../services/postgres";
7
+ import { aiTurnErrorText } from "./chat/turn-error";
7
8
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
8
9
  import type { AiTurnBlock } from "./protocol";
10
+ import { AiRunTimeout } from "./run-timeout";
9
11
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
10
12
  import { parseAiTodoPlan } from "./todo-contracts";
11
13
  import { activeTurnWaits } from "./turn-timing";
@@ -29,6 +31,7 @@ import type {
29
31
  AiToolPresentation,
30
32
  AiTurn,
31
33
  AiTurnClaim,
34
+ AiTurnError,
32
35
  AiTurnRunConfig,
33
36
  AiTurnStatus,
34
37
  AiTurnSteer,
@@ -112,6 +115,7 @@ type ConversationRow = {
112
115
  latest_browser_pending?: boolean;
113
116
  latest_turn_error?: string | null;
114
117
  latest_turn_completed_at?: Date | string | null;
118
+ latest_turn_short_id?: string | null;
115
119
  enrich_fail_count: number | null;
116
120
  project_id: string | null;
117
121
  draft_content: unknown;
@@ -304,10 +308,10 @@ const rowToConversationResourceRef = (row: ConversationResourceRefRow): AiConver
304
308
  });
305
309
 
306
310
  type ResourceCursor = { at: string; type: string; id: string };
307
- const encodeResourceCursor = (row: ConversationResourceRefRow): string =>
308
- encodeURIComponent(JSON.stringify({ at: iso(row.last_seen_at), type: row.resource_type, id: row.resource_id } satisfies ResourceCursor));
309
- const encodeSourceCursor = (row: ConversationSourceRow): string =>
310
- encodeURIComponent(JSON.stringify({ at: iso(row.last_seen_at), type: row.source_kind, id: row.source_key } satisfies ResourceCursor));
311
+ const encodeResourceCursor = (row: ConversationResourceRefRow & { cursor_at: string }): string =>
312
+ encodeURIComponent(JSON.stringify({ at: row.cursor_at, type: row.resource_type, id: row.resource_id } satisfies ResourceCursor));
313
+ const encodeSourceCursor = (row: ConversationSourceRow & { cursor_at: string }): string =>
314
+ encodeURIComponent(JSON.stringify({ at: row.cursor_at, type: row.source_kind, id: row.source_key } satisfies ResourceCursor));
311
315
  const decodeResourceCursor = (value: string | undefined): ResourceCursor | null => {
312
316
  if (!value) return null;
313
317
  try {
@@ -348,6 +352,7 @@ const rowToConversationSource = (row: ConversationSourceRow): AiConversationSour
348
352
  lastSeenAt: iso(row.last_seen_at),
349
353
  sourceTurnId: row.source_turn_id,
350
354
  sourceCallId: row.source_call_id,
355
+ sourceMessageSeq: null,
351
356
  });
352
357
 
353
358
  const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage => ({
@@ -389,10 +394,10 @@ const interChatMessageSelect = () => sql`
389
394
  `;
390
395
 
391
396
  type ResourceOccurrenceCursor = ResourceCursor & { chat: string };
392
- const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow): string =>
397
+ const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow & { cursor_at: string }): string =>
393
398
  encodeURIComponent(
394
399
  JSON.stringify({
395
- at: iso(row.last_seen_at),
400
+ at: row.cursor_at,
396
401
  type: row.resource_type,
397
402
  id: row.resource_id,
398
403
  chat: row.conversation_short_id,
@@ -551,6 +556,7 @@ const rowToConversation = (row: ConversationRow): AiConversation => ({
551
556
  lastUsedAt: iso(row.last_used_at),
552
557
  runStatus: conversationRunStatus(row.latest_turn_status, row.latest_browser_pending),
553
558
  runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
559
+ runTurnId: row.latest_turn_short_id ?? null,
554
560
  unreadCompletion:
555
561
  Boolean(
556
562
  row.background_received_at &&
@@ -722,10 +728,10 @@ const loadConversationSummary = async (input: {
722
728
  latest.status AS latest_turn_status,
723
729
  ${browserWorkPending()} AS latest_browser_pending,
724
730
  latest.error AS latest_turn_error,
725
- latest.completed_at AS latest_turn_completed_at
731
+ latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
726
732
  FROM ai.conversations conversation
727
733
  LEFT JOIN LATERAL (
728
- SELECT status, error, completed_at, live_blocks
734
+ SELECT short_id, status, error, completed_at, live_blocks
729
735
  FROM ai.turns
730
736
  WHERE conversation_id = conversation.id AND NOT COALESCE(run_config ? 'background', false)
731
737
  ORDER BY created_at DESC, id DESC
@@ -783,13 +789,30 @@ const messageSearchText = (message: Message): string => {
783
789
  * time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
784
790
  * never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
785
791
  * limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
792
+ * A failure's reason goes to the turn's last message, also when that is the user's own, so history can say why the
793
+ * turn failed and what to do next.
786
794
  */
787
795
  const recordTurnEnd = async (
788
796
  db: typeof sql,
789
- input: { conversationId: string; turnId: string; reason: "aborted" | "error" },
797
+ input: { conversationId: string; turnId: string; reason: "aborted" | "error"; turnError?: AiTurnError | null },
790
798
  ): Promise<void> => {
791
799
  const replaces = input.reason === "error" ? "aborted" : null;
792
800
  for (const table of ["ai.messages", "ai.task_messages"]) {
801
+ if (input.turnError) {
802
+ await db`
803
+ UPDATE ${db(table)}
804
+ SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object('turnError', ${JSON.stringify(input.turnError)}::text::jsonb)
805
+ WHERE id = (
806
+ SELECT id
807
+ FROM ${db(table)}
808
+ WHERE conversation_id = ${input.conversationId}
809
+ AND loop_id = ${input.turnId}::text
810
+ AND kind = 'message'
811
+ ORDER BY seq DESC
812
+ LIMIT 1
813
+ )
814
+ `;
815
+ }
793
816
  await db`
794
817
  UPDATE ${db(table)}
795
818
  SET loop_done_reason = ${input.reason}
@@ -1183,10 +1206,10 @@ export const aiConversations: AiConversationService = {
1183
1206
  latest.status AS latest_turn_status,
1184
1207
  ${browserWorkPending()} AS latest_browser_pending,
1185
1208
  latest.error AS latest_turn_error,
1186
- latest.completed_at AS latest_turn_completed_at
1209
+ latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
1187
1210
  FROM ai.conversations conversation
1188
1211
  LEFT JOIN LATERAL (
1189
- SELECT status, error, completed_at, live_blocks
1212
+ SELECT short_id, status, error, completed_at, live_blocks
1190
1213
  FROM ai.turns
1191
1214
  WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
1192
1215
  ORDER BY created_at DESC, id DESC
@@ -1239,12 +1262,12 @@ export const aiConversations: AiConversationService = {
1239
1262
  SELECT conversation.*, ${effectiveDone()} AS is_done,
1240
1263
  EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
1241
1264
  latest.status AS latest_turn_status, ${browserWorkPending()} AS latest_browser_pending,
1242
- latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at,
1265
+ latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id,
1243
1266
  jsonb_build_object('completed', COALESCE(progress.completed,0), 'total', COALESCE(progress.total,0),
1244
1267
  'step', progress.step, 'tool', ai.sidebar_tool_label(latest.live_blocks)->>'label') AS activity
1245
1268
  FROM ai.conversations conversation
1246
1269
  LEFT JOIN LATERAL (
1247
- SELECT status, error, completed_at, live_blocks FROM ai.turns
1270
+ SELECT short_id, status, error, completed_at, live_blocks FROM ai.turns
1248
1271
  WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id ORDER BY created_at DESC, id DESC LIMIT 1
1249
1272
  ) latest ON TRUE
1250
1273
  LEFT JOIN LATERAL (
@@ -1286,10 +1309,10 @@ export const aiConversations: AiConversationService = {
1286
1309
  latest.status AS latest_turn_status,
1287
1310
  ${browserWorkPending()} AS latest_browser_pending,
1288
1311
  latest.error AS latest_turn_error,
1289
- latest.completed_at AS latest_turn_completed_at
1312
+ latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
1290
1313
  FROM ai.conversations conversation
1291
1314
  LEFT JOIN LATERAL (
1292
- SELECT status, error, completed_at, live_blocks
1315
+ SELECT short_id, status, error, completed_at, live_blocks
1293
1316
  FROM ai.turns
1294
1317
  WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
1295
1318
  ORDER BY created_at DESC, id DESC
@@ -1440,9 +1463,19 @@ export const aiConversations: AiConversationService = {
1440
1463
  preview = COALESCE(EXCLUDED.preview, ai.conversation_resource_refs.preview),
1441
1464
  icon = COALESCE(EXCLUDED.icon, ai.conversation_resource_refs.icon),
1442
1465
  href = COALESCE(EXCLUDED.href, ai.conversation_resource_refs.href),
1443
- source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_resource_refs.source_turn_id),
1466
+ -- Project context is indexed on every turn without a call. It must not move a resource a tool call
1467
+ -- read to the latest turn, or the chat would show it as read just now.
1468
+ source_turn_id = CASE
1469
+ WHEN EXCLUDED.source_call_id IS NULL AND ai.conversation_resource_refs.source_call_id IS NOT NULL
1470
+ THEN ai.conversation_resource_refs.source_turn_id
1471
+ ELSE COALESCE(EXCLUDED.source_turn_id, ai.conversation_resource_refs.source_turn_id)
1472
+ END,
1444
1473
  source_call_id = COALESCE(EXCLUDED.source_call_id, ai.conversation_resource_refs.source_call_id),
1445
- last_seen_at = now()
1474
+ last_seen_at = CASE
1475
+ WHEN EXCLUDED.source_call_id IS NULL AND ai.conversation_resource_refs.source_call_id IS NOT NULL
1476
+ THEN ai.conversation_resource_refs.last_seen_at
1477
+ ELSE now()
1478
+ END
1446
1479
  `;
1447
1480
  }
1448
1481
  });
@@ -1452,9 +1485,10 @@ export const aiConversations: AiConversationService = {
1452
1485
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1453
1486
  const pattern = searchPattern(input.search);
1454
1487
  const cursor = decodeResourceCursor(input.before);
1455
- const rows = await sql<ConversationResourceRefRow[]>`
1488
+ const rows = await sql<(ConversationResourceRefRow & { cursor_at: string })[]>`
1456
1489
  SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
1457
- source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
1490
+ source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
1491
+ to_char(indexed.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at
1458
1492
  FROM ai.conversation_resource_refs indexed
1459
1493
  LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
1460
1494
  WHERE indexed.conversation_id = ${input.conversationId}::uuid
@@ -1475,11 +1509,12 @@ export const aiConversations: AiConversationService = {
1475
1509
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1476
1510
  const pattern = searchPattern(input.search);
1477
1511
  const cursor = decodeResourceOccurrenceCursor(input.before);
1478
- const rows = await sql<ConversationResourceOccurrenceRow[]>`
1512
+ const rows = await sql<(ConversationResourceOccurrenceRow & { cursor_at: string })[]>`
1479
1513
  SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
1480
1514
  source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
1481
1515
  conversation.short_id AS conversation_short_id, conversation.title AS conversation_title,
1482
- conversation.updated_at AS conversation_updated_at
1516
+ conversation.updated_at AS conversation_updated_at,
1517
+ to_char(indexed.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at
1483
1518
  FROM ai.conversation_resource_refs indexed
1484
1519
  JOIN ai.conversations conversation ON conversation.id = indexed.conversation_id
1485
1520
  LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
@@ -1502,17 +1537,21 @@ export const aiConversations: AiConversationService = {
1502
1537
  const source = input.source;
1503
1538
  await sql`
1504
1539
  INSERT INTO ai.conversation_sources (
1505
- conversation_id, kind, source_key, title, preview, icon, href, source_turn_id, source_call_id
1540
+ conversation_id, kind, source_key, title, preview, icon, href, resource_type, resource_id, source_turn_id, source_call_id
1506
1541
  ) VALUES (
1507
1542
  ${input.conversationId}::uuid, ${source.kind}, ${source.key}, ${source.title}, ${source.preview ?? null},
1508
- ${source.icon ?? null}, ${source.href ?? null}, ${input.turnId ?? null}::uuid, ${input.callId ?? null}
1543
+ ${source.icon ?? null}, ${source.href ?? null}, ${source.ref?.type ?? null}, ${source.ref?.id ?? null},
1544
+ ${input.turnId ?? null}::uuid, ${input.callId ?? null}
1509
1545
  )
1510
1546
  ON CONFLICT (conversation_id, kind, source_key)
1511
1547
  DO UPDATE SET
1512
1548
  title = EXCLUDED.title,
1513
- preview = COALESCE(EXCLUDED.preview, ai.conversation_sources.preview),
1549
+ -- A new delivery describes the result anew; an old description must not outlive changed content.
1550
+ preview = CASE WHEN EXCLUDED.kind = 'result' THEN EXCLUDED.preview ELSE COALESCE(EXCLUDED.preview, ai.conversation_sources.preview) END,
1514
1551
  icon = COALESCE(EXCLUDED.icon, ai.conversation_sources.icon),
1515
1552
  href = COALESCE(EXCLUDED.href, ai.conversation_sources.href),
1553
+ resource_type = COALESCE(EXCLUDED.resource_type, ai.conversation_sources.resource_type),
1554
+ resource_id = COALESCE(EXCLUDED.resource_id, ai.conversation_sources.resource_id),
1516
1555
  occurrences = ai.conversation_sources.occurrences +
1517
1556
  CASE WHEN ai.conversation_sources.source_call_id IS DISTINCT FROM EXCLUDED.source_call_id THEN 1 ELSE 0 END,
1518
1557
  source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_sources.source_turn_id),
@@ -1525,50 +1564,85 @@ export const aiConversations: AiConversationService = {
1525
1564
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1526
1565
  const pattern = searchPattern(input.search);
1527
1566
  const cursor = decodeResourceCursor(input.before);
1528
- const rows = await sql<ConversationSourceRow[]>`
1529
- SELECT *
1530
- FROM (
1531
- SELECT source.kind AS source_kind, source.source_key, source.title, source.preview,
1532
- source.icon, source.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
1533
- NULL::text AS resource_type, NULL::text AS resource_id, source.occurrences,
1534
- turn.short_id AS source_turn_id, source.source_call_id, source.first_seen_at, source.last_seen_at
1535
- FROM ai.conversation_sources source
1536
- LEFT JOIN ai.turns turn ON turn.id = source.source_turn_id
1537
- WHERE source.conversation_id = ${input.conversationId}::uuid
1538
-
1539
- UNION ALL
1540
-
1541
- SELECT 'file' AS source_kind, file.path AS source_key,
1542
- regexp_replace(file.path, '^.*/', '') AS title, 'Attached to the conversation' AS preview,
1543
- CASE WHEN file.media_type LIKE 'image/%' THEN 'ti ti-photo' ELSE 'ti ti-file' END AS icon,
1544
- NULL::text AS href, file.path, file.media_type, file.size,
1545
- NULL::text AS resource_type, NULL::text AS resource_id, 1 AS occurrences,
1546
- NULL::text AS source_turn_id, NULL::text AS source_call_id,
1547
- file.updated_at AS first_seen_at, file.updated_at AS last_seen_at
1548
- FROM ai.files file
1549
- WHERE file.conversation_id = ${input.conversationId}::uuid
1550
-
1551
- UNION ALL
1552
-
1553
- SELECT 'resource' AS source_kind, indexed.resource_type || ':' || indexed.resource_id AS source_key,
1554
- COALESCE(indexed.title, indexed.resource_type || ' ' || indexed.resource_id) AS title,
1555
- indexed.preview, indexed.icon, indexed.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
1556
- indexed.resource_type, indexed.resource_id, 1 AS occurrences,
1557
- turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
1558
- FROM ai.conversation_resource_refs indexed
1559
- LEFT JOIN ai.turns turn ON turn.id = indexed.source_turn_id
1560
- WHERE indexed.conversation_id = ${input.conversationId}::uuid
1561
- ) source
1562
- WHERE (${pattern}::text IS NULL OR LOWER(source.title || ' ' || COALESCE(source.preview, '') || ' ' || source.source_key) LIKE ${pattern})
1563
- AND (${cursor?.at ?? null}::timestamptz IS NULL OR (source.last_seen_at, source.source_kind, source.source_key) <
1564
- (${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text))
1565
- ORDER BY source.last_seen_at DESC, source.source_kind DESC, source.source_key DESC
1566
- LIMIT ${limit + 1}
1567
- `;
1568
- const page = rows.slice(0, limit);
1567
+ const kinds = input.kinds?.length ? toPgTextArray([...input.kinds]) : null;
1568
+ const observed = input.observed === true;
1569
+ const rows = await sql<(ConversationSourceRow & { total: number | string; source_message_seq: number | null; cursor_at: string })[]>`
1570
+ WITH matched AS (
1571
+ SELECT *
1572
+ FROM (
1573
+ SELECT source.kind AS source_kind, source.source_key, source.title, source.preview,
1574
+ source.icon, source.href, file.path, file.media_type, file.size,
1575
+ source.resource_type, source.resource_id, source.occurrences,
1576
+ turn.short_id AS source_turn_id, source.source_turn_id AS turn_uuid, source.source_call_id,
1577
+ source.first_seen_at, source.last_seen_at
1578
+ FROM ai.conversation_sources source
1579
+ LEFT JOIN ai.turns turn ON turn.id = source.source_turn_id
1580
+ LEFT JOIN ai.files file
1581
+ ON source.kind = 'result' AND file.conversation_id = source.conversation_id AND file.path = source.source_key
1582
+ WHERE source.conversation_id = ${input.conversationId}::uuid
1583
+
1584
+ UNION ALL
1585
+
1586
+ SELECT 'file' AS source_kind, file.path AS source_key,
1587
+ regexp_replace(file.path, '^.*/', '') AS title, 'Attached to the conversation' AS preview,
1588
+ CASE WHEN file.media_type LIKE 'image/%' THEN 'ti ti-photo' ELSE 'ti ti-file' END AS icon,
1589
+ NULL::text AS href, file.path, file.media_type, file.size,
1590
+ NULL::text AS resource_type, NULL::text AS resource_id, 1 AS occurrences,
1591
+ NULL::text AS source_turn_id, NULL::uuid AS turn_uuid, NULL::text AS source_call_id,
1592
+ file.updated_at AS first_seen_at, file.updated_at AS last_seen_at
1593
+ FROM ai.files file
1594
+ WHERE file.conversation_id = ${input.conversationId}::uuid
1595
+
1596
+ UNION ALL
1597
+
1598
+ SELECT 'resource' AS source_kind, indexed.resource_type || ':' || indexed.resource_id AS source_key,
1599
+ COALESCE(indexed.title, indexed.resource_type || ' ' || indexed.resource_id) AS title,
1600
+ indexed.preview, indexed.icon, indexed.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
1601
+ indexed.resource_type, indexed.resource_id, 1 AS occurrences,
1602
+ turn.short_id AS source_turn_id, indexed.source_turn_id AS turn_uuid, indexed.source_call_id,
1603
+ indexed.first_seen_at, indexed.last_seen_at
1604
+ FROM ai.conversation_resource_refs indexed
1605
+ LEFT JOIN ai.turns turn ON turn.id = indexed.source_turn_id
1606
+ WHERE indexed.conversation_id = ${input.conversationId}::uuid
1607
+ ) source
1608
+ WHERE (${kinds}::text[] IS NULL OR source.source_kind = ANY(${kinds}::text[]))
1609
+ AND (NOT ${observed} OR source.source_kind <> 'resource' OR source.source_call_id IS NOT NULL)
1610
+ AND (${pattern}::text IS NULL OR LOWER(source.title || ' ' || COALESCE(source.preview, '') || ' ' || source.source_key) LIKE ${pattern})
1611
+ ),
1612
+ page AS (
1613
+ SELECT *
1614
+ FROM matched source
1615
+ WHERE ${cursor?.at ?? null}::timestamptz IS NULL OR (source.last_seen_at, source.source_kind, source.source_key) <
1616
+ (${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text)
1617
+ ORDER BY source.last_seen_at DESC, source.source_kind DESC, source.source_key DESC
1618
+ LIMIT ${limit + 1}
1619
+ )
1620
+ SELECT page.*, to_char(page.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at, totals.total,
1621
+ COALESCE(
1622
+ (SELECT message.seq FROM ai.messages message
1623
+ WHERE message.conversation_id = ${input.conversationId}::uuid AND message.loop_id = page.turn_uuid::text
1624
+ AND message.role = 'assistant' AND page.source_call_id IS NOT NULL
1625
+ AND (CASE WHEN jsonb_typeof(message.message) = 'string' THEN (message.message #>> '{}')::jsonb ELSE message.message END) -> 'content'
1626
+ @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', page.source_call_id))
1627
+ ORDER BY message.seq ASC
1628
+ LIMIT 1),
1629
+ (SELECT min(message.seq) FROM ai.messages message
1630
+ WHERE message.conversation_id = ${input.conversationId}::uuid AND message.loop_id = page.turn_uuid::text)
1631
+ ) AS source_message_seq
1632
+ FROM (SELECT count(*) AS total FROM matched) totals
1633
+ LEFT JOIN page ON true
1634
+ ORDER BY page.last_seen_at DESC, page.source_kind DESC, page.source_key DESC
1635
+ `;
1636
+ // One row carries the total even when the page is empty.
1637
+ const total = Number(rows[0]?.total ?? 0);
1638
+ const page = rows.filter((row) => row.source_kind !== null).slice(0, limit);
1569
1639
  return {
1570
- sources: page.map(rowToConversationSource),
1640
+ sources: page.map((row) => ({
1641
+ ...rowToConversationSource(row),
1642
+ sourceMessageSeq: row.source_message_seq === null ? null : Number(row.source_message_seq),
1643
+ })),
1571
1644
  ...(rows.length > limit && page.at(-1) ? { nextCursor: encodeSourceCursor(page.at(-1)!) } : {}),
1645
+ total,
1572
1646
  };
1573
1647
  },
1574
1648
 
@@ -2905,6 +2979,7 @@ export const aiConversations: AiConversationService = {
2905
2979
  conversationId: input.conversationId,
2906
2980
  turnId: input.turnId,
2907
2981
  reason: input.status === "aborted" ? "aborted" : "error",
2982
+ turnError: input.status === "failed" ? (input.turnError ?? { code: "failed" }) : null,
2908
2983
  });
2909
2984
  await tx`
2910
2985
  UPDATE ai.turn_steers
@@ -2925,11 +3000,12 @@ export const aiConversations: AiConversationService = {
2925
3000
  // 1) Finalize turns that exhausted actual recovery attempts. Resuming a
2926
3001
  // resolved user/frontend action is normal progress and does not consume
2927
3002
  // this budget.
3003
+ // The sweep does not know a turn's language; the chat words the recorded reason in the reader's.
2928
3004
  const exhaustedRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
2929
3005
  UPDATE ai.turns
2930
3006
  SET status = 'failed',
2931
3007
  completed_at = now(),
2932
- error = 'AI turn exhausted its recovery attempts.',
3008
+ error = ${aiTurnErrorText({ code: "interrupted" }, "en")},
2933
3009
  lease_owner = NULL,
2934
3010
  lease_expires_at = NULL,
2935
3011
  live_blocks = NULL
@@ -2977,11 +3053,13 @@ export const aiConversations: AiConversationService = {
2977
3053
  `;
2978
3054
 
2979
3055
  // 2) Finalize over-budget turns without a live lease.
2980
- const budgetRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
3056
+ const budgetRows = await sql<
3057
+ { id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string; run_budget_ms: number | null }[]
3058
+ >`
2981
3059
  UPDATE ai.turns
2982
3060
  SET status = 'failed',
2983
3061
  completed_at = now(),
2984
- error = 'Run time limit reached. You can continue the task in a new message.',
3062
+ error = ${aiTurnErrorText({ code: "time_limit" }, "en")},
2985
3063
  lease_owner = NULL,
2986
3064
  lease_expires_at = NULL,
2987
3065
  live_blocks = NULL
@@ -2995,8 +3073,12 @@ export const aiConversations: AiConversationService = {
2995
3073
  AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now())
2996
3074
  LIMIT ${limit}
2997
3075
  )
2998
- RETURNING id, conversation_id, error, attempt, live_seq
3076
+ RETURNING id, conversation_id, error, attempt, live_seq, run_budget_ms
2999
3077
  `;
3078
+ const turnErrors = new Map<string, AiTurnError>([
3079
+ ...exhaustedRows.map((row): [string, AiTurnError] => [row.id, { code: "interrupted" }]),
3080
+ ...budgetRows.map((row): [string, AiTurnError] => [row.id, new AiRunTimeout(Number(row.run_budget_ms) || null).turnError()]),
3081
+ ]);
3000
3082
  result.failed = [...exhaustedRows, ...budgetRows].map((row) => ({
3001
3083
  conversationId: row.conversation_id,
3002
3084
  turnId: row.id,
@@ -3035,10 +3117,12 @@ export const aiConversations: AiConversationService = {
3035
3117
  // History tells a stop from a turn that failed or whose wait expired, as it does for turns that end on their own.
3036
3118
  const stopped = new Set(abortedRows.filter((row) => row.stopped).map((row) => row.id));
3037
3119
  for (const finalized of [...result.failed, ...result.aborted]) {
3120
+ const stop = stopped.has(finalized.turnId);
3038
3121
  await recordTurnEnd(sql, {
3039
3122
  conversationId: finalized.conversationId,
3040
3123
  turnId: finalized.turnId,
3041
- reason: stopped.has(finalized.turnId) ? "aborted" : "error",
3124
+ reason: stop ? "aborted" : "error",
3125
+ turnError: stop ? null : (turnErrors.get(finalized.turnId) ?? { code: "wait_expired" }),
3042
3126
  });
3043
3127
  await sql`
3044
3128
  UPDATE ai.pending_actions