@k2b/cloud 0.25.0 → 0.26.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.
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,
@@ -777,6 +778,76 @@ const messageSearchText = (message: Message): string => {
777
778
  return text.slice(0, SEARCH_TEXT_MAX_CHARS);
778
779
  };
779
780
 
781
+ /**
782
+ * Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
783
+ * time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
784
+ * never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
785
+ * limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
786
+ */
787
+ const recordTurnEnd = async (
788
+ db: typeof sql,
789
+ input: { conversationId: string; turnId: string; reason: "aborted" | "error" },
790
+ ): Promise<void> => {
791
+ const replaces = input.reason === "error" ? "aborted" : null;
792
+ for (const table of ["ai.messages", "ai.task_messages"]) {
793
+ await db`
794
+ UPDATE ${db(table)}
795
+ SET loop_done_reason = ${input.reason}
796
+ WHERE id = (
797
+ SELECT id
798
+ FROM ${db(table)}
799
+ WHERE conversation_id = ${input.conversationId}
800
+ AND loop_id = ${input.turnId}::text
801
+ AND compacted_at IS NULL
802
+ AND kind = 'message'
803
+ AND role = 'assistant'
804
+ ORDER BY seq DESC
805
+ LIMIT 1
806
+ )
807
+ AND (loop_done_reason IS NULL OR loop_done_reason = ${replaces}::text)
808
+ `;
809
+ // An approved call without a result keeps the decision on the message that holds the call. A decision on a custom
810
+ // approval belongs to the call that asked for it.
811
+ await db`
812
+ UPDATE ${db(table)} target
813
+ SET meta = jsonb_set(
814
+ COALESCE(target.meta, '{}'::jsonb),
815
+ '{toolOutcomes}',
816
+ COALESCE(target.meta->'toolOutcomes', '{}'::jsonb) || outcomes.value
817
+ )
818
+ FROM (
819
+ SELECT message.id, jsonb_object_agg(approved.call_id, 'approved'::text) AS value
820
+ FROM (
821
+ SELECT DISTINCT
822
+ CASE
823
+ WHEN action.kind = 'custom_approval' THEN COALESCE(substring(action.call_id FROM '^(.*)-approval-[0-9]+$'), action.call_id)
824
+ ELSE action.call_id
825
+ END AS call_id
826
+ FROM ai.pending_actions action
827
+ WHERE action.turn_id = ${input.turnId}
828
+ AND action.resolved_event->>'type' = 'approval_response'
829
+ AND action.resolved_event->>'approved' = 'true'
830
+ ) approved
831
+ JOIN ${db(table)} message
832
+ ON message.conversation_id = ${input.conversationId}
833
+ AND message.loop_id = ${input.turnId}::text
834
+ AND message.role = 'assistant'
835
+ AND message.message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', approved.call_id))
836
+ WHERE NOT EXISTS (
837
+ SELECT 1
838
+ FROM ${db(table)} result
839
+ WHERE result.conversation_id = ${input.conversationId}
840
+ AND result.loop_id = ${input.turnId}::text
841
+ AND result.role = 'tool_result'
842
+ AND result.message->>'callId' = approved.call_id
843
+ )
844
+ GROUP BY message.id
845
+ ) outcomes
846
+ WHERE target.id = outcomes.id
847
+ `;
848
+ }
849
+ };
850
+
780
851
  /** Insert a message inside an open conversation-lock transaction and bump the conversation. */
