@k2b/cloud 0.28.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 (101) 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/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +97 -17
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +12 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +16 -0
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-names.ts +157 -0
  19. package/src/ai/chat/turn-layout.ts +12 -0
  20. package/src/ai/chat/turn-view.tsx +15 -7
  21. package/src/ai/chat-tasks.ts +16 -3
  22. package/src/ai/code-mode-skill.ts +4 -4
  23. package/src/ai/compaction.ts +22 -3
  24. package/src/ai/default-tools.ts +9 -2
  25. package/src/ai/executor.ts +127 -40
  26. package/src/ai/file-media-type.ts +4 -0
  27. package/src/ai/files-store.ts +17 -10
  28. package/src/ai/http.ts +11 -10
  29. package/src/ai/index.ts +1 -0
  30. package/src/ai/memories.ts +2 -0
  31. package/src/ai/memory-learning.ts +59 -5
  32. package/src/ai/memory-workflow-evidence.ts +35 -10
  33. package/src/ai/projects-routes.ts +5 -1
  34. package/src/ai/projects.ts +5 -1
  35. package/src/ai/protocol.ts +34 -1
  36. package/src/ai/provider.ts +16 -1
  37. package/src/ai/quota-provider.ts +4 -1
  38. package/src/ai/routes.ts +20 -10
  39. package/src/ai/settings.ts +54 -17
  40. package/src/ai/skill-seeds.ts +11 -3
  41. package/src/ai/store.ts +56 -20
  42. package/src/ai/structured.ts +2 -1
  43. package/src/ai/system-prompt.ts +5 -1
  44. package/src/ai/transcription.ts +58 -13
  45. package/src/ai/turn-failure.ts +5 -1
  46. package/src/ai/turn-guidance.ts +505 -0
  47. package/src/ai/types.ts +1 -0
  48. package/src/ai/ui.tsx +1 -0
  49. package/src/api/admin-core-settings.ts +16 -1
  50. package/src/api/me.ts +42 -1
  51. package/src/browser/FileChooser.tsx +71 -295
  52. package/src/browser/FileSaver.tsx +407 -0
  53. package/src/browser/app-badges.ts +16 -0
  54. package/src/browser/choose-files.browser-harness.tsx +22 -6
  55. package/src/browser/choose-files.tsx +23 -36
  56. package/src/browser/file-chooser-messages.ts +64 -0
  57. package/src/browser/file-providers.ts +258 -8
  58. package/src/browser/files.tsx +26 -3
  59. package/src/browser/notifications.ts +37 -1
  60. package/src/browser/provider-folder.tsx +301 -0
  61. package/src/browser/provider-list.ts +39 -0
  62. package/src/browser/save-files.browser-harness.tsx +35 -0
  63. package/src/browser/save-files.tsx +107 -0
  64. package/src/contracts/app.ts +10 -0
  65. package/src/contracts/commands.ts +3 -1
  66. package/src/contracts/notification-group.ts +6 -0
  67. package/src/contracts/notification-types.ts +6 -0
  68. package/src/contracts/registry.ts +1 -0
  69. package/src/contracts/user-notifications.ts +74 -0
  70. package/src/server/middleware/request-logger.ts +11 -2
  71. package/src/services/accounts/avatar.ts +5 -2
  72. package/src/services/image-metadata.ts +438 -0
  73. package/src/services/index.ts +1 -0
  74. package/src/services/logging/redaction.ts +50 -0
  75. package/src/services/mandates/index.ts +4 -2
  76. package/src/services/mandates/policy.ts +20 -0
  77. package/src/services/notifications/browser.ts +48 -15
  78. package/src/services/notifications/dispatcher.ts +31 -1
  79. package/src/services/notifications/platform.ts +32 -4
  80. package/src/services/notifications/preview.ts +23 -0
  81. package/src/services/notifications/quiet.ts +153 -0
  82. package/src/services/notifications/user.ts +6 -0
  83. package/src/services/pdf/template-preview.ts +2 -1
  84. package/src/services/settings/app.ts +3 -0
  85. package/src/services/settings/store.ts +4 -1
  86. package/src/services/weather/geo.ts +6 -2
  87. package/src/shared/ai-platform-prompt.ts +2 -2
  88. package/src/shared/redirect.ts +2 -1
  89. package/src/shared/template-rendering.ts +120 -7
  90. package/src/ssr/AppBadge.tsx +30 -0
  91. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  92. package/src/ssr/IslandError.tsx +33 -0
  93. package/src/ssr/Layout.tsx +3 -0
  94. package/src/ssr/LayoutHelp.tsx +4 -2
  95. package/src/ssr/RailApps.island.tsx +9 -2
  96. package/src/ssr/app-badges.ts +93 -0
  97. package/src/ssr/rail-messages.ts +4 -0
  98. package/src/ssr/rail-navigation.ts +2 -0
  99. package/src/styles/effects.css +23 -0
  100. package/src/styles/file-chooser.css +25 -0
  101. package/src/styles/utilities-navigation.css +41 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "./server/*": "./src/server/*",
31
31
  "./browser": "./src/server/api-client.ts",
32
32
  "./browser/app-approval": "./src/browser/app-approval.ts",
33
+ "./browser/app-badges": "./src/browser/app-badges.ts",
33
34
  "./browser/live": "./src/browser/live.ts",
