pi-roundtable 0.19.2 → 0.21.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 (31) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/README.zh-TW.md +1 -1
  4. package/docs/plugins.md +51 -5
  5. package/package.json +2 -1
  6. package/src/core/discord/card-audience.ts +47 -0
  7. package/src/core/discord/owner-card-answers.ts +180 -0
  8. package/src/core/discord/owner-card-asks.ts +149 -0
  9. package/src/core/discord/owner-card-questionnaire.ts +281 -0
  10. package/src/core/discord/owner-card-rows.ts +40 -18
  11. package/src/core/discord/owner-card-types.ts +54 -5
  12. package/src/core/discord/owner-cards-fixture.ts +37 -3
  13. package/src/core/discord/owner-cards.ts +62 -133
  14. package/src/core/discord/utf16.ts +11 -0
  15. package/src/core/holds.ts +15 -2
  16. package/src/core/i18n/cards.ts +32 -2
  17. package/src/core/interactions/prompts.ts +43 -22
  18. package/src/core/interactions/questionnaire.ts +213 -0
  19. package/src/core/runtime/ask-questions.ts +93 -0
  20. package/src/core/runtime/extensions/ask-user-arguments.ts +52 -0
  21. package/src/core/runtime/extensions/ask-user-text.ts +146 -0
  22. package/src/core/runtime/extensions/ask-user.ts +87 -81
  23. package/src/core/runtime/extensions/card-ask.ts +3 -0
  24. package/src/core/runtime/extensions/confirmation-gate.ts +2 -0
  25. package/src/core/runtime/extensions/gate-refusals.ts +2 -0
  26. package/src/core/runtime/extensions/hold-lookups.ts +16 -4
  27. package/src/core/runtime/prompt-slot.ts +21 -1
  28. package/src/core/testing/surface-contract-questionnaire.ts +192 -0
  29. package/src/core/testing/surface-contract-support.ts +29 -1
  30. package/src/core/testing/surface-contract.ts +46 -20
  31. package/src/index.ts +11 -0
@@ -5,30 +5,24 @@ import type { ChannelKey } from "../domain/conversation.ts";
5
5
  import { messages } from "../i18n/index.ts";
6
6
  import type {
7
7
  Approval,
8
- OwnerAnswer,
9
- OwnerQuestion,
10
8
  PromptScope,
11
9
  Prompts,
12
- PromptWait,
13
10
  } from "../interactions/prompts.ts";
14
11
  import type { Logger } from "../log.ts";
15
- import {
16
- type Audience,
17
- type CardAudienceOptions,
18
- CardAudiences,
19
- } from "./card-audience.ts";
12
+ import { type CardAudienceOptions, CardAudiences } from "./card-audience.ts";
20
13
  import { CardCopies, copyReply, offeredCopy } from "./card-copies.ts";
21
14
  import { DiscordOwners, discordUser } from "./discord-owners.ts";
22
15
  import type { InteractionModule } from "./interaction-module.ts";
23
16
  import {
24
- answeredLine,
25
- answerModal,
17
+ answerDeferred,
18
+ answerForm,
19
+ answerSingle,
20
+ showPreviews,
21
+ } from "./owner-card-answers.ts";
22
+ import { askPrompts } from "./owner-card-asks.ts";
23
+ import {
26
24
  approvalRows,
27
- askRows,
28
25
  CARD_PREFIX,
29
- OTHER,
30
- type Rows,
31
- TEXT_FIELD,
32
26
  withdrawnFooter,
33
27
  } from "./owner-card-rows.ts";
