@k2b/cloud 0.40.0 → 0.41.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/_internal/capability-sentences.ts +2 -1
  3. package/src/_internal/define-app.ts +13 -3
  4. package/src/_internal/heartbeat.ts +203 -14
  5. package/src/_internal/registry.ts +28 -1
  6. package/src/_internal/setup-lock.ts +103 -0
  7. package/src/ai/chat/action-card.ts +69 -3
  8. package/src/ai/chat/action-question.tsx +12 -1
  9. package/src/ai/chat/action-text.ts +288 -0
  10. package/src/ai/chat/tool-action-words.ts +31 -0
  11. package/src/ai/chat/tool-action.tsx +1 -26
  12. package/src/ai/executor.ts +10 -2
  13. package/src/ai/firecrawl-tools.ts +15 -2
  14. package/src/ai/index.ts +1 -0
  15. package/src/ai/migrate.ts +15 -0
  16. package/src/ai/open-questions.ts +172 -0
  17. package/src/ai/question-text.ts +8 -0
  18. package/src/ai/routes.ts +51 -5
  19. package/src/ai/store.ts +18 -0
  20. package/src/ai/testing.ts +36 -0
  21. package/src/ai/tool-sentences.ts +152 -34
  22. package/src/ai/tool-wording.ts +12 -6
  23. package/src/ai/tools.ts +8 -3
  24. package/src/ai/types.ts +16 -2
  25. package/src/api/app-approval-actions.ts +31 -7
  26. package/src/api/auth/schemas.ts +3 -0
  27. package/src/api/auth.ts +54 -18
  28. package/src/api/mcp.ts +27 -3
  29. package/src/api/me.ts +79 -3
  30. package/src/approvals/Approvals.tsx +25 -63
  31. package/src/approvals/approval-card.ts +68 -0
  32. package/src/contracts/app-approval.ts +3 -3
  33. package/src/contracts/capabilities.ts +1 -1
  34. package/src/contracts/shared.ts +3 -0
  35. package/src/server/middleware/auth.ts +4 -2
  36. package/src/services/accounts/app.ts +23 -3
  37. package/src/services/accounts/users.ts +12 -1
  38. package/src/services/approvals/device.ts +49 -33
  39. package/src/services/approvals/index.ts +1 -0
  40. package/src/services/approvals/list.ts +1 -1
  41. package/src/services/approvals/question.ts +68 -0
  42. package/src/services/audit/index.ts +1 -0
  43. package/src/services/auth-flows/ipa.ts +39 -2
  44. package/src/services/auth-flows/password-reset.ts +32 -6
  45. package/src/services/index.ts +2 -0
  46. package/src/services/pdf/markdown.ts +160 -2
  47. package/src/services/session/index.ts +203 -29
  48. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  49. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  50. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  51. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  52. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  53. package/src/ssr/HelpPanelView.island.tsx +27 -0
  54. package/src/ssr/LayoutHelp.tsx +182 -540
  55. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  56. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  57. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  58. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  59. package/src/ssr/help.ts +4 -3
  60. package/src/ssr/index.ts +1 -0
  61. package/src/ssr/islands/index.ts +3 -4
  62. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  63. package/src/ssr/layout-help-panel-document.ts +67 -0
  64. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  65. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  66. package/src/ssr/layout-help-panel-modules.ts +7 -0
  67. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  68. package/src/ssr/layout-help-panel-search.tsx +69 -0
  69. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  70. package/src/ssr/layout-help-panel-state.ts +141 -0
  71. package/src/ssr/layout-help-panel-types.ts +19 -0
  72. package/src/ssr/layout-help-views.tsx +484 -0
  73. package/src/ssr/panel-idle.ts +9 -0
  74. package/src/styles/approvals.css +6 -0
  75. package/src/styles/effects.css +15 -0
@@ -0,0 +1,172 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+ import { APPROVALS_PAGE_SIZE } from "../contracts/approvals";
4
+ import {
5
+ CapabilityActionCallReviewSchema,
6
+ CapabilityActionReviewDataSchema,
7
+ CapabilitySemanticLinkSchema,
8
+ } from "../contracts/capabilities";
9
+ import { openRequests } from "../services/approvals/list";
10
+ import { logger } from "../services/logging";
11
+ import { reviewShortText, toolActionCard, toolBlockReview } from "./chat/action-card";
12
+ import { aiToolIcon } from "./chat/message-utils";
13
+ import { legacyOutcomeWords } from "./chat/tool-action-words";
14
+ import { type AiTurnBlock, toolBlockId } from "./protocol";
15
+
16
+ export type AiOpenQuestion = {
17
+ /** The approval request id (approvals.requests.id). */
18
+ id: string;
19
+ /** Short id of the chat the question waits in. */
20
+ conversationId: string;
21
+ /** The chat's current title, at most 200 code points; chat content, never Cloud's words. */
22
+ conversationTitle: string | null;
23
+ /** The question's data-chat-anchor: the id of the tool block the chat renders it in. */
24
+ anchor: string;
25
+ /** The question's short form, plain text, at most 160 code points. */
26
+ short: string;
27
+ /** The app the question is about, as the card shows it. */
28
+ app: { name: string; icon: string; accent?: string };
29
+ createdAt: string;
30
+ };
31
+
32
+ export type AiOpenQuestions = {
33
+ items: AiOpenQuestion[];
34
+ /** All open questions of the person, across pages. */
35
+ count: number;
36
+ };
37
+
38
+ type QuestionRow = {
39
+ id: string;
40
+ conversationId: string;
41
+ conversationTitle: string | null;
42
+ displayCallId: string;
43
+ appId: string;
44
+ sentence: string;
45
+ toolName: string;
46
+ createdAt: Date;
47
+ block: Extract<AiTurnBlock, { kind: "tool" }> | null;
48
+ };
49
+
50
+ const log = logger("ai:open-questions");
51
+ const legacyReviewSchema = z.object({
52
+ question: z.string().min(1),
53
+ rows: z.array(z.object({ label: z.string(), value: z.string(), format: z.enum(["date", "date-time"]).optional() })),
54
+ markdown: z.string().optional(),
55
+ summary: z.string().optional(),
56
+ links: z.array(CapabilitySemanticLinkSchema).optional(),
57
+ });
58
+
59
+ const ownedRequests = (ownerUserId: string) => sql`
60
+ SELECT r.* FROM (${openRequests(ownerUserId)}) r
61
+ WHERE EXISTS (
62
+ SELECT 1 FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id
63
+ WHERE p.approval_id=r.id AND p.status='pending' AND p.kind IN ('approval','custom_approval')
64
+ AND c.created_by_user_id=${ownerUserId}::uuid
65
+ )`;
66
+
67
+ /** One bounded, owner-scoped snapshot, using exactly the chat's stored question card. */
68
+ export const listAiOpenQuestions = async (input: { ownerUserId: string; locale: string; timeZone: string }): Promise<AiOpenQuestions> => {
69
+ const [counts] = await sql<{ count: number }[]>`
70
+ SELECT count(*)::int AS count FROM (${ownedRequests(input.ownerUserId)}) r`;
71
+ const rows = await sql<QuestionRow[]>`
72
+ WITH page AS (
73
+ SELECT * FROM (${ownedRequests(input.ownerUserId)}) r
74
+ ORDER BY r.created_at DESC, r.id DESC LIMIT ${APPROVALS_PAGE_SIZE}
75
+ )
76
+ SELECT r.id, r.app_id AS "appId", r.sentence, r.created_at AS "createdAt",
77
+ question.short_id AS "conversationId", question.title AS "conversationTitle",
78
+ question.display_call_id AS "displayCallId", question.tool_name AS "toolName", question.block
79
+ FROM page r JOIN LATERAL (
80
+ SELECT c.short_id, c.title, p.tool_name,
81
+ CASE WHEN p.kind='custom_approval'
82
+ THEN COALESCE(substring(p.call_id FROM '^(.*)-approval-[0-9]+$'), p.call_id)
83
+ ELSE p.call_id END AS display_call_id,
84
+ (SELECT block FROM jsonb_array_elements(
85
+ CASE WHEN jsonb_typeof(t.live_blocks)='array' THEN t.live_blocks ELSE '[]'::jsonb END
86
+ ) AS blocks(block)
87
+ WHERE block->>'kind'='tool' AND block->>'callId'=p.call_id LIMIT 1) AS block
88
+ FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id
89
+ JOIN ai.turns t ON t.id=p.turn_id
90
+ WHERE p.approval_id=r.id AND p.status='pending' AND p.kind IN ('approval','custom_approval')
91
+ AND c.created_by_user_id=${input.ownerUserId}::uuid
92
+ ORDER BY p.created_at DESC, p.id DESC LIMIT 1
93
+ ) question ON true
94
+ ORDER BY r.created_at DESC, r.id DESC`;
95
+
96
+ return {
97
+ count: counts?.count ?? 0,
98
+ items: rows.map((row) => openQuestionFromRow(row, input)),
99
+ };
100
+ };
101
+
102
+ /** Stop at the payload bound without splitting a Unicode code point or copying the rest of a legacy message. */
103
+ const boundedText = (text: string, max: number): string => {
104
+ const points: string[] = [];
105
+ for (const point of text) {
106
+ if (points.length === max) return `${points.slice(0, max - 1).join("")}…`;
107
+ points.push(point);
108
+ }
109
+ return text;
110
+ };
111
+
112
+ /** Present one stored question without database access. */
113
+ export const openQuestionFromRow = (row: QuestionRow, input: { locale: string; timeZone: string }): AiOpenQuestion => {
114
+ const block = row.block;
115
+ const presentation = block?.presentation;
116
+ const question: AiOpenQuestion = {
117
+ id: row.id,
118
+ conversationId: row.conversationId,
119
+ conversationTitle: row.conversationTitle === null ? null : boundedText(row.conversationTitle, 200),
120
+ // The id the chat renders the card under: a custom approval's card carries its parent call, also when a stored
121
+ // block still has the approval's own id (see normalizeCustomApprovalBlocks in client/projection.ts).
122
+ anchor: toolBlockId(row.displayCallId),
123
+ short: boundedText(row.sentence.split(/\r?\n/, 1)[0] ?? "", 160),
124
+ app: {
125
+ name: typeof presentation?.appName === "string" ? presentation.appName : block ? "Cloud" : row.appId,
126
+ icon: aiToolIcon(
127
+ typeof block?.name === "string" ? block.name : row.toolName,
128
+ typeof presentation?.appIcon === "string" ? presentation.appIcon : undefined,
129
+ ),
130
+ ...(typeof presentation?.appAccent === "string" ? { accent: presentation.appAccent } : {}),
131
+ },
132
+ createdAt: row.createdAt.toISOString(),
133
+ };
134
+ if (!block) return question;
135
+ try {
136
+ if (
137
+ typeof block.id !== "string" ||
138
+ !block.id ||
139
+ typeof block.name !== "string" ||
140
+ !block.name ||
141
+ typeof block.callId !== "string" ||
142
+ !["running", "awaiting_approval", "awaiting_client", "completed", "failed", "rejected"].includes(block.status)
143
+ )
144
+ throw new Error("Malformed tool block");
145
+ if (block.review == null && block.approval?.review != null) CapabilityActionReviewDataSchema.parse(block.approval.review);
146
+ const review = toolBlockReview(block);
147
+ if (review !== null) {
148
+ if ("legacy" in review) legacyReviewSchema.parse(review.legacy);
149
+ else CapabilityActionCallReviewSchema.parse(review);
150
+ }
151
+ const card = toolActionCard(block, {
152
+ locale: input.locale,
153
+ timeZone: input.timeZone,
154
+ outcome: legacyOutcomeWords(block, input.locale, input.timeZone),
155
+ });
156
+ const short =
157
+ review && !("legacy" in review)
158
+ ? reviewShortText(review, { locale: input.locale, timeZone: input.timeZone, app: card.app })
159
+ : card.questionText;
160
+ if (
161
+ typeof short !== "string" ||
162
+ typeof card.app.name !== "string" ||
163
+ typeof card.app.icon !== "string" ||
164
+ (card.app.accent !== undefined && typeof card.app.accent !== "string")
165
+ )
166
+ throw new Error("Malformed question card");
167
+ return { ...question, short: boundedText(short, 160), app: card.app };
168
+ } catch {
169
+ log.warn("Failed to present open question", { approvalId: row.id, conversationId: row.conversationId });
170
+ return question;
171
+ }
172
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The plain-text form of an Assistant question, the same one the chat card shows: for `cld`, notifications, and Cloud
3
+ * Login. Pure and browser-safe, in its own entry so neither the CLI bundle nor browser bundles that use
4
+ * `@k2b/cloud/ai/browser` pull it in by accident.
5
+ */
6
+ export { type ApprovalCardInput, approvalCard } from "../approvals/approval-card";
7
+ export type { ActionCard } from "./chat/action-card";
8
+ export { type ActionQuestionText, type ActionTextRow, actionCardText, actionQuestionLines } from "./chat/action-text";
package/src/ai/routes.ts CHANGED
@@ -2,10 +2,11 @@ import { type Context, Hono } from "hono";
2
2
  import { bodyLimit } from "hono/body-limit";
3
3
  import { describeRoute } from "hono-openapi";
4
4
  import { z } from "zod";
5
- import { listCapabilities } from "../_internal/registry";
5
+ import { getApp, listCapabilities } from "../_internal/registry";
6
6
  import { QUERY_TIMEOUT_MS } from "../api/capabilities";
7
7
  import { ErrorResponseSchema } from "../contracts";
8
8
  import { APPROVAL_CLIENT_HEADER } from "../contracts/approvals";
9
+ import { capabilityActionPolicy } from "../contracts/capabilities";
9
10
  import {
10
11
  type AccessSubject,
11
12
  type AuthContext,
@@ -26,12 +27,14 @@ import { isAppSessionActor } from "../server/actor";
26
27
  import { isRequestCredentialCurrent } from "../server/middleware/auth";
27
28
  import { noTransform } from "../server/middleware/no-transform";
28
29
  import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
29
- import { approvalDecisionChannel } from "../services/approvals";
30
+ import { approvalDecisionChannel, approvals } from "../services/approvals";
31
+ import { pendingApprovalReview } from "../services/approvals/question";
30
32
  import { ImageMetadataError } from "../services/image-metadata";
31
33
  import { logger } from "../services/logging";
32
34
  import { coreSettings } from "../services/settings/api";
35
+ import { resolveAppIdentityPresentation } from "../shared/app-presentation";
33
36
  import { readThemeFromCookieHeader } from "../shared/theme";
34
- import { isUnknownActionOutcome } from "./action-review";
37
+ import { type AiStoredActionReview, isLegacyActionReview, isUnknownActionOutcome, storedActionReview } from "./action-review";
35
38
  import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
36
39
  import { AI_AUDIO_MAX_BYTES } from "./audio-format";
37
40
  import { buildAiCapabilityCatalog } from "./capabilities";
@@ -1352,8 +1355,51 @@ export const aiRoutes = (() => {
1352
1355
  if (!conversation) return notFound(c);
1353
1356
  const turn = await loadTurn(conversation, c.req.param("turnId"));
1354
1357
  if (!turn) return notFound(c);
1355
- const actions = await listPendingAiTurnActions({ conversationId: conversation.id, turnId: turn.id });
1356
- return respond(c, ok(actions.map((action) => publicPendingAction(action, conversation.shortId, turn.shortId))));
1358
+ const scope = { conversationId: conversation.id, turnId: turn.id };
1359
+ const actions = await listPendingAiTurnActions(scope);
1360
+ if (!actions.some((action) => action.type === "approval_request"))
1361
+ return respond(c, ok(actions.map((action) => publicPendingAction(action, conversation.shortId, turn.shortId))));
1362
+ const [records, reviews, config] = await Promise.all([
1363
+ aiConversations.listPendingActionRecords(scope),
1364
+ aiConversations.listTurnToolReviews(scope),
1365
+ aiConversations.getTurnRunConfig(scope),
1366
+ ]);
1367
+ const locale = config?.kind !== "compact" ? (config?.locale ?? getLocale(c)) : getLocale(c);
1368
+ let capabilities: Promise<Awaited<ReturnType<typeof listCapabilities>> | null> | undefined;
1369
+ const presented = await Promise.all(
1370
+ actions.map(async (action) => {
1371
+ const result = publicPendingAction(action, conversation.shortId, turn.shortId);
1372
+ if (action.type !== "approval_request") return result;
1373
+ const record = records.find((record) => record.callId === action.callId);
1374
+ const request = record?.approvalId ? await approvals.get(record.approvalId) : null;
1375
+ const reviewCallId = record?.kind === "custom_approval" ? action.callId.replace(/-approval-[0-9]+$/, "") : action.callId;
1376
+ const stored = reviews.get(reviewCallId);
1377
+ let review: AiStoredActionReview | null = stored && !isLegacyActionReview(stored) ? stored : null;
1378
+ if (!review && request) {
1379
+ review = pendingApprovalReview({ storedReview: null, details: request.details });
1380
+ if (review && !isLegacyActionReview(review) && !review.ask) {
1381
+ // As the approvals page: the declared reason when the review names none, read once per listing.
1382
+ capabilities ??= listCapabilities().catch(() => null);
1383
+ const manifest = (await capabilities)?.find((entry) => entry.appId === request.appId)?.manifest;
1384
+ review = storedActionReview(review, manifest ? capabilityActionPolicy(manifest, request.action) : undefined);
1385
+ }
1386
+ }
1387
+ const appId = request?.appId;
1388
+ // The name is presentation: a registry that cannot answer leaves the app ID, never the waiting approvals unread.
1389
+ const app = appId && appId !== "assistant" ? await getApp(appId).catch(() => null) : null;
1390
+ return {
1391
+ ...result,
1392
+ ...(review && !isLegacyActionReview(review) ? { review } : {}),
1393
+ app: {
1394
+ id: appId ?? "assistant",
1395
+ name: app ? resolveAppIdentityPresentation(app, locale).name : appId && appId !== "assistant" ? appId : "Cloud",
1396
+ },
1397
+ chatTitle: conversation.title,
1398
+ ...(request ? { expiresAt: request.expiresAt.toISOString() } : {}),
1399
+ };
1400
+ }),
1401
+ );
1402
+ return respond(c, ok(presented));
1357
1403
  })
1358
1404
  .get("/conversations/:conversationId/stream", async (c) => {
1359
1405
  const ctx = await resolveContext(c);
package/src/ai/store.ts CHANGED
@@ -3607,6 +3607,24 @@ export const aiConversations: AiConversationService = {
3607
3607
  return row ? rowToPendingActionRecord(row) : null;
3608
3608
  },
3609
3609
 
3610
+ listTurnToolReviews: async (input) => {
3611
+ // Only the review metadata, never the messages themselves, which can carry large tool results.
3612
+ const rows = await sql<{ tool_reviews: Record<string, AiStoredActionReview> | null }[]>`
3613
+ SELECT meta->'toolReviews' AS tool_reviews
3614
+ FROM (
3615
+ SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
3616
+ UNION ALL
3617
+ SELECT meta, seq, conversation_id, loop_id, role FROM ai.task_messages
3618
+ ) messages
3619
+ WHERE conversation_id = ${input.conversationId}
3620
+ AND loop_id = ${input.turnId}
3621
+ AND role = 'assistant'
3622
+ AND meta ? 'toolReviews'
3623
+ ORDER BY seq ASC
3624
+ `;
3625
+ return new Map(rows.flatMap((row) => Object.entries(row.tool_reviews ?? {})));
3626
+ },
3627
+
3610
3628
  listPendingActionRecords: async (input) => {
3611
3629
  const rows = await sql<PendingActionRow[]>`
3612
3630
  SELECT *
@@ -0,0 +1,36 @@
1
+ import { actionFixtures } from "./chat/action-question.fixtures";
2
+ import { type AiOpenQuestion, openQuestionFromRow } from "./open-questions";
3
+ import type { AiTurnBlock } from "./protocol";
4
+
5
+ /**
6
+ * Demo Assistant questions for host tests and screenshots, in German and English, with fixed times. The demo data may
7
+ * change in any release: assert on what your host renders from it, not on its wording.
8
+ */
9
+ export const aiQuestionFixtures = (locale: "de" | "en"): Record<string, { block: Extract<AiTurnBlock, { kind: "tool" }> }> =>
10
+ actionFixtures(locale);
11
+
12
+ /** A waiting question as `listAiOpenQuestions` presents it from the chat's tool block. */
13
+ export const aiOpenQuestionFixture = (
14
+ input: {
15
+ id: string;
16
+ conversationId: string;
17
+ conversationTitle: string | null;
18
+ createdAt: string;
19
+ block: Extract<AiTurnBlock, { kind: "tool" }>;
20
+ },
21
+ context: { locale: string; timeZone: string },
22
+ ): AiOpenQuestion =>
23
+ openQuestionFromRow(
24
+ {
25
+ id: input.id,
26
+ conversationId: input.conversationId,
27
+ conversationTitle: input.conversationTitle,
28
+ displayCallId: input.block.callId,
29
+ appId: input.block.presentation?.kind === "capability" ? input.block.presentation.appId : "core",
30
+ sentence: "",
31
+ toolName: input.block.name,
32
+ createdAt: new Date(input.createdAt),
33
+ block: input.block,
34
+ },
35
+ context,
36
+ );
@@ -1,38 +1,156 @@
1
- import type { CapabilityPresentationCatalog } from "../contracts/capabilities";
1
+ import { actionReview } from "../capabilities/reviews";
2
+ import type { CapabilityActionCallReview } from "../contracts/capabilities";
3
+ import { CodeActionInput } from "./browser-code-contracts";
2
4
 
3
- /**
4
- * Sentences for Cloud's own Assistant tools that act on something, in the catalog shape every app uses for
5
- * its Actions and keyed by tool name. The chat words these calls through the same renderer as app Actions;
6
- * a test checks every placeholder against the tool's input schema. A tool that only reads or does several
7
- * things, such as `memory` searching, adding, or forgetting, keeps its title with what it acted on instead.
8
- */
9
- export const CLOUD_AI_TOOL_PRESENTATION = {
10
- baseLocale: "en",
11
- sentences: {
12
- // Code asks for approvals of what it does inside the run, so a rejection or a stop does not mean the code did not run.
13
- code_run: { approval: "Run code", done: "Ran code" },
14
- code_action: { approval: "Run app action {input.action}", done: "Ran app action {input.action}" },
15
- web_extract: { approval: "Read {input.url}", done: "Read {input.url}", notRun: "{input.url} not read" },
16
- fetch_file: { approval: "Download {input.url}", done: "Downloaded {input.url}", notRun: "{input.url} not downloaded" },
17
- write_file: { approval: "Write {input.path}", done: "Wrote {input.path}", notRun: "{input.path} not written" },
5
+ // Like the catalog these forms replace, code runs name no "not run" form: code asks about what it does inside the run,
6
+ // so a declined or stopped question does not mean the code did not run. No tool names a "rejected" form, so a declined
7
+ // call keeps Cloud's own words.
8
+ const forms = {
9
+ en: {
10
+ code_run: {
11
+ action: "Run code",
12
+ running: "Running code",
13
+ done: "Ran code",
14
+ confirm: "Run code",
15
+ decline: "Do not run",
16
+ },
17
+ code_action: {
18
+ action: "Run app action {action}",
19
+ running: "Running app action {action}",
20
+ done: "Ran app action {action}",
21
+ confirm: "Run action",
22
+ decline: "Do not run",
23
+ },
24
+ web_extract: {
25
+ action: "Read page on {site}",
26
+ running: "Reading page on {site}",
27
+ done: "Read page on {site}",
28
+ confirm: "Read page",
29
+ decline: "Do not read",
30
+ notRun: "Page on {site} not read",
31
+ },
32
+ fetch_file: {
33
+ action: "Download file from {site}",
34
+ running: "Downloading file from {site}",
35
+ done: "Downloaded file from {site}",
36
+ confirm: "Download file",
37
+ decline: "Do not download",
38
+ notRun: "File from {site} not downloaded",
39
+ },
40
+ write_file: {
41
+ action: "Write {file}",
42
+ running: "Writing {file}",
43
+ done: "Wrote {file}",
44
+ confirm: "Write file",
45
+ decline: "Do not write",
46
+ notRun: "{file} not written",
47
+ },
18
48
  },
19
- translations: {
20
- de: {
21
- actions: {
22
- code_run: { sentences: { approval: "Code ausführen", done: "Code ausgeführt" } },
23
- code_action: { sentences: { approval: "App-Aktion {input.action} ausführen", done: "App-Aktion {input.action} ausgeführt" } },
24
- web_extract: { sentences: { approval: "{input.url} lesen", done: "{input.url} gelesen", notRun: "{input.url} nicht gelesen" } },
25
- fetch_file: {
26
- sentences: {
27
- approval: "{input.url} herunterladen",
28
- done: "{input.url} heruntergeladen",
29
- notRun: "{input.url} nicht heruntergeladen",
30
- },
31
- },
32
- write_file: {
33
- sentences: { approval: "{input.path} schreiben", done: "{input.path} geschrieben", notRun: "{input.path} nicht geschrieben" },
34
- },
35
- },
49
+ de: {
50
+ code_run: {
51
+ action: "Code ausführen",
52
+ running: "Code wird ausgeführt",
53
+ done: "Code ausgeführt",
54
+ confirm: "Code ausführen",
55
+ decline: "Nicht ausführen",
56
+ },
57
+ code_action: {
58
+ action: "App-Aktion {action} ausführen",
59
+ running: "App-Aktion {action} wird ausgeführt",
60
+ done: "App-Aktion {action} ausgeführt",
61
+ confirm: "Aktion ausführen",
62
+ decline: "Nicht ausführen",
63
+ },
64
+ web_extract: {
65
+ action: "Seite auf {site} lesen",
66
+ running: "Seite auf {site} wird gelesen",
67
+ done: "Seite auf {site} gelesen",
68
+ confirm: "Seite lesen",
69
+ decline: "Nicht lesen",
70
+ notRun: "Seite auf {site} nicht gelesen",
71
+ },
72
+ fetch_file: {
73
+ action: "Datei von {site} herunterladen",
74
+ running: "Datei von {site} wird heruntergeladen",
75
+ done: "Datei von {site} heruntergeladen",
76
+ confirm: "Datei herunterladen",
77
+ decline: "Nicht herunterladen",
78
+ notRun: "Datei von {site} nicht heruntergeladen",
79
+ },
80
+ write_file: {
81
+ action: "{file} schreiben",
82
+ running: "{file} wird geschrieben",
83
+ done: "{file} geschrieben",
84
+ confirm: "Datei schreiben",
85
+ decline: "Nicht schreiben",
86
+ notRun: "{file} nicht geschrieben",
36
87
  },
37
88
  },
38
- } as const satisfies CapabilityPresentationCatalog;
89
+ } as const;
90
+
91
+ /** The bound of a `fields` preview value. */
92
+ const ADDRESS_MAX = 2000;
93
+ const readableAddress = (address: string): string | null => {
94
+ try {
95
+ return decodeURI(address);
96
+ } catch {
97
+ return null;
98
+ }
99
+ };
100
+
101
+ const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
102
+
103
+ /** Cloud tools use the same per-call review contract as application Actions. Safe for historical, incomplete inputs. */
104
+ export const cloudToolReview = (name: string, args: unknown, locale: string): CapabilityActionCallReview | null => {
105
+ if (!isRecord(args)) return null;
106
+ const language = /^de(?:-|$)/i.test(locale) ? "de" : "en";
107
+ const input = args;
108
+ const sentence = forms[language];
109
+ try {
110
+ if (name === "code_run") {
111
+ return actionReview({ locale: language, sentence: sentence.code_run }, {});
112
+ }
113
+ if (name === "code_action") {
114
+ const parsed = CodeActionInput.shape.action.safeParse(input.action);
115
+ if (!parsed.success) return null;
116
+ return actionReview({ locale: language, sentence: sentence.code_action }, { action: { kind: "text", value: parsed.data } });
117
+ }
118
+ if (name === "web_extract" || name === "fetch_file") {
119
+ if (typeof input.url !== "string" || !input.url.trim()) return null;
120
+ const href = input.url;
121
+ const url = new URL(href);
122
+ if (!url.host) return null;
123
+ const label = language === "de" ? "Adresse" : "Address";
124
+ const review = (rows: { label: string; value: string }[]) =>
125
+ actionReview(
126
+ { locale: language, sentence: sentence[name] },
127
+ // A place title holds 200 characters; the address rows keep the whole host.
128
+ { site: { kind: "place", title: url.host.length > 200 ? `${url.host.slice(0, 99)}…${url.host.slice(-100)}` : url.host } },
129
+ { preview: { kind: "fields", rows } },
130
+ );
131
+ // One address row holds at most 2,000 characters. An address whose encoded form is longer shows as written;
132
+ // if even that does not fit, the encoded address continues over numbered rows, so every character stays visible.
133
+ for (const address of [href, readableAddress(href)])
134
+ if (address && address.length <= ADDRESS_MAX) {
135
+ try {
136
+ return review([{ label, value: address }]);
137
+ } catch {
138
+ // Try the next form.
139
+ }
140
+ }
141
+ const parts = Array.from({ length: Math.ceil(href.length / ADDRESS_MAX) }, (_, index) =>
142
+ href.slice(index * ADDRESS_MAX, (index + 1) * ADDRESS_MAX),
143
+ );
144
+ return review(parts.map((value, index) => ({ label: `${label} ${index + 1}/${parts.length}`, value })));
145
+ }
146
+ if (name === "write_file") {
147
+ if (typeof input.path !== "string" || !input.path.trim()) return null;
148
+ const file = input.path.trim().split("/").at(-1);
149
+ if (!file) return null;
150
+ return actionReview({ locale: language, sentence: sentence.write_file }, { file: { kind: "object", title: file.slice(0, 200) } });
151
+ }
152
+ } catch {
153
+ // Historical calls can predate the current bounds or carry partial input; they keep their generic tool title.
154
+ }
155
+ return null;
156
+ };
@@ -4,13 +4,12 @@ import {
4
4
  capabilityActionSubject,
5
5
  capabilityReviewSentence,
6
6
  capabilitySentenceText,
7
- resolveCapabilityActionSentences,
8
7
  } from "../_internal/capability-sentences";
9
8
  import { CapabilityActionReceiptSchema } from "../contracts/capabilities";
10
9
  import { type AiStoredActionReview, isLegacyActionReview, storedActionReview } from "./action-review";
11
- import { displayToolName, isRecord } from "./chat/message-utils";
10
+ import { isRecord } from "./chat/message-utils";
12
11
  import type { AiTurnBlock } from "./protocol";
13
- import { CLOUD_AI_TOOL_PRESENTATION } from "./tool-sentences";
12
+ import { cloudToolReview } from "./tool-sentences";
14
13
 
15
14
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
16
15
 
@@ -20,7 +19,7 @@ const reviewOf = (block: ToolBlock): AiStoredActionReview | null =>
20
19
 
21
20
  /**
22
21
  * How the chat words one call that acts on something: an app Action through its saved presentation, a
23
- * Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
22
+ * Cloud tool through its per-call review. `null` for calls that only read.
24
23
  */
25
24
  export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
26
25
  const presentation = block.presentation;
@@ -32,8 +31,10 @@ export const toolWording = (block: ToolBlock, locale: string): CapabilityActionW
32
31
  ...(presentation.fields ? { fields: presentation.fields } : {}),
33
32
  };
34
33
  }
35
- const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
36
- return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
34
+ const review = cloudToolReview(block.name, block.args, locale);
35
+ return review
36
+ ? { title: capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone: "UTC" }) }
37
+ : null;
37
38
  };
38
39
 
39
40
  /**
@@ -45,6 +46,8 @@ export const toolSubject = (block: ToolBlock, locale: string, timeZone: string):
45
46
  const review = reviewOf(block);
46
47
  if (review && !isLegacyActionReview(review))
47
48
  return capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone });
49
+ const cloudReview = cloudToolReview(block.name, block.args, locale);
50
+ if (cloudReview) return capabilitySentenceText(cloudReview.sentence.action, cloudReview.slots, { locale: cloudReview.locale, timeZone });
48
51
  const wording = toolWording(block, locale);
49
52
  return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
50
53
  };
@@ -59,6 +62,9 @@ export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun
59
62
  return capabilityReviewSentence(review, key, { timeZone });
60
63
  }
61
64
  if (review && key === "done" && review.legacy.summary) return review.legacy.summary;
65
+ const cloudReview = cloudToolReview(block.name, block.args, locale);
66
+ // Only a form the tool names; otherwise the card keeps Cloud's own outcome words for the call.
67
+ if (cloudReview) return key === "done" || cloudReview.sentence[key] ? capabilityReviewSentence(cloudReview, key, { timeZone }) : null;
62
68
  const wording = toolWording(block, locale);
63
69
  if (!wording) return null;
64
70
  // App Actions return their data inside the result envelope; Cloud tools return the data itself.
package/src/ai/tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Tool, ToolContext } from "@k2b/nessi";
2
2
  import { defineTool as defineNessiTool } from "@k2b/nessi";
3
3
  import type { z } from "zod";
4
+ import type { CapabilityActionCallReview } from "../contracts/capabilities";
4
5
  import type { RequestActor } from "../server";
5
6
  import { aiToolNeedsApproval } from "./approvals";
6
7
  import type {
@@ -64,7 +65,7 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
64
65
  /** Localized short status; never include payloads or secrets. */
65
66
  reportProgress?: (message: string) => Promise<void>;
66
67
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
67
- requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
68
+ requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
68
69
  /** Relays a decision for an effect another platform component consumes and executes. */
69
70
  requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
70
71
  /** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
@@ -122,6 +123,8 @@ export type AiToolPreparationContext = {
122
123
  linkApproval?: (approvalCallId: string, approvalId: string) => void;
123
124
  /** Hears what a nested approval asks for before the approval is requested. */
124
125
  describeApproval?: (approvalCallId: string, target: AiApprovalTarget) => void;
126
+ /** Registers the deterministic review before requesting or consuming a nested approval. */
127
+ describeReview?: (approvalCallId: string, review: CapabilityActionCallReview) => void | Promise<void>;
125
128
  reportWebsiteReceipts?: (callId: string, receipts: AiWebsiteReceipt[]) => Promise<void>;
126
129
  };
127
130
 
@@ -168,10 +171,11 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
168
171
  }
169
172
  // Nessi numbers the approvals of one execution in request order; count them to name each one's call ID.
170
173
  let approvals = 0;
171
- const requestApproval = async (message: string, target?: AiApprovalTarget): Promise<boolean> => {
174
+ const requestApproval = async (message: string, target?: AiApprovalTarget, review?: CapabilityActionCallReview): Promise<boolean> => {
172
175
  while (true) {
173
176
  const approvalCallId = `${callId}-approval-${approvals++}`;
174
177
  if (target) input.describeApproval?.(approvalCallId, target);
178
+ if (review) await input.describeReview?.(approvalCallId, review);
175
179
  if (!(await ctx.requestApproval(message))) return false;
176
180
  const used = await use(approvalCallId);
177
181
  if (used === "approved") return true;
@@ -182,7 +186,8 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
182
186
  return {
183
187
  ...ctx,
184
188
  requestApproval,
185
- requestApprovalFor: (message: string, target: AiApprovalTarget) => requestApproval(message, target),
189
+ requestApprovalFor: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) =>
190
+ requestApproval(message, target, review),
186
191
  requestRelayedApproval: async (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => {
187
192
  const approvalCallId = `${callId}-approval-${approvals++}`;
188
193
  if (options.approvalId) input.linkApproval?.(approvalCallId, options.approvalId);