@gavin-hu/my-pi-agent 0.11.3 → 0.12.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.
Files changed (86) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +22 -11
  73. package/extensions/wechat/bridge.ts +90 -22
  74. package/extensions/wechat/commands.ts +45 -1
  75. package/extensions/wechat/doctor.ts +1 -0
  76. package/extensions/wechat/index.ts +3 -1
  77. package/extensions/wechat/status-screen.ts +32 -2
  78. package/extensions/wechat/tools.ts +0 -2
  79. package/lib/README.md +2 -1
  80. package/lib/config.ts +43 -1
  81. package/lib/env.ts +12 -0
  82. package/lib/requirements.ts +76 -2
  83. package/lib/todo-state.ts +62 -0
  84. package/lib/tui/glyphs.ts +1 -0
  85. package/package.json +1 -1
  86. package/assets/bytefly.schema.json +0 -785
@@ -5,7 +5,7 @@
5
5
  * runs is asserted without a process. `openTarget` takes an injectable spawn so
6
6
  * a test launches nothing. Opening is best-effort: the caller decides what to do
7
7
  * with a failure, which is reported through `onError` rather than thrown, since
8
- * `spawn` reports a missing handler asynchronously.
8
+ * the launcher reports a start failure and a non-zero exit asynchronously.
9
9
  */
10
10
 
11
11
  import { spawn } from "node:child_process";
@@ -19,6 +19,11 @@ export type OpenMode = "open" | "reveal";
19
19
  export interface NativeCommand {
20
20
  command: string;
21
21
  args: string[];
22
+ /**
23
+ * Whether a non-zero exit means the target was not opened. Windows
24
+ * `explorer` can exit non-zero after a successful open, so it opts out.
25
+ */
26
+ exitMeansFailure: boolean;
22
27
  }
23
28
 
24
29
  /**
@@ -51,49 +56,75 @@ export function buildCommand(
51
56
  platform: NodeJS.Platform = process.platform,
52
57
  ): NativeCommand {
53
58
  if (platform === "darwin") {
54
- return mode === "reveal" ? { command: "open", args: ["-R", target] } : { command: "open", args: [target] };
59
+ return mode === "reveal"
60
+ ? { command: "open", args: ["-R", target], exitMeansFailure: true }
61
+ : { command: "open", args: [target], exitMeansFailure: true };
55
62
  }
56
63
  if (platform === "win32") {
64
+ // `explorer` opens with the default handler without routing the path
65
+ // through cmd, which would expand `%NAME%` inside it.
57
66
  return mode === "reveal"
58
- ? { command: "explorer", args: [`/select,${target}`] }
59
- : { command: "cmd", args: ["/c", "start", "", target] };
67
+ ? { command: "explorer", args: [`/select,${target}`], exitMeansFailure: false }
68
+ : { command: "explorer", args: [target], exitMeansFailure: false };
60
69
  }
61
70
  // Linux et al. xdg-open has no "select" verb, so reveal opens the parent.
62
- return { command: "xdg-open", args: [mode === "reveal" ? dirname(target) : target] };
71
+ return {
72
+ command: "xdg-open",
73
+ args: [mode === "reveal" ? dirname(target) : target],
74
+ exitMeansFailure: true,
75
+ };
76
+ }
77
+
78
+ /** The child surface `openTarget` listens on; an injected launcher may return none. */
79
+ export interface LauncherChild {
80
+ on(event: "error", listener: (error: Error) => void): unknown;
81
+ on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
63
82
  }
64
83
 
65
84
  /** A launcher seam; the default spawns the command detached. */
66
- export type SpawnFn = (command: string, args: string[]) => unknown;
85
+ export type SpawnFn = (command: string, args: string[]) => LauncherChild | undefined;
67
86
 
68
87
  export interface OpenTargetOptions {
69
88
  /** Platform to build the command for; defaults to the running one. */
70
89
  platform?: NodeJS.Platform;
71
90
  /** Override the launcher (tests). */
72
91
  spawn?: SpawnFn;
73
- /** Called when the handler cannot be started or reports an error. */
92
+ /** Called once when the handler cannot start, or exits non-zero where that is meaningful. */
74
93
  onError?: (error: Error) => void;
75
94
  }
76
95
 
96
+ /** Spawn the handler detached so the application outlives the session. */
97
+ const spawnDetached: SpawnFn = (command, args) => {
98
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
99
+ child.unref();
100
+ return child;
101
+ };
102
+
77
103
  /** Start the native handler for `target`, detached so it outlives the session. */
