@gavin-hu/my-pi-agent 0.11.4 → 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.
- package/README.md +13 -8
- package/README.zh-CN.md +5 -3
- package/assets/chrome.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +291 -207
- package/extensions/bytefly/doctor/runtime.ts +16 -0
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/segments.ts +3 -2
- package/extensions/bytefly/footer/types.ts +1 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +13 -3
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +201 -38
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +32 -0
- package/extensions/bytefly/update/register.ts +72 -0
- package/extensions/bytefly/update/state.ts +28 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +2 -2
- package/extensions/document/tool.ts +1 -3
- package/extensions/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +39 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/memory/README.md +1 -1
- package/extensions/plan/README.md +24 -8
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +18 -12
- package/extensions/plan/plans.ts +1 -1
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +243 -0
- package/extensions/plan/runtime.ts +4 -0
- package/extensions/plan/steps.ts +34 -23
- package/extensions/plan/todos.ts +5 -58
- package/extensions/plan/tools.ts +66 -222
- package/extensions/plan/tui.ts +17 -9
- package/extensions/plan/types.ts +3 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +5 -0
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +100 -36
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- package/extensions/subagent/index.ts +0 -2
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/fetch/tool.ts +1 -3
- package/extensions/web-access/search/tool.ts +1 -3
- package/extensions/wechat/README.md +5 -4
- package/extensions/wechat/tools.ts +0 -2
- package/lib/README.md +2 -1
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/requirements.ts +76 -2
- package/lib/todo-state.ts +62 -0
- package/lib/tui/glyphs.ts +1 -0
- package/package.json +1 -1
- 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
|
-
*
|
|
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"
|
|
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: "
|
|
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 {
|
|
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[]) =>
|
|
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
|
|
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
|
|
105
|
+
const { command, args, exitMeansFailure } = buildCommand(mode, target, options.platform);
|
|
106
|
+
const launch = options.spawn ?? spawnDetached;
|
|
81
107
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 =
|
|
93
|
-
child
|
|
94
|
-
child
|
|
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
|
-
|
|
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
|
|
48
|
-
restatement
|
|
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,
|
|
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:
|
|
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 |
|
|
170
|
-
| Questions | When the goal is not yet understood, the command asks the model's questions through `ctx.ui.select` / `ctx.ui.input` and
|
|
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
|
|
185
|
-
the
|
|
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` |
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
* the command asks those questions through host dialogs
|
|
9
|
-
* answers into the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
19
|
+
import { buildClarifyMessages } from "./clarify-prompt.ts";
|
|
20
20
|
|
|
21
|
-
/** Bound on
|
|
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
|
-
*
|
|
80
|
+
* One clarifier model call, behind the TUI loader.
|
|
90
81
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
158
|
+
* Carry the answers into the editor as notes under the restatement.
|
|
182
159
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
{
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
/**
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
*
|
|
59
|
-
* not malformed keywords
|
|
60
|
-
*
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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));
|