@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 +3 -2
- package/src/access/PermissionEditor.tsx +32 -11
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +6 -0
- package/src/ai/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +45 -2
- package/src/ai/model-access.ts +4 -2
- package/src/ai/prefs.ts +19 -5
- package/src/ai/projects.ts +5 -2
- package/src/ai/skills.ts +5 -2
- package/src/ai/store.ts +14 -0
- package/src/browser/FileChooser.tsx +600 -0
- package/src/browser/choose-files.browser-harness.tsx +32 -0
- package/src/browser/choose-files.tsx +70 -0
- package/src/browser/file-chooser-messages.ts +86 -0
- package/src/browser/file-providers.ts +217 -0
- package/src/browser/files.tsx +23 -0
- package/src/cli/admin/account-administration.ts +2 -2
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +44 -11
- package/src/server/services/index.ts +1 -0
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
47
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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={
|
|
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
|
-
|
|
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);
|
package/src/access/messages.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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(),
|
package/src/ai/model-access.ts
CHANGED
|
@@ -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:
|
|
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 (
|
|
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 *
|
package/src/ai/projects.ts
CHANGED
|
@@ -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())
|