78
104
  export function openTarget(mode: OpenMode, target: string, options: OpenTargetOptions = {}): void {
79
- const { command, args } = buildCommand(mode, target, options.platform);
80
- const onError = options.onError;
105
+ const { command, args, exitMeansFailure } = buildCommand(mode, target, options.platform);
106
+ const launch = options.spawn ?? spawnDetached;
81
107
 
82
- if (options.spawn) {
83
- try {
84
- options.spawn(command, args);
85
- } catch (error) {
86
- onError?.(toError(error));
87
- }
88
- return;
89
- }
108
+ // A start failure and an exit both arrive asynchronously; report at most
109
+ // one, since both can fire for the same launch.
110
+ let reported = false;
111
+ const report = (error: Error): void => {
112
+ if (reported) return;
113
+ reported = true;
114
+ options.onError?.(error);
115
+ };
90
116
 
91
117
  try {
92
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
93
- child.on("error", (error) => onError?.(error));
94
- child.unref();
118
+ const child = launch(command, args);
119
+ child?.on("error", report);
120
+ if (child && exitMeansFailure) {
121
+ child.on("exit", (code, signal) => {
122
+ if (signal) report(new Error(`the OS handler was stopped by ${signal}`));
123
+ else if (code !== 0) report(new Error(`the OS handler exited with status ${code}`));
124
+ });
125
+ }
95
126
  } catch (error) {
96
- onError?.(toError(error));
127
+ report(toError(error));
97
128
  }
98
129
  }
99
130
 
@@ -21,7 +21,11 @@ How the `goal` call and its result look in the transcript — the call line as a
21
21
  objective. Send the complete objective every call; an empty objective clears
22
22
  it.
23
23
  - Tracks `status`: `active` while the goal is being pursued, `achieved` when it
24
- is done.
24
+ is done. Achieving it while the branch's todo list still has open items names
25
+ the count in the result (`1 todo item is still open…`), so the model closes the
26
+ *how* instead of declaring the *what* done over an unfinished list. The count
27
+ is read from the branch through `lib/todo-state.ts`; the structured result is
28
+ unchanged.
25
29
  - Shows a persistent one-line rail above the editor whenever a goal exists,
26
30
  label-first to match the [`todo`](../todo/) widget, pinned at the top of the
27
31
  `Goal / Todos` stack when both are loaded:
@@ -44,8 +48,9 @@ How the `goal` call and its result look in the transcript — the call line as a
44
48
  - Understands the goal before recording it: `/goal <prompt>` asks the session
45
49
  model to restate the goal as it understands it and, when it cannot yet, to ask
46
50
  up to three questions — which the command asks directly through host dialogs
