@k2b/cloud 0.32.0 → 0.34.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 (113) hide show
  1. package/package.json +5 -5
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +18 -8
  4. package/src/_internal/page-responses.ts +5 -1
  5. package/src/_internal/registry.ts +10 -1
  6. package/src/_internal/widget-declarations.ts +16 -3
  7. package/src/_internal/widget-settings.ts +93 -0
  8. package/src/ai/capabilities.ts +8 -5
  9. package/src/ai/code-source-contracts.ts +17 -2
  10. package/src/ai/code-source-tools.ts +1 -0
  11. package/src/ai/default-tools.ts +4 -4
  12. package/src/ai/memories.ts +48 -1
  13. package/src/ai/memory-learning.ts +17 -6
  14. package/src/ai/memory-tool.ts +1 -0
  15. package/src/ai/migrate.ts +8 -0
  16. package/src/ai/provider.ts +4 -3
  17. package/src/ai/resource-refs.ts +35 -3
  18. package/src/ai/system-prompt.ts +3 -2
  19. package/src/api/index.ts +2 -0
  20. package/src/api/mcp.ts +48 -17
  21. package/src/api/pwa.ts +7 -2
  22. package/src/api/resource-links.ts +43 -0
  23. package/src/api/widgets.ts +163 -82
  24. package/src/browser/CloudResourceSearch.tsx +6 -6
  25. package/src/browser/command-shortcuts.ts +2 -11
  26. package/src/browser/navigation-search.ts +4 -1
  27. package/src/browser/resource-picker.tsx +1 -0
  28. package/src/browser/resource-search.ts +7 -2
  29. package/src/browser/widgets.ts +23 -5
  30. package/src/capabilities/testing.ts +13 -1
  31. package/src/cli/capabilities.ts +61 -17
  32. package/src/cli/index.ts +2 -0
  33. package/src/cli/resource-links.ts +13 -0
  34. package/src/contracts/app.ts +7 -1
  35. package/src/contracts/capabilities.ts +25 -5
  36. package/src/contracts/registry.ts +3 -1
  37. package/src/contracts/resource-href.ts +7 -0
  38. package/src/contracts/widgets.ts +80 -6
  39. package/src/desktop/index.ts +3 -2
  40. package/src/desktop/solid.tsx +7 -5
  41. package/src/server/widget-request.ts +34 -5
  42. package/src/services/app-approval.ts +4 -2
  43. package/src/services/approvals/index.ts +40 -11
  44. package/src/services/approvals/list.ts +1 -0
  45. package/src/services/approvals/migrate.ts +10 -0
  46. package/src/services/outbox.ts +13 -6
  47. package/src/services/pdf/markdown.ts +11 -7
  48. package/src/services/pwa-devices.ts +68 -15
  49. package/src/shared/ai-platform-prompt.ts +3 -2
  50. package/src/shared/app-presentation.ts +49 -1
  51. package/src/shared/index.ts +1 -0
  52. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  53. package/src/shared/markdown/index.ts +8 -2
  54. package/src/shared/theme.ts +41 -20
  55. package/src/ssr/AnnouncementBanners.tsx +30 -0
  56. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  57. package/src/ssr/CloudShell.island.tsx +91 -0
  58. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  59. package/src/ssr/Layout.tsx +58 -99
  60. package/src/ssr/LayoutHeader.tsx +31 -77
  61. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  62. package/src/ssr/LayoutHelp.tsx +319 -238
  63. package/src/ssr/LayoutHelpTrigger.tsx +49 -0
  64. package/src/ssr/LayoutPreferences.tsx +31 -0
  65. package/src/ssr/LayoutRail.tsx +15 -69
  66. package/src/ssr/MinimalLayout.tsx +2 -3
  67. package/src/ssr/MinimalLayoutPreferences.island.tsx +6 -15
  68. package/src/ssr/MobileNavigation.tsx +13 -8
  69. package/src/ssr/MobileProfileActions.tsx +4 -6
  70. package/src/ssr/PageTrailRow.tsx +14 -0
  71. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +8 -5
  72. package/src/ssr/PwaLayout.tsx +0 -2
  73. package/src/ssr/PwaRuntime.island.tsx +1 -1
  74. package/src/ssr/ShellRuntime.island.tsx +166 -0
  75. package/src/ssr/ThemeCycleButton.tsx +46 -0
  76. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  77. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  78. package/src/ssr/announcements-dialog.tsx +66 -0
  79. package/src/ssr/app-appearance.ts +6 -1
  80. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  81. package/src/ssr/busy-action.ts +19 -0
  82. package/src/ssr/islands/index.ts +9 -6
  83. package/src/ssr/launchpad-surface.tsx +15 -0
  84. package/src/ssr/layout-help-registry.tsx +60 -0
  85. package/src/ssr/layout-help-url.ts +39 -0
  86. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  87. package/src/ssr/platform-messages.ts +0 -8
  88. package/src/ssr/preference-controller.ts +62 -19
  89. package/src/ssr/profile-actions.ts +12 -5
  90. package/src/ssr/profile-preferences-messages.ts +10 -4
  91. package/src/ssr/rail-context.ts +8 -12
  92. package/src/ssr/rail-navigation.ts +3 -0
  93. package/src/ssr/shell-data.tsx +48 -0
  94. package/src/ssr/timezone-cookie.ts +22 -0
  95. package/src/ssr/workspace-navigation.ts +25 -2
  96. package/src/styles/effects.css +13 -3
  97. package/src/styles/global.css +43 -5
  98. package/src/styles/input.css +0 -8
  99. package/src/styles/tokens.css +0 -2
  100. package/src/styles/utilities-navigation.css +10 -0
  101. package/src/workflows/language/compiler.ts +1 -1
  102. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  103. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  104. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  105. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
  106. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  107. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  108. package/src/ssr/LayoutPreferences.island.tsx +0 -21
  109. package/src/ssr/PageTrailRow.island.tsx +0 -20
  110. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  111. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  112. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
  113. /package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.32.0",
