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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.21.0] - 2026-10-20
9
+
10
+ ### Added
11
+
12
+ - A held call's approval card can be its speaker's alone (`pi-roundtable`). A hold answers `{ text, approval: "speaker" }` (`HoldDescription.approval`, beside `copy` and `supersede`; a tool's `hold` and a rule's `describeInTurn` both may) and the card reaches the surface as `ApprovalOptions.speakerOnly`. Only the speaker whose turn made the call answers it, on any of their identities the surface knows and at the call's tier, checked again when they press: in a shared conversation no owner does, and an owner's press or decline is refused with the card's own line while the card stays open for the speaker. A speaker below the tier, a turn with no speaker and a speaker with no Discord identity get `unavailable` at once, shown to no one, so the call stays held. With `approvers` the card is still the speaker's alone, and the runtime accepts their press only when the approvers name them or they are an owner. The option governs the card only; a message that confirms the held call is judged as before, so a tool that must act on the speaker's press alone checks `ToolTurn.approvedPrompt` and `ToolTurn.approvedBy` in `run`. Without it every card behaves as before. The Discord cards and the web chat honour it, and `SurfaceContractSubject.speakerOnly` makes `describeSurfaceContract` check, with an `owner`, that an owner's answer leaves such an approval open in a shared conversation; a subject that does not declare it skips the check.
13
+
14
+ ## [0.20.0] - 2026-10-18
15
+
16
+ ### Added
17
+
18
+ - The core model for asking several questions in one `ask_user` call (`pi-roundtable`): `Questionnaire` (one to four `OwnerQuestion`s), `QuestionnaireAnswer` (`answers` aligned with the questions, each undefined when left blank, and an optional `note`), `AskSupport` (`{ questions, previews, note }`, what a surface can show) and `SINGLE_QUESTION` (the 0.19 contract). `AskOption` gains `preview` and `OwnerQuestion` gains `header`. `Prompts` gains two optional members, `asks` and `questionnaire(title, questionnaire, signal?, wait?)`; a `Prompts` without them type-checks and behaves as before. `LateAnswer` gains `{ kind: "questionnaire"; answer: QuestionnaireAnswer }`, which only a `questionnaire` call produces, so a consumer that switches on `kind` exhaustively needs a case for it. `QUESTIONNAIRE_LIMITS` is the one table of limits; `validateQuestionnaire` returns the model-facing reason a set is refused; `checkAnswer` and `checkQuestionnaireAnswer` check an answer against its questions; `shapeFor` (`shapeFor(questionnaire, support)`) degrades a set to what a surface shows, folding previews into the question text as fenced blocks for a surface without previews and dropping headers for one that takes a single question.
19
+
20
+ - The Discord card for a whole `ask_user` set (`pi-roundtable`): `Prompts.asks` is `{ questions: 4, previews: true, note: true }` and `Prompts.questionnaire` posts one card with a menu per question that has options (it can be cleared again), **Submit**, **Write answers / note…** (a form with one field per question that takes text of its own, plus a note on the whole set; five fields at most), and, when an option has a preview, **Previews**. The draft is one per card and shared by everyone who may answer; each pick or edit restarts the grace period of a turn that waits on the card, and the one who presses Submit is reported as the answerer. Answers may be partial, but Submit with nothing chosen or written is refused. Previews opens an ephemeral reply for anyone who can see the card, never answers, and is sent as a file when a preview holds a code fence. A Submit after the turn stopped waiting starts a turn with the questionnaire answer. One question with previews is the single-question card with a Previews button, so it still answers on the first click. The card keeps within Discord's limits for four questions of 25 options each (ten container children, 40 components, 4,000 characters of text, five form fields, 45-character labels); `src/core/testing/discord-limits.ts` checks them. A restart abandons open cards as before. Every limit is counted in UTF-16 units, as Discord counts them, so an emoji or rare character weighs two, and the core's refusal of an over-long set (3,000 characters of questions and headers) counts them the same way; a card of emoji-heavy questions is refused with a reason instead of failing to post.
21
+
22
+ - The web chat shows a whole set on one card too, to clients that declared they can (see the `pi-roundtable-webchat` changelog); a person whose tabs have not is asked one question at a time.
23
+
24
+ - Questionnaire checks in the surface contract (`pi-roundtable/testing`): a surface that declares `asks` must settle a questionnaire as its person answers it (a blank question stays blank, the note comes back where `asks.note`), close it when the turn stops, and ignore another person's answer. `SurfaceContractSubject` and `SurfaceContractAnswerer` gain an optional `answerQuestionnaire(prompt, answer, questionnaire)`; without it the checks are skipped, and a subject that gives it must declare `asks`, or the contract fails it. The Discord cards and the web chat run them; the example surface stays on one question at a time.
25
+
26
+ ### Changed
27
+
28
+ - `ask_user` takes `questions`: one to four related questions in one call, each with an optional `header`, options that may carry a `description` and a markdown `preview`, `multi`, and `allow_other` (which defaults to true in this form; set it false for a closed choice). A call in the old form (`question`, `options`, `multi`, `allow_other` at the top level) and a `questions` or `options` value that arrives as a JSON string are folded into it before the schema is checked, so old sessions keep working; the old form keeps `allow_other` false by default. A set is checked first and a refused one (more than four questions, an option offered twice, a question asked twice, a text or preview over its limit, questions and headers together over 3,000 characters) posts no card and tells the model why. This refuses duplicate option labels and duplicate questions for every call, the old form included, which 0.19 accepted, and a whitespace-only question text is refused too. A surface without a questionnaire card is asked one question at a time, stopping at the first that is not answered and telling the model which questions it did not ask; previews are folded into the question text and headers are dropped there. A single question posts the same card and returns the same text as 0.19, previews or not, and so does its late answer. `PromptSlot` forwards `asks` and `questionnaire` to the prompts it wraps when they have them.
29
+
30
+ ### Fixed
31
+
32
+ - A single-question Discord card with 25 options and an answer of their own (`allow_other`) dropped the 25th option, because "Other…" took its place in the menu. The menu now keeps all 25 options and an "Answer…" button under it opens the form. A card with fewer options is unchanged.
33
+
8
34
  ## [0.19.2] - 2026-10-18
