@k2b/cloud 0.24.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.
Files changed (53) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/registry.ts +1 -0
  4. package/src/access/GroupCoverage.tsx +175 -0
  5. package/src/access/PermissionEditor.tsx +134 -93
  6. package/src/access/managers.ts +32 -0
  7. package/src/access/messages.ts +36 -0
  8. package/src/ai/approval-routes.ts +5 -5
  9. package/src/ai/capabilities.ts +34 -12
  10. package/src/ai/chat/blocks.tsx +86 -179
  11. package/src/ai/chat/builtin-tools.tsx +57 -30
  12. package/src/ai/chat/live-turn.browser-harness.tsx +37 -0
  13. package/src/ai/chat/message-actions.tsx +6 -2
  14. package/src/ai/chat/message-utils.ts +17 -14
  15. package/src/ai/chat/messages.ts +324 -2
  16. package/src/ai/chat/presentation.tsx +257 -103
  17. package/src/ai/chat/tool-groups.ts +55 -35
  18. package/src/ai/chat/turn-layout.ts +141 -0
  19. package/src/ai/chat/turn-view.tsx +609 -0
  20. package/src/ai/client/projection.ts +37 -6
  21. package/src/ai/executor.ts +38 -5
  22. package/src/ai/memory-learning.ts +7 -12
  23. package/src/ai/memory-workflow-evidence.ts +10 -2
  24. package/src/ai/migrate.ts +45 -2
  25. package/src/ai/model-access.ts +4 -2
  26. package/src/ai/prefs.ts +19 -5
  27. package/src/ai/projects.ts +5 -2
  28. package/src/ai/protocol.ts +20 -4
  29. package/src/ai/provider-fetch.ts +67 -15
  30. package/src/ai/provider-retry.ts +105 -0
  31. package/src/ai/quota-provider.ts +14 -5
  32. package/src/ai/skills.ts +5 -2
  33. package/src/ai/store.ts +108 -3
  34. package/src/ai/stream.ts +2 -0
  35. package/src/ai/timeline.ts +9 -11
  36. package/src/ai/turn-timing.ts +31 -3
  37. package/src/ai/types.ts +12 -2
  38. package/src/browser/FileChooser.tsx +600 -0
  39. package/src/browser/choose-files.browser-harness.tsx +32 -0
  40. package/src/browser/choose-files.tsx +70 -0
  41. package/src/browser/file-chooser-messages.ts +86 -0
  42. package/src/browser/file-providers.ts +217 -0
  43. package/src/browser/files.tsx +23 -0
  44. package/src/cli/admin/account-administration.ts +2 -2
  45. package/src/contracts/registry.ts +2 -0
  46. package/src/server/index.ts +1 -0
  47. package/src/server/services/access.ts +44 -11
  48. package/src/server/services/index.ts +1 -0
  49. package/src/shared/app-presentation.ts +10 -2
  50. package/src/ssr/platform-messages.ts +2 -2
  51. package/src/styles/effects.css +69 -0
  52. package/src/styles/file-chooser.css +169 -0
  53. package/src/styles/global.css +1 -0
@@ -1,27 +1,63 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
 
3
3
  /**
4
- * Dates the provider response headers and the first body byte of the inference
5
- * call that is currently streaming, without touching the wire. nessi adapters
6
- * call the global `fetch`; the wrapper is installed once, on first use, and
7
- * only observes requests made inside `runWithProviderFetchMarks`.
4
+ * Observes the provider request of the inference call that is currently
5
+ * running, without touching the wire: when its headers and first body byte
6
+ * arrived, whether the provider certainly did not process it, and how long a
7
+ * rejecting provider asked the caller to wait. nessi adapters call the global
8
+ * `fetch`; the wrapper is installed once, on first use, and only observes
9
+ * requests made inside `runWithProviderFetchMarks`. Scopes nest, so an outer
10
+ * retry policy and the inner per-call accounting each see the same request.
8
11
  *
9
12
  * Nothing here logs: frames may carry reasoning text and headers carry keys.
10
13
  */
