@fyeeme/pi-goal 1.0.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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/index.ts +651 -0
- package/package.json +65 -0
- package/src/commands.ts +325 -0
- package/src/evaluator.ts +218 -0
- package/src/format.ts +35 -0
- package/src/prompts/evaluator-complete.md +26 -0
- package/src/prompts/evaluator-impossible.md +20 -0
- package/src/prompts/goal-budget-limit.md +15 -0
- package/src/prompts/goal-continuation.md +30 -0
- package/src/prompts/goal-mode-active.md +23 -0
- package/src/prompts/goal-mode-context.md +4 -0
- package/src/prompts/goal-todo-context.md +12 -0
- package/src/prompts/goal.md +11 -0
- package/src/prompts/guided-goal-interview.md +37 -0
- package/src/restore.ts +92 -0
- package/src/runtime.ts +565 -0
- package/src/state.ts +124 -0
- package/src/template.ts +154 -0
- package/src/todo-bridge.ts +136 -0
- package/src/tool.ts +557 -0
- package/test/evaluator.test.ts +186 -0
- package/test/index.test.ts +557 -0
- package/test/omp-alignment.test.ts +975 -0
- package/test/runtime.test.ts +471 -0
- package/test/template.test.ts +89 -0
- package/test/tool.test.ts +528 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal — goal runtime.
|
|
3
|
+
*
|
|
4
|
+
* Ported from oh-my-pi `packages/coding-agent/src/goals/runtime.ts`
|
|
5
|
+
* behavior-for-behavior: budget accounting (token delta since turn baseline +
|
|
6
|
+
* wall-clock seconds), budget-limited flips with one-shot steering, pause on
|
|
7
|
+
* interrupt, resume/pause/drop/complete transitions, and the serialized
|
|
8
|
+
* accounting tail. The host interface is implementation-agnostic and maps to
|
|
9
|
+
* pi's extension API in index.ts:
|
|
10
|
+
*
|
|
11
|
+
* omp host surface → pi adaptation
|
|
12
|
+
* ─────────────────────────────────────────────────────────────────
|
|
13
|
+
* getState/setState (session field) → closure state
|
|
14
|
+
* getCurrentUsage (session stats tokens) → entries scan (same sums as
|
|
15
|
+
* pi's getSessionStats)
|
|
16
|
+
* emit(goal_updated session event) → pi.events.emit("goal_updated")
|
|
17
|
+
* persist (appendModeChange goal/goal_paused/none)
|
|
18
|
+
* → pi.appendEntry custom entries
|
|
19
|
+
* sendHiddenMessage → pi.sendMessage display:false
|
|
20
|
+
*
|
|
21
|
+
* Prompts load from static .md assets next to this module (published with the
|
|
22
|
+
* package). Loaded at runtime: pi loads extensions through jiti, which does
|
|
23
|
+
* not support bundler-style text imports (pi-todo/pi-dynamic-workflows
|
|
24
|
+
* precedent). omp `Snowflake.next()` ids become `crypto.randomUUID()`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import type { Goal, GoalBudgetSteering, GoalModeState, GoalRuntimeEvent, GoalTokenUsage } from "./state.ts";
|
|
31
|
+
import { cloneGoal, cloneState } from "./state.ts";
|
|
32
|
+
import { escapeXmlText, renderTemplate } from "./template.ts";
|
|
33
|
+
|
|
34
|
+
const promptsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "prompts");
|
|
35
|
+
const goalBudgetLimitPrompt = readFileSync(path.join(promptsDir, "goal-budget-limit.md"), "utf8");
|
|
36
|
+
const goalContinuationPrompt = readFileSync(path.join(promptsDir, "goal-continuation.md"), "utf8");
|
|
37
|
+
const goalModeActivePrompt = readFileSync(path.join(promptsDir, "goal-mode-active.md"), "utf8");
|
|
38
|
+
|
|
39
|
+
export interface GoalRuntimeHost {
|
|
40
|
+
getState(): GoalModeState | undefined;
|
|
41
|
+
setState(state: GoalModeState | undefined): void;
|
|
42
|
+
getCurrentUsage(): GoalTokenUsage;
|
|
43
|
+
emit(event: GoalRuntimeEvent): void | Promise<void>;
|
|
44
|
+
persist(mode: "goal" | "goal_paused" | "none", state?: GoalModeState): void;
|
|
45
|
+
sendHiddenMessage(message: {
|
|
46
|
+
customType: string;
|
|
47
|
+
content: string;
|
|
48
|
+
deliverAs?: "steer" | "followUp" | "nextTurn";
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
now?(): number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GoalTurnSnapshot {
|
|
54
|
+
turnId: string;
|
|
55
|
+
baselineUsage: GoalTokenUsage;
|
|
56
|
+
activeGoalId?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface GoalWallClockSnapshot {
|
|
60
|
+
lastAccountedAt: number;
|
|
61
|
+
activeGoalId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface GoalRuntimeSnapshot {
|
|
65
|
+
turnSnapshot?: GoalTurnSnapshot;
|
|
66
|
+
wallClock: GoalWallClockSnapshot;
|
|
67
|
+
budgetReportedFor?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type GoalPromptKind = "active" | "continuation" | "budget-limit";
|
|
71
|
+
|
|
72
|
+
function budgetValue(goal: Goal): string {
|
|
73
|
+
return goal.tokenBudget === undefined ? "none" : String(goal.tokenBudget);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function remainingValue(goal: Goal): string {
|
|
77
|
+
return goal.tokenBudget === undefined ? "unbounded" : String(Math.max(0, goal.tokenBudget - goal.tokensUsed));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function remainingTokens(goal: Goal | null | undefined): number | null {
|
|
81
|
+
if (!goal || goal.tokenBudget === undefined) return null;
|
|
82
|
+
return Math.max(0, goal.tokenBudget - goal.tokensUsed);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function renderTrustedObjective(objective: string): string {
|
|
86
|
+
return `<objective>\n${escapeXmlText(objective)}\n</objective>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function goalTokenDelta(current: GoalTokenUsage, baseline: GoalTokenUsage): number {
|
|
90
|
+
// Diverges from codex-rs: codex omits cache creation because its target providers
|
|
91
|
+
// do not bill cache writes distinctly through the token-usage stream. Pi receives
|
|
92
|
+
// cacheWrite separately on Anthropic/Bedrock; rotating a 1h ephemeral cache or
|
|
93
|
+
// re-anchoring a changed system prompt can write 100K+ tokens, which the goal
|
|
94
|
+
// budget must account for. cacheRead is excluded because it is reused prefix,
|
|
95
|
+
// not new work consumed by the goal.
|
|
96
|
+
return (
|
|
97
|
+
Math.max(0, current.input - baseline.input) +
|
|
98
|
+
Math.max(0, current.cacheWrite - baseline.cacheWrite) +
|
|
99
|
+
Math.max(0, current.output - baseline.output)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function renderGoalPrompt(kind: GoalPromptKind, goal: Goal): string {
|
|
104
|
+
const template =
|
|
105
|
+
kind === "active"
|
|
106
|
+
? goalModeActivePrompt
|
|
107
|
+
: kind === "continuation"
|
|
108
|
+
? goalContinuationPrompt
|
|
109
|
+
: goalBudgetLimitPrompt;
|
|
110
|
+
return renderTemplate(template, {
|
|
111
|
+
objective: escapeXmlText(goal.objective),
|
|
112
|
+
tokensUsed: String(goal.tokensUsed),
|
|
113
|
+
tokenBudget: budgetValue(goal),
|
|
114
|
+
remainingTokens: remainingValue(goal),
|
|
115
|
+
timeUsedSeconds: String(goal.timeUsedSeconds),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function completionBudgetReport(goal: Goal): string | null {
|
|
120
|
+
const parts: string[] = [];
|
|
121
|
+
if (goal.tokenBudget !== undefined) {
|
|
122
|
+
parts.push(`tokens used: ${goal.tokensUsed} of ${goal.tokenBudget}`);
|
|
123
|
+
}
|
|
124
|
+
if (goal.timeUsedSeconds > 0) {
|
|
125
|
+
parts.push(`time used: ${goal.timeUsedSeconds} seconds`);
|
|
126
|
+
}
|
|
127
|
+
if (parts.length === 0) return null;
|
|
128
|
+
return `Goal achieved. Report final budget usage to the user: ${parts.join("; ")}.`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateTokenBudget(tokenBudget: number | undefined): void {
|
|
132
|
+
if (tokenBudget !== undefined && (!Number.isInteger(tokenBudget) || tokenBudget <= 0)) {
|
|
133
|
+
throw new Error("goal token_budget must be a positive integer when provided");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isAccountingStatus(goal: Goal): boolean {
|
|
138
|
+
return goal.status === "active" || goal.status === "budget-limited";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class GoalRuntime {
|
|
142
|
+
readonly #host: GoalRuntimeHost;
|
|
143
|
+
#turnSnapshot: GoalTurnSnapshot | undefined;
|
|
144
|
+
#wallClock: GoalWallClockSnapshot;
|
|
145
|
+
#budgetReportedFor: string | undefined;
|
|
146
|
+
#accountingTail: Promise<void> = Promise.resolve();
|
|
147
|
+
|
|
148
|
+
constructor(host: GoalRuntimeHost) {
|
|
149
|
+
this.#host = host;
|
|
150
|
+
this.#wallClock = { lastAccountedAt: this.#now() };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get snapshot(): GoalRuntimeSnapshot {
|
|
154
|
+
return {
|
|
155
|
+
turnSnapshot: this.#turnSnapshot
|
|
156
|
+
? { ...this.#turnSnapshot, baselineUsage: { ...this.#turnSnapshot.baselineUsage } }
|
|
157
|
+
: undefined,
|
|
158
|
+
wallClock: { ...this.#wallClock },
|
|
159
|
+
budgetReportedFor: this.#budgetReportedFor,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#now(): number {
|
|
164
|
+
return this.#host.now?.() ?? Date.now();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#hasAccountingState(): boolean {
|
|
168
|
+
const state = this.#host.getState();
|
|
169
|
+
return Boolean(state?.enabled && isAccountingStatus(state.goal));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async #withAccounting<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
173
|
+
const previous = this.#accountingTail;
|
|
174
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
175
|
+
this.#accountingTail = previous.then(
|
|
176
|
+
() => promise,
|
|
177
|
+
() => promise,
|
|
178
|
+
);
|
|
179
|
+
await previous.catch(() => {});
|
|
180
|
+
try {
|
|
181
|
+
return await fn();
|
|
182
|
+
} finally {
|
|
183
|
+
resolve();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#getStateClone(): GoalModeState | undefined {
|
|
188
|
+
const state = this.#host.getState();
|
|
189
|
+
return state ? cloneState(state) : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async #commitState(
|
|
193
|
+
state: GoalModeState | undefined,
|
|
194
|
+
options?: { persist?: "goal" | "goal_paused" | "none"; emit?: boolean },
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
this.#host.setState(state ? cloneState(state) : undefined);
|
|
197
|
+
if (options?.persist) {
|
|
198
|
+
this.#host.persist(options.persist, state);
|
|
199
|
+
}
|
|
200
|
+
if (options?.emit !== false) {
|
|
201
|
+
await this.#host.emit({ type: "goal_updated", goal: state ? cloneGoal(state.goal) : null, state });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#markActiveAccounting(goal: Goal, resetWallClock = false): void {
|
|
206
|
+
if (resetWallClock || this.#wallClock.activeGoalId !== goal.id) {
|
|
207
|
+
this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: goal.id };
|
|
208
|
+
}
|
|
209
|
+
if (this.#turnSnapshot) {
|
|
210
|
+
this.#turnSnapshot.activeGoalId = goal.id;
|
|
211
|
+
this.#turnSnapshot.baselineUsage = { ...this.#host.getCurrentUsage() };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#clearActiveAccounting(): void {
|
|
216
|
+
this.#wallClock = { lastAccountedAt: this.#now() };
|
|
217
|
+
if (this.#turnSnapshot) {
|
|
218
|
+
this.#turnSnapshot.activeGoalId = undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
clearAccounting(): void {
|
|
223
|
+
this.#turnSnapshot = undefined;
|
|
224
|
+
this.#clearActiveAccounting();
|
|
225
|
+
this.#budgetReportedFor = undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
onTurnStart(turnId: string, baselineUsage: GoalTokenUsage): void {
|
|
229
|
+
this.#turnSnapshot = { turnId, baselineUsage: { ...baselineUsage } };
|
|
230
|
+
const state = this.#host.getState();
|
|
231
|
+
if (state?.enabled && isAccountingStatus(state.goal)) {
|
|
232
|
+
this.#turnSnapshot.activeGoalId = state.goal.id;
|
|
233
|
+
if (this.#wallClock.activeGoalId !== state.goal.id) {
|
|
234
|
+
this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: state.goal.id };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async onToolCompleted(toolName: string): Promise<void> {
|
|
240
|
+
if (toolName === "goal") return;
|
|
241
|
+
if (!this.#hasAccountingState()) return;
|
|
242
|
+
await this.flushUsage("allowed");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async onGoalToolCompleted(): Promise<void> {
|
|
246
|
+
if (!this.#hasAccountingState()) return;
|
|
247
|
+
await this.flushUsage("suppressed");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async onAgentEnd(options?: { turnCompleted?: boolean; currentUsage?: GoalTokenUsage }): Promise<void> {
|
|
251
|
+
if (!this.#hasAccountingState()) {
|
|
252
|
+
this.#turnSnapshot = undefined;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await this.flushUsage("suppressed", options?.currentUsage);
|
|
256
|
+
this.#turnSnapshot = undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async onTaskAborted(options?: { reason?: "interrupted" | "internal" }): Promise<void> {
|
|
260
|
+
const state = this.#host.getState();
|
|
261
|
+
const needsAccounting = state?.enabled && isAccountingStatus(state.goal);
|
|
262
|
+
const needsPause = options?.reason === "interrupted" && state?.enabled && state.goal.status === "active";
|
|
263
|
+
if (!needsAccounting && !needsPause) {
|
|
264
|
+
this.#turnSnapshot = undefined;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
await this.#withAccounting(async () => {
|
|
268
|
+
await this.#flushUsageLocked("suppressed", undefined, options?.reason === "internal");
|
|
269
|
+
this.#turnSnapshot = undefined;
|
|
270
|
+
if (options?.reason !== "interrupted") return;
|
|
271
|
+
const cloned = this.#getStateClone();
|
|
272
|
+
if (!cloned?.enabled || cloned.goal.status !== "active") return;
|
|
273
|
+
cloned.enabled = false;
|
|
274
|
+
cloned.goal.status = "paused";
|
|
275
|
+
cloned.goal.updatedAt = this.#now();
|
|
276
|
+
this.#clearActiveAccounting();
|
|
277
|
+
this.#budgetReportedFor = undefined;
|
|
278
|
+
await this.#commitState(cloned, { persist: "goal_paused" });
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async onThreadResumed(options?: { preserveActiveGoal?: boolean }): Promise<GoalModeState | undefined> {
|
|
283
|
+
const state = this.#getStateClone();
|
|
284
|
+
if (!state) return undefined;
|
|
285
|
+
if (options?.preserveActiveGoal && state.enabled && state.goal.status === "active") {
|
|
286
|
+
this.#markActiveAccounting(state.goal, true);
|
|
287
|
+
await this.#commitState(state, { emit: true });
|
|
288
|
+
return state;
|
|
289
|
+
}
|
|
290
|
+
if (state.goal.status === "active") {
|
|
291
|
+
state.enabled = false;
|
|
292
|
+
state.goal.status = "paused";
|
|
293
|
+
state.goal.updatedAt = this.#now();
|
|
294
|
+
this.#clearActiveAccounting();
|
|
295
|
+
this.#budgetReportedFor = undefined;
|
|
296
|
+
await this.#commitState(state, { persist: "goal_paused" });
|
|
297
|
+
return state;
|
|
298
|
+
}
|
|
299
|
+
if (state.enabled && isAccountingStatus(state.goal)) {
|
|
300
|
+
this.#markActiveAccounting(state.goal);
|
|
301
|
+
} else {
|
|
302
|
+
this.#clearActiveAccounting();
|
|
303
|
+
}
|
|
304
|
+
await this.#commitState(state, { emit: true });
|
|
305
|
+
return state;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async onBudgetMutated(newBudget: number | undefined): Promise<GoalModeState | undefined> {
|
|
309
|
+
validateTokenBudget(newBudget);
|
|
310
|
+
return await this.#withAccounting(async () => {
|
|
311
|
+
this.#budgetReportedFor = undefined;
|
|
312
|
+
await this.#flushUsageLocked("suppressed");
|
|
313
|
+
const state = this.#getStateClone();
|
|
314
|
+
if (!state?.goal) return undefined;
|
|
315
|
+
state.goal.tokenBudget = newBudget;
|
|
316
|
+
state.goal.updatedAt = this.#now();
|
|
317
|
+
let shouldSteer = false;
|
|
318
|
+
if (newBudget !== undefined && state.goal.tokensUsed >= newBudget) {
|
|
319
|
+
if (state.goal.status === "active") {
|
|
320
|
+
state.goal.status = "budget-limited";
|
|
321
|
+
shouldSteer = true;
|
|
322
|
+
}
|
|
323
|
+
} else if (state.goal.status === "budget-limited") {
|
|
324
|
+
state.goal.status = "active";
|
|
325
|
+
state.enabled = true;
|
|
326
|
+
this.#markActiveAccounting(state.goal);
|
|
327
|
+
}
|
|
328
|
+
await this.#commitState(state, { persist: state.enabled ? "goal" : "goal_paused" });
|
|
329
|
+
if (shouldSteer) {
|
|
330
|
+
await this.#sendBudgetLimitSteer(state.goal);
|
|
331
|
+
}
|
|
332
|
+
return state;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async #flushUsageLocked(
|
|
337
|
+
steering: GoalBudgetSteering,
|
|
338
|
+
currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(),
|
|
339
|
+
persistWallClock = false,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
const state = this.#getStateClone();
|
|
342
|
+
if (!state?.enabled || !isAccountingStatus(state.goal)) return;
|
|
343
|
+
if (this.#turnSnapshot?.activeGoalId !== state.goal.id && this.#wallClock.activeGoalId !== state.goal.id) return;
|
|
344
|
+
|
|
345
|
+
const tokenDelta =
|
|
346
|
+
this.#turnSnapshot?.activeGoalId === state.goal.id
|
|
347
|
+
? goalTokenDelta(currentUsage, this.#turnSnapshot.baselineUsage)
|
|
348
|
+
: 0;
|
|
349
|
+
const wallSeconds =
|
|
350
|
+
this.#wallClock.activeGoalId === state.goal.id
|
|
351
|
+
? Math.max(0, Math.floor((this.#now() - this.#wallClock.lastAccountedAt) / 1000))
|
|
352
|
+
: 0;
|
|
353
|
+
if (tokenDelta <= 0 && wallSeconds <= 0) return;
|
|
354
|
+
|
|
355
|
+
state.goal.tokensUsed += tokenDelta;
|
|
356
|
+
state.goal.timeUsedSeconds += wallSeconds;
|
|
357
|
+
state.goal.updatedAt = this.#now();
|
|
358
|
+
const flippedToBudgetLimited =
|
|
359
|
+
state.goal.tokenBudget !== undefined &&
|
|
360
|
+
state.goal.tokensUsed >= state.goal.tokenBudget &&
|
|
361
|
+
state.goal.status === "active";
|
|
362
|
+
if (flippedToBudgetLimited) {
|
|
363
|
+
state.goal.status = "budget-limited";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (this.#turnSnapshot?.activeGoalId === state.goal.id) {
|
|
367
|
+
this.#turnSnapshot.baselineUsage = { ...currentUsage };
|
|
368
|
+
}
|
|
369
|
+
if (this.#wallClock.activeGoalId === state.goal.id && wallSeconds > 0) {
|
|
370
|
+
this.#wallClock.lastAccountedAt += wallSeconds * 1000;
|
|
371
|
+
}
|
|
372
|
+
// Persisting wall-clock-only accounting on every tool event bloats /goal sessions with full
|
|
373
|
+
// objective snapshots. Keep normal tool flushes in memory/UI only, but make wall-clock
|
|
374
|
+
// usage durable before internal session switches because the active runtime is leaving.
|
|
375
|
+
const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited || (persistWallClock && wallSeconds > 0);
|
|
376
|
+
await this.#commitState(state, { persist: shouldPersistUsage ? "goal" : undefined });
|
|
377
|
+
|
|
378
|
+
if (state.goal.status !== "budget-limited") {
|
|
379
|
+
this.#budgetReportedFor = undefined;
|
|
380
|
+
}
|
|
381
|
+
if (steering === "allowed" && flippedToBudgetLimited && this.#budgetReportedFor !== state.goal.id) {
|
|
382
|
+
await this.#sendBudgetLimitSteer(state.goal);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async flushUsage(
|
|
387
|
+
steering: GoalBudgetSteering,
|
|
388
|
+
currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(),
|
|
389
|
+
): Promise<void> {
|
|
390
|
+
await this.#withAccounting(() => this.#flushUsageLocked(steering, currentUsage));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#createGoalState(objective: string, tokenBudget: number | undefined): GoalModeState {
|
|
394
|
+
const now = this.#now();
|
|
395
|
+
const goal: Goal = {
|
|
396
|
+
id: crypto.randomUUID(),
|
|
397
|
+
objective,
|
|
398
|
+
status: "active",
|
|
399
|
+
tokenBudget,
|
|
400
|
+
tokensUsed: 0,
|
|
401
|
+
timeUsedSeconds: 0,
|
|
402
|
+
createdAt: now,
|
|
403
|
+
updatedAt: now,
|
|
404
|
+
};
|
|
405
|
+
return { enabled: true, mode: "active", goal };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async createGoal(input: { objective: string; tokenBudget?: number }): Promise<GoalModeState> {
|
|
409
|
+
const objective = input.objective.trim();
|
|
410
|
+
if (!objective) throw new Error("objective is required when op=create");
|
|
411
|
+
validateTokenBudget(input.tokenBudget);
|
|
412
|
+
return await this.#withAccounting(async () => {
|
|
413
|
+
const existing = this.#host.getState();
|
|
414
|
+
if (existing?.goal && existing.goal.status !== "dropped" && existing.goal.status !== "complete") {
|
|
415
|
+
throw new Error("cannot create a new goal because this session already has a goal");
|
|
416
|
+
}
|
|
417
|
+
const state = this.#createGoalState(objective, input.tokenBudget);
|
|
418
|
+
this.#budgetReportedFor = undefined;
|
|
419
|
+
this.#markActiveAccounting(state.goal);
|
|
420
|
+
await this.#commitState(state, { persist: "goal" });
|
|
421
|
+
return state;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async replaceGoal(input: { objective: string; tokenBudget?: number }): Promise<GoalModeState> {
|
|
426
|
+
const objective = input.objective.trim();
|
|
427
|
+
if (!objective) throw new Error("objective is required when op=replace");
|
|
428
|
+
validateTokenBudget(input.tokenBudget);
|
|
429
|
+
return await this.#withAccounting(async () => {
|
|
430
|
+
const existing = this.#host.getState();
|
|
431
|
+
if (!existing?.enabled || !isAccountingStatus(existing.goal)) {
|
|
432
|
+
throw new Error("cannot replace goal because no goal is active");
|
|
433
|
+
}
|
|
434
|
+
await this.#flushUsageLocked("suppressed");
|
|
435
|
+
const state = this.#createGoalState(objective, input.tokenBudget);
|
|
436
|
+
this.#budgetReportedFor = undefined;
|
|
437
|
+
this.#markActiveAccounting(state.goal);
|
|
438
|
+
await this.#commitState(state, { persist: "goal" });
|
|
439
|
+
return state;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async resumeGoal(): Promise<GoalModeState> {
|
|
444
|
+
return await this.#withAccounting(async () => {
|
|
445
|
+
const state = this.#getStateClone();
|
|
446
|
+
if (!state?.goal) throw new Error("No paused goal.");
|
|
447
|
+
if (state.goal.status === "complete") throw new Error("Goal is already complete.");
|
|
448
|
+
state.enabled = true;
|
|
449
|
+
state.mode = "active";
|
|
450
|
+
state.reason = undefined;
|
|
451
|
+
state.goal.status = "active";
|
|
452
|
+
state.goal.updatedAt = this.#now();
|
|
453
|
+
this.#budgetReportedFor = undefined;
|
|
454
|
+
this.#markActiveAccounting(state.goal);
|
|
455
|
+
await this.#commitState(state, { persist: "goal" });
|
|
456
|
+
return state;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async pauseGoal(options?: { reason?: GoalModeState["reason"] }): Promise<GoalModeState | undefined> {
|
|
461
|
+
return await this.#withAccounting(async () => {
|
|
462
|
+
await this.#flushUsageLocked("suppressed");
|
|
463
|
+
const state = this.#getStateClone();
|
|
464
|
+
if (!state?.goal) return undefined;
|
|
465
|
+
state.enabled = false;
|
|
466
|
+
state.mode = "active";
|
|
467
|
+
state.reason = options?.reason;
|
|
468
|
+
if (state.goal.status === "active" || state.goal.status === "budget-limited") {
|
|
469
|
+
state.goal.status = "paused";
|
|
470
|
+
}
|
|
471
|
+
state.goal.updatedAt = this.#now();
|
|
472
|
+
this.#clearActiveAccounting();
|
|
473
|
+
this.#budgetReportedFor = undefined;
|
|
474
|
+
await this.#commitState(state, { persist: "goal_paused" });
|
|
475
|
+
return state;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Record one agent-reported impossibility (CC 2.1.261's `impossible`
|
|
481
|
+
* channel): flush usage, bump the dispute counter, persist. The caller
|
|
482
|
+
* (goal tool) then runs the independent evaluator — confirmation pauses
|
|
483
|
+
* the goal, refutation leaves it active and the count feeds the cap.
|
|
484
|
+
*/
|
|
485
|
+
async recordImpossibleReport(): Promise<Goal> {
|
|
486
|
+
return await this.#withAccounting(async () => {
|
|
487
|
+
await this.#flushUsageLocked("suppressed");
|
|
488
|
+
const state = this.#getStateClone();
|
|
489
|
+
if (!state?.enabled || !isAccountingStatus(state.goal)) {
|
|
490
|
+
throw new Error("cannot report impossibility because no active goal exists");
|
|
491
|
+
}
|
|
492
|
+
state.goal.impossibleReports = (state.goal.impossibleReports ?? 0) + 1;
|
|
493
|
+
state.goal.updatedAt = this.#now();
|
|
494
|
+
await this.#commitState(state, { persist: "goal" });
|
|
495
|
+
return state.goal;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async dropGoal(): Promise<Goal | undefined> {
|
|
500
|
+
return await this.#withAccounting(async () => {
|
|
501
|
+
await this.#flushUsageLocked("suppressed");
|
|
502
|
+
const state = this.#getStateClone();
|
|
503
|
+
if (!state?.goal) return undefined;
|
|
504
|
+
const dropped = { ...state.goal, status: "dropped" as const, updatedAt: this.#now() };
|
|
505
|
+
this.#clearActiveAccounting();
|
|
506
|
+
this.#budgetReportedFor = undefined;
|
|
507
|
+
await this.#host.emit({
|
|
508
|
+
type: "goal_updated",
|
|
509
|
+
goal: dropped,
|
|
510
|
+
state: { ...state, enabled: false, goal: dropped },
|
|
511
|
+
});
|
|
512
|
+
await this.#commitState(undefined, { persist: "none", emit: false });
|
|
513
|
+
return dropped;
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async completeGoalFromTool(): Promise<Goal> {
|
|
518
|
+
return await this.#withAccounting(async () => {
|
|
519
|
+
await this.#flushUsageLocked("suppressed");
|
|
520
|
+
const state = this.#getStateClone();
|
|
521
|
+
if (!state?.goal) {
|
|
522
|
+
throw new Error("cannot complete goal because no goal is active");
|
|
523
|
+
}
|
|
524
|
+
if (state.goal.status === "complete") {
|
|
525
|
+
throw new Error("goal is already complete");
|
|
526
|
+
}
|
|
527
|
+
if (state.goal.status === "dropped") {
|
|
528
|
+
throw new Error("cannot complete a dropped goal");
|
|
529
|
+
}
|
|
530
|
+
state.enabled = false;
|
|
531
|
+
state.goal.status = "complete";
|
|
532
|
+
state.goal.updatedAt = this.#now();
|
|
533
|
+
state.mode = "exiting";
|
|
534
|
+
state.reason = "completed";
|
|
535
|
+
this.#clearActiveAccounting();
|
|
536
|
+
this.#budgetReportedFor = undefined;
|
|
537
|
+
await this.#commitState(state, { persist: "goal" });
|
|
538
|
+
return state.goal;
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
buildActivePrompt(): string | undefined {
|
|
543
|
+
const state = this.#host.getState();
|
|
544
|
+
return state?.enabled && state.goal && state.goal.status === "active"
|
|
545
|
+
? renderGoalPrompt("active", state.goal)
|
|
546
|
+
: undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
buildContinuationPrompt(): string | undefined {
|
|
550
|
+
const state = this.#host.getState();
|
|
551
|
+
return state?.enabled && state.goal.status === "active"
|
|
552
|
+
? renderGoalPrompt("continuation", state.goal)
|
|
553
|
+
: undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async #sendBudgetLimitSteer(goal: Goal): Promise<void> {
|
|
557
|
+
if (this.#budgetReportedFor === goal.id) return;
|
|
558
|
+
this.#budgetReportedFor = goal.id;
|
|
559
|
+
await this.#host.sendHiddenMessage({
|
|
560
|
+
customType: "goal-budget-limit",
|
|
561
|
+
content: renderGoalPrompt("budget-limit", goal),
|
|
562
|
+
deliverAs: "steer",
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal — pure goal state types.
|
|
3
|
+
*
|
|
4
|
+
* Ported from oh-my-pi (github.com/can1357/oh-my-pi, a fork of badlogic/pi-mono)
|
|
5
|
+
* `packages/coding-agent/src/goals/state.ts` — the data model is carried over
|
|
6
|
+
* verbatim. Dropped from the omp source (host-internal surfaces with no pi
|
|
7
|
+
* counterpart):
|
|
8
|
+
* - UsageStatistics pick (replaced by the local GoalTokenUsage shape, the
|
|
9
|
+
* four fields the budget actually accounts for)
|
|
10
|
+
*
|
|
11
|
+
* Goal ids: omp used `Snowflake.next()`; pi-goal uses `crypto.randomUUID()`
|
|
12
|
+
* (ids are opaque uniqueness tokens inside one session — nothing sorts them).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type GoalStatus = "active" | "paused" | "budget-limited" | "complete" | "dropped";
|
|
16
|
+
|
|
17
|
+
export interface Goal {
|
|
18
|
+
id: string;
|
|
19
|
+
objective: string;
|
|
20
|
+
status: GoalStatus;
|
|
21
|
+
tokenBudget?: number;
|
|
22
|
+
tokensUsed: number;
|
|
23
|
+
timeUsedSeconds: number;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
/** Times the agent reported the goal impossible and the independent
|
|
27
|
+
* evaluator REFUTED the claim (a confirmed impossibility pauses the goal
|
|
28
|
+
* instead). Drives the dispute cap; absent on pre-evaluator snapshots. */
|
|
29
|
+
impossibleReports?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GoalModeState {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
mode: "active" | "exiting";
|
|
35
|
+
reason?: "completed" | "impossible-confirmed" | "impossible-disputed";
|
|
36
|
+
goal: Goal;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How the independent evaluator resolved the last complete/impossible call
|
|
40
|
+
* (carried in tool details and surfaced by the result renderer). */
|
|
41
|
+
export type GoalEvaluatorVerdict =
|
|
42
|
+
| "confirmed"
|
|
43
|
+
| "rejected"
|
|
44
|
+
| "unavailable-fallback"
|
|
45
|
+
| "impossible-confirmed"
|
|
46
|
+
| "impossible-refuted"
|
|
47
|
+
| "impossible-disputed"
|
|
48
|
+
| "unavailable";
|
|
49
|
+
|
|
50
|
+
export interface GoalToolDetails {
|
|
51
|
+
op: "create" | "get" | "complete" | "resume" | "drop" | "impossible";
|
|
52
|
+
goal?: Goal | null;
|
|
53
|
+
remainingTokens?: number | null;
|
|
54
|
+
completionBudgetReport?: string | null;
|
|
55
|
+
evaluator?: { verdict: GoalEvaluatorVerdict; reason: string };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type GoalRuntimeEvent =
|
|
59
|
+
| { type: "goal_updated"; goal: Goal | null; state?: GoalModeState }
|
|
60
|
+
| { type: "goal_continuation_requested"; prompt: string };
|
|
61
|
+
|
|
62
|
+
export interface GoalTokenUsage {
|
|
63
|
+
input: number;
|
|
64
|
+
output: number;
|
|
65
|
+
cacheRead: number;
|
|
66
|
+
cacheWrite: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type GoalBudgetSteering = "allowed" | "suppressed";
|
|
70
|
+
export type GoalTerminalMetricEmission = "emit" | "suppress";
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// Guards (session restore)
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether an unknown value is a persisted goal object. */
|
|
81
|
+
export function isGoal(value: unknown): value is Goal {
|
|
82
|
+
if (!isRecord(value)) return false;
|
|
83
|
+
if (typeof value.id !== "string" || typeof value.objective !== "string") return false;
|
|
84
|
+
if (
|
|
85
|
+
value.status !== "active" &&
|
|
86
|
+
value.status !== "paused" &&
|
|
87
|
+
value.status !== "budget-limited" &&
|
|
88
|
+
value.status !== "complete" &&
|
|
89
|
+
value.status !== "dropped"
|
|
90
|
+
) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
value.tokenBudget !== undefined &&
|
|
95
|
+
(typeof value.tokenBudget !== "number" || !Number.isInteger(value.tokenBudget))
|
|
96
|
+
)
|
|
97
|
+
return false;
|
|
98
|
+
if (
|
|
99
|
+
value.impossibleReports !== undefined &&
|
|
100
|
+
(typeof value.impossibleReports !== "number" || !Number.isInteger(value.impossibleReports) || value.impossibleReports < 0)
|
|
101
|
+
)
|
|
102
|
+
return false;
|
|
103
|
+
return (
|
|
104
|
+
typeof value.tokensUsed === "number" &&
|
|
105
|
+
typeof value.timeUsedSeconds === "number" &&
|
|
106
|
+
typeof value.createdAt === "number" &&
|
|
107
|
+
typeof value.updatedAt === "number"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether an unknown value is a persisted `{ enabled, goal }` snapshot. */
|
|
112
|
+
export function isGoalStateSnapshot(value: unknown): value is { enabled: boolean; goal: Goal } {
|
|
113
|
+
return isRecord(value) && typeof value.enabled === "boolean" && isGoal(value.goal);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function cloneGoal(goal: Goal): Goal {
|
|
117
|
+
return goal.tokenBudget === undefined
|
|
118
|
+
? { ...goal, tokenBudget: undefined }
|
|
119
|
+
: { ...goal, tokenBudget: goal.tokenBudget };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function cloneState(state: GoalModeState): GoalModeState {
|
|
123
|
+
return { ...state, reason: state.reason, goal: cloneGoal(state.goal) };
|
|
124
|
+
}
|