pi-roundtable 0.14.1 → 0.15.1
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 +22 -0
- package/docs/plugins.md +46 -11
- package/examples/fake-surface.ts +9 -5
- package/package.json +4 -4
- package/src/core/define.ts +15 -0
- package/src/core/discord/agent-discord.ts +26 -6
- package/src/core/discord/owner-card-rows.ts +44 -1
- package/src/core/discord/owner-card-types.ts +3 -2
- package/src/core/discord/owner-cards.ts +57 -58
- package/src/core/domain/client.ts +7 -0
- package/src/core/domain/progress.ts +8 -2
- package/src/core/holds.ts +18 -2
- package/src/core/i18n/cards.ts +4 -2
- package/src/core/interactions/prompts.ts +43 -1
- package/src/core/runtime/conversation-sessions.ts +16 -0
- package/src/core/runtime/extensions/approval-card.ts +24 -36
- package/src/core/runtime/extensions/approval-fields.ts +212 -0
- package/src/core/runtime/extensions/card-ask.ts +74 -0
- package/src/core/runtime/extensions/card-text.ts +27 -0
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +18 -7
- package/src/core/runtime/extensions/confirmation-gate.ts +159 -162
- package/src/core/runtime/extensions/gate-refusals.ts +45 -2
- package/src/core/runtime/extensions/grants.ts +30 -0
- package/src/core/runtime/extensions/held-calls.ts +8 -0
- package/src/core/runtime/extensions/hold-describer.ts +99 -0
- package/src/core/runtime/extensions/hold-lookups.ts +24 -7
- package/src/core/runtime/extensions/late-answer.ts +36 -0
- package/src/core/runtime/extensions/withdrawals.ts +95 -0
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/sessions.ts +6 -0
- package/src/core/testing/surface-contract.ts +71 -1
- package/src/core/tool-turn.ts +3 -0
- package/src/index.ts +1 -1
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
ActionRowBuilder,
|
|
4
|
-
ButtonBuilder,
|
|
5
|
-
ButtonStyle,
|
|
6
|
-
type Interaction,
|
|
7
|
-
MessageFlags,
|
|
8
|
-
TextDisplayBuilder,
|
|
9
|
-
} from "discord.js";
|
|
2
|
+
import { type Interaction, MessageFlags, TextDisplayBuilder } from "discord.js";
|
|
10
3
|
import { parseChannelKey } from "../contract/surface.ts";
|
|
11
4
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
12
5
|
import { messages } from "../i18n/index.ts";
|
|
@@ -30,11 +23,13 @@ import type { InteractionModule } from "./interaction-module.ts";
|
|
|
30
23
|
import {
|
|
31
24
|
answeredLine,
|
|
32
25
|
answerModal,
|
|
26
|
+
approvalRows,
|
|
33
27
|
askRows,
|
|
34
28
|
CARD_PREFIX,
|
|
35
29
|
OTHER,
|
|
36
30
|
type Rows,
|
|
37
31
|
TEXT_FIELD,
|
|
32
|
+
withdrawnFooter,
|
|
38
33
|
} from "./owner-card-rows.ts";
|
|
39
34
|
import type {
|
|
40
35
|
Answerer,
|
|
@@ -158,52 +153,26 @@ export class OwnerCards implements InteractionModule {
|
|
|
158
153
|
? {
|
|
159
154
|
wait: {
|
|
160
155
|
pending: "pending",
|
|
161
|
-
late: (value, by) =>
|
|
156
|
+
late: (value, by, prompt) =>
|
|
162
157
|
wait.late({
|
|
163
158
|
kind: "approval",
|
|
164
159
|
approved: value === "approved",
|
|
165
160
|
by: by.user.name,
|
|
166
161
|
...(by.approver ? { approvedBy: by.approver } : {}),
|
|
162
|
+
prompt,
|
|
167
163
|
}),
|
|
168
164
|
},
|
|
169
165
|
}
|
|
170
166
|
: {}),
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
},
|
|
176
|
-
}
|
|
177
|
-
: {}),
|
|
167
|
+
approved: (by: Answerer, prompt: string) => {
|
|
168
|
+
if (by.approver) options?.approvedBy?.(by.approver);
|
|
169
|
+
options?.approvedPrompt?.(prompt);
|
|
170
|
+
},
|
|
178
171
|
title,
|
|
179
172
|
sections: [message],
|
|
173
|
+
...(options?.supersede ? { supersede: options.supersede } : {}),
|
|
180
174
|
...(copy ? { copy: copy.text } : {}),
|
|
181
|
-
rows: (id, disabled) =>
|
|
182
|
-
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
183
|
-
new ButtonBuilder()
|
|
184
|
-
.setCustomId(`${CARD_PREFIX}${id}:yes`)
|
|
185
|
-
.setLabel(messages().cardRunLabel)
|
|
186
|
-
.setEmoji("✅")
|
|
187
|
-
.setStyle(ButtonStyle.Success)
|
|
188
|
-
.setDisabled(disabled),
|
|
189
|
-
new ButtonBuilder()
|
|
190
|
-
.setCustomId(`${CARD_PREFIX}${id}:no`)
|
|
191
|
-
.setLabel(messages().cardCancelLabel)
|
|
192
|
-
.setEmoji("❌")
|
|
193
|
-
.setStyle(ButtonStyle.Secondary)
|
|
194
|
-
.setDisabled(disabled),
|
|
195
|
-
// Copying answers nothing, so it stays usable once the card has ended.
|
|
196
|
-
...(copy
|
|
197
|
-
? [
|
|
198
|
-
new ButtonBuilder()
|
|
199
|
-
.setCustomId(`${CARD_PREFIX}${id}:copy`)
|
|
200
|
-
.setLabel(copy.label)
|
|
201
|
-
.setEmoji("📋")
|
|
202
|
-
.setStyle(ButtonStyle.Secondary),
|
|
203
|
-
]
|
|
204
|
-
: []),
|
|
205
|
-
),
|
|
206
|
-
],
|
|
175
|
+
rows: (id, disabled) => approvalRows(id, disabled, copy),
|
|
207
176
|
footer: messages().cardApprovalFooter,
|
|
208
177
|
},
|
|
209
178
|
);
|
|
@@ -257,19 +226,26 @@ export class OwnerCards implements InteractionModule {
|
|
|
257
226
|
sections: string[];
|
|
258
227
|
/** The text behind the card's copy button, kept under the card's id. */
|
|
259
228
|
copy?: string;
|
|
229
|
+
/** Closes the card, also after its turn stopped waiting, when it aborts; see `ApprovalOptions.supersede`. */
|
|
230
|
+
supersede?: AbortSignal;
|
|
260
231
|
question?: OwnerQuestion;
|
|
261
232
|
rows(id: string, disabled: boolean): Rows;
|
|
262
233
|
footer: string;
|
|
263
234
|
wait?: {
|
|
264
235
|
pending: T;
|
|
265
|
-
late(
|
|
236
|
+
late(
|
|
237
|
+
value: unknown,
|
|
238
|
+
by: Answerer,
|
|
239
|
+
prompt: string,
|
|
240
|
+
): ReturnType<PromptWait["late"]>;
|
|
266
241
|
};
|
|
267
242
|
/** Hears who approved the card, before it resolves approved in its turn. */
|
|
268
|
-
approved?(by: Answerer): void;
|
|
243
|
+
approved?(by: Answerer, prompt: string): void;
|
|
269
244
|
},
|
|
270
245
|
): Promise<T> {
|
|
271
246
|
const { logger, graceMs = CARD_GRACE_MS, resume } = this.#options;
|
|
272
|
-
if (signal?.aborted
|
|
247
|
+
if (signal?.aborted || card.supersede?.aborted)
|
|
248
|
+
return Promise.resolve(cancelled);
|
|
273
249
|
const wait = resume ? card.wait : undefined;
|
|
274
250
|
const id = randomUUID();
|
|
275
251
|
if (card.copy !== undefined) this.#copies.remember(id, card.copy);
|
|
@@ -282,20 +258,23 @@ export class OwnerCards implements InteractionModule {
|
|
|
282
258
|
let finish: string | undefined;
|
|
283
259
|
/** The turn went on without the answer; the card is the conversation's now. */
|
|
284
260
|
let late = false;
|
|
261
|
+
/** Shows the card as ended with `footer`, or once it is posted. */
|
|
262
|
+
const close = (footer: string) => {
|
|
263
|
+
finish = footer;
|
|
264
|
+
void message
|
|
265
|
+
?.edit(view(footer))
|
|
266
|
+
.catch((error: unknown) =>
|
|
267
|
+
logger.warn({ channelId, err: error }, "card not closed"),
|
|
268
|
+
);
|
|
269
|
+
};
|
|
285
270
|
const settle = (value: T, outcome: string | undefined) => {
|
|
286
271
|
if (!this.#open.delete(id)) return;
|
|
287
272
|
clearTimeout(timer);
|
|
288
273
|
signal?.removeEventListener("abort", abort);
|
|
274
|
+
card.supersede?.removeEventListener("abort", withdraw);
|
|
289
275
|
if (late) return;
|
|
290
276
|
// A card ended by an answer is edited by the answering interaction.
|
|
291
|
-
if (outcome !== undefined)
|
|
292
|
-
finish = outcome;
|
|
293
|
-
void message
|
|
294
|
-
?.edit(view(outcome))
|
|
295
|
-
.catch((error: unknown) =>
|
|
296
|
-
logger.warn({ channelId, err: error }, "card not closed"),
|
|
297
|
-
);
|
|
298
|
-
}
|
|
277
|
+
if (outcome !== undefined) close(outcome);
|
|
299
278
|
resolve(value);
|
|
300
279
|
};
|
|
301
280
|
const view = (footer: string, disabled = true) =>
|
|
@@ -310,6 +289,14 @@ export class OwnerCards implements InteractionModule {
|
|
|
310
289
|
card.audience.mentions,
|
|
311
290
|
);
|
|
312
291
|
const abort = () => settle(cancelled, messages().cardStopped);
|
|
292
|
+
/** The plugin withdrew the call: the card closes, its turn waiting or not, unless an answer came first. */
|
|
293
|
+
const withdraw = () => {
|
|
294
|
+
if (!this.#open.delete(id) || !card.supersede) return;
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
signal?.removeEventListener("abort", abort);
|
|
297
|
+
close(withdrawnFooter(card.supersede));
|
|
298
|
+
if (!late) resolve(cancelled);
|
|
299
|
+
};
|
|
313
300
|
const timer = wait
|
|
314
301
|
? setTimeout(() => {
|
|
315
302
|
if (!this.#open.has(id)) return;
|
|
@@ -319,19 +306,22 @@ export class OwnerCards implements InteractionModule {
|
|
|
319
306
|
}, graceMs)
|
|
320
307
|
: undefined;
|
|
321
308
|
signal?.addEventListener("abort", abort, { once: true });
|
|
309
|
+
card.supersede?.addEventListener("abort", withdraw, { once: true });
|
|
322
310
|
const open: OpenCard = {
|
|
323
311
|
title: card.title,
|
|
324
312
|
sections,
|
|
325
313
|
rows: (disabled) => card.rows(id, disabled),
|
|
326
314
|
audience: card.audience,
|
|
327
315
|
end: (value, by) => {
|
|
316
|
+
// Withdrawn or answered already: this answer changes nothing.
|
|
317
|
+
if (!this.#open.has(id)) return false;
|
|
328
318
|
const answeredLate = late;
|
|
329
|
-
if (value === "approved" && !answeredLate) card.approved?.(by);
|
|
319
|
+
if (value === "approved" && !answeredLate) card.approved?.(by, id);
|
|
330
320
|
settle(value as T, undefined);
|
|
331
|
-
if (!answeredLate || !wait || !resume) return;
|
|
321
|
+
if (!answeredLate || !wait || !resume) return true;
|
|
332
322
|
try {
|
|
333
|
-
const text = wait.late(value, by);
|
|
334
|
-
if (text === undefined) return;
|
|
323
|
+
const text = wait.late(value, by, id);
|
|
324
|
+
if (text === undefined) return true;
|
|
335
325
|
resume({
|
|
336
326
|
channelId,
|
|
337
327
|
messageId: by.messageId ?? `card-${id}`,
|
|
@@ -343,6 +333,7 @@ export class OwnerCards implements InteractionModule {
|
|
|
343
333
|
} catch (error) {
|
|
344
334
|
logger.warn({ channelId, err: error }, "late card answer lost");
|
|
345
335
|
}
|
|
336
|
+
return true;
|
|
346
337
|
},
|
|
347
338
|
...(card.question ? { question: card.question } : {}),
|
|
348
339
|
};
|
|
@@ -453,10 +444,18 @@ export class OwnerCards implements InteractionModule {
|
|
|
453
444
|
return undefined;
|
|
454
445
|
})
|
|
455
446
|
: undefined;
|
|
456
|
-
card.end(
|
|
447
|
+
const accepted = card.end(
|
|
457
448
|
approved ? "approved" : "declined",
|
|
458
449
|
approver ? { ...by, approver } : by,
|
|
459
450
|
);
|
|
451
|
+
// The plugin withdrew the call, or another press answered, while this one was being checked.
|
|
452
|
+
if (!accepted) {
|
|
453
|
+
await interaction.reply({
|
|
454
|
+
content: messages().cardInactive,
|
|
455
|
+
flags: MessageFlags.Ephemeral,
|
|
456
|
+
});
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
460
459
|
await interaction.update(
|
|
461
460
|
closed(approved ? messages().cardApproved : messages().cardDeclined),
|
|
462
461
|
);
|
|
@@ -39,6 +39,13 @@ export interface ClientCallOptions {
|
|
|
39
39
|
* need not pass the turn's signal; the core adds it to every call.
|
|
40
40
|
*/
|
|
41
41
|
signal?: AbortSignal;
|
|
42
|
+
/**
|
|
43
|
+
* The approval this call acts on, for a client that must check the person approved it: `prompt`
|
|
44
|
+
* is the id of the approval card (`ToolTurn.approvedPrompt`). The transport hands it to the
|
|
45
|
+
* client beside the call; a tool passes it only when it has one, so a call without it carries
|
|
46
|
+
* no approval. The client, not the core, decides whether it satisfies the function.
|
|
47
|
+
*/
|
|
48
|
+
approval?: { prompt: string };
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/**
|
|
@@ -10,9 +10,15 @@
|
|
|
10
10
|
* the owner turned down from a call that failed: `declined` on its approval card, `expired` when
|
|
11
11
|
* a card was shown and went unanswered (the call is held for the owner's next message),
|
|
12
12
|
* `pending` while the card stays open, and `held` when no card could be shown (no one may approve
|
|
13
|
-
* it, or it could not be posted; the call waits for the owner's next message)
|
|
13
|
+
* it, or it could not be posted; the call waits for the owner's next message), and `withdrawn` when the
|
|
14
|
+
* plugin that held it withdrew it (`HoldDescription.supersede`). A turn that was stopped is none of these.
|
|
14
15
|
*/
|
|
15
|
-
export type HoldRefusal =
|
|
16
|
+
export type HoldRefusal =
|
|
17
|
+
| "declined"
|
|
18
|
+
| "expired"
|
|
19
|
+
| "pending"
|
|
20
|
+
| "held"
|
|
21
|
+
| "withdrawn";
|
|
16
22
|
|
|
17
23
|
export type TurnProgress =
|
|
18
24
|
| { type: "text"; delta: string }
|
package/src/core/holds.ts
CHANGED
|
@@ -19,11 +19,27 @@ export interface HoldContext {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* What a hold that runs in a turn answers with: the description of the call, or the description
|
|
22
|
-
* as `text` with a `copy` the owner's approval card offers beside it
|
|
22
|
+
* as `text` with a `copy` the owner's approval card offers beside it (see `ApprovalDetails.copy`)
|
|
23
|
+
* and a `supersede` signal to withdraw the call by.
|
|
24
|
+
*
|
|
25
|
+
* Aborting `supersede` withdraws the call the hold held, when the plugin no longer wants it
|
|
26
|
+
* (a newer request replaced it, its subject is gone): its approval card is closed, also after
|
|
27
|
+
* the turn stopped waiting on it, with the abort reason as its footer when that is a text
|
|
28
|
+
* (`controller.abort("replaced by a newer request")`); the held call is forgotten, so a later
|
|
29
|
+
* approval, on the card or by message, runs nothing; a turn still waiting on the card is told
|
|
30
|
+
* the call was withdrawn. An approval that was answered before the abort stands: the call runs
|
|
31
|
+
* once, or has run. Aborting again, or after the call ran or was answered, does nothing. A
|
|
32
|
+
* signal that is already aborted when the hold answers refuses the call without a card, and
|
|
33
|
+
* drops a confirmation of it, which is how a hold withdraws what a restart left held: the
|
|
34
|
+
* signals do not survive a restart, but a repeated call is described again.
|
|
23
35
|
*/
|
|
24
36
|
export type HoldDescription =
|
|
25
37
|
| string
|
|
26
|
-
| {
|
|
38
|
+
| {
|
|
39
|
+
text: string;
|
|
40
|
+
copy?: NonNullable<ApprovalDetails["copy"]>;
|
|
41
|
+
supersede?: AbortSignal;
|
|
42
|
+
};
|
|
27
43
|
|
|
28
44
|
/**
|
|
29
45
|
* Decides whether a tool call waits for the owner's approval. A rule answers with a short
|
package/src/core/i18n/cards.ts
CHANGED
|
@@ -13,6 +13,7 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
13
13
|
"Nothing runs until you approve; answer whenever you are ready.",
|
|
14
14
|
cardQuestionFooter: "Answer whenever you are ready.",
|
|
15
15
|
cardStopped: "⏹️ This round was stopped.",
|
|
16
|
+
cardWithdrawn: "↩️ Withdrawn; nothing will run.",
|
|
16
17
|
cardOwnerOnly: `Only ${ctx.assistant}'s owner can answer this card.`,
|
|
17
18
|
cardApproversNote: (...userIds: readonly string[]) =>
|
|
18
19
|
`-# Who can approve: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
@@ -23,7 +24,7 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
23
24
|
cardAskerNote: (...userIds: readonly string[]) =>
|
|
24
25
|
`-# Who can answer: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
25
26
|
cardAskerRefusal: "This question is for another speaker.",
|
|
26
|
-
cardInactive: `This card is no longer valid (${ctx.assistant} restarted since it was posted, or it was already answered); ask again, or reply in text.`,
|
|
27
|
+
cardInactive: `This card is no longer valid (${ctx.assistant} restarted since it was posted, or it was already answered or withdrawn); ask again, or reply in text.`,
|
|
27
28
|
cardApproved: "✅ Approved to run.",
|
|
28
29
|
cardDeclined: "❌ Cancelled.",
|
|
29
30
|
cardAnswerLabel: "Answer…",
|
|
@@ -49,6 +50,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
|
|
|
49
50
|
cardApprovalFooter: "你核准之前不會執行;準備好再回覆就好。",
|
|
50
51
|
cardQuestionFooter: "準備好再回答就好。",
|
|
51
52
|
cardStopped: "⏹️ 這一輪已停止。",
|
|
53
|
+
cardWithdrawn: "↩️ 已撤回,不會執行。",
|
|
52
54
|
cardOwnerOnly: `只有 ${ctx.assistant} 的擁有者能回答這張卡片。`,
|
|
53
55
|
cardApproversNote: (...userIds: readonly string[]) =>
|
|
54
56
|
`-# 可以核准的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
@@ -57,7 +59,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
|
|
|
57
59
|
cardAskerNote: (...userIds: readonly string[]) =>
|
|
58
60
|
`-# 可以回答的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
|
|
59
61
|
cardAskerRefusal: "這張問題是問另一位發話者的。",
|
|
60
|
-
cardInactive: `這張卡片已經失效(${ctx.assistant}
|
|
62
|
+
cardInactive: `這張卡片已經失效(${ctx.assistant} 在它發出後重新啟動過,或它已經回答過或被撤回);請再問一次,或直接打字回覆。`,
|
|
61
63
|
cardApproved: "✅ 已核准執行。",
|
|
62
64
|
cardDeclined: "❌ 已取消。",
|
|
63
65
|
cardAnswerLabel: "回答…",
|
|
@@ -84,6 +84,42 @@ export interface ApprovalOptions {
|
|
|
84
84
|
* refusal, or by a surface that does not know who answered.
|
|
85
85
|
*/
|
|
86
86
|
approvedBy?(by: ApprovedBy): void;
|
|
87
|
+
/**
|
|
88
|
+
* Called with the id of the card whose approval released the call, right before the approval
|
|
89
|
+
* resolves `approved` (after `approvedBy`); not called for a refusal, or by a surface whose
|
|
90
|
+
* cards have no stable id. The id is the one the surface shows its people (Discord's card id in
|
|
91
|
+
* the custom ids of its buttons, the web chat's `prompt` id), and reaches the tool's `run` as
|
|
92
|
+
* `ToolTurn.approvedPrompt`, for a tool that must prove to a client which approval it acts on.
|
|
93
|
+
*/
|
|
94
|
+
approvedPrompt?(prompt: string): void;
|
|
95
|
+
/**
|
|
96
|
+
* Aborts when the plugin that caused the call withdraws it (`HoldDescription.supersede`). A
|
|
97
|
+
* surface that shows the card closes it then, controls off, with `withdrawalNote(signal)` as
|
|
98
|
+
* its footer, also when the turn has stopped waiting and the card stays open; a card already
|
|
99
|
+
* answered stays as answered, whichever came first. It resolves `cancelled`, or not at all when
|
|
100
|
+
* it already resolved `pending`. A card that arrives with the signal already aborted is not
|
|
101
|
+
* shown. A surface that ignores it loses nothing safety-relevant: the gate refuses the
|
|
102
|
+
* withdrawn call whatever the card answers.
|
|
103
|
+
*/
|
|
104
|
+
supersede?: AbortSignal;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The longest footer a withdrawn card shows, in characters. */
|
|
108
|
+
const WITHDRAWAL_NOTE_CHARS = 200;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The footer for a card whose `ApprovalOptions.supersede` aborted: the abort reason when it is a
|
|
112
|
+
* text (`controller.abort("replaced by a newer request")`), one line cut to a short length, else
|
|
113
|
+
* `fallback`.
|
|
114
|
+
*/
|
|
115
|
+
export function withdrawalNote(signal: AbortSignal, fallback: string): string {
|
|
116
|
+
const reason: unknown = signal.reason;
|
|
117
|
+
if (typeof reason !== "string") return fallback;
|
|
118
|
+
const line = reason.replace(/\s+/g, " ").trim();
|
|
119
|
+
if (line === "") return fallback;
|
|
120
|
+
return line.length > WITHDRAWAL_NOTE_CHARS
|
|
121
|
+
? `${line.slice(0, WITHDRAWAL_NOTE_CHARS - 1)}…`
|
|
122
|
+
: line;
|
|
87
123
|
}
|
|
88
124
|
|
|
89
125
|
/**
|
|
@@ -91,7 +127,13 @@ export interface ApprovalOptions {
|
|
|
91
127
|
* names the approver too, where the surface knows them.
|
|
92
128
|
*/
|
|
93
129
|
export type LateAnswer = { by: string } & (
|
|
94
|
-
| {
|
|
130
|
+
| {
|
|
131
|
+
kind: "approval";
|
|
132
|
+
approved: boolean;
|
|
133
|
+
approvedBy?: ApprovedBy;
|
|
134
|
+
/** The id of the card that was answered; see `ApprovalOptions.approvedPrompt`. */
|
|
135
|
+
prompt?: string;
|
|
136
|
+
}
|
|
95
137
|
| { kind: "question"; answer: OwnerAnswer }
|
|
96
138
|
);
|
|
97
139
|
|
|
@@ -80,6 +80,8 @@ export class ConversationSessions {
|
|
|
80
80
|
|
|
81
81
|
/** Disposes every open session. */
|
|
82
82
|
dispose(): void {
|
|
83
|
+
// A withdrawal after this must not touch what a later runtime keeps for the same conversations.
|
|
84
|
+
for (const gate of this.#gates.values()) gate.abandon();
|
|
83
85
|
for (const pending of this.#sessions.values()) {
|
|
84
86
|
void pending
|
|
85
87
|
.then(({ session }) => session.dispose())
|
|
@@ -118,6 +120,19 @@ export class ConversationSessions {
|
|
|
118
120
|
: undefined;
|
|
119
121
|
},
|
|
120
122
|
);
|
|
123
|
+
// A plugin's withdrawal of a held call is kept across a restart, unless the conversation was dropped since.
|
|
124
|
+
const own = gate;
|
|
125
|
+
gate.onHeldChange = (held) => {
|
|
126
|
+
if (this.#gates.get(key) !== own) return;
|
|
127
|
+
this.#options.confirmations
|
|
128
|
+
.save(key, held)
|
|
129
|
+
.catch((error: unknown) =>
|
|
130
|
+
this.#options.logger.error(
|
|
131
|
+
{ channel: key, err: error },
|
|
132
|
+
"held actions not saved",
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
};
|
|
121
136
|
this.#gates.set(key, gate);
|
|
122
137
|
this.#restored.delete(key);
|
|
123
138
|
}
|
|
@@ -295,6 +310,7 @@ export class ConversationSessions {
|
|
|
295
310
|
await pending
|
|
296
311
|
?.then(({ session }) => session.dispose())
|
|
297
312
|
.catch(() => undefined);
|
|
313
|
+
this.#gates.get(channel)?.abandon();
|
|
298
314
|
this.#gates.delete(channel);
|
|
299
315
|
this.#restored.delete(channel);
|
|
300
316
|
this.#slots.delete(channel);
|
|
@@ -3,6 +3,8 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import type { HeldCall } from "../../domain/conversation.ts";
|
|
4
4
|
import { messages } from "../../i18n/index.ts";
|
|
5
5
|
import type { ApprovalDetails } from "../../interactions/prompts.ts";
|
|
6
|
+
import { fieldList, schemaFields } from "./approval-fields.ts";
|
|
7
|
+
import { countPoints, firstPoints, prefix } from "./card-text.ts";
|
|
6
8
|
|
|
7
9
|
/** The fewest characters of a long string value in a held call's input its approval card keeps, when the input does not fit whole. */
|
|
8
10
|
const CARD_VALUE_CHARS = 200;
|
|
@@ -32,21 +34,30 @@ export interface CardLimits {
|
|
|
32
34
|
* and never below `limits.valueChars`, each marked `… [N chars]` with its original length in code
|
|
33
35
|
* points, until it fits, so one long value does not push the keys after it out of the card. If it
|
|
34
36
|
* still does not fit, the rest is dropped and the keys that are no longer shown are listed.
|
|
37
|
+
*
|
|
38
|
+
* With the tool's `parameters` (its JSON Schema), the input is shown as fields instead of JSON:
|
|
39
|
+
* one per argument in the schema's order, labelled by the property's `title` or its name, a
|
|
40
|
+
* short value on the line (`**label**: value`, markdown in it escaped), a multi-line or long
|
|
41
|
+
* string, and JSON for anything nested, in a block that keeps its line breaks. The same limits
|
|
42
|
+
* apply, the cut values marked the same way and the fields no longer shown listed. A schema that
|
|
43
|
+
* lists no properties, or none, gives the JSON.
|
|
35
44
|
*/
|
|
36
45
|
export function approvalCard(
|
|
37
46
|
call: HeldCall,
|
|
38
47
|
workspace?: string,
|
|
39
48
|
limits: CardLimits = {},
|
|
49
|
+
parameters?: unknown,
|
|
40
50
|
): string {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
const valueChars = limits.valueChars ?? CARD_VALUE_CHARS;
|
|
52
|
+
const totalChars = limits.totalChars ?? CARD_INPUT_CHARS;
|
|
53
|
+
const fields = schemaFields(objectInput(call.input), parameters);
|
|
54
|
+
const body = fields
|
|
55
|
+
? fieldList(fields, { valueChars, totalChars })
|
|
56
|
+
: `\`\`\`json\n${shownInput(call.input, valueChars, totalChars).replaceAll("```", "`\u200b``")}\n\`\`\``;
|
|
46
57
|
const files = pathFiles(call.input).map(
|
|
47
58
|
(path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
|
|
48
59
|
);
|
|
49
|
-
return `**${call.action}**\n-# \`${call.tool}\`\n
|
|
60
|
+
return `**${call.action}**\n-# \`${call.tool}\`\n${body}${files.join("")}`;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
/** The input as a card shows it within `total` characters; see `approvalCard`. */
|
|
@@ -120,34 +131,6 @@ function hiddenKeys(parsed: unknown, limit: number, shown: number): string {
|
|
|
120
131
|
return `\n${messages().cardHiddenKeys(names)}`;
|
|
121
132
|
}
|
|
122
133
|
|
|
123
|
-
/** How many code points `text` has. */
|
|
124
|
-
function countPoints(text: string): number {
|
|
125
|
-
let points = 0;
|
|
126
|
-
for (let i = 0; i < text.length; i++) {
|
|
127
|
-
const code = text.charCodeAt(i);
|
|
128
|
-
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) i++;
|
|
129
|
-
points++;
|
|
130
|
-
}
|
|
131
|
-
return points;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The first `count` code points of `text`. */
|
|
135
|
-
function firstPoints(text: string, count: number): string {
|
|
136
|
-
let end = 0;
|
|
137
|
-
for (let points = 0; points < count && end < text.length; points++) {
|
|
138
|
-
const code = text.charCodeAt(end);
|
|
139
|
-
end += code >= 0xd800 && code <= 0xdbff && end + 1 < text.length ? 2 : 1;
|
|
140
|
-
}
|
|
141
|
-
return text.slice(0, end);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** The first `max` characters of `text`, backing off a cut that would split a surrogate pair. */
|
|
145
|
-
function prefix(text: string, max: number): string {
|
|
146
|
-
if (text.length <= max) return text;
|
|
147
|
-
const code = text.charCodeAt(max - 1);
|
|
148
|
-
return text.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
134
|
/**
|
|
152
135
|
* A held call as data for a card that is not text: the action, the tool, the whole input, and
|
|
153
136
|
* each file it sends by path with its size now (absent when unreadable), and the `copy` its hold
|
|
@@ -173,11 +156,16 @@ export function approvalDetails(
|
|
|
173
156
|
|
|
174
157
|
/** A call's input object; `{}` for input that is not one (a held call's always is). */
|
|
175
158
|
function parsedInput(input: string): Record<string, unknown> {
|
|
159
|
+
return objectInput(input) ?? {};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A call's input as an object; undefined for input that is not one. */
|
|
163
|
+
function objectInput(input: string): Record<string, unknown> | undefined {
|
|
176
164
|
try {
|
|
177
165
|
const parsed: unknown = JSON.parse(input);
|
|
178
|
-
return isPlainObject(parsed) ? parsed :
|
|
166
|
+
return isPlainObject(parsed) ? parsed : undefined;
|
|
179
167
|
} catch {
|
|
180
|
-
return
|
|
168
|
+
return undefined;
|
|
181
169
|
}
|
|
182
170
|
}
|
|
183
171
|
|