@k2b/cloud 0.7.0 → 0.8.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 (182) hide show
  1. package/package.json +5 -4
  2. package/scripts/README.md +10 -0
  3. package/scripts/build.ts +6 -1
  4. package/scripts/preload.ts +7 -0
  5. package/scripts/sync-recovery-smoke.ts +21 -6
  6. package/src/_internal/build-metadata.ts +8 -1
  7. package/src/_internal/capabilities.ts +35 -12
  8. package/src/_internal/define-app.ts +28 -11
  9. package/src/_internal/page-responses.ts +1 -1
  10. package/src/_internal/readiness.ts +4 -1
  11. package/src/_internal/runtime-context.ts +1 -1
  12. package/src/access/PermissionEditor.tsx +15 -9
  13. package/src/access/PrincipalPicker.tsx +9 -3
  14. package/src/access/ResourceApiKeys.tsx +1 -1
  15. package/src/access/ui.ts +1 -2
  16. package/src/ai/admin.ts +2 -2
  17. package/src/ai/assistant-models.ts +1 -1
  18. package/src/ai/audio-tool.ts +1 -1
  19. package/src/ai/browser-code-contracts.ts +174 -52
  20. package/src/ai/browser.ts +4 -2
  21. package/src/ai/capabilities.ts +3 -3
  22. package/src/ai/chat/blocks.tsx +127 -50
  23. package/src/ai/chat/builtin-tools.tsx +2 -4
  24. package/src/ai/chat/capability-table.tsx +1 -1
  25. package/src/ai/chat/composer-adapter.ts +48 -13
  26. package/src/ai/chat/message-utils.ts +18 -5
  27. package/src/ai/chat/presentation.tsx +24 -11
  28. package/src/ai/chat/tool-disclosure.tsx +6 -2
  29. package/src/ai/chat/tool-groups.ts +8 -2
  30. package/src/ai/chat-quotas.ts +2 -2
  31. package/src/ai/chat-task-contracts.ts +9 -13
  32. package/src/ai/chat-tasks.ts +39 -18
  33. package/src/ai/client/controller.ts +87 -38
  34. package/src/ai/client/projection.ts +13 -6
  35. package/src/ai/client/transport.ts +4 -2
  36. package/src/ai/code-capability-routes.ts +22 -27
  37. package/src/ai/code-capability-transport.ts +2 -1
  38. package/src/ai/code-execution.ts +53 -0
  39. package/src/ai/code-mode-skill.ts +9 -5
  40. package/src/ai/code-runtime-tools.ts +16 -3
  41. package/src/ai/code-source-contracts.ts +100 -32
  42. package/src/ai/code-source-tools.ts +49 -6
  43. package/src/ai/compaction.ts +3 -3
  44. package/src/ai/default-tools.ts +87 -14
  45. package/src/ai/dictation-runtime.ts +1 -1
  46. package/src/ai/draft-content.ts +27 -12
  47. package/src/ai/enrich.ts +1 -1
  48. package/src/ai/executor.ts +8 -12
  49. package/src/ai/fetch-file-tool.ts +2 -2
  50. package/src/ai/file-content-version.ts +6 -1
  51. package/src/ai/file-tools.ts +8 -2
  52. package/src/ai/files-store.ts +55 -17
  53. package/src/ai/grids-skill.ts +2 -0
  54. package/src/ai/http.ts +18 -7
  55. package/src/ai/index.ts +24 -25
  56. package/src/ai/inference-calls.ts +3 -4
  57. package/src/ai/live.ts +1 -1
  58. package/src/ai/markdown-pdf-tool.ts +2 -2
  59. package/src/ai/memories.ts +30 -27
  60. package/src/ai/memory-learning-runs.ts +1 -1
  61. package/src/ai/memory-learning.ts +5 -5
  62. package/src/ai/memory-tool.ts +1 -5
  63. package/src/ai/memory-workflow-evidence.ts +9 -4
  64. package/src/ai/message-queue.ts +23 -12
  65. package/src/ai/migrate.ts +6 -4
  66. package/src/ai/model-pricing.ts +4 -4
  67. package/src/ai/pdf-render-worker.ts +3 -2
  68. package/src/ai/projects-routes.ts +15 -40
  69. package/src/ai/projects.ts +18 -13
  70. package/src/ai/quota-provider.ts +6 -5
  71. package/src/ai/quota-report.ts +5 -5
  72. package/src/ai/quotas.ts +3 -3
  73. package/src/ai/routes.ts +60 -34
  74. package/src/ai/run-timeout.ts +3 -1
  75. package/src/ai/runtime-tools.ts +6 -7
  76. package/src/ai/runtime.ts +38 -20
  77. package/src/ai/settings.ts +1 -1
  78. package/src/ai/short-id.ts +4 -5
  79. package/src/ai/skill-search.ts +21 -10
  80. package/src/ai/skill-seeds.ts +16 -7
  81. package/src/ai/skill-tool.ts +6 -1
  82. package/src/ai/skills-routes.ts +32 -15
  83. package/src/ai/skills.ts +54 -28
  84. package/src/ai/store.ts +80 -47
  85. package/src/ai/structured.ts +6 -2
  86. package/src/ai/system-prompt.ts +7 -5
  87. package/src/ai/task-contracts.ts +84 -0
  88. package/src/ai/task-execution.ts +92 -0
  89. package/src/ai/todo-contracts.ts +24 -12
  90. package/src/ai/todo-tool.ts +16 -13
  91. package/src/ai/tools.ts +1 -1
  92. package/src/ai/transcription.ts +4 -4
  93. package/src/ai/turn-timing.ts +1 -1
  94. package/src/ai/ui.tsx +2 -1
  95. package/src/ai/usage.ts +1 -1
  96. package/src/ai/vision-tool.ts +38 -17
  97. package/src/api/admin-ai-quotas.ts +14 -8
  98. package/src/api/admin-ai-skills.ts +3 -3
  99. package/src/api/admin-linux-identities.ts +10 -4
  100. package/src/api/admin-rail.ts +2 -2
  101. package/src/api/app-approval.ts +2 -2
  102. package/src/api/auth.ts +1 -1
  103. package/src/api/capabilities.ts +45 -13
  104. package/src/api/index.ts +5 -5
  105. package/src/api/mcp.ts +5 -12
  106. package/src/browser/CloudResourceSearch.tsx +1 -1
  107. package/src/browser/command-bridge.ts +1 -1
  108. package/src/browser/command-shortcuts.ts +1 -1
  109. package/src/browser/commands.ts +8 -7
  110. package/src/browser/testing.ts +2 -1
  111. package/src/capabilities/client.ts +1 -1
  112. package/src/capabilities/command-link.ts +1 -1
  113. package/src/capabilities/server.ts +2 -1
  114. package/src/cli/admin/ai-quotas.ts +5 -5
  115. package/src/cli/admin/app-credentials.ts +7 -9
  116. package/src/cli/admin/index.ts +3 -3
  117. package/src/cli/admin/instance.ts +2 -2
  118. package/src/cli/admin/linux.ts +4 -2
  119. package/src/cli/admin/sync.ts +1 -1
  120. package/src/cli/capabilities.ts +42 -29
  121. package/src/config/define-env.ts +97 -0
  122. package/src/config/env.ts +147 -54
  123. package/src/config/index.ts +3 -1
  124. package/src/contracts/capabilities.ts +8 -1
  125. package/src/contracts/index.ts +5 -7
  126. package/src/contracts/registry.ts +2 -0
  127. package/src/contracts/shared.ts +12 -3
  128. package/src/desktop/index.ts +1 -0
  129. package/src/desktop/solid.tsx +1 -1
  130. package/src/index.ts +8 -2
  131. package/src/server/index.ts +2 -2
  132. package/src/server/ratelimit.ts +1 -1
  133. package/src/server/services/access-revision.ts +3 -3
  134. package/src/server/services/access.ts +3 -1
  135. package/src/services/accounts/notification-sender.ts +12 -2
  136. package/src/services/announcements/index.ts +4 -4
  137. package/src/services/app-approval.ts +11 -10
  138. package/src/services/identity/key-config.ts +6 -4
  139. package/src/services/identity/runtime-config.ts +6 -5
  140. package/src/services/ipa/posix.ts +1 -1
  141. package/src/services/ipa/sync.ts +1 -1
  142. package/src/services/logging/index.ts +1 -1
  143. package/src/services/logging/trace.ts +1 -1
  144. package/src/services/mandates/index.ts +5 -2
  145. package/src/services/mandates/policy.ts +106 -6
  146. package/src/services/notifications/batches.ts +1 -0
  147. package/src/services/pdf/gotenberg.ts +50 -14
  148. package/src/services/pdf/index.ts +11 -11
  149. package/src/services/public-http.ts +1 -1
  150. package/src/services/rail-shortcuts.ts +1 -1
  151. package/src/services/rail-snapshot.ts +1 -1
  152. package/src/services/request-cache-redis.ts +2 -1
  153. package/src/services/session/user.ts +1 -1
  154. package/src/services/settings/app.ts +5 -1
  155. package/src/services/settings/core-settings.ts +6 -8
  156. package/src/services/settings/index.ts +3 -3
  157. package/src/services/settings/store.ts +3 -3
  158. package/src/services/webauthn.ts +1 -1
  159. package/src/shared/index.ts +3 -6
  160. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  161. package/src/ssr/AdminSidebar.tsx +2 -2
  162. package/src/ssr/AppLaunchpad.island.tsx +12 -2
  163. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  164. package/src/ssr/GlobalSearchDialog.tsx +6 -6
  165. package/src/ssr/GlobalSearchTrigger.island.tsx +2 -2
  166. package/src/ssr/Layout.tsx +2 -2
  167. package/src/ssr/LayoutHeader.tsx +1 -1
  168. package/src/ssr/LayoutHelp.tsx +4 -6
  169. package/src/ssr/LayoutHelpTrigger.island.tsx +2 -2
  170. package/src/ssr/MinimalLayout.tsx +1 -1
  171. package/src/ssr/MobileNavigation.tsx +2 -2
  172. package/src/ssr/ProfilePreferences.island.tsx +1 -1
  173. package/src/ssr/admin-navigation.ts +6 -2
  174. package/src/ssr/islands/index.ts +2 -2
  175. package/src/ssr/mobile-menu-history.ts +1 -1
  176. package/src/workflows/ai/index.ts +2 -2
  177. package/src/workflows/ai/runtime.ts +16 -91
  178. package/src/workflows/ai/store.ts +1 -1
  179. package/src/workflows/ai/types.ts +6 -82
  180. package/src/workflows/store/index.ts +2 -2
  181. package/src/server/services/freeipa/test-certificates.ts +0 -44
  182. package/src/services/session/test-fixture.ts +0 -18
