@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
@@ -217,6 +217,8 @@ function MessagePreview(props: {
217
217
  locale: string;
218
218
  timeZone: string;
219
219
  readMore?: string;
220
+ /** Only a question that sends says when the mail goes out; a draft that is created or deleted goes nowhere. */
221
+ sends: boolean;
220
222
  }) {
221
223
  const t = () => actionMessages(props.locale);
222
224
  const rows = () => {
@@ -233,6 +235,7 @@ function MessagePreview(props: {
233
235
  };
234
236
  const note = () => {
235
237
  const p = props.preview;
238
+ if (!props.sends) return undefined;
236
239
  if (p.sendAt) {
237
240
  const [before, after] = t().sendsAt({ at: "\u0000" }).split("\u0000");
238
241
  return (
@@ -307,7 +310,15 @@ function ReviewPreviewBody(props: { model: Extract<ActionPreviewModel, { kind: "
307
310
  <Chat.Preview>
308
311
  <Switch>
309
312
  <Match when={preview()?.kind === "message" ? (preview() as Extract<CapabilityPreview, { kind: "message" }>) : undefined}>
310
- {(message) => <MessagePreview preview={message()} locale={locale()} timeZone={tz()} readMore={props.readMore} />}
313
+ {(message) => (
314
+ <MessagePreview
315
+ preview={message()}
316
+ locale={locale()}
317
+ timeZone={tz()}
318
+ readMore={props.readMore}
319
+ sends={props.card.reason.code === "sends"}
320
+ />
321
+ )}
311
322
  </Match>
312
323
  <Match when={preview()?.kind === "event" ? (preview() as Extract<CapabilityPreview, { kind: "event" }>) : undefined}>
313
324
  {(event) => (
@@ -0,0 +1,288 @@
1
+ import { dates, i18n } from "@k2b/stdlib";
2
+ import { capabilityPersonText, capabilitySentenceText } from "../../_internal/capability-sentences";
3
+ import type { CapabilityAskReason, CapabilityPerson, CapabilitySentence } from "../../contracts/capabilities";
4
+ import { formatBytes } from "../../shared/format";
5
+ import type { ActionCard } from "./action-card";
6
+ import { actionMessages } from "./action-messages";
7
+
8
+ export type ActionTextRow = { label: string; value: string; format?: "date" | "date-time"; block?: true };
9
+ export type ActionQuestionText = {
10
+ locale: string;
11
+ question: string;
12
+ reason: string;
13
+ reasonCode: CapabilityAskReason;
14
+ rows: ActionTextRow[];
15
+ readMore?: string;
16
+ confirm: string;
17
+ decline: string;
18
+ incomplete: boolean;
19
+ };
20
+
21
+ const messages = i18n.define({
22
+ baseLocale: "en",
23
+ messages: {
24
+ en: {
25
+ from: "From",
26
+ to: "To",
27
+ cc: "Cc",
28
+ bcc: "Bcc",
29
+ subject: "Subject",
30
+ sendAt: "Send at",
31
+ warning: "Warning",
32
+ attachments: "Attachments",
33
+ text: "Text",
34
+ when: "When",
35
+ until: "Until",
36
+ where: "Where",
37
+ with: "With",
38
+ repeats: "Repeats",
39
+ due: "Due",
40
+ assignees: "Assignees",
41
+ status: "Status",
42
+ checklist: "Checklist",
43
+ tags: "Tags",
44
+ folder: "Folder",
45
+ size: "Size",
46
+ target: "To",
47
+ place: "Place",
48
+ added: "Added",
49
+ removed: "Removed",
50
+ starts: "Starts",
51
+ may: "May",
52
+ mayAdded: "May (added)",
53
+ mayRemoved: "May (removed)",
54
+ also: "Also",
55
+ readAll: "Read all",
56
+ title: "Title",
57
+ name: "Name",
58
+ note: "Note",
59
+ before: ({ label }: { label: string }) => `${label} (before)`,
60
+ more: ({ count }: { count: number }) => `${count.toLocaleString("en")} more`,
61
+ },
62
+ de: {
63
+ from: "Von",
64
+ to: "An",
65
+ cc: "Cc",
66
+ bcc: "Bcc",
67
+ subject: "Betreff",
68
+ sendAt: "Senden am",
69
+ warning: "Warnung",
70
+ attachments: "Anhänge",
71
+ text: "Text",
72
+ when: "Wann",
73
+ until: "Bis",
74
+ where: "Wo",
75
+ with: "Mit",
76
+ repeats: "Wiederholung",
77
+ due: "Fällig",
78
+ assignees: "Zuständig",
79
+ status: "Status",
80
+ checklist: "Checkliste",
81
+ tags: "Tags",
82
+ folder: "Ordner",
83
+ size: "Größe",
84
+ target: "Ziel",
85
+ place: "Ort",
86
+ added: "Neu",
87
+ removed: "Entfernt",
88
+ starts: "Startet",
89
+ may: "Darf",
90
+ mayAdded: "Darf (neu)",
91
+ mayRemoved: "Darf (entfernt)",
92
+ also: "Folge",
93
+ readAll: "Ganz lesen",
94
+ title: "Titel",
95
+ name: "Name",
96
+ note: "Hinweis",
97
+ before: ({ label }) => `${label} (vorher)`,
98
+ more: ({ count }) => `${count.toLocaleString("de")} weitere`,
99
+ },
100
+ },
101
+ });
102
+ export const checkActionTextMessages = () => messages.check();
103
+ /** The label for a row an old review left without one, where a transport requires labels. */
104
+ export const actionNoteLabel = (locale: string): string => messages.resolve([locale]).t.note;
105
+
106
+ /** Flatten the question card once. Transport limits and terminal escaping belong to its callers. */
107
+ export const actionCardText = (card: ActionCard): ActionQuestionText => {
108
+ const t = messages.resolve([card.locale]).t;
109
+ const rows: ActionTextRow[] = [];
110
+ let incomplete = false;
111
+ const row = (label: string, value: string | undefined, extra: Pick<ActionTextRow, "block" | "format"> = {}) => {
112
+ if (value !== undefined && value !== "") rows.push({ label, value, ...extra });
113
+ };
114
+ const list = (values: string[], more = 0) => {
115
+ if (more > 0) {
116
+ incomplete = true;
117
+ values.push(t.more({ count: more }));
118
+ }
119
+ return new Intl.ListFormat(card.locale, { type: "conjunction" }).format(values);
120
+ };
121
+ const people = (values: CapabilityPerson[] | undefined, more = 0) => list((values ?? []).map(capabilityPersonText), more);
122
+ // The thing's own name, unless the question already says it.
123
+ const named = (label: string, value: string) => {
124
+ if (!card.questionText.includes(value)) row(label, value);
125
+ };
126
+ const model = card.preview;
127
+ if (model?.kind === "legacy") {
128
+ rows.push(...model.rows);
129
+ row(t.text, model.markdown, { block: true });
130
+ } else if (model?.kind === "review") {
131
+ const context = { locale: card.locale, timeZone: model.timeZone };
132
+ const sentence = (value: CapabilitySentence) => capabilitySentenceText(value.text, value.slots ?? model.slots ?? {}, context);
133
+ const preview = model.preview;
134
+ if (preview && "truncated" in preview && preview.truncated) incomplete = true;
135
+ switch (preview?.kind) {
136
+ case "message":
137
+ row(t.from, preview.from && capabilityPersonText(preview.from));
138
+ row(t.to, people(preview.to, preview.more?.to));
139
+ row(t.cc, people(preview.cc, preview.more?.cc));
140
+ row(t.bcc, people(preview.bcc, preview.more?.bcc));
141
+ row(t.subject, preview.subject);
142
+ row(t.sendAt, preview.sendAt, { format: "date-time" });
143
+ row(t.warning, preview.warnings?.join(" · "));
144
+ row(
145
+ t.attachments,
146
+ list(
147
+ (preview.attachments ?? []).map((file) =>
148
+ file.size === undefined ? file.name : `${file.name} (${formatBytes(file.size, { locale: card.locale })})`,
149
+ ),
150
+ preview.moreAttachments,
151
+ ),
152
+ );
153
+ row(t.text, preview.body, { block: true });
154
+ break;
155
+ case "event": {
156
+ named(t.title, preview.title);
157
+ const dateOnly = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
158
+ const allDay = preview.allDay === true || dateOnly(preview.start);
159
+ // Both ends stay typed, so a device shows them in its own zone. An all-day instant becomes its calendar day
160
+ // in the card's zone first, so no reader's zone moves it to another day.
161
+ const day = (value: string) =>
162
+ dateOnly(value)
163
+ ? value
164
+ : new Intl.DateTimeFormat("en-CA", { timeZone: model.timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(
165
+ new Date(value),
166
+ );
167
+ const format = allDay ? "date" : "date-time";
168
+ row(t.when, allDay ? day(preview.start) : preview.start, { format });
169
+ if (preview.end) row(t.until, allDay ? day(preview.end) : preview.end, { format });
170
+ row(t.where, [preview.place, preview.location].filter(Boolean).join(" · "));
171
+ row(t.with, people(preview.attendees));
172
+ row(t.repeats, preview.recurrence);
173
+ row(t.text, preview.description, { block: true });
174
+ break;
175
+ }
176
+ case "task":
177
+ named(t.title, preview.title);
178
+ row(t.place, preview.place);
179
+ row(t.due, preview.due, { format: /^\d{4}-\d{2}-\d{2}$/.test(preview.due ?? "") ? "date" : "date-time" });
180
+ row(t.assignees, people(preview.assignees));
181
+ row(t.status, preview.status);
182
+ row(t.checklist, preview.checklist?.map((item) => `${item.done ? "☑" : "☐"} ${item.label}`).join(" · "));
183
+ row(t.tags, preview.tags?.join(", "));
184
+ row(t.text, preview.description, { block: true });
185
+ break;
186
+ case "file":
187
+ named(t.name, preview.name);
188
+ row(t.folder, preview.place ?? preview.path);
189
+ row(t.size, preview.size === undefined ? undefined : formatBytes(preview.size, { locale: card.locale }));
190
+ row(t.target, preview.to);
191
+ break;
192
+ case "page":
193
+ named(t.title, preview.title);
194
+ row(t.place, preview.place);
195
+ row(t.text, preview.body, { block: true });
196
+ for (const change of preview.changes ?? []) row(change.change === "added" ? t.added : t.removed, change.text);
197
+ break;
198
+ case "rule":
199
+ row(t.starts, sentence(preview.when));
200
+ row(t.text, preview.does, { block: true });
201
+ for (const may of preview.may)
202
+ row(may.mark === "added" ? t.mayAdded : may.mark === "removed" ? t.mayRemoved : t.may, sentence(may));
203
+ // The card's fixed line: a routine knows only its instruction, never this chat.
204
+ if (!preview.seesChat) row(t.note, actionMessages(card.locale).ruleBlindToChat);
205
+ break;
206
+ case "fields":
207
+ for (const field of preview.rows) {
208
+ rows.push({ label: field.label, value: field.value, ...(field.format ? { format: field.format } : {}) });
209
+ if (field.before !== undefined)
210
+ rows.push({ label: t.before({ label: field.label }), value: field.before, ...(field.format ? { format: field.format } : {}) });
211
+ }
212
+ break;
213
+ }
214
+ for (const consequence of model.consequences)
215
+ row(consequence.mark === "added" ? t.added : consequence.mark === "removed" ? t.removed : t.also, consequence.plain);
216
+ }
217
+ // A long question shortens slot values and names at most three people of a slot, then "N more". The text is
218
+ // complete only when the question or a row shows each value whole; people the app left out are never shown.
219
+ const shown = [card.questionText, ...rows.map((entry) => entry.value)];
220
+ const whole = (value: string) => shown.some((text) => text.includes(value));
221
+ const typed = (value: string, format: "date" | "date-time") =>
222
+ rows.some((entry) => entry.value === value && (entry.format === "date-time" || entry.format === format));
223
+ if (
224
+ card.slotValues?.unlisted ||
225
+ card.slotValues?.values.some((value) => !whole(value)) ||
226
+ card.slotValues?.times.some(
227
+ (time) => !whole(time.text) && !(typed(time.start, time.format) && (!time.end || typed(time.end, time.format))),
228
+ )
229
+ )
230
+ incomplete = true;
231
+ return {
232
+ locale: card.locale,
233
+ question: card.questionText,
234
+ reason: card.reason.text,
235
+ reasonCode: card.reason.code,
236
+ rows,
237
+ ...(card.readMore ? { readMore: card.readMore } : {}),
238
+ confirm: card.confirm.label,
239
+ decline: card.decline,
240
+ incomplete,
241
+ };
242
+ };
243
+
244
+ /** The CLI's plain question block, before terminal-safe sanitizing at the CLI boundary. */
245
+ export const actionQuestionLines = (
246
+ text: ActionQuestionText,
247
+ extra: { origin?: string; commands?: { approve: string; reject: string }; timeZone: string },
248
+ ): string[] => {
249
+ const t = messages.resolve([text.locale]).t;
250
+ const lines = [`? ${text.question}`, ` ${text.reason}`];
251
+ if (extra.origin) lines.push(` ${extra.origin}`);
252
+ const width = Math.max(0, ...text.rows.filter((row) => !row.block && row.label).map((row) => Array.from(row.label).length + 1));
253
+ let markedIncomplete = false;
254
+ for (const row of text.rows) {
255
+ if (row.block) {
256
+ const body = row.value.split(/\r?\n/);
257
+ // Cut a long text only when "Read all" can open the whole thing; otherwise the terminal shows all of it.
258
+ const shown = text.readMore ? body.slice(0, 8) : body;
259
+ lines.push(...shown.map((line) => ` ${line}`));
260
+ if (shown.length < body.length || (text.incomplete && !markedIncomplete)) {
261
+ lines.push(" …");
262
+ markedIncomplete = true;
263
+ }
264
+ continue;
265
+ }
266
+ const options = { locale: text.locale, timeZone: extra.timeZone };
267
+ const value =
268
+ row.format === "date-time"
269
+ ? dates.formatDateTime(row.value, options)
270
+ : row.format === "date"
271
+ ? dates.formatDate(/^\d{4}-\d{2}-\d{2}$/.test(row.value) ? `${row.value}T12:00:00.000Z` : row.value, {
272
+ ...options,
273
+ ...(/^\d{4}-\d{2}-\d{2}$/.test(row.value) ? { timeZone: "UTC" } : {}),
274
+ })
275
+ : row.value;
276
+ lines.push(row.label ? ` ${(row.label + ":").padEnd(width)} ${value}` : ` ${value}`);
277
+ }
278
+ if (text.incomplete && !markedIncomplete) lines.push(" …");
279
+ if (text.readMore) lines.push(` ${t.readAll}: ${text.readMore}`);
280
+ if (extra.commands) {
281
+ const width = Math.max(Array.from(text.confirm).length, Array.from(text.decline).length) + 1;
282
+ lines.push(
283
+ ` ${(text.confirm + ":").padEnd(width)} ${extra.commands.approve}`,
284
+ ` ${(text.decline + ":").padEnd(width)} ${extra.commands.reject}`,
285
+ );
286
+ }
287
+ return lines;
288
+ };
@@ -0,0 +1,31 @@
1
+ import { capabilityUnknownText } from "../../_internal/capability-sentences";
2
+ import type { AiTurnBlock } from "../protocol";
3
+ import { toolOutcome } from "../tool-wording";
4
+ import { isRecord } from "./message-utils";
5
+ import { aiChatMessages } from "./messages";
6
+
7
+ type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
8
+
9
+ /** The application's own short summary of a result, if it fits on one line. */
10
+ const resultSummary = (block: ToolBlock): string | undefined => {
11
+ const value = isRecord(block.result) ? block.result.summary : undefined;
12
+ return typeof value === "string" && value.trim() && value.trim().length <= 500 ? value.trim() : undefined;
13
+ };
14
+
15
+ /** The words of a call without an app sentence: Cloud's frames around its catalog sentence or title. */
16
+ export const legacyOutcomeWords = (block: ToolBlock, locale: string, timeZone: string) => (subject: string) => {
17
+ const t = aiChatMessages(locale);
18
+ const capability = block.presentation?.kind === "capability";
19
+ return {
20
+ running: t.receiptRunning({ title: subject }),
21
+ // The app's done sentence, else its own summary of the call, else Cloud's frame.
22
+ done:
23
+ toolOutcome(block, "done", locale, timeZone) ??
24
+ resultSummary(block) ??
25
+ (capability ? t.receiptDone({ title: subject }) : t.receiptApproved({ title: subject })),
26
+ rejected: toolOutcome(block, "rejected", locale, timeZone) ?? t.receiptRejected({ title: subject }),
27
+ notRun: toolOutcome(block, "notRun", locale, timeZone) ?? t.receiptNotRun({ title: subject, stopped: false }),
28
+ unknown: capabilityUnknownText(subject, locale),
29
+ unknownStill: capabilityUnknownText(subject, locale, true),
30
+ };
31
+ };
@@ -1,11 +1,9 @@
1
1
  import { dates } from "@k2b/stdlib";
2
2
  import { Button, ButtonLink, type ChatDecisionState, useLocale } from "@k2b/ui";
3
3
  import { createEffect, createMemo, createSignal, For, type JSX, on, onCleanup, Show } from "solid-js";
4
- import { capabilityUnknownText } from "../../_internal/capability-sentences";
5
4
  import { CapabilityActionReceiptSchema } from "../../contracts/capabilities";
6
5
  import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck } from "../action-review";
7
6
  import type { AiTurnBlock } from "../protocol";
8
- import { toolOutcome } from "../tool-wording";
9
7
  import { type ActionCard, type ActionText, actionReceiptText, toolActionCard } from "./action-card";
10
8
  import { actionMessages } from "./action-messages";
11
9
  import { type ActionLine, ActionQuestion, ActionReceipt } from "./action-question";
@@ -13,6 +11,7 @@ import { useReaderTimeZone } from "./action-sentences";
13
11
  import { useAiChatActions } from "./message-actions";
14
12
  import { capabilityErrorDescription, isRecord } from "./message-utils";
15
13
  import { aiChatMessages } from "./messages";
14
+ import { legacyOutcomeWords } from "./tool-action-words";
16
15
  import type { AiTurnActionState, AiTurnPhase } from "./turn-layout";
17
16
 
18
17
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -44,30 +43,6 @@ export const toolErrorCode = (result: unknown): string | null => {
44
43
  /** The message without its code, so the detail under a line reads as a sentence. */
45
44
  const errorDetail = (result: unknown): string => capabilityErrorDescription(result).replace(/^[A-Z][A-Z0-9_]{2,}:\s*/, "");
46
45
 
47
- /** The application's own short summary of a result, if it fits on one line. */
48
- const resultSummary = (block: ToolBlock): string | undefined => {
49
- const value = isRecord(block.result) ? block.result.summary : undefined;
50
- return typeof value === "string" && value.trim() && value.trim().length <= 500 ? value.trim() : undefined;
51
- };
52
-
53
- /** The words of a call without an app sentence: Cloud's frames around its catalog sentence or title. */
54
- export const legacyOutcomeWords = (block: ToolBlock, locale: string, timeZone: string) => (subject: string) => {
55
- const t = aiChatMessages(locale);
56
- const capability = block.presentation?.kind === "capability";
57
- return {
58
- running: t.receiptRunning({ title: subject }),
59
- // The app's done sentence, else its own summary of the call, else Cloud's frame.
60
- done:
61
- toolOutcome(block, "done", locale, timeZone) ??
62
- resultSummary(block) ??
63
- (capability ? t.receiptDone({ title: subject }) : t.receiptApproved({ title: subject })),
64
- rejected: toolOutcome(block, "rejected", locale, timeZone) ?? t.receiptRejected({ title: subject }),
65
- notRun: toolOutcome(block, "notRun", locale, timeZone) ?? t.receiptNotRun({ title: subject, stopped: false }),
66
- unknown: capabilityUnknownText(subject, locale),
67
- unknownStill: capabilityUnknownText(subject, locale, true),
68
- };
69
- };
70
-
71
46
  /** The card of one tool block in the reader's locale and zone. */
72
47
  export const useToolActionCard = (block: () => ToolBlock): (() => ActionCard) => {
73
48
  const locale = useLocale();
@@ -1055,6 +1055,8 @@ export class AiTurnExecutor {
1055
1055
  const approvalTargets = new Map<string, AiApprovalTarget>();
1056
1056
  const linkedApprovals = new Map<string, string>();
1057
1057
  const freshApprovalRequired = new Set<string>();
1058
+ const capabilityActionReviews = new Map<string, CapabilityActionReviewData>();
1059
+ const storedReviews = new Map<string, AiStoredActionReview>();
1058
1060
  const dynamicToolRuntimeContext = {
1059
1061
  turnId,
1060
1062
  reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
@@ -1096,6 +1098,14 @@ export class AiTurnExecutor {
1096
1098
  return "changed";
1097
1099
  },
1098
1100
  describeApproval: (approvalCallId: string, target: AiApprovalTarget) => approvalTargets.set(approvalCallId, target),
1101
+ describeReview: async (approvalCallId: string, review: CapabilityActionReviewData) => {
1102
+ capabilityActionReviews.set(approvalCallId, review);
1103
+ const callId = customApprovalParentCallId(approvalCallId) ?? approvalCallId;
1104
+ const stored = storedActionReview(review);
1105
+ await recordAiToolReview({ conversationId, turnId, callId, review: stored });
1106
+ storedReviews.set(callId, stored);
1107
+ await pipeline.setToolReview(callId, stored);
1108
+ },
1099
1109
  };
1100
1110
  const prepared = prepareAiTools({
1101
1111
  tools: activeTools,
@@ -1105,8 +1115,6 @@ export class AiTurnExecutor {
1105
1115
  });
1106
1116
  let backgroundError: string | null = null;
1107
1117
  const rememberableCapabilityApprovals = new Map<string, string>();
1108
- const capabilityActionReviews = new Map<string, CapabilityActionReviewData>();
1109
- const storedReviews = new Map<string, AiStoredActionReview>();
1110
1118
  const onCapabilityReview = async (
1111
1119
  callId: string,
1112
1120
  review: CapabilityActionReviewData,
@@ -1,10 +1,12 @@
1
1
  import { type ToolContext, truncateMiddle } from "@k2b/nessi";
2
2
  import { z } from "zod";
3
+ import type { CapabilityActionCallReview } from "../contracts/capabilities";
3
4
  import type { RequestActor } from "../server";
4
5
  import { coreSettings } from "../services";
5
6
  import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
6
7
  import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsWebsiteApprovals, aiWebsiteApprovalScope, findRememberedAiToolApproval } from "./approvals";
7
8
  import { aiConversations } from "./store";
9
+ import { cloudToolReview } from "./tool-sentences";
8
10
  import { defineAiTool } from "./tools";
9
11
  import type { AiApprovalTarget, AiWebsiteReceipt } from "./types";
10
12
  import { hasWebAddressProvenance, recordWebAddresses, webAddressesIn } from "./web-provenance";
@@ -280,7 +282,7 @@ export type WebReadContext = Pick<ToolContext, "requestApproval"> & {
280
282
  conversationId?: string;
281
283
  turnId?: string;
282
284
  locale?: string;
283
- requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
285
+ requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
284
286
  reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
285
287
  };
286
288
 
@@ -306,7 +308,18 @@ export const authorizeWebRead = async (kind: "page" | "file", url: string, ctx:
306
308
  return true;
307
309
  }
308
310
  const message = webReadApprovalMessage(kind, url, ctx.locale);
309
- const approved = ctx.requestApprovalFor ? await ctx.requestApprovalFor(message, target) : await ctx.requestApproval(message);
311
+ const review = cloudToolReview(kind === "page" ? "web_extract" : "fetch_file", { url }, ctx.locale ?? "en");
312
+ const question: CapabilityActionCallReview | undefined = review
313
+ ? {
314
+ ...review,
315
+ ask: "sends",
316
+ consequences: message
317
+ .split("\n")
318
+ .slice(1)
319
+ .map((text) => ({ text })),
320
+ }
321
+ : undefined;
322
+ const approved = ctx.requestApprovalFor ? await ctx.requestApprovalFor(message, target, question) : await ctx.requestApproval(message);
310
323
  if (!approved) throw new Error("The person did not allow reading this address. Use an address from their message or a search result.");
311
324
  return false;
312
325
  };
package/src/ai/index.ts CHANGED
@@ -262,6 +262,7 @@ export {
262
262
  createCloudAiMemoryTool,
263
263
  } from "./memory-tool";
264
264
  export { migrateCloudAi } from "./migrate";
265
+ export { type AiOpenQuestion, type AiOpenQuestions, listAiOpenQuestions } from "./open-questions";
265
266
  export { personalAiModelPolicy } from "./personal-agent";
266
267
  export { type AiUserPrefs, aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
267
268
  export {
package/src/ai/migrate.ts CHANGED
@@ -2297,6 +2297,21 @@ export const migrateCloudAi = async (): Promise<void> => {
2297
2297
  AFTER UPDATE OF status, error, completed_at, cancel_requested_at ON ai.turns
2298
2298
  FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list,conversation-detail')
2299
2299
  `.simple();
2300
+ // Only questions for the person change the list of open questions; browser tool calls come and go per turn.
2301
+ await sql`DROP TRIGGER IF EXISTS ai_live_pending_actions_changed ON ai.pending_actions`.simple();
2302
+ await sql`DROP TRIGGER IF EXISTS ai_live_pending_actions_removed ON ai.pending_actions`.simple();
2303
+ await sql`
2304
+ CREATE TRIGGER ai_live_pending_actions_changed
2305
+ AFTER INSERT OR UPDATE OF status ON ai.pending_actions
2306
+ FOR EACH ROW WHEN (NEW.kind IN ('approval', 'custom_approval'))
2307
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')
2308
+ `.simple();
2309
+ await sql`
2310
+ CREATE TRIGGER ai_live_pending_actions_removed
2311
+ AFTER DELETE ON ai.pending_actions
2312
+ FOR EACH ROW WHEN (OLD.kind IN ('approval', 'custom_approval'))
2313
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')
2314
+ `.simple();
2300
2315
  await sql`DROP TRIGGER IF EXISTS ai_live_chat_tasks_changed ON ai.chat_tasks`.simple();
2301
2316
  await sql`
2302
2317
  CREATE TRIGGER ai_live_chat_tasks_changed AFTER INSERT OR UPDATE OR DELETE ON ai.chat_tasks