pi-goal 0.1.0 → 0.1.2
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/.pi/extensions/pi-goal/index.ts +167 -37
- package/README.md +7 -3
- package/package.json +3 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Box, Spacer, Text } from "@mariozechner/pi-tui";
|
|
2
3
|
|
|
3
4
|
const CUSTOM_TYPE = "pi-goal";
|
|
5
|
+
const EVENT_TYPE = "pi-goal-event";
|
|
4
6
|
|
|
5
7
|
type GoalStatus = "active" | "paused" | "budget_limited" | "complete";
|
|
6
8
|
|
|
@@ -16,9 +18,13 @@ type GoalState = {
|
|
|
16
18
|
updatedAt: number;
|
|
17
19
|
};
|
|
18
20
|
|
|
21
|
+
type GoalEventKind = "active" | "continuation" | "paused" | "resumed" | "cleared" | "budget_limited" | "complete";
|
|
22
|
+
|
|
19
23
|
let goal: GoalState | null = null;
|
|
24
|
+
let statusBarEnabled = true;
|
|
20
25
|
let activeTurnStartedAt: number | null = null;
|
|
21
26
|
let continuationQueued = false;
|
|
27
|
+
let pendingControlPrompt: string | null = null;
|
|
22
28
|
|
|
23
29
|
function parseTokenBudget(input: string): { objective: string; tokenBudget: number | null; error?: string } {
|
|
24
30
|
const match = input.match(/(?:^|\s)--tokens(?:=|\s+)([0-9]+(?:\.[0-9]+)?\s*[kKmM]?)(?:\s|$)/);
|
|
@@ -61,22 +67,85 @@ function statusLine(state: GoalState | null): string | undefined {
|
|
|
61
67
|
return `Goal achieved${budget}`;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
function
|
|
70
|
+
function goalUsage(state: GoalState): string {
|
|
71
|
+
if (state.tokenBudget != null) return `${formatTokens(state.tokensUsed)} / ${formatTokens(state.tokenBudget)} tokens`;
|
|
72
|
+
return formatElapsed(state.timeUsedSeconds);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function tokenDeltaFromUsage(usage: any): number {
|
|
76
|
+
if (!usage) return 0;
|
|
77
|
+
if (typeof usage.totalTokens === "number") return Math.max(0, usage.totalTokens);
|
|
78
|
+
return Math.max(0, (Number(usage.input) || 0) + (Number(usage.output) || 0));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function truncateObjective(objective: string, max = 96): string {
|
|
82
|
+
const singleLine = objective.replace(/\s+/g, " ").trim();
|
|
83
|
+
return singleLine.length > max ? `${singleLine.slice(0, max - 1)}…` : singleLine;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function goalEventStatus(kind: GoalEventKind): string {
|
|
87
|
+
const labels: Record<GoalEventKind, string> = {
|
|
88
|
+
active: "active",
|
|
89
|
+
continuation: "continuing",
|
|
90
|
+
paused: "paused",
|
|
91
|
+
resumed: "resumed",
|
|
92
|
+
cleared: "cleared",
|
|
93
|
+
budget_limited: "budget reached",
|
|
94
|
+
complete: "achieved",
|
|
95
|
+
};
|
|
96
|
+
return labels[kind];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function emitGoalEvent(
|
|
100
|
+
pi: ExtensionAPI,
|
|
101
|
+
kind: GoalEventKind,
|
|
102
|
+
state: GoalState | null,
|
|
103
|
+
content?: string,
|
|
104
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
105
|
+
) {
|
|
106
|
+
const text = content ?? `Goal ${goalEventStatus(kind)} (ctrl+o to expand)`;
|
|
107
|
+
pi.sendMessage(
|
|
108
|
+
{
|
|
109
|
+
customType: EVENT_TYPE,
|
|
110
|
+
content: text,
|
|
111
|
+
display: true,
|
|
112
|
+
details: {
|
|
113
|
+
kind,
|
|
114
|
+
goal: state,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
options,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function latestStateFromSession(ctx: ExtensionContext): { goal: GoalState | null; statusBarEnabled: boolean } {
|
|
65
123
|
const entries = ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries();
|
|
66
124
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
67
125
|
const entry = entries[i] as any;
|
|
68
126
|
if (entry.type === "custom" && entry.customType === CUSTOM_TYPE) {
|
|
69
|
-
return
|
|
127
|
+
return {
|
|
128
|
+
goal: entry.data?.goal ?? null,
|
|
129
|
+
statusBarEnabled: entry.data?.statusBarEnabled ?? true,
|
|
130
|
+
};
|
|
70
131
|
}
|
|
71
132
|
}
|
|
72
|
-
return null;
|
|
133
|
+
return { goal: null, statusBarEnabled: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function updateStatusBar(ctx: ExtensionContext) {
|
|
137
|
+
ctx.ui.setStatus(CUSTOM_TYPE, statusBarEnabled ? statusLine(goal) ?? "" : "");
|
|
73
138
|
}
|
|
74
139
|
|
|
75
140
|
function persist(pi: ExtensionAPI, ctx: ExtensionContext, next: GoalState | null) {
|
|
76
141
|
goal = next;
|
|
77
|
-
pi.appendEntry(CUSTOM_TYPE, { goal: next });
|
|
78
|
-
|
|
79
|
-
|
|
142
|
+
pi.appendEntry(CUSTOM_TYPE, { goal: next, statusBarEnabled });
|
|
143
|
+
updateStatusBar(ctx);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function persistSettings(pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
147
|
+
pi.appendEntry(CUSTOM_TYPE, { goal, statusBarEnabled });
|
|
148
|
+
updateStatusBar(ctx);
|
|
80
149
|
}
|
|
81
150
|
|
|
82
151
|
function continuationPrompt(state: GoalState): string {
|
|
@@ -131,25 +200,49 @@ The system has marked the goal as budget_limited, so do not start new substantiv
|
|
|
131
200
|
Do not call update_goal unless the goal is actually complete.`;
|
|
132
201
|
}
|
|
133
202
|
|
|
134
|
-
function
|
|
203
|
+
function queueContinuation(pi: ExtensionAPI, state: GoalState) {
|
|
135
204
|
if (continuationQueued || state.status !== "active") return;
|
|
136
205
|
continuationQueued = true;
|
|
137
206
|
queueMicrotask(() => {
|
|
138
207
|
continuationQueued = false;
|
|
139
208
|
if (!goal || goal.id !== state.id || goal.status !== "active") return;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
customType: CUSTOM_TYPE,
|
|
143
|
-
content: continuationPrompt(goal),
|
|
144
|
-
display: false,
|
|
145
|
-
details: { kind: "goal-continuation", goalId: goal.id },
|
|
146
|
-
},
|
|
147
|
-
{ triggerTurn: true, deliverAs: "followUp" },
|
|
148
|
-
);
|
|
209
|
+
pendingControlPrompt = continuationPrompt(goal);
|
|
210
|
+
emitGoalEvent(pi, "continuation", goal, undefined, { triggerTurn: true, deliverAs: "followUp" });
|
|
149
211
|
});
|
|
150
212
|
}
|
|
151
213
|
|
|
152
214
|
export default function piGoal(pi: ExtensionAPI) {
|
|
215
|
+
pi.registerMessageRenderer(EVENT_TYPE, (message, { expanded }, theme) => {
|
|
216
|
+
const details = message.details as { kind?: GoalEventKind; goal?: GoalState | null; timestamp?: number } | undefined;
|
|
217
|
+
const kind = details?.kind ?? "continuation";
|
|
218
|
+
const state = details?.goal ?? null;
|
|
219
|
+
const box = new Box(1, 1, (value) => theme.bg("customMessageBg", value));
|
|
220
|
+
box.addChild(new Text(theme.fg("customMessageLabel", theme.bold("Goal")), 0, 0));
|
|
221
|
+
box.addChild(new Spacer(1));
|
|
222
|
+
if (!expanded) {
|
|
223
|
+
box.addChild(new Text(`${theme.fg("customMessageText", goalEventStatus(kind))} ${theme.fg("dim", "(ctrl+o to expand)")}`, 0, 0));
|
|
224
|
+
return box;
|
|
225
|
+
}
|
|
226
|
+
const lines = [
|
|
227
|
+
`${theme.fg("dim", "Status: ")}${theme.fg("customMessageText", goalEventStatus(kind))}`,
|
|
228
|
+
];
|
|
229
|
+
if (state) {
|
|
230
|
+
lines.push(`${theme.fg("dim", "Goal: ")}${theme.fg("customMessageText", state.objective)}`);
|
|
231
|
+
lines.push(`${theme.fg("dim", "Usage: ")}${theme.fg("customMessageText", goalUsage(state))}`);
|
|
232
|
+
}
|
|
233
|
+
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
234
|
+
return box;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.on("before_agent_start", (event) => {
|
|
238
|
+
const prompt = pendingControlPrompt;
|
|
239
|
+
pendingControlPrompt = null;
|
|
240
|
+
if (!prompt) return;
|
|
241
|
+
return {
|
|
242
|
+
systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
|
|
153
246
|
pi.registerTool({
|
|
154
247
|
name: "get_goal",
|
|
155
248
|
label: "Get Goal",
|
|
@@ -196,6 +289,7 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
196
289
|
const now = Date.now();
|
|
197
290
|
const next: GoalState = { ...goal, status: "complete", updatedAt: now };
|
|
198
291
|
persist(pi, ctx, next);
|
|
292
|
+
emitGoalEvent(pi, "complete", next);
|
|
199
293
|
return {
|
|
200
294
|
content: [{ type: "text", text: JSON.stringify({ goal: next, remainingTokens: next.tokenBudget == null ? null : Math.max(0, next.tokenBudget - next.tokensUsed) }, null, 2) }],
|
|
201
295
|
details: { goal: next },
|
|
@@ -204,9 +298,9 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
204
298
|
});
|
|
205
299
|
|
|
206
300
|
pi.registerCommand("goal", {
|
|
207
|
-
description: "Set, view, pause, resume, or
|
|
301
|
+
description: "Set, view, pause, resume, clear, or configure a long-running goal",
|
|
208
302
|
getArgumentCompletions: (prefix) => {
|
|
209
|
-
const values = ["pause", "resume", "clear", "status"];
|
|
303
|
+
const values = ["pause", "resume", "clear", "status", "statusbar", "statusbar on", "statusbar off"];
|
|
210
304
|
const filtered = values.filter((value) => value.startsWith(prefix));
|
|
211
305
|
return filtered.length ? filtered.map((value) => ({ value, label: value })) : null;
|
|
212
306
|
},
|
|
@@ -216,13 +310,22 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
216
310
|
|
|
217
311
|
if (!trimmed || trimmed === "status") {
|
|
218
312
|
if (!goal) ctx.ui.notify("Usage: /goal [--tokens 50k] <objective>", "info");
|
|
219
|
-
else ctx.ui.notify(`${statusLine(goal)}\nObjective: ${goal.objective}`, "info");
|
|
313
|
+
else ctx.ui.notify(`${statusLine(goal)}\nObjective: ${goal.objective}\nStatus bar: ${statusBarEnabled ? "on" : "off"}`, "info");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (trimmed === "statusbar" || trimmed === "statusbar toggle" || trimmed === "statusbar on" || trimmed === "statusbar off") {
|
|
318
|
+
const [, value] = trimmed.split(/\s+/, 2);
|
|
319
|
+
statusBarEnabled = value === "on" ? true : value === "off" ? false : !statusBarEnabled;
|
|
320
|
+
persistSettings(pi, ctx);
|
|
321
|
+
ctx.ui.notify(`Goal status bar ${statusBarEnabled ? "enabled" : "disabled"}.`, "info");
|
|
220
322
|
return;
|
|
221
323
|
}
|
|
222
324
|
|
|
223
325
|
if (trimmed === "clear") {
|
|
326
|
+
const previous = goal;
|
|
224
327
|
persist(pi, ctx, null);
|
|
225
|
-
|
|
328
|
+
emitGoalEvent(pi, "cleared", previous);
|
|
226
329
|
return;
|
|
227
330
|
}
|
|
228
331
|
|
|
@@ -234,8 +337,8 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
234
337
|
const status: GoalStatus = trimmed === "pause" ? "paused" : "active";
|
|
235
338
|
const next = { ...goal, status, updatedAt: now };
|
|
236
339
|
persist(pi, ctx, next);
|
|
237
|
-
|
|
238
|
-
if (status === "active" && ctx.isIdle())
|
|
340
|
+
emitGoalEvent(pi, status === "active" ? "resumed" : "paused", next);
|
|
341
|
+
if (status === "active" && ctx.isIdle()) queueContinuation(pi, next);
|
|
239
342
|
return;
|
|
240
343
|
}
|
|
241
344
|
|
|
@@ -264,15 +367,42 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
264
367
|
updatedAt: now,
|
|
265
368
|
};
|
|
266
369
|
persist(pi, ctx, next);
|
|
267
|
-
ctx.
|
|
268
|
-
|
|
370
|
+
if (ctx.isIdle()) {
|
|
371
|
+
pendingControlPrompt = continuationPrompt(next);
|
|
372
|
+
emitGoalEvent(pi, "active", next, undefined, { triggerTurn: true });
|
|
373
|
+
} else {
|
|
374
|
+
emitGoalEvent(pi, "active", next);
|
|
375
|
+
}
|
|
269
376
|
},
|
|
270
377
|
});
|
|
271
378
|
|
|
272
|
-
pi.on("session_start", (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
379
|
+
pi.on("session_start", (event, ctx) => {
|
|
380
|
+
const restored = latestStateFromSession(ctx);
|
|
381
|
+
goal = restored.goal;
|
|
382
|
+
statusBarEnabled = restored.statusBarEnabled;
|
|
383
|
+
pendingControlPrompt = null;
|
|
384
|
+
continuationQueued = false;
|
|
385
|
+
activeTurnStartedAt = null;
|
|
386
|
+
if (goal?.status === "active" && event.reason === "reload") {
|
|
387
|
+
goal = { ...goal, status: "paused", updatedAt: Date.now() };
|
|
388
|
+
persist(pi, ctx, goal);
|
|
389
|
+
emitGoalEvent(
|
|
390
|
+
pi,
|
|
391
|
+
"paused",
|
|
392
|
+
goal,
|
|
393
|
+
`Ⅱ goal paused after reload: ${truncateObjective(goal.objective)}\nUse /goal resume to continue, or /goal clear to stop.`,
|
|
394
|
+
);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
updateStatusBar(ctx);
|
|
398
|
+
if (goal?.status === "active") {
|
|
399
|
+
emitGoalEvent(
|
|
400
|
+
pi,
|
|
401
|
+
"active",
|
|
402
|
+
goal,
|
|
403
|
+
`⚑ goal restored: ${truncateObjective(goal.objective)}\nUse /goal pause to stop continuation, or /goal clear to remove it.`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
276
406
|
});
|
|
277
407
|
|
|
278
408
|
pi.on("turn_start", (_event, _ctx) => {
|
|
@@ -283,8 +413,7 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
283
413
|
if (!goal || goal.status !== "active") return;
|
|
284
414
|
const elapsed = activeTurnStartedAt ? Math.max(0, Math.round((Date.now() - activeTurnStartedAt) / 1000)) : 0;
|
|
285
415
|
activeTurnStartedAt = null;
|
|
286
|
-
const
|
|
287
|
-
const tokenDelta = Math.max(0, Number(usage?.totalTokens ?? usage?.input + usage?.output ?? 0) || 0);
|
|
416
|
+
const tokenDelta = tokenDeltaFromUsage((event.message as any)?.usage);
|
|
288
417
|
let next: GoalState = {
|
|
289
418
|
...goal,
|
|
290
419
|
tokensUsed: goal.tokensUsed + tokenDelta,
|
|
@@ -296,16 +425,17 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
296
425
|
}
|
|
297
426
|
persist(pi, ctx, next);
|
|
298
427
|
if (next.status === "budget_limited") {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
{ triggerTurn: true, deliverAs: "followUp" },
|
|
302
|
-
);
|
|
428
|
+
pendingControlPrompt = budgetLimitPrompt(next);
|
|
429
|
+
emitGoalEvent(pi, "budget_limited", next, undefined, { triggerTurn: true, deliverAs: "followUp" });
|
|
303
430
|
}
|
|
304
431
|
});
|
|
305
432
|
|
|
306
433
|
pi.on("agent_end", (_event, ctx) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
434
|
+
const currentGoal = goal;
|
|
435
|
+
if (!currentGoal || currentGoal.status !== "active" || ctx.hasPendingMessages()) return;
|
|
436
|
+
setTimeout(() => {
|
|
437
|
+
if (!goal || goal.id !== currentGoal.id || goal.status !== "active") return;
|
|
438
|
+
queueContinuation(pi, goal);
|
|
439
|
+
}, 0);
|
|
310
440
|
});
|
|
311
441
|
}
|
package/README.md
CHANGED
|
@@ -25,9 +25,12 @@ pi install git:github.com/Michaelliv/pi-goal
|
|
|
25
25
|
/goal pause
|
|
26
26
|
/goal resume
|
|
27
27
|
/goal clear
|
|
28
|
+
/goal statusbar off
|
|
28
29
|
```
|
|
29
30
|
|
|
30
|
-
When a goal is active, the extension
|
|
31
|
+
When a goal is active, the extension shows compact visible lifecycle markers like `Goal active` and `Goal continuing`; expand them with `ctrl+o` to inspect the objective and usage. The actual continuation instructions are injected into the next turn's system prompt, so the full prompt does not clutter the transcript.
|
|
32
|
+
|
|
33
|
+
The same Pi agent keeps running normal turns in the same session context until it calls `update_goal({ status: "complete" })`, the user pauses/clears it, or the token budget is reached. Reloading Pi pauses an active goal instead of silently resuming it; use `/goal resume` to continue.
|
|
31
34
|
|
|
32
35
|
## What it adds
|
|
33
36
|
|
|
@@ -36,6 +39,7 @@ When a goal is active, the extension injects a hidden continuation prompt after
|
|
|
36
39
|
- `/goal pause`: stop autonomous continuation without deleting the goal
|
|
37
40
|
- `/goal resume`: reactivate a paused goal
|
|
38
41
|
- `/goal clear`: remove the goal
|
|
42
|
+
- `/goal statusbar on|off`: show or hide the footer status line
|
|
39
43
|
- `get_goal` tool: read current goal state
|
|
40
44
|
- `update_goal` tool: model can only mark the goal `complete`
|
|
41
45
|
- footer status: `Pursuing goal`, `Goal paused`, `Goal achieved`, or `Goal unmet`
|
|
@@ -45,8 +49,8 @@ When a goal is active, the extension injects a hidden continuation prompt after
|
|
|
45
49
|
```text
|
|
46
50
|
/goal <objective>
|
|
47
51
|
-> persist goal in the current Pi session
|
|
48
|
-
-> show footer status
|
|
49
|
-
-> inject hidden continuation
|
|
52
|
+
-> show compact Goal marker and footer status
|
|
53
|
+
-> inject hidden continuation instructions into the next system prompt
|
|
50
54
|
-> trigger an agent turn
|
|
51
55
|
-> account time/tokens on turn_end
|
|
52
56
|
-> queue another continuation on agent_end while active
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Persistent autonomous goals for pi — /goal loops until complete, paused, or budget-limited",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"keywords": [
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
]
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@mariozechner/pi-coding-agent": "*"
|
|
24
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
25
|
+
"@mariozechner/pi-tui": "*"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
27
28
|
"check": "pi --no-extensions -e ./.pi/extensions/pi-goal/index.ts --list-models __pi_goal_load_check__",
|