@k2b/cloud 0.23.0 → 0.25.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 (68) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/capabilities.ts +230 -75
  3. package/src/_internal/define-app.ts +12 -3
  4. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  5. package/src/_internal/page-responses.ts +1 -1
  6. package/src/_internal/registry.ts +78 -3
  7. package/src/access/PermissionEditor.tsx +32 -11
  8. package/src/access/managers.ts +32 -0
  9. package/src/access/messages.ts +6 -0
  10. package/src/ai/chat/blocks.tsx +2 -1
  11. package/src/ai/chat/messages.ts +6 -0
  12. package/src/ai/client/controller.ts +65 -20
  13. package/src/ai/client/transport.ts +5 -5
  14. package/src/ai/code-mode-skill.ts +2 -2
  15. package/src/ai/index.ts +0 -7
  16. package/src/ai/live-events.ts +1 -262
  17. package/src/ai/live.ts +31 -2
  18. package/src/ai/memory-learning.ts +7 -12
  19. package/src/ai/memory-workflow-evidence.ts +10 -2
  20. package/src/ai/migrate.ts +80 -18
  21. package/src/ai/model-access.ts +4 -2
  22. package/src/ai/prefs.ts +19 -5
  23. package/src/ai/projects.ts +5 -2
  24. package/src/ai/runtime.ts +1 -6
  25. package/src/ai/skills.ts +5 -2
  26. package/src/ai/solid.ts +0 -5
  27. package/src/ai/store.ts +14 -0
  28. package/src/api/help.ts +2 -2
  29. package/src/api/pwa-phone.ts +10 -3
  30. package/src/api/search/schemas.ts +34 -0
  31. package/src/api/search.ts +118 -50
  32. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  33. package/src/browser/CloudResourceSearch.tsx +208 -80
  34. package/src/browser/FileChooser.tsx +600 -0
  35. package/src/browser/choose-files.browser-harness.tsx +32 -0
  36. package/src/browser/choose-files.tsx +70 -0
  37. package/src/browser/file-chooser-messages.ts +86 -0
  38. package/src/browser/file-providers.ts +217 -0
  39. package/src/browser/files.tsx +23 -0
  40. package/src/browser/resource-search-messages.ts +15 -2
  41. package/src/browser/search-stream.ts +151 -0
  42. package/src/cli/admin/account-administration.ts +2 -2
  43. package/src/contracts/capabilities.ts +89 -24
  44. package/src/contracts/capability-compatibility.ts +28 -14
  45. package/src/contracts/file-provider.ts +158 -0
  46. package/src/contracts/index.ts +1 -0
  47. package/src/events/live-engine.ts +11 -7
  48. package/src/events/live.ts +32 -32
  49. package/src/server/help.ts +3 -3
  50. package/src/server/index.ts +1 -0
  51. package/src/server/services/access.ts +44 -11
  52. package/src/server/services/index.ts +1 -0
  53. package/src/services/index.ts +1 -1
  54. package/src/services/outbox.ts +29 -115
  55. package/src/services/pdf/markdown.ts +22 -2
  56. package/src/services/pwa-devices.ts +74 -8
  57. package/src/shared/markdown/formula.ts +122 -26
  58. package/src/shared/markdown/index.ts +27 -14
  59. package/src/ssr/PwaLayout.tsx +2 -1
  60. package/src/ssr/platform-messages.ts +2 -2
  61. package/src/styles/file-chooser.css +169 -0
  62. package/src/styles/global.css +1 -0
  63. package/src/styles/resource-search.css +19 -0
  64. package/src/ai/client/live-connection.ts +0 -141
  65. package/src/ai/live-messages.ts +0 -45
  66. package/src/ai/live-outbox.ts +0 -102
  67. package/src/ai/live-routes.ts +0 -412
  68. package/src/shared/markdown/extensions/info-blocks.ts +0 -79
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- import type { AiStreamEvent } from "./protocol";
3
2
 
