@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
@@ -9,6 +9,14 @@ export type AiActiveTurn = {
9
9
  status: "running" | "waiting_for_action";
10
10
  blocks: AiTurnBlock[];
11
11
  modelProfileId: string | null;
12
+ /** A model call failed transiently and waits for its retry. Live only: the next turn event or a snapshot ends it. */
13
+ providerRetry?: boolean;
14
+ /** Server start of the turn, known from a state snapshot. */
15
+ createdAt?: string;
16
+ /** Time already spent waiting for answered user actions, from the last state snapshot. */
17
+ actionWaitMs?: number;
18
+ /** Start of the user action the turn waited for at the last state snapshot. */
19
+ waitingSince?: string | null;
12
20
  };
13
21
 
14
22
  export type AiChatProjection = {
@@ -90,7 +98,7 @@ export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiv
90
98
  const pendingSteers = previous.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed");
91
99
  const known = new Set(incoming.blocks.map((block) => block.id));
92
100
  const blocks = [...incoming.blocks, ...pendingSteers.filter((block) => !known.has(block.id))];
93
- return { ...incoming, blocks, status: deriveStatus(blocks) };
101
+ return { ...turnTiming(previous), ...incoming, blocks, status: deriveStatus(blocks) };
94
102
  };
95
103
 
96
104
  export const reconcileActiveTurnActions = (
@@ -112,9 +120,22 @@ export const activeTurnFromSnapshot = (snapshot: AiTurnSnapshot | null): AiActiv
112
120
  status: snapshot.status === "waiting_for_action" ? "waiting_for_action" : "running",
113
121
  blocks: normalizeCustomApprovalBlocks(snapshot.blocks),
114
122
  modelProfileId: snapshot.modelProfileId,
123
+ createdAt: snapshot.createdAt,
124
+ ...(snapshot.actionWaitMs !== undefined ? { actionWaitMs: snapshot.actionWaitMs } : {}),
125
+ ...(snapshot.waitingSince !== undefined ? { waitingSince: snapshot.waitingSince } : {}),
115
126
  };
116
127
  };
117
128
 
129
+ /** Snapshot timing survives later attempts of the same turn, which carry no timing of their own. */
130
+ const turnTiming = (turn: AiActiveTurn | null | undefined): Pick<AiActiveTurn, "createdAt" | "actionWaitMs" | "waitingSince"> =>
131
+ turn
132
+ ? {
133
+ ...(turn.createdAt !== undefined ? { createdAt: turn.createdAt } : {}),
134
+ ...(turn.actionWaitMs !== undefined ? { actionWaitMs: turn.actionWaitMs } : {}),
135
+ ...(turn.waitingSince !== undefined ? { waitingSince: turn.waitingSince } : {}),
136
+ }
137
+ : {};
138
+
118
139
  /**
119
140
  * Fold one stream event into the projection. Pure and total: the client and any
120
141
  * test converge on the same state for the same ordered event sequence.
@@ -125,6 +146,7 @@ export const activeTurnFromSnapshot = (snapshot: AiTurnSnapshot | null): AiActiv
125
146
  * stale attempts are ignored. Older senders without a baseline reset to only
126
147
  * locally pending steering blocks.
127
148
  * - block events apply only when strictly newer than the active turn's cursor.
149
+ * - `provider_retry` marks the active turn until its next event.
128
150
  * - `turn_finished` folds the turn's persisted messages in and clears the active turn.
129
151
  */
130
152
  export const reduceProjection = (state: AiChatProjection, event: AiStreamEvent): AiChatProjection => {
@@ -146,6 +168,8 @@ export const reduceProjection = (state: AiChatProjection, event: AiStreamEvent):
146
168
  return reduceWireEvent(state, event);
147
169
  };
148
170
 
171
+ const withoutProviderRetry = ({ providerRetry: _, ...turn }: AiActiveTurn): AiActiveTurn => turn;
172
+
149
173
  export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): AiChatProjection => {
150
174
  const active = state.activeTurn;
151
175
 
@@ -163,6 +187,7 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
163
187
  status: deriveStatus(blocks),
164
188
  blocks,
165
189
  modelProfileId: event.modelProfileId,
190
+ ...turnTiming(active?.turnId === event.turnId ? active : null),
166
191
  },
167
192
  };
168
193
  }
@@ -172,20 +197,26 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
172
197
  return { ...state, messages: mergeMessages(state.messages, event.messages ?? []), activeTurn: null };