781
852
  const insertMessageLocked = async (
782
853
  input: {
@@ -925,10 +996,14 @@ const toolMessageMeta = (
925
996
  message: Message,
926
997
  presentations: ReadonlyMap<string, AiToolPresentation> | undefined,
927
998
  rejectedToolCallIds: ReadonlySet<string> | undefined,
999
+ approvedToolCallIds?: ReadonlySet<string>,
928
1000
  ): AiStoredMessage["meta"] => {
929
1001
  if (message.role === "tool_result" && rejectedToolCallIds?.has(message.callId)) {
930
1002
  return { toolOutcomes: { [message.callId]: "rejected" } };
931
1003
  }
1004
+ if (message.role === "tool_result" && approvedToolCallIds?.has(message.callId)) {
1005
+ return { toolOutcomes: { [message.callId]: "approved" } };
1006
+ }
932
1007
  if (message.role !== "assistant" || !presentations || presentations.size === 0) return null;
933
1008
  const toolPresentations = Object.fromEntries(
934
1009
  message.content.flatMap((block) => {
@@ -2618,10 +2693,14 @@ export const aiConversations: AiConversationService = {
2618
2693
  LIMIT 1
2619
2694
  `;
2620
2695
  if (!rows[0]) return null;
2696
+ // The waits let a reconnecting client show work time without time spent waiting for the user.
2697
+ const waits = await activeTurnWaits(rows[0].id);
2621
2698
  return {
2622
2699
  turn: rowToTurn(rows[0]),
2623
2700
  liveBlocks: rowToLiveBlocks(rows[0]),
2624
2701
  liveSeq: Number(rows[0].live_seq ?? 0),
2702
+ actionWaitMs: Math.max(0, Math.round(waits.actionWaitMs)),
2703
+ waitingSince: waits.waitingSince,
2625
2704
  };
2626
2705
  },
2627
2706
 
@@ -2822,6 +2901,11 @@ export const aiConversations: AiConversationService = {
2822
2901
  AND status = 'pending'
2823
2902
  `;
2824
2903
  if (input.status !== "completed") {
2904
+ await recordTurnEnd(tx, {
2905
+ conversationId: input.conversationId,
2906
+ turnId: input.turnId,
2907
+ reason: input.status === "aborted" ? "aborted" : "error",
2908
+ });
2825
2909
  await tx`
2826
2910
  UPDATE ai.turn_steers
2827
2911
  SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
@@ -2922,7 +3006,7 @@ export const aiConversations: AiConversationService = {
2922
3006
  }));
2923
3007
 
2924
3008
  // 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 }[]>`
3009
+ const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string; stopped: boolean }[]>`
2926
3010
  UPDATE ai.turns
2927
3011
  SET status = 'aborted',
2928
3012
  completed_at = now(),
@@ -2939,7 +3023,7 @@ export const aiConversations: AiConversationService = {
2939
3023
  )
2940
3024
  LIMIT ${limit}
2941
3025
  )
2942
- RETURNING id, conversation_id, attempt, live_seq
3026
+ RETURNING id, conversation_id, attempt, live_seq, cancel_requested_at IS NOT NULL AS stopped
2943
3027
  `;
2944
3028
  result.aborted = abortedRows.map((row) => ({
2945
3029
  conversationId: row.conversation_id,
@@ -2948,7 +3032,14 @@ export const aiConversations: AiConversationService = {
2948
3032
  seq: Number(row.live_seq) + 1,
2949
3033
  }));
2950
3034
 
3035
+ // History tells a stop from a turn that failed or whose wait expired, as it does for turns that end on their own.
3036
+ const stopped = new Set(abortedRows.filter((row) => row.stopped).map((row) => row.id));
2951
3037
  for (const finalized of [...result.failed, ...result.aborted]) {
3038
+ await recordTurnEnd(sql, {
3039
+ conversationId: finalized.conversationId,
3040
+ turnId: finalized.turnId,
3041
+ reason: stopped.has(finalized.turnId) ? "aborted" : "error",
3042
+ });
2952
3043
  await sql`
2953
3044
  UPDATE ai.pending_actions
2954
3045
  SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
@@ -3300,7 +3391,7 @@ export const aiConversations: AiConversationService = {
3300
3391
  append: async (message, opts) => {
3301
3392
  // Initial input and durable steering are already persisted transactionally before Nessi appends them.
3302
3393
  if (message.role === "user") return;
3303
- const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds);
3394
+ const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds, input.approvedToolCallIds);
3304
3395
 
3305
3396
  if (input.turnId && input.leaseOwner) {
3306
3397
  const appended = await appendTurnOwnedMessage({
package/src/ai/stream.ts CHANGED
@@ -94,6 +94,8 @@ const turnSnapshotFromActive = (active: NonNullable<Awaited<ReturnType<typeof ai
94
94
  blocks: [...active.liveBlocks],
95
95
  modelProfileId: active.turn.modelProfileId,
96
96
  createdAt: active.turn.createdAt,
97
+ actionWaitMs: active.actionWaitMs,
98
+ waitingSince: active.waitingSince,
97
99
  });
98
100
 
99
101
  /** Initial history window; older messages load on demand while scrolling up. */
@@ -12,9 +12,9 @@ export type AiAssistantTimelineItem = {
12
12
  /** The entry whose message-actions row (copy/retry/fork) is shown. */
13
13
  actionEntry: AiStoredMessage | null;
14
14
  /**
15
- * Active work duration of the loop (nessi timing: generation + tool
16
- * execution, excluding approval/client waits); legacy fallback is user
17
- * message submitted → last message persisted. Retained for response metrics.
15
+ * Work time of the loop: wall time minus time spent waiting for user actions,
16
+ * from the loop's durable timing. Without timing, the time from the user
17
+ * message to the last persisted message.
18
18
  */
19
19
  workedMs: number;
20
20
  };
@@ -32,13 +32,6 @@ export const assistantVisibleTextFromMessage = (message: Message): string => {
32
32
  .trim();
33
33
  };
34
34
 
35
- export const copyTextFromAssistantEntries = (entries: AiStoredMessage[]): string =>
36
- entries
37
- .map((entry) => assistantVisibleTextFromMessage(entry.message))
38
- .filter(Boolean)
39
- .join("\n\n")
40
- .trim();
41
-
42
35
  const isAssistantPart = (entry: AiStoredMessage): boolean =>
43
36
  entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "tool_result");
44
37
 
@@ -123,7 +116,12 @@ export const buildAiMessageTimeline = (messages: AiStoredMessage[]): AiMessageTi
123
116
  const startedAt =
124
117
  loopId && lastUserEntry?.loopId === loopId ? timestampMs(lastUserEntry.createdAt) : timestampMs(entries[0]?.createdAt);
125
118
  const finishedAt = timestampMs(entries.at(-1)?.createdAt);
126
- const workedMs = startedAt !== null && finishedAt !== null ? Math.max(0, finishedAt - startedAt) : 0;
119
+ const timing = entries.findLast((stored) => stored.loopAggregate?.timing)?.loopAggregate?.timing;
120
+ const workedMs = timing
121
+ ? Math.max(0, timing.wallMs - timing.actionWaitMs)
122
+ : startedAt !== null && finishedAt !== null
123
+ ? Math.max(0, finishedAt - startedAt)
124
+ : 0;
127
125
 
128
126
  const blocks = [
129
127
  ...steerBlocks,
@@ -80,6 +80,36 @@ export function createTurnTimingRecorder(turnId: string, db = sql) {
80
80
  };
81
81
  }
82
82
 
83
+ /**
84
+ * Time a turn waited for the person: approvals and answers until they were given. A tool the browser runs by itself
85
+ * waits only until it starts; a secret prompt waits for its answer even though the browser claimed it. An open wait
86
+ * ends now.
87
+ */
88
+ const actionWaits = (turnId: string, db: typeof sql) =>
89
+ db<{ start: Date; end: Date; open: boolean }[]>`SELECT a.created_at AS start,
90
+ COALESCE(w.end, now()) AS end, w.end IS NULL AS open
91
+ FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id
92
+ CROSS JOIN LATERAL (SELECT CASE WHEN a.kind='client_tool' AND a.tool_name<>'code_secret' THEN LEAST(t.started_at,a.resolved_at)
93
+ ELSE a.resolved_at END AS end) w
94
+ WHERE a.turn_id=${turnId}::uuid`;
95
+
96
+ /**
97
+ * Waits of a running turn by the same rules as its durable timing: `actionWaitMs` is the time already waited, and
98
+ * `waitingSince` the start of the wait that is still open, overlapping waits counted once.
99
+ */
100
+ export async function activeTurnWaits(turnId: string, db = sql): Promise<{ actionWaitMs: number; waitingSince: string | null }> {
101
+ const rows = await actionWaits(turnId, db);
102
+ const waits = union(rows.map((x) => ({ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() })));
103
+ const opens = rows.filter((x) => x.open).map((x) => new Date(x.start).getTime());
104
+ const openStart = opens.length > 0 ? Math.min(...opens) : null;
105
+ // An open wait reaches until now, so the merged wait that holds it is the last one.
106
+ const current = openStart === null ? undefined : waits.find((x) => x.start <= openStart && openStart <= x.end);
107
+ return {
108
+ actionWaitMs: duration(waits.filter((x) => x !== current)),
109
+ waitingSince: current ? new Date(current.start).toISOString() : null,
110
+ };
111
+ }
112
+
83
113
  export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggregate, db = sql): Promise<LoopAggregate> {
84
114
  const [turn] = await db<
85
115
  { start: Date; end: Date }[]
@@ -95,9 +125,7 @@ export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggre
95
125
  const tools = await db<
96
126
  { start: Date; end: Date }[]
97
127
  >`SELECT started_at AS start,COALESCE(completed_at,now()) AS end FROM ai.tool_calls WHERE turn_id=${turnId}::uuid AND started_at IS NOT NULL`;
98
- const waits = await db<{ start: Date; end: Date }[]>`SELECT a.created_at AS start,
99
- CASE WHEN a.kind='client_tool' THEN LEAST(COALESCE(t.started_at,a.resolved_at,now()),COALESCE(a.resolved_at,now())) ELSE COALESCE(a.resolved_at,now()) END AS end
100
- FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id WHERE a.turn_id=${turnId}::uuid`;
128
+ const waits = await actionWaits(turnId, db);
101
129
  const intervals = (rows: { start: Date; end: Date | null }[]) =>
102
130
  rows.flatMap((x) => (x.end ? [{ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() }] : []));
103
131
  return {
package/src/ai/types.ts CHANGED
@@ -296,7 +296,7 @@ export type AiStoredMessage = {
296
296
  trigger: "scheduled" | "manual";
297
297
  };
298
298
  toolPresentations?: Record<string, AiToolPresentation>;
299
- toolOutcomes?: Record<string, "rejected">;
299
+ toolOutcomes?: Record<string, "rejected" | "approved">;
300
300
  } | null;
301
301
  /** Private owner feedback for this rendered assistant response. Never enters model context. */
302
302
  feedback?: AiMessageFeedback | null;
@@ -864,7 +864,15 @@ export type AiConversationService = {
864
864
  getLatestTurn(input: { conversationId: string }): Promise<AiTurn | null>;
865
865
  getTurn(input: { conversationId: string; turnId: string }): Promise<AiTurn | null>;
866
866
  getTurnByShortId(input: { conversationId: string; shortId: string }): Promise<AiTurn | null>;
867
- getActiveTurn(input: { conversationId: string }): Promise<{ turn: AiTurn; liveBlocks: AiTurnBlock[]; liveSeq: number } | null>;
867
+ getActiveTurn(input: { conversationId: string }): Promise<{
868
+ turn: AiTurn;
869
+ liveBlocks: AiTurnBlock[];
870
+ liveSeq: number;
871
+ /** Time spent waiting for answered user actions. */
872
+ actionWaitMs: number;
873
+ /** Start of the oldest unanswered user action, if the turn waits now. */
874
+ waitingSince: string | null;
875
+ } | null>;
868
876
  /**
869
877
  * Claim a turn attempt. Increments attempt and takes the lease atomically.
870
878
  * `from: "queue"` claims queued or lease-expired running turns; `from: "waiting"`
@@ -944,6 +952,8 @@ export type AiConversationService = {
944
952
  toolPresentations?: ReadonlyMap<string, AiToolPresentation>;
945
953
  /** Mutable call-id set read only when a rejected tool result is persisted. */
946
954
  rejectedToolCallIds?: ReadonlySet<string>;
955
+ /** Mutable call-id set read only when the result of a call the user approved is persisted. */
956
+ approvedToolCallIds?: ReadonlySet<string>;
947
957
  }): SessionStore;
948
958
  };
949
959
 
@@ -38,6 +38,8 @@ export type CapabilityRegistryEntry = {
38
38
  endpoint: string;
39
39
  manifest: CapabilityManifest;
40
40
  presentation?: CapabilityPresentationCatalog;
41
+ /** The app's own name and description overlays, so capability consumers can present the app in the reader's locale. */
42
+ appPresentation?: AppPresentationCatalog;
41
43
  };
42
44
 
43
45
  export type AppRegistryHelpSummary = { manifestHash: string; pageBase: string; baseLocale: string };
@@ -27,6 +27,15 @@ const mergedTranslation = (catalog: AppPresentationCatalog, requestedLocale: str
27
27
  }, {});
28
28
  };
29
29
 
30
+ /** Resolve only an app's display name and description, for consumers that carry no other app metadata. */
31
+ export const resolveAppIdentityPresentation = (
32
+ app: { name: string; description: string; presentation?: AppPresentationCatalog },
33
+ requestedLocale: string,
34
+ ): { name: string; description: string } => {
35
+ const translation = app.presentation ? mergedTranslation(app.presentation, requestedLocale) : {};
36
+ return { name: translation.name ?? app.name, description: translation.description ?? app.description };
37
+ };
38
+
30
39
  /** Resolve only human presentation; stable app identity, routes, icons, and authorization stay untouched. */
31
40
  export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocale: string): T => {
32
41
  if (!app.presentation) return app;
@@ -34,8 +43,7 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
34
43
  if (Object.keys(translation).length === 0) return app;
35
44
  return {
36
45
  ...app,
37
- name: translation.name ?? app.name,
38
- description: translation.description ?? app.description,
46
+ ...resolveAppIdentityPresentation(app, requestedLocale),
39
47
  adminNav: app.adminNav?.map((group) => ({
40
48
  ...group,
41
49
  label: (group.id && translation.adminGroups?.[group.id]) || group.label,
@@ -27,6 +27,75 @@
27
27
  max-width: 100%;
28
28
  }
29
29
 
30
+ /* One assistant turn in four fixed places: work line, results, newest text, actions. */
31
+ .k2b-ui .ai-turn {
32
+ display: flex;
33
+ min-width: 0;
34
+ flex-direction: column;
35
+ gap: 0.5rem;
36
+ }
37
+
38
+ .k2b-ui .ai-turn__work {
39
+ min-width: 0;
40
+ }
41
+
42
+ .k2b-ui .ai-turn-work__meta {
43
+ font-variant-numeric: tabular-nums;
44
+ white-space: nowrap;
45
+ }
46
+
47
+ /* Phones keep the clock while the turn runs; the step count follows once it is finished. */
48
+ @media (max-width: 40rem) {
49
+ .k2b-ui .ai-turn-work__live-steps {
50
+ display: none;
51
+ }
52
+ }
53
+
54
+ /* Expanded work: one indent, one size, no lines or boxes; intermediate texts are quiet paragraphs. */
55
+ .k2b-ui .ai-turn-steps {
56
+ display: flex;
57
+ min-width: 0;
58
+ flex-direction: column;
59
+ }
60
+
61
+ .k2b-ui .ai-turn-steps__text {
62
+ padding-block: 0.25rem;
63
+ color: var(--k2b-text-muted);
64
+ }
65
+
66
+ .k2b-ui .ai-turn-steps__text .assistant-markdown-block,
67
+ .k2b-ui .ai-turn-steps__text .assistant-markdown-block :where(h1, h2, h3, h4, h5, h6) {
68
+ color: inherit;
69
+ font-size: 0.75rem;
70
+ line-height: 1.55;
71
+ }
72
+
73
+ .k2b-ui .ai-turn-steps__text .assistant-markdown-block :where(p, ul, ol, pre, blockquote) {
74
+ margin-block: 0.375rem;
75
+ }
76
+
77
+ .k2b-ui .ai-turn-steps__thought {
78
+ margin: 0;
79
+ color: var(--k2b-text-muted);
80
+ white-space: pre-wrap;
81
+ overflow-wrap: anywhere;
82
+ }
83
+
84
+ /* A decided approval keeps focus on its receipt; the place only shows a ring for keyboard focus. */
85
+ .k2b-ui .ai-turn__action {
86
+ min-width: 0;
87
+ border-radius: var(--k2b-radius-control);
88
+ }
89
+
90
+ .k2b-ui .ai-turn__action:focus {
91
+ outline: none;
92
+ }
93
+
94
+ .k2b-ui .ai-turn__action:focus-visible {
95
+ outline: var(--k2b-focus-width) solid var(--k2b-focus-ring);
96
+ outline-offset: var(--k2b-focus-offset);
97
+ }
98
+
30
99
  /* Result links sit inside an already padded activity row. Keep them inline so
31
100
  they do not make linked capability results taller than neighboring rows. */
32
101
  .k2b-ui .k2b-button.ai-chat-result-link[data-size="xs"] {