9
35
 
10
36
  ### Changed
package/README.md CHANGED
@@ -201,7 +201,7 @@ When a conversation is started over in a server channel or thread, the assistant
201
201
  | text | Markdown renders except tables | What the model is told, in English, about how a reply renders in Discord; appended to the system prompt of every turn that replies there |
202
202
  | `false` | | Tell the model nothing |
203
203
 
204
- An approval card or an `ask_user` question holds its turn for `discord.cardGraceSeconds` (default `120`).
204
+ An approval card or an `ask_user` question (a set of up to four questions is one card) holds its turn for `discord.cardGraceSeconds` (default `120`).
205
205
  The files of a message in an agent's channel download within `discord.attachmentDownload.deadlineMs` (default 60,000) for the message and the one it replies to together, and a file that sends no byte for `discord.attachmentDownload.idleMs` (default 10,000) is given up; the turn goes on without a file it could not get.
206
206
  The copy button of an approval card keeps its text in `card-copies.json` under the data directory, the newest 100 for 30 days; `discord.cardCopies` (`{ keepDays?, keep? }`) changes that, and `false` keeps them in memory only.
207
207
  Answered within it, the turn goes on with the answer; otherwise the turn ends saying it is waiting, the card stays open with no deadline, and answering it later starts a new turn in that conversation with the answer, or with the result of the approved action.
package/README.zh-TW.md CHANGED
@@ -184,7 +184,7 @@ export default {
184
184
  | `foreignWebhook` | 無 | `(webhookId) => boolean \| "skip"`:回傳 true 的 webhook 所發的貼文視為別人的訊息,不是助理自己的;回傳 `"skip"` 則把該 webhook 的貼文當作不存在,不納入也不截斷範圍(只能用程式碼設定) |
185
185
  | `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`:替這一輪抓取保留訊息中的幾張圖片,最多 4 張 |
186
186
 
187
- 核准卡和 `ask_user` 問題會讓這一輪等 `discord.cardGraceSeconds` 秒(預設 `120`)。
187
+ 核准卡和 `ask_user` 問題(一次最多四題的一組問題放在同一張卡片上)會讓這一輪等 `discord.cardGraceSeconds` 秒(預設 `120`)。
188
188
  agent 頻道裡訊息的附件,整則訊息(連同它回覆的那則)要在 `discord.attachmentDownload.deadlineMs`(預設 60,000)毫秒內下載完;單一檔案連續 `discord.attachmentDownload.idleMs`(預設 10,000)毫秒沒收到任何位元組就放棄,這一輪會略過收不到的檔案繼續。
189
189
  核准卡「複製」按鈕的文字存在資料目錄的 `card-copies.json`,預設留最新 100 張、30 天;`discord.cardCopies`(`{ keepDays?, keep? }`)可以調整,設成 `false` 則只留在記憶體。
190
190
  在這段時間內回答,這一輪就帶著答案繼續;沒回答的話,這一輪會說它在等你,然後結束,卡片繼續開著、沒有期限,之後再回答就會在同一個對話開新的一輪,帶上你的答案,或核准後那個動作的結果。
package/docs/plugins.md CHANGED
@@ -876,6 +876,21 @@ A rule whose verdict depends on the input, such as one that holds only a `delete
876
876
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
877
877
  A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owners where the conversation is shared ([who answers a prompt](#who-answers-a-prompt-promptscope)); nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId` and their principal as `principalId`, so a confirming message from another identity of theirs counts after a restart too; a call held without them is the owners' to approve, and one held before 0.9, without a principal, is matched by `speakerId`.
878
878
 
879
+ #### A hold that makes its card the speaker's alone
880
+
881
+ By default the speaker whose turn made a held call may answer its card, and in a shared conversation so may every owner. A hold that wants only the speaker to press, such as one whose tool books something under the speaker's name, answers `{ text, approval: "speaker" }` (`HoldDescription.approval`, beside `copy` and `supersede`; a tool's `hold` and a rule's `describeInTurn` both may):
882
+
883
+ ```ts
884
+ hold: async ({ draft }, turn) => ({ text: await summaryOf(draft, turn), approval: "speaker" }),
885
+ ```
886
+
887
+ - Only the speaker answers the card: their principal, on any of their identities the surface knows, at the call's tier, checked again when they press. An owner's press is refused with the card's own line, the card stays open for the speaker, and the owner's decline is refused too. An owner who is the speaker answers as the speaker.
888
+ - It holds for a late press as well: a card answered after the turn stopped waiting is still the speaker's alone, so the turn that follows is started by the speaker.
889
+ - A speaker below the call's tier, a turn with no speaker, and a surface that cannot tell who the speaker is get `unavailable` at once, shown to no one, and the call stays held, as for any card nobody may answer. It never falls back to the owners.
890
+ - Combined with [approvers](#a-hold-that-names-its-approvers) the card is still the speaker's alone, and the runtime accepts the press only when the approvers name the speaker (or they are an owner); otherwise the call stays held.
891
+ - The option governs the card only. A message that confirms the held call is judged as for any call, so an owner's message can still release it. A tool that must act on the speaker's press alone checks in `run` that `turn.approvedPrompt` is set (a message sets none) and that `turn.approvedBy?.speakerId` is the speaker's.
892
+ - Anything but `"speaker"` leaves the card as every card is. A surface implements this through `ApprovalOptions.speakerOnly`; the Discord cards and the web chat (whose prompts are its person's alone) honour it, and the contract checks it where a subject declares `speakerOnly`.
893
+
879
894
  #### A hold that names its approvers
880
895
 
881
896
  A held call is approved by the speaker whose turn made it, or by an owner where the conversation is shared. A tool whose call stands for someone else, such as a message sent under another person's name, can name who approves it instead:
@@ -2608,7 +2623,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
2608
2623
  | `startTyping(channel)` | Shows a typing indicator until the returned function is called | none is shown |
2609
2624
  | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
2610
2625
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
2611
- | `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`; without a scope, the owners | the action is held until a message approves it |
2626
+ | `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`, and, where the surface declares `asks`, `questionnaire` ([several questions on one card](#several-questions-on-one-card-questionnaire-and-asksupport)); without a scope, the owners | the action is held until a message approves it |
2612
2627
  | `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
2613
2628
  | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok, refused? }` (`refused`, only on a failed end that a hold refused, says how: `declined` on its approval card, `expired` when a card was shown and went unanswered, `pending` while the card stays open, or `held` when no card could be shown (no one may approve it, or it could not be posted); it is absent for a success and for a real failure). The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
2614
2629
 
@@ -2645,6 +2660,8 @@ A surface's prompts keep these rules:
2645
2660
  - Without a scope, as for a turn with no speaker, the prompts are the owners'.
2646
2661
  - An approval that names [approvers](#a-hold-that-names-its-approvers) (`ApprovalOptions.approvers`, which `confirm` receives beside the scope) is theirs **instead of the speaker's**, where the scope escalates to the owners: each named principal answers on any of their identities the surface knows, at the tier it needs, checked again when they answer; the speaker does not answer it unless named, and an owner still does. When none of the named has an identity on the surface, or none is named, it is the owners' alone. Where the scope escalates to no one, the person alone answers, and only when they are named or an owner: for anyone else the approval resolves `unavailable` at once, shown to no one. The runtime also refuses such a call before it asks, whatever the surface does.
2647
2662
 
2663
+ - An approval that is the [speaker's alone](#a-hold-that-makes-its-card-the-speakers-alone) (`ApprovalOptions.speakerOnly`) is theirs without the owner escalation, whatever the scope says: the speaker's principal answers on any of their identities the surface knows, at the tier it needs, checked again when they answer, and no owner does. Without a scope, or when the speaker is below the tier or has no identity on the surface, it resolves `unavailable` at once, shown to no one.
2664
+
2648
2665
  On Discord a card for named approvers mentions every Discord identity of every named principal that has one, in a thread, and refuses anyone else with a line that says it is for the people it names and the owners.
2649
2666
  The web chat shows prompts to the conversation's person only. The runtime never asks it about a call that names approvers the person is neither among nor an owner of, and the person is the approver of one that names them.
2650
2667
 
@@ -2656,7 +2673,7 @@ The web chat shows prompts to the conversation's person only: its owners are not
2656
2673
 
2657
2674
  `confirm(title, message, signal?, minTier?, wait?, details?, options?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
2658
2675
  The confirmation gate and a coding worker's cards pass both.
2659
- `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. `approvedPrompt(prompt)` is called, likewise before `confirm` resolves `approved`, with the id of the card that approved the call, for `ToolTurn.approvedPrompt`; a surface whose cards have no stable id leaves it uncalled. A surface may ignore all three for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2676
+ `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), `speakerOnly`, true when the card is the speaker's alone and no owner may answer it (see [a hold that makes its card the speaker's alone](#a-hold-that-makes-its-card-the-speakers-alone); a surface that cannot honour it resolves `unavailable` instead of showing the card to the owners), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. `approvedPrompt(prompt)` is called, likewise before `confirm` resolves `approved`, with the id of the card that approved the call, for `ToolTurn.approvedPrompt`; a surface whose cards have no stable id leaves it uncalled. A surface may ignore all three for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2660
2677
  A surface that shows text, such as Discord, uses `message` and reads only `details.copy` (below); the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
2661
2678
  A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
2662
2679
 
@@ -2668,7 +2685,7 @@ Because anyone who can see the card can copy the text, it holds nothing they may
2668
2685
 
2669
2686
  #### A prompt that outlives its turn: `PromptWait`
2670
2687
 
2671
- `confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy?, prompt? }`, `approvedBy` naming the approver where the surface knows them and `prompt` the id of the card that was answered, or `{ kind: "question", answer, by }`, `by` naming who answered) and returns the text of the turn that tells the conversation, or undefined for none.
2688
+ `confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by, approvedBy?, prompt? }`, `approvedBy` naming the approver where the surface knows them and `prompt` the id of the card that was answered, `{ kind: "question", answer, by }`, or `{ kind: "questionnaire", answer: QuestionnaireAnswer, by }`, `by` naming who answered; only a `questionnaire` call can produce the last) and returns the text of the turn that tells the conversation, or undefined for none.
2672
2689
  Given one, a surface may stop the turn's wait after a grace period: `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
2673
2690
  Without one, a prompt waits for its answer, or for its turn's stop; a surface that cannot start a turn ignores `wait` and waits too.
2674
2691
  A stop cancels a prompt only while its turn still waits on it; after that the prompt belongs to the conversation.
@@ -2682,6 +2699,25 @@ On Discord the grace period is `discord.cardGraceSeconds` (default 120), and the
2682
2699
  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.
2683
2700
  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.
2684
2701
 
2702
+ #### Several questions on one card: `questionnaire` and `AskSupport`
2703
+
2704
+ `ask_user` takes one to four questions in one call (`questions`; a whitespace-only question is refused), each with an optional short `header`, options with an optional `description` and markdown `preview`, `multi`, and `allow_other` (true by default in this form, so every question takes a written answer too), and the person may add a note on the whole set. The older single-question arguments (`question`, `options`, `multi`, `allow_other`) still work and read as one question with `allow_other` false; double-encoded `questions` and `options` values are decoded. A call that breaks `QUESTIONNAIRE_LIMITS` (the table of every limit: four questions, a header of 30 characters, 25 options, 100 characters for a label or description, 1,500 characters for a lone question and 3,000 for the questions and headers of a set, 1,200 characters per preview and 10,000 in all, 1,000 for an answer of their own or the note) is refused to the model with the reason and posts no card. `validateQuestionnaire` gives the reason. Since 0.20 the same option label twice in a question, or the same question twice in a call, is refused for every call; 0.19 accepted both.
2705
+
2706
+ A surface says what it can show with `Prompts.asks` (`AskSupport`: `{ questions, previews, note }`) and, when it declares it, implements `Prompts.questionnaire(title, questionnaire, signal?, wait?)`, which resolves with a `QuestionnaireAnswer` (`answers`, one per question in order and `undefined` for one left blank, and `note`), `"pending"`, or `undefined` as `ask` does. Both members are optional: a `Prompts` without them is the 0.19 contract (`SINGLE_QUESTION`: one question, no previews, no note) and keeps working unchanged. The runtime gives `questionnaire` a set already shaped for `asks` (`shapeFor`), so a surface renders only what it declared:
2707
+
2708
+ | The surface lacks | What the core does |
2709
+ |---|---|
2710
+ | room for the questions (`asks.questions`, or no `asks` at all) | asks them one at a time through `ask` and stops at the first one that does not come back answered; the model is told which were not asked |
2711
+ | `previews` | appends each preview to its question as `**label**` and a fenced block (a longer fence when the preview holds one), cut to the question limit with `…` |
2712
+ | a `header` (a surface that takes one question) | drops it; the tool description already asks that each question read on its own |
2713
+ | `note` | shows no note control, and the answer carries no `note` |
2714
+
2715
+ One question without previews always goes through `ask` and returns the same text as 0.19. `checkAnswer` and `checkQuestionnaireAnswer` check that an answer is one the questions allow (offered labels only, one unless `multi`, text of their own only where `allowOther`); a surface that takes answers from a network uses them rather than its own rules. `describeSurfaceContract` runs the questionnaire checks (answered, blank question kept blank, closed on stop, a stranger's answer ignored) for a subject that gives `answerQuestionnaire(prompt, answer, questionnaire)`; a subject that gives it must declare `asks`, and the contract fails it otherwise.
2716
+
2717
+ On Discord the whole set is one card (`asks` is four questions, previews and a note): a menu per question with its options (an option can be cleared again, and the menus show the draft), `Submit`, a button that opens one form with a field per question that takes written text plus the note, and, when an option has a preview, a `Previews` button that replies ephemerally to whoever presses it and answers nothing. A short footer outside the container says how many questions are answered. The draft is one per card, shared by everyone who may answer and visible to everyone who sees the card; the last write wins and the submitter is reported. Presses are checked against the same audience as any question each time (menus, form and Submit). `Submit` needs at least one answer or the note, and a partial answer is allowed. Every limit Discord enforces is counted in UTF-16 units, so an emoji weighs two: the core's refusal of an over-long set counts them too. With a `wait`, each draft edit restarts the grace period, and a `Submit` after the turn stopped waiting starts the late turn with a `questionnaire` `LateAnswer`. A single question keeps its 0.19 card (a menu answers at once; "Other…" opens the form, or, with 25 options plus `allow_other`, an `Answer…` button does), with a `Previews` button when an option has a preview. Open cards live in memory, as before.
2718
+
2719
+ On the web chat the support is the least of the person's connections that declared theirs (`prompt_features`), so the assistant only sends a set a tab can show, and a person whose tabs all lack it is asked one question at a time. A questionnaire that no connection can show expires after 60 seconds: it settles unanswered, the model is told, and if it asks again it gets the one-at-a-time fallback. See the web chat package's README for the frames.
2720
+
2685
2721
  #### Interim text: what a turn writes before its final answer
2686
2722
 
2687
2723
  A model often writes text in an assistant message that then calls tools, such as a proposal before it asks `ask_user` "go with this version?".
@@ -3400,11 +3436,12 @@ test("the production configuration registers every plugin", async () => {
3400
3436
  ### `describeSurfaceContract`: what every chat surface keeps
3401
3437
 
3402
3438
  A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
3403
- `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s. `approver`, when given, is a third person on the network with a `principalId`, not an owner and at the person's tier, whom the contract names in `approvers`; `privateOnly` marks a surface that serves only conversations private to one person, such as the web chat.
3439
+ `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s. `approver`, when given, is a third person on the network with a `principalId`, not an owner and at the person's tier, whom the contract names in `approvers`; `speakerOnly` marks a surface that honours `ApprovalOptions.speakerOnly` in a shared conversation, which the contract then checks with the `owner`; `privateOnly` marks a surface that serves only conversations private to one person, such as the web chat.
3404
3440
  The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
3405
- It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops or its plugin withdraws it (`ApprovalOptions.supersede`; one answered first stays answered), with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3441
+ It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops or its plugin withdraws it (`ApprovalOptions.supersede`; one answered first stays answered), with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; with a `speakerOnly` subject and an `owner`, that an owner's answer leaves an approval that is the speaker's alone open in a shared conversation, unless the surface is `privateOnly`; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3406
3442
  A check for a part the surface does not offer passes.
3407
3443
  The contract has no check for `ApprovalDetails.copy`: a surface that shows it keeps it informational, so using it never resolves, approves or declines the prompt, whoever uses it.
3444
+ A subject that gives `answerQuestionnaire` (and a `stranger` that gives its own) must declare `asks` and also keeps the questionnaire checks.
3408
3445
  `checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
3409
3446
 
3410
3447
  ## What happens when the bot starts and stops
@@ -3665,6 +3702,7 @@ Import from the entries listed below; source area files are internal.
3665
3702
  | `ApprovalOptions` | `pi-roundtable` | type |
3666
3703
  | `ApprovedBy` | `pi-roundtable` | type |
3667
3704
  | `AskOption` | `pi-roundtable` | type |
3705
+ | `AskSupport` | `pi-roundtable` | type |
3668
3706
  | `AttachmentFailure` | `pi-roundtable` | type |
3669
3707
  | `AttachmentPort` | `pi-roundtable` | type |
3670
3708
  | `AttachmentRef` | `pi-roundtable` | type |
@@ -3793,6 +3831,9 @@ Import from the entries listed below; source area files are internal.
3793
3831
  | `Provisioning` | `pi-roundtable` | type |
3794
3832
  | `ProvisioningRule` | `pi-roundtable` | type |
3795
3833
  | `QueuePort` | `pi-roundtable` | type |
3834
+ | `QUESTIONNAIRE_LIMITS` | `pi-roundtable` | value |
3835
+ | `Questionnaire` | `pi-roundtable` | type |
3836
+ | `QuestionnaireAnswer` | `pi-roundtable` | type |
3796
3837
  | `Recurrence` | `pi-roundtable` | type |
3797
3838
  | `ReferenceImage` | `pi-roundtable` | type |
3798
3839
  | `ResolvedProviders` | `pi-roundtable` | type |
@@ -3825,6 +3866,7 @@ Import from the entries listed below; source area files are internal.
3825
3866
  | `PrecheckTool` | `pi-roundtable` | type |
3826
3867
  | `PRECHECK_SCRIPT_CHARS` | `pi-roundtable` | value |
3827
3868
  | `SKILLS` | `pi-roundtable` | value |
3869
+ | `SINGLE_QUESTION` | `pi-roundtable` | value |
3828
3870
  | `Schedule` | `pi-roundtable` | type |
3829
3871
  | `ScheduleChange` | `pi-roundtable` | type |
3830
3872
  | `ScheduleError` | `pi-roundtable` | value |
@@ -3886,6 +3928,7 @@ Import from the entries listed below; source area files are internal.
3886
3928
  | `Weekday` | `pi-roundtable` | type |
3887
3929
  | `attachReplyFile` | `pi-roundtable` | value |
3888
3930
  | `promptScope` | `pi-roundtable` | value |
3931
+ | `validateQuestionnaire` | `pi-roundtable` | value |
3889
3932
  | `withReplyFiles` | `pi-roundtable` | value |
3890
3933
  | `withdrawalNote` | `pi-roundtable` | value |
3891
3934
  | `prepareImageBytes` | `pi-roundtable/kit` | value |
@@ -3895,12 +3938,15 @@ Import from the entries listed below; source area files are internal.
3895
3938
  | `SquarePngOptions` | `pi-roundtable/kit` | type |
3896
3939
  | `scrubDiagnostic` | `pi-roundtable/kit` | value |
3897
3940
  | `channelKey` | `pi-roundtable` | value |
3941
+ | `checkAnswer` | `pi-roundtable` | value |
3942
+ | `checkQuestionnaireAnswer` | `pi-roundtable` | value |
3898
3943
  | `definePlugin` | `pi-roundtable` | value |
3899
3944
  | `defineRoundtable` | `pi-roundtable` | value |
3900
3945
  | `defineTool` | `pi-roundtable` | value |
3901
3946
  | `migrateDatabase` | `pi-roundtable` | value |
3902
3947
  | `parseChannelKey` | `pi-roundtable` | value |
3903
3948
  | `serviceKey` | `pi-roundtable` | value |
3949
+ | `shapeFor` | `pi-roundtable` | value |
3904
3950
  | `FakeDiscord` | `pi-roundtable/testing` | type |
3905
3951
  | `FakeThreadHost` | `pi-roundtable/testing` | type |
3906
3952
  | `OWNER_SPEAKER` | `pi-roundtable/testing` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.19.2",
3
+ "version": "0.21.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "src",
27
27
  "!src/**/*.test.ts",
28
28
  "!src/core/testing/file-size.ts",
29
+ "!src/core/testing/discord-limits.ts",
29
30
  "!src/core/testing/prompt-capture*.ts",
30
31
  "!src/core/testing/discord-api-emulator*.ts",
31
32
  "!src/core/discord/poster/poster-contract*.ts",
@@ -58,7 +58,9 @@ export class CardAudiences {
58
58
  scope: PromptScope | undefined,
59
59
  minTier: Tier,
60
60
  named?: readonly string[],
61
+ speakerOnly = false,
61
62
  ): Promise<Audience | undefined> {
63
+ if (speakerOnly) return this.#speakers(scope, minTier);
62
64
  let approvers = named;
63
65
  // A private conversation's person is the only one who answers, so a call naming others is theirs only if named or an owner.
64
66
  if (approvers && scope?.escalate === "none") {
@@ -78,6 +80,51 @@ export class CardAudiences {
78
80
  );
79
81
  }
80
82
 
83
+ /**
84
+ * The card of a call that is its speaker's alone (`ApprovalOptions.speakerOnly`): the scope's
85
+ * principal on any of their Discord identities still theirs when they press, at the tier the
86
+ * call needs, checked again then, and no owner as such, whatever the scope escalates to. The
87
+ * primary owner answers their own card, and where there is no identity service the primary owner
88
+ * is the only speaker who has one. Undefined, so the call is unavailable at once, when there is
89
+ * no scope, or the speaker is below the tier or has no Discord identity.
90
+ */
91
+ async #speakers(
92
+ scope: PromptScope | undefined,
93
+ minTier: Tier,
94
+ ): Promise<Audience | undefined> {
95
+ if (!scope) return undefined;
96
+ const { ownerId, identity } = this.#options;
97
+ if (!identity) {
98
+ // Without an identity service the primary owner is the only one who answers, and the only speaker who can.
99
+ if (scope.speakerId !== ownerId) return undefined;
100
+ return {
101
+ allows: async (user) => user.id === ownerId,
102
+ mentions: [ownerId],
103
+ note: "",
104
+ refusal: messages().cardOwnerOnly,
105
+ approver: (user) => this.#approver(user),
106
+ };
107
+ }
108
+ const own = await this.#ownIdentities(scope, minTier);
109
+ if (!own) return undefined;
110
+ const mentions = own.includes(scope.speakerId) ? [scope.speakerId] : own;
111
+ const primary = own.includes(ownerId);
112
+ return {
113
+ allows: async (user) =>
114
+ own.includes(user.id) &&
115
+ // Read the links again: one moved or unlinked since the card was posted is no longer theirs.
116
+ (await this.#stillOwn(scope.principalId, user.id)) &&
117
+ ((primary && user.id === ownerId) ||
118
+ (await this.#owners.holds(user, minTier, scope.principalId))),
119
+ mentions,
120
+ note: primary ? "" : messages().cardApproversNote(...mentions),
121
+ refusal: primary
122
+ ? messages().cardOwnerOnly
123
+ : messages().cardApproversRefusal,
124
+ approver: (user) => this.#approver(user),
125
+ };
126
+ }
127
+
81
128
  /** Who may answer a question; undefined when no one may. */
82
129
  question(scope: PromptScope | undefined): Promise<Audience | undefined> {
83
130
  return this.#audience(scope, undefined, {
@@ -0,0 +1,180 @@
1
+ import { type Interaction, MessageFlags } from "discord.js";
2
+ import { messages } from "../i18n/index.ts";
3
+ import {
4
+ checkQuestionnaireAnswer,
5
+ type QuestionnaireAnswer,
6
+ } from "../interactions/questionnaire.ts";
7
+ import {
8
+ answerOf,
9
+ formFields,
10
+ previewReplies,
11
+ questionnaireModal,
12
+ } from "./owner-card-questionnaire.ts";
13
+ import {
14
+ answeredLine,
15
+ answerModal,
16
+ OTHER,
17
+ TEXT_FIELD,
18
+ } from "./owner-card-rows.ts";
19
+ import type { Answerer, OpenCard } from "./owner-card-types.ts";
20
+
21
+ const ephemeral = (content: string) => ({
22
+ content,
23
+ flags: MessageFlags.Ephemeral as const,
24
+ });
25
+
26
+ /** The single-question card's controls: a menu pick, "Write answer…", and the answer form. */
27
+ export async function answerSingle(
28
+ card: OpenCard,
29
+ id: string,
30
+ action: string,
31
+ interaction: Interaction,
32
+ by: Answerer,
33
+ ): Promise<void> {
34
+ const question = card.question;
35
+ if (!question) return;
36
+ if (interaction.isButton() && action === "write") {
37
+ card.picked = [];
38
+ await interaction.showModal(answerModal(id));
39
+ return;
40
+ }
41
+ if (interaction.isStringSelectMenu() && action === "pick") {
42
+ const values = interaction.values;
43
+ const choices = question.options.flatMap((option, i) =>
44
+ values.includes(String(i)) ? [option.label] : [],
45
+ );
46
+ if (values.includes(OTHER)) {
47
+ card.picked = choices;
48
+ await interaction.showModal(answerModal(id));
49
+ return;
50
+ }
51
+ const answer = { choices };
52
+ card.end(answer, by);
53
+ await interaction.update(card.view(answeredLine(answer)));
54
+ return;
55
+ }
56
+ if (interaction.isModalSubmit() && action === "text") {
57
+ const answer = {
58
+ choices: card.picked ?? [],
59
+ text: interaction.fields.getTextInputValue(TEXT_FIELD),
60
+ };
61
+ card.end(answer, by);
62
+ if (interaction.isFromMessage())
63
+ await interaction.update(card.view(answeredLine(answer)));
64
+ else await interaction.deferUpdate();
65
+ }
66
+ }
67
+
68
+ /** Previews answer nothing, so anyone who sees the card may open them. */
69
+ export async function showPreviews(
70
+ card: OpenCard,
71
+ interaction: Interaction,
72
+ ): Promise<void> {
73
+ const source = card.form?.questionnaire ?? card.previews;
74
+ if (!interaction.isButton() || !source) return;
75
+ const [first, ...rest] = previewReplies(source);
76
+ if (first) await interaction.reply(first);
77
+ for (const page of rest) await interaction.followUp(page);
78
+ }
79
+
80
+ /** Ends the card with `answer`; false when another press or the plugin ended it first. */
81
+ function finish(
82
+ card: OpenCard,
83
+ answer: QuestionnaireAnswer,
84
+ by: Answerer,
85
+ ): boolean {
86
+ const draft = card.form?.draft;
87
+ if (draft) {
88
+ for (const [i, a] of answer.answers.entries()) {
89
+ draft.choices[i] = [...(a?.choices ?? [])];
90
+ draft.texts[i] = a?.text;
91
+ }
92
+ if (answer.note !== undefined) draft.note = answer.note;
93
+ }
94
+ return card.end(answer, by);
95
+ }
96
+
97
+ /** The note an ended card shows as its footer: the draft's note, when it has one. */
98
+ const endedFooter = (card: OpenCard) => {
99
+ const note = card.form?.draft.note?.trim();
100
+ return note ? messages().cardNoteLine(note) : "";
101
+ };
102
+
103
+ /**
104
+ * A menu pick, Submit or the submitted form of a questionnaire card. The press is deferred first
105
+ * (the audience lookup may take a while), then the audience and the card are checked: a refusal
106
+ * or a closed card changes nothing, and a refused pick re-draws the card so their menu resets.
107
+ */
108
+ export async function answerDeferred(
109
+ card: OpenCard,
110
+ action: string,
111
+ index: number,
112
+ interaction: Interaction,
113
+ by: Answerer,
114
+ ): Promise<void> {
115
+ const form = card.form;
116
+ if (!form) return;
117
+ const picks = interaction.isStringSelectMenu() && action === "qpick";
118
+ const submits = interaction.isButton() && action === "submit";
119
+ const writes = interaction.isModalSubmit() && action === "qtext";
120
+ if (!picks && !submits && !writes) return;
121
+ await interaction.deferUpdate();
122
+ const fromMessage =
123
+ !interaction.isModalSubmit() || interaction.isFromMessage();
124
+ if (!(await card.audience.allows(by.user))) {
125
+ await interaction.followUp(ephemeral(card.audience.refusal));
126
+ if (picks && card.isOpen())
127
+ await interaction.editReply(card.view("", false));
128
+ return;
129
+ }
130
+ // Stopped, withdrawn or answered meanwhile: the closed card must not be drawn open again.
131
+ if (!card.isOpen()) {
132
+ await interaction.followUp(ephemeral(messages().cardInactive));
133
+ return;
134
+ }
135
+ const { questions } = form.questionnaire;
136
+ if (picks) {
137
+ const question = questions[index];
138
+ if (!question) return;
139
+ form.draft.choices[index] = question.options.flatMap((option, n) =>
140
+ interaction.values.includes(String(n)) ? [option.label] : [],
141
+ );
142
+ } else if (writes) {
143
+ const { texts, note } = formFields(form, (field) =>
144
+ interaction.fields.getTextInputValue(field),
145
+ );
146
+ for (const [i, value] of texts)
147
+ form.draft.texts[i] = value.trim() || undefined;
148
+ form.draft.note = note.trim() || undefined;
149
+ }
150
+ if (!submits) {
151
+ card.touch();
152
+ if (fromMessage) await interaction.editReply(card.view("", false));
153
+ return;
154
+ }
155
+ const answer = answerOf(form.draft);
156
+ if (!checkQuestionnaireAnswer(form.questionnaire, answer)) {
157
+ await interaction.followUp(ephemeral(messages().cardNothingYet));
158
+ return;
159
+ }
160
+ if (!finish(card, answer, by)) {
161
+ await interaction.followUp(ephemeral(messages().cardInactive));
162
+ return;
163
+ }
164
+ await interaction.editReply(card.view(endedFooter(card)));
165
+ }
166
+
167
+ /** "Write answers / note…": opens the form, which a modal cannot defer; the audience was checked. */
168
+ export async function answerForm(
169
+ card: OpenCard,
170
+ id: string,
171
+ action: string,
172
+ interaction: Interaction,
173
+ ): Promise<void> {
174
+ if (!card.form || !interaction.isButton() || action !== "notes") return;
175
+ if (!card.isOpen()) {
176
+ await interaction.reply(ephemeral(messages().cardInactive));
177
+ return;
178
+ }
179
+ await interaction.showModal(questionnaireModal(id, card.form));
180
+ }
@@ -0,0 +1,149 @@
1
+ import { messages } from "../i18n/index.ts";
2
+ import type {
3
+ OwnerAnswer,
4
+ PromptScope,
5
+ Prompts,
6
+ } from "../interactions/prompts.ts";
7
+ import type {
8
+ AskSupport,
9
+ QuestionnaireAnswer,
10
+ } from "../interactions/questionnaire.ts";
11
+ import type { CardAudiences } from "./card-audience.ts";
12
+ import type { QuestionnaireForm } from "./owner-card-questionnaire.ts";
13
+ import { newDraft, questionnaireCard } from "./owner-card-questionnaire.ts";
14
+ import { askRows, previewsRow } from "./owner-card-rows.ts";
15
+ import type { PostCard } from "./owner-card-types.ts";
16
+
17
+ /** What the Discord card shows of a questionnaire: four questions, previews, and a note. */
18
+ export const DISCORD_ASKS: AskSupport = {
19
+ questions: 4,
20
+ previews: true,
21
+ note: true,
22
+ };
23
+
24
+ /** A channel's question cards: one question on its own card, or up to four on one. */
25
+ export function askPrompts(args: {
26
+ channelId: string;
27
+ scope: PromptScope | undefined;
28
+ audiences: CardAudiences;
29
+ post: PostCard;
30
+ }): Pick<Prompts, "ask" | "asks" | "questionnaire"> {
31
+ const { channelId, scope, audiences, post } = args;
32
+ return {
33
+ asks: DISCORD_ASKS,
34
+ ask: async (title, question, signal, wait) => {
35
+ const audience = await audiences.question(scope);
36
+ if (!audience) return undefined;
37
+ return post<OwnerAnswer | "pending" | undefined>(
38
+ channelId,
39
+ signal,
40
+ undefined,
41
+ undefined,
42
+ {
43
+ audience,
44
+ ...(wait
45
+ ? {
46
+ wait: {
47
+ pending: "pending",
48
+ late: (value, by) =>
49
+ wait.late({
50
+ kind: "question",
51
+ answer: value as OwnerAnswer,
52
+ by: by.user.name,
53
+ }),
54
+ },
55
+ }
56
+ : {}),
57
+ title,
58
+ sections: [question.question],
59
+ question,
60
+ rows: (id, disabled) => askRows(id, question, disabled),
61
+ footer: messages().cardQuestionFooter,
62
+ },
63
+ );
64
+ },
65
+ questionnaire: async (title, questionnaire, signal, wait) => {
66
+ const audience = await audiences.question(scope);
67
+ if (!audience) return undefined;
68
+ const [only] = questionnaire.questions;
69
+ if (only && questionnaire.questions.length === 1) {
70
+ // One question is the 0.19 card, answered by one click, with a Previews button.
71
+ const answer = await post<OwnerAnswer | "pending" | undefined>(
72
+ channelId,
73
+ signal,
74
+ undefined,
75
+ undefined,
76
+ {
77
+ audience,
78
+ ...(wait
79
+ ? {
80
+ wait: {
81
+ pending: "pending",
82
+ late: (value, by) =>
83
+ wait.late({
84
+ kind: "questionnaire",
85
+ answer: { answers: [value as OwnerAnswer] },
86
+ by: by.user.name,
87
+ }),
88
+ },
89
+ }
90
+ : {}),
91
+ title,
92
+ sections: [only.question],
93
+ question: only,
94
+ previews: questionnaire,
95
+ rows: (id, disabled) => [
96
+ ...askRows(id, only, disabled),
97
+ ...(only.options.some((o) => o.preview !== undefined)
98
+ ? [previewsRow(id, disabled)]
99
+ : []),
100
+ ],
101
+ footer: messages().cardQuestionFooter,
102
+ },
103
+ );
104
+ return answer === "pending" || answer === undefined
105
+ ? answer
106
+ : { answers: [answer] };
107
+ }
108
+ const form: QuestionnaireForm = {
109
+ questionnaire,
110
+ draft: newDraft(questionnaire.questions.length),
111
+ };
112
+ return post<QuestionnaireAnswer | "pending" | undefined>(
113
+ channelId,
114
+ signal,
115
+ undefined,
116
+ undefined,
117
+ {
118
+ audience,
119
+ ...(wait
120
+ ? {
121
+ wait: {
122
+ pending: "pending",
123
+ late: (value, by) =>
124
+ wait.late({
125
+ kind: "questionnaire",
126
+ answer: value as QuestionnaireAnswer,
127
+ by: by.user.name,
128
+ }),
129
+ },
130
+ }
131
+ : {}),
132
+ title,
133
+ sections: [],
134
+ form,
135
+ build: (id, footer, disabled) =>
136
+ questionnaireCard({
137
+ id,
138
+ title,
139
+ note: audience.note,
140
+ form,
141
+ disabled,
142
+ footer,
143
+ }),
144
+ footer: "",
145
+ },
146
+ );
147
+ },
148
+ };
149
+ }