173
198
  }
174
199
 
200
+ if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
201
+
202
+ if (event.type === "provider_retry") {
203
+ return { ...state, activeTurn: { ...active, seq: event.seq, attempt: event.attempt, providerRetry: true } };
204
+ }
205
+
175
206
  if (event.type === "message_saved") {
176
- if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
177
207
  return {
178
208
  ...state,
179
209
  messages: mergeMessages(state.messages, [event.message]),
180
- activeTurn: { ...active, seq: event.seq, attempt: event.attempt },
210
+ activeTurn: { ...withoutProviderRetry(active), seq: event.seq, attempt: event.attempt },
181
211
  };
182
212
  }
183
213
 
184
214
  // block_set / block_delta
185
- if (!active || active.turnId !== event.turnId) return state;
186
- if (!isNewerWireEvent(event, active)) return state;
187
215
  const blocks = applyWireEventToBlocks(active.blocks, event);
188
- return { ...state, activeTurn: { ...active, attempt: event.attempt, seq: event.seq, blocks, status: deriveStatus(blocks) } };
216
+ return {
217
+ ...state,
218
+ activeTurn: { ...withoutProviderRetry(active), attempt: event.attempt, seq: event.seq, blocks, status: deriveStatus(blocks) },
219
+ };
189
220
  };
190
221
 
191
222
  /** Assistant/tool messages of the active turn are represented by live blocks; hide them. */
@@ -40,6 +40,7 @@ import {
40
40
  streamBlockId,
41
41
  toolBlockId,
42
42
  } from "./protocol";
43
+ import { retryTransientProviderErrors } from "./provider-retry";
43
44
  import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
44
45
  import { collectConversationResourceObservations } from "./resource-refs";
45
46
  import { AiRunTimeout } from "./run-timeout";
@@ -249,6 +250,8 @@ export type ExecutorConfig = {
249
250
  validateTurn?: typeof validateAiTurnRequest;
250
251
  /** Runs after the durable turn state and final wire event are flushed. */
251
252
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
253
+ /** Waits before retrying a transient provider failure without Retry-After; tests shorten them. */
254
+ providerRetryDelaysMs?: readonly number[];
252
255
  };
253
256
 
254
257
  type ResolvedModel = Awaited<ReturnType<typeof resolveAiModel>>;
@@ -392,6 +395,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
392
395
  const setRejectedCallIds = (items: Set<string>) => {
393
396
  rejectedCallIds = items;
394
397
  };
398
+ let approvedCallIds = new Set<string>();
399
+ const setApprovedCallIds = (items: Set<string>) => {
400
+ approvedCallIds = items;
401
+ };
395
402
  /** nessi stream block ids (turn-scoped) that belong to tool_call blocks — their deltas are raw args JSON. */
396
403
  const toolStreamIds = new Set<string>();
397
404
  /** kind per open Cloud stream block id, for delta create-if-missing. */
@@ -414,6 +421,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
414
421
  approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false } : approval,
415
422
  frontendMode: patch.frontendMode ?? existing?.frontendMode,
416
423
  presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
424
+ ...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
417
425
  };
418
426
  toolBlocks.set(callId, block);
419
427
  return { type: "block_set", block };
@@ -518,6 +526,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
518
526
  setApprovalPolicies,
519
527
  setApprovalReviews,
520
528
  setRejectedCallIds,
529
+ setApprovedCallIds,
521
530
  };
522
531
  };
523
532
 
@@ -829,6 +838,7 @@ export class AiTurnExecutor {
829
838
  pipeline.setApprovalPolicies(prepared.approvalPolicies);
830
839
  const toolPresentations = new Map<string, AiToolPresentation>();
831
840
  const rejectedToolCallIds = new Set<string>();
841
+ const approvedToolCallIds = new Set<string>();
832
842
  pipeline.setPresentations(toolPresentations);
833
843
  pipeline.setApprovalReviews(capabilityActionReviews);
834
844
  let turnInput = config.input;
@@ -868,17 +878,19 @@ export class AiTurnExecutor {
868
878
  turnInput,
869
879
  toolPresentations,
870
880
  rejectedToolCallIds,
881
+ approvedToolCallIds,
871
882
  });
872
883
 
873
884
  const { loopMessages, pendingRecords, resolvedRecords, turnSteers } =
