pi-roundtable 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.12.0] - 2026-10-13
9
+
10
+ ### Added
11
+
12
+ - A held tool call's approval card can carry a copy button. A tool's `hold`, and a hold rule's `describeInTurn`, may answer `HoldDescription` (new type, exported from `pi-roundtable`): the description as before, or `{ text, copy? }` with `copy` (`{ label?, text }`, as `ApprovalDetails.copy`) for the card to offer; the confirmation gate puts it in the `ApprovalDetails` it builds, so every limit of `ApprovalDetails.copy` applies (Discord's button, `discord.cardCopies`, the 20,000 character cap, the web chat's `approval.copy`). It shares the description's wait and failure: a rejection, a throw or a timeout holds the call under the generic description with no copy, and a `copy` that is not a non-empty text is left out. The copy is not kept with the held call, so a confirmation by message after a restart has no button; a host with no turn (a precheck script's call) uses `text`. A hold that answers a string is unchanged, as is `holdChain(rules).inTurn(...)` for rules that do.
13
+ - `ChannelContextOptions.foreignWebhook` may return `"skip"` as well as a boolean (`(webhookId: string) => boolean | "skip"`): a post through that webhook of the application is dropped from the channel context and does not end the window, as if it were not in the channel, for a plugin whose own status notices go through a webhook of their own. `true` and `false` mean what they did, and any other answer reads as false. A skipped post is still one of the `fetch` messages read, so it uses that budget.
14
+
15
+ ### Changed
16
+
17
+ - Code that reads a hold through `HoldCheck.inTurn`, `holdChain(rules)`, `testPlugin().holds.inTurn`, a wrapped `describeInTurn` or `ToolSpec.hold` now gets a `HoldDescription` (a string or `{ text, copy? }`) where it got a string; `ChannelContextOptions.foreignWebhook` may return the truthy `"skip"`, so a caller that treats its answer as a boolean should compare with `true`.
18
+
8
19
  ## [0.11.0] - 2026-10-13
9
20
 
10
21
  ### Added
package/README.md CHANGED
@@ -186,7 +186,7 @@ Tune it under `discord.channelContext`, or set `channelContext: false` to turn i
186
186
  | `similarity` | `0.8` | How alike two consecutive messages of one author must be to count as one |
187
187
  | `messageChars` | `500` | Where a message is cut |
188
188
  | `botMessageChars` | `80` | Where another bot's message is cut |
189
- | `foreignWebhook` | none | `(webhookId) => boolean`: posts through those webhooks of the application are read as other people's messages, not the assistant's (code only) |
189
+ | `foreignWebhook` | none | `(webhookId) => boolean \| "skip"`: posts through the webhooks it returns true for are read as other people's messages, not the assistant's; `"skip"` drops a webhook's posts as if absent, without ending the window (code only) |
190
190
  | `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`: how many images of the kept messages are fetched for the turn, at most 4 |
191
191
 
192
192
  When a conversation is started over in a server channel or thread, the assistant posts a short divider there so the next turn does not read what came before; direct messages get none.
package/README.zh-TW.md CHANGED
@@ -181,7 +181,7 @@ export default {
181
181
  | `similarity` | `0.8` | 同一作者相鄰兩則訊息相似到什麼程度算同一則 |
182
182
  | `messageChars` | `500` | 訊息截斷的長度 |
183
183
  | `botMessageChars` | `80` | 其他 bot 訊息截斷的長度 |
184
- | `foreignWebhook` | 無 | `(webhookId) => boolean`:經由這些 webhook 發出的貼文視為別人的訊息,不是助理自己的(只能用程式碼設定) |
184
+ | `foreignWebhook` | 無 | `(webhookId) => boolean \| "skip"`:回傳 true 的 webhook 所發的貼文視為別人的訊息,不是助理自己的;回傳 `"skip"` 則把該 webhook 的貼文當作不存在,不納入也不截斷範圍(只能用程式碼設定) |
185
185
  | `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`:替這一輪抓取保留訊息中的幾張圖片,最多 4 張 |
186
186
 
187
187
  核准卡和 `ask_user` 問題會讓這一輪等 `discord.cardGraceSeconds` 秒(預設 `120`)。
package/docs/plugins.md CHANGED
@@ -810,7 +810,18 @@ The gate awaits the description before it posts the card, so the card's bold lin
810
810
  - A hold must change nothing: it may be called again for the same call, such as when the owner's confirming message makes the call again.
811
811
  - A hold that declares only its arguments and answers at once is called as it always was. Where a host checks the hold with no turn, such as the `holds` of a test harness or a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s tool call, a hold that declares `turn` is not called; it is described as `run <tool>` and so counts as held.
812
812
 
813
- A rule of `holdRules` does the same with `describeInTurn(tool, input, context, turn)`, which may return a promise and which the gate asks instead of `describe` in a turn.
813
+ A hold may also offer the owner's approval card a text to copy, such as the whole message a call would send, by answering `{ text, copy }` (the type `HoldDescription`: the description alone, or `{ text, copy?: { label?, text } }`) instead of the description:
814
+
815
+ ```ts
816
+ hold: async ({ customer, body }, turn) => ({
817
+ text: `Send a message to ${(await customers.get(customer, turn.speaker, turn.signal)).name}`,
818
+ copy: { label: "Copy message", text: body },
819
+ }),
820
+ ```
821
+
822
+ `text` is the description, with every behaviour above; `copy` goes to the gate's `ApprovalDetails.copy` for that call, so the Discord card gets the [copy button](#who-answers-a-prompt-promptscope) with all of its limits (the 20,000 character cap, the label, `discord.cardCopies`), and the web chat forwards it as `approval.copy`. It is part of the same answer, so a rejection, a throw or a timeout drops it with the description: the call is held under the generic description with no copy, and the exact input is still on the card. A `copy` whose `text` is not a non-empty string is left out and the call is still held. The copy is not kept with the held call (`HeldCall` has no copy), so a card the owner answers by a message after a restart has no button, and a host that shows no card, or that checks the hold with no turn, such as a precheck script's call, uses `text` and ignores `copy`. A hold that answers a plain string is unchanged. The copy is visible to anyone who sees the card, so it holds nothing they may not read.
823
+
824
+ A rule of `holdRules` does the same with `describeInTurn(tool, input, context, turn)`, which may return a promise, and a `HoldDescription` as well as a string, and which the gate asks instead of `describe` in a turn.
814
825
  `describe` is still required and is what a host without a turn sees. `holdChain(rules).inTurn(tool, input, context, turn)` asks the rules as a turn does, in order: each rule's `describeInTurn`, or its `describe` for a rule without one.
815
826
 
816
827
  A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool. A rule with `approversInTurn` whose answer depends on the input can likewise answer `mayName(tool)`: whether it may name approvers for some call of that tool; without it the rule is taken to name approvers for every tool.
@@ -2281,14 +2292,14 @@ It reads Discord, not the host's history, so it survives restarts:
2281
2292
  - It keeps the newest `keep` (default 15), oldest first. Mentions read as names, and stickers and files as text with their names and links; by default nothing is downloaded or shown to the model as an image (see `attachments` below). A message is cut at `messageChars` (default 500), another bot's at `botMessageChars` (default 80).
2282
2293
  - It never fails the turn over Discord: a Discord error, such as a channel the bot cannot read, is logged and the turn runs without context.
2283
2294
 
2284
- A plugin may mirror an outside chat into a channel through a webhook of its own, created by the bot, so that the application owns it; by default such a post counts as the assistant's own, which ends the window and is left out, and the people mirrored never reach a turn. `foreignWebhook(webhookId)` changes that for the webhooks it returns true for: a post through one is another author's, kept like another bot's (`bot: true`, `authorName` the post's username, `authorId` the webhook's id) and no longer ends the window, which then ends only at the assistant's own posts: its bot user's and every webhook `foreignWebhook` returns false for, such as an agent's voice. The host sets it as `discord.channelContext.foreignWebhook` and a call overrides it for that call; being a function, it can be set only in code. Posts through a webhook that `foreignWebhook` returns true for are merged as near repeats only when the poster's name is the same too, since one webhook carries many people; every other message merges by author alone, as before. An edited message reads as Discord now holds it.
2295
+ A plugin may mirror an outside chat into a channel through a webhook of its own, created by the bot, so that the application owns it; by default such a post counts as the assistant's own, which ends the window and is left out, and the people mirrored never reach a turn. `foreignWebhook(webhookId)` changes that for the webhooks it returns true for: a post through one is another author's, kept like another bot's (`bot: true`, `authorName` the post's username, `authorId` the webhook's id) and no longer ends the window, which then ends only at the assistant's own posts: its bot user's and every webhook `foreignWebhook` returns false for, such as an agent's voice. It may also return `"skip"` for a webhook that is neither, such as one the plugin posts its own status notices through: a post through it is dropped from the context and does not end the window, as if it were not in the channel, so notices between two mirrored posts neither appear as someone's words nor cut the window, and they are not counted among the messages delivered. Any answer other than `true` and `"skip"` reads as false. A skipped post is still one of the `fetch` messages read, so it uses the budget the window is chosen from. The host sets it as `discord.channelContext.foreignWebhook` and a call overrides it for that call; being a function, it can be set only in code. Posts through a webhook that `foreignWebhook` returns true for are merged as near repeats only when the poster's name is the same too, since one webhook carries many people; every other message merges by author alone, as before. An edited message reads as Discord now holds it.
2285
2296
 
2286
2297
  Every returned message has `attachments` (`ChannelContextAttachment`: `name`, `contentType`, `size`, `url`, `messageId`), whether or not anything was fetched. With `attachments: { images, maxBytes }` (`ChannelContextAttachmentOptions`; default none) a call also fetches up to `images` (at most 4, a turn's limit) PNG, JPEG, GIF or WebP images of the kept messages, the newest first, each at most `maxBytes` (default 10 MiB, at most 25 MiB), and returns them as `context.images`, prepared as a turn's own images are (large ones are scaled down) and kept in memory by this call and never logged; once a turn is given them they are in its session history like the turn's own images, and stay there. Pass them on with `withChannelContextImages(attachments, context)`, which returns `{ attachments, kept }`: the turn's attachments with the context's images after the turn's own, within the four a turn carries, and how many of them fitted (a turn that already carries four keeps none, and an image crowded out is not noted anywhere). Give `kept` to `withChannelContext(text, context, kept)`: above zero it adds a line that pictures are shown among the turn's images, and it adds none otherwise, so the block never claims images the turn does not have. The image is sent as the type its bytes show (PNG, JPEG, GIF or WebP), not as Discord's guess from the file name, and bytes of none of them are skipped as `unsupported type`. Up to two images are downloaded at once, within 20 seconds for the whole call; the images still to be fetched when it passes are skipped as `download failed`. An image that is not a type the model takes, is over `maxBytes`, cannot be downloaded or cannot be decoded is skipped, and its message's text says `[image not attached: <name>: <reason>]`, the reason being `unsupported type` (by Discord's type or by the bytes), `too large`, `download failed` or `could not be decoded`; skipped images do not count toward `images`, but at most twice that many are tried. An image of a message already handed over is not fetched again, since its message is not returned again.
2287
2298
 
2288
2299
  `DISCORD.channelContext` resolves undefined in a direct message, on another surface, when nothing new was said, and when the host's `discord.channelContext` is `false`, which turns it off for the agent server and every claim.
2289
2300
  The configuration's values are the host's defaults, and a call's `options` override them for that call.
2290
2301
  Call it once per answered message: what it returns is remembered as handed over.
2291
- A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function, `attachments.images` an integer from 0 to 4, `attachments.maxBytes` an integer from 1 to 25 MiB); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
2302
+ A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function of a webhook id returning `true`, `false` or `"skip"`, `attachments.images` an integer from 0 to 4, `attachments.maxBytes` an integer from 1 to 25 MiB); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
2292
2303
  It remembers what it handed over and what addressed the assistant, not every message that ran a turn: a post a claim answered some other way, such as a webhook report turn or a steered message, can also come in the context of a later turn if the assistant has not posted since.
