pi-tuck 1.0.9 → 1.0.10
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 +44 -0
- package/README.md +14 -4
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.js +104 -81
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/diagnostics.js +1 -1
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +105 -72
- package/src/diagnostics.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,38 @@
|
|
|
3
3
|
All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [1.0.10] - 2026-11-01
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- A session no longer stays stuck when Pi's `compaction.keepRecentTokens` covers the whole conversation (the 1.0.8 and 1.0.9 known issue,
|
|
11
|
+
at Pi's defaults on small windows). The cut a prompt needs is now pi-tuck's own: it is planned when the prompt arrives, as at a tool
|
|
12
|
+
boundary, every request of that run carries it, and the run's first boundary saves it. Pi's cut finder, which keeps the last
|
|
13
|
+
keepRecentTokens and then has nothing to cut, is no longer asked. A run that ends before a boundary drops the cut, and the next prompt
|
|
14
|
+
decides afresh.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- A prompt's cut no longer goes through Pi's `session_before_compact`, so another extension's veto there no longer stops it. It never
|
|
19
|
+
stopped a cut between tool rounds either. Pi's own threshold and overflow cuts, and `/compact`, still ask other extensions as before.
|
|
20
|
+
|
|
21
|
+
### Known issue
|
|
22
|
+
|
|
23
|
+
- When the previous summary alone fills the room and a large prompt arrives, the prompt is dropped: a cut cannot yet shrink a summary on its
|
|
24
|
+
own. 1.0.10 drops it with no message, where 1.0.9 refused it with one ("Already compacted"). The next prompt runs normally. Seen once, on a
|
|
25
|
+
25,160-character prompt at a 32k window. A fix is planned for 1.0.11.
|
|
26
|
+
|
|
27
|
+
### Release gates
|
|
28
|
+
|
|
29
|
+
- Passed: tests; replay of the exact build on 29 recordings (no dead task, no refused request, every oversize result clipped and read
|
|
30
|
+
back); the Pi-defaults rerun on chain t06, which lost 28 of 30 tasks on 1.0.8: 0 dead tasks and 0 stuck sessions
|
|
31
|
+
([`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md)). Two of its tasks sat on a first request the local server never
|
|
32
|
+
answered, ran to the 30-minute cap and recorded no token; they are read as server stalls, and a strict no-token count would not meet the bar.
|
|
33
|
+
One task was refused mid-run: its round carried 9,020 tokens of reasoning, so the next request was about 17,801 against the 16,384-token room.
|
|
34
|
+
- **Failed as registered:** the paired 32k gate against 1.0.9 (chains t01 and t02, 60 tasks). No task was lost in either arm, but 1.0.10
|
|
35
|
+
resolved 49 tasks against 50, so "resolved no lower" failed by one task (−1.7 points, one-sided 95% lower bound −8.3;
|
|
36
|
+
[`benchmark/RESULTS-V110.md`](benchmark/RESULTS-V110.md)). The user approved shipping as an exception because of the small-window defect.
|
|
37
|
+
|
|
6
38
|
## [1.0.9] - 2026-11-01
|
|
7
39
|
|
|
8
40
|
### Changed
|
|
@@ -18,6 +50,18 @@ All notable changes to pi-tuck. The format follows [Keep a Changelog](https://ke
|
|
|
18
50
|
- `tuck_search` takes a result back as returned: each entry of `priorResults` is a source's `sourceSha` and `read`, unchanged. It asked for
|
|
19
51
|
`start` and `end`, which no result carried.
|
|
20
52
|
|
|
53
|
+
### Known issue
|
|
54
|
+
|
|
55
|
+
- Affects 1.0.8 and 1.0.9. At a small window, Pi's default `compaction.keepRecentTokens` (20,000) can exceed pi-tuck's input room (the window
|
|
56
|
+
less the larger of `compaction.reserveTokens` and the model's maximum output; 16,384 at a 32k window with the default reserve). After a
|
|
57
|
+
run that is stopped (Esc, or a time limit) with the context above the room, every later prompt can be refused with "The request needs a cut
|
|
58
|
+
and Pi has none to make (Nothing to compact (session too small))", and the session stays stuck. Workaround until 1.0.10: set
|
|
59
|
+
`"compaction": {"keepRecentTokens": N}` in Pi's `settings.json` (or `compaction.modelOverrides["provider/model"].keepRecentTokens` for one
|
|
60
|
+
model), with N well below the room. Windows of 128k and up are not affected at the defaults. 1.0.10 is planned to fix this: pi-tuck will
|
|
61
|
+
make prompt-time cuts itself. Evidence: in one check at these defaults (1.0.8, 32k window, one session of 30 tasks, one model), 28 tasks
|
|
62
|
+
were lost ([`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md)); it shows that the failure occurs, not how often.
|
|
63
|
+
|
|
64
|
+
## [1.0.8] - 2026-11-01
|
|
21
65
|
|
|
22
66
|
### Fixed
|
|
23
67
|
|
package/README.md
CHANGED
|
@@ -57,8 +57,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
|
|
|
57
57
|
|
|
58
58
|
- Needs Pi 1.2.0 or later (tested on 1.2.0 and 1.4.0), on macOS or Linux. On an older Pi, pi-tuck stays off, Pi's own compaction runs, and pi-tuck says so once. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
|
|
59
59
|
- On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once). Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
|
|
60
|
-
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.
|
|
61
|
-
- To pin a version, add it: `pi install npm:pi-tuck@1.0.
|
|
60
|
+
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.10`.
|
|
61
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.10`.
|
|
62
62
|
|
|
63
63
|
## How it works
|
|
64
64
|
|
|
@@ -105,6 +105,7 @@ Known limits today:
|
|
|
105
105
|
|
|
106
106
|
- pi-tuck's summary does not explain why things happened. It holds the words that were said, not a model's reading of them. The agent must look things up. Each look-up costs a tool call.
|
|
107
107
|
- Small windows: an upstream that spends far more context on tool schemas than others (some OpenRouter upstreams do) can leave a small input room too little space, and pi-tuck then refuses to cut rather than overflow. This is what made 1.0.1 refuse at a 32k window with Pi's default 16,384 reserve. Since 1.0.4 the notice says when the provider is counting far above pi-tuck's estimate. In 1.0.4, after an oversize tool result or a "Nothing to compact" error from Pi, a session could keep refusing later prompts. 1.0.5 fixes both. Since 1.0.8, a fresh tool result too large for the room is clipped (its head and tail, with a `tuck_recall` locator to the rest) instead of refusing the run. Pin a different upstream, lower the reserve, or use a larger window; see [Small windows](#small-windows).
|
|
108
|
+
- Small window, after a stopped run (1.0.8 and 1.0.9): Pi's default `compaction.keepRecentTokens` (20,000) can exceed pi-tuck's input room, which is the window less the larger of `compaction.reserveTokens` and the model's maximum output. At a 32k window with the default 16,384 reserve the room is 16,384. After a run that is stopped (Esc, or a time limit) with the context above the room, every later prompt can be refused with "The request needs a cut and Pi has none to make (Nothing to compact (session too small))", and the session stays stuck. In one check at these defaults (pi-tuck 1.0.8, 32k window, one session of 30 tasks, one local model), 28 tasks were lost, tasks 3 to 30; it shows that the failure occurs, not how often ([`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md)). Workaround until 1.0.10: set `"compaction": { "keepRecentTokens": N }` in Pi's `settings.json`, with N well below the room; the 32k benchmark used 3,333 (see [Small windows](#small-windows)). Windows of 128k and up are not affected at the defaults, since the room there is far above 20,000 tokens. 1.0.10 is planned to fix this: pi-tuck will make prompt-time cuts itself.
|
|
108
109
|
- Semantic search (`tuck_search`) needs a Mac with Metal (Apple silicon). Elsewhere, compaction and `tuck_recall`'s word search work, there is no `tuck_search`, and nothing is downloaded.
|
|
109
110
|
|
|
110
111
|
## Benchmarks
|
|
@@ -113,8 +114,9 @@ Each row says what was measured, under which conditions, and where the data is.
|
|
|
113
114
|
|
|
114
115
|
| What | Result | Conditions | Source |
|
|
115
116
|
|---|---|---|---|
|
|
116
|
-
|
|
|
117
|
-
|
|
|
117
|
+
| Pi's default compaction settings, 32k window | 28 of 30 tasks lost (tasks 3 to 30, each refused at its prompt); 1 resolved. Fails the pre-registered bar of 0 lost tasks | pi-tuck 1.0.8 (`86935e9`), `mac-studio/main`, window 32,768, output cap 16,384, `reserveTokens` 16,384 and `keepRecentTokens` 20,000 (larger than the room), one session of chain t06. One session, one model, one build: it shows that the failure occurs at these settings, not how often. Not comparable with other rows | [`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md) |
|
|
118
|
+
| Tasks resolved, 32k window | Pi's built-in compaction 94 of 150; pi-tuck 120 of 150 (+17.3 points, 95% lower bound +10.7); observation masking 117 of 150 (pi-tuck +2.0, lower bound −2.7: no difference shown). Turns per task: 9.4 (built-in), 24.0 (pi-tuck), 11.9 (masking). Tokens per session: 6.10 M, 6.39 M, 7.02 M. Spend per arm for the five sessions: $0.87, $0.55, $0.94 | pi-tuck 1.0.5 (commit `aa30824`, the 1.0.5 code; 1.0.6 and 1.0.7 are not measured). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, 5 chains of 30 SWE-bench Verified tasks, paired by task, one-sided bootstrap over tasks. **Limits:** only pi-tuck is held to the 16,384-token room (the built-in sent a request over it in 97 of 150 tasks, masking in 73, so the window is nominal for them). The difference by chain is +10.0, −3.3, +50.0, +16.7 and +13.3 points; leaving out chain t03 it is +9.2. The bootstrap resamples tasks, not chains. The pi-tuck arm ran at a different time from the others. One model, one window. pi-tuck refused to cut mid-run in 5 tasks, where one fresh tool result was larger than the room; they count as unresolved. In the same setup pi-tuck 1.0.3 resolved 72 of 150, with 52 dead tasks (tasks refused at their prompt after an earlier failure). In the pi-tuck arm of this row there was 1 dead task (t03 task 24: refused at its prompt with "Nothing to compact"; the next task ran normally), so this build missed the pre-registered acceptance bar of zero dead tasks by one task (the other part of the bar, no loss against 1.0.3 by more than 3 points, was met) | [`benchmark/RESULTS-32K-ACCEPT.md`](benchmark/RESULTS-32K-ACCEPT.md) |
|
|
119
|
+
| The 1.0.8 candidate against 1.0.7, 32k window | 0 dead tasks in both arms. Resolved 46 of 60 in both (+0.0 points, 95% lower bound −6.7). No mid-run refusal in either arm. Turns per task 25.37 (1.0.7) and 26.10 (1.0.8) | Pre-registered gate, passed as registered. pi-tuck `a2e7d1a` (the tool-result clip and the cut at the end of a run, on 1.0.6) against 1.0.7. Released 1.0.8 is that build merged with 1.0.7's Metal-only recall; its replay requests are reported byte-identical to the gated build on 29 recordings (stated in the source file, not re-run for this README). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, chains t01 and t02, 60 paired tasks, both arms run together. One model, one window, one run per arm; 60 tasks cannot show a difference smaller than the bound. The run shows that the change introduced no refusal; no refusal occurred in the 1.0.7 arm for the clip to prevent | [`benchmark/RESULTS-V108.md`](benchmark/RESULTS-V108.md) |
|
|
118
120
|
| Cut time, live | pi-tuck median 0.087 s (90th percentile 0.205 s); Pi's built-in median 268.8 s (90th percentile 421.6 s). Context carried after a cut, median: 27,751 against 29,241 tokens | **Interim.** pi-tuck 1.0.1, Pi 1.4.0, SWE-bench Verified chains of 30 tasks, 131,072-token window, reply reserve 32,768, local model `mac-studio/main` writing Pi's summaries. 27 cuts per arm, 5 chains. The built-in time depends on that model and on server load. pi-tuck's time is an upper bound read from the session file | [`benchmark/RESULTS-INTERIM.md`](benchmark/RESULTS-INTERIM.md) section 1 |
|
|
119
121
|
| Cut time, paired replay | pi-tuck median 0.062 s (range 0.005 to 0.48 s); local-model summarizer 193.4 s (77.3 to 413.3 s); frontier summarizer 96.1 s (39.1 to 267.8 s; measured on the retired model `openai/gpt-6-astra`; feeds no test). Context carried after a cut, median: 28,006 (pi-tuck), 26,424 (local), 25,555 (frontier) tokens | **Interim.** The same recorded sessions cut at the same positions. pi-tuck: 10 recordings, 52 cuts. Summarizers: 6 recordings, 28 and 29 cuts. pi-tuck's time leaves out the background search index | [`benchmark/RESULTS-INTERIM.md`](benchmark/RESULTS-INTERIM.md) section 2 |
|
|
120
122
|
| Billed input, cutting at 60% of the room instead of 100% | −17.9% at 128k, −14.0% at 32k with automatic prompt caching; −17.0% and −9.6% with explicit cache breakpoints. Cuts go from 6 to 12 (128k) and from 84 to 157 (32k); none frees under a tenth of the room | Simulated billing, not provider bills. Prompt caching priced. Build `1bda64b` (the default shipped in 1.0.3). 5 recorded live sessions. 128k = 131,072 window, reserve 32,768. 32k = 32,768 window, reserve 16,384 | [`rnd/replay32/RESULT-lean.md`](rnd/replay32/RESULT-lean.md) |
|
|
@@ -154,6 +156,14 @@ pi-tuck keeps room for the model's reply: the larger of the model's output limit
|
|
|
154
156
|
|
|
155
157
|
The reserve is the larger of `reserveTokens` and the model's `maxTokens`, so lowering `reserveTokens` alone does nothing when `models.json` sets a larger `maxTokens` for that model. Lower both, and keep `maxTokens` above your largest expected reply.
|
|
156
158
|
|
|
159
|
+
If a session gets stuck after a stopped run (see Known limits), set `keepRecentTokens` well below the room, in `~/.pi/agent/settings.json` for all models, or for one model:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{ "compaction": { "modelOverrides": { "my-provider/my-model": { "keepRecentTokens": 3333 } } } }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
3,333 is the value the 32k benchmark used: the main run's 20,000 of a 98,304 room, scaled to a 16,384 room (`benchmark/PREREG-32K.md`).
|
|
166
|
+
|
|
157
167
|
If you reach the model through OpenRouter, you can also pin one upstream, so that every request is served by the same one. Put this in `models.json` in Pi's agent folder, and name the upstream you want:
|
|
158
168
|
|
|
159
169
|
```json
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "3c4da6131a995651dda4f51db08a27e509958ef5a18dcfc14a264b07c04663aa";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "3c4da6131a995651dda4f51db08a27e509958ef5a18dcfc14a264b07c04663aa";
|
package/dist/src/compaction.js
CHANGED
|
@@ -270,7 +270,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
270
270
|
learn(ratio);
|
|
271
271
|
});
|
|
272
272
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
273
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); });
|
|
273
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
274
274
|
const active = () => options.active?.() ?? true;
|
|
275
275
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
276
276
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -312,8 +312,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
312
312
|
const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
313
313
|
// A missing model is configuration, not capacity.
|
|
314
314
|
const missingModel = () => new Error("No context model for the request budget");
|
|
315
|
-
|
|
316
|
-
let pendingCut;
|
|
315
|
+
let promptCut;
|
|
317
316
|
let failure;
|
|
318
317
|
let pendingBoundaryCommit = false;
|
|
319
318
|
// Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
|
|
@@ -327,6 +326,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
327
326
|
failure = error instanceof Error ? error : new Error(String(error));
|
|
328
327
|
pendingBoundaryCommit = false;
|
|
329
328
|
pending = undefined;
|
|
329
|
+
promptCut = undefined;
|
|
330
330
|
try {
|
|
331
331
|
options.fail?.(error);
|
|
332
332
|
}
|
|
@@ -429,9 +429,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
429
429
|
recorder.stop("cut-cancelled");
|
|
430
430
|
return { cancel: true };
|
|
431
431
|
}
|
|
432
|
-
// Pi
|
|
433
|
-
|
|
434
|
-
|
|
432
|
+
// Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
|
|
433
|
+
// is the answer, and it is saved before the first request.
|
|
434
|
+
if (promptCut && !admittedRun && event.reason === "threshold") {
|
|
435
|
+
access();
|
|
436
|
+
const { type: _, ...compaction } = promptCut;
|
|
437
|
+
promptCut = undefined;
|
|
438
|
+
return { compaction };
|
|
439
|
+
}
|
|
440
|
+
const operatorManual = event.reason === "manual";
|
|
435
441
|
if (operatorManual && !admittedRun)
|
|
436
442
|
forgetFailure();
|
|
437
443
|
access();
|
|
@@ -442,19 +448,18 @@ export async function installCompaction(pi, options = {}) {
|
|
|
442
448
|
}
|
|
443
449
|
const { response, room, band } = limits(ctx);
|
|
444
450
|
const retain = operatorManual || admittedRun;
|
|
445
|
-
//
|
|
446
|
-
//
|
|
451
|
+
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
452
|
+
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
447
453
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
448
|
-
const host =
|
|
449
|
-
const mandatory =
|
|
450
|
-
|
|
451
|
-
const before = incoming ? incoming.before : plannerMeasureFor(ctx)(saved);
|
|
454
|
+
const host = hostTokens(ctx);
|
|
455
|
+
const mandatory = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
|
|
456
|
+
const before = plannerMeasureFor(ctx)(saved);
|
|
452
457
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
453
458
|
// it: a basis this compactor does not plan on. It may ask for a cut on a request that fits. This compactor cuts only where its own
|
|
454
459
|
// trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
|
|
455
460
|
// cancel (Pi reports it as aborted), never a failure.
|
|
456
461
|
let thresholdOpportunistic = false;
|
|
457
|
-
if (
|
|
462
|
+
if (event.reason === "threshold") {
|
|
458
463
|
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
459
464
|
if (gate.kind !== "plan")
|
|
460
465
|
return { cancel: true };
|
|
@@ -463,21 +468,18 @@ export async function installCompaction(pi, options = {}) {
|
|
|
463
468
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
464
469
|
const decision = chooseCut({
|
|
465
470
|
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
466
|
-
opportunistic:
|
|
467
|
-
unreduced:
|
|
468
|
-
: "Compaction did not reduce context within the estimated request budget",
|
|
471
|
+
opportunistic: thresholdOpportunistic,
|
|
472
|
+
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
469
473
|
plan: (target) => {
|
|
470
474
|
const reserve = planReserve(target, room, mandatory, ctx);
|
|
471
|
-
// Every cut asked while no run is admitted (
|
|
472
|
-
//
|
|
473
|
-
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual,
|
|
475
|
+
// Every cut asked while no run is admitted (an operator's cut) is planned for a run that has not begun: the session has moved on
|
|
476
|
+
// from any run that ended. A retry is inside its run.
|
|
477
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
474
478
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
475
479
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
476
480
|
},
|
|
477
481
|
});
|
|
478
482
|
if (decision.kind === "cut") {
|
|
479
|
-
if (incoming)
|
|
480
|
-
incoming.proposed = true;
|
|
481
483
|
return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore, retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) };
|
|
482
484
|
}
|
|
483
485
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
@@ -499,12 +501,23 @@ export async function installCompaction(pi, options = {}) {
|
|
|
499
501
|
}
|
|
500
502
|
});
|
|
501
503
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
502
|
-
pi.on("session_start", () => { clips.clear(); recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
503
|
-
// The
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
504
|
+
pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
505
|
+
// The session as it will be once a cut that is not saved yet is saved, and its conversation as the next request carries it.
|
|
506
|
+
const cutSession = (ctx, cut) => {
|
|
507
|
+
const header = ctx.sessionManager.getHeader();
|
|
508
|
+
if (!header)
|
|
509
|
+
throw new Error("Missing native session header for the prompt's cut");
|
|
510
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
511
|
+
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
512
|
+
return preview;
|
|
513
|
+
};
|
|
514
|
+
const withCut = (ctx, cut) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
515
|
+
// The cut before a run, decided on `input` while the session is idle; before_agent_start decides only for a run no input started (one a
|
|
516
|
+
// custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
|
|
517
|
+
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
518
|
+
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
507
519
|
const preflight = async (ctx, incomingPart, observedPrompt, tools) => {
|
|
520
|
+
promptCut = undefined;
|
|
508
521
|
if (!active())
|
|
509
522
|
return;
|
|
510
523
|
access();
|
|
@@ -512,55 +525,40 @@ export async function installCompaction(pi, options = {}) {
|
|
|
512
525
|
return;
|
|
513
526
|
if (!ctx.model)
|
|
514
527
|
throw missingModel();
|
|
515
|
-
const { room, band } = limits(ctx);
|
|
516
|
-
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const before = measureFor(ctx)(saved);
|
|
523
|
-
return { incoming: incomingPart + system, host, before, mandatory: incomingPart + system + host };
|
|
524
|
-
};
|
|
525
|
-
const first = snapshot(), total = first.before + first.mandatory;
|
|
526
|
-
if (!fits(first.incoming, room, ctx))
|
|
528
|
+
const { response, room, band } = limits(ctx);
|
|
529
|
+
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
|
|
530
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
531
|
+
const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
|
|
532
|
+
const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
533
|
+
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
534
|
+
if (!fits(incoming, room, ctx))
|
|
527
535
|
throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
|
|
528
536
|
// A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
|
|
529
537
|
// help: leave it to the complete-context guard, which fails closed.
|
|
530
|
-
const gate = gateCut({ total
|
|
538
|
+
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
531
539
|
if (gate.kind !== "plan")
|
|
532
540
|
return;
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
} }));
|
|
555
|
-
}
|
|
556
|
-
finally {
|
|
557
|
-
pendingCut = undefined;
|
|
558
|
-
}
|
|
559
|
-
access();
|
|
560
|
-
// The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
|
|
561
|
-
const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
|
|
562
|
-
if (!cut.opportunistic && (after >= total || !afterFits))
|
|
563
|
-
throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
|
|
541
|
+
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
542
|
+
const decision = chooseCut({
|
|
543
|
+
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
|
|
544
|
+
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
545
|
+
plan: (target) => {
|
|
546
|
+
const reserve = planReserve(target, room, mandatory, ctx);
|
|
547
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
548
|
+
if (!draft.compaction)
|
|
549
|
+
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
550
|
+
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
551
|
+
assertNativeToolRounds(messages);
|
|
552
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
556
|
+
if (decision.kind === "none")
|
|
557
|
+
return;
|
|
558
|
+
if (decision.kind === "fault")
|
|
559
|
+
throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
560
|
+
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
|
|
561
|
+
promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
|
|
564
562
|
};
|
|
565
563
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
566
564
|
const prompted = phase === "decided";
|
|
@@ -646,12 +644,28 @@ export async function installCompaction(pi, options = {}) {
|
|
|
646
644
|
return { entries: [{ type: "compaction", summary: compaction.summary,
|
|
647
645
|
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
648
646
|
};
|
|
647
|
+
const atBoundary = (event, ctx, reason, decide) => {
|
|
648
|
+
if (!promptCut)
|
|
649
|
+
return decide ? boundaryCut(event, ctx, reason) : undefined;
|
|
650
|
+
const entry = promptCut;
|
|
651
|
+
promptCut = undefined;
|
|
652
|
+
const preview = cutSession(ctx, entry), view = Object.create(ctx, { sessionManager: { value: preview } });
|
|
653
|
+
const more = decide && !event.entries.length
|
|
654
|
+
? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, view, reason) : undefined;
|
|
655
|
+
const { tokensBefore: _, ...saved } = entry;
|
|
656
|
+
// As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
|
|
657
|
+
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
658
|
+
return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
|
|
659
|
+
};
|
|
649
660
|
pi.on("turn_end", (event, ctx) => {
|
|
650
661
|
try {
|
|
651
|
-
if (!active() || !admittedRun
|
|
652
|
-
|
|
662
|
+
if (!active() || !admittedRun)
|
|
663
|
+
return;
|
|
664
|
+
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
665
|
+
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
666
|
+
if (!promptCut && !decide)
|
|
653
667
|
return;
|
|
654
|
-
const result =
|
|
668
|
+
const result = atBoundary(event, ctx, "tool-loop", decide);
|
|
655
669
|
if (result)
|
|
656
670
|
pendingBoundaryCommit = true;
|
|
657
671
|
return result;
|
|
@@ -660,12 +674,23 @@ export async function installCompaction(pi, options = {}) {
|
|
|
660
674
|
fail(error, ctx);
|
|
661
675
|
}
|
|
662
676
|
});
|
|
677
|
+
// A request of a run whose prompt's cut is not saved yet: the saved conversation in it, which other handlers may surround, is replaced by
|
|
678
|
+
// the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
|
|
679
|
+
// here. A request that does not carry the saved conversation as it stands cannot take the cut.
|
|
680
|
+
const cutRequest = (messages, ctx, cut) => {
|
|
681
|
+
const conversation = (all) => all.filter(message => message.role !== "system");
|
|
682
|
+
const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
|
|
683
|
+
for (let at = 0; at + saved.length <= messages.length; at++)
|
|
684
|
+
if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
|
|
685
|
+
return [...messages.slice(0, at), ...conversation(withCut(ctx, cut)), ...messages.slice(at + saved.length)];
|
|
686
|
+
throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
|
|
687
|
+
};
|
|
663
688
|
pi.on("context", (event, ctx) => {
|
|
664
689
|
try {
|
|
665
690
|
if (!active())
|
|
666
691
|
return;
|
|
667
692
|
access();
|
|
668
|
-
return { messages: applyClips(projectedMessages(event.messages, ctx, options.recallPolicy), clips) };
|
|
693
|
+
return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
|
|
669
694
|
}
|
|
670
695
|
catch (error) {
|
|
671
696
|
try {
|
|
@@ -677,10 +702,6 @@ export async function installCompaction(pi, options = {}) {
|
|
|
677
702
|
}
|
|
678
703
|
});
|
|
679
704
|
pi.on("session_compact_failed", (event, ctx) => {
|
|
680
|
-
// The end of an opportunistic cut that proposed nothing, however it came about, is no event for the recorder or for failure handling;
|
|
681
|
-
// a latched failure still is, and so is the failure of a cut the compactor proposed.
|
|
682
|
-
if (pendingCut?.opportunistic && !failure && !pendingCut.proposed)
|
|
683
|
-
return;
|
|
684
705
|
// This compactor's own refusal of a Pi threshold ask (the gate, or an opportunistic cut that cannot reach the band) reaches here as a
|
|
685
706
|
// threshold compaction cancelled and not from an extension: no event for the recorder.
|
|
686
707
|
if (event.reason === "threshold" && event.aborted && !event.fromExtension)
|
|
@@ -707,10 +728,12 @@ export async function installCompaction(pi, options = {}) {
|
|
|
707
728
|
}
|
|
708
729
|
// Pi commits these entries before the run settles.
|
|
709
730
|
try {
|
|
710
|
-
if (!active() || !admittedRun ||
|
|
711
|
-
|
|
731
|
+
if (!active() || !admittedRun || failure || !settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
732
|
+
return;
|
|
733
|
+
const decide = event.outcome === "completed" && !ctx.signal?.aborted;
|
|
734
|
+
if (promptCut ? event.outcome === "error" : !decide)
|
|
712
735
|
return;
|
|
713
|
-
return
|
|
736
|
+
return atBoundary(event, ctx, "run-end", decide);
|
|
714
737
|
}
|
|
715
738
|
catch (error) {
|
|
716
739
|
fail(error, ctx);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type RecallPolicy } from './recall.js';
|
|
2
2
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
export declare const DIAGNOSTIC_BUILD = "
|
|
3
|
+
export declare const DIAGNOSTIC_BUILD = "3c4da6131a995651dda4f51db08a27e509958ef5a18dcfc14a264b07c04663aa";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createRecall } from './recall.js';
|
|
|
8
8
|
export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
9
9
|
export function attachReceipt(draft, reason, reserveTokens, contextWindow, resolved = {}) {
|
|
10
10
|
const details = draft.details && typeof draft.details === 'object' ? draft.details : {};
|
|
11
|
-
const receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
11
|
+
const receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' || reason === 'prompt' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
12
12
|
return { ...draft, details: { ...details, diagnosticReceipt: receipt } };
|
|
13
13
|
}
|
|
14
14
|
export function receiptId(entry) {
|
package/package.json
CHANGED
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "3c4da6131a995651dda4f51db08a27e509958ef5a18dcfc14a264b07c04663aa";
|
package/src/compaction.ts
CHANGED
|
@@ -241,7 +241,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
241
241
|
if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
242
242
|
});
|
|
243
243
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
244
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); });
|
|
244
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
245
245
|
const active = () => options.active?.() ?? true;
|
|
246
246
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
247
247
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -284,8 +284,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
284
284
|
const isNoWork = (message: string | undefined) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
285
285
|
// A missing model is configuration, not capacity.
|
|
286
286
|
const missingModel = () => new Error("No context model for the request budget");
|
|
287
|
-
//
|
|
288
|
-
|
|
287
|
+
// A prompt's cut, until its run saves it at its first boundary: every request of that run carries it (the context hook). A run that ends
|
|
288
|
+
// before a boundary drops it, and the next prompt decides afresh.
|
|
289
|
+
type CutEntry = { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown };
|
|
290
|
+
let promptCut: CutEntry | undefined;
|
|
289
291
|
let failure: Error | undefined;
|
|
290
292
|
let pendingBoundaryCommit = false;
|
|
291
293
|
// Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
|
|
@@ -298,6 +300,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
298
300
|
if (!(failure instanceof CommitFault)) failure = error instanceof Error ? error : new Error(String(error));
|
|
299
301
|
pendingBoundaryCommit = false;
|
|
300
302
|
pending = undefined;
|
|
303
|
+
promptCut = undefined;
|
|
301
304
|
try { options.fail?.(error); } finally {
|
|
302
305
|
try { if (notify) ctx.ui.notify(refusalNotice(failure.message, factor), "error"); }
|
|
303
306
|
finally { ctx.abort(); }
|
|
@@ -370,9 +373,15 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
370
373
|
try {
|
|
371
374
|
if (!active()) return { cancel: true };
|
|
372
375
|
if (event.signal.aborted || ctx.signal?.aborted) { recorder.stop("cut-cancelled"); return { cancel: true }; }
|
|
373
|
-
// Pi
|
|
374
|
-
|
|
375
|
-
|
|
376
|
+
// Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
|
|
377
|
+
// is the answer, and it is saved before the first request.
|
|
378
|
+
if (promptCut && !admittedRun && event.reason === "threshold") {
|
|
379
|
+
access();
|
|
380
|
+
const { type: _, ...compaction } = promptCut;
|
|
381
|
+
promptCut = undefined;
|
|
382
|
+
return { compaction };
|
|
383
|
+
}
|
|
384
|
+
const operatorManual = event.reason === "manual";
|
|
376
385
|
if (operatorManual && !admittedRun) forgetFailure();
|
|
377
386
|
access();
|
|
378
387
|
if (event.customInstructions?.trim()) {
|
|
@@ -382,19 +391,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
382
391
|
}
|
|
383
392
|
const { response, room, band } = limits(ctx);
|
|
384
393
|
const retain = operatorManual || admittedRun;
|
|
385
|
-
//
|
|
386
|
-
//
|
|
394
|
+
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
395
|
+
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
387
396
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
388
|
-
const host =
|
|
389
|
-
const mandatory =
|
|
390
|
-
|
|
391
|
-
const before = incoming ? incoming.before : plannerMeasureFor(ctx)(saved);
|
|
397
|
+
const host = hostTokens(ctx);
|
|
398
|
+
const mandatory = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
|
|
399
|
+
const before = plannerMeasureFor(ctx)(saved);
|
|
392
400
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
393
401
|
// it: a basis this compactor does not plan on. It may ask for a cut on a request that fits. This compactor cuts only where its own
|
|
394
402
|
// trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
|
|
395
403
|
// cancel (Pi reports it as aborted), never a failure.
|
|
396
404
|
let thresholdOpportunistic = false;
|
|
397
|
-
if (
|
|
405
|
+
if (event.reason === "threshold") {
|
|
398
406
|
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
399
407
|
if (gate.kind !== "plan") return { cancel: true };
|
|
400
408
|
thresholdOpportunistic = gate.opportunistic;
|
|
@@ -402,19 +410,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
402
410
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
403
411
|
const decision = chooseCut({
|
|
404
412
|
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
405
|
-
opportunistic:
|
|
406
|
-
unreduced:
|
|
407
|
-
: "Compaction did not reduce context within the estimated request budget",
|
|
413
|
+
opportunistic: thresholdOpportunistic,
|
|
414
|
+
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
408
415
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
|
|
409
416
|
const reserve = planReserve(target, room, mandatory, ctx);
|
|
410
|
-
// Every cut asked while no run is admitted (
|
|
411
|
-
//
|
|
412
|
-
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual,
|
|
417
|
+
// Every cut asked while no run is admitted (an operator's cut) is planned for a run that has not begun: the session has moved on
|
|
418
|
+
// from any run that ended. A retry is inside its run.
|
|
419
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
413
420
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
414
421
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
415
422
|
},
|
|
416
423
|
});
|
|
417
|
-
if (decision.kind === "cut") {
|
|
424
|
+
if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
|
|
418
425
|
retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
|
|
419
426
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
420
427
|
if (decision.kind === "none") return { cancel: true };
|
|
@@ -427,57 +434,57 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
427
434
|
}
|
|
428
435
|
});
|
|
429
436
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
430
|
-
pi.on("session_start", () => { clips.clear(); recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
431
|
-
// The
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
437
|
+
pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
438
|
+
// The session as it will be once a cut that is not saved yet is saved, and its conversation as the next request carries it.
|
|
439
|
+
const cutSession = (ctx: ExtensionContext, cut: CutEntry) => {
|
|
440
|
+
const header = ctx.sessionManager.getHeader();
|
|
441
|
+
if (!header) throw new Error("Missing native session header for the prompt's cut");
|
|
442
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
443
|
+
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
444
|
+
return preview;
|
|
445
|
+
};
|
|
446
|
+
const withCut = (ctx: ExtensionContext, cut: CutEntry) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
447
|
+
// The cut before a run, decided on `input` while the session is idle; before_agent_start decides only for a run no input started (one a
|
|
448
|
+
// custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
|
|
449
|
+
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
450
|
+
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
435
451
|
const preflight = async (ctx: ExtensionContext, incomingPart: number, observedPrompt: string, tools: readonly string[]) => {
|
|
452
|
+
promptCut = undefined;
|
|
436
453
|
if (!active()) return;
|
|
437
454
|
access();
|
|
438
455
|
if (!settingsFor().getCompactionSettings(ctx.model).enabled) return;
|
|
439
456
|
if (!ctx.model) throw missingModel();
|
|
440
|
-
const { room, band } = limits(ctx);
|
|
441
|
-
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const before = measureFor(ctx)(saved);
|
|
448
|
-
return { incoming: incomingPart + system, host, before, mandatory: incomingPart + system + host };
|
|
449
|
-
};
|
|
450
|
-
const first = snapshot(), total = first.before + first.mandatory;
|
|
451
|
-
if (!fits(first.incoming, room, ctx))
|
|
457
|
+
const { response, room, band } = limits(ctx);
|
|
458
|
+
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
|
|
459
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
460
|
+
const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
|
|
461
|
+
const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
462
|
+
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
463
|
+
if (!fits(incoming, room, ctx))
|
|
452
464
|
throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
|
|
453
465
|
// A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
|
|
454
466
|
// help: leave it to the complete-context guard, which fails closed.
|
|
455
|
-
const gate = gateCut({ total
|
|
467
|
+
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
456
468
|
if (gate.kind !== "plan") return;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
access();
|
|
477
|
-
// The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
|
|
478
|
-
const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
|
|
479
|
-
if (!cut.opportunistic && (after >= total || !afterFits))
|
|
480
|
-
throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
|
|
469
|
+
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
470
|
+
const decision = chooseCut({
|
|
471
|
+
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
|
|
472
|
+
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
473
|
+
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
474
|
+
const reserve = planReserve(target, room, mandatory, ctx);
|
|
475
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
476
|
+
if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
477
|
+
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
478
|
+
assertNativeToolRounds(messages);
|
|
479
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
483
|
+
if (decision.kind === "none") return;
|
|
484
|
+
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
485
|
+
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary,
|
|
486
|
+
{ response, host, mandatory, target: decision.target, margin: room - band });
|
|
487
|
+
promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
|
|
481
488
|
};
|
|
482
489
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
483
490
|
const prompted = phase === "decided";
|
|
@@ -549,26 +556,51 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
549
556
|
return { entries: [{ type: "compaction" as const, summary: compaction.summary,
|
|
550
557
|
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
551
558
|
};
|
|
559
|
+
// A boundary's cut. The run's first boundary also saves the prompt's cut, after any entries an earlier handler proposed, and decides on
|
|
560
|
+
// the session as it is with that cut: a fresh result can still need a cut of its own there.
|
|
561
|
+
type Boundary = Parameters<typeof boundaryCut>[0];
|
|
562
|
+
const atBoundary = (event: Boundary, ctx: ExtensionContext, reason: "tool-loop" | "run-end", decide: boolean) => {
|
|
563
|
+
if (!promptCut) return decide ? boundaryCut(event, ctx, reason) : undefined;
|
|
564
|
+
const entry = promptCut;
|
|
565
|
+
promptCut = undefined;
|
|
566
|
+
const preview = cutSession(ctx, entry), view: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
|
|
567
|
+
const more = decide && !event.entries.length
|
|
568
|
+
? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, view, reason) : undefined;
|
|
569
|
+
const { tokensBefore: _, ...saved } = entry;
|
|
570
|
+
// As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
|
|
571
|
+
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
572
|
+
return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
|
|
573
|
+
};
|
|
552
574
|
pi.on("turn_end", (event, ctx) => {
|
|
553
575
|
try {
|
|
554
|
-
if (!active() || !admittedRun
|
|
555
|
-
|
|
556
|
-
|
|
576
|
+
if (!active() || !admittedRun) return;
|
|
577
|
+
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
578
|
+
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
579
|
+
if (!promptCut && !decide) return;
|
|
580
|
+
const result = atBoundary(event, ctx, "tool-loop", decide);
|
|
557
581
|
if (result) pendingBoundaryCommit = true;
|
|
558
582
|
return result;
|
|
559
583
|
} catch (error) { fail(error, ctx); }
|
|
560
584
|
});
|
|
585
|
+
// A request of a run whose prompt's cut is not saved yet: the saved conversation in it, which other handlers may surround, is replaced by
|
|
586
|
+
// the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
|
|
587
|
+
// here. A request that does not carry the saved conversation as it stands cannot take the cut.
|
|
588
|
+
const cutRequest = <M,>(messages: M[], ctx: ExtensionContext, cut: CutEntry): M[] => {
|
|
589
|
+
const conversation = (all: Messages) => all.filter(message => message.role !== "system") as unknown[];
|
|
590
|
+
const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
|
|
591
|
+
for (let at = 0; at + saved.length <= messages.length; at++)
|
|
592
|
+
if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
|
|
593
|
+
return [...messages.slice(0, at), ...conversation(withCut(ctx, cut)) as M[], ...messages.slice(at + saved.length)];
|
|
594
|
+
throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
|
|
595
|
+
};
|
|
561
596
|
pi.on("context", (event, ctx) => {
|
|
562
597
|
try {
|
|
563
598
|
if (!active()) return;
|
|
564
599
|
access();
|
|
565
|
-
return { messages: applyClips(projectedMessages(event.messages, ctx, options.recallPolicy), clips) };
|
|
600
|
+
return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
|
|
566
601
|
} catch (error) { try { fail(error, ctx); } finally { return { messages: [] }; } }
|
|
567
602
|
});
|
|
568
603
|
pi.on("session_compact_failed", (event, ctx) => {
|
|
569
|
-
// The end of an opportunistic cut that proposed nothing, however it came about, is no event for the recorder or for failure handling;
|
|
570
|
-
// a latched failure still is, and so is the failure of a cut the compactor proposed.
|
|
571
|
-
if (pendingCut?.opportunistic && !failure && !pendingCut.proposed) return;
|
|
572
604
|
// This compactor's own refusal of a Pi threshold ask (the gate, or an opportunistic cut that cannot reach the band) reaches here as a
|
|
573
605
|
// threshold compaction cancelled and not from an extension: no event for the recorder.
|
|
574
606
|
if (event.reason === "threshold" && event.aborted && !event.fromExtension) return;
|
|
@@ -589,9 +621,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
589
621
|
}
|
|
590
622
|
// Pi commits these entries before the run settles.
|
|
591
623
|
try {
|
|
592
|
-
if (!active() || !admittedRun ||
|
|
593
|
-
|
|
594
|
-
|
|
624
|
+
if (!active() || !admittedRun || failure || !settingsFor().getCompactionSettings(ctx.model).enabled) return;
|
|
625
|
+
const decide = event.outcome === "completed" && !ctx.signal?.aborted;
|
|
626
|
+
if (promptCut ? event.outcome === "error" : !decide) return;
|
|
627
|
+
return atBoundary(event, ctx, "run-end", decide);
|
|
595
628
|
} catch (error) { fail(error, ctx); }
|
|
596
629
|
});
|
|
597
630
|
pi.on("context_with_system", (event, ctx) => {
|
package/src/diagnostics.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
|
10
10
|
export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
|
|
11
11
|
export function attachReceipt<T extends { details?: unknown; firstKeptEntryId: string; tokensBefore: number }>(draft: T, reason: string, reserveTokens: number, contextWindow: number, resolved: Partial<Receipt['settings']> = {}): T & { details: Record<string, unknown> & { diagnosticReceipt: Receipt } } {
|
|
12
12
|
const details = draft.details && typeof draft.details === 'object' ? draft.details as Record<string, unknown> : {};
|
|
13
|
-
const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
13
|
+
const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' || reason === 'prompt' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
14
14
|
return { ...draft, details: { ...details, diagnosticReceipt: receipt } };
|
|
15
15
|
}
|
|
16
16
|
export function receiptId(entry: { details?: unknown }): string | undefined {
|