47
- (`select` / `input`). The model then folds the answers into the final
48
- restatement, and the user confirms or corrects it in the editor. `/goal set
51
+ (`select` / `input`). The answers are carried into the editor under the
52
+ restatement as `Also: <question> → <answer>` notes, and the user confirms,
53
+ folds them in, or corrects the goal there. `/goal set
49
54
  <prompt>` reuses the loop but records without starting a turn, `/goal edit`
50
55
  changes the current goal in place, and `/goal raw <prompt>` records the typed
51
56
  words verbatim. The loop is best-effort and needs a dialog UI plus a model; the
@@ -79,6 +84,13 @@ line: control characters (including `ESC`) become spaces and any whitespace run
79
84
  (newlines, tabs, repeated spaces) collapses to one space. This keeps the widget
80
85
  to one logical line and stops model text from injecting terminal sequences.
81
86
 
87
+ A successful `status: "achieved"` call also appends one line to the model-facing
88
+ text when the branch's todo list still has open items — `3 todo items are still
89
+ open; close or clear the list if that work is done.` The list is replayed from
90
+ the branch, so the note appears whether the todos were written by the model, by
91
+ `/todos`, or by an approved plan. It is text only: `details`,
92
+ `structuredContent`, and the transcript header are unaffected.
93
+
82
94
  `goal` is allowlisted in `plan-mode` (alongside `todo`), so the model can record
83
95
  or update the objective during read-only exploration without leaving plan mode.
84
96
 
@@ -90,9 +102,9 @@ entry; `reconstructGoal` replays both entry kinds in branch order.
90
102
  | Command | What it does |
91
103
  |---|---|
92
104
  | `/goal` | Show the current goal, or the command usage when none is set. |
93
- | `/goal <prompt>` | Run the understanding loop — restate, ask any questions the model needs, re-comprehend, then confirm/edit — and start the run. Replacing a live goal is confirmed first. |
105
+ | `/goal <prompt>` | Run the understanding loop — restate, ask any questions the model needs, then confirm/edit in the editor, which carries the answers as notes — and start the run. Replacing a live goal is confirmed first. |
94
106
  | `/goal raw <prompt>` | Skip the loop: record the typed words verbatim and start the run. |
95
- | `/goal set <prompt>` (or `/goal edit <prompt>`) | The same loop, but record the goal without starting a run. |
107
+ | `/goal set <prompt>` (or `/goal edit <prompt>`) | The same loop, but record the goal without starting a run. An explicit `set`/`edit <text>` replaces an active goal without asking, since the command already says so. |
96
108
  | `/goal set` (or `/goal edit`) | Open the editor prefilled with the current goal and save it as the active goal, without starting a run. |
97
109
  | `/goal done` (or `/goal achieved`) | Mark the current goal achieved (idempotent). |
98
110
  | `/goal clear` | Clear the goal. |
@@ -113,7 +125,7 @@ without one they show the usage instead. `/goal raw` never needs one.
113
125
 
114
126
  The tool works in every mode. The persistent widget requires interactive
115
127
  (`tui`) mode; the reminder and the `/goal` command work everywhere. The
116
- `/goal <prompt>` runs the loop: each model call uses a cancellable `tui` spinner
128
+ `/goal <prompt>` runs the loop: its model call uses a cancellable `tui` spinner
117
129
  in interactive mode and runs directly in RPC; the questions and the confirm step
118
130
  are host dialogs, forwarded by RPC. In print and JSON mode the loop is skipped
119
131
  and the typed text is sent. The widget is always a single line; the transcript
@@ -166,8 +178,8 @@ depends on config.
166
178
  | State storage | tool-result `details` plus a `pi.appendEntry("goal", { goal })` custom entry for command-set goals |
167
179
  | Lifecycle hooks | `session_start` (load config, reconstruct), `session_tree` (reconstruct), `session_shutdown` (clear widget); `before_agent_start` (inject `goal-context`), `context` (filter stale/duplicate reminders) |
168
180
  | UI | `ctx.ui.setWidget` one-line rail in `tui` mode; coordinated through `pi.events` so it stays the top rail |
169
- | Model calls | Up to two `ctx.modelRegistry.complete` calls on the session model (comprehend, then re-comprehend), each with a 30 s timeout and `cacheRetention: "none"`; skipped without a dialog UI or a model; their usage is not counted in session totals |
170
- | Questions | When the goal is not yet understood, the command asks the model's questions through `ctx.ui.select` / `ctx.ui.input` and passes the answers to the re-comprehension call. No cross-extension tool is used. |
181
+ | Model calls | One `ctx.modelRegistry.complete` call on the session model with a 30 s timeout and `cacheRetention: "none"`; skipped without a dialog UI or a model; its usage is not counted in session totals |
182
+ | Questions | When the goal is not yet understood, the command asks the model's questions through `ctx.ui.select` / `ctx.ui.input` and carries the answers into the editor prefill. No cross-extension tool is used. |
171
183
 
172
184
  ## Design notes
173
185
 
@@ -181,8 +193,10 @@ depends on config.
181
193
  user-driven path: one comprehension call (`clarify-prompt.ts` + `clarify.ts`)
182
194
  returns `{ understood, goal, questions }`. An understood goal opens a prefilled
183
195
  `ctx.ui.editor`, then `pi.sendUserMessage` sets and runs it. A not-understood
184
- goal has its questions asked through host dialogs and its answers folded into
185
- the goal by a second model call, then confirmed in the editor. `/goal set`
196
+ goal has its questions asked through host dialogs, and its answers ride into
197
+ the editor as `Also:` notes under the restatement: no second model call
198
+ rewrites the goal, so the user is the one who folds them into the sentence.
199
+ `/goal set`
186
200
  reuses the loop but skips the send; a bare `/goal set` or `/goal edit` opens
187
201
  the editor prefilled with the current goal; and `/goal raw` skips the loop
188
202
  entirely. The loop is best-effort (a failure keeps the typed text) and a
@@ -214,6 +228,18 @@ depends on config.
214
228
  the status header alone (`Goal · active`), because the call line above it
215
229
  already carries the objective; it does not restate it. `achieved: "show"`
216
230
  restores the dimmed widget line; config only affects presentation.
231
+ - **Completing the *what* checks the *how*.** Marking a goal achieved while the
232
+ todo list still holds open items appends one line to the model-facing result
233
+ naming the count. The list is replayed from the branch by
234
+ `lib/todo-state.ts` (shared with plan's approval-time merge, which reads the
235
+ same channel), never by importing the `todo` extension. Only the text is
236
+ affected: `details` and `structuredContent` stay the goal alone, and the
237
+ transcript result is still the status header.
238
+ - **One verb table.** `GOAL_VERBS` is the only list of reserved words: the
239
+ parser looks a first word up in it, the completions are generated from it, and
240
+ a test asserts every word is offered as a completion and named in `GOAL_HELP`.
241
+ Adding a verb without documenting or completing it fails that test instead of
242
+ drifting silently.
217
243
  - **Sanitize at the boundary.** `normalizeGoal` and `parseClarification` both route
218
244
  model text through `lib/format.ts`'s `sanitizeLine`, so an embedded newline
219
245
  cannot corrupt the widget and raw escapes cannot restyle the terminal.
@@ -225,16 +251,16 @@ depends on config.
225
251
  | `index.ts` | Factory: wire the runtime, tool, command, reminder, and session events. |
226
252
  | `types.ts` | `Goal`, `GoalStatus`, `GoalDetails`. |
227
253
  | `schema.ts` | TypeBox parameters and pure validation/normalization. |
228
- | `clarify-prompt.ts` | Comprehension and re-comprehension prompts plus their message builders (pure). |
254
+ | `clarify-prompt.ts` | The comprehension prompt and its message builder (pure). |
229
255
  | `clarify-parse.ts` | Parse the `{ understood, goal, questions }` reply (and the plain-text fallback), normalize questions, and expose the limits (pure). |
230
- | `clarify.ts` | The two model calls, their cancellable loaders, the in-command questionnaire, and the confirm/edit dialog (host). |
256
+ | `clarify.ts` | The model call, its cancellable loader, the in-command questionnaire, and the confirm/edit dialog (host). |
231
257
  | `state.ts` | Branch reconstruction (pure). |
232
258
  | `format.ts` | Model-facing and terminal text plus the header and rail vocabulary (pure). |
233
259
  | `config.ts` | Widget config load/validation (the `goal` section of `bytefly.json`). |
234
260
  | `tui.ts` | The persistent goal widget and the transcript status header. |
235
261
  | `runtime.ts` | Session-scoped state and widget synchronization. |
236
262
  | `tools.ts` | `goal` tool registration and rendering. |
237
- | `commands.ts` | `/goal`. |
263
+ | `commands.ts` | `/goal`: the `GOAL_VERBS` table, the argument parser, the completions, and the command handler. |
238
264
 
239
265
  ## Testing
240
266
 
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Prompt text for the goal comprehension step (pure).
3
3
  *
4
- * Both system prompts are sent only on the nested clarifier calls, so they are
5
- * not part of Pi's always-on prompt budget. The model's job is to understand and
6
- * restate the user's goal — not to improve it — and, when it cannot yet, to ask
7
- * only what it needs from the user. The second call folds the answers into the
8
- * final restatement.
4
+ * Sent only on the nested clarifier call, so it is not part of Pi's always-on
5
+ * prompt budget. The model's job is to understand and restate the user's goal —
6
+ * not to improve it — and, when it cannot yet, to ask only what it needs from
7
+ * the user.
9
8
  */
10
9
 
11
10
  import type { UserMessage } from "@earendil-works/pi-ai";
12
- import { MAX_QUESTIONS, type ClarifyAnswer } from "./clarify-parse.ts";
11
+ import { MAX_QUESTIONS } from "./clarify-parse.ts";
13
12
 
14
13
  /** The comprehension system prompt: understand the goal, ask only what is missing. */
15
14
  export const CLARIFY_SYSTEM_PROMPT = `You are establishing a shared understanding of the user's goal for this session.
