@k2b/cloud 0.24.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -39,6 +39,7 @@
39
39
  "./browser/search": "./src/browser/search.ts",
40
40
  "./browser/testing": "./src/browser/testing.ts",
41
41
  "./browser/resource-picker": "./src/browser/resource-picker.tsx",
42
+ "./browser/files": "./src/browser/files.tsx",
42
43
  "./capabilities": "./src/capabilities/client.ts",
43
44
  "./capabilities/server": "./src/capabilities/server.ts",
44
45
  "./capabilities/store": "./src/capabilities/executions.ts",
@@ -99,7 +100,7 @@
99
100
  "@tailwindcss/typography": "0.5.20",
100
101
  "@k2b/nessi": "0.12.1",
101
102
  "@k2b/ssr": "0.14.0",
102
- "@k2b/ui": "0.14.0",
103
+ "@k2b/ui": "0.15.0",
103
104
  "@k2b/stdlib": "0.27.0",
104
105
  "@k2b/sync": "7.0.0",
105
106
  "@nats-io/jetstream": "3.4.0",
@@ -4,6 +4,7 @@ import { createSignal, For, Show } from "solid-js";
4
4
  import { CloudAvatar } from "../account/Avatar";
5
5
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
6
6
  import { groupDisplayName } from "../shared/account-display";
7
+ import { isManagerEntry } from "./managers";
7
8
  import { accessMessages } from "./messages";
8
9
  import PrincipalPicker from "./PrincipalPicker";
9
10
  import { serviceAccountKindDisplay } from "./service-account-kind";
@@ -43,8 +44,10 @@ type PermissionEditorProps = {
43
44
  /** Update an existing entry's permission level. */
44
45
  updateAccess: (accessId: string, permission: GrantableLevel) => Promise<void>;
45
46
 
46
- /** Revoke an existing entry. The last entry IS deletable — for
47
- * hierarchical resources the parent ACL still applies. */
47
+ /** Revoke an existing entry. Any entry can be revoked except the last
48
+ * manager: once exactly one entry manages the resource (`admin` for a
49
+ * person, group, all signed-in users, or a standalone or agent service
50
+ * account), its row can neither be lowered nor removed. */
48
51
  revokeAccess: (accessId: string) => Promise<void>;
49
52
 
50
53
  /** Allow granting `public` access from this editor. */
@@ -120,9 +123,10 @@ const resolveEntryDisplay = (
120
123
  };
121
124
 
122
125
  const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"], locale: string): string => {
123
- if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
126
+ // Audiences always use the localized label; a server-supplied name is not translated.
124
127
  if (entry.principal.type === "authenticated") return t.allUsers;
125
128
  if (entry.principal.type === "public") return t.public;
129
+ if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
126
130
  if (entry.principal.type === "user") return entry.principal.userId;
127
131
  if (entry.principal.type === "service_account") return entry.principal.serviceAccountId;
128
132
  return entry.principal.groupId;
@@ -209,6 +213,11 @@ export default function PermissionEditor(props: PermissionEditorProps) {
209
213
  onError: (err) => prompts.error(err.message),
210
214
  });
211
215
  const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
216
+ // The service refuses to remove the last manager; the row says so up front.
217
+ const lastManagerId = () => {
218
+ const managers = entries().filter(isManagerEntry);
219
+ return managers.length === 1 ? managers[0]!.id : null;
220
+ };
212
221
 