34
35
  "./browser/mermaid": "./src/browser/mermaid.ts",
35
36
  "./browser/reload": "./src/browser/reload.ts",
@@ -52,6 +53,7 @@
52
53
  "./cli/admin": "./src/cli/admin/index.ts",
53
54
  "./shared": "./src/shared/index.ts",
54
55
  "./services": "./src/services/index.ts",
56
+ "./services/image-metadata": "./src/services/image-metadata.ts",
55
57
  "./services/document-extraction": "./src/services/document-extraction.ts",
56
58
  "./services/ipa/service-account": null,
57
59
  "./services/*": "./src/services/*",
@@ -98,10 +100,10 @@
98
100
  "@simplewebauthn/server": "14.0.3",
99
101
  "@tabler/icons": "3.48.0",
100
102
  "@tailwindcss/typography": "0.5.20",
101
- "@k2b/nessi": "0.15.0",
102
- "@k2b/ssr": "0.14.0",
103
- "@k2b/ui": "0.18.0",
104
- "@k2b/stdlib": "0.27.0",
103
+ "@k2b/nessi": "0.17.0",
104
+ "@k2b/ssr": "0.15.0",
105
+ "@k2b/ui": "0.19.0",
106
+ "@k2b/stdlib": "0.28.0",
105
107
  "@k2b/sync": "7.0.0",
106
108
  "@nats-io/jetstream": "3.4.0",
107
109
  "@nats-io/transport-node": "3.4.0",
@@ -94,6 +94,24 @@ export const buildCliPlugin = async (params: {
94
94
  minify: true,
95
95
  // Playwright references this optional BiDi adapter lazily; Cloud uses CDP.
96
96
  external: ["chromium-bidi/*"],
97
+ plugins: [
98
+ {
99
+ name: "playwright-metadata",
100
+ setup(build) {
101
+ build.onLoad({ filter: /[/\\]playwright-core[/\\]lib[/\\](?:coreBundle|serverRegistry|package)\.js$/ }, async ({ path }) => ({
102
+ // Computed package-relative JSON requires evade Bun's dependency
103
+ // graph and retain the container's absolute path. Static requires
104
+ // carry the version and browser registry in the single-file plugin.
105
+ contents: (await readFile(path, "utf8")).replace(
106
+ /require\(import_path\d*\.default\.join\(packageRoot, "(package|browsers)\.json"\)\)/g,
107
+ (_, file: string) => `require("../${file}.json")`,
108
+ ),
109
+ loader: "js",
110
+ resolveDir: dirname(path),
111
+ }));
112
+ },
113
+ },
114
+ ],
97
115
  });
98
116
  if (!result.success) {
99
117
  throw new Error(`CLI module "${name}" failed to bundle:\n${result.logs.map((log) => String(log)).join("\n")}`);
@@ -86,6 +86,7 @@ import { configurePostgresApplicationName } from "./postgres-application-name";
86
86
  import { getProcessSync, startProcessSync } from "./process-sync";
87
87
  import { APP_READINESS_PATH, appReadinessResponse } from "./readiness";
88
88
  import { appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
89
+ import { canonicalRoute, isOwnNavBadge } from "./registry-validation";
89
90
  import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
90
91
  import { servePublicAsset } from "./static-assets";
91
92
  import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
@@ -144,6 +145,13 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
144
145
  section: "primary" | "more" | "hidden";
145
146
  requiresAuth?: boolean;
146
147
  requiresRoles?: Role[];
148
+ /**
149
+ * Same-origin path of a `GET` route this app serves below one of its
150
+ * `routes`. After the page loads, the shell reads it with the person's
151
+ * session and shows the returned `AppNavBadge` count on the app's icon in
152
+ * the rail and the app grid. Protect it like any other route.
153
+ */
154
+ badge?: string;
147
155
  };
148
156
  /**
149
157
  * Settings owned by this app, declared as a map of dotted-key → definition.
@@ -332,11 +340,6 @@ export type AppDefinition<S extends AppSettingsMap = {}, N extends NotificationD
332
340
  // ── Implementation ──────────────────────────────────────────────────────────
333
341
 
334
342
  const isPwaPath = (route: string): boolean => route === "/pwa" || route.startsWith("/pwa/");
335
- /** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
336
- const canonicalRoute = (route: string): string => {
337
- const trimmed = route.trim();
338
- return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
339
- };
340
343
 
341
344
  /**
342
345
  * `/pwa` belongs to the mobile app shell, `/pwa/_auth` to Core, and `/pwa/<id>`
@@ -357,6 +360,14 @@ const validateAppPwaPart = (opts: Pick<AppOptions, "id" | "routes" | "pwa">): Ap
357
360
  return { href: `/pwa/${opts.id}`, requiresRoles: opts.pwa.requiresRoles ? [...opts.pwa.requiresRoles] : undefined };
358
361
  };
359
362
 
363
+ /** The badge route is read by the browser through the gateway, so it must be one of this app's own paths. */
364
+ const validateAppNavBadge = (opts: Pick<AppOptions, "id" | "routes" | "nav">): void => {
365
+ const badge = opts.nav?.badge;
366
+ if (badge !== undefined && !isOwnNavBadge(badge, opts.routes)) {
367
+ throw new Error(`App "${opts.id}" declares nav.badge "${badge}"; it must be a same-origin path below one of its routes`);
368
+ }
369
+ };
370
+
360
371
  export const defineApp = <
361
372
  const S extends AppSettingsMap = {},
362
373
  const N extends NotificationDefinitionMap = {},
@@ -369,6 +380,7 @@ export const defineApp = <
369
380
  const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
370
381
  const cliModules = validateAppCliModules(opts.id, opts.cli);
371
382
  const pwaPart = validateAppPwaPart(opts);
383
+ validateAppNavBadge(opts);
372
384
 
373
385
  // ── 0. Register declared settings into the runtime registry ──────────
374
386
  // SETTINGS_MAP is the single source of truth for validation in store.ts
@@ -387,6 +399,9 @@ export const defineApp = <
387
399
  rootDir,
388
400
  componentRoots: [resolve(env.APP_DIR ?? rootDir, "src"), fileURLToPath(new URL("../", import.meta.url))],
389
401
  basePath: opts.basePath,
402
+ // Every island instance already mounts inside its own error boundary; this
403
+ // only gives the fallback Cloud's look and the reader's language.
404
+ errorFallback: fileURLToPath(new URL("../ssr/IslandError.tsx", import.meta.url)),
390
405
  template: ({ body, scripts, title, description, theme, lang, performanceRoute, pwa }) => {
391
406
  const themeFixed = theme !== undefined;
392
407
  // App pages paint the canvas before any stylesheet and give it to the
@@ -571,6 +586,7 @@ export const defineApp = <
571
586
  section: meta.nav?.section ?? "hidden",
572
587
  requiresAuth: meta.nav?.requiresAuth,
573
588
  requiresRoles: meta.nav?.requiresRoles,
589
+ badge: meta.nav?.badge,
574
590
  adminHref: meta.adminHref,
575
591
  }
576
592
  : undefined,
@@ -7,6 +7,32 @@ const isString = (value: unknown): value is string => typeof value === "string";
7
7
  const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString);
