@k2b/cloud 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/define-app.ts +8 -1
  4. package/src/_internal/process-identity.ts +7 -1
  5. package/src/_internal/registry-validation.ts +3 -0
  6. package/src/_internal/registry.ts +1 -0
  7. package/src/_internal/runtime-context.ts +1 -0
  8. package/src/access/GroupCoverage.tsx +175 -0
  9. package/src/access/PermissionEditor.tsx +119 -99
  10. package/src/access/messages.ts +30 -0
  11. package/src/ai/admin.ts +1 -0
  12. package/src/ai/approval-routes.ts +5 -5
  13. package/src/ai/browser-code-contracts.ts +14 -2
  14. package/src/ai/browser.ts +8 -1
  15. package/src/ai/capabilities.ts +115 -33
  16. package/src/ai/chat/blocks.tsx +86 -179
  17. package/src/ai/chat/builtin-tools.tsx +83 -48
  18. package/src/ai/chat/file-tools.tsx +4 -1
  19. package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
  20. package/src/ai/chat/message-actions.tsx +6 -2
  21. package/src/ai/chat/message-utils.ts +17 -14
  22. package/src/ai/chat/messages.ts +330 -2
  23. package/src/ai/chat/presentation.tsx +278 -104
  24. package/src/ai/chat/tool-groups.ts +55 -35
  25. package/src/ai/chat/turn-layout.ts +141 -0
  26. package/src/ai/chat/turn-view.tsx +644 -0
  27. package/src/ai/client/controller.ts +60 -31
  28. package/src/ai/client/file-source.ts +20 -3
  29. package/src/ai/client/projection.ts +42 -6
  30. package/src/ai/code-mode-skill.ts +27 -27
  31. package/src/ai/code-runtime-tools.ts +10 -1
  32. package/src/ai/code-source-contracts.ts +54 -4
  33. package/src/ai/code-source-tools.ts +10 -3
  34. package/src/ai/credentials.ts +17 -3
  35. package/src/ai/data-analysis-skill.ts +2 -2
  36. package/src/ai/default-tools.ts +2 -2
  37. package/src/ai/executor.ts +177 -80
  38. package/src/ai/file-context.ts +14 -2
  39. package/src/ai/file-tools.ts +17 -3
  40. package/src/ai/files-store.ts +134 -11
  41. package/src/ai/grids-skill.ts +2 -2
  42. package/src/ai/index.ts +7 -0
  43. package/src/ai/memories.ts +14 -0
  44. package/src/ai/migrate.ts +125 -0
  45. package/src/ai/model-request-settings.ts +98 -0
  46. package/src/ai/protocol.ts +26 -4
  47. package/src/ai/provider-fetch.ts +67 -15
  48. package/src/ai/provider-retry.ts +105 -0
  49. package/src/ai/provider.ts +7 -1
  50. package/src/ai/quota-provider.ts +16 -7
  51. package/src/ai/request-headers.ts +117 -0
  52. package/src/ai/routes.ts +34 -6
  53. package/src/ai/runtime.ts +1 -1
  54. package/src/ai/settings.ts +19 -2
  55. package/src/ai/skill-seeds.ts +31 -3
  56. package/src/ai/skills.ts +26 -0
  57. package/src/ai/solid.ts +1 -1
  58. package/src/ai/store.ts +202 -59
  59. package/src/ai/stream.ts +182 -37
  60. package/src/ai/structured.ts +20 -5
  61. package/src/ai/system-prompt.ts +25 -0
  62. package/src/ai/timeline.ts +9 -11
  63. package/src/ai/tool-call-names.ts +45 -0
  64. package/src/ai/turn-policy.ts +247 -0
  65. package/src/ai/turn-timing.ts +31 -3
  66. package/src/ai/types.ts +36 -5
  67. package/src/api/admin-ai-quotas.ts +36 -1
  68. package/src/api/admin-core-settings.ts +16 -23
  69. package/src/api/admin-outgoing-mail.ts +62 -0
  70. package/src/api/index.ts +2 -0
  71. package/src/cli/admin/ai-quotas.ts +70 -1
  72. package/src/cli/admin/index.ts +6 -0
  73. package/src/cli/admin/outgoing-mail.ts +118 -0
  74. package/src/contracts/app.ts +2 -0
  75. package/src/contracts/index.ts +1 -0
  76. package/src/contracts/outgoing-mail.ts +77 -0
  77. package/src/contracts/registry.ts +4 -0
  78. package/src/services/index.ts +3 -0
  79. package/src/services/notifications/email.ts +16 -26
  80. package/src/services/outgoing-mail/index.ts +19 -0
  81. package/src/services/outgoing-mail/store.ts +286 -0
  82. package/src/services/outgoing-mail/test-send.ts +40 -0
  83. package/src/services/outgoing-mail/transport.ts +13 -0
  84. package/src/services/settings/core-settings.ts +1 -38
  85. package/src/services/settings/store.ts +5 -1
  86. package/src/shared/ai-model-request-settings.ts +21 -0
  87. package/src/shared/ai-platform-prompt.ts +1 -1
  88. package/src/shared/ai-request-options.ts +185 -0
  89. package/src/shared/app-presentation.ts +10 -2
  90. package/src/ssr/admin-navigation.ts +1 -1
  91. package/src/ssr/platform-messages.ts +2 -0
  92. package/src/ssr/workspace-navigation.ts +7 -1
  93. package/src/styles/effects.css +69 -0
