@k2b/cloud 0.29.1 → 0.31.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 (136) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/app-roles.ts +14 -0
  4. package/src/_internal/capabilities.ts +113 -5
  5. package/src/_internal/capability-sentences.ts +413 -0
  6. package/src/_internal/define-app.ts +24 -5
  7. package/src/_internal/registry-validation.ts +5 -0
  8. package/src/_internal/registry.ts +50 -6
  9. package/src/_internal/widget-declarations.ts +53 -0
  10. package/src/access/PermissionEditor.tsx +43 -24
  11. package/src/ai/app-skill-store.ts +298 -0
  12. package/src/ai/app-skills.ts +105 -0
  13. package/src/ai/approval-routes.ts +40 -4
  14. package/src/ai/approvals.ts +68 -10
  15. package/src/ai/browser.ts +10 -0
  16. package/src/ai/capabilities.ts +68 -9
  17. package/src/ai/chart-block.ts +380 -0
  18. package/src/ai/chat/action-sentences.ts +49 -0
  19. package/src/ai/chat/blocks.tsx +97 -40
  20. package/src/ai/chat/chart-block.tsx +79 -0
  21. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  22. package/src/ai/chat/message-actions.tsx +3 -1
  23. package/src/ai/chat/message-utils.ts +3 -0
  24. package/src/ai/chat/messages.ts +26 -2
  25. package/src/ai/chat/tool-groups.ts +66 -12
  26. package/src/ai/chat/tool-names.ts +6 -2
  27. package/src/ai/chat/turn-layout.ts +10 -2
  28. package/src/ai/chat/turn-view.tsx +123 -36
  29. package/src/ai/chat/web-tools.tsx +95 -1
  30. package/src/ai/client/controller.ts +6 -3
  31. package/src/ai/code-runtime-tools.ts +109 -61
  32. package/src/ai/code-source-contracts.ts +25 -21
  33. package/src/ai/code-source-tools.ts +3 -1
  34. package/src/ai/default-tool-contracts.ts +198 -0
  35. package/src/ai/default-tools.ts +25 -1
  36. package/src/ai/executor.ts +189 -35
  37. package/src/ai/fetch-file-tool.ts +41 -4
  38. package/src/ai/firecrawl-tools.ts +87 -4
  39. package/src/ai/index.ts +17 -1
  40. package/src/ai/migrate.ts +172 -14
  41. package/src/ai/protocol.ts +22 -1
  42. package/src/ai/routes.ts +11 -0
  43. package/src/ai/runtime-tools.ts +1 -0
  44. package/src/ai/runtime.ts +46 -7
  45. package/src/ai/skill-content.ts +54 -0
  46. package/src/ai/skills-routes.ts +22 -13
  47. package/src/ai/skills.ts +177 -231
  48. package/src/ai/store.ts +66 -1
  49. package/src/ai/tool-audit.ts +11 -1
  50. package/src/ai/tool-sentences.ts +38 -0
  51. package/src/ai/tools.ts +91 -3
  52. package/src/ai/types.ts +47 -1
  53. package/src/ai/web-provenance.ts +103 -0
  54. package/src/api/admin-ai-skills.ts +35 -16
  55. package/src/api/capabilities.ts +3 -2
  56. package/src/api/fanout.ts +128 -0
  57. package/src/api/help.ts +8 -3
  58. package/src/api/mcp.ts +4 -1
  59. package/src/api/search.ts +31 -106
  60. package/src/api/widgets.ts +291 -101
  61. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  62. package/src/browser/CloudResourceSearch.tsx +1 -1
  63. package/src/browser/command-shortcuts.ts +21 -6
  64. package/src/browser/ndjson.ts +34 -0
  65. package/src/browser/reload.ts +46 -0
  66. package/src/browser/search-stream.ts +4 -26
  67. package/src/browser/spotlight-position.ts +4 -1
  68. package/src/browser/widgets.ts +30 -0
  69. package/src/capabilities/executions.ts +3 -0
  70. package/src/capabilities/server.ts +20 -2
  71. package/src/cli/access.ts +48 -14
  72. package/src/cli/admin/ai-skills.ts +59 -39
  73. package/src/cli/commands.ts +20 -0
  74. package/src/cli/index.ts +16 -0
  75. package/src/cli/plugin.ts +30 -0
  76. package/src/contracts/announcement-cookie.ts +62 -0
  77. package/src/contracts/announcements.ts +14 -52
  78. package/src/contracts/app.ts +34 -2
  79. package/src/contracts/capabilities.ts +24 -0
  80. package/src/contracts/pwa-paths.ts +7 -2
  81. package/src/contracts/pwa.ts +2 -7
  82. package/src/contracts/registry.ts +10 -1
  83. package/src/contracts/shared.ts +5 -0
  84. package/src/contracts/widgets.ts +67 -2
  85. package/src/desktop/solid.tsx +6 -7
  86. package/src/index.ts +1 -0
  87. package/src/server/help.ts +8 -1
  88. package/src/server/index.ts +1 -0
  89. package/src/server/widget-request.ts +27 -0
  90. package/src/services/accounts/users.ts +15 -0
  91. package/src/services/approvals/index.ts +225 -0
  92. package/src/services/approvals/migrate.ts +33 -0
  93. package/src/services/help/index.ts +18 -5
  94. package/src/services/help/types.ts +3 -2
  95. package/src/services/identity/constants.ts +2 -0
  96. package/src/services/identity/key-ring.ts +123 -42
  97. package/src/services/identity/runtime-config.ts +13 -2
  98. package/src/shared/app-presentation.ts +11 -0
  99. package/src/shared/email-html.ts +43 -8
  100. package/src/ssr/AdminLayout.tsx +4 -7
  101. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  102. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  103. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  104. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  105. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  106. package/src/ssr/Layout.tsx +38 -14
  107. package/src/ssr/LayoutFooter.tsx +1 -1
  108. package/src/ssr/LayoutHeader.tsx +17 -20
  109. package/src/ssr/LayoutHelp.tsx +1 -1
  110. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  111. package/src/ssr/LayoutRail.tsx +1 -2
  112. package/src/ssr/PageTrail.tsx +23 -0
  113. package/src/ssr/PageTrailRow.island.tsx +20 -0
  114. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  115. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  116. package/src/ssr/PwaRuntime.island.tsx +1 -1
  117. package/src/ssr/app-navigation.ts +5 -7
  118. package/src/ssr/global-search-host.tsx +55 -0
  119. package/src/ssr/help.ts +3 -3
  120. package/src/ssr/islands/index.ts +1 -0
  121. package/src/ssr/layout-runtime.ts +60 -0
  122. package/src/ssr/mobile-menu-history.ts +1 -1
  123. package/src/ssr/page-trail-runtime.ts +313 -0
  124. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  125. package/src/ssr/page-trail.ts +59 -0
  126. package/src/ssr/platform-messages.ts +4 -0
  127. package/src/ssr/rail-context.ts +4 -3
  128. package/src/ssr/runtime.ts +14 -1
  129. package/src/styles/effects.css +21 -4
  130. package/src/styles/global.css +35 -2
  131. package/src/styles/utilities-layout.css +24 -1
  132. package/src/styles/utilities-navigation.css +6 -18
  133. package/src/ai/code-mode-skill.ts +0 -114
  134. package/src/ai/data-analysis-skill.ts +0 -10
  135. package/src/ai/grids-skill.ts +0 -158
  136. package/src/ai/skill-seeds.ts +0 -560
