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.
Files changed (33) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/docs/plugins.md +46 -11
  3. package/examples/fake-surface.ts +9 -5
  4. package/package.json +4 -4
  5. package/src/core/define.ts +15 -0
  6. package/src/core/discord/agent-discord.ts +26 -6
  7. package/src/core/discord/owner-card-rows.ts +44 -1
  8. package/src/core/discord/owner-card-types.ts +3 -2
  9. package/src/core/discord/owner-cards.ts +57 -58
  10. package/src/core/domain/client.ts +7 -0
  11. package/src/core/domain/progress.ts +8 -2
  12. package/src/core/holds.ts +18 -2
  13. package/src/core/i18n/cards.ts +4 -2
  14. package/src/core/interactions/prompts.ts +43 -1
  15. package/src/core/runtime/conversation-sessions.ts +16 -0
  16. package/src/core/runtime/extensions/approval-card.ts +24 -36
  17. package/src/core/runtime/extensions/approval-fields.ts +212 -0
  18. package/src/core/runtime/extensions/card-ask.ts +74 -0
  19. package/src/core/runtime/extensions/card-text.ts +27 -0
  20. package/src/core/runtime/extensions/confirmation-gate-extension.ts +18 -7
  21. package/src/core/runtime/extensions/confirmation-gate.ts +159 -162
  22. package/src/core/runtime/extensions/gate-refusals.ts +45 -2
  23. package/src/core/runtime/extensions/grants.ts +30 -0
  24. package/src/core/runtime/extensions/held-calls.ts +8 -0
  25. package/src/core/runtime/extensions/hold-describer.ts +99 -0
  26. package/src/core/runtime/extensions/hold-lookups.ts +24 -7
  27. package/src/core/runtime/extensions/late-answer.ts +36 -0
  28. package/src/core/runtime/extensions/withdrawals.ts +95 -0
  29. package/src/core/runtime/session-factory.ts +1 -0
  30. package/src/core/sessions.ts +6 -0
  31. package/src/core/testing/surface-contract.ts +71 -1
  32. package/src/core/tool-turn.ts +3 -0
  33. 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
- ...(options?.approvedBy
172
- ? {
173
- approved: (by: Answerer) => {
174
- if (by.approver) options.approvedBy?.(by.approver);
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(value: unknown, by: Answerer): ReturnType<PromptWait["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) return Promise.resolve(cancelled);
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). A turn that was stopped is none of these.
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 = "declined" | "expired" | "pending" | "held";
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; see `ApprovalDetails.copy`.
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
- | { text: string; copy?: NonNullable<ApprovalDetails["copy"]> };
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
@@ -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
- | { kind: "approval"; approved: boolean; approvedBy?: ApprovedBy }
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 input = shownInput(
42
- call.input,
43
- limits.valueChars ?? CARD_VALUE_CHARS,
44
- limits.totalChars ?? CARD_INPUT_CHARS,
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\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
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