@@ -33,12 +32,6 @@ Rules:
33
32
  - Ask only what only the user can answer: purpose, scope, preference, or priority. Never ask for a fact the work itself would reveal, and never ask the user to restate the goal.
34
33
  - Give a question 2-4 concrete options when the answers are a small known set, or no options at all when free-form.`;
35
34
 
36
- /** The re-comprehension system prompt: fold the answers into one goal statement. */
37
- export const REFINE_SYSTEM_PROMPT = `The user has answered questions about their goal. Restate the goal as you now understand it.
38
-
39
- Reply with ONLY the goal as one sentence, no prose and no code fence.
40
- Use their answers and the original request; add nothing they did not state.`;
41
-
42
35
  /** The user message sent to the clarifier alongside {@link CLARIFY_SYSTEM_PROMPT}. */
43
36
  export function buildClarifyUserText(text: string): string {
44
37
  return `Understand this session goal:\n\n${text}`;
@@ -54,27 +47,3 @@ export function buildClarifyMessages(
54
47
  messages: [{ role: "user", content: [{ type: "text", text: buildClarifyUserText(text) }], timestamp: now }],
55
48
  };
56
49
  }
57
-
58
- /** The user message that carries the original request, the first restatement, and the answers. */
59
- export function buildRefineUserText(original: string, goal: string, answers: readonly ClarifyAnswer[]): string {
60
- const lines = [`Original request:\n${original}`];
61
- if (goal) lines.push(`First restatement: ${goal}`);
62
- lines.push("The user was asked, and answered:");
63
- for (const item of answers) lines.push(`- ${item.question}\n ${item.answer}`);
64
- return lines.join("\n");
65
- }
66
-
67
- /** The request sent to the clarifier after the user answers. */
68
- export function buildRefineMessages(
69
- original: string,
70
- goal: string,
71
- answers: readonly ClarifyAnswer[],
72
- now = Date.now(),
73
- ): { systemPrompt: string; messages: UserMessage[] } {
74
- return {
75
- systemPrompt: REFINE_SYSTEM_PROMPT,
76
- messages: [
77
- { role: "user", content: [{ type: "text", text: buildRefineUserText(original, goal, answers) }], timestamp: now },
78
- ],
79
- };
80
- }
@@ -1,24 +1,24 @@
1
1
  /**
2
- * Host orchestration for the goal comprehension step: the nested clarifier
3
- * calls, their cancellable TUI loaders, the in-command questionnaire, and the
4
- * confirm/edit dialog.
2
+ * Host orchestration for the goal comprehension step: the clarifier call, its
3
+ * cancellable TUI loader, the in-command questionnaire, and the confirm/edit
4
+ * dialog.
5
5
  *
6
- * The loop's target is understanding what the user wants. The first call asks
7
- * the model whether it understands the goal and, if not, what it needs to know;
8
- * the command asks those questions through host dialogs; a second call folds the
9
- * answers into the final restatement; the editor lets the user confirm or
10
- * correct it. The flow never throws and never half-applies: a failed,
11
- * timed-out, cancelled, or unusable comprehension falls back to the text the
12
- * user typed, and a dismissed dialog cancels the whole command.
6
+ * The loop's target is understanding what the user wants. One call asks the
7
+ * model whether it understands the goal and, when it cannot yet, what it needs
8
+ * to know; the command asks those questions through host dialogs and carries
9
+ * the answers into the editor, where the user confirms or corrects the goal.
10
+ * The flow never throws and never half-applies: a failed, timed-out, cancelled,
11
+ * or unusable comprehension falls back to the text the user typed, and a
12
+ * dismissed dialog cancels the whole command.
13
13
  */
14
14
 
15
15
  import { BorderedLoader, type ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import type { UserMessage } from "@earendil-works/pi-ai";
17
17
  import { sanitizeLine } from "../../lib/format.ts";
18
18
  import { type Clarification, type ClarifyAnswer, type ClarifyQuestion, parseClarification } from "./clarify-parse.ts";
19
- import { buildClarifyMessages, buildRefineMessages } from "./clarify-prompt.ts";
19
+ import { buildClarifyMessages } from "./clarify-prompt.ts";
20
20
 
21
- /** Bound on each clarifier model call, in milliseconds. */
21
+ /** Bound on the clarifier model call, in milliseconds. */
22
22
  const CLARIFY_TIMEOUT_MS = 30_000;
23
23
 
24
24
  /** The slice of the context the clarify step reads. */
@@ -27,9 +27,6 @@ export type ClarifyContext = Pick<ExtensionContext, "mode" | "ui" | "signal" | "
27
27
  /** Loader text shown while the comprehension call runs. */
28
28
  const LOADER_MESSAGE = "Understanding goal…";
29
29
 
30
- /** Loader text shown while the goal is restated from the answers. */
31
- const REFINE_LOADER_MESSAGE = "Updating the goal…";
32
-
33
30
  /** The confirm/edit dialog title, reading as a confirmation of understanding. */
34
31
  const EDITOR_TITLE = "Confirm the goal — Enter to save, Esc to cancel";
35
32
 
@@ -79,74 +76,54 @@ function assistantText(response: { content?: unknown }): string {
79
76
  return parts.join("\n");
80
77
  }
81
78
 
82
- /** Runs a nested operation, optionally behind the TUI loader. */
83
- type CompleterRun = <T>(fn: (signal: AbortSignal | undefined) => Promise<T>) => Promise<T | undefined>;
84
-
85
- /** The context slice the completer needs. */
86
- type CompleterContext = Pick<ExtensionContext, "model" | "modelRegistry" | "signal">;
87
-
88
79
  /**
89
- * Build one clarifier caller.
80
+ * One clarifier model call, behind the TUI loader.
90
81
  *
91
- * `buildMessages` produces the request; the call runs through `run` (the loader
92
- * wrapper) with an `AbortController` aborted on timeout, on the session signal,
93
- * or when the loader is cancelled. Any throw becomes `undefined`, so a provider
94
- * failure never blocks the command.
82
+ * The call is aborted on timeout, on the session signal, or when the loader is
83
+ * cancelled, and any failure becomes `undefined` so a provider error never
84
+ * blocks the command. Nothing from the conversation is sent: the model must not
85
+ * invent scope the user did not state.
95
86
  */
96
- function createCompleter(
97
- ctx: CompleterContext,
98
- timeoutMs: number,
99
- run: CompleterRun,
100
- buildMessages: () => { systemPrompt: string; messages: UserMessage[] },
101
- ): () => Promise<string | undefined> {
102
- return async () => {
103
- const model = ctx.model;
104
- if (!model) return undefined;
105
-
106
- const { systemPrompt, messages } = buildMessages();
107
- const controller = new AbortController();
108
- const timer = setTimeout(() => controller.abort(), timeoutMs);
109
- const onAbort = () => controller.abort();
110
- ctx.signal?.addEventListener("abort", onAbort, { once: true });
111
- try {
112
- return await run(async (signal) => {
113
- if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true });
114
- const response = await ctx.modelRegistry.complete(
115
- model,
116
- { systemPrompt, messages },
117
- { signal: controller.signal, cacheRetention: "none" },
118
- );
119
- if (response.stopReason === "aborted") return undefined;
120
- return assistantText(response);
121
- });
122
- } catch {
123
- return undefined;
124
- } finally {
125
- clearTimeout(timer);
126
- ctx.signal?.removeEventListener("abort", onAbort);
127
- }
128
- };
129
- }
130
-
131
- /** Run one clarifier call behind its loader and return the raw reply. */
132
- async function completeOnce(
87
+ async function askClarifier(
133
88
  ctx: ClarifyContext,
134
- loaderMessage: string,
135
89
  buildMessages: () => { systemPrompt: string; messages: UserMessage[] },
136
90
  ): Promise<string | undefined> {
137
- return createCompleter(ctx, CLARIFY_TIMEOUT_MS, (run) => withLoader(ctx, loaderMessage, run), buildMessages)();
91
+ const model = ctx.model;
92
+ if (!model) return undefined;
93
+
94
+ const { systemPrompt, messages } = buildMessages();
95
+ const controller = new AbortController();
96
+ const timer = setTimeout(() => controller.abort(), CLARIFY_TIMEOUT_MS);
97
+ const onAbort = () => controller.abort();
98
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
99
+ try {
100
+ return await withLoader(ctx, LOADER_MESSAGE, async (signal) => {
101
+ if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true });
102
+ const response = await ctx.modelRegistry.complete(
103
+ model,
104
+ { systemPrompt, messages },
105
+ { signal: controller.signal, cacheRetention: "none" },
106
+ );
107
+ if (response.stopReason === "aborted") return undefined;
108
+ return assistantText(response);
109
+ });
110
+ } catch {
111
+ return undefined;
112
+ } finally {
113
+ clearTimeout(timer);
114
+ ctx.signal?.removeEventListener("abort", onAbort);
115
+ }
138
116
  }
139
117
 
140
118
  /**
141
- * One comprehension call on the session model, parsed into a
142
- * {@link Clarification}.
119
+ * The comprehension call, parsed into a {@link Clarification}.
143
120
  *
144
121
  * Returns `undefined` when there is no dialog UI or no model, or when the call
145
122
  * fails, times out, is cancelled, or returns nothing usable.
146
123
  */
147
124
  export async function clarifyGoalPrompt(ctx: ClarifyContext, text: string): Promise<Clarification | undefined> {
148
125
  if (!ctx.hasUI || !ctx.model) return undefined;
149
- return parseClarification(await completeOnce(ctx, LOADER_MESSAGE, () => buildClarifyMessages(text)));
126
+ return parseClarification(await askClarifier(ctx, () => buildClarifyMessages(text)));
150
127
  }
151
128
 
152
129
  /**
@@ -155,7 +132,7 @@ export async function clarifyGoalPrompt(ctx: ClarifyContext, text: string): Prom
155
132
  * A question with options uses a selector; a free-form question uses a text
156
133
  * input. Resolves `undefined` when any dialog is dismissed, so the caller cancels
157
134
  * the whole command; an empty answer is skipped. The answer is sanitized to one
158
- * line, since it is folded into a model message and later the goal.
135
+ * line, since it is folded into the goal the editor holds.
159
136
  */
160
137
  async function askQuestions(
161
138
  ctx: Pick<ClarifyContext, "ui">,
@@ -178,19 +155,15 @@ async function askQuestions(
178
155
  }
179
156
 
180
157
  /**
181
- * Fold the user's answers into the final restatement of the goal.
158
+ * Carry the answers into the editor as notes under the restatement.
182
159
  *
183
- * Returns `undefined` on any failure, so the caller falls back to the first
184
- * restatement.
160
+ * No model call rewrites the goal from the answers, so they are shown verbatim
161
+ * with the question that asked for them: the user folds them into the sentence
162
+ * or deletes them, and whatever the editor holds is what gets recorded.
185
163
  */
186
- async function refineWithAnswers(
187
- ctx: ClarifyContext,
188
- original: string,
189
- goal: string,
190
- answers: readonly ClarifyAnswer[],
191
- ): Promise<string | undefined> {
192
- const reply = await completeOnce(ctx, REFINE_LOADER_MESSAGE, () => buildRefineMessages(original, goal, answers));
193
- return parseClarification(reply)?.goal || undefined;
164
+ function withAnswers(goal: string, answers: readonly ClarifyAnswer[]): string {
165
+ if (answers.length === 0) return goal;
166
+ return [goal, "", ...answers.map((answer) => `Also: ${answer.question} → ${answer.answer}`)].join("\n");
194
167
  }
195
168
 
196
169
  /** Open the confirm/edit dialog, or cancel when it is dismissed. */
@@ -201,7 +174,7 @@ async function editOrCancel(ctx: ClarifyContext, prefill: string): Promise<GoalP
201
174
 
202
175
  /**
203
176
  * Run the understanding loop for `/goal <prompt>`: comprehend, ask what is
204
- * missing, re-comprehend, then let the user confirm or correct the goal.
177
+ * missing, then let the user confirm or correct the goal.
205
178
  *
206
179
  * Without a dialog UI or a model the typed text is used unchanged, so the
207
180
  * command never blocks on the loop in print or JSON mode. A failed comprehension
@@ -224,11 +197,7 @@ export async function prepareGoalPrompt(ctx: ClarifyContext, typed: string): Pro
224
197
  ctx.ui.notify("Goal not set.", "info");
225
198
  return { action: "cancel" };
226
199
  }
227
- if (answers.length > 0) {
228
- const refined = await refineWithAnswers(ctx, typed, proposed, answers);
229
- if (refined) proposed = refined;
230
- else ctx.ui.notify("Could not update the goal from your answers; edit it below.", "info");
231
- }
200
+ proposed = withAnswers(proposed, answers);
232
201
  }
233
202
  return editOrCancel(ctx, proposed);
234
203
  }
@@ -23,23 +23,37 @@ import { GOAL_ENTRY_TYPE, type Goal } from "./types.ts";
23
23
  /** Title of the record-only edit dialog, spelling out both exits. */
24
24
  const EDIT_TITLE = "Goal — Enter to save, Esc to cancel";
25
25
 
26
- /** Completion values for the goal keywords: `set`/`edit`/`raw` take an argument. */
27
- const GOAL_COMPLETIONS = [
28
- { value: "set ", label: "set" },
29
- { value: "edit ", label: "edit" },
30
- { value: "raw ", label: "raw" },
31
- { value: "clear", label: "clear" },
32
- { value: "done", label: "done" },
33
- { value: "achieved", label: "achieved" },
34
- { value: "help", label: "help" },
35
- { value: "-h", label: "-h" },
36
- { value: "--help", label: "--help" },
26
+ /**
27
+ * One reserved `/goal` verb: the word, what a bare occurrence means, and whether
28
+ * it takes the rest of the line as its argument.
29
+ */
30
+ type GoalVerb =
31
+ | { word: string; kind: "usage" | "clear" | "done"; argument: "none" }
32
+ | { word: string; kind: "raw"; argument: "required" }
33
+ | { word: string; kind: "record"; argument: "required" | "optional" };
34
+
35
+ /**
36
+ * The single list behind the parser, the completions, and the help text, so a
37
+ * new verb cannot land in one of them and be missing from another.
38
+ * `set`/`edit` share the `record` kind; the two words are interchangeable.
39
+ */
40
+ export const GOAL_VERBS: readonly GoalVerb[] = [
41
+ { word: "set", kind: "record", argument: "optional" },
42
+ { word: "edit", kind: "record", argument: "optional" },
43
+ { word: "raw", kind: "raw", argument: "required" },
44
+ { word: "clear", kind: "clear", argument: "none" },
45
+ { word: "done", kind: "done", argument: "none" },
46
+ { word: "achieved", kind: "done", argument: "none" },
47
+ { word: "help", kind: "usage", argument: "none" },
48
+ { word: "-h", kind: "usage", argument: "none" },
49
+ { word: "--help", kind: "usage", argument: "none" },
37
50
  ];
38
51
 
39
- /** `/goal set <text>` and `/goal edit <text>`: `text` is optional. */
40
- const RECORD_PATTERN = /^(set|edit)(?:\s+([\s\S]*))?$/i;
41
- /** `/goal raw <text>`: `text` is required. */
42
- const RAW_PATTERN = /^raw(?:\s+([\s\S]*))?$/i;
52
+ /** Completion values: a verb that takes an argument completes with a space. */
53
+ const GOAL_COMPLETIONS = GOAL_VERBS.map((verb) => ({
54
+ value: verb.argument === "none" ? verb.word : `${verb.word} `,
55
+ label: verb.word,
56
+ }));
43
57
 
44
58
  /** One parsed `/goal` invocation. */
45
59
  export type GoalCommand =
@@ -55,30 +69,22 @@ export type GoalCommand =
55
69
  /**
56
70
  * Parse `/goal`'s argument into one command.
57
71
  *
58
- * Keywords match whole words, so `setfoo` and `edit-foo` are free-form prompts,
59
- * not malformed keywords. Only `help`/`clear`/`done`/`achieved` are reserved
60
- * without an argument; anything else is a prompt.
72
+ * Only the first word is looked up, so a keyword matches a whole word: `setfoo`
73
+ * and `edit-foo` are free-form prompts, not malformed keywords, and a verb that
74
+ * takes no argument leaves the whole line a prompt (`clear the cache`). Anything
75
+ * unlisted is a prompt.
61
76
  */
62
77
  export function parseGoalCommand(input: string): GoalCommand {
63
78
  const text = input.trim();
64
79
  if (!text) return { kind: "show" };
65
80
 
66
- const keyword = text.toLowerCase();
67
- if (keyword === "help" || keyword === "-h" || keyword === "--help") return { kind: "usage" };
68
- if (keyword === "clear") return { kind: "clear" };
69
- if (keyword === "done" || keyword === "achieved") return { kind: "done" };
70
-
71
- const record = RECORD_PATTERN.exec(text);
72
- if (record) {
73
- const objective = (record[2] ?? "").trim();
74
- return objective ? { kind: "record", text: objective } : { kind: "editor" };
75
- }
76
- const raw = RAW_PATTERN.exec(text);
77
- if (raw) {
78
- const objective = (raw[1] ?? "").trim();
79
- return objective ? { kind: "raw", text: objective } : { kind: "usage" };
80
- }
81
- return { kind: "prompt", text };
81
+ const head = text.split(/\s+/, 1)[0] ?? "";
82
+ const rest = text.slice(head.length).trim();
83
+ const verb = GOAL_VERBS.find((candidate) => candidate.word === head.toLowerCase());
84
+ if (!verb) return { kind: "prompt", text };
85
+ if (verb.argument === "none") return rest ? { kind: "prompt", text } : { kind: verb.kind };
86
+ if (verb.kind === "raw") return rest ? { kind: "raw", text: rest } : { kind: "usage" };
87
+ return rest ? { kind: "record", text: rest } : { kind: "editor" };
82
88
  }
83
89
 
84
90
  /**
@@ -87,7 +93,7 @@ export function parseGoalCommand(input: string): GoalCommand {
87
93
  * once the prefix contains a space (or the start of the prompt does not look
88
94
  * like a keyword) the argument is left to the user.
89
95
  */
90
- function completeGoalArg(prefix: string): Array<{ value: string; label: string }> | null {
96
+ export function completeGoalArg(prefix: string): Array<{ value: string; label: string }> | null {
91
97
  const text = prefix.trimStart();
92
98
  if (text.includes(" ")) return null;
93
99
  const items = GOAL_COMPLETIONS.filter((item) => item.label.startsWith(text));