@k2b/cloud 0.27.0 → 0.29.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 (155) hide show
  1. package/package.json +7 -5
  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/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
@@ -1,5 +1,7 @@
1
1
  import { type SQL, sql } from "bun";
2
+ import { stripImageMetadata } from "../services/image-metadata";
2
3
  import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
4
+ import { isAiImage } from "./file-media-type";
3
5
 
4
6
  export { guessAiMediaType } from "./file-media-type";
5
7
 
@@ -79,9 +81,11 @@ export const createUniqueAiFileInTransaction = async (
79
81
  throw new AiFileWriteError("STORAGE_FULL", `File exceeds the per-file limit of ${Math.floor(maxFile / (1024 * 1024))} MB.`);
80
82
  }
81
83
 
84
+ const bytes = isAiImage(input.path, input.mediaType ?? "application/octet-stream") ? stripImageMetadata(input.bytes) : input.bytes;
85
+
82
86
  await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
83
87
  const total = await aiConversationStoredBytes(tx, input.conversationId);
84
- if (total + input.bytes.byteLength > maxConversation) {
88
+ if (total + bytes.byteLength > maxConversation) {
85
89
  throw new AiFileWriteError("STORAGE_FULL", `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`);
86
90
  }
87
91
 
@@ -89,7 +93,7 @@ export const createUniqueAiFileInTransaction = async (
89
93
  const path = numberedAiFilePath(input.path, number);
90
94
  const rows = await tx<FileRow[]>`
91
95
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at)
92
- SELECT ${input.conversationId}, ${path}, ${input.bytes}, ${input.mediaType ?? "application/octet-stream"}, ${input.bytes.byteLength}, ${input.origin}, ${input.dictationRecordedAt ?? null}, now()
96
+ SELECT ${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, ${input.origin}, ${input.dictationRecordedAt ?? null}, now()
93
97
  WHERE NOT EXISTS (
94
98
  SELECT 1 FROM ai.files WHERE conversation_id = ${input.conversationId} AND normalize(path, NFC) = ${path.normalize("NFC")}
95
99
  )
@@ -191,7 +195,8 @@ export const aiFileStore = {
191
195
  producerCallKey: string;
192
196
  mediaType: string;
193
197
  }): Promise<AiFileStat> {
194
- if (input.bytes.byteLength > AI_FILES_MAX_FILE_BYTES_DEFAULT)
198
+ const bytes = isAiImage(input.path, input.mediaType) ? stripImageMetadata(input.bytes) : input.bytes;
199
+ if (bytes.byteLength > AI_FILES_MAX_FILE_BYTES_DEFAULT)
195
200
  throw new AiFileWriteError("STORAGE_FULL", "File exceeds the destination file limit; nothing was written.");
196
201
  if (!normalizeAiFilePath(input.path)) {
197
202
  throw new Error("Invalid tool artifact path or file size.");
@@ -210,18 +215,18 @@ export const aiFileStore = {
210
215
  Number(row.version) === 1 &&
211
216
  row.producer_call_key === input.producerCallKey &&
212
217
  row.media_type === input.mediaType &&
213
- Buffer.from(row.bytes).equals(Buffer.from(input.bytes))
218
+ Buffer.from(row.bytes).equals(Buffer.from(bytes))
214
219
  )
215
220
  return toStat(row);
216
221
  throw new AiFileWriteError("CONFLICT", "Destination file already exists. Choose another path; nothing was written.");
217
222
  }
218
223
  const total = await aiConversationStoredBytes(tx, input.conversationId);
219
- if (total + input.bytes.byteLength > AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT) {
224
+ if (total + bytes.byteLength > AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT) {
220
225
  throw new AiFileWriteError("STORAGE_FULL", "Conversation storage limit exceeded.");
221
226
  }
222
227
  const rows = await tx<FileRow[]>`
223
228
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, producer_call_key)
224
- VALUES (${input.conversationId}, ${path}, ${input.bytes}, ${input.mediaType}, ${input.bytes.byteLength}, 'assistant', ${input.producerCallKey})
229
+ VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType}, ${bytes.byteLength}, 'assistant', ${input.producerCallKey})
225
230
  RETURNING path, size, media_type, origin, dictation_recorded_at, updated_at, version
