@k2b/cloud 0.29.0 → 0.30.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 (92) hide show
  1. package/package.json +5 -3
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +203 -185
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
  17. package/src/ai/chat/message-utils.ts +3 -0
  18. package/src/ai/chat/messages.ts +12 -26
  19. package/src/ai/chat/tool-disclosure.tsx +21 -14
  20. package/src/ai/chat/tool-groups.ts +64 -37
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +4 -4
  23. package/src/ai/chat/turn-view.tsx +141 -69
  24. package/src/ai/code-runtime-tools.ts +59 -44
  25. package/src/ai/default-tool-contracts.ts +198 -0
  26. package/src/ai/default-tools.ts +25 -1
  27. package/src/ai/executor.ts +19 -21
  28. package/src/ai/index.ts +9 -1
  29. package/src/ai/migrate.ts +59 -12
  30. package/src/ai/protocol.ts +9 -4
  31. package/src/ai/runtime-tools.ts +1 -0
  32. package/src/ai/skill-content.ts +54 -0
  33. package/src/ai/skills-routes.ts +22 -13
  34. package/src/ai/skills.ts +177 -231
  35. package/src/ai/store.ts +20 -13
  36. package/src/ai/tool-sentences.ts +38 -0
  37. package/src/ai/types.ts +10 -2
  38. package/src/api/admin-ai-skills.ts +35 -16
  39. package/src/api/admin-outgoing-mail.ts +18 -0
  40. package/src/api/fanout.ts +128 -0
  41. package/src/api/help.ts +8 -3
  42. package/src/api/mcp.ts +4 -1
  43. package/src/api/search.ts +31 -106
  44. package/src/api/widgets.ts +259 -100
  45. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  46. package/src/browser/ndjson.ts +34 -0
  47. package/src/browser/reload.ts +46 -0
  48. package/src/browser/search-stream.ts +4 -26
  49. package/src/browser/widgets.ts +28 -0
  50. package/src/capabilities/server.ts +20 -2
  51. package/src/cli/access.ts +48 -14
  52. package/src/cli/admin/ai-skills.ts +59 -39
  53. package/src/cli/admin/outgoing-mail.ts +30 -2
  54. package/src/contracts/announcement-cookie.ts +62 -0
  55. package/src/contracts/announcements.ts +14 -52
  56. package/src/contracts/capabilities.ts +24 -0
  57. package/src/contracts/outgoing-mail.ts +56 -11
  58. package/src/contracts/pwa-paths.ts +7 -2
  59. package/src/contracts/pwa.ts +2 -7
  60. package/src/contracts/registry.ts +1 -0
  61. package/src/contracts/shared.ts +5 -0
  62. package/src/contracts/widgets.ts +20 -0
  63. package/src/index.ts +1 -0
  64. package/src/server/help.ts +8 -1
  65. package/src/services/accounts/users.ts +15 -0
  66. package/src/services/help/index.ts +18 -5
  67. package/src/services/help/types.ts +3 -2
  68. package/src/services/identity/constants.ts +2 -0
  69. package/src/services/identity/key-ring.ts +123 -42
  70. package/src/services/identity/runtime-config.ts +13 -2
  71. package/src/services/outbox.ts +24 -9
  72. package/src/services/outgoing-mail/index.ts +33 -5
  73. package/src/services/outgoing-mail/messages.ts +60 -19
  74. package/src/services/outgoing-mail/store.ts +63 -4
  75. package/src/shared/network-address.ts +1 -0
  76. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  77. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  78. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  79. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  80. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  81. package/src/ssr/PwaRuntime.island.tsx +1 -1
  82. package/src/ssr/app-navigation.ts +5 -7
  83. package/src/ssr/global-search-host.tsx +55 -0
  84. package/src/ssr/help.ts +3 -3
  85. package/src/ssr/rail-context.ts +4 -3
  86. package/src/ssr/runtime.ts +14 -1
  87. package/src/styles/effects.css +142 -0
  88. package/src/styles/utilities-markdown-table.css +5 -1
  89. package/src/ai/code-mode-skill.ts +0 -114
  90. package/src/ai/data-analysis-skill.ts +0 -10
  91. package/src/ai/grids-skill.ts +0 -158
  92. package/src/ai/skill-seeds.ts +0 -560
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.29.0",
3
+ "version": "0.30.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": {
@@ -38,6 +38,7 @@
38
38
  "./browser/resource-clipboard": "./src/browser/resource-clipboard.ts",
39
39
  "./browser/commands": "./src/browser/commands.ts",
40
40
  "./browser/search": "./src/browser/search.ts",
41
+ "./browser/widgets": "./src/browser/widgets.ts",
41
42
  "./browser/testing": "./src/browser/testing.ts",
42
43
  "./browser/resource-picker": "./src/browser/resource-picker.tsx",
43
44
  "./browser/files": "./src/browser/files.tsx",
@@ -55,6 +56,7 @@
55
56
  "./services": "./src/services/index.ts",
56
57
  "./services/image-metadata": "./src/services/image-metadata.ts",
57
58
  "./services/document-extraction": "./src/services/document-extraction.ts",
59
+ "./services/outbox": "./src/services/outbox.ts",
58
60
  "./services/ipa/service-account": null,
59
61
  "./services/*": "./src/services/*",
60
62
  "./ai": "./src/ai/index.ts",
@@ -91,7 +93,7 @@
91
93
  },