8
8
  const isStringRecord = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every(isString);
9
9
 
10
+ /** An absolute path on the current origin: one leading slash, no backslash or whitespace. */
11
+ const SAME_ORIGIN_PATH = /^\/(?![\/\\])[^\\\s]*$/;
12
+
13
+ /** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
14
+ export const canonicalRoute = (route: string): string => {
15
+ const trimmed = route.trim();
16
+ return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
17
+ };
18
+
19
+ /**
20
+ * Whether a `nav.badge` path stays below one of the app's own `routes`. It is
21
+ * checked on the path the browser actually requests, after dot segments, and
22
+ * never starts with `//`, which a URL resolver reads as another host.
23
+ */
24
+ export const isOwnNavBadge = (badge: string, routes: readonly string[]): boolean => {
25
+ if (!SAME_ORIGIN_PATH.test(badge)) return false;
26
+ const path = new URL(badge, "https://cloud.invalid").pathname;
27
+ return (
28
+ !path.startsWith("//") &&
29
+ routes.some((route) => {
30
+ const prefix = canonicalRoute(route);
31
+ return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
32
+ })
33
+ );
34
+ };
35
+
10
36
  const invalid = (path: string, expected: string): string => `${path} must be ${expected}`;
11
37
 
12
38
  export const validateAppRegistryEntry = (value: unknown): string | null => {
@@ -61,6 +87,9 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
61
87
  if (value.nav.requiresRoles !== undefined && !isStringArray(value.nav.requiresRoles)) {
62
88
  return invalid("nav.requiresRoles", "an array of strings");
63
89
  }
90
+ if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !isOwnNavBadge(value.nav.badge, value.routes))) {
91
+ return invalid("nav.badge", "a same-origin path below one of the entry's routes");
92
+ }
64
93
  }
65
94
  if (value.adminNav !== undefined) {
66
95
  if (
@@ -98,7 +127,7 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
98
127
  !link.label.trim() ||
99
128
  (link.description !== undefined && !isString(link.description)) ||
100
129
  !isString(link.href) ||
101
- !/^\/(?![\/\\])[^\\\s]*$/.test(link.href) ||
130
+ !SAME_ORIGIN_PATH.test(link.href) ||
102
131
  (link.icon !== undefined && !isString(link.icon)) ||
103
132
  (link.keywords !== undefined && !isStringArray(link.keywords)),
104
133
  )
@@ -55,6 +55,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
55
55
  // Registry stores roles as serialized strings; the source type is
56
56
  // Role[] and round-trip is value-preserving.
57
57
  requiresRoles: e.nav.requiresRoles as Role[] | undefined,
58
+ badge: e.nav.badge,
58
59
  }
59
60
  : undefined,
60
61
  help: e.help ? { ...e.help } : undefined,
@@ -1,6 +1,6 @@
1
1
  import { mutation } from "@k2b/stdlib/solid";
2
2
  import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
- import { For, Show } from "solid-js";
3
+ import { createMemo, For, Show } from "solid-js";
4
4
  import { createStore } from "solid-js/store";
5
5
  import { CloudAvatar } from "../account/Avatar";
6
6
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
@@ -52,6 +52,13 @@ type PermissionEditorProps = {
52
52
  * account), its row can neither be lowered nor removed. */