@@ -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
+ };
@@ -1,3 +1,4 @@
1
+ import { type AppSkillDefinition, appSkillManifestHash, registerAppSkills, validateAppSkillCatalog } from "../ai/app-skills";
1
2
  import { startLiveOutbox } from "../events/live";
2
3
  import { registerHelp } from "../services/help";
3
4
  import { preloadLayoutHelp } from "../ssr/help";
@@ -53,6 +54,7 @@ import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-
53
54
  import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
54
55
  import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
55
56
  import { validationErrorResponse } from "../server/middleware/validator";
57
+ import { setWidgetRequest } from "../server/widget-request";
56
58
  import {
57
59
  capabilityInvocationOperation,
58
60
  searchInvocationOperation,
@@ -91,6 +93,7 @@ import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./r
91
93
  import { servePublicAsset } from "./static-assets";
92
94
  import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
93
95
  import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
96
+ import { compileWidgetDeclarations } from "./widget-declarations";
94
97
 
95
98
  /** Cache-busting version stamp — changes on every server start / rebuild. */
96
99
  const v = Date.now();
@@ -167,6 +170,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
167
170
  settings?: S;
168
171
  /** Notification kinds owned by this app, conventionally imported from `src/notifications.ts`. */
169
172
  notifications?: N;
173
+ /** Assistant skills owned by this app, conventionally imported from `src/skills.ts`. */
174
+ skills?: readonly AppSkillDefinition[];
170
175
  /**
171
176
  * Legal/info links contributed by this app — aggregated app-wide via
172
177
  * `listLegalLinks()` and rendered in login footer, app Footer, rail more
@@ -375,6 +380,8 @@ export const defineApp = <
375
380
  >(
376
381
  opts: AppOptions<S, N, AppId>,
377
382
  ): AppDefinition<S, N, AppId> => {
383
+ const appSkills = opts.skills ? validateAppSkillCatalog(opts.skills) : undefined;
384
+ const skillsManifestHash = appSkills ? appSkillManifestHash(appSkills) : undefined;
378
385
  configurePostgresApplicationName(opts.id);
379
386
  const isDevelopment = env.IS_DEVELOPMENT;
380
387
  const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
@@ -510,7 +517,7 @@ export const defineApp = <
510
517
  legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
511
518
  searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
512
519
  pwa: pwaPart,
513
- widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
520
+ widgets: compileWidgetDeclarations(opts.id, opts.widgets),
514
521
  settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
515
522
  openapi: opts.openapi,
516
523
  cli: opts.cli,
@@ -603,12 +610,17 @@ export const defineApp = <
603
610
  }
604
611
  : undefined,
605
612
  help: compiledHelp?.summary,
613
+ skills: skillsManifestHash ? { manifestHash: skillsManifestHash } : undefined,
606
614
  legalLinks: meta.legalLinks ? meta.legalLinks.map((l) => ({ ...l })) : undefined,
607
615
  searchLinks: meta.searchLinks,
608
616
  pwa: meta.pwa
609
617
  ? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
610
618
  : undefined,
611
- widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
619
+ widgets: meta.widgets?.map((widget) => ({
620
+ ...widget,
621
+ sizes: widget.sizes ? [...widget.sizes] : undefined,
622
+ requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
623
+ })),
612
624
  platformPermissions: meta.platformPermissions ? [...meta.platformPermissions] : undefined,
613
625
  settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
614
626
  openapi: advertiseOpenapi ? opts.openapi : undefined,
@@ -618,7 +630,10 @@ export const defineApp = <
618
630
  // Heartbeat
619
631
  const heartbeat = createHeartbeat(meta.id, entry, {
620
632
  registry: appRegistry(),
621
- beforeWrite: compiledHelp ? () => registerHelp(compiledHelp.corpus) : undefined,
633
+ beforeWrite: async () => {
634
+ if (compiledHelp) await registerHelp(compiledHelp.corpus);
635
+ if (appSkills && skillsManifestHash) await registerAppSkills(meta.id, appSkills, skillsManifestHash);
636
+ },
622
637
  onError: (error) =>
623
638
  log.error("Registry heartbeat failed", {
624
639
  appId: meta.id,
@@ -854,8 +869,12 @@ export const defineApp = <
854
869
  runtimeMiddleware(),
855
870
  settingsMiddleware(),
856
871
  async (c) => {
857
- const handler = startOpts.widgets?.[c.req.param("widgetId") ?? ""];
858
- return handler ? handler(c, async () => {}) : c.json({ message: "Widget not found" }, 404);
872
+ const widgetId = c.req.param("widgetId") ?? "";
873
+ const handler = startOpts.widgets?.[widgetId];
874
+ const declaration = meta.widgets?.find((widget) => widget.id === widgetId);
875
+ if (!handler || !declaration) return c.json({ message: "Widget not found" }, 404);
876
+ setWidgetRequest(c, declaration);
877
+ return handler(c, async () => {});
859
878
  },
860
879
  );
861
880
  }
@@ -166,6 +166,11 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
166
166
  return invalid("capabilities", "a valid capability summary");
167
167
  }
168
168
  }
169
+ if (
170
+ value.skills !== undefined &&
171
+ (!isRecord(value.skills) || !isString(value.skills.manifestHash) || !/^[a-f0-9]{64}$/.test(value.skills.manifestHash))
172
+ )
173
+ return invalid("skills", "a valid app skill manifest hash");
169
174
  if (
170
175
  value.help !== undefined &&
171
176
  (!isRecord(value.help) ||
@@ -3,7 +3,14 @@ import type { AppAppearanceColor } from "../contracts/app";
3
3
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
4
4
  import { fileProviderIssues } from "../contracts/file-provider";
5
5
  import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
6
- import type { DashboardWidgetPresentation } from "../contracts/widgets";
6
+ import { type Role, RoleSchema } from "../contracts/shared";
7
+ import {
8
+ type DashboardWidgetPresentation,
9
+ type DashboardWidgetSize,
10
+ resolveWidgetSizes,
11
+ WIDGET_DESCRIPTION_MAX_LENGTH,
12
+ WIDGET_TITLE_MAX_LENGTH,
13
+ } from "../contracts/widgets";
7
14
  import { resolveAppPresentations } from "../shared/app-presentation";
8
15
  import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
9
16
  import { lazySync } from "./process-sync";
@@ -332,7 +339,9 @@ export const listLegalLinks = async (locale?: string): Promise<Array<{ label: st
332
339
  * the dashboard app to build the widget grid and for Core to resolve the
333
340
  * exact target before proxying a widget request.
334
341
  *
335
- * Order = registration order across apps.
342
+ * Order = registration order across apps. Every optional declaration field is
343
+ * resolved here, so readers never see a registry record from an older app
344
+ * without `title`, `sizes`, or `defaultSize`, nor an unknown size or role.
336
345
  */
337
346
  export type DashboardWidget = {
338
347
  appId: string;
@@ -341,23 +350,58 @@ export type DashboardWidget = {
341
350
  widgetId: string;
342
351
  /** Fully-qualified URL — `<baseUrl>/<path>`. */
343
352
  url: string;
353
+ /** The declared title in the requested locale, or the app's name. */
354
+ title: string;
355
+ /** The declared description in the requested locale, or the app's description. */
356
+ description: string;
357
+ sizes: DashboardWidgetSize[];
358
+ defaultSize: DashboardWidgetSize;
359
+ suggest: boolean;
360
+ /** Visibility only, like `nav.requiresRoles`; empty means everyone. */
361
+ requiresRoles: Role[];
362
+ /**
363
+ * @deprecated The zone and width the app recommended before widgets had sizes. Only the dashboard reads it, to
364
+ * convert a board saved then into the board the person saw.
365
+ */
344
366
  presentation?: DashboardWidgetPresentation;
345
367
  };
346
368
 
347
- export const listWidgets = async (): Promise<DashboardWidget[]> => {
348
- const apps = await listApps();
369
+ const LEGACY_ZONES = ["focus", "overview", "context"] as const;
370
+ const LEGACY_SPANS = ["standard", "wide"] as const;
371
+
372
+ const legacyPresentation = (value: DashboardWidgetPresentation | undefined): DashboardWidgetPresentation | undefined => {
373
+ const defaultZone = LEGACY_ZONES.find((zone) => zone === value?.defaultZone);
374
+ const defaultSpan = LEGACY_SPANS.find((span) => span === value?.defaultSpan);
375
+ return defaultZone || defaultSpan ? { defaultZone, defaultSpan } : undefined;
376
+ };
377
+
378
+ const declaredText = (value: unknown, max: number): string | undefined =>
379
+ typeof value === "string" && value.trim() && value.trim().length <= max ? value.trim() : undefined;
380
+
381
+ /** The widgets `apps` declare, resolved as `listWidgets()` returns them. */
382
+ export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: string): DashboardWidget[] => {
349
383
  const out: DashboardWidget[] = [];
350
- for (const app of apps) {
384
+ for (const app of locale ? resolveAppPresentations(apps, locale) : apps) {
351
385
  for (const w of app.widgets ?? []) {
386
+ const { sizes, defaultSize } = resolveWidgetSizes(w);
352
387
  out.push({
353
388
  appId: app.id,
354
389
  appName: app.name,
355
390
  appIcon: app.icon,
356
391
  widgetId: w.id,
357
392
  url: `${app.baseUrl.replace(/\/$/, "")}${w.path.startsWith("/") ? w.path : `/${w.path}`}`,
358
- presentation: w.presentation,
393
+ title: declaredText(w.title, WIDGET_TITLE_MAX_LENGTH) ?? app.name,
394
+ description: declaredText(w.description, WIDGET_DESCRIPTION_MAX_LENGTH) ?? app.description,
395
+ sizes,
396
+ defaultSize,
397
+ suggest: w.suggest === true,
398
+ requiresRoles: Array.isArray(w.requiresRoles) ? w.requiresRoles.filter((role) => RoleSchema.safeParse(role).success) : [],
399
+ presentation: legacyPresentation(w.presentation),
359
400
  });
360
401
  }
361
402
  }
362
403
  return out;
363
404
  };
405
+
406
+ /** Every declared widget, with titles and descriptions in `locale` when given. */
407
+ export const listWidgets = async (locale?: string): Promise<DashboardWidget[]> => dashboardWidgetsOf(await listApps(), locale);
@@ -0,0 +1,53 @@
1
+ import type { WidgetEndpoint } from "../contracts/app";
2
+ import { RoleSchema } from "../contracts/shared";
3
+ import {
4
+ DASHBOARD_WIDGET_SIZES,
5
+ isDashboardWidgetSize,
6
+ WIDGET_DESCRIPTION_MAX_LENGTH,
7
+ WIDGET_TITLE_MAX_LENGTH,
8
+ } from "../contracts/widgets";
9
+
10
+ const text = (value: string | undefined, label: string, max: number): string | undefined => {
11
+ if (value === undefined) return undefined;
12
+ const normalized = value.trim();
13
+ if (!normalized || normalized.length > max) throw new Error(`${label} must contain 1 to ${max} characters`);
14
+ return normalized;
15
+ };
16
+
17
+ /**
18
+ * Validates and copies an app's widget declarations at `defineApp()`, so a typo fails at startup instead of a widget
19
+ * silently missing from the gallery: unique ids without `@`, which separates a widget's key from the size the
20
+ * dashboard asks for, bounded titles and descriptions, known sizes with the default among them, a boolean `suggest`,
21
+ * and known roles.
22
+ */
23
+ export const compileWidgetDeclarations = (
24
+ appId: string,
25
+ widgets: ReadonlyArray<WidgetEndpoint> | undefined,
26
+ ): WidgetEndpoint[] | undefined => {
27
+ if (!widgets) return undefined;
28
+ const ids = new Set<string>();
29
+ return widgets.map((widget) => {
30
+ const label = `Widget ${JSON.stringify(widget.id)} of app ${JSON.stringify(appId)}`;
31
+ if (!widget.id.trim()) throw new Error(`Widgets of app ${JSON.stringify(appId)} need a non-empty id`);
32
+ if (widget.id.includes("@")) throw new Error(`${label} must not contain "@"`);
33
+ if (ids.has(widget.id)) throw new Error(`${label} is declared twice`);
34
+ ids.add(widget.id);
35
+ const sizes = widget.sizes ? [...widget.sizes] : undefined;
36
+ if (sizes && (sizes.length === 0 || sizes.some((size) => !isDashboardWidgetSize(size)) || new Set(sizes).size !== sizes.length)) {
37
+ throw new Error(`${label} must declare sizes as distinct values of ${DASHBOARD_WIDGET_SIZES.join(", ")}`);
38
+ }
39
+ if (widget.defaultSize !== undefined && !(sizes ?? ["large"]).includes(widget.defaultSize)) {
40
+ throw new Error(`${label} must declare a defaultSize it offers in sizes`);
41
+ }
42
+ if (widget.suggest !== undefined && typeof widget.suggest !== "boolean")
43
+ throw new Error(`${label} must declare suggest as true or false`);
44
+ if (widget.requiresRoles?.some((role) => !RoleSchema.safeParse(role).success)) throw new Error(`${label} requires an unknown role`);
45
+ return {
46
+ ...widget,
47
+ title: text(widget.title, `${label} title`, WIDGET_TITLE_MAX_LENGTH),
48
+ description: text(widget.description, `${label} description`, WIDGET_DESCRIPTION_MAX_LENGTH),
49
+ sizes: sizes ? DASHBOARD_WIDGET_SIZES.filter((size) => sizes.includes(size)) : undefined,
50
+ requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
51
+ };
52
+ });
53
+ };