92
94
  "scripts": {
93
95
  "test": "bun test --isolate --path-ignore-patterns '**/*.behavior.test.*'",
94
- "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
96
+ "typecheck": "tsc --checkers 2 -p tsconfig.json --noEmit --pretty false"
95
97
  },
96
98
  "dependencies": {
97
99
  "@firecrawl/anydoc": "0.2.4",
@@ -102,7 +104,7 @@
102
104
  "@tailwindcss/typography": "0.5.20",
103
105
  "@k2b/nessi": "0.17.0",
104
106
  "@k2b/ssr": "0.15.0",
105
- "@k2b/ui": "0.19.0",
107
+ "@k2b/ui": "0.21.0",
106
108
  "@k2b/stdlib": "0.28.0",
107
109
  "@k2b/sync": "7.0.0",
108
110
  "@nats-io/jetstream": "3.4.0",
@@ -0,0 +1,14 @@
1
+ // Types only: the shell evaluates this on every page and must not load the account schemas.
2
+ import type { AppRegistryNav } from "../contracts/registry";
3
+ import type { Role, User } from "../contracts/shared";
4
+
5
+ /** An app's declared roles: absent roles admit everyone, and `guest` matches the guest profile. */
6
+ export const hasAnyAppRole = (user: User | undefined, roles: readonly Role[] | undefined): boolean =>
7
+ !roles || (!!user && roles.some((role) => (role === "guest" ? user.profile === "guest" : user.roles.includes(role))));
8
+
9
+ /**
10
+ * Who may see an app's Help and Skills: its navigation roles, or administrators for an app reached only through the
11
+ * admin area (`adminHref` without its own `href`). `undefined` admits every signed-in user.
12
+ */
13
+ export const appAudienceRoles = (nav: AppRegistryNav | undefined): readonly Role[] | undefined =>
14
+ nav?.requiresRoles ?? (nav && !nav.href && nav.adminHref ? ["admin"] : undefined);
@@ -10,6 +10,7 @@ import {
10
10
  type CapabilityActionManifest,
11
11
  type CapabilityActionReviewResult,
12
12
  CapabilityActionReviewSchema,
13
+ type CapabilityActionSentences,
13
14
  type CapabilityCommandDefinition,
14
15
  type CapabilityCommandManifest,
15
16
  CapabilityCommandManifestSchema,
@@ -38,6 +39,14 @@ import {
38
39
  } from "../contracts/capabilities";
39
40
  import { fileProviderIssues } from "../contracts/file-provider";
40
41
  import { canonicalLocale, localeFallbackChain, normalizeLocale } from "../shared/locale";
42
+ import {
43
+ CAPABILITY_SENTENCE_KEYS,
44
+ CAPABILITY_SENTENCE_MAX_CHARS,
45
+ type CapabilityActionWording,
46
+ capabilitySentenceFields,
47
+ parseCapabilitySentence,
48
+ resolveCapabilityActionSentences,
49
+ } from "./capability-sentences";
41
50
 
42
51
  type JsonSchema = Record<string, unknown>;
43
52
  const MAX_CAPABILITY_MANIFEST_BYTES = 256 * 1024;
@@ -274,6 +283,56 @@ const compileSchemaPresentation = (
274
283
  return Object.fromEntries(entries);
275
284
  };
276
285
 
286
+ const isAction = (
287
+ operation: CapabilityQueryManifest | CapabilityActionManifest | CapabilityCommandManifest,
288
+ ): operation is CapabilityActionManifest => "idempotency" in operation;
289
+
290
+ /**
291
+ * Checks one Action's sentences against its schemas: every placeholder names a field the Action declares,
292
+ * and only `done` reads result data, because the other sentences are shown before or instead of a result.
293
+ */
294
+ export const compileActionSentences = (
295
+ value: unknown,
296
+ operation: Pick<CapabilityActionManifest, "inputSchema" | "dataSchema">,
297
+ label: string,
298
+ strict = true,
299
+ ): CapabilityActionSentences => {
300
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
301
+ const copy = value as Record<string, unknown>;
302
+ const extra = Object.keys(copy).find((key) => !(CAPABILITY_SENTENCE_KEYS as readonly string[]).includes(key));
303
+ if (strict && extra) throw new Error(`${label} contains unsupported field "${extra}"`);
304
+ const paths = { input: schemaFieldPaths(operation.inputSchema), data: schemaFieldPaths(operation.dataSchema) };
305
+ const sentences: Record<string, string> = {};
306
+ for (const key of CAPABILITY_SENTENCE_KEYS) {
307
+ const template = copy[key];
308
+ if (template === undefined) continue;
309
+ if (typeof template !== "string") throw new Error(`${label}.${key} must be text`);
310
+ assertText(template, `${label}.${key}`, CAPABILITY_SENTENCE_MAX_CHARS);
311
+ const parsed = parseCapabilitySentence(template.trim());
312
+ if ("error" in parsed) throw new Error(`${label}.${key} ${parsed.error}`);
313
+ for (const part of parsed.parts) {
314
+ if (typeof part === "string") continue;
315
+ if (part.source === "data" && key !== "done") throw new Error(`${label}.${key} may read {data.${part.path}} only in done`);
316
+ if (!paths[part.source].has(part.path)) {
317
+ throw new Error(`${label}.${key} placeholder {${part.source}.${part.path}} does not exist in the projected ${part.source} schema`);
318
+ }
319
+ }
320
+ sentences[key] = template.trim();
321
+ }
322
+ if (Object.keys(sentences).length === 0) throw new Error(`${label} needs at least one sentence`);
323
+ return sentences;
324
+ };
325
+
326
+ /** A reader keeps an entry's other copy when only its sentences come from a newer or broken producer. */
327
+ const readActionSentences = (value: unknown, operation: CapabilityActionManifest, label: string, strict: boolean) => {
328
+ if (strict) return compileActionSentences(value, operation, label, strict);
329
+ try {
330
+ return compileActionSentences(value, operation, label, strict);
331
+ } catch {
332
+ return undefined;
333
+ }
334
+ };
335
+
277
336
  const compileOperationPresentation = (
278
337
  value: unknown,
279
338
  operation: CapabilityQueryManifest | CapabilityActionManifest | CapabilityCommandManifest,
@@ -283,7 +342,9 @@ const compileOperationPresentation = (
283
342
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
284
343
  const translation = value as Record<string, unknown>;
285
344
  const allowed = new Set(
286
- "path" in operation ? ["title", "description", "input"] : ["title", "description", "input", "data", "searchTags"],
345
+ "path" in operation
346
+ ? ["title", "description", "input"]
347
+ : ["title", "description", "input", "data", "searchTags", ...(isAction(operation) ? ["sentences"] : [])],
287
348
  );
288
349
  const extra = Object.keys(translation).find((key) => !allowed.has(key));
289
350
  if (strict && extra) throw new Error(`${label} contains unsupported field "${extra}"`);
@@ -330,12 +391,17 @@ const compileOperationPresentation = (
330
391
  }
331
392
  const input = compileSchemaPresentation(translation.input, operation.inputSchema, `${label}.input`);
332
393
  const data = "dataSchema" in operation ? compileSchemaPresentation(translation.data, operation.dataSchema, `${label}.data`) : undefined;
394
+ const sentences =
395
+ translation.sentences !== undefined && isAction(operation)
396
+ ? readActionSentences(translation.sentences, operation, `${label}.sentences`, strict)
397
+ : undefined;
333
398
  return {
334
399
  ...(typeof translation.title === "string" ? { title: translation.title.trim() } : {}),
335
400
  ...(typeof translation.description === "string" ? { description: translation.description.trim() } : {}),
336
401
  ...(input ? { input } : {}),
337
402
  ...(data ? { data } : {}),
338
403
  ...(searchTags ? { searchTags } : {}),
404
+ ...(sentences ? { sentences } : {}),
339
405
  };
340
406
  };
341
407
 
@@ -353,7 +419,7 @@ export const compileCapabilityPresentation = (
353
419
  if (value === undefined) return undefined;
354
420
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Capability presentation must be an object");
355
421
  const catalog = value as Record<string, unknown>;
356
- if (strict && Object.keys(catalog).some((key) => key !== "baseLocale" && key !== "translations")) {
422
+ if (strict && Object.keys(catalog).some((key) => key !== "baseLocale" && key !== "sentences" && key !== "translations")) {
357
423
  throw new Error("Capability presentation contains unsupported fields");
358
424
  }
359
425
  const baseLocale = typeof catalog.baseLocale === "string" ? canonicalLocale(catalog.baseLocale) : undefined;
@@ -365,6 +431,23 @@ export const compileCapabilityPresentation = (
365
431
  const queries = new Map(manifest.queries.map((operation) => [operation.localId, operation]));
366
432
  const actions = new Map(manifest.actions.map((operation) => [operation.localId, operation]));
367
433
  const commands = new Map(manifest.commands.map((operation) => [operation.localId, operation]));
434
+ let sentences: Record<string, CapabilityActionSentences> | undefined;
435
+ if (catalog.sentences !== undefined) {
436
+ if (!catalog.sentences || typeof catalog.sentences !== "object" || Array.isArray(catalog.sentences)) {
437
+ throw new Error("Capability presentation sentences must be an object");
438
+ }
439
+ sentences = Object.fromEntries(
440
+ Object.entries(catalog.sentences as Record<string, unknown>).flatMap(([localId, entry]) => {
441
+ const action = actions.get(localId);
442
+ if (!action) {
443
+ if (strict) throw new Error(`Action sentences reference unknown localId "${localId}"`);
444
+ return [];
445
+ }
446
+ const compiled = readActionSentences(entry, action, `Action ${localId}.sentences`, strict);
447
+ return compiled ? [[localId, compiled]] : [];
448
+ }),
449
+ );
450
+ }
368
451
  const translations: Record<string, CapabilityPresentationTranslation> = {};
369
452
  for (const [locale, rawTranslation] of Object.entries(catalog.translations as Record<string, unknown>)) {
370
453
  const canonical = canonicalLocale(locale);
@@ -431,7 +514,7 @@ export const compileCapabilityPresentation = (
431
514
  ...(translatedCommands ? { commands: translatedCommands } : {}),
432
515
  };
433
516
  }
434
- return { baseLocale, translations };
517
+ return { baseLocale, ...(sentences && Object.keys(sentences).length > 0 ? { sentences } : {}), translations };
435
518
  };
436
519
 
437
520
  const projectSchema = (schema: z.ZodType, label: string, io: "input" | "output"): JsonSchema => {
@@ -798,6 +881,31 @@ export const resolveCapabilityOperationTitle = (
798
881
  operation.title,
799
882
  );
800
883
 
884
+ /**
885
+ * Everything a reader needs to word calls of one Action in the requested locale: its title, the app's
886
+ * sentences, and the labelled fields of the generic sentence. Derived only from the live manifest and
887
+ * presentation catalog, the same way for every app.
888
+ */
889
+ export const resolveCapabilityActionWording = (
890
+ operation: CapabilityActionManifest,
891
+ catalog: CapabilityPresentationCatalog | undefined,
892
+ requestedLocale: string,
893
+ ): CapabilityActionWording => {
894
+ const overlays = catalog ? presentationOverlays(catalog, requestedLocale) : [];
895
+ const inputDescriptions = Object.assign({}, ...overlays.map((translation) => translation.actions?.[operation.localId]?.input ?? {}));
896
+ const sentences = resolveCapabilityActionSentences(operation.localId, catalog, requestedLocale);
897
+ return {
898
+ title: resolveCapabilityOperationTitle(operation, "actions", catalog, requestedLocale),
899
+ ...(sentences ? { sentences } : {}),
900
+ fields: capabilitySentenceFields({
901
+ inputSchema: operation.inputSchema,
902
+ dataSchema: operation.dataSchema,
903
+ sentences,
904
+ inputDescriptions,
905
+ }),
906
+ };
907
+ };
908
+
801
909
  /** Resolve only human presentation; stable IDs, flags, tags, aliases, and data shapes stay untouched. */
802
910
  export const resolveCapabilityManifestPresentation = (
803
911
  manifest: CapabilityManifest,
@@ -0,0 +1,413 @@
1
+ import { dates, i18n } from "@k2b/stdlib";
2
+ import type { CapabilityActionSentences, CapabilityPresentationCatalog } from "../contracts/capabilities";
3
+ import { canonicalLocale, localeFallbackChain, normalizeLocale } from "../shared/locale";
4
+ import { normalizeTimeZone } from "../shared/time";
5
+
6
+ /**
7
+ * Action sentences: what an Action will do or did, in the words of its app. One pure renderer serves the
8
+ * server (approval text for the CLI and notifications) and the chat in the browser, so both read the same.
9
+ * It never knows an app: everything comes from the Action's presentation catalog and its schemas.
10
+ */
11
+
12
+ export const CAPABILITY_SENTENCE_KEYS = ["approval", "done", "rejected", "notRun"] as const;
13
+ export type CapabilitySentenceKey = (typeof CAPABILITY_SENTENCE_KEYS)[number];
14
+
15
+ export const CAPABILITY_SENTENCE_MAX_CHARS = 200;
16
+ const MAX_PLACEHOLDERS = 6;
17
+ const MAX_VALUE_CHARS = 60;
18
+ const MAX_LIST_ITEMS = 3;
19
+ const MAX_RENDERED_CHARS = 240;
20
+ const MAX_FALLBACK_FIELDS = 2;
21
+ const MAX_LABELLED_FIELDS = 8;
22
+ const MAX_LABEL_CHARS = 40;
23
+ const MAX_DEPTH = 2;
24
+
25
+ export type CapabilitySentencePlaceholder = { source: "input" | "data"; path: string };
26
+ type Part = string | CapabilitySentencePlaceholder;
27
+
28
+ const PLACEHOLDER = /\{(input|data)\.([A-Za-z_][\w-]*(?:\[\])?(?:\.[A-Za-z_][\w-]*(?:\[\])?)*)\}/g;
29
+ /** Controls, invisible format characters such as direction marks, joiners, and soft hyphens, and line breaks. */
30
+ const UNSAFE_CHARACTER = /[\p{Cc}\p{Cf}\u2028\u2029]/u;
31
+ const UNSAFE_CHARACTERS = new RegExp(UNSAFE_CHARACTER.source, "gu");
32
+
33
+ /** Splits a template into text and placeholders, or names what makes it invalid. */
34
+ export const parseCapabilitySentence = (template: string): { parts: Part[] } | { error: string } => {
35
+ const parts: Part[] = [];
36
+ let index = 0;
37
+ for (const match of template.matchAll(PLACEHOLDER)) {
38
+ parts.push(template.slice(index, match.index));
39
+ parts.push({ source: match[1] as "input" | "data", path: match[2]! });
40
+ index = match.index + match[0].length;
41
+ }
42
+ parts.push(template.slice(index));
43
+ const text = parts.filter((part): part is string => typeof part === "string");
44
+ if (text.some((part) => part.includes("{") || part.includes("}"))) {
45
+ return { error: "uses a placeholder other than {input.path} or {data.path}" };
46
+ }
47
+ if (text.some((part) => UNSAFE_CHARACTER.test(part))) return { error: "contains control or direction characters" };
48
+ if (parts.length - text.length > MAX_PLACEHOLDERS) return { error: `uses more than ${MAX_PLACEHOLDERS} placeholders` };
49
+ return { parts: parts.filter((part) => part !== "") };
50
+ };
51
+
52
+ /** Placeholders of one valid template; empty for an invalid one. */
53
+ export const capabilitySentencePlaceholders = (template: string): CapabilitySentencePlaceholder[] => {
54
+ const parsed = parseCapabilitySentence(template);
55
+ return "parts" in parsed ? parsed.parts.filter((part): part is CapabilitySentencePlaceholder => typeof part !== "string") : [];
56
+ };
57
+
58
+ /** How the renderer reads one schema field: its label for the generic sentence and its date semantics. */
59
+ export type CapabilitySentenceField = {
60
+ /** `input.<path>` or `data.<path>`. */
61
+ path: string;
62
+ /** Short field title in the reader's locale; only labelled input fields appear in the generic sentence. */
63
+ label?: string;
64
+ format?: "date" | "date-time";
65
+ };
66
+
67
+ /** Everything a reader needs to word one Action call, resolved for one locale. */
68
+ export type CapabilityActionWording = {
69
+ title: string;
70
+ sentences?: CapabilityActionSentences;
71
+ fields?: readonly CapabilitySentenceField[];
72
+ };
73
+
74
+ /** The reader: locale for words and numbers, IANA time zone for instants (UTC when unknown or invalid). */
75
+ export type CapabilitySentenceContext = { locale: string; timeZone?: string };
76
+
77
+ const words = i18n.define({
78
+ baseLocale: "en",
79
+ messages: {
80
+ en: {
81
+ yes: "yes",
82
+ no: "no",
83
+ more: ({ count }: { count: number }) => `${count} more`,
84
+ why: "Why",
85
+ },
86
+ de: {
87
+ yes: "ja",
88
+ no: "nein",
89
+ more: ({ count }) => `${count} weitere`,
90
+ why: "Warum",
91
+ },
92
+ },
93
+ });
94
+
95
+ export const checkCapabilitySentenceMessages = () => words.check();
96
+
97
+ /** The input field a model may add to an Action call that waits for approval: its own reason, never app input. */
98
+ export const CAPABILITY_APPROVAL_REASON_FIELD = "approvalReason";
99
+ export const CAPABILITY_APPROVAL_REASON_MAX_CHARS = 300;
100
+
101
+ /** The model's reason for one call as one bounded line, or `null` when it gave none. */
102
+ export const capabilityApprovalReason = (args: unknown): string | null => {
103
+ const value =
104
+ isRecord(args) && Object.hasOwn(args, CAPABILITY_APPROVAL_REASON_FIELD) ? args[CAPABILITY_APPROVAL_REASON_FIELD] : undefined;
105
+ return typeof value === "string" ? plain(value, CAPABILITY_APPROVAL_REASON_MAX_CHARS) || null : null;
106
+ };
107
+
108
+ /** The label of the model's reason, so a reader never takes it for the app's own words. */
109
+ export const capabilityApprovalReasonLabel = (locale: string): string => words.resolve([locale]).t.why;
110
+
111
+ /**
112
+ * The approval as text, for readers without the card such as the CLI: the app and what the call does, the app's live
113
+ * review of this call, which may say what the input cannot, such as a scheduled send, then the model's labelled reason.
114
+ */
115
+ export const capabilityApprovalText = (input: {
116
+ appName: string;
117
+ subject: string;
118
+ review?: string | null;
119
+ reason?: string | null;
120
+ locale: string;
121
+ }): string =>
122
+ [
123
+ `${input.appName}: ${input.subject}`,
124
+ ...(input.review?.trim() ? [input.review.trim()] : []),
125
+ ...(input.reason ? [`${capabilityApprovalReasonLabel(input.locale)}: ${input.reason}`] : []),
126
+ ].join("\n");
127
+
128
+ const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value && typeof value === "object" && !Array.isArray(value));
129
+
130
+ const own = (value: unknown, key: string): unknown => (isRecord(value) && Object.hasOwn(value, key) ? value[key] : undefined);
131
+
132
+ /**
133
+ * Plain one-line text without control or invisible format characters, cut at a code point boundary. A value keeps
134
+ * its start and its end, so a long path keeps its file name and an address its domain; prose keeps its start.
135
+ */
136
+ const plain = (value: string, max: number, cut: "end" | "middle" = "end"): string => {
137
+ const text = value.replace(UNSAFE_CHARACTERS, " ").replace(/\s+/g, " ").trim();
138
+ const points = Array.from(text);
139
+ if (points.length <= max) return text;
140
+ if (cut === "end")
141
+ return `${points
142
+ .slice(0, max - 1)
143
+ .join("")
144
+ .trimEnd()}…`;
145
+ const tail = Math.floor((max - 1) / 2);
146
+ return `${points
147
+ .slice(0, max - 1 - tail)
148
+ .join("")
149
+ .trimEnd()}…${points.slice(-tail).join("").trimStart()}`;
150
+ };
151
+
152
+ /** One inserted value: bounded, and cut in the middle so both of its ends stay readable. */
153
+ const valueText = (value: string): string => plain(value, MAX_VALUE_CHARS, "middle");
154
+
155
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
156
+ const DATE_TIME = /^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/;
157
+
158
+ /** A real calendar day: JavaScript rolls "2026-02-30" over to March, and year 0 would read as year 1. */
159
+ const isCalendarDate = (value: string): boolean => {
160
+ const date = new Date(`${value}T12:00:00.000Z`);
161
+ return !Number.isNaN(date.getTime()) && date.getUTCFullYear() >= 1 && date.toISOString().slice(0, 10) === value;
162
+ };
163
+
164
+ /**
165
+ * A value of a field whose schema declares `format: "date"` or `"date-time"`: a calendar date stays that date
166
+ * everywhere, an instant shows in the reader's time zone, like review details. Anything that is not a real date
167
+ * or instant stays the text the call carries, so a sentence never names a different value than the one that runs.
168
+ */
169
+ const formatDate = (value: string, format: "date" | "date-time", context: CapabilitySentenceContext): string | null => {
170
+ if (!isCalendarDate(value.slice(0, 10))) return null;
171
+ if (DATE.test(value)) return dates.formatDate(`${value}T12:00:00.000Z`, { locale: context.locale, timeZone: "UTC" });
172
+ if (!DATE_TIME.test(value) || Number.isNaN(new Date(value).getTime())) return null;
173
+ const options = { locale: context.locale, timeZone: normalizeTimeZone(context.timeZone) };
174
+ return format === "date-time" ? dates.formatDateTime(value, options) : dates.formatDate(value, options);
175
+ };
176
+
177
+ /**
178
+ * Keys that name a person or resource. The value an Action acts on comes first: a name from the model's input is
179
+ * not verified, so "Jana Berger" must never stand in for the address a draft actually goes to.
180
+ */
181
+ const DISPLAY_KEYS = ["email", "address", "path", "displayName", "name", "title", "label"] as const;
182
+
183
+ const formatValue = (
184
+ value: unknown,
185
+ format: CapabilitySentenceField["format"],
186
+ context: CapabilitySentenceContext,
187
+ depth = 0,
188
+ ): string | null => {
189
+ if (value === null || value === undefined) return null;
190
+ if (typeof value === "string") {
191
+ const text = value.trim();
192
+ if (!text) return null;
193
+ return (format ? formatDate(text, format, context) : null) ?? (valueText(text) || null);
194
+ }
195
+ if (typeof value === "number") return Number.isFinite(value) ? valueText(new Intl.NumberFormat(context.locale).format(value)) : null;
196
+ if (typeof value === "boolean") {
197
+ const t = words.resolve([context.locale]).t;
198
+ return value ? t.yes : t.no;
199
+ }
200
+ if (depth >= MAX_DEPTH) return null;
201
+ if (Array.isArray(value)) {
202
+ // An entry without a value to show still counts, so a list never reads shorter than the call it describes.
203
+ const items = value.flatMap((item) => {
204
+ const text = formatValue(item, format, context, depth + 1);
205
+ return text ? [text] : [];
206
+ });
207
+ if (items.length === 0) return null;
208
+ const shown = items.slice(0, value.length > MAX_LIST_ITEMS ? MAX_LIST_ITEMS - 1 : MAX_LIST_ITEMS);
209
+ const rest = value.length - shown.length;
210
+ const list = rest > 0 ? [...shown, words.resolve([context.locale]).t.more({ count: rest })] : shown;
211
+ return new Intl.ListFormat(context.locale, { type: "conjunction" }).format(list);
212
+ }
213
+ if (isRecord(value)) {
214
+ for (const key of DISPLAY_KEYS) {
215
+ const text = formatValue(own(value, key), undefined, context, MAX_DEPTH);
216
+ if (text) return text;
217
+ }
218
+ }
219
+ return null;
220
+ };
221
+
222
+ /**
223
+ * Reads a dotted schema path such as `to[].name` from a value without touching inherited properties. Inside a list
224
+ * an entry without the field stays as `undefined`, so `{input.to[].name}` still counts a recipient without a name.
225
+ */
226
+ const valueAt = (root: unknown, path: string): unknown => {
227
+ let current: unknown[] = [root];
228
+ let list = false;
229
+ for (const segment of path.split(".")) {
230
+ const many = segment.endsWith("[]");
231
+ const key = many ? segment.slice(0, -2) : segment;
232
+ current = current.flatMap((entry) => {
233
+ const next = own(entry, key);
234
+ if (many && Array.isArray(next)) return next;
235
+ return list || (!many && next !== undefined) ? [many ? undefined : next] : [];
236
+ });
237
+ list ||= many;
238
+ }
239
+ return list ? current : current[0];
240
+ };
241
+
242
+ /**
243
+ * Renders one template, or `null` when a placeholder has no value to show or the sentence would not fit: the
244
+ * caller then words the call generically instead of showing a sentence with a gap or without its last words,
245
+ * which in German often carry the verb or a "nicht".
246
+ */
247
+ export const renderCapabilitySentence = (
248
+ template: string,
249
+ values: { input?: unknown; data?: unknown },
250
+ fields: readonly CapabilitySentenceField[] | undefined,
251
+ context: CapabilitySentenceContext,
252
+ ): string | null => {
253
+ const parsed = parseCapabilitySentence(template);
254
+ if ("error" in parsed) return null;
255
+ let text = "";
256
+ for (const part of parsed.parts) {
257
+ if (typeof part === "string") {
258
+ text += part;
259
+ continue;
260
+ }
261
+ const path = `${part.source}.${part.path}`;
262
+ const format = fields?.find((field) => field.path === path)?.format;
263
+ const value = formatValue(valueAt(part.source === "input" ? values.input : values.data, part.path), format, context);
264
+ if (value === null) return null;
265
+ text += value;
266
+ }
267
+ const rendered = plain(text, Number.MAX_SAFE_INTEGER);
268
+ return rendered && Array.from(rendered).length <= MAX_RENDERED_CHARS ? rendered : null;
269
+ };
270
+
271
+ /** The generic sentence: the title with up to two labelled input fields, in schema order. */
272
+ export const capabilitySentenceFallback = (
273
+ wording: CapabilityActionWording,
274
+ input: unknown,
275
+ context: CapabilitySentenceContext,
276
+ ): string => {
277
+ const details: string[] = [];
278
+ for (const field of wording.fields ?? []) {
279
+ if (details.length >= MAX_FALLBACK_FIELDS) break;
280
+ if (!field.label || !field.path.startsWith("input.")) continue;
281
+ const value = formatValue(valueAt(input, field.path.slice("input.".length)), field.format, context);
282
+ if (value) details.push(`${field.label}: ${value}`);
283
+ }
284
+ return plain([plain(wording.title, MAX_VALUE_CHARS * 2), ...details].join(" · "), MAX_RENDERED_CHARS);
285
+ };
286
+
287
+ /** What the call does, as one sentence: the app's approval sentence, else the generic sentence. */
288
+ export const capabilityActionSubject = (wording: CapabilityActionWording, input: unknown, context: CapabilitySentenceContext): string =>
289
+ (wording.sentences?.approval ? renderCapabilitySentence(wording.sentences.approval, { input }, wording.fields, context) : null) ??
290
+ capabilitySentenceFallback(wording, input, context);
291
+
292
+ /** The app's own sentence for one outcome, or `null` when it has none that renders for this call. */
293
+ export const capabilityActionOutcome = (
294
+ wording: CapabilityActionWording,
295
+ key: Exclude<CapabilitySentenceKey, "approval">,
296
+ values: { input?: unknown; data?: unknown },
297
+ context: CapabilitySentenceContext,
298
+ ): string | null => {
299
+ const template = wording.sentences?.[key];
300
+ return template ? renderCapabilitySentence(template, values, wording.fields, context) : null;
301
+ };
302
+
303
+ /**
304
+ * The sentences of one Action in the requested locale: the most specific locale that words the Action
305
+ * provides the whole set, so one receipt never mixes two languages.
306
+ */
307
+ export const resolveCapabilityActionSentences = (
308
+ localId: string,
309
+ catalog: Pick<CapabilityPresentationCatalog, "baseLocale" | "sentences" | "translations"> | undefined,
310
+ requestedLocale: string,
311
+ ): CapabilityActionSentences | undefined => {
312
+ if (!catalog) return undefined;
313
+ const base = normalizeLocale(catalog.baseLocale);
314
+ const byLocale = new Map(
315
+ Object.entries(catalog.translations).flatMap(([locale, translation]) => {
316
+ const canonical = canonicalLocale(locale);
317
+ return canonical ? [[canonical, translation] as const] : [];
318
+ }),
319
+ );
320
+ for (const locale of localeFallbackChain(requestedLocale, base)) {
321
+ const sentences = locale === base ? catalog.sentences?.[localId] : byLocale.get(locale)?.actions?.[localId]?.sentences;
322
+ if (sentences) return sentences;
323
+ }
324
+ return undefined;
325
+ };
326
+
327
+ type JsonSchema = Record<string, unknown>;
328
+
329
+ /** The schema of one field path, following properties, array items, and union members. */
330
+ const schemaAt = (schema: unknown, path: string): JsonSchema | undefined => {
331
+ let nodes: unknown[] = [schema];
332
+ const expand = (node: unknown): JsonSchema[] => {
333
+ if (!isRecord(node)) return [];
334
+ const members = (["anyOf", "oneOf", "allOf"] as const).flatMap((keyword) => (Array.isArray(node[keyword]) ? node[keyword] : []));
335
+ return [node, ...members.flatMap(expand)];
336
+ };
337
+ for (const segment of path.split(".")) {
338
+ const many = segment.endsWith("[]");
339
+ const key = many ? segment.slice(0, -2) : segment;
340
+ nodes = nodes.flatMap(expand).flatMap((node) => {
341
+ const property = own(node.properties, key);
342
+ if (!many) return property === undefined ? [] : [property];
343
+ return expand(property).flatMap((candidate) => (candidate.items === undefined ? [] : [candidate.items]));
344
+ });
345
+ }
346
+ return nodes.flatMap(expand)[0];
347
+ };
348
+
349
+ const schemaFormat = (schema: JsonSchema | undefined): CapabilitySentenceField["format"] => {
350
+ const nodes = [schema, ...(["anyOf", "oneOf"] as const).flatMap((keyword) => (Array.isArray(schema?.[keyword]) ? schema[keyword] : []))];
351
+ const format = nodes.map((node) => own(node, "format")).find((value) => value === "date" || value === "date-time");
352
+ return format as CapabilitySentenceField["format"];
353
+ };
354
+
355
+ const schemaTypes = (schema: JsonSchema): string[] => {
356
+ const nodes = [schema, ...(["anyOf", "oneOf"] as const).flatMap((keyword) => (Array.isArray(schema[keyword]) ? schema[keyword] : []))];
357
+ return nodes.flatMap((node) => {
358
+ const type = own(node, "type");
359
+ return Array.isArray(type)
360
+ ? type.filter((entry): entry is string => typeof entry === "string")
361
+ : typeof type === "string"
362
+ ? [type]
363
+ : [];
364
+ });
365
+ };
366
+
367
+ /** Identifiers and concurrency tokens say nothing to a person. */
368
+ const OPAQUE_FIELD = /(^id$|Ids?$|Revision$|Hash$|Token$|Cursor$|^idempotencyKey$)/;
369
+ /** Long text such as a message body is not a detail of a one-line sentence. */
370
+ const MAX_DETAIL_TEXT_LENGTH = 1000;
371
+
372
+ /** A field description as a short title: its first clause, if that is short enough to label a value. */
373
+ const shortLabel = (description: unknown): string | undefined => {
374
+ if (typeof description !== "string") return undefined;
375
+ const clause = plain(description, 200)
376
+ .split(/[.;:(]\s|[;:(]|\.$/)[0]!
377
+ .trim();
378
+ return clause && Array.from(clause).length <= MAX_LABEL_CHARS ? clause : undefined;
379
+ };
380
+
381
+ /**
382
+ * The fields a reader needs to word one Action: the formats of the placeholders its sentences use and
383
+ * the labelled top-level input fields that the generic sentence may show, in schema order and bounded.
384
+ * `inputDescriptions` are the reader's localized field descriptions keyed by dotted path.
385
+ */
386
+ export const capabilitySentenceFields = (input: {
387
+ inputSchema: JsonSchema;
388
+ dataSchema?: JsonSchema;
389
+ sentences?: CapabilityActionSentences;
390
+ inputDescriptions?: Readonly<Record<string, string>>;
391
+ }): CapabilitySentenceField[] => {
392
+ const fields = new Map<string, CapabilitySentenceField>();
393
+ const properties = isRecord(input.inputSchema.properties) ? input.inputSchema.properties : {};
394
+ for (const [key, value] of Object.entries(properties)) {
395
+ if (fields.size >= MAX_LABELLED_FIELDS) break;
396
+ if (!isRecord(value) || OPAQUE_FIELD.test(key) || value.format === "uuid") continue;
397
+ const types = schemaTypes(value);
398
+ if (types.length > 0 && types.every((type) => type === "boolean" || type === "null")) continue;
399
+ if (typeof value.maxLength === "number" && value.maxLength > MAX_DETAIL_TEXT_LENGTH) continue;
400
+ const label = shortLabel(input.inputDescriptions?.[key] ?? value.description);
401
+ if (!label) continue;
402
+ const format = schemaFormat(value);
403
+ fields.set(`input.${key}`, { path: `input.${key}`, label, ...(format ? { format } : {}) });
404
+ }
405
+ const templates = CAPABILITY_SENTENCE_KEYS.flatMap((key) => (input.sentences?.[key] ? [input.sentences[key]] : []));
406
+ for (const placeholder of templates.flatMap(capabilitySentencePlaceholders)) {
407
+ const path = `${placeholder.source}.${placeholder.path}`;
408
+ if (fields.has(path)) continue;
409
+ const format = schemaFormat(schemaAt(placeholder.source === "input" ? input.inputSchema : input.dataSchema, placeholder.path));
410
+ fields.set(path, { path, ...(format ? { format } : {}) });
411
+ }
412
+ return [...fields.values()];
413
+ };