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.
- 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-roundtable.ts +6 -3
- package/src/core/define.ts +15 -0
- 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
|
@@ -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
|
|
|
@@ -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 } =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 (
|
|
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
|
});
|