toolroll 0.9.37 → 0.9.38
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/CHANGELOG.md +19 -0
- package/dist/browser/THIRD_PARTY_NOTICES.txt +3 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +26 -26
- package/dist/browser-crew.js +5 -0
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-channel.js +20 -9
- package/dist/chat-decide.d.ts +4 -3
- package/dist/chat-decide.js +19 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/mate-progress.d.ts +26 -0
- package/dist/mate-progress.js +24 -0
- package/dist/mate.js +11 -2
- package/dist/serve.js +14 -3
- package/dist/telegram-flow.d.ts +9 -0
- package/dist/telegram-flow.js +40 -5
- package/dist/telegram-mate.js +1 -1
- package/dist/telegram.js +42 -26
- package/package.json +17 -12
package/dist/browser-crew.js
CHANGED
|
@@ -38,6 +38,10 @@ export function browserCrewFromIndex(page, conversationId) {
|
|
|
38
38
|
const link = (href) => conversationId && href.startsWith('/chat?') ? href + '&conversation=' + encodeURIComponent(conversationId) : href;
|
|
39
39
|
const rows = page.items.map(summary => {
|
|
40
40
|
const status = summary.status;
|
|
41
|
+
// Keep the server's sentence intact; ignore only empty or repeated badge words.
|
|
42
|
+
const words = (text) => text.trim().replace(/\s+/g, ' ').replace(/[.!?]+$/, '').toLowerCase();
|
|
43
|
+
const detail = ['Waiting', 'Failed', 'Needs you'].includes(status.label)
|
|
44
|
+
&& words(status.detail) !== '' && words(status.detail) !== words(status.label) ? status.detail : undefined;
|
|
41
45
|
const resultHref = summary.resultRunId === null || summary.resultTaskId === null || (summary.resultOutcome !== 'built' && summary.resultOutcome !== 'no-change')
|
|
42
46
|
? null : chatResultHref(summary.resultTaskId, summary.resultRunId);
|
|
43
47
|
const href = chatControlHref('task', summary.rootId);
|
|
@@ -48,6 +52,7 @@ export function browserCrewFromIndex(page, conversationId) {
|
|
|
48
52
|
state: summary.assignmentState, label: status.label, tone: status.tone, href: link(href), resultHref: resultHref === null ? null : link(resultHref),
|
|
49
53
|
action: action === null || actionHref === null ? null : { label: action.label, href: link(actionHref) },
|
|
50
54
|
updatedAt: summary.updatedAt,
|
|
55
|
+
...(detail === undefined ? {} : { detail }),
|
|
51
56
|
...(summary.lead === undefined ? {} : { lead: summary.lead }),
|
|
52
57
|
};
|
|
53
58
|
return { item, rank: status.rank };
|
|
@@ -49,6 +49,8 @@ export type BrowserCrewItem = {
|
|
|
49
49
|
} | null;
|
|
50
50
|
/** When the task family last changed (the index's own sort time), shown as the row's age. */
|
|
51
51
|
updatedAt: string;
|
|
52
|
+
/** The server's status detail, when Waiting, Failed or Needs you adds to its badge. */
|
|
53
|
+
detail?: string;
|
|
52
54
|
/** "<name> is on it.": the person's own lead took it on, by the name they gave it. */
|
|
53
55
|
lead?: string;
|
|
54
56
|
};
|
package/dist/chat-channel.js
CHANGED
|
@@ -273,16 +273,27 @@ export function proposalPreview(store, proposal, repos, channel = null) {
|
|
|
273
273
|
: undefined;
|
|
274
274
|
return path === undefined ? id : projectLabel(path);
|
|
275
275
|
})();
|
|
276
|
+
// Telegram's card leads with the action and what confirming does, then the exact terms; its buttons say the rest.
|
|
277
|
+
const phone = channel === "telegram";
|
|
276
278
|
const card = (headline, body = [], consequence = null) => ({
|
|
277
|
-
text:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
text: (phone
|
|
280
|
+
? [
|
|
281
|
+
headline,
|
|
282
|
+
...(consequence === null ? [] : [consequence]),
|
|
283
|
+
...(body.length === 0 ? [] : ["", ...body]),
|
|
284
|
+
"",
|
|
285
|
+
"Nothing changes until you confirm.",
|
|
286
|
+
]
|
|
287
|
+
: [
|
|
288
|
+
headline,
|
|
289
|
+
...body,
|
|
290
|
+
...(consequence === null ? [] : ["", consequence]),
|
|
291
|
+
"",
|
|
292
|
+
"Confirm or Dismiss below. Nothing changes until you confirm.",
|
|
293
|
+
]).join("\n"),
|
|
284
294
|
buttons: true,
|
|
285
295
|
});
|
|
296
|
+
const lasting = phone ? " ⚠ can't be undone" : " — IRREVERSIBLE";
|
|
286
297
|
// A handoff's text is origin-free on purpose: it is persisted before the
|
|
287
298
|
// send, and the button or the missing-setup line joins it at send time.
|
|
288
299
|
const handoff = (headline, body = []) => ({
|
|
@@ -361,11 +372,11 @@ export function proposalPreview(store, proposal, repos, channel = null) {
|
|
|
361
372
|
? ["(the decision is gone)"]
|
|
362
373
|
: [
|
|
363
374
|
`Q: ${phoneText(decision.question, 400)}`,
|
|
364
|
-
...decision.options.map((one) => `${one.id === pick ? "→" : " "} ${phoneText(one.label, 120)}${one.reversible ? "" :
|
|
375
|
+
...decision.options.map((one) => `${one.id === pick ? "→" : " "} ${phoneText(one.label, 120)}${one.reversible ? "" : lasting}${one.id === decision.recommendation ? " (the builder recommends this)" : ""}: ${phoneText(one.consequence, 300)}`),
|
|
365
376
|
...(t("rationale") === "" ? [] : [`Why: ${t("rationale", 300)}`]),
|
|
366
377
|
];
|
|
367
378
|
return card(`Answer decision #${String(id ?? "?")} on ${taskName} with "${t("optionLabel", 120) || pick}"`, body, irreversible
|
|
368
|
-
? "⚠ This choice is irreversible. Confirming asks you once more."
|
|
379
|
+
? phone ? "⚠ This choice can't be undone. Confirming asks you once more." : "⚠ This choice is irreversible. Confirming asks you once more."
|
|
369
380
|
: null);
|
|
370
381
|
}
|
|
371
382
|
case "task_action": {
|
package/dist/chat-decide.d.ts
CHANGED
|
@@ -108,11 +108,12 @@ export declare function mergeInChat(store: Store, taskId: string, run: number, a
|
|
|
108
108
|
ok: false;
|
|
109
109
|
why: string;
|
|
110
110
|
};
|
|
111
|
-
/** The plan card: title, goal in two lines, what changes, done when, and what the yes allows.
|
|
112
|
-
|
|
111
|
+
/** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
|
|
112
|
+
* terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
|
|
113
|
+
export declare function planCardText(store: Store, taskId: string, scope: Scope, root?: string, channel?: DecideChannel): string;
|
|
113
114
|
/** The acts a card about this target can take in chat right now, or null when none can (the card keeps its own
|
|
114
115
|
* link). Reads only; nothing is minted. */
|
|
115
|
-
export declare function decideOffer(store: Store, target: DecideTarget, who: VerifiedApprover, now: Date, root?: string): DecideOffer | null;
|
|
116
|
+
export declare function decideOffer(store: Store, target: DecideTarget, who: VerifiedApprover, now: Date, root?: string, channel?: DecideChannel): DecideOffer | null;
|
|
116
117
|
/** One fingerprint of what the card offers, without its tokens: a repaint with the same offer keeps its buttons. */
|
|
117
118
|
export declare function offerFingerprint(offer: DecideOffer): string;
|
|
118
119
|
/** The card's buttons, minted now (message unknown until it is sent: `placeDecideTokens` puts them on it). */
|
package/dist/chat-decide.js
CHANGED
|
@@ -155,8 +155,9 @@ export function mergeInChat(store, taskId, run, approver, now) {
|
|
|
155
155
|
return mode;
|
|
156
156
|
return { ok: true, modeDigest: mode.digest, head: publication.headSha };
|
|
157
157
|
}
|
|
158
|
-
/** The plan card: title, goal in two lines, what changes, done when, and what the yes allows.
|
|
159
|
-
|
|
158
|
+
/** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
|
|
159
|
+
* terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
|
|
160
|
+
export function planCardText(store, taskId, scope, root, channel) {
|
|
160
161
|
const ref = store.lookupRef(taskId);
|
|
161
162
|
const artifact = ref === null ? null : store.latestPlanArtifact(ref.id);
|
|
162
163
|
let milestones = [];
|
|
@@ -176,6 +177,19 @@ export function planCardText(store, taskId, scope, root) {
|
|
|
176
177
|
const goal = scope.goal.split("\n").filter(one => one.trim() !== "").slice(0, 2).map(one => phoneText(one, 160));
|
|
177
178
|
const allowing = [permissionPlainWords(scope.profile), scope.budgetMicrousd === null ? "no attempt limit" : `up to ${money(scope.budgetMicrousd)} per attempt`]
|
|
178
179
|
.filter((one) => one !== null);
|
|
180
|
+
if (channel === "telegram") {
|
|
181
|
+
return [
|
|
182
|
+
`Plan ready: ${title(store, taskId)}`,
|
|
183
|
+
`Starting allows: ${allowing.join(" · ")}`,
|
|
184
|
+
"",
|
|
185
|
+
...goal,
|
|
186
|
+
...(scope.touches.length > 0 ? [`Only in: ${scope.touches.slice(0, 6).map(one => phoneText(one, 80)).join(", ")}${scope.touches.length > 6 ? ", …" : ""}`] : ["Any file in the project."]),
|
|
187
|
+
"",
|
|
188
|
+
"Done when:",
|
|
189
|
+
...(scope.acceptance.length > 0 ? bullets(scope.acceptance.map(one => one.statement), 5) : ["You decide when you review the result."]),
|
|
190
|
+
...(milestones.length > 0 ? ["", "Steps:", ...bullets(milestones, 3)] : []),
|
|
191
|
+
].join("\n");
|
|
192
|
+
}
|
|
179
193
|
return [
|
|
180
194
|
`Plan ready: ${title(store, taskId)}`,
|
|
181
195
|
"",
|
|
@@ -194,7 +208,7 @@ export function planCardText(store, taskId, scope, root) {
|
|
|
194
208
|
// ---- what a card offers ---------------------------------------------------------------
|
|
195
209
|
/** The acts a card about this target can take in chat right now, or null when none can (the card keeps its own
|
|
196
210
|
* link). Reads only; nothing is minted. */
|
|
197
|
-
export function decideOffer(store, target, who, now, root) {
|
|
211
|
+
export function decideOffer(store, target, who, now, root, channel) {
|
|
198
212
|
const ref = store.lookupRef(target.taskId);
|
|
199
213
|
if (ref === null || ref.repo === null || !who.repos.includes(ref.repo))
|
|
200
214
|
return null;
|
|
@@ -222,7 +236,7 @@ export function decideOffer(store, target, who, now, root) {
|
|
|
222
236
|
const plan = planInChat(store, target.taskId, who.name, now, root);
|
|
223
237
|
if (!plan.ok)
|
|
224
238
|
return null;
|
|
225
|
-
return { text: planCardText(store, target.taskId, plan.scope, root),
|
|
239
|
+
return { text: planCardText(store, target.taskId, plan.scope, root, channel),
|
|
226
240
|
rows: [[{ act: "approve", run: null, digest: plan.digest }], [{ label: DECIDE_LABELS.edit, path: chatControlHref("approval", target.taskId) }, { act: "not-now", run: null, digest: plan.digest }]] };
|
|
227
241
|
}
|
|
228
242
|
case "merge": {
|
|
@@ -414,7 +428,7 @@ export function applyDecideTap(store, seat, input) {
|
|
|
414
428
|
}
|
|
415
429
|
if (row.phase === "cancel") {
|
|
416
430
|
spend();
|
|
417
|
-
const offer = decideOffer(store, target, who, now, input.root);
|
|
431
|
+
const offer = decideOffer(store, target, who, now, input.root, seat.channel);
|
|
418
432
|
if (offer === null)
|
|
419
433
|
return { ack: "Cancelled.", edit: { text: body, rows: linksFor(target), tokens: [] } };
|
|
420
434
|
const minted = mintDecideButtons(store, seat, target, offer, now, input.message);
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -125,13 +125,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
125
125
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
126
126
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
127
127
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
128
|
-
error?: never;
|
|
129
128
|
active: boolean;
|
|
130
129
|
phase: string;
|
|
131
130
|
detail: string;
|
|
132
131
|
running: boolean;
|
|
133
132
|
canResume: boolean;
|
|
134
133
|
canCancel: boolean;
|
|
134
|
+
error?: never;
|
|
135
135
|
automaticRecovery?: never;
|
|
136
136
|
recoveryAttempts?: never;
|
|
137
137
|
recoveryRepairs?: never;
|
package/dist/mate-progress.d.ts
CHANGED
|
@@ -12,14 +12,40 @@ export type MateProgress = {
|
|
|
12
12
|
kind: "tool";
|
|
13
13
|
turn: number;
|
|
14
14
|
step: number;
|
|
15
|
+
id: string;
|
|
15
16
|
label: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "tool-result";
|
|
19
|
+
turn: number;
|
|
20
|
+
step: number;
|
|
21
|
+
id: string;
|
|
22
|
+
outcome: MateToolOutcome;
|
|
16
23
|
} | {
|
|
17
24
|
kind: "text";
|
|
18
25
|
turn: number;
|
|
19
26
|
step: number;
|
|
20
27
|
text: string;
|
|
21
28
|
};
|
|
29
|
+
export type MateToolOutcome = {
|
|
30
|
+
state: "succeeded";
|
|
31
|
+
} | {
|
|
32
|
+
state: "failed";
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
35
|
+
export type MateLiveTool = {
|
|
36
|
+
id: string;
|
|
37
|
+
label: string;
|
|
38
|
+
} & ({
|
|
39
|
+
state: "running";
|
|
40
|
+
} | MateToolOutcome);
|
|
41
|
+
/** Keep tools as labels for older clients; toolCalls adds per-call outcomes. */
|
|
42
|
+
export type MateLiveStep = {
|
|
43
|
+
tools: string[];
|
|
44
|
+
toolCalls: MateLiveTool[];
|
|
45
|
+
text: string;
|
|
46
|
+
};
|
|
22
47
|
export declare function mateToolLabel(name: string): string;
|
|
48
|
+
export declare function mateToolFailureReason(name: string, message: string): string;
|
|
23
49
|
/** The `text` of a structured answer still being written, when the answer
|
|
24
50
|
* opens with it (`{"text": "…`); null until then. Escapes are decoded, and
|
|
25
51
|
* an escape cut off at the end is left for the next chunk. */
|
package/dist/mate-progress.js
CHANGED
|
@@ -43,6 +43,30 @@ export function mateToolLabel(name) {
|
|
|
43
43
|
return TOOL_LABELS[name];
|
|
44
44
|
return name.startsWith("propose_") ? "Preparing a card for you to confirm" : "Working";
|
|
45
45
|
}
|
|
46
|
+
/** Tool errors instruct the model how to retry. Only known, fixed copy belongs
|
|
47
|
+
* in the live UI; never echo arguments, redaction placeholders or exceptions. */
|
|
48
|
+
const TOOL_FAILURE_REASONS = new Map([
|
|
49
|
+
["Choose a project from list_repos.", "That project isn't available."],
|
|
50
|
+
["Choose an available project from list_repos.", "That project isn't available."],
|
|
51
|
+
["repo must be one of the ids from list_repos", "That project isn't available."],
|
|
52
|
+
["not-found: no such task in your projects", "That task isn't available in your projects."],
|
|
53
|
+
["That task is not in your projects.", "That task isn't available in your projects."],
|
|
54
|
+
["not-found: no such decision in your projects", "That decision isn't available in your projects."],
|
|
55
|
+
["that decision is no longer open", "That decision is already closed."],
|
|
56
|
+
["No such flow in your projects.", "No such flow in your projects."],
|
|
57
|
+
["That action is outside your access.", "You don't have access to that action."],
|
|
58
|
+
["Read the current task version before proposing this action.", "The task needs to be checked again before this action."],
|
|
59
|
+
["That file is not in these changes. Call get_diff without file for the list.", "That file isn't in the saved changes."],
|
|
60
|
+
["The saved changes could not be verified.", "The saved changes could not be verified."],
|
|
61
|
+
["The saved check log could not be verified.", "The saved check log could not be verified."],
|
|
62
|
+
["Saved results are unavailable here.", "Saved results are unavailable here."],
|
|
63
|
+
["There is no finished result for that version yet.", "There is no finished result for that version yet."],
|
|
64
|
+
["Task text cannot contain credentials.", "The task text contains a password or secret key."],
|
|
65
|
+
["that tool refused — the plane could not answer it right now", "Toolroll couldn't finish that step right now."],
|
|
66
|
+
]);
|
|
67
|
+
export function mateToolFailureReason(name, message) {
|
|
68
|
+
return TOOL_FAILURE_REASONS.get(message) ?? `That step didn't work (${mateToolLabel(name)}).`;
|
|
69
|
+
}
|
|
46
70
|
/** The `text` of a structured answer still being written, when the answer
|
|
47
71
|
* opens with it (`{"text": "…`); null until then. Escapes are decoded, and
|
|
48
72
|
* an escape cut off at the end is left for the next chunk. */
|
package/dist/mate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
-
import { mateToolLabel } from "./mate-progress.js";
|
|
2
|
+
import { mateToolFailureReason, mateToolLabel } from "./mate-progress.js";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { mateTimeoutNotice } from "./store.js";
|
|
5
5
|
import { RESULT_IMAGES_PER_TURN_CAP } from "./chat-evidence.js";
|
|
@@ -499,8 +499,17 @@ export async function runMateTurn(input) {
|
|
|
499
499
|
if (changed !== null)
|
|
500
500
|
return changed;
|
|
501
501
|
}
|
|
502
|
-
|
|
502
|
+
// Position within this turn is unique even if a provider reuses call ids.
|
|
503
|
+
const progressId = `${turnId}:${steps}:${index}`;
|
|
504
|
+
progress({ kind: "tool", turn: turnId, step: steps, id: progressId, label: mateToolLabel(call.name) });
|
|
503
505
|
const outcome = executeMateTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
|
|
506
|
+
let displayOutcome = { state: "succeeded" };
|
|
507
|
+
if (!outcome.ok) {
|
|
508
|
+
const reason = mateToolFailureReason(call.name, outcome.message);
|
|
509
|
+
displayOutcome = { state: "failed", reason: scanForSecrets(reason).length === 0
|
|
510
|
+
? reason : `That step didn't work (${mateToolLabel(call.name)}).` };
|
|
511
|
+
}
|
|
512
|
+
progress({ kind: "tool-result", turn: turnId, step: steps, id: progressId, outcome: displayOutcome });
|
|
504
513
|
if (READ_TOOLS.has(call.name))
|
|
505
514
|
reads++;
|
|
506
515
|
if (call.name === "show_control" && outcome.ok)
|
package/dist/serve.js
CHANGED
|
@@ -4540,14 +4540,25 @@ export function createDecisionServer(options) {
|
|
|
4540
4540
|
clearTimeout(previous.expiry);
|
|
4541
4541
|
const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set() };
|
|
4542
4542
|
liveTurns.set(thread, live);
|
|
4543
|
-
const current = () => live.steps.at(-1) ?? (live.steps.push({ tools: [], text: "" }), live.steps.at(-1));
|
|
4543
|
+
const current = () => live.steps.at(-1) ?? (live.steps.push({ tools: [], toolCalls: [], text: "" }), live.steps.at(-1));
|
|
4544
4544
|
return event => {
|
|
4545
4545
|
if (live.done)
|
|
4546
4546
|
return;
|
|
4547
4547
|
if (event.kind === "step")
|
|
4548
|
-
live.steps.push({ tools: [], text: "" });
|
|
4549
|
-
else if (event.kind === "tool")
|
|
4548
|
+
live.steps.push({ tools: [], toolCalls: [], text: "" });
|
|
4549
|
+
else if (event.kind === "tool") {
|
|
4550
4550
|
current().tools.push(event.label);
|
|
4551
|
+
current().toolCalls.push({ id: event.id, label: event.label, state: "running" });
|
|
4552
|
+
}
|
|
4553
|
+
else if (event.kind === "tool-result") {
|
|
4554
|
+
for (const step of live.steps) {
|
|
4555
|
+
const index = step.toolCalls.findIndex(tool => tool.id === event.id);
|
|
4556
|
+
if (index !== -1) {
|
|
4557
|
+
step.toolCalls[index] = { ...step.toolCalls[index], ...event.outcome };
|
|
4558
|
+
break;
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4551
4562
|
else if (event.kind === "text")
|
|
4552
4563
|
current().text = event.text;
|
|
4553
4564
|
for (const listener of live.listeners)
|
package/dist/telegram-flow.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ type Waiting = {
|
|
|
11
11
|
};
|
|
12
12
|
/** The card, if it is still at that visit, waiting at a decision. */
|
|
13
13
|
export declare function flowDecisionAt(store: Store, cardId: number, entry: number): Waiting | null;
|
|
14
|
+
/**
|
|
15
|
+
* A flow decision as Telegram shows it: the action and where each button takes the card first, then the draft (as
|
|
16
|
+
* the card holds it now, so what is approved is what is read) and why a teammate handed it over, then the flow and
|
|
17
|
+
* zone. `said` is the saved notice's body: the words before its instruction are the teammate's handoff.
|
|
18
|
+
*/
|
|
19
|
+
export declare function flowDecisionText(waiting: Waiting, said: string | null, head?: string): string;
|
|
14
20
|
/** Buttons for one visit, minted before the send; `place` stamps the message they landed on. */
|
|
15
21
|
export declare function flowButtons(store: Store, binding: TelegramBinding, waiting: Waiting, now: Date): {
|
|
16
22
|
keyboard: InlineButton[][];
|
|
@@ -20,6 +26,9 @@ export declare function flowButtons(store: Store, binding: TelegramBinding, wait
|
|
|
20
26
|
export declare function flowSentContent(store: Store, card: number, entry: number): FlowSendContent | null;
|
|
21
27
|
/** A sent visit's links as one row of buttons: Toolroll's pages under the trusted origin, and its pull request. */
|
|
22
28
|
export declare function flowSendKeyboardRow(origin: string | null, content: FlowSendContent): InlineButton[];
|
|
29
|
+
/** A choice as Telegram shows it, first: what to choose, then where each option (and a reply) takes the card. The
|
|
30
|
+
* options are the ones its buttons carry: offered when sent, and still offered. */
|
|
31
|
+
export declare function flowChoiceHead(visit: FlowChoiceVisit, content: FlowSendContent): string[];
|
|
23
32
|
/** A choice's buttons for one visit, one option to a row, minted before the send; `place` stamps the message they landed on. */
|
|
24
33
|
export declare function flowChoiceButtons(store: Store, binding: TelegramBinding, visit: FlowChoiceVisit, content: FlowSendContent, now: Date): {
|
|
25
34
|
keyboard: InlineButton[][];
|
package/dist/telegram-flow.js
CHANGED
|
@@ -24,7 +24,7 @@ import { LIMITS } from "./decision.js";
|
|
|
24
24
|
import { keptDraft } from "./flow-draft.js";
|
|
25
25
|
import { decideFlowCard, draftFor, flowDefinitionOf } from "./flow-engine.js";
|
|
26
26
|
import { chooseFlowCard, flowChoiceAt, flowSendPaths, readFlowSend } from "./flow-send.js";
|
|
27
|
-
import { deciderOf } from "./flows.js";
|
|
27
|
+
import { deciderOf, FLOW_END, replyTarget } from "./flows.js";
|
|
28
28
|
import { phoneLinkButton } from "./telegram-mate.js";
|
|
29
29
|
import { telegramButton } from "./contracts/telegram-callback.js";
|
|
30
30
|
export const FLOW_DECIDE_KEY = /^flow-decide:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
|
|
@@ -41,6 +41,29 @@ export function flowDecisionAt(store, cardId, entry) {
|
|
|
41
41
|
return null;
|
|
42
42
|
return { card, flow, stage, draft: draftFor(definition, stage) };
|
|
43
43
|
}
|
|
44
|
+
/** Where Approve and Send back take the card, on one line. */
|
|
45
|
+
function decisionTerms(waiting) {
|
|
46
|
+
const approve = waiting.stage.next === null ? "Approve → done" : `Approve → ${titleOf(waiting, waiting.stage.next)}`;
|
|
47
|
+
return [approve, ...(waiting.stage.onFail === null ? [] : [`Send back → ${titleOf(waiting, waiting.stage.onFail)}, with your note`])].join(" · ");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A flow decision as Telegram shows it: the action and where each button takes the card first, then the draft (as
|
|
51
|
+
* the card holds it now, so what is approved is what is read) and why a teammate handed it over, then the flow and
|
|
52
|
+
* zone. `said` is the saved notice's body: the words before its instruction are the teammate's handoff.
|
|
53
|
+
*/
|
|
54
|
+
export function flowDecisionText(waiting, said, head = `Approve “${waiting.card.title}”`) {
|
|
55
|
+
const at = said === null ? -1 : said.indexOf(`${waiting.stage.title}: approve`);
|
|
56
|
+
const handoff = at > 0 ? said.slice(0, at).trim() : "";
|
|
57
|
+
const draft = waiting.draft === null ? undefined : waiting.card.outputs[waiting.draft.id]?.trim();
|
|
58
|
+
return [
|
|
59
|
+
head,
|
|
60
|
+
decisionTerms(waiting),
|
|
61
|
+
...(handoff === "" ? [] : ["", handoff]),
|
|
62
|
+
...(draft === undefined || draft === "" ? [] : ["", "Draft:", draft]),
|
|
63
|
+
"",
|
|
64
|
+
`${waiting.flow.name} · ${waiting.stage.title}`,
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
44
67
|
/** Buttons for one visit, minted before the send; `place` stamps the message they landed on. */
|
|
45
68
|
export function flowButtons(store, binding, waiting, now) {
|
|
46
69
|
const mint = (action) => ({ action, token: randomBytes(16).toString("hex") });
|
|
@@ -64,6 +87,17 @@ export function flowSendKeyboardRow(origin, content) {
|
|
|
64
87
|
const pull = content.links.flatMap(one => "url" in one ? [{ text: one.label, url: one.url }] : []);
|
|
65
88
|
return [...pages.slice(0, 1), ...pull, ...pages.slice(1)].slice(0, 3);
|
|
66
89
|
}
|
|
90
|
+
/** A choice as Telegram shows it, first: what to choose, then where each option (and a reply) takes the card. The
|
|
91
|
+
* options are the ones its buttons carry: offered when sent, and still offered. */
|
|
92
|
+
export function flowChoiceHead(visit, content) {
|
|
93
|
+
const titleIn = (id) => visit.definition.stages.find(one => one.id === id)?.title ?? id;
|
|
94
|
+
const options = (content.options ?? []).flatMap(one => {
|
|
95
|
+
const option = visit.stage.options?.[one.choice];
|
|
96
|
+
return option === undefined || option.label !== one.label ? [] : [`${one.label} → ${option.to === FLOW_END ? "closes the card" : titleIn(option.to)}`];
|
|
97
|
+
});
|
|
98
|
+
const reply = content.reply === true ? replyTarget(visit.stage) : null;
|
|
99
|
+
return [`Choose what happens to “${visit.card.title}”`, [...options, ...(reply === null ? [] : [`or reply → ${titleIn(reply)}, with your note`])].join(" · ")];
|
|
100
|
+
}
|
|
67
101
|
/** A choice's buttons for one visit, one option to a row, minted before the send; `place` stamps the message they landed on. */
|
|
68
102
|
export function flowChoiceButtons(store, binding, visit, content, now) {
|
|
69
103
|
// Only the options the zone still offers as they were sent: a flow changed since sends no stale button.
|
|
@@ -88,7 +122,9 @@ export function applyFlowChoiceTap(store, binding, choice, message, repos, now)
|
|
|
88
122
|
if (!chosen.ok)
|
|
89
123
|
return [{ kind: "ack", text: chosen.message.slice(0, 190) }];
|
|
90
124
|
store.retireFlowChoices(choice.card, choice.entry, now);
|
|
91
|
-
|
|
125
|
+
// The door's words name the option already ("Ship it. Moved to Ship."); an ending says what happened instead.
|
|
126
|
+
const said = chosen.said.startsWith(`${choice.label}.`) ? chosen.said : `You chose “${choice.label}”. ${chosen.said}`;
|
|
127
|
+
return [{ kind: "ack", text: choice.label.slice(0, 190) }, { kind: "edit", text: `${message.text}\n\n✅ ${said}`.slice(0, 4000) }];
|
|
92
128
|
}
|
|
93
129
|
/** A tapped flow button, applied inside the update's transaction. */
|
|
94
130
|
export function applyFlowTap(store, binding, action, message, repos, now) {
|
|
@@ -107,7 +143,7 @@ export function applyFlowTap(store, binding, action, message, repos, now) {
|
|
|
107
143
|
if (!decided.ok)
|
|
108
144
|
return [{ kind: "ack", text: decided.message.slice(0, 190) }];
|
|
109
145
|
store.retireTelegramFlowVisit(action.card, action.entry, now);
|
|
110
|
-
return [{ kind: "ack", text: "Approved" }, { kind: "edit", text: `${message.text}\n\n✅
|
|
146
|
+
return [{ kind: "ack", text: "Approved" }, { kind: "edit", text: `${message.text}\n\n✅ ${decided.said}`.slice(0, 4000) }];
|
|
111
147
|
}
|
|
112
148
|
const prompt = { chatId: binding.chatId, binding: binding.id, card: action.card, entry: action.entry, mode: action.action };
|
|
113
149
|
if (action.action === "edit") {
|
|
@@ -157,8 +193,7 @@ export function applyFlowReply(store, binding, prompt, text, repos, now) {
|
|
|
157
193
|
store.retireTelegramFlowVisit(prompt.card, prompt.entry, now);
|
|
158
194
|
const fresh = flowDecisionAt(store, prompt.card, prompt.entry);
|
|
159
195
|
const buttons = flowButtons(store, binding, fresh, now);
|
|
160
|
-
return [{ kind: "decide", keyboard: buttons.keyboard, tokens: buttons.tokens,
|
|
161
|
-
text: `${waiting.flow.name}: your version of the draft for “${waiting.card.title}”\n\n${kept}\n\nApprove to send it as written.` }];
|
|
196
|
+
return [{ kind: "decide", keyboard: buttons.keyboard, tokens: buttons.tokens, text: flowDecisionText(fresh, null, `Approve your version of “${waiting.card.title}”`) }];
|
|
162
197
|
}
|
|
163
198
|
// A note over its limit is refused with the limit, never cut.
|
|
164
199
|
if (said.length > LIMITS.note)
|
package/dist/telegram-mate.js
CHANGED
|
@@ -256,7 +256,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
|
|
|
256
256
|
return;
|
|
257
257
|
}
|
|
258
258
|
ack("irreversible — confirm it");
|
|
259
|
-
edit(
|
|
259
|
+
edit(`${body}\n\n⚠ Last step: this answer can't be undone. Confirm?`, [
|
|
260
260
|
[telegramButton("⚠ Yes, answer it", yes)],
|
|
261
261
|
[telegramButton("Cancel", cancel)],
|
|
262
262
|
]);
|
package/dist/telegram.js
CHANGED
|
@@ -34,7 +34,7 @@ import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerso
|
|
|
34
34
|
import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
|
|
35
35
|
import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
|
|
36
36
|
import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
|
|
37
|
-
import { applyFlowChoiceTap, applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowChoiceButtons, flowDecisionAt, flowSendKeyboardRow, flowSentContent } from "./telegram-flow.js";
|
|
37
|
+
import { applyFlowChoiceTap, applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowChoiceButtons, flowChoiceHead, flowDecisionAt, flowDecisionText, flowSendKeyboardRow, flowSentContent } from "./telegram-flow.js";
|
|
38
38
|
import { flowChoiceAt, flowSendTail, FLOW_CHOOSE_KEY, FLOW_SEND_KEY } from "./flow-send.js";
|
|
39
39
|
import { cleanFlowMessage, fitFlowMessage } from "./flow-items.js";
|
|
40
40
|
import { telegramReply } from "./reply-shape.js";
|
|
@@ -634,7 +634,7 @@ function decideOfferFor(store, binding, target, projects, now, root) {
|
|
|
634
634
|
if (target === null || target === undefined)
|
|
635
635
|
return null;
|
|
636
636
|
const principal = verifyApproverStanding(store, binding.approver, binding.approverGeneration, telegramConversationRepos(store, binding.approver, projects));
|
|
637
|
-
return principal.ok ? decideOffer(store, target, principal.who, now, root) : null;
|
|
637
|
+
return principal.ok ? decideOffer(store, target, principal.who, now, root, "telegram") : null;
|
|
638
638
|
}
|
|
639
639
|
/** Team-conversation traffic to the chats that follow one — after the outbox, under the same delivery switch, never a problem to raise when nothing follows anything. */
|
|
640
640
|
async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
|
|
@@ -1225,13 +1225,23 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1225
1225
|
} })();
|
|
1226
1226
|
if (sentRow !== null)
|
|
1227
1227
|
button = sentRow.length === 0 ? null : sentRow;
|
|
1228
|
+
// A flow decision or choice for this person leads with what to do and where each button takes the card
|
|
1229
|
+
// (telegram-flow.ts); the flow and zone close it. The saved notice keeps its own words for every other place.
|
|
1230
|
+
const pull = sent === null ? [] : sent.links.flatMap(one => "url" in one ? [`${one.label}: ${one.url}`] : []).slice(0, 1);
|
|
1231
|
+
const choiceHead = choosing === null || sent === null ? null : flowChoiceHead(choosing, sent);
|
|
1232
|
+
const choiceTail = choosing === null ? [] : [...pull, `${choosing.flow.name}${sent?.from === undefined ? "" : ` · after ${sent.from}`}`];
|
|
1233
|
+
if (offer === null && flowKeys !== null && waiting !== null)
|
|
1234
|
+
parts = split(`${notificationIdentity(notification)}${chatText(flowDecisionText(waiting, notification.body), task)}`);
|
|
1235
|
+
else if (offer === null && choiceHead !== null && sent !== null)
|
|
1236
|
+
parts = split(`${notificationIdentity(notification)}${chatText([...choiceHead, "", sent.summary, "", ...choiceTail.flatMap((line, index) => index === 0 ? [line] : ["", line])].join("\n"), task)}`);
|
|
1228
1237
|
// After research (flow-items.ts): each item numbered under the summary, its words cleaned as the rest of the message's
|
|
1229
1238
|
// are, within Telegram's limit with every link kept — one message, unless head, links and tail alone overflow it.
|
|
1230
1239
|
let entities = [];
|
|
1231
1240
|
if (sent?.items !== undefined && sent.items.length > 0 && offer === null) {
|
|
1232
1241
|
const subject = chatText(leadSubjectOf(store, notification, binding.approver), task);
|
|
1233
|
-
const head =
|
|
1234
|
-
|
|
1242
|
+
const head = choiceHead !== null ? `${notificationIdentity(notification)}${choiceHead.join("\n")}`
|
|
1243
|
+
: `${notificationIdentity(notification, title !== undefined && mentions(subject, title) ? undefined : title)}${subject}`;
|
|
1244
|
+
const message = cleanFlowMessage({ head, summary: sent.summary, items: sent.items, tail: choiceHead !== null ? choiceTail : flowSendTail(sent) }, words => chatText(words, task));
|
|
1235
1245
|
const voiced = fitFlowMessage(message, TELEGRAM_TEXT_MAX, shaped => telegramReply(shaped).text.length).map(telegramReply);
|
|
1236
1246
|
parts = voiced.map(one => one.text);
|
|
1237
1247
|
entities = voiced.map(one => one.entities);
|
|
@@ -1268,24 +1278,11 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1268
1278
|
return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
|
|
1269
1279
|
}
|
|
1270
1280
|
// A decision. Every safety-bearing word goes out before anything tappable
|
|
1271
|
-
// exists:
|
|
1272
|
-
// many plain messages as they need — a button whose
|
|
1273
|
-
// away is a trap, so the keyboard rides the LAST part
|
|
1274
|
-
// every earlier part arrived.
|
|
1275
|
-
const
|
|
1276
|
-
`${notificationIdentity(notification)}Decision needed`,
|
|
1277
|
-
"",
|
|
1278
|
-
decision.recap,
|
|
1279
|
-
"",
|
|
1280
|
-
`Q: ${decision.question}`,
|
|
1281
|
-
"",
|
|
1282
|
-
...decision.options.flatMap(option => [
|
|
1283
|
-
`[${option.id}] ${option.label}${option.id === decision.recommendation ? " (recommended)" : ""}${option.reversible ? "" : " — IRREVERSIBLE"}`,
|
|
1284
|
-
` ${option.consequence}`,
|
|
1285
|
-
]),
|
|
1286
|
-
...(decision.deadline === null ? [] : ["", `deadline: ${decision.deadline}`]),
|
|
1287
|
-
];
|
|
1288
|
-
const parts = split(lines.join("\n"));
|
|
1281
|
+
// exists: the question and every option's consequence first, then the
|
|
1282
|
+
// recap, split across as many plain messages as they need — a button whose
|
|
1283
|
+
// warning was truncated away is a trap, so the keyboard rides the LAST part
|
|
1284
|
+
// only, and only if every earlier part arrived.
|
|
1285
|
+
const parts = split([`${notificationIdentity(notification)}${decisionAsk(decision)}`, "", `Background: ${decision.recap}`].join("\n"));
|
|
1289
1286
|
for (const part of parts.slice(0, -1)) {
|
|
1290
1287
|
const sent = await sender(part);
|
|
1291
1288
|
if (!sent.ok)
|
|
@@ -2382,7 +2379,7 @@ function applyCallback(context, update, effects) {
|
|
|
2382
2379
|
store.createTelegramAction({ token: confirm, binding: binding.id, decision: decision.id, optionId: option.id, phase: "confirm", chatId: binding.chatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS, ...(digest === undefined ? {} : { noteDigest: digest }) }, clock());
|
|
2383
2380
|
store.createTelegramAction({ token: cancel, binding: binding.id, decision: decision.id, optionId: option.id, phase: "cancel", chatId: binding.chatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS }, clock());
|
|
2384
2381
|
ack("irreversible — confirm it");
|
|
2385
|
-
editText(
|
|
2382
|
+
editText(armedDecisionText(option, draft?.note ?? null), [
|
|
2386
2383
|
[telegramButton(`⚠ Yes, ${option.label}`, confirm)],
|
|
2387
2384
|
[telegramButton("Cancel", cancel)],
|
|
2388
2385
|
]);
|
|
@@ -2430,7 +2427,9 @@ function applyCallback(context, update, effects) {
|
|
|
2430
2427
|
store.createTelegramAction({ token: choose, binding: binding.id, decision: decision.id, optionId: option.id, phase: "choose", chatId: binding.chatId, messageId: String(message.message_id) }, clock());
|
|
2431
2428
|
}
|
|
2432
2429
|
ack("cancelled");
|
|
2433
|
-
|
|
2430
|
+
// The card again, within one message; a decision too long for one keeps the consequences its earlier parts show.
|
|
2431
|
+
const ask = decisionAsk(decision);
|
|
2432
|
+
editText(ask.length <= PART_CAP ? ask : `Decide: ${decision.question}`, fresh.map(({ option, token: choose }) => [
|
|
2434
2433
|
telegramButton(`${option.label}${option.id === decision.recommendation ? " ✓" : ""}${option.reversible ? "" : " ⚠"}`, choose),
|
|
2435
2434
|
]));
|
|
2436
2435
|
}
|
|
@@ -2460,10 +2459,27 @@ function answerNow(context, decision, choice, binding, ack, editText) {
|
|
|
2460
2459
|
}
|
|
2461
2460
|
ack(`could not answer: ${answered.reason}`);
|
|
2462
2461
|
}
|
|
2462
|
+
/** A decision's card, action first: the question, then each option with what it does, then its deadline. */
|
|
2463
|
+
function decisionAsk(decision) {
|
|
2464
|
+
return [
|
|
2465
|
+
`Decide: ${decision.question}`,
|
|
2466
|
+
...decision.options.map(option => `• ${option.label}${option.id === decision.recommendation ? " (recommended)" : ""}${option.reversible ? "" : " ⚠ can't be undone"}: ${option.consequence}`),
|
|
2467
|
+
...(decision.deadline === null ? [] : [`Decide by ${decision.deadline.slice(0, 16).replace("T", " ")} UTC`]),
|
|
2468
|
+
].join("\n");
|
|
2469
|
+
}
|
|
2470
|
+
/** The armed confirm of an option that can't be undone: what it is and what it does, then the note that travels. */
|
|
2471
|
+
function armedDecisionText(option, note) {
|
|
2472
|
+
return [
|
|
2473
|
+
`${option.label}? ⚠ This can't be undone.`,
|
|
2474
|
+
option.consequence,
|
|
2475
|
+
...(note === null ? [] : ["", "With your note:", ...note.split("\n").map(line => `| ${line}`)]),
|
|
2476
|
+
].join("\n");
|
|
2477
|
+
}
|
|
2463
2478
|
function answeredText(store, decision) {
|
|
2479
|
+
const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice ?? "?";
|
|
2464
2480
|
return [
|
|
2465
|
-
`✓
|
|
2466
|
-
|
|
2481
|
+
`✓ Answered: ${chosen}`,
|
|
2482
|
+
`${taskOf(store, decision)} · by ${decision.answeredBy ?? "?"} via ${decision.answeredVia ?? "?"}`,
|
|
2467
2483
|
// Line-prefixed, never inline: a multiline note must not be able to
|
|
2468
2484
|
// draw fake status lines (Codex free-text review, finding 7).
|
|
2469
2485
|
...(decision.note === null ? [] : ["with note:", ...decision.note.split("\n").map(line => `| ${line}`)]),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.38",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|
|
@@ -64,18 +64,18 @@
|
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@tailwindcss/cli": "4.3.3",
|
|
67
|
-
"@types/mailparser": "3.
|
|
68
|
-
"@types/node": "26.
|
|
67
|
+
"@types/mailparser": "3.9.0",
|
|
68
|
+
"@types/node": "26.6.4",
|
|
69
69
|
"@types/react": "19.3.0",
|
|
70
70
|
"@types/react-dom": "19.3.0",
|
|
71
71
|
"esbuild": "0.28.2",
|
|
72
|
-
"happy-dom": "20.
|
|
72
|
+
"happy-dom": "20.14.5",
|
|
73
73
|
"paper": "0.12.18",
|
|
74
74
|
"simple-icons": "16.34.0",
|
|
75
75
|
"tailwindcss": "4.3.3",
|
|
76
|
-
"tsx": "4.23.
|
|
76
|
+
"tsx": "4.23.15",
|
|
77
77
|
"typescript": "7.0.2",
|
|
78
|
-
"vitest": "4.1.
|
|
78
|
+
"vitest": "4.1.11"
|
|
79
79
|
},
|
|
80
80
|
"files": [
|
|
81
81
|
"dist",
|
|
@@ -93,6 +93,11 @@
|
|
|
93
93
|
"scripts/fixtures/restart-service.mjs",
|
|
94
94
|
"THIRD_PARTY_NOTICES.md"
|
|
95
95
|
],
|
|
96
|
+
"overrides": {
|
|
97
|
+
"@tailwindcss/cli@4.3.3": {
|
|
98
|
+
"@parcel/watcher": "2.6.0"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
96
101
|
"dependencies": {
|
|
97
102
|
"@radix-ui/react-collapsible": "1.1.20",
|
|
98
103
|
"@radix-ui/react-dialog": "1.1.23",
|
|
@@ -105,15 +110,15 @@
|
|
|
105
110
|
"@radix-ui/react-switch": "1.3.7",
|
|
106
111
|
"@radix-ui/react-tabs": "1.1.21",
|
|
107
112
|
"@radix-ui/react-tooltip": "1.2.16",
|
|
108
|
-
"@slack/socket-mode": "3.0
|
|
109
|
-
"@xyflow/react": "12.
|
|
113
|
+
"@slack/socket-mode": "3.1.0",
|
|
114
|
+
"@xyflow/react": "12.12.0",
|
|
110
115
|
"class-variance-authority": "0.7.1",
|
|
111
116
|
"clsx": "2.1.1",
|
|
112
117
|
"discord.js": "14.27.0",
|
|
113
|
-
"imapflow": "2.
|
|
114
|
-
"lucide-react": "1.
|
|
115
|
-
"mailparser": "3.9.
|
|
116
|
-
"nodemailer": "10.0.
|
|
118
|
+
"imapflow": "2.2.5",
|
|
119
|
+
"lucide-react": "1.52.0",
|
|
120
|
+
"mailparser": "3.9.36",
|
|
121
|
+
"nodemailer": "10.0.15",
|
|
117
122
|
"react": "19.3.0",
|
|
118
123
|
"react-dom": "19.3.0",
|
|
119
124
|
"sonner": "2.0.8",
|