@k2b/cloud 0.28.0 → 0.29.1

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 (112) hide show
  1. package/package.json +9 -6
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. package/src/styles/utilities-navigation.css +41 -0
@@ -12,6 +12,7 @@ import { type AiMemoryLearningChange, aiMemoryLearningRuns } from "./memory-lear
12
12
  import {
13
13
  type AiMemoryWorkflowEvidence,
14
14
  type AiMemoryWorkflowPattern,
15
+ isAiWorkflowPatternEligible,
15
16
  listAiPendingWorkflowPatterns,
16
17
  listAiTurnWorkflowEvidence,
17
18
  markAiWorkflowPatternReviewed,
@@ -199,6 +200,21 @@ const markFailed = async (candidate: Candidate): Promise<void> => {
199
200
 
200
201
  const learningEnabled = async (userId: string): Promise<boolean> => (await aiUserPrefs.get(userId)).memoryLearningEnabled;
201
202
 
203
+ const turnEligible = async (candidate: Candidate): Promise<boolean> => {
204
+ const [row] = await sql<{ id: string }[]>`
205
+ SELECT turn.id
206
+ FROM ai.turns turn
207
+ JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
208
+ WHERE turn.id = ${candidate.turnId}::uuid
209
+ AND conversation.id = ${candidate.conversationId}::uuid
210
+ AND conversation.created_by_user_id = ${candidate.userId}::uuid
211
+ AND conversation.archived_at IS NULL
212
+ AND turn.status = 'completed'
213
+ AND turn.memory_learned_at IS NULL
214
+ `;
215
+ return Boolean(row);
216
+ };
217
+
202
218
  const monthlyAccountedTokens = async (userId: string): Promise<number> => {
203
219
  const [row] = await sql<{ tokens: number }[]>`
204
220
  SELECT COALESCE(sum(accounted_tokens), 0)::int AS tokens
@@ -342,6 +358,7 @@ const applyChanges = async (
342
358
  const workflowPatternInput = async (
343
359
  pattern: AiMemoryWorkflowPattern,
344
360
  ): Promise<{ input: string; source: TurnEvidence | null; mutableMemoryIds: Set<string> }> => {
361
+ if (!(await isAiWorkflowPatternEligible(pattern))) return { input: "", source: null, mutableMemoryIds: new Set() };
345
362
  const sources = await Promise.all(
346
363
  pattern.turnIds.map(async (turnId) => {
347
364
  const [row] = await sql<{ conversation_id: string; completed_as_of: string; fail_count: number }[]>`
@@ -349,6 +366,7 @@ const workflowPatternInput = async (
349
366
  FROM ai.turns turn
350
367
  JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
351
368
  WHERE turn.id = ${turnId}::uuid AND conversation.created_by_user_id = ${pattern.userId}::uuid
369
+ AND conversation.archived_at IS NULL
352
370
  `;
353
371
  if (!row) return null;
354
372
  return loadTurnEvidence({
@@ -361,6 +379,7 @@ const workflowPatternInput = async (
361
379
  }),
362
380
  );
363
381
  const evidence = sources.filter((source): source is TurnEvidence => Boolean(source));
382
+ if (evidence.length !== pattern.turnIds.length) return { input: "", source: null, mutableMemoryIds: new Set() };
364
383
  const query = evidence.map((source) => source.userText).join(" ");
365
384
  const selected = await aiMemories.selectHot(pattern.userId, query);
366
385
  const examples = evidence
@@ -441,6 +460,12 @@ export const learnAiMemoriesFromPrivateChats = async (
441
460
  let runId: string | null = null;
442
461
  let changes: AiMemoryLearningChange[] = [];
443
462
  try {
463
+ if (!(await turnEligible(candidate))) {
464
+ await markLearned(candidate);
465
+ summary.skipped += 1;
466
+ processedCandidates += 1;
467
+ continue;
468
+ }
444
469
  const evidence = await loadTurnEvidence(candidate);
445
470
  if (!evidence.userText) {
446
471
  await markLearned(candidate);
@@ -458,7 +483,6 @@ export const learnAiMemoriesFromPrivateChats = async (
458
483
  });
459
484
  const reservedTokens = estimatedTokens(systemPrompt, taskInput);
460
485
  if ((await readMonthlyUsage(candidate.userId)) + reservedTokens > monthlyTokenBudget) continue;
461
- // Read the choice again right before the turn can reach the model.
462
486
  if (!(await learningEnabled(candidate.userId))) continue;
463
487
  runId = await aiMemoryLearningRuns.start({
464
488
  userId: candidate.userId,
@@ -468,6 +492,22 @@ export const learnAiMemoriesFromPrivateChats = async (
468
492
  modelProfileId: resolved.profile.id,
469
493
  accountedTokens: reservedTokens,
470
494
  });
495
+ // Claiming the run also awaits the database: recheck immediately before inference.
496
+ // runAiStructured may still wait for background admission; checks after the call discard results for chats that became ineligible.
497
+ if (!(await turnEligible(candidate))) {
498
+ await markLearned(candidate);
499
+ if (runId)
500
+ await aiMemoryLearningRuns.finish({
501
+ runId,
502
+ status: "skipped",
503
+ durationMs: Date.now() - startedAt,
504
+ changes: [],
505
+ accountedTokens: 0,
506
+ });
507
+ summary.skipped += 1;
508
+ processedCandidates += 1;
509
+ continue;
510
+ }
471
511
  if (!runId) continue;
472
512
  const result = await structured({
473
513
  task: "memory-learn-turn",
@@ -481,7 +521,7 @@ export const learnAiMemoriesFromPrivateChats = async (
481
521
  signal: input.signal,
482
522
  resolveModel: async () => resolved,
483
523
  });
484
- if (!(await learningEnabled(candidate.userId))) {
524
+ if (!(await learningEnabled(candidate.userId)) || !(await turnEligible(candidate))) {
485
525
  await markLearned(candidate);
486
526
  } else {
487
527
  changes = await applyChanges(
@@ -547,7 +587,8 @@ export const learnAiMemoriesFromPrivateChats = async (
547
587
  try {
548
588
  const context = await workflowPatternInput(pattern);
549
589
  if (!context.source) {
550
- await markAiWorkflowPatternReviewed(pattern);
590
+ // Archive retires its own receipts; preserve other chats' evidence for a later pattern.
591
+ summary.skipped += 1;
551
592
  continue;
552
593
  }
553
594
  const locale = await resolveLearningLocale(context.source.candidate.locale);
@@ -568,6 +609,18 @@ export const learnAiMemoriesFromPrivateChats = async (
568
609
  modelProfileId: resolved.profile.id,
569
610
  accountedTokens: reservedTokens,
570
611
  });
612
+ if (!(await isAiWorkflowPatternEligible(pattern))) {
613
+ if (runId)
614
+ await aiMemoryLearningRuns.finish({
615
+ runId,
616
+ status: "skipped",
617
+ durationMs: Date.now() - startedAt,
618
+ changes: [],
619
+ accountedTokens: 0,
620
+ });
621
+ summary.skipped += 1;
622
+ continue;
623
+ }
571
624
  if (!runId) continue;
572
625
  const result = await structured({
573
626
  task: "memory-learn-workflow",
@@ -586,7 +639,8 @@ export const learnAiMemoriesFromPrivateChats = async (
586
639
  resolveModel: async () => resolved,
587
640
  });
588
641
  let changes: AiMemoryLearningChange[] = [];
589
- if (result.output.workflow && (await learningEnabled(pattern.userId))) {
642
+ // The write locks only the source chat; check the other examples right before it.
643
+ if (result.output.workflow && (await learningEnabled(pattern.userId)) && (await isAiWorkflowPatternEligible(pattern))) {
590
644
  const memoryIds = result.output.workflow.memoryIds;
591
645
  const action = memoryIds.length > 1 ? "merge" : memoryIds.length === 1 ? "replace" : "add";
592
646
  changes = await applyChanges(
@@ -603,7 +657,7 @@ export const learnAiMemoriesFromPrivateChats = async (
603
657
  context.mutableMemoryIds,
604
658
  );
605
659
  }
606
- await markAiWorkflowPatternReviewed(pattern);
660
+ if (await isAiWorkflowPatternEligible(pattern)) await markAiWorkflowPatternReviewed(pattern);
607
661
  await aiMemoryLearningRuns.finish({
608
662
  runId,
609
663
  status: changes.length > 0 ? "ok" : "skipped",
@@ -25,8 +25,8 @@ export type AiMemoryWorkflowPattern = {
25
25
 
26
26
  /**
27
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.
28
+ * while learning is on for the user and the chat is active: receipts from a
29
+ * time it was off or the chat was archived never count toward a pattern.
30
30
  */
31
31
  export const recordAiMemoryWorkflowEvidence = async (input: {
32
32
  userId: string;
@@ -70,6 +70,7 @@ export const recordAiMemoryWorkflowEvidence = async (input: {
70
70
  WHERE turn.id = ${input.turnId}::uuid
71
71
  AND conversation.id = ${input.conversationId}::uuid
72
72
  AND conversation.created_by_user_id = ${input.userId}::uuid
73
+ AND conversation.archived_at IS NULL
73
74
  AND COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
74
75
  ON CONFLICT (turn_id, capability_id, resource_type, resource_id)
75
76
  DO UPDATE SET resource_title = COALESCE(EXCLUDED.resource_title, ai.memory_workflow_evidence.resource_title)
@@ -96,6 +97,7 @@ export const listAiTurnWorkflowEvidence = async (userId: string, turnId: string)
96
97
  WHERE evidence.user_id = ${userId}::uuid
97
98
  AND evidence.turn_id = ${turnId}::uuid
98
99
  AND conversation.created_by_user_id = evidence.user_id
100
+ AND conversation.archived_at IS NULL
99
101
  ORDER BY evidence.capability_id, evidence.resource_type, evidence.resource_id
100
102
  `;
101
103
  return rows.map((row) => ({
@@ -127,6 +129,7 @@ export const listAiPendingWorkflowPatterns = async (limit = 5): Promise<AiMemory
127
129
  JOIN ai.conversations conversation
128
130
  ON conversation.id = evidence.conversation_id AND conversation.created_by_user_id = evidence.user_id
129
131
  WHERE evidence.reviewed_at IS NULL
132
+ AND conversation.archived_at IS NULL
130
133
  AND COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
131
134
  GROUP BY evidence.user_id, evidence.capability_id, evidence.resource_type, evidence.resource_id
132
135
  HAVING count(*) >= 3
@@ -136,14 +139,17 @@ export const listAiPendingWorkflowPatterns = async (limit = 5): Promise<AiMemory
136
139
  return Promise.all(
137
140
  groups.map(async (group) => {
138
141
  const turns = await sql<{ turn_id: string }[]>`
139
- SELECT turn_id
140
- FROM ai.memory_workflow_evidence
141
- WHERE user_id = ${group.user_id}::uuid
142
- AND capability_id = ${group.capability_id}
143
- AND resource_type = ${group.resource_type}
144
- AND resource_id = ${group.resource_id}
145
- AND reviewed_at IS NULL
146
- ORDER BY observed_at DESC, turn_id DESC
142
+ SELECT evidence.turn_id
143
+ FROM ai.memory_workflow_evidence evidence
144
+ JOIN ai.conversations conversation
145
+ ON conversation.id = evidence.conversation_id AND conversation.created_by_user_id = evidence.user_id
146
+ WHERE evidence.user_id = ${group.user_id}::uuid
147
+ AND evidence.capability_id = ${group.capability_id}
148
+ AND evidence.resource_type = ${group.resource_type}
149
+ AND evidence.resource_id = ${group.resource_id}
150
+ AND evidence.reviewed_at IS NULL
151
+ AND conversation.archived_at IS NULL
152
+ ORDER BY evidence.observed_at DESC, evidence.turn_id DESC
147
153
  LIMIT 3
148
154
  `;
149
155
  return {
@@ -158,6 +164,25 @@ export const listAiPendingWorkflowPatterns = async (limit = 5): Promise<AiMemory
158
164
  );
159
165
  };
160
166
 
167
+ /** Every example used by the model must still belong to pending, active evidence. */
168
+ export const isAiWorkflowPatternEligible = async (pattern: AiMemoryWorkflowPattern): Promise<boolean> => {
169
+ if (pattern.turnIds.length < 3) return false;
170
+ const [row] = await sql<{ count: number }[]>`
171
+ SELECT count(*)::int AS count
172
+ FROM ai.memory_workflow_evidence evidence
173
+ JOIN ai.conversations conversation
174
+ ON conversation.id = evidence.conversation_id AND conversation.created_by_user_id = evidence.user_id
175
+ WHERE evidence.user_id = ${pattern.userId}::uuid
176
+ AND evidence.capability_id = ${pattern.capabilityId}
177
+ AND evidence.resource_type = ${pattern.resourceRef.type}
178
+ AND evidence.resource_id = ${pattern.resourceRef.id}
179
+ AND evidence.turn_id IN ${sql(pattern.turnIds)}
180
+ AND evidence.reviewed_at IS NULL
181
+ AND conversation.archived_at IS NULL
182
+ `;
183
+ return row?.count === pattern.turnIds.length;
184
+ };
185
+
161
186
  export const markAiWorkflowPatternReviewed = async (pattern: AiMemoryWorkflowPattern): Promise<void> => {
162
187
  await sql`
163
188
  UPDATE ai.memory_workflow_evidence
@@ -3,7 +3,9 @@ import { z } from "zod";
3
3
  import { getCapability } from "../_internal/registry";
4
4
  import { CloudResourceRefSchema, cloudResourceRefAppId, resolveCapabilityResourceReader } from "../contracts/capabilities";
5
5
  import { AuthenticatedPrincipalSchema } from "../contracts/shared";
6
- import { type AuthContext, auth, err, fail, ok, rateLimit, respond, v } from "../server";
6
+ import { type AuthContext, auth, err, fail, getLocale, ok, rateLimit, respond, v } from "../server";
7
+ import { ImageMetadataError } from "../services/image-metadata";
8
+ import { aiChatMessages } from "./chat/messages";
7
9
  import { decodeAiFileContent } from "./files-store";
8
10
  import {
9
11
  AI_PROJECT_DESCRIPTION_MAX_CHARS,
@@ -188,6 +190,8 @@ const buildAiProjectsRoutes = (dependencies: AiProjectsRouteDependencies = {}) =
188
190
  : null;
189
191
  return file ? respond(c, ok({ file: publicFile(file, project!.shortId) }), 201) : notFound(c);
190
192
  } catch (error) {
193
+ if (error instanceof ImageMetadataError)
194
+ return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
191
195
  return respond(c, fail(err.badInput(error instanceof Error ? error.message : "Invalid project file.")));
192
196
  }
193
197
  })
@@ -10,8 +10,10 @@ import {
10
10
  type PermissionLevel,
11
11
  type Principal,
12
12
  } from "../server/services/access";
13
+ import { stripImageMetadata } from "../services/image-metadata";
13
14
  import { toPgUuidArray } from "../services/postgres";
14
15
  import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
16
+ import { isAiImage } from "./file-media-type";
15
17
  import { mountAiProjectFilePath } from "./file-mount";
16
18
  import { pickStoredAiFilePath } from "./files-store";
17
19
  import { withAiShortIdForDb } from "./short-id";
@@ -819,6 +821,8 @@ export const aiProjects = {
819
821
  if (!(await requireProject(projectId, subject, "write"))) return null;
820
822
  if (input.bytes.byteLength > AI_PROJECT_FILE_MAX_BYTES)
821
823
  throw new AiFileWriteError("STORAGE_FULL", "Project file exceeds the size limit; nothing was written.");
824
+ const mimeType = input.mediaType.trim() || "application/octet-stream";
825
+ const bytes = isAiImage(input.path, mimeType) ? stripImageMetadata(input.bytes) : input.bytes;
822
826
  const path = normalizeProjectPath(input.path);
823
827
  return sql.begin(async (tx) => {
824
828
  const [row] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id=${projectId}::uuid FOR UPDATE`;
@@ -836,7 +840,7 @@ export const aiProjects = {
836
840
  "idx_ai_project_files_short_id",
837
841
  (attempt, shortId) => attempt<FileRow[]>`
838
842
  INSERT INTO ai.project_files (short_id, project_id, path, media_type, bytes, size, created_by_user_id)
839
- VALUES (${shortId}, ${projectId}::uuid, ${path}, ${input.mediaType.trim() || "application/octet-stream"}, ${input.bytes}, ${input.bytes.byteLength}, ${actorUserId(subject)}::uuid)
843
+ VALUES (${shortId}, ${projectId}::uuid, ${path}, ${mimeType}, ${bytes}, ${bytes.byteLength}, ${actorUserId(subject)}::uuid)
840
844
  ON CONFLICT (project_id, path) DO UPDATE SET
841
845
  media_type = EXCLUDED.media_type, bytes = EXCLUDED.bytes, size = EXCLUDED.size, updated_at = now()
842
846
  RETURNING id, short_id, project_id, path, media_type, size, updated_at
@@ -45,7 +45,10 @@ export type AiTurnBlock =
45
45
  isError?: boolean;
46
46
  /** Present while status is awaiting_approval. */
47
47
  approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
48
- /** The user approved this call in the chat; the decided approval stays visible as a receipt. */
48
+ /**
49
+ * `true` once the user approved this call in the chat; `false` when its turn ended while the approval still
50
+ * waited. Either way the approval stays visible as a receipt.
51
+ */
49
52
  approved?: boolean;
50
53
  /** Present for frontend tools. */
51
54
  frontendMode?: AiFrontendToolMode;
@@ -179,6 +182,39 @@ export const reconcileResolvedTurnActions = (
179
182
  });
180
183
  };
181
184
 
185
+ /** Decode complete provider names; hashes and ambiguous separators have no exact inverse. */
186
+ const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
187
+ if (name.length === 64 && /__[0-9a-f]{12}$/.test(name)) return name;
188
+ const decode = (value: string): string | undefined => {
189
+ const tokens = /[a-zA-Z0-9-]|__|_dot_|_u([0-9a-f]+)_/gy;
190
+ let decoded = "",
191
+ offset = 0;
192
+ while (offset < value.length) {
193
+ const token = tokens.exec(value);
194
+ if (!token) return undefined;
195
+ if (token[0] === "__") decoded += "_";
196
+ else if (token[0] === "_dot_") decoded += ".";
197
+ else if (token[1]) {
198
+ const codePoint = Number.parseInt(token[1], 16);
199
+ if (codePoint > 0x10ffff || codePoint.toString(16) !== token[1]) return undefined;
200
+ const character = String.fromCodePoint(codePoint);
201
+ if (/^[a-zA-Z0-9_.-]$/.test(character)) return undefined;
202
+ decoded += character;
203
+ } else decoded += token[0];
204
+ offset = tokens.lastIndex;
205
+ }
206
+ return decoded || undefined;
207
+ };
208
+ const candidates: string[] = [];
209
+ for (const match of name.matchAll(/(?=__(query|action)__)/g)) {
210
+ const appId = decode(name.slice(0, match.index));
211
+ const localId = decode(name.slice(match.index + match[1]!.length + 4));
212
+ if (appId && localId && (!presentation || (presentation.appId === appId && presentation.capabilityKind === match[1])))
213
+ candidates.push(`${appId}.${localId}`);
214
+ }
215
+ return candidates.length === 1 ? candidates[0]! : name;
216
+ };
217
+
182
218
  /**
183
219
  * Convert persisted loop messages into the block model. Shared by the executor
184
220
  * (baseline rebuild on claim) and the client (rendering finished turns), so a
@@ -191,7 +227,7 @@ export const buildBlocksFromMessages = (
191
227
  meta?: {
192
228
  steerId?: string;
193
229
  toolPresentations?: Record<string, AiToolPresentation>;
194
- toolOutcomes?: Record<string, "rejected" | "approved">;
230
+ toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
195
231
  } | null;
196
232
  }[],
197
233
  ): AiTurnBlock[] => {
@@ -206,17 +242,19 @@ export const buildBlocksFromMessages = (
206
242
  } else if (block.type === "thinking") {
207
243
  blocks.push({ id: messageBlockId(seq, index), kind: "thinking", text: block.thinking });
208
244
  } else if (block.type === "tool_call") {
245
+ const outcome = meta?.toolOutcomes?.[block.id];
209
246
  toolIndex.set(block.id, blocks.length);
210
247
  blocks.push({
211
248
  id: toolBlockId(block.id),
212
249
  kind: "tool",
213
250
  callId: block.id,
214
- name: block.name,
251
+ name: canonicalToolName(block.name, meta?.toolPresentations?.[block.id]),
215
252
  args: block.args,
216
253
  status: "running",
217
254
  presentation: meta?.toolPresentations?.[block.id],
218
- // A turn that ended before an approved call returned records the approval on the call's message.
219
- ...(meta?.toolOutcomes?.[block.id] === "approved" ? { approved: true } : {}),
255
+ // A turn that ended before an approved call returned records the approval on the call's message, and an
256
+ // approval it left waiting as expired.
257
+ ...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
220
258
  });
221
259
  }
222
260
  });
@@ -1,10 +1,25 @@
1
- import type { Provider } from "@k2b/nessi/ai";
1
+ import type { Provider, ReasoningEffort } from "@k2b/nessi/ai";
2
2
  import { anthropic, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
3
3
  import type { AiModelProfile } from "./types";
4
4
 
5
5
  /** Bound provider stalls while allowing long-context requests up to 60s to begin streaming. */
6
6
  const PROVIDER_TIMEOUTS = { firstByteMs: 60_000, idleMs: 60_000 } as const;
7
7
 
8
+ /**
9
+ * Reasoning for structured tasks and compaction: the requests every provider accepted before Nessi 0.17. A global
10
+ * "none" breaks models that cannot turn reasoning off, so Anthropic, Mistral and Ollama keep their model defaults.
11
+ */
12
+ export const taskReasoningEffort = (provider: Pick<Provider, "family">): ReasoningEffort | undefined => {
13
+ switch (provider.family) {
14
+ case "openai-compatible":
15
+ return "low";
16
+ case "gemini":
17
+ return "none";
18
+ default:
19
+ return undefined;
20
+ }
21
+ };
22
+
8
23
  const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
9
24
  apiKey,
10
25
  baseURL: profile.baseURL,
@@ -170,7 +170,10 @@ export function inferenceProvider(
170
170
  )
171
171
  usage = { input: 0, output: 0 };
172
172
  if (event.type === "usage") {
173
- if (event.finishReason === "aborted" || event.finishReason === "interrupted" || event.finishReason === "error") failed = true;
173
+ if (event.finishReason === "aborted" || event.finishReason === "interrupted" || event.finishReason === "error") {
174
+ failed = true;
175
+ error ??= `The provider finished with ${event.finishReason}.`;
176
+ }
174
177
  const { input, output } = event.usage;
175
178
  // Some adapters synthesize all-zero usage when the endpoint reports none.
176
179
  if (Number.isSafeInteger(input) && Number.isSafeInteger(output) && input >= 0 && output >= 0 && input + output > 0)
package/src/ai/routes.ts CHANGED
@@ -18,13 +18,15 @@ import {
18
18
  v,
19
19
  } from "../server";
20
20
  import { isRequestCredentialCurrent } from "../server/middleware/auth";
21
+ import { ImageMetadataError } from "../services/image-metadata";
21
22
  import { logger } from "../services/logging";
22
23
  import { coreSettings } from "../services/settings/api";
23
24
  import { readThemeFromCookieHeader } from "../shared/theme";
24
25
  import type { AiToolApprovalContext } from "./approvals";
25
- import { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
26
+ import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
26
27
  import { AI_AUDIO_MAX_BYTES } from "./audio-format";
27
28
  import { buildAiCapabilityCatalog } from "./capabilities";
29
+ import { aiChatMessages } from "./chat/messages";
28
30
  import { getAiChatQuotas } from "./chat-quotas";
29
31
  import { createConfiguredDefaultCloudAiTools } from "./default-tools";
30
32
  import { enqueueAiDictation } from "./dictation-runtime";
@@ -58,7 +60,6 @@ import {
58
60
  queuedMessageAccepted,
59
61
  updateQueuedMessage,
60
62
  } from "./message-queue";
61
- import { aiModelAccess } from "./model-access";
62
63
  import { personalAiModelPolicy } from "./personal-agent";
63
64
  import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
64
65
  import { aiProjects } from "./projects";
@@ -84,7 +85,6 @@ import { aiConversations } from "./store";
84
85
  import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
85
86
  import { composeAiSystemPrompt } from "./system-prompt";
86
87
  import { aiToolPromptHints } from "./tools";
87
- import { resolveAiAudioModel } from "./transcription";
88
88
  import type { AiConversation, AiModelPolicy, AiTurn } from "./types";
89
89
 
90
90
  /** Everything a resolved, authorized request needs to run against the shared runtime. */
@@ -420,7 +420,10 @@ export const aiRoutes = (() => {
420
420
  const skillCatalog = selectAiSkillCatalog(availableSkills, previewProfile?.contextWindow ?? 0, "");
421
421
  const tools = toolsSupported
422
422
  ? [
423
- ...(await createConfiguredDefaultCloudAiTools()),
423
+ ...(await createConfiguredDefaultCloudAiTools({
424
+ accessSubject: c.get("accessSubject"),
425
+ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
426
+ })),
424
427
  ...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
425
428
  ...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
426
429
  ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
@@ -526,7 +529,14 @@ export const aiRoutes = (() => {
526
529
  return respond(c, fail(err.internal("The live capability catalog is unavailable.")));
527
530
  }
528
531
  }
529
- const builtInNames = new Set((await createConfiguredDefaultCloudAiTools()).map((tool) => tool.def.name));
532
+ const builtInNames = new Set(
533
+ (
534
+ await createConfiguredDefaultCloudAiTools({
535
+ accessSubject: c.get("accessSubject"),
536
+ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
537
+ })
538
+ ).map((tool) => tool.def.name),
539
+ );
530
540
  const preloadTools = (body.preloadTools ?? []).map((requested) => {
531
541
  if ("name" in requested) return builtInNames.has(requested.name) ? requested.name : null;
532
542
  return (
@@ -1178,11 +1188,7 @@ export const aiRoutes = (() => {
1178
1188
  conversationId: conversation.id,
1179
1189
  userId: ctx.ownerUserId,
1180
1190
  bytes: new Uint8Array(await file.arrayBuffer()),
1181
- resolveModel: async () => {
1182
- const model = await resolveAiAudioModel({ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries });
1183
- await aiModelAccess.assertAllowed(model.profile.id, c.get("accessSubject"));
1184
- return model;
1185
- },
1191
+ resolveModel: () => resolveAssistantAudioModel(c.get("accessSubject"), ctx.modelPolicy.allowedDataBoundaries),
1186
1192
  });
1187
1193
  await enqueueAiDictation(dictation.id).catch(() => undefined); // The DB sweep heals failed delivery.
1188
1194
  return respond(c, ok(dictation));
@@ -1312,6 +1318,8 @@ export const aiRoutes = (() => {
1312
1318
  });
1313
1319
  return respond(c, ok({ file: stat }));
1314
1320
  } catch (error) {
1321
+ if (error instanceof ImageMetadataError)
1322
+ return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
1315
1323
  logger("ai:files").warn("Conversation upload failed", {
1316
1324
  code: "file_upload_failed",
1317
1325
  conversationId: conversation.id,
@@ -1372,6 +1380,8 @@ export const aiRoutes = (() => {
1372
1380
  allowUserOverwrite: existing?.origin === "user",
1373
1381
  });
1374
1382
  } catch (error) {
1383
+ if (error instanceof ImageMetadataError)
1384
+ return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
1375
1385
  return respond(c, fail(err.badInput(error instanceof Error ? error.message : "Failed to write file")));
1376
1386
  }
1377
1387
  return respond(c, ok({ file: await aiFileStore.stat({ conversationId: conversation.id, path }) }));
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { coreSettings } from "../services";
3
+ import { stripImageDataUrlMetadata } from "../services/image-metadata";
3
4
  import { AiModelPricingSchema } from "../shared/ai-costs";
4
5
  import {
5
6
  AI_REQUEST_HEADERS_PROVIDER_ERROR,
@@ -152,6 +153,18 @@ const normalizeProfile = (raw: z.infer<typeof ModelProfileSchema>): AiModelProfi
152
153
  };
153
154
  };
154
155
 
156
+ /** Sanitize every submitted model logo, including unchanged images from stored profiles. */
157
+ export const prepareAiModelProfileImages = (rawJson: string): string => {
158
+ const raw: unknown = JSON.parse(rawJson);
159
+ if (!Array.isArray(raw)) return rawJson;
160
+ return JSON.stringify(
161
+ raw.map((profile: unknown) => {
162
+ if (typeof profile !== "object" || profile === null || !("image" in profile) || typeof profile.image !== "string") return profile;
163
+ return { ...profile, image: stripImageDataUrlMetadata(profile.image) };
164
+ }),
165
+ );
166
+ };
167
+
155
168
  export const parseAiModelProfiles = (rawJson: string): { profiles: AiModelProfile[]; error?: AiSettingsError } => {
156
169
  let raw: unknown;
157
170
  try {
@@ -468,13 +481,18 @@ export const selectAiModelProfile = (
468
481
  const modelId = resolvePolicyModelId(state, policy, requestedModelId);
469
482
  const profile = state.profiles.find((candidate) => candidate.id === modelId);
470
483
  if (!profile || !matchesPolicy(profile, policy)) {
471
- throw Object.assign(new Error(`AI model "${modelId}" is not allowed for this chat.`), {
472
- aiError: {
473
- code: "model_policy_mismatch",
474
- message: `AI model "${modelId}" is not allowed for this chat.`,
475
- fields: { modelProfileId: "Choose an allowed enabled model profile." },
476
- } satisfies AiSettingsError,
477
- });
484
+ throw Object.assign(
485
+ new Error(
486
+ `AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
487
+ ),
488
+ {
489
+ aiError: {
490
+ code: "model_policy_mismatch",
491
+ message: `AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
492
+ fields: { modelProfileId: "Choose an allowed enabled model profile." },
493
+ } satisfies AiSettingsError,
494
+ },
495
+ );
478
496
  }
479
497
  return profile;
480
498
  };
@@ -497,8 +515,19 @@ const visionModelPolicy = (modelId: string, allowedDataBoundaries?: AiDataBounda
497
515
  export const resolveAiVisionModel = async (allowedDataBoundaries?: AiDataBoundary[]): Promise<AiResolvedModel> => {
498
516
  const state = await readAiSettingsState();
499
517
  if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
518
+ if (!state.enabled) {
519
+ throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
520
+ aiError: {
521
+ code: "ai_disabled",
522
+ message: "AI is disabled. An administrator can enable AI in the AI settings.",
523
+ } satisfies AiSettingsError,
524
+ });
525
+ }
500
526
  const modelId = (state.visionModelId ?? "").trim();
501
- if (!modelId) throw new Error("No vision model is configured.");
527
+ if (!modelId)
528
+ throw new Error(
529
+ "Image inspection is not set up: no vision model is configured. An administrator can configure a vision model in the AI settings.",
530
+ );
502
531
  return resolveAiModelFromState(state, visionModelPolicy(modelId, allowedDataBoundaries));
503
532
  };
504
533
 
@@ -520,8 +549,11 @@ export const resolveAiModelFromState = async (
520
549
  ): Promise<AiResolvedModel> => {
521
550
  if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
522
551
  if (!state.enabled) {
523
- throw Object.assign(new Error("AI is disabled."), {
524
- aiError: { code: "ai_disabled", message: "AI is disabled." } satisfies AiSettingsError,
552
+ throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
553
+ aiError: {
554
+ code: "ai_disabled",
555
+ message: "AI is disabled. An administrator can enable AI in the AI settings.",
556
+ } satisfies AiSettingsError,
525
557
  });
526
558
  }
527
559
 
@@ -529,13 +561,18 @@ export const resolveAiModelFromState = async (
529
561
 
530
562
  const credential = providerSupportsCredential(profile.provider) ? await getAiCredential(profile.id) : null;
531
563
  if (providerRequiresCredential(profile.provider) && !credential?.trim()) {
532
- throw Object.assign(new Error(`AI model "${profile.id}" is missing provider credentials.`), {
533
- aiError: {
534
- code: "missing_provider_credential",
535
- message: `AI model "${profile.id}" is missing provider credentials.`,
536
- fields: { "ai.model_profiles_json": "Enter the provider API key on this model profile." },
537
- } satisfies AiSettingsError,
538
- });
564
+ throw Object.assign(
565
+ new Error(
566
+ `AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
567
+ ),
568
+ {
569
+ aiError: {
570
+ code: "missing_provider_credential",
571
+ message: `AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
572
+ fields: { "ai.model_profiles_json": "Enter the provider API key on this model profile." },
573
+ } satisfies AiSettingsError,
574
+ },
575
+ );
539
576
  }
540
577
 
541
578
  const headers = providerSupportsRequestHeaders(profile.provider) ? await getAiRequestHeaders(profile.id) : {};