226
231
  `;
227
232
  return toStat(rows[0]!);
@@ -364,6 +369,8 @@ export const aiFileStore = {
364
369
  throw new AiFileWriteError("STORAGE_FULL", `File exceeds the per-file limit of ${Math.floor(maxFile / (1024 * 1024))} MB.`);
365
370
  }
366
371
 
372
+ const bytes = isAiImage(input.path, input.mediaType ?? "application/octet-stream") ? stripImageMetadata(input.bytes) : input.bytes;
373
+
367
374
  return sql.begin(async (tx) => {
368
375
  const [conversation] = await tx<
369
376
  { id: string; created_by_user_id: string; archived_at: Date | null }[]
@@ -384,7 +391,7 @@ export const aiFileStore = {
384
391
  if (version !== input.expectedVersion) throw new AiFileVersionConflict();
385
392
  }
386
393
  const otherBytes = await aiConversationStoredBytes(tx, input.conversationId, path);
387
- if (otherBytes + input.bytes.byteLength > maxConversation) {
394
+ if (otherBytes + bytes.byteLength > maxConversation) {
388
395
  throw new AiFileWriteError(
389
396
  "STORAGE_FULL",
390
397
  `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`,
@@ -394,7 +401,7 @@ export const aiFileStore = {
394
401
  if (input.allowUserOverwrite) {
395
402
  const written = await tx<{ id: string }[]>`
396
403
  UPDATE ai.files
397
- SET dictation_recorded_at = NULL, bytes = ${input.bytes}, media_type = ${input.mediaType ?? "application/octet-stream"}, size = ${input.bytes.byteLength}, updated_at = now(), version = version + 1
404
+ SET dictation_recorded_at = NULL, bytes = ${bytes}, media_type = ${input.mediaType ?? "application/octet-stream"}, size = ${bytes.byteLength}, updated_at = now(), version = version + 1
398
405
  WHERE conversation_id = ${input.conversationId} AND path = ${path} AND origin = 'user'
399
406
  RETURNING id
400
407
  `;
@@ -402,13 +409,13 @@ export const aiFileStore = {
402
409
  } else {
403
410
  await tx`
404
411
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
405
- VALUES (${input.conversationId}, ${path}, ${input.bytes}, ${input.mediaType ?? "application/octet-stream"}, ${input.bytes.byteLength}, 'user', now())
412
+ VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, 'user', now())
406
413
  `;
407
414
  }
408
415
  } else {
409
416
  const written = await tx<{ id: string }[]>`
410
417
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
411
- VALUES (${input.conversationId}, ${path}, ${input.bytes}, ${input.mediaType ?? "application/octet-stream"}, ${input.bytes.byteLength}, 'assistant', now())
418
+ VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, 'assistant', now())
412
419
  ON CONFLICT (conversation_id, path) DO UPDATE SET
413
420
  bytes = EXCLUDED.bytes,
414
421
  media_type = EXCLUDED.media_type,
package/src/ai/http.ts CHANGED
@@ -122,6 +122,15 @@ export type AiTurnContentPart = z.infer<typeof AiUserContentPartSchema>;
122
122
 
123
123
  export const AiClientToolIdSchema = z.enum(["local_bash", ...CODE_RUNTIME_TOOL_NAMES] satisfies [AiClientToolId, ...AiClientToolId[]]);
124
124
 
125
+ // Names use aiCapabilityToolName's provider grammar and 64-character limit. The 64-item cap matches allowedTools, with headroom
126
+ // for retired and not-yet-known names; 64 names × 64 ASCII characters bounds advertised name text to 4 KiB.
127
+ const AI_CLIENT_TOOL_MAX_ITEMS = 64;
128
+ const AiClientToolIdsSchema = z
129
+ .array(z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/))
130
+ .max(AI_CLIENT_TOOL_MAX_ITEMS)
131
+ .refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
132
+ .transform((ids) => ids.filter((id): id is AiClientToolId => AiClientToolIdSchema.safeParse(id).success));
133
+
125
134
  export const AiTurnInputSchema = z
126
135
  .object({
127
136
  message: z.string().trim().max(20000).optional(),
@@ -131,11 +140,7 @@ export const AiTurnInputSchema = z
131
140
  .max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1)
132
141
  .optional(),
133
142
  modelProfileId: z.string().trim().min(1).optional(),
134
- clientToolIds: z
135
- .array(AiClientToolIdSchema)
136
- .max(1 + CODE_RUNTIME_TOOL_NAMES.length)
137
- .refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
138
- .optional(),
143
+ clientToolIds: AiClientToolIdsSchema.optional(),
139
144
  })
