@pify/ask-question 0.2.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
@@ -10,6 +10,7 @@ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify instal
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
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.
13
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.
14
15
  - Structured results return to the model as both readable text and `details.answers`.
15
16
 
@@ -20,12 +20,16 @@ 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
27
  headlessText,
28
+ parseAskRoute,
27
29
  parseSingleRow,
28
30
  parseToggleRow,
31
+ replayRounds,
32
+ routeText,
29
33
  singleRows,
30
34
  toggleRows,
31
35
  validateQuestions,
@@ -132,6 +136,10 @@ export default function askQuestion(pi: ExtensionAPI) {
132
136
  }
133
137
  }
134
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
+
135
143
  const text = [
136
144
  formatAnswers(answers),
137
145
  ...(result.warnings.length > 0 ? [`Warnings: ${result.warnings.join("; ")}`] : []),
@@ -139,4 +147,13 @@ export default function askQuestion(pi: ExtensionAPI) {
139
147
  return { content: [{ type: "text", text }], details: { answers } };
140
148
  },
141
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
+ });
142
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/ask-question",
3
- "version": "0.2.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
@@ -184,6 +184,80 @@ export function headlessText(questions: AskQuestion[]): string {
184
184
  ].join("\n");
185
185
  }
186
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
+ }
259
+ }
260
+
187
261
  /** Text block the model receives. */
188
262
  export function formatAnswers(answers: AskAnswer[]): string {
189
263
  return answers