@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
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { chartDataIssue, chartDateTime, chartDrawingIssue } from "./chart-block";
2
3
 
3
4
  const ToneSchema = z.enum(["neutral", "blue", "teal", "green", "amber", "red"]);
4
5
 
@@ -14,6 +15,202 @@ export const CloudAiCardInputSchema = z.object({
14
15
  });
15
16
  export const CloudAiCardOutputSchema = z.object({ displayed: z.boolean() });
16
17
 
18
+ /** The chart palette has eight colors; a ninth series would repeat one. */
19
+ export const CLOUD_AI_CHART_MAX_SERIES = 8;
20
+ /** Charts draw on a 480-unit wide plane: more values per list than units cannot be told apart. */
21
+ export const CLOUD_AI_CHART_MAX_VALUES = 480;
22
+
23
+ // Every chart schema is built by a function whose call is marked pure. Bundles that only use other exports of this
24
+ // module, such as every Studio app frame through `@k2b/cloud/ai/browser`, can then leave the chart contract out.
25
+ const chartParts = () => {
26
+ const ChartLabel = z.string().trim().min(1).max(80);
27
+ const ChartDate = z
28
+ .string()
29
+ .regex(/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?$/)
30
+ .refine((value) => chartDateTime(value) !== undefined, "Not a valid date.");
31
+ const ChartPoint = z.object({
32
+ x: z
33
+ .union([z.number(), ChartDate])
34
+ .describe(
35
+ "A number, a date YYYY-MM-DD, or a date and time YYYY-MM-DDTHH:mm or YYYY-MM-DDTHH:mm:ss in the user's time zone without an offset.",
36
+ ),
37
+ y: z.number(),
38
+ });
39
+ return {
40
+ ChartLabel,
41
+ ChartAxis: z.object({
42
+ label: ChartLabel.optional(),
43
+ domain: z.array(z.number()).length(2).optional().describe("Exact [min, max]; must contain every value."),
44
+ ticks: z.number().int().min(2).max(12).optional(),
45
+ scale: z.enum(["linear", "log"]).optional().describe("log needs values above zero."),
46
+ }),
47
+ ChartCategory: z.object({ label: ChartLabel, value: z.number() }),
48
+ ChartSlice: z.object({ label: ChartLabel, value: z.number().min(0) }),
49
+ ChartSeries: (points: z.ZodArray<typeof ChartPoint>) => z.object({ label: ChartLabel.optional(), data: points }),
50
+ ChartPoints: z.array(ChartPoint).max(CLOUD_AI_CHART_MAX_VALUES),
51
+ ChartBins: z
52
+ .union([
53
+ z.number().int().min(1).max(CLOUD_AI_CHART_MAX_VALUES),
54
+ z
55
+ .array(z.number())
56
+ .min(2)
57
+ .max(CLOUD_AI_CHART_MAX_VALUES + 1),
58
+ ])
59
+ .describe("A bin count or ascending bin edges."),
60
+ ChartCommon: {
61
+ title: z.string().trim().min(1).max(160),
62
+ subtitle: z.string().trim().min(1).max(300).optional().describe("Unit, period or source, shown under the title."),
63
+ },
64
+ };
65
+ };
66
+
67
+ const chartKinds = () => {
68
+ const { ChartAxis, ChartBins, ChartCategory, ChartCommon, ChartLabel, ChartPoints, ChartSeries, ChartSlice } = chartParts();
69
+ return z.discriminatedUnion("kind", [
70
+ z.object({
71
+ kind: z.literal("bar"),
72
+ ...ChartCommon,
73
+ data: z.array(ChartCategory).min(1).max(CLOUD_AI_CHART_MAX_VALUES),
74
+ yAxis: ChartAxis.optional(),
75
+ colorByBar: z.boolean().optional(),
76
+ showValues: z.boolean().optional(),
77
+ legend: z.boolean().optional(),
78
+ }),
79
+ z.object({
80
+ kind: z.literal("line"),
81
+ ...ChartCommon,
82
+ series: z
83
+ .array(
84
+ ChartSeries(ChartPoints.min(2, "A line needs at least two points per series; show single values as a bar or scatter chart.")),
85
+ )
86
+ .min(1)
87
+ .max(CLOUD_AI_CHART_MAX_SERIES),
88
+ xAxis: ChartAxis.optional(),
89
+ yAxis: ChartAxis.optional(),
90
+ legend: z.boolean().optional(),
91
+ area: z.boolean().optional(),
92
+ smooth: z.boolean().optional(),
93
+ }),
94
+ z.object({
95
+ kind: z.literal("scatter"),
96
+ ...ChartCommon,
97
+ series: z
98
+ .array(ChartSeries(ChartPoints.min(1)))
99
+ .min(1)
100
+ .max(CLOUD_AI_CHART_MAX_SERIES),
101
+ xAxis: ChartAxis.optional(),
102
+ yAxis: ChartAxis.optional(),
103
+ legend: z.boolean().optional(),
104
+ }),
105
+ z.object({
106
+ kind: z.enum(["pie", "donut"]),
107
+ ...ChartCommon,
108
+ data: z.array(ChartSlice).min(1).max(CLOUD_AI_CHART_MAX_VALUES),
109
+ legend: z.boolean().optional(),
110
+ showLabels: z.boolean().optional(),
111
+ }),
112
+ z.object({
113
+ kind: z.literal("histogram"),
114
+ ...ChartCommon,
115
+ data: z.array(z.number()).min(1).max(CLOUD_AI_CHART_MAX_VALUES),
116
+ bins: ChartBins.optional(),
117
+ }),
118
+ z.object({
119
+ kind: z.literal("gauge"),
120
+ ...ChartCommon,
121
+ value: z.number(),
122
+ min: z.number().optional(),
123
+ max: z.number().optional(),
124
+ label: ChartLabel.optional(),
125
+ unit: z.string().trim().min(1).max(12).optional(),
126
+ }),
127
+ z.object({
128
+ kind: z.literal("sparkline"),
129
+ ...ChartCommon,
130
+ data: z.array(z.number()).min(2).max(CLOUD_AI_CHART_MAX_VALUES),
131
+ area: z.boolean().optional(),
132
+ }),
133
+ ]);
134
+ };
135
+
136
+ /** Checks run on complete input only, so out-of-range input fails on its bounds before anything is computed or drawn. */
137
+ const whenValid = { when: (payload: { issues: readonly unknown[] }) => payload.issues.length === 0 };
138
+
139
+ const chartSchema = () =>
140
+ chartKinds().superRefine((chart, context) => {
141
+ const issue = chartDataIssue(chart);
142
+ if (issue) context.addIssue({ code: "custom", message: issue });
143
+ }, whenValid);
144
+ const ChartSchema = /* @__PURE__ */ chartSchema();
145
+
146
+ /** One chart as the renderers read it: a `cloud.chart()` kind with its options. */
147
+ export type CloudAiChartInput = z.output<ReturnType<typeof chartKinds>>;
148
+
149
+ /**
150
+ * Reads the arguments of a `chart` call without drawing them; undefined when they are not a chart. The tool schema
151
+ * has drawn them once already, so renderers draw only for display.
152
+ */
153
+ export const parseCloudAiChartInput = (value: unknown): CloudAiChartInput | undefined => {
154
+ const parsed = ChartSchema.safeParse(value);
155
+ return parsed.success ? parsed.data : undefined;
156
+ };
157
+
158
+ const chartInputSchema = () => {
159
+ const { ChartAxis, ChartBins, ChartCategory, ChartCommon, ChartLabel, ChartPoints, ChartSeries } = chartParts();
160
+ return z
161
+ .object({
162
+ kind: z.enum(["bar", "line", "scatter", "pie", "donut", "histogram", "gauge", "sparkline"]),
163
+ ...ChartCommon,
164
+ data: z
165
+ .union([z.array(ChartCategory).min(1).max(CLOUD_AI_CHART_MAX_VALUES), z.array(z.number()).min(1).max(CLOUD_AI_CHART_MAX_VALUES)])
166
+ .optional()
167
+ .describe("bar, pie, donut: [{ label, value }]. histogram: raw observations to count into bins. sparkline: values in order."),
168
+ series: z
169
+ .array(ChartSeries(ChartPoints.min(1)))
170
+ .min(1)
171
+ .max(CLOUD_AI_CHART_MAX_SERIES)
172
+ .optional()
173
+ .describe("line and scatter: one entry per series. A line needs at least two points per series."),
174
+ xAxis: ChartAxis.optional().describe("line and scatter."),
175
+ yAxis: ChartAxis.optional().describe("bar, line and scatter."),
176
+ legend: z
177
+ .boolean()
178
+ .optional()
179
+ .describe(
180
+ "bar (with colorByBar), line, scatter, pie, donut. Defaults to true for several series, and for pie and donut unless showLabels is set.",
181
+ ),
182
+ colorByBar: z.boolean().optional().describe("bar: one palette color per bar."),
183
+ showValues: z.boolean().optional().describe("bar: print each value on its bar."),
184
+ area: z.boolean().optional().describe("line and sparkline: fill the area under the line."),
185
+ smooth: z.boolean().optional().describe("line."),
186
+ showLabels: z.boolean().optional().describe("pie and donut: name each slice next to it instead of in a legend."),
187
+ bins: ChartBins.optional().describe("histogram: a bin count or ascending bin edges."),
188
+ value: z.number().optional().describe("gauge: the value to show."),
189
+ min: z.number().optional().describe("gauge: start of the scale, default 0."),
190
+ max: z.number().optional().describe("gauge: end of the scale, default 100."),
191
+ label: ChartLabel.optional().describe("gauge: what the value measures."),
192
+ unit: z.string().trim().min(1).max(12).optional().describe("gauge: unit after the value."),
193
+ })
194
+ .superRefine((value, context) => {
195
+ const parsed = ChartSchema.safeParse(value);
196
+ if (!parsed.success) {
197
+ for (const issue of parsed.error.issues) context.addIssue({ code: "custom", message: issue.message, path: issue.path });
198
+ return;
199
+ }
200
+ const issue = chartDrawingIssue(parsed.data);
201
+ if (issue) context.addIssue({ code: "custom", message: issue });
202
+ }, whenValid);
203
+ };
204
+
205
+ /**
206
+ * The `cloud.chart()` options of Studio code as plain data, without `width`, `height` and `format` functions. Model
207
+ * providers accept only an object at the root of a tool schema, so every kind shares one object and each field names
208
+ * the kinds it belongs to; the kind's own rules are checked after that. Valid input also draws: values outside a
209
+ * domain, values at or below zero on a log axis, or a pie too small for its legend fail here instead of in the chat.
210
+ */
211
+ export const CloudAiChartInputSchema = /* @__PURE__ */ chartInputSchema();
212
+ export const CloudAiChartOutputSchema = z.object({ displayed: z.boolean() });
213
+
17
214
  const SurveyQuestionSchema = z.discriminatedUnion("type", [
18
215
  z.object({
19
216
  type: z.literal("single"),
@@ -100,6 +297,7 @@ export const CloudAiLocalBashOutputSchema = z.object({
100
297
 
101
298
  export type CloudAiCardInput = z.infer<typeof CloudAiCardInputSchema>;
102
299
  export type CloudAiCardOutput = z.infer<typeof CloudAiCardOutputSchema>;
300
+ export type CloudAiChartOutput = z.infer<typeof CloudAiChartOutputSchema>;
103
301
  export type CloudAiSurveyInput = z.infer<typeof CloudAiSurveyInputSchema>;
104
302
  export type CloudAiSurveyOutput = z.infer<typeof CloudAiSurveyOutputSchema>;
105
303
  export type CloudAiTextEditorInput = z.infer<typeof CloudAiTextEditorInputSchema>;
@@ -20,6 +20,8 @@ import { createCodeSourceTools } from "./code-source-tools";
20
20
  import {
21
21
  CloudAiCardInputSchema,
22
22
  CloudAiCardOutputSchema,
23
+ CloudAiChartInputSchema,
24
+ CloudAiChartOutputSchema,
23
25
  CloudAiLocalBashInputSchema,
24
26
  CloudAiLocalBashOutputSchema,
25
27
  CloudAiSurveyInputSchema,
@@ -48,6 +50,10 @@ export {
48
50
  CloudAiCardInputSchema,
49
51
  type CloudAiCardOutput,
50
52
  CloudAiCardOutputSchema,
53
+ type CloudAiChartInput,
54
+ CloudAiChartInputSchema,
55
+ type CloudAiChartOutput,
56
+ CloudAiChartOutputSchema,
51
57
  type CloudAiLocalBashInput,
52
58
  CloudAiLocalBashInputSchema,
53
59
  type CloudAiLocalBashOutput,
@@ -73,6 +79,18 @@ export const createCloudAiCardTool = () =>
73
79
  promptHint: "show one compact highlight card (metric, KPI, status) — not for tables, lists, or long text.",
74
80
  }).clientView();
75
81
 
82
+ export const createCloudAiChartTool = () =>
83
+ defineAiTool({
84
+ name: "chart",
85
+ description:
86
+ "Show one chart in the chat from data you already have: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge or sparkline. Cloud draws it with the renderer of cloud.chart() in Studio apps and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Aggregate first. Compute derived numbers with calculate or code_run before charting them. Say what the chart shows in title and put the unit, period or source in subtitle. The chart has no state, filters or actions: for 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.",
87
+ inputSchema: CloudAiChartInputSchema,
88
+ outputSchema: CloudAiChartOutputSchema,
89
+ approval: "never",
90
+ promptHint:
91
+ "show data you already have as a chart in the chat, without code. For interaction use a chat app (code_present); for kept data or reuse, a saved Studio App.",
92
+ }).clientView();
93
+
76
94
  export const createCloudAiSurveyTool = () =>
77
95
  defineAiTool({
78
96
  name: "survey",
@@ -182,7 +200,12 @@ export const createCloudAiCodeTools = () => [
182
200
  }).server(runManagedCodeTool("code_export")),
183
201
  ];
184
202
 
185
- export const createDefaultCloudAiTools = () => [createAiTodoTool(), createCloudAiSurveyTool(), createCloudAiTextEditorTool()];
203
+ export const createDefaultCloudAiTools = () => [
204
+ createAiTodoTool(),
205
+ createCloudAiSurveyTool(),
206
+ createCloudAiTextEditorTool(),
207
+ createCloudAiChartTool(),
208
+ ];
186
209
 
187
210
  /** Built-ins advertised through discovery and loaded only when needed. */
188
211
  export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
@@ -190,6 +213,7 @@ export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
190
213
  ...Object.keys(CODE_SOURCE_TOOLS),
191
214
  "survey",
192
215
  "text_editor",
216
+ "chart",
193
217
  "list_files",
194
218
  "write_file",
195
219
  "markdown_to_pdf",
@@ -1574,29 +1574,12 @@ export class AiTurnExecutor {
1574
1574
  const approvalScope = capabilityApprovalScope ?? aiToolApprovalScope(toolName, approvalPolicy);
1575
1575
  const allowAlways = allowRememberedApprovals && (capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy));
1576
1576
 
1577
- const runConfig = await aiConversations.getTurnRunConfig({ conversationId, turnId });
1578
- if (runConfig?.kind !== "compact" && (runConfig?.background || runConfig?.mandate)) {
1579
- input.onBackgroundBlocked?.(
1580
- `Background operation ${toolName} requires ${event.kind === "client_tool" ? "an interactive browser" : "interactive approval"}. Update the task in the normal chat.`,
1581
- );
1582
- if (event.kind === "client_tool")
1583
- loop.push({
1584
- type: "tool_result",
1585
- callId: event.callId,
1586
- result: { error: "This operation requires an interactive browser and is unavailable in a background run." },
1587
- });
1588
- else loop.push({ type: "approval_response", callId: event.callId, approved: false });
1589
- return false;
1590
- }
1591
-
1592
- // Display-only client_view tools (e.g. cards) never need user input — resolve
1593
- // inline and keep streaming instead of taking a full suspend/continuation trip.
1577
+ // Display-only client_view tools (cards, charts) need neither a browser nor input: Cloud answers them itself, in
1578
+ // background runs too, whose transcript shows them later. The block goes from running straight to completed:
1579
+ // a published awaiting_client would read as an open request to clients and remount the result in the chat.
1580
+ // nessi emits no tool_execution_end for client tools, so Cloud ends the call itself.
1594
1581
  if (frontendMode === "client_view") {
1595
- await pipeline.apply(event);
1596
1582
  loop.push({ type: "tool_result", callId: event.callId, result: { displayed: true } });
1597
- // Mark the block completed immediately: nessi emits no tool_execution_end
1598
- // for client tools, and a block stuck in awaiting_client would look like
1599
- // an open action request if the turn suspends for another tool later.
1600
1583
  await pipeline.apply({
1601
1584
  type: "tool_execution_end",
1602
1585
  callId: event.callId,
@@ -1610,6 +1593,21 @@ export class AiTurnExecutor {
1610
1593
  return false;
1611
1594
  }
1612
1595
 
1596
+ const runConfig = await aiConversations.getTurnRunConfig({ conversationId, turnId });
1597
+ if (runConfig?.kind !== "compact" && (runConfig?.background || runConfig?.mandate)) {
1598
+ input.onBackgroundBlocked?.(
1599
+ `Background operation ${toolName} requires ${event.kind === "client_tool" ? "an interactive browser" : "interactive approval"}. Update the task in the normal chat.`,
1600
+ );
1601
+ if (event.kind === "client_tool")
1602
+ loop.push({
1603
+ type: "tool_result",
1604
+ callId: event.callId,
1605
+ result: { error: "This operation requires an interactive browser and is unavailable in a background run." },
1606
+ });
1607
+ else loop.push({ type: "approval_response", callId: event.callId, approved: false });
1608
+ return false;
1609
+ }
1610
+
1613
1611
  // Remembered approvals resolve inline too.
1614
1612
  if (event.kind !== "client_tool" && allowAlways && approvalContext) {
1615
1613
  const remembered = await hasRememberedAiToolApproval(approvalContext, { toolName, approvalScope }).catch(() => false);
package/src/ai/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { type AiAppSkillIssue, aiAppSkills, appSkillsRegistryState, reconcileAppSkills } from "./app-skill-store";
1
2
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
2
3
  export {
3
4
  type AiToolApprovalContext,
@@ -47,11 +48,17 @@ export { authorizeCodeExecution } from "./code-execution";
47
48
  export { CODE_SOURCE_TOOLS, type CodeSourceToolName } from "./code-source-contracts";
48
49
  export { listAiCredentialProfileIds } from "./credentials";
49
50
  export {
51
+ CLOUD_AI_CHART_MAX_SERIES,
52
+ CLOUD_AI_CHART_MAX_VALUES,
50
53
  CLOUD_AI_TEXT_EDITOR_MAX_CHARS,
51
54
  type CloudAiCardInput,
52
55
  CloudAiCardInputSchema,
53
56
  type CloudAiCardOutput,
54
57
  CloudAiCardOutputSchema,
58
+ type CloudAiChartInput,
59
+ CloudAiChartInputSchema,
60
+ type CloudAiChartOutput,
61
+ CloudAiChartOutputSchema,
55
62
  type CloudAiLocalBashInput,
56
63
  CloudAiLocalBashInputSchema,
57
64
  type CloudAiLocalBashOutput,
@@ -64,6 +71,7 @@ export {
64
71
  CloudAiTextEditorInputSchema,
65
72
  type CloudAiTextEditorOutput,
66
73
  CloudAiTextEditorOutputSchema,
74
+ parseCloudAiChartInput,
67
75
  } from "./default-tool-contracts";
68
76
  export {
69
77
  type AiChatEnrichment,
@@ -258,6 +266,7 @@ export {
258
266
  type AiSkillAccess,
259
267
  type AiSkillAdminListItem,
260
268
  type AiSkillAdminSummary,
269
+ AiSkillAppForbiddenError,
261
270
  AiSkillInputError,
262
271
  AiSkillLastAdminError,
263
272
  type AiSkillPermission,
@@ -265,7 +274,6 @@ export {
265
274
  AiSkillRevisionConflictError,
266
275
  type AiSkillSummary,
267
276
  aiSkills,
268
- seedCloudAiSkills,
269
277
  } from "./skills";
270
278
  export { type AiSkillsRoutes, aiSkillsRoutes } from "./skills-routes";
271
279
  export { aiConversations } from "./store";
package/src/ai/migrate.ts CHANGED
@@ -1620,18 +1620,65 @@ export const migrateCloudAi = async (): Promise<void> => {
1620
1620
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_name ON ai.skills(name)`.simple();
1621
1621
  await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS managed_key TEXT`.simple();
1622
1622
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_managed_key ON ai.skills(managed_key) WHERE managed_key IS NOT NULL`.simple();
1623
- await sql`
1624
- CREATE TABLE IF NOT EXISTS ai.skill_seeds (
1625
- key TEXT PRIMARY KEY,
1626
- seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
1627
- )
1628
- `.simple();
1629
- // A seed's UUID deliberately has no FK: it is a durable deletion tombstone.
1630
- await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS skill_id UUID`.simple();
1631
- await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS catalog_version INTEGER NOT NULL DEFAULT 0`.simple();
1632
- await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS template_id TEXT,
1633
- ADD COLUMN IF NOT EXISTS template_version INTEGER, ADD COLUMN IF NOT EXISTS template_hash TEXT`.simple();
1634
- await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_template_id ON ai.skills(template_id) WHERE template_id IS NOT NULL`.simple();
1623
+ await sql.begin(async (tx) => {
1624
+ await tx`SELECT pg_advisory_xact_lock(hashtext('cloud:ai:app-skills'))`;
1625
+ await tx`CREATE TABLE IF NOT EXISTS ai.app_skill_catalogs (
1626
+ app_id text NOT NULL, manifest_hash text NOT NULL, skills jsonb NOT NULL,
1627
+ created_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(),
1628
+ PRIMARY KEY (app_id, manifest_hash)
1629
+ )`.simple();
1630
+ await tx`CREATE INDEX IF NOT EXISTS idx_ai_app_skill_catalogs_last_seen ON ai.app_skill_catalogs(last_seen_at)`.simple();
1631
+ await tx`CREATE TABLE IF NOT EXISTS ai.app_skills (
1632
+ key text PRIMARY KEY, app_id text NOT NULL, app_name text NOT NULL, name text NOT NULL,
1633
+ skill_id uuid NULL, source jsonb NULL, source_hash text NULL, applied_hash text NULL,
1634
+ manifest_hash text NULL, available boolean NOT NULL DEFAULT false, required_roles text[] NULL,
1635
+ conflict text NULL CHECK (conflict IN ('name_taken', 'invalid')),
1636
+ updated_at timestamptz NOT NULL DEFAULT now()
1637
+ )`.simple();
1638
+ await tx`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_app_skills_skill ON ai.app_skills(skill_id) WHERE skill_id IS NOT NULL`.simple();
1639
+ await tx`CREATE INDEX IF NOT EXISTS idx_ai_app_skills_app ON ai.app_skills(app_id)`.simple();
1640
+ // Adopt only durable seed associations. A missing linked row remains a deletion tombstone.
1641
+ await tx`DO $$ BEGIN
1642
+ IF to_regclass('ai.skill_seeds') IS NOT NULL THEN
1643
+ -- Installations from before seed links lack these columns; their seeds adopt nothing.
1644
+ ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS skill_id uuid;
1645
+ ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS template_id text, ADD COLUMN IF NOT EXISTS template_hash text;
1646
+ -- A seed never installed its Skill again. An unlinked seed whose name is free stays deleted through a link
1647
+ -- to no Skill; when a Skill holds the name, it stays unlinked and an administrator decides who owns it.
1648
+ INSERT INTO ai.app_skills(key, app_id, app_name, name, skill_id, applied_hash)
1649
+ SELECT 'app:' || mapping.app_id || '/' || mapping.name, mapping.app_id, mapping.app_name, mapping.name,
1650
+ CASE WHEN seed.skill_id IS NOT NULL THEN seed.skill_id
1651
+ WHEN NOT EXISTS (SELECT 1 FROM ai.skills named WHERE named.name = mapping.name) THEN gen_random_uuid() END,
1652
+ CASE WHEN skill.template_id = seed.key THEN skill.template_hash ELSE NULL END
1653
+ FROM ai.skill_seeds seed
1654
+ JOIN (VALUES
1655
+ ('grids:cloud-grids', 'grids', 'Grids', 'cloud-grids'),
1656
+ ('assistant:cloud-assistant', 'assistant', 'Assistant', 'cloud-assistant'),
1657
+ ('assistant:scheduled-tasks', 'assistant', 'Assistant', 'scheduled-tasks'),
1658
+ ('core:skill-creator', 'assistant', 'Assistant', 'skill-creator'),
1659
+ ('mail:cloud-mail', 'mail', 'Mail', 'cloud-mail'),
1660
+ ('notebooks:cloud-notebooks', 'notebooks', 'Notebooks', 'cloud-notebooks'),
1661
+ ('contacts:cloud-contacts', 'contacts', 'Contacts', 'cloud-contacts'),
1662
+ ('spaces:cloud-spaces', 'spaces', 'Spaces', 'cloud-spaces'),
1663
+ ('weather:cloud-weather', 'weather', 'Weather', 'cloud-weather'),
1664
+ ('assistant:code-mode', 'assistant', 'Assistant', 'assistant-code-mode'),
1665
+ ('assistant:data-analysis', 'assistant', 'Assistant', 'assistant-data-analysis')
1666
+ ) mapping(seed_key, app_id, app_name, name) ON mapping.seed_key = seed.key
1667
+ LEFT JOIN ai.skills skill ON skill.id = seed.skill_id
1668
+ WHERE true
1669
+ ON CONFLICT (key) DO NOTHING;
1670
+ DROP TABLE ai.skill_seeds;
1671
+ END IF;
1672
+ END $$`.simple();
1673
+ // Only alter ai.skills once: ALTER TABLE takes an exclusive lock even when nothing changes.
1674
+ await tx`DO $$ BEGIN
1675
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'ai' AND table_name = 'skills'
1676
+ AND column_name IN ('template_id', 'template_version', 'template_hash')) THEN
1677
+ ALTER TABLE ai.skills DROP COLUMN IF EXISTS template_id,
1678
+ DROP COLUMN IF EXISTS template_version, DROP COLUMN IF EXISTS template_hash;
1679
+ END IF;
1680
+ END $$`.simple();
1681
+ });
1635
1682
  await backfillAiShortIds(
1636
1683
  "idx_ai_skills_short_id",
1637
1684
  await sql<{ id: string }[]>`SELECT id FROM ai.skills WHERE short_id IS NULL`,
@@ -45,7 +45,10 @@ export type AiTurnBlock =
45
45
  isError?: boolean;
46
46
  /** Present while status is awaiting_approval. */
47
47
  approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
48
- /** The user approved this call in the chat; the decided approval stays visible as a receipt. */
48
+ /**
49
+ * `true` once the user approved this call in the chat; `false` when its turn ended while the approval still
50
+ * waited. Either way the approval stays visible as a receipt.
51
+ */
49
52
  approved?: boolean;
50
53
  /** Present for frontend tools. */
51
54
  frontendMode?: AiFrontendToolMode;
@@ -224,7 +227,7 @@ export const buildBlocksFromMessages = (
224
227
  meta?: {
225
228
  steerId?: string;
226
229
  toolPresentations?: Record<string, AiToolPresentation>;
227
- toolOutcomes?: Record<string, "rejected" | "approved">;
230
+ toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
228
231
  } | null;
229
232
  }[],
230
233
  ): AiTurnBlock[] => {
@@ -239,6 +242,7 @@ export const buildBlocksFromMessages = (
239
242
  } else if (block.type === "thinking") {
240
243
  blocks.push({ id: messageBlockId(seq, index), kind: "thinking", text: block.thinking });
241
244
  } else if (block.type === "tool_call") {
245
+ const outcome = meta?.toolOutcomes?.[block.id];
242
246
  toolIndex.set(block.id, blocks.length);
243
247
  blocks.push({
244
248
  id: toolBlockId(block.id),
@@ -248,8 +252,9 @@ export const buildBlocksFromMessages = (
248
252
  args: block.args,
249
253
  status: "running",
250
254
  presentation: meta?.toolPresentations?.[block.id],
251
- // A turn that ended before an approved call returned records the approval on the call's message.
252
- ...(meta?.toolOutcomes?.[block.id] === "approved" ? { approved: true } : {}),
255
+ // A turn that ended before an approved call returned records the approval on the call's message, and an
256
+ // approval it left waiting as expired.
257
+ ...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
253
258
  });
254
259
  }
255
260
  });
@@ -2,6 +2,7 @@ export { CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema,
2
2
  export {
3
3
  CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES,
4
4
  createCloudAiCardTool,
5
+ createCloudAiChartTool,
5
6
  createCloudAiCodeTools,
6
7
  createCloudAiLocalBashTool,
7
8
  createCloudAiSurveyTool,
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ type AiSkillDocument,
4
+ type AiSkillExtraFrontmatter,
5
+ type AiSkillReferenceInput,
6
+ validateAiSkillDescription,
7
+ validateAiSkillExtraFrontmatter,
8
+ validateAiSkillInstructions,
9
+ validateAiSkillName,
10
+ validateAiSkillReferences,
11
+ } from "./skill-format";
12
+
13
+ export type AiSkillContent = AiSkillDocument & { references: readonly AiSkillReferenceInput[] };
14
+
15
+ export const validateSkillContent = (input: {
16
+ name: string;
17
+ description: string;
18
+ instructions: string;
19
+ extraFrontmatter?: AiSkillExtraFrontmatter;
20
+ references?: readonly AiSkillReferenceInput[];
21
+ }): AiSkillContent => ({
22
+ name: validateAiSkillName(input.name),
23
+ description: validateAiSkillDescription(input.description),
24
+ instructions: validateAiSkillInstructions(input.instructions),
25
+ extraFrontmatter: validateAiSkillExtraFrontmatter(input.extraFrontmatter),
26
+ references: validateAiSkillReferences(input.references ?? []).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
27
+ });
28
+
29
+ // Keep the former template hash algorithm: key order and reference insertion order are immaterial.
30
+ export const canonicalJson = (value: unknown): string => {
31
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
32
+ if (value !== null && typeof value === "object")
33
+ return `{${Object.entries(value)
34
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
35
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
36
+ .join(",")}}`;
37
+ const result = JSON.stringify(value);
38
+ if (result === undefined) throw new Error("Skill content must be JSON.");
39
+ return result;
40
+ };
41
+ export const canonicalHash = (value: unknown): string => createHash("sha256").update(canonicalJson(value)).digest("hex");
42
+ export const contentHash = (fields: AiSkillContent): string =>
43
+ canonicalHash({
44
+ name: fields.name,
45
+ description: fields.description,
46
+ instructions: fields.instructions,
47
+ extraFrontmatter: fields.extraFrontmatter,
48
+ references: [...fields.references].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
49
+ });
50
+
51
+ /** How an installed app Skill relates to its app: the app's version, an administrator's override, or an override with a newer app version. */
52
+ export type AppSkillStatus = "current" | "modified" | "update_available";
53
+ export const appSkillStatus = (rowHash: string, sourceHash: string | null, appliedHash: string | null): AppSkillStatus =>
54
+ rowHash === (sourceHash ?? appliedHash) ? "current" : sourceHash === null || sourceHash === appliedHash ? "modified" : "update_available";