@kky42/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.
Files changed (41) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/package.json +73 -0
  5. package/src/commands.ts +107 -0
  6. package/src/continuation-scheduler.ts +174 -0
  7. package/src/format.ts +232 -0
  8. package/src/goal-accounting.ts +128 -0
  9. package/src/goal-persistence.ts +73 -0
  10. package/src/goal-runtime-agent-handlers.ts +51 -0
  11. package/src/goal-runtime-controller.ts +162 -0
  12. package/src/goal-runtime-event-handler-types.ts +166 -0
  13. package/src/goal-runtime-event-handlers.ts +31 -0
  14. package/src/goal-runtime-event-utils.ts +93 -0
  15. package/src/goal-runtime-events.ts +24 -0
  16. package/src/goal-runtime-input-context-handlers.ts +144 -0
  17. package/src/goal-runtime-session-handlers.ts +131 -0
  18. package/src/goal-runtime-state.ts +22 -0
  19. package/src/goal-runtime-status.ts +62 -0
  20. package/src/goal-runtime-turn-handlers.ts +66 -0
  21. package/src/goal-state-controller.ts +210 -0
  22. package/src/goal-transition-effects.ts +91 -0
  23. package/src/goal-transition.ts +396 -0
  24. package/src/index.ts +9 -0
  25. package/src/prompts.ts +170 -0
  26. package/src/queued-goal-messages.ts +166 -0
  27. package/src/queued-goal-work.ts +96 -0
  28. package/src/recovery-adapters.ts +66 -0
  29. package/src/recovery-machine.ts +196 -0
  30. package/src/recovery-phase.ts +95 -0
  31. package/src/recovery-runtime.ts +97 -0
  32. package/src/recovery.ts +151 -0
  33. package/src/runtime-config.ts +7 -0
  34. package/src/stale-queued-work-guard.ts +114 -0
  35. package/src/stale-queued-work-obligations.ts +291 -0
  36. package/src/stale-queued-work-reducer.ts +483 -0
  37. package/src/stale-queued-work-terminal-cleanup.ts +84 -0
  38. package/src/stale-queued-work-types.ts +81 -0
  39. package/src/state.ts +404 -0
  40. package/src/tools.ts +101 -0
  41. package/src/types.ts +60 -0
