pi-roundtable 0.19.1 → 0.20.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.
@@ -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
+ }
@@ -1,40 +1,16 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
- import { Type } from "typebox";
3
- import { messages } from "../../i18n/index.ts";
4
- import { type OwnerIdentity, ownerWords, PRONOUNS } from "../../identity.ts";
5
- import type { OwnerAnswer, OwnerQuestion } from "../../interactions/prompts.ts";
2
+ import { type Static, Type } from "typebox";
3
+ import { type OwnerIdentity, ownerWords } from "../../identity.ts";
4
+ import type { OwnerQuestion } from "../../interactions/prompts.ts";
5
+ import { QUESTIONNAIRE_LIMITS as L } from "../../interactions/questionnaire.ts";
6
6
  import { toolText } from "../../shared/tool-result.ts";
7
+ import { askQuestions } from "../ask-questions.ts";
7
8
  import type { PromptSlot } from "../prompt-slot.ts";
9
+ import { foldAskArguments } from "./ask-user-arguments.ts";
8
10
 
9
- export const ASK_USER_TOOL = "ask_user";
10
-
11
- /** The tool's result: what the owner chose and wrote. */
12
- export function answerText(
13
- answer: OwnerAnswer | "pending" | undefined,
14
- owner: OwnerIdentity,
15
- ): string {
16
- const o = ownerWords(owner);
17
- if (answer === "pending")
18
- 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.`;
19
- if (!answer)
20
- return `No answer: ${o.name} did not answer. Go on without it if you safely can, otherwise ask in your reply.`;
21
- const lines = [
22
- ...(answer.choices.length > 0
23
- ? [`${o.name} chose: ${answer.choices.join("; ")}`]
24
- : []),
25
- ...(answer.text !== undefined ? [`${o.name} wrote: ${answer.text}`] : []),
26
- ];
27
- return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
28
- }
11
+ export { answerText, lateAnswerText } from "./ask-user-text.ts";
29
12
 
30
- /** The turn a late answer starts: the question it answers and what was chosen and written. */
31
- export function lateAnswerText(
32
- question: string,
33
- answer: OwnerAnswer,
34
- by: string,
35
- ): string {
36
- 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 })}`;
37
- }
13
+ export const ASK_USER_TOOL = "ask_user";
38
14
 
39
15
  /** Registers ask_user, which asks the owner on a card in the turn's channel and waits. */
40
16
  export function askUserExtension(
@@ -45,36 +21,68 @@ export function askUserExtension(
45
21
  ): ExtensionFactory {
46
22
  const o = ownerWords(owner);
47
23
  return (pi) => {
24
+ const parameters = Type.Object(
25
+ {
26
+ questions: Type.Array(
27
+ Type.Object(
28
+ {
29
+ question: Type.String({
30
+ minLength: 1,
31
+ maxLength: L.questionChars,
32
+ }),
33
+ header: Type.Optional(
34
+ Type.String({
35
+ maxLength: L.headerChars,
36
+ description: "A short name for the question in a set.",
37
+ }),
38
+ ),
39
+ options: Type.Optional(
40
+ Type.Array(
41
+ Type.Object(
42
+ {
43
+ label: Type.String({
44
+ minLength: 1,
45
+ maxLength: L.labelChars,
46
+ }),
47
+ description: Type.Optional(
48
+ Type.String({ maxLength: L.descriptionChars }),
49
+ ),
50
+ preview: Type.Optional(
51
+ Type.String({
52
+ maxLength: L.previewChars,
53
+ description:
54
+ "A markdown mock-up or snippet shown with the option.",
55
+ }),
56
+ ),
57
+ },
58
+ { additionalProperties: false },
59
+ ),
60
+ { maxItems: L.options },
61
+ ),
62
+ ),
63
+ multi: Type.Optional(
64
+ Type.Boolean({ description: "Several options may be chosen." }),
65
+ ),
66
+ allow_other: Type.Optional(
67
+ Type.Boolean({
68
+ description: `${o.He} may write an answer besides the options (default true; false for a closed choice).`,
69
+ }),
70
+ ),
71
+ },
72
+ { additionalProperties: false },
73
+ ),
74
+ { minItems: 1, maxItems: L.questions },
75
+ ),
76
+ },
77
+ { additionalProperties: false },
78
+ );
48
79
  pi.registerTool({
49
80
  name: ASK_USER_TOOL,
50
81
  label: `Ask ${o.name}`,
51
- description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. If ${o.he} does not answer soon, the result says so and the card stays open: then end your turn saying you are waiting, and ${o.his} answer comes in a new turn.`,
52
- parameters: Type.Object(
53
- {
54
- question: Type.String({ minLength: 1, maxLength: 1500 }),
55
- options: Type.Optional(
56
- Type.Array(
57
- Type.Object(
58
- {
59
- label: Type.String({ minLength: 1, maxLength: 100 }),
60
- description: Type.Optional(Type.String({ maxLength: 100 })),
61
- },
62
- { additionalProperties: false },
63
- ),
64
- { maxItems: 25 },
65
- ),
66
- ),
67
- multi: Type.Optional(
68
- Type.Boolean({ description: "Several options may be chosen." }),
69
- ),
70
- allow_other: Type.Optional(
71
- Type.Boolean({
72
- description: `${o.He} may write an answer besides the options.`,
73
- }),
74
- ),
75
- },
76
- { additionalProperties: false },
77
- ),
82
+ description: `Ask ${o.name} a question, or up to four related ones, on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Put every question you have into one call as questions. Offer options when the choices are known, each with a short description, and a preview (markdown) when seeing the option helps; set multi when several may apply, and allow_other to false for a closed choice (otherwise ${o.he} may write ${o.his} own answer). Give each question a short header when you ask several. If ${o.he} does not answer soon, the result says so and the card stays open: then end your turn saying you are waiting, and ${o.his} answer comes in a new turn.`,
83
+ parameters,
84
+ prepareArguments: (args) =>
85
+ foldAskArguments(args) as Static<typeof parameters>,
78
86
  execute: async (_toolCallId, params, signal) => {
79
87
  const prompts = slot.prompts;
80
88
  if (!prompts)
@@ -82,31 +90,29 @@ export function askUserExtension(
82
90
  `${o.name} cannot answer buttons in this turn, such as a schedule's. Ask ${o.him} in your reply instead.`,
83
91
  );
84
92
  const p = params as {
85
- question: string;
86
- options?: { label: string; description?: string }[];
87
- multi?: boolean;
88
- allow_other?: boolean;
89
- };
90
- const question: OwnerQuestion = {
91
- question: p.question,
92
- options: p.options ?? [],
93
- multi: p.multi === true,
94
- allowOther: p.allow_other === true,
93
+ questions: {
94
+ question: string;
95
+ header?: string;
96
+ options?: OwnerQuestion["options"];
97
+ multi?: boolean;
98
+ allow_other?: boolean;
99
+ }[];
95
100
  };
96
101
  return toolText(
97
- answerText(
98
- await prompts.ask(
99
- messages().askTitle(slot.asker),
100
- question,
101
- signal,
102
- {
103
- late: (late) =>
104
- late.kind === "question"
105
- ? lateAnswerText(p.question, late.answer, late.by)
106
- : undefined,
107
- },
108
- ),
102
+ await askQuestions(
103
+ prompts,
104
+ slot.asker,
105
+ {
106
+ questions: p.questions.map((q) => ({
107
+ question: q.question,
108
+ ...(q.header !== undefined ? { header: q.header } : {}),
109
+ options: q.options ?? [],
110
+ multi: q.multi === true,
111
+ allowOther: q.allow_other !== false,
112
+ })),
113
+ },
109
114
  addressee?.() ?? owner,
115
+ signal,
110
116
  ),
111
117
  );
112
118
  },
@@ -1,6 +1,11 @@
1
1
  import type { HoldRefusal } from "../domain/progress.ts";
2
2
  import { assistantName } from "../i18n/index.ts";
3
- import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
3
+ import type {
4
+ OwnerQuestion,
5
+ Prompts,
6
+ PromptWait,
7
+ Questionnaire,
8
+ } from "../interactions/prompts.ts";
4
9
  import { turnBudget } from "./turn-budget.ts";
5
10
 
6
11
  /**
@@ -61,7 +66,22 @@ export class PromptSlot {
61
66
  get prompts(): Prompts | undefined {
62
67
  const prompts = this.#prompts;
63
68
  if (!prompts) return undefined;
69
+ const { questionnaire } = prompts;
64
70
  return {
71
+ ...(prompts.asks && questionnaire
72
+ ? {
73
+ asks: prompts.asks,
74
+ questionnaire: (
75
+ title: string,
76
+ asked: Questionnaire,
77
+ signal?: AbortSignal,
78
+ wait?: PromptWait,
79
+ ) =>
80
+ this.#waiting(() =>
81
+ questionnaire.call(prompts, title, asked, signal, wait),
82
+ ),
83
+ }
84
+ : {}),
65
85
  confirm: (title, message, signal, minTier, wait, details, options) =>
66
86
  this.#waiting(() =>
67
87
  prompts.confirm(