34
28
  import type {
@@ -36,10 +30,11 @@ import type {
36
30
  CardChannel,
37
31
  CardMessage,
38
32
  CardPayload,
33
+ CardSpec,
39
34
  LateTurn,
40
35
  OpenCard,
41
36
  } from "./owner-card-types.ts";
42
- import { ownerPanel, type PanelContent } from "./owner-panel.ts";
37
+ import { ownerPanel } from "./owner-panel.ts";
43
38
 
44
39
  export type {
45
40
  CardChannel,
@@ -47,6 +42,7 @@ export type {
47
42
  LateTurn,
48
43
  } from "./owner-card-types.ts";
49
44
  export { CARD_PREFIX };
45
+
50
46
  /** How long a turn that can be resumed waits on its card before it goes on without the answer. */
51
47
  export const CARD_GRACE_MS = 2 * 60_000;
52
48
 
@@ -120,6 +116,12 @@ export class OwnerCards implements InteractionModule {
120
116
  prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
121
117
  const channelId = parseChannelKey(channel).id;
122
118
  return {
119
+ ...askPrompts({
120
+ channelId,
121
+ scope,
122
+ audiences: this.#audiences,
123
+ post: (c, s, u, x, card) => this.#post(c, s, u, x, card),
124
+ }),
123
125
  confirm: async (
124
126
  title,
125
127
  message,
@@ -139,6 +141,7 @@ export class OwnerCards implements InteractionModule {
139
141
  scope,
140
142
  minTier,
141
143
  options?.approvers,
144
+ options?.speakerOnly === true,
142
145
  );
143
146
  // A private conversation's call above its person's tier is no one's to approve.
144
147
  if (!audience) return "unavailable";
@@ -177,37 +180,6 @@ export class OwnerCards implements InteractionModule {
177
180
  },
178
181
  );
179
182
  },
180
- ask: async (title, question, signal, wait) => {
181
- const audience = await this.#audiences.question(scope);
182
- if (!audience) return undefined;
183
- return this.#post<OwnerAnswer | "pending" | undefined>(
184
- channelId,
185
- signal,
186
- undefined,
187
- undefined,
188
- {
189
- audience,
190
- ...(wait
191
- ? {
192
- wait: {
193
- pending: "pending",
194
- late: (value, by) =>
195
- wait.late({
196
- kind: "question",
197
- answer: value as OwnerAnswer,
198
- by: by.user.name,
199
- }),
200
- },
201
- }
202
- : {}),
203
- title,
204
- sections: [question.question],
205
- question,
206
- rows: (id, disabled) => askRows(id, question, disabled),
207
- footer: messages().cardQuestionFooter,
208
- },
209
- );
210
- },
211
183
  };
212
184
  }
213
185
 
@@ -220,28 +192,7 @@ export class OwnerCards implements InteractionModule {
220
192
  signal: AbortSignal | undefined,
221
193
  unposted: T,
222
194
  cancelled: T,
223
- card: {
224
- audience: Audience;
225
- title: string;
226
- sections: string[];
227
- /** The text behind the card's copy button, kept under the card's id. */
228
- copy?: string;
229
- /** Closes the card, also after its turn stopped waiting, when it aborts; see `ApprovalOptions.supersede`. */
230
- supersede?: AbortSignal;
231
- question?: OwnerQuestion;
232
- rows(id: string, disabled: boolean): Rows;
233
- footer: string;
234
- wait?: {
235
- pending: T;
236
- late(
237
- value: unknown,
238
- by: Answerer,
239
- prompt: string,
240
- ): ReturnType<PromptWait["late"]>;
241
- };
242
- /** Hears who approved the card, before it resolves approved in its turn. */
243
- approved?(by: Answerer, prompt: string): void;
244
- },
195
+ card: CardSpec<T>,
245
196
  ): Promise<T> {
246
197
  const { logger, graceMs = CARD_GRACE_MS, resume } = this.#options;
247
198
  if (signal?.aborted || card.supersede?.aborted)
@@ -279,12 +230,13 @@ export class OwnerCards implements InteractionModule {
279
230
  };
280
231
  const view = (footer: string, disabled = true) =>
281
232
  this.#panel(
282
- {
283
- title: card.title,
284
- sections,
285
- rows: card.rows(id, disabled),
286
- footer,
287
- },
233
+ card.build?.(id, footer, disabled) ??
234
+ ownerPanel({
235
+ title: card.title,
236
+ sections,
237
+ rows: card.rows?.(id, disabled) ?? [],
238
+ footer,
239
+ }),
288
240
  open.mention === true,
289
241
  card.audience.mentions,
290
242
  );
