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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/docs/plugins.md +51 -5
- package/package.json +2 -1
- package/src/core/discord/card-audience.ts +47 -0
- package/src/core/discord/owner-card-answers.ts +180 -0
- package/src/core/discord/owner-card-asks.ts +149 -0
- package/src/core/discord/owner-card-questionnaire.ts +281 -0
- package/src/core/discord/owner-card-rows.ts +40 -18
- package/src/core/discord/owner-card-types.ts +54 -5
- package/src/core/discord/owner-cards-fixture.ts +37 -3
- package/src/core/discord/owner-cards.ts +62 -133
- package/src/core/discord/utf16.ts +11 -0
- package/src/core/holds.ts +15 -2
- package/src/core/i18n/cards.ts +32 -2
- package/src/core/interactions/prompts.ts +43 -22
- package/src/core/interactions/questionnaire.ts +213 -0
- package/src/core/runtime/ask-questions.ts +93 -0
- package/src/core/runtime/extensions/ask-user-arguments.ts +52 -0
- package/src/core/runtime/extensions/ask-user-text.ts +146 -0
- package/src/core/runtime/extensions/ask-user.ts +87 -81
- package/src/core/runtime/extensions/card-ask.ts +3 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +2 -0
- package/src/core/runtime/extensions/gate-refusals.ts +2 -0
- package/src/core/runtime/extensions/hold-lookups.ts +16 -4
- package/src/core/runtime/prompt-slot.ts +21 -1
- package/src/core/testing/surface-contract-questionnaire.ts +192 -0
- package/src/core/testing/surface-contract-support.ts +29 -1
- package/src/core/testing/surface-contract.ts +46 -20
- package/src/index.ts +11 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** One choice a question offers. */
|
|
2
|
+
export interface AskOption {
|
|
3
|
+
label: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
/** A markdown mock-up or snippet shown beside the option, where the surface can; see `AskSupport.previews`. */
|
|
6
|
+
preview?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OwnerQuestion {
|
|
10
|
+
question: string;
|
|
11
|
+
/** A short name for the question in a set (see `QUESTIONNAIRE_LIMITS.headerChars`); surfaces that take one question drop it. */
|
|
12
|
+
header?: string;
|
|
13
|
+
/** Up to 25; none means a free-text answer. */
|
|
14
|
+
options: readonly AskOption[];
|
|
15
|
+
/** More than one option may be chosen. */
|
|
16
|
+
multi: boolean;
|
|
17
|
+
/** They may write an answer of their own besides the options. */
|
|
18
|
+
allowOther: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OwnerAnswer {
|
|
22
|
+
/** The chosen options' labels, in the order they were offered. */
|
|
23
|
+
choices: string[];
|
|
24
|
+
/** What they wrote themselves, if anything. */
|
|
25
|
+
text?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One to four related questions asked on a single card. */
|
|
29
|
+
export interface Questionnaire {
|
|
30
|
+
questions: readonly OwnerQuestion[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The answer to a `Questionnaire`: `answers[i]` is for `questions[i]`, undefined when left blank. */
|
|
34
|
+
export interface QuestionnaireAnswer {
|
|
35
|
+
answers: readonly (OwnerAnswer | undefined)[];
|
|
36
|
+
/** A note on the whole set, where the surface takes one. */
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a surface can show of a questionnaire. A `Prompts` without `asks` is the 0.19 contract:
|
|
42
|
+
* one question, no previews, no note.
|
|
43
|
+
*/
|
|
44
|
+
export interface AskSupport {
|
|
45
|
+
/** The most questions one card holds (1 to 4). */
|
|
46
|
+
questions: number;
|
|
47
|
+
/** Options may carry a `preview`. */
|
|
48
|
+
previews: boolean;
|
|
49
|
+
/** The card takes a note on the whole set. */
|
|
50
|
+
note: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const SINGLE_QUESTION: AskSupport = {
|
|
54
|
+
questions: 1,
|
|
55
|
+
previews: false,
|
|
56
|
+
note: false,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Every limit of a questionnaire, one table for the tool schema, the validation and the tests. */
|
|
60
|
+
export const QUESTIONNAIRE_LIMITS = {
|
|
61
|
+
questions: 4,
|
|
62
|
+
/** A question's text, alone in a call. */
|
|
63
|
+
questionChars: 1500,
|
|
64
|
+
/** Question texts and headers together when a call holds several questions. */
|
|
65
|
+
setChars: 3000,
|
|
66
|
+
headerChars: 30,
|
|
67
|
+
options: 25,
|
|
68
|
+
labelChars: 100,
|
|
69
|
+
descriptionChars: 100,
|
|
70
|
+
/** One option's preview. */
|
|
71
|
+
previewChars: 1200,
|
|
72
|
+
/** All previews of a call together. */
|
|
73
|
+
previewTotalChars: 10000,
|
|
74
|
+
/** Their own answer to one question, and the note. */
|
|
75
|
+
answerChars: 1000,
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
const L = QUESTIONNAIRE_LIMITS;
|
|
79
|
+
|
|
80
|
+
const pointsOf = (text: string) => Array.from(text).length;
|
|
81
|
+
const units = (text: string) => Math.max(pointsOf(text), text.length);
|
|
82
|
+
|
|
83
|
+
function duplicate(values: readonly string[]): string | undefined {
|
|
84
|
+
return values.find((v, i) => values.indexOf(v) !== i);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The model-facing reason a questionnaire is refused, or undefined when it is sound. */
|
|
88
|
+
export function validateQuestionnaire(q: Questionnaire): string | undefined {
|
|
89
|
+
const n = q.questions.length;
|
|
90
|
+
if (n < 1 || n > L.questions)
|
|
91
|
+
return `Ask between 1 and ${L.questions} questions in one call (got ${n}).`;
|
|
92
|
+
const many = n > 1;
|
|
93
|
+
let setChars = 0;
|
|
94
|
+
let previews = 0;
|
|
95
|
+
for (const [i, question] of q.questions.entries()) {
|
|
96
|
+
const at = many ? `Question ${i + 1}: ` : "";
|
|
97
|
+
const text = question.question.trim();
|
|
98
|
+
if (text === "") return `${at}the question is empty.`;
|
|
99
|
+
if (!many && pointsOf(question.question) > L.questionChars)
|
|
100
|
+
return `${at}the question is over ${L.questionChars} characters.`;
|
|
101
|
+
if (
|
|
102
|
+
question.header !== undefined &&
|
|
103
|
+
pointsOf(question.header) > L.headerChars
|
|
104
|
+
)
|
|
105
|
+
return `${at}the header is over ${L.headerChars} characters.`;
|
|
106
|
+
// Discord counts UTF-16 units, so an emoji or rare character weighs two here.
|
|
107
|
+
setChars += units(question.question) + units(question.header ?? "");
|
|
108
|
+
if (question.options.length > L.options)
|
|
109
|
+
return `${at}offer at most ${L.options} options.`;
|
|
110
|
+
for (const option of question.options) {
|
|
111
|
+
if (option.label.trim() === "") return `${at}an option has no label.`;
|
|
112
|
+
if (pointsOf(option.label) > L.labelChars)
|
|
113
|
+
return `${at}an option label is over ${L.labelChars} characters.`;
|
|
114
|
+
if (pointsOf(option.description ?? "") > L.descriptionChars)
|
|
115
|
+
return `${at}an option description is over ${L.descriptionChars} characters.`;
|
|
116
|
+
const preview = pointsOf(option.preview ?? "");
|
|
117
|
+
if (preview > L.previewChars)
|
|
118
|
+
return `${at}the preview of "${option.label}" is over ${L.previewChars} characters.`;
|
|
119
|
+
previews += preview;
|
|
120
|
+
}
|
|
121
|
+
const same = duplicate(question.options.map((o) => o.label));
|
|
122
|
+
if (same !== undefined)
|
|
123
|
+
return `${at}the option "${same}" is offered twice.`;
|
|
124
|
+
}
|
|
125
|
+
if (many && setChars > L.setChars)
|
|
126
|
+
return `The questions and headers together are over ${L.setChars} characters (an emoji or rare character counts as two); shorten them or ask fewer.`;
|
|
127
|
+
if (previews > L.previewTotalChars)
|
|
128
|
+
return `The previews together are over ${L.previewTotalChars} characters.`;
|
|
129
|
+
const twice = duplicate(q.questions.map((x) => x.question.trim()));
|
|
130
|
+
if (twice !== undefined) return `The question "${twice}" is asked twice.`;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether an answer is one the question allows: offered labels, one unless multi, own text only where allowed. */
|
|
135
|
+
export function checkAnswer(q: OwnerQuestion, a: OwnerAnswer): boolean {
|
|
136
|
+
const labels = q.options.map((option) => option.label);
|
|
137
|
+
const text = a.text?.trim();
|
|
138
|
+
if (!a.choices.every((choice) => labels.includes(choice))) return false;
|
|
139
|
+
if (new Set(a.choices).size !== a.choices.length) return false;
|
|
140
|
+
if (!q.multi && a.choices.length > 1) return false;
|
|
141
|
+
if (labels.length === 0) return a.choices.length === 0 && Boolean(text);
|
|
142
|
+
if (text && !q.allowOther) return false;
|
|
143
|
+
return a.choices.length > 0 || Boolean(text);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether an answer fits the set: aligned with the questions, each given answer allowed, something given. */
|
|
147
|
+
export function checkQuestionnaireAnswer(
|
|
148
|
+
q: Questionnaire,
|
|
149
|
+
a: QuestionnaireAnswer,
|
|
150
|
+
): boolean {
|
|
151
|
+
if (a.answers.length !== q.questions.length) return false;
|
|
152
|
+
const note = a.note?.trim();
|
|
153
|
+
if (a.note !== undefined && pointsOf(a.note) > L.answerChars) return false;
|
|
154
|
+
let given = Boolean(note);
|
|
155
|
+
for (const [i, answer] of a.answers.entries()) {
|
|
156
|
+
const question = q.questions[i];
|
|
157
|
+
if (answer === undefined || !question) continue;
|
|
158
|
+
if (!checkAnswer(question, answer)) return false;
|
|
159
|
+
if (answer.text !== undefined && pointsOf(answer.text) > L.answerChars)
|
|
160
|
+
return false;
|
|
161
|
+
given = true;
|
|
162
|
+
}
|
|
163
|
+
return given;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `text` cut to `max` characters with a closing `…`. */
|
|
167
|
+
function cut(text: string, max: number): string {
|
|
168
|
+
const chars = Array.from(text);
|
|
169
|
+
return chars.length <= max ? text : `${chars.slice(0, max - 1).join("")}…`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A fenced block for `body`, its fence longer than any backtick run inside. */
|
|
173
|
+
function fenced(body: string): string {
|
|
174
|
+
const longest = Math.max(
|
|
175
|
+
0,
|
|
176
|
+
...(body.match(/`+/g) ?? []).map((run) => run.length),
|
|
177
|
+
);
|
|
178
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
179
|
+
return `${fence}\n${body}\n${fence}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function foldPreviews(question: OwnerQuestion): OwnerQuestion {
|
|
183
|
+
if (!question.options.some((o) => o.preview)) return question;
|
|
184
|
+
const shown = question.options
|
|
185
|
+
.filter((o) => o.preview)
|
|
186
|
+
.map((o) => `**${o.label}**\n${fenced(o.preview ?? "")}`);
|
|
187
|
+
return {
|
|
188
|
+
...question,
|
|
189
|
+
question: cut(
|
|
190
|
+
`${question.question}\n\n${shown.join("\n\n")}`,
|
|
191
|
+
L.questionChars,
|
|
192
|
+
),
|
|
193
|
+
options: question.options.map(({ preview: _preview, ...rest }) => rest),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The questionnaire a surface with `support` can show; the same questions, in the same order.
|
|
199
|
+
* A surface without previews gets each preview appended to its question as a fenced block, cut
|
|
200
|
+
* to the question limit. A surface that takes one question drops the headers. Questions beyond
|
|
201
|
+
* `support.questions` are not removed here: the runtime asks a surface that takes fewer one
|
|
202
|
+
* question at a time.
|
|
203
|
+
*/
|
|
204
|
+
export function shapeFor(q: Questionnaire, support: AskSupport): Questionnaire {
|
|
205
|
+
return {
|
|
206
|
+
questions: q.questions.map((question) => {
|
|
207
|
+
const shaped = support.previews ? question : foldPreviews(question);
|
|
208
|
+
if (support.questions > 1) return shaped;
|
|
209
|
+
const { header: _header, ...rest } = shaped;
|
|
210
|
+
return rest;
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { messages } from "../i18n/index.ts";
|
|
2
|
+
import type { OwnerIdentity } from "../identity.ts";
|
|
3
|
+
import type { OwnerAnswer, Prompts } from "../interactions/prompts.ts";
|
|
4
|
+
import {
|
|
5
|
+
type Questionnaire,
|
|
6
|
+
SINGLE_QUESTION,
|
|
7
|
+
shapeFor,
|
|
8
|
+
validateQuestionnaire,
|
|
9
|
+
} from "../interactions/questionnaire.ts";
|
|
10
|
+
import {
|
|
11
|
+
answerText,
|
|
12
|
+
lateAnswerText,
|
|
13
|
+
lateQuestionnaireText,
|
|
14
|
+
questionnaireText,
|
|
15
|
+
sequentialText,
|
|
16
|
+
} from "./extensions/ask-user-text.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Asks a questionnaire on `prompts` and returns the tool's result text. A set the core refuses
|
|
20
|
+
* posts no card. One question without previews, and a surface without `questionnaire` or with
|
|
21
|
+
* room for fewer questions, go through `ask`: one question exactly as before 0.20, several one
|
|
22
|
+
* at a time, stopping at the first that does not come back answered.
|
|
23
|
+
*/
|
|
24
|
+
export async function askQuestions(
|
|
25
|
+
prompts: Prompts,
|
|
26
|
+
asker: string,
|
|
27
|
+
questionnaire: Questionnaire,
|
|
28
|
+
owner: OwnerIdentity,
|
|
29
|
+
signal: AbortSignal | undefined,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const refused = validateQuestionnaire(questionnaire);
|
|
32
|
+
if (refused !== undefined) return refused;
|
|
33
|
+
const title = messages().askTitle(asker);
|
|
34
|
+
const n = questionnaire.questions.length;
|
|
35
|
+
const support = prompts.asks;
|
|
36
|
+
const previews = questionnaire.questions.some((q) =>
|
|
37
|
+
q.options.some((o) => o.preview !== undefined),
|
|
38
|
+
);
|
|
39
|
+
if (
|
|
40
|
+
prompts.questionnaire &&
|
|
41
|
+
support &&
|
|
42
|
+
support.questions >= n &&
|
|
43
|
+
(n > 1 || (previews && support.previews))
|
|
44
|
+
) {
|
|
45
|
+
const answer = await prompts.questionnaire(
|
|
46
|
+
title,
|
|
47
|
+
shapeFor(questionnaire, support),
|
|
48
|
+
signal,
|
|
49
|
+
{
|
|
50
|
+
late: (late) =>
|
|
51
|
+
late.kind === "questionnaire"
|
|
52
|
+
? n === 1
|
|
53
|
+
? lateAnswerText(
|
|
54
|
+
questionnaire.questions[0]?.question ?? "",
|
|
55
|
+
late.answer.answers[0] ?? { choices: [] },
|
|
56
|
+
late.by,
|
|
57
|
+
)
|
|
58
|
+
: lateQuestionnaireText(questionnaire, late.answer, late.by)
|
|
59
|
+
: undefined,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
if (n === 1)
|
|
63
|
+
return answerText(
|
|
64
|
+
answer === "pending" || answer === undefined
|
|
65
|
+
? answer
|
|
66
|
+
: (answer.answers[0] ?? { choices: [] }),
|
|
67
|
+
owner,
|
|
68
|
+
);
|
|
69
|
+
return questionnaireText(answer, questionnaire, owner);
|
|
70
|
+
}
|
|
71
|
+
const shaped = shapeFor(questionnaire, SINGLE_QUESTION);
|
|
72
|
+
const answers: OwnerAnswer[] = [];
|
|
73
|
+
for (const [i, question] of shaped.questions.entries()) {
|
|
74
|
+
const text = questionnaire.questions[i]?.question ?? question.question;
|
|
75
|
+
const answer = await prompts.ask(title, question, signal, {
|
|
76
|
+
late: (late) =>
|
|
77
|
+
late.kind === "question"
|
|
78
|
+
? lateAnswerText(text, late.answer, late.by)
|
|
79
|
+
: undefined,
|
|
80
|
+
});
|
|
81
|
+
if (n === 1) return answerText(answer, owner);
|
|
82
|
+
if (answer === "pending" || answer === undefined)
|
|
83
|
+
return sequentialText(
|
|
84
|
+
questionnaire,
|
|
85
|
+
answers,
|
|
86
|
+
answer === "pending" ? "pending" : "unanswered",
|
|
87
|
+
owner,
|
|
88
|
+
answerText(answer, owner),
|
|
89
|
+
);
|
|
90
|
+
answers.push(answer);
|
|
91
|
+
}
|
|
92
|
+
return sequentialText(questionnaire, answers, undefined, owner, "");
|
|
93
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Parses a value a model double-encoded as a JSON string; anything else, or a bad string, comes back as it was. */
|
|
2
|
+
// pi-lens-ignore: no-unknown-returns — this reads untyped model JSON; the tool schema is where it is parsed
|
|
3
|
+
function decoded(value: unknown): unknown {
|
|
4
|
+
if (typeof value !== "string") return value;
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(value) as unknown;
|
|
7
|
+
} catch {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The arguments of an `ask_user` call in the `questions` form: a legacy call's top-level
|
|
14
|
+
* `{ question, options, multi, allow_other }` becomes one question (its `allow_other` stays false
|
|
15
|
+
* unless set, as before 0.20), and a `questions` or `options` value that arrived as a JSON string
|
|
16
|
+
* is decoded. Anything else is left for the schema to refuse.
|
|
17
|
+
*/
|
|
18
|
+
// pi-lens-ignore: no-unknown-returns — pi checks the folded arguments against the tool schema right after
|
|
19
|
+
export function foldAskArguments(args: unknown): unknown {
|
|
20
|
+
if (typeof args !== "object" || args === null || Array.isArray(args))
|
|
21
|
+
return args;
|
|
22
|
+
const input = args as Record<string, unknown>;
|
|
23
|
+
if (input.questions !== undefined && input.question !== undefined)
|
|
24
|
+
throw new Error(
|
|
25
|
+
"Use either `questions` or the old top-level `question`, not both.",
|
|
26
|
+
);
|
|
27
|
+
if (input.questions === undefined && typeof input.question === "string") {
|
|
28
|
+
const { question, options, multi, allow_other, ...rest } = input;
|
|
29
|
+
return {
|
|
30
|
+
...rest,
|
|
31
|
+
questions: [
|
|
32
|
+
{
|
|
33
|
+
question,
|
|
34
|
+
...(options !== undefined ? { options: decoded(options) } : {}),
|
|
35
|
+
...(multi !== undefined ? { multi } : {}),
|
|
36
|
+
allow_other: allow_other ?? false,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const questions = decoded(input.questions);
|
|
42
|
+
if (!Array.isArray(questions)) return { ...input, questions };
|
|
43
|
+
return {
|
|
44
|
+
...input,
|
|
45
|
+
questions: questions.map((item: unknown) => {
|
|
46
|
+
const q = decoded(item);
|
|
47
|
+
return typeof q === "object" && q !== null && "options" in q
|
|
48
|
+
? { ...q, options: decoded((q as { options: unknown }).options) }
|
|
49
|
+
: q;
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type OwnerIdentity, ownerWords, PRONOUNS } from "../../identity.ts";
|
|
2
|
+
import type {
|
|
3
|
+
OwnerAnswer,
|
|
4
|
+
OwnerQuestion,
|
|
5
|
+
Questionnaire,
|
|
6
|
+
QuestionnaireAnswer,
|
|
7
|
+
} from "../../interactions/prompts.ts";
|
|
8
|
+
|
|
9
|
+
/** The longest a question's name is when it has no header, in characters. */
|
|
10
|
+
const NAME_CHARS = 120;
|
|
11
|
+
|
|
12
|
+
/** The tool's result: what the owner chose and wrote. */
|
|
13
|
+
export function answerText(
|
|
14
|
+
answer: OwnerAnswer | "pending" | undefined,
|
|
15
|
+
owner: OwnerIdentity,
|
|
16
|
+
): string {
|
|
17
|
+
const o = ownerWords(owner);
|
|
18
|
+
if (answer === "pending")
|
|
19
|
+
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
|
+
if (!answer)
|
|
21
|
+
return `No answer: ${o.name} did not answer. Go on without it if you safely can, otherwise ask in your reply.`;
|
|
22
|
+
const lines = [
|
|
23
|
+
...(answer.choices.length > 0
|
|
24
|
+
? [`${o.name} chose: ${answer.choices.join("; ")}`]
|
|
25
|
+
: []),
|
|
26
|
+
...(answer.text !== undefined ? [`${o.name} wrote: ${answer.text}`] : []),
|
|
27
|
+
];
|
|
28
|
+
return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The turn a late answer starts: the question it answers and what was chosen and written. */
|
|
32
|
+
export function lateAnswerText(
|
|
33
|
+
question: string,
|
|
34
|
+
answer: OwnerAnswer,
|
|
35
|
+
by: string,
|
|
36
|
+
): string {
|
|
37
|
+
return `(${by} answered your earlier ask_user question on its card, after your turn had ended. Continue from where you stopped, with this answer.)\nQuestion: ${question}\n${answerText(answer, { name: by, pronouns: PRONOUNS.they })}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How a question is named in a result: its header, else its text cut short. */
|
|
41
|
+
function nameOf(question: OwnerQuestion): string {
|
|
42
|
+
if (question.header) return question.header;
|
|
43
|
+
const chars = Array.from(question.question.replace(/\s+/g, " ").trim());
|
|
44
|
+
return chars.length <= NAME_CHARS
|
|
45
|
+
? chars.join("")
|
|
46
|
+
: `${chars.slice(0, NAME_CHARS - 1).join("")}…`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lineOf(answer: OwnerAnswer | undefined) {
|
|
50
|
+
const parts = [
|
|
51
|
+
...(answer && answer.choices.length > 0
|
|
52
|
+
? [`chose ${answer.choices.join("; ")}`]
|
|
53
|
+
: []),
|
|
54
|
+
...(answer?.text !== undefined ? [`Wrote: ${answer.text}`] : []),
|
|
55
|
+
];
|
|
56
|
+
return parts.length > 0 ? parts.join(". ") : "not answered";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One numbered line per question, then the note. */
|
|
60
|
+
function listOf(
|
|
61
|
+
questionnaire: Questionnaire,
|
|
62
|
+
answer: QuestionnaireAnswer,
|
|
63
|
+
): string[] {
|
|
64
|
+
return [
|
|
65
|
+
...questionnaire.questions.map(
|
|
66
|
+
(question, i) =>
|
|
67
|
+
`${i + 1}. ${nameOf(question)}: ${lineOf(answer.answers[i])}`,
|
|
68
|
+
),
|
|
69
|
+
...(answer.note !== undefined ? [`Note: ${answer.note}`] : []),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The tool's result for a questionnaire: each question's answer, and the note. */
|
|
74
|
+
export function questionnaireText(
|
|
75
|
+
answer: QuestionnaireAnswer | "pending" | undefined,
|
|
76
|
+
questionnaire: Questionnaire,
|
|
77
|
+
owner: OwnerIdentity,
|
|
78
|
+
): string {
|
|
79
|
+
if (answer === "pending" || !answer) return answerText(answer, owner);
|
|
80
|
+
return [
|
|
81
|
+
`${owner.name} answered your questions on the card:`,
|
|
82
|
+
...listOf(questionnaire, answer),
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The turn a late questionnaire answer starts: the same list, under a line saying when it came. */
|
|
87
|
+
export function lateQuestionnaireText(
|
|
88
|
+
questionnaire: Questionnaire,
|
|
89
|
+
answer: QuestionnaireAnswer,
|
|
90
|
+
by: string,
|
|
91
|
+
): string {
|
|
92
|
+
return [
|
|
93
|
+
`(${by} answered your earlier ask_user questions on their card, after your turn had ended. Continue from where you stopped, with these answers.)`,
|
|
94
|
+
...listOf(questionnaire, answer),
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The result of questions asked one at a time on a surface that takes one: what was answered
|
|
100
|
+
* before the first that was not (`stopped` says how it ended), and the questions not asked.
|
|
101
|
+
*/
|
|
102
|
+
export function sequentialText(
|
|
103
|
+
asked: Questionnaire,
|
|
104
|
+
answers: readonly OwnerAnswer[],
|
|
105
|
+
stopped: "pending" | "unanswered" | undefined,
|
|
106
|
+
owner: OwnerIdentity,
|
|
107
|
+
stoppedText: string,
|
|
108
|
+
): string {
|
|
109
|
+
const notAsked = asked.questions.slice(
|
|
110
|
+
answers.length + (stopped === undefined ? 0 : 1),
|
|
111
|
+
);
|
|
112
|
+
return [
|
|
113
|
+
...(answers.length > 0
|
|
114
|
+
? [
|
|
115
|
+
`${owner.name} answered your questions on the card:`,
|
|
116
|
+
...asked.questions
|
|
117
|
+
.slice(0, answers.length)
|
|
118
|
+
.map(
|
|
119
|
+
(question, i) =>
|
|
120
|
+
`${i + 1}. ${nameOf(question)}: ${lineOf(answers[i])}`,
|
|
121
|
+
),
|
|
122
|
+
]
|
|
123
|
+
: []),
|
|
124
|
+
...(stopped === undefined
|
|
125
|
+
? []
|
|
126
|
+
: [
|
|
127
|
+
`${
|
|
128
|
+
asked.questions.length > 1
|
|
129
|
+
? `${answers.length + 1}. ${nameOf(asked.questions[answers.length] as OwnerQuestion)}: `
|
|
130
|
+
: ""
|
|
131
|
+
}${stoppedText}`,
|
|
132
|
+
]),
|
|
133
|
+
...(notAsked.length > 0
|
|
134
|
+
? [
|
|
135
|
+
`Not asked: ${notAsked
|
|
136
|
+
.map(
|
|
137
|
+
(question, i) =>
|
|
138
|
+
`${answers.length + (stopped === undefined ? 0 : 1) + i + 1}. ${nameOf(question)}`,
|
|
139
|
+
)
|
|
140
|
+
.join(
|
|
141
|
+
"; ",
|
|
142
|
+
)}; this channel takes one question at a time, so ask again if you still need them.`,
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|