4
3
  export const AI_INVALIDATION_DOMAINS = [
5
4
  "conversation-list",
@@ -17,8 +16,8 @@ export const AiInvalidationDomainSchema = z.enum(AI_INVALIDATION_DOMAINS);
17
16
  export type AiInvalidationDomain = z.infer<typeof AiInvalidationDomainSchema>;
18
17
 
19
18
  export const AiResourceIdSchema = z.string().regex(/^[0-9A-Za-z]{6}$/);
20
- export const AiLiveCursorSchema = z.string().regex(/^s6t\.[a-zA-Z0-9_-]+\.\d+$/);
21
19
 
20
+ /** The data of one AI live update on the `user` channel of `/api/ai/live`: which views of the user's AI data changed. */
22
21
  export const AiInvalidationSchema = z
23
22
  .object({
24
23
  type: z.literal("ai.invalidated"),
@@ -31,263 +30,3 @@ export const AiInvalidationSchema = z
31
30
  .strict();
32
31
 
33
32
  export type AiInvalidation = z.infer<typeof AiInvalidationSchema>;
34
-
35
- export const AI_LIVE_WS_TYPE = {
36
- subscribe: "ai.live.subscribe",
37
- ready: "ai.live.ready",
38
- event: "ai.live.event",
39
- scopeChanged: "ai.live.scope_changed",
40
- revoked: "ai.live.revoked",
41
- error: "ai.live.error",
42
- turnSubscribe: "ai.turn.subscribe",
43
- turnUnsubscribe: "ai.turn.unsubscribe",
44
- turnEvent: "ai.turn.event",
45
- turnError: "ai.turn.error",
46
- } as const;
47
-
48
- const AiLiveSubscribeMessageSchema = z
49
- .object({
50
- type: z.literal(AI_LIVE_WS_TYPE.subscribe),
51
- payload: z.object({ fromCursor: AiLiveCursorSchema.nullable(), recover: z.boolean() }).strict(),
52
- })
53
- .strict();
54
-
55
- const AiTurnSubscribeMessageSchema = z
56
- .object({
57
- type: z.literal(AI_LIVE_WS_TYPE.turnSubscribe),
58
- payload: z.object({ conversationId: AiResourceIdSchema }).strict(),
59
- })
60
- .strict();
61
-
62
- const AiTurnUnsubscribeMessageSchema = z
63
- .object({
64
- type: z.literal(AI_LIVE_WS_TYPE.turnUnsubscribe),
65
- payload: z.object({ conversationId: AiResourceIdSchema }).strict(),
66
- })
67
- .strict();
68
-
69
- export const AiLiveClientMessageSchema = z.discriminatedUnion("type", [
70
- AiLiveSubscribeMessageSchema,
71
- AiTurnSubscribeMessageSchema,
72
- AiTurnUnsubscribeMessageSchema,
73
- ]);
74
- export type AiLiveClientMessage = z.infer<typeof AiLiveClientMessageSchema>;
75
-
76
- const AiLiveRevocationCodeSchema = z.enum(["login_required", "access_denied"]);
77
- const AiLiveErrorCodeSchema = z.enum(["invalid_json", "invalid_message", "backpressure", "internal_error", "stream_failed"]);
78
- const AiTurnErrorCodeSchema = z.enum(["not_found", "access_denied", "stream_failed"]);
79
-
80
- export type AiLiveRevocationCode = z.infer<typeof AiLiveRevocationCodeSchema>;
81
- export type AiLiveErrorCode = z.infer<typeof AiLiveErrorCodeSchema>;
82
- export type AiTurnErrorCode = z.infer<typeof AiTurnErrorCodeSchema>;
83
-
84
- const AiTurnBlockSchema = z.discriminatedUnion("kind", [
85
- z.object({ id: z.string(), kind: z.literal("text"), text: z.string() }).strict(),
86
- z.object({ id: z.string(), kind: z.literal("thinking"), text: z.string() }).strict(),
87
- z
88
- .object({
89
- id: z.string(),
90
- kind: z.literal("steer_message"),
91
- steerId: z.string(),
92
- text: z.string(),
93
- status: z.enum(["pending", "consumed", "failed"]),
94
- })
95
- .strict(),
96
- z.object({ id: z.string(), kind: z.literal("steer_applied"), steerId: z.string() }).strict(),
97
- z
98
- .object({
99
- id: z.string(),
100
- kind: z.literal("tool"),
101
- callId: z.string(),
102
- name: z.string(),
103
- args: z.unknown().optional(),
104
- status: z.enum(["running", "awaiting_approval", "awaiting_client", "completed", "failed", "rejected"]),
105
- result: z.unknown().optional(),
106
- isError: z.boolean().optional(),
107
- approval: z.unknown().optional(),
108
- frontendMode: z.enum(["client", "client_view", "client_interaction"]).optional(),
109
- presentation: z.unknown().optional(),
110
- })
111
- .strict(),
112
- z
113
- .object({
114
- id: z.string(),
115
- kind: z.literal("compaction"),
116
- status: z.enum(["running", "completed", "skipped", "failed"]),
117
- result: z.unknown().optional(),
118
- })
119
- .strict(),
120
- ]);
121
-
122
- const AiConversationSchema = z
123
- .object({
124
- id: AiResourceIdSchema,
125
- shortId: AiResourceIdSchema,
126
- title: z.string(),
127
- titleSource: z.enum(["default", "auto", "user"]),
128
- description: z.string(),
129
- descriptionSource: z.enum(["default", "auto", "user"]),
130
- keywords: z.array(z.string()),
131
- pinnedAt: z.string().nullable(),
132
- archivedAt: z.string().nullable(),
133
- done: z.boolean().nullable(),
134
- isDone: z.boolean(),
135
- lastUsedAt: z.string(),
136
- runStatus: z.enum(["idle", "queued", "running", "needs_attention", "waiting_for_browser", "failed"]),
137
- runError: z.string().nullable(),
138
- unreadCompletion: z.boolean(),
139
- projectId: z.string().nullable(),
140
- draft: z.object({ content: z.array(z.unknown()), revision: z.number().int(), updatedAt: z.string().nullable() }).strict(),
141
- createdByUserId: z.string().nullable(),
142
- createdAt: z.string(),
143
- updatedAt: z.string(),
144
- })
145
- .strict();
146
-
147
- const AiStoredMessageSchema = z
148
- .object({
149
- id: AiResourceIdSchema,
150
- shortId: AiResourceIdSchema,
151
- conversationId: AiResourceIdSchema,
152
- seq: z.number().int().nonnegative(),
153
- kind: z.enum(["message", "summary"]),
154
- message: z.unknown(),
155
- loopId: AiResourceIdSchema.nullable(),
156
- modelProfileId: z.string().nullable(),
157
- providerModel: z.string().nullable(),
158
- usage: z.unknown().nullable(),
159
- stopReason: z.string().nullable(),
160
- loopAggregate: z.unknown().nullable(),
161
- loopDoneReason: z.string().nullable(),
162
- compactedAt: z.string().nullable(),
163
- meta: z.unknown().nullable(),
164
- createdAt: z.string(),
165
- })
166
- .strict();
167
-
168
- const AiTurnSnapshotSchema = z
169
- .object({
170
- turnId: AiResourceIdSchema,
171
- attempt: z.number().int().nonnegative(),
172
- status: z.enum(["queued", "running", "waiting_for_action", "completed", "failed", "aborted"]),
173
- seq: z.number().int().nonnegative(),
174
- blocks: z.array(AiTurnBlockSchema),
175
- modelProfileId: z.string().nullable(),
176
- createdAt: z.string(),
177
- })
178
- .strict();
179
-
180
- const AiWireEventBaseSchema = {
181
- v: z.literal(1),
182
- conversationId: AiResourceIdSchema,
183
- turnId: AiResourceIdSchema,
184
- attempt: z.number().int().nonnegative(),
185
- seq: z.number().int().nonnegative(),
186
- } as const;
187
-
188
- const AiStreamEventWireSchema = z.union([
189
- z
190
- .object({
191
- type: z.literal("state"),
192
- conversation: AiConversationSchema,
193
- messages: z.array(AiStoredMessageSchema),
194
- hasMoreMessages: z.boolean().optional(),
195
- activeTurn: AiTurnSnapshotSchema.nullable(),
196
- })
197
- .strict(),
198
- z
199
- .object({
200
- ...AiWireEventBaseSchema,
201
- type: z.literal("turn_started"),
202
- modelProfileId: z.string(),
203
- providerModel: z.string(),
204
- blocks: z.array(AiTurnBlockSchema).optional(),
205
- })
206
- .strict(),
207
- z.object({ ...AiWireEventBaseSchema, type: z.literal("block_set"), block: AiTurnBlockSchema }).strict(),
208
- z
209
- .object({
210
- ...AiWireEventBaseSchema,
211
- type: z.literal("block_delta"),
212
- blockId: z.string(),
213
- blockKind: z.enum(["text", "thinking"]),
214
- delta: z.string(),
215
- })
216
- .strict(),
217
- z
218
- .object({
219
- ...AiWireEventBaseSchema,
220
- type: z.literal("turn_finished"),
221
- status: z.enum(["completed", "failed", "aborted"]),
222
- error: z.string().nullable(),
223
- messages: z.array(AiStoredMessageSchema).optional(),
224
- })
225
- .strict(),
226
- ]);
227
-
228
- export const AiStreamEventSchema = z.custom<AiStreamEvent>((value) => AiStreamEventWireSchema.safeParse(value).success);
229
-
230
- export const AiLiveServerMessageSchema = z.discriminatedUnion("type", [
231
- z
232
- .object({
233
- type: z.literal(AI_LIVE_WS_TYPE.ready),
234
- payload: z.object({ cursor: AiLiveCursorSchema, recovered: z.boolean() }).strict(),
235
- })
236
- .strict(),
237
- z
238
- .object({
239
- type: z.literal(AI_LIVE_WS_TYPE.event),
240
- payload: z.object({ cursor: AiLiveCursorSchema, event: AiInvalidationSchema }).strict(),
241
- })
242
- .strict(),
243
- z
244
- .object({
245
- type: z.literal(AI_LIVE_WS_TYPE.scopeChanged),
246
- payload: z.object({ at: z.string().datetime() }).strict(),
247
- })
248
- .strict(),
249
- z
250
- .object({
251
- type: z.literal(AI_LIVE_WS_TYPE.revoked),
252
- payload: z.object({ code: AiLiveRevocationCodeSchema, message: z.string().min(1).max(500) }).strict(),
253
- })
254
- .strict(),
255
- z
256
- .object({
257
- type: z.literal(AI_LIVE_WS_TYPE.error),
258
- payload: z.object({ code: AiLiveErrorCodeSchema, message: z.string().min(1).max(500) }).strict(),
259
- })
260
- .strict(),
261
- z
262
- .object({
263
- type: z.literal(AI_LIVE_WS_TYPE.turnEvent),
264
- payload: z.object({ conversationId: AiResourceIdSchema, event: AiStreamEventSchema }).strict(),
265
- })
266
- .strict(),
267
- z
268
- .object({
269
- type: z.literal(AI_LIVE_WS_TYPE.turnError),
270
- payload: z.object({ conversationId: AiResourceIdSchema, code: AiTurnErrorCodeSchema, message: z.string().min(1).max(500) }).strict(),
271
- })
272
- .strict(),
273
- ]);
274
-
275
- export type AiLiveServerMessage = z.infer<typeof AiLiveServerMessageSchema>;
276
-
277
- export const aiTurnEventMessage = (conversationId: string, event: AiStreamEvent): AiLiveServerMessage => ({
278
- type: AI_LIVE_WS_TYPE.turnEvent,
279
- payload: { conversationId, event },
280
- });
281
-
282
- export const parseAiLiveServerMessage = (raw: string): AiLiveServerMessage | null => {
283
- try {
284
- const parsed = AiLiveServerMessageSchema.safeParse(JSON.parse(raw));
285
- if (!parsed.success) return null;
286
- if (parsed.data.type !== AI_LIVE_WS_TYPE.turnEvent) return parsed.data;
287
- const eventConversationId =
288
- parsed.data.payload.event.type === "state" ? parsed.data.payload.event.conversation.id : parsed.data.payload.event.conversationId;
289
- return eventConversationId === parsed.data.payload.conversationId ? parsed.data : null;
290
- } catch {
291
- return null;
292
- }
293
- };
package/src/ai/live.ts CHANGED
@@ -1,2 +1,31 @@
1
- export { latestAiInvalidationCursor } from "./live-outbox";
2
- export { type AiLiveRoutes, type AiLiveRoutesConfig, aiLiveRoutes } from "./live-routes";
1
+ import { z } from "zod";
2
+ import { defineLive, type LiveViewer } from "../events/live";
3
+ import { AiInvalidationSchema } from "./live-events";
4
+
5
+ /**
6
+ * Live updates of a user's AI data. Core publishes and serves them at
7
+ * `/api/ai/live`; the AI's database triggers write them, keyed `u:<user ID>`,
8
+ * in the transaction of every change. Other applications, such as Assistant,
9
+ * read `cursor()` before their snapshot.
10
+ */
11
+ export const aiLive = defineLive({ appId: "core", event: AiInvalidationSchema });
12
+
13
+ /** The user an AI request acts for: the user, or the user a credential is delegated by. */
14
+ const ownerOf = (viewer: LiveViewer): string | null => (viewer.accessSubject.type === "user" ? viewer.accessSubject.userId : null);
15
+
16
+ /**
17
+ * `user` follows the viewer's own AI data: conversations, their files, tasks and
18
+ * dictations, and the Projects the viewer can read. Its single key admits only
19
+ * that user; who receives a Project change was decided when it was written.
20
+ */
21
+ export const aiLiveChannels = {
22
+ user: {
23
+ scope: z.object({}).strict(),
24
+ keys: async (_scope: unknown, viewer: LiveViewer) => {
25
+ const owner = ownerOf(viewer);
26
+ return owner ? [`u:${owner}`] : null;
27
+ },
28
+ authorize: async (key: string, viewers: readonly LiveViewer[]) =>
29
+ new Set(viewers.filter((viewer) => `u:${ownerOf(viewer)}` === key).map((viewer) => viewer.id)),
30
+ },
31
+ };
@@ -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
@@ -13,6 +13,14 @@ const backfillAiShortIds = async (
13
13
  };
14
14
 
15
15
  export const migrateCloudAi = async (): Promise<void> => {
16
+ // AI writes its live updates to Core's platform outbox, in the transaction of every
17
+ // AI write: without events.enqueue(), every one of them would fail.
18
+ const [outbox] = await sql<{ ready: boolean }[]>`
19
+ SELECT to_regprocedure('events.enqueue(uuid,text,text,text,jsonb,text)') IS NOT NULL AS ready
20
+ `;
21
+ if (!outbox?.ready) {
22
+ throw new Error("AI live updates write to events.outbox, which does not exist. Run Core's events migration before the AI migration.");
23
+ }
16
24
  await sql`CREATE SCHEMA IF NOT EXISTS ai`.simple();
17
25
  console.log(" ✓ ai schema");
18
26
 
@@ -943,7 +951,7 @@ export const migrateCloudAi = async (): Promise<void> => {
943
951
  CREATE TABLE IF NOT EXISTS ai.user_prefs (
944
952
  user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
945
953
  memory_enabled BOOLEAN NOT NULL DEFAULT TRUE,
946
- memory_learning_enabled BOOLEAN NOT NULL DEFAULT FALSE,
954
+ memory_learning_enabled BOOLEAN,
947
955
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
948
956
  )
949
957
  `.simple();
@@ -952,7 +960,16 @@ export const migrateCloudAi = async (): Promise<void> => {
952
960
  // memory blob. Deliberately discard it instead of migrating ambiguous lines.
953
961
  await sql`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS memory`.simple();
954
962
  await sql`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS instructions`.simple();
955
- 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();
956
973
 
957
974
  // Last model the user actually ran a turn with — preselected for new chats.
958
975
  await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS last_model_id TEXT NOT NULL DEFAULT ''`.simple();
@@ -1117,6 +1134,40 @@ export const migrateCloudAi = async (): Promise<void> => {
1117
1134
  WHERE reviewed_at IS NULL
1118
1135
  `.simple();
1119
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
+
1120
1171
  await sql`
1121
1172
  CREATE TABLE IF NOT EXISTS ai.memory_learning_runs (
1122
1173
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1722,10 +1773,12 @@ export const migrateCloudAi = async (): Promise<void> => {
1722
1773
  WHERE project_id IS NOT NULL AND archived_at IS NULL
1723
1774
  `.simple();
1724
1775
 
1725
- // Durable Realtime UI invalidations. Triggers live at the persistence seam so
1726
- // route, worker, tool, enrichment, and scheduler writes cannot bypass them.
1727
- // The outbox row commits or rolls back with the authoritative domain write;
1728
- // publishing remains an after-commit runtime responsibility.
1776
+ // Live updates. Triggers live at the persistence seam so route, worker, tool,
1777
+ // enrichment, and scheduler writes cannot bypass them. They write to Core's
1778
+ // platform outbox in the transaction of the domain write (see the start of this migration).
1779
+
1780
+ // The previous outbox. Nothing writes it anymore; an older Core that still runs during
1781
+ // an update finds it empty. The cleanup release drops it with the topic cloud-ai-invalidations.
1729
1782
  await sql`
1730
1783
  CREATE TABLE IF NOT EXISTS ai.live_invalidation_outbox (
1731
1784
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1786,6 +1839,10 @@ export const migrateCloudAi = async (): Promise<void> => {
1786
1839
  DROP FUNCTION IF EXISTS ai.enqueue_live_for_user(UUID, TEXT, UUID, TEXT, TEXT, TEXT[])
1787
1840
  `.simple();
1788
1841
 
1842
+ // Every AI live update goes through this function: one update for one user,
1843
+ // keyed `u:<user>`, on Core's live topic. Its data is an `AiInvalidation`.
1844
+ // No coalesce key: the last payload would win and drop the domains of earlier ones.
1845
+ // Gateway health reports a body without events.enqueue: an older Core restored its own.
1789
1846
  await sql`
1790
1847
  CREATE OR REPLACE FUNCTION ai.enqueue_live_for_user(
1791
1848
  p_change_id UUID,
@@ -1796,11 +1853,23 @@ export const migrateCloudAi = async (): Promise<void> => {
1796
1853
  ) RETURNS void AS $$
1797
1854
  BEGIN
1798
1855
  IF p_user_id IS NULL OR cardinality(p_domains) = 0 THEN RETURN; END IF;
1799
- INSERT INTO ai.live_invalidation_outbox (
1800
- change_id, audience_user_id, conversation_short_id, project_short_id, domains
1801
- ) VALUES (
1802
- p_change_id, p_user_id, p_conversation_short_id, p_project_short_id,
1803
- ARRAY(SELECT DISTINCT domain FROM unnest(p_domains) domain ORDER BY domain)
1856
+ PERFORM events.enqueue(
1857
+ gen_random_uuid(),
1858
+ 'core',
1859
+ 'live',
1860
+ 'u:' || p_user_id::text,
1861
+ jsonb_build_object(
1862
+ 'v', 1,
1863
+ 'k', 'u:' || p_user_id::text,
1864
+ 'd', jsonb_build_object(
1865
+ 'type', 'ai.invalidated',
1866
+ 'changeId', p_change_id,
1867
+ 'conversationId', p_conversation_short_id,
1868
+ 'projectId', p_project_short_id,
1869
+ 'domains', to_jsonb(ARRAY(SELECT DISTINCT domain FROM unnest(p_domains) domain ORDER BY domain)),
1870
+ 'at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
1871
+ )
1872
+ )
1804
1873
  );
1805
1874
  END
1806
1875
  $$ LANGUAGE plpgsql
@@ -2213,13 +2282,6 @@ export const migrateCloudAi = async (): Promise<void> => {
2213
2282
  )`.simple();
2214
2283
  await sql`CREATE INDEX IF NOT EXISTS ai_dictations_pending ON ai.dictations(conversation_id, created_at, id) WHERE disposition = 'pending'`.simple();
2215
2284
  await sql`CREATE INDEX IF NOT EXISTS ai_dictations_recovery ON ai.dictations(status, next_attempt_at, lease_until) WHERE status IN ('queued', 'running')`.simple();
2216
- await sql.begin(async (tx) => {
2217
- await tx`ALTER TABLE ai.live_invalidation_outbox DROP CONSTRAINT IF EXISTS ai_live_invalidation_domains_check`;
2218
- await tx`ALTER TABLE ai.live_invalidation_outbox ADD CONSTRAINT ai_live_invalidation_domains_check CHECK (
2219
- domains <@ ARRAY['conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
2220
- 'conversation-dictations', 'project-list', 'project-detail', 'project-context']::text[] AND cardinality(domains) > 0
2221
- )`;
2222
- });
2223
2285
  await sql`DROP TRIGGER IF EXISTS ai_live_dictations_changed ON ai.dictations`.simple();
2224
2286
  await sql`DROP TRIGGER IF EXISTS ai_live_dictations_state_changed ON ai.dictations`.simple();
2225
2287
  await sql`CREATE TRIGGER ai_live_dictations_changed AFTER INSERT OR DELETE ON ai.dictations
@@ -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 *