2293
2304
 
2294
2305
  What it returns is public channel text, visible to everyone in the channel; it is not private memory, carries no `privateTo`, and goes into the turn's text, so it lives in the conversation's history, not in the system prompt.
@@ -3550,6 +3561,7 @@ Import from the entries listed below; source area files are internal.
3550
3561
  | `HeldCall` | `pi-roundtable` | type |
3551
3562
  | `HoldCheck` | `pi-roundtable` | type |
3552
3563
  | `HoldContext` | `pi-roundtable` | type |
3564
+ | `HoldDescription` | `pi-roundtable` | type |
3553
3565
  | `HoldRefusal` | `pi-roundtable` | type |
3554
3566
  | `HoldRule` | `pi-roundtable` | type |
3555
3567
  | `HostEnv` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -130,7 +130,7 @@ const channelContextShape = shape({
130
130
  foreignWebhook: optional(
131
131
  guarded(
132
132
  "a function of a webhook id",
133
- (value): value is (webhookId: string) => boolean =>
133
+ (value): value is (webhookId: string) => boolean | "skip" =>
134
134
  typeof value === "function",
135
135
  ),
136
136
  ),
@@ -29,8 +29,13 @@ export interface ChannelContextOptions {
29
29
  * plugin mirrors into the channel through a webhook of its own are read. The assistant's own
30
30
  * posts, which end the window, stay its bot user and every webhook this returns false for, such
31
31
  * as an agent's voice. Default: none, every webhook of the application is the assistant's.
32
+ *
33
+ * `"skip"` is for a webhook that is neither: one the plugin posts its own status notices
34
+ * through. A post through it is dropped from the context and does not end the window, as if it
35
+ * were not in the channel; it is not another author's words and not the assistant's answer.
36
+ * Only `true` and `"skip"` change anything; any other answer reads as false.
32
37
  */
33
- foreignWebhook?: (webhookId: string) => boolean;
38
+ foreignWebhook?: (webhookId: string) => boolean | "skip";
34
39
  /** Which attachments of the kept messages are fetched for the turn; none by default. */
35
40
  attachments?: ChannelContextAttachmentOptions;
36
41
  }
@@ -6,7 +6,7 @@ import type {
6
6
  import type { ClientPort } from "./domain/client.ts";
7
7
  import type { ReplyFile } from "./domain/conversation.ts";
8
8
  import { PluginError } from "./errors.ts";
9
- import { type HoldRule, isPromise } from "./holds.ts";
9
+ import { type HoldDescription, type HoldRule, isPromise } from "./holds.ts";
10
10
  import { messages } from "./i18n/index.ts";
11
11
  import type { ApprovedBy } from "./interactions/prompts.ts";
12
12
  import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
@@ -110,11 +110,18 @@ export interface ToolSpec<Schema extends TObject> {
110
110
  * A host that checks the hold with no turn, such as a precheck script's call, never calls a hold
111
111
  * that declares `turn`; it sees the generic description. A hold that declares only `args` and
112
112
  * answers at once is called there as it always was.
113
+ *
114
+ * Instead of the description alone it may answer `{ text, copy }`, where `copy` (`{ label?, text }`)
115
+ * is a text the owner's approval card offers to copy, such as the whole message the call would
116
+ * send; see `ApprovalDetails.copy` for what a surface does with it and its limits. It is the same
117
+ * answer as the description, so it shares its wait and its failure: a hold that rejects or
118
+ * outlasts the timeout holds the call with the generic description and no copy. A host with no
119
+ * card, or that checks the hold with no turn, uses `text` and ignores `copy`.
113
120
  */
114
121
  hold?: (
115
122
  args: Static<Schema>,
116
123
  turn: ToolTurn,
117
- ) => string | undefined | Promise<string | undefined>;
124
+ ) => HoldDescription | undefined | Promise<HoldDescription | undefined>;
118
125
  /**
119
126
  * The principal ids that may approve this call when it is held, instead of the speaker whose
120
127
  * turn makes it, looked up as that turn like `hold` (same wait, same `turn.signal`, and it
@@ -253,9 +260,18 @@ function holdWithoutTurn<Schema extends TObject>(
253
260
  const answer = (
254
261
  hold as (
255
262
  args: Static<Schema>,
256
- ) => string | undefined | Promise<string | undefined>
263
+ ) => HoldDescription | undefined | Promise<HoldDescription | undefined>
257
264
  )(input as Static<Schema>);
258
- if (!isPromise(answer)) return answer;
265
+ if (!isPromise(answer)) {
266
+ if (answer === undefined || typeof answer === "string") return answer;
267
+ // An object without a non-empty text still holds the call, never lets it run.
268
+ return typeof answer === "object" &&
269
+ answer !== null &&
270
+ typeof answer.text === "string" &&
271
+ answer.text !== ""
272
+ ? answer.text
273
+ : messages().holdGeneric(name);
274
+ }
259
275
  // Its answer is not waited for here; a rejection must not surface on its own.
260
276
  Promise.resolve(answer).catch(() => undefined);
261
277
  } catch {
@@ -241,11 +241,17 @@ export function selectChannelContext(
241
241
  fetched: readonly ContextSourceMessage[],
242
242
  { seen, owners, settings }: ChannelContextSelection,
243
243
  ): { messages: ChannelContextMessage[]; window: string[] } {
244
- const ordered = fetched.toSorted((a, b) => a.at - b.at);
245
- // A webhook of the application that the host calls foreign speaks for other people, not for the assistant.
246
- const foreign = (message: ContextSourceMessage) =>
247
- message.webhookId !== undefined &&
248
- settings.foreignWebhook?.(message.webhookId) === true;
244
+ // A webhook of the application that the host calls foreign speaks for other people, not for the
245
+ // assistant; one it calls "skip" speaks for no one: its posts are not in the channel as far as the
246
+ // window and the context go.
247
+ const verdict = (message: ContextSourceMessage) =>
248
+ message.webhookId === undefined
249
+ ? undefined
250
+ : settings.foreignWebhook?.(message.webhookId);
251
+ const ordered = fetched
252
+ .filter((message) => verdict(message) !== "skip")
253
+ .toSorted((a, b) => a.at - b.at);
254
+ const foreign = (message: ContextSourceMessage) => verdict(message) === true;
249
255
  const assistants = (message: ContextSourceMessage) =>
250
256
  message.own && !foreign(message);
251
257
  const lastOwn = ordered.findLastIndex(assistants);
package/src/core/holds.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ToolTurn } from "./define.ts";
2
2
  import { PluginError } from "./errors.ts";
3
+ import type { ApprovalDetails } from "./interactions/prompts.ts";
3
4
  import { type Tier, tierAtLeast } from "./speakers.ts";
4
5
 
5
6
  /**
@@ -16,6 +17,14 @@ export interface HoldContext {
16
17
  scratchDir?: string;
17
18
  }
18
19
 
20
+ /**
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`.
23
+ */
24
+ export type HoldDescription =
25
+ | string
26
+ | { text: string; copy?: NonNullable<ApprovalDetails["copy"]> };
27
+
19
28
  /**
20
29
  * Decides whether a tool call waits for the owner's approval. A rule answers with a short
21
30
  * description of what the call would do, or undefined when it has no opinion on the call.
@@ -40,14 +49,15 @@ export interface HoldRule {
40
49
  * when a turn makes the call, and waits for the answer up to `HOLD_DESCRIBE_TIMEOUT_MS`; a
41
50
  * rejection or a timeout holds the call under a generic description. `turn.signal` aborts when the
42
51
  * turn stops or the wait ends. `describe` stays what a host without a turn sees, such as a
43
- * precheck script's call, and answers at once.
52
+ * precheck script's call, and answers at once. It may answer `{ text, copy }` instead of the
53
+ * text alone, for the approval card to offer `copy`; a host that shows no card uses `text`.
44
54
  */
45
55
  describeInTurn?(
46
56
  tool: string,
47
57
  input: Record<string, unknown>,
48
58
  context: HoldContext,
49
59
  turn: ToolTurn,
50
- ): string | undefined | Promise<string | undefined>;
60
+ ): HoldDescription | undefined | Promise<HoldDescription | undefined>;
51
61
  /**
52
62
  * The lowest tier that may approve a call this rule holds, when it is higher than the tool's own:
53
63
  * for a call that stands for others, such as saving a script whose runs make held calls.
@@ -98,7 +108,7 @@ export type HoldCheck = ((
98
108
  input: Record<string, unknown>,
99
109
  context: HoldContext,
100
110
  turn: ToolTurn,
101
- ): string | undefined | Promise<string | undefined>;
111
+ ): HoldDescription | undefined | Promise<HoldDescription | undefined>;
102
112
  /** The highest tier any rule requires to approve the call; see `HoldRule.approvalTier`. */
103
113
  approvalTier?(
104
114
  tool: string,
@@ -150,7 +160,7 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
150
160
  check.inTurn = (tool, input, context, turn) => {
151
161
  const ask = (
152
162
  from: number,
153
- ): string | undefined | Promise<string | undefined> => {
163
+ ): HoldDescription | undefined | Promise<HoldDescription | undefined> => {
154
164
  for (let at = from; at < rules.length; at++) {
155
165
  const rule = rules[at];
156
166
  if (!rule) continue;
@@ -150,12 +150,13 @@ function prefix(text: string, max: number): string {
150
150
 
151
151
  /**
152
152
  * A held call as data for a card that is not text: the action, the tool, the whole input, and
153
- * each file it sends by path with its size now (absent when unreadable). Unlike the text card,
154
- * nothing is cut and nothing is worded for a locale.
153
+ * each file it sends by path with its size now (absent when unreadable), and the `copy` its hold
154
+ * offered. Unlike the text card, nothing is cut and nothing is worded for a locale.
155
155
  */
156
156
  export function approvalDetails(
157
157
  call: HeldCall,
158
158
  workspace?: string,
159
+ copy?: ApprovalDetails["copy"],
159
160
  ): ApprovalDetails {
160
161
  const files = pathFiles(call.input).map((path) => {
161
162
  const bytes = fileSize(path, workspace);
@@ -166,6 +167,7 @@ export function approvalDetails(
166
167
  tool: call.tool,
167
168
  input: parsedInput(call.input),
168
169
  ...(files.length > 0 ? { files } : {}),
170
+ ...(copy ? { copy } : {}),
169
171
  };
170
172
  }
171
173
 
@@ -39,6 +39,7 @@ import {
39
39
  awaited,
40
40
  type Described,
41
41
  type HoldTurn,
42
+ type Judgment,
42
43
  joinApprovers,
43
44
  STOPPED,
44
45
  } from "./hold-lookups.ts";
@@ -284,7 +285,7 @@ export class ConfirmationGate {
284
285
  ask.signal,
285
286
  higherTier(this.#tiers?.minTier(call.tool), call.minTier),
286
287
  { late: (late) => this.#late(call, late) },
287
- approvalDetails(call, this.#fileRoot),
288
+ approvalDetails(call, this.#fileRoot, needing.copy),
288
289
  options,
289
290
  );
290
291
  if (answer === "approved") {
@@ -361,7 +362,7 @@ export class ConfirmationGate {
361
362
  if (!turn || !inTurn) return this.#needing(tool, input);
362
363
  const controller = new AbortController();
363
364
  if (turn.signal?.aborted) controller.abort();
364
- let answer: string | undefined | Promise<string | undefined>;
365
+ let answer: ReturnType<NonNullable<HoldCheck["inTurn"]>>;
365
366
  try {
366
367
  answer = inTurn.call(
367
368
  this.#holds,
@@ -373,7 +374,7 @@ export class ConfirmationGate {
373
374
  } catch {
374
375
  answer = messages().holdGeneric(tool);
375
376
  }
376
- const described: Described | Promise<Described> = isPromise(answer)
377
+ const found: Described | Promise<Described> = isPromise(answer)
377
378
  ? awaited(
378
379
  answer,
379
380
  messages().holdGeneric(tool),
@@ -384,7 +385,7 @@ export class ConfirmationGate {
384
385
  : answer;
385
386
  return this.#inOrder(
386
387
  joinApprovers(
387
- described,
388
+ found,
388
389
  {
389
390
  holds: this.#holds,
390
391
  context: this.#context,
@@ -431,7 +432,7 @@ export class ConfirmationGate {
431
432
  #unlessApproved(
432
433
  tool: string,
433
434
  input: Record<string, unknown>,
434
- judged: { action: string; approvers?: readonly string[] },
435
+ judged: Judgment,
435
436
  ): Needing {
436
437
  const minTier = this.#holds.approvalTier?.(tool, input, this.#context);
437
438
  const call: HeldCall = {
@@ -448,7 +449,7 @@ export class ConfirmationGate {
448
449
  const [spent] = approved === -1 ? [] : released.splice(approved, 1);
449
450
  if (spent) return { released: spent.by };
450
451
  }
451
- return { held: call };
452
+ return { held: call, ...(judged.copy ? { copy: judged.copy } : {}) };
452
453
  }
453
454
 
454
455
  /** Records the call as held and returns what the model is told about it. */
@@ -3,7 +3,10 @@ import type {
3
3
  PendingConfirmation,
4
4
  } from "../../domain/conversation.ts";
5
5
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
6
- import type { ApprovedBy } from "../../interactions/prompts.ts";
6
+ import type {
7
+ ApprovalDetails,
8
+ ApprovedBy,
9
+ } from "../../interactions/prompts.ts";
7
10
  import type { Speaker } from "../../speakers.ts";
8
11
  import { mayApproveNamed } from "./held-calls.ts";
9
12
 
@@ -23,7 +26,9 @@ export interface Release {
23
26
  }
24
27
 
25
28
  /** What a call needs of the gate: nothing, a hold, or a release that spends an approval. */
26
- export type Needing = { held: HeldCall } | { released: ApprovedBy | undefined };
29
+ export type Needing =
30
+ | { held: HeldCall; copy?: NonNullable<ApprovalDetails["copy"]> }
31
+ | { released: ApprovedBy | undefined };
27
32
 
28
33
  /** The text for a held call that the speaker's own message confirms. */
29
34
  export function askSpeaker(addressee: OwnerIdentity, call: HeldCall): string {
@@ -1,5 +1,12 @@
1
1
  import type { ToolTurn } from "../../define.ts";
2
- import { type HoldCheck, type HoldContext, isPromise } from "../../holds.ts";
2
+ import {
3
+ type HoldCheck,
4
+ type HoldContext,
5
+ type HoldDescription,
6
+ isPromise,
7
+ } from "../../holds.ts";
8
+ import { messages } from "../../i18n/index.ts";
9
+ import type { ApprovalDetails } from "../../interactions/prompts.ts";
3
10
 
4
11
  /** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
5
12
  export interface HoldTurn {
@@ -13,13 +20,48 @@ export interface HoldTurn {
13
20
  export const STOPPED = Symbol("stopped");
14
21
 
15
22
  /** What waiting for a call's description comes to: its text, or the turn stopped first. */
16
- export type Described = string | undefined | typeof STOPPED;
23
+ export type Described = HoldDescription | undefined | typeof STOPPED;
17
24
 
18
- /** A held call's description with who may approve it instead of the speaker; see `HoldRule.approversInTurn`. */
19
- export type Judged =
20
- | { action: string; approvers?: readonly string[] }
21
- | undefined
22
- | typeof STOPPED;
25
+ /**
26
+ * A held call's description, the text its card offers to copy, and who may approve it instead of
27
+ * the speaker; see `HoldRule.approversInTurn`.
28
+ */
29
+ export interface Judgment {
30
+ action: string;
31
+ copy?: NonNullable<ApprovalDetails["copy"]>;
32
+ approvers?: readonly string[];
33
+ }
34
+
35
+ export type Judged = Judgment | undefined | typeof STOPPED;
36
+
37
+ /**
38
+ * A hold's description as the action and the copy it carries. An answer that is neither a text
39
+ * nor a text with a copy still holds the call, under the generic description; a copy that is not
40
+ * a non-empty text is left out, so a hold that gets it wrong never lets the call run.
41
+ */
42
+ export function described(
43
+ answer: HoldDescription,
44
+ tool: string,
45
+ ): { action: string; copy?: NonNullable<ApprovalDetails["copy"]> } {
46
+ if (typeof answer === "string") return { action: answer };
47
+ if (typeof answer !== "object" || answer === null)
48
+ return { action: messages().holdGeneric(tool) };
49
+ const { text, copy } = answer;
50
+ if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
51
+ if (
52
+ !copy ||
53
+ typeof copy !== "object" ||
54
+ typeof copy.text !== "string" ||
55
+ copy.text === ""
56
+ )
57
+ return { action: text };
58
+ const label = typeof copy.label === "string" ? copy.label : undefined;
59
+ return {
60
+ action: text,
61
+ copy:
62
+ label === undefined ? { text: copy.text } : { label, text: copy.text },
63
+ };
64
+ }
23
65
 
24
66
  /**
25
67
  * What a lookup that answers later comes to: its answer; `fallback` when it rejects or outlasts
@@ -58,16 +100,17 @@ export function awaited<T>(
58
100
  * none, so only the owners approve the call. A call no rule holds asks no one.
59
101
  */
60
102
  export function joinApprovers(
61
- described: Described | Promise<Described>,
103
+ answer: Described | Promise<Described>,
62
104
  from: { holds: HoldCheck; context: HoldContext; timeoutMs: number },
63
105
  tool: string,
64
106
  input: Record<string, unknown>,
65
107
  turn: HoldTurn,
66
108
  ): Judged | Promise<Judged> {
67
109
  const lookup = from.holds.approversInTurn;
68
- if (!lookup) return withoutApprovers(described);
110
+ if (!lookup) return withoutApprovers(answer, tool);
69
111
  const join = (action: Described): Judged | Promise<Judged> => {
70
112
  if (action === STOPPED || action === undefined) return action;
113
+ const { action: text, copy } = described(action, tool);
71
114
  const controller = new AbortController();
72
115
  if (turn.signal?.aborted) controller.abort();
73
116
  let named:
@@ -87,22 +130,31 @@ export function joinApprovers(
87
130
  }
88
131
  if (!isPromise(named)) {
89
132
  controller.abort();
90
- return named ? { action, approvers: named } : { action };
133
+ return {
134
+ action: text,
135
+ ...(copy ? { copy } : {}),
136
+ ...(named ? { approvers: named } : {}),
137
+ };
91
138
  }
92
139
  return awaited(named, [], controller, turn.signal, from.timeoutMs).then(
93
140
  (approvers) => {
94
141
  if (approvers === STOPPED) return STOPPED;
95
- return approvers ? { action, approvers } : { action };
142
+ return {
143
+ action: text,
144
+ ...(copy ? { copy } : {}),
145
+ ...(approvers ? { approvers } : {}),
146
+ };
96
147
  },
97
148
  );
98
149
  };
99
- return isPromise(described) ? described.then(join) : join(described);
150
+ return isPromise(answer) ? answer.then(join) : join(answer);
100
151
  }
101
152
 
102
153
  function withoutApprovers(
103
- described: Described | Promise<Described>,
154
+ answer: Described | Promise<Described>,
155
+ tool: string,
104
156
  ): Judged | Promise<Judged> {
105
- const wrap = (action: Described): Judged =>
106
- action === STOPPED || action === undefined ? action : { action };
107
- return isPromise(described) ? described.then(wrap) : wrap(described);
157
+ const wrap = (found: Described): Judged =>
158
+ found === STOPPED || found === undefined ? found : described(found, tool);
159
+ return isPromise(answer) ? answer.then(wrap) : wrap(answer);
108
160
  }
package/src/index.ts CHANGED
@@ -147,7 +147,12 @@ export {
147
147
  PluginError,
148
148
  ProviderError,
149
149
  } from "./core/errors.ts";
150
- export type { HoldCheck, HoldContext, HoldRule } from "./core/holds.ts";
150
+ export type {
151
+ HoldCheck,
152
+ HoldContext,
153
+ HoldDescription,
154
+ HoldRule,
155
+ } from "./core/holds.ts";
151
156
  export type { HostEnvironment, RoundtableOptions } from "./core/host.ts";
152
157
  export { Roundtable } from "./core/host.ts";
153
158
  export type {