@@ -108,7 +108,12 @@ export const CloudAiListFilesOutputSchema = z.object({
108
108
  mediaType: z.string(),
109
109
  origin: z.enum(["user", "assistant", "project", "skill"]),
110
110
  updatedAt: z.string(),
111
- dictationRecordedAt: z.string().optional().describe("Present for prompt dictation recordings, not manually uploaded audio. Original audio remains available for re-evaluation."),
111
+ dictationRecordedAt: z
112
+ .string()
113
+ .optional()
114
+ .describe(
115
+ "Present for prompt dictation recordings, not manually uploaded audio. Original audio remains available for re-evaluation.",
116
+ ),
112
117
  }),
113
118
  ),
114
119
  truncated: z.boolean(),
@@ -342,7 +347,8 @@ export const CloudAiPresentOutputSchema = z.object({ path: z.string(), size: z.n
342
347
  export const createCloudAiPresentTool = () =>
343
348
  defineAiTool({
344
349
  name: "present",
345
- description: "Present a conversation file to the user as an openable and downloadable chat attachment. The returned path is a conversation-local file identifier, not a website URL. Refer to the delivered attachment by title; do not turn its path into a Markdown link.",
350
+ description:
351
+ "Present a conversation file to the user as an openable and downloadable chat attachment. The returned path is a conversation-local file identifier, not a website URL. Refer to the delivered attachment by title; do not turn its path into a Markdown link.",
346
352
  inputSchema: CloudAiPresentInputSchema,
347
353
  outputSchema: CloudAiPresentOutputSchema,
348
354
  approval: "never",
@@ -1,5 +1,5 @@
1
- import { aiFileContentVersion, AiFileWriteError, AiFileVersionConflict } from "./file-content-version";
2
- import { sql, type SQL } from "bun";
1
+ import { type SQL, sql } from "bun";
2
+ import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
3
3
 
4
4
  export { guessAiMediaType } from "./file-media-type";
5
5
 
@@ -142,7 +142,8 @@ export const aiFileStore = {
142
142
  producerCallKey: string;
143
143
  mediaType: string;
144
144
  }): Promise<AiFileStat> {
145
- if (input.bytes.byteLength > AI_FILES_MAX_FILE_BYTES_DEFAULT) throw new AiFileWriteError("STORAGE_FULL", "File exceeds the destination file limit; nothing was written.");
145
+ if (input.bytes.byteLength > AI_FILES_MAX_FILE_BYTES_DEFAULT)
146
+ throw new AiFileWriteError("STORAGE_FULL", "File exceeds the destination file limit; nothing was written.");
146
147
  if (!normalizeAiFilePath(input.path)) {
147
148
  throw new Error("Invalid tool artifact path or file size.");
148
149
  }
@@ -199,7 +200,8 @@ export const aiFileStore = {
199
200
  },
200
201
 
201
202
  async list(input: { conversationId: string; prefix?: string; after?: string; limit?: number }): Promise<AiFileStat[]> {
202
- if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000)) throw new Error("Invalid file page limit");
203
+ if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000))
204
+ throw new Error("Invalid file page limit");
203
205
  const prefix = input.prefix ?? "/";
204
206
  const pattern = `${prefix.endsWith("/") ? prefix : `${prefix}/`}%`;
205
207
  const rows = await sql<FileRow[]>`
@@ -306,16 +308,29 @@ export const aiFileStore = {
306
308
  }
307
309
 
308
310
  return sql.begin(async (tx) => {
309
- const [conversation] = await tx<{ id: string; created_by_user_id: string; archived_at: Date | null }[]>`SELECT id,created_by_user_id,archived_at FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
310
- if (input.ownerUserId !== undefined && (!conversation || conversation.created_by_user_id !== input.ownerUserId || conversation.archived_at)) throw new Error("Conversation access denied");
311
+ const [conversation] = await tx<
312
+ { id: string; created_by_user_id: string; archived_at: Date | null }[]
313
+ >`SELECT id,created_by_user_id,archived_at FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
314
+ if (
315
+ input.ownerUserId !== undefined &&
316
+ (!conversation || conversation.created_by_user_id !== input.ownerUserId || conversation.archived_at)
317
+ )
318
+ throw new Error("Conversation access denied");
311
319
  if (input.expectedVersion !== undefined) {
312
- const [existing] = await tx<FileContentRow[]>`SELECT path,size,media_type,origin,updated_at,version,bytes FROM ai.files WHERE conversation_id=${input.conversationId} AND path=${input.path}`;
313
- const version = existing ? aiFileContentVersion({ ...toContent(existing), id: `${input.conversationId}:${input.path}:${existing.version}` }) : null;
320
+ const [existing] = await tx<
321
+ FileContentRow[]
322
+ >`SELECT path,size,media_type,origin,updated_at,version,bytes FROM ai.files WHERE conversation_id=${input.conversationId} AND path=${input.path}`;
323
+ const version = existing
324
+ ? aiFileContentVersion({ ...toContent(existing), id: `${input.conversationId}:${input.path}:${existing.version}` })
325
+ : null;
314
326
  if (version !== input.expectedVersion) throw new AiFileVersionConflict();
315
327
  }
316
328
  const otherBytes = await aiConversationStoredBytes(tx, input.conversationId, input.path);
317
329
  if (otherBytes + input.bytes.byteLength > maxConversation) {
318
- throw new AiFileWriteError("STORAGE_FULL", `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`);
330
+ throw new AiFileWriteError(
331
+ "STORAGE_FULL",
332
+ `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`,
333
+ );
319
334
  }
320
335
  if (input.origin === "user") {
321
336
  if (input.allowUserOverwrite) {
@@ -347,7 +362,9 @@ export const aiFileStore = {
347
362
  `;
348
363
  if (!written[0]) throw new Error(`Cannot overwrite user-uploaded file ${input.path}.`);
349
364
  }