53
53
  revokeAccess: (accessId: string) => Promise<void>;
54
54
 
55
+ /** The entries that still apply when the resource lets one entry shadow
56
+ * another, such as a `none` that overrides the same principal's `admin`.
57
+ * The editor counts managers only among them, so pass the same function
58
+ * the service applies before `ensureManagerRemains()`. Defaults to every
59
+ * entry. */
60
+ effectiveEntries?: (entries: readonly AccessEntry[]) => readonly AccessEntry[];
61
+
55
62
  /** Allow granting `public` access from this editor. */
56
63
  allowPublic?: boolean;
57
64
 
@@ -218,10 +225,10 @@ export default function PermissionEditor(props: PermissionEditorProps) {
218
225
  });
219
226
  const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
220
227
  // The service refuses to remove the last manager; the row says so up front.
221
- const lastManagerId = () => {
222
- const managers = entries.filter(isManagerEntry);
228
+ const lastManagerId = createMemo(() => {
229
+ const managers = (props.effectiveEntries?.(entries) ?? entries).filter(isManagerEntry);
223
230
  return managers.length === 1 ? managers[0]!.id : null;
224
- };
231
+ });
225
232
 
226
233
  return (
227
234
  <div class="flex flex-col gap-3">
@@ -2,8 +2,9 @@ import type { AccessSubject, RequestActor } from "../server";
2
2
  import { aiModelAccess } from "./model-access";
3
3
  import { personalAiModelPolicy } from "./personal-agent";
4
4
  import { listAiModels, toPublicAiSettingsState } from "./settings";
5
- import { resolveAiAudioModel } from "./transcription";
6
- import type { AiChatTurnRunConfig, AiModelPolicy, AiSettingsError } from "./types";
5
+ import { type AiResolvedAudioModel, AiTranscriptionError, resolveAiAudioModel } from "./transcription";
6
+ import type { AiChatTurnRunConfig, AiDataBoundary, AiModelPolicy, AiSettingsError } from "./types";
7
+ import { isAiSettingsError } from "./validate";
7
8
 
8
9
  export const aiChatAccessSubject = (actor: RequestActor | undefined): AccessSubject | null => {
9
10
  if (!actor) return null;
@@ -20,14 +21,34 @@ export const isAssistantChatTurn = (config: AiChatTurnRunConfig): boolean => con
20
21
  export const listAssistantAiModels = async (subject: AccessSubject | null, policy: AiModelPolicy = personalAiModelPolicy) =>
21
22
  aiModelAccess.filterModels(await listAiModels(policy), subject);
22
23
 
24
+ /** The one audio check behind the composer microphone, dictation, and offering and running transcribe_audio. */
25
+ export const resolveAssistantAudioModel = async (
26
+ subject: AccessSubject | null,
27
+ allowedDataBoundaries?: AiDataBoundary[],
28
+ ): Promise<AiResolvedAudioModel> => {
29
+ const model = await resolveAiAudioModel({ allowedDataBoundaries });
30
+ try {
31
+ await aiModelAccess.assertAllowed(model.profile.id, subject);
32
+ } catch (error) {
33
+ if (!isAiSettingsError(error) || error.aiError.code !== "model_access_denied") throw error;
34
+ throw Object.assign(
35
+ new AiTranscriptionError(
36
+ "transcription_access_denied",
37
+ "Audio transcription is not available to you: you do not have access to the audio model. An administrator can grant access in the AI settings.",
38
+ ),
39
+ { aiError: error.aiError },
40
+ );
41
+ }
42
+ return model;
43
+ };
44
+
23
45
  export const assistantAiSettingsState = async (subject: AccessSubject | null) => {
24
46
  const [status, models, audioModelConfigured] = await Promise.all([
25
47
  toPublicAiSettingsState(personalAiModelPolicy.allowedDataBoundaries),
26
48
  listAssistantAiModels(subject),
27
49
  (async () => {
28
50
  try {
29
- const model = await resolveAiAudioModel({ allowedDataBoundaries: personalAiModelPolicy.allowedDataBoundaries });
30
- await aiModelAccess.assertAllowed(model.profile.id, subject);
51
+ await resolveAssistantAudioModel(subject, personalAiModelPolicy.allowedDataBoundaries);
31
52
  return true;
32
53
  } catch {
33
54
  return false;
@@ -1,10 +1,9 @@
1
1
  import { z } from "zod";
2
- import { aiChatAccessSubject } from "./assistant-models";
2
+ import { aiChatAccessSubject, resolveAssistantAudioModel } from "./assistant-models";
3
3
  import { aiProjectFilePathFromMount } from "./file-mount";
4
4
  import { aiFileStore, normalizeAiFilePath } from "./files-store";
5
- import { aiModelAccess } from "./model-access";
6
5
  import { defineAiTool } from "./tools";
7
- import { type AiResolvedAudioModel, resolveAiAudioModel, runAiTranscription } from "./transcription";
6
+ import { type AiResolvedAudioModel, runAiTranscription } from "./transcription";
8
7
 
9
8
  export const CloudAiTranscribeAudioInputSchema = z.object({
10
9
  path: z.string().trim().min(1).describe("Absolute path of the stored audio file. Project files are below /project."),
@@ -53,13 +52,7 @@ export const createCloudAiTranscribeAudioTool = (options: { resolveModel?: () =>
53
52
  signal: ctx.signal,
54
53
  allowedDataBoundaries: ctx.allowedDataBoundaries,
55
54
  attribution: { conversationId: ctx.conversationId, turnId: ctx.turnId },
56
- resolveModel:
57
- options.resolveModel ??
58
- (async () => {
59
- const resolved = await resolveAiAudioModel({ allowedDataBoundaries: ctx.allowedDataBoundaries });
60
- await aiModelAccess.assertAllowed(resolved.profile.id, aiChatAccessSubject(ctx.actor));
61
- return resolved;
62
- }),
55
+ resolveModel: options.resolveModel ?? (() => resolveAssistantAudioModel(aiChatAccessSubject(ctx.actor), ctx.allowedDataBoundaries)),
63
56
  });
64
57
  ctx.signal.throwIfAborted();
65
58
  const producerCallKey = `${ctx.turnId}:${ctx.callId}`;
@@ -14,6 +14,7 @@ import {
14
14
  import type { CapabilityRegistryEntry } from "../contracts/registry";
15
15
  import type { RequestActor } from "../server";
16
16
  import { createHelpReader, type HelpReaderFactory } from "../services/help";
17
+ import { type MandatePolicyV1, mandatePolicyCanPermitCapability } from "../services/mandates/policy";
17
18
  import { resolveAppIdentityPresentation } from "../shared/app-presentation";
18
19
  import { recordRejectedAiCapability } from "./capability-execution";
19
20
  import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
@@ -76,7 +77,7 @@ export type AiRememberableCapabilityApprovals = ReadonlyMap<string, string>;
76
77
  * Why `load_tools` could not make a requested name callable:
77
78
  * - `unknown`: no tool has this exact name;
78
79
  * - `not_offered_in_turn`: the tool exists, but this turn's client or task does not provide it;
79
- * - `not_allowed`: the conversation's fixed tool scope excludes it;
80
+ * - `not_allowed`: the conversation's fixed tool scope or task grants exclude it;
80
81
  * - `app_offline`: the app operation is not in the live registry now.
81
82
  */
82
83
  export const AI_TOOL_UNAVAILABLE_REASONS = ["unknown", "not_offered_in_turn", "not_allowed", "app_offline"] as const;
@@ -475,8 +476,8 @@ export const createAiToolMetaTools = (input: {
475
476
  store: ToolStateStore;
476
477
  maxLoadedTools?: number;
477
478
  unavailableLoadedNames?: readonly string[];
478
- /** False when this turn offers no app operations at all. */
479
- appOperationsOffered?: boolean;
479
+ /** Whether this turn offers app operations, offers none, or its fixed scope or task grants exclude all of them. */
480
+ appOperations?: "not_offered" | "not_allowed" | "offered";
480
481
  /** Why a requested name that is not in `catalog` cannot be loaded; defaults to `unknown`. */
481
482
  unavailableReason?: (name: string) => AiToolUnavailableReason;
482
483
  }): AiRuntimeTool[] => {
@@ -494,9 +495,11 @@ export const createAiToolMetaTools = (input: {
494
495
  const liveAppDirectory =
495
496
  directoryEntries.length > 0
496
497
  ? ` Live capability apps: ${directoryEntries.join(", ")}${hiddenAppCount > 0 ? `, and ${hiddenAppCount} more` : ""}.`
497
- : input.appOperationsOffered === false
498
+ : input.appOperations === "not_offered"
498
499
  ? " App operations are not offered in this turn."
499
- : " No Cloud app publishes operations right now. Do not search for app operations again in this turn; tell the user which app is not reachable instead of claiming a permanent product limitation.";
500
+ : input.appOperations === "not_allowed"
501
+ ? " This chat's tool scope or task grants allow no app operations. Do not search for app operations in this turn; continue with the available tools or tell the user which grant is missing."
502
+ : " No Cloud app publishes operations right now. Do not search for app operations again in this turn; tell the user which app is not reachable instead of claiming a permanent product limitation.";
500
503
  const unavailableLoadedNames = input.unavailableLoadedNames ?? [];
501
504
  const unavailableLoadedNotice =
502
505
  unavailableLoadedNames.length > 0
@@ -542,7 +545,7 @@ export const createAiToolMetaTools = (input: {
542
545
  const load = defineAiTool({
543
546
  name: "load_tools",
544
547
  description:
545
- "Load stable capability ids such as mail.conversation.list, or exact built-in names returned by search_tools, as ordinary tools for the next model turn. Skills may name capability ids directly, so load them without searching first. Call each loaded tool by the `call` name returned for it. `unavailable` gives a reason per name: unknown means no tool has this exact name, so look it up once with search_tools instead of guessing; not_offered_in_turn means this client or task does not provide the tool; not_allowed means this chat's fixed tool scope excludes it; app_offline means its app is not reachable now. For the last three, do not search or load the name again in this turn.",
548
+ "Load stable capability ids such as mail.conversation.list, or exact built-in names returned by search_tools, as ordinary tools for the next model turn. Skills may name capability ids directly, so load them without searching first. Call each loaded tool by the `call` name returned for it. `unavailable` gives a reason per name: unknown means no tool has this exact name, so look it up once with search_tools instead of guessing; not_offered_in_turn means this client or task does not provide the tool; not_allowed means this chat's fixed tool scope or task grants exclude it; app_offline means its app is not reachable now. For the last three, do not search or load the name again in this turn.",
546
549
  inputSchema: z.object({ names: z.array(z.string().trim().min(1)).min(1).max(25) }).strict(),
547
550
  outputSchema: z
548
551
  .object({
@@ -689,6 +692,8 @@ export const createAiToolResolver =
689
692
  actor: RequestActor;
690
693
  staticTools: AiRuntimeTool[];
691
694
  allowedTools?: readonly string[] | null;
695
+ /** Undefined for interactive runs; null fails closed when a background mandate cannot be loaded. */
696
+ mandatePolicy?: MandatePolicyV1 | null;
692
697
  /** Built-in tools that exist but that this turn does not offer, such as client tools its client did not declare or tools outside `allowedTools`. */
693
698
  unofferedTools?: readonly string[];
694
699
  runtimeContext?: Omit<AiToolPreparationContext, "actor" | "conversationId">;
@@ -725,7 +730,19 @@ export const createAiToolResolver =
725
730
  }
726
731
  const allowed = input.allowedTools == null ? null : new Set(input.allowedTools);
727
732
  const fullCapabilityCatalog = buildAiCapabilityCatalog(registry, input.locale);
728
- const capabilityCatalog = fullCapabilityCatalog.filter((entry) => !allowed || allowed.has(entry.name));
733
+ const mandateAllows = (entry: AiCapabilityCatalogEntry) =>
734
+ input.mandatePolicy === undefined ||
735
+ (input.mandatePolicy !== null &&
736
+ mandatePolicyCanPermitCapability(input.mandatePolicy, {
737
+ appId: entry.appId,
738
+ capabilityId: entry.operation.localId,
739
+ kind: entry.kind,
740
+ approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
741
+ }));
742
+ const mandateExcluded = new Set(
743
+ fullCapabilityCatalog.filter((entry) => !mandateAllows(entry)).flatMap((entry) => [entry.name, entry.providerName]),
744
+ );
745
+ const capabilityCatalog = fullCapabilityCatalog.filter((entry) => (!allowed || allowed.has(entry.name)) && mandateAllows(entry));
729
746
  const helpTools = input.help ? createAiHelpTools(input.help, input.locale) : [];
730
747
  const resourceTool =
731
748
  input.execute && (capabilityCatalog.length > 0 || input.staticTools.some((tool) => tool.def.name === "code_read"))
@@ -753,24 +770,33 @@ export const createAiToolResolver =
753
770
  // App operation IDs always contain a dot; built-in names never do.
754
771
  const unavailableReason = (requested: string): AiToolUnavailableReason => {
755
772
  const name = loadedByProviderName.get(requested) ?? requested;
773
+ if (mandateExcluded.has(name)) return "not_allowed";
756
774
  if (allowed && outOfScope.has(name) && !allowed.has(name)) return "not_allowed";
757
775
  if (unoffered.has(name)) return "not_offered_in_turn";
758
776
  if (!name.includes(".")) return "unknown";
759
777
  if (!input.listRegistry) return "not_offered_in_turn";
760
778
  return liveRegistry === null || persistedLoadedNames.includes(name) ? "app_offline" : "unknown";
761
779
  };
780
+ // Independent of which apps are live: the fixed scope or task grants name no app operation at all.
781
+ const scopeExcludesAppOperations =
782
+ input.mandatePolicy === null ||
783
+ (input.mandatePolicy?.grants !== undefined && !input.mandatePolicy.grants.some((grant) => "appId" in grant)) ||
784
+ (allowed !== null && ![...allowed].some((name) => name.includes(".")));
762
785
  const eagerNames = new Set(builtIns.map((tool) => tool.def.name).filter((name) => !CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES.has(name)));
763
786
  const activeBuiltIns = builtIns.filter((tool) => eagerNames.has(tool.def.name) || loadedNames.includes(tool.def.name));
764
787
  const runtimeTools = [
765
788
  ...createAiToolMetaTools({
766
- apps: allowed ? registry.filter((app) => capabilityCatalog.some((entry) => entry.appId === app.appId)) : registry,
789
+ apps:
790
+ allowed || input.mandatePolicy !== undefined
791
+ ? registry.filter((app) => capabilityCatalog.some((entry) => entry.appId === app.appId))
792
+ : registry,
767
793
  catalog,
768
794
  eagerNames,
769
795
  conversationId: input.conversationId,
770
796
  store: input.store,
771
797
  maxLoadedTools: input.maxLoadedTools,
772
798
  unavailableLoadedNames,
773
- appOperationsOffered: Boolean(input.listRegistry),
799
+ appOperations: !input.listRegistry ? "not_offered" : scopeExcludesAppOperations ? "not_allowed" : "offered",
774
800
  unavailableReason,
775
801
  }),
776
802
  ...activeBuiltIns,
@@ -1,7 +1,7 @@
1
1
  import { dates } from "@k2b/stdlib";
2
2
  import { mutation } from "@k2b/stdlib/solid";
3
3
  import { Button, ButtonLink, Chat, isStructuredDataValue, MarkdownView, SplitButton, StructuredDataPreview, useLocale } from "@k2b/ui";
4
- import { createSignal, For, type JSX, Match, Show, Switch, untrack } from "solid-js";
4
+ import { createSignal, createUniqueId, For, type JSX, Match, Show, Switch, untrack } from "solid-js";
5
5
  import type { CapabilityActionReview } from "../../contracts/capabilities";
6
6
  import { markdown } from "../../shared";
7
7
  import type { AiTurnBlock } from "../protocol";
@@ -13,6 +13,7 @@ import { useAiChatActions } from "./message-actions";
13
13
  import {
14
14
  aiToolIcon,
15
15
  capabilityErrorDescription,
16
+ codeCheckOutcome,
16
17
  displayToolName,
17
18
  fetchFileErrorPresentation,
18
19
  formatToolDetailText,
@@ -22,6 +23,7 @@ import {
22
23
  isTextEditorToolName,
23
24
  jsonPreview,
24
25
  memoryToolPresentation,
26
+ viewedChatImage,
25
27
  } from "./message-utils";
26
28
  import { aiChatMessages } from "./messages";
27
29
  import { AssistantMarkdownBlock } from "./primitives";
@@ -200,7 +202,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
200
202
  const submit = (input: { approved: boolean; remember?: "always" }) => {
201
203
  if (!actionDisabled() && !approval.loading()) void approval.mutate(input);
202
204
  };
203
- const title = () => props.block.presentation?.title ?? displayToolName(props.block.name);
205
+ const title = () => props.block.presentation?.title ?? displayToolName(props.block.name, locale());
204
206
  const ownerName = () => props.block.presentation?.appName ?? t().assistant;
205
207
  const description = () => {
206
208
  const reviewMessage = props.block.approval?.review?.message.trim();
@@ -535,7 +537,7 @@ function CapabilityToolView(props: { block: ToolBlock }) {
535
537
  function RejectedToolView(props: { block: ToolBlock }) {
536
538
  const locale = useLocale();
537
539
  const presentation = () => props.block.presentation;
538
- const title = () => presentation()?.title ?? displayToolName(props.block.name);
540
+ const title = () => presentation()?.title ?? displayToolName(props.block.name, locale());
539
541
  return (
540
542
  <Chat.Activity
541
543
  icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
@@ -664,7 +666,7 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
664
666
  fallback={
665
667
  <ToolResultDisclosure
666
668
  blockId={props.block.id}
667
- name={displayToolName(props.block.name)}
669
+ name={displayToolName(props.block.name, locale())}
668
670
  toolName={props.block.name}
669
671
  args={props.block.args}
670
672
  result={props.block.result}
@@ -718,11 +720,14 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
718
720
  <TextEditorToolView turnId={props.turnId} block={props.block} active={props.active} />
719
721
  </Match>
720
722
  <Match when={status() === "running" || status() === "awaiting_client"}>
721
- <Chat.Activity label={displayToolName(props.block.name)} icon={aiToolIcon(props.block.name)} busy />
723
+ <Chat.Activity label={displayToolName(props.block.name, locale())} icon={aiToolIcon(props.block.name)} busy />
722
724
  </Match>
723
725
  </Switch>
724
726
  <Show when={hasCapabilityTable(props.block)}>
725
- <CapabilityTablePreview result={props.block.result} label={props.block.presentation?.title ?? displayToolName(props.block.name)} />
727
+ <CapabilityTablePreview
728
+ result={props.block.result}
729
+ label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
730
+ />
726
731
  </Show>
727
732
  </>
728
733
  );
@@ -751,7 +756,56 @@ export function AiTurnBlockView(props: { block: AiTurnBlock; turnId: string; str
751
756
  );
752
757
  }
753
758
 
754
- /** One step in the expanded work list: its title, target, and state, with input and output on demand. */
759
+ /** An alt text is a short description: the first line of the image description, clipped at a word. */
760
+ const altText = (description: string): string => {
761
+ const line =
762
+ description
763
+ .split("\n")
764
+ .find((entry) => entry.trim())
765
+ ?.trim() ?? "";
766
+ if (line.length <= 240) return line;
767
+ const clipped = line.slice(0, 240);
768
+ return `${clipped.slice(0, Math.max(clipped.lastIndexOf(" "), 160))}…`;
769
+ };
770
+
771
+ /**
772
+ * The image a view_image step looked at, as a small fixed-size thumbnail: it loads lazily through the chat's own file
773
+ * route, so it shows only what the viewer may read, and it keeps its box while it loads or fails. A host that opens
774
+ * chat files shows it larger on click; the button is named for that action and described by the image's alt text.
775
+ */
776
+ function StepImage(props: { path: string; src: string; description: string }) {
777
+ const actions = useAiChatActions();
778
+ const locale = useLocale();
779
+ const imageId = createUniqueId();
780
+ const [failed, setFailed] = createSignal(false);
781
+ const name = () => props.path.slice(props.path.lastIndexOf("/") + 1) || props.path;
782
+ const alt = () => altText(props.description);
783
+ const image = () => (
784
+ <Show when={!failed()} fallback={<i class="ti ti-photo-off text-lg text-dimmed" aria-hidden="true" />}>
785
+ <img id={imageId} src={props.src} alt={alt() || name()} loading="lazy" decoding="async" onError={() => setFailed(true)} />
786
+ </Show>
787
+ );
788
+ return (
789
+ <Show when={actions.onOpenFile} fallback={<div class="ai-step-image">{image()}</div>}>
790
+ {(open) => (
791
+ <button
792
+ type="button"
793
+ class="ai-step-image focus-ui"
794
+ aria-label={aiChatMessages(locale()).openImage({ name: name() })}
795
+ aria-describedby={!failed() && alt() ? imageId : undefined}
796
+ onClick={() => open()(props.path)}
797
+ >
798
+ {image()}
799
+ </button>
800
+ )}
801
+ </Show>
802
+ );
803
+ }
804
+
805
+ /**
806
+ * One step in the expanded work list: its title, target, state, and outcome, with input and output on demand. An app
807
+ * check names its outcome in words and with its own icon, so a passed and a failed check differ without colour.
808
+ */
755
809
  export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
756
810
  const locale = useLocale();
757
811
  const t = () => aiChatMessages(locale());
@@ -760,28 +814,54 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
760
814
  const waiting = () => props.block.status === "awaiting_client" || props.block.status === "awaiting_approval";
761
815
  const state = () =>
762
816
  waiting() ? t().stepWaiting : props.block.status === "rejected" ? t().stepRejected : isFailedTool(props.block) ? t().stepFailed : "";
817
+ const finished = () => props.block.status === "completed" && !props.block.isError;
818
+ const check = () => (finished() ? codeCheckOutcome(props.block.name, props.block.result) : null);
819
+ const outcome = () => {
820
+ const value = check();
821
+ if (!value) return "";
822
+ const result = value.passed ? t().checkPassed : value.errors > 0 ? t().checkFindings({ count: value.errors }) : t().checkNotPassed;
823
+ return value.warnings > 0 ? `${result} · ${t().checkWarnings({ count: value.warnings })}` : result;
824
+ };
825
+ const actions = useAiChatActions();
826
+ /** Only a host that resolves chat file URLs shows the image; without one there is no preview, not an empty box. */
827
+ const image = () => {
828
+ const viewed = finished() ? viewedChatImage(props.block.name, props.block.result) : null;
829
+ const src = viewed ? actions.fileUrl?.(viewed.path) : null;
830
+ return viewed && src ? { ...viewed, src } : null;
831
+ };
832
+ const icon = () => {
833
+ if (waiting()) return "ti ti-clock";
834
+ const value = check();
835
+ if (value) return value.passed ? "ti ti-circle-check" : "ti ti-alert-triangle";
836
+ return aiToolIcon(props.block.name, props.block.presentation?.appIcon);
837
+ };
763
838
  return (
764
839
  <AiToolActivity
765
840
  blockId={props.block.id}
766
- label={props.block.presentation?.title ?? displayToolName(props.block.name)}
841
+ label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
767
842
  description={[
768
843
  props.block.status === "running" ? (props.block.progress ?? "") : "",
769
844
  typeof detail() === "string" ? String(detail()) : "",
770
845
  state(),
846
+ outcome(),
771
847
  ]
772
848
  .filter(Boolean)
773
849
  .join(" · ")}
774
- icon={waiting() ? "ti ti-clock" : aiToolIcon(props.block.name, props.block.presentation?.appIcon)}
850
+ icon={icon()}
775
851
  busy={props.busy && props.block.status === "running"}
776
852
  renderBody={() => (
777
- <div
778
- class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
779
- tabIndex={0}
780
- role="region"
781
- aria-label={t().toolInputOutput}
782
- >
783
- <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
784
- <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
853
+ <div class="flex min-w-0 flex-col gap-2">
854
+ <div
855
+ class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
856
+ tabIndex={0}
857
+ role="region"
858
+ aria-label={t().toolInputOutput}
859
+ >
860
+ <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
861
+ <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
862
+ </div>
863
+ {/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
864
+ <Show when={image()}>{(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}</Show>
785
865
  </div>
786
866
  )}
787
867
  />
@@ -106,6 +106,7 @@ function SearchToolsView(props: { block: ToolBlock }) {
106
106
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
107
107
  const tools = () => records(result().tools);
108
108
  const t = useMessages();
109
+ const locale = useLocale();
109
110
  const query = () => text(args().query) || t().tools;
110
111
  return (
111
112
  <CompletedActivity block={props.block} label={t().searchTools({ query: query() })} description={t().found({ count: tools().length })}>
@@ -115,7 +116,8 @@ function SearchToolsView(props: { block: ToolBlock }) {
115
116
  {(tool) => (
116
117
  <ResultRow
117
118
  icon={tool.kind === "action" ? "ti-bolt" : tool.kind === "query" ? "ti-search" : aiToolIcon(text(tool.name))}
118
- title={text(tool.title) || displayToolName(text(tool.name))}
119
+ // The catalog titles a built-in tool in English, so the reader's language names it; a capability brings its own title.
120
+ title={tool.kind !== "builtin" && text(tool.title) ? text(tool.title) : displayToolName(text(tool.name), locale())}
119
121
  description={text(tool.description)}
120
122
  meta={text(tool.appId) || text(tool.kind)}
121
123
  />