@@ -0,0 +1,247 @@
1
+ import type { Provider, Tool, ToolResolver } from "@k2b/nessi";
2
+ import type { AiToolBlockStatus } from "./protocol";
3
+
4
+ /** Calls that only find or load other tools. */
5
+ const DISCOVERY_TOOL_NAMES = new Set(["search_tools", "list_apps", "load_tools"]);
6
+ /** Discovery calls in a row, without a completed working step, that earn a hint: three app lookups of search plus load. */
7
+ const DISCOVERY_STREAK_LIMIT = 6;
8
+ /** The last tenth of a turn's run time limit is kept for the answer. */
9
+ const FINAL_ANSWER_BUDGET_SHARE = 0.1;
10
+
11
+ export type AiTurnFinalReason = "tool_rounds" | "run_time" | "loop";
12
+ export type AiTurnHint = "repeated_failure" | "discovery_loop";
13
+ export type AiTurnPolicyDecision = { kind: "hint"; hints: AiTurnHint[] } | { kind: "final_answer"; reason: AiTurnFinalReason };
14
+
15
+ /** A finished tool call under the name the model called it by. */
16
+ export type AiTurnPolicyToolCall = { name: string; args?: unknown; status: AiToolBlockStatus; result?: unknown };
17
+
18
+ const FINAL_ANSWER_CAUSE: Record<AiTurnFinalReason, string> = {
19
+ tool_rounds: "The configured tool-round budget has been reached, so no more tools are available in this turn.",
20
+ run_time: "The run time limit of this turn is almost reached, so no more tools are available in this turn.",
21
+ loop: "This turn kept repeating steps that made no progress, so no more tools are available in this turn.",
22
+ };
23
+
24
+ const FINAL_ANSWER_CLOSE: Record<AiTurnFinalReason, string> = {
25
+ tool_rounds: "",
26
+ run_time: " Say which parts are still open; the user can continue the task with a new message.",
27
+ loop: " Say what blocked you and what the user can do about it.",
28
+ };
29
+
30
+ const finalAnswerPrompt = (reason: AiTurnFinalReason) =>
31
+ `# Final response
32
+ ${FINAL_ANSWER_CAUSE[reason]} Answer the user's request now with the best result supported by the evidence already gathered. State any material uncertainty or incomplete part clearly.${FINAL_ANSWER_CLOSE[reason]}`;
33
+
34
+ const listFormat = new Intl.ListFormat("en", { type: "conjunction" });
35
+
36
+ /** Names a hint may repeat: only tools the model was offered, never a name it made up. */
37
+ const offeredNames = (names: readonly string[], offered: ReadonlySet<string>) => {
38
+ const known = names.filter((name) => offered.has(name));
39
+ return listFormat.format(known.length < names.length ? [...known, "a tool that is not available"] : known);
40
+ };
41
+
42
+ const hintPrompt = (hints: { repeatedFailures: string[]; discoveryStreak: number | null }, offered: ReadonlySet<string>) =>
43
+ [
44
+ "# Turn check",
45
+ hints.repeatedFailures.length > 0
46
+ ? `Calls to ${offeredNames(hints.repeatedFailures, offered)} failed twice with the same input. Do not repeat them with that input. Change your approach, or tell the user what blocks you.`
47
+ : undefined,
48
+ hints.discoveryStreak !== null
49
+ ? `You searched for or loaded tools ${hints.discoveryStreak} times in a row without completing another step. Stop searching. Use the tools you already have, load the ones a search found, or tell the user what is not available in this turn.`
50
+ : undefined,
51
+ ]
52
+ .filter(Boolean)
53
+ .join("\n");
54
+
55
+ /** A completed load_tools call that made at least one more tool callable. */
56
+ const loadedTools = ({ name, status, result }: AiTurnPolicyToolCall) =>
57
+ name === "load_tools" &&
58
+ status === "completed" &&
59
+ typeof result === "object" &&
60
+ result !== null &&
61
+ "loaded" in result &&
62
+ Array.isArray(result.loaded) &&
63
+ result.loaded.length > 0;
64
+
65
+ const canonicalJson = (value: unknown): string => {
66
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
67
+ if (value && typeof value === "object")
68
+ return `{${Object.entries(value)
69
+ .filter(([, item]) => item !== undefined)
70
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
71
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
72
+ .join(",")}}`;
73
+ return JSON.stringify(value) ?? "null";
74
+ };
75
+
76
+ const withPrompt = (systemPrompt: string | undefined, suffix: string | null) =>
77
+ suffix ? `${systemPrompt ?? ""}\n\n${suffix}`.trim() : systemPrompt;
78
+
79
+ /**
80
+ * Keeps a chat turn from ending without an answer (#530).
81
+ *
82
+ * The policy is checked before every model call:
83
+ * - the same call (tool name and input) failing twice, or six discovery calls
84
+ * in a row without a completed working step, adds a one-time hint to the next
85
+ * model call;
86
+ * - either pattern after its hint, a positive `maxToolRounds` that is used up,
87
+ * or the last tenth of the run time limit ends tool use: the next model call
88
+ * gets no tools and a prompt to answer with what the turn has. If that call
89
+ * still asks for tools, the turn fails instead of looping without them.
90
+ *
91
+ * Rejected approvals are user decisions, not failures, and loading tools a
92
+ * search found does not continue a discovery loop. A steering message starts
93
+ * the loop checks over. The deadline abort stays as the backstop when a single
94
+ * model or tool call outlasts the reserve. Calls a resumed attempt already
95
+ * finished seed the counts without a hint of their own; a hint the earlier
96
+ * attempt gave is not remembered.
97
+ */
98
+ export const applyAiTurnPolicy = (input: {
99
+ provider: Provider;
100
+ tools: Tool[] | ToolResolver;
101
+ maxToolRounds?: number;
102
+ issuedToolRounds: number;
103
+ completedToolRounds: number;
104
+ /** Epoch milliseconds when the run time limit ends; null without a limit. */
105
+ deadline: number | null;
106
+ runBudgetMs: number | null;
107
+ /** Tool calls this turn finished before this attempt and after its last steering message, oldest first. */
108
+ finishedToolCalls?: readonly AiTurnPolicyToolCall[];
109
+ onDecision?: (decision: AiTurnPolicyDecision) => void;
110
+ now?: () => number;
111
+ }): {
112
+ provider: Provider;
113
+ tools: ToolResolver;
114
+ maxTurns?: number;
115
+ noteToolRound: () => void;
116
+ noteToolCall: (call: AiTurnPolicyToolCall) => void;
117
+ noteSteering: () => void;
118
+ } => {
119
+ const now = input.now ?? Date.now;
120
+ const limit = Math.floor(input.maxToolRounds ?? 0);
121
+ const issuedAtStart = Math.max(0, Math.floor(input.issuedToolRounds));
122
+ let completed = Math.max(0, Math.floor(input.completedToolRounds));
123
+ // nessi resolves the tools once more to finish a round an earlier attempt left open.
124
+ let resumingRound = issuedAtStart > completed;
125
+ const reserveMs = input.runBudgetMs && input.runBudgetMs > 0 ? input.runBudgetMs * FINAL_ANSWER_BUDGET_SHARE : 0;
126
+
127
+ const failures = new Map<string, number>();
128
+ let repeatedFailures = new Set<string>();
129
+ let discoveryStreak = 0;
130
+ // A discovery call since the last check that made no new tool callable.
131
+ let discoveredSinceCheck = false;
132
+ const hinted = new Set<AiTurnHint>();
133
+ let finalReason: AiTurnFinalReason | null = null;
134
+ let completedAtFinal = 0;
135
+ let pendingHint: string | null = null;
136
+ let offered = new Set<string>();
137
+
138
+ const noteToolCall = (call: AiTurnPolicyToolCall) => {
139
+ const { name, args, status } = call;
140
+ if (status !== "completed" && status !== "failed") return;
141
+ if (status === "failed") {
142
+ const key = `${name}\u0000${canonicalJson(args)}`;
143
+ const count = (failures.get(key) ?? 0) + 1;
144
+ failures.set(key, count);
145
+ if (count >= 2) repeatedFailures.add(name);
146
+ }
147
+ if (DISCOVERY_TOOL_NAMES.has(name)) {
148
+ discoveryStreak += 1;
149
+ if (!loadedTools(call)) discoveredSinceCheck = true;
150
+ } else if (status === "completed") {
151
+ discoveryStreak = 0;
152
+ }
153
+ };
154
+ for (const call of input.finishedToolCalls ?? []) noteToolCall(call);
155
+ repeatedFailures = new Set();
156
+ discoveredSinceCheck = false;
157
+
158
+ const reserveReached = () => input.deadline !== null && reserveMs > 0 && input.deadline - now() <= reserveMs;
159
+ const endToolUse = (reason: AiTurnFinalReason) => {
160
+ finalReason = reason;
161
+ completedAtFinal = completed;
162
+ pendingHint = null;
163
+ input.onDecision?.({ kind: "final_answer", reason });
164
+ };
165
+
166
+ const check = () => {
167
+ if (finalReason) return;
168
+ if (limit > 0 && completed >= limit) endToolUse("tool_rounds");
169
+ else if (reserveReached()) endToolUse("run_time");
170
+ else {
171
+ const triggered: AiTurnHint[] = [];
172
+ if (repeatedFailures.size > 0) triggered.push("repeated_failure");
173
+ if (discoveredSinceCheck && discoveryStreak >= DISCOVERY_STREAK_LIMIT) triggered.push("discovery_loop");
174
+ if (triggered.some((hint) => hinted.has(hint))) endToolUse("loop");
175
+ else if (triggered.length > 0) {
176
+ for (const hint of triggered) hinted.add(hint);
177
+ pendingHint = hintPrompt(
178
+ {
179
+ repeatedFailures: triggered.includes("repeated_failure") ? [...repeatedFailures] : [],
180
+ discoveryStreak: triggered.includes("discovery_loop") ? discoveryStreak : null,
181
+ },
182
+ offered,
183
+ );
184
+ input.onDecision?.({ kind: "hint", hints: triggered });
185
+ }
186
+ }
187
+ repeatedFailures = new Set();
188
+ discoveredSinceCheck = false;
189
+ };
190
+
191
+ const tools: ToolResolver = async () => {
192
+ if (resumingRound) resumingRound = false;
193
+ else check();
194
+ if (finalReason) {
195
+ if (completed > completedAtFinal) throw new Error("The model did not produce a final answer without tools.");
196
+ return [];
197
+ }
198
+ const resolved = typeof input.tools === "function" ? await input.tools() : input.tools;
199
+ offered = new Set(resolved.map((tool) => tool.def.name));
200
+ return resolved;
201
+ };
202
+ const provider: Provider = {
203
+ name: input.provider.name,
204
+ family: input.provider.family,
205
+ model: input.provider.model,
206
+ contextWindow: input.provider.contextWindow,
207
+ capabilities: input.provider.capabilities,
208
+ complete: (request) => input.provider.complete(request),
209
+ stream: async function* (request) {
210
+ // Compaction may run between the tool check and this call; the reserve still holds.
211
+ if (!finalReason && reserveReached()) endToolUse("run_time");
212
+ let overflowed = false;
213
+ for await (const event of input.provider.stream({
214
+ ...request,
215
+ tools: finalReason ? [] : request.tools,
216
+ systemPrompt: withPrompt(request.systemPrompt, finalReason ? finalAnswerPrompt(finalReason) : pendingHint),
217
+ })) {
218
+ if (event.type === "issue" && event.issue.kind === "provider_error" && event.issue.contextOverflow) overflowed = true;
219
+ yield event;
220
+ }
221
+ // nessi compacts an overflowed request and sends it again; the hint goes with it.
222
+ if (!overflowed) pendingHint = null;
223
+ },
224
+ };
225
+
226
+ return {
227
+ provider,
228
+ tools,
229
+ // Nessi checks this before provider calls. The extra round is the tool-free
230
+ // synthesis call after the last allowed tool-using round.
231
+ ...(limit > 0 ? { maxTurns: Math.max(1, limit - issuedAtStart + 1) } : {}),
232
+ noteToolRound: () => {
233
+ completed += 1;
234
+ },
235
+ noteToolCall,
236
+ noteSteering: () => {
237
+ // New user input: the loop checks start over, and a loop's final answer may use tools again.
238
+ failures.clear();
239
+ repeatedFailures = new Set();
240
+ discoveryStreak = 0;
241
+ discoveredSinceCheck = false;
242
+ hinted.clear();
243
+ pendingHint = null;
244
+ finalReason = null;
245
+ },
246
+ };
247
+ };
@@ -80,6 +80,36 @@ export function createTurnTimingRecorder(turnId: string, db = sql) {
80
80
  };
