pi-roundtable 0.22.2 → 0.22.3
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 +6 -0
- package/docs/plugins.md +1 -0
- package/package.json +1 -1
- package/src/core/discord/owner-card-asks.ts +42 -38
- package/src/core/discord/owner-card-questionnaire.ts +2 -1
- package/src/core/discord/owner-card-rows.ts +19 -1
- package/src/core/discord/owner-card-types.ts +8 -0
- package/src/core/discord/owner-cards.ts +18 -0
- package/src/core/i18n/cards.ts +2 -0
- package/src/core/interactions/prompts.ts +6 -4
- package/src/core/runtime/ask-questions.ts +3 -3
- package/src/core/runtime/extensions/ask-user-text.ts +7 -4
- package/src/core/testing/surface-contract-questionnaire.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.22.3] - 2026-10-23
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Question cards can be cancelled (`pi-roundtable`). Every Discord `ask_user` card, single or several questions, has a **Cancel** button beside Submit, for those who may answer it. Pressed while the turn still waits, `Prompts.ask` and `Prompts.questionnaire` resolve the new `"declined"`, and the `ask_user` result tells the model the owner cancelled the question without answering, not to ask it again, and to continue without the answer or end the turn if it cannot. Pressed after the grace period, it only closes the card and starts no turn. The card shows "❌ Cancelled, not answered." with its controls disabled. A surface that implements `Prompts` need not return `"declined"`; code that reads `ask` or `questionnaire` results should handle it. See [a prompt that outlives its turn](docs/plugins.md#a-prompt-that-outlives-its-turn-promptwait).
|
|
13
|
+
|
|
8
14
|
## [0.22.2] - 2026-10-21
|
|
9
15
|
|
|
10
16
|
### Fixed
|
package/docs/plugins.md
CHANGED
|
@@ -2730,6 +2730,7 @@ A coding worker's cards give no `wait`, since nothing could resume a worker: it
|
|
|
2730
2730
|
|
|
2731
2731
|
On Discord the grace period is `discord.cardGraceSeconds` (default 120), and the cards carry no countdown.
|
|
2732
2732
|
A card answered late is edited to show the answer with its controls disabled, as one answered in time is; a second press finds it answered.
|
|
2733
|
+
Every question card, single or several questions, has a **Cancel** button beside its answer controls, for the same audience as the answers. Pressed while the turn still waits, `ask` and `questionnaire` resolve `"declined"`, and `ask_user` tells the model the person cancelled the question without answering, not to ask it again, and to go on without the answer or end its turn if it cannot. Pressed after the turn stopped waiting, it only closes the card: no turn starts and nothing stays pending. Either way the card shows "❌ Cancelled, not answered." with its controls disabled, and a second press finds it ended.
|
|
2733
2734
|
Open cards live in memory: after a restart, pressing one replies, to the presser alone, that it is no longer valid and to ask again.
|
|
2734
2735
|
|
|
2735
2736
|
#### Several questions on one card: `questionnaire` and `AskSupport`
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
import type { CardAudiences } from "./card-audience.ts";
|
|
12
12
|
import type { QuestionnaireForm } from "./owner-card-questionnaire.ts";
|
|
13
13
|
import { newDraft, questionnaireCard } from "./owner-card-questionnaire.ts";
|
|
14
|
-
import { askRows, previewsRow } from "./owner-card-rows.ts";
|
|
14
|
+
import { askRows, cancelRow, previewsRow } from "./owner-card-rows.ts";
|
|
15
15
|
import type { PostCard } from "./owner-card-types.ts";
|
|
16
16
|
|
|
17
17
|
/** What the Discord card shows of a questionnaire: four questions, previews, and a note. */
|
|
@@ -34,7 +34,7 @@ export function askPrompts(args: {
|
|
|
34
34
|
ask: async (title, question, signal, wait) => {
|
|
35
35
|
const audience = await audiences.question(scope);
|
|
36
36
|
if (!audience) return undefined;
|
|
37
|
-
return post<OwnerAnswer | "pending" | undefined>(
|
|
37
|
+
return post<OwnerAnswer | "pending" | "declined" | undefined>(
|
|
38
38
|
channelId,
|
|
39
39
|
signal,
|
|
40
40
|
undefined,
|
|
@@ -57,7 +57,11 @@ export function askPrompts(args: {
|
|
|
57
57
|
title,
|
|
58
58
|
sections: [question.question],
|
|
59
59
|
question,
|
|
60
|
-
|
|
60
|
+
declined: "declined",
|
|
61
|
+
rows: (id, disabled) => [
|
|
62
|
+
...askRows(id, question, disabled),
|
|
63
|
+
cancelRow(id, disabled),
|
|
64
|
+
],
|
|
61
65
|
footer: messages().cardQuestionFooter,
|
|
62
66
|
},
|
|
63
67
|
);
|
|
@@ -68,40 +72,39 @@ export function askPrompts(args: {
|
|
|
68
72
|
const [only] = questionnaire.questions;
|
|
69
73
|
if (only && questionnaire.questions.length === 1) {
|
|
70
74
|
// One question is the 0.19 card, answered by one click, with a Previews button.
|
|
71
|
-
const answer = await post<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return answer === "pending" || answer === undefined
|
|
75
|
+
const answer = await post<
|
|
76
|
+
OwnerAnswer | "pending" | "declined" | undefined
|
|
77
|
+
>(channelId, signal, undefined, undefined, {
|
|
78
|
+
audience,
|
|
79
|
+
...(wait
|
|
80
|
+
? {
|
|
81
|
+
wait: {
|
|
82
|
+
pending: "pending",
|
|
83
|
+
late: (value, by) =>
|
|
84
|
+
wait.late({
|
|
85
|
+
kind: "questionnaire",
|
|
86
|
+
answer: { answers: [value as OwnerAnswer] },
|
|
87
|
+
by: by.user.name,
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
: {}),
|
|
92
|
+
title,
|
|
93
|
+
sections: [only.question],
|
|
94
|
+
question: only,
|
|
95
|
+
previews: questionnaire,
|
|
96
|
+
declined: "declined",
|
|
97
|
+
rows: (id, disabled) => [
|
|
98
|
+
...askRows(id, only, disabled),
|
|
99
|
+
only.options.some((o) => o.preview !== undefined)
|
|
100
|
+
? previewsRow(id, disabled)
|
|
101
|
+
: cancelRow(id, disabled),
|
|
102
|
+
],
|
|
103
|
+
footer: messages().cardQuestionFooter,
|
|
104
|
+
});
|
|
105
|
+
return answer === "pending" ||
|
|
106
|
+
answer === "declined" ||
|
|
107
|
+
answer === undefined
|
|
105
108
|
? answer
|
|
106
109
|
: { answers: [answer] };
|
|
107
110
|
}
|
|
@@ -109,7 +112,7 @@ export function askPrompts(args: {
|
|
|
109
112
|
questionnaire,
|
|
110
113
|
draft: newDraft(questionnaire.questions.length),
|
|
111
114
|
};
|
|
112
|
-
return post<QuestionnaireAnswer | "pending" | undefined>(
|
|
115
|
+
return post<QuestionnaireAnswer | "pending" | "declined" | undefined>(
|
|
113
116
|
channelId,
|
|
114
117
|
signal,
|
|
115
118
|
undefined,
|
|
@@ -132,6 +135,7 @@ export function askPrompts(args: {
|
|
|
132
135
|
title,
|
|
133
136
|
sections: [],
|
|
134
137
|
form,
|
|
138
|
+
declined: "declined",
|
|
135
139
|
build: (id, footer, disabled) =>
|
|
136
140
|
questionnaireCard({
|
|
137
141
|
id,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type Questionnaire,
|
|
20
20
|
type QuestionnaireAnswer,
|
|
21
21
|
} from "../interactions/questionnaire.ts";
|
|
22
|
-
import { CARD_PREFIX } from "./owner-card-rows.ts";
|
|
22
|
+
import { CARD_PREFIX, cancelButton } from "./owner-card-rows.ts";
|
|
23
23
|
import type { CardPayload } from "./owner-card-types.ts";
|
|
24
24
|
import { ephemeralPanel, ownerPanels, plain } from "./owner-panel.ts";
|
|
25
25
|
import { clip } from "./utf16.ts";
|
|
@@ -127,6 +127,7 @@ function buttons(id: string, form: QuestionnaireForm, disabled: boolean) {
|
|
|
127
127
|
.setDisabled(disabled);
|
|
128
128
|
row.addComponents(
|
|
129
129
|
button("submit", messages().cardSubmitLabel, ButtonStyle.Primary),
|
|
130
|
+
cancelButton(id, disabled),
|
|
130
131
|
button("notes", messages().cardWriteLabel, ButtonStyle.Secondary),
|
|
131
132
|
);
|
|
132
133
|
if (questions.some((q) => q.options.some((o) => o.preview !== undefined)))
|
|
@@ -85,7 +85,7 @@ export function askRows(
|
|
|
85
85
|
];
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** The Previews button of a single-question card
|
|
88
|
+
/** The Previews button of a single-question card, which answers nothing, beside its Cancel button. */
|
|
89
89
|
export function previewsRow(id: string, disabled: boolean) {
|
|
90
90
|
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
91
91
|
new ButtonBuilder()
|
|
@@ -93,6 +93,24 @@ export function previewsRow(id: string, disabled: boolean) {
|
|
|
93
93
|
.setLabel(messages().cardPreviewsLabel)
|
|
94
94
|
.setStyle(ButtonStyle.Secondary)
|
|
95
95
|
.setDisabled(disabled),
|
|
96
|
+
cancelButton(id, disabled),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A question card's Cancel button: the question is declined, not answered. */
|
|
101
|
+
export function cancelButton(id: string, disabled: boolean): ButtonBuilder {
|
|
102
|
+
return new ButtonBuilder()
|
|
103
|
+
.setCustomId(`${CARD_PREFIX}${id}:cancel`)
|
|
104
|
+
.setLabel(messages().cardCancelLabel)
|
|
105
|
+
.setEmoji("❌")
|
|
106
|
+
.setStyle(ButtonStyle.Secondary)
|
|
107
|
+
.setDisabled(disabled);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A single-question card's row with only its Cancel button. */
|
|
111
|
+
export function cancelRow(id: string, disabled: boolean) {
|
|
112
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
113
|
+
cancelButton(id, disabled),
|
|
96
114
|
);
|
|
97
115
|
}
|
|
98
116
|
|
|
@@ -72,6 +72,12 @@ export interface OpenCard {
|
|
|
72
72
|
* already, answered by another press or withdrawn by its plugin, and this answer changed nothing.
|
|
73
73
|
*/
|
|
74
74
|
end(value: unknown, by: Answerer): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Ends a question card declined, its Cancel button: a turn still waiting gets `declined`, and
|
|
77
|
+
* a card whose turn stopped waiting only closes, starting no turn. False when the card had
|
|
78
|
+
* ended already or is not a question.
|
|
79
|
+
*/
|
|
80
|
+
decline(): boolean;
|
|
75
81
|
question?: OwnerQuestion;
|
|
76
82
|
/** A questionnaire card's questions and its shared draft. */
|
|
77
83
|
form?: QuestionnaireForm;
|
|
@@ -99,6 +105,8 @@ export interface CardSpec<T> {
|
|
|
99
105
|
question?: OwnerQuestion;
|
|
100
106
|
form?: QuestionnaireForm;
|
|
101
107
|
previews?: Questionnaire;
|
|
108
|
+
/** What a question card resolves with when it is cancelled; without it the card has no Cancel. */
|
|
109
|
+
declined?: T;
|
|
102
110
|
/** Draws the card itself, as a questionnaire does; without it the card is the title, sections and rows. */
|
|
103
111
|
build?(id: string, footer: string, disabled: boolean): CardPayload;
|
|
104
112
|
rows?(id: string, disabled: boolean): Rows;
|
|
@@ -295,6 +295,12 @@ export class OwnerCards implements InteractionModule {
|
|
|
295
295
|
}
|
|
296
296
|
return true;
|
|
297
297
|
},
|
|
298
|
+
decline: () => {
|
|
299
|
+
if (!this.#open.has(id) || !("declined" in card)) return false;
|
|
300
|
+
// After the grace period settling only closes the card: no turn starts.
|
|
301
|
+
settle(card.declined as T, undefined);
|
|
302
|
+
return true;
|
|
303
|
+
},
|
|
298
304
|
...(card.question ? { question: card.question } : {}),
|
|
299
305
|
...(card.form ? { form: card.form } : {}),
|
|
300
306
|
...(card.previews ? { previews: card.previews } : {}),
|
|
@@ -428,6 +434,18 @@ export class OwnerCards implements InteractionModule {
|
|
|
428
434
|
);
|
|
429
435
|
return true;
|
|
430
436
|
}
|
|
437
|
+
if (interaction.isButton() && action === "cancel") {
|
|
438
|
+
// Another press, a stop or a withdrawal ended it while this one was being checked.
|
|
439
|
+
if (!card.decline()) {
|
|
440
|
+
await interaction.reply({
|
|
441
|
+
content: messages().cardInactive,
|
|
442
|
+
flags: MessageFlags.Ephemeral,
|
|
443
|
+
});
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
await interaction.update(card.view(messages().cardQuestionCancelled));
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
431
449
|
if (card.form) await answerForm(card, id, action, interaction);
|
|
432
450
|
else await answerSingle(card, id, action, interaction, by);
|
|
433
451
|
return true;
|
package/src/core/i18n/cards.ts
CHANGED
|
@@ -34,6 +34,7 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
34
34
|
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.`,
|
|
35
35
|
cardApproved: "✅ Approved to run.",
|
|
36
36
|
cardDeclined: "❌ Cancelled.",
|
|
37
|
+
cardQuestionCancelled: "❌ Cancelled, not answered.",
|
|
37
38
|
cardAnswerLabel: "Answer…",
|
|
38
39
|
cardOtherLabel: "Other…",
|
|
39
40
|
cardOtherDescription: "Write your own answer",
|
|
@@ -81,6 +82,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
|
|
|
81
82
|
cardInactive: `這張卡片已經失效(${ctx.assistant} 在它發出後重新啟動過,或它已經回答過或被撤回);請再問一次,或直接打字回覆。`,
|
|
82
83
|
cardApproved: "✅ 已核准執行。",
|
|
83
84
|
cardDeclined: "❌ 已取消。",
|
|
85
|
+
cardQuestionCancelled: "❌ 已取消,沒有回答。",
|
|
84
86
|
cardAnswerLabel: "回答…",
|
|
85
87
|
cardOtherLabel: "其他…",
|
|
86
88
|
cardOtherDescription: "自己寫答案",
|
|
@@ -248,15 +248,17 @@ export interface Prompts {
|
|
|
248
248
|
options?: ApprovalOptions,
|
|
249
249
|
): Promise<Approval>;
|
|
250
250
|
/**
|
|
251
|
-
* Asks a question; undefined when no one answered it or it was cancelled,
|
|
252
|
-
*
|
|
251
|
+
* Asks a question; undefined when no one answered it or it was cancelled, `declined` when the
|
|
252
|
+
* one asked cancelled the question on its card instead of answering, and `pending`, only with
|
|
253
|
+
* a `wait`, when the turn stopped waiting and the question stays open. A question cancelled
|
|
254
|
+
* after the turn stopped waiting only closes: it starts no turn.
|
|
253
255
|
*/
|
|
254
256
|
ask(
|
|
255
257
|
title: string,
|
|
256
258
|
question: OwnerQuestion,
|
|
257
259
|
signal?: AbortSignal,
|
|
258
260
|
wait?: PromptWait,
|
|
259
|
-
): Promise<OwnerAnswer | "pending" | undefined>;
|
|
261
|
+
): Promise<OwnerAnswer | "pending" | "declined" | undefined>;
|
|
260
262
|
/**
|
|
261
263
|
* What `questionnaire` can show; absent means the 0.19 contract (one question, no previews,
|
|
262
264
|
* no note), and `ask` is the only way to ask.
|
|
@@ -272,7 +274,7 @@ export interface Prompts {
|
|
|
272
274
|
questionnaire: Questionnaire,
|
|
273
275
|
signal?: AbortSignal,
|
|
274
276
|
wait?: PromptWait,
|
|
275
|
-
): Promise<QuestionnaireAnswer | "pending" | undefined>;
|
|
277
|
+
): Promise<QuestionnaireAnswer | "pending" | "declined" | undefined>;
|
|
276
278
|
}
|
|
277
279
|
|
|
278
280
|
/** @deprecated Since 0.9 the prompts are `Prompts`, answered by those their `PromptScope` names; this name goes away in 1.0. */
|
|
@@ -61,7 +61,7 @@ export async function askQuestions(
|
|
|
61
61
|
);
|
|
62
62
|
if (n === 1)
|
|
63
63
|
return answerText(
|
|
64
|
-
answer === "pending" || answer === undefined
|
|
64
|
+
answer === "pending" || answer === "declined" || answer === undefined
|
|
65
65
|
? answer
|
|
66
66
|
: (answer.answers[0] ?? { choices: [] }),
|
|
67
67
|
owner,
|
|
@@ -79,11 +79,11 @@ export async function askQuestions(
|
|
|
79
79
|
: undefined,
|
|
80
80
|
});
|
|
81
81
|
if (n === 1) return answerText(answer, owner);
|
|
82
|
-
if (answer === "pending" || answer === undefined)
|
|
82
|
+
if (answer === "pending" || answer === "declined" || answer === undefined)
|
|
83
83
|
return sequentialText(
|
|
84
84
|
questionnaire,
|
|
85
85
|
answers,
|
|
86
|
-
answer
|
|
86
|
+
answer ?? "unanswered",
|
|
87
87
|
owner,
|
|
88
88
|
answerText(answer, owner),
|
|
89
89
|
);
|
|
@@ -11,10 +11,12 @@ const NAME_CHARS = 120;
|
|
|
11
11
|
|
|
12
12
|
/** The tool's result: what the owner chose and wrote. */
|
|
13
13
|
export function answerText(
|
|
14
|
-
answer: OwnerAnswer | "pending" | undefined,
|
|
14
|
+
answer: OwnerAnswer | "pending" | "declined" | undefined,
|
|
15
15
|
owner: OwnerIdentity,
|
|
16
16
|
): string {
|
|
17
17
|
const o = ownerWords(owner);
|
|
18
|
+
if (answer === "declined")
|
|
19
|
+
return `${o.name} cancelled the question on its card without answering. Do not ask the same question again. Continue without the answer, or, if you cannot go on without it, end your turn saying so.`;
|
|
18
20
|
if (answer === "pending")
|
|
19
21
|
return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
|
|
20
22
|
if (!answer)
|
|
@@ -72,11 +74,12 @@ function listOf(
|
|
|
72
74
|
|
|
73
75
|
/** The tool's result for a questionnaire: each question's answer, and the note. */
|
|
74
76
|
export function questionnaireText(
|
|
75
|
-
answer: QuestionnaireAnswer | "pending" | undefined,
|
|
77
|
+
answer: QuestionnaireAnswer | "pending" | "declined" | undefined,
|
|
76
78
|
questionnaire: Questionnaire,
|
|
77
79
|
owner: OwnerIdentity,
|
|
78
80
|
): string {
|
|
79
|
-
if (answer === "pending" ||
|
|
81
|
+
if (answer === "pending" || answer === "declined" || !answer)
|
|
82
|
+
return answerText(answer, owner);
|
|
80
83
|
return [
|
|
81
84
|
`${owner.name} answered your questions on the card:`,
|
|
82
85
|
...listOf(questionnaire, answer),
|
|
@@ -102,7 +105,7 @@ export function lateQuestionnaireText(
|
|
|
102
105
|
export function sequentialText(
|
|
103
106
|
asked: Questionnaire,
|
|
104
107
|
answers: readonly OwnerAnswer[],
|
|
105
|
-
stopped: "pending" | "unanswered" | undefined,
|
|
108
|
+
stopped: "pending" | "declined" | "unanswered" | undefined,
|
|
106
109
|
owner: OwnerIdentity,
|
|
107
110
|
stoppedText: string,
|
|
108
111
|
): string {
|
|
@@ -93,7 +93,7 @@ export const QUESTIONNAIRE_CHECKS: readonly QuestionnaireCheck[] = [
|
|
|
93
93
|
const given = answerOf(set, found.asks);
|
|
94
94
|
await answer(id, given, set);
|
|
95
95
|
const got = await result;
|
|
96
|
-
if (got === "pending" || got === undefined)
|
|
96
|
+
if (got === "pending" || got === "declined" || got === undefined)
|
|
97
97
|
throw new ContractBroken(`the person's answers came back ${got}`);
|
|
98
98
|
expectThat(
|
|
99
99
|
JSON.stringify(got.answers.map((a) => a ?? null)) ===
|