@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
@@ -14,6 +14,29 @@ Help the user turn a recurring workflow, specialist knowledge, or a set of instr
14
14
  - Ask only about a missing choice that would materially change discovery, behavior, or output. If the request is already clear, draft the Skill directly.
15
15
  - Preserve the user's scope. Do not turn one example, preference, or past failure into a universal rule unless the user clearly intends that.
16
16
 
17
+ ## Choose the right place
18
+
19
+ Recommend the lightest place that does the job:
20
+
21
+ - one lasting fact or preference, such as reports always as PDF: personalization memory;
22
+ - always using one mailbox, notebook, or Space for a kind of request: a personalization workflow default, which Assistant learns from the user's stated rule or repeated use;
23
+ - rules for one Project's shared work: Project instructions;
24
+ - a recurring procedure, judgment, or output format used across chats: a Skill;
25
+ - work that should run at a set time: a scheduled task, which can load the Skill;
26
+ - a repeated calculation or an interactive tool: a Studio App that the Skill references.
27
+
28
+ ## Draft from the conversation
29
+
30
+ When the Skill comes from work in this conversation, capture what made it succeed:
31
+
32
+ - when to load it, as narrowly as the user's real requests: name the subject, such as the team or the report, not only the output format;
33
+ - the steps and the exact capability IDs that worked, so later runs can call load_tools without searching;
34
+ - every correction the user made, rewritten as a positive rule; corrections are the most valuable content;
35
+ - the output shape, as a compact template when layout matters;
36
+ - inputs by role and title, such as "the Space Sales"; when a personalization workflow default already routes this kind of request, refer to it instead of repeating it. Never copy IDs of mailboxes, Spaces, notebooks, records, or other resources from the chat; the exact ID of a Studio App the Skill calls is the one exception.
37
+
38
+ Keep only procedure, format, and stable references. Leave out content from attachments, mails, web pages, or other quoted data; names of people or customers, amounts, and example records from this chat; one-off values; and failed attempts. Before the create review, tell the user in one or two sentences what the Skill will do and when it will load.
39
+
17
40
  ## Design the Skill
18
41
 
19
42
  ### Name
@@ -59,9 +82,10 @@ Read the draft once as if you were a different Assistant receiving it later. Che
59
82
  - the workflow can run without private conversation context;
60
83
  - instructions are direct, non-repetitive, and compatible with available capabilities;
61
84
  - examples generalize beyond the original case;
85
+ - it holds no names of people or customers, amounts, records, or resource IDs from this conversation, except the exact ID of a Studio App it calls;
62
86
  - no surprising mutation, permission expansion, or external side effect is implied.
63
87
 
