pi-roundtable 0.14.0 → 0.15.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.
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-roundtable.ts +6 -3
  6. package/src/core/define.ts +15 -0
  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
@@ -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
 
@@ -0,0 +1,212 @@
1
+ import { messages } from "../../i18n/index.ts";
2
+ import { countPoints, firstPoints } from "./card-text.ts";
3
+
4
+ /** The longest label a card shows for a field, in code points. */
5
+ const LABEL_CHARS = 40;
6
+
7
+ /** A one-line string longer than this goes in a block of its own, as does any with a line break. */
8
+ const INLINE_CHARS = 100;
9
+
10
+ /** How many hidden labels a card lists. */
11
+ const HIDDEN_LABELS = 20;
12
+
13
+ /** One argument of a held call as a card shows it. */
14
+ export interface CardField {
15
+ label: string;
16
+ /** An argument the schema does not list: its name is shown in code format, so it reads as a name the tool never declared. */
17
+ extra?: true;
18
+ // pi-lens-ignore: no-unknown-parameters — a tool's argument is untyped model JSON
19
+ value: unknown;
20
+ }
21
+
22
+ /** What the fields of a card may take up; see `fieldList`. */
23
+ export interface FieldLimits {
24
+ /** The fewest code points of a long string kept when the fields do not fit whole. */
25
+ valueChars: number;
26
+ /** The most characters the field list takes up. */
27
+ totalChars: number;
28
+ }
29
+
30
+ // pi-lens-ignore: no-unknown-parameters — a tool's parameter schema is untyped JSON
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
33
+ }
34
+
35
+ /**
36
+ * The arguments of a call in the order its tool's parameter schema lists them, each labelled by
37
+ * its property's `title` or else its name; absent arguments are left out, and an argument the
38
+ * schema does not list follows the listed ones under its own name in code format. Undefined when the schema
39
+ * lists no properties, or the call has no arguments to show.
40
+ */
41
+ export function schemaFields(
42
+ input: Record<string, unknown> | undefined,
43
+ // pi-lens-ignore: no-unknown-parameters — a tool's parameter schema is untyped JSON
44
+ schema: unknown,
45
+ ): CardField[] | undefined {
46
+ if (!input || !isRecord(schema) || !isRecord(schema.properties))
47
+ return undefined;
48
+ const properties = schema.properties;
49
+ const fields: CardField[] = [];
50
+ // Own properties only: a name like `toString` or `constructor` is not the prototype's.
51
+ for (const [name, property] of Object.entries(properties)) {
52
+ if (!Object.hasOwn(input, name)) continue;
53
+ const value = input[name];
54
+ if (value === undefined) continue;
55
+ const title =
56
+ isRecord(property) && typeof property.title === "string"
57
+ ? property.title.trim()
58
+ : "";
59
+ fields.push({ label: title || name, value });
60
+ }
61
+ for (const [name, value] of Object.entries(input))
62
+ if (value !== undefined && !Object.hasOwn(properties, name))
63
+ fields.push({ label: name, value, extra: true });
64
+ return fields.length > 0 ? fields : undefined;
65
+ }
66
+
67
+ /**
68
+ * The fields as the lines of a card, within `limits.totalChars`: a long string is cut, the
69
+ * longest first and never below `limits.valueChars`, and marked `… [N chars]` with its length in
70
+ * code points. If that is not enough the last fields are dropped and their labels listed, so a
71
+ * card never hides that it shows less than the call carries.
72
+ */
73
+ export function fieldList(fields: CardField[], limits: FieldLimits): string {
74
+ const render = (limit: number, among: CardField[] = fields) =>
75
+ among.map((field) => fieldLines(field, limit)).join("\n");
76
+ const whole = render(Number.POSITIVE_INFINITY);
77
+ if (whole.length <= limits.totalChars) return whole;
78
+ let limit = limits.valueChars;
79
+ if (render(limit).length <= limits.totalChars) {
80
+ // The largest limit that still fits: the longest values give up characters first.
81
+ let low = limit;
82
+ let high = Math.max(low, longestValue(fields) - 1);
83
+ while (low < high) {
84
+ const mid = Math.ceil((low + high) / 2);
85
+ if (render(mid).length <= limits.totalChars) low = mid;
86
+ else high = mid - 1;
87
+ }
88
+ limit = low;
89
+ return render(limit);
90
+ }
91
+ // At least the first field stays, cut as far as the limit allows.
92
+ const shown: CardField[] = [];
93
+ let size = 0;
94
+ for (const field of fields) {
95
+ const lines = fieldLines(field, limit);
96
+ if (shown.length > 0 && size + lines.length + 1 > limits.totalChars) break;
97
+ shown.push(field);
98
+ size += lines.length + 1;
99
+ }
100
+ const hidden = fields.slice(shown.length);
101
+ const labels = hidden.slice(0, HIDDEN_LABELS).map(labelText);
102
+ if (hidden.length > HIDDEN_LABELS) labels.push("…");
103
+ return `${render(limit, shown)}\n${messages().cardHiddenKeys(labels)}`;
104
+ }
105
+
106
+ /** The code points in the longest string a field carries. */
107
+ function longestValue(fields: CardField[]): number {
108
+ let longest = 0;
109
+ for (const field of fields) {
110
+ const text = textOf(field.value);
111
+ if (text !== undefined) longest = Math.max(longest, countPoints(text));
112
+ }
113
+ return longest;
114
+ }
115
+
116
+ /** The line-break characters a model can put in a string besides `\n`, made `\n` before a value is laid out. */
117
+ const LINE_BREAKS = /\r\n?|\u2028|\u2029|\u0085/g;
118
+
119
+ const lines = (text: string): string => text.replace(LINE_BREAKS, "\n");
120
+
121
+ /** Whether a string shows nothing: empty, or only white space and zero-width characters. */
122
+ const isInvisible = (text: string): boolean =>
123
+ /^[\s\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]*$/.test(
124
+ text,
125
+ );
126
+
127
+ /** `text` written with every character that is not printable ASCII escaped, so nothing in it is invisible. */
128
+ function visible(text: string): string {
129
+ return JSON.stringify(text).replace(
130
+ /[^\x20-\x7e]/g,
131
+ (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The text a field's value is shown as, or undefined for a number, a boolean or null, which are
137
+ * never cut: a string as it is, an array of such scalars joined, anything else as compact JSON.
138
+ */
139
+ // pi-lens-ignore: no-unknown-parameters — a tool's argument is untyped model JSON
140
+ function textOf(value: unknown): string | undefined {
141
+ if (typeof value === "string") return lines(value);
142
+ if (value === null || typeof value === "number" || typeof value === "boolean")
143
+ return undefined;
144
+ if (Array.isArray(value) && value.every(isScalarText))
145
+ return value.map((item) => String(item)).join(", ");
146
+ return JSON.stringify(value);
147
+ }
148
+
149
+ /** Whether an array item reads unambiguously in a comma-joined line: not a string that is empty, invisible, has a comma and space, or a line break. */
150
+ // pi-lens-ignore: no-unknown-parameters — a tool's argument is untyped model JSON
151
+ function isScalarText(item: unknown): boolean {
152
+ if (typeof item === "string")
153
+ return (
154
+ !isInvisible(item) &&
155
+ !item.includes(", ") &&
156
+ lines(item) === item &&
157
+ !item.includes("\n")
158
+ );
159
+ return item === null || typeof item === "number" || typeof item === "boolean";
160
+ }
161
+
162
+ /** The label of a field as a card shows it: its title or name made safe, or a name the schema did not list in code format. */
163
+ function labelText(field: CardField): string {
164
+ const label = firstPoints(lines(field.label), LABEL_CHARS);
165
+ if (field.extra)
166
+ return `\`${label.replace(/\s*\n\s*/g, " ").replaceAll("`", "'")}\``;
167
+ return inline(label);
168
+ }
169
+
170
+ const labelOf = (field: CardField): string => `**${labelText(field)}**`;
171
+
172
+ /** One field as the lines of a card; strings over `limit` code points are cut and marked. */
173
+ function fieldLines(field: CardField, limit: number): string {
174
+ const label = labelOf(field);
175
+ const text = textOf(field.value);
176
+ if (text === undefined) return `${label}: ${String(field.value)}`;
177
+ const points = countPoints(text);
178
+ const cut = points > limit;
179
+ const shown = cut ? firstPoints(text, limit) : text;
180
+ const note = cut ? `… [${points} chars]` : "";
181
+ // A string that shows nothing is written out, so the person sees it is there.
182
+ if (typeof field.value === "string" && isInvisible(shown))
183
+ return `${label}: \`${visible(shown)}\`${note}`;
184
+ const structured = typeof field.value !== "string" && !isScalarArray(field);
185
+ if (structured)
186
+ return shown.length + note.length <= INLINE_CHARS && !shown.includes("`")
187
+ ? `${label}: \`${shown}\`${note}`
188
+ : `${label}:\n${fenced(`${shown}${note}`, "json")}`;
189
+ return shown.includes("\n") || points > INLINE_CHARS
190
+ ? `${label}:\n${fenced(`${shown}${note}`)}`
191
+ : `${label}: ${inline(shown)}${note}`;
192
+ }
193
+
194
+ function isScalarArray(field: CardField): boolean {
195
+ return Array.isArray(field.value) && field.value.every(isScalarText);
196
+ }
197
+
198
+ /** `text` in a fenced block, which keeps its line breaks and in which nothing is markdown. */
199
+ function fenced(text: string, language = ""): string {
200
+ return `\`\`\`${language}\n${text.replaceAll("```", "`\u200b``")}\n\`\`\``;
201
+ }
202
+
203
+ /**
204
+ * `text` made safe to put inside a line of Discord markdown: nothing in it formats, links or
205
+ * mentions anyone, and a line break becomes a space. An `@` stays, so an address reads as written.
206
+ */
207
+ function inline(text: string): string {
208
+ return text
209
+ .replace(/\s*\n\s*/g, " ")
210
+ .replace(/[\\*_~`|[\]<>]/g, "\\$&")
211
+ .replace(/@(?=everyone|here)/g, "@\u200b");
212
+ }
@@ -0,0 +1,74 @@
1
+ import type {
2
+ Approval,
3
+ ApprovalDetails,
4
+ ApprovalOptions,
5
+ ApprovedBy,
6
+ Prompts,
7
+ PromptWait,
8
+ } from "../../interactions/prompts.ts";
9
+ import type { Tier } from "../../speakers.ts";
10
+ import type { Watch } from "./withdrawals.ts";
11
+
12
+ /** What a held call's card is opened with. */
13
+ export interface CardAsk {
14
+ title: string;
15
+ message: string;
16
+ signal: AbortSignal | undefined;
17
+ minTier: Tier | undefined;
18
+ wait: PromptWait;
19
+ details: ApprovalDetails;
20
+ approvers: readonly string[] | undefined;
21
+ /** The call's watch, when its plugin may withdraw it. */
22
+ watch: Watch | undefined;
23
+ }
24
+
25
+ /** How a card came out: its answer, or `withdrawn` when the plugin took the call back first, and who approved it on which card. */
26
+ export interface CardAnswer {
27
+ answer: Approval | "withdrawn";
28
+ approvedBy?: ApprovedBy;
29
+ approvedPrompt?: string;
30
+ }
31
+
32
+ /**
33
+ * Opens the card and waits for the first of its answer and the plugin's withdrawal of the call;
34
+ * the other changes nothing. A card that fails answers nothing, so its watch ends and the
35
+ * failure goes on.
36
+ */
37
+ export async function askOnCard(
38
+ prompts: Prompts,
39
+ ask: CardAsk,
40
+ ): Promise<CardAnswer> {
41
+ const found: Omit<CardAnswer, "answer"> = {};
42
+ const options: ApprovalOptions = {
43
+ ...(ask.approvers ? { approvers: ask.approvers } : {}),
44
+ approvedBy: (by) => {
45
+ found.approvedBy = by;
46
+ },
47
+ approvedPrompt: (prompt) => {
48
+ found.approvedPrompt = prompt;
49
+ },
50
+ ...(ask.watch ? { supersede: ask.watch.signal } : {}),
51
+ };
52
+ try {
53
+ const asking = prompts.confirm(
54
+ ask.title,
55
+ ask.message,
56
+ ask.signal,
57
+ ask.minTier,
58
+ ask.wait,
59
+ ask.details,
60
+ options,
61
+ );
62
+ // What the surface reports is read after the answer, never before it.
63
+ if (!ask.watch) {
64
+ const answer = await asking;
65
+ return { ...found, answer };
66
+ }
67
+ asking.catch(() => undefined);
68
+ const answer = await Promise.race([asking, ask.watch.whenWithdrawn]);
69
+ return { ...found, answer };
70
+ } catch (error) {
71
+ ask.watch?.end();
72
+ throw error;
73
+ }
74
+ }
@@ -0,0 +1,27 @@
1
+ /** How many code points `text` has. */
2
+ export function countPoints(text: string): number {
3
+ let points = 0;
4
+ for (let i = 0; i < text.length; i++) {
5
+ const code = text.charCodeAt(i);
6
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) i++;
7
+ points++;
8
+ }
9
+ return points;
10
+ }
11
+
12
+ /** The first `count` code points of `text`. */
13
+ export function firstPoints(text: string, count: number): string {
14
+ let end = 0;
15
+ for (let points = 0; points < count && end < text.length; points++) {
16
+ const code = text.charCodeAt(end);
17
+ end += code >= 0xd800 && code <= 0xdbff && end + 1 < text.length ? 2 : 1;
18
+ }
19
+ return text.slice(0, end);
20
+ }
21
+
22
+ /** The first `max` characters of `text`, backing off a cut that would split a surrogate pair. */
23
+ export function prefix(text: string, max: number): string {
24
+ if (text.length <= max) return text;
25
+ const code = text.charCodeAt(max - 1);
26
+ return text.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
27
+ }
@@ -26,6 +26,9 @@ export function confirmationGateExtension(
26
26
  };
27
27
  });
28
28
  pi.on("tool_call", async (event, ctx) => {
29
+ // A tool call id the model reused must not inherit an earlier call's approval.
30
+ gate.finish(event.toolCallId);
31
+ approved.delete(event.toolCallId);
29
32
  const prompts = slot?.prompts;
30
33
  let ask: Parameters<ConfirmationGate["review"]>[2];
31
34
  if (prompts && slot) {
@@ -35,14 +38,22 @@ export function confirmationGateExtension(
35
38
  const turn: HoldTurn | undefined = turnOf
36
39
  ? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
37
40
  : undefined;
38
- const { reason, approvedOnCard, approvedBy, refused } = await gate.decide(
39
- event.toolName,
40
- event.input as Record<string, unknown>,
41
- ask,
42
- turn,
43
- );
41
+ const { reason, approvedOnCard, approvedBy, approvedPrompt, refused } =
42
+ await gate.decide(
43
+ event.toolName,
44
+ event.input as Record<string, unknown>,
45
+ ask,
46
+ turn,
47
+ // A host that gives no tool list shows the call as JSON.
48
+ pi.getAllTools?.().find((tool) => tool.name === event.toolName)
49
+ ?.parameters,
50
+ );
44
51
  if (approvedOnCard) approved.add(event.toolCallId);
45
- if (approvedBy && !reason) gate.grant(event.toolCallId, approvedBy);
52
+ if (!reason && (approvedBy || approvedPrompt))
53
+ gate.grant(event.toolCallId, {
54
+ ...(approvedBy ? { by: approvedBy } : {}),
55
+ ...(approvedPrompt ? { prompt: approvedPrompt } : {}),
56
+ });
46
57
  if (refused) slot?.refuse(event.toolCallId, refused);
47
58
  return reason ? { block: true, reason } : undefined;
48
59
  });