81
81
  }
82
82
 
83
+ /**
84
+ * Time a turn waited for the person: approvals and answers until they were given. A tool the browser runs by itself
85
+ * waits only until it starts; a secret prompt waits for its answer even though the browser claimed it. An open wait
86
+ * ends now.
87
+ */
88
+ const actionWaits = (turnId: string, db: typeof sql) =>
89
+ db<{ start: Date; end: Date; open: boolean }[]>`SELECT a.created_at AS start,
90
+ COALESCE(w.end, now()) AS end, w.end IS NULL AS open
91
+ FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id
92
+ CROSS JOIN LATERAL (SELECT CASE WHEN a.kind='client_tool' AND a.tool_name<>'code_secret' THEN LEAST(t.started_at,a.resolved_at)
93
+ ELSE a.resolved_at END AS end) w
94
+ WHERE a.turn_id=${turnId}::uuid`;
95
+
96
+ /**
97
+ * Waits of a running turn by the same rules as its durable timing: `actionWaitMs` is the time already waited, and
98
+ * `waitingSince` the start of the wait that is still open, overlapping waits counted once.
99
+ */
100
+ export async function activeTurnWaits(turnId: string, db = sql): Promise<{ actionWaitMs: number; waitingSince: string | null }> {
101
+ const rows = await actionWaits(turnId, db);
102
+ const waits = union(rows.map((x) => ({ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() })));
103
+ const opens = rows.filter((x) => x.open).map((x) => new Date(x.start).getTime());
104
+ const openStart = opens.length > 0 ? Math.min(...opens) : null;
105
+ // An open wait reaches until now, so the merged wait that holds it is the last one.
106
+ const current = openStart === null ? undefined : waits.find((x) => x.start <= openStart && openStart <= x.end);
107
+ return {
108
+ actionWaitMs: duration(waits.filter((x) => x !== current)),
109
+ waitingSince: current ? new Date(current.start).toISOString() : null,
110
+ };
111
+ }
112
+
83
113
  export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggregate, db = sql): Promise<LoopAggregate> {
84
114
  const [turn] = await db<
85
115
  { start: Date; end: Date }[]
@@ -95,9 +125,7 @@ export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggre
95
125
  const tools = await db<
96
126
  { start: Date; end: Date }[]
97
127
  >`SELECT started_at AS start,COALESCE(completed_at,now()) AS end FROM ai.tool_calls WHERE turn_id=${turnId}::uuid AND started_at IS NOT NULL`;
98
- const waits = await db<{ start: Date; end: Date }[]>`SELECT a.created_at AS start,
99
- CASE WHEN a.kind='client_tool' THEN LEAST(COALESCE(t.started_at,a.resolved_at,now()),COALESCE(a.resolved_at,now())) ELSE COALESCE(a.resolved_at,now()) END AS end
100
- FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id WHERE a.turn_id=${turnId}::uuid`;
128
+ const waits = await actionWaits(turnId, db);
101
129
  const intervals = (rows: { start: Date; end: Date | null }[]) =>
102
130
  rows.flatMap((x) => (x.end ? [{ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() }] : []));
103
131
  return {
package/src/ai/types.ts CHANGED
@@ -45,6 +45,8 @@ export type AiModelProfile = {
45
45
  contextWindow?: number;
46
46
  temperature?: number;
47
47
  maxOutputTokens?: number;
48
+ reasoningEffort?: string;
49
+ extraBody?: Record<string, unknown>;
48
50
  /** Maximum deferred tools retained per conversation. Missing or <= 0 keeps all loaded tools. */
49
51
  maxLoadedTools?: number;
50
52
  /** Tool-using model rounds per chat turn. Missing or <= 0 is unlimited. */
@@ -296,7 +298,7 @@ export type AiStoredMessage = {
296
298
  trigger: "scheduled" | "manual";
297
299
  };
298
300
  toolPresentations?: Record<string, AiToolPresentation>;
299
- toolOutcomes?: Record<string, "rejected">;
301
+ toolOutcomes?: Record<string, "rejected" | "approved">;
300
302
  } | null;
301
303
  /** Private owner feedback for this rendered assistant response. Never enters model context. */
302
304
  feedback?: AiMessageFeedback | null;
@@ -510,7 +512,14 @@ export type AiConversationResourceOccurrence = AiConversationResourceRef & {
510
512
  chat: Pick<AiConversation, "shortId" | "title" | "updatedAt">;
511
513
  };
512
514
 
513
- export type AiConversationSourceKind = "web" | "file" | "resource" | "activity";
515
+ /**
516
+ * - `result`: something the assistant delivered with `present` (a file, `path` set), `code_open` (an app, `ref` set) or
517
+ * `code_present` (a chat visualization, neither set; `sourceCallId` names it).
518
+ * - `web`: a page read with `web_extract`; `activity`: a web search, one entry per query.
519
+ * - `resource`: a Cloud resource a tool call read, or one indexed as Project context without a call.
520
+ * - `file`: a conversation file.
521
+ */
522
+ export type AiConversationSourceKind = "result" | "web" | "file" | "resource" | "activity";
514
523
 
515
524
  export type AiConversationSource = {
516
525
  kind: AiConversationSourceKind;
@@ -528,15 +537,23 @@ export type AiConversationSource = {
528
537
  lastSeenAt: string;
529
538
  sourceTurnId: string | null;
530
539
  sourceCallId: string | null;
540
+ /**
541
+ * Chat position of the source: the assistant message that holds `sourceCallId`, otherwise the start of its turn.
542
+ * Use it as the `message` link target; null when the turn is gone.
543
+ */
544
+ sourceMessageSeq: number | null;
531
545
  };
532
546
 
533
547
  export type AiConversationSourceObservation = {
534
- kind: "web" | "activity";
548
+ kind: "result" | "web" | "activity";
535
549
  key: string;
536
550
  title: string;
551
+ /** For `result`, the delivered description; a new delivery always replaces it, also with none. */
537
552
  preview?: string;
538
553
  icon?: string;
539
554
  href?: string;
555
+ /** The delivered Cloud resource of a `result`, for example an opened app. */
556
+ ref?: CloudResourceRef;
540
557
  };
541
558
 
542
559
  export type AiConversationFileSnapshotEntry = {
@@ -707,7 +724,11 @@ export type AiConversationService = {
707
724
  search?: string;
708
725
  before?: string;
709
726
  limit?: number;
710
- }): Promise<{ sources: AiConversationSource[]; nextCursor?: string }>;
727
+ /** Only these kinds; all kinds when omitted. */
728
+ kinds?: readonly AiConversationSourceKind[];
729
+ /** Only resources a tool call read; leaves out resources indexed as Project context without a call. */
730
+ observed?: boolean;
731
+ }): Promise<{ sources: AiConversationSource[]; nextCursor?: string; total: number }>;
711
732
  getCapabilityInvocationOrigin(input: { idempotencyKey: string; toolName: string }): Promise<{
712
733
  conversationId: string;
713
734
  conversationShortId: string;
@@ -864,7 +885,15 @@ export type AiConversationService = {
864
885
  getLatestTurn(input: { conversationId: string }): Promise<AiTurn | null>;
865
886
  getTurn(input: { conversationId: string; turnId: string }): Promise<AiTurn | null>;
866
887
  getTurnByShortId(input: { conversationId: string; shortId: string }): Promise<AiTurn | null>;
867
- getActiveTurn(input: { conversationId: string }): Promise<{ turn: AiTurn; liveBlocks: AiTurnBlock[]; liveSeq: number } | null>;
888
+ getActiveTurn(input: { conversationId: string }): Promise<{
889
+ turn: AiTurn;
890
+ liveBlocks: AiTurnBlock[];
891
+ liveSeq: number;
892
+ /** Time spent waiting for answered user actions. */
893
+ actionWaitMs: number;
894
+ /** Start of the oldest unanswered user action, if the turn waits now. */
895
+ waitingSince: string | null;
896
+ } | null>;
868
897
  /**
869
898
  * Claim a turn attempt. Increments attempt and takes the lease atomically.
870
899
  * `from: "queue"` claims queued or lease-expired running turns; `from: "waiting"`
@@ -944,6 +973,8 @@ export type AiConversationService = {
944
973
  toolPresentations?: ReadonlyMap<string, AiToolPresentation>;
945
974
  /** Mutable call-id set read only when a rejected tool result is persisted. */
946
975
  rejectedToolCallIds?: ReadonlySet<string>;
976
+ /** Mutable call-id set read only when the result of a call the user approved is persisted. */
977
+ approvedToolCallIds?: ReadonlySet<string>;
947
978
  }): SessionStore;
948
979
  };
