@mocito/pi-goal 0.1.10 → 0.2.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 +60 -9
- package/README.md +183 -8
- package/SECURITY.md +47 -1
- package/extensions/index.ts +84 -10
- package/package.json +12 -9
- package/src/accounting.ts +33 -10
- package/src/commands.ts +4 -3
- package/src/continuation.ts +68 -32
- package/src/install-telemetry.ts +19 -46
- package/src/metadata.ts +9 -1
- package/src/state.ts +45 -5
- package/src/tool-contracts.ts +33 -0
- package/src/tools.ts +21 -4
- package/src/types.ts +1 -0
- package/src/ui.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,21 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This project follows the spirit of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning for releases.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-10-09
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Declare `{ goal }` structured summaries for `get_goal` and `create_goal`, including `goal: null` for empty state, plus `goal` namespace discovery and behavior annotations. Codemode receives objects without `JSON.parse`; terminal updates remain model-only without a new schema.
|
|
14
|
+
- Mark usage-refreshing queries as stateful and serialize them with goal mutations.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Restrict footer/widget updates to TUI mode; print, JSON and RPC retain goal lifecycle behavior without terminal writes.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Update the shared Pi development and contract-test baseline to 1.1.0; require Node.js >=22.19.0 to match the host runtime. Pi remains a host-supplied peer dependency.
|
|
23
|
+
- Share install telemetry mechanics through `@mocito/install-telemetry` while preserving Pi-specific settings and state paths.
|
|
24
|
+
- Require Pi 1.1.0 for usage-accounting lifecycle and cache-warming hooks. Stop idle cache warming for retained token-budgeted or usage-limited goals without changing no-goal sessions.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Request automatic goal continuation through `agent_before_settle` after Pi's recovery and queued work, rather than an `agent_end` timer. Preserve other handlers' proposals and append-only history while sending at most one current goal context to the model.
|
|
29
|
+
- Do not restart cancelled/failed runs or failed automatic compaction, including after successful queued work within the same run. Provider-limit notices, like budget notices, no longer request a wrap-up turn.
|
|
30
|
+
- Preserve explicit start/resume/reload activation and wait for tree navigation to finish. Invalidate stale activations across goal/session/branch changes; reject oversized stored objectives without truncation.
|
|
31
|
+
- Keep `update_goal` model-only on supported Pi runtimes (Pi 1.0.2 or newer), preventing codemode and other nested tool calls from completing or blocking a goal before the model inspects verification results. Preserve direct final-turn updates and script access to `get_goal` and `create_goal`.
|
|
32
|
+
- Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
|
|
33
|
+
- Count finalized tool, standalone, compaction, and branch-summary usage in goal budgets, including already-aggregated nested calls and context-omitted billed attempts. Preserve schema-v1 ledgers and backfill previously missed entries once within the branch-local goal lifetime.
|
|
34
|
+
- Enforce saved usage before continuation and settlement, after compaction/navigation, on goal queries, and through a session-scoped idle usage check. Budget notices no longer request an extra model turn.
|
|
35
|
+
|
|
36
|
+
## [0.1.11] - 2026-07-17
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- Preserve accumulated wall-clock time when `/goal resume` is used on an already-active goal.
|
|
41
|
+
- Derive persisted `piGoalVersion` metadata from the package version so release versions cannot drift.
|
|
42
|
+
- Ignore malformed persisted mutations instead of allowing invalid status, budget, or accounting data to corrupt reconstructed goal state.
|
|
43
|
+
|
|
44
|
+
## [0.1.10] - 2026-07-01
|
|
6
45
|
|
|
7
46
|
### Changed
|
|
8
47
|
|
|
9
48
|
- Update Pi core development dependencies for Pi 0.80 compatibility.
|
|
10
49
|
|
|
11
|
-
## 0.1.9 - 2026-06-16
|
|
50
|
+
## [0.1.9] - 2026-06-16
|
|
12
51
|
|
|
13
52
|
### Fixed
|
|
14
53
|
|
|
15
54
|
- Notify the model when a goal transitions to `budget_limited` or `usage_limited` so it can stop work and call `update_goal` instead of silently continuing to spend tokens after the budget is exhausted or the provider hits a rate limit. Previously the model was only ever informed via a UI notification that it could not see, leaving the current turn to keep running for additional assistant entries and allowing `update_goal` to mark the goal `complete` long after the budget had been blown.
|
|
16
55
|
- Branch the continuation prompt on the `budget_limited` reason so any future continuation of a budget-limited goal instructs the model to wrap up and call `update_goal` rather than push forward.
|
|
17
56
|
|
|
18
|
-
## 0.1.8 - 2026-06-08
|
|
57
|
+
## [0.1.8] - 2026-06-08
|
|
19
58
|
|
|
20
59
|
### Fixed
|
|
21
60
|
|
|
@@ -23,7 +62,7 @@
|
|
|
23
62
|
- Reject `update_goal` when it is batched with verification/tool calls in the same assistant turn, forcing completion/blocking decisions to happen after inspecting verification results.
|
|
24
63
|
- Preserve the elapsed-time fix for old sessions by ignoring legacy account mutation `timeUsedSeconds` fields during reconstruction.
|
|
25
64
|
|
|
26
|
-
## 0.1.7 - 2026-06-08
|
|
65
|
+
## [0.1.7] - 2026-06-08
|
|
27
66
|
|
|
28
67
|
### Fixed
|
|
29
68
|
|
|
@@ -33,7 +72,7 @@
|
|
|
33
72
|
|
|
34
73
|
- Pause goals on assistant-surfaced provider usage/quota/billing limit errors and repeated provider failures, not only HTTP 429 response hooks.
|
|
35
74
|
|
|
36
|
-
## 0.1.6 - 2026-06-06
|
|
75
|
+
## [0.1.6] - 2026-06-06
|
|
37
76
|
|
|
38
77
|
### Changed
|
|
39
78
|
|
|
@@ -41,14 +80,26 @@
|
|
|
41
80
|
- Aligned package structure with monorepo guidelines (added root `index.ts` re-export, `.editorconfig`, `.gitignore`; fixed extension entry point; removed non-canonical tsconfig flags).
|
|
42
81
|
- Update `author` field to full name for monorepo consistency.
|
|
43
82
|
|
|
44
|
-
## 0.1.3
|
|
83
|
+
## [0.1.3] - 2026-06-06
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- Temporarily rename the package to unscoped `pi-goal`; this version was not published because that npm name was unavailable.
|
|
88
|
+
|
|
89
|
+
## [0.1.2] - 2026-05-28
|
|
90
|
+
|
|
91
|
+
### Changed
|
|
45
92
|
|
|
46
93
|
- Move the extension entry point to `extensions/index.ts` so Pi displays the package extension compactly.
|
|
47
94
|
|
|
48
|
-
## 0.1.1
|
|
95
|
+
## [0.1.1] - 2026-05-28
|
|
96
|
+
|
|
97
|
+
### Added
|
|
49
98
|
|
|
50
99
|
- Add best-effort install/update telemetry, gated by Pi telemetry/offline settings and disabled in CI.
|
|
51
100
|
|
|
52
|
-
## 0.1.0
|
|
101
|
+
## [0.1.0] - 2026-05-28
|
|
102
|
+
|
|
103
|
+
### Added
|
|
53
104
|
|
|
54
105
|
- Initial `@mocito/pi-goal` package with `/goal`, goal tools, branch-scoped session persistence, usage accounting, UI status, and automatic continuation.
|
package/README.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# @mocito/pi-goal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give Pi a durable objective and let it keep working until the job is verified done.
|
|
4
|
+
|
|
5
|
+
`pi-goal` adds branch-aware, long-running goals modeled after Codex `/goal`, with automatic continuation, token budgets, progress visibility, and explicit completion checks.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Work beyond one turn** — Pi continues an active goal after successful runs, without restarting cancelled or failed work.
|
|
10
|
+
- **Persistent branch-aware state** — goals survive reloads and follow session trees, forks, and clones without leaking across divergent branches.
|
|
11
|
+
- **Budget control** — cap token use, monitor remaining budget, and pause or resume work at any time.
|
|
12
|
+
- **Visible progress** — track status, active time, token usage, and budget from Pi's UI.
|
|
13
|
+
- **Verified endings** — model tools require explicit completion evidence and stop repeated retries when work is blocked or provider limits intervene.
|
|
4
14
|
|
|
5
15
|
## Install
|
|
6
16
|
|
|
@@ -8,6 +18,10 @@ Persistent long-running goals for Pi, modeled after Codex `/goal`.
|
|
|
8
18
|
pi install npm:@mocito/pi-goal
|
|
9
19
|
```
|
|
10
20
|
|
|
21
|
+
Requires Pi 1.1.0 or newer for complete usage accounting and the settlement
|
|
22
|
+
and cache-warming hooks. Development and session-contract tests use Pi 1.1.0.
|
|
23
|
+
There is no legacy fallback for nested terminal updates.
|
|
24
|
+
|
|
11
25
|
For local development:
|
|
12
26
|
|
|
13
27
|
```bash
|
|
@@ -36,15 +50,148 @@ Replacing a non-complete goal asks for confirmation when UI is available.
|
|
|
36
50
|
- `create_goal` creates a goal only when explicitly requested and fails if one already exists.
|
|
37
51
|
- `update_goal` lets the model mark a goal `complete` or `blocked` only. It should mark complete only after requirement-by-requirement verification, and blocked only after the same blocker repeats for at least three goal turns.
|
|
38
52
|
|
|
39
|
-
|
|
53
|
+
`update_goal` is model-only: it remains available directly to the model with
|
|
54
|
+
codemode disabled, `on`, or `only`, but cannot be called from codemode scripts or
|
|
55
|
+
other tools through `ctx.executeTool()`. Run verification first, inspect the
|
|
56
|
+
results, then call `update_goal` as the only tool call in a separate final
|
|
57
|
+
assistant turn. `get_goal` and `create_goal` remain callable from scripts.
|
|
40
58
|
|
|
41
|
-
|
|
59
|
+
The extension enforces this call boundary, not the truth of the model's
|
|
60
|
+
verification evidence. Trusted extensions still run with the user's permissions.
|
|
61
|
+
|
|
62
|
+
### Structured script contract
|
|
63
|
+
|
|
64
|
+
On Pi 1.1.0 or newer, `get_goal` and `create_goal` declare output schemas:
|
|
65
|
+
|
|
66
|
+
- `get_goal` returns `{ goal: summary | null }`, including a stable `null` when
|
|
67
|
+
no goal exists. Its direct no-goal text stays `No goal is set.`.
|
|
68
|
+
- `create_goal` returns `{ goal: summary }`. It still requires an explicit
|
|
69
|
+
creation request; discovery metadata is not permission to create a goal.
|
|
70
|
+
- `summary` contains `goalId`, `objective`, `status`, `tokensUsed`,
|
|
71
|
+
`timeUsedSeconds`, `createdAt`, `updatedAt`, and optional `tokenBudget`,
|
|
72
|
+
`remainingTokens`, `activeStartedAt`. Internal accounting ledgers,
|
|
73
|
+
verification metadata and branch entries are not included.
|
|
42
74
|
|
|
43
|
-
|
|
75
|
+
```js
|
|
76
|
+
const { goal } = await tools.get_goal({});
|
|
77
|
+
text(goal ? { status: goal.status, remainingTokens: goal.remainingTokens } : "No goal");
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Remove old `JSON.parse(await tools.get_goal({}))` wrappers. Direct results remain
|
|
81
|
+
readable JSON and the renderers retain their summaries. Invalid requests and
|
|
82
|
+
creation conflicts throw; scripts must catch rejection. Neither tool uses a
|
|
83
|
+
structured success object with `isError: true`. Legacy oversized objectives remain
|
|
84
|
+
inspectable without silent truncation; new objectives retain the 4,000-character
|
|
85
|
+
input limit.
|
|
86
|
+
|
|
87
|
+
Await `describeNamespace("goal")`, `describeTool("get_goal")`, or
|
|
88
|
+
`searchTools("goal budget", { namespace: "goal" })`, including with zero inline
|
|
89
|
+
budget. No deferred/codemode-only option or default exposure change is added.
|
|
90
|
+
`update_goal` remains absent from nested discovery and has no output schema.
|
|
91
|
+
|
|
92
|
+
All goal tools run sequentially. `get_goal` is **not** annotated read-only because
|
|
93
|
+
it refreshes and can persist usage accounting; its repeated bookkeeping is
|
|
94
|
+
idempotent. Creation is non-idempotent. Terminal update is destructive and
|
|
95
|
+
non-idempotent. All are local-only, and the annotations are advisory, not
|
|
96
|
+
approval or evidence verification. Pi owns aggregation of nested usage; these
|
|
97
|
+
snapshots do not add a second usage report.
|
|
44
98
|
|
|
45
|
-
|
|
99
|
+
## Behavior
|
|
100
|
+
|
|
101
|
+
Goal state is stored as immutable `pi-goal` custom session entries and reconstructed from `ctx.sessionManager.getBranch()`, so state follows Pi session branches, tree navigation, forks, and reloads.
|
|
46
102
|
|
|
47
|
-
|
|
103
|
+
After a successful run, the extension accounts finalized usage and proposes a
|
|
104
|
+
hidden `pi-goal-context` message at Pi's actionable `agent_before_settle` boundary.
|
|
105
|
+
Pi finishes its retry, overflow recovery, compaction, and queued work before
|
|
106
|
+
reaching that boundary. Pi then validates the proposed context and owns the next
|
|
107
|
+
request; there is no post-run `agent_end` timer or new run from `agent_settled`.
|
|
108
|
+
A context filter keeps only the latest message for the current active goal.
|
|
109
|
+
Earlier messages remain in append-only session history.
|
|
110
|
+
|
|
111
|
+
Cancellation, terminal assistant errors, and failed/cancelled automatic
|
|
112
|
+
compaction suppress goal continuation for that run. Cancellation and ordinary
|
|
113
|
+
errors leave the stored goal active; they do not silently pause or clear it.
|
|
114
|
+
The compaction-failure stop survives queued steering/follow-up work and resets
|
|
115
|
+
only when the whole run reaches the notification-only `agent_settled` event.
|
|
116
|
+
Explicit new input can start work again. Use `/goal pause` to keep it paused.
|
|
117
|
+
Pending steering, follow-up, and user messages take priority and are not consumed
|
|
118
|
+
or duplicated by the goal extension.
|
|
119
|
+
|
|
120
|
+
Initial `/goal` commands, `/goal resume`, active session startup/reload, and tree
|
|
121
|
+
navigation retain a separate activation path because there may be no running
|
|
122
|
+
boundary. Tree activation waits for navigation to finish. Each activation
|
|
123
|
+
rechecks goal/session/branch identity, saved usage, idle state, and pending work.
|
|
124
|
+
A real run or a goal/session/branch transition invalidates an obsolete activation.
|
|
125
|
+
Resuming or reloading a session with an active goal can therefore restart it,
|
|
126
|
+
including after an earlier cancellation.
|
|
127
|
+
|
|
128
|
+
Boundary proposals preserve other extensions' entries and continuation requests.
|
|
129
|
+
The incoming `context.canContinue` describes the preview before the goal message:
|
|
130
|
+
it can be false after a normal assistant reply. The goal's custom-message draft
|
|
131
|
+
makes that preview runnable; Pi checks the final preview after all handlers.
|
|
132
|
+
Declining goal continuation does not veto another extension's continuation.
|
|
133
|
+
|
|
134
|
+
The TUI footer and optional editor widget show status, elapsed active time, token usage, and budget.
|
|
135
|
+
Print, JSON and RPC retain the goal lifecycle without terminal footer/widget updates.
|
|
136
|
+
|
|
137
|
+
Provider usage-limit handling pauses active goals when Pi exposes HTTP 429 responses or assistant error messages that indicate subscription, quota, billing, balance, or repeated provider failures. This prevents automatic continuation from retrying indefinitely after provider limits such as 5-hour subscription caps. When the budget is exhausted or a provider limit is detected, a visible `pi-goal-event` notice is saved to model context without requesting a wrap-up turn. The model can act on that notice during a request independently started by Pi, the user, or another extension; the notice itself does not spend more tokens.
|
|
138
|
+
|
|
139
|
+
### Token accounting and attribution
|
|
140
|
+
|
|
141
|
+
The goal counts reported tokens from finalized assistant messages, tool results,
|
|
142
|
+
standalone usage entries (including cache warming), compactions, and branch
|
|
143
|
+
summaries. Failed or cancelled calls count when Pi persists their usage.
|
|
144
|
+
Nested tool usage is already aggregated by Pi onto the parent tool result and
|
|
145
|
+
is counted once, not by walking nested calls. Cached input tokens count too.
|
|
146
|
+
This is a token budget, not a currency or provider-quota budget.
|
|
147
|
+
|
|
148
|
+
Virtual-model selections do not change that rule: Pi records the physical
|
|
149
|
+
assistant's usage, and codemode classifier usage is aggregated onto its parent
|
|
150
|
+
tool result. However, a raw `ctx.modelRegistry.classify()` call inside a router
|
|
151
|
+
does **not** automatically create a session usage entry in Pi 1.1.0. The goal
|
|
152
|
+
cannot charge an unrecorded result. Paid automatic routers need an explicit,
|
|
153
|
+
single-count attribution contract before they can be treated as budget-safe;
|
|
154
|
+
do not infer free classification from an unchanged goal total. Classifier
|
|
155
|
+
judgments are not completion evidence or permission to bypass approvals.
|
|
156
|
+
|
|
157
|
+
The accounting interval starts at the goal's creation/replacement entry and
|
|
158
|
+
ends when it is replaced or cleared on that branch. Earlier work is excluded,
|
|
159
|
+
including entries before creation with the same timestamp. Entries need valid
|
|
160
|
+
timestamps within the interval and no later than the scan time.
|
|
161
|
+
As in earlier v1 releases, **pause stops continuation, not accounting**:
|
|
162
|
+
work while paused, limited, blocked, or complete is still charged to the
|
|
163
|
+
retained goal. Clear or replace the goal before doing unrelated work.
|
|
164
|
+
Completion reports are snapshots of finalized usage at completion.
|
|
165
|
+
|
|
166
|
+
Accounting uses raw branch history, not the model-context projection. Compacted
|
|
167
|
+
or context-omitted billed attempts still count. Reloads and forks retain the
|
|
168
|
+
inherited entry-ID ledger; divergent branch work is not imported. A tree
|
|
169
|
+
summary's own usage belongs to the destination branch. Navigating back before
|
|
170
|
+
work was done restores that branch's earlier total, not a session-wide spend cap.
|
|
171
|
+
|
|
172
|
+
Schema-v1 mutations remain compatible; no migration or session rewrite is needed.
|
|
173
|
+
Previously accounted assistant tokens and IDs are retained. The next scan
|
|
174
|
+
backfills newly supported usage entries once, so old goals may show higher
|
|
175
|
+
totals or reach their budgets after upgrading.
|
|
176
|
+
|
|
177
|
+
Budget enforcement scans saved usage at turn end, before settlement, after
|
|
178
|
+
compaction/tree navigation, on reload, before scheduled continuation, and when
|
|
179
|
+
goals are queried or changed. Pi 1.1 has no extension notification for newly
|
|
180
|
+
saved idle usage, so a session-scoped one-second poll checks for branch changes
|
|
181
|
+
while idle. It does not make provider requests. A budget notice updates visible
|
|
182
|
+
state and model context **without requesting another model turn**.
|
|
183
|
+
|
|
184
|
+
### Idle cache warming
|
|
185
|
+
|
|
186
|
+
The extension requests `stop` at an idle warming decision when the retained
|
|
187
|
+
goal has a token budget or is `budget_limited`/`usage_limited`. It never requests
|
|
188
|
+
`warm`, changes Pi's settings, or overrides decisions for no-goal sessions or
|
|
189
|
+
unbudgeted, non-limited goals. Pi uses the last handler that returns an action;
|
|
190
|
+
a later extension can explicitly override this stop.
|
|
191
|
+
|
|
192
|
+
This policy is idle-only. It does not intercept every provider request, cancel
|
|
193
|
+
in-flight warming, or stop streaming-phase warming. Any persisted warming usage
|
|
194
|
+
within the goal interval is still accounted.
|
|
48
195
|
|
|
49
196
|
## Examples
|
|
50
197
|
|
|
@@ -83,15 +230,16 @@ Environment flags:
|
|
|
83
230
|
## Troubleshooting
|
|
84
231
|
|
|
85
232
|
- If continuation does not start, run `/goal status` and confirm the goal is `active`.
|
|
86
|
-
- If a goal stops unexpectedly, check whether
|
|
233
|
+
- If a goal stops unexpectedly, check whether the run was cancelled, failed, or hit a token budget/provider limit. Usage-limit pauses may include a provider reset hint. Budget/provider notices do not start another request; the goal remains `budget_limited` or `usage_limited` until explicitly resumed, cleared, or finalized during later work.
|
|
87
234
|
- If context appears stale after tree navigation or reload, run `/goal status`; branch state is reconstructed from the active branch.
|
|
88
235
|
- In print/JSON modes, commands and tools work, but interactive confirmations/editors are unavailable.
|
|
89
236
|
|
|
90
237
|
## Limitations
|
|
91
238
|
|
|
92
|
-
- Token budget is enforced after finalized
|
|
239
|
+
- Token budget is enforced after finalized usage is saved. It stops goal-driven continuation, not an in-flight provider call, Pi's own recovery, user-requested work, or another extension's continuation. Usage that a provider/tool does not report cannot be counted.
|
|
93
240
|
- Usage-limit handling is best-effort via HTTP responses and assistant error messages; provider transports vary in how much structured limit information they expose.
|
|
94
241
|
- Automatic continuation is session-local, not a background daemon.
|
|
242
|
+
- Oversized stored objectives (over 4,000 characters) are not automatically continued or silently truncated. Edit the objective before resuming.
|
|
95
243
|
|
|
96
244
|
## Development and validation
|
|
97
245
|
|
|
@@ -105,6 +253,33 @@ npm run pack:dry-run --workspace packages/pi-goal
|
|
|
105
253
|
npm audit --omit=dev
|
|
106
254
|
```
|
|
107
255
|
|
|
256
|
+
The session-contract suite uses real Pi loading, tool dispatch, provider
|
|
257
|
+
serialization, and session trees with synthetic goals and mocked provider
|
|
258
|
+
traffic. It checks direct and nested calls, codemode discovery, termination,
|
|
259
|
+
reloads, model changes, and branch reconstruction without live credentials or
|
|
260
|
+
provider requests.
|
|
261
|
+
|
|
262
|
+
Accounting regressions also cover multi-level metered codemode calls, failed
|
|
263
|
+
results, mocked cache warming and remote compaction, branch summaries, context
|
|
264
|
+
omissions, inherited fork histories, and tool-only budget exhaustion. The warming
|
|
265
|
+
tests load the compaction/reasoning hooks in both orders and verify that idle
|
|
266
|
+
refreshes do not change saved reasoning state.
|
|
267
|
+
|
|
268
|
+
The settlement suite exercises the production extension in real Pi sessions
|
|
269
|
+
with bounded mocked responses. It covers cancellation (including retry and
|
|
270
|
+
pre-settlement cancellation), successful/exhausted retries, compaction
|
|
271
|
+
success/failure, canonical context edits, user queues, extension ordering,
|
|
272
|
+
terminal tools, startup/resume/reload, replacement, forks, tree navigation, and
|
|
273
|
+
TUI/print/JSON/RPC bindings. It also advances controlled timers after settlement
|
|
274
|
+
to detect an obsolete timer that would restart the run. No live credentials are
|
|
275
|
+
used; HTTP responses are mocked and WebSocket traffic is blocked.
|
|
276
|
+
|
|
277
|
+
Run the focused lifecycle smoke test from the monorepo root:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
node --import tsx --test packages/pi-goal/tests/settlement-contract.test.mjs
|
|
281
|
+
```
|
|
282
|
+
|
|
108
283
|
Before publishing, also run the root validation loop:
|
|
109
284
|
|
|
110
285
|
```bash
|
package/SECURITY.md
CHANGED
|
@@ -25,6 +25,52 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
|
|
|
25
25
|
|
|
26
26
|
The package does not require API keys and does not read provider credentials. Its model tools can only inspect the current goal, create a new explicitly requested goal when none exists, or mark the current goal `complete`/`blocked`.
|
|
27
27
|
|
|
28
|
-
On
|
|
28
|
+
On supported Pi runtimes (Pi 1.1.0 or newer), `update_goal` uses Pi's
|
|
29
|
+
`model-only` exposure. Codemode discovery and nested dispatch through
|
|
30
|
+
`ctx.executeTool()` cannot reach it. A direct update must be the only tool call
|
|
31
|
+
in its assistant turn, after the model has inspected verification results.
|
|
32
|
+
Older runtimes are unsupported; there is no legacy nested-call fallback.
|
|
33
|
+
`get_goal` and `create_goal` remain callable from scripts.
|
|
34
|
+
|
|
35
|
+
Those two tools expose a declared `{ goal }` summary through `structuredContent`,
|
|
36
|
+
not the full persisted entry or accounting ledger. The objective is user data,
|
|
37
|
+
not automatically secret-scanned. Direct text and renderer details keep the
|
|
38
|
+
same summary fields. `get_goal` can persist refreshed accounting, so its metadata
|
|
39
|
+
does not claim a pure read; all goal tool calls are sequential. Namespace and
|
|
40
|
+
annotation metadata are advisory and do not grant creation or completion authority.
|
|
41
|
+
|
|
42
|
+
This is a tool-call boundary, not an independent verifier of completion or
|
|
43
|
+
blocker evidence. The model remains responsible for that evidence. A trusted
|
|
44
|
+
extension can execute arbitrary code with the user's permissions; model-only
|
|
45
|
+
exposure is not a sandbox against installed extensions.
|
|
46
|
+
|
|
47
|
+
On startup, `@mocito/install-telemetry` sends a best-effort install/update telemetry ping to the configured telemetry endpoint once per package version unless Pi telemetry is disabled, offline mode is enabled, or Pi runs in CI. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, goal objectives, file paths, session data, config values, or API keys. Telemetry writes a local deduplication marker under Pi's agent extensions directory.
|
|
29
48
|
|
|
30
49
|
Goal objectives are treated as untrusted user-provided task data when continuation context is built. They are JSON-encoded before being embedded in the hidden context message to reduce prompt-injection risk from delimiter-breaking text.
|
|
50
|
+
|
|
51
|
+
Budget accounting reads finalized usage from the raw current branch, including
|
|
52
|
+
context-omitted entries. Its metadata contains only token totals, entry IDs,
|
|
53
|
+
timestamps, and aggregate scan diagnostics—not prompts, tool arguments, raw
|
|
54
|
+
responses, credentials, or encrypted compaction checkpoints. A session-scoped
|
|
55
|
+
idle poll reads the branch leaf ID and scans changed history without making
|
|
56
|
+
network requests.
|
|
57
|
+
|
|
58
|
+
Budgets are best-effort token accounting, not a hard spending or authorization
|
|
59
|
+
boundary. Unreported usage cannot be counted, active requests can overrun, and
|
|
60
|
+
other extensions can override idle warming decisions. Pause and terminal goal
|
|
61
|
+
states stop goal continuation but do not exclude later branch usage; clear or
|
|
62
|
+
replace a retained goal before unrelated work.
|
|
63
|
+
|
|
64
|
+
Automatic goal continuation is proposed at Pi's `agent_before_settle` boundary,
|
|
65
|
+
after recovery and queued work, rather than starting a new run from a timer.
|
|
66
|
+
Cancelled/error outcomes and failed automatic compaction do not request more
|
|
67
|
+
goal work. Budget and provider-limit notices are context-only and do not request
|
|
68
|
+
a wrap-up turn. These checks control the goal extension's requests, not Pi's own
|
|
69
|
+
recovery, user input, or another trusted extension's continuation.
|
|
70
|
+
|
|
71
|
+
Explicit activation on creation, resume, startup/reload, and tree navigation
|
|
72
|
+
rechecks the current goal, session, branch, pending work, and finalized budget.
|
|
73
|
+
Old activations are invalidated on lifecycle changes. An active stored goal can
|
|
74
|
+
resume on reload; use `/goal pause` to persist a stop across reloads.
|
|
75
|
+
Continuation retains JSON framing for untrusted objectives and refuses stored
|
|
76
|
+
objectives beyond the public 4,000-character limit instead of truncating them.
|
package/extensions/index.ts
CHANGED
|
@@ -19,7 +19,35 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
19
19
|
|
|
20
20
|
let goal: GoalState | null = null;
|
|
21
21
|
let consecutiveAssistantErrors = 0;
|
|
22
|
-
|
|
22
|
+
let automaticCompactionFailed = false;
|
|
23
|
+
const scheduler = new GoalContinuationScheduler(pi, { getGoal: () => goal, beforeContinue: accountAndEnforceBudget });
|
|
24
|
+
let idleAccountingTimer: ReturnType<typeof setInterval> | undefined;
|
|
25
|
+
|
|
26
|
+
function stopIdleAccounting(): void {
|
|
27
|
+
if (idleAccountingTimer) clearInterval(idleAccountingTimer);
|
|
28
|
+
idleAccountingTimer = undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function startIdleAccounting(ctx: ExtensionContext): void {
|
|
32
|
+
stopIdleAccounting();
|
|
33
|
+
let lastLeaf = ctx.sessionManager.getLeafId?.();
|
|
34
|
+
// Pi 1.1 emits warming entry_appended only to SDK listeners, not extensions.
|
|
35
|
+
// Poll the cheap public leaf ID; scan billing history only when it changes.
|
|
36
|
+
idleAccountingTimer = setInterval(() => {
|
|
37
|
+
try {
|
|
38
|
+
if (!ctx.isIdle() || !goal) return;
|
|
39
|
+
const leaf = ctx.sessionManager.getLeafId?.();
|
|
40
|
+
if (leaf === lastLeaf) return;
|
|
41
|
+
accountAndEnforceBudget(ctx);
|
|
42
|
+
lastLeaf = ctx.sessionManager.getLeafId?.();
|
|
43
|
+
} catch {
|
|
44
|
+
// SDK disposal can invalidate contexts without dispatching shutdown.
|
|
45
|
+
// Never keep using a stale context; normal lifecycle checks still apply.
|
|
46
|
+
stopIdleAccounting();
|
|
47
|
+
}
|
|
48
|
+
}, 1000);
|
|
49
|
+
idleAccountingTimer.unref();
|
|
50
|
+
}
|
|
23
51
|
|
|
24
52
|
const setGoal = (next: GoalState | null) => { goal = next; };
|
|
25
53
|
|
|
@@ -36,11 +64,8 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
36
64
|
// Send a visible message to the model so it can react to a terminal
|
|
37
65
|
// status transition (budget exhausted, provider limit hit) instead of
|
|
38
66
|
// silently continuing to spend tokens on work that is about to be cut
|
|
39
|
-
// off.
|
|
40
|
-
// is the model's only chance to learn about the transition before the
|
|
41
|
-
// user notices.
|
|
67
|
+
// off. Terminal notices are context-only: they must not buy another request.
|
|
42
68
|
function notifyAgentOfTerminalTransition(
|
|
43
|
-
ctx: ExtensionContext,
|
|
44
69
|
content: string,
|
|
45
70
|
details: Record<string, unknown>,
|
|
46
71
|
): void {
|
|
@@ -51,7 +76,7 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
51
76
|
display: true,
|
|
52
77
|
details: { ...details, piGoalVersion: PI_GOAL_VERSION },
|
|
53
78
|
},
|
|
54
|
-
{ triggerTurn:
|
|
79
|
+
{ triggerTurn: false },
|
|
55
80
|
);
|
|
56
81
|
}
|
|
57
82
|
|
|
@@ -74,7 +99,6 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
74
99
|
updateGoalUi(ctx, goal);
|
|
75
100
|
scheduler.clear();
|
|
76
101
|
notifyAgentOfTerminalTransition(
|
|
77
|
-
ctx,
|
|
78
102
|
`<provider_limit>\nYour persistent Pi goal has been paused because the provider hit a usage/rate limit${suffix}. The goal is in the "usage_limited" state. Do not do any more work that would use additional tokens. Use /goal resume to continue after the limit resets, or call update_goal with status "complete" or "blocked" to finalize the goal now.\n</provider_limit>`,
|
|
79
103
|
{
|
|
80
104
|
kind: "provider_limit",
|
|
@@ -103,7 +127,6 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
103
127
|
ctx.ui.notify("Goal token budget reached.", "warning");
|
|
104
128
|
scheduler.clear();
|
|
105
129
|
notifyAgentOfTerminalTransition(
|
|
106
|
-
ctx,
|
|
107
130
|
`<budget_exceeded>\nYour persistent Pi goal's token budget of ${snapshot.tokenBudget} has been reached (used ${snapshot.tokensUsed} tokens). Stop work immediately to avoid burning more tokens. If all requirements are satisfied, call update_goal with status "complete". If the goal cannot be completed, call update_goal with status "blocked". Do not do any more work that would use additional tokens.\n</budget_exceeded>`,
|
|
108
131
|
{
|
|
109
132
|
kind: "budget_exceeded",
|
|
@@ -119,6 +142,7 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
119
142
|
registerGoalRenderers(pi);
|
|
120
143
|
registerGoalTools(pi, {
|
|
121
144
|
getGoal: () => goal,
|
|
145
|
+
refreshUsage: accountAndEnforceBudget,
|
|
122
146
|
setGoal,
|
|
123
147
|
afterGoalChanged,
|
|
124
148
|
clearContinuation: () => scheduler.clear(),
|
|
@@ -128,6 +152,7 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
128
152
|
description: "set or view the goal for a long-running task",
|
|
129
153
|
getArgumentCompletions: goalCompletions,
|
|
130
154
|
handler: async (args, ctx) => {
|
|
155
|
+
accountAndEnforceBudget(ctx);
|
|
131
156
|
await handleGoalCommand(pi, args, ctx, {
|
|
132
157
|
getGoal: () => goal,
|
|
133
158
|
setGoal,
|
|
@@ -138,6 +163,9 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
138
163
|
});
|
|
139
164
|
|
|
140
165
|
pi.on("session_start", async (_event, ctx) => {
|
|
166
|
+
scheduler.clear();
|
|
167
|
+
automaticCompactionFailed = false;
|
|
168
|
+
consecutiveAssistantErrors = 0;
|
|
141
169
|
const diagnostics: string[] = [];
|
|
142
170
|
goal = reconstructGoalState(ctx.sessionManager.getBranch() as any[], diagnostics);
|
|
143
171
|
if (goal?.status === "active" && !goal.activeStartedAt) {
|
|
@@ -147,22 +175,48 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
147
175
|
goal = applyGoalMutation(goal, resumed);
|
|
148
176
|
}
|
|
149
177
|
if (diagnostics.length) ctx.ui.notify(`pi-goal: ${diagnostics[0]}`, "warning");
|
|
178
|
+
accountAndEnforceBudget(ctx);
|
|
150
179
|
updateGoalUi(ctx, goal);
|
|
180
|
+
startIdleAccounting(ctx);
|
|
151
181
|
if (goal?.status === "active" && ctx.isIdle() && !ctx.hasPendingMessages()) scheduler.schedule(ctx, "continue");
|
|
152
182
|
});
|
|
153
183
|
|
|
154
184
|
pi.on("session_tree", async (_event, ctx) => {
|
|
155
185
|
scheduler.clear();
|
|
186
|
+
automaticCompactionFailed = false;
|
|
187
|
+
consecutiveAssistantErrors = 0;
|
|
156
188
|
goal = reconstructGoalState(ctx.sessionManager.getBranch() as any[]);
|
|
189
|
+
accountAndEnforceBudget(ctx);
|
|
157
190
|
updateGoalUi(ctx, goal);
|
|
158
|
-
|
|
191
|
+
startIdleAccounting(ctx);
|
|
192
|
+
if (goal?.status === "active" && !ctx.hasPendingMessages()) scheduler.schedule(ctx, "continue", { afterTree: true });
|
|
159
193
|
});
|
|
160
194
|
|
|
161
195
|
pi.on("session_compact", async (_event, ctx) => {
|
|
162
196
|
goal = reconstructGoalState(ctx.sessionManager.getBranch() as any[]);
|
|
197
|
+
accountAndEnforceBudget(ctx);
|
|
163
198
|
updateGoalUi(ctx, goal);
|
|
164
199
|
});
|
|
165
200
|
|
|
201
|
+
pi.on("session_compact_failed", (event) => {
|
|
202
|
+
if (event.reason !== "manual") {
|
|
203
|
+
automaticCompactionFailed = true;
|
|
204
|
+
scheduler.clear();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
pi.on("agent_start", () => {
|
|
209
|
+
// A real run supersedes any idle activation. Never leave a timer behind
|
|
210
|
+
// that could restart the session after cancellation or recovery.
|
|
211
|
+
scheduler.clear();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
pi.on("agent_settled", () => {
|
|
215
|
+
// agent_start also fires for queued work inside the same high-level run.
|
|
216
|
+
// Keep a compaction failure latched until that entire run has settled.
|
|
217
|
+
automaticCompactionFailed = false;
|
|
218
|
+
});
|
|
219
|
+
|
|
166
220
|
pi.on("message_end", async (event: any, ctx) => {
|
|
167
221
|
if (event.message?.role !== "assistant") return;
|
|
168
222
|
const classification = classifyAssistantError(event.message);
|
|
@@ -183,12 +237,26 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
183
237
|
});
|
|
184
238
|
|
|
185
239
|
pi.on("turn_end", async (_event, ctx) => {
|
|
240
|
+
// Unlike message_end, this boundary sees persisted assistant/tool entries.
|
|
186
241
|
accountAndEnforceBudget(ctx);
|
|
187
242
|
});
|
|
188
243
|
|
|
189
244
|
pi.on("agent_end", async (_event, ctx) => {
|
|
190
245
|
accountAndEnforceBudget(ctx);
|
|
191
|
-
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
pi.on("agent_before_settle", (event, ctx) => {
|
|
249
|
+
accountAndEnforceBudget(ctx);
|
|
250
|
+
if (!automaticCompactionFailed) return scheduler.beforeSettle(event, ctx);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
pi.on("cache_warming_decision", async (_event, ctx) => {
|
|
254
|
+
accountAndEnforceBudget(ctx);
|
|
255
|
+
// Never enable warming or change no-goal sessions. Pi chains actions in
|
|
256
|
+
// registration order, so a later handler can explicitly override this stop.
|
|
257
|
+
if (ctx.isIdle() && goal && (goal.tokenBudget != null || goal.status === "budget_limited" || goal.status === "usage_limited")) {
|
|
258
|
+
return { action: "stop" };
|
|
259
|
+
}
|
|
192
260
|
});
|
|
193
261
|
|
|
194
262
|
pi.on("after_provider_response", async (event, ctx) => {
|
|
@@ -208,7 +276,13 @@ export default function piGoal(pi: ExtensionAPI) {
|
|
|
208
276
|
accountAndEnforceBudget(ctx);
|
|
209
277
|
scheduler.clear();
|
|
210
278
|
});
|
|
279
|
+
pi.on("session_before_tree", (_event, ctx) => {
|
|
280
|
+
accountAndEnforceBudget(ctx);
|
|
281
|
+
scheduler.clear();
|
|
282
|
+
});
|
|
211
283
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
284
|
+
stopIdleAccounting();
|
|
285
|
+
accountAndEnforceBudget(ctx);
|
|
212
286
|
if (goal?.status === "active") {
|
|
213
287
|
const time = realizedTimeUsed(goal);
|
|
214
288
|
const stopped = statusMutation(goal, "active", time, undefined, transitionMeta("session_shutdown", goal, time, nowIso()));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mocito/pi-goal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Persistent long-running goals for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,18 +53,21 @@
|
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-ai": "
|
|
57
|
-
"@earendil-works/pi-coding-agent": "
|
|
58
|
-
"@earendil-works/pi-tui": "
|
|
59
|
-
"@types/node": "^
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"typescript": "^
|
|
56
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
58
|
+
"@earendil-works/pi-tui": "1.1.0",
|
|
59
|
+
"@types/node": "^26.6.3",
|
|
60
|
+
"tsx": "^4.23.15",
|
|
61
|
+
"typebox": "^1.3.34",
|
|
62
|
+
"typescript": "^7.0.2"
|
|
63
63
|
},
|
|
64
64
|
"publishConfig": {
|
|
65
65
|
"access": "public"
|
|
66
66
|
},
|
|
67
67
|
"engines": {
|
|
68
|
-
"node": ">=
|
|
68
|
+
"node": ">=22.19.0"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@mocito/install-telemetry": "0.1.1"
|
|
69
72
|
}
|
|
70
73
|
}
|
package/src/accounting.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { BranchEntry, GoalMutation, GoalState } from "./types.js";
|
|
2
|
-
import { GOAL_SCHEMA_VERSION } from "./types.js";
|
|
2
|
+
import { GOAL_ENTRY_TYPE, GOAL_SCHEMA_VERSION } from "./types.js";
|
|
3
3
|
import { withPiGoalVersion } from "./metadata.js";
|
|
4
4
|
import { nowIso } from "./utils.js";
|
|
5
|
+
import { isKnownMutation } from "./state.js";
|
|
5
6
|
|
|
6
7
|
export interface UsageAccountingResult {
|
|
7
8
|
mutation?: GoalMutation;
|
|
@@ -12,7 +13,10 @@ export interface UsageAccountingResult {
|
|
|
12
13
|
|
|
13
14
|
export function assistantUsageTokens(message: any): number {
|
|
14
15
|
if (!message || message.role !== "assistant") return 0;
|
|
15
|
-
|
|
16
|
+
return usageTokens(message.usage);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function usageTokens(usage: any): number {
|
|
16
20
|
if (!usage || typeof usage !== "object") return 0;
|
|
17
21
|
if (Number.isFinite(usage.totalTokens)) return Math.max(0, Math.floor(usage.totalTokens));
|
|
18
22
|
return [usage.input, usage.output, usage.cacheRead, usage.cacheWrite]
|
|
@@ -20,28 +24,47 @@ export function assistantUsageTokens(message: any): number {
|
|
|
20
24
|
.reduce((sum, n) => sum + Math.max(0, Math.floor(n)), 0);
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
function entryUsage(entry: BranchEntry): any {
|
|
28
|
+
if (entry.type === "message" && (entry.message?.role === "assistant" || entry.message?.role === "toolResult")) {
|
|
29
|
+
// Pi already aggregates every nesting level onto the transcript tool result.
|
|
30
|
+
return entry.message.usage;
|
|
31
|
+
}
|
|
32
|
+
if (entry.type === "usage" || entry.type === "compaction" || entry.type === "branch_summary") return entry.usage;
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
export function accountUsageFromBranch(goal: GoalState, branchEntries: BranchEntry[], endMs = Date.now()): UsageAccountingResult {
|
|
24
36
|
const accounted = new Set(goal.accountedUsage.entryIds);
|
|
25
37
|
const createdMs = Date.parse(goal.createdAt);
|
|
38
|
+
// Entry order disambiguates work finalized in the same millisecond as creation.
|
|
39
|
+
// Timestamp-only fallback supports existing callers with an unpersisted goal.
|
|
40
|
+
const startIndex = branchEntries.findIndex((entry) => entry?.type === "custom" && entry.customType === GOAL_ENTRY_TYPE
|
|
41
|
+
&& isKnownMutation(entry.data) && entry.data.goalId === goal.goalId && (entry.data.kind === "create" || entry.data.kind === "replace"));
|
|
26
42
|
let addedTokens = 0;
|
|
27
43
|
let scannedAssistantEntries = 0;
|
|
44
|
+
let scannedUsageEntries = 0;
|
|
28
45
|
let cacheTokensIncluded = false;
|
|
29
46
|
const addedEntryIds: string[] = [];
|
|
30
|
-
for (const entry of branchEntries) {
|
|
31
|
-
if (entry
|
|
32
|
-
|
|
47
|
+
for (const entry of branchEntries.slice(startIndex + 1)) {
|
|
48
|
+
if (!entry || typeof entry.id !== "string" || !entry.id) continue;
|
|
49
|
+
// A retained goal ends at replacement/clear, not pause or terminal status.
|
|
50
|
+
if (entry.type === "custom" && entry.customType === GOAL_ENTRY_TYPE
|
|
51
|
+
&& isKnownMutation(entry.data)
|
|
52
|
+
&& (entry.data.kind === "create" || entry.data.kind === "replace" || entry.data.kind === "clear")) break;
|
|
33
53
|
if (accounted.has(entry.id)) continue;
|
|
34
54
|
const entryMs = Date.parse(entry.timestamp ?? "");
|
|
35
|
-
if (Number.isFinite(createdMs)
|
|
36
|
-
const usage = entry
|
|
55
|
+
if (!Number.isFinite(createdMs) || !Number.isFinite(entryMs) || entryMs < createdMs || entryMs > endMs) continue;
|
|
56
|
+
const usage = entryUsage(entry);
|
|
57
|
+
if (!usage) continue;
|
|
58
|
+
scannedUsageEntries++;
|
|
59
|
+
if (entry.message?.role === "assistant") scannedAssistantEntries++;
|
|
37
60
|
if (usage && (Number.isFinite(usage.cacheRead) || Number.isFinite(usage.cacheWrite))) cacheTokensIncluded = true;
|
|
38
|
-
const tokens =
|
|
61
|
+
const tokens = usageTokens(usage);
|
|
39
62
|
if (tokens <= 0) continue;
|
|
40
63
|
addedTokens += tokens;
|
|
64
|
+
accounted.add(entry.id);
|
|
41
65
|
addedEntryIds.push(entry.id);
|
|
42
66
|
}
|
|
43
67
|
if (addedTokens === 0) return { goal, addedTokens: 0, addedEntryIds };
|
|
44
|
-
void endMs;
|
|
45
68
|
const mutation: GoalMutation = {
|
|
46
69
|
schemaVersion: GOAL_SCHEMA_VERSION,
|
|
47
70
|
kind: "account",
|
|
@@ -51,7 +74,7 @@ export function accountUsageFromBranch(goal: GoalState, branchEntries: BranchEnt
|
|
|
51
74
|
at: nowIso(),
|
|
52
75
|
meta: withPiGoalVersion({
|
|
53
76
|
source: "accounting",
|
|
54
|
-
accounting: { scannedAssistantEntries, addedEntryCount: addedEntryIds.length, cacheTokensIncluded },
|
|
77
|
+
accounting: { scannedAssistantEntries, scannedUsageEntries, addedEntryCount: addedEntryIds.length, cacheTokensIncluded },
|
|
55
78
|
}),
|
|
56
79
|
};
|
|
57
80
|
return {
|
package/src/commands.ts
CHANGED
|
@@ -94,9 +94,10 @@ async function editGoal(pi: ExtensionAPI, ctx: ExtensionCommandContext, runtime:
|
|
|
94
94
|
function setGoalStatus(pi: ExtensionAPI, ctx: ExtensionCommandContext, runtime: CommandRuntime, status: "paused" | "active"): void {
|
|
95
95
|
const current = accountCurrent(pi, ctx, runtime.getGoal());
|
|
96
96
|
if (!current) return ctx.ui.notify("No goal set.", "error");
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const
|
|
97
|
+
const transitionedAt = nowIso();
|
|
98
|
+
const time = realizedTimeUsed(current, Date.parse(transitionedAt));
|
|
99
|
+
const activeStartedAt = status === "active" ? transitionedAt : undefined;
|
|
100
|
+
const mutation = statusMutation(current, status, time, activeStartedAt, transitionMeta(status === "active" ? "command:/goal resume" : "command:/goal pause", current, time, transitionedAt));
|
|
100
101
|
appendGoalMutation(pi, mutation);
|
|
101
102
|
runtime.setGoal(applyGoalMutation(current, mutation));
|
|
102
103
|
runtime.afterGoalChanged(ctx, status === "active" ? "Goal resumed." : "Goal paused.");
|
package/src/continuation.ts
CHANGED
|
@@ -1,63 +1,99 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { AgentBeforeSettleEvent, BoundaryResult, CustomMessageEntryDraft, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { buildGoalContextMessage } from "./prompts.js";
|
|
3
3
|
import { PI_GOAL_VERSION } from "./metadata.js";
|
|
4
|
-
import { GOAL_CONTEXT_TYPE } from "./types.js";
|
|
4
|
+
import { GOAL_CONTEXT_TYPE, MAX_OBJECTIVE_CHARS } from "./types.js";
|
|
5
5
|
import type { GoalContextReason, GoalState } from "./types.js";
|
|
6
6
|
|
|
7
7
|
export interface ContinuationRuntime {
|
|
8
8
|
getGoal(): GoalState | null;
|
|
9
|
+
beforeContinue?(ctx: ExtensionContext): void;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export class GoalContinuationScheduler {
|
|
12
|
-
private scheduled = false;
|
|
13
|
-
private inFlight = false;
|
|
14
13
|
private timer: NodeJS.Timeout | undefined;
|
|
15
14
|
private turnCount = 0;
|
|
16
|
-
private
|
|
15
|
+
private generation = 0;
|
|
17
16
|
|
|
18
17
|
constructor(private readonly pi: ExtensionAPI, private readonly runtime: ContinuationRuntime) {}
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
/** Only for explicit activation when there is no running settlement boundary. */
|
|
20
|
+
schedule(ctx: ExtensionContext, reason: GoalContextReason | string = "continue", options: { afterTree?: boolean } = {}): void {
|
|
21
21
|
const goal = this.runtime.getGoal();
|
|
22
|
-
if (!goal || goal.status !== "active") return;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
if (!goal || goal.status !== "active" || (!options.afterTree && !ctx.isIdle()) || ctx.hasPendingMessages()) return;
|
|
23
|
+
// A replacement or a second explicit activation supersedes the old timer.
|
|
24
|
+
this.clear();
|
|
25
|
+
const generation = this.generation;
|
|
26
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
27
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
28
|
+
const fire = () => {
|
|
28
29
|
this.timer = undefined;
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
try {
|
|
31
|
+
if (generation !== this.generation || ctx.signal?.aborted || ctx.hasPendingMessages()) return;
|
|
32
|
+
if (ctx.sessionManager.getSessionId() !== sessionId) return;
|
|
33
|
+
if (!ctx.isIdle()) {
|
|
34
|
+
// Pi releases the navigation operation only after ALL session_tree
|
|
35
|
+
// handlers return. A later async handler may still be finishing.
|
|
36
|
+
// agent_start or any invalidation clears this activation meanwhile.
|
|
37
|
+
if (options.afterTree) this.timer = setTimeout(fire, 50);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (leafId && !ctx.sessionManager.getBranch().some((entry) => entry.id === leafId)) return;
|
|
41
|
+
this.runtime.beforeContinue?.(ctx);
|
|
42
|
+
const current = this.runtime.getGoal();
|
|
43
|
+
if (generation !== this.generation || !current || current.status !== "active" || current.goalId !== goal.goalId) return;
|
|
44
|
+
const draft = this.buildDraft(current, reason as GoalContextReason);
|
|
45
|
+
if (!draft) return;
|
|
46
|
+
const { type: _type, ...message } = draft;
|
|
47
|
+
this.pi.sendMessage(message, { triggerTurn: true });
|
|
48
|
+
} catch {
|
|
49
|
+
// A disposed/replaced SDK runtime invalidates captured contexts, even
|
|
50
|
+
// without session_shutdown. Fail closed rather than restart stale work.
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
this.timer = setTimeout(fire, 0);
|
|
31
54
|
}
|
|
32
55
|
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
/** The caller accounts finalized usage before making this synchronous proposal. */
|
|
57
|
+
beforeSettle(event: AgentBeforeSettleEvent, ctx: ExtensionContext): BoundaryResult | undefined {
|
|
58
|
+
this.clear();
|
|
35
59
|
const goal = this.runtime.getGoal();
|
|
36
|
-
if (!goal || goal.status !== "active" ||
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
display: false,
|
|
45
|
-
details: { goalId: goal.goalId, reason, turnCount: this.turnCount, piGoalVersion: PI_GOAL_VERSION, status: goal.status, usage: { tokensUsed: goal.tokensUsed, timeUsedSeconds: goal.timeUsedSeconds } },
|
|
46
|
-
}, { triggerTurn: true, deliverAs: ctx.isIdle() ? "steer" : "followUp" });
|
|
47
|
-
} finally {
|
|
48
|
-
this.inFlight = false;
|
|
60
|
+
if (!goal || goal.status !== "active" || event.outcome !== "completed" || ctx.signal?.aborted) return;
|
|
61
|
+
if (goal.objective.length > MAX_OBJECTIVE_CHARS) return;
|
|
62
|
+
if (ctx.hasPendingMessages() || event.context.pendingMessages.length > 0) return;
|
|
63
|
+
|
|
64
|
+
const proposed = event.entries.some((entry) => entry.type === "custom_message"
|
|
65
|
+
&& entry.customType === GOAL_CONTEXT_TYPE && (entry.details as { goalId?: string } | undefined)?.goalId === goal.goalId);
|
|
66
|
+
if (proposed && event.context.canContinue) {
|
|
67
|
+
return { continue: true };
|
|
49
68
|
}
|
|
69
|
+
// canContinue describes the preview BEFORE our draft. It is normally false
|
|
70
|
+
// after an assistant reply. This user-role custom message makes it runnable;
|
|
71
|
+
// Pi rebuilds and validates the final preview after all handlers have run.
|
|
72
|
+
const draft = this.buildDraft(goal, "continue");
|
|
73
|
+
if (draft) return { entries: [...event.entries, draft], continue: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private buildDraft(goal: GoalState, reason: GoalContextReason): CustomMessageEntryDraft | undefined {
|
|
77
|
+
// Public inputs are validated already; do not turn oversized legacy/raw
|
|
78
|
+
// session data into an unbounded prompt or silently truncate the objective.
|
|
79
|
+
if (goal.objective.length > MAX_OBJECTIVE_CHARS) return;
|
|
80
|
+
return {
|
|
81
|
+
type: "custom_message",
|
|
82
|
+
customType: GOAL_CONTEXT_TYPE,
|
|
83
|
+
content: buildGoalContextMessage(goal, reason === "created" || reason === "resumed" || reason === "objective_updated" ? reason : "continue"),
|
|
84
|
+
display: false,
|
|
85
|
+
details: { goalId: goal.goalId, reason, turnCount: ++this.turnCount, piGoalVersion: PI_GOAL_VERSION, status: goal.status, usage: { tokensUsed: goal.tokensUsed, timeUsedSeconds: goal.timeUsedSeconds } },
|
|
86
|
+
};
|
|
50
87
|
}
|
|
51
88
|
|
|
52
89
|
clear(): void {
|
|
53
|
-
this.
|
|
54
|
-
this.inFlight = false;
|
|
90
|
+
this.generation++;
|
|
55
91
|
if (this.timer) clearTimeout(this.timer);
|
|
56
92
|
this.timer = undefined;
|
|
57
93
|
}
|
|
58
94
|
|
|
59
95
|
isScheduled(): boolean {
|
|
60
|
-
return this.
|
|
96
|
+
return this.timer !== undefined;
|
|
61
97
|
}
|
|
62
98
|
}
|
|
63
99
|
|
package/src/install-telemetry.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
7
|
const PACKAGE_NAME = "pi-goal";
|
|
8
|
-
const
|
|
9
|
-
const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
|
|
8
|
+
const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
|
|
10
9
|
const CI_ENVIRONMENT_VARIABLES = [
|
|
11
10
|
"APPVEYOR",
|
|
12
11
|
"BITBUCKET_BUILD_NUMBER",
|
|
@@ -24,10 +23,6 @@ const CI_ENVIRONMENT_VARIABLES = [
|
|
|
24
23
|
"VERCEL",
|
|
25
24
|
];
|
|
26
25
|
|
|
27
|
-
interface InstallTelemetryState {
|
|
28
|
-
lastReportedVersion?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
26
|
interface PiSettingsDocument {
|
|
32
27
|
enableInstallTelemetry?: unknown;
|
|
33
28
|
}
|
|
@@ -51,18 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
|
|
|
51
46
|
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
52
47
|
}
|
|
53
48
|
|
|
54
|
-
function
|
|
55
|
-
if (isTruthyEnvFlag(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
function isInstallTelemetryEnabled(): boolean {
|
|
60
|
-
if (isCiEnvironment()) return false;
|
|
61
|
-
if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
|
|
62
|
-
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
49
|
+
export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
|
|
50
|
+
if (isTruthyEnvFlag(env.CI)) return false;
|
|
51
|
+
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
|
|
52
|
+
if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
|
|
63
53
|
|
|
64
|
-
const settings = readJsonFile(
|
|
65
|
-
|
|
54
|
+
const settings = readJsonFile(settingsPath) as PiSettingsDocument;
|
|
55
|
+
if (settings.enableInstallTelemetry === false) return false;
|
|
56
|
+
if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
|
|
57
|
+
return true;
|
|
66
58
|
}
|
|
67
59
|
|
|
68
60
|
function getPackageVersion(): string {
|
|
@@ -70,35 +62,16 @@ function getPackageVersion(): string {
|
|
|
70
62
|
return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
|
|
71
63
|
}
|
|
72
64
|
|
|
73
|
-
function
|
|
74
|
-
const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
|
|
75
|
-
const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
|
|
76
|
-
return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function reportInstallTelemetryAsync(): Promise<void> {
|
|
65
|
+
export function reportInstallTelemetry(): void {
|
|
80
66
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await mkdir(extensionsDir, { recursive: true });
|
|
90
|
-
await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
|
|
91
|
-
|
|
92
|
-
const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
|
|
93
|
-
await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
|
|
94
|
-
headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
|
|
95
|
-
signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
|
|
96
|
-
});
|
|
67
|
+
void report({
|
|
68
|
+
endpoint: INSTALL_TELEMETRY_ENDPOINT,
|
|
69
|
+
tool: PACKAGE_NAME,
|
|
70
|
+
version: getPackageVersion(),
|
|
71
|
+
statePath: join(getAgentDir(), "extensions", "pi-goal-install.json"),
|
|
72
|
+
enabled: isInstallTelemetryEnabled(),
|
|
73
|
+
}).catch(() => undefined);
|
|
97
74
|
} catch {
|
|
98
|
-
// Best-effort telemetry: ignore
|
|
75
|
+
// Best-effort telemetry: ignore local policy and filesystem failures.
|
|
99
76
|
}
|
|
100
77
|
}
|
|
101
|
-
|
|
102
|
-
export function reportInstallTelemetry(): void {
|
|
103
|
-
void reportInstallTelemetryAsync();
|
|
104
|
-
}
|
package/src/metadata.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import type { BranchEntry, GoalMutationMeta, GoalState } from "./types.js";
|
|
2
3
|
import { secondsBetween } from "./utils.js";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
const packageJson = createRequire(import.meta.url)("../package.json") as { version?: unknown };
|
|
6
|
+
|
|
7
|
+
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
|
8
|
+
throw new Error("pi-goal package version is missing or invalid.");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Package version that produced the persisted metadata; schema versioning is separate. */
|
|
12
|
+
export const PI_GOAL_VERSION = packageJson.version;
|
|
5
13
|
|
|
6
14
|
export function withPiGoalVersion(meta: GoalMutationMeta = {}): GoalMutationMeta {
|
|
7
15
|
return { piGoalVersion: PI_GOAL_VERSION, ...meta };
|
package/src/state.ts
CHANGED
|
@@ -69,11 +69,51 @@ export function applyGoalMutation(current: GoalState | null, mutation: GoalMutat
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function isKnownMutation(value: Partial<GoalMutation> | undefined): value is GoalMutation {
|
|
73
|
-
if (!value || value.schemaVersion !== GOAL_SCHEMA_VERSION || typeof value.kind !== "string" ||
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
72
|
+
export function isKnownMutation(value: Partial<GoalMutation> | undefined): value is GoalMutation {
|
|
73
|
+
if (!value || value.schemaVersion !== GOAL_SCHEMA_VERSION || typeof value.kind !== "string" || !validTimestamp(value.at)) return false;
|
|
74
|
+
const mutation = value as Record<string, unknown>;
|
|
75
|
+
if (value.kind === "clear") return optionalNonNegativeInteger(mutation.timeUsedSeconds);
|
|
76
|
+
if (typeof mutation.goalId !== "string" || mutation.goalId.length === 0) return false;
|
|
77
|
+
switch (value.kind) {
|
|
78
|
+
case "create":
|
|
79
|
+
case "replace":
|
|
80
|
+
return typeof mutation.objective === "string" && optionalPositiveInteger(mutation.tokenBudget);
|
|
81
|
+
case "edit":
|
|
82
|
+
return typeof mutation.objective === "string" && (mutation.status === undefined || isGoalStatus(mutation.status));
|
|
83
|
+
case "status":
|
|
84
|
+
return isGoalStatus(mutation.status)
|
|
85
|
+
&& optionalNonNegativeInteger(mutation.timeUsedSeconds)
|
|
86
|
+
&& (mutation.activeStartedAt === undefined || validTimestamp(mutation.activeStartedAt));
|
|
87
|
+
case "budget":
|
|
88
|
+
return optionalPositiveInteger(mutation.tokenBudget);
|
|
89
|
+
case "account":
|
|
90
|
+
return nonNegativeInteger(mutation.tokens)
|
|
91
|
+
&& Array.isArray(mutation.entryIds)
|
|
92
|
+
&& mutation.entryIds.every((id) => typeof id === "string");
|
|
93
|
+
default:
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isGoalStatus(value: unknown): value is GoalStatus {
|
|
99
|
+
return typeof value === "string" && ["active", "paused", "blocked", "usage_limited", "budget_limited", "complete"].includes(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validTimestamp(value: unknown): value is string {
|
|
103
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function nonNegativeInteger(value: unknown): value is number {
|
|
107
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function optionalNonNegativeInteger(value: unknown): boolean {
|
|
111
|
+
return value === undefined || nonNegativeInteger(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function optionalPositiveInteger(value: unknown): boolean {
|
|
115
|
+
return value === undefined
|
|
116
|
+
|| (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0);
|
|
77
117
|
}
|
|
78
118
|
|
|
79
119
|
export function statusMutation(goal: GoalState, status: GoalStatus, timeUsedSeconds: number, activeStartedAt?: string, meta?: GoalMutationMeta): GoalMutation {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { GoalSummary } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const GOAL_NAMESPACE = {
|
|
5
|
+
name: "goal",
|
|
6
|
+
description: "Inspect or explicitly create the current branch's persistent goal.",
|
|
7
|
+
instructions: "get_goal and create_goal return { goal }, with goal:null only when no goal is set. Await dependent calls: create_goal mutates branch state and fails if a goal exists. get_goal refreshes usage bookkeeping before returning a snapshot. Errors reject rather than returning a success object. Creation requires an explicit user/system/developer request. update_goal remains model-only: call it directly, alone in a final assistant turn after verification, never from a script.",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const summary = Type.Object({
|
|
11
|
+
goalId: Type.String(),
|
|
12
|
+
// Creation is limited to 4,000 chars; legacy stored objectives remain readable.
|
|
13
|
+
objective: Type.String(),
|
|
14
|
+
status: Type.Union(["active", "paused", "blocked", "usage_limited", "budget_limited", "complete"].map(value => Type.Literal(value))),
|
|
15
|
+
tokenBudget: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
16
|
+
tokensUsed: Type.Number({ minimum: 0 }),
|
|
17
|
+
remainingTokens: Type.Optional(Type.Number()),
|
|
18
|
+
timeUsedSeconds: Type.Number({ minimum: 0 }),
|
|
19
|
+
createdAt: Type.String(), updatedAt: Type.String(), activeStartedAt: Type.Optional(Type.String()),
|
|
20
|
+
}, { additionalProperties: false });
|
|
21
|
+
|
|
22
|
+
export const GET_GOAL_OUTPUT = Type.Object({ goal: Type.Union([summary, Type.Null()]) }, { additionalProperties: false });
|
|
23
|
+
export const CREATE_GOAL_OUTPUT = Type.Object({ goal: summary }, { additionalProperties: false });
|
|
24
|
+
|
|
25
|
+
export function goalResult(goal: GoalSummary | null) {
|
|
26
|
+
const text = JSON.stringify({ goal }, null, 2);
|
|
27
|
+
const payload = JSON.parse(text);
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text" as const, text: goal ? text : "No goal is set." }],
|
|
30
|
+
structuredContent: payload,
|
|
31
|
+
details: payload,
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -8,9 +8,11 @@ import { findToolCallContext, transitionMeta } from "./metadata.js";
|
|
|
8
8
|
import type { BranchEntry, GoalState, GoalStatus } from "./types.js";
|
|
9
9
|
import { goalToSummary, goalUsageSummary, nowIso, realizedTimeUsed, truncateOneLine } from "./utils.js";
|
|
10
10
|
import { validateObjective, validateTokenBudget } from "./validation.js";
|
|
11
|
+
import { CREATE_GOAL_OUTPUT, GET_GOAL_OUTPUT, GOAL_NAMESPACE, goalResult } from "./tool-contracts.js";
|
|
11
12
|
|
|
12
13
|
export interface ToolRuntime {
|
|
13
14
|
getGoal(): GoalState | null;
|
|
15
|
+
refreshUsage?(ctx: ExtensionContext): void;
|
|
14
16
|
setGoal(goal: GoalState | null): void;
|
|
15
17
|
afterGoalChanged(ctx: ExtensionContext, event?: string): void;
|
|
16
18
|
clearContinuation(): void;
|
|
@@ -34,15 +36,21 @@ const UpdateGoalParams = Type.Object({
|
|
|
34
36
|
export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void {
|
|
35
37
|
pi.registerTool({
|
|
36
38
|
name: "get_goal",
|
|
39
|
+
namespace: GOAL_NAMESPACE,
|
|
40
|
+
outputSchema: GET_GOAL_OUTPUT,
|
|
41
|
+
executionMode: "sequential",
|
|
42
|
+
// Querying refreshes/persists usage accounting; it is not a pure read.
|
|
43
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
37
44
|
label: "Get Goal",
|
|
38
45
|
description: "Return the current persistent Pi goal and usage for this session branch.",
|
|
39
46
|
promptSnippet: "Inspect the active persistent goal and token/time budget.",
|
|
40
47
|
promptGuidelines: ["Use get_goal when you need current pi-goal objective, status, usage, or remaining budget."],
|
|
41
48
|
parameters: Type.Object({}, { additionalProperties: false }),
|
|
42
|
-
async execute() {
|
|
49
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
50
|
+
runtime.refreshUsage?.(ctx);
|
|
43
51
|
const goal = runtime.getGoal();
|
|
44
52
|
const payload = goal ? goalToSummary(goal) : null;
|
|
45
|
-
return
|
|
53
|
+
return goalResult(payload);
|
|
46
54
|
},
|
|
47
55
|
renderCall(_args, theme) {
|
|
48
56
|
return new Text(theme.fg("toolTitle", theme.bold("get_goal")), 0, 0);
|
|
@@ -55,6 +63,9 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void
|
|
|
55
63
|
|
|
56
64
|
pi.registerTool({
|
|
57
65
|
name: "create_goal",
|
|
66
|
+
namespace: GOAL_NAMESPACE,
|
|
67
|
+
outputSchema: CREATE_GOAL_OUTPUT,
|
|
68
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
58
69
|
label: "Create Goal",
|
|
59
70
|
description: "Create a persistent Pi goal only when explicitly requested by user/system/developer instructions. Fails if a goal already exists.",
|
|
60
71
|
promptSnippet: "Create a new persistent goal when explicitly requested.",
|
|
@@ -76,7 +87,7 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void
|
|
|
76
87
|
runtime.setGoal(goal);
|
|
77
88
|
runtime.afterGoalChanged(ctx, "Goal created.");
|
|
78
89
|
const summary = goalToSummary(goal);
|
|
79
|
-
return
|
|
90
|
+
return goalResult(summary);
|
|
80
91
|
},
|
|
81
92
|
renderCall(args, theme) {
|
|
82
93
|
return new Text(`${theme.fg("toolTitle", theme.bold("create_goal"))} ${theme.fg("accent", truncateOneLine(String((args as any).objective ?? "")))}`, 0, 0);
|
|
@@ -89,15 +100,19 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void
|
|
|
89
100
|
|
|
90
101
|
pi.registerTool({
|
|
91
102
|
name: "update_goal",
|
|
103
|
+
namespace: GOAL_NAMESPACE,
|
|
104
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
92
105
|
label: "Update Goal",
|
|
93
|
-
description: "Mark the current Pi goal complete or blocked.
|
|
106
|
+
description: "Mark the current Pi goal complete or blocked. Call directly as the only tool call in a separate final assistant turn, after inspecting verification results. Not callable from codemode or other tools.",
|
|
94
107
|
promptSnippet: "Mark the persistent goal complete or blocked after strict verification.",
|
|
95
108
|
promptGuidelines: [
|
|
109
|
+
"Call update_goal directly as the only tool call in a separate final assistant turn, never from codemode or another tool.",
|
|
96
110
|
"Use update_goal with status complete only after explicit requirement-by-requirement verification proves the full goal is satisfied.",
|
|
97
111
|
"Use update_goal with status blocked only after the same blocker repeats for at least three consecutive goal turns.",
|
|
98
112
|
"Never use update_goal for pause, resume, budget_limited, usage_limited, or edits; those are user/system-controlled.",
|
|
99
113
|
],
|
|
100
114
|
parameters: UpdateGoalParams,
|
|
115
|
+
exposure: "model-only",
|
|
101
116
|
executionMode: "sequential",
|
|
102
117
|
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
103
118
|
if (params.status !== "complete" && params.status !== "blocked") throw new Error("update_goal only accepts status complete or blocked.");
|
|
@@ -142,6 +157,8 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void
|
|
|
142
157
|
|
|
143
158
|
export function assertStandaloneUpdateGoalCall(branchEntries: BranchEntry[], toolCallId: string) {
|
|
144
159
|
const toolContext = findToolCallContext(branchEntries, toolCallId);
|
|
160
|
+
// A direct executor may have no transcript context. This guard checks sibling
|
|
161
|
+
// calls, not caller authority: Pi enforces nested-call denial via model-only exposure.
|
|
145
162
|
if (!toolContext) return undefined;
|
|
146
163
|
if (toolContext.siblingToolCalls.length > 1) {
|
|
147
164
|
throw new Error("update_goal must be the only tool call in its assistant turn. Run verification commands first, inspect their results, then call update_goal in a separate final turn.");
|
package/src/types.ts
CHANGED
package/src/ui.ts
CHANGED
|
@@ -3,11 +3,13 @@ import type { GoalState } from "./types.js";
|
|
|
3
3
|
import { formatGoalFooter, formatWidget } from "./commands.js";
|
|
4
4
|
|
|
5
5
|
export function updateGoalUi(ctx: ExtensionContext, goal: GoalState | null): void {
|
|
6
|
+
if (ctx.mode !== "tui") return;
|
|
6
7
|
ctx.ui.setStatus("pi-goal", formatGoalFooter(goal));
|
|
7
8
|
ctx.ui.setWidget("pi-goal", formatWidget(goal), { placement: "belowEditor" });
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export function clearGoalUi(ctx: ExtensionContext): void {
|
|
12
|
+
if (ctx.mode !== "tui") return;
|
|
11
13
|
ctx.ui.setStatus("pi-goal", undefined);
|
|
12
14
|
ctx.ui.setWidget("pi-goal", undefined);
|
|
13
15
|
}
|