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 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.9`.
61
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.9`.
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
- | 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 tasks lost because an earlier refusal carried forward; 1.0.5 fixes that | [`benchmark/RESULTS-32K-ACCEPT.md`](benchmark/RESULTS-32K-ACCEPT.md) |
117
- | 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 adds 1.0.7's Metal-only recall to that build. 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) |
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
@@ -1 +1 @@
1
- export declare const BUILD_ID = "c4e6e6034a54ff7a893ee62484e54cf471964be3435aa587af8a288dcbf41314";
1
+ export declare const BUILD_ID = "3c4da6131a995651dda4f51db08a27e509958ef5a18dcfc14a264b07c04663aa";
@@ -1 +1 @@
1
- export const BUILD_ID = "c4e6e6034a54ff7a893ee62484e54cf471964be3435aa587af8a288dcbf41314";
1
+ export const BUILD_ID = "3c4da6131a995651dda4f51db08a27e509958ef5a18dcfc14a264b07c04663aa";
@@ -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
- // Scoped to one awaited initial-prompt cut; never a persisted controller. It carries that decision's snapshot.
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 also labels the awaited idle incoming preflight "manual".
433
- const incoming = pendingCut;
434
- const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
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
- // The incoming-prompt preflight brings its own snapshot. Any other cut (operator manual, Pi's threshold or overflow
446
- // recovery) charges what the request must carry whatever is cut: the system part and the host input.
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 = incoming ? incoming.host : hostTokens(ctx);
449
- const mandatory = incoming ? incoming.mandatory
450
- : systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
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 (!incoming && event.reason === "threshold") {
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: incoming?.opportunistic ?? thresholdOpportunistic,
467
- unreduced: incoming ? "Incoming Pi preflight compaction did not reduce context within the estimated request budget"
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 (a prompt's preflight, an operator's cut, Pi's own cut before a run a custom message
472
- // starts) is planned for a run that has not begun: the session has moved on from any run that ended. A retry is inside its run.
473
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, Boolean(incoming) || operatorManual || !admittedRun);
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 cut before a run. Pi 1.4 counts a run as started before before_agent_start, and ctx.compact() first aborts and waits for an
504
- // idle session, so a cut asked for there would wait on its own run. A prompt's cut is therefore made on `input`, while the session
505
- // is idle. before_agent_start decides only for a run no input started (one a custom message starts) and cuts only if the session is
506
- // still idle (Pi 1.1); otherwise it declines an opportunistic cut and fails a needed one closed.
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
- // guard's responsibility. One snapshot per decision, taken again after the awaited cut.
518
- const snapshot = () => {
519
- const saved = ctx.sessionManager.buildSessionProjection().messages;
520
- const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
521
- const host = hostTokens(ctx);
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, mandatory: first.mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
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
- if (!ctx.isIdle()) {
534
- if (gate.opportunistic)
535
- return;
536
- throw new CapacityError("The request needs a cut that Pi cannot make once the run has started");
537
- }
538
- const cut = pendingCut = { mandatory: first.mandatory, before: first.before, host: first.host, opportunistic: gate.opportunistic, proposed: false };
539
- try {
540
- // CompactOptions callbacks are Pi's supported terminal completion surface.
541
- await new Promise((resolve, reject) => ctx.compact({ onComplete: () => resolve(),
542
- onError: error => {
543
- // An opportunistic cut is not needed to send the request, which is below the room: the guard decides. However it ends (the
544
- // planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
545
- // or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
546
- // A needed cut that Pi has nothing to make (no history older than its keepRecentTokens since the last cut) is a refusal of
547
- // this request, which ends with it: as a fault it stayed, and every later prompt of the session failed on it.
548
- if (cut.opportunistic && !failure && !cut.proposed)
549
- resolve();
550
- else if (!cut.proposed && isNoWork(error.message))
551
- reject(new CapacityError(`The request needs a cut and Pi has none to make (${error.message})`));
552
- else
553
- reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : cut.proposed ? new CommitFault(error.message) : error);
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 || event.outcome !== "completed" || ctx.signal?.aborted ||
652
- !event.context.canContinue || !event.toolResults.length || !settingsFor().getCompactionSettings(ctx.model).enabled)
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 = boundaryCut(event, ctx, "tool-loop");
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 || event.outcome !== "completed" || ctx.signal?.aborted || failure ||
711
- !settingsFor().getCompactionSettings(ctx.model).enabled)
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 boundaryCut(event, ctx, "run-end");
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 = "c4e6e6034a54ff7a893ee62484e54cf471964be3435aa587af8a288dcbf41314";
3
+ export declare const DIAGNOSTIC_BUILD = "3c4da6131a995651dda4f51db08a27e509958ef5a18dcfc14a264b07c04663aa";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "c4e6e6034a54ff7a893ee62484e54cf471964be3435aa587af8a288dcbf41314";
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
- // Scoped to one awaited initial-prompt cut; never a persisted controller. It carries that decision's snapshot.
288
- let pendingCut: { mandatory: number; before: number; host: number; opportunistic: boolean; proposed: boolean } | undefined;
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 also labels the awaited idle incoming preflight "manual".
374
- const incoming = pendingCut;
375
- const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
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
- // The incoming-prompt preflight brings its own snapshot. Any other cut (operator manual, Pi's threshold or overflow
386
- // recovery) charges what the request must carry whatever is cut: the system part and the host input.
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 = incoming ? incoming.host : hostTokens(ctx);
389
- const mandatory = incoming ? incoming.mandatory
390
- : systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
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 (!incoming && event.reason === "threshold") {
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: incoming?.opportunistic ?? thresholdOpportunistic,
406
- unreduced: incoming ? "Incoming Pi preflight compaction did not reduce context within the estimated request budget"
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 (a prompt's preflight, an operator's cut, Pi's own cut before a run a custom message
411
- // starts) is planned for a run that has not begun: the session has moved on from any run that ended. A retry is inside its run.
412
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, Boolean(incoming) || operatorManual || !admittedRun);
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") { if (incoming) incoming.proposed = true; return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
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 cut before a run. Pi 1.4 counts a run as started before before_agent_start, and ctx.compact() first aborts and waits for an
432
- // idle session, so a cut asked for there would wait on its own run. A prompt's cut is therefore made on `input`, while the session
433
- // is idle. before_agent_start decides only for a run no input started (one a custom message starts) and cuts only if the session is
434
- // still idle (Pi 1.1); otherwise it declines an opportunistic cut and fails a needed one closed.
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
- // guard's responsibility. One snapshot per decision, taken again after the awaited cut.
443
- const snapshot = () => {
444
- const saved = ctx.sessionManager.buildSessionProjection().messages;
445
- const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
446
- const host = hostTokens(ctx);
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, mandatory: first.mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
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
- if (!ctx.isIdle()) {
458
- if (gate.opportunistic) return;
459
- throw new CapacityError("The request needs a cut that Pi cannot make once the run has started");
460
- }
461
- const cut = pendingCut = { mandatory: first.mandatory, before: first.before, host: first.host, opportunistic: gate.opportunistic, proposed: false };
462
- try {
463
- // CompactOptions callbacks are Pi's supported terminal completion surface.
464
- await new Promise<void>((resolve, reject) => ctx.compact({ onComplete: () => resolve(),
465
- onError: error => {
466
- // An opportunistic cut is not needed to send the request, which is below the room: the guard decides. However it ends (the
467
- // planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
468
- // or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
469
- // A needed cut that Pi has nothing to make (no history older than its keepRecentTokens since the last cut) is a refusal of
470
- // this request, which ends with it: as a fault it stayed, and every later prompt of the session failed on it.
471
- if (cut.opportunistic && !failure && !cut.proposed) resolve();
472
- else if (!cut.proposed && isNoWork(error.message)) reject(new CapacityError(`The request needs a cut and Pi has none to make (${error.message})`));
473
- else reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : cut.proposed ? new CommitFault(error.message) : error);
474
- } }));
475
- } finally { pendingCut = undefined; }
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 || event.outcome !== "completed" || ctx.signal?.aborted ||
555
- !event.context.canContinue || !event.toolResults.length || !settingsFor().getCompactionSettings(ctx.model).enabled) return;
556
- const result = boundaryCut(event, ctx, "tool-loop");
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 || event.outcome !== "completed" || ctx.signal?.aborted || failure ||
593
- !settingsFor().getCompactionSettings(ctx.model).enabled) return;
594
- return boundaryCut(event, ctx, "run-end");
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) => {
@@ -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 {