874
885
  attemptState ?? (await loadChatAttemptState(conversationId, turnId));
875
886
  for (const action of resolvedRecords) {
876
- if (action.resolvedEvent?.type !== "approval_response" || action.resolvedEvent.approved) continue;
877
- rejectedToolCallIds.add(
878
- action.kind === "custom_approval" ? (customApprovalParentCallId(action.callId) ?? action.callId) : action.callId,
879
- );
887
+ if (action.resolvedEvent?.type !== "approval_response") continue;
888
+ // A decision on a custom approval belongs to the call that asked for it.
889
+ const callId = action.kind === "custom_approval" ? (customApprovalParentCallId(action.callId) ?? action.callId) : action.callId;
890
+ (action.resolvedEvent.approved ? approvedToolCallIds : rejectedToolCallIds).add(callId);
880
891
  }
881
892
  pipeline.setRejectedCallIds(rejectedToolCallIds);
893
+ pipeline.setApprovedCallIds(approvedToolCallIds);
882
894
  const assistantMessages = loopMessages.filter((message) => message.message.role !== "user");
883
895
  const isFresh = assistantMessages.length === 0 && resolvedRecords.length === 0 && !skipResolvedActions;
884
896
 
@@ -1064,8 +1076,19 @@ export class AiTurnExecutor {
1064
1076
  });
1065
1077
  const priorToolRounds = toolRoundState(loopMessages);
1066
1078
  const quotaSubject = accessSubjectForActor(material.actor);
