pi-goal 0.1.0 → 0.1.1
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 +134 -26
- package/README.md +5 -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,12 @@ 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;
|
|
20
24
|
let activeTurnStartedAt: number | null = null;
|
|
21
25
|
let continuationQueued = false;
|
|
26
|
+
let pendingControlPrompt: string | null = null;
|
|
22
27
|
|
|
23
28
|
function parseTokenBudget(input: string): { objective: string; tokenBudget: number | null; error?: string } {
|
|
24
29
|
const match = input.match(/(?:^|\s)--tokens(?:=|\s+)([0-9]+(?:\.[0-9]+)?\s*[kKmM]?)(?:\s|$)/);
|
|
@@ -61,6 +66,58 @@ function statusLine(state: GoalState | null): string | undefined {
|
|
|
61
66
|
return `Goal achieved${budget}`;
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
function goalUsage(state: GoalState): string {
|
|
70
|
+
if (state.tokenBudget != null) return `${formatTokens(state.tokensUsed)} / ${formatTokens(state.tokenBudget)} tokens`;
|
|
71
|
+
return formatElapsed(state.timeUsedSeconds);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tokenDeltaFromUsage(usage: any): number {
|
|
75
|
+
if (!usage) return 0;
|
|
76
|
+
if (typeof usage.totalTokens === "number") return Math.max(0, usage.totalTokens);
|
|
77
|
+
return Math.max(0, (Number(usage.input) || 0) + (Number(usage.output) || 0));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function truncateObjective(objective: string, max = 96): string {
|
|
81
|
+
const singleLine = objective.replace(/\s+/g, " ").trim();
|
|
82
|
+
return singleLine.length > max ? `${singleLine.slice(0, max - 1)}…` : singleLine;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function goalEventStatus(kind: GoalEventKind): string {
|
|
86
|
+
const labels: Record<GoalEventKind, string> = {
|
|
87
|
+
active: "active",
|
|
88
|
+
continuation: "continuing",
|
|
89
|
+
paused: "paused",
|
|
90
|
+
resumed: "resumed",
|
|
91
|
+
cleared: "cleared",
|
|
92
|
+
budget_limited: "budget reached",
|
|
93
|
+
complete: "achieved",
|
|
94
|
+
};
|
|
95
|
+
return labels[kind];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function emitGoalEvent(
|
|
99
|
+
pi: ExtensionAPI,
|
|
100
|
+
kind: GoalEventKind,
|
|
101
|
+
state: GoalState | null,
|
|
102
|
+
content?: string,
|
|
103
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
104
|
+
) {
|
|
105
|
+
const text = content ?? `Goal ${goalEventStatus(kind)} (ctrl+o to expand)`;
|
|
106
|
+
pi.sendMessage(
|
|
107
|
+
{
|
|
108
|
+
customType: EVENT_TYPE,
|
|
109
|
+
content: text,
|
|
110
|
+
display: true,
|
|
111
|
+
details: {
|
|
112
|
+
kind,
|
|
113
|
+
goal: state,
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
options,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
64
121
|
function latestGoalFromSession(ctx: ExtensionContext): GoalState | null {
|
|
65
122
|
const entries = ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries();
|
|
66
123
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
@@ -131,25 +188,49 @@ The system has marked the goal as budget_limited, so do not start new substantiv
|
|
|
131
188
|
Do not call update_goal unless the goal is actually complete.`;
|
|
132
189
|
}
|
|
133
190
|
|
|
134
|
-
function
|
|
191
|
+
function queueContinuation(pi: ExtensionAPI, state: GoalState) {
|
|
135
192
|
if (continuationQueued || state.status !== "active") return;
|
|
136
193
|
continuationQueued = true;
|
|
137
194
|
queueMicrotask(() => {
|
|
138
195
|
continuationQueued = false;
|
|
139
196
|
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
|
-
);
|
|
197
|
+
pendingControlPrompt = continuationPrompt(goal);
|
|
198
|
+
emitGoalEvent(pi, "continuation", goal, undefined, { triggerTurn: true, deliverAs: "followUp" });
|
|
149
199
|
});
|
|
150
200
|
}
|
|
151
201
|
|
|
152
202
|
export default function piGoal(pi: ExtensionAPI) {
|
|
203
|
+
pi.registerMessageRenderer(EVENT_TYPE, (message, { expanded }, theme) => {
|
|
204
|
+
const details = message.details as { kind?: GoalEventKind; goal?: GoalState | null; timestamp?: number } | undefined;
|
|
205
|
+
const kind = details?.kind ?? "continuation";
|
|
206
|
+
const state = details?.goal ?? null;
|
|
207
|
+
const box = new Box(1, 1, (value) => theme.bg("customMessageBg", value));
|
|
208
|
+
box.addChild(new Text(theme.fg("customMessageLabel", theme.bold("Goal")), 0, 0));
|
|
209
|
+
box.addChild(new Spacer(1));
|
|
210
|
+
if (!expanded) {
|
|
211
|
+
box.addChild(new Text(`${theme.fg("customMessageText", goalEventStatus(kind))} ${theme.fg("dim", "(ctrl+o to expand)")}`, 0, 0));
|
|
212
|
+
return box;
|
|
213
|
+
}
|
|
214
|
+
const lines = [
|
|
215
|
+
`${theme.fg("dim", "Status: ")}${theme.fg("customMessageText", goalEventStatus(kind))}`,
|
|
216
|
+
];
|
|
217
|
+
if (state) {
|
|
218
|
+
lines.push(`${theme.fg("dim", "Goal: ")}${theme.fg("customMessageText", state.objective)}`);
|
|
219
|
+
lines.push(`${theme.fg("dim", "Usage: ")}${theme.fg("customMessageText", goalUsage(state))}`);
|
|
220
|
+
}
|
|
221
|
+
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
222
|
+
return box;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
pi.on("before_agent_start", (event) => {
|
|
226
|
+
const prompt = pendingControlPrompt;
|
|
227
|
+
pendingControlPrompt = null;
|
|
228
|
+
if (!prompt) return;
|
|
229
|
+
return {
|
|
230
|
+
systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
|
|
153
234
|
pi.registerTool({
|
|
154
235
|
name: "get_goal",
|
|
155
236
|
label: "Get Goal",
|
|
@@ -196,6 +277,7 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
196
277
|
const now = Date.now();
|
|
197
278
|
const next: GoalState = { ...goal, status: "complete", updatedAt: now };
|
|
198
279
|
persist(pi, ctx, next);
|
|
280
|
+
emitGoalEvent(pi, "complete", next);
|
|
199
281
|
return {
|
|
200
282
|
content: [{ type: "text", text: JSON.stringify({ goal: next, remainingTokens: next.tokenBudget == null ? null : Math.max(0, next.tokenBudget - next.tokensUsed) }, null, 2) }],
|
|
201
283
|
details: { goal: next },
|
|
@@ -221,8 +303,9 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
221
303
|
}
|
|
222
304
|
|
|
223
305
|
if (trimmed === "clear") {
|
|
306
|
+
const previous = goal;
|
|
224
307
|
persist(pi, ctx, null);
|
|
225
|
-
|
|
308
|
+
emitGoalEvent(pi, "cleared", previous);
|
|
226
309
|
return;
|
|
227
310
|
}
|
|
228
311
|
|
|
@@ -234,8 +317,8 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
234
317
|
const status: GoalStatus = trimmed === "pause" ? "paused" : "active";
|
|
235
318
|
const next = { ...goal, status, updatedAt: now };
|
|
236
319
|
persist(pi, ctx, next);
|
|
237
|
-
|
|
238
|
-
if (status === "active" && ctx.isIdle())
|
|
320
|
+
emitGoalEvent(pi, status === "active" ? "resumed" : "paused", next);
|
|
321
|
+
if (status === "active" && ctx.isIdle()) queueContinuation(pi, next);
|
|
239
322
|
return;
|
|
240
323
|
}
|
|
241
324
|
|
|
@@ -264,15 +347,40 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
264
347
|
updatedAt: now,
|
|
265
348
|
};
|
|
266
349
|
persist(pi, ctx, next);
|
|
267
|
-
ctx.
|
|
268
|
-
|
|
350
|
+
if (ctx.isIdle()) {
|
|
351
|
+
pendingControlPrompt = continuationPrompt(next);
|
|
352
|
+
emitGoalEvent(pi, "active", next, undefined, { triggerTurn: true });
|
|
353
|
+
} else {
|
|
354
|
+
emitGoalEvent(pi, "active", next);
|
|
355
|
+
}
|
|
269
356
|
},
|
|
270
357
|
});
|
|
271
358
|
|
|
272
|
-
pi.on("session_start", (
|
|
359
|
+
pi.on("session_start", (event, ctx) => {
|
|
273
360
|
goal = latestGoalFromSession(ctx);
|
|
361
|
+
pendingControlPrompt = null;
|
|
362
|
+
continuationQueued = false;
|
|
363
|
+
activeTurnStartedAt = null;
|
|
364
|
+
if (goal?.status === "active" && event.reason === "reload") {
|
|
365
|
+
goal = { ...goal, status: "paused", updatedAt: Date.now() };
|
|
366
|
+
persist(pi, ctx, goal);
|
|
367
|
+
emitGoalEvent(
|
|
368
|
+
pi,
|
|
369
|
+
"paused",
|
|
370
|
+
goal,
|
|
371
|
+
`Ⅱ goal paused after reload: ${truncateObjective(goal.objective)}\nUse /goal resume to continue, or /goal clear to stop.`,
|
|
372
|
+
);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
274
375
|
ctx.ui.setStatus(CUSTOM_TYPE, statusLine(goal) ?? "");
|
|
275
|
-
if (goal?.status === "active"
|
|
376
|
+
if (goal?.status === "active") {
|
|
377
|
+
emitGoalEvent(
|
|
378
|
+
pi,
|
|
379
|
+
"active",
|
|
380
|
+
goal,
|
|
381
|
+
`⚑ goal restored: ${truncateObjective(goal.objective)}\nUse /goal pause to stop continuation, or /goal clear to remove it.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
276
384
|
});
|
|
277
385
|
|
|
278
386
|
pi.on("turn_start", (_event, _ctx) => {
|
|
@@ -283,8 +391,7 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
283
391
|
if (!goal || goal.status !== "active") return;
|
|
284
392
|
const elapsed = activeTurnStartedAt ? Math.max(0, Math.round((Date.now() - activeTurnStartedAt) / 1000)) : 0;
|
|
285
393
|
activeTurnStartedAt = null;
|
|
286
|
-
const
|
|
287
|
-
const tokenDelta = Math.max(0, Number(usage?.totalTokens ?? usage?.input + usage?.output ?? 0) || 0);
|
|
394
|
+
const tokenDelta = tokenDeltaFromUsage((event.message as any)?.usage);
|
|
288
395
|
let next: GoalState = {
|
|
289
396
|
...goal,
|
|
290
397
|
tokensUsed: goal.tokensUsed + tokenDelta,
|
|
@@ -296,16 +403,17 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
296
403
|
}
|
|
297
404
|
persist(pi, ctx, next);
|
|
298
405
|
if (next.status === "budget_limited") {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
{ triggerTurn: true, deliverAs: "followUp" },
|
|
302
|
-
);
|
|
406
|
+
pendingControlPrompt = budgetLimitPrompt(next);
|
|
407
|
+
emitGoalEvent(pi, "budget_limited", next, undefined, { triggerTurn: true, deliverAs: "followUp" });
|
|
303
408
|
}
|
|
304
409
|
});
|
|
305
410
|
|
|
306
411
|
pi.on("agent_end", (_event, ctx) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
412
|
+
const currentGoal = goal;
|
|
413
|
+
if (!currentGoal || currentGoal.status !== "active" || ctx.hasPendingMessages()) return;
|
|
414
|
+
setTimeout(() => {
|
|
415
|
+
if (!goal || goal.id !== currentGoal.id || goal.status !== "active") return;
|
|
416
|
+
queueContinuation(pi, goal);
|
|
417
|
+
}, 0);
|
|
310
418
|
});
|
|
311
419
|
}
|
package/README.md
CHANGED
|
@@ -27,7 +27,9 @@ pi install git:github.com/Michaelliv/pi-goal
|
|
|
27
27
|
/goal clear
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
When a goal is active, the extension
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
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
33
|
|
|
32
34
|
## What it adds
|
|
33
35
|
|
|
@@ -45,8 +47,8 @@ When a goal is active, the extension injects a hidden continuation prompt after
|
|
|
45
47
|
```text
|
|
46
48
|
/goal <objective>
|
|
47
49
|
-> persist goal in the current Pi session
|
|
48
|
-
-> show footer status
|
|
49
|
-
-> inject hidden continuation
|
|
50
|
+
-> show compact Goal marker and footer status
|
|
51
|
+
-> inject hidden continuation instructions into the next system prompt
|
|
50
52
|
-> trigger an agent turn
|
|
51
53
|
-> account time/tokens on turn_end
|
|
52
54
|
-> 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.1",
|
|
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__",
|