pi-goals 0.1.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/.pi/extensions/goal/budget.ts +74 -0
- package/.pi/extensions/goal/command.ts +201 -0
- package/.pi/extensions/goal/constants.ts +39 -0
- package/.pi/extensions/goal/continuation.ts +122 -0
- package/.pi/extensions/goal/format.ts +153 -0
- package/.pi/extensions/goal/index.ts +19 -0
- package/.pi/extensions/goal/lifecycle.ts +366 -0
- package/.pi/extensions/goal/model-output.ts +76 -0
- package/.pi/extensions/goal/monitor-prompts.ts +161 -0
- package/.pi/extensions/goal/monitor-report.ts +93 -0
- package/.pi/extensions/goal/monitor-state.ts +77 -0
- package/.pi/extensions/goal/monitor.ts +191 -0
- package/.pi/extensions/goal/prompts.ts +98 -0
- package/.pi/extensions/goal/state.ts +141 -0
- package/.pi/extensions/goal/telemetry.ts +153 -0
- package/.pi/extensions/goal/templates.ts +228 -0
- package/.pi/extensions/goal/tools.ts +279 -0
- package/.pi/extensions/goal/types.ts +168 -0
- package/.pi/extensions/goal/ui.ts +41 -0
- package/.pi/extensions/goal/widget.ts +159 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/package.json +44 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { BUDGET_HARD_STOP_MULTIPLIER, TIME_BUDGET_WARNING_REMAINING_SECONDS, TOKEN_BUDGET_WARNING_REMAINING } from "./constants";
|
|
2
|
+
import type { BudgetLimitReason, BudgetPressure, BudgetPressureKind, GoalState } from "./types";
|
|
3
|
+
|
|
4
|
+
export function evaluateBudgetPressure(goal: GoalState): BudgetPressure {
|
|
5
|
+
const token = tokenPressure(goal);
|
|
6
|
+
const time = timePressure(goal);
|
|
7
|
+
return moreSevere(token, time);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function budgetLimitReason(goal: GoalState): BudgetLimitReason | undefined {
|
|
11
|
+
const pressure = evaluateBudgetPressure(goal);
|
|
12
|
+
if (pressure.kind === "tokenReached" || pressure.kind === "tokenHardStop") return "tokenBudget";
|
|
13
|
+
if (pressure.kind === "timeReached" || pressure.kind === "timeHardStop") return "timeBudget";
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isBudgetWarning(kind: BudgetPressureKind): boolean {
|
|
18
|
+
return kind === "tokenWarning" || kind === "timeWarning";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isBudgetReached(kind: BudgetPressureKind): boolean {
|
|
22
|
+
return kind === "tokenReached" || kind === "timeReached";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isBudgetHardStop(kind: BudgetPressureKind): boolean {
|
|
26
|
+
return kind === "tokenHardStop" || kind === "timeHardStop";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isBudgetExhausted(goal: GoalState): BudgetLimitReason | undefined {
|
|
30
|
+
if (goal.tokenBudget !== undefined && goal.tokensUsed >= goal.tokenBudget) return "tokenBudget";
|
|
31
|
+
if (goal.timeBudgetSeconds !== undefined && goal.timeUsedSeconds >= goal.timeBudgetSeconds) return "timeBudget";
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tokenPressure(goal: GoalState): BudgetPressure {
|
|
36
|
+
if (goal.tokenBudget === undefined) return none();
|
|
37
|
+
const remaining = goal.tokenBudget - goal.tokensUsed;
|
|
38
|
+
if (goal.tokensUsed >= hardStopBudget(goal.tokenBudget)) return pressure("tokenHardStop", remaining);
|
|
39
|
+
if (goal.tokensUsed >= goal.tokenBudget) return pressure("tokenReached", remaining);
|
|
40
|
+
if (remaining <= TOKEN_BUDGET_WARNING_REMAINING) return pressure("tokenWarning", remaining);
|
|
41
|
+
return none();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function timePressure(goal: GoalState): BudgetPressure {
|
|
45
|
+
if (goal.timeBudgetSeconds === undefined) return none();
|
|
46
|
+
const remaining = goal.timeBudgetSeconds - goal.timeUsedSeconds;
|
|
47
|
+
if (goal.timeUsedSeconds >= hardStopBudget(goal.timeBudgetSeconds)) return pressure("timeHardStop", remaining);
|
|
48
|
+
if (goal.timeUsedSeconds >= goal.timeBudgetSeconds) return pressure("timeReached", remaining);
|
|
49
|
+
if (remaining <= TIME_BUDGET_WARNING_REMAINING_SECONDS) return pressure("timeWarning", remaining);
|
|
50
|
+
return none();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hardStopBudget(budget: number): number {
|
|
54
|
+
return Math.ceil((budget * Math.round(BUDGET_HARD_STOP_MULTIPLIER * 10)) / 10);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function moreSevere(a: BudgetPressure, b: BudgetPressure): BudgetPressure {
|
|
58
|
+
return severity(a.kind) >= severity(b.kind) ? a : b;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function severity(kind: BudgetPressureKind): number {
|
|
62
|
+
if (isBudgetHardStop(kind)) return 3;
|
|
63
|
+
if (isBudgetReached(kind)) return 2;
|
|
64
|
+
if (isBudgetWarning(kind)) return 1;
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pressure(kind: BudgetPressureKind, remaining: number): BudgetPressure {
|
|
69
|
+
return { kind, remaining };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function none(): BudgetPressure {
|
|
73
|
+
return { kind: "none" };
|
|
74
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { GOAL_USAGE, GOAL_USAGE_HINT } from "./constants";
|
|
4
|
+
import { validateObjective } from "./format";
|
|
5
|
+
import { discoverGoalTemplates, resolveGoalTemplateInvocation } from "./templates";
|
|
6
|
+
import { createTelemetry, resetSafetyCounters } from "./telemetry";
|
|
7
|
+
import {
|
|
8
|
+
createGoalState,
|
|
9
|
+
getGoal,
|
|
10
|
+
getTelemetry,
|
|
11
|
+
persistClearGoal,
|
|
12
|
+
persistSetGoal,
|
|
13
|
+
persistTelemetry,
|
|
14
|
+
persistUpdateGoal,
|
|
15
|
+
} from "./state";
|
|
16
|
+
import { notifyGoal, notifyInfo, notifyWarning, showGoalSummary, showNoGoal, syncGoalUi } from "./ui";
|
|
17
|
+
import type { GoalCommandScheduler, GoalContinuationCanceller, GoalMonitorCanceller, GoalMonitorScheduler, GoalPauseInterrupter, GoalState } from "./types";
|
|
18
|
+
|
|
19
|
+
type GoalSubcommand = {
|
|
20
|
+
name: "pause" | "resume" | "clear";
|
|
21
|
+
description: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const GOAL_SUBCOMMANDS: GoalSubcommand[] = [
|
|
25
|
+
{ name: "pause", description: "Pause the current goal" },
|
|
26
|
+
{ name: "resume", description: "Resume a paused goal" },
|
|
27
|
+
{ name: "clear", description: "Clear the current goal" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function registerGoalCommand(
|
|
31
|
+
pi: ExtensionAPI,
|
|
32
|
+
scheduleContinuation: GoalCommandScheduler,
|
|
33
|
+
cancelContinuation: GoalContinuationCanceller,
|
|
34
|
+
interruptActiveTurn: GoalPauseInterrupter,
|
|
35
|
+
scheduleMonitor: GoalMonitorScheduler,
|
|
36
|
+
cancelMonitor: GoalMonitorCanceller,
|
|
37
|
+
): void {
|
|
38
|
+
pi.registerCommand("goal", {
|
|
39
|
+
description: "Set or view the goal for a long-running task",
|
|
40
|
+
getArgumentCompletions: goalArgumentCompletions,
|
|
41
|
+
handler: async (args, ctx) => handleGoalCommand(pi, args, ctx, scheduleContinuation, cancelContinuation, interruptActiveTurn, scheduleMonitor, cancelMonitor),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
46
|
+
const query = argumentPrefix.trimStart();
|
|
47
|
+
if (/\s/.test(query)) return null;
|
|
48
|
+
const scored = GOAL_SUBCOMMANDS.map((subcommand) => ({
|
|
49
|
+
...subcommand,
|
|
50
|
+
score: subcommandScore(subcommand.name, query),
|
|
51
|
+
})).filter((item): item is GoalSubcommand & { score: number } => item.score !== undefined);
|
|
52
|
+
scored.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
|
|
53
|
+
const subcommands = scored.map(({ name, description }) => ({ value: name, label: name, description }));
|
|
54
|
+
const templates = discoverGoalTemplates()
|
|
55
|
+
.filter((template) => template.name.toLowerCase().includes(query.toLowerCase()) || template.aliases.some((alias) => alias.toLowerCase().includes(query.toLowerCase())))
|
|
56
|
+
.slice(0, 20)
|
|
57
|
+
.map((template) => ({ value: template.name, label: template.name, description: template.description ?? `Goal template from ${template.path}` }));
|
|
58
|
+
return [...subcommands, ...templates];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function subcommandScore(value: string, query: string): number | undefined {
|
|
62
|
+
const normalized = query.toLowerCase();
|
|
63
|
+
if (!normalized) return 0;
|
|
64
|
+
if (value.startsWith(normalized)) return 1;
|
|
65
|
+
if (value.includes(normalized)) return 2;
|
|
66
|
+
return isOrderedMatch(value, normalized) ? 3 : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isOrderedMatch(value: string, query: string): boolean {
|
|
70
|
+
let index = 0;
|
|
71
|
+
for (const char of query) {
|
|
72
|
+
index = value.indexOf(char, index);
|
|
73
|
+
if (index < 0) return false;
|
|
74
|
+
index++;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function handleGoalCommand(
|
|
80
|
+
pi: ExtensionAPI,
|
|
81
|
+
args: string,
|
|
82
|
+
ctx: ExtensionCommandContext,
|
|
83
|
+
scheduleContinuation: GoalCommandScheduler,
|
|
84
|
+
cancelContinuation: GoalContinuationCanceller,
|
|
85
|
+
interruptActiveTurn: GoalPauseInterrupter,
|
|
86
|
+
scheduleMonitor: GoalMonitorScheduler,
|
|
87
|
+
cancelMonitor: GoalMonitorCanceller,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const trimmed = args.trim();
|
|
90
|
+
if (!trimmed) {
|
|
91
|
+
const goal = getGoal();
|
|
92
|
+
if (goal) showGoalSummary(ctx, goal);
|
|
93
|
+
else showNoGoal(ctx);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const control = GOAL_SUBCOMMANDS.find((subcommand) => subcommand.name === trimmed.toLowerCase())?.name;
|
|
98
|
+
if (control === "pause") return pauseGoal(pi, ctx, cancelContinuation, interruptActiveTurn, cancelMonitor);
|
|
99
|
+
if (control === "resume") return resumeGoal(pi, ctx, scheduleContinuation, scheduleMonitor);
|
|
100
|
+
if (control === "clear") return clearGoal(pi, ctx, cancelContinuation, cancelMonitor);
|
|
101
|
+
|
|
102
|
+
await setGoalObjective(pi, resolveTemplateOrObjective(trimmed, ctx), ctx, scheduleContinuation, cancelContinuation, scheduleMonitor, cancelMonitor);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveTemplateOrObjective(input: string, ctx: ExtensionCommandContext): string {
|
|
106
|
+
const resolution = resolveGoalTemplateInvocation(input);
|
|
107
|
+
if (resolution.ok) return resolution.template.objective;
|
|
108
|
+
if ("notTemplate" in resolution) return input;
|
|
109
|
+
notifyWarning(ctx, resolution.error);
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function setGoalObjective(
|
|
114
|
+
pi: ExtensionAPI,
|
|
115
|
+
input: string,
|
|
116
|
+
ctx: ExtensionCommandContext,
|
|
117
|
+
scheduleContinuation: GoalCommandScheduler,
|
|
118
|
+
cancelContinuation: GoalContinuationCanceller,
|
|
119
|
+
scheduleMonitor: GoalMonitorScheduler,
|
|
120
|
+
cancelMonitor: GoalMonitorCanceller,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
if (!input) return;
|
|
123
|
+
const validation = validateObjective(input);
|
|
124
|
+
if (!validation.ok) {
|
|
125
|
+
notifyWarning(ctx, validation.hint ? `${validation.message}\n${validation.hint}` : validation.message);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const existing = getGoal();
|
|
130
|
+
if (existing) {
|
|
131
|
+
const ok = await ctx.ui.confirm("Replace goal?", `New objective: ${validation.objective}`);
|
|
132
|
+
if (!ok) {
|
|
133
|
+
notifyInfo(ctx, "Goal replacement cancelled. Current goal kept.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
cancelContinuation(existing.goalId, "replace");
|
|
137
|
+
cancelMonitor(existing.goalId, "replace");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const goal = createGoalState(validation.objective);
|
|
141
|
+
const telemetry = createTelemetry(goal.goalId, goal.createdAt);
|
|
142
|
+
persistSetGoal(pi, goal, telemetry, "command");
|
|
143
|
+
syncGoalUi(ctx, goal);
|
|
144
|
+
notifyGoal(ctx, goal);
|
|
145
|
+
scheduleMonitor(ctx);
|
|
146
|
+
scheduleContinuation(ctx, "created");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function pauseGoal(
|
|
150
|
+
pi: ExtensionAPI,
|
|
151
|
+
ctx: ExtensionCommandContext,
|
|
152
|
+
cancelContinuation: GoalContinuationCanceller,
|
|
153
|
+
interruptActiveTurn: GoalPauseInterrupter,
|
|
154
|
+
cancelMonitor: GoalMonitorCanceller,
|
|
155
|
+
): void {
|
|
156
|
+
const goal = getGoal();
|
|
157
|
+
if (!goal) {
|
|
158
|
+
notifyInfo(ctx, `${GOAL_USAGE}\nNo goal is currently set.`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
cancelContinuation(goal.goalId, "pause");
|
|
162
|
+
cancelMonitor(goal.goalId, "pause");
|
|
163
|
+
const paused: GoalState = { ...goal, status: "paused", updatedAt: Date.now() };
|
|
164
|
+
persistUpdateGoal(pi, paused, getTelemetry(), "command");
|
|
165
|
+
syncGoalUi(ctx, paused);
|
|
166
|
+
notifyGoal(ctx, paused);
|
|
167
|
+
if (!ctx.isIdle()) {
|
|
168
|
+
interruptActiveTurn(ctx, paused);
|
|
169
|
+
notifyWarning(ctx, "Goal paused. The active turn was interrupted; run /goal resume to continue.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resumeGoal(pi: ExtensionAPI, ctx: ExtensionCommandContext, scheduleContinuation: GoalCommandScheduler, scheduleMonitor: GoalMonitorScheduler): void {
|
|
174
|
+
const goal = getGoal();
|
|
175
|
+
if (!goal) {
|
|
176
|
+
notifyInfo(ctx, `${GOAL_USAGE}\n${GOAL_USAGE_HINT}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (goal.status === "complete") {
|
|
180
|
+
notifyInfo(ctx, "Goal is complete. Use /goal clear before starting a new goal.");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const active: GoalState = { ...goal, status: "active", updatedAt: Date.now() };
|
|
184
|
+
const telemetry = resetSafetyCounters(getTelemetry());
|
|
185
|
+
persistUpdateGoal(pi, active, telemetry, "resume");
|
|
186
|
+
if (telemetry) persistTelemetry(pi, telemetry, "resume");
|
|
187
|
+
syncGoalUi(ctx, active);
|
|
188
|
+
notifyGoal(ctx, active);
|
|
189
|
+
scheduleMonitor(ctx);
|
|
190
|
+
scheduleContinuation(ctx, "resumed");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function clearGoal(pi: ExtensionAPI, ctx: ExtensionCommandContext, cancelContinuation: GoalContinuationCanceller, cancelMonitor: GoalMonitorCanceller): void {
|
|
194
|
+
const goal = getGoal();
|
|
195
|
+
const hadGoal = Boolean(goal);
|
|
196
|
+
cancelContinuation(goal?.goalId, "clear");
|
|
197
|
+
cancelMonitor(goal?.goalId, "clear");
|
|
198
|
+
const result = persistClearGoal(pi, "command");
|
|
199
|
+
syncGoalUi(ctx, result.goal);
|
|
200
|
+
notifyInfo(ctx, hadGoal ? "Goal cleared" : "No goal to clear\nThis session does not currently have a goal.");
|
|
201
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const STATE_ENTRY_TYPE = "pi-goal-state";
|
|
2
|
+
export const CONTINUATION_MESSAGE_TYPE = "pi-goal-continuation";
|
|
3
|
+
export const BUDGET_LIMIT_MESSAGE_TYPE = "pi-goal-budget-limit";
|
|
4
|
+
export const PAUSE_MESSAGE_TYPE = "pi-goal-pause";
|
|
5
|
+
export const GOAL_MONITOR_MESSAGE_TYPE = "pi-goal-monitor-steer";
|
|
6
|
+
export const GOAL_MONITOR_LOG_ENTRY_TYPE = "pi-goal-monitor-log";
|
|
7
|
+
|
|
8
|
+
export const MAX_OBJECTIVE_CHARS = 15000;
|
|
9
|
+
export const LONG_OBJECTIVE_HINT =
|
|
10
|
+
"Put longer instructions in a file and refer to that file in the goal, for example: /goal follow the instructions in docs/goal.md.";
|
|
11
|
+
|
|
12
|
+
export const STATUS_UI_KEY = "pi-goal";
|
|
13
|
+
export const WIDGET_UI_KEY = "pi-goal";
|
|
14
|
+
|
|
15
|
+
export const TELEMETRY_SCHEMA_VERSION = 1 as const;
|
|
16
|
+
export const STATE_EVENT_VERSION = 1 as const;
|
|
17
|
+
|
|
18
|
+
export const MAX_CONSECUTIVE_AUTO_TURNS = 50;
|
|
19
|
+
export const MAX_NO_PROGRESS_AUTO_TURNS = 3;
|
|
20
|
+
export const OBJECTIVE_EXCERPT_CHARS = 96;
|
|
21
|
+
export const TOKEN_BUDGET_WARNING_REMAINING = 100_000;
|
|
22
|
+
export const TIME_BUDGET_WARNING_REMAINING_SECONDS = 60;
|
|
23
|
+
export const BUDGET_HARD_STOP_MULTIPLIER = 1.1;
|
|
24
|
+
|
|
25
|
+
export const GOAL_MONITOR_REPORT_INTERVAL_SECONDS = 90;
|
|
26
|
+
export const GOAL_MONITOR_RECENT_LOG_LIMIT = 10;
|
|
27
|
+
export const GOAL_MONITOR_RECENT_BRANCH_ENTRY_LIMIT = 12;
|
|
28
|
+
export const GOAL_MONITOR_ENTRY_SUMMARY_CHARS = 700;
|
|
29
|
+
export const GOAL_MONITOR_PROCESS_TIMEOUT_MS = 60_000;
|
|
30
|
+
export const GOAL_MONITOR_OUTPUT_CHARS = 20_000;
|
|
31
|
+
|
|
32
|
+
export const CONTINUATION_PROMPT_ID = "pi-goal-continuation-v1";
|
|
33
|
+
export const BUDGET_LIMIT_PROMPT_ID = "pi-goal-budget-limit-v1";
|
|
34
|
+
export const BUDGET_WARNING_PROMPT_ID = "pi-goal-budget-warning-v1";
|
|
35
|
+
export const PAUSE_PROMPT_ID = "pi-goal-pause-v1";
|
|
36
|
+
export const GOAL_MONITOR_PROMPT_ID = "pi-goal-monitor-v1";
|
|
37
|
+
|
|
38
|
+
export const GOAL_USAGE = "Usage: /goal <objective>";
|
|
39
|
+
export const GOAL_USAGE_HINT = "Example: /goal improve benchmark coverage";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
BUDGET_LIMIT_MESSAGE_TYPE,
|
|
4
|
+
CONTINUATION_MESSAGE_TYPE,
|
|
5
|
+
PAUSE_MESSAGE_TYPE,
|
|
6
|
+
MAX_CONSECUTIVE_AUTO_TURNS,
|
|
7
|
+
MAX_NO_PROGRESS_AUTO_TURNS,
|
|
8
|
+
} from "./constants";
|
|
9
|
+
import { buildBudgetLimitPrompt, buildContinuationPrompt, buildPausePrompt } from "./prompts";
|
|
10
|
+
import { getGoal, getTelemetry, persistTelemetry } from "./state";
|
|
11
|
+
import { noteBudgetWrapUpSent, noteContinuationScheduled, noteContinuationSkipped, setNextTurnOrigin } from "./telemetry";
|
|
12
|
+
import type { ContinuationReason, GoalState } from "./types";
|
|
13
|
+
|
|
14
|
+
type PendingContinuation = {
|
|
15
|
+
goalId: string;
|
|
16
|
+
reason: ContinuationReason;
|
|
17
|
+
timer: ReturnType<typeof setTimeout>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type PendingBudgetWrapUp = {
|
|
21
|
+
goalId: string;
|
|
22
|
+
timer: ReturnType<typeof setTimeout>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let pendingContinuation: PendingContinuation | undefined;
|
|
26
|
+
let budgetWrapUps = new Map<string, PendingBudgetWrapUp>();
|
|
27
|
+
|
|
28
|
+
export function scheduleMaybeContinueGoal(pi: ExtensionAPI, ctx: ExtensionContext, reason: ContinuationReason): void {
|
|
29
|
+
const goal = getGoal();
|
|
30
|
+
if (!goal || goal.status !== "active") return;
|
|
31
|
+
cancelGoalContinuation(goal.goalId, "reschedule-continuation");
|
|
32
|
+
const telemetry = noteContinuationScheduled(getTelemetry(), reason);
|
|
33
|
+
if (telemetry) persistTelemetry(pi, telemetry, "continuation");
|
|
34
|
+
const goalId = goal.goalId;
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
if (pendingContinuation?.goalId === goalId) pendingContinuation = undefined;
|
|
37
|
+
void safelyRun(() => maybeContinueGoal(pi, ctx, reason, goalId));
|
|
38
|
+
}, 25);
|
|
39
|
+
pendingContinuation = { goalId, reason, timer };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function scheduleBudgetLimitWrapUp(pi: ExtensionAPI, ctx: ExtensionContext, goal: GoalState): void {
|
|
43
|
+
if (budgetWrapUps.has(goal.goalId)) return;
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
budgetWrapUps.delete(goal.goalId);
|
|
46
|
+
void safelyRun(() => maybeSendBudgetWrapUp(pi, ctx, goal.goalId));
|
|
47
|
+
}, 25);
|
|
48
|
+
budgetWrapUps.set(goal.goalId, { goalId: goal.goalId, timer });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function cancelGoalContinuation(goalId?: string, _reason = "cancelled"): void {
|
|
52
|
+
if (pendingContinuation && (!goalId || pendingContinuation.goalId === goalId)) {
|
|
53
|
+
clearTimeout(pendingContinuation.timer);
|
|
54
|
+
pendingContinuation = undefined;
|
|
55
|
+
}
|
|
56
|
+
for (const [pendingGoalId, pending] of budgetWrapUps) {
|
|
57
|
+
if (!goalId || pendingGoalId === goalId) {
|
|
58
|
+
clearTimeout(pending.timer);
|
|
59
|
+
budgetWrapUps.delete(pendingGoalId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function interruptActiveGoalTurn(pi: ExtensionAPI, ctx: ExtensionContext, goal: GoalState): void {
|
|
65
|
+
if (ctx.isIdle()) return;
|
|
66
|
+
const prompt = buildPausePrompt(goal);
|
|
67
|
+
pi.sendMessage(
|
|
68
|
+
{ customType: PAUSE_MESSAGE_TYPE, content: prompt.content, display: false, details: prompt.details },
|
|
69
|
+
{ deliverAs: "steer" },
|
|
70
|
+
);
|
|
71
|
+
ctx.abort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resetContinuationRuntime(): void {
|
|
75
|
+
cancelGoalContinuation();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function maybeContinueGoal(pi: ExtensionAPI, ctx: ExtensionContext, reason: ContinuationReason, goalId: string): Promise<void> {
|
|
79
|
+
const goal = getGoal();
|
|
80
|
+
if (!goal || goal.goalId !== goalId || goal.status !== "active") return skip(pi, "notActive");
|
|
81
|
+
if (!ctx.isIdle()) return skip(pi, "notIdle");
|
|
82
|
+
if (ctx.hasPendingMessages()) return skip(pi, "pendingMessages");
|
|
83
|
+
const telemetry = getTelemetry();
|
|
84
|
+
if (telemetry && telemetry.consecutiveAutoTurns >= MAX_CONSECUTIVE_AUTO_TURNS) return skip(pi, "safetyCap");
|
|
85
|
+
if (telemetry && telemetry.consecutiveNoProgressTurns >= MAX_NO_PROGRESS_AUTO_TURNS) return skip(pi, "safetyCap");
|
|
86
|
+
|
|
87
|
+
const prompt = buildContinuationPrompt(goal);
|
|
88
|
+
setNextTurnOrigin("auto");
|
|
89
|
+
pi.sendMessage(
|
|
90
|
+
{ customType: CONTINUATION_MESSAGE_TYPE, content: prompt.content, display: false, details: { ...prompt.details, reason } },
|
|
91
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function maybeSendBudgetWrapUp(pi: ExtensionAPI, ctx: ExtensionContext, goalId: string): Promise<void> {
|
|
96
|
+
const goal = getGoal();
|
|
97
|
+
if (!goal || goal.goalId !== goalId || goal.status !== "budgetLimited") return;
|
|
98
|
+
if (!ctx.isIdle()) return;
|
|
99
|
+
if (ctx.hasPendingMessages()) return;
|
|
100
|
+
const prompt = buildBudgetLimitPrompt(goal);
|
|
101
|
+
setNextTurnOrigin("budgetWrapUp");
|
|
102
|
+
pi.sendMessage(
|
|
103
|
+
{ customType: BUDGET_LIMIT_MESSAGE_TYPE, content: prompt.content, display: false, details: prompt.details },
|
|
104
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
105
|
+
);
|
|
106
|
+
const telemetry = noteBudgetWrapUpSent(getTelemetry());
|
|
107
|
+
if (telemetry) persistTelemetry(pi, telemetry, "budget");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function skip(pi: ExtensionAPI, reason: "notIdle" | "pendingMessages" | "notActive" | "budgetLimited" | "safetyCap"): void {
|
|
111
|
+
const telemetry = noteContinuationSkipped(getTelemetry(), reason);
|
|
112
|
+
if (telemetry) persistTelemetry(pi, telemetry, "continuation");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function safelyRun(task: () => Promise<void>): Promise<void> {
|
|
116
|
+
try {
|
|
117
|
+
await task();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
120
|
+
if (!message.includes("ctx is stale")) console.warn(`[pi-goal] continuation failed: ${message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { budgetLimitReason } from "./budget";
|
|
2
|
+
import { GOAL_USAGE, GOAL_USAGE_HINT, LONG_OBJECTIVE_HINT, MAX_OBJECTIVE_CHARS, OBJECTIVE_EXCERPT_CHARS } from "./constants";
|
|
3
|
+
import type { GoalState, GoalStatus } from "./types";
|
|
4
|
+
|
|
5
|
+
export type ObjectiveValidation = { ok: true; objective: string } | { ok: false; message: string; hint?: string };
|
|
6
|
+
|
|
7
|
+
export function validateObjective(input: string): ObjectiveValidation {
|
|
8
|
+
const objective = input.trim();
|
|
9
|
+
if (!objective) {
|
|
10
|
+
return { ok: false, message: "Goal objective must not be empty.", hint: `${GOAL_USAGE}\n${GOAL_USAGE_HINT}` };
|
|
11
|
+
}
|
|
12
|
+
if ([...objective].length > MAX_OBJECTIVE_CHARS) {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
message: `Goal objective is too long (${[...objective].length}/${MAX_OBJECTIVE_CHARS} characters).`,
|
|
16
|
+
hint: LONG_OBJECTIVE_HINT,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { ok: true, objective };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function escapeXml(value: string): string {
|
|
23
|
+
return value
|
|
24
|
+
.replace(/&/g, "&")
|
|
25
|
+
.replace(/</g, "<")
|
|
26
|
+
.replace(/>/g, ">")
|
|
27
|
+
.replace(/"/g, """)
|
|
28
|
+
.replace(/'/g, "'");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatElapsed(seconds: number): string {
|
|
32
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
33
|
+
if (total < 60) return `${total}s`;
|
|
34
|
+
const minutes = Math.floor(total / 60);
|
|
35
|
+
if (minutes < 60) return `${minutes}m`;
|
|
36
|
+
const hours = Math.floor(minutes / 60);
|
|
37
|
+
const remMinutes = minutes % 60;
|
|
38
|
+
if (hours < 24) return remMinutes > 0 ? `${hours}h ${remMinutes}m` : `${hours}h`;
|
|
39
|
+
const days = Math.floor(hours / 24);
|
|
40
|
+
const remHours = hours % 24;
|
|
41
|
+
return `${days}d ${remHours}h ${remMinutes}m`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatTokensCompact(tokens: number): string {
|
|
45
|
+
const value = Math.max(0, Math.round(tokens));
|
|
46
|
+
if (value < 1000) return String(value);
|
|
47
|
+
if (value < 1_000_000) return `${trimFixed(value / 1000)}k`;
|
|
48
|
+
return `${trimFixed(value / 1_000_000)}M`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function trimFixed(value: number): string {
|
|
52
|
+
return value >= 10 ? value.toFixed(0) : value.toFixed(1).replace(/\.0$/, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function statusLabel(status: GoalStatus): string {
|
|
56
|
+
switch (status) {
|
|
57
|
+
case "active":
|
|
58
|
+
return "active";
|
|
59
|
+
case "paused":
|
|
60
|
+
return "paused";
|
|
61
|
+
case "budgetLimited":
|
|
62
|
+
return "limited by budget";
|
|
63
|
+
case "complete":
|
|
64
|
+
return "complete";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function goalStatusLabel(goal: GoalState): string {
|
|
69
|
+
if (goal.status !== "budgetLimited") return statusLabel(goal.status);
|
|
70
|
+
const reason = budgetLimitReason(goal);
|
|
71
|
+
if (reason === "tokenBudget") return "token budget reached";
|
|
72
|
+
if (reason === "timeBudget") return "time budget reached";
|
|
73
|
+
return "budget reached";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function commandHint(status: GoalStatus): string {
|
|
77
|
+
switch (status) {
|
|
78
|
+
case "active":
|
|
79
|
+
return "Commands: /goal pause, /goal clear";
|
|
80
|
+
case "paused":
|
|
81
|
+
return "Commands: /goal resume, /goal clear";
|
|
82
|
+
case "budgetLimited":
|
|
83
|
+
case "complete":
|
|
84
|
+
return "Commands: /goal clear";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function objectiveExcerpt(objective: string, maxChars = OBJECTIVE_EXCERPT_CHARS): string {
|
|
89
|
+
const chars = [...objective];
|
|
90
|
+
if (chars.length <= maxChars) return objective;
|
|
91
|
+
return `${chars.slice(0, Math.max(0, maxChars - 1)).join("")}…`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatTimeResource(goal: GoalState): string {
|
|
95
|
+
const used = formatElapsed(goal.timeUsedSeconds);
|
|
96
|
+
if (goal.timeBudgetSeconds === undefined) return `Time: ${used}`;
|
|
97
|
+
return `Time: ${used} / ${formatElapsed(goal.timeBudgetSeconds)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatTokenResource(goal: GoalState): string {
|
|
101
|
+
const used = formatTokensCompact(goal.tokensUsed);
|
|
102
|
+
if (goal.tokenBudget === undefined) return `Tokens: ${used}`;
|
|
103
|
+
return `Tokens: ${used} / ${formatTokensCompact(goal.tokenBudget)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function goalUsageSummary(goal: GoalState): string {
|
|
107
|
+
return `${formatTimeResource(goal)}; ${formatTokenResource(goal)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function footerStatusText(goal: GoalState): string {
|
|
111
|
+
const usage = footerUsage(goal);
|
|
112
|
+
switch (goal.status) {
|
|
113
|
+
case "active":
|
|
114
|
+
return usage ? `Pursuing goal (${usage})` : "Pursuing goal";
|
|
115
|
+
case "paused":
|
|
116
|
+
return "Goal paused (/goal resume)";
|
|
117
|
+
case "budgetLimited":
|
|
118
|
+
return usage ? `${goalStatusLabel(goal)} (${usage})` : goalStatusLabel(goal);
|
|
119
|
+
case "complete":
|
|
120
|
+
return usage ? `Goal achieved (${usage})` : "Goal achieved";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function footerUsage(goal: GoalState): string | undefined {
|
|
125
|
+
if (goal.status === "paused") return undefined;
|
|
126
|
+
if (goal.tokenBudget !== undefined) {
|
|
127
|
+
const used = formatTokensCompact(goal.tokensUsed);
|
|
128
|
+
const budget = formatTokensCompact(goal.tokenBudget);
|
|
129
|
+
if (goal.status === "active") return `${used} / ${budget}`;
|
|
130
|
+
if (goal.status === "budgetLimited") return `${used} / ${budget} tokens`;
|
|
131
|
+
return `${used} tokens`;
|
|
132
|
+
}
|
|
133
|
+
if (goal.timeBudgetSeconds !== undefined) {
|
|
134
|
+
const used = formatElapsed(goal.timeUsedSeconds);
|
|
135
|
+
const budget = formatElapsed(goal.timeBudgetSeconds);
|
|
136
|
+
return goal.status === "complete" ? used : `${used} / ${budget}`;
|
|
137
|
+
}
|
|
138
|
+
return goal.timeUsedSeconds > 0 ? formatElapsed(goal.timeUsedSeconds) : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function goalSummaryLines(goal: GoalState): string[] {
|
|
142
|
+
const lines = [
|
|
143
|
+
"Goal",
|
|
144
|
+
`Status: ${goalStatusLabel(goal)}`,
|
|
145
|
+
`Objective: ${goal.objective}`,
|
|
146
|
+
`Time used: ${formatElapsed(goal.timeUsedSeconds)}`,
|
|
147
|
+
`Tokens used: ${formatTokensCompact(goal.tokensUsed)}`,
|
|
148
|
+
];
|
|
149
|
+
if (goal.tokenBudget !== undefined) lines.push(`Token budget: ${formatTokensCompact(goal.tokenBudget)}`);
|
|
150
|
+
if (goal.timeBudgetSeconds !== undefined) lines.push(`Time budget: ${formatElapsed(goal.timeBudgetSeconds)}`);
|
|
151
|
+
lines.push("", commandHint(goal.status));
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerGoalCommand } from "./command";
|
|
3
|
+
import { cancelGoalContinuation, interruptActiveGoalTurn, scheduleMaybeContinueGoal } from "./continuation";
|
|
4
|
+
import { registerGoalLifecycle } from "./lifecycle";
|
|
5
|
+
import { cancelGoalMonitor, scheduleGoalMonitor } from "./monitor";
|
|
6
|
+
import { registerGoalTools } from "./tools";
|
|
7
|
+
|
|
8
|
+
export default function goalExtension(pi: ExtensionAPI): void {
|
|
9
|
+
registerGoalCommand(
|
|
10
|
+
pi,
|
|
11
|
+
(ctx, reason) => scheduleMaybeContinueGoal(pi, ctx, reason),
|
|
12
|
+
cancelGoalContinuation,
|
|
13
|
+
(ctx, goal) => interruptActiveGoalTurn(pi, ctx, goal),
|
|
14
|
+
(ctx) => scheduleGoalMonitor(pi, ctx),
|
|
15
|
+
cancelGoalMonitor,
|
|
16
|
+
);
|
|
17
|
+
registerGoalTools(pi, (ctx, reason) => scheduleMaybeContinueGoal(pi, ctx, reason), cancelGoalContinuation, (ctx) => scheduleGoalMonitor(pi, ctx), cancelGoalMonitor);
|
|
18
|
+
registerGoalLifecycle(pi);
|
|
19
|
+
}
|