@k2b/cloud 0.7.0 → 0.9.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 (190) hide show
  1. package/package.json +5 -4
  2. package/scripts/README.md +10 -0
  3. package/scripts/build.ts +14 -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/schemas.ts +7 -1
  103. package/src/api/auth.ts +4 -3
  104. package/src/api/capabilities.ts +45 -13
  105. package/src/api/index.ts +5 -5
  106. package/src/api/mcp.ts +5 -12
  107. package/src/browser/CloudResourceSearch.tsx +1 -1
  108. package/src/browser/command-bridge.ts +1 -1
  109. package/src/browser/command-shortcuts.ts +1 -1
  110. package/src/browser/commands.ts +8 -7
  111. package/src/browser/testing.ts +2 -1
  112. package/src/capabilities/client.ts +1 -1
  113. package/src/capabilities/command-link.ts +1 -1
  114. package/src/capabilities/server.ts +2 -1
  115. package/src/cli/admin/ai-quotas.ts +5 -5
  116. package/src/cli/admin/app-credentials.ts +7 -9
  117. package/src/cli/admin/index.ts +3 -3
  118. package/src/cli/admin/instance.ts +2 -2
  119. package/src/cli/admin/linux.ts +4 -2
  120. package/src/cli/admin/sync.ts +1 -1
  121. package/src/cli/capabilities.ts +42 -29
  122. package/src/config/define-env.ts +97 -0
  123. package/src/config/env.ts +147 -54
  124. package/src/config/index.ts +3 -1
  125. package/src/contracts/capabilities.ts +8 -1
  126. package/src/contracts/index.ts +5 -7
  127. package/src/contracts/registry.ts +2 -0
  128. package/src/contracts/shared.ts +12 -3
  129. package/src/desktop/index.ts +1 -0
  130. package/src/desktop/solid.tsx +1 -1
  131. package/src/index.ts +8 -2
  132. package/src/server/app-assets.ts +27 -0
  133. package/src/server/index.ts +3 -2
  134. package/src/server/ratelimit.ts +1 -1
  135. package/src/server/services/access-revision.ts +3 -3
  136. package/src/server/services/access.ts +3 -1
  137. package/src/services/accounts/notification-sender.ts +12 -2
  138. package/src/services/announcements/index.ts +4 -4
  139. package/src/services/app-approval.ts +11 -10
  140. package/src/services/auth-flows/magic-link.ts +13 -1
  141. package/src/services/identity/key-config.ts +6 -4
  142. package/src/services/identity/runtime-config.ts +6 -5
  143. package/src/services/ipa/posix.ts +1 -1
  144. package/src/services/ipa/sync.ts +1 -1
  145. package/src/services/logging/index.ts +1 -1
  146. package/src/services/logging/trace.ts +1 -1
  147. package/src/services/mandates/index.ts +5 -2
  148. package/src/services/mandates/policy.ts +106 -6
  149. package/src/services/notifications/batches.ts +1 -0
  150. package/src/services/pdf/gotenberg.ts +50 -14
  151. package/src/services/pdf/index.ts +11 -11
  152. package/src/services/public-http.ts +1 -1
  153. package/src/services/rail-shortcuts.ts +1 -1
  154. package/src/services/rail-snapshot.ts +1 -1
  155. package/src/services/request-cache-redis.ts +2 -1
  156. package/src/services/session/user.ts +1 -1
  157. package/src/services/settings/app.ts +5 -1
  158. package/src/services/settings/core-settings.ts +6 -8
  159. package/src/services/settings/index.ts +3 -3
  160. package/src/services/settings/store.ts +3 -3
  161. package/src/services/webauthn.ts +1 -1
  162. package/src/shared/index.ts +3 -6
  163. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  164. package/src/ssr/AdminSidebar.tsx +2 -2
  165. package/src/ssr/AppLaunchpad.island.tsx +12 -2
  166. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  167. package/src/ssr/GlobalSearchDialog.tsx +6 -6
  168. package/src/ssr/GlobalSearchTrigger.island.tsx +2 -2
  169. package/src/ssr/Layout.tsx +4 -3
  170. package/src/ssr/LayoutHeader.tsx +1 -1
  171. package/src/ssr/LayoutHelp.tsx +4 -6
  172. package/src/ssr/LayoutHelpTrigger.island.tsx +2 -2
  173. package/src/ssr/LayoutRail.tsx +3 -2
  174. package/src/ssr/MinimalLayout.tsx +1 -1
  175. package/src/ssr/MobileNavigation.tsx +2 -2
  176. package/src/ssr/ProfilePreferences.island.tsx +1 -1
  177. package/src/ssr/RailApps.island.tsx +7 -7
  178. package/src/ssr/admin-navigation.ts +6 -2
  179. package/src/ssr/index.ts +1 -0
  180. package/src/ssr/islands/index.ts +2 -2
  181. package/src/ssr/mobile-menu-history.ts +1 -1
  182. package/src/ssr/rail-navigation.ts +3 -2
  183. package/src/ssr/request-path.ts +12 -0
  184. package/src/workflows/ai/index.ts +2 -2
  185. package/src/workflows/ai/runtime.ts +16 -91
  186. package/src/workflows/ai/store.ts +1 -1
  187. package/src/workflows/ai/types.ts +6 -82
  188. package/src/workflows/store/index.ts +2 -2
  189. package/src/server/services/freeipa/test-certificates.ts +0 -44
  190. package/src/services/session/test-fixture.ts +0 -18