213
222
  return (
214
223
  <div class="flex flex-col gap-3">
@@ -220,6 +229,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
220
229
  entry={entry}
221
230
  canEdit={canEdit()}
222
231
  disabled={busy()}
232
+ lastManager={entry.id === lastManagerId()}
223
233
  allowed={allowed(entry.principal)}
224
234
  singlePicker={allowed(entry.principal).length === 1}
225
235
  onUpdatePermission={(permission) => {
@@ -264,6 +274,8 @@ function AccessEntryRow(props: {
264
274
  entry: AccessEntry;
265
275
  canEdit: boolean;
266
276
  disabled: boolean;
277
+ /** The only entry that manages the resource: it keeps its level and cannot be removed. */
278
+ lastManager: boolean;
267
279
  allowed: ResolvedLevel[];
268
280
  /** When true the per-row picker collapses to a non-interactive badge
269
281
  * (single-level mode — there's nothing to switch to). */
@@ -275,6 +287,8 @@ function AccessEntryRow(props: {
275
287
  const t = () => accessMessages.resolve([locale()]).t;
276
288
  const displayName = () => getEntryDisplayName(props.entry, t(), locale());
277
289
  const display = () => resolveEntryDisplay(props.entry.permission, props.allowed, t());
290
+ const manageLabel = () => resolveEntryDisplay("admin", props.allowed, t()).label;
291
+ const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
278
292
  const isInteractive = () =>
279
293
  props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.level === props.entry.permission);
280
294
 
@@ -334,11 +348,16 @@ function AccessEntryRow(props: {
334
348
  <SelectChip
335
349
  aria-label={t().permissionFor({ name: displayName() })}
336
350
  value={() => props.entry.permission as GrantableLevel}
337
- options={props.allowed.map((option) => ({
338
- value: option.level,
339
- label: option.label,
340
- icon: `ti ${option.icon}`,
341
- }))}
351
+ options={props.allowed.map((option) => {
352
+ const locked = props.lastManager && option.level !== "admin";
353
+ return {
354
+ value: option.level,
355
+ label: option.label,
356
+ icon: `ti ${option.icon}`,
357
+ disabled: locked,
358
+ description: locked ? t().lastManagerOption({ level: manageLabel() }) : undefined,
359
+ };
360
+ })}
342
361
  icon={`ti ${display().icon}`}
343
362
  position="bottom-left"
344
363
  onValueChange={(permission) => {
@@ -351,7 +370,7 @@ function AccessEntryRow(props: {
351
370
  remain keyboard reachable and stay visible on touch-sized layouts. */}
352
371
  <Show when={props.canEdit}>
353
372
  <Tooltip.Anchor
354
- content={t().remove({ name: displayName() })}
373
+ content={removeLabel()}
355
374
  class="shrink-0 opacity-100 transition-opacity sm:opacity-0 sm:group-hover/access-row:opacity-100 sm:group-focus-within/access-row:opacity-100"
356
375
  >
357
376
  <IconButton
@@ -360,9 +379,11 @@ function AccessEntryRow(props: {
360
379
  variant="ghost"
361
380
  size="xs"
362
381
  onClick={props.onRevoke}
363
- disabled={props.disabled}
382
+ disabled={props.disabled || props.lastManager}
364
383
  aria-label={t().remove({ name: displayName() })}
365
- class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-red-500/[0.08] hover:text-red-600 focus:opacity-100 dark:hover:text-red-400"
384
+ // A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
385
+ aria-description={props.lastManager ? removeLabel() : undefined}
386
+ class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
366
387
  >
367
388
  <i class="ti ti-x text-sm" />
368
389
  </IconButton>
@@ -0,0 +1,32 @@
1
+ import type { PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
2
+
3
+ /** The fields of an access entry that decide whether it manages its resource. */
4
+ export type ManagerCandidate = {
5
+ principal: Principal;
6
+ permission: PermissionLevel;
7
+ serviceAccountKind?: ServiceAccountKind | null;
8
+ };
9
+
10
+ /**
11
+ * Whether an entry keeps its resource manageable: `admin` for a user, a group,
12
+ * all signed-in users, or a standalone or agent service account. Public access
13
+ * never counts, and neither do resource-bound or user-delegated keys: they
14
+ * disappear with their key or their user without passing the resource's rules.
15
+ */
16
+ export const isManagerEntry = (entry: ManagerCandidate): boolean => {
17
+ if (entry.permission !== "admin") return false;
18
+ switch (entry.principal.type) {
19
+ case "user":
20
+ case "group":
21
+ case "authenticated":
22
+ return true;
23
+ case "service_account":
24
+ return entry.serviceAccountKind === "standalone" || entry.serviceAccountKind === "agent";
25
+ case "public":
26
+ return false;
27
+ }
28
+ };
29
+
30
+ /** Whether a change from `before` to `after` takes a resource from at least one manager to none. */
31
+ export const removesLastManager = (before: readonly ManagerCandidate[], after: readonly ManagerCandidate[]): boolean =>
32
+ before.some(isManagerEntry) && !after.some(isManagerEntry);
@@ -26,6 +26,9 @@ export const accessMessages = i18n.define({
26
26
  anyoneWithLink: "Anyone with the link",
27
27
  permissionFor: ({ name }: { name: string }) => `Permission for ${name}`,
28
28
  remove: ({ name }: { name: string }) => `Remove ${name}`,
29
+ lastManager: ({ level }: { level: string }) =>
30
+ `The last entry with “${level}” access can't be lowered or removed. Give another person or group “${level}” access first.`,
31
+ lastManagerOption: ({ level }: { level: string }) => `Available once another person or group has “${level}” access`,
29
32
  read: "Read",
30
33
  readDescription: "Read this resource through the app API.",
31
34
  write: "Write",
@@ -86,6 +89,9 @@ export const accessMessages = i18n.define({
86
89
  anyoneWithLink: "Alle mit dem Link",
87
90
  permissionFor: ({ name }) => `Berechtigung für ${name}`,
88
91
  remove: ({ name }) => `${name} entfernen`,
92
+ lastManager: ({ level }) =>
93
+ `Der letzte Eintrag mit Zugriff „${level}“ kann nicht herabgestuft oder entfernt werden. Gib zuerst einer anderen Person oder Gruppe Zugriff „${level}“.`,
94
+ lastManagerOption: ({ level }) => `Möglich, sobald eine weitere Person oder Gruppe Zugriff „${level}“ hat`,
89
95
  read: "Lesen",
90
96
  readDescription: "Diese Ressource über die App-API lesen.",
91
97
  write: "Schreiben",
@@ -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
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,6 +4,7 @@ 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";
@@ -2801,6 +2802,19 @@ export const aiConversations: AiConversationService = {
2801
2802
  live_blocks = NULL
2802
2803
  WHERE id = ${input.turnId}
2803
2804
  `;
2805
+ if (input.status === "completed") {
2806
+ // Learning considers only turns that finish while it is on for the chat
2807
+ // owner; turning it on later does not reach back to this turn.
2808
+ await tx`
2809
+ UPDATE ai.turns turn
2810
+ SET memory_learned_at = turn.completed_at
2811
+ FROM ai.conversations conversation
2812
+ LEFT JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
2813
+ WHERE turn.id = ${input.turnId}
2814
+ AND conversation.id = turn.conversation_id
2815
+ AND NOT COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
2816
+ `;
2817
+ }
2804
2818
  await tx`
2805
2819
  UPDATE ai.pending_actions
2806
2820
  SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())