140
145
  .refine((input) => Boolean(input.message?.trim() || input.content?.length), {
141
146
  message: "Message or content is required.",
@@ -156,11 +161,7 @@ export const AiSubmitConversationDraftInputSchema = z.object({
156
161
  queueId: z.uuid().optional(),
157
162
  draftRevision: z.number().int().min(1),
158
163
  modelProfileId: z.string().trim().min(1).optional(),
159
- clientToolIds: z
160
- .array(AiClientToolIdSchema)
161
- .max(1 + CODE_RUNTIME_TOOL_NAMES.length)
162
- .refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
163
- .optional(),
164
+ clientToolIds: AiClientToolIdsSchema.optional(),
164
165
  });
165
166
 
166
167
  export const AiSteerInputSchema = z.object({
package/src/ai/index.ts CHANGED
@@ -76,6 +76,7 @@ export {
76
76
  } from "./enrich";
77
77
  export { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
78
78
  export { aiConversationFileManifest } from "./file-context";
79
+ export { isAiImage } from "./file-media-type";
79
80
  export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
80
81
  export {
81
82
  AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
@@ -345,6 +346,8 @@ export type {
345
346
  AiToolPresentation,
346
347
  AiToolRuntime,
347
348
  AiTurn,
349
+ AiTurnError,
350
+ AiTurnErrorCode,
348
351
  AiTurnFinalizedEvent,
349
352
  AiTurnStatus,
350
353
  AiTurnToolSource,
@@ -412,6 +412,7 @@ export const aiMemories = {
412
412
  * Apply one model proposal as an ownership-checked transaction. Background
413
413
  * learning may only mutate normal background memories; user, agent, and
414
414
  * pinned entries remain authoritative.
415
+ * The source chat must still be active; the row lock makes this atomic with archiving.
415
416
  */
416
417
  async applyBackgroundProposal(input: {
417
418
  userId: string;
@@ -437,6 +438,7 @@ export const aiMemories = {
437
438
  FROM ai.conversations c
438
439
  WHERE c.id = ${input.sourceConversationId}::uuid
439
440
  AND c.created_by_user_id = ${input.userId}::uuid
441
+ AND c.archived_at IS NULL
440
442
  AND (${input.sourceMessageId ?? null}::uuid IS NULL OR EXISTS (
441
443
  SELECT 1 FROM ai.messages m
442
444
  WHERE m.id = ${input.sourceMessageId ?? null}::uuid AND m.conversation_id = c.id
@@ -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
@@ -0,0 +1,87 @@
1
+ import type { Message, Provider } from "@k2b/nessi";
2
+
3
+ /** What the model reads for a call of an earlier turn that never returned. */
4
+ export const AI_OPEN_TOOL_CALL_RESULT =
5
+ "No result: the turn ended before this call returned, so it may or may not have run. Check its effect before you repeat it.";
6
+
7
+ /**
8
+ * nessi answers a call without a directly following result with this text before it calls the provider. It does not
9
+ * say whether the call ran, and it leaves a result stored after another message apart from its call. nessi does not
10
+ * export the text; the tests that run a request through nessi fail when it changes.
11
+ */
12
+ const NESSI_INTERRUPTED_RESULT = "Tool call was interrupted before it returned a result.";
13
+
14
+ const isNessiAnswer = (message: Message): boolean =>
15
+ message.role === "tool_result" && message.isError === true && message.result === NESSI_INTERRUPTED_RESULT;
16
+
17
+ /**
18
+ * Where each call of the model message at `index` has its result: the first result for it before the next user
19
+ * message, or before a later model message with a call of the same ID, since some providers reuse call IDs from turn to
20
+ * turn.
21
+ */
22
+ const resultsOf = (messages: readonly Message[], index: number, ids: ReadonlySet<string>): Map<string, number> => {
23
+ const found = new Map<string, number>();
24
+ for (let next = index + 1; next < messages.length && found.size < ids.size; next++) {
25
+ const later = messages[next]!;
26
+ if (later.role === "user") break;
27
+ if (later.role === "assistant" && later.content.some((block) => block.type === "tool_call" && ids.has(block.id))) break;
28
+ if (later.role === "tool_result" && ids.has(later.callId) && !found.has(later.callId)) found.set(later.callId, next);
29
+ }
30
+ return found;
31
+ };
32
+
33
+ /**
34
+ * Gives every call its result right after the message that made it. A call without one gets an error result; a result
35
+ * stored after another model message, such as a scheduled result delivered while the call ran, moves up to its call.
36
+ */
37
+ const answerOpenCalls = (request: Message[]): Message[] => {
38
+ // nessi's own answers make way for the result stored apart from the call, or for a clearer answer.
39
+ const messages = request.some(isNessiAnswer) ? request.filter((message) => !isNessiAnswer(message)) : request;
40
+ let out: Message[] | null = null;
41
+ const moved = new Set<number>();
42
+ for (let index = 0; index < messages.length; index++) {
43
+ if (moved.has(index)) continue;
44
+ const message = messages[index]!;
45
+ out?.push(message);
46
+ if (message.role !== "assistant") continue;
47
+ const calls = message.content.flatMap((block) => (block.type === "tool_call" ? [block] : []));
48
+ if (calls.length === 0) continue;
49
+ const results = resultsOf(messages, index, new Set(calls.map((call) => call.id)));
50
+ const late = (at: number) => messages.slice(index + 1, at).some((later) => later.role === "assistant");
51
+ const misplaced = calls.filter((call) => {
52
+ const at = results.get(call.id);
53
+ return at === undefined || late(at);
54
+ });
55
+ if (misplaced.length === 0) continue;
56
+ out ??= messages.slice(0, index + 1);
57
+ for (const call of misplaced) {
58
+ const at = results.get(call.id);
59
+ if (at === undefined) {
60
+ out.push({ role: "tool_result", callId: call.id, name: call.name, result: AI_OPEN_TOOL_CALL_RESULT, isError: true });
61
+ } else {
62
+ out.push(messages[at]!);
63
+ moved.add(at);
64
+ }
65
+ }
66
+ }
67
+ return out ?? messages;
68
+ };
69
+
70
+ /**
71
+ * A turn that was stopped or failed while a call ran or waited for approval leaves that call without a result, and a
72
+ * scheduled result delivered while a call ran is stored between the call and its result. Providers reject such a
73
+ * history, so every later turn of the chat would fail, and the model could not tell whether the call ran. The request
74
+ * answers each open call as not returned and puts each result next to its call; the stored history keeps everything
75
+ * as it was, so the chat still shows an open call as not run. A running turn does not see scheduled results that
76
+ * arrived during it, and nessi answers every call before the next model call, so only earlier turns need this. nessi
77
+ * also answers an open call itself, but neither says that it may have run nor moves a result up to its call.
78
+ */
79
+ export const answerOpenToolCalls = (provider: Provider): Provider => ({
80
+ name: provider.name,
81
+ family: provider.family,
82
+ model: provider.model,
83
+ contextWindow: provider.contextWindow,
84
+ capabilities: provider.capabilities,
85
+ complete: (request) => provider.complete({ ...request, messages: answerOpenCalls(request.messages) }),
86
+ stream: (request) => provider.stream({ ...request, messages: answerOpenCalls(request.messages) }),
87
+ });
@@ -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
@@ -179,6 +179,39 @@ export const reconcileResolvedTurnActions = (
179
179
  });
180
180
  };
181
181
 
182
+ /** Decode complete provider names; hashes and ambiguous separators have no exact inverse. */
183
+ const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
184
+ if (name.length === 64 && /__[0-9a-f]{12}$/.test(name)) return name;
185
+ const decode = (value: string): string | undefined => {
186
+ const tokens = /[a-zA-Z0-9-]|__|_dot_|_u([0-9a-f]+)_/gy;
187
+ let decoded = "",
188
+ offset = 0;
189
+ while (offset < value.length) {
190
+ const token = tokens.exec(value);
191
+ if (!token) return undefined;
192
+ if (token[0] === "__") decoded += "_";
193
+ else if (token[0] === "_dot_") decoded += ".";
194
+ else if (token[1]) {
195
+ const codePoint = Number.parseInt(token[1], 16);
196
+ if (codePoint > 0x10ffff || codePoint.toString(16) !== token[1]) return undefined;
197
+ const character = String.fromCodePoint(codePoint);
198
+ if (/^[a-zA-Z0-9_.-]$/.test(character)) return undefined;
199
+ decoded += character;
200
+ } else decoded += token[0];
201
+ offset = tokens.lastIndex;
202
+ }
203
+ return decoded || undefined;
204
+ };
205
+ const candidates: string[] = [];
206
+ for (const match of name.matchAll(/(?=__(query|action)__)/g)) {
207
+ const appId = decode(name.slice(0, match.index));
208
+ const localId = decode(name.slice(match.index + match[1]!.length + 4));
209
+ if (appId && localId && (!presentation || (presentation.appId === appId && presentation.capabilityKind === match[1])))
210
+ candidates.push(`${appId}.${localId}`);
211
+ }
212
+ return candidates.length === 1 ? candidates[0]! : name;
213
+ };
214
+
182
215
  /**
183
216
  * Convert persisted loop messages into the block model. Shared by the executor
184
217
  * (baseline rebuild on claim) and the client (rendering finished turns), so a
@@ -211,7 +244,7 @@ export const buildBlocksFromMessages = (
211
244
  id: toolBlockId(block.id),
212
245
  kind: "tool",
213
246
  callId: block.id,
214
- name: block.name,
247
+ name: canonicalToolName(block.name, meta?.toolPresentations?.[block.id]),
215
248
  args: block.args,
216
249
  status: "running",
217
250
  presentation: meta?.toolPresentations?.[block.id],
@@ -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)