@@ -108,10 +108,10 @@ export const aiComposerAttachmentRecords = (attachments: readonly ChatAttachment
108
108
 
109
109
  export const aiComposerSendInput = (input: ChatSubmitInput): AiComposerSendInput => {
110
110
  const attachments = aiComposerAttachmentRecords(input.attachments);
111
- if (input.mentions?.length || attachments.some(attachment => attachment.kind === "project-file")) {
111
+ if (input.mentions?.length || attachments.some((attachment) => attachment.kind === "project-file")) {
112
112
  const draftContent: AiDraftContentPart[] = [];
113
113
  let offset = 0;
114
- for (const mention of [...(input.mentions ?? [])].sort((a,b) => a.start - b.start)) {
114
+ for (const mention of [...(input.mentions ?? [])].sort((a, b) => a.start - b.start)) {
115
115
  if (mention.start < offset || mention.end > input.text.length || mention.start >= mention.end) continue;
116
116
  const attachment = aiComposerAttachmentRecords([mention.attachment])[0];
117
117
  if (!attachment || attachment.kind === "file" || attachment.kind === "image") continue;
@@ -123,7 +123,7 @@ export const aiComposerSendInput = (input: ChatSubmitInput): AiComposerSendInput
123
123
  for (const attachment of attachments) {
124
124
  if (attachment.kind !== "file" && attachment.kind !== "image") draftContent.push(composerAttachmentPart(attachment));
125
125
  }
126
- const files = attachments.flatMap(attachment => attachment.kind === "file" || attachment.kind === "image" ? [attachment.file] : []);
126
+ const files = attachments.flatMap((attachment) => (attachment.kind === "file" || attachment.kind === "image" ? [attachment.file] : []));
127
127
  return { draftContent, files: files.length ? files : undefined };
128
128
  }
129
129
  const files = attachments.filter(
@@ -187,24 +187,59 @@ export const readAiComposerFiles = async (
187
187
  };
188
188
  };
189
189
 
190
- const composerAttachmentPart = (attachment: Exclude<AiComposerAttachment, {kind: "image" | "file"}>, inline = false): AiDraftContentPart => {
191
- if (attachment.kind === "resource") return { type: "resource", ref: attachment.ref, title: attachment.name, icon: attachment.icon, href: attachment.href, inline };
190
+ const composerAttachmentPart = (
191
+ attachment: Exclude<AiComposerAttachment, { kind: "image" | "file" }>,
192
+ inline = false,
193
+ ): AiDraftContentPart => {
194
+ if (attachment.kind === "resource")
195
+ return { type: "resource", ref: attachment.ref, title: attachment.name, icon: attachment.icon, href: attachment.href, inline };
192
196
  if (attachment.kind === "project-file") return { type: "project-file", path: attachment.path, inline };
193
- return { type: "file", path: attachment.path, version: attachment.version, mediaType: attachment.mediaType, size: attachment.size, inline };
197
+ return {
198
+ type: "file",
199
+ path: attachment.path,
200
+ version: attachment.version,
201
+ mediaType: attachment.mediaType,
202
+ size: attachment.size,
203
+ inline,
204
+ };
194
205
  };
195
206
 
196
207
  /** Hydrate the same ordered content used by the server draft and queued messages. */
197
- export function aiComposerDraft(content: readonly AiDraftContentPart[]): { text: string; mentions: ChatMention[]; attachments: AiComposerAttachment[] } {
208
+ export function aiComposerDraft(content: readonly AiDraftContentPart[]): {
209
+ text: string;
210
+ mentions: ChatMention[];
211
+ attachments: AiComposerAttachment[];
212
+ } {
198
213
  let text = "";
199
214
  const mentions: ChatMention[] = [];
200
215
  const attachments: AiComposerAttachment[] = [];
201
216
  for (const part of content) {
202
- if (part.type === "text") { text += part.text; continue; }
203
- const attachment: AiComposerAttachment = part.type === "resource"
204
- ? { kind: "resource", id: `resource:${part.ref.type}:${part.ref.id}`, ref: part.ref, name: part.title ?? part.ref.id, icon: part.icon ?? "ti ti-link", href: part.href }
205
- : part.type === "project-file"
206
- ? { kind: "project-file", id: part.path, path: part.path, name: part.path, icon: "ti ti-file" }
207
- : { kind: "stored-file", id: `file:${part.path}:${part.version}`, path: part.path, name: part.path.split("/").at(-1) || part.path, version: part.version, mediaType: part.mediaType, size: part.size, icon: "ti ti-file" };
217
+ if (part.type === "text") {
218
+ text += part.text;
219
+ continue;
220
+ }
221
+ const attachment: AiComposerAttachment =
222
+ part.type === "resource"
223
+ ? {
224
+ kind: "resource",
225
+ id: `resource:${part.ref.type}:${part.ref.id}`,
226
+ ref: part.ref,
227
+ name: part.title ?? part.ref.id,
228
+ icon: part.icon ?? "ti ti-link",
229
+ href: part.href,
230
+ }
231
+ : part.type === "project-file"
232
+ ? { kind: "project-file", id: part.path, path: part.path, name: part.path, icon: "ti ti-file" }
233
+ : {
234
+ kind: "stored-file",
235
+ id: `file:${part.path}:${part.version}`,
236
+ path: part.path,
237
+ name: part.path.split("/").at(-1) || part.path,
238
+ version: part.version,
239
+ mediaType: part.mediaType,
240
+ size: part.size,
241
+ icon: "ti ti-file",
242
+ };
208
243
  if (part.inline) {
209
244
  mentions.push({ start: text.length, end: text.length + attachment.name.length, attachment: aiChatAttachments([attachment])[0]! });
210
245
  text += attachment.name;
@@ -1,8 +1,8 @@
1
- import { AI_AUDIO_EXTENSIONS } from "../audio-format";
2
1
  import type { Message, Usage } from "@k2b/nessi";
3
2
  import { fileIcons } from "@k2b/stdlib";
4
3
  import { formatBytes as sharedFormatBytes } from "../../shared/format";
5
4
  import { type AiAttachmentRef, parseAiAttachmentMarkers } from "../attachments";
5
+ import { AI_AUDIO_EXTENSIONS } from "../audio-format";
6
6
  import { AI_IMAGE_INPUT_MAX_BYTES, AI_TURN_ATTACHMENT_MAX_ITEMS } from "../limits";
7
7
  import { type AiResourceMarker, parseAiResourceMarker } from "../resource-markers";
8
8
  import { assistantVisibleTextFromMessage } from "../timeline";
@@ -237,10 +237,23 @@ export const latestUsageSnapshot = (messages: AiStoredMessage[]): AiLatestUsageS
237
237
  if (request) {
238
238
  return {
239
239
  request,
240
- loop: entry?.loopAggregate?.usage ?? (entry?.loopId ? messages.filter(message => message.loopId === entry.loopId && message.message.role === "assistant").reduce<Usage>((sum, message) => ({
241
- input: sum.input + (message.usage?.input ?? 0), output: sum.output + (message.usage?.output ?? 0), total: sum.total + (message.usage?.total ?? 0),
242
- ...((sum.cacheRead !== undefined || message.usage?.cacheRead !== undefined) ? { cacheRead: (sum.cacheRead ?? 0) + (message.usage?.cacheRead ?? 0) } : {}),
243
- }), { input: 0, output: 0, total: 0 }) : request),
240
+ loop:
241
+ entry?.loopAggregate?.usage ??
242
+ (entry?.loopId
243
+ ? messages
244
+ .filter((message) => message.loopId === entry.loopId && message.message.role === "assistant")
245
+ .reduce<Usage>(
246
+ (sum, message) => ({
247
+ input: sum.input + (message.usage?.input ?? 0),
248
+ output: sum.output + (message.usage?.output ?? 0),
249
+ total: sum.total + (message.usage?.total ?? 0),
250
+ ...(sum.cacheRead !== undefined || message.usage?.cacheRead !== undefined
251
+ ? { cacheRead: (sum.cacheRead ?? 0) + (message.usage?.cacheRead ?? 0) }
252
+ : {}),
253
+ }),
254
+ { input: 0, output: 0, total: 0 },
255
+ )
256
+ : request),
244
257
  modelProfileId: entry?.modelProfileId ?? null,
245
258
  };
246
259
  }
@@ -1,15 +1,14 @@
1
1
  import { Button, Chat, type ChatTimelineItem, useLocale } from "@k2b/ui";
2
2
  import { type Accessor, createEffect, createMemo, createSignal, type JSX, onCleanup, Show } from "solid-js";
3
3
  import type { AiActiveTurn } from "../client/projection";
4
- import { type AiActiveTurnSegment, isRenderableTurnBlock, splitActiveTurnBlocks } from "../protocol";
4
+ import { type AiActiveTurnSegment, splitActiveTurnBlocks } from "../protocol";
5
5
  import { type AiAssistantTimelineItem, buildAiMessageTimeline, copyTextFromAssistantEntries } from "../timeline";
6
6
  import type { AiConversationTimelineEntry, AiStoredMessage } from "../types";
7
7
  import { AiTurnBlockList } from "./blocks";
8
8
  import { type AiChatActions, AiChatActionsProvider, createAssistantMessageActions, useAiChatActions } from "./message-actions";
9
9
  import { formatWorkedDuration, isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
10
- import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
11
10
  import { aiChatMessages } from "./messages";
12
- import { CloudSurveyResultBlock } from "./visual-tools";
11
+ import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
13
12
  import { TurnNavigator } from "./turn-navigator";
14
13
  import { activeTimelineSeq } from "./turn-navigator-utils";
15
14
  import {
@@ -19,6 +18,7 @@ import {
19
18
  createAiSteerMessageActions,
20
19
  createAiUserMessageActions,
21
20
  } from "./user-message";
21
+ import { CloudSurveyResultBlock } from "./visual-tools";
22
22
 
23
23
  export type AiChatTimelineSession = {
24
24
  messages: readonly AiStoredMessage[];
@@ -70,10 +70,15 @@ export function AiAssistantContent(props: {
70
70
  }): JSX.Element {
71
71
  const locale = useLocale();
72
72
  const turnId = () => props.item.loopId ?? props.item.id;
73
- return <div class="flex flex-col gap-2">
74
- <Chat.Activity icon="ti ti-route" label={`${locale().startsWith("de") ? "Gearbeitet für" : "Worked for"} ${formatWorkedDuration(props.item.workedMs)}`} />
75
- <AiTurnBlockList blocks={props.item.blocks} turnId={turnId()} disclosureState={props.disclosureState} />
76
- </div>;
73
+ return (
74
+ <div class="flex flex-col gap-2">
75
+ <Chat.Activity
76
+ icon="ti ti-route"
77
+ label={`${locale().startsWith("de") ? "Gearbeitet für" : "Worked for"} ${formatWorkedDuration(props.item.workedMs)}`}
78
+ />
79
+ <AiTurnBlockList blocks={props.item.blocks} turnId={turnId()} disclosureState={props.disclosureState} />
80
+ </div>
81
+ );
77
82
  }
78
83
 
79
84
  const storedItems = (
@@ -171,10 +176,18 @@ const storedItems = (
171
176
  class: segment.blocks.some(isWideBlock) ? "ai-chat-message-wide" : undefined,
172
177
  content: (
173
178
  <>
174
- <Show when={index === 0 && item.entries.find(entry => entry.meta?.scheduledTask)?.meta?.scheduledTask}>
175
- {task => <Button variant="ghost" size="xs" class="mb-2" onClick={() => actions.onOpenScheduledTaskRun?.(task().taskId, task().occurrenceId)} disabled={!actions.onOpenScheduledTaskRun}>
176
- <i class="ti ti-calendar-time" aria-hidden="true" /> {aiChatMessages(locale).backgroundRun}
177
- </Button>}
179
+ <Show when={index === 0 && item.entries.find((entry) => entry.meta?.scheduledTask)?.meta?.scheduledTask}>
180
+ {(task) => (
181
+ <Button
182
+ variant="ghost"
183
+ size="xs"
184
+ class="mb-2"
185
+ onClick={() => actions.onOpenScheduledTaskRun?.(task().taskId, task().occurrenceId)}
186
+ disabled={!actions.onOpenScheduledTaskRun}
187
+ >
188
+ <i class="ti ti-calendar-time" aria-hidden="true" /> {aiChatMessages(locale).backgroundRun}
189
+ </Button>
190
+ )}
178
191
  </Show>
179
192
  <AiAssistantContent
180
193
  item={{ ...item, blocks: segment.blocks }}
@@ -15,7 +15,9 @@ export const createAiToolDisclosureState = (): AiToolDisclosureState => {
15
15
  try {
16
16
  const saved = window.sessionStorage.getItem(storageKey(blockId));
17
17
  if (saved === "open") openByBlockId.set(blockId, true);
18
- } catch { /* SSR and disabled storage keep the in-memory state. */ }
18
+ } catch {
19
+ /* SSR and disabled storage keep the in-memory state. */
20
+ }
19
21
  }
20
22
  return openByBlockId.get(blockId);
21
23
  },
@@ -24,7 +26,9 @@ export const createAiToolDisclosureState = (): AiToolDisclosureState => {
24
26
  try {
25
27
  if (open) window.sessionStorage.setItem(storageKey(blockId), "open");
26
28
  else window.sessionStorage.removeItem(storageKey(blockId));
27
- } catch { /* Storage limits must not prevent expanding a tool. */ }
29
+ } catch {
30
+ /* Storage limits must not prevent expanding a tool. */
31
+ }
28
32
  },
29
33
  };
30
34
  };
@@ -1,5 +1,5 @@
1
- import { hasCapabilityTable } from "./capability-result";
2
1
  import type { AiTurnBlock } from "../protocol";
2
+ import { hasCapabilityTable } from "./capability-result";
3
3
  import { isCardToolName, isSurveyToolName, isTextEditorToolName } from "./message-utils";
4
4
 
5
5
  type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -8,7 +8,13 @@ type Group = { kind: "tools"; blocks: Tool[] } | { kind: "block"; block: AiTurnB
8
8
  export function isFailedTool(tool: Tool): boolean {
9
9
  if (tool.isError || tool.status === "failed" || tool.status === "rejected") return true;
10
10
  const result = tool.result;
11
- return ["code_run","code_inspect","code_interact"].includes(tool.name) && result !== null && typeof result === "object" && "status" in result && result.status === "error";
11
+ return (
12
+ ["code_run", "code_inspect", "code_interact"].includes(tool.name) &&
13
+ result !== null &&
14
+ typeof result === "object" &&
15
+ "status" in result &&
16
+ result.status === "error"
17
+ );
12
18
  }
13
19
 
14
20
  /** Visible content and interactive decisions are boundaries; model request boundaries are not. */
@@ -1,9 +1,9 @@
1
- import { hasBillableAiPricing } from "../shared/ai-costs";
2
- import { readAiSettingsState } from "./settings";
3
1
  import type { AccessSubject } from "../server/services/access";
2
+ import { hasBillableAiPricing } from "../shared/ai-costs";
4
3
  import type { AiChatQuotaSnapshot } from "../shared/ai-quotas";
5
4
  import { listAssistantAiModels } from "./assistant-models";
6
5
  import { aiQuotas } from "./quotas";
6
+ import { readAiSettingsState } from "./settings";
7
7
 
8
8
  /** Own allowances only. Grant identities and unavailable model profiles stay private. */
9
9
  export async function getAiChatQuotas(subject: AccessSubject): Promise<AiChatQuotaSnapshot> {
@@ -1,17 +1,13 @@
1
- import { CapabilityGrantsSchema, type CapabilityGrant } from "../services/mandates";
2
- export { CapabilityGrantsSchema as ChatTaskGrantsSchema };
1
+ import { type TaskGrant, TaskGrantsSchema } from "../services/mandates";
2
+
3
+ export { TaskGrantsSchema as ChatTaskGrantsSchema };
4
+
3
5
  import { dates } from "@k2b/stdlib";
4
- import {
5
- type AiChatTask,
6
- type AiChatTaskOccurrence,
7
- type AiChatTaskOccurrenceState,
8
- type AiChatTaskSchedule,
9
- type AiChatTaskState,
10
- } from "./chat-tasks";
11
- import { AI_SHORT_ID_PATTERN } from "./short-id";
6
+ import { z } from "zod";
12
7
  import { coreSettings } from "../services";
13
8
  import { normalizeWorkflowSchedule } from "../workflows/runtime";
14
- import { z } from "zod";
9
+ import type { AiChatTask, AiChatTaskOccurrence, AiChatTaskOccurrenceState, AiChatTaskSchedule, AiChatTaskState } from "./chat-tasks";
10
+ import { AI_SHORT_ID_PATTERN } from "./short-id";
15
11
 
16
12
  export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character scheduled task ID.");
17
13
  export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character task occurrence ID.");
@@ -22,7 +18,7 @@ export type AiChatTaskView = {
22
18
  id: string;
23
19
  chatId: string;
24
20
  chatTitle: string;
25
- grants: CapabilityGrant[];
21
+ grants: TaskGrant[];
26
22
  prompt: string;
27
23
  schedule: AiChatTaskSchedule;
28
24
  timezone: string;
@@ -99,7 +95,7 @@ export const chatTaskCreateFingerprint = (input: {
99
95
  prompt: string;
100
96
  schedule: z.infer<typeof ChatTaskScheduleInputSchema>;
101
97
  timezone?: string;
102
- grants?: CapabilityGrant[];
98
+ grants?: TaskGrant[];
103
99
  }): string =>
104
100
  new Bun.CryptoHasher("sha256")
105
101
  .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone ?? null, input.grants ?? []]))
@@ -1,23 +1,23 @@
1
- import { aiConversations } from "./store";
2
1
  import type { Message } from "@k2b/nessi";
3
2
  import { type SQL, sql } from "bun";
3
+ import { getCapability } from "../_internal/registry";
4
4
  import { logger } from "../services/logging";
5
5
  import {
6
6
  createMandate,
7
- CapabilityGrantsSchema,
8
- type CapabilityGrant,
9
- updateMandatePolicy,
10
- validateMandateIssueAuthority,
11
7
  type Mandate,
12
8
  type MandatePolicyV1,
13
9
  MandatePolicyV1Schema,
14
10
  pauseMandate,
15
11
  resumeMandate,
16
12
  revokeMandate,
13
+ type TaskGrant,
14
+ TaskGrantsSchema,
15
+ updateMandatePolicy,
16
+ validateMandateIssueAuthority,
17
17
  } from "../services/mandates";
18
- import { getCapability } from "../_internal/registry";
19
18
  import { parsePgJsonValue } from "../services/postgres";
20
19
  import { withAiShortIdForDb } from "./short-id";
20
+ import { aiConversations } from "./store";
21
21
  import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
22
22
 
23
23
  export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
@@ -31,7 +31,7 @@ export type AiChatTask = {
31
31
  sponsorUserId: string;
32
32
  mandateId: string | null;
33
33
  mandateRevision: number | null;
34
- grants: CapabilityGrant[];
34
+ grants: TaskGrant[];
35
35
  prompt: string;
36
36
  schedule: AiChatTaskSchedule;
37
37
  timezone: string;
@@ -110,24 +110,27 @@ type OccurrenceRow = {
110
110
  const iso = (value: Date | string): string => new Date(value).toISOString();
111
111
  const nullableIso = (value: Date | string | null): string | null => (value === null ? null : iso(value));
112
112
  const taskIdempotencyKey = (key: string): string => `task.create:${key}`;
113
- const chatTaskMandatePolicy = (grants: CapabilityGrant[]): MandatePolicyV1 => ({
113
+ const chatTaskMandatePolicy = (grants: TaskGrant[]): MandatePolicyV1 => ({
114
114
  version: 1,
115
- apps: [...new Set(grants.map((grant) => grant.appId))],
115
+ apps: [...new Set(grants.map((grant) => ("appId" in grant ? grant.appId : "assistant")))],
116
116
  operations: [
117
117
  ...new Set(
118
118
  grants.flatMap((grant) =>
119
- grant.kind === "query"
120
- ? [`capability.query:${grant.capabilityId}`]
121
- : [`capability.action.review:${grant.capabilityId}`, `capability.action.run:${grant.capabilityId}`],
119
+ !("appId" in grant)
120
+ ? [`runtime.${grant.kind}`]
121
+ : grant.kind === "query"
122
+ ? [`capability.query:${grant.capabilityId}`]
123
+ : [`capability.action.review:${grant.capabilityId}`, `capability.action.run:${grant.capabilityId}`],
122
124
  ),
123
125
  ),
124
126
  ],
125
127
  actions: grants.length ? "preapproved" : "deny",
126
128
  grants,
127
129
  });
128
- const validateTaskGrants = async (input: unknown): Promise<CapabilityGrant[]> => {
129
- const grants = CapabilityGrantsSchema.parse(input);
130
+ const validateTaskGrants = async (input: unknown): Promise<TaskGrant[]> => {
131
+ const grants = TaskGrantsSchema.parse(input);
130
132
  for (const grant of grants) {
133
+ if (!("appId" in grant)) continue;
131
134
  const entry = await getCapability(grant.appId);
132
135
  const operation = (grant.kind === "query" ? entry?.manifest.queries : entry?.manifest.actions)?.find(
133
136
  (item) => item.localId === grant.capabilityId,
@@ -290,7 +293,7 @@ const taskFingerprint = (input: {
290
293
  prompt: string;
291
294
  schedule: AiChatTaskSchedule;
292
295
  timezone: string;
293
- grants?: CapabilityGrant[];
296
+ grants?: TaskGrant[];
294
297
  }): string =>
295
298
  new Bun.CryptoHasher("sha256")
296
299
  .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone, input.grants ?? []]))
@@ -301,7 +304,7 @@ const taskSelect = () => sql`
301
304
  JOIN ai.conversations conversation ON conversation.id = task.conversation_id
302
305
  `;
303
306
 
304
- const taskGrants = (row: TaskRow): CapabilityGrant[] => {
307
+ const taskGrants = (row: TaskRow): TaskGrant[] => {
305
308
  try {
306
309
  const policy = MandatePolicyV1Schema.safeParse(parsePgJsonValue(row.mandate_policy));
307
310
  return policy.success ? (policy.data.grants ?? []) : [];
@@ -459,6 +462,24 @@ export const aiChatTasks = {
459
462
  },
460
463
 
461
464
  validateGrants: validateTaskGrants,
465
+ authorizeRuntime: async (input: {
466
+ mandate: { id: string; revision: number };
467
+ kind: "http" | "database";
468
+ input: unknown;
469
+ }): Promise<void> => {
470
+ const result = await validateMandateIssueAuthority({
471
+ mandateId: input.mandate.id,
472
+ expectedRevision: input.mandate.revision,
473
+ ownerAppId: "core",
474
+ targetAppId: "assistant",
475
+ operation: `runtime.${input.kind}`,
476
+ input: input.input,
477
+ });
478
+ if (!result.ok)
479
+ throw new AiChatTaskAuthorityError(
480
+ `Background ${input.kind} access denied: ${result.error.message}. Update this task's grants in the normal chat.`,
481
+ );
482
+ },
462
483
  authorizeCapability: async (input: {
463
484
  mandate: { id: string; revision: number };
464
485
  appId: string;
@@ -516,7 +537,7 @@ export const aiChatTasks = {
516
537
  prompt: string;
517
538
  schedule: AiChatTaskSchedule;
518
539
  timezone: string;
519
- grants?: CapabilityGrant[];
540
+ grants?: TaskGrant[];
520
541
  idempotencyKey?: string;
521
542
  idempotencyFingerprint?: string;
522
543
  }): Promise<AiChatTask | null> => {
@@ -620,7 +641,7 @@ export const aiChatTasks = {
620
641
  userId: string;
621
642
  taskId: string;
622
643
  prompt?: string;
623
- grants?: CapabilityGrant[];
644
+ grants?: TaskGrant[];
624
645
  schedule?: AiChatTaskSchedule;
625
646
  timezone?: string;
626
647
  }): Promise<AiChatTask | null> =>
@@ -152,11 +152,12 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
152
152
  const [state, setState] = createStore<AiChatProjection>(initialProjection);
153
153
  const completedDuringSend = new Map<string, string>();
154
154
  const [pendingSends, setPendingSends] = createSignal<Record<string, AiStoredMessage | undefined>>({});
155
- const clearPendingSend = (conversationId: string) => setPendingSends(current => {
156
- const next = { ...current };
157
- delete next[conversationId];
158
- return next;
159
- });
155
+ const clearPendingSend = (conversationId: string) =>
156
+ setPendingSends((current) => {
157
+ const next = { ...current };
158
+ delete next[conversationId];
159
+ return next;
160
+ });
160
161
 
161
162
  // Cache of projections for conversations opened this session (fast switching).
162
163
  const cache = new Map<string, AiChatProjection>();
@@ -258,7 +259,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
258
259
  setProjection(detailToProjection(detail), session.conversationId);
259
260
  setHasMore(session.conversationId, detail.hasMoreMessages ?? false);
260
261
  if (detail.timeline) setTimeline(session.conversationId, detail.timeline);
261
- if (event.type === "turn_finished" && pendingSends()[session.conversationId]?.loopId === event.turnId) clearPendingSend(session.conversationId);
262
+ if (event.type === "turn_finished" && pendingSends()[session.conversationId]?.loopId === event.turnId)
263
+ clearPendingSend(session.conversationId);
262
264
  } else reduceEvent(session.conversationId, event);
263
265
  setRunStatusRaw(null);
264
266
  void markConversationViewed(session.conversationId);
@@ -727,31 +729,51 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
727
729
  return queueDraftOperation(async () => persistComposerDraft(input, await targetConversation));
728
730
  };
729
731
 
730
- const queuedAttempts = new Map<string, {key:string;files?:File[];id:string;saved:{revision:number}}>();
732
+ const queuedAttempts = new Map<string, { key: string; files?: File[]; id: string; saved: { revision: number } }>();
731
733
  const queueMessage = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
732
734
  if (!input.conversationId || !isComposerDraftSendable(input)) return Promise.resolve(false);
733
735
  const conversationId = input.conversationId;
734
- const key = JSON.stringify({message:input.message,content:input.content,draftContent:input.draftContent,resources:input.resources,storedFiles:input.storedFiles,model:input.modelProfileId});
736
+ const key = JSON.stringify({
737
+ message: input.message,
738
+ content: input.content,
739
+ draftContent: input.draftContent,
740
+ resources: input.resources,
741
+ storedFiles: input.storedFiles,
742
+ model: input.modelProfileId,
743
+ });
735
744
  return queueDraftOperation(async () => {
736
745
  try {
737
746
  const previous = queuedAttempts.get(conversationId);
738
- const same = previous?.key === key && (previous.files ?? []).length === (input.files ?? []).length && (previous.files ?? []).every((file,index)=>file === input.files?.[index]);
747
+ const same =
748
+ previous?.key === key &&
749
+ (previous.files ?? []).length === (input.files ?? []).length &&
750
+ (previous.files ?? []).every((file, index) => file === input.files?.[index]);
739
751
  const saved = same ? previous.saved : await persistComposerDraft(input, conversationId);
740
752
  if (!saved) return false;
741
753
  const queueId = same ? previous.id : crypto.randomUUID();
742
- queuedAttempts.set(conversationId,{key,files:input.files,id:queueId,saved});
743
- await request(`/conversations/${conversationId}/turns`, {method:"POST", body:JSON.stringify({
744
- draftRevision:saved.revision, queueId, modelProfileId:input.modelProfileId,
745
- clientToolIds:options.clientToolIds?.filter(id => options.frontendTools?.[id]),
746
- })}, "Failed to queue message");
754
+ queuedAttempts.set(conversationId, { key, files: input.files, id: queueId, saved });
755
+ await request(
756
+ `/conversations/${conversationId}/turns`,
757
+ {
758
+ method: "POST",
759
+ body: JSON.stringify({
760
+ draftRevision: saved.revision,
761
+ queueId,
762
+ modelProfileId: input.modelProfileId,
763
+ clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
764
+ }),
765
+ },
766
+ "Failed to queue message",
767
+ );
747
768
  if (isActiveConversation(conversationId)) {
748
- if ((state.conversation?.draft.revision ?? 0) <= saved.revision) setState("conversation", "draft", {content:[],revision:saved.revision+1,updatedAt:new Date().toISOString()});
769
+ if ((state.conversation?.draft.revision ?? 0) <= saved.revision)
770
+ setState("conversation", "draft", { content: [], revision: saved.revision + 1, updatedAt: new Date().toISOString() });
749
771
  } else invalidateInactiveCache(conversationId);
750
772
  queuedAttempts.delete(conversationId);
751
773
  if (isActiveConversation(conversationId)) setGlobalError(null);
752
774
  return true;
753
775
  } catch (error) {
754
- setConversationError(conversationId,error instanceof Error ? error.message : "Failed to queue message");
776
+ setConversationError(conversationId, error instanceof Error ? error.message : "Failed to queue message");
755
777
  return false;
756
778
  }
757
779
  });
@@ -770,9 +792,12 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
770
792
  const optimisticContent: AiUserContentPart[] = [
771
793
  ...(input.content ?? []),
772
794
  ...(!input.content?.length && input.message ? [{ type: "text" as const, text: input.message }] : []),
773
- ...(input.resources ?? []).map(ref => ({ type: "text" as const, text: aiResourceMarker(ref) })),
774
- ...(input.storedFiles ?? []).map(file => ({ type: "text" as const, text: aiAttachmentMarker(file) })),
775
- ...(input.files ?? []).map(file => ({ type: "text" as const, text: aiAttachmentMarker({ path: `/${file.name}`, mediaType: file.type, size: file.size }) })),
795
+ ...(input.resources ?? []).map((ref) => ({ type: "text" as const, text: aiResourceMarker(ref) })),
796
+ ...(input.storedFiles ?? []).map((file) => ({ type: "text" as const, text: aiAttachmentMarker(file) })),
797
+ ...(input.files ?? []).map((file) => ({
798
+ type: "text" as const,
799
+ text: aiAttachmentMarker({ path: `/${file.name}`, mediaType: file.type, size: file.size }),
800
+ })),
776
801
  ];
777
802
 
778
803
  // Optimistic: show the user message immediately.
@@ -794,7 +819,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
794
819
  meta: null,
795
820
  createdAt: new Date().toISOString(),
796
821
  };
797
- setPendingSends(current => ({ ...current, [conversationId]: optimistic }));
822
+ setPendingSends((current) => ({ ...current, [conversationId]: optimistic }));
798
823
  if (isActiveConversation(conversationId)) {
799
824
  setRunStatusRaw("streaming");
800
825
  }
@@ -806,13 +831,27 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
806
831
  if (isActiveConversation(conversationId)) setRunStatusRaw("failed");
807
832
  return false;
808
833
  }
809
- setPendingSends(current => ({ ...current, [conversationId]: {
810
- ...optimistic,
811
- meta: { submittedDraftRevision: savedDraft.revision },
812
- message: { role: "user", content: savedDraft.content.map(part => ({
813
- type: "text", text: part.type === "text" ? part.text : part.type === "file" ? aiAttachmentMarker(part) : part.type === "project-file" ? `Project file: ${part.path}` : aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
814
- })) },
815
- } }));
834
+ setPendingSends((current) => ({
835
+ ...current,
836
+ [conversationId]: {
837
+ ...optimistic,
838
+ meta: { submittedDraftRevision: savedDraft.revision },
839
+ message: {
840
+ role: "user",
841
+ content: savedDraft.content.map((part) => ({
842
+ type: "text",
843
+ text:
844
+ part.type === "text"
845
+ ? part.text
846
+ : part.type === "file"
847
+ ? aiAttachmentMarker(part)
848
+ : part.type === "project-file"
849
+ ? `Project file: ${part.path}`
850
+ : aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
851
+ })),
852
+ },
853
+ },
854
+ }));
816
855
  const result = await request<SubmitTurnResult>(
817
856
  `/conversations/${conversationId}/turns`,
818
857
  {
@@ -828,21 +867,24 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
828
867
  const alreadyFinished = completedDuringSend.get(conversationId) === result.turn.id;
829
868
  completedDuringSend.delete(conversationId);
830
869
  if (alreadyFinished) clearPendingSend(conversationId);
831
- else setPendingSends(current => ({ ...current, [conversationId]: result.message }));
870
+ else setPendingSends((current) => ({ ...current, [conversationId]: result.message }));
832
871
  if (invalidateInactiveCache(conversationId)) return true;
833
872
  // Replace the optimistic message with the persisted one.
834
- setState("messages", (prev) => [...prev.filter(message => message.id !== result.message.id), result.message]);
873
+ setState("messages", (prev) => [...prev.filter((message) => message.id !== result.message.id), result.message]);
835
874
  setState(
836
875
  "activeTurn",
837
876
  (current) =>
838
- current ?? (alreadyFinished ? null : {
839
- turnId: result.turn.id,
840
- attempt: 0,
841
- seq: 0,
842
- status: "running",
843
- blocks: [],
844
- modelProfileId: result.turn.modelProfileId,
845
- }),
877
+ current ??
878
+ (alreadyFinished
879
+ ? null
880
+ : {
881
+ turnId: result.turn.id,
882
+ attempt: 0,
883
+ seq: 0,
884
+ status: "running",
885
+ blocks: [],
886
+ modelProfileId: result.turn.modelProfileId,
887
+ }),
846
888
  );
847
889
  if (state.conversation) {
848
890
  setState("conversation", "draft", {
@@ -1271,7 +1313,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1271
1313
  export type AiChatController = ReturnType<typeof createAiChatController>;
1272
1314
 
1273
1315
  const isComposerDraftSendable = (input: ComposerDraftInput): boolean =>
1274
- Boolean(input.draftContent?.length || input.message?.trim() || input.content?.length || input.files?.length || input.resources?.length || input.storedFiles?.length);
1316
+ Boolean(
1317
+ input.draftContent?.length ||
1318
+ input.message?.trim() ||
1319
+ input.content?.length ||
1320
+ input.files?.length ||
1321
+ input.resources?.length ||
1322
+ input.storedFiles?.length,
1323
+ );
1275
1324
 
1276
1325
  export const __aiControllerTest = {
1277
1326
  claimFrontendCall,