package/src/state.ts ADDED
@@ -0,0 +1,404 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import {
4
+ CUSTOM_ENTRY_TYPE,
5
+ MAX_OBJECTIVE_CHARS,
6
+ type GoalCustomEntry,
7
+ type GoalEntrySource,
8
+ type GoalResult,
9
+ type GoalSnapshot,
10
+ type GoalStatus,
11
+ type SessionEntryLike,
12
+ type ThreadGoal,
13
+ } from "./types.js";
14
+
15
+ export interface ApplyUsageOptions {
16
+ expectedGoalId?: string | null;
17
+ accountBudgetLimited?: boolean;
18
+ }
19
+
20
+ export function unixSeconds(): number {
21
+ return Math.floor(Date.now() / 1000);
22
+ }
23
+
24
+ export function cloneGoal(goal: ThreadGoal): ThreadGoal {
25
+ return {
26
+ ...goal,
27
+ usage: { ...goal.usage },
28
+ };
29
+ }
30
+
31
+ export function goalsEquivalent(left: ThreadGoal, right: ThreadGoal): boolean {
32
+ return (
33
+ left.goalId === right.goalId &&
34
+ left.objective === right.objective &&
35
+ left.status === right.status &&
36
+ left.tokenBudget === right.tokenBudget &&
37
+ left.createdAt === right.createdAt &&
38
+ left.updatedAt === right.updatedAt &&
39
+ left.usage.tokensUsed === right.usage.tokensUsed &&
40
+ left.usage.activeSeconds === right.usage.activeSeconds
41
+ );
42
+ }
43
+
44
+ export function validateObjective(objective: string): string | null {
45
+ const trimmed = objective.trim();
46
+ if (trimmed.length === 0) {
47
+ return "Objective must not be empty.";
48
+ }
49
+ if ([...trimmed].length > MAX_OBJECTIVE_CHARS) {
50
+ return `Objective must be ${MAX_OBJECTIVE_CHARS} characters or fewer.`;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ export function validateTokenBudget(tokenBudget: number | null | undefined): string | null {
56
+ if (tokenBudget === null || tokenBudget === undefined) {
57
+ return null;
58
+ }
59
+ if (!Number.isInteger(tokenBudget) || tokenBudget <= 0) {
60
+ return "Token budget must be a positive integer.";
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function statusAfterBudgetLimit(status: GoalStatus, tokensUsed: number, tokenBudget: number | null): GoalStatus {
66
+ if (status === "active" && tokenBudget !== null && tokensUsed >= tokenBudget) {
67
+ return "budgetLimited";
68
+ }
69
+ return status;
70
+ }
71
+
72
+ export function createThreadGoal(objective: string, tokenBudget?: number | null, now = unixSeconds()): ThreadGoal {
73
+ return {
74
+ goalId: randomUUID(),
75
+ objective: objective.trim(),
76
+ status: "active",
77
+ tokenBudget: tokenBudget ?? null,
78
+ usage: {
79
+ tokensUsed: 0,
80
+ activeSeconds: 0,
81
+ },
82
+ createdAt: now,
83
+ updatedAt: now,
84
+ };
85
+ }
86
+
87
+ export function setEntry(goal: ThreadGoal, source: GoalEntrySource, at = unixSeconds()): GoalCustomEntry {
88
+ return {
89
+ version: 1,
90
+ kind: "set",
91
+ source,
92
+ goal: cloneGoal(goal),
93
+ at,
94
+ };
95
+ }
96
+
97
+ export function clearEntry(
98
+ clearedGoalId: string | null,
99
+ source: GoalEntrySource,
100
+ at = unixSeconds(),
101
+ ): GoalCustomEntry {
102
+ return {
103
+ version: 1,
104
+ kind: "clear",
105
+ source,
106
+ clearedGoalId,
107
+ at,
108
+ };
109
+ }
110
+
111
+ export function hostOverflowCapResetEntry(active: boolean, at = unixSeconds()): GoalCustomEntry {
112
+ return {
113
+ version: 1,
114
+ kind: "host_overflow_cap_reset",
115
+ active,
116
+ at,
117
+ };
118
+ }
119
+
120
+ export function isGoalCustomEntry(data: unknown): data is GoalCustomEntry {
121
+ if (!data || typeof data !== "object") {
122
+ return false;
123
+ }
124
+ const entry = data as GoalCustomEntry;
125
+ if (entry.version !== 1 || typeof entry.at !== "number") {
126
+ return false;
127
+ }
128
+ if (entry.kind === "clear") {
129
+ return entry.clearedGoalId === null || typeof entry.clearedGoalId === "string";
130
+ }
131
+ if (entry.kind === "host_overflow_cap_reset") {
132
+ return typeof entry.active === "boolean";
133
+ }
134
+ return entry.kind === "set" && isThreadGoal(entry.goal);
135
+ }
136
+
137
+ export function isThreadGoal(goal: unknown): goal is ThreadGoal {
138
+ if (!goal || typeof goal !== "object") {
139
+ return false;
140
+ }
141
+ const candidate = goal as ThreadGoal;
142
+ return (
143
+ typeof candidate.goalId === "string" &&
144
+ typeof candidate.objective === "string" &&
145
+ isGoalStatus(candidate.status) &&
146
+ (candidate.tokenBudget === null || typeof candidate.tokenBudget === "number") &&
147
+ typeof candidate.createdAt === "number" &&
148
+ typeof candidate.updatedAt === "number" &&
149
+ candidate.usage !== undefined &&
150
+ typeof candidate.usage.tokensUsed === "number" &&
151
+ typeof candidate.usage.activeSeconds === "number"
152
+ );
153
+ }
154
+
155
+ export function isGoalStatus(status: unknown): status is GoalStatus {
156
+ return (
157
+ status === "active" ||
158
+ status === "paused" ||
159
+ status === "blocked" ||
160
+ status === "budgetLimited" ||
161
+ status === "complete"
162
+ );
163
+ }
164
+
165
+ export function reconstructGoal(entries: Iterable<SessionEntryLike>): GoalSnapshot {
166
+ let goal: ThreadGoal | null = null;
167
+
168
+ for (const entry of entries) {
169
+ if (entry.type !== "custom" || entry.customType !== CUSTOM_ENTRY_TYPE) {
170
+ continue;
171
+ }
172
+ if (!isGoalCustomEntry(entry.data)) {
173
+ continue;
174
+ }
175
+ if (entry.data.kind === "clear") {
176
+ goal = null;
177
+ } else if (entry.data.kind === "set") {
178
+ goal = cloneGoal(entry.data.goal);
179
+ }
180
+ }
181
+
182
+ return {
183
+ goal,
184
+ hasGoal: goal !== null,
185
+ };
186
+ }
187
+
188
+ export function reconstructHostOverflowCapNeedsUserReset(entries: Iterable<SessionEntryLike>): boolean {
189
+ let needsReset = false;
190
+
191
+ for (const entry of entries) {
192
+ if (entry.type !== "custom" || entry.customType !== CUSTOM_ENTRY_TYPE) {
193
+ continue;
194
+ }
195
+ if (!isGoalCustomEntry(entry.data)) {
196
+ continue;
197
+ }
198
+ if (entry.data.kind === "host_overflow_cap_reset") {
199
+ needsReset = entry.data.active;
200
+ }
201
+ }
202
+
203
+ return needsReset;
204
+ }
205
+
206
+ export function createGoal(current: ThreadGoal | null, objective: string, tokenBudget?: number | null): GoalResult {
207
+ if (current && current.status !== "complete") {
208
+ return {
209
+ ok: false,
210
+ message:
211
+ "cannot create a new goal because this thread already has a non-complete goal; use update_goal to mark it complete, /goal clear, or /goal <objective> to replace it",
212
+ goal: current,
213
+ };
214
+ }
215
+
216
+ const objectiveError = validateObjective(objective);
217
+ if (objectiveError) {
218
+ return { ok: false, message: objectiveError, goal: null };
219
+ }
220
+
221
+ const budgetError = validateTokenBudget(tokenBudget);
222
+ if (budgetError) {
223
+ return { ok: false, message: budgetError, goal: null };
224
+ }
225
+
226
+ const goal = createThreadGoal(objective, tokenBudget);
227
+ return {
228
+ ok: true,
229
+ message: "Goal created.",
230
+ goal,
231
+ };
232
+ }
233
+
234
+ export function replaceGoal(objective: string, tokenBudget?: number | null): GoalResult {
235
+ const objectiveError = validateObjective(objective);
236
+ if (objectiveError) {
237
+ return { ok: false, message: objectiveError, goal: null };
238
+ }
239
+
240
+ const budgetError = validateTokenBudget(tokenBudget);
241
+ if (budgetError) {
242
+ return { ok: false, message: budgetError, goal: null };
243
+ }
244
+
245
+ const goal = createThreadGoal(objective, tokenBudget);
246
+ return {
247
+ ok: true,
248
+ message: "Goal set.",
249
+ goal,
250
+ };
251
+ }
252
+
253
+ export function updateGoalStatus(current: ThreadGoal | null, status: GoalStatus): GoalResult {
254
+ if (!current) {
255
+ return {
256
+ ok: false,
257
+ message: "No active goal exists.",
258
+ goal: null,
259
+ };
260
+ }
261
+
262
+ if (current.status === "complete") {
263
+ if (status === "complete") {
264
+ return {
265
+ ok: true,
266
+ message: "Goal already complete.",
267
+ goal: current,
268
+ };
269
+ }
270
+ return {
271
+ ok: false,
272
+ message: "Completed goals are terminal; use /goal <objective> to replace or /goal clear before changing status.",
273
+ goal: current,
274
+ };
275
+ }
276
+
277
+ if (status === "complete") {
278
+ const goal = cloneGoal(current);
279
+ goal.status = "complete";
280
+ goal.updatedAt = unixSeconds();
281
+ return {
282
+ ok: true,
283
+ message: "Goal marked complete.",
284
+ goal,
285
+ };
286
+ }
287
+
288
+ if (status === "blocked") {
289
+ if (current.status === "blocked") {
290
+ return {
291
+ ok: true,
292
+ message: "Goal already blocked.",
293
+ goal: current,
294
+ };
295
+ }
296
+ if (current.status !== "active") {
297
+ return {
298
+ ok: false,
299
+ message: "Only active goals can be blocked.",
300
+ goal: current,
301
+ };
302
+ }
303
+ }
304
+
305
+ if (status === "paused" && current.status !== "active") {
306
+ return {
307
+ ok: false,
308
+ message: "Only active goals can be paused.",
309
+ goal: current,
310
+ };
311
+ }
312
+
313
+ if (status === "active" && current.status === "budgetLimited") {
314
+ return {
315
+ ok: false,
316
+ message: "Budget-limited goals are system-controlled and cannot be resumed.",
317
+ goal: current,
318
+ };
319
+ }
320
+
321
+ if (status === "active" && current.status !== "paused" && current.status !== "blocked") {
322
+ return {
323
+ ok: false,
324
+ message: "Only paused or blocked goals can be resumed.",
325
+ goal: current,
326
+ };
327
+ }
328
+
329
+ const goal = cloneGoal(current);
330
+ goal.status = statusAfterBudgetLimit(status, goal.usage.tokensUsed, goal.tokenBudget);
331
+ goal.updatedAt = unixSeconds();
332
+
333
+ return {
334
+ ok: true,
335
+ message: `Goal marked ${goal.status}.`,
336
+ goal,
337
+ };
338
+ }
339
+
340
+ export function applyUsage(
341
+ current: ThreadGoal | null,
342
+ tokensDelta: number,
343
+ activeSecondsDelta: number,
344
+ options: ApplyUsageOptions = {},
345
+ ): { goal: ThreadGoal | null; changed: boolean; crossedBudget: boolean } {
346
+ if (!current) {
347
+ return { goal: current, changed: false, crossedBudget: false };
348
+ }
349
+
350
+ if (
351
+ options.expectedGoalId !== undefined &&
352
+ options.expectedGoalId !== null &&
353
+ current.goalId !== options.expectedGoalId
354
+ ) {
355
+ return { goal: current, changed: false, crossedBudget: false };
356
+ }
357
+
358
+ const canAccount =
359
+ current.status === "active" || (options.accountBudgetLimited === true && current.status === "budgetLimited");
360
+ if (!canAccount) {
361
+ return { goal: current, changed: false, crossedBudget: false };
362
+ }
363
+
364
+ const tokens = Math.max(0, Math.trunc(tokensDelta));
365
+ const seconds = Math.max(0, Math.trunc(activeSecondsDelta));
366
+ if (tokens === 0 && seconds === 0) {
367
+ return { goal: current, changed: false, crossedBudget: false };
368
+ }
369
+
370
+ const goal = cloneGoal(current);
371
+ const wasUnderBudget = goal.tokenBudget === null || goal.usage.tokensUsed < goal.tokenBudget;
372
+ goal.usage.tokensUsed += tokens;
373
+ goal.usage.activeSeconds += seconds;
374
+ goal.status = statusAfterBudgetLimit(goal.status, goal.usage.tokensUsed, goal.tokenBudget);
375
+ goal.updatedAt = unixSeconds();
376
+
377
+ const crossedBudget =
378
+ current.status === "active" &&
379
+ wasUnderBudget &&
380
+ goal.tokenBudget !== null &&
381
+ goal.usage.tokensUsed >= goal.tokenBudget;
382
+
383
+ return { goal, changed: true, crossedBudget };
384
+ }
385
+
386
+ export function goalWithLiveUsage(
387
+ current: ThreadGoal | null,
388
+ activeGoalId: string | null,
389
+ lastAccountedAt: number | null,
390
+ now = Date.now(),
391
+ ): ThreadGoal | null {
392
+ if (!current || current.status !== "active" || activeGoalId !== current.goalId || lastAccountedAt === null) {
393
+ return current;
394
+ }
395
+
396
+ const liveSeconds = Math.max(0, Math.floor((now - lastAccountedAt) / 1000));
397
+ if (liveSeconds === 0) {
398
+ return current;
399
+ }
400
+
401
+ const goal = cloneGoal(current);
402
+ goal.usage.activeSeconds += liveSeconds;
403
+ return goal;
404
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+
5
+ import { goalToolResponse, toToolText, type GoalToolResponse } from "./format.js";
6
+ import { createGoal } from "./state.js";
7
+ import { TOOL_PROMPT_GUIDELINES } from "./prompts.js";
8
+ import type { GoalEntrySource, GoalResult, ThreadGoal } from "./types.js";
9
+
10
+ const EmptyParams = Type.Object({});
11
+
12
+ const CreateGoalParams = Type.Object({
13
+ objective: Type.String({
14
+ description: "Concrete objective to pursue until completion.",
15
+ }),
16
+ token_budget: Type.Optional(
17
+ Type.Integer({
18
+ description: "Optional positive integer token budget.",
19
+ minimum: 1,
20
+ }),
21
+ ),
22
+ });
23
+
24
+ const UpdateGoalParams = Type.Object({
25
+ status: StringEnum(["complete", "blocked"] as const, {
26
+ description:
27
+ "Set to complete only when the objective is achieved and no required work remains. Set to blocked only after the strict blocked audit is satisfied.",
28
+ }),
29
+ });
30
+
31
+ export interface ToolHost {
32
+ getGoal(): ThreadGoal | null;
33
+ setGoal(goal: ThreadGoal, source: GoalEntrySource, ctx: ExtensionContext): void;
34
+ updateGoal(status: "complete" | "blocked", source: GoalEntrySource, ctx: ExtensionContext): GoalResult;
35
+ }
36
+
37
+ function textResult(
38
+ text: string,
39
+ goal: ThreadGoal | null,
40
+ includeCompletionBudgetReport = false,
41
+ ): AgentToolResult<GoalToolResponse & { error: string | null }> {
42
+ return {
43
+ content: [{ type: "text", text }],
44
+ details: { ...goalToolResponse(goal, includeCompletionBudgetReport), error: null },
45
+ };
46
+ }
47
+
48
+ function throwToolError(message: string): never {
49
+ throw new Error(message);
50
+ }
51
+
52
+ export function registerGoalTools(pi: ExtensionAPI, host: ToolHost): void {
53
+ pi.registerTool({
54
+ name: "get_goal",
55
+ label: "Get Goal",
56
+ description: "Get the current Codex-style goal and usage for this pi session.",
57
+ promptSnippet: "Inspect the current goal, status, token budget, tokens used, and active elapsed time.",
58
+ promptGuidelines: TOOL_PROMPT_GUIDELINES,
59
+ parameters: EmptyParams,
60
+ async execute() {
61
+ const goal = host.getGoal();
62
+ return textResult(toToolText(goal), goal);
63
+ },
64
+ });
65
+
66
+ pi.registerTool({
67
+ name: "create_goal",
68
+ label: "Create Goal",
69
+ description: "Create a Codex-style long-running goal for this pi session.",
70
+ promptSnippet:
71
+ "Create one goal with an objective and optional positive token budget. Fails when a non-complete goal already exists; replaces a completed goal.",
72
+ promptGuidelines: TOOL_PROMPT_GUIDELINES,
73
+ parameters: CreateGoalParams,
74
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
75
+ const result = createGoal(host.getGoal(), params.objective, params.token_budget ?? null);
76
+ if (!result.ok || !result.goal) {
77
+ throwToolError(result.message);
78
+ }
79
+ host.setGoal(result.goal, "tool", ctx);
80
+ return textResult(toToolText(result.goal), result.goal);
81
+ },
82
+ });
83
+
84
+ pi.registerTool({
85
+ name: "update_goal",
86
+ label: "Update Goal",
87
+ description:
88
+ "Mark the current Codex-style goal complete or blocked. Complete requires verified achievement. Blocked requires the strict repeated-blocker audit; pause, resume, and budget limits are user/system controlled.",
89
+ promptSnippet:
90
+ "Mark the current goal complete after a completion audit, or blocked only after the strict repeated-blocker audit is satisfied.",
91
+ promptGuidelines: TOOL_PROMPT_GUIDELINES,
92
+ parameters: UpdateGoalParams,
93
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
94
+ const result = host.updateGoal(params.status, "tool", ctx);
95
+ if (!result.ok || !result.goal) {
96
+ throwToolError(result.message);
97
+ }
98
+ return textResult(toToolText(result.goal, true), result.goal, true);
99
+ },
100
+ });
101
+ }
package/src/types.ts ADDED
@@ -0,0 +1,60 @@
1
+ export const CUSTOM_ENTRY_TYPE = "pi-codex-goal";
2
+ export const MAX_OBJECTIVE_CHARS = 8000;
3
+
4
+ export type GoalStatus = "active" | "paused" | "blocked" | "budgetLimited" | "complete";
5
+
6
+ export interface GoalUsage {
7
+ tokensUsed: number;
8
+ activeSeconds: number;
9
+ }
10
+
11
+ export interface ThreadGoal {
12
+ goalId: string;
13
+ objective: string;
14
+ status: GoalStatus;
15
+ tokenBudget: number | null;
16
+ usage: GoalUsage;
17
+ createdAt: number;
18
+ updatedAt: number;
19
+ }
20
+
21
+ export type GoalEntrySource = "command" | "tool" | "runtime";
22
+
23
+ export type GoalCustomEntry =
24
+ | {
25
+ version: 1;
26
+ kind: "set";
27
+ source: GoalEntrySource;
28
+ goal: ThreadGoal;
29
+ at: number;
30
+ }
31
+ | {
32
+ version: 1;
33
+ kind: "clear";
34
+ source: GoalEntrySource;
35
+ clearedGoalId: string | null;
36
+ at: number;
37
+ }
38
+ | {
39
+ version: 1;
40
+ kind: "host_overflow_cap_reset";
41
+ active: boolean;
42
+ at: number;
43
+ };
44
+
45
+ export interface GoalResult {
46
+ ok: boolean;
47
+ message: string;
48
+ goal: ThreadGoal | null;
49
+ }
50
+
51
+ export interface GoalSnapshot {
52
+ goal: ThreadGoal | null;
53
+ hasGoal: boolean;
54
+ }
55
+
56
+ export interface SessionEntryLike {
57
+ type: string;
58
+ customType?: string;
59
+ data?: unknown;
60
+ }