pi-roundtable 0.10.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.
@@ -24,6 +24,7 @@ import {
24
24
  type CardAudienceOptions,
25
25
  CardAudiences,
26
26
  } from "./card-audience.ts";
27
+ import { CardCopies, copyReply, offeredCopy } from "./card-copies.ts";
27
28
  import { DiscordOwners, discordUser } from "./discord-owners.ts";
28
29
  import type { InteractionModule } from "./interaction-module.ts";
29
30
  import {
@@ -75,6 +76,13 @@ export interface OwnerCardsOptions {
75
76
  * answered. Without it no turn stops waiting on its cards.
76
77
  */
77
78
  resume?(turn: LateTurn): void;
79
+ /**
80
+ * Where and how long the text behind approval cards' copy buttons is kept. With a `path` it is
81
+ * a JSON file that survives a restart; without one the texts live in memory and a card's copy
82
+ * button stops working when the process restarts. `keep` (default 100) is how many cards'
83
+ * texts, the newest, and `keepDays` (default 30) how long.
84
+ */
85
+ copies?: { path?: string; keep?: number; keepDays?: number };
78
86
  }
79
87
 
80
88
  /**
@@ -91,11 +99,18 @@ export class OwnerCards implements InteractionModule {
91
99
  readonly #options: OwnerCardsOptions;
92
100
  readonly #audiences: CardAudiences;
93
101
  readonly #open = new Map<string, OpenCard>();
102
+ readonly #copies: CardCopies;
94
103
 
95
104
  constructor(options: OwnerCardsOptions) {
96
105
  this.#options = options;
106
+ this.#copies = new CardCopies({
107
+ ...options.copies,
108
+ logger: options.logger,
109
+ });
97
110
  this.#audiences = new CardAudiences(options, new DiscordOwners(options));
98
111
  }
112
+ /** Resolves once the remembered copy texts are on disk. */
113
+ flushCopies = (): Promise<void> => this.#copies.flush();
99
114
 
100
115
  commands() {
101
116
  return [];
@@ -114,9 +129,15 @@ export class OwnerCards implements InteractionModule {
114
129
  signal,
115
130
  minTier = "owner",
116
131
  wait,
117
- _details,
132
+ details,
118
133
  options,
119
134
  ) => {
135
+ const copy = offeredCopy(details?.copy, (chars) =>
136
+ this.#options.logger.warn(
137
+ { channelId, chars },
138
+ "card copy text too long; the card has no copy button",
139
+ ),
140
+ );
120
141
  const audience = await this.#audiences.approval(
121
142
  scope,
122
143
  minTier,
@@ -154,6 +175,7 @@ export class OwnerCards implements InteractionModule {
154
175
  : {}),
155
176
  title,
156
177
  sections: [message],
178
+ ...(copy ? { copy: copy.text } : {}),
157
179
  rows: (id, disabled) => [
158
180
  new ActionRowBuilder<ButtonBuilder>().addComponents(
159
181
  new ButtonBuilder()
@@ -168,6 +190,16 @@ export class OwnerCards implements InteractionModule {
168
190
  .setEmoji("❌")
169
191
  .setStyle(ButtonStyle.Secondary)
170
192
  .setDisabled(disabled),
193
+ // Copying answers nothing, so it stays usable once the card has ended.
194
+ ...(copy
195
+ ? [
196
+ new ButtonBuilder()
197
+ .setCustomId(`${CARD_PREFIX}${id}:copy`)
198
+ .setLabel(copy.label)
199
+ .setEmoji("📋")
200
+ .setStyle(ButtonStyle.Secondary),
201
+ ]
202
+ : []),
171
203
  ),
172
204
  ],
173
205
  footer: messages().cardApprovalFooter,
@@ -221,6 +253,8 @@ export class OwnerCards implements InteractionModule {
221
253
  audience: Audience;
222
254
  title: string;
223
255
  sections: string[];
256
+ /** The text behind the card's copy button, kept under the card's id. */
257
+ copy?: string;
224
258
  question?: OwnerQuestion;
225
259
  rows(id: string, disabled: boolean): Rows;
226
260
  footer: string;
@@ -236,6 +270,7 @@ export class OwnerCards implements InteractionModule {
236
270
  if (signal?.aborted) return Promise.resolve(cancelled);
237
271
  const wait = resume ? card.wait : undefined;
238
272
  const id = randomUUID();
273
+ if (card.copy !== undefined) this.#copies.remember(id, card.copy);
239
274
  const sections = [
240
275
  ...card.sections,
241
276
  ...(card.audience.note ? [card.audience.note] : []),
@@ -361,6 +396,17 @@ export class OwnerCards implements InteractionModule {
361
396
  const [id = "", action = ""] = interaction.customId
362
397
  .slice(CARD_PREFIX.length)
363
398
  .split(":");
399
+ // Copying answers nothing: anyone who sees the card may copy, as often as they like, also
400
+ // once the card has ended or the process restarted.
401
+ if (interaction.isButton() && action === "copy") {
402
+ const text = this.#copies.recall(id);
403
+ await interaction.reply(
404
+ text === undefined
405
+ ? { content: messages().cardCopyGone, flags: MessageFlags.Ephemeral }
406
+ : copyReply(text),
407
+ );
408
+ return true;
409
+ }
364
410
  const card = this.#open.get(id);
365
411
  if (!card) {
366
412
  await interaction.reply({
@@ -19,6 +19,9 @@ export interface AttachmentFailure {
19
19
  fromReference: boolean;
20
20
  }
21
21
 
22
+ /** The most images one turn carries to the model. */
23
+ export const MAX_TURN_IMAGES = 4;
24
+
22
25
  /** An image in the form the model accepts. */
23
26
  export interface ModelImage {
24
27
  data: string;
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;
@@ -5,6 +5,10 @@ export function cardsEn(ctx: CatalogContext) {
5
5
  return {
6
6
  cardRunLabel: "Run",
7
7
  cardCancelLabel: "Cancel",
8
+ cardCopyLabel: "Copy",
9
+ cardCopyGone:
10
+ "The text of this card is no longer kept; ask the assistant to show it again.",
11
+ cardCopyFile: "The text is long, so it is attached as a file.",
8
12
  cardApprovalFooter:
9
13
  "Nothing runs until you approve; answer whenever you are ready.",
10
14
  cardQuestionFooter: "Answer whenever you are ready.",
@@ -39,6 +43,9 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
39
43
  return {
40
44
  cardRunLabel: "執行",
41
45
  cardCancelLabel: "取消",
46
+ cardCopyLabel: "複製",
47
+ cardCopyGone: "這張卡片的文字已經不在了;請再請助理顯示一次。",
48
+ cardCopyFile: "文字比較長,所以用檔案附上。",
42
49
  cardApprovalFooter: "你核准之前不會執行;準備好再回覆就好。",
43
50
  cardQuestionFooter: "準備好再回答就好。",
44
51
  cardStopped: "⏹️ 這一輪已停止。",
@@ -35,6 +35,16 @@ export interface ApprovalDetails {
35
35
  input: Record<string, unknown>;
36
36
  /** The files the call sends by path; absent when it sends none. */
37
37
  files?: ApprovalFile[];
38
+ /**
39
+ * A text the card offers to copy, such as a command or a message the call would send. A surface
40
+ * that can shows a control, usable by anyone who sees the card, any number of times, that hands
41
+ * them `text` (`label` names the control, default the surface's own); using it never answers
42
+ * the prompt. It is informational: a surface that cannot show it ignores it. The text is
43
+ * visible to anyone who can see the card, so it holds nothing they may not read. A text over
44
+ * 20,000 characters is not offered (Discord logs a warning), and a surface with a size limit of
45
+ * its own leaves it out before it cuts the call's `input`.
46
+ */
47
+ copy?: { label?: string; text: string };
38
48
  }
39
49
 
40
50
  /**
@@ -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
  }
@@ -6,10 +6,15 @@ export { DISCORD } from "../core/builtin/discord.ts";
6
6
  export type { DiscordAdapterConfig } from "../core/config/config.ts";
7
7
  export type {
8
8
  ChannelContext,
9
+ ChannelContextAttachment,
10
+ ChannelContextAttachmentOptions,
9
11
  ChannelContextMessage,
10
12
  ChannelContextOptions,
11
13
  } from "../core/contract/channel-context.ts";
12
- export { withChannelContext } from "../core/contract/channel-context.ts";
14
+ export {
15
+ withChannelContext,
16
+ withChannelContextImages,
17
+ } from "../core/contract/channel-context.ts";
13
18
  export type { AgentPanelMessage } from "../core/discord/agent-commands.ts";
14
19
  export type {
15
20
  AgentPanel,
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 {