949
980
 
@@ -1,12 +1,15 @@
1
1
  import { Hono, type MiddlewareHandler } from "hono";
2
+ import { describeRoute } from "hono-openapi";
2
3
  import { z } from "zod";
3
4
  import { AiBackgroundCostError, backgroundCostState, releaseBackgroundCostStop } from "../ai/inference-calls";
4
5
  import { setAiModelPricing } from "../ai/model-pricing";
6
+ import { AiModelRequestSettingsInvalid, getAiModelRequestSettings, setAiModelRequestSettings } from "../ai/model-request-settings";
5
7
  import { quotaAdminConfig, quotaReport } from "../ai/quota-report";
6
8
  import { AiQuotaError, aiQuotas } from "../ai/quotas";
7
9
  import { readAiSettingsState } from "../ai/settings";
8
- import { type AuthContext, auth, v } from "../server";
10
+ import { type AuthContext, auth, jsonResponse, requiresAdmin, v } from "../server";
9
11
  import { AiModelPricingSchema, hasBillableAiPricing } from "../shared/ai-costs";
12
+ import { AiModelRequestSettingsSchema, AiModelRequestSettingsUpdateSchema } from "../shared/ai-model-request-settings";
10
13
  import {
11
14
  AiQuotaConfigSchema,
12
15
  AiQuotaIdentitySchema,
@@ -21,6 +24,7 @@ export const createAdminAiQuotaRoutes = (authenticate: MiddlewareHandler<AuthCon
21
24
  new Hono<AuthContext>()
22
25
  .use("*", authenticate)
23
26
  .onError((error, c) => {
27
+ if (error instanceof AiModelRequestSettingsInvalid) return c.json({ message: error.message }, 400);
24
28
  if (error instanceof AiQuotaError || error instanceof AiBackgroundCostError)
25
29
  return c.json({ error: error.code, message: error.message }, 409);
26
30
  throw error;
@@ -46,6 +50,37 @@ export const createAdminAiQuotaRoutes = (authenticate: MiddlewareHandler<AuthCon
46
50
  return c.json(await setAiModelPricing(c.req.param("id")!, data.pricing, data.expected, c.get("user")!.id));
47
51
  },
48
52
  )
53
+ .get(
54
+ "/models/:id/settings",
55
+ describeRoute({
56
+ tags: ["Administration"],
57
+ summary: "Read masked model request settings",
58
+ ...requiresAdmin,
59
+ responses: { 200: jsonResponse(AiModelRequestSettingsSchema, "Model request settings (header names only)") },
60
+ }),
61
+ async (c) => {
62
+ c.header("Cache-Control", "no-store");
63
+ return c.json(await getAiModelRequestSettings(c.req.param("id")!));
64
+ },
65
+ )
66
+ .put(
67
+ "/models/:id/settings",
68
+ describeRoute({
69
+ tags: ["Administration"],
70
+ summary: "Patch model request settings with a revision guard",
71
+ ...requiresAdmin,
72
+ responses: {
73
+ 200: jsonResponse(AiModelRequestSettingsSchema, "Updated model request settings (header names only)"),
74
+ 400: jsonResponse(z.object({ message: z.string() }), "Invalid model request settings"),
75
+ 409: jsonResponse(z.object({ message: z.string(), error: z.string() }), "Model request settings changed"),
76
+ },
77
+ }),
78
+ v("json", AiModelRequestSettingsUpdateSchema),
79
+ async (c) => {
80
+ c.header("Cache-Control", "no-store");
81
+ return c.json(await setAiModelRequestSettings(c.req.param("id")!, c.req.valid("json"), c.get("user")!.id));
82
+ },
83
+ )
49
84
  .get("/background", async (c) => c.json(await backgroundCostState()))
50
85
  .post("/background/release", async (c) => c.json(await releaseBackgroundCostStop(c.get("user")!.id)))
51
86
  .get("/report", v("query", AiQuotaReportQuerySchema), async (c) => c.json(await quotaReport(c.req.valid("query"))))
@@ -19,6 +19,7 @@ import {
19
19
  aiModelAccess,
20
20
  splitAiModelAccess,
21
21
  } from "../ai/model-access";
22
+ import { listAiRequestHeaderNames, planAiProfileRequestHeaders, storeAiRequestHeaderPlan } from "../ai/request-headers";
22
23
  import { parseAiModelProfiles, planAiProfileCredentials, validateAiSettingsConfiguration } from "../ai/settings";
23
24
  import { type AiSettingsIssueWithMessage, aiSettingsNotSavedMessage, describeAiSettingsIssues } from "../ai/settings-messages";
24
25
  import { type AuthContext, auth, getLocale, jsonResponse, requiresAdmin, v } from "../server";
@@ -27,7 +28,6 @@ import { readAccountCategoryPolicy } from "../services/account-category-policy";
27
28
  import { audit } from "../services/audit";
28
29
  import { validateFreeIpaCaCert } from "../services/freeipa-config";
29
30
  import { testFreeIpaConnection } from "../services/ipa/connection";
30
- import { sendEmail } from "../services/notifications/email";
31
31
  import { GotenbergRenderError, testGotenberg } from "../services/pdf";
32
32
  import * as settings from "../services/settings";
33
33
  import { SETTINGS_MAP, validateSettingValue } from "../services/settings/defaults";
@@ -43,10 +43,6 @@ const BulkSaveSchema = z.union([
43
43
  .strict(),
44
44
  z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[] })),
45
45
  ]);
