@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.
- package/package.json +3 -2
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +12 -3
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +1 -1
- package/src/_internal/registry.ts +78 -3
- package/src/access/PermissionEditor.tsx +32 -11
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +6 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +65 -20
- package/src/ai/client/transport.ts +5 -5
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +80 -18
- 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/runtime.ts +1 -6
- package/src/ai/skills.ts +5 -2
- package/src/ai/solid.ts +0 -5
- package/src/ai/store.ts +14 -0
- package/src/api/help.ts +2 -2
- package/src/api/pwa-phone.ts +10 -3
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- 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/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/cli/admin/account-administration.ts +2 -2
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +1 -0
- package/src/events/live-engine.ts +11 -7
- package/src/events/live.ts +32 -32
- package/src/server/help.ts +3 -3
- 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/services/index.ts +1 -1
- package/src/services/outbox.ts +29 -115
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +74 -8
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -14
- package/src/ssr/PwaLayout.tsx +2 -1
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
- package/src/styles/resource-search.css +19 -0
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -79
package/src/ai/live-events.ts
CHANGED
|
@@ -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
|
-
|
|
2
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
|
|
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
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
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 *
|