@k2b/cloud 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/registry.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +134 -93
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +36 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/capabilities.ts +34 -12
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +57 -30
- package/src/ai/chat/live-turn.browser-harness.tsx +37 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +324 -2
- package/src/ai/chat/presentation.tsx +257 -103
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +609 -0
- package/src/ai/client/projection.ts +37 -6
- package/src/ai/executor.ts +38 -5
- 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/protocol.ts +20 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/quota-provider.ts +14 -5
- package/src/ai/skills.ts +5 -2
- package/src/ai/store.ts +108 -3
- package/src/ai/stream.ts +2 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +12 -2
- 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/contracts/registry.ts +2 -0
- 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/shared/app-presentation.ts +10 -2
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/effects.css +69 -0
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
|
@@ -9,6 +9,14 @@ export type AiActiveTurn = {
|
|
|
9
9
|
status: "running" | "waiting_for_action";
|
|
10
10
|
blocks: AiTurnBlock[];
|
|
11
11
|
modelProfileId: string | null;
|
|
12
|
+
/** A model call failed transiently and waits for its retry. Live only: the next turn event or a snapshot ends it. */
|
|
13
|
+
providerRetry?: boolean;
|
|
14
|
+
/** Server start of the turn, known from a state snapshot. */
|
|
15
|
+
createdAt?: string;
|
|
16
|
+
/** Time already spent waiting for answered user actions, from the last state snapshot. */
|
|
17
|
+
actionWaitMs?: number;
|
|
18
|
+
/** Start of the user action the turn waited for at the last state snapshot. */
|
|
19
|
+
waitingSince?: string | null;
|
|
12
20
|
};
|
|
13
21
|
|
|
14
22
|
export type AiChatProjection = {
|
|
@@ -90,7 +98,7 @@ export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiv
|
|
|
90
98
|
const pendingSteers = previous.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed");
|
|
91
99
|
const known = new Set(incoming.blocks.map((block) => block.id));
|
|
92
100
|
const blocks = [...incoming.blocks, ...pendingSteers.filter((block) => !known.has(block.id))];
|
|
93
|
-
return { ...incoming, blocks, status: deriveStatus(blocks) };
|
|
101
|
+
return { ...turnTiming(previous), ...incoming, blocks, status: deriveStatus(blocks) };
|
|
94
102
|
};
|
|
95
103
|
|
|
96
104
|
export const reconcileActiveTurnActions = (
|
|
@@ -112,9 +120,22 @@ export const activeTurnFromSnapshot = (snapshot: AiTurnSnapshot | null): AiActiv
|
|
|
112
120
|
status: snapshot.status === "waiting_for_action" ? "waiting_for_action" : "running",
|
|
113
121
|
blocks: normalizeCustomApprovalBlocks(snapshot.blocks),
|
|
114
122
|
modelProfileId: snapshot.modelProfileId,
|
|
123
|
+
createdAt: snapshot.createdAt,
|
|
124
|
+
...(snapshot.actionWaitMs !== undefined ? { actionWaitMs: snapshot.actionWaitMs } : {}),
|
|
125
|
+
...(snapshot.waitingSince !== undefined ? { waitingSince: snapshot.waitingSince } : {}),
|
|
115
126
|
};
|
|
116
127
|
};
|
|
117
128
|
|
|
129
|
+
/** Snapshot timing survives later attempts of the same turn, which carry no timing of their own. */
|
|
130
|
+
const turnTiming = (turn: AiActiveTurn | null | undefined): Pick<AiActiveTurn, "createdAt" | "actionWaitMs" | "waitingSince"> =>
|
|
131
|
+
turn
|
|
132
|
+
? {
|
|
133
|
+
...(turn.createdAt !== undefined ? { createdAt: turn.createdAt } : {}),
|
|
134
|
+
...(turn.actionWaitMs !== undefined ? { actionWaitMs: turn.actionWaitMs } : {}),
|
|
135
|
+
...(turn.waitingSince !== undefined ? { waitingSince: turn.waitingSince } : {}),
|
|
136
|
+
}
|
|
137
|
+
: {};
|
|
138
|
+
|
|
118
139
|
/**
|
|
119
140
|
* Fold one stream event into the projection. Pure and total: the client and any
|
|
120
141
|
* test converge on the same state for the same ordered event sequence.
|
|
@@ -125,6 +146,7 @@ export const activeTurnFromSnapshot = (snapshot: AiTurnSnapshot | null): AiActiv
|
|
|
125
146
|
* stale attempts are ignored. Older senders without a baseline reset to only
|
|
126
147
|
* locally pending steering blocks.
|
|
127
148
|
* - block events apply only when strictly newer than the active turn's cursor.
|
|
149
|
+
* - `provider_retry` marks the active turn until its next event.
|
|
128
150
|
* - `turn_finished` folds the turn's persisted messages in and clears the active turn.
|
|
129
151
|
*/
|
|
130
152
|
export const reduceProjection = (state: AiChatProjection, event: AiStreamEvent): AiChatProjection => {
|
|
@@ -146,6 +168,8 @@ export const reduceProjection = (state: AiChatProjection, event: AiStreamEvent):
|
|
|
146
168
|
return reduceWireEvent(state, event);
|
|
147
169
|
};
|
|
148
170
|
|
|
171
|
+
const withoutProviderRetry = ({ providerRetry: _, ...turn }: AiActiveTurn): AiActiveTurn => turn;
|
|
172
|
+
|
|
149
173
|
export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): AiChatProjection => {
|
|
150
174
|
const active = state.activeTurn;
|
|
151
175
|
|
|
@@ -163,6 +187,7 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
|
|
|
163
187
|
status: deriveStatus(blocks),
|
|
164
188
|
blocks,
|
|
165
189
|
modelProfileId: event.modelProfileId,
|
|
190
|
+
...turnTiming(active?.turnId === event.turnId ? active : null),
|
|
166
191
|
},
|
|
167
192
|
};
|
|
168
193
|
}
|
|
@@ -172,20 +197,26 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
|
|
|
172
197
|
return { ...state, messages: mergeMessages(state.messages, event.messages ?? []), activeTurn: null };
|
|
173
198
|
}
|
|
174
199
|
|
|
200
|
+
if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
|
|
201
|
+
|
|
202
|
+
if (event.type === "provider_retry") {
|
|
203
|
+
return { ...state, activeTurn: { ...active, seq: event.seq, attempt: event.attempt, providerRetry: true } };
|
|
204
|
+
}
|
|
205
|
+
|
|
175
206
|
if (event.type === "message_saved") {
|
|
176
|
-
if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
|
|
177
207
|
return {
|
|
178
208
|
...state,
|
|
179
209
|
messages: mergeMessages(state.messages, [event.message]),
|
|
180
|
-
activeTurn: { ...active, seq: event.seq, attempt: event.attempt },
|
|
210
|
+
activeTurn: { ...withoutProviderRetry(active), seq: event.seq, attempt: event.attempt },
|
|
181
211
|
};
|
|
182
212
|
}
|
|
183
213
|
|
|
184
214
|
// block_set / block_delta
|
|
185
|
-
if (!active || active.turnId !== event.turnId) return state;
|
|
186
|
-
if (!isNewerWireEvent(event, active)) return state;
|
|
187
215
|
const blocks = applyWireEventToBlocks(active.blocks, event);
|
|
188
|
-
return {
|
|
216
|
+
return {
|
|
217
|
+
...state,
|
|
218
|
+
activeTurn: { ...withoutProviderRetry(active), attempt: event.attempt, seq: event.seq, blocks, status: deriveStatus(blocks) },
|
|
219
|
+
};
|
|
189
220
|
};
|
|
190
221
|
|
|
191
222
|
/** Assistant/tool messages of the active turn are represented by live blocks; hide them. */
|
package/src/ai/executor.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
streamBlockId,
|
|
41
41
|
toolBlockId,
|
|
42
42
|
} from "./protocol";
|
|
43
|
+
import { retryTransientProviderErrors } from "./provider-retry";
|
|
43
44
|
import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
|
|
44
45
|
import { collectConversationResourceObservations } from "./resource-refs";
|
|
45
46
|
import { AiRunTimeout } from "./run-timeout";
|
|
@@ -249,6 +250,8 @@ export type ExecutorConfig = {
|
|
|
249
250
|
validateTurn?: typeof validateAiTurnRequest;
|
|
250
251
|
/** Runs after the durable turn state and final wire event are flushed. */
|
|
251
252
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
|
|
253
|
+
/** Waits before retrying a transient provider failure without Retry-After; tests shorten them. */
|
|
254
|
+
providerRetryDelaysMs?: readonly number[];
|
|
252
255
|
};
|
|
253
256
|
|
|
254
257
|
type ResolvedModel = Awaited<ReturnType<typeof resolveAiModel>>;
|
|
@@ -392,6 +395,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
392
395
|
const setRejectedCallIds = (items: Set<string>) => {
|
|
393
396
|
rejectedCallIds = items;
|
|
394
397
|
};
|
|
398
|
+
let approvedCallIds = new Set<string>();
|
|
399
|
+
const setApprovedCallIds = (items: Set<string>) => {
|
|
400
|
+
approvedCallIds = items;
|
|
401
|
+
};
|
|
395
402
|
/** nessi stream block ids (turn-scoped) that belong to tool_call blocks — their deltas are raw args JSON. */
|
|
396
403
|
const toolStreamIds = new Set<string>();
|
|
397
404
|
/** kind per open Cloud stream block id, for delta create-if-missing. */
|
|
@@ -414,6 +421,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
414
421
|
approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false } : approval,
|
|
415
422
|
frontendMode: patch.frontendMode ?? existing?.frontendMode,
|
|
416
423
|
presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
|
|
424
|
+
...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
|
|
417
425
|
};
|
|
418
426
|
toolBlocks.set(callId, block);
|
|
419
427
|
return { type: "block_set", block };
|
|
@@ -518,6 +526,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
|
|
|
518
526
|
setApprovalPolicies,
|
|
519
527
|
setApprovalReviews,
|
|
520
528
|
setRejectedCallIds,
|
|
529
|
+
setApprovedCallIds,
|
|
521
530
|
};
|
|
522
531
|
};
|
|
523
532
|
|
|
@@ -829,6 +838,7 @@ export class AiTurnExecutor {
|
|
|
829
838
|
pipeline.setApprovalPolicies(prepared.approvalPolicies);
|
|
830
839
|
const toolPresentations = new Map<string, AiToolPresentation>();
|
|
831
840
|
const rejectedToolCallIds = new Set<string>();
|
|
841
|
+
const approvedToolCallIds = new Set<string>();
|
|
832
842
|
pipeline.setPresentations(toolPresentations);
|
|
833
843
|
pipeline.setApprovalReviews(capabilityActionReviews);
|
|
834
844
|
let turnInput = config.input;
|
|
@@ -868,17 +878,19 @@ export class AiTurnExecutor {
|
|
|
868
878
|
turnInput,
|
|
869
879
|
toolPresentations,
|
|
870
880
|
rejectedToolCallIds,
|
|
881
|
+
approvedToolCallIds,
|
|
871
882
|
});
|
|
872
883
|
|
|
873
884
|
const { loopMessages, pendingRecords, resolvedRecords, turnSteers } =
|
|
874
885
|
attemptState ?? (await loadChatAttemptState(conversationId, turnId));
|
|
875
886
|
for (const action of resolvedRecords) {
|
|
876
|
-
if (action.resolvedEvent?.type !== "approval_response"
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
);
|
|
887
|
+
if (action.resolvedEvent?.type !== "approval_response") continue;
|
|
888
|
+
// A decision on a custom approval belongs to the call that asked for it.
|
|
889
|
+
const callId = action.kind === "custom_approval" ? (customApprovalParentCallId(action.callId) ?? action.callId) : action.callId;
|
|
890
|
+
(action.resolvedEvent.approved ? approvedToolCallIds : rejectedToolCallIds).add(callId);
|
|
880
891
|
}
|
|
881
892
|
pipeline.setRejectedCallIds(rejectedToolCallIds);
|
|
893
|
+
pipeline.setApprovedCallIds(approvedToolCallIds);
|
|
882
894
|
const assistantMessages = loopMessages.filter((message) => message.message.role !== "user");
|
|
883
895
|
const isFresh = assistantMessages.length === 0 && resolvedRecords.length === 0 && !skipResolvedActions;
|
|
884
896
|
|
|
@@ -1064,8 +1076,19 @@ export class AiTurnExecutor {
|
|
|
1064
1076
|
});
|
|
1065
1077
|
const priorToolRounds = toolRoundState(loopMessages);
|
|
1066
1078
|
const quotaSubject = accessSubjectForActor(material.actor);
|
|
1079
|
+
const deadline = claim.turn.deadline ? Date.parse(claim.turn.deadline) : null;
|
|
1067
1080
|
const toolRoundPolicy = applyToolRoundPolicy({
|
|
1068
|
-
provider:
|
|
1081
|
+
provider: retryTransientProviderErrors(
|
|
1082
|
+
assistantQuotaProvider(resolved.provider, config, quotaSubject, resolved.profile, turnId, conversationId),
|
|
1083
|
+
{
|
|
1084
|
+
deadline,
|
|
1085
|
+
delaysMs: this.config.providerRetryDelaysMs,
|
|
1086
|
+
onRetry: async ({ retry, delayMs, issue }) => {
|
|
1087
|
+
log.warn("AI provider call retried", { conversationId, turnId, retry, delayMs, kind: issue.kind, message: issue.message });
|
|
1088
|
+
await pipeline.emitProviderRetry();
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1091
|
+
),
|
|
1069
1092
|
tools,
|
|
1070
1093
|
maxToolRounds: resolved.profile.maxToolRounds,
|
|
1071
1094
|
issuedToolRounds: priorToolRounds.issued,
|
|
@@ -1667,6 +1690,10 @@ class StreamPipeline {
|
|
|
1667
1690
|
this.mapper.setRejectedCallIds(callIds);
|
|
1668
1691
|
}
|
|
1669
1692
|
|
|
1693
|
+
setApprovedCallIds(callIds: Set<string>): void {
|
|
1694
|
+
this.mapper.setApprovedCallIds(callIds);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1670
1697
|
async emitBaseline(): Promise<void> {
|
|
1671
1698
|
for (const block of this.blocks) {
|
|
1672
1699
|
const seq = this.nextSeq();
|
|
@@ -1765,6 +1792,12 @@ class StreamPipeline {
|
|
|
1765
1792
|
await this.publish(event);
|
|
1766
1793
|
}
|
|
1767
1794
|
|
|
1795
|
+
/** Transient: says a model call is waiting to be retried. Snapshots never carry it. */
|
|
1796
|
+
async emitProviderRetry(): Promise<void> {
|
|
1797
|
+
const seq = this.nextSeq();
|
|
1798
|
+
await this.publish(this.envelope({ type: "provider_retry" as const, seq }));
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1768
1801
|
async emitTurnFinished(status: "completed" | "failed" | "aborted", error: string | null): Promise<void> {
|
|
1769
1802
|
const seq = this.nextSeq();
|
|
1770
1803
|
await this.publish(this.envelope({ type: "turn_finished" as const, seq, status, error }) as AiWireEvent);
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
listAiTurnWorkflowEvidence,
|
|
17
17
|
markAiWorkflowPatternReviewed,
|
|
18
18
|
} from "./memory-workflow-evidence";
|
|
19
|
+
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED, aiUserPrefs } from "./prefs";
|
|
19
20
|
import { aiConversations } from "./store";
|
|
20
21
|
import type { RunAiStructuredInput, RunAiStructuredResult } from "./structured";
|
|
21
22
|
import { resolveAiBackgroundModel, runAiStructured } from "./structured";
|
|
@@ -148,11 +149,11 @@ export const listAiMemoryLearningCandidates = async (limit: number, monthlyToken
|
|
|
148
149
|
) AS user_rank
|
|
149
150
|
FROM ai.turns turn
|
|
150
151
|
JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
|
|
151
|
-
JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
|
|
152
|
+
LEFT JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
|
|
152
153
|
LEFT JOIN monthly_usage usage ON usage.user_id = conversation.created_by_user_id
|
|
153
154
|
WHERE conversation.created_by_user_id IS NOT NULL
|
|
154
155
|
AND conversation.archived_at IS NULL
|
|
155
|
-
AND prefs.memory_learning_enabled
|
|
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/protocol.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type AiTurnBlock =
|
|
|
39
39
|
isError?: boolean;
|
|
40
40
|
/** Present while status is awaiting_approval. */
|
|
41
41
|
approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
|
|
42
|
+
/** The user approved this call in the chat; the decided approval stays visible as a receipt. */
|
|
43
|
+
approved?: boolean;
|
|
42
44
|
/** Present for frontend tools. */
|
|
43
45
|
frontendMode?: AiFrontendToolMode;
|
|
44
46
|
/** Saved Cloud-owned display snapshot for capability calls. */
|
|
@@ -67,6 +69,8 @@ export type AiWireEvent =
|
|
|
67
69
|
| (AiWireEventBase & { type: "message_saved"; message: AiStoredMessage })
|
|
68
70
|
| (AiWireEventBase & { type: "block_set"; block: AiTurnBlock })
|
|
69
71
|
| (AiWireEventBase & { type: "block_delta"; blockId: string; blockKind: "text" | "thinking"; delta: string })
|
|
72
|
+
/** A model call failed transiently and waits for its retry. Transient: the next event of the turn ends it. */
|
|
73
|
+
| (AiWireEventBase & { type: "provider_retry" })
|
|
70
74
|
| (AiWireEventBase & {
|
|
71
75
|
type: "turn_finished";
|
|
72
76
|
status: AiTurnFinishedStatus;
|
|
@@ -85,6 +89,10 @@ export type AiTurnSnapshot = {
|
|
|
85
89
|
blocks: AiTurnBlock[];
|
|
86
90
|
modelProfileId: string | null;
|
|
87
91
|
createdAt: string;
|
|
92
|
+
/** Time the turn already waited for answered user actions such as approvals. Absent from older servers. */
|
|
93
|
+
actionWaitMs?: number;
|
|
94
|
+
/** Start of the user action the turn waits for now, or null while it works. Absent from older servers. */
|
|
95
|
+
waitingSince?: string | null;
|
|
88
96
|
};
|
|
89
97
|
|
|
90
98
|
/** Full projection seed sent as the first SSE event on every (re)connect. */
|
|
@@ -151,7 +159,12 @@ export const reconcileResolvedTurnActions = (
|
|
|
151
159
|
const event = resolvedByCallId.get(block.callId);
|
|
152
160
|
if (!event || event.callId !== block.callId) return block;
|
|
153
161
|
if (block.status === "awaiting_approval" && event.type === "approval_response") {
|
|
154
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
...block,
|
|
164
|
+
status: event.approved ? "running" : "rejected",
|
|
165
|
+
approval: undefined,
|
|
166
|
+
...(event.approved ? { approved: true } : {}),
|
|
167
|
+
};
|
|
155
168
|
}
|
|
156
169
|
if (block.status === "awaiting_client" && event.type === "tool_result") {
|
|
157
170
|
return { ...block, status: "completed", result: event.result, isError: false };
|
|
@@ -172,7 +185,7 @@ export const buildBlocksFromMessages = (
|
|
|
172
185
|
meta?: {
|
|
173
186
|
steerId?: string;
|
|
174
187
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
175
|
-
toolOutcomes?: Record<string, "rejected">;
|
|
188
|
+
toolOutcomes?: Record<string, "rejected" | "approved">;
|
|
176
189
|
} | null;
|
|
177
190
|
}[],
|
|
178
191
|
): AiTurnBlock[] => {
|
|
@@ -196,6 +209,8 @@ export const buildBlocksFromMessages = (
|
|
|
196
209
|
args: block.args,
|
|
197
210
|
status: "running",
|
|
198
211
|
presentation: meta?.toolPresentations?.[block.id],
|
|
212
|
+
// A turn that ended before an approved call returned records the approval on the call's message.
|
|
213
|
+
...(meta?.toolOutcomes?.[block.id] === "approved" ? { approved: true } : {}),
|
|
199
214
|
});
|
|
200
215
|
}
|
|
201
216
|
});
|
|
@@ -212,12 +227,13 @@ export const buildBlocksFromMessages = (
|
|
|
212
227
|
const at = toolIndex.get(message.callId);
|
|
213
228
|
const existing = at !== undefined ? blocks[at] : undefined;
|
|
214
229
|
if (existing?.kind === "tool") {
|
|
215
|
-
const
|
|
230
|
+
const outcome = meta?.toolOutcomes?.[message.callId];
|
|
216
231
|
blocks[at!] = {
|
|
217
232
|
...existing,
|
|
218
|
-
status: rejected ? "rejected" : message.isError ? "failed" : "completed",
|
|
233
|
+
status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
|
|
219
234
|
result: message.result,
|
|
220
235
|
isError: message.isError,
|
|
236
|
+
...(outcome === "approved" ? { approved: true } : {}),
|
|
221
237
|
};
|
|
222
238
|
}
|
|
223
239
|
}
|