@narumitw/pi-goal 0.1.12 → 0.1.14
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 +38 -9
- package/package.json +1 -1
- package/src/goal.ts +425 -53
package/README.md
CHANGED
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@narumitw/pi-goal) [](https://pi.dev) [](./LICENSE)
|
|
4
4
|
|
|
5
|
-
`@narumitw/pi-goal` is a native [Pi coding agent](https://pi.dev) extension that adds `/goal
|
|
5
|
+
`@narumitw/pi-goal` is a native [Pi coding agent](https://pi.dev) extension that adds durable `/goal` commands and a `goal_complete` tool for autonomous, verifiable task completion.
|
|
6
6
|
|
|
7
|
-
Goal mode keeps sending automatic follow-up messages until the agent calls `goal_complete`,
|
|
7
|
+
Goal mode keeps sending guarded automatic follow-up messages until the agent calls `goal_complete`, the user pauses or clears the goal, or an optional token budget is reached.
|
|
8
8
|
|
|
9
9
|
## ✨ Features
|
|
10
10
|
|
|
11
11
|
- Adds `/goal <goal_to_complete>` to start goal mode.
|
|
12
|
-
-
|
|
13
|
-
- Adds `/goal
|
|
12
|
+
- Bare `/goal` shows the current goal summary.
|
|
13
|
+
- Adds `/goal pause`, `/goal resume`, `/goal clear`, and `/goal edit <goal_to_complete>`.
|
|
14
|
+
- Keeps `/goal-status` and `/goal-stop` as compatibility aliases.
|
|
15
|
+
- Supports optional token budgets such as `/goal --tokens 100k <goal>`.
|
|
16
|
+
- Tracks `active`, `paused`, `budget_limited`, and `complete` states.
|
|
17
|
+
- Persists in-progress goal state per working directory under the Pi agent config directory.
|
|
14
18
|
- Registers a `goal_complete` tool for explicit completion.
|
|
15
|
-
- Automatically prompts the agent to continue if
|
|
19
|
+
- Automatically prompts the agent to continue if an active turn ends early.
|
|
20
|
+
- Guards auto-follow-ups so replaced, paused, cleared, completed, or budget-limited goals are not continued.
|
|
16
21
|
- Encourages verification before the goal is marked complete.
|
|
17
22
|
|
|
18
23
|
## 📦 Install
|
|
@@ -36,20 +41,44 @@ pi -e ./extensions/pi-goal
|
|
|
36
41
|
## 🚀 Commands
|
|
37
42
|
|
|
38
43
|
```text
|
|
44
|
+
/goal
|
|
39
45
|
/goal implement snake game
|
|
46
|
+
/goal --tokens 100k fix the failing test and verify it
|
|
47
|
+
/goal pause
|
|
48
|
+
/goal resume
|
|
49
|
+
/goal clear
|
|
50
|
+
/goal edit ship the smaller fix first
|
|
40
51
|
/goal-status
|
|
41
52
|
/goal-stop
|
|
42
53
|
```
|
|
43
54
|
|
|
44
|
-
- `/goal
|
|
45
|
-
- `/goal
|
|
46
|
-
- `/goal
|
|
55
|
+
- `/goal` shows the current goal, status, iteration count, elapsed time, and token usage.
|
|
56
|
+
- `/goal <goal_to_complete>` starts goal mode when no other goal is active.
|
|
57
|
+
- `/goal --tokens 100k <goal_to_complete>` starts goal mode with a token budget. `k` and `m` suffixes are accepted, for example `100k` or `1.5m`.
|
|
58
|
+
- `/goal pause` stops prompt injection and auto-continuation without forgetting the goal.
|
|
59
|
+
- `/goal resume` resumes a paused or budget-limited goal.
|
|
60
|
+
- `/goal clear` cancels the current goal and clears persisted state.
|
|
61
|
+
- `/goal edit <goal_to_complete>` replaces the current goal with a new active goal.
|
|
62
|
+
- `/goal-status` is a compatibility alias for `/goal`.
|
|
63
|
+
- `/goal-stop` is a compatibility alias for `/goal clear`.
|
|
64
|
+
|
|
65
|
+
Goal objectives are limited to 4,000 characters. Put longer instructions in a file and reference the file path from `/goal`.
|
|
66
|
+
|
|
67
|
+
## 📊 Statusline states
|
|
68
|
+
|
|
69
|
+
`pi-goal` writes compact status strings for statusline extensions:
|
|
70
|
+
|
|
71
|
+
- `goal: active 3m` — an active goal without a token budget.
|
|
72
|
+
- `goal: active 18k/100k` — an active goal with token usage and budget.
|
|
73
|
+
- `goal: paused` — auto-continuation is paused.
|
|
74
|
+
- `goal: budget 100k/100k` — the token budget was reached; auto-continuation stops.
|
|
75
|
+
- `goal: complete` — shown briefly after `goal_complete` succeeds.
|
|
47
76
|
|
|
48
77
|
## ✅ How completion works
|
|
49
78
|
|
|
50
79
|
The extension registers a `goal_complete` tool. While a goal is active, the system prompt tells the agent to keep working, verify the result, and call `goal_complete` only when the goal is fully done.
|
|
51
80
|
|
|
52
|
-
If an agent turn ends before `goal_complete` is called, the extension
|
|
81
|
+
If an agent turn ends before `goal_complete` is called, the extension records elapsed time and token usage, checks the budget, verifies that the same goal id is still active, then sends a follow-up prompt to continue the same goal.
|
|
53
82
|
|
|
54
83
|
## 🧠 Use cases
|
|
55
84
|
|
package/package.json
CHANGED
package/src/goal.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
1
5
|
import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
6
|
import { Type } from "typebox";
|
|
3
7
|
|
|
8
|
+
type GoalStatus = "active" | "paused" | "budget_limited" | "complete";
|
|
9
|
+
|
|
4
10
|
interface ActiveGoal {
|
|
11
|
+
id: string;
|
|
5
12
|
text: string;
|
|
13
|
+
status: GoalStatus;
|
|
6
14
|
startedAt: number;
|
|
15
|
+
updatedAt: number;
|
|
7
16
|
iteration: number;
|
|
17
|
+
tokenBudget?: number;
|
|
18
|
+
tokensUsed: number;
|
|
19
|
+
timeUsedSeconds: number;
|
|
20
|
+
baselineTokens: number;
|
|
8
21
|
}
|
|
9
22
|
|
|
10
23
|
interface GoalCompleteDetails {
|
|
@@ -12,7 +25,31 @@ interface GoalCompleteDetails {
|
|
|
12
25
|
summary: string;
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
interface CommandResult {
|
|
29
|
+
kind: "show" | "start" | "pause" | "resume" | "clear" | "edit";
|
|
30
|
+
objective?: string;
|
|
31
|
+
tokenBudget?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface StatusContext {
|
|
35
|
+
cwd: string;
|
|
36
|
+
ui: {
|
|
37
|
+
notify: (message: string, level?: "info" | "warning" | "error") => void;
|
|
38
|
+
setStatus: (key: string, value: string | undefined) => void;
|
|
39
|
+
};
|
|
40
|
+
isIdle?: () => boolean;
|
|
41
|
+
sessionManager?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const STATUS_KEY = "goal";
|
|
45
|
+
const MAX_OBJECTIVE_LENGTH = 4_000;
|
|
46
|
+
const STATE_FILE = join(
|
|
47
|
+
process.env.PI_CODING_AGENT_DIR ?? join(process.env.HOME ?? ".", ".pi", "agent"),
|
|
48
|
+
"pi-goal-state.json",
|
|
49
|
+
);
|
|
50
|
+
|
|
15
51
|
let activeGoal: ActiveGoal | undefined;
|
|
52
|
+
let completionStatusTimer: NodeJS.Timeout | undefined;
|
|
16
53
|
|
|
17
54
|
const goalCompleteTool = defineTool({
|
|
18
55
|
name: "goal_complete",
|
|
@@ -31,12 +68,18 @@ const goalCompleteTool = defineTool({
|
|
|
31
68
|
}),
|
|
32
69
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
33
70
|
const completedGoal = activeGoal;
|
|
34
|
-
|
|
35
|
-
|
|
71
|
+
if (completedGoal) {
|
|
72
|
+
activeGoal = transitionGoal(completedGoal, "complete");
|
|
73
|
+
updateGoalUsage(activeGoal, ctx);
|
|
74
|
+
persistGoal(ctx.cwd, activeGoal);
|
|
75
|
+
}
|
|
36
76
|
|
|
37
77
|
const goal = completedGoal?.text ?? "unknown goal";
|
|
38
78
|
const summary = params.summary.trim();
|
|
39
79
|
|
|
80
|
+
ctx.ui.setStatus(STATUS_KEY, completedGoal ? formatStatus(activeGoal) : undefined);
|
|
81
|
+
clearActiveGoal(ctx);
|
|
82
|
+
showCompletionStatus(ctx);
|
|
40
83
|
ctx.ui.notify(`Goal complete: ${goal}`, "info");
|
|
41
84
|
|
|
42
85
|
return {
|
|
@@ -51,93 +94,422 @@ export default function goal(pi: ExtensionAPI) {
|
|
|
51
94
|
pi.registerTool(goalCompleteTool);
|
|
52
95
|
|
|
53
96
|
pi.registerCommand("goal", {
|
|
54
|
-
description: "Run a goal to completion: /goal <goal_to_complete>",
|
|
97
|
+
description: "Run a goal to completion: /goal [--tokens 100k] <goal_to_complete>",
|
|
55
98
|
handler: async (args, ctx) => {
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
ctx.ui.notify(
|
|
99
|
+
const result = parseCommand(args);
|
|
100
|
+
if (typeof result === "string") {
|
|
101
|
+
ctx.ui.notify(result, "warning");
|
|
59
102
|
return;
|
|
60
103
|
}
|
|
61
104
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
105
|
+
switch (result.kind) {
|
|
106
|
+
case "show":
|
|
107
|
+
showGoal(ctx);
|
|
108
|
+
return;
|
|
109
|
+
case "pause":
|
|
110
|
+
pauseGoal(ctx);
|
|
111
|
+
return;
|
|
112
|
+
case "resume":
|
|
113
|
+
resumeGoal(ctx);
|
|
114
|
+
return;
|
|
115
|
+
case "clear":
|
|
116
|
+
clearGoal(ctx);
|
|
117
|
+
return;
|
|
118
|
+
case "edit":
|
|
119
|
+
editGoal(result.objective ?? "", result.tokenBudget, ctx);
|
|
120
|
+
return;
|
|
121
|
+
case "start":
|
|
122
|
+
startGoal(result.objective ?? "", result.tokenBudget, pi, ctx);
|
|
123
|
+
return;
|
|
76
124
|
}
|
|
77
125
|
},
|
|
78
126
|
});
|
|
79
127
|
|
|
80
128
|
pi.registerCommand("goal-stop", {
|
|
81
|
-
description: "
|
|
82
|
-
handler: async (_args, ctx) =>
|
|
83
|
-
if (!activeGoal) {
|
|
84
|
-
ctx.ui.notify("No active goal.", "info");
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const stoppedGoal = activeGoal.text;
|
|
89
|
-
activeGoal = undefined;
|
|
90
|
-
ctx.ui.setStatus("goal", undefined);
|
|
91
|
-
ctx.ui.notify(`Goal stopped: ${stoppedGoal}`, "warning");
|
|
92
|
-
},
|
|
129
|
+
description: "Compatibility alias for /goal clear",
|
|
130
|
+
handler: async (_args, ctx) => clearGoal(ctx),
|
|
93
131
|
});
|
|
94
132
|
|
|
95
133
|
pi.registerCommand("goal-status", {
|
|
96
|
-
description: "
|
|
97
|
-
handler: async (_args, ctx) =>
|
|
98
|
-
if (!activeGoal) {
|
|
99
|
-
ctx.ui.notify("No active goal.", "info");
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
ctx.ui.notify(`Active goal: ${activeGoal.text} (iteration ${activeGoal.iteration})`, "info");
|
|
104
|
-
},
|
|
134
|
+
description: "Compatibility alias for bare /goal",
|
|
135
|
+
handler: async (_args, ctx) => showGoal(ctx),
|
|
105
136
|
});
|
|
106
137
|
|
|
107
138
|
pi.on("session_start", (_event, ctx) => {
|
|
108
|
-
|
|
139
|
+
activeGoal = loadGoal(ctx.cwd);
|
|
140
|
+
if (activeGoal) updateStatus(ctx, activeGoal);
|
|
141
|
+
else ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
109
142
|
});
|
|
110
143
|
|
|
111
144
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
112
|
-
ctx.
|
|
145
|
+
if (activeGoal) persistGoal(ctx.cwd, activeGoal);
|
|
146
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
147
|
+
if (completionStatusTimer) clearTimeout(completionStatusTimer);
|
|
113
148
|
});
|
|
114
149
|
|
|
115
150
|
pi.on("before_agent_start", (event) => {
|
|
116
|
-
if (!activeGoal) return;
|
|
151
|
+
if (!activeGoal || activeGoal.status !== "active") return;
|
|
117
152
|
|
|
118
153
|
return {
|
|
119
|
-
systemPrompt: `${event.systemPrompt}\n\n${buildGoalSystemPrompt(activeGoal
|
|
154
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildGoalSystemPrompt(activeGoal)}`,
|
|
120
155
|
};
|
|
121
156
|
});
|
|
122
157
|
|
|
123
158
|
pi.on("agent_end", (_event, ctx) => {
|
|
124
|
-
if (!activeGoal) return;
|
|
159
|
+
if (!activeGoal || activeGoal.status !== "active") return;
|
|
160
|
+
|
|
161
|
+
const goalId = activeGoal.id;
|
|
162
|
+
activeGoal = incrementGoal(activeGoal);
|
|
163
|
+
updateGoalUsage(activeGoal, ctx);
|
|
164
|
+
|
|
165
|
+
if (activeGoal.tokenBudget !== undefined && activeGoal.tokensUsed >= activeGoal.tokenBudget) {
|
|
166
|
+
activeGoal = transitionGoal(activeGoal, "budget_limited");
|
|
167
|
+
persistGoal(ctx.cwd, activeGoal);
|
|
168
|
+
updateStatus(ctx, activeGoal);
|
|
169
|
+
ctx.ui.notify(`Goal token budget reached: ${formatBudget(activeGoal)}`, "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
125
172
|
|
|
126
|
-
|
|
127
|
-
ctx
|
|
173
|
+
persistGoal(ctx.cwd, activeGoal);
|
|
174
|
+
updateStatus(ctx, activeGoal);
|
|
128
175
|
|
|
129
|
-
|
|
176
|
+
const currentGoal = activeGoal;
|
|
177
|
+
if (!currentGoal || currentGoal.id !== goalId || currentGoal.status !== "active") return;
|
|
178
|
+
pi.sendUserMessage(buildContinuePrompt(currentGoal), { deliverAs: "followUp" });
|
|
130
179
|
});
|
|
131
180
|
}
|
|
132
181
|
|
|
133
|
-
function
|
|
134
|
-
|
|
182
|
+
function startGoal(objective: string, tokenBudget: number | undefined, pi: ExtensionAPI, ctx: StatusContext) {
|
|
183
|
+
const validationError = validateObjective(objective);
|
|
184
|
+
if (validationError) {
|
|
185
|
+
ctx.ui.notify(validationError, "warning");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (activeGoal && activeGoal.status !== "complete") {
|
|
190
|
+
ctx.ui.notify(
|
|
191
|
+
`A goal is already ${activeGoal.status}. Use /goal edit <objective> to replace it, /goal clear to stop it, or /goal to inspect it.`,
|
|
192
|
+
"warning",
|
|
193
|
+
);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
activeGoal = createGoal(objective, tokenBudget, currentTokenTotal(ctx));
|
|
198
|
+
persistGoal(ctx.cwd, activeGoal);
|
|
199
|
+
updateStatus(ctx, activeGoal);
|
|
200
|
+
ctx.ui.notify(`Goal started: ${objective}`, "info");
|
|
201
|
+
sendGoalPrompt(pi, ctx, activeGoal);
|
|
135
202
|
}
|
|
136
203
|
|
|
137
|
-
function
|
|
138
|
-
|
|
204
|
+
function pauseGoal(ctx: StatusContext) {
|
|
205
|
+
if (!activeGoal) {
|
|
206
|
+
ctx.ui.notify("No active goal.", "info");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (activeGoal.status !== "active") {
|
|
210
|
+
ctx.ui.notify(`Goal is ${activeGoal.status}; only active goals can be paused.`, "warning");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
activeGoal = transitionGoal(activeGoal, "paused");
|
|
214
|
+
persistGoal(ctx.cwd, activeGoal);
|
|
215
|
+
updateStatus(ctx, activeGoal);
|
|
216
|
+
ctx.ui.notify(`Goal paused: ${activeGoal.text}`, "info");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resumeGoal(ctx: StatusContext) {
|
|
220
|
+
if (!activeGoal) {
|
|
221
|
+
ctx.ui.notify("No active goal.", "info");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (activeGoal.status !== "paused" && activeGoal.status !== "budget_limited") {
|
|
225
|
+
ctx.ui.notify(`Goal is ${activeGoal.status}; only paused or budget-limited goals can be resumed.`, "warning");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
activeGoal = transitionGoal(activeGoal, "active");
|
|
229
|
+
persistGoal(ctx.cwd, activeGoal);
|
|
230
|
+
updateStatus(ctx, activeGoal);
|
|
231
|
+
ctx.ui.notify(`Goal resumed: ${activeGoal.text}`, "info");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function clearGoal(ctx: StatusContext) {
|
|
235
|
+
if (!activeGoal) {
|
|
236
|
+
ctx.ui.notify("No active goal.", "info");
|
|
237
|
+
clearPersistedGoal(ctx.cwd);
|
|
238
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const stoppedGoal = activeGoal.text;
|
|
243
|
+
clearActiveGoal(ctx);
|
|
244
|
+
ctx.ui.notify(`Goal cleared: ${stoppedGoal}`, "warning");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function editGoal(objective: string, tokenBudget: number | undefined, ctx: StatusContext) {
|
|
248
|
+
const validationError = validateObjective(objective);
|
|
249
|
+
if (validationError) {
|
|
250
|
+
ctx.ui.notify(validationError, "warning");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
activeGoal = createGoal(objective, tokenBudget, currentTokenTotal(ctx));
|
|
255
|
+
persistGoal(ctx.cwd, activeGoal);
|
|
256
|
+
updateStatus(ctx, activeGoal);
|
|
257
|
+
ctx.ui.notify(`Goal updated: ${objective}`, "info");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function showGoal(ctx: StatusContext) {
|
|
261
|
+
if (!activeGoal) {
|
|
262
|
+
ctx.ui.notify("No active goal.", "info");
|
|
263
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
updateGoalUsage(activeGoal, ctx);
|
|
267
|
+
persistGoal(ctx.cwd, activeGoal);
|
|
268
|
+
updateStatus(ctx, activeGoal);
|
|
269
|
+
ctx.ui.notify(goalSummary(activeGoal), "info");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function createGoal(text: string, tokenBudget: number | undefined, baselineTokens: number): ActiveGoal {
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
return {
|
|
275
|
+
id: randomUUID(),
|
|
276
|
+
text,
|
|
277
|
+
status: "active",
|
|
278
|
+
startedAt: now,
|
|
279
|
+
updatedAt: now,
|
|
280
|
+
iteration: 0,
|
|
281
|
+
tokenBudget,
|
|
282
|
+
tokensUsed: 0,
|
|
283
|
+
timeUsedSeconds: 0,
|
|
284
|
+
baselineTokens,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function transitionGoal(goal: ActiveGoal, status: GoalStatus): ActiveGoal {
|
|
289
|
+
return { ...goal, status, updatedAt: Date.now() };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function incrementGoal(goal: ActiveGoal): ActiveGoal {
|
|
293
|
+
return { ...goal, iteration: goal.iteration + 1, updatedAt: Date.now() };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function updateGoalUsage(goal: ActiveGoal, ctx: StatusContext) {
|
|
297
|
+
goal.tokensUsed = Math.max(0, currentTokenTotal(ctx) - goal.baselineTokens);
|
|
298
|
+
goal.timeUsedSeconds = Math.max(0, Math.floor((Date.now() - goal.startedAt) / 1000));
|
|
299
|
+
goal.updatedAt = Date.now();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function parseCommand(args: string): CommandResult | string {
|
|
303
|
+
const tokens = tokenize(args.trim());
|
|
304
|
+
if (tokens.length === 0) return { kind: "show" };
|
|
305
|
+
|
|
306
|
+
const [first, ...rest] = tokens;
|
|
307
|
+
if (first === "pause") return rest.length === 0 ? { kind: "pause" } : "Usage: /goal pause";
|
|
308
|
+
if (first === "resume") return rest.length === 0 ? { kind: "resume" } : "Usage: /goal resume";
|
|
309
|
+
if (first === "clear" || first === "stop") return rest.length === 0 ? { kind: "clear" } : "Usage: /goal clear";
|
|
310
|
+
if (first === "status") return rest.length === 0 ? { kind: "show" } : "Usage: /goal status";
|
|
311
|
+
if (first === "edit") return parseObjective("edit", rest);
|
|
312
|
+
return parseObjective("start", tokens);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function parseObjective(kind: "start" | "edit", tokens: string[]): CommandResult | string {
|
|
316
|
+
let tokenBudget: number | undefined;
|
|
317
|
+
const objectiveTokens = [...tokens];
|
|
318
|
+
|
|
319
|
+
if (objectiveTokens[0] === "--tokens") {
|
|
320
|
+
const rawBudget = objectiveTokens[1];
|
|
321
|
+
if (!rawBudget) return "Usage: /goal --tokens 100k <goal_to_complete>";
|
|
322
|
+
const parsedBudget = parseTokenBudget(rawBudget);
|
|
323
|
+
if (parsedBudget === undefined) return `Invalid token budget: ${rawBudget}`;
|
|
324
|
+
tokenBudget = parsedBudget;
|
|
325
|
+
objectiveTokens.splice(0, 2);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (objectiveTokens.length === 0) {
|
|
329
|
+
return kind === "edit" ? "Usage: /goal edit <goal_to_complete>" : "Usage: /goal <goal_to_complete>";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { kind, objective: objectiveTokens.join(" "), tokenBudget };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function tokenize(input: string): string[] {
|
|
336
|
+
const tokens: string[] = [];
|
|
337
|
+
let current = "";
|
|
338
|
+
let quote: '"' | "'" | undefined;
|
|
339
|
+
|
|
340
|
+
for (const char of input) {
|
|
341
|
+
if (quote) {
|
|
342
|
+
if (char === quote) quote = undefined;
|
|
343
|
+
else current += char;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (char === '"' || char === "'") {
|
|
347
|
+
quote = char;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (/\s/.test(char)) {
|
|
351
|
+
if (current) tokens.push(current);
|
|
352
|
+
current = "";
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
current += char;
|
|
356
|
+
}
|
|
357
|
+
if (current) tokens.push(current);
|
|
358
|
+
return tokens;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseTokenBudget(value: string): number | undefined {
|
|
362
|
+
const match = /^(\d+(?:\.\d+)?)([km])?$/iu.exec(value.trim());
|
|
363
|
+
if (!match) return undefined;
|
|
364
|
+
const amount = Number(match[1]);
|
|
365
|
+
if (!Number.isFinite(amount) || amount <= 0) return undefined;
|
|
366
|
+
const multiplier = match[2]?.toLowerCase() === "m" ? 1_000_000 : match[2]?.toLowerCase() === "k" ? 1_000 : 1;
|
|
367
|
+
return Math.floor(amount * multiplier);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function validateObjective(objective: string): string | undefined {
|
|
371
|
+
const trimmed = objective.trim();
|
|
372
|
+
if (!trimmed) return "Usage: /goal <goal_to_complete>";
|
|
373
|
+
if (trimmed.length > MAX_OBJECTIVE_LENGTH) {
|
|
374
|
+
return `Goal objective is too long (${trimmed.length}/${MAX_OBJECTIVE_LENGTH} characters). Put long instructions in a file and reference it from /goal instead.`;
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function sendGoalPrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
|
|
380
|
+
const prompt = buildGoalPrompt(goal);
|
|
381
|
+
if (ctx.isIdle?.()) pi.sendUserMessage(prompt);
|
|
382
|
+
else pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function updateStatus(ctx: StatusContext, goal: ActiveGoal) {
|
|
386
|
+
ctx.ui.setStatus(STATUS_KEY, formatStatus(goal));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function formatStatus(goal: ActiveGoal | undefined) {
|
|
390
|
+
if (!goal) return undefined;
|
|
391
|
+
if (goal.status === "complete") return "goal: complete";
|
|
392
|
+
if (goal.status === "paused") return "goal: paused";
|
|
393
|
+
if (goal.status === "budget_limited") return `goal: budget ${formatBudget(goal)}`;
|
|
394
|
+
if (goal.tokenBudget !== undefined) return `goal: active ${formatBudget(goal)}`;
|
|
395
|
+
return `goal: active ${formatDuration(goal.timeUsedSeconds)}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function formatBudget(goal: ActiveGoal) {
|
|
399
|
+
return `${formatTokenCount(goal.tokensUsed)}/${formatTokenCount(goal.tokenBudget ?? 0)}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function goalSummary(goal: ActiveGoal) {
|
|
403
|
+
return [
|
|
404
|
+
`Goal: ${goal.text}`,
|
|
405
|
+
`Status: ${goal.status}`,
|
|
406
|
+
`Iteration: ${goal.iteration}`,
|
|
407
|
+
`Elapsed: ${formatDuration(goal.timeUsedSeconds)}`,
|
|
408
|
+
`Tokens: ${goal.tokenBudget === undefined ? formatTokenCount(goal.tokensUsed) : formatBudget(goal)}`,
|
|
409
|
+
].join("\n");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function formatDuration(seconds: number) {
|
|
413
|
+
if (seconds < 60) return `${seconds}s`;
|
|
414
|
+
const minutes = Math.floor(seconds / 60);
|
|
415
|
+
if (minutes < 60) return `${minutes}m`;
|
|
416
|
+
const hours = Math.floor(minutes / 60);
|
|
417
|
+
return `${hours}h${minutes % 60}m`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function formatTokenCount(value: number) {
|
|
421
|
+
if (value < 1_000) return `${value}`;
|
|
422
|
+
if (value < 1_000_000) return `${Number.isInteger(value / 1_000) ? value / 1_000 : (value / 1_000).toFixed(1)}k`;
|
|
423
|
+
return `${Number.isInteger(value / 1_000_000) ? value / 1_000_000 : (value / 1_000_000).toFixed(1)}m`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function buildGoalPrompt(goal: ActiveGoal) {
|
|
427
|
+
const budgetLine = goal.tokenBudget === undefined ? "" : `\nToken budget: ${formatTokenCount(goal.tokenBudget)}.`;
|
|
428
|
+
return `Goal mode is active. Complete this goal fully:\n\n${goal.text}${budgetLine}\n\nKeep working until the goal is done. Do not stop after planning or partial progress. When the goal is fully complete and verified, call the goal_complete tool with a concise completion summary.`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function buildGoalSystemPrompt(goal: ActiveGoal) {
|
|
432
|
+
const budgetLine = goal.tokenBudget === undefined ? "" : `\n- Respect the goal token budget (${formatBudget(goal)} used).`;
|
|
433
|
+
return `Active /goal: ${goal.text}\n\nGoal-mode rules:\n- Continue making concrete progress until the active goal is fully complete.\n- Do not end your response with only a plan, TODO list, or partial progress.\n- Verify the result when possible using appropriate checks.\n- If the goal is not complete at the end of a turn, expect an automatic follow-up and continue from where you left off.\n- Only call the goal_complete tool after the goal is fully complete and verified.${budgetLine}`;
|
|
139
434
|
}
|
|
140
435
|
|
|
141
436
|
function buildContinuePrompt(goal: ActiveGoal) {
|
|
142
437
|
return `Continue the active /goal until it is complete:\n\n${goal.text}\n\nThis is automatic continuation #${goal.iteration}. If the goal is not complete yet, keep working and verify progress. If it is fully complete and verified, call the goal_complete tool.`;
|
|
143
438
|
}
|
|
439
|
+
|
|
440
|
+
function currentTokenTotal(ctx: StatusContext): number {
|
|
441
|
+
const sessionManager = ctx.sessionManager as
|
|
442
|
+
| { getBranch?: () => Array<{ type?: string; message?: { role?: string; usage?: unknown } }> }
|
|
443
|
+
| undefined;
|
|
444
|
+
const branch = sessionManager?.getBranch?.() ?? [];
|
|
445
|
+
let total = 0;
|
|
446
|
+
for (const entry of branch) {
|
|
447
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
|
|
448
|
+
const usage = entry.message.usage as { input?: number; output?: number } | undefined;
|
|
449
|
+
total += usage?.input ?? 0;
|
|
450
|
+
total += usage?.output ?? 0;
|
|
451
|
+
}
|
|
452
|
+
return total;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function persistGoal(cwd: string, goal: ActiveGoal) {
|
|
456
|
+
writeState(cwd, goal);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function clearPersistedGoal(cwd: string) {
|
|
460
|
+
writeState(cwd, undefined);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function loadGoal(cwd: string): ActiveGoal | undefined {
|
|
464
|
+
const goals = readState();
|
|
465
|
+
const stored = goals[cwd];
|
|
466
|
+
return isGoal(stored) && stored.status !== "complete" ? stored : undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function clearActiveGoal(ctx: StatusContext) {
|
|
470
|
+
activeGoal = undefined;
|
|
471
|
+
clearPersistedGoal(ctx.cwd);
|
|
472
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function showCompletionStatus(ctx: StatusContext) {
|
|
476
|
+
if (completionStatusTimer) clearTimeout(completionStatusTimer);
|
|
477
|
+
ctx.ui.setStatus(STATUS_KEY, "goal: complete");
|
|
478
|
+
completionStatusTimer = setTimeout(() => ctx.ui.setStatus(STATUS_KEY, undefined), 8_000);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function readState(): Record<string, unknown> {
|
|
482
|
+
if (!existsSync(STATE_FILE)) return {};
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(readFileSync(STATE_FILE, "utf8")) as unknown;
|
|
485
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
486
|
+
? (parsed as Record<string, unknown>)
|
|
487
|
+
: {};
|
|
488
|
+
} catch {
|
|
489
|
+
return {};
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function writeState(cwd: string, goal: ActiveGoal | undefined) {
|
|
494
|
+
const goals = readState();
|
|
495
|
+
if (goal) goals[cwd] = goal;
|
|
496
|
+
else delete goals[cwd];
|
|
497
|
+
mkdirSync(dirname(STATE_FILE), { recursive: true });
|
|
498
|
+
writeFileSync(STATE_FILE, `${JSON.stringify(goals, null, 2)}\n`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function isGoal(value: unknown): value is ActiveGoal {
|
|
502
|
+
if (!value || typeof value !== "object") return false;
|
|
503
|
+
const goal = value as Partial<ActiveGoal>;
|
|
504
|
+
return (
|
|
505
|
+
typeof goal.id === "string" &&
|
|
506
|
+
typeof goal.text === "string" &&
|
|
507
|
+
["active", "paused", "budget_limited", "complete"].includes(String(goal.status)) &&
|
|
508
|
+
typeof goal.startedAt === "number" &&
|
|
509
|
+
typeof goal.updatedAt === "number" &&
|
|
510
|
+
typeof goal.iteration === "number" &&
|
|
511
|
+
typeof goal.tokensUsed === "number" &&
|
|
512
|
+
typeof goal.timeUsedSeconds === "number" &&
|
|
513
|
+
typeof goal.baselineTokens === "number"
|
|
514
|
+
);
|
|
515
|
+
}
|