11
- export type ProviderFetchMarks = { headersAt?: number; firstByteAt?: number };
14
+ export type ProviderFetchMarks = {
15
+ headersAt?: number;
16
+ firstByteAt?: number;
17
+ /** The request never reached the provider, or the provider answered with a status that says it did not process it. */
18
+ refused?: boolean;
19
+ retryAfterMs?: number;
20
+ };
12
21
 
13
- const marks = new AsyncLocalStorage<ProviderFetchMarks>();
22
+ const scopes = new AsyncLocalStorage<readonly ProviderFetchMarks[]>();
14
23
  let installed = false;
15
24
 
16
25
  export const runWithProviderFetchMarks = <T>(store: ProviderFetchMarks, run: () => Promise<T>): Promise<T> => {
17
26
  install();
18
- return marks.run(store, run);
27
+ return scopes.run([...(scopes.getStore() ?? []), store], run);
28
+ };
29
+
30
+ /** `retry-after-ms` (OpenAI) wins over the standard `retry-after` seconds or HTTP date. */
31
+ export const retryAfterMs = (headers: Headers, now = Date.now()): number | undefined => {
32
+ const milliseconds = Number(headers.get("retry-after-ms")?.trim() || Number.NaN);
33
+ if (Number.isFinite(milliseconds) && milliseconds >= 0) return milliseconds;
34
+ const value = headers.get("retry-after")?.trim();
35
+ if (!value) return undefined;
36
+ const seconds = Number(value);
37
+ if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1_000 : undefined;
38
+ const date = Date.parse(value);
39
+ return Number.isFinite(date) ? Math.max(0, date - now) : undefined;
19
40
  };
20
41
 
21
- const firstByteObserver = (store: ProviderFetchMarks) =>
42
+ /**
43
+ * A client error, 503, or Anthropic's overloaded 529 says the provider did not
44
+ * process the request. Other server errors, such as a gateway's 502 or 504, can
45
+ * follow processing upstream.
46
+ */
47
+ const refusedStatus = (status: number) => (status >= 400 && status < 500) || status === 503 || status === 529;
48
+
49
+ /** Bun's codes for a request that never left: no connection, or no address for the host. */
50
+ const unsentCodes = new Set(["ConnectionRefused", "ENOTFOUND"]);
51
+ const unsent = (error: unknown) =>
52
+ typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && unsentCodes.has(error.code);
53
+
54
+ const firstByteObserver = (stores: readonly ProviderFetchMarks[]) =>
22
55
  new TransformStream<Uint8Array, Uint8Array>({
23
56
  transform(chunk, controller) {
24
- if (chunk.byteLength > 0 && store.firstByteAt === undefined) store.firstByteAt = Date.now();
57
+ if (chunk.byteLength > 0)
58
+ for (const store of stores) {
59
+ store.firstByteAt ??= Date.now();
60
+ }
25
61
  controller.enqueue(chunk);
26
62
  },
27
63
  });
