@pify/ask-question 0.1.0 → 0.3.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/README.md CHANGED
@@ -9,7 +9,9 @@ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify instal
9
9
  - **`ask_question`** — the agent batches up to 4 questions, each with up to 4 options (`label` + `description` trade-offs, recommendation marked "(Recommended)" and listed first), optional `multiSelect`, and free-text via "Other…".
10
10
  - **Built entirely on pi's built-in dialogs** (`select`/`input`) — no custom TUI overlay, so it works identically in the terminal and RPC/GUI hosts and can't break with pi UI changes. Multi-select is a checkbox toggle loop with `✓ Done`.
11
11
  - **Discipline encoded in the tool description** (zhushanwen's three conditions): only when 2+ reasonable approaches exist, context is already gathered, and a wrong pick means rework. Never for permissions or things the agent can look up.
12
- - **Declining is an answer**: Esc cleanly reports "the user declined" for the rest of the batch — no error, no re-asking. Headless runs get "proceed with your best judgment and state the assumption" instead of a failure (asking is advisory, unlike the fail-closed safety gates).
12
+ - **Declining is an answer**: Esc cleanly reports "the user declined" for the rest of the batch — no error, no re-asking. Headless runs get the full questionnaire back — every question with its options — plus "proceed with your best judgment and say which option you assumed", so the decision stays in the CI transcript instead of vanishing (asking is advisory, unlike the fail-closed safety gates).
13
+ - **The decisions stay on the record** (v0.3): every questionnaire is appended to the session as its own entry, and `/ask` prints the last one (`/ask all` for the whole history) with what you chose or declined. Forks and `/reload` keep their own history, because the entries live on the branch.
14
+ - **Rows the user can actually pick** (v0.2): two options sharing a label, or one labelled `Other…`, used to render as indistinguishable rows where the second was unselectable. Duplicates are now suffixed, reserved labels renamed, and every pick resolves by its position in the dialog rather than by its text.
13
15
  - Structured results return to the model as both readable text and `details.answers`.
14
16
 
15
17
  ## Why no fancy overlay?
@@ -20,12 +20,17 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
20
20
  import { Type } from "typebox";
21
21
 
22
22
  import {
23
+ ASK_STATE,
23
24
  DONE_LABEL,
24
25
  OTHER_LABEL,
25
26
  formatAnswers,
26
- labelFromDisplay,
27
- optionDisplay,
27
+ headlessText,
28
+ parseAskRoute,
29
+ parseSingleRow,
28
30
  parseToggleRow,
31
+ replayRounds,
32
+ routeText,
33
+ singleRows,
29
34
  toggleRows,
30
35
  validateQuestions,
31
36
  type AskAnswer,
@@ -36,30 +41,29 @@ type UiContext = ExtensionContext;
36
41
 
37
42
  export default function askQuestion(pi: ExtensionAPI) {
38
43
  async function askSingle(ctx: UiContext, q: AskQuestion): Promise<AskAnswer> {
39
- const rows = [...q.options.map(optionDisplay), ...(q.allowOther ? [OTHER_LABEL] : [])];
44
+ const rows = singleRows(q.options, q.allowOther);
40
45
  const picked = await ctx.ui.select(q.question, rows);
41
46
  if (picked === undefined) return { question: q.question, answers: [], declined: true };
42
- if (picked === OTHER_LABEL) {
47
+ const action = parseSingleRow(picked, rows, q.options);
48
+ if (!action) return { question: q.question, answers: [], declined: true };
49
+ if (action.kind === "other") {
43
50
  const text = await ctx.ui.input(q.question, "Type your answer");
44
51
  if (text === undefined || !text.trim()) {
45
52
  return { question: q.question, answers: [], declined: true };
46
53
  }
47
54
  return { question: q.question, answers: [], other: text.trim() };
48
55
  }
49
- const label = labelFromDisplay(picked, q.options);
50
- return { question: q.question, answers: label ? [label] : [] };
56
+ return { question: q.question, answers: [q.options[action.index]!.label] };
51
57
  }
52
58
 
53
59
  async function askMulti(ctx: UiContext, q: AskQuestion): Promise<AskAnswer> {
54
60
  const selected = new Set<number>();
55
61
  let other: string | undefined;
56
62
  for (;;) {
57
- const picked = await ctx.ui.select(
58
- `${q.question}\n(toggle options, then ${DONE_LABEL})`,
59
- toggleRows(q.options, selected, q.allowOther),
60
- );
63
+ const rows = toggleRows(q.options, selected, q.allowOther);
64
+ const picked = await ctx.ui.select(`${q.question}\n(toggle options, then ${DONE_LABEL})`, rows);
61
65
  if (picked === undefined) return { question: q.question, answers: [], declined: true };
62
- const action = parseToggleRow(picked, q.options);
66
+ const action = parseToggleRow(picked, rows, q.options);
63
67
  if (!action) continue;
64
68
  if (action.kind === "done") break;
65
69
  if (action.kind === "other") {
@@ -114,13 +118,8 @@ export default function askQuestion(pi: ExtensionAPI) {
114
118
 
115
119
  if (!uiCtx.hasUI) {
116
120
  return {
117
- content: [
118
- {
119
- type: "text",
120
- text: "No UI is available to ask the user. Proceed with your best judgment and clearly state the assumption you made.",
121
- },
122
- ],
123
- details: { headless: true },
121
+ content: [{ type: "text", text: headlessText(result.questions) }],
122
+ details: { headless: true, questions: result.questions },
124
123
  };
125
124
  }
126
125
 
@@ -137,6 +136,10 @@ export default function askQuestion(pi: ExtensionAPI) {
137
136
  }
138
137
  }
139
138
 
139
+ // Record the round so /ask can show it later, and a fork keeps its own
140
+ // history; appended per round, never overwritten.
141
+ pi.appendEntry(ASK_STATE, { timestamp: Date.now(), answers });
142
+
140
143
  const text = [
141
144
  formatAnswers(answers),
142
145
  ...(result.warnings.length > 0 ? [`Warnings: ${result.warnings.join("; ")}`] : []),
@@ -144,4 +147,13 @@ export default function askQuestion(pi: ExtensionAPI) {
144
147
  return { content: [{ type: "text", text }], details: { answers } };
145
148
  },
146
149
  });
150
+
151
+ pi.registerCommand("ask", {
152
+ description: "Show what the agent asked you and how you answered: /ask [last | all]",
153
+ handler: async (args, ctx) => {
154
+ if (!ctx.hasUI) return;
155
+ const rounds = replayRounds(ctx.sessionManager.getBranch() as never);
156
+ ctx.ui.notify(routeText(parseAskRoute(args ?? ""), rounds), "info");
157
+ },
158
+ });
147
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/ask-question",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Let the model ask instead of guessing: CC AskUserQuestion-shaped tool on built-in dialogs - 1-4 questions, multi-select, Other free-text, works in TUI and RPC",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask.ts CHANGED
@@ -75,9 +75,11 @@ export function validateQuestions(raw: unknown): ValidationResult {
75
75
  warnings.push(`question "${entry.question.slice(0, 30)}" has no options and allowOther=false — dropped`);
76
76
  continue;
77
77
  }
78
+ const normalized = normalizeOptions(options);
79
+ warnings.push(...normalized.warnings);
78
80
  questions.push({
79
81
  question: entry.question.trim(),
80
- options,
82
+ options: normalized.options,
81
83
  multiSelect: entry.multiSelect === true,
82
84
  allowOther,
83
85
  });
@@ -89,6 +91,34 @@ export function validateQuestions(raw: unknown): ValidationResult {
89
91
  return { questions, warnings, error: null };
90
92
  }
91
93
 
94
+ /**
95
+ * Make every option in a question distinguishable in the dialog. Two options
96
+ * sharing a label render as identical rows (the second is then unselectable),
97
+ * and an option labelled like a control row steals that row's meaning — both
98
+ * are the model's doing, so they are repaired rather than rejected.
99
+ */
100
+ export function normalizeOptions(options: AskOption[]): { options: AskOption[]; warnings: string[] } {
101
+ const warnings: string[] = [];
102
+ const seen = new Map<string, number>();
103
+ const result: AskOption[] = [];
104
+ for (const option of options) {
105
+ let label = option.label;
106
+ if (label === OTHER_LABEL || label === DONE_LABEL) {
107
+ label = `${label} (option)`;
108
+ warnings.push(`renamed an option labelled "${option.label}" — that label is reserved`);
109
+ }
110
+ const count = seen.get(label) ?? 0;
111
+ seen.set(label, count + 1);
112
+ if (count > 0) {
113
+ const unique = `${label} (${count + 1})`;
114
+ warnings.push(`renamed a duplicate option label "${label}"`);
115
+ label = unique;
116
+ }
117
+ result.push({ ...option, label });
118
+ }
119
+ return { options: result, warnings };
120
+ }
121
+
92
122
  /** Display string for one option in the select dialog. */
93
123
  export function optionDisplay(option: AskOption): string {
94
124
  const desc = option.description
@@ -97,10 +127,24 @@ export function optionDisplay(option: AskOption): string {
97
127
  return `${option.label}${desc}`;
98
128
  }
99
129
 
100
- /** Map a picked display string back to its option label. */
101
- export function labelFromDisplay(display: string, options: AskOption[]): string | null {
102
- const found = options.find((o) => optionDisplay(o) === display);
103
- return found ? found.label : null;
130
+ /** Rows for a single-select question: the options, then Other… if allowed. */
131
+ export function singleRows(options: AskOption[], allowOther: boolean): string[] {
132
+ const rows = options.map(optionDisplay);
133
+ if (allowOther) rows.push(OTHER_LABEL);
134
+ return rows;
135
+ }
136
+
137
+ export type SingleAction = { kind: "option"; index: number } | { kind: "other" };
138
+
139
+ /**
140
+ * Resolve a pick by its position in the rows that were shown. Matching on the
141
+ * display string instead would confuse an option with the control row that
142
+ * happens to read the same.
143
+ */
144
+ export function parseSingleRow(picked: string, rows: string[], options: AskOption[]): SingleAction | null {
145
+ const index = rows.indexOf(picked);
146
+ if (index < 0) return null;
147
+ return index < options.length ? { kind: "option", index } : { kind: "other" };
104
148
  }
105
149
 
106
150
  /** Rows for one round of the multi-select toggle loop. */
@@ -113,12 +157,105 @@ export function toggleRows(options: AskOption[], selected: ReadonlySet<number>,
113
157
 
114
158
  export type ToggleAction = { kind: "toggle"; index: number } | { kind: "done" } | { kind: "other" };
115
159
 
116
- export function parseToggleRow(row: string, options: AskOption[]): ToggleAction | null {
117
- if (row === DONE_LABEL) return { kind: "done" };
118
- if (row === OTHER_LABEL) return { kind: "other" };
119
- const body = row.replace(/^\[[x ]\] /, "");
120
- const index = options.findIndex((o) => optionDisplay(o) === body);
121
- return index >= 0 ? { kind: "toggle", index } : null;
160
+ export function parseToggleRow(row: string, rows: string[], options: AskOption[]): ToggleAction | null {
161
+ const index = rows.indexOf(row);
162
+ if (index < 0) return null;
163
+ if (index < options.length) return { kind: "toggle", index };
164
+ return index === options.length ? { kind: "done" } : { kind: "other" };
165
+ }
166
+
167
+ /**
168
+ * What the model gets back when there is nobody to ask (RPC, CI, headless).
169
+ * Replaying the questions and options keeps the decision in the transcript,
170
+ * so the assumption it states can be checked against what it offered.
171
+ */
172
+ export function headlessText(questions: AskQuestion[]): string {
173
+ const blocks = questions.map((q) => {
174
+ const options = q.options.map((o) => ` - ${optionDisplay(o)}`);
175
+ if (q.allowOther) options.push(" - (free text)");
176
+ return [`Q: ${q.question}`, ...options].join("\n");
177
+ });
178
+ return [
179
+ "No UI is available to ask the user. These are the questions you would have asked:",
180
+ "",
181
+ blocks.join("\n\n"),
182
+ "",
183
+ "Proceed with your best judgment, and say plainly which option you assumed and why.",
184
+ ].join("\n");
185
+ }
186
+
187
+ export const ASK_STATE = "ask-question-round";
188
+
189
+ export interface AskRound {
190
+ timestamp: number;
191
+ answers: AskAnswer[];
192
+ }
193
+
194
+ export interface BranchEntryLike {
195
+ type?: string;
196
+ customType?: string;
197
+ data?: unknown;
198
+ [key: string]: unknown;
199
+ }
200
+
201
+ /**
202
+ * Every questionnaire is appended as its own entry (not a last-wins
203
+ * snapshot): the point of the record is the sequence of decisions, and an
204
+ * earlier answer stays true after a later one is given.
205
+ */
206
+ export function replayRounds(entries: BranchEntryLike[]): AskRound[] {
207
+ const rounds: AskRound[] = [];
208
+ for (const entry of entries) {
209
+ if (entry.type !== "custom" || entry.customType !== ASK_STATE) continue;
210
+ const data = entry.data;
211
+ if (!isRecord(data) || !Array.isArray(data.answers)) continue;
212
+ rounds.push({
213
+ timestamp: typeof data.timestamp === "number" ? data.timestamp : 0,
214
+ answers: data.answers as AskAnswer[],
215
+ });
216
+ }
217
+ return rounds;
218
+ }
219
+
220
+ export type AskRoute = { kind: "last" } | { kind: "all" } | { kind: "help" } | { kind: "unknown"; input: string };
221
+
222
+ export const ASK_USAGE = "Usage: /ask [last | all]";
223
+
224
+ export function parseAskRoute(raw: string): AskRoute {
225
+ const text = (raw ?? "").trim().toLowerCase();
226
+ if (!text || text === "last") return { kind: "last" };
227
+ if (text === "all" || text === "history") return { kind: "all" };
228
+ if (text === "help" || text === "?") return { kind: "help" };
229
+ return { kind: "unknown", input: text };
230
+ }
231
+
232
+ function stamp(timestamp: number): string {
233
+ if (!timestamp) return "";
234
+ const d = new Date(timestamp);
235
+ const pad = (n: number) => String(n).padStart(2, "0");
236
+ return `${pad(d.getHours())}:${pad(d.getMinutes())} `;
237
+ }
238
+
239
+ /** What /ask prints. */
240
+ export function routeText(route: AskRoute, rounds: AskRound[]): string {
241
+ switch (route.kind) {
242
+ case "help":
243
+ return ASK_USAGE;
244
+ case "unknown":
245
+ return `Unknown route "${route.input}". ${ASK_USAGE}`;
246
+ case "last": {
247
+ const last = rounds[rounds.length - 1];
248
+ return last
249
+ ? `${stamp(last.timestamp)}last questionnaire\n${formatAnswers(last.answers)}`
250
+ : "No questions have been asked in this session.";
251
+ }
252
+ case "all":
253
+ return rounds.length === 0
254
+ ? "No questions have been asked in this session."
255
+ : rounds
256
+ .map((round, i) => `#${i + 1} ${stamp(round.timestamp)}\n${formatAnswers(round.answers)}`)
257
+ .join("\n\n");
258
+ }
122
259
  }
123
260
 
124
261
  /** Text block the model receives. */