@@ -297,20 +249,26 @@ export class OwnerCards implements InteractionModule {
297
249
  close(withdrawnFooter(card.supersede));
298
250
  if (!late) resolve(cancelled);
299
251
  };
300
- const timer = wait
301
- ? setTimeout(() => {
302
- if (!this.#open.has(id)) return;
303
- late = true;
304
- signal?.removeEventListener("abort", abort);
305
- resolve(wait.pending);
306
- }, graceMs)
307
- : undefined;
252
+ const arm = () =>
253
+ wait
254
+ ? setTimeout(() => {
255
+ if (!this.#open.has(id)) return;
256
+ late = true;
257
+ signal?.removeEventListener("abort", abort);
258
+ resolve(wait.pending);
259
+ }, graceMs)
260
+ : undefined;
261
+ let timer = arm();
308
262
  signal?.addEventListener("abort", abort, { once: true });
309
263
  card.supersede?.addEventListener("abort", withdraw, { once: true });
310
264
  const open: OpenCard = {
311
- title: card.title,
312
- sections,
313
- rows: (disabled) => card.rows(id, disabled),
265
+ view,
266
+ isOpen: () => this.#open.has(id),
267
+ touch: () => {
268
+ if (late || !this.#open.has(id)) return;
269
+ clearTimeout(timer);
270
+ timer = arm();
271
+ },
314
272
  audience: card.audience,
315
273
  end: (value, by) => {
316
274
  // Withdrawn or answered already: this answer changes nothing.
@@ -336,6 +294,8 @@ export class OwnerCards implements InteractionModule {
336
294
  return true;
337
295
  },
338
296
  ...(card.question ? { question: card.question } : {}),
297
+ ...(card.form ? { form: card.form } : {}),
298
+ ...(card.previews ? { previews: card.previews } : {}),
339
299
  };
340
300
  this.#open.set(id, open);
341
301
  this.#options
@@ -358,11 +318,10 @@ export class OwnerCards implements InteractionModule {
358
318
 
359
319
  /** The card's panel; in a thread it leads with the mention of who may answer. */
360
320
  #panel(
361
- content: PanelContent,
321
+ panel: CardPayload,
362
322
  mention: boolean,
363
323
  userIds: readonly string[],
364
324
  ): CardPayload {
365
- const panel = ownerPanel(content);
366
325
  if (!mention) return panel;
367
326
  return {
368
327
  ...panel,
@@ -386,7 +345,7 @@ export class OwnerCards implements InteractionModule {
386
345
  !interaction.customId.startsWith(CARD_PREFIX)
387
346
  )
388
347
  return false;
389
- const [id = "", action = ""] = interaction.customId
348
+ const [id = "", action = "", index = ""] = interaction.customId
390
349
  .slice(CARD_PREFIX.length)
391
350
  .split(":");
392
351
  // Copying answers nothing: anyone who sees the card may copy, as often as they like, also
@@ -408,6 +367,10 @@ export class OwnerCards implements InteractionModule {
408
367
  });
409
368
  return true;
410
369
  }
370
+ if (action === "previews") {
371
+ await showPreviews(card, interaction);
372
+ return true;
373
+ }
411
374
  const user = discordUser(interaction);
412
375
  const by: Answerer = {
413
376
  user,
@@ -415,6 +378,14 @@ export class OwnerCards implements InteractionModule {
415
378
  ...(interaction.guildId ? { guildId: interaction.guildId } : {}),
416
379
  isDirect: !interaction.inGuild(),
417
380
  };
381
+ // A menu pick or Submit is deferred before the audience is looked up, which may take a while.
382
+ if (
383
+ card.form &&
384
+ (action === "qpick" || action === "submit" || action === "qtext")
385
+ ) {
386
+ await answerDeferred(card, action, Number(index), interaction, by);
387
+ return true;
388
+ }
418
389
  if (!(await card.audience.allows(user))) {
419
390
  await interaction.reply({
420
391
  content: card.audience.refusal,
@@ -422,17 +393,6 @@ export class OwnerCards implements InteractionModule {
422
393
  });
423
394
  return true;
424
395
  }
425
- const closed = (outcome: string) =>
426
- this.#panel(
427
- {
428
- title: card.title,
429
- sections: card.sections,
430
- rows: card.rows(true),
431
- footer: outcome,
432
- },
433
- card.mention === true,
434
- card.audience.mentions,
435
- );
436
396
  if (interaction.isButton() && (action === "yes" || action === "no")) {
437
397
  const approved = action === "yes";
438
398
  const approver = approved
@@ -457,43 +417,12 @@ export class OwnerCards implements InteractionModule {
457
417
  return true;
458
418
  }
459
419
  await interaction.update(
460
- closed(approved ? messages().cardApproved : messages().cardDeclined),
420
+ card.view(approved ? messages().cardApproved : messages().cardDeclined),
461
421
  );
462
422
  return true;
463
423
  }
464
- const question = card.question;
465
- if (!question) return true;
466
- if (interaction.isButton() && action === "write") {
467
- card.picked = [];
468
- await interaction.showModal(answerModal(id));
469
- return true;
470
- }
471
- if (interaction.isStringSelectMenu() && action === "pick") {
472
- const values = interaction.values;
473
- const choices = question.options.flatMap((option, i) =>
474
- values.includes(String(i)) ? [option.label] : [],
475
- );
476
- if (values.includes(OTHER)) {
477
- card.picked = choices;
478
- await interaction.showModal(answerModal(id));
479
- return true;
480
- }
481
- const answer = { choices };
482
- card.end(answer, by);
483
- await interaction.update(closed(answeredLine(answer)));
484
- return true;
485
- }
486
- if (interaction.isModalSubmit() && action === "text") {
487
- const answer = {
488
- choices: card.picked ?? [],
489
- text: interaction.fields.getTextInputValue(TEXT_FIELD),
490
- };
491
- card.end(answer, by);
492
- if (interaction.isFromMessage())
493
- await interaction.update(closed(answeredLine(answer)));
494
- else await interaction.deferUpdate();
495
- return true;
496
- }
424
+ if (card.form) await answerForm(card, id, action, interaction);
425
+ else await answerSingle(card, id, action, interaction, by);
497
426
  return true;
498
427
  }
499
428
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `text` cut to at most `max` UTF-16 units, the count discord.js validates, without splitting a
3
+ * surrogate pair; with `ellipsis` the cut ends in `…` inside the limit.
4
+ */
5
+ export function clip(text: string, max: number, ellipsis = false): string {
6
+ if (text.length <= max) return text;
7
+ let end = ellipsis ? max - 1 : max;
8
+ const last = text.charCodeAt(end - 1);
9
+ if (last >= 0xd800 && last <= 0xdbff) end -= 1;
10
+ return ellipsis ? `${text.slice(0, end)}…` : text.slice(0, end);
11
+ }
package/src/core/holds.ts CHANGED
@@ -19,8 +19,20 @@ 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`)
23
- * and a `supersede` signal to withdraw the call by.
22
+ * as `text` with a `copy` the owner's approval card offers beside it (see `ApprovalDetails.copy`),
23
+ * a `supersede` signal to withdraw the call by, and `approval: "speaker"` to make the card the
24
+ * speaker's alone.
25
+ *
26
+ * With `approval: "speaker"` only the speaker whose turn made the call may answer its card, on
27
+ * any of their identities the surface knows and at the call's tier; no owner may answer it as an
28
+ * owner (an owner who is the speaker answers as the speaker). A speaker below the call's tier, a
29
+ * turn with no speaker, or a surface that cannot tell who the speaker is gets `unavailable` at
30
+ * once and the call stays held. Combined with approvers the card is still the speaker's alone,
31
+ * and the runtime accepts their press only when the approvers name them or they are an owner;
32
+ * otherwise the call stays held. It governs the card only: a message that confirms the held call
33
+ * is judged as for any call, so a tool that must act on the speaker's press alone checks
34
+ * `ToolTurn.approvedPrompt` and `ToolTurn.approvedBy` in `run`. Anything but `"speaker"` leaves
35
+ * the card as every card is.
24
36
  *
25
37
  * Aborting `supersede` withdraws the call the hold held, when the plugin no longer wants it
26
38
  * (a newer request replaced it, its subject is gone): its approval card is closed, also after
@@ -39,6 +51,7 @@ export type HoldDescription =
39
51
  text: string;
40
52
  copy?: NonNullable<ApprovalDetails["copy"]>;
41
53
  supersede?: AbortSignal;
54
+ approval?: "speaker";
42
55
  };
43
56
 
44
57
  /**
@@ -1,5 +1,12 @@
1
1
  import type { CatalogContext } from "./types.ts";
2
2
 
3
+ /** `text` cut to at most 300 UTF-16 units without splitting a surrogate pair. */
4
+ function clip(text: string, max: number): string {
5
+ if (text.length <= max) return text;
6
+ const last = text.charCodeAt(max - 1);
7
+ return text.slice(0, last >= 0xd800 && last <= 0xdbff ? max - 1 : max);
8
+ }
9
+
3
10
  /** The owner's approval and question cards. */
4
11
  export function cardsEn(ctx: CatalogContext) {
5
12
  return {
@@ -36,7 +43,19 @@ export function cardsEn(ctx: CatalogContext) {
36
43
  cardModalField: "Your answer",
37
44
  cardQuote: (text: string) => `“${text}”`,
38
45
  cardAnswered: (parts: readonly string[]) =>
39
- `Answered: ${parts.join(", ").slice(0, 300)}`,
46
+ `Answered: ${clip(parts.join(", "), 300)}`,
47
+ cardPreviewsFile:
48
+ "The previews contain code blocks, so they are attached as a file.",
49
+ cardSubmitLabel: "Submit",
50
+ cardWriteLabel: "Write answers / note…",
51
+ cardPreviewsLabel: "Previews",
52
+ cardQuestionName: (n: number) => `Question ${n}`,
53
+ cardNoteField: "Note on all answers",
54
+ cardNothingYet: "Choose or write at least one answer first.",
55
+ cardQuestionnaireFooter: (answered: number, total: number) =>
56
+ `${answered} of ${total} answered; submit when you are ready.`,
57
+ cardUnanswered: "not answered",
58
+ cardNoteLine: (note: string) => `Note: ${note}`,
40
59
  };
41
60
  }
42
61
 
@@ -71,6 +90,17 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
71
90
  cardModalField: "你的回答",
72
91
  cardQuote: (text: string) => `「${text}」`,
73
92
  cardAnswered: (parts: readonly string[]) =>
74
- `已回答:${parts.join("、").slice(0, 300)}`,
93
+ `已回答:${clip(parts.join("、"), 300)}`,
94
+ cardPreviewsFile: "預覽裡有程式碼區塊,所以用檔案附上。",
95
+ cardSubmitLabel: "送出",
96
+ cardWriteLabel: "寫答案/備註…",
97
+ cardPreviewsLabel: "預覽",
98
+ cardQuestionName: (n: number) => `問題 ${n}`,
99
+ cardNoteField: "給全部答案的備註",
100
+ cardNothingYet: "請先選或寫至少一個答案。",
101
+ cardQuestionnaireFooter: (answered: number, total: number) =>
102
+ `已回答 ${answered}/${total} 題;準備好再送出。`,
103
+ cardUnanswered: "未回答",
104
+ cardNoteLine: (note: string) => `備註:${note}`,
75
105
  };
76
106
  }
@@ -1,6 +1,22 @@
1
1
  import type { ConversationVisibility } from "../conversations/conversation-registry.ts";
2
2
  import type { TurnRequest } from "../domain/ports.ts";
3
3
  import type { Speaker, Tier } from "../speakers.ts";
4
+ import type {
5
+ AskSupport,
6
+ OwnerAnswer,
7
+ OwnerQuestion,
8
+ Questionnaire,
9
+ QuestionnaireAnswer,
10
+ } from "./questionnaire.ts";
11
+
12
+ export type {
13
+ AskOption,
14
+ AskSupport,
15
+ OwnerAnswer,
16
+ OwnerQuestion,
17
+ Questionnaire,
18
+ QuestionnaireAnswer,
19
+ } from "./questionnaire.ts";
4
20
 
5
21
  /**
6
22
  * How a prompt ended: answered yes or no; `expired` when the card was shown and no one answered
@@ -79,6 +95,16 @@ export interface ApprovalOptions {
79
95
  * conversation an approval it does not name is not accepted for a call that names approvers.
80
96
  */
81
97
  approvers?: readonly string[];
98
+ /**
99
+ * When true the card is the scope's speaker's alone: they answer it on any of their identities
100
+ * the surface knows, at the call's `minTier` checked again when they answer, and the owners do
101
+ * not, whatever the scope escalates to. Without a speaker, below the tier, or on a surface that
102
+ * cannot tell, it resolves `unavailable` at once. With `approvers`, the speaker's press is
103
+ * accepted only if they are named (the runtime refuses the rest). A surface that cannot honour
104
+ * it must resolve `unavailable` rather than show the card to the owners. Absent or false leaves
105
+ * the card as every card is.
106
+ */
107
+ speakerOnly?: boolean;
82
108
  /**
83
109
  * Called with who approved the call, before the approval resolves `approved`; not called for a
84
110
  * refusal, or by a surface that does not know who answered.
@@ -135,6 +161,7 @@ export type LateAnswer = { by: string } & (
135
161
  prompt?: string;
136
162
  }
137
163
  | { kind: "question"; answer: OwnerAnswer }
164
+ | { kind: "questionnaire"; answer: QuestionnaireAnswer }
138
165
  );
139
166
 
140
167
  /**
@@ -148,28 +175,6 @@ export interface PromptWait {
148
175
  late(answer: LateAnswer): string | undefined;
149
176
  }
150
177
 
151
- export interface AskOption {
152
- label: string;
153
- description?: string;
154
- }
155
-
156
- export interface OwnerQuestion {
157
- question: string;
158
- /** Up to 25; none means a free-text answer. */
159
- options: readonly AskOption[];
160
- /** More than one option may be chosen. */
161
- multi: boolean;
162
- /** They may write an answer of their own besides the options. */
163
- allowOther: boolean;
164
- }
165
-
166
- export interface OwnerAnswer {
167
- /** The chosen options' labels, in the order they were offered. */
168
- choices: string[];
169
- /** What they wrote themselves, if anything. */
170
- text?: string;
171
- }
172
-
173
178
  /**
174
179
  * Who may answer a running turn's prompts: the person whose turn asks, and, in a conversation
175
180
  * others share, the owners. The runtime makes it from the turn's speaker and the conversation's
@@ -235,6 +240,22 @@ export interface Prompts {
235
240
  signal?: AbortSignal,
236
241
  wait?: PromptWait,
237
242
  ): Promise<OwnerAnswer | "pending" | undefined>;
243
+ /**
244
+ * What `questionnaire` can show; absent means the 0.19 contract (one question, no previews,
245
+ * no note), and `ask` is the only way to ask.
246
+ */
247
+ asks?: AskSupport;
248
+ /**
249
+ * Asks several questions on one card, shaped for `asks` (see `shapeFor`); the answer is
250
+ * index-aligned with the questions. Results are as for `ask`; a late answer is a
251
+ * `questionnaire` `LateAnswer`. Present only on a surface that declares `asks`.
252
+ */
253
+ questionnaire?(
254
+ title: string,
255
+ questionnaire: Questionnaire,
256
+ signal?: AbortSignal,
257
+ wait?: PromptWait,
258
+ ): Promise<QuestionnaireAnswer | "pending" | undefined>;
238
259
  }
239
260
 
240
261
  /** @deprecated Since 0.9 the prompts are `Prompts`, answered by those their `PromptScope` names; this name goes away in 1.0. */