@astrosheep/pi-goal-next 0.1.6 → 0.1.8
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/README.md +7 -7
- package/docs/architecture.md +11 -9
- package/docs/limits.md +14 -6
- package/package.json +1 -1
- package/src/accounting.ts +6 -1
- package/src/commands.ts +11 -6
- package/src/goal-commit.ts +7 -3
- package/src/goal.ts +18 -2
- package/src/lifecycle.ts +52 -21
- package/src/store.ts +1 -1
- package/src/tools.ts +1 -1
package/README.md
CHANGED
|
@@ -29,13 +29,13 @@ For a project-local extension, add the package path to the project's Pi extensio
|
|
|
29
29
|
/goal clear
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
With no goal, or when the current goal is complete, `/goal <objective>` creates a fresh goal. With an unfinished goal it instead updates the objective in place (Codex `thread/goal/set` semantics): same goal id, status, token budget, and cumulative usage are preserved, and an `objective_updated` steering message is sent. `/goal edit <objective>` performs the same in-place update. The model-facing `create_goal` tool still refuses while an unfinished goal exists. `pause` and `resume` are user commands; `clear` removes the goal semantically from the journal. Token suffixes accepted by create are `k` and `M` (for example, `200k` and `2M`).
|
|
33
33
|
|
|
34
34
|
## Model tools
|
|
35
35
|
|
|
36
36
|
- `get_goal` returns the current snapshot plus `remainingBudget` and `elapsedSeconds`, or reports that no goal exists.
|
|
37
37
|
- `create_goal` creates an active goal after an explicit request. It accepts `objective` and optional `token_budget` (a positive integer).
|
|
38
|
-
- `update_goal` accepts only `complete` or `blocked` after the model's self-audit.
|
|
38
|
+
- `update_goal` accepts only `complete` or `blocked` after the model's self-audit. `complete` is permitted from `active` and `budget_limited`, not `paused` or `blocked`; the host does not validate the declaration. On `complete` the result reports the final token usage.
|
|
39
39
|
|
|
40
40
|
The prompt text is copied byte-for-byte from Codex Goal (`continuation.md`, `budget_limit.md`, `objective_updated.md`); the only deletion is Codex's `update_plan` "Progress visibility" paragraph, because Pi has no `update_plan` tool. Continuation prompts state the objective inside `<objective>` as user-provided data and carry the budget, evidence, fidelity, completion-audit, and blocked-audit rules. The three-consecutive-turn blocked audit is prompt-level guidance; the runtime does not enforce it.
|
|
41
41
|
|
|
@@ -53,24 +53,24 @@ The prompt text is copied byte-for-byte from Codex Goal (`continuation.md`, `bud
|
|
|
53
53
|
^
|
|
54
54
|
update_goal blocked|
|
|
55
55
|
|
|
56
|
-
active --accounting limit--> budget_limited
|
|
56
|
+
active --accounting limit--> budget_limited --update_goal complete--> complete
|
|
57
57
|
any unfinished --/goal clear--> no goal
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
`complete` is terminal. A goal is one journal-folded goal per session branch; a new goal cannot be created until the prior one is complete (or cleared). `blocked` is a model declaration, not an automatic retry decision. Codex's three-consecutive-turn blocker guard lives verbatim in the continuation prompt and the `update_goal` description; it is prompt-level only, not enforced in runtime code.
|
|
60
|
+
`complete` is terminal. It may be declared from `active` or `budget_limited`, never from `paused` or `blocked`. A goal is one journal-folded goal per session branch; a new goal cannot be created until the prior one is complete (or cleared). `blocked` is a model declaration, not an automatic retry decision. Codex's three-consecutive-turn blocker guard lives verbatim in the continuation prompt and the `update_goal` description; it is prompt-level only, not enforced in runtime code.
|
|
61
61
|
|
|
62
62
|
## Limits and accounting
|
|
63
63
|
|
|
64
64
|
- The default maximum is 25 continuation turns (`maxContinuations`) per run. Change it with `/goal turns N`. `/goal resume` resets the run's continuation count and immediately schedules work when idle. It preserves the objective, journal history, cumulative usage, and token budget. If the token budget is exhausted or the continuation allowance is zero, resume reports the limit instead of claiming success.
|
|
65
65
|
- A token budget is unset by default. Set one at creation with `/goal --tokens N[k|M] ...` or later with `/goal budget N`.
|
|
66
66
|
- Usage comes directly from each completed assistant/tool-result event. Repeated delivery of the same live message object is deduplicated. Pi has not assigned a session entry id at this event boundary; historical messages are not re-accounted on reload.
|
|
67
|
-
-
|
|
67
|
+
- In usage journal intents, omitted usage fields serialize as zero; an explicit `null` preserves existing unknown semantics. An absent entire Pi usage object remains unknown.
|
|
68
68
|
- Nested or subagent usage is counted only when Pi exposes it as `toolResult.usage` data; see [limits](docs/limits.md).
|
|
69
|
-
- The final completing turn is
|
|
69
|
+
- Usage is attributed to the goal that owns the run. The final completing turn remains charged; runs begun after a goal is complete, paused, or blocked are unrelated and are not charged to it.
|
|
70
70
|
|
|
71
71
|
## Completion and blocking contract
|
|
72
72
|
|
|
73
|
-
The model calls `update_goal` after auditing the current goal against the Codex completion audit carried in every continuation prompt. The host accepts `complete` and `blocked` at face value and performs no independent verification; the prompt is the only guard. `complete` reports final token usage
|
|
73
|
+
The model calls `update_goal` after auditing the current goal against the Codex completion audit carried in every continuation prompt. The host accepts `complete` and `blocked` at face value and performs no independent verification; the prompt is the only guard. `complete` may transition an `active` or `budget_limited` goal and reports final token usage; it is prohibited from `paused` and `blocked`. `blocked` is meant to follow three consecutive turns with the same blocker. `paused` is user-only (`/goal pause`). Runtime accounting may instead move an active goal to `budget_limited`; the model cannot declare that state, and the transition triggers one `budget_limit.md` steering message.
|
|
74
74
|
|
|
75
75
|
Automatic continuation waits for a fresh normal assistant completion and for the entire Pi run to settle, including tools, retries, compaction, and queued input. Errors, cancellation (including cancellation after text finishes), and runs ending at a tool boundary do not trigger continuation or budget steering. Ordinary user input has no continuation attached; once the response to that input finishes normally, an active goal may continue.
|
|
76
76
|
|
package/docs/architecture.md
CHANGED
|
@@ -10,7 +10,7 @@ Pi 0.85.1 extension package adding a long-running `/goal`. Behavior follows Code
|
|
|
10
10
|
|
|
11
11
|
`active | paused | blocked | budget_limited | complete`
|
|
12
12
|
|
|
13
|
-
- Model's `update_goal` accepts only: `complete` | `blocked`. The model self-audits; the host does not validate the declaration.
|
|
13
|
+
- Model's `update_goal` accepts only: `complete` | `blocked`. `complete` is permitted from `active` and `budget_limited`, but not from `paused` or `blocked`. The model self-audits; the host does not validate the declaration.
|
|
14
14
|
- `budget_limited` is set only by runtime/system paths (accounting).
|
|
15
15
|
- `clear` is a journal entry `goal.cleared`; `fold()` yields null. Deletion is semantic.
|
|
16
16
|
- One goal per session branch. `create_goal` refuses when an unfinished goal exists.
|
|
@@ -37,11 +37,12 @@ Dependency direction: `index → {tools, commands, lifecycle, ui} → {goal-comm
|
|
|
37
37
|
|
|
38
38
|
```ts
|
|
39
39
|
current(): GoalSnapshot | null // { goal, revision }
|
|
40
|
+
getRevision(): number // available even when current() is null
|
|
40
41
|
commit(intent, expectedRevision): Promise<{ ok, snapshot } | { conflict, snapshot }>
|
|
41
42
|
subscribe(fn): unsubscribe
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
Internally: check `revision === expectedRevision` and no pending → mark pending → `await store.append` → success: swap in, revision+1, notify; failure: release pending, report. Critical section wraps one append only — never verification, prompt building, or sendMessage.
|
|
45
|
+
Internally: check `revision === expectedRevision` and no pending → mark pending → `await store.append` → success: swap in, revision+1, notify; failure: release pending, report. `getRevision()` remains available after a clear, so a clear→create sequence can use the current CAS revision. Critical section wraps one append only — never verification, prompt building, or sendMessage.
|
|
45
46
|
|
|
46
47
|
## Two counters
|
|
47
48
|
|
|
@@ -62,7 +63,7 @@ User messages win: `input` invalidates outstanding continuation attempts before
|
|
|
62
63
|
|
|
63
64
|
1. `session_start`: invalidate pending work, rebuild, and transition any restored `active` goal to `paused`. Never silently resume.
|
|
64
65
|
2. `session_before_tree`: invalidate pending work; the next event rebuilds from the selected branch.
|
|
65
|
-
3.
|
|
66
|
+
3. Pi generates its normal conversation summary without a goal override. Goal state remains in its separate journal; `get_goal` and continuation prompts restore the relevant goal context.
|
|
66
67
|
4. `agent_start`: reset completion state and capture `ctx.signal`. Retain that signal after Pi clears its current signal at idle, so cancellation during message/agent-end handlers still suppresses continuation.
|
|
67
68
|
5. `message_end`: account the current message and capture its stop reason. Pi dispatches this hook **before** appending the message to SessionManager; never substitute the last persisted same-role message.
|
|
68
69
|
6. `input` / user `message_start`: invalidate old scheduling and track input receipt through delivery.
|
|
@@ -72,8 +73,9 @@ Retry messages are separately accounted because each response costs real tokens.
|
|
|
72
73
|
|
|
73
74
|
## accounting rules
|
|
74
75
|
|
|
75
|
-
- Dedup key: an in-memory ID assigned by WeakMap to each assistant/toolResult event message object. Session entry IDs do not yet exist at `message_end`. Usage deltas are journaled immediately; historical messages are not replayed into accounting on reload.
|
|
76
|
-
-
|
|
76
|
+
- Dedup key: an in-memory ID assigned by WeakMap to each assistant/toolResult event message object. Session entry IDs do not yet exist at `message_end`. Usage deltas are journaled immediately; historical messages are not replayed into accounting on reload. A message is acknowledged only after its usage commit succeeds. Failed writes remain pending for the next message or settled event; unresolved writes suppress automatic continuation.
|
|
77
|
+
- Usage is attributed to the goal that owns the run. The final run completing a goal remains charged; later unrelated runs after complete, pause, or block do not charge that goal.
|
|
78
|
+
- In usage journal intents, omitted usage fields serialize as zero; an explicit `null` preserves existing unknown semantics. An absent entire Pi usage object remains unknown.
|
|
77
79
|
- toolResult.usage (nested/subagent usage) counts only when present; the coverage gap is disclosed in UI + limits.md.
|
|
78
80
|
- Final completing turn IS accounted (same as both reference implementations).
|
|
79
81
|
|
|
@@ -83,7 +85,7 @@ Retry messages are separately accounted because each response costs real tokens.
|
|
|
83
85
|
|
|
84
86
|
- `continuationPrompt(goal)` (`continuation.md`): the objective inside `<objective>` as user-provided data; budget block; "Work from evidence"; "Fidelity"; completion audit; blocked audit (three consecutive goal turns); closing rules.
|
|
85
87
|
- `budgetLimitPrompt(goal)` (`budget_limit.md`): sent once per goal instance when the status flips to `budget_limited`.
|
|
86
|
-
- `objectiveUpdatedPrompt(goal)` (`objective_updated.md`): sent after a successful `/goal edit
|
|
88
|
+
- `objectiveUpdatedPrompt(goal)` (`objective_updated.md`): sent after a successful in-place objective update (`/goal <new>` or `/goal edit` on an unfinished goal); uses `<untrusted_objective>` and reports remaining tokens as `unknown` when no budget is set.
|
|
87
89
|
|
|
88
90
|
Substitution is trivial `{{ name }}` replacement. `escapeXmlText` (`&`→`&`, `<`→`<`, `>`→`>`) is applied to the objective only. `tokens_used` is `input+output+cacheRead+cacheWrite`; `token_budget` is the budget or `none`; `remaining_tokens` is `max(0, budget-used)`, or `unbounded` (continuation) / `unknown` (objective update) without a budget; `time_used_seconds` is `floor((Date.now()-createdAt)/1000)`.
|
|
89
91
|
|
|
@@ -91,9 +93,9 @@ The blocked audit is prompt-level only: the runtime does not count blocking turn
|
|
|
91
93
|
|
|
92
94
|
## Commands
|
|
93
95
|
|
|
94
|
-
`/goal` or `/goal status` · `/goal [--tokens N[k|M]] <objective>` (create
|
|
96
|
+
`/goal` or `/goal status` · `/goal [--tokens N[k|M]] <objective>` (create, or in-place objective update while unfinished — Codex `thread/goal/set` semantics) · `/goal edit <objective>` (same in-place update) · `/goal pause` · `/goal resume` · `/goal clear` · `/goal budget <tokens|none>` · `/goal turns <max-continuations>`.
|
|
95
97
|
|
|
96
|
-
A successful `resume` atomically journals `resetContinuations: true` on the user transition to active, resetting the run's continuation count while preserving the objective, usage, budget, and historical entries. It also accepts an already-active goal. Exhausted token budgets and zero continuation allowances are reported without resuming. A successful `create` or `resume` calls `continuation.onSettled()` so an idle session starts pursuing immediately (Codex starts the turn directly). A successful `
|
|
98
|
+
A successful `resume` atomically journals `resetContinuations: true` on the user transition to active, resetting the run's continuation count while preserving the objective, usage, budget, and historical entries. It also accepts an already-active goal. Exhausted token budgets and zero continuation allowances are reported without resuming. A successful `create` or `resume` calls `continuation.onSettled()` so an idle session starts pursuing immediately (Codex starts the turn directly). A successful in-place objective update atomically appends `goal.objective_updated`, preserving the goal id, status, limits, and cumulative usage, then sends `objectiveUpdatedPrompt(goal)` with `triggerTurn: true`; `pause` and `clear` send nothing (the active-status check stops continuation).
|
|
97
99
|
|
|
98
100
|
## Defaults
|
|
99
101
|
|
|
@@ -104,7 +106,7 @@ max continuations (turns): 25 · token budget: unset (opt-in) · the blocked-loo
|
|
|
104
106
|
1. Stale already-sent continuation runs one more turn (no retraction, no selective abort).
|
|
105
107
|
2. Subagent/nested tokens counted only when toolResult.usage present.
|
|
106
108
|
3. Stale identity rides on continuation custom messages observed via `message_start`; if those events disappear, prompt self-termination is the fallback.
|
|
107
|
-
4.
|
|
109
|
+
4. Pi owns normal conversation-summary generation; goal context is restored separately from the journal through `get_goal` and continuation prompts.
|
|
108
110
|
|
|
109
111
|
## Testing
|
|
110
112
|
|
package/docs/limits.md
CHANGED
|
@@ -16,9 +16,17 @@ The extension uses the public Pi 0.85.1 event, session, message, and send APIs.
|
|
|
16
16
|
|
|
17
17
|
**Cannot guarantee:** Pi does not expose nested or subagent token usage consistently for every tool result.
|
|
18
18
|
|
|
19
|
-
**What it does:** Counts nested usage only when it is present in `toolResult.usage
|
|
19
|
+
**What it does:** Counts nested usage only when it is present in `toolResult.usage`. In usage journal intents, omitted usage fields serialize as zero; an explicit `null` preserves existing unknown semantics. An absent entire Pi usage object remains unknown. Top-level assistant and tool-result events are deduplicated by live message-object identity, since their session entry IDs do not yet exist during `message_end`. Journaled usage survives reload; historical events are not counted again.
|
|
20
20
|
|
|
21
|
-
**User sees:** The status line can show `unknown messages=N`, and budget totals can be lower than provider-side totals when nested usage was not reported.
|
|
21
|
+
**User sees:** The status line can show `unknown messages=N`, and budget totals can be lower than provider-side totals when nested usage was not reported. If a usage journal write fails, automatic continuation is suppressed and the next message or settled event retries it. Pending, uncommitted usage is held in memory and cannot survive a reload or branch change.
|
|
22
|
+
|
|
23
|
+
### Goal-owned run usage
|
|
24
|
+
|
|
25
|
+
**Cannot guarantee:** Usage for an in-flight response cannot be split precisely at the instant a user pauses or replaces a goal.
|
|
26
|
+
|
|
27
|
+
**What it does:** Attributes usage to the goal that owns a run. A goal's final completing run remains charged; later unrelated runs after it is complete, paused, or blocked do not charge that goal.
|
|
28
|
+
|
|
29
|
+
**User sees:** Finishing, pausing, or blocking a goal does not cause later unrelated conversation usage to accumulate against it.
|
|
22
30
|
|
|
23
31
|
### Continuation message identity
|
|
24
32
|
|
|
@@ -28,13 +36,13 @@ The extension uses the public Pi 0.85.1 event, session, message, and send APIs.
|
|
|
28
36
|
|
|
29
37
|
**User sees:** If Pi stops emitting custom-message events, stale turns degrade to prompt self-termination only; subsequent continuation remains subject to normal checks.
|
|
30
38
|
|
|
31
|
-
### Compaction
|
|
39
|
+
### Compaction and goal context
|
|
32
40
|
|
|
33
|
-
**Cannot guarantee:** Pi
|
|
41
|
+
**Cannot guarantee:** Pi controls the content and timing of its normal conversation summary.
|
|
34
42
|
|
|
35
|
-
**What it does:**
|
|
43
|
+
**What it does:** Does not override Pi's compaction summary. Goal state remains in a separate journal and is restored through `get_goal` and self-contained continuation prompts.
|
|
36
44
|
|
|
37
|
-
**User sees:**
|
|
45
|
+
**User sees:** Normal conversation compaction proceeds under Pi's standard behavior; the goal remains available independently of that summary.
|
|
38
46
|
|
|
39
47
|
## Additional implementation boundaries
|
|
40
48
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-goal-next",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Persistent autonomous goals for pi, with Codex-verbatim goal semantics: continuation prompts, self-audited completion, budgets, and CAS-journaled state.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
package/src/accounting.ts
CHANGED
|
@@ -19,6 +19,11 @@ export function createAccounting() {
|
|
|
19
19
|
const unknown = new Set<string>();
|
|
20
20
|
const deltas = new Map<string, Delta>();
|
|
21
21
|
|
|
22
|
+
function previewMessage(message: Message): Omit<Delta, "stale"> {
|
|
23
|
+
const u = message.usage;
|
|
24
|
+
return { input: n(u?.input) ?? 0, output: n(u?.output) ?? 0, cacheRead: n(u?.cacheRead) ?? 0, cacheWrite: n(u?.cacheWrite) ?? 0, unknownMessages: u ? 0 : 1 };
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
function recordMessage(message: Message): { duplicate: boolean; delta: Delta } | null {
|
|
23
28
|
if (!message || typeof message.entryId !== "string" || !message.entryId) return null;
|
|
24
29
|
if (seen.has(message.entryId)) return { duplicate: true, delta: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, unknownMessages: 0, stale: stale.has(message.entryId) } };
|
|
@@ -51,5 +56,5 @@ export function createAccounting() {
|
|
|
51
56
|
const unknownText = u.unknownMessages || unknown.size ? `, unknown messages=${u.unknownMessages + unknown.size}` : "";
|
|
52
57
|
return `usage input=${u.input} output=${u.output} cacheRead=${u.cacheRead} cacheWrite=${u.cacheWrite}${unknownText}${staleText}`;
|
|
53
58
|
}
|
|
54
|
-
return { recordMessage, recordStale, settleTurn, summary };
|
|
59
|
+
return { previewMessage, hasMessage: (id: string) => seen.has(id), recordMessage, recordStale, settleTurn, summary };
|
|
55
60
|
}
|
package/src/commands.ts
CHANGED
|
@@ -28,21 +28,26 @@ export function registerGoalCommands(piLike: CommandPiLike, deps: CommandDeps):
|
|
|
28
28
|
piLike.registerCommand("goal", { description: "Manage the current goal", handler: async (raw: string, ctx?: { ui?: { notify(message: string, level?: string): void } }) => {
|
|
29
29
|
const run = async (cmd: ReturnType<typeof parseGoalCommand>): Promise<string> => {
|
|
30
30
|
const current = goalCommit.current();
|
|
31
|
-
const revision = current?.revision ??
|
|
31
|
+
const revision = current?.revision ?? goalCommit.getRevision();
|
|
32
32
|
const finish = (r: Awaited<ReturnType<GoalCommitLike["commit"]>>, ok: string) => r.kind === "ok" ? ok : "Goal update failed.";
|
|
33
33
|
switch (cmd.kind) {
|
|
34
34
|
case "status": return summarize(current);
|
|
35
35
|
case "create": {
|
|
36
|
-
|
|
36
|
+
// Codex thread/goal/set semantics: an unfinished goal's objective is
|
|
37
|
+
// updated in place (same goal id, usage/budget preserved); a missing
|
|
38
|
+
// or complete goal starts a fresh one.
|
|
39
|
+
if (current && current.goal.status !== "complete") {
|
|
40
|
+
const r = await goalCommit.commit({ type: "update_objective", objective: cmd.objective as string, ...(cmd.tokenBudget === undefined || cmd.tokenBudget === null ? {} : { tokenBudget: cmd.tokenBudget }) }, revision);
|
|
41
|
+
if (r.kind === "ok" && r.snapshot) deps.send({ customType: "pi-goal-next/objective_updated", content: objectiveUpdatedPrompt(r.snapshot.goal), display: false, details: { goalId: r.snapshot.goal.id } }, { triggerTurn: true });
|
|
42
|
+
return r.kind === "ok" ? "Goal updated." : "Goal update failed.";
|
|
43
|
+
}
|
|
37
44
|
const r = await goalCommit.commit({ type: "create", id: newGoalId(), objective: cmd.objective as string, tokenBudget: cmd.tokenBudget ?? null }, revision);
|
|
38
45
|
if (r.kind === "ok") await deps.kick();
|
|
39
46
|
return r.kind === "ok" ? "Goal created." : "Goal update failed.";
|
|
40
47
|
}
|
|
41
48
|
case "edit": {
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
if (cleared.kind !== "ok") return "Goal update failed.";
|
|
45
|
-
const r = await goalCommit.commit({ type: "create", id: newGoalId(), objective: cmd.objective as string, tokenBudget: previous?.goal.tokenBudget, maxContinuations: previous?.goal.maxContinuations }, cleared.snapshot?.revision ?? revision);
|
|
49
|
+
if (!current) return "Goal update failed.";
|
|
50
|
+
const r = await goalCommit.commit({ type: "update_objective", objective: cmd.objective as string }, revision);
|
|
46
51
|
if (r.kind === "ok" && r.snapshot) deps.send({ customType: "pi-goal-next/objective_updated", content: objectiveUpdatedPrompt(r.snapshot.goal), display: false, details: { goalId: r.snapshot.goal.id } }, { triggerTurn: true });
|
|
47
52
|
return r.kind === "ok" ? "Goal edited." : "Goal update failed.";
|
|
48
53
|
}
|
package/src/goal-commit.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type CommitResult =
|
|
|
10
10
|
| { kind: "ok"; snapshot: GoalSnapshot | null }
|
|
11
11
|
| { kind: "conflict"; snapshot: GoalSnapshot | null }
|
|
12
12
|
| { kind: "error"; error: unknown };
|
|
13
|
-
export type GoalCommitLike = Pick<ReturnType<typeof createGoalCommit>, "current" | "commit">;
|
|
13
|
+
export type GoalCommitLike = Pick<ReturnType<typeof createGoalCommit>, "current" | "commit" | "getRevision">;
|
|
14
14
|
|
|
15
15
|
export function createGoalCommit(store: GoalStore) {
|
|
16
16
|
let bootstrapped = false;
|
|
@@ -34,13 +34,17 @@ export function createGoalCommit(store: GoalStore) {
|
|
|
34
34
|
return goal ? { goal, revision } : null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function getRevision(): number { bootstrap(); return revision; }
|
|
38
|
+
|
|
37
39
|
function entryFor(intent: Intent, next: Goal | null, previous: Goal | null): Entry {
|
|
38
40
|
const seq = ++sequence;
|
|
39
41
|
switch (intent.type) {
|
|
40
42
|
case "create": return { type: "goal.created", version: 1, seq, goal: next! };
|
|
43
|
+
case "replace": return { type: "goal.replaced", version: 1, seq, goal: next! };
|
|
44
|
+
case "update_objective": return { type: "goal.objective_updated", version: 1, seq, objective: intent.objective };
|
|
41
45
|
case "clear": return { type: "goal.cleared", version: 1, seq };
|
|
42
46
|
case "transition": return { type: "goal.transition", version: 1, seq, from: previous!.status, to: intent.to, by: intent.by, ...(intent.userRequest ? { userRequest: intent.userRequest } : {}), ...(intent.resetContinuations ? { resetContinuations: true } : {}) };
|
|
43
|
-
case "usage": return { type: "goal.usage", version: 1, seq, input: intent.input
|
|
47
|
+
case "usage": return { type: "goal.usage", version: 1, seq, input: intent.input === undefined ? 0 : intent.input, output: intent.output === undefined ? 0 : intent.output, cacheRead: intent.cacheRead === undefined ? 0 : intent.cacheRead, cacheWrite: intent.cacheWrite === undefined ? 0 : intent.cacheWrite, unknownMessages: intent.unknownMessages ?? 0 };
|
|
44
48
|
case "continuation_sent": return { type: "goal.continuation_sent", version: 1, seq, generation: intent.generation };
|
|
45
49
|
case "stale_turn": return { type: "goal.stale_turn", version: 1, seq, generation: intent.generation };
|
|
46
50
|
case "limit_config": return { type: "goal.limit_config", version: 1, seq, tokenBudget: intent.tokenBudget, maxContinuations: intent.maxContinuations };
|
|
@@ -89,5 +93,5 @@ export function createGoalCommit(store: GoalStore) {
|
|
|
89
93
|
for (const subscriber of subscribers) subscriber(snapshot);
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
return { current, commit, subscribe, rebuild };
|
|
96
|
+
return { current, getRevision, commit, subscribe, rebuild };
|
|
93
97
|
}
|
package/src/goal.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type Goal = {
|
|
|
7
7
|
};
|
|
8
8
|
export type Entry =
|
|
9
9
|
| { type: "goal.created"; version: 1; seq: number; goal: Goal }
|
|
10
|
+
| { type: "goal.replaced"; version: 1; seq: number; goal: Goal }
|
|
11
|
+
| { type: "goal.objective_updated"; version: 1; seq: number; objective: string }
|
|
10
12
|
| { type: "goal.transition"; version: 1; seq: number; from: Status; to: Status; by: Actor; userRequest?: string; resetContinuations?: boolean }
|
|
11
13
|
| { type: "goal.cleared"; version: 1; seq: number }
|
|
12
14
|
| { type: "goal.usage"; version: 1; seq: number; input: number | null; output: number | null; cacheRead: number | null; cacheWrite: number | null; unknownMessages: number }
|
|
@@ -15,6 +17,8 @@ export type Entry =
|
|
|
15
17
|
| { type: "goal.limit_config"; version: 1; seq: number; tokenBudget: number | null; maxContinuations: number };
|
|
16
18
|
export type Intent =
|
|
17
19
|
| { type: "create"; id: string; objective: string; tokenBudget?: number | null; maxContinuations?: number }
|
|
20
|
+
| { type: "replace"; id: string; objective: string }
|
|
21
|
+
| { type: "update_objective"; objective: string; tokenBudget?: number | null }
|
|
18
22
|
| { type: "transition"; to: Status; by: Actor; userRequest?: string; resetContinuations?: boolean }
|
|
19
23
|
| { type: "clear" }
|
|
20
24
|
| { type: "usage"; input?: number | null; output?: number | null; cacheRead?: number | null; cacheWrite?: number | null; unknownMessages?: number }
|
|
@@ -40,6 +44,10 @@ function safeAdd(a: number, b: number, name: string): number {
|
|
|
40
44
|
return result;
|
|
41
45
|
}
|
|
42
46
|
export function transition(state: Goal | null, intent: Intent): Goal | null {
|
|
47
|
+
if (intent.type === "replace") {
|
|
48
|
+
if (!state) throw new GoalError("missing", "no goal exists");
|
|
49
|
+
return transition(null, { type: "create", id: intent.id, objective: intent.objective, tokenBudget: state.tokenBudget, maxContinuations: state.maxContinuations });
|
|
50
|
+
}
|
|
43
51
|
if (intent.type === "create") {
|
|
44
52
|
if (state && unfinished(state.status)) throw new GoalError("unfinished", "an unfinished goal already exists");
|
|
45
53
|
if (typeof intent.objective !== "string" || !intent.objective.trim() || !intent.id) throw new GoalError("invalid", "objective and id are required");
|
|
@@ -50,11 +58,18 @@ export function transition(state: Goal | null, intent: Intent): Goal | null {
|
|
|
50
58
|
}
|
|
51
59
|
if (!state) throw new GoalError("missing", "no goal exists");
|
|
52
60
|
if (intent.type === "clear") return null;
|
|
61
|
+
if (intent.type === "update_objective") {
|
|
62
|
+
if (typeof intent.objective !== "string" || !intent.objective.trim()) throw new GoalError("invalid", "objective is required");
|
|
63
|
+
if (intent.tokenBudget !== undefined && intent.tokenBudget !== null && (!Number.isFinite(intent.tokenBudget) || intent.tokenBudget < 0)) throw new GoalError("invalid", "invalid limits");
|
|
64
|
+
// Codex thread/goal/set semantics: update the objective in place, preserving
|
|
65
|
+
// status, limits, and cumulative usage on the same goal id.
|
|
66
|
+
return { ...state, objective: intent.objective.trim(), ...(intent.tokenBudget !== undefined ? { tokenBudget: intent.tokenBudget } : {}) };
|
|
67
|
+
}
|
|
53
68
|
if (intent.type === "transition") {
|
|
54
69
|
if (!statuses.includes(intent.to)) throw new GoalError("invalid", "unknown status");
|
|
55
70
|
if (intent.to === "paused" && !intent.userRequest?.trim()) throw new GoalError("forbidden", "paused requires user request evidence");
|
|
56
71
|
if (intent.by === "agent" && !["complete", "blocked"].includes(intent.to)) throw new GoalError("forbidden", "agent cannot set this status");
|
|
57
|
-
if (intent.by === "agent" && intent.to === "complete" && state.status
|
|
72
|
+
if (intent.by === "agent" && intent.to === "complete" && !["active", "budget_limited"].includes(state.status)) throw new GoalError("forbidden", "agent can complete only an active or budget-limited goal");
|
|
58
73
|
const reset = intent.resetContinuations === true;
|
|
59
74
|
if (reset && (intent.by !== "user" || intent.to !== "active")) throw new GoalError("forbidden", "only user resume can reset continuations");
|
|
60
75
|
if (reset && state.tokenBudget !== null && usageKeys.reduce((sum, key) => sum + state.usage[key], 0) >= state.tokenBudget) throw new GoalError("budget", "token budget exhausted; adjust /goal budget before resuming");
|
|
@@ -96,8 +111,9 @@ export function fold(entries: readonly Entry[]): Goal | null {
|
|
|
96
111
|
let state: Goal | null = null;
|
|
97
112
|
for (const entry of entries) {
|
|
98
113
|
if (!entry.type.startsWith("goal.")) continue;
|
|
99
|
-
if (entry.type === "goal.created") state = { ...entry.goal };
|
|
114
|
+
if (entry.type === "goal.created" || entry.type === "goal.replaced") state = { ...entry.goal };
|
|
100
115
|
else if (entry.type === "goal.cleared") state = null;
|
|
116
|
+
else if (state && entry.type === "goal.objective_updated") state = transition(state, { type: "update_objective", objective: entry.objective });
|
|
101
117
|
else if (state && entry.type === "goal.transition") state = transition(state, { type: "transition", to: entry.to, by: entry.by, userRequest: entry.userRequest, resetContinuations: entry.resetContinuations });
|
|
102
118
|
else if (state && entry.type === "goal.limit_config") state = transition(state, { type: "limit_config", tokenBudget: entry.tokenBudget, maxContinuations: entry.maxContinuations });
|
|
103
119
|
else if (state && entry.type === "goal.continuation_sent") state = transition(state, { type: "continuation_sent", generation: entry.generation });
|
package/src/lifecycle.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { summarize } from "./goal.ts";
|
|
2
1
|
import { budgetLimitPrompt } from "./prompts.ts";
|
|
3
2
|
import type { Message } from "./accounting.ts";
|
|
4
3
|
import type { GoalSnapshot, CommitResult } from "./goal-commit.ts";
|
|
5
4
|
import type { Intent } from "./goal.ts";
|
|
6
5
|
import { createAccounting } from "./accounting.ts";
|
|
7
6
|
import type { Continuation } from "./continuation.ts";
|
|
8
|
-
import type {
|
|
7
|
+
import type { MessageEndEvent, MessageStartEvent, InputEvent, AgentSettledEvent, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
9
8
|
|
|
10
9
|
export type LifecycleDeps = {
|
|
11
10
|
goalCommit: { current(): GoalSnapshot | null; commit(intent: Intent, expectedRevision: number): Promise<CommitResult> };
|
|
@@ -17,13 +16,15 @@ export type LifecycleDeps = {
|
|
|
17
16
|
|
|
18
17
|
export type PiEvents = { on(name: string, handler: (event: any, ctx: any) => unknown): void };
|
|
19
18
|
export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
|
|
20
|
-
async function commitWithRetry(intent: Intent, attempts = 3): Promise<
|
|
19
|
+
async function commitWithRetry(intent: Intent, attempts = 3, expectedGoalId?: string): Promise<boolean> {
|
|
21
20
|
for (let i = 0; i < attempts; i++) {
|
|
22
21
|
const snapshot = deps.goalCommit.current();
|
|
23
|
-
if (!snapshot) return;
|
|
22
|
+
if (!snapshot || (expectedGoalId !== undefined && snapshot.goal.id !== expectedGoalId)) return false;
|
|
24
23
|
const result = await deps.goalCommit.commit(intent, snapshot.revision);
|
|
25
|
-
if (result.kind
|
|
24
|
+
if (result.kind === "ok") return true;
|
|
25
|
+
if (result.kind === "error") return false;
|
|
26
26
|
}
|
|
27
|
+
return false;
|
|
27
28
|
}
|
|
28
29
|
let pendingRebuild = false;
|
|
29
30
|
const bootstrap = () => deps.rebuild();
|
|
@@ -35,6 +36,30 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
|
|
|
35
36
|
let epoch = 0;
|
|
36
37
|
let inputPending = false;
|
|
37
38
|
let runSignal: AbortSignal | undefined;
|
|
39
|
+
// Keep ownership through completion so its remaining tool results/final reply
|
|
40
|
+
// count, but never transfer an old run's usage to a replacement goal.
|
|
41
|
+
let accountingGoalId: string | null = null;
|
|
42
|
+
let startGoalId: string | null = null;
|
|
43
|
+
const pendingUsage = new Map<string, { goalId: string; message: Message }>();
|
|
44
|
+
let usageFlush: Promise<boolean> | undefined;
|
|
45
|
+
async function flushUsage(): Promise<boolean> {
|
|
46
|
+
if (usageFlush) return usageFlush;
|
|
47
|
+
usageFlush = (async () => {
|
|
48
|
+
for (const [id, item] of pendingUsage) {
|
|
49
|
+
if (deps.goalCommit.current()?.goal.id !== item.goalId) { pendingUsage.delete(id); continue; }
|
|
50
|
+
const delta = deps.accounting.previewMessage(item.message);
|
|
51
|
+
if (!await commitWithRetry({ type: "usage", ...delta }, 3, item.goalId)) return false;
|
|
52
|
+
deps.accounting.recordMessage(item.message);
|
|
53
|
+
pendingUsage.delete(id);
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
})();
|
|
57
|
+
try { return await usageFlush; } finally { usageFlush = undefined; }
|
|
58
|
+
}
|
|
59
|
+
const eligibleGoalId = () => {
|
|
60
|
+
const goal = deps.goalCommit.current()?.goal;
|
|
61
|
+
return goal && (goal.status === "active" || goal.status === "budget_limited") ? goal.id : null;
|
|
62
|
+
};
|
|
38
63
|
// message_end precedes SessionManager.appendMessage. Its object is authoritative;
|
|
39
64
|
// no persisted entry ID exists yet. Deduplicate live events by object identity.
|
|
40
65
|
const messageIds = new WeakMap<object, string>();
|
|
@@ -49,11 +74,16 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
|
|
|
49
74
|
lastAssistantStop = undefined;
|
|
50
75
|
// Retain the signal: ctx.signal becomes undefined once the run is idle.
|
|
51
76
|
runSignal = ctx.signal;
|
|
77
|
+
startGoalId = deps.goalCommit.current()?.goal.id ?? null;
|
|
78
|
+
accountingGoalId = eligibleGoalId();
|
|
52
79
|
});
|
|
53
80
|
|
|
54
81
|
pi.on("session_start", async (_event: SessionStartEvent, ctx: any) => {
|
|
55
82
|
invalidate();
|
|
56
83
|
inputPending = false;
|
|
84
|
+
accountingGoalId = null;
|
|
85
|
+
startGoalId = null;
|
|
86
|
+
pendingUsage.clear();
|
|
57
87
|
await bootstrap();
|
|
58
88
|
const snapshot = deps.goalCommit.current();
|
|
59
89
|
if (snapshot?.goal?.status === "active") {
|
|
@@ -64,38 +94,34 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
|
|
|
64
94
|
pi.on("session_before_tree", async (_event: any, ctx: any) => {
|
|
65
95
|
invalidate();
|
|
66
96
|
inputPending = false;
|
|
97
|
+
accountingGoalId = null;
|
|
98
|
+
startGoalId = null;
|
|
99
|
+
pendingUsage.clear();
|
|
67
100
|
pendingRebuild = true;
|
|
68
101
|
// Pi's tree hook does not expose the post-navigation branch; rebuild on the next event.
|
|
69
102
|
void ctx;
|
|
70
103
|
});
|
|
71
104
|
|
|
72
|
-
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, _ctx: any) => {
|
|
73
|
-
const snapshot = deps.goalCommit.current();
|
|
74
|
-
if (!snapshot) return;
|
|
75
|
-
if (event?.preparation?.firstKeptEntryId && Number.isFinite(event.preparation.tokensBefore)) {
|
|
76
|
-
return { compaction: { summary: summarize(snapshot.goal), firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore } };
|
|
77
|
-
}
|
|
78
|
-
// Older Pi hooks may not support extension-provided compaction content.
|
|
79
|
-
return;
|
|
80
|
-
});
|
|
81
|
-
|
|
82
105
|
const before = async (_ctx: any) => { if (pendingRebuild) { pendingRebuild = false; await bootstrap(); } };
|
|
83
106
|
pi.on("message_end", async (event: MessageEndEvent, ctx: any) => {
|
|
84
107
|
await before(ctx);
|
|
85
108
|
const m = event.message;
|
|
86
109
|
if (m.role !== "assistant" && m.role !== "toolResult") return;
|
|
87
110
|
if (m.role === "assistant") lastAssistantStop = m.stopReason;
|
|
111
|
+
// A create_goal tool can establish ownership after agent_start.
|
|
112
|
+
const eligible = eligibleGoalId();
|
|
113
|
+
if (!accountingGoalId && (!startGoalId || startGoalId === eligible)) accountingGoalId = eligible;
|
|
114
|
+
if (!accountingGoalId || deps.goalCommit.current()?.goal.id !== accountingGoalId) return;
|
|
88
115
|
let entryId = messageIds.get(m);
|
|
89
116
|
if (!entryId) { entryId = `live-message:${++messageSeq}`; messageIds.set(m, entryId); }
|
|
90
117
|
const message: Message = { ...m, entryId };
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const delta = recorded.delta;
|
|
94
|
-
await commitWithRetry({ type: "usage", input: delta.input, output: delta.output, cacheRead: delta.cacheRead, cacheWrite: delta.cacheWrite, unknownMessages: delta.unknownMessages });
|
|
95
|
-
}
|
|
118
|
+
if (!deps.accounting.hasMessage(entryId)) pendingUsage.set(entryId, { goalId: accountingGoalId, message });
|
|
119
|
+
await flushUsage();
|
|
96
120
|
});
|
|
97
121
|
pi.on("agent_settled", async (_event: AgentSettledEvent, ctx: any) => {
|
|
98
122
|
await before(ctx);
|
|
123
|
+
if (!await flushUsage()) return; // No automatic work while usage persistence is unresolved.
|
|
124
|
+
accountingGoalId = null;
|
|
99
125
|
const snapshot = deps.goalCommit.current();
|
|
100
126
|
if (!snapshot) { steeredGoalId = null; steered = false; return; }
|
|
101
127
|
const settledEpoch = epoch;
|
|
@@ -124,7 +150,12 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
|
|
|
124
150
|
await deps.continuation.onSettled(canSend);
|
|
125
151
|
});
|
|
126
152
|
pi.on("message_start", async (event: MessageStartEvent, ctx: any) => {
|
|
127
|
-
if (event.message.role === "user") {
|
|
153
|
+
if (event.message.role === "user") {
|
|
154
|
+
inputPending = false;
|
|
155
|
+
invalidate();
|
|
156
|
+
startGoalId = deps.goalCommit.current()?.goal.id ?? null;
|
|
157
|
+
accountingGoalId = eligibleGoalId();
|
|
158
|
+
}
|
|
128
159
|
await before(ctx);
|
|
129
160
|
await deps.continuation.onMessageStart(event.message);
|
|
130
161
|
});
|
package/src/store.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type BranchContext = { sessionManager: { getBranch(): readonly unknown[]
|
|
|
3
3
|
export type PiCustomEntry = { type: "custom"; customType: string; data: unknown };
|
|
4
4
|
export type PiAppender = { appendEntry(customType: string, data: unknown): unknown };
|
|
5
5
|
export const VERSION = 1 as const;
|
|
6
|
-
const entryTypes = new Set(["goal.created", "goal.transition", "goal.cleared", "goal.usage", "goal.continuation_sent", "goal.stale_turn", "goal.limit_config"]);
|
|
6
|
+
const entryTypes = new Set(["goal.created", "goal.replaced", "goal.objective_updated", "goal.transition", "goal.cleared", "goal.usage", "goal.continuation_sent", "goal.stale_turn", "goal.limit_config"]);
|
|
7
7
|
export function isEntry(entry: unknown): entry is Entry { return !!entry && typeof entry === "object" && (entry as { version?: unknown }).version === VERSION && entryTypes.has((entry as { type?: unknown }).type as string) && Number.isSafeInteger((entry as { seq?: unknown }).seq); }
|
|
8
8
|
export function readBranch(ctx: BranchContext): Entry[] { return ctx.sessionManager.getBranch().filter((item): item is PiCustomEntry => { if (!item || typeof item !== "object") return false; const wrapped = item as Partial<PiCustomEntry>; return wrapped.type === "custom" && wrapped.customType === "pi-goal-next"; }).map(item => item.data).filter(isEntry); }
|
|
9
9
|
export async function append(pi: PiAppender, entry: Entry): Promise<void> { if (!isEntry(entry)) throw new Error("unsupported journal version"); await pi.appendEntry("pi-goal-next", entry); }
|
package/src/tools.ts
CHANGED
|
@@ -40,7 +40,7 @@ export function registerGoalTools(piLike: PiLike, deps: GoalToolDeps): void {
|
|
|
40
40
|
if (!params || typeof params.objective !== "string" || !params.objective.trim()) return result("Invalid objective: a non-empty objective is required.");
|
|
41
41
|
const current = goalCommit.current();
|
|
42
42
|
if (current && current.goal.status !== "complete") return result("Cannot create goal: an unfinished goal already exists.");
|
|
43
|
-
const revision = current?.revision ??
|
|
43
|
+
const revision = current?.revision ?? goalCommit.getRevision();
|
|
44
44
|
const intent: Intent = { type: "create", id: newGoalId(), objective: params.objective, tokenBudget: params.token_budget ?? null };
|
|
45
45
|
const r = await goalCommit.commit(intent, revision);
|
|
46
46
|
return result(r.kind === "ok" ? "Goal created." : commitMessage(r));
|