1079
+ const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
1067
1080
  const toolRoundPolicy = applyToolRoundPolicy({
1068
- provider: assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
1081
+ provider: retryTransientProviderErrors(
1082
+ assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
1083
+ {
1084
+ deadline,
1085
+ delaysMs: this.config.providerRetryDelaysMs,
1086
+ onRetry: async ({ retry, delayMs, issue }) => {
1087
+ log.warn("AI provider call retried", { conversationId, turnId, retry, delayMs, kind: issue.kind, message: issue.message });
1088
+ await pipeline.emitProviderRetry();
1089
+ },
1090
+ },
1091
+ ),
1069
1092
  tools,
1070
1093
  maxToolRounds: resolved.profile.maxToolRounds,
1071
1094
  issuedToolRounds: priorToolRounds.issued,
@@ -1667,6 +1690,10 @@ class StreamPipeline {
1667
1690
  this.mapper.setRejectedCallIds(callIds);
1668
1691
  }
1669
1692
 
1693
+ setApprovedCallIds(callIds: Set<string>): void {
1694
+ this.mapper.setApprovedCallIds(callIds);
1695
+ }
1696
+
1670
1697
  async emitBaseline(): Promise<void> {
1671
1698
  for (const block of this.blocks) {
1672
1699
  const seq = this.nextSeq();
@@ -1765,6 +1792,12 @@ class StreamPipeline {
1765
1792
  await this.publish(event);
1766
1793
  }
1767
1794
 
1795
+ /** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
1796
+ async emitProviderRetry(): Promise<void> {
1797
+ const seq = this.nextSeq();
1798
+ await this.publish(this.envelope({ type: "provider_retry" as const, seq }));
1799
+ }
1800
+
1768
1801
  async emitTurnFinished(status: "completed" | "failed" | "aborted", error: string | null): Promise<void> {
1769
1802
  const seq = this.nextSeq();
1770
1803
  await this.publish(this.envelope({ type: "turn_finished" as const, seq, status, error }) as AiWireEvent);
@@ -16,6 +16,7 @@ import {
16
16
  listAiTurnWorkflowEvidence,
17
17
  markAiWorkflowPatternReviewed,
18
18
  } from "./memory-workflow-evidence";
19
+ import { AI_MEMORY_LEARNING_DEFAULT_ENABLED, aiUserPrefs } from "./prefs";
19
20
  import { aiConversations } from "./store";
20
21
  import type { RunAiStructuredInput, RunAiStructuredResult } from "./structured";
21
22
  import { resolveAiBackgroundModel, runAiStructured } from "./structured";
@@ -148,11 +149,11 @@ export const listAiMemoryLearningCandidates = async (limit: number, monthlyToken
148
149
  ) AS user_rank
149
150
  FROM ai.turns turn
150
151
  JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
151
- JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
152
+ LEFT JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
152
153
  LEFT JOIN monthly_usage usage ON usage.user_id = conversation.created_by_user_id
153
154
  WHERE conversation.created_by_user_id IS NOT NULL
154
155
  AND conversation.archived_at IS NULL
155
- AND prefs.memory_learning_enabled = TRUE
156
+ AND COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
156
157
  AND turn.status = 'completed'
157
158
  AND COALESCE(turn.run_config->>'kind', 'chat') = 'chat'
158
159
  AND turn.memory_learned_at IS NULL
@@ -196,14 +197,7 @@ const markFailed = async (candidate: Candidate): Promise<void> => {
196
197
  `;
197
198
  };
198
199
 
199
- const learningEnabled = async (userId: string): Promise<boolean> => {
200
- const [row] = await sql<{ enabled: boolean }[]>`
201
- SELECT COALESCE(memory_learning_enabled, FALSE) AS enabled
202
- FROM ai.user_prefs
203
- WHERE user_id = ${userId}::uuid
204
- `;
205
- return Boolean(row?.enabled);
206
- };
200
+ const learningEnabled = async (userId: string): Promise<boolean> => (await aiUserPrefs.get(userId)).memoryLearningEnabled;
207
201
 
208
202
  const monthlyAccountedTokens = async (userId: string): Promise<number> => {
209
203
  const [row] = await sql<{ tokens: number }[]>`
@@ -447,7 +441,6 @@ export const learnAiMemoriesFromPrivateChats = async (
447
441
  let runId: string | null = null;
448
442
  let changes: AiMemoryLearningChange[] = [];
449
443
  try {
450
- if (!(await learningEnabled(candidate.userId))) continue;
451
444
  const evidence = await loadTurnEvidence(candidate);
452
445
  if (!evidence.userText) {
453
446
  await markLearned(candidate);
@@ -465,6 +458,8 @@ export const learnAiMemoriesFromPrivateChats = async (
465
458
  });
466
459
  const reservedTokens = estimatedTokens(systemPrompt, taskInput);
467
460
  if ((await readMonthlyUsage(candidate.userId)) + reservedTokens > monthlyTokenBudget) continue;
461
+ // Read the choice again right before the turn can reach the model.
462
+ if (!(await learningEnabled(candidate.userId))) continue;
468
463
  runId = await aiMemoryLearningRuns.start({
469
464
  userId: candidate.userId,
470
465
  conversationId: candidate.conversationId,
@@ -550,7 +545,6 @@ export const learnAiMemoriesFromPrivateChats = async (
550
545
  let runId: string | null = null;
551
546
  const startedAt = Date.now();
552
547
  try {
553
- if (!(await learningEnabled(pattern.userId))) continue;
554
548
  const context = await workflowPatternInput(pattern);
555
549
  if (!context.source) {
556
550
  await markAiWorkflowPatternReviewed(pattern);
@@ -564,6 +558,7 @@ export const learnAiMemoriesFromPrivateChats = async (
564
558
  });
565
559
  const reservedTokens = estimatedTokens(workflowPrompt, context.input);
566
560
  if ((await readMonthlyUsage(pattern.userId)) + reservedTokens > monthlyTokenBudget) continue;
561
+ if (!(await learningEnabled(pattern.userId))) continue;
567
562
  runId = await aiMemoryLearningRuns.start({
568
563
  userId: pattern.userId,
569
564
  conversationId: context.source.candidate.conversationId,
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import { type CloudResourceRef, CloudResourceRefSchema } from "../contracts/capabilities";
3
+ import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
3
4
 
4
5
  const MAX_RECEIPT_RESOURCES = 20;
5
6
  const MAX_CAPABILITY_ID_CHARS = 180;
@@ -22,7 +23,11 @@ export type AiMemoryWorkflowPattern = {
22
23
  turnIds: string[];
23
24
  };
24
25
 
25
- /** Persist only successful, schema-valid capability/resource receipts. */
26
+ /**
27
+ * Persist only successful, schema-valid capability/resource receipts, and only
28
+ * while learning is on for the user: receipts from a time it was off never
29
+ * count toward a workflow pattern.
30
+ */
26
31
  export const recordAiMemoryWorkflowEvidence = async (input: {
27
32
  userId: string;
28
33
  conversationId: string;
@@ -61,9 +66,11 @@ export const recordAiMemoryWorkflowEvidence = async (input: {
61
66
  ${resource.ref.type}, ${resource.ref.id}, ${resource.title ?? null}
62
67
  FROM ai.turns turn
63
68
  JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
69
+ LEFT JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
64
70
  WHERE turn.id = ${input.turnId}::uuid
65
71
  AND conversation.id = ${input.conversationId}::uuid
66
72
  AND conversation.created_by_user_id = ${input.userId}::uuid
73
+ AND COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
67
74
  ON CONFLICT (turn_id, capability_id, resource_type, resource_id)
68
75
  DO UPDATE SET resource_title = COALESCE(EXCLUDED.resource_title, ai.memory_workflow_evidence.resource_title)
69
76
  `;
@@ -116,10 +123,11 @@ export const listAiPendingWorkflowPatterns = async (limit = 5): Promise<AiMemory
116
123
  FILTER (WHERE evidence.resource_title IS NOT NULL))[1] AS resource_title,
117
124
  count(*)::int AS observation_count
118
125
  FROM ai.memory_workflow_evidence evidence
119
- JOIN ai.user_prefs prefs ON prefs.user_id = evidence.user_id AND prefs.memory_learning_enabled = TRUE
126
+ LEFT JOIN ai.user_prefs prefs ON prefs.user_id = evidence.user_id
120
127
  JOIN ai.conversations conversation
121
128
  ON conversation.id = evidence.conversation_id AND conversation.created_by_user_id = evidence.user_id
122
129
  WHERE evidence.reviewed_at IS NULL
130
+ AND COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
123
131
  GROUP BY evidence.user_id, evidence.capability_id, evidence.resource_type, evidence.resource_id
124
132
  HAVING count(*) >= 3
125
133
  ORDER BY min(evidence.observed_at), evidence.user_id, evidence.capability_id, evidence.resource_type, evidence.resource_id
package/src/ai/migrate.ts CHANGED
@@ -951,7 +951,7 @@ export const migrateCloudAi = async (): Promise<void> => {
951
951
  CREATE TABLE IF NOT EXISTS ai.user_prefs (
952
952
  user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
953
953
  memory_enabled BOOLEAN NOT NULL DEFAULT TRUE,
954
- memory_learning_enabled BOOLEAN NOT NULL DEFAULT FALSE,
954
+ memory_learning_enabled BOOLEAN,
955
955
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
956
956
  )
957
957
  `.simple();
@@ -960,7 +960,16 @@ export const migrateCloudAi = async (): Promise<void> => {
960
960
  // memory blob. Deliberately discard it instead of migrating ambiguous lines.
961
961
  await sql`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS memory`.simple();
962
962
  await sql`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS instructions`.simple();
963
- await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS memory_learning_enabled BOOLEAN NOT NULL DEFAULT FALSE`.simple();
963
+ await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS memory_learning_enabled BOOLEAN`.simple();
964
+ // NULL means the user never chose, so the default (on) applies. Earlier
965
+ // releases stored FALSE for every new row, and an explicit "off" looks the
966
+ // same, so existing FALSE rows stay as they are rather than being switched on.
967
+ // memory_learning_chosen_at, added below, tells later explicit choices apart.
968
+ await sql`
969
+ ALTER TABLE ai.user_prefs
970
+ ALTER COLUMN memory_learning_enabled DROP NOT NULL,
971
+ ALTER COLUMN memory_learning_enabled DROP DEFAULT
972
+ `.simple();
964
973
 
965
974
  // Last model the user actually ran a turn with — preselected for new chats.
966
975
  await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS last_model_id TEXT NOT NULL DEFAULT ''`.simple();
@@ -1125,6 +1134,40 @@ export const migrateCloudAi = async (): Promise<void> => {
1125
1134
  WHERE reviewed_at IS NULL
1126
1135
  `.simple();
1127
1136
 
1137
+ // Learning considers only turns that finish while it is on: completeTurn marks
1138
+ // the others, and receipts are recorded only while it is on. When learning
1139
+ // became the default, every earlier turn and receipt of a person without an
1140
+ // explicit "on" came from a time it was off, so they are marked as considered
1141
+ // exactly once, when the column that records explicit choices is introduced.
1142
+ await sql`
1143
+ DO $$
1144
+ BEGIN
1145
+ IF NOT EXISTS (
1146
+ SELECT 1 FROM information_schema.columns
1147
+ WHERE table_schema = 'ai' AND table_name = 'user_prefs' AND column_name = 'memory_learning_chosen_at'
1148
+ ) THEN
1149
+ ALTER TABLE ai.user_prefs ADD COLUMN memory_learning_chosen_at TIMESTAMPTZ;
1150
+ UPDATE ai.turns turn
1151
+ SET memory_learned_at = COALESCE(turn.completed_at, turn.created_at)
1152
+ FROM ai.conversations conversation
1153
+ WHERE conversation.id = turn.conversation_id
1154
+ AND turn.status = 'completed'
1155
+ AND turn.memory_learned_at IS NULL
1156
+ AND NOT EXISTS (
1157
+ SELECT 1 FROM ai.user_prefs prefs
1158
+ WHERE prefs.user_id = conversation.created_by_user_id AND prefs.memory_learning_enabled
1159
+ );
1160
+ UPDATE ai.memory_workflow_evidence evidence
1161
+ SET reviewed_at = now()
1162
+ WHERE evidence.reviewed_at IS NULL
1163
+ AND NOT EXISTS (
1164
+ SELECT 1 FROM ai.user_prefs prefs
1165
+ WHERE prefs.user_id = evidence.user_id AND prefs.memory_learning_enabled
1166
+ );
1167
+ END IF;
1168
+ END $$
1169
+ `.simple();
1170
+
1128
1171
  await sql`
1129
1172
  CREATE TABLE IF NOT EXISTS ai.memory_learning_runs (
1130
1173
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -114,13 +114,14 @@ export const aiModelAccess = {
114
114
  service_account_id: string | null;
115
115
  authenticated_only: boolean | null;
116
116
  display_name: string | null;
117
+ avatar_hash: string | null;
117
118
  service_account_kind: ServiceAccountKind | null;
118
119
  }[]
119
120
  >`
120
121
  SELECT resource.profile_id, resource.revision, access.id, access.created_at,
121
122
  access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
122
- COALESCE(users.display_name, groups.name, service_accounts.name) AS display_name,
123
- service_accounts.kind AS service_account_kind
123
+ COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name) AS display_name,
124
+ users.avatar_hash, service_accounts.kind AS service_account_kind
124
125
  FROM ai.model_access_resources resource LEFT JOIN ai.model_access model ON model.profile_id = resource.profile_id
125
126
  LEFT JOIN auth.access access ON access.id = model.access_id
126
127
  LEFT JOIN auth.users users ON users.id = access.user_id
@@ -148,6 +149,7 @@ export const aiModelAccess = {
148
149
  permission: "read",
149
150
  createdAt: row.created_at.toISOString(),
150
151
  ...(row.display_name ? { displayName: row.display_name } : {}),
152
+ ...(row.user_id ? { avatarHash: row.avatar_hash } : {}),
151
153
  ...(row.service_account_kind ? { serviceAccountKind: row.service_account_kind } : {}),
152
154
  });
153
155
  }
package/src/ai/prefs.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  import { sql } from "bun";
2
2
  import type { RequestActor } from "../server";
3
3
 
4
+ /**
5
+ * Learning from private chats applies to everyone who has not chosen.
6
+ * A stored `NULL` means "never chose"; only an explicit save stores a boolean.
7
+ */
8
+ export const AI_MEMORY_LEARNING_DEFAULT_ENABLED = true;
9
+
4
10
  export type AiUserPrefs = {
5
11
  userId: string;
6
12
  memoryEnabled: boolean;
13
+ /** Effective setting: the user's explicit choice, otherwise the default. */
7
14
  memoryLearningEnabled: boolean;
8
15
  /** Model profile id of the user's most recent chat turn — preselected for new chats. */
9
16
  lastModelId: string;
@@ -13,7 +20,7 @@ export type AiUserPrefs = {
13
20
  type PrefsRow = {
14
21
  user_id: string;
15
22
  memory_enabled: boolean;
16
- memory_learning_enabled: boolean;
23
+ memory_learning_enabled: boolean | null;
17
24
  last_model_id: string | null;
18
25
  updated_at: string | Date;
19
26
  };
@@ -21,7 +28,7 @@ type PrefsRow = {
21
28
  const toPrefs = (row: PrefsRow): AiUserPrefs => ({
22
29
  userId: row.user_id,
23
30
  memoryEnabled: row.memory_enabled,
24
- memoryLearningEnabled: row.memory_learning_enabled,
31
+ memoryLearningEnabled: row.memory_learning_enabled ?? AI_MEMORY_LEARNING_DEFAULT_ENABLED,
25
32
  lastModelId: row.last_model_id ?? "",
26
33
  updatedAt: new Date(row.updated_at).toISOString(),
27
34
  });
@@ -29,7 +36,7 @@ const toPrefs = (row: PrefsRow): AiUserPrefs => ({
29
36
  const emptyPrefs = (userId: string): AiUserPrefs => ({
30
37
  userId,
31
38
  memoryEnabled: true,
32
- memoryLearningEnabled: false,
39
+ memoryLearningEnabled: AI_MEMORY_LEARNING_DEFAULT_ENABLED,
33
40
  lastModelId: "",
34
41
  updatedAt: new Date(0).toISOString(),
35
42
  });
@@ -56,13 +63,20 @@ export const aiUserPrefs = {
56
63
  ): Promise<AiUserPrefs> {
57
64
  const memoryEnabled = patch.memoryEnabled ?? null;
58
65
  const memoryLearningEnabled = patch.memoryLearningEnabled ?? null;
66
+ // Records when the person last saved the learning switch, so a stored "off"
67
+ // from an earlier release stays distinguishable from a choice made since.
68
+ const learningChosen = memoryLearningEnabled !== null;
59
69
  const lastModelId = patch.lastModelId?.trim().slice(0, 200) ?? null;
60
70
  const rows = (await sql`
61
- INSERT INTO ai.user_prefs (user_id, memory_enabled, memory_learning_enabled, last_model_id, updated_at)
62
- VALUES (${userId}, ${memoryEnabled ?? true}, ${memoryLearningEnabled ?? false}, ${lastModelId ?? ""}, now())
71
+ INSERT INTO ai.user_prefs (user_id, memory_enabled, memory_learning_enabled, memory_learning_chosen_at, last_model_id, updated_at)
72
+ VALUES (
73
+ ${userId}, ${memoryEnabled ?? true}, ${memoryLearningEnabled},
74
+ CASE WHEN ${learningChosen}::boolean THEN now() END, ${lastModelId ?? ""}, now()
75
+ )
63
76
  ON CONFLICT (user_id) DO UPDATE SET
64
77
  memory_enabled = COALESCE(${memoryEnabled}, ai.user_prefs.memory_enabled),
65
78
  memory_learning_enabled = COALESCE(${memoryLearningEnabled}, ai.user_prefs.memory_learning_enabled),
79
+ memory_learning_chosen_at = CASE WHEN ${learningChosen}::boolean THEN now() ELSE ai.user_prefs.memory_learning_chosen_at END,
66
80
  last_model_id = COALESCE(${lastModelId}, ai.user_prefs.last_model_id),
67
81
  updated_at = now()
68
82
  RETURNING *
@@ -82,6 +82,7 @@ export type AiProjectAccess = {
82
82
  principal: Principal;
83
83
  permission: AiProjectPermission;
84
84
  displayName?: string;
85
+ avatarHash?: string | null;
85
86
  /** Kind of a `service_account` principal; presentation only. */
86
87
  serviceAccountKind?: ServiceAccountKind;
87
88
  createdAt: string;
@@ -157,6 +158,7 @@ type ProjectAccessRow = {
157
158
  permission: AiProjectPermission;
158
159
  created_at: Date | string;
159
160
  display_name: string | null;
161
+ avatar_hash: string | null;
160
162
  service_account_kind: ServiceAccountKind | null;
161
163
  };
162
164
 
@@ -260,6 +262,7 @@ const toProjectAccess = (row: ProjectAccessRow): AiProjectAccess => ({
260
262
  : { type: "public" },
261
263
  permission: row.permission,
262
264
  displayName: row.display_name ?? undefined,
265
+ ...(row.user_id ? { avatarHash: row.avatar_hash } : {}),
263
266
  serviceAccountKind: row.service_account_kind ?? undefined,
264
267
  createdAt: iso(row.created_at),
265
268
  });
@@ -290,9 +293,9 @@ const listProjectAccess = async (projectId: string, db: SQL = sql): Promise<AiPr
290
293
  const rows = await db<ProjectAccessRow[]>`
291
294
  SELECT project_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
292
295
  access.permission, access.created_at,
293
- COALESCE(users.display_name, groups.name, service_accounts.name,
296
+ COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name,
294
297
  CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
295
- service_accounts.kind AS service_account_kind
298
+ users.avatar_hash, service_accounts.kind AS service_account_kind
296
299
  FROM ai.project_access project_access
297
300
  JOIN auth.access access ON access.id = project_access.access_id
298
301
  LEFT JOIN auth.users users ON users.id = access.user_id
@@ -39,6 +39,8 @@ export type AiTurnBlock =
39
39
  isError?: boolean;
40
40
  /** Present while status is awaiting_approval. */
41
41
  approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
42
+ /** The user approved this call in the chat; the decided approval stays visible as a receipt. */
43
+ approved?: boolean;
42
44
  /** Present for frontend tools. */
43
45
  frontendMode?: AiFrontendToolMode;
44
46
  /** Saved Cloud-owned display snapshot for capability calls. */
@@ -67,6 +69,8 @@ export type AiWireEvent =
67
69
  | (AiWireEventBase & { type: "message_saved"; message: AiStoredMessage })
68
70
  | (AiWireEventBase & { type: "block_set"; block: AiTurnBlock })
69
71
  | (AiWireEventBase & { type: "block_delta"; blockId: string; blockKind: "text" | "thinking"; delta: string })
72
+ /** A model call failed transiently and waits for its retry. Transient: the next event of the turn ends it. */
73
+ | (AiWireEventBase & { type: "provider_retry" })
70
74
  | (AiWireEventBase & {
71
75
  type: "turn_finished";
72
76
  status: AiTurnFinishedStatus;
@@ -85,6 +89,10 @@ export type AiTurnSnapshot = {
85
89
  blocks: AiTurnBlock[];
86
90
  modelProfileId: string | null;
87
91
  createdAt: string;
92
+ /** Time the turn already waited for answered user actions such as approvals. Absent from older servers. */
93
+ actionWaitMs?: number;
94
+ /** Start of the user action the turn waits for now, or null while it works. Absent from older servers. */
95
+ waitingSince?: string | null;
88
96
  };
89
97
 
90
98
  /** Full projection seed sent as the first SSE event on every (re)connect. */
@@ -151,7 +159,12 @@ export const reconcileResolvedTurnActions = (
151
159
  const event = resolvedByCallId.get(block.callId);
152
160
  if (!event || event.callId !== block.callId) return block;
153
161
  if (block.status === "awaiting_approval" && event.type === "approval_response") {
154
- return { ...block, status: event.approved ? "running" : "rejected", approval: undefined };
162
+ return {
163
+ ...block,
164
+ status: event.approved ? "running" : "rejected",
165
+ approval: undefined,
166
+ ...(event.approved ? { approved: true } : {}),
167
+ };
155
168
  }
156
169
  if (block.status === "awaiting_client" && event.type === "tool_result") {
157
170
  return { ...block, status: "completed", result: event.result, isError: false };
@@ -172,7 +185,7 @@ export const buildBlocksFromMessages = (
172
185
  meta?: {
173
186
  steerId?: string;
174
187
  toolPresentations?: Record<string, AiToolPresentation>;
175
- toolOutcomes?: Record<string, "rejected">;
188
+ toolOutcomes?: Record<string, "rejected" | "approved">;
176
189
  } | null;
177
190
  }[],
178
191
  ): AiTurnBlock[] => {
@@ -196,6 +209,8 @@ export const buildBlocksFromMessages = (
196
209
  args: block.args,
197
210
  status: "running",
198
211
  presentation: meta?.toolPresentations?.[block.id],
212
+ // A turn that ended before an approved call returned records the approval on the call's message.
213
+ ...(meta?.toolOutcomes?.[block.id] === "approved" ? { approved: true } : {}),
199
214
  });
200
215
  }
201
216
  });
@@ -212,12 +227,13 @@ export const buildBlocksFromMessages = (
212
227
  const at = toolIndex.get(message.callId);
213
228
  const existing = at !== undefined ? blocks[at] : undefined;
214
229
  if (existing?.kind === "tool") {
215
- const rejected = meta?.toolOutcomes?.[message.callId] === "rejected";
230
+ const outcome = meta?.toolOutcomes?.[message.callId];
216
231
  blocks[at!] = {
217
232
  ...existing,
218
- status: rejected ? "rejected" : message.isError ? "failed" : "completed",
233
+ status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
219
234
  result: message.result,
220
235
  isError: message.isError,
236
+ ...(outcome === "approved" ? { approved: true } : {}),
221
237
  };
222
238
  }
223
239
  }