3
+ "version": "0.34.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": {
@@ -105,10 +105,10 @@
105
105
  "@simplewebauthn/server": "14.0.3",
106
106
  "@tabler/icons": "3.48.0",
107
107
  "@tailwindcss/typography": "0.5.20",
108
- "@k2b/nessi": "0.17.1",
109
- "@k2b/ssr": "0.15.0",
110
- "@k2b/ui": "0.23.0",
111
- "@k2b/stdlib": "0.28.0",
108
+ "@k2b/nessi": "0.18.0",
109
+ "@k2b/ssr": "0.16.0",
110
+ "@k2b/ui": "0.25.0",
111
+ "@k2b/stdlib": "0.29.0",
112
112
  "@k2b/sync": "7.0.0",
113
113
  "@nats-io/jetstream": "3.4.0",
114
114
  "@nats-io/transport-node": "3.4.0",
@@ -1,4 +1,4 @@
1
- import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation } from "../contracts/app";
1
+ import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation, WidgetEndpoint } from "../contracts/app";
2
2
  import { WIDGET_DESCRIPTION_MAX_LENGTH, WIDGET_TITLE_MAX_LENGTH } from "../contracts/widgets";
3
3
  import { canonicalLocale } from "../shared/locale";
4
4
 
@@ -14,23 +14,53 @@ const text = (value: string, label: string, max = MAX_SHORT_TEXT): string => {
14
14
 
15
15
  const labels = (values: Readonly<Record<string, string>> | undefined, allowed: Set<string>, label: string, max = MAX_SHORT_TEXT) => {
16
16
  if (!values) return undefined;
17
- const normalized: Record<string, string> = {};
18
- for (const [key, value] of Object.entries(values)) {
19
- if (!allowed.has(key)) throw new Error(`${label} contains unknown key ${JSON.stringify(key)}`);
20
- normalized[key] = text(value, `${label}.${key}`, max);
21
- }
22
- return normalized;
17
+ return Object.fromEntries(
18
+ Object.entries(values).map(([key, value]) => {
19
+ if (!allowed.has(key)) throw new Error(`${label} contains unknown key ${JSON.stringify(key)}`);
20
+ return [key, text(value, `${label}.${key}`, max)];
21
+ }),
22
+ );
23
23
  };
24
24
 
25
25
  const widgetTexts = (
26
26
  values: Readonly<Record<string, AppWidgetTranslation>>,
27
- allowed: Set<string>,
27
+ widgets: readonly WidgetEndpoint[],
28
28
  label: string,
29
29
  ): Record<string, AppWidgetTranslation> => {
30
30
  const normalized: Record<string, AppWidgetTranslation> = {};
31
31
  for (const [id, value] of Object.entries(values)) {
32
- if (!allowed.has(id)) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
32
+ const widget = widgets.find((widget) => widget.id === id);
33
+ if (!widget) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
34
+ const settings: NonNullable<AppWidgetTranslation["settings"]> = Object.fromEntries(
35
+ Object.entries(value.settings ?? {}).map(([name, translation]) => {
36
+ const field = widget.settings?.[name];
37
+ const settingLabel = `${label}.${id}.settings.${name}`;
38
+ if (!field) throw new Error(`${settingLabel} is not declared`);
39
+ return [
40
+ name,
41
+ {
42
+ ...(translation.label !== undefined
43
+ ? { label: text(translation.label, `${settingLabel}.label`, WIDGET_TITLE_MAX_LENGTH) }
44
+ : {}),
45
+ ...(translation.placeholder !== undefined
46
+ ? { placeholder: text(translation.placeholder, `${settingLabel}.placeholder`, WIDGET_TITLE_MAX_LENGTH) }
47
+ : {}),
48
+ ...(translation.options
49
+ ? {
50
+ options: labels(
51
+ translation.options,
52
+ new Set(field.kind === "choice" ? field.options.map((option) => option.value) : []),
53
+ `${settingLabel}.options`,
54
+ WIDGET_TITLE_MAX_LENGTH,
55
+ ),
56
+ }
57
+ : {}),
58
+ },
59
+ ];
60
+ }),
61
+ );
33
62
  normalized[id] = {
63
+ ...(value.settings ? { settings } : {}),
34
64
  ...(value.title !== undefined ? { title: text(value.title, `${label}.${id}.title`, WIDGET_TITLE_MAX_LENGTH) } : {}),
35
65
  ...(value.description !== undefined
36
66
  ? { description: text(value.description, `${label}.${id}.description`, WIDGET_DESCRIPTION_MAX_LENGTH) }
@@ -55,7 +85,6 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
55
85
  const adminHrefs = new Set((app.adminNav ?? []).flatMap((group) => group.links.map((link) => link.href)));
56
86
  const searchHrefs = new Set((app.searchLinks ?? []).map((link) => link.href));
57
87
  const legalHrefs = new Set((app.legalLinks ?? []).map((link) => link.href));
58
- const widgetIds = new Set((app.widgets ?? []).map((widget) => widget.id));
59
88
  const translations: Record<string, AppPresentationTranslation> = {};
60
89
 
61
90
  for (const [locale, translation] of Object.entries(catalog.translations)) {
@@ -82,7 +111,7 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
82
111
  }
83
112
  : {}),
84
113
  ...(translation.legalLinks ? { legalLinks: labels(translation.legalLinks, legalHrefs, `${canonical}.legalLinks`) } : {}),
85
- ...(translation.widgets ? { widgets: widgetTexts(translation.widgets, widgetIds, `${canonical}.widgets`) } : {}),
114
+ ...(translation.widgets ? { widgets: widgetTexts(translation.widgets, app.widgets ?? [], `${canonical}.widgets`) } : {}),
86
115
  };
87
116
  }
88
117
 
@@ -74,7 +74,7 @@ import { createSyncOpsRoutes } from "../services/sync-ops";
74
74
  import { capabilityMessages } from "../shared/capability-messages";
75
75
  import { normalizeLocale } from "../shared/locale";
76
76
  import { escapeHtml } from "../shared/markdown/shared";
77
- import { themeBootstrapScript } from "../shared/theme";
77
+ import { type CloudThemePreference, readThemePreferenceFromCookieHeader, themeBootstrapScript } from "../shared/theme";
78
78
  import { appFaviconHref } from "./app-favicon";
79
79
  import { compileAppPresentation } from "./app-presentation";
80
80
  import { readBoundedJson } from "./bounded-json";
@@ -102,6 +102,7 @@ const v = Date.now();
102
102
  export type PageOptions = {
103
103
  title?: string;
104
104
  description?: string;
105
+ /** Fix public displays such as Pulse, Weather and Venue monitors to one theme; otherwise follow the person's preference. */
105
106
  theme?: "light" | "dark";
106
107
  /** Framework-resolved BCP 47 document language — always the canonical request locale, never a page override. */
107
108
  lang?: string;
@@ -109,7 +110,7 @@ export type PageOptions = {
109
110
  pwa?: { name: string };
110
111
  };
111
112
 
112
- type ResolvedPageOptions = PageOptions & { performanceRoute?: string };
113
+ type ResolvedPageOptions = PageOptions & { performanceRoute?: string; themePreference?: CloudThemePreference };
113
114
 
114
115
  // ── Public types ────────────────────────────────────────────────────────────
115
116
 
@@ -411,17 +412,24 @@ export const defineApp = <
411
412
  // Every island instance already mounts inside its own error boundary; this
412
413
  // only gives the fallback Cloud's look and the reader's language.
413
414
  errorFallback: fileURLToPath(new URL("../ssr/IslandError.tsx", import.meta.url)),
414
- template: ({ body, scripts, title, description, theme, lang, performanceRoute, pwa }) => {
415
- const themeFixed = theme !== undefined;
415
+ template: ({ body, scripts, title, description, theme, themePreference, lang, performanceRoute, pwa }) => {
416
+ const fixed = theme !== undefined;
417
+ const resolved = theme ?? (themePreference === "light" || themePreference === "dark" ? themePreference : undefined);
416
418
  // App pages paint the canvas before any stylesheet and give it to the
417
419
  // status bar. No status-bar style: `black-translucent` lays a blur band
418
420
  // over the top edge on iOS 26.
419
- const canvas = PWA_CANVAS_COLORS[theme ?? "light"];
421
+ const canvas = resolved ? PWA_CANVAS_COLORS[resolved] : undefined;
420
422
  const head = pwa
421
423
  ? {
422
- html: ` style="background-color:${canvas}"`,
424
+ html: canvas ? ` style="background-color:${canvas}"` : "",
423
425
  viewport: "width=device-width, initial-scale=1, viewport-fit=cover",
424
- app: `<meta name="theme-color" content="${canvas}">
426
+ app: `${
427
+ canvas
428
+ ? `<meta name="theme-color" content="${canvas}">`
429
+ : `<meta name="theme-color" media="(prefers-color-scheme: light)" content="${PWA_CANVAS_COLORS.light}">
430
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="${PWA_CANVAS_COLORS.dark}">
431
+ <style data-cloud-canvas>html{background-color:${PWA_CANVAS_COLORS.light}}@media (prefers-color-scheme: dark){html{background-color:${PWA_CANVAS_COLORS.dark}}}</style>`
432
+ }
425
433
  <link rel="manifest" href="${PWA_MANIFEST_PATH}">
426
434
  <link rel="apple-touch-icon" href="/branding/apple-touch-icon.png">
427
435
  <meta name="apple-mobile-web-app-title" content="${escapeHtml(pwa.name)}">`,
@@ -442,7 +450,7 @@ export const defineApp = <
442
450
  // stylesheet declares a layer. Tailwind's `properties` layer resets
443
451
  // `--tw-*` in browsers without `@property` and must stay lowest.
444
452
  return `<!DOCTYPE html>
445
- <html lang="${normalizeLocale(lang)}" class="${theme ?? "light"}"${themeFixed ? " data-theme-fixed" : ""}${head.html}>
453
+ <html lang="${normalizeLocale(lang)}" class="${resolved ?? "light"}"${fixed ? " data-theme-fixed" : ""}${head.html}>
446
454
  <head><meta charset="UTF-8"><meta name="viewport" content="${head.viewport}">
447
455
  <meta name="view-transition" content="same-origin">
448
456
  <title>${escapeHtml(title ?? "Cloud")}</title>
@@ -480,6 +488,7 @@ export const defineApp = <
480
488
  const ssr = Object.assign(
481
489
  createStatusPreservingSsrHandler<ResolvedPageOptions>(html, async (c) => {
482
490
  c.get("page").lang = getLocale(c);
491
+ c.get("page").themePreference = readThemePreferenceFromCookieHeader(c.req.header("Cookie"));
483
492
  const user = c.get("user");
484
493
  c.get("page").performanceRoute =
485
494
  user && c.get("settings")?.observability?.web_vitals?.enabled === true ? (matchedRouteTemplate(c) ?? undefined) : undefined;
@@ -620,6 +629,7 @@ export const defineApp = <
620
629
  : undefined,
621
630
  widgets: meta.widgets?.map((widget) => ({
622
631
  ...widget,
632
+ ...(widget.settings ? { settings: structuredClone(widget.settings) } : {}),
623
633
  sizes: widget.sizes ? [...widget.sizes] : undefined,
624
634
  requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
625
635
  })),
@@ -5,6 +5,7 @@ import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa";
5
5
  import { getLocale } from "../server/locale";
6
6
  import { preloadLayoutAnnouncements } from "../server/middleware/settings";
7
7
  import { createLoginRedirectUrl } from "../shared/redirect";
8
+ import { readThemePreferenceFromCookieHeader } from "../shared/theme";
8
9
  import type { PageOptions } from "./define-app";
9
10
 
10
11
  export type PageErrorOptions = {
@@ -21,7 +22,10 @@ export type PageErrorStatus = ClientErrorStatusCode | ServerErrorStatusCode;
21
22
  export const createPageResponses = (html: HtmlFn<PageOptions>) => {
22
23
  const error = async (c: Context, status: PageErrorStatus, options: PageErrorOptions = {}): Promise<Response> => {
23
24
  // Auth middleware may reject before ssr() initializes page metadata.
24
- c.set("page", { lang: getLocale(c) });
25
+ c.set("page", {
26
+ lang: getLocale(c),
27
+ themePreference: readThemePreferenceFromCookieHeader(c.req.header("Cookie")),
28
+ });
25
29
  c.header("Cache-Control", "private, no-store");
26
30
  if (options.layout !== "pwa") await preloadLayoutAnnouncements(c);
27
31
  // Load JSX only after the application's SSR plugin has been installed.
@@ -10,12 +10,14 @@ import {
10
10
  resolveWidgetSizes,
11
11
  WIDGET_DESCRIPTION_MAX_LENGTH,
12
12
  WIDGET_TITLE_MAX_LENGTH,
13
+ type WidgetSettingsDeclaration,
13
14
  } from "../contracts/widgets";
14
15
  import { resolveAppPresentations } from "../shared/app-presentation";
15
16
  import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
16
17
  import { lazySync } from "./process-sync";
17
18
  import { validateAppRegistryEntry } from "./registry-validation";
18
19
  import { watchRegistryChanges } from "./registry-watch";
20
+ import { WidgetSettingsDeclarationSchema } from "./widget-settings";
19
21
 
20
22
  /**
21
23
  * Shared app registry: two @k2b/sync ephemerals on the process Sync
@@ -356,6 +358,8 @@ export type DashboardWidget = {
356
358
  description: string;
357
359
  sizes: DashboardWidgetSize[];
358
360
  defaultSize: DashboardWidgetSize;
361
+ /** Validated setting presentation in the requested locale, absent when none is valid. */
362
+ settings?: WidgetSettingsDeclaration;
359
363
  suggest: boolean;
360
364
  /** Visibility only, like `nav.requiresRoles`; empty means everyone. */
361
365
  requiresRoles: Role[];
@@ -381,9 +385,13 @@ const declaredText = (value: unknown, max: number): string | undefined =>
381
385
  /** The widgets `apps` declare, resolved as `listWidgets()` returns them. */
382
386
  export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: string): DashboardWidget[] => {
383
387
  const out: DashboardWidget[] = [];
384
- for (const app of locale ? resolveAppPresentations(apps, locale) : apps) {
388
+ for (const [index, app] of (locale ? resolveAppPresentations(apps, locale) : apps).entries()) {
385
389
  for (const w of app.widgets ?? []) {
386
390
  const { sizes, defaultSize } = resolveWidgetSizes(w);
391
+ const translated = WidgetSettingsDeclarationSchema.safeParse(w.settings);
392
+ const original = translated.success
393
+ ? translated
394
+ : WidgetSettingsDeclarationSchema.safeParse(apps[index]?.widgets?.find((widget) => widget.id === w.id)?.settings);
387
395
  out.push({
388
396
  appId: app.id,
389
397
  appName: app.name,
@@ -394,6 +402,7 @@ export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: s
394
402
  description: declaredText(w.description, WIDGET_DESCRIPTION_MAX_LENGTH) ?? app.description,
395
403
  sizes,
396
404
  defaultSize,
405
+ ...(original.success ? { settings: original.data } : {}),
397
406
  suggest: w.suggest === true,
398
407
  requiresRoles: Array.isArray(w.requiresRoles) ? w.requiresRoles.filter((role) => RoleSchema.safeParse(role).success) : [],
399
408
  presentation: legacyPresentation(w.presentation),
@@ -4,8 +4,10 @@ import {
4
4
  DASHBOARD_WIDGET_SIZES,
5
5
  isDashboardWidgetSize,
6
6
  WIDGET_DESCRIPTION_MAX_LENGTH,
7
+ WIDGET_ID_MAX_LENGTH,
7
8
  WIDGET_TITLE_MAX_LENGTH,
8
9
  } from "../contracts/widgets";
10
+ import { WidgetSettingsDeclarationSchema } from "./widget-settings";
9
11
 
10
12
  const text = (value: string | undefined, label: string, max: number): string | undefined => {
11
13
  if (value === undefined) return undefined;
@@ -16,9 +18,9 @@ const text = (value: string | undefined, label: string, max: number): string | u
16
18
 
17
19
  /**
18
20
  * 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.
21
+ * silently missing from the gallery: unique ids that fit Core's `widget.read:<id>` invocation, without `@`, which
22
+ * separates a widget's key from the requested size, bounded titles and descriptions, known sizes with the default
23
+ * among them, a boolean `suggest`, known roles, and a bounded settings declaration.
22
24
  */
23
25
  export const compileWidgetDeclarations = (
24
26
  appId: string,
@@ -30,6 +32,11 @@ export const compileWidgetDeclarations = (
30
32
  const label = `Widget ${JSON.stringify(widget.id)} of app ${JSON.stringify(appId)}`;
31
33
  if (!widget.id.trim()) throw new Error(`Widgets of app ${JSON.stringify(appId)} need a non-empty id`);
32
34
  if (widget.id.includes("@")) throw new Error(`${label} must not contain "@"`);
35
+ if (!/^[a-z0-9:._-]+$/.test(widget.id) || widget.id.length > WIDGET_ID_MAX_LENGTH) {
36
+ throw new Error(
37
+ `${label} id must use 1 to ${WIDGET_ID_MAX_LENGTH} lowercase letters, digits, ".", "_", "-", or ":" because Core invokes it as widget.read:<id>`,
38
+ );
39
+ }
33
40
  if (ids.has(widget.id)) throw new Error(`${label} is declared twice`);
34
41
  ids.add(widget.id);
35
42
  const sizes = widget.sizes ? [...widget.sizes] : undefined;
@@ -42,8 +49,14 @@ export const compileWidgetDeclarations = (
42
49
  if (widget.suggest !== undefined && typeof widget.suggest !== "boolean")
43
50
  throw new Error(`${label} must declare suggest as true or false`);
44
51
  if (widget.requiresRoles?.some((role) => !RoleSchema.safeParse(role).success)) throw new Error(`${label} requires an unknown role`);
52
+ const settings = widget.settings === undefined ? undefined : WidgetSettingsDeclarationSchema.safeParse(widget.settings);
53
+ if (settings && !settings.success) {
54
+ const issue = settings.error.issues[0]!;
55
+ throw new Error(`${label} setting ${JSON.stringify(issue.path[0] ?? "settings")}: ${issue.message}`);
56
+ }
45
57
  return {
46
58
  ...widget,
59
+ ...(settings?.success ? { settings: settings.data } : {}),
47
60
  title: text(widget.title, `${label} title`, WIDGET_TITLE_MAX_LENGTH),
48
61
  description: text(widget.description, `${label} description`, WIDGET_DESCRIPTION_MAX_LENGTH),
49
62
  sizes: sizes ? DASHBOARD_WIDGET_SIZES.filter((size) => sizes.includes(size)) : undefined,
@@ -0,0 +1,93 @@
1
+ import { z } from "zod";
2
+ import { CapabilityQualifiedIdSchema, CloudResourceRefSchema } from "../contracts/capabilities";
3
+ import {
4
+ WIDGET_SETTING_MAX_OPTIONS,
5
+ WIDGET_SETTING_NAME_MAX_LENGTH,
6
+ WIDGET_SETTING_TEXT_MAX_LENGTH,
7
+ WIDGET_SETTINGS_MAX_FIELDS,
8
+ WIDGET_TITLE_MAX_LENGTH,
9
+ type WidgetSettings,
10
+ type WidgetSettingsDeclaration,
11
+ type WidgetSettingValue,
12
+ } from "../contracts/widgets";
13
+
14
+ const controlCharacters = /[\u0000-\u001f\u007f-\u009f]/u;
15
+ const settingLabel = z.string().trim().min(1).max(WIDGET_TITLE_MAX_LENGTH);
16
+ /** The single declaration schema used at startup and for tolerant registry reads. */
17
+ export const WidgetSettingsDeclarationSchema = z
18
+ .record(
19
+ z.string().regex(new RegExp(`^[a-z][a-zA-Z0-9]{0,${WIDGET_SETTING_NAME_MAX_LENGTH - 1}}$`)),
20
+ z.discriminatedUnion("kind", [
21
+ z
22
+ .object({
23
+ kind: z.literal("choice"),
24
+ label: settingLabel,
25
+ options: z
26
+ .array(
27
+ z.object({
28
+ label: settingLabel,
29
+ value: z
30
+ .string()
31
+ .min(1)
32
+ .max(100)
33
+ .refine((value) => !controlCharacters.test(value)),
34
+ }),
35
+ )
36
+ .min(1)
37
+ .max(WIDGET_SETTING_MAX_OPTIONS),
38
+ default: z.string().optional(),
39
+ })
40
+ .superRefine((field, ctx) => {
41
+ if (new Set(field.options.map((option) => option.value)).size !== field.options.length)
42
+ ctx.addIssue({ code: "custom", path: ["options"], message: "Choice option values must be distinct" });
43
+ if (field.default !== undefined && !field.options.some((option) => option.value === field.default))
44
+ ctx.addIssue({ code: "custom", path: ["default"], message: "Choice default must be a declared option" });
45
+ }),
46
+ z.object({ kind: z.literal("resource"), label: settingLabel, type: CapabilityQualifiedIdSchema }),
47
+ z.object({ kind: z.literal("toggle"), label: settingLabel, default: z.boolean().optional() }),
48
+ z.object({
49
+ kind: z.literal("text"),
50
+ label: settingLabel,
51
+ placeholder: settingLabel.optional(),
52
+ maxLength: z.number().int().min(1).max(WIDGET_SETTING_TEXT_MAX_LENGTH).optional(),
53
+ }),
54
+ ]),
55
+ )
56
+ .superRefine((fields, ctx) => {
57
+ for (const name of Object.keys(fields)) {
58
+ if (name in Object.prototype)
59
+ ctx.addIssue({ code: "custom", path: [name], message: "Setting names must not be Object.prototype members" });
60
+ }
61
+ })
62
+ .refine((fields) => Object.keys(fields).length <= WIDGET_SETTINGS_MAX_FIELDS, `Declare at most ${WIDGET_SETTINGS_MAX_FIELDS} settings`);
63
+
64
+ /** Drop invalid or undeclared values and fill choice and toggle defaults without reporting input errors. */
65
+ export const parseWidgetSettings = (declaration: WidgetSettingsDeclaration | undefined, raw: unknown): WidgetSettings => {
66
+ const values = z.record(z.string(), z.unknown()).safeParse(raw);
67
+ const settings: Record<string, WidgetSettingValue> = {};
68
+ for (const [name, field] of Object.entries(declaration ?? {})) {
69
+ const value = values.success && Object.hasOwn(values.data, name) ? values.data[name] : undefined;
70
+ switch (field.kind) {
71
+ case "choice":
72
+ if (typeof value === "string" && field.options.some((option) => option.value === value)) settings[name] = value;
73
+ else if (field.default !== undefined) settings[name] = field.default;
74
+ break;
75
+ case "toggle":
76
+ if (typeof value === "boolean") settings[name] = value;
77
+ else if (field.default !== undefined) settings[name] = field.default;
78
+ break;
79
+ case "text": {
80
+ if (typeof value !== "string" || controlCharacters.test(value)) break;
81
+ const text = value.trim();
82
+ if (text && text.length <= (field.maxLength ?? WIDGET_SETTING_TEXT_MAX_LENGTH)) settings[name] = text;
83
+ break;
84
+ }
85
+ case "resource": {
86
+ const ref = CloudResourceRefSchema.safeParse(value);
87
+ if (ref.success && ref.data.type === field.type) settings[name] = ref.data;
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ return settings;
93
+ };
@@ -15,8 +15,8 @@ import {
15
15
  type CapabilityActionManifest,
16
16
  type CapabilityActionReview,
17
17
  type CapabilityQueryManifest,
18
- CloudResourceRefSchema,
19
18
  cloudResourceRefAppId,
19
+ LinkedCloudResourceReferenceSchema,
20
20
  resolveCapabilityResourceReader,
21
21
  } from "../contracts/capabilities";
22
22
  import type { CapabilityRegistryEntry } from "../contracts/registry";
@@ -29,6 +29,7 @@ import { recordRejectedAiCapability } from "./capability-execution";
29
29
  import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
30
30
  import { createCodeSourceTool } from "./code-source-tools";
31
31
  import { CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES } from "./default-tools";
32
+ import { addCloudResourceHrefs } from "./resource-refs";
32
33
  import { type AiToolPreparationContext, defineAiTool, type PreparedAiTools, prepareAiTools } from "./tools";
33
34
  import type { AiConversationService, AiRuntimeTool, AiToolPresentation } from "./types";
34
35
 
@@ -638,6 +639,7 @@ export const createAiToolMetaTools = (input: {
638
639
  };
639
640
 
640
641
  export const createLoadedAiCapabilityTools = (input: {
642
+ apps: readonly CapabilityRegistryEntry[];
641
643
  catalog: readonly AiCapabilityCatalogEntry[];
642
644
  loadedNames: readonly string[];
643
645
  actor: RequestActor;
@@ -686,7 +688,7 @@ export const createLoadedAiCapabilityTools = (input: {
686
688
  throw new Error("Capability Action was rejected by the user.");
687
689
  }
688
690
  }
689
- return input.execute(entry, args, context);
691
+ return addCloudResourceHrefs(await input.execute(entry, args, context), input.apps);
690
692
  }),
691
693
  ];
692
694
  });
@@ -701,14 +703,14 @@ export const createAiResourceReaderTool = (input: {
701
703
  name: "read_cloud_resource",
702
704
  description:
703
705
  "Read a Cloud resource from its structured reference using the resource type's current canonical reader. Pass refs returned by search, Projects, or other capabilities unchanged; never substitute an id from another resource type.",
704
- inputSchema: CloudResourceRefSchema,
706
+ inputSchema: LinkedCloudResourceReferenceSchema,
705
707
  outputSchema: z.unknown(),
706
708
  approval: "never",
707
709
  }).server(async (ref, context) => {
708
710
  if (ref.type === "assistant.artifact") {
709
711
  const tool = createCodeSourceTool("code_read");
710
712
  if (tool.location !== "server") throw new Error("Code reader must execute on the server.");
711
- return tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context);
713
+ return addCloudResourceHrefs(await tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context), input.apps);
712
714
  }
713
715
  const appId = cloudResourceRefAppId(ref);
714
716
  const app = input.apps.find((candidate) => candidate.appId === appId);
@@ -718,7 +720,7 @@ export const createAiResourceReaderTool = (input: {
718
720
  (candidate) => candidate.appId === appId && candidate.kind === "query" && candidate.operation.localId === reader.localId,
719
721
  );
720
722
  if (!entry) throw new Error(`Cloud resource reader ${appId}.${reader.localId} is unavailable.`);
721
- return input.execute(entry, { id: ref.id }, context);
723
+ return addCloudResourceHrefs(await input.execute(entry, { id: ref.id }, context), input.apps);
722
724
  });
723
725
 
724
726
  /** Nessi resolver: one registry/load-state snapshot drives discovery, loading, schemas, and execution per model turn. */
@@ -860,6 +862,7 @@ export const createAiToolResolver =
860
862
  ...activeBuiltIns,
861
863
  ...(input.execute
862
864
  ? createLoadedAiCapabilityTools({
865
+ apps: registry,
863
866
  catalog: capabilityCatalog,
864
867
  loadedNames,
865
868
  actor: input.actor,
@@ -283,9 +283,24 @@ export const CODE_SOURCE_TOOLS = {
283
283
  input: Id.extend(ArtifactMetadata.shape),
284
284
  },
285
285
  code_create: {
286
+ promptHint:
287
+ "build every app, calculator, converter, timer, form or other tool the person asks for as a saved Studio app. In one turn, load_skill assistant-code-mode and load_tools code_create, code_write, code_check and code_open.",
286
288
  description:
287
- "Create a private reusable App: an index.html interface with optional style.css and app.js, published actions and optional persistence. Starts with a minimal index.html. For one-off analysis, use code_run with code instead. Returns id and entry path; write source with code_write. Optional icon uses the complete class, e.g. ti ti-chart-bar; omit it when unsure. Does not run or share anything.",
288
- input: ArtifactCreate.pick({ title: true, description: true, icon: true }),
289
+ "Create a saved Studio app; it appears in the person's Studio Apps and can be opened, reused and shared. Use it for every app, calculator, converter, timer, form or other interactive tool the person asks for, even when it keeps no data. Pass the complete first version as html (a small app is this one file) and steps (its steps.json), so one call creates it; add style.css, app.js or more files with code_write. Without html it starts with a minimal index.html. Returns id, revision and the lines and length of every file; with html or steps also diagnostics, like code_write. Optional icon uses the complete class, e.g. ti ti-calculator; omit it when unsure. For a one-off analysis use code_run instead. Does not run, check or share anything.",
290
+ input: ArtifactCreate.pick({ title: true, description: true, icon: true }).extend({
291
+ html: z
292
+ .string()
293
+ .max(1024 * 1024)
294
+ .optional()
295
+ .describe(
296
+ 'Complete index.html. A small app fits in this one file: put its script in <script type="module"> and its CSS in <style>. Omit it to start with a minimal page.',
297
+ ),
298
+ steps: z
299
+ .string()
300
+ .max(1024 * 1024)
301
+ .optional()
302
+ .describe("Complete steps.json text for code_check: the main flow, at most 20 steps."),
303
+ }),
289
304
  },
290
305
  // Source text stays top-level because some providers corrupt strings nested in arrays.
291
306
  // Imports carry only references, so they keep an array and one review per batch.
@@ -50,6 +50,7 @@ export function createCodeSourceTool(name: CodeSourceToolName) {
50
50
  inputSchema: definition.input,
51
51
  outputSchema: z.unknown(),
52
52
  approval: "never",
53
+ promptHint: "promptHint" in definition ? definition.promptHint : undefined,
53
54
  }).server(async (input, context) => {
54
55
  if (!context.conversationId) throw new Error("Code tools require an Assistant conversation.");
55
56
  const { actor } = await resolveAiCapabilityActor({
@@ -99,12 +99,12 @@ export const createCloudAiCardTool = () =>
99
99
  export const createCloudAiChartTool = () =>
100
100
  defineAiTool({
101
101
  name: "chart",
102
- description: `Show one chart in the chat from a few values you already have that did not come from a code run, such as figures from the conversation, a web page or a small capability result: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge, sparkline, boxplot, heatmap, barGauge or stat. Several related measures go into one call as a group of small charts (charts, at most ${CLOUD_AI_GROUP_MAX_CHARTS}); linkX shares the x cursor between line charts with the same kind of x. There is no second y axis: compare measures with different units as a group. Cloud draws it with the renderer of cloud.chart() and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Values computed in code_run never go here: the script shows them itself with cloud.view.chart, cloud.view.group, cloud.view.table, cloud.view.stats, cloud.view.details or cloud.view.explorer, and you present the returned path. Aggregate first. Say what the chart shows in title and put the unit, period or source in subtitle; formats are data ({ style: "percent" } takes fractions). The chart has no state, filters or actions: filters, steps and comparison over computed rows come from cloud.view.explorer in code_run; for other interaction show an app in the chat with code_present, and build a saved Studio App only when data must be kept or the app reused. After the chart, state the finding instead of repeating its values.`,
102
+ description: `Show one chart in the chat from a few values you already have that did not come from a code run, such as figures from the conversation, a web page or a small capability result: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge, sparkline, boxplot, heatmap, barGauge or stat. Several related measures go into one call as a group of small charts (charts, at most ${CLOUD_AI_GROUP_MAX_CHARTS}); linkX shares the x cursor between line charts with the same kind of x. There is no second y axis: compare measures with different units as a group. Cloud draws it with the renderer of cloud.chart() and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Values computed in code_run never go here: the script shows them itself with cloud.view.chart, cloud.view.group, cloud.view.table, cloud.view.stats, cloud.view.details or cloud.view.explorer, and you present the returned path. Aggregate first. Say what the chart shows in title and put the unit, period or source in subtitle; formats are data ({ style: "percent" } takes fractions). The chart has no state, filters or actions: filters, steps and comparison over computed rows come from cloud.view.explorer in code_run; anything else the person interacts with, such as a calculator, converter or form, is a Studio app built with code_create. After the chart, state the finding instead of repeating its values.`,
103
103
  inputSchema: CloudAiChartInputSchema,
104
104
  outputSchema: CloudAiChartOutputSchema,
105
105
  approval: "never",
106
106
  promptHint:
107
- "show a few values you already have (not from a code run) as a chart in the chat. Code results use cloud.view.* and present; filters, steps and comparison use cloud.view.explorer in code_run; interaction beyond that needs a chat app (code_present).",
107
+ "show a few values you already have (not from a code run) as a chart in the chat. Code results use cloud.view.* and present; filters, steps and comparison use cloud.view.explorer in code_run; an app or tool to interact with is a Studio app (code_create).",
108
108
  }).clientView();
109
109
 
110
110
  export const createCloudAiSurveyTool = () =>
@@ -246,7 +246,7 @@ export const createCloudAiCodeTools = () => [
246
246
  defineAiTool({
247
247
  name: "code_open",
248
248
  description:
249
- "Show the app beside the chat without starting it; HTML apps require a passing code_check for the current files and tables. For one-off calculations, return the result instead of opening an app.",
249
+ "Show a saved Studio app beside the chat without starting it; HTML apps require a passing code_check for the current files and tables. When the person only wants one calculated result, answer it instead of building an app.",
250
250
  inputSchema: CodeOpenInput,
251
251
  outputSchema: z.json(),
252
252
  approval: "never",
@@ -262,7 +262,7 @@ export const createCloudAiCodeTools = () => [
262
262
  defineAiTool({
263
263
  name: "code_present",
264
264
  description:
265
- "Show an HTML app as a card in this chat after a passing code_check for exactly these files and table definitions. Pass files (index.html plus optional style.css and app.js) for a one-off app without saved data, or the id of a saved app to run it live with its data. The person starts the card with a click. Static problems such as CDN scripts, inline handlers or a missing index.html are rejected before anything is saved. For a reusable app beside the chat use code_open.",
265
+ "Show a saved Studio app as a card in this chat: pass its id after a passing code_check for its current files and tables; it runs live with its data, and the person starts the card with a click. Every app, calculator, converter, form or tool the person asks for is a Studio app created with code_create; code_open shows it beside the chat instead. files (index.html plus optional style.css and app.js) instead of id show a throwaway card that is not saved and does not appear in Studio: only when the person explicitly asks for a preview they do not want to keep, after a passing code_check for exactly these files. Static problems such as CDN scripts, inline handlers or a missing index.html are rejected before anything is saved.",
266
266
  inputSchema: CodePresentInput,
267
267
  outputSchema: z.json(),
268
268
  approval: "never",
@@ -7,6 +7,10 @@ export const AI_MEMORY_CONTENT_MAX_CHARS = 500;
7
7
  export const AI_MEMORY_HOT_MAX_ITEMS = 20;
8
8
  export const AI_MEMORY_HOT_MAX_CHARS = 6_000;
9
9
 
10
+ // PostgreSQL stores at most 16,383 word positions per tsvector; a parsed token takes at least
11
+ // one character. 12,000 characters keep every position distinct for the stop-word pairing below.
12
+ const AI_MEMORY_RELEVANCE_MAX_CHARS = 12_000;
13
+
10
14
  export type AiMemoryKind = "fact" | "preference" | "workflow";
11
15
  export type AiMemoryPriority = "normal" | "pinned";
12
16
  export type AiMemorySource = "user" | "agent" | "background";
@@ -131,6 +135,49 @@ const listActive = async (userId: string, limit: number): Promise<AiMemory[]> =>
131
135
  return rows.map(toMemory);
132
136
  };
133
137
 
138
+ const selectRelevant = async (input: { userId: string; text: string; total: number; limit: number }): Promise<AiMemory[]> => {
139
+ const text = (
140
+ input.text.length > AI_MEMORY_RELEVANCE_MAX_CHARS
141
+ ? `${input.text.slice(0, AI_MEMORY_RELEVANCE_MAX_CHARS / 2)}\n${input.text.slice(-AI_MEMORY_RELEVANCE_MAX_CHARS / 2)}`
142
+ : input.text
143
+ ).trim();
144
+ if (!text) return [];
145
+
146
+ const rows = await sql<MemoryRow[]>`
147
+ WITH english AS (
148
+ SELECT position, token.lexeme
149
+ FROM unnest(to_tsvector('english', ${text}::text)) AS token, unnest(token.positions) AS position
150
+ ), german AS (
151
+ SELECT position, token.lexeme
152
+ FROM unnest(to_tsvector('german', ${text}::text)) AS token, unnest(token.positions) AS position
153
+ ), words AS (
154
+ -- Both configurations parse the same tokens, so a position missing in one holds that language's stop word.
155
+ SELECT row_number() OVER () AS word, english, german
156
+ FROM (SELECT DISTINCT english.lexeme AS english, german.lexeme AS german FROM english JOIN german USING (position)) AS pairs
157
+ ), lexemes AS (
158
+ SELECT DISTINCT words.word, lexeme FROM words, unnest(ARRAY[words.english, words.german]) AS lexeme
159
+ ), matches AS (
160
+ SELECT DISTINCT memory.id, lexemes.word
161
+ FROM ai.memories AS memory
162
+ CROSS JOIN LATERAL unnest(tsvector_to_array(memory.stem_document)) AS document(lexeme)
163
+ JOIN lexemes ON lexemes.lexeme = document.lexeme
164
+ WHERE memory.user_id = ${input.userId}::uuid AND memory.deleted_at IS NULL AND memory.superseded_by_id IS NULL
165
+ ), scores AS (
166
+ -- A word found in fewer of the user's memories says more about relevance.
167
+ SELECT id, sum(ln((${input.total} + 1)::float8 / matching)) AS score
168
+ FROM (SELECT id, count(*) OVER (PARTITION BY word) AS matching FROM matches) AS weighted
169
+ GROUP BY id
170
+ )
171
+ SELECT memory.id, memory.short_id, memory.user_id, memory.kind, memory.content, memory.priority, memory.source,
172
+ memory.source_conversation_id, memory.source_message_id, memory.resource_type, memory.resource_id,
173
+ memory.created_at, memory.updated_at
174
+ FROM ai.memories AS memory JOIN scores ON scores.id = memory.id
175
+ ORDER BY scores.score DESC, memory.updated_at DESC, memory.id ASC
176
+ LIMIT ${input.limit}
177
+ `;
178
+ return rows.map(toMemory);
179
+ };
180
+
134
181
  const searchRows = async (input: { userId: string; query: string; limit: number; backend: SearchBackend }): Promise<AiMemory[]> => {
135
182
  const query = input.query.trim();
136
183
  if (!query) return listActive(input.userId, input.limit);
@@ -609,7 +656,7 @@ export const aiMemories = {
609
656
 
610
657
  const [pinned, relevant, recent] = await Promise.all([
611
658
  listActive(userId, 50).then((items) => items.filter((item) => item.priority === "pinned")),
612
- search({ userId, query, limit: 40 }),
659
+ selectRelevant({ userId, text: query, total: count, limit: 40 }),
613
660
  listActive(userId, 40),
614
661
  ]);
615
662
  const seen = new Set<string>();