46
- const TestEmailSchema = z.object({
47
- recipient: z.email(),
48
- });
49
-
50
46
  const LEGAL_DOCUMENTS = [
51
47
  { kind: "terms", path: "/legal/terms" },
52
48
  { kind: "privacy", path: "/legal/privacy" },
@@ -101,6 +97,7 @@ type AiSettingsMutationPlan = {
101
97
  /** Structured validation issues with stable codes; `errors` carries the same messages keyed by setting. */
102
98
  issues?: AiSettingsIssueWithMessage[];
103
99
  keepCredentialProfileIds?: string[];
100
+ headerPlan?: ReturnType<typeof planAiProfileRequestHeaders>;
104
101
  modelProfileIds?: string[];
105
102
  };
106
103
 
@@ -168,6 +165,17 @@ const prepareAiSettingsMutation = async (
168
165
  keepCredentialProfileIds = credentialPlan.keepCredentialProfileIds;
169
166
  }
170
167
 
168
+ const headerPlan =
169
+ profilesUpdated || profilesReset
170
+ ? planAiProfileRequestHeaders({
171
+ currentProfiles: currentParsed.profiles,
172
+ nextProfiles: nextParsed.profiles,
173
+ existingNames: profilesUpdated ? await listAiRequestHeaderNames() : {},
174
+ submitted: aiSplit?.requestHeaders ?? [],
175
+ })
176
+ : undefined;
177
+ if (headerPlan?.error) return { errors: { [AI_PROFILES_KEY]: headerPlan.error } };
178
+
171
179
  const issues = validateAiSettingsConfiguration({
172
180
  enabled: nextEnabled,
173
181
  defaultModelId: String(valueAfterMutation(AI_DEFAULT_MODEL_KEY, currentDefaultModelId ?? "", updates, resets)),
@@ -183,6 +191,7 @@ const prepareAiSettingsMutation = async (
183
191
  return {
184
192
  errors: described.errors,
185
193
  issues: described.issues,
194
+ headerPlan,
186
195
  keepCredentialProfileIds,
187
196
  modelProfileIds: profilesUpdated || profilesReset ? nextParsed.profiles.map((profile) => profile.id) : undefined,
188
197
  };
@@ -294,24 +303,6 @@ const app = new Hono<AuthContext>()
294
303
  .delete("/legacy", auth.requireRole("admin"), async (c) => {
295
304
  return c.json(await settingsDeleteLegacyKeys(await liveSettingKeys()));
296
305
  })
297
- .post("/test-email", auth.requireRole("admin"), v("json", TestEmailSchema), async (c) => {
298
- const { recipient } = c.req.valid("json");
299
- const sentAt = new Date().toISOString();
300
-
301
- try {
302
- await sendEmail(recipient, "Cloud test email", {
303
- rawHtml: `
304
- <p>This is a test email from Cloud.</p>
305
- <p>If you received this message, SMTP delivery is configured correctly.</p>
306
- <p style="margin-top:24px;color:#71717a;font-size:12px;">Sent at ${sentAt}</p>
307
- `,
308
- });
309
- return c.json({ ok: true });
310
- } catch (error) {
311
- const message = error instanceof Error ? error.message : "Failed to send test email";
312
- return c.json({ message }, 500);
313
- }
314
- })
315
306
  .post("/run-ai-enrichment", auth.requireRole("admin"), async (c) => {
316
307
  try {
317
308
  // Small manual batch — the cron handles bulk; this is "kick it now".
@@ -463,6 +454,7 @@ const app = new Hono<AuthContext>()
463
454
  } else if (aiPlan.keepCredentialProfileIds) {
464
455
  await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
465
456
  }
457
+ if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
466
458
  if (aiPlan.modelProfileIds) {
467
459
  await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
468
460
  }
@@ -524,6 +516,7 @@ const app = new Hono<AuthContext>()
524
516
  if (aiPlan.keepCredentialProfileIds) {
525
517
  await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
526
518
  }
519
+ if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
527
520
  if (aiPlan.modelProfileIds) {
528
521
  await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, [], tx);
529
522
  }
@@ -0,0 +1,62 @@
1
+ import { type Context, Hono, type MiddlewareHandler } from "hono";
2
+ import { validator } from "hono-openapi";
3
+ import { z } from "zod";
4
+ import { MailAppAccessSchema, MailProfileInputSchema, MailProfileKeySchema } from "../contracts/outgoing-mail";
5
+ import { type AuthContext, auth } from "../server";
6
+ import { type MailAuditContext, OutgoingMailError, outgoingMailStore } from "../services/outgoing-mail/store";
7
+ import { outgoingMailTest } from "../services/outgoing-mail/test-send";
8
+
9
+ const validate = <Target extends "param" | "json", Schema extends z.ZodType>(target: Target, schema: Schema) =>
10
+ validator(target, schema, (result, c) => {
11
+ if (!result.success) {
12
+ const issue = result.error?.[0];
13
+ const path = issue?.path?.map((part) => (typeof part === "object" && "key" in part ? String(part.key) : String(part))).join(".");
14
+ return c.json(
15
+ {
16
+ code: "invalid_profile" as const,
17
+ message: issue ? `${path ? `${path}: ` : ""}${issue.message}` : "Invalid outgoing mail configuration.",
18
+ },
19
+ 400,
20
+ );
21
+ }
22
+ });
23
+ const context = (c: Context<AuthContext>): MailAuditContext => ({
24
+ actor: { userId: c.get("user").id, uid: c.get("user").uid, provider: c.get("user").provider, roles: c.get("user").roles },
25
+ requestId: c.req.header("x-request-id"),
26
+ });
27
+ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
28
+ new Hono<AuthContext>()
29
+ .use("*", authenticate)
30
+ .use("*", async (c, next) => {
31
+ c.header("Cache-Control", "no-store");
32
+ await next();
33
+ })
34
+ .onError((error, c) => {
35
+ if (error instanceof OutgoingMailError) return c.json({ error: error.code, code: error.code, message: error.message }, error.status);
36
+ throw error;
37
+ })
38
+ .get("/profiles", async (c) => c.json({ items: await outgoingMailStore.list() }))
39
+ .get("/profiles/:key", async (c) => c.json(await outgoingMailStore.get(c.req.param("key"))))
40
+ .put(
41
+ "/profiles/:key",
42
+ validate("param", z.object({ key: MailProfileKeySchema })),
43
+ validate("json", MailProfileInputSchema),
44
+ async (c) => {
45
+ const result = await outgoingMailStore.put(c.req.valid("param").key, c.req.valid("json"), context(c));
46
+ return c.json(result.profile, result.created ? 201 : 200);
47
+ },
48
+ )
49
+ .post("/profiles/:key/default", async (c) => c.json(await outgoingMailStore.setDefault(c.req.param("key")!, context(c))))
50
+ .delete("/profiles/:key", async (c) => {
51
+ await outgoingMailStore.delete(c.req.param("key")!, context(c));
52
+ return c.body(null, 204);
53
+ })
54
+ .post("/profiles/:key/test", validate("json", z.object({ recipient: z.email() }).strict()), async (c) => {
55
+ await outgoingMailTest.send(c.req.param("key")!, c.req.valid("json").recipient, context(c));
56
+ return c.json({ ok: true as const });
57
+ })
58
+ .get("/apps", async (c) => c.json(await outgoingMailStore.apps()))
59
+ .put("/apps/:appId", validate("json", MailAppAccessSchema), async (c) =>
60
+ c.json(await outgoingMailStore.setAppAccess(c.req.param("appId")!, c.req.valid("json"), context(c))),
61
+ );
62
+ export default createAdminOutgoingMailRoutes();
package/src/api/index.ts CHANGED
@@ -29,6 +29,7 @@ import adminCoreSettingsRoutes from "./admin-core-settings";
29
29
  import adminIdentityRoutes from "./admin-identity";
30
30
  import adminLifecycleRoutes from "./admin-lifecycle";
31
31
  import adminLinuxIdentityRoutes from "./admin-linux-identities";
32
+ import adminOutgoingMailRoutes from "./admin-outgoing-mail";
32
33
  import adminRailRoutes from "./admin-rail";
33
34
  import { adminAnnouncementRoutes, announcementRoutes } from "./announcements";
34
35
  import { createAppApprovalRoutes } from "./app-approval";
@@ -77,6 +78,7 @@ const buildCoreApi = (options: CoreApiOptions) => {
77
78
  .route("/admin/core/ai-projects", adminAiProjectsRoutes)
78
79
  .route("/admin/core/ai-skills", adminAiSkillsRoutes)
79
80
  .route("/admin/core/announcements", adminAnnouncementRoutes)
81
+ .route("/admin/core/outgoing-mail", adminOutgoingMailRoutes)
80
82
  .route("/admin/core/settings", adminCoreSettingsRoutes)
81
83
  .route("/admin/core/rail", adminRailRoutes)
82
84
  .route("/admin/core/linux-identities", adminLinuxIdentityRoutes)