350
- const [written] = await tx<FileRow[]>`SELECT path,size,media_type,origin,dictation_recorded_at,updated_at,version FROM ai.files WHERE conversation_id=${input.conversationId} AND path=${input.path}`;
365
+ const [written] = await tx<
366
+ FileRow[]
367
+ >`SELECT path,size,media_type,origin,dictation_recorded_at,updated_at,version FROM ai.files WHERE conversation_id=${input.conversationId} AND path=${input.path}`;
351
368
  return toStat(written!);
352
369
  });
353
370
  },
@@ -374,7 +391,10 @@ export const aiFileStore = {
374
391
  }
375
392
  const total = await aiConversationStoredBytes(tx, input.conversationId);
376
393
  if (total + input.bytes.byteLength > maxConversation) {
377
- throw new AiFileWriteError("STORAGE_FULL", `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`);
394
+ throw new AiFileWriteError(
395
+ "STORAGE_FULL",
396
+ `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`,
397
+ );
378
398
  }
379
399
  const appended = await tx<{ id: string }[]>`
380
400
  INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
@@ -461,11 +481,19 @@ export const aiFileStore = {
461
481
  };
462
482
 
463
483
  /** Authorized services may expose this read after resolving the conversation owner. */
464
- export const listAiConversationFiles = (conversationId: string, prefix?: string, page?: { after?: string; limit: number }): Promise<AiFileStat[]> =>
465
- aiFileStore.list({ conversationId, prefix, ...page });
484
+ export const listAiConversationFiles = (
485
+ conversationId: string,
486
+ prefix?: string,
487
+ page?: { after?: string; limit: number },
488
+ ): Promise<AiFileStat[]> => aiFileStore.list({ conversationId, prefix, ...page });
466
489
 
467
490
  /** Authorized byte-preserving read for app-owned artifact importers. */
468
- export async function readAiConversationFile(input: { conversationId: string; ownerUserId: string; path: string; version?: number }): Promise<AiFileContent | null> {
491
+ export async function readAiConversationFile(input: {
492
+ conversationId: string;
493
+ ownerUserId: string;
494
+ path: string;
495
+ version?: number;
496
+ }): Promise<AiFileContent | null> {
469
497
  const { aiConversations } = await import("./store");
470
498
  const conversation = await aiConversations.getConversation({ conversationId: input.conversationId, ownerUserId: input.ownerUserId });
471
499
  if (!conversation || conversation.archivedAt) return null;
@@ -475,17 +503,27 @@ export async function readAiConversationFile(input: { conversationId: string; ow
475
503
 
476
504
  /** Save one agent-produced file without overwriting an existing or user-edited file. */
477
505
  export async function createAiConversationArtifact(input: {
478
- conversationId: string; ownerUserId: string; path: string; bytes: Uint8Array; mediaType: string; producerCallKey: string;
506
+ conversationId: string;
507
+ ownerUserId: string;
508
+ path: string;
509
+ bytes: Uint8Array;
510
+ mediaType: string;
511
+ producerCallKey: string;
479
512
  }): Promise<AiFileStat> {
480
513
  const { aiConversations } = await import("./store");
481
- const conversation = await aiConversations.getConversation({conversationId:input.conversationId,ownerUserId:input.ownerUserId});
514
+ const conversation = await aiConversations.getConversation({ conversationId: input.conversationId, ownerUserId: input.ownerUserId });
482
515
  if (!conversation || conversation.archivedAt) throw new Error("Conversation access denied");
483
516
  return aiFileStore.createToolArtifact(input);
484
517
  }
485
518
 
486
519
  /** Explicit, revision-checked file replacement for authorized transfer services. */
487
520
  export async function writeAiConversationFile(input: {
488
- conversationId: string; ownerUserId: string; path: string; bytes: Uint8Array; mediaType: string; expectedVersion: string | null;
521
+ conversationId: string;
522
+ ownerUserId: string;
523
+ path: string;
524
+ bytes: Uint8Array;
525
+ mediaType: string;
526
+ expectedVersion: string | null;
489
527
  }): Promise<AiFileStat> {
490
528
  if (normalizeAiFilePath(input.path) !== input.path) throw new Error("Invalid file path");
491
529
  const current = await aiFileStore.stat(input);
@@ -153,4 +153,6 @@ A left join preserves unmatched readable source records. A relation with many ta
153
153
 
154
154
  ## Read stored document files
155
155
 
156
+ For download links, use the downloadUrl returned by document.list/read verbatim. It requires the user's current Cloud session and is not a public share. Studio/code tools needing file bytes use document.content.read plus capabilities.streams.read; do not fetch invented paths.
157
+
156
158
  Use \`grids.document.read\` for metadata and available artifact keys. When the task needs actual PDF, XML or CSV bytes, use \`grids.document.content.read\` with the document ID and optional artifactKey; omission selects the primary artifact. In code mode, obtain the stream via capabilities.run in the current run and pass it to capabilities.streams.read to receive a File. Read XML/CSV as text or use an available PDF processor. A binary download alone does not extract PDF text or prove that you inspected its contents. Prefer GQL for structured data analysis. This is a read, not document issuance, a public share or sending a file. Respect the 50 MiB code-mode payload budget and 250 MiB total transfer budget; larger stored artifacts require a supported HTTP/CLI transfer. Streams expire, require current permissions and cannot be reused across turns or in mandate-backed background tasks. Never invent stream references or export capabilities.`;