@@ -31,13 +67,29 @@ const install = (): void => {
31
67
  installed = true;
32
68
  const realFetch = globalThis.fetch;
33
69
  const instrumented = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
34
- const store = marks.getStore();
35
- // Only the first request of a call is the provider request; nessi retries create a new call.
36
- if (store === undefined || store.headersAt !== undefined) return realFetch(input, init);
37
- const response = await realFetch(input, init);
38
- store.headersAt = Date.now();
70
+ // Only the first request of a scope is its provider request; a retry opens a new scope.
71
+ const stores = (scopes.getStore() ?? []).filter((store) => store.headersAt === undefined);
72
+ if (stores.length === 0) return realFetch(input, init);
73
+ let response: Response;
74
+ try {
75
+ response = await realFetch(input, init);
76
+ } catch (error) {
77
+ // A reset, an abort, or a timeout can follow delivery, so only an unsent request counts as refused.
78
+ if (unsent(error))
79
+ for (const store of stores) {
80
+ store.refused = true;
81
+ }
82
+ throw error;
83
+ }
84
+ const headersAt = Date.now();
85
+ const wait = response.ok ? undefined : retryAfterMs(response.headers, headersAt);
86
+ for (const store of stores) {
87
+ store.headersAt = headersAt;
88
+ store.refused = refusedStatus(response.status);
89
+ if (wait !== undefined) store.retryAfterMs = wait;
90
+ }
39
91
  if (!response.body) return response;
40
- return new Response(response.body.pipeThrough(firstByteObserver(store)), {
92
+ return new Response(response.body.pipeThrough(firstByteObserver(stores)), {
41
93
  status: response.status,
42
94
  statusText: response.statusText,
43
95
  headers: response.headers,
@@ -0,0 +1,105 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import type { NessiIssue, Provider, ProviderIssue, StreamEvent, TimeoutIssue } from "@k2b/nessi/ai";
3
+ import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
4
+
5
+ /** Waits before the first and the second retry when the provider names none. nessi itself never retries. */
6
+ export const AI_PROVIDER_RETRY_DELAYS_MS: readonly number[] = [1_000, 4_000];
7
+
8
+ /**
9
+ * The longest Retry-After that is honored, the bound the OpenAI and Anthropic
10
+ * SDKs apply. A provider that asks for a longer wait is unavailable for this
11
+ * turn, so the call fails now with the provider's message.
12
+ */
13
+ const AI_PROVIDER_MAX_RETRY_AFTER_MS = 60_000;
14
+
15
+ export type AiProviderRetry = {
16
+ /** 1 for the first retry. */
17
+ retry: number;
18
+ delayMs: number;
19
+ issue: ProviderIssue | TimeoutIssue;
20
+ };
21
+
22
+ const transientIssue = (issue: NessiIssue): issue is ProviderIssue | TimeoutIssue =>
23
+ (issue.kind === "provider_error" && issue.retryable && !issue.contextOverflow) ||
24
+ (issue.kind === "timeout" && issue.retryable && issue.scope !== "tool");
25
+
26
+ const isBlockEvent = (event: StreamEvent) => event.type === "block_start" || event.type === "block_delta" || event.type === "block_end";
27
+
28
+ /**
29
+ * Repeats a model call that failed transiently (429, 5xx, a lost connection, a
30
+ * provider timeout) before it produced any output. Each attempt passes through
31
+ * `provider` again, so quota admission and accounting see every request.
32
+ *
33
+ * Once a block event was emitted the attempt is final: streamed text cannot be
34
+ * taken back, so a failure mid-stream still ends the call. Context overflow is
35
+ * left to compaction. Waits follow the provider's Retry-After, otherwise
36
+ * `delaysMs`, end before `deadline`, and stop on the request's abort signal.
37
+ */
38
+ export function retryTransientProviderErrors(
39
+ provider: Provider,
40
+ options: {
41
+ /** Epoch milliseconds by which a wait must end; null when the turn has no run time limit. */
42
+ deadline: number | null;
43
+ delaysMs?: readonly number[];
44
+ /** Announces a wait before it starts. */
45
+ onRetry?: (retry: AiProviderRetry) => Promise<void>;
46
+ },
47
+ ): Provider {
48
+ const delays = options.delaysMs ?? AI_PROVIDER_RETRY_DELAYS_MS;
49
+ const waitFor = (retry: number, marks: ProviderFetchMarks, signal: AbortSignal | undefined): number | null => {
50
+ if (retry >= delays.length || signal?.aborted) return null;
51
+ const delayMs = marks.retryAfterMs ?? delays[retry]!;
52
+ if (delayMs > AI_PROVIDER_MAX_RETRY_AFTER_MS) return null;
53
+ if (options.deadline !== null && Date.now() + delayMs >= options.deadline) return null;
54
+ return delayMs;
55
+ };
56
+ return {
57
+ name: provider.name,
58
+ family: provider.family,
59
+ model: provider.model,
60
+ contextWindow: provider.contextWindow,
61
+ capabilities: provider.capabilities,
62
+ complete: (request) => provider.complete(request),
63
+ stream: async function* (request) {
64
+ for (let retry = 0; ; retry += 1) {
65
+ const marks: ProviderFetchMarks = {};
66
+ const events = provider.stream(request)[Symbol.asyncIterator]();
67
+ const next = () => runWithProviderFetchMarks(marks, () => events.next());
68
+ // Events before the first block (usage, issues) are held so that a retried attempt leaves no trace.
69
+ const held: StreamEvent[] = [];
70
+ let committed = false;
71
+ let pending: AiProviderRetry | null = null;
72
+ try {
73
+ for (let step = await next(); !step.done; step = await next()) {
74
+ const event = step.value;
75
+ if (!committed && event.type === "issue" && transientIssue(event.issue)) {
76
+ const delayMs = waitFor(retry, marks, request.signal);
77
+ if (delayMs !== null) {
78
+ pending = { retry: retry + 1, delayMs, issue: event.issue };
79
+ break;
80
+ }
81
+ }
82
+ if (!committed && !isBlockEvent(event)) {
83
+ held.push(event);
84
+ continue;
85
+ }
86
+ if (!committed) {
87
+ committed = true;
88
+ yield* held.splice(0);
89
+ }
90
+ yield event;
91
+ }
92
+ } finally {
93
+ // Settles the failed attempt's accounting before the wait starts.
94
+ await events.return?.();
95
+ }
96
+ if (!pending) {
97
+ yield* held;
98
+ return;
99
+ }
100
+ await options.onRetry?.(pending);
101
+ await delay(pending.delayMs, undefined, { signal: request.signal });
102
+ }
103
+ },
104
+ };
105
+ }
@@ -5,7 +5,7 @@ import type { AccessSubject } from "../server/services/access";
5
5
  import { logger } from "../services/logging";
6
6
  import { isAssistantChatTurn } from "./assistant-models";
7
7
  import { AiBackgroundAdmissionError, type AiCallContext, type AiCallDetails, beginAiCall, finishAiCall } from "./inference-calls";
8
- import { runWithProviderFetchMarks } from "./provider-fetch";
8
+ import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
9
9
  import type { AiModelProfile } from "./types";
10
10
 
11
11
  const log = logger("ai:quotas");
@@ -93,7 +93,7 @@ export function inferenceProvider(
93
93
  let status: "ok" | "failed" = "failed";
94
94
  let error: string | undefined;
95
95
  const requestStartedAt = Date.now();
96
- const marks: { headersAt?: number; firstByteAt?: number } = {};
96
+ const marks: ProviderFetchMarks = {};
97
97
  try {
98
98
  const result = await runWithProviderFetchMarks(marks, () =>
99
99
  provider.complete({ ...request, maxOutputTokens: call.maxOutputTokens }),
@@ -112,7 +112,9 @@ export function inferenceProvider(
112
112
  throw thrown;
113
113
  } finally {
114
114
  call.stop();
115
- if (!usage && status === "failed") usage = { input: call.inputTokens, output: 0, estimated: true };
115
+ // A request the provider refused or never received cost nothing; any other failure may have been processed.
116
+ if (!usage && status === "failed")
117
+ usage = marks.refused ? { input: 0, output: 0 } : { input: call.inputTokens, output: 0, estimated: true };
116
118
  const cancelled = request.signal?.aborted === true;
117
119
  await finish(call.id, usage, cancelled && status === "failed" ? "aborted" : status, {
118
120
  error: cancelled ? null : error,
@@ -133,7 +135,7 @@ export function inferenceProvider(
133
135
  const outputBlocks = new Map<string, number>();
134
136
  let generated = false;
135
137
  const requestStartedAt = Date.now();
136
- const marks: { headersAt?: number; firstByteAt?: number; firstBlockAt?: number } = {};
138
+ const marks: ProviderFetchMarks & { firstBlockAt?: number } = {};
137
139
  // The wrapped adapter reads lazily, so the request only leaves once the first pull runs inside the marked scope.
138
140
  const events = provider.stream({ ...request, maxOutputTokens: call.maxOutputTokens })[Symbol.asyncIterator]();
139
141
  const next = () => runWithProviderFetchMarks(marks, () => events.next());
@@ -158,7 +160,14 @@ export function inferenceProvider(
158
160
  outputBlocks.set(event.blockId, size);
159
161
  }
160
162
  if (event.type === "block_start" || event.type === "block_delta" || event.type === "block_end") generated = true;
161
- if (event.type === "issue" && event.issue.kind === "provider_error" && event.issue.contextOverflow && !generated && !usage)
163
+ // A provider that refused the request, or never received it, did not process it.
164
+ if (
165
+ event.type === "issue" &&
166
+ event.issue.kind === "provider_error" &&
167
+ (event.issue.contextOverflow || marks.refused) &&
168
+ !generated &&
169
+ !usage
170
+ )
162
171
  usage = { input: 0, output: 0 };
163
172
  if (event.type === "usage") {
164
173
  if (event.finishReason === "aborted" || event.finishReason === "interrupted" || event.finishReason === "error") failed = true;
package/src/ai/skills.ts CHANGED
@@ -58,6 +58,7 @@ export type AiSkillAccess = {
58
58
  principal: Principal;
59
59
  permission: AiSkillPermission;
60
60
  displayName?: string;
61
+ avatarHash?: string | null;
61
62
  /** Kind of a `service_account` principal; presentation only. */
62
63
  serviceAccountKind?: ServiceAccountKind;
63
64
  createdAt: string;
@@ -130,6 +131,7 @@ type SkillAccessRow = {
130
131
  permission: AiSkillPermission;
131
132
  created_at: Date | string;
132
133
  display_name: string | null;
134
+ avatar_hash: string | null;
133
135
  service_account_kind: ServiceAccountKind | null;
134
136
  };
135
137
 
@@ -284,9 +286,9 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
284
286
  const rows = await db<SkillAccessRow[]>`
285
287
  SELECT skill_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
286
288
  access.permission, access.created_at,
287
- COALESCE(users.display_name, groups.name, service_accounts.name,
289
+ COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name,
288
290
  CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
289
- service_accounts.kind AS service_account_kind
291
+ users.avatar_hash, service_accounts.kind AS service_account_kind
290
292
  FROM ai.skill_access skill_access
291
293
  JOIN auth.access access ON access.id = skill_access.access_id
292
294
  LEFT JOIN auth.users users ON users.id = access.user_id
@@ -309,6 +311,7 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
309
311
  : { type: "public" },
310
312
  permission: row.permission,
311
313
  displayName: row.display_name ?? undefined,
314
+ ...(row.user_id ? { avatarHash: row.avatar_hash } : {}),
312
315
  serviceAccountKind: row.service_account_kind ?? undefined,
313
316
  createdAt: iso(row.created_at),
314
317
  }));
package/src/ai/store.ts CHANGED
@@ -4,9 +4,11 @@ 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 { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
7
8
  import type { AiTurnBlock } from "./protocol";
8
9
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
9
10
  import { parseAiTodoPlan } from "./todo-contracts";
11
+ import { activeTurnWaits } from "./turn-timing";
10
12
  import type {
11
13
  AiConversation,
12
14
  AiConversationDraft,
@@ -776,6 +778,76 @@ const messageSearchText = (message: Message): string => {
776
778
  return text.slice(0, SEARCH_TEXT_MAX_CHARS);
777
779
  };
778
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
+
779
851
  /** Insert a message inside an open conversation-lock transaction and bump the conversation. */
780
852
  const insertMessageLocked = async (
781
853
  input: {
@@ -924,10 +996,14 @@ const toolMessageMeta = (
924
996
  message: Message,
925
997
  presentations: ReadonlyMap<string, AiToolPresentation> | undefined,
926
998
  rejectedToolCallIds: ReadonlySet<string> | undefined,
999
+ approvedToolCallIds?: ReadonlySet<string>,
927
1000
  ): AiStoredMessage["meta"] => {
928
1001
  if (message.role === "tool_result" && rejectedToolCallIds?.has(message.callId)) {
929
1002
  return { toolOutcomes: { [message.callId]: "rejected" } };
930
1003
  }
1004
+ if (message.role === "tool_result" && approvedToolCallIds?.has(message.callId)) {
1005
+ return { toolOutcomes: { [message.callId]: "approved" } };
1006
+ }
931
1007
  if (message.role !== "assistant" || !presentations || presentations.size === 0) return null;
932
1008
  const toolPresentations = Object.fromEntries(
933
1009
  message.content.flatMap((block) => {
@@ -2617,10 +2693,14 @@ export const aiConversations: AiConversationService = {
2617
2693
  LIMIT 1
2618
2694
  `;
2619
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);
2620
2698
  return {
2621
2699
  turn: rowToTurn(rows[0]),
2622
2700
  liveBlocks: rowToLiveBlocks(rows[0]),
2623
2701
  liveSeq: Number(rows[0].live_seq ?? 0),
2702
+ actionWaitMs: Math.max(0, Math.round(waits.actionWaitMs)),
2703
+ waitingSince: waits.waitingSince,
2624
2704
  };
2625
2705
  },
2626
2706
 
@@ -2801,6 +2881,19 @@ export const aiConversations: AiConversationService = {
2801
2881
  live_blocks = NULL
2802
2882
  WHERE id = ${input.turnId}
2803
2883
  `;
2884
+ if (input.status === "completed") {
2885
+ // Learning considers only turns that finish while it is on for the chat
2886
+ // owner; turning it on later does not reach back to this turn.
2887
+ await tx`
2888
+ UPDATE ai.turns turn
2889
+ SET memory_learned_at = turn.completed_at
2890
+ FROM ai.conversations conversation
2891
+ LEFT JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
2892
+ WHERE turn.id = ${input.turnId}
2893
+ AND conversation.id = turn.conversation_id
2894
+ AND NOT COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
2895
+ `;
2896
+ }
2804
2897
  await tx`
2805
2898
  UPDATE ai.pending_actions
2806
2899
  SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
@@ -2808,6 +2901,11 @@ export const aiConversations: AiConversationService = {
2808
2901
  AND status = 'pending'
2809
2902
  `;
2810
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
+ });
2811
2909
  await tx`
2812
2910
  UPDATE ai.turn_steers
2813
2911
  SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
@@ -2908,7 +3006,7 @@ export const aiConversations: AiConversationService = {
2908
3006
  }));
2909
3007
 
2910
3008
  // 3) Finalize aborts: cancel-requested turns without a live lease, and expired waits.
2911
- 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 }[]>`
2912
3010
  UPDATE ai.turns
2913
3011
  SET status = 'aborted',
2914
3012
  completed_at = now(),
@@ -2925,7 +3023,7 @@ export const aiConversations: AiConversationService = {
2925
3023
  )
2926
3024
  LIMIT ${limit}
2927
3025
  )
2928
- RETURNING id, conversation_id, attempt, live_seq
3026
+ RETURNING id, conversation_id, attempt, live_seq, cancel_requested_at IS NOT NULL AS stopped
2929
3027
  `;
2930
3028
  result.aborted = abortedRows.map((row) => ({
2931
3029
  conversationId: row.conversation_id,
@@ -2934,7 +3032,14 @@ export const aiConversations: AiConversationService = {
2934
3032
  seq: Number(row.live_seq) + 1,
2935
3033
  }));
2936
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));
2937
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
+ });
2938
3043
  await sql`
2939
3044
  UPDATE ai.pending_actions
2940
3045
  SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
@@ -3286,7 +3391,7 @@ export const aiConversations: AiConversationService = {
3286
3391
  append: async (message, opts) => {
3287
3392
  // Initial input and durable steering are already persisted transactionally before Nessi appends them.
3288
3393
  if (message.role === "user") return;
3289
- const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds);
3394
+ const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds, input.approvedToolCallIds);
3290
3395
 
3291
3396
  if (input.turnId && input.leaseOwner) {
3292
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