64
- For an existing Skill, read it first and preserve fields the user did not ask to change. Prefer a narrow correction over accumulating rules for every observed example.
88
+ For an existing Skill, read it first and preserve fields the user did not ask to change. Prefer a narrow correction over accumulating rules for every observed example. Built-in Skills and Skills shared with others change for everyone who uses them, so change one only when you can edit it and the user wants the change for everyone. \`core.ai.skill.read\` shows your permission, and \`core.ai.skill.access.read\` shows who else can use a Skill you manage. Otherwise leave the Skill unchanged. When personalization memory is on, a correction meant only for the user can become a preference; memory saves only the user's own words, so ask the user to state the rule, such as "always write mails formally".
65
89
 
66
90
  ## Use Cloud Skill capabilities
67
91
 
@@ -82,6 +106,10 @@ Pass the exact current revision to mutations and read again after a revision con
82
106
 
83
107
  Creating, changing, and deleting Skills are reviewed mutations. Prepare the concrete content the user requested, then use the Capability review instead of asking for an extra confirmation that duplicates it.
84
108
 
109
+ ## After saving
110
+
111
+ Say that the Skill now loads for matching requests and can also be selected with /skill. When a later run needs a correction, update the Skill narrowly instead of creating another one. Share it only when the user asks.
112
+
85
113
  ## Optional App-backed workflows
86
114
 
87
115
  A Skill can reference a reusable Studio App and its published actions instead of duplicating code. Load \`assistant-code-mode\` only when building, changing, or discovering such an App is useful. Apps may be stateless scripts, agent-only services with shared data, dashboards, or a combination. Many Skills need no code and many Apps need no Skill.
@@ -438,7 +466,7 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
438
466
  ASSISTANT_CODE_MODE_SKILL,
439
467
  ASSISTANT_DATA_ANALYSIS_SKILL,
440
468
  {
441
- version: 6,
469
+ version: 7,
442
470
  key: "grids:cloud-grids",
443
471
  name: "cloud-grids",
444
472
  description:
@@ -463,7 +491,7 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
463
491
  instructions: SCHEDULED_TASKS_INSTRUCTIONS,
464
492
  },
465
493
  {
466
- version: 2,
494
+ version: 3,
467
495
  key: "core:skill-creator",
468
496
  name: "skill-creator",
469
497
  description:
package/src/ai/skills.ts CHANGED
@@ -562,7 +562,33 @@ async function linkedSkillProjects(skillId: string, subject: AccessSubject | nul
562
562
  return result;
563
563
  }
564
564
 
565
+ export type AiConversationSkillUse = { name: string; description: string; turns: number; lastLoadedAt: string };
566
+
565
567
  export const aiSkills = {
568
+ /**
569
+ * Skills a chat loaded, from the revision snapshots its turns pinned, most recently loaded first. The caller resolves
570
+ * the chat for its owner first; this is the chat's own history and does not recheck current Skill access.
571
+ */
572
+ async listConversationUses(conversationId: string, limit = 50): Promise<AiConversationSkillUse[]> {
573
+ const rows = await sql<{ name: string; description: string; turns: number | string; last_loaded_at: Date | string }[]>`
574
+ SELECT snapshot.skill_name AS name,
575
+ (array_agg(snapshot.description ORDER BY snapshot.loaded_at DESC))[1] AS description,
576
+ count(*) AS turns, max(snapshot.loaded_at) AS last_loaded_at
577
+ FROM ai.turn_skill_snapshots snapshot
578
+ JOIN ai.turns turn ON turn.id = snapshot.turn_id
579
+ WHERE turn.conversation_id = ${conversationId}::uuid
580
+ GROUP BY snapshot.skill_name
581
+ ORDER BY max(snapshot.loaded_at) DESC, snapshot.skill_name ASC
582
+ LIMIT ${Math.min(Math.max(Math.floor(limit), 1), 100)}
583
+ `;
584
+ return rows.map((row) => ({
585
+ name: row.name,
586
+ description: row.description,
587
+ turns: Number(row.turns),
588
+ lastLoadedAt: (row.last_loaded_at instanceof Date ? row.last_loaded_at : new Date(row.last_loaded_at)).toISOString(),
589
+ }));
590
+ },
591
+
566
592
  async seedOnce(template: AiSkillTemplate): Promise<void> {
567
593
  const { fields, hash } = validateTemplate(template);
568
594
  await sql.begin(async (tx) => {
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
@@ -8,6 +8,7 @@ import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
8
8
  import type { AiTurnBlock } from "./protocol";
9
9
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
10
10
  import { parseAiTodoPlan } from "./todo-contracts";
11
+ import { activeTurnWaits } from "./turn-timing";
11
12
  import type {
12
13
  AiConversation,
13
14
  AiConversationDraft,
@@ -303,10 +304,10 @@ const rowToConversationResourceRef = (row: ConversationResourceRefRow): AiConver
303
304
  });
304
305
 
305
306
  type ResourceCursor = { at: string; type: string; id: string };
306
- const encodeResourceCursor = (row: ConversationResourceRefRow): string =>
307
- encodeURIComponent(JSON.stringify({ at: iso(row.last_seen_at), type: row.resource_type, id: row.resource_id } satisfies ResourceCursor));
308
- const encodeSourceCursor = (row: ConversationSourceRow): string =>
309
- encodeURIComponent(JSON.stringify({ at: iso(row.last_seen_at), type: row.source_kind, id: row.source_key } satisfies ResourceCursor));
307
+ const encodeResourceCursor = (row: ConversationResourceRefRow & { cursor_at: string }): string =>
308
+ encodeURIComponent(JSON.stringify({ at: row.cursor_at, type: row.resource_type, id: row.resource_id } satisfies ResourceCursor));
309
+ const encodeSourceCursor = (row: ConversationSourceRow & { cursor_at: string }): string =>
310
+ encodeURIComponent(JSON.stringify({ at: row.cursor_at, type: row.source_kind, id: row.source_key } satisfies ResourceCursor));
310
311
  const decodeResourceCursor = (value: string | undefined): ResourceCursor | null => {
311
312
  if (!value) return null;
312
313
  try {
@@ -347,6 +348,7 @@ const rowToConversationSource = (row: ConversationSourceRow): AiConversationSour
347
348
  lastSeenAt: iso(row.last_seen_at),
348
349
  sourceTurnId: row.source_turn_id,
349
350
  sourceCallId: row.source_call_id,
351
+ sourceMessageSeq: null,
350
352
  });
351
353
 
352
354
  const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage => ({
@@ -388,10 +390,10 @@ const interChatMessageSelect = () => sql`
388
390
  `;
389
391
 
390
392
  type ResourceOccurrenceCursor = ResourceCursor & { chat: string };
391
- const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow): string =>
393
+ const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow & { cursor_at: string }): string =>
392
394
  encodeURIComponent(
393
395
  JSON.stringify({
394
- at: iso(row.last_seen_at),
396
+ at: row.cursor_at,
395
397
  type: row.resource_type,
396
398
  id: row.resource_id,
397
399
  chat: row.conversation_short_id,
@@ -777,6 +779,76 @@ const messageSearchText = (message: Message): string => {
777
779
  return text.slice(0, SEARCH_TEXT_MAX_CHARS);
778
780
  };
779
781
 
782
+ /**
783
+ * Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
784
+ * time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
785
+ * never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
786
+ * limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
787
+ */
788
+ const recordTurnEnd = async (
789
+ db: typeof sql,
790
+ input: { conversationId: string; turnId: string; reason: "aborted" | "error" },
791
+ ): Promise<void> => {
792
+ const replaces = input.reason === "error" ? "aborted" : null;
793
+ for (const table of ["ai.messages", "ai.task_messages"]) {
794
+ await db`
795
+ UPDATE ${db(table)}
796
+ SET loop_done_reason = ${input.reason}
797
+ WHERE id = (
798
+ SELECT id
799
+ FROM ${db(table)}
800
+ WHERE conversation_id = ${input.conversationId}
801
+ AND loop_id = ${input.turnId}::text
802
+ AND compacted_at IS NULL
803
+ AND kind = 'message'
804
+ AND role = 'assistant'
805
+ ORDER BY seq DESC
806
+ LIMIT 1
807
+ )
808
+ AND (loop_done_reason IS NULL OR loop_done_reason = ${replaces}::text)
809
+ `;
810
+ // An approved call without a result keeps the decision on the message that holds the call. A decision on a custom
811
+ // approval belongs to the call that asked for it.
812
+ await db`
813
+ UPDATE ${db(table)} target
814
+ SET meta = jsonb_set(
815
+ COALESCE(target.meta, '{}'::jsonb),
816
+ '{toolOutcomes}',
817
+ COALESCE(target.meta->'toolOutcomes', '{}'::jsonb) || outcomes.value
818
+ )
819
+ FROM (
820
+ SELECT message.id, jsonb_object_agg(approved.call_id, 'approved'::text) AS value
821
+ FROM (
822
+ SELECT DISTINCT
823
+ CASE
824
+ WHEN action.kind = 'custom_approval' THEN COALESCE(substring(action.call_id FROM '^(.*)-approval-[0-9]+$'), action.call_id)
825
+ ELSE action.call_id
826
+ END AS call_id
827
+ FROM ai.pending_actions action
828
+ WHERE action.turn_id = ${input.turnId}
829
+ AND action.resolved_event->>'type' = 'approval_response'
830
+ AND action.resolved_event->>'approved' = 'true'
831
+ ) approved
832
+ JOIN ${db(table)} message
833
+ ON message.conversation_id = ${input.conversationId}
834
+ AND message.loop_id = ${input.turnId}::text
835
+ AND message.role = 'assistant'
836
+ AND message.message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', approved.call_id))
837
+ WHERE NOT EXISTS (
838
+ SELECT 1
839
+ FROM ${db(table)} result
840
+ WHERE result.conversation_id = ${input.conversationId}
841
+ AND result.loop_id = ${input.turnId}::text
842
+ AND result.role = 'tool_result'
843
+ AND result.message->>'callId' = approved.call_id
844
+ )
845
+ GROUP BY message.id
846
+ ) outcomes
847
+ WHERE target.id = outcomes.id
848
+ `;
849
+ }
850
+ };
851
+
780
852
  /** Insert a message inside an open conversation-lock transaction and bump the conversation. */
781
853
  const insertMessageLocked = async (
782
854
  input: {
@@ -925,10 +997,14 @@ const toolMessageMeta = (
925
997
  message: Message,
926
998
  presentations: ReadonlyMap<string, AiToolPresentation> | undefined,
927
999
  rejectedToolCallIds: ReadonlySet<string> | undefined,
1000
+ approvedToolCallIds?: ReadonlySet<string>,
928
1001
  ): AiStoredMessage["meta"] => {
929
1002
  if (message.role === "tool_result" && rejectedToolCallIds?.has(message.callId)) {
930
1003
  return { toolOutcomes: { [message.callId]: "rejected" } };
931
1004
  }
1005
+ if (message.role === "tool_result" && approvedToolCallIds?.has(message.callId)) {
1006
+ return { toolOutcomes: { [message.callId]: "approved" } };
1007
+ }
932
1008
  if (message.role !== "assistant" || !presentations || presentations.size === 0) return null;
933
1009
  const toolPresentations = Object.fromEntries(
934
1010
  message.content.flatMap((block) => {
@@ -1365,9 +1441,19 @@ export const aiConversations: AiConversationService = {
1365
1441
  preview = COALESCE(EXCLUDED.preview, ai.conversation_resource_refs.preview),
1366
1442
  icon = COALESCE(EXCLUDED.icon, ai.conversation_resource_refs.icon),
1367
1443
  href = COALESCE(EXCLUDED.href, ai.conversation_resource_refs.href),
1368
- source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_resource_refs.source_turn_id),
1444
+ -- Project context is indexed on every turn without a call. It must not move a resource a tool call
1445
+ -- read to the latest turn, or the chat would show it as read just now.
1446
+ source_turn_id = CASE
1447
+ WHEN EXCLUDED.source_call_id IS NULL AND ai.conversation_resource_refs.source_call_id IS NOT NULL
1448
+ THEN ai.conversation_resource_refs.source_turn_id
1449
+ ELSE COALESCE(EXCLUDED.source_turn_id, ai.conversation_resource_refs.source_turn_id)
1450
+ END,
1369
1451
  source_call_id = COALESCE(EXCLUDED.source_call_id, ai.conversation_resource_refs.source_call_id),
1370
- last_seen_at = now()
1452
+ last_seen_at = CASE
1453
+ WHEN EXCLUDED.source_call_id IS NULL AND ai.conversation_resource_refs.source_call_id IS NOT NULL
1454
+ THEN ai.conversation_resource_refs.last_seen_at
1455
+ ELSE now()
1456
+ END
1371
1457
  `;
1372
1458
  }
1373
1459
  });
@@ -1377,9 +1463,10 @@ export const aiConversations: AiConversationService = {
1377
1463
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1378
1464
  const pattern = searchPattern(input.search);
1379
1465
  const cursor = decodeResourceCursor(input.before);
1380
- const rows = await sql<ConversationResourceRefRow[]>`
1466
+ const rows = await sql<(ConversationResourceRefRow & { cursor_at: string })[]>`
1381
1467
  SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
1382
- source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
1468
+ source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
1469
+ to_char(indexed.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at
1383
1470
  FROM ai.conversation_resource_refs indexed
1384
1471
  LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
1385
1472
  WHERE indexed.conversation_id = ${input.conversationId}::uuid
@@ -1400,11 +1487,12 @@ export const aiConversations: AiConversationService = {
1400
1487
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1401
1488
  const pattern = searchPattern(input.search);
1402
1489
  const cursor = decodeResourceOccurrenceCursor(input.before);
1403
- const rows = await sql<ConversationResourceOccurrenceRow[]>`
1490
+ const rows = await sql<(ConversationResourceOccurrenceRow & { cursor_at: string })[]>`
1404
1491
  SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
1405
1492
  source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
1406
1493
  conversation.short_id AS conversation_short_id, conversation.title AS conversation_title,
1407
- conversation.updated_at AS conversation_updated_at
1494
+ conversation.updated_at AS conversation_updated_at,
1495
+ to_char(indexed.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at
1408
1496
  FROM ai.conversation_resource_refs indexed
1409
1497
  JOIN ai.conversations conversation ON conversation.id = indexed.conversation_id
1410
1498
  LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
@@ -1427,17 +1515,21 @@ export const aiConversations: AiConversationService = {
1427
1515
  const source = input.source;
1428
1516
  await sql`
1429
1517
  INSERT INTO ai.conversation_sources (
1430
- conversation_id, kind, source_key, title, preview, icon, href, source_turn_id, source_call_id
1518
+ conversation_id, kind, source_key, title, preview, icon, href, resource_type, resource_id, source_turn_id, source_call_id
1431
1519
  ) VALUES (
1432
1520
  ${input.conversationId}::uuid, ${source.kind}, ${source.key}, ${source.title}, ${source.preview ?? null},
1433
- ${source.icon ?? null}, ${source.href ?? null}, ${input.turnId ?? null}::uuid, ${input.callId ?? null}
1521
+ ${source.icon ?? null}, ${source.href ?? null}, ${source.ref?.type ?? null}, ${source.ref?.id ?? null},
1522
+ ${input.turnId ?? null}::uuid, ${input.callId ?? null}
1434
1523
  )
1435
1524
  ON CONFLICT (conversation_id, kind, source_key)
1436
1525
  DO UPDATE SET
1437
1526
  title = EXCLUDED.title,
1438
- preview = COALESCE(EXCLUDED.preview, ai.conversation_sources.preview),
1527
+ -- A new delivery describes the result anew; an old description must not outlive changed content.
1528
+ preview = CASE WHEN EXCLUDED.kind = 'result' THEN EXCLUDED.preview ELSE COALESCE(EXCLUDED.preview, ai.conversation_sources.preview) END,
1439
1529
  icon = COALESCE(EXCLUDED.icon, ai.conversation_sources.icon),
1440
1530
  href = COALESCE(EXCLUDED.href, ai.conversation_sources.href),
1531
+ resource_type = COALESCE(EXCLUDED.resource_type, ai.conversation_sources.resource_type),
1532
+ resource_id = COALESCE(EXCLUDED.resource_id, ai.conversation_sources.resource_id),
1441
1533
  occurrences = ai.conversation_sources.occurrences +
1442
1534
  CASE WHEN ai.conversation_sources.source_call_id IS DISTINCT FROM EXCLUDED.source_call_id THEN 1 ELSE 0 END,
1443
1535
  source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_sources.source_turn_id),
@@ -1450,50 +1542,85 @@ export const aiConversations: AiConversationService = {
1450
1542
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1451
1543
  const pattern = searchPattern(input.search);
1452
1544
  const cursor = decodeResourceCursor(input.before);
1453
- const rows = await sql<ConversationSourceRow[]>`
1454
- SELECT *
1455
- FROM (
1456
- SELECT source.kind AS source_kind, source.source_key, source.title, source.preview,
1457
- source.icon, source.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
1458
- NULL::text AS resource_type, NULL::text AS resource_id, source.occurrences,
1459
- turn.short_id AS source_turn_id, source.source_call_id, source.first_seen_at, source.last_seen_at
1460
- FROM ai.conversation_sources source
1461
- LEFT JOIN ai.turns turn ON turn.id = source.source_turn_id
1462
- WHERE source.conversation_id = ${input.conversationId}::uuid
1463
-
1464
- UNION ALL
1465
-
1466
- SELECT 'file' AS source_kind, file.path AS source_key,
1467
- regexp_replace(file.path, '^.*/', '') AS title, 'Attached to the conversation' AS preview,
1468
- CASE WHEN file.media_type LIKE 'image/%' THEN 'ti ti-photo' ELSE 'ti ti-file' END AS icon,
1469
- NULL::text AS href, file.path, file.media_type, file.size,
1470
- NULL::text AS resource_type, NULL::text AS resource_id, 1 AS occurrences,
1471
- NULL::text AS source_turn_id, NULL::text AS source_call_id,
1472
- file.updated_at AS first_seen_at, file.updated_at AS last_seen_at
1473
- FROM ai.files file
1474
- WHERE file.conversation_id = ${input.conversationId}::uuid
1475
-
1476
- UNION ALL
1477
-
1478
- SELECT 'resource' AS source_kind, indexed.resource_type || ':' || indexed.resource_id AS source_key,
1479
- COALESCE(indexed.title, indexed.resource_type || ' ' || indexed.resource_id) AS title,
1480
- indexed.preview, indexed.icon, indexed.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
1481
- indexed.resource_type, indexed.resource_id, 1 AS occurrences,
1482
- turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
1483
- FROM ai.conversation_resource_refs indexed
1484
- LEFT JOIN ai.turns turn ON turn.id = indexed.source_turn_id
1485
- WHERE indexed.conversation_id = ${input.conversationId}::uuid
1486
- ) source
1487
- WHERE (${pattern}::text IS NULL OR LOWER(source.title || ' ' || COALESCE(source.preview, '') || ' ' || source.source_key) LIKE ${pattern})
1488
- AND (${cursor?.at ?? null}::timestamptz IS NULL OR (source.last_seen_at, source.source_kind, source.source_key) <
1489
- (${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text))
1490
- ORDER BY source.last_seen_at DESC, source.source_kind DESC, source.source_key DESC
1491
- LIMIT ${limit + 1}
1545
+ const kinds = input.kinds?.length ? toPgTextArray([...input.kinds]) : null;
1546
+ const observed = input.observed === true;
1547
+ const rows = await sql<(ConversationSourceRow & { total: number | string; source_message_seq: number | null; cursor_at: string })[]>`
1548
+ WITH matched AS (
1549
+ SELECT *
1550
+ FROM (
1551
+ SELECT source.kind AS source_kind, source.source_key, source.title, source.preview,
1552
+ source.icon, source.href, file.path, file.media_type, file.size,
1553
+ source.resource_type, source.resource_id, source.occurrences,
1554
+ turn.short_id AS source_turn_id, source.source_turn_id AS turn_uuid, source.source_call_id,
1555
+ source.first_seen_at, source.last_seen_at
1556
+ FROM ai.conversation_sources source
1557
+ LEFT JOIN ai.turns turn ON turn.id = source.source_turn_id
1558
+ LEFT JOIN ai.files file
1559
+ ON source.kind = 'result' AND file.conversation_id = source.conversation_id AND file.path = source.source_key
1560
+ WHERE source.conversation_id = ${input.conversationId}::uuid
1561
+
1562
+ UNION ALL
1563
+
1564
+ SELECT 'file' AS source_kind, file.path AS source_key,
1565
+ regexp_replace(file.path, '^.*/', '') AS title, 'Attached to the conversation' AS preview,
1566
+ CASE WHEN file.media_type LIKE 'image/%' THEN 'ti ti-photo' ELSE 'ti ti-file' END AS icon,
1567
+ NULL::text AS href, file.path, file.media_type, file.size,
1568
+ NULL::text AS resource_type, NULL::text AS resource_id, 1 AS occurrences,
1569
+ NULL::text AS source_turn_id, NULL::uuid AS turn_uuid, NULL::text AS source_call_id,
1570
+ file.updated_at AS first_seen_at, file.updated_at AS last_seen_at
1571
+ FROM ai.files file
1572
+ WHERE file.conversation_id = ${input.conversationId}::uuid
1573
+
1574
+ UNION ALL
1575
+
1576
+ SELECT 'resource' AS source_kind, indexed.resource_type || ':' || indexed.resource_id AS source_key,
1577
+ COALESCE(indexed.title, indexed.resource_type || ' ' || indexed.resource_id) AS title,
1578
+ indexed.preview, indexed.icon, indexed.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
1579
+ indexed.resource_type, indexed.resource_id, 1 AS occurrences,
1580
+ turn.short_id AS source_turn_id, indexed.source_turn_id AS turn_uuid, indexed.source_call_id,
1581
+ indexed.first_seen_at, indexed.last_seen_at
1582
+ FROM ai.conversation_resource_refs indexed
1583
+ LEFT JOIN ai.turns turn ON turn.id = indexed.source_turn_id
1584
+ WHERE indexed.conversation_id = ${input.conversationId}::uuid
1585
+ ) source
1586
+ WHERE (${kinds}::text[] IS NULL OR source.source_kind = ANY(${kinds}::text[]))
1587
+ AND (NOT ${observed} OR source.source_kind <> 'resource' OR source.source_call_id IS NOT NULL)
1588
+ AND (${pattern}::text IS NULL OR LOWER(source.title || ' ' || COALESCE(source.preview, '') || ' ' || source.source_key) LIKE ${pattern})
1589
+ ),
1590
+ page AS (
1591
+ SELECT *
1592
+ FROM matched source
1593
+ WHERE ${cursor?.at ?? null}::timestamptz IS NULL OR (source.last_seen_at, source.source_kind, source.source_key) <
1594
+ (${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text)
1595
+ ORDER BY source.last_seen_at DESC, source.source_kind DESC, source.source_key DESC
1596
+ LIMIT ${limit + 1}
1597
+ )
1598
+ 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,
1599
+ COALESCE(
1600
+ (SELECT message.seq FROM ai.messages message
1601
+ WHERE message.conversation_id = ${input.conversationId}::uuid AND message.loop_id = page.turn_uuid::text
1602
+ AND message.role = 'assistant' AND page.source_call_id IS NOT NULL
1603
+ AND (CASE WHEN jsonb_typeof(message.message) = 'string' THEN (message.message #>> '{}')::jsonb ELSE message.message END) -> 'content'
1604
+ @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', page.source_call_id))
1605
+ ORDER BY message.seq ASC
1606
+ LIMIT 1),
1607
+ (SELECT min(message.seq) FROM ai.messages message
1608
+ WHERE message.conversation_id = ${input.conversationId}::uuid AND message.loop_id = page.turn_uuid::text)
1609
+ ) AS source_message_seq
1610
+ FROM (SELECT count(*) AS total FROM matched) totals
1611
+ LEFT JOIN page ON true
1612
+ ORDER BY page.last_seen_at DESC, page.source_kind DESC, page.source_key DESC
1492
1613
  `;
1493
- const page = rows.slice(0, limit);
1614
+ // One row carries the total even when the page is empty.
1615
+ const total = Number(rows[0]?.total ?? 0);
1616
+ const page = rows.filter((row) => row.source_kind !== null).slice(0, limit);
1494
1617
  return {
1495
- sources: page.map(rowToConversationSource),
1618
+ sources: page.map((row) => ({
1619
+ ...rowToConversationSource(row),
1620
+ sourceMessageSeq: row.source_message_seq === null ? null : Number(row.source_message_seq),
1621
+ })),
1496
1622
  ...(rows.length > limit && page.at(-1) ? { nextCursor: encodeSourceCursor(page.at(-1)!) } : {}),
1623
+ total,
1497
1624
  };
1498
1625
  },
1499
1626
 
@@ -2618,10 +2745,14 @@ export const aiConversations: AiConversationService = {
2618
2745
  LIMIT 1
2619
2746
  `;
2620
2747
  if (!rows[0]) return null;
2748
+ // The waits let a reconnecting client show work time without time spent waiting for the user.
2749
+ const waits = await activeTurnWaits(rows[0].id);
2621
2750
  return {
2622
2751
  turn: rowToTurn(rows[0]),
2623
2752
  liveBlocks: rowToLiveBlocks(rows[0]),
2624
2753
  liveSeq: Number(rows[0].live_seq ?? 0),
2754
+ actionWaitMs: Math.max(0, Math.round(waits.actionWaitMs)),
2755
+ waitingSince: waits.waitingSince,
2625
2756
  };
2626
2757
  },
2627
2758
 
@@ -2822,6 +2953,11 @@ export const aiConversations: AiConversationService = {
2822
2953
  AND status = 'pending'
2823
2954
  `;
2824
2955
  if (input.status !== "completed") {
2956
+ await recordTurnEnd(tx, {
2957
+ conversationId: input.conversationId,
2958
+ turnId: input.turnId,
2959
+ reason: input.status === "aborted" ? "aborted" : "error",
2960
+ });
2825
2961
  await tx`
2826
2962
  UPDATE ai.turn_steers
2827
2963
  SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
@@ -2922,7 +3058,7 @@ export const aiConversations: AiConversationService = {
2922
3058
  }));
2923
3059
 
2924
3060
  // 3) Finalize aborts: cancel-requested turns without a live lease, and expired waits.
2925
- const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string }[]>`
3061
+ const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string; stopped: boolean }[]>`
2926
3062
  UPDATE ai.turns
2927
3063
  SET status = 'aborted',
2928
3064
  completed_at = now(),
@@ -2939,7 +3075,7 @@ export const aiConversations: AiConversationService = {
2939
3075
  )
2940
3076
  LIMIT ${limit}
2941
3077
  )
2942
- RETURNING id, conversation_id, attempt, live_seq
3078
+ RETURNING id, conversation_id, attempt, live_seq, cancel_requested_at IS NOT NULL AS stopped
2943
3079
  `;
2944
3080
  result.aborted = abortedRows.map((row) => ({
2945
3081
  conversationId: row.conversation_id,
@@ -2948,7 +3084,14 @@ export const aiConversations: AiConversationService = {
2948
3084
  seq: Number(row.live_seq) + 1,
2949
3085
  }));
2950
3086
 
3087
+ // History tells a stop from a turn that failed or whose wait expired, as it does for turns that end on their own.
3088
+ const stopped = new Set(abortedRows.filter((row) => row.stopped).map((row) => row.id));
2951
3089
  for (const finalized of [...result.failed, ...result.aborted]) {
3090
+ await recordTurnEnd(sql, {
3091
+ conversationId: finalized.conversationId,
3092
+ turnId: finalized.turnId,
3093
+ reason: stopped.has(finalized.turnId) ? "aborted" : "error",
3094
+ });
2952
3095
  await sql`
2953
3096
  UPDATE ai.pending_actions
2954
3097
  SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
@@ -3300,7 +3443,7 @@ export const aiConversations: AiConversationService = {
3300
3443
  append: async (message, opts) => {
3301
3444
  // Initial input and durable steering are already persisted transactionally before Nessi appends them.
3302
3445
  if (message.role === "user") return;
3303
- const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds);
3446
+ const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds, input.approvedToolCallIds);
3304
3447
 
3305
3448
  if (input.turnId && input.leaseOwner) {
3306
3449
  const appended = await appendTurnOwnedMessage({