package/src/ai/http.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { AiQuotaError } from "./quotas";
2
- import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
3
1
  import type { Input, Message } from "@k2b/nessi";
4
2
  import type { Context } from "hono";
5
3
  import { z } from "zod";
6
4
  import { CapabilityAppIdSchema } from "../contracts/capabilities";
7
5
  import { type AuthContext, err, fail, respond } from "../server";
8
6
  import { aiAttachmentMarker } from "./attachments";
7
+ import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
9
8
  import { AI_TURN_ATTACHMENT_MAX_ITEMS } from "./limits";
9
+ import { AiQuotaError } from "./quotas";
10
10
  import { AiResourceMarkerSchema } from "./resource-markers";
11
11
  import { AI_SHORT_ID_PATTERN } from "./short-id";
12
12
  import { AI_SKILL_NAME_MAX_CHARS, AI_SKILL_NAME_PATTERN } from "./skill-format";
@@ -38,7 +38,8 @@ export const AiConversationDraftInputSchema = z
38
38
  content: z.array(AiDraftContentPartSchema).max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1),
39
39
  })
40
40
  .superRefine((value, context) => {
41
- if (value.content.reduce((length, part) => length + (part.type === "text" ? part.text.length : 0), 0) > 20_000) context.addIssue({ code: "custom", path: ["content"], message: "Draft text exceeds 20000 characters" });
41
+ if (value.content.reduce((length, part) => length + (part.type === "text" ? part.text.length : 0), 0) > 20_000)
42
+ context.addIssue({ code: "custom", path: ["content"], message: "Draft text exceeds 20000 characters" });
42
43
  if (value.content.filter((part) => part.type !== "text").length > AI_TURN_ATTACHMENT_MAX_ITEMS) {
43
44
  context.addIssue({ code: "custom", path: ["content"], message: `A draft can attach at most ${AI_TURN_ATTACHMENT_MAX_ITEMS} items` });
44
45
  }
@@ -46,7 +47,9 @@ export const AiConversationDraftInputSchema = z
46
47
 
47
48
  export const AiInitialConversationDraftInputSchema = z
48
49
  .object({
49
- content: z.array(z.union([AiDraftContentPartSchema.options[0], AiDraftContentPartSchema.options[1]])).max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1),
50
+ content: z
51
+ .array(z.union([AiDraftContentPartSchema.options[0], AiDraftContentPartSchema.options[1]]))
52
+ .max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1),
50
53
  })
51
54
  .superRefine((value, context) => {
52
55
  if (value.content.filter((part) => part.type === "resource").length > AI_TURN_ATTACHMENT_MAX_ITEMS) {
@@ -128,7 +131,11 @@ export const AiTurnInputSchema = z
128
131
  .max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1)
129
132
  .optional(),
130
133
  modelProfileId: z.string().trim().min(1).optional(),
131
- clientToolIds: z.array(AiClientToolIdSchema).max(1 + CODE_RUNTIME_TOOL_NAMES.length).refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique").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(),
132
139
  })
133
140
  .refine((input) => Boolean(input.message?.trim() || input.content?.length), {
134
141
  message: "Message or content is required.",
@@ -149,7 +156,11 @@ export const AiSubmitConversationDraftInputSchema = z.object({
149
156
  queueId: z.uuid().optional(),
150
157
  draftRevision: z.number().int().min(1),
151
158
  modelProfileId: z.string().trim().min(1).optional(),
152
- clientToolIds: z.array(AiClientToolIdSchema).max(1 + CODE_RUNTIME_TOOL_NAMES.length).refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique").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(),
153
164
  });
154
165
 
155
166
  export const AiSteerInputSchema = z.object({
@@ -251,7 +262,7 @@ const aiSettingsServiceError = (error: AiSettingsError) => {
251
262
  };
252
263
 
253
264
  export const toAiErrorResponse = (c: Context<AuthContext>, error: unknown) => {
254
- if (error instanceof AiQuotaError) return c.json({ok:false,error:error.message,message:error.message,code:error.code},429);
265
+ if (error instanceof AiQuotaError) return c.json({ ok: false, error: error.message, message: error.message, code: error.code }, 429);
255
266
  if (isAiSettingsError(error)) {
256
267
  return respond(c, fail(aiSettingsServiceError(error.aiError)));
257
268
  }
package/src/ai/index.ts CHANGED
@@ -1,6 +1,3 @@
1
- export { CODE_SOURCE_TOOLS, type CodeSourceToolName } from "./code-source-contracts";
2
- export { CodeResourceId, CODE_RUNTIME_TOOL_NAMES, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
3
- export { assistantAiSettingsState, listAssistantAiModels } from "./assistant-models";
4
1
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
5
2
  export {
6
3
  type AiToolApprovalContext,
@@ -14,14 +11,18 @@ export {
14
11
  rememberAiToolApproval,
15
12
  revokeAiToolApprovalPreference,
16
13
  } from "./approvals";
14
+ export { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
17
15
  export { type AiAttachmentRef, aiAttachmentMarker, formatAiFileSize, parseAiAttachmentMarkers } from "./attachments";
16
+ export { CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema, createCloudAiTranscribeAudioTool } from "./audio-tool";
17
+ export { CODE_RUNTIME_TOOL_NAMES, CodeResourceId, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
18
18
  export { aiCapabilityId, aiCapabilityToolName } from "./capabilities";
19
+ export { getAiChatQuotas } from "./chat-quotas";
19
20
  export {
20
21
  type AiChatTaskOccurrenceView,
21
22
  type AiChatTaskView,
22
23
  AiConversationIdSchema,
23
- ChatTaskIdSchema,
24
24
  ChatTaskGrantsSchema,
25
+ ChatTaskIdSchema,
25
26
  ChatTaskOccurrenceIdSchema,
26
27
  ChatTaskScheduleInputSchema,
27
28
  chatTaskCreateFingerprint,
@@ -40,6 +41,10 @@ export {
40
41
  aiChatTasks,
41
42
  } from "./chat-tasks";
42
43
  export { parseAiSse } from "./client/transport";
44
+ // Framework-owned managed execution host transport.
45
+ export { CODE_CAPABILITY_TOKEN_HEADER, createCodeCapabilityTransport } from "./code-capability-transport";
46
+ export { authorizeCodeExecution } from "./code-execution";
47
+ export { CODE_SOURCE_TOOLS, type CodeSourceToolName } from "./code-source-contracts";
43
48
  export { listAiCredentialProfileIds } from "./credentials";
44
49
  export {
45
50
  CLOUD_AI_TEXT_EDITOR_MAX_CHARS,
@@ -69,16 +74,18 @@ export {
69
74
  shouldApplyEnrichedDescription,
70
75
  shouldApplyEnrichedTitle,
71
76
  } from "./enrich";
77
+ export { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
78
+ export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
72
79
  export {
73
80
  AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
74
81
  AI_FILES_MAX_FILE_BYTES_DEFAULT,
75
82
  type AiFileStat,
83
+ createAiConversationArtifact,
76
84
  guessAiMediaType,
77
85
  listAiConversationFiles,
86
+ normalizeAiFilePath,
78
87
  readAiConversationFile,
79
- createAiConversationArtifact,
80
88
  writeAiConversationFile,
81
- normalizeAiFilePath,
82
89
  } from "./files-store";
83
90
  export {
84
91
  AI_FIRECRAWL_API_KEY_SETTING_KEY,
@@ -208,6 +215,7 @@ export {
208
215
  isNewerWireEvent,
209
216
  } from "./protocol";
210
217
  export { createAiProvider } from "./provider";
218
+ export { AiQuotaError } from "./quotas";
211
219
  export { isConversationResourceCursor } from "./resource-refs";
212
220
  export type { AiRoutes } from "./routes";
213
221
  export {
@@ -243,7 +251,6 @@ export {
243
251
  validateAiSkillName,
244
252
  validateAiSkillReferences,
245
253
  } from "./skill-format";
246
- export { seedCloudAiSkills } from "./skills";
247
254
  export {
248
255
  type AiLoadedSkillSnapshot,
249
256
  type AiSkill,
@@ -257,6 +264,7 @@ export {
257
264
  AiSkillRevisionConflictError,
258
265
  type AiSkillSummary,
259
266
  aiSkills,
267
+ seedCloudAiSkills,
260
268
  } from "./skills";
261
269
  export { type AiSkillsRoutes, aiSkillsRoutes } from "./skills-routes";
262
270
  export { aiConversations } from "./store";
@@ -279,8 +287,17 @@ export {
279
287
  runAiStructured,
280
288
  } from "./structured";
281
289
  export { aiGlobalInstructionsContext, composeAiSystemPrompt, renderAiGlobalInstructions } from "./system-prompt";
290
+ export { type AiTaskRequest, type AiTaskRequestInput, AiTaskRequestSchema } from "./task-contracts";
291
+ export { executeAiTask } from "./task-execution";
282
292
  export { type AiToolApprovalState, type AiToolCallLocation, aiToolAudit } from "./tool-audit";
283
293
  export { defineAiTool, isFrontendToolMode, type PreparedAiTools, prepareAiTools } from "./tools";
294
+ export {
295
+ type AiResolvedAudioModel,
296
+ createAiTranscriptionProvider,
297
+ type RunAiTranscriptionInput,
298
+ resolveAiAudioModel,
299
+ runAiTranscription,
300
+ } from "./transcription";
284
301
  export type {
285
302
  AiAccessResult,
286
303
  AiCapabilityToolPresentation,
@@ -341,21 +358,3 @@ export {
341
358
  type CloudAiViewImageOutput,
342
359
  CloudAiViewImageOutputSchema,
343
360
  } from "./vision-tool";
344
-
345
- export {
346
- createAiTranscriptionProvider,
347
- resolveAiAudioModel,
348
- runAiTranscription,
349
- type AiResolvedAudioModel,
350
- type RunAiTranscriptionInput,
351
- } from "./transcription";
352
-
353
- export { createCloudAiTranscribeAudioTool, CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema } from "./audio-tool";
354
-
355
- export { getAiChatQuotas } from "./chat-quotas";
356
-
357
- export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
358
- export { aiFileContentVersion, AiFileWriteError, AiFileVersionConflict } from "./file-content-version";
359
-
360
- // Framework-owned managed execution host transport.
361
- export { createCodeCapabilityTransport, CODE_CAPABILITY_TOKEN_HEADER } from "./code-capability-transport";
@@ -1,9 +1,8 @@
1
- import { hasBillableAiPricing } from "../shared/ai-costs";
2
- import { sql, type SQL } from "bun";
1
+ import { type SQL, sql } from "bun";
3
2
  import type { AccessSubject } from "../server/services/access";
4
- import { aiCostDecimal, aiCostUnits, aiDecimalUnits, aiBudgetUnits } from "../shared/ai-costs";
3
+ import { aiBudgetUnits, aiCostDecimal, aiCostUnits, aiDecimalUnits, hasBillableAiPricing } from "../shared/ai-costs";
4
+ import { AiQuotaError, aiQuotas, quotaWindow } from "./quotas";
5
5
  import type { AiModelProfile } from "./types";
6
- import { aiQuotas, AiQuotaError, quotaWindow } from "./quotas";
7
6
 
8
7
  export class AiBackgroundCostError extends Error {
9
8
  readonly code = "ai_background_cost_stop";
package/src/ai/live.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { latestAiInvalidationCursor } from "./live-outbox";
2
- export { aiLiveRoutes, type AiLiveRoutes, type AiLiveRoutesConfig } from "./live-routes";
2
+ export { type AiLiveRoutes, type AiLiveRoutesConfig, aiLiveRoutes } from "./live-routes";
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  import {
2
3
  MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES,
3
4
  MARKDOWN_PDF_MAX_MARKDOWN_BYTES,
@@ -5,9 +6,8 @@ import {
5
6
  type RenderMarkdownToPdfInput,
6
7
  renderMarkdownToPdf,
7
8
  } from "../services/pdf";
8
- import { z } from "zod";
9
9
  import { aiProjectFilePathFromMount } from "./file-mount";
10
- import { aiFileStore, normalizeAiFilePath, type AiFileContent } from "./files-store";
10
+ import { type AiFileContent, aiFileStore, normalizeAiFilePath } from "./files-store";
11
11
  import { defineAiTool } from "./tools";
12
12
 
13
13
  export const CloudAiMarkdownToPdfInputSchema = z
@@ -459,9 +459,7 @@ export const aiMemories = {
459
459
  action: "retired" as const,
460
460
  kind: memory.kind,
461
461
  content: memory.content,
462
- ...(memory.resource_type && memory.resource_id
463
- ? { resourceRef: { type: memory.resource_type, id: memory.resource_id } }
464
- : {}),
462
+ ...(memory.resource_type && memory.resource_id ? { resourceRef: { type: memory.resource_type, id: memory.resource_id } } : {}),
465
463
  }));
466
464
  }
467
465
 
@@ -509,19 +507,20 @@ export const aiMemories = {
509
507
  );
510
508
  const created = rows[0];
511
509
  return created
512
- ? [{
513
- action: "added",
514
- kind: created.kind,
515
- content: created.content,
516
- ...(resourceRef ? { resourceRef } : {}),
517
- }]
510
+ ? [
511
+ {
512
+ action: "added",
513
+ kind: created.kind,
514
+ content: created.content,
515
+ ...(resourceRef ? { resourceRef } : {}),
516
+ },
517
+ ]
518
518
  : [];
519
519
  }
520
520
 
521
521
  if (duplicate && !targets.some((memory) => memory.id === duplicate.id)) {
522
522
  const duplicateResourceMatches =
523
- proposal.kind !== "workflow" ||
524
- (duplicate.resource_type === resourceRef?.type && duplicate.resource_id === resourceRef?.id);
523
+ proposal.kind !== "workflow" || (duplicate.resource_type === resourceRef?.type && duplicate.resource_id === resourceRef?.id);
525
524
  if (duplicate.kind !== proposal.kind || !duplicateResourceMatches) return [];
526
525
  await tx`
527
526
  UPDATE ai.memories old
@@ -532,15 +531,17 @@ export const aiMemories = {
532
531
  AND old.source = 'background' AND old.priority = 'normal'
533
532
  AND old.deleted_at IS NULL AND old.superseded_by_id IS NULL
534
533
  `;
535
- return [{
536
- action: "merged",
537
- kind: duplicate.kind,
538
- content: duplicate.content,
539
- previousContent: targets.map((memory) => memory.content).join(" · "),
540
- ...(duplicate.resource_type && duplicate.resource_id
541
- ? { resourceRef: { type: duplicate.resource_type, id: duplicate.resource_id } }
542
- : {}),
543
- }];
534
+ return [
535
+ {
536
+ action: "merged",
537
+ kind: duplicate.kind,
538
+ content: duplicate.content,
539
+ previousContent: targets.map((memory) => memory.content).join(" · "),
540
+ ...(duplicate.resource_type && duplicate.resource_id
541
+ ? { resourceRef: { type: duplicate.resource_type, id: duplicate.resource_id } }
542
+ : {}),
543
+ },
544
+ ];
544
545
  }
545
546
 
546
547
  const destination = targets[0]!;
@@ -565,13 +566,15 @@ export const aiMemories = {
565
566
  AND deleted_at IS NULL AND superseded_by_id IS NULL
566
567
  `;
567
568
  }
568
- return [{
569
- action: proposal.action === "merge" ? "merged" : "updated",
570
- kind: proposal.kind,
571
- content,
572
- previousContent: targets.map((memory) => memory.content).join(" · "),
573
- ...(resourceRef ? { resourceRef } : {}),
574
- }];
569
+ return [
570
+ {
571
+ action: proposal.action === "merge" ? "merged" : "updated",
572
+ kind: proposal.kind,
573
+ content,
574
+ previousContent: targets.map((memory) => memory.content).join(" · "),
575
+ ...(resourceRef ? { resourceRef } : {}),
576
+ },
577
+ ];
575
578
  });
576
579
  },
577
580
 
@@ -1,7 +1,7 @@
1
1
  import { sql } from "bun";
2
2
  import type { CloudResourceRef } from "../contracts/capabilities";
3
- import { withAiShortId } from "./short-id";
4
3
  import type { AiMemoryKind } from "./memories";
4
+ import { withAiShortId } from "./short-id";
5
5
 
6
6
  export type AiMemoryLearningRunStatus = "running" | "ok" | "skipped" | "failed";
7
7
  export type AiMemoryLearningRunKind = "turn" | "workflow";
@@ -1,21 +1,21 @@
1
- import { AiBackgroundAdmissionError, AiBackgroundCostError } from "./inference-calls";
2
- import { sql } from "bun";
3
1
  import { createHash } from "node:crypto";
2
+ import { sql } from "bun";
4
3
  import { z } from "zod";
5
4
  import { CloudResourceRefSchema } from "../contracts/capabilities";
6
5
  import { coreSettings } from "../services";
7
6
  import { logger } from "../services/logging";
8
7
  import { normalizeLocale } from "../shared/locale";
9
8
  import { parseAiAttachmentMarkers } from "./attachments";
9
+ import { AiBackgroundAdmissionError, AiBackgroundCostError } from "./inference-calls";
10
+ import { type AiBackgroundMemoryProposal, aiMemories } from "./memories";
10
11
  import { type AiMemoryLearningChange, aiMemoryLearningRuns } from "./memory-learning-runs";
11
12
  import {
13
+ type AiMemoryWorkflowEvidence,
14
+ type AiMemoryWorkflowPattern,
12
15
  listAiPendingWorkflowPatterns,
13
16
  listAiTurnWorkflowEvidence,
14
17
  markAiWorkflowPatternReviewed,
15
- type AiMemoryWorkflowEvidence,
16
- type AiMemoryWorkflowPattern,
17
18
  } from "./memory-workflow-evidence";
18
- import { type AiBackgroundMemoryProposal, aiMemories } from "./memories";
19
19
  import { aiConversations } from "./store";
20
20
  import type { RunAiStructuredInput, RunAiStructuredResult } from "./structured";
21
21
  import { resolveAiBackgroundModel, runAiStructured } from "./structured";
@@ -108,11 +108,7 @@ export const createCloudAiMemoryTool = (evidence = "") =>
108
108
  }
109
109
 
110
110
  const memory = await aiMemories.getByShortId(userId, input.id);
111
- if (
112
- memory &&
113
- !memoryMutationSupportedByEvidence(evidence, input.id) &&
114
- !memoryMutationSupportedByEvidence(evidence, memory.content)
115
- ) {
111
+ if (memory && !memoryMutationSupportedByEvidence(evidence, input.id) && !memoryMutationSupportedByEvidence(evidence, memory.content)) {
116
112
  return { ok: false, message: "A memory can only be forgotten when the user identifies it in this turn." };
117
113
  }
118
114
  if (!memory || !(await aiMemories.deleteByShortId(userId, input.id))) return { ok: false, message: "Memory not found." };
@@ -37,10 +37,15 @@ export const recordAiMemoryWorkflowEvidence = async (input: {
37
37
  input.resources.flatMap((resource) => {
38
38
  const parsed = CloudResourceRefSchema.safeParse(resource.ref);
39
39
  if (!parsed.success) return [];
40
- return [[`${parsed.data.type}\0${parsed.data.id}`, {
41
- ref: parsed.data,
42
- ...(resource.title ? { title: resource.title.trim().slice(0, MAX_RESOURCE_TITLE_CHARS) } : {}),
43
- }] as const];
40
+ return [
41
+ [
42
+ `${parsed.data.type}\0${parsed.data.id}`,
43
+ {
44
+ ref: parsed.data,
45
+ ...(resource.title ? { title: resource.title.trim().slice(0, MAX_RESOURCE_TITLE_CHARS) } : {}),
46
+ },
47
+ ] as const,
48
+ ];
44
49
  }),
45
50
  ).values(),
46
51
  ].slice(0, MAX_RECEIPT_RESOURCES);
@@ -1,14 +1,14 @@
1
- import { editAiDraftText } from "./draft-content";
2
- import { aiQuotas, AiQuotaError } from "./quotas";
1
+ import { sql } from "bun";
2
+ import { logger } from "../services/logging";
3
3
  import { aiChatAccessSubject, isAssistantChatTurn } from "./assistant-models";
4
- import { aiInputToUserMessage, aiTurnInputToContent } from "./http";
4
+ import { editAiDraftText } from "./draft-content";
5
5
  import { canonicalizeAiConversationAttachments } from "./file-context";
6
+ import { AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT } from "./files-store";
7
+ import { aiInputToUserMessage, aiTurnInputToContent } from "./http";
8
+ import { AiQuotaError, aiQuotas } from "./quotas";
6
9
  import { aiResourceMarker } from "./resource-markers";
7
- import { sql } from "bun";
8
- import type { AiConversationService, AiDraftContentPart } from "./types";
9
10
  import { aiConversations } from "./store";
10
- import { logger } from "../services/logging";
11
- import { AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT } from "./files-store";
11
+ import type { AiConversationService, AiDraftContentPart } from "./types";
12
12
 
13
13
  const log = logger("ai:message-queue");
14
14
  export const AI_MESSAGE_QUEUE_LIMIT = 32;
@@ -112,15 +112,26 @@ export const editQueuedMessage = async (conversationId: string, id: string, text
112
112
  const parts = content.map((part) =>
113
113
  part.type === "text"
114
114
  ? part
115
- : part.type === "project-file" ? { type: "text" as const, text: `Project file: ${part.path}` }
116
- : part.type === "file"
117
- ? { type: "attachment" as const, path: part.path, mediaType: part.mediaType, size: part.size }
118
- : { type: "text" as const, text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }) },
115
+ : part.type === "project-file"
116
+ ? { type: "text" as const, text: `Project file: ${part.path}` }
117
+ : part.type === "file"
118
+ ? { type: "attachment" as const, path: part.path, mediaType: part.mediaType, size: part.size }
119
+ : { type: "text" as const, text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }) },
119
120
  );
120
121
  const { message } = aiInputToUserMessage(aiTurnInputToContent({ content: parts }));
121
122
  const userMessage = canonicalizeAiConversationAttachments(message, row.submission.runConfig.files);
122
123
  if (userMessage.role !== "user") throw new Error("Queued message must be a user message.");
123
- const submission = { ...row.submission, userMessage, runConfig: { ...row.submission.runConfig, input: userMessage.content, selectedSkillIds: [...new Set(content.flatMap(part => part.type === "resource" && part.ref.type === "core.ai.skill" ? [part.ref.id] : []))] } };
124
+ const submission = {
125
+ ...row.submission,
126
+ userMessage,
127
+ runConfig: {
128
+ ...row.submission.runConfig,
129
+ input: userMessage.content,
130
+ selectedSkillIds: [
131
+ ...new Set(content.flatMap((part) => (part.type === "resource" && part.ref.type === "core.ai.skill" ? [part.ref.id] : []))),
132
+ ],
133
+ },
134
+ };
124
135
  await tx`UPDATE ai.queued_messages SET submission = (${JSON.stringify(submission)}::text)::jsonb,
125
136
  content = (${JSON.stringify(content)}::text)::jsonb WHERE id = ${id}::uuid`;
126
137
  return true;
package/src/ai/migrate.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { migrateAiQuotas } from "./quotas-migrate";
2
- import { migrateAiMessageQueue } from "./message-queue";
3
1
  import { sql } from "bun";
2
+ import { migrateAiMessageQueue } from "./message-queue";
4
3
  import { migrateAiModelAccess } from "./model-access-migrate";
4
+ import { migrateAiQuotas } from "./quotas-migrate";
5
5
  import { withAiShortId } from "./short-id";
6
6
 
7
7
  const backfillAiShortIds = async (
@@ -1441,8 +1441,10 @@ export const migrateCloudAi = async (): Promise<void> => {
1441
1441
  SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch') AS installed
1442
1442
  `;
1443
1443
  if (extension?.installed) {
1444
- await sql.unsafe(`CREATE INDEX IF NOT EXISTS skills_search_bm25_idx
1445
- ON ai.skills USING bm25 ((search_text)) WITH (text_config='simple')`).simple();
1444
+ await sql
1445
+ .unsafe(`CREATE INDEX IF NOT EXISTS skills_search_bm25_idx
1446
+ ON ai.skills USING bm25 ((search_text)) WITH (text_config='simple')`)
1447
+ .simple();
1446
1448
  }
1447
1449
  } catch (error) {
1448
1450
  console.warn("Optional Skill BM25 index unavailable; native Skill search remains active", error);