@fitchmultz/pi-ask-question 0.6.5 → 0.6.7
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/extensions/ask-question.ts +7 -1340
- package/extensions/lib/ask-dialogs.ts +155 -0
- package/extensions/lib/ask-keyboard.ts +55 -0
- package/extensions/lib/form-answers.ts +86 -0
- package/extensions/lib/form-page.ts +411 -0
- package/extensions/lib/form-scrolls.ts +250 -0
- package/extensions/lib/grill-me.ts +152 -0
- package/extensions/lib/normalize.ts +126 -0
- package/extensions/lib/prompt-channel.ts +205 -0
- package/extensions/lib/question-form.ts +545 -0
- package/extensions/lib/question-observer.ts +177 -101
- package/extensions/lib/remote-answers.ts +73 -0
- package/extensions/lib/schema.ts +49 -0
- package/extensions/lib/text.ts +32 -0
- package/extensions/lib/tool.ts +150 -0
- package/extensions/lib/types.ts +56 -0
- package/extensions/lib/validate.ts +16 -0
- package/package.json +2 -2
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/** Scroll state and paging math for the question form. */
|
|
2
|
+
|
|
3
|
+
import { sliceByColumn } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import type { FormView, PageBuilder } from "./form-page.ts";
|
|
6
|
+
|
|
7
|
+
/** Question-section clipping outcome: how many lines exist and fit. */
|
|
8
|
+
type QuestionWindow = {
|
|
9
|
+
lineCount: number;
|
|
10
|
+
rows: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Scroll positions and window sizes for question, choice, and review sections. */
|
|
14
|
+
export class FormScrolls {
|
|
15
|
+
optionScroll = 0;
|
|
16
|
+
labelScroll = 0;
|
|
17
|
+
pageRows = 0;
|
|
18
|
+
maxLabelScroll = 0;
|
|
19
|
+
questionScroll = 0;
|
|
20
|
+
questionPageRows = 0;
|
|
21
|
+
maxQuestionScroll = 0;
|
|
22
|
+
reviewScroll: number | undefined;
|
|
23
|
+
reviewPageRows = 0;
|
|
24
|
+
maxReviewScroll = 0;
|
|
25
|
+
|
|
26
|
+
resetForWidth(): void {
|
|
27
|
+
this.optionScroll = 0;
|
|
28
|
+
this.labelScroll = 0;
|
|
29
|
+
this.questionScroll = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pageQuestionUp(): void {
|
|
33
|
+
this.questionScroll = Math.max(0, this.questionScroll - Math.max(1, this.questionPageRows - 1));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pageQuestionDown(): void {
|
|
37
|
+
this.questionScroll = Math.min(
|
|
38
|
+
this.maxQuestionScroll,
|
|
39
|
+
this.questionScroll + Math.max(1, this.questionPageRows - 1),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pageChoiceUp(): void {
|
|
44
|
+
if (this.labelScroll > 0) {
|
|
45
|
+
this.labelScroll = Math.max(0, this.labelScroll - Math.max(1, this.pageRows - 1));
|
|
46
|
+
} else {
|
|
47
|
+
this.pageQuestionUp();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pageChoiceDown(): void {
|
|
52
|
+
if (this.questionScroll < this.maxQuestionScroll) {
|
|
53
|
+
this.pageQuestionDown();
|
|
54
|
+
} else {
|
|
55
|
+
this.labelScroll = Math.min(
|
|
56
|
+
this.maxLabelScroll,
|
|
57
|
+
this.labelScroll + Math.max(1, this.pageRows - 1),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pageReviewUp(): void {
|
|
63
|
+
this.reviewScroll = Math.max(
|
|
64
|
+
0,
|
|
65
|
+
(this.reviewScroll ?? this.maxReviewScroll) - Math.max(1, this.reviewPageRows - 1),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pageReviewDown(): void {
|
|
70
|
+
this.reviewScroll = Math.min(
|
|
71
|
+
this.maxReviewScroll,
|
|
72
|
+
(this.reviewScroll ?? this.maxReviewScroll) + Math.max(1, this.reviewPageRows - 1),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Window the question and choice sections to the terminal height. */
|
|
77
|
+
applyQuestionScrolling(page: PageBuilder, view: FormView, height: number): void {
|
|
78
|
+
const questionLines = page.lines.slice(page.questionStart, page.questionEnd);
|
|
79
|
+
const question = this.clipQuestionLines(page, questionLines, view, height);
|
|
80
|
+
this.applyOptionScrolling(page, view, height, question);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Row ranges exist for every rendered choice; indexing is total by construction. */
|
|
84
|
+
private optionRow(page: PageBuilder, index: number): { start: number; end: number } {
|
|
85
|
+
const range = page.optionRows[index];
|
|
86
|
+
if (range !== undefined) {
|
|
87
|
+
return range;
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`ask-question: option row ${index} is missing`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private clipQuestionLines(
|
|
93
|
+
page: PageBuilder,
|
|
94
|
+
questionLines: string[],
|
|
95
|
+
view: FormView,
|
|
96
|
+
height: number,
|
|
97
|
+
): QuestionWindow {
|
|
98
|
+
const selectedRow = view.editing ? 0 : view.option;
|
|
99
|
+
const selectedHeight =
|
|
100
|
+
this.optionRow(page, selectedRow).end - this.optionRow(page, selectedRow).start;
|
|
101
|
+
const room = Math.max(
|
|
102
|
+
2,
|
|
103
|
+
height - (page.lines.length - questionLines.length - (page.optionEnd - page.optionStart)),
|
|
104
|
+
);
|
|
105
|
+
const questionRows = Math.max(1, room - selectedHeight);
|
|
106
|
+
this.maxQuestionScroll = Math.max(0, questionLines.length - questionRows);
|
|
107
|
+
this.questionScroll = Math.min(this.questionScroll, this.maxQuestionScroll);
|
|
108
|
+
this.questionPageRows = questionRows;
|
|
109
|
+
if (this.maxQuestionScroll > 0) {
|
|
110
|
+
page.lines.splice(
|
|
111
|
+
page.questionStart,
|
|
112
|
+
questionLines.length,
|
|
113
|
+
...questionLines.slice(this.questionScroll, this.questionScroll + questionRows),
|
|
114
|
+
);
|
|
115
|
+
page.shiftOptions(questionLines.length - questionRows);
|
|
116
|
+
}
|
|
117
|
+
return { lineCount: questionLines.length, rows: questionRows };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private applyOptionScrolling(
|
|
121
|
+
page: PageBuilder,
|
|
122
|
+
view: FormView,
|
|
123
|
+
height: number,
|
|
124
|
+
question: QuestionWindow,
|
|
125
|
+
): void {
|
|
126
|
+
const optionLines = page.lines.slice(page.optionStart, page.optionEnd);
|
|
127
|
+
const available = Math.max(1, height);
|
|
128
|
+
const visibleOptions = Math.max(1, available - (page.lines.length - optionLines.length));
|
|
129
|
+
let optionStatus = "";
|
|
130
|
+
if (optionLines.length > visibleOptions) {
|
|
131
|
+
optionStatus = this.scrollOptionsIntoView(page, view, optionLines, visibleOptions);
|
|
132
|
+
} else {
|
|
133
|
+
this.optionScroll = 0;
|
|
134
|
+
this.labelScroll = 0;
|
|
135
|
+
this.maxLabelScroll = 0;
|
|
136
|
+
}
|
|
137
|
+
if (this.maxQuestionScroll > 0 || optionStatus !== "") {
|
|
138
|
+
this.addScrollStatus(page, view, optionStatus, question);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private scrollOptionsIntoView(
|
|
143
|
+
page: PageBuilder,
|
|
144
|
+
view: FormView,
|
|
145
|
+
optionLines: string[],
|
|
146
|
+
visibleOptions: number,
|
|
147
|
+
): string {
|
|
148
|
+
const selectedRow = view.editing ? 0 : view.option;
|
|
149
|
+
// Scroll at choice boundaries so labels that fit are shown in full.
|
|
150
|
+
this.optionScroll = Math.min(this.optionScroll, selectedRow);
|
|
151
|
+
const end = this.findChoiceWindow(page, selectedRow, visibleOptions);
|
|
152
|
+
while (
|
|
153
|
+
this.optionScroll > 0 &&
|
|
154
|
+
this.optionRow(page, end - 1).end - this.optionRow(page, this.optionScroll - 1).start <=
|
|
155
|
+
visibleOptions
|
|
156
|
+
) {
|
|
157
|
+
this.optionScroll -= 1;
|
|
158
|
+
}
|
|
159
|
+
this.maxLabelScroll = Math.max(
|
|
160
|
+
0,
|
|
161
|
+
this.optionRow(page, selectedRow).end -
|
|
162
|
+
this.optionRow(page, selectedRow).start -
|
|
163
|
+
visibleOptions,
|
|
164
|
+
);
|
|
165
|
+
this.labelScroll = Math.min(this.labelScroll, this.maxLabelScroll);
|
|
166
|
+
this.pageRows = visibleOptions;
|
|
167
|
+
const startLine = this.optionRow(page, this.optionScroll).start + this.labelScroll;
|
|
168
|
+
const endLine = Math.min(this.optionRow(page, end - 1).end, startLine + visibleOptions);
|
|
169
|
+
const above = this.optionScroll > 0 || this.labelScroll > 0 ? "↑ " : "";
|
|
170
|
+
const below = endLine < optionLines.length ? " ↓" : "";
|
|
171
|
+
page.lines.splice(
|
|
172
|
+
page.optionStart,
|
|
173
|
+
optionLines.length,
|
|
174
|
+
...optionLines.slice(startLine, endLine),
|
|
175
|
+
);
|
|
176
|
+
return `${above}${view.option + 1}/${view.choices().length}${below}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private findChoiceWindow(page: PageBuilder, selectedRow: number, visibleOptions: number): number {
|
|
180
|
+
while (
|
|
181
|
+
this.optionScroll < selectedRow &&
|
|
182
|
+
this.optionRow(page, selectedRow).end - this.optionRow(page, this.optionScroll).start >
|
|
183
|
+
visibleOptions
|
|
184
|
+
) {
|
|
185
|
+
this.optionScroll += 1;
|
|
186
|
+
}
|
|
187
|
+
let end = selectedRow + 1;
|
|
188
|
+
while (
|
|
189
|
+
end < page.optionRows.length &&
|
|
190
|
+
this.optionRow(page, end).end - this.optionRow(page, this.optionScroll).start <=
|
|
191
|
+
visibleOptions
|
|
192
|
+
) {
|
|
193
|
+
end += 1;
|
|
194
|
+
}
|
|
195
|
+
return end;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private addScrollStatus(
|
|
199
|
+
page: PageBuilder,
|
|
200
|
+
view: FormView,
|
|
201
|
+
optionStatus: string,
|
|
202
|
+
question: QuestionWindow,
|
|
203
|
+
): void {
|
|
204
|
+
const questionStatus =
|
|
205
|
+
this.maxQuestionScroll > 0
|
|
206
|
+
? `Q ${this.questionScroll + 1}-${Math.min(
|
|
207
|
+
this.questionScroll + question.rows,
|
|
208
|
+
question.lineCount,
|
|
209
|
+
)}/${question.lineCount}`
|
|
210
|
+
: "";
|
|
211
|
+
const read =
|
|
212
|
+
!view.editing && (this.maxQuestionScroll > 0 || this.maxLabelScroll > 0)
|
|
213
|
+
? ` • ${view.keyText("tui.select.pageUp")}/${view.keyText("tui.select.pageDown")} read (Shift+Page fullscreen)`
|
|
214
|
+
: "";
|
|
215
|
+
const status =
|
|
216
|
+
view.editing && this.maxQuestionScroll > 0
|
|
217
|
+
? `Shift+PageUp/Down question • ${questionStatus}`
|
|
218
|
+
: `${[questionStatus, optionStatus].filter(Boolean).join(" • ")}${read}`;
|
|
219
|
+
page.setLine(
|
|
220
|
+
page.optionStart - 1,
|
|
221
|
+
sliceByColumn(view.theme.fg("dim", ` ${status}`), 0, page.width, true),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Window the review section to the terminal height, sticking to the end. */
|
|
226
|
+
applyReviewScrolling(page: PageBuilder, view: FormView, height: number): void {
|
|
227
|
+
const answerLines = page.lines.slice(page.reviewStart, page.reviewEnd);
|
|
228
|
+
const visibleAnswers = Math.max(1, height - (page.lines.length - answerLines.length));
|
|
229
|
+
const wasAtEnd = this.reviewScroll === undefined || this.reviewScroll === this.maxReviewScroll;
|
|
230
|
+
this.maxReviewScroll = Math.max(0, answerLines.length - visibleAnswers);
|
|
231
|
+
this.reviewScroll = wasAtEnd
|
|
232
|
+
? this.maxReviewScroll
|
|
233
|
+
: Math.min(this.reviewScroll ?? 0, this.maxReviewScroll);
|
|
234
|
+
this.reviewPageRows = visibleAnswers;
|
|
235
|
+
if (this.maxReviewScroll > 0) {
|
|
236
|
+
const above = this.reviewScroll > 0 ? "↑ " : "";
|
|
237
|
+
const below = this.reviewScroll < this.maxReviewScroll ? " ↓" : "";
|
|
238
|
+
const read = `${view.keyText("tui.select.pageUp")}/${view.keyText("tui.select.pageDown")} read answers (Shift+Page fullscreen)`;
|
|
239
|
+
page.setLine(
|
|
240
|
+
page.reviewStart - 1,
|
|
241
|
+
sliceByColumn(view.theme.fg("dim", ` ${above}${read}${below}`), 0, page.width, true),
|
|
242
|
+
);
|
|
243
|
+
page.lines.splice(
|
|
244
|
+
page.reviewStart,
|
|
245
|
+
answerLines.length,
|
|
246
|
+
...answerLines.slice(this.reviewScroll, this.reviewScroll + visibleAnswers),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** /grill-me mode: command, persisted state, and agent guidance. */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
BeforeAgentStartEventResult,
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
9
|
+
import { isRecord } from "./validate.ts";
|
|
10
|
+
|
|
11
|
+
type GrillMeState = {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type GrillMeAction = "toggle" | "enable" | "disable" | "status" | "invalid";
|
|
16
|
+
|
|
17
|
+
const GRILL_ME_STATE_TYPE = "ask-question.grill-me";
|
|
18
|
+
const GRILL_ME_STATUS_KEY = "ask-question.grill-me";
|
|
19
|
+
const GRILL_ME_ARGUMENTS = ["on", "off", "status"];
|
|
20
|
+
|
|
21
|
+
function grillMePrompt(useTool: boolean): string {
|
|
22
|
+
return `IMPORTANT: /grill-me mode is active.
|
|
23
|
+
|
|
24
|
+
Pressure-test the user's request before doing meaningful work:
|
|
25
|
+
- Walk the decision tree: resolve upstream choices before downstream details.
|
|
26
|
+
- If a question can be answered by reading local files, docs, tests, or command output, inspect those sources instead of asking the user.
|
|
27
|
+
- For non-trivial, risky, underspecified, or strategic requests, ${useTool ? "call ask_question first" : "ask clarifying questions first in normal text"}.
|
|
28
|
+
- Ask exactly one blocking question at a time, then wait for the answer before asking the next one.
|
|
29
|
+
- For every question, include your recommended answer as the first option and make it specific.
|
|
30
|
+
- Do not ask when the next step is obvious, low-risk, or already constrained; proceed normally.
|
|
31
|
+
- After the user answers, continue the work directly.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseGrillMeAction(args: string): GrillMeAction {
|
|
35
|
+
switch (args.trim().toLowerCase()) {
|
|
36
|
+
case "":
|
|
37
|
+
return "toggle";
|
|
38
|
+
case "on":
|
|
39
|
+
return "enable";
|
|
40
|
+
case "off":
|
|
41
|
+
return "disable";
|
|
42
|
+
case "status":
|
|
43
|
+
return "status";
|
|
44
|
+
default:
|
|
45
|
+
return "invalid";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function completeGrillMeArgs(prefix: string): AutocompleteItem[] | null {
|
|
50
|
+
const matches = GRILL_ME_ARGUMENTS.filter((arg) => arg.startsWith(prefix.trim().toLowerCase()));
|
|
51
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isGrillMeState(data: unknown): data is GrillMeState {
|
|
55
|
+
return (
|
|
56
|
+
data !== null &&
|
|
57
|
+
typeof data === "object" &&
|
|
58
|
+
"enabled" in data &&
|
|
59
|
+
typeof data.enabled === "boolean"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function restoreGrillMeState(entries: Iterable<unknown>): boolean {
|
|
64
|
+
let enabled = false;
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (!isRecord(entry)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
entry.type === "custom" &&
|
|
71
|
+
entry.customType === GRILL_ME_STATE_TYPE &&
|
|
72
|
+
isGrillMeState(entry.data)
|
|
73
|
+
) {
|
|
74
|
+
enabled = entry.data.enabled;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return enabled;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Register the /grill-me command and its session-aware guidance hooks. */
|
|
81
|
+
export function registerGrillMe(pi: ExtensionAPI): void {
|
|
82
|
+
let grillMeMode = false;
|
|
83
|
+
|
|
84
|
+
function persistGrillMeMode(): void {
|
|
85
|
+
pi.appendEntry<GrillMeState>(GRILL_ME_STATE_TYPE, { enabled: grillMeMode });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function updateGrillMeStatus(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
|
|
89
|
+
if (ctx.mode !== "tui") {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
ctx.ui.setStatus(
|
|
93
|
+
GRILL_ME_STATUS_KEY,
|
|
94
|
+
grillMeMode ? ctx.ui.theme.fg("dim", "grill-mode") : undefined,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function restoreGrillMeMode(ctx: ExtensionContext): void {
|
|
99
|
+
grillMeMode = restoreGrillMeState(ctx.sessionManager.getBranch());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pi.registerCommand("grill-me", {
|
|
103
|
+
description: "Toggle a mode that makes the agent pressure-test requests with ask_question",
|
|
104
|
+
getArgumentCompletions: completeGrillMeArgs,
|
|
105
|
+
handler: async (args, ctx): Promise<void> => {
|
|
106
|
+
const action = parseGrillMeAction(args);
|
|
107
|
+
if (action === "enable") {
|
|
108
|
+
grillMeMode = true;
|
|
109
|
+
} else if (action === "disable") {
|
|
110
|
+
grillMeMode = false;
|
|
111
|
+
} else if (action === "toggle") {
|
|
112
|
+
grillMeMode = !grillMeMode;
|
|
113
|
+
} else if (action === "invalid") {
|
|
114
|
+
ctx.ui.notify("Usage: /grill-me [on|off|status]", "warning");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (action !== "status") {
|
|
119
|
+
persistGrillMeMode();
|
|
120
|
+
}
|
|
121
|
+
updateGrillMeStatus(ctx);
|
|
122
|
+
ctx.ui.notify(`Grill-me mode ${grillMeMode ? "enabled" : "disabled"}`, "info");
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.on("session_start", async (_event, ctx): Promise<void> => {
|
|
127
|
+
restoreGrillMeMode(ctx);
|
|
128
|
+
updateGrillMeStatus(ctx);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
pi.on("session_tree", async (_event, ctx): Promise<void> => {
|
|
132
|
+
restoreGrillMeMode(ctx);
|
|
133
|
+
updateGrillMeStatus(ctx);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
pi.on(
|
|
137
|
+
"before_agent_start",
|
|
138
|
+
async (event, ctx): Promise<BeforeAgentStartEventResult | undefined> => {
|
|
139
|
+
if (!grillMeMode) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const useTool = ctx.hasUI && pi.getActiveTools().includes("ask_question");
|
|
143
|
+
const guidance = grillMePrompt(useTool);
|
|
144
|
+
if (event.systemPromptOptions.forceSystemPrompt === undefined) {
|
|
145
|
+
// oxlint-disable-next-line eslint/no-param-reassign -- QX-002: Pi documents systemPromptOptions.sections as mutable by handlers; mutation is the supported section-injection contract.
|
|
146
|
+
event.systemPromptOptions.sections.grill_me = guidance;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Question normalization and answer summarizing. */
|
|
2
|
+
|
|
3
|
+
import { TIMEOUT_MESSAGE, type Answer, type NormalizedQuestion, type Question } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Trim a value and drop empty results; undefined passes through. */
|
|
6
|
+
export function clean(value: string | undefined): string | undefined {
|
|
7
|
+
const trimmed = value?.trim();
|
|
8
|
+
if (trimmed === undefined || trimmed.length === 0) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
return trimmed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Add a numeric suffix until the label no longer collides with existing options. */
|
|
15
|
+
export function uniqueOptionLabel(label: string, options: string[]): string {
|
|
16
|
+
let result = label;
|
|
17
|
+
for (let suffix = 2; options.includes(result); suffix += 1) {
|
|
18
|
+
result = `${label} (${suffix})`;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalize schema input into complete, deduplicated questions. */
|
|
24
|
+
export function normalize(params: {
|
|
25
|
+
question?: string;
|
|
26
|
+
options?: string[];
|
|
27
|
+
multiSelect?: boolean;
|
|
28
|
+
questions?: Question[];
|
|
29
|
+
}): NormalizedQuestion[] {
|
|
30
|
+
const questions = params.questions;
|
|
31
|
+
const raw =
|
|
32
|
+
questions !== undefined && questions.length > 0
|
|
33
|
+
? questions
|
|
34
|
+
: [
|
|
35
|
+
{
|
|
36
|
+
id: "question_1",
|
|
37
|
+
question: params.question ?? "",
|
|
38
|
+
options: params.options,
|
|
39
|
+
multiSelect: params.multiSelect,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const assigned = new Set<string>();
|
|
44
|
+
const result: NormalizedQuestion[] = [];
|
|
45
|
+
|
|
46
|
+
for (const [index, question] of raw.entries()) {
|
|
47
|
+
const text = clean(question.question);
|
|
48
|
+
if (text === undefined) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const baseId = clean(question.id) ?? `question_${index + 1}`;
|
|
53
|
+
let id = baseId;
|
|
54
|
+
let suffix = 2;
|
|
55
|
+
while (assigned.has(id)) {
|
|
56
|
+
id = `${baseId}_${suffix}`;
|
|
57
|
+
suffix += 1;
|
|
58
|
+
}
|
|
59
|
+
assigned.add(id);
|
|
60
|
+
|
|
61
|
+
result.push({
|
|
62
|
+
id,
|
|
63
|
+
question: text,
|
|
64
|
+
options: (question.options ?? [])
|
|
65
|
+
.map(clean)
|
|
66
|
+
.filter((option): option is string => option !== undefined),
|
|
67
|
+
multiSelect: question.multiSelect === true,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Answers in question order, skipping unanswered questions. */
|
|
75
|
+
export function orderedAnswers(
|
|
76
|
+
questions: NormalizedQuestion[],
|
|
77
|
+
answers: Map<string, Answer>,
|
|
78
|
+
): Answer[] {
|
|
79
|
+
return questions
|
|
80
|
+
.map((question) => answers.get(question.id))
|
|
81
|
+
.filter((answer): answer is Answer => answer !== undefined);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One-line rendering of an answer for summaries and the review page. */
|
|
85
|
+
export function formatAnswer(answer: Answer | undefined): string {
|
|
86
|
+
if (answer === undefined) {
|
|
87
|
+
return "unanswered";
|
|
88
|
+
}
|
|
89
|
+
return answer.selectedOptions !== undefined
|
|
90
|
+
? JSON.stringify(answer.selectedOptions)
|
|
91
|
+
: answer.answer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Human-readable summary returned as the tool result text. */
|
|
95
|
+
export function summarize(
|
|
96
|
+
questions: NormalizedQuestion[],
|
|
97
|
+
answers: Map<string, Answer>,
|
|
98
|
+
cancelled: boolean,
|
|
99
|
+
timedOut: boolean,
|
|
100
|
+
): string {
|
|
101
|
+
if (timedOut) {
|
|
102
|
+
return [
|
|
103
|
+
`Timed out after 5 minutes. ${TIMEOUT_MESSAGE}`,
|
|
104
|
+
...(answers.size > 0
|
|
105
|
+
? [
|
|
106
|
+
"",
|
|
107
|
+
"Answers already provided:",
|
|
108
|
+
...orderedAnswers(questions, answers).map(
|
|
109
|
+
(answer) => `- ${answer.id}: ${formatAnswer(answer)}`,
|
|
110
|
+
),
|
|
111
|
+
]
|
|
112
|
+
: []),
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
if (cancelled) {
|
|
116
|
+
return "User cancelled the question.";
|
|
117
|
+
}
|
|
118
|
+
const [only] = questions;
|
|
119
|
+
if (questions.length === 1 && only !== undefined) {
|
|
120
|
+
return `User answered: ${formatAnswer(answers.get(only.id))}`;
|
|
121
|
+
}
|
|
122
|
+
return [
|
|
123
|
+
"User answered:",
|
|
124
|
+
...questions.map((question) => `- ${question.id}: ${formatAnswer(answers.get(question.id))}`),
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|