@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.
- package/package.json +6 -3
- package/src/_internal/capability-sentences.ts +2 -1
- package/src/_internal/define-app.ts +13 -3
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/setup-lock.ts +103 -0
- package/src/ai/chat/action-card.ts +69 -3
- package/src/ai/chat/action-question.tsx +12 -1
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- package/src/ai/executor.ts +10 -2
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +15 -0
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/question-text.ts +8 -0
- package/src/ai/routes.ts +51 -5
- package/src/ai/store.ts +18 -0
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +16 -2
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- package/src/api/mcp.ts +27 -3
- package/src/api/me.ts +79 -3
- package/src/approvals/Approvals.tsx +25 -63
- package/src/approvals/approval-card.ts +68 -0
- package/src/contracts/app-approval.ts +3 -3
- package/src/contracts/capabilities.ts +1 -1
- package/src/contracts/shared.ts +3 -0
- package/src/server/middleware/auth.ts +4 -2
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/approvals/device.ts +49 -33
- package/src/services/approvals/index.ts +1 -0
- package/src/services/approvals/list.ts +1 -1
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/index.ts +2 -0
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/session/index.ts +203 -29
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/LayoutHelp.tsx +182 -540
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/help.ts +4 -3
- package/src/ssr/index.ts +1 -0
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-idle.ts +9 -0
- package/src/styles/approvals.css +6 -0
- 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) =>
|
|
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();
|
package/src/ai/executor.ts
CHANGED
|
@@ -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
|
|
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
|