pi-tuck 1.0.21 → 1.0.23

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,89 @@
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.23] - 2026-11-10
7
+
8
+ ### Fixed
9
+
10
+ - **Near the new refusal line, replies were cut to a few hundred tokens (1.0.22).** Pi sets each request's reply cap to the window less its own
11
+ estimate of the request less a 4,096-token safety margin. 1.0.22 left only its reply floor and did not count that margin. pi-tuck now:
12
+ - reads Pi's margin from Pi itself;
13
+ - sends a request above the room only if Pi's own cap still leaves the reply floor;
14
+ - never draws the line below the room, where 1.0.21 also sends.
15
+
16
+ The floor is the larger of the branch's largest reply and 3,584 tokens, capped at the model's own reply cap. After a reply stopped for length or
17
+ refused for context overflow, it is the reserve.
18
+ - **`tuck_search` said semantic recall was still indexing when it was fully indexed.** After the session grew, the next index plan could still be
19
+ preparing when a search ran. The search now ranks the plan its history grew from, as long as nothing in it changed, and says which entries are
20
+ still being indexed.
21
+ - **`tuck_search` in the turn after a prompt's cut ranked nothing ("nothing is out of view").** The cut reaches the requests at once but the session
22
+ only at the turn's end. Search now uses the pending cut.
23
+ - **A keyword-search timeout on a very long history failed the whole `tuck_search`.** The keyword half now reports `keyword_timeout`, and semantic
24
+ results still answer. The timeout message names the history's size, not a pattern.
25
+
26
+ ### Added
27
+
28
+ - The settings stress test builds every request through pi-ai's real request path, so Pi's own reply cap is tested. A large-session stress test
29
+ searches an ~85 MB session that grows while the next index plan prepares.
30
+
31
+ ### Release gates
32
+
33
+ - **Shipped without a live gate, because its replay matches 1.0.22:** 13,946 requests on 29 recordings, read per request. One recording's 13
34
+ differing requests matched exactly in two more runs of each build. 0 lost and 0 dead tasks.
35
+ - **Stress on Pi 1.2.0 and 1.5.0, 310 cells, through pi-ai's request path:**
36
+ - no cell answers fewer than 1.0.21;
37
+ - replies cut off below the floor 11 → 0;
38
+ - native-parity findings all inside its four exceptions;
39
+ - the 85 MB test passes.
40
+
41
+ Tests (712) and CI.
42
+ - **The 1.0.22 floor patch's Tier 1 gate runs on this version instead,** because its pilot found the reply-cap defect fixed here.
43
+ - Sessions that are open keep the build they started with until they are restarted.
44
+
45
+ ## [1.0.22] - 2026-11-10
46
+
47
+ ### Fixed
48
+
49
+ - **pi-tuck refused requests that fit the window, and could strand a session (1.0.0 to 1.0.21).** It refused at the room (the window less the full
50
+ reply reserve), where native Pi still sends. With Pi's defaults and a large setup, a session could be refused at every prompt. The final refusal
51
+ now sits at the window less a reply floor:
52
+ - the floor is the larger of the biggest reply seen on the branch and 4,096 tokens, capped at the reserve;
53
+ - after a reply stopped for length or a context-overflow refusal, the floor is the full reserve, as in 1.0.20;
54
+ - everything pi-tuck does before refusing (cut to the band, cut to the room, a smaller summary, clipping a fresh result) is unchanged and still
55
+ comes first;
56
+ - a session that never reaches the room sends the same requests as 1.0.21.
57
+ - **A run's request always carries the whole prompt that started the run.** If it cannot fit after every reduction, pi-tuck stops the run with a
58
+ notice saying to shorten the prompt or use a larger window, instead of sending part of it. In-run cuts now keep the run's starting prompt whole
59
+ beside a later steering message.
60
+
61
+ ### Added
62
+
63
+ - **Each pi-tuck refusal, and each request sent above the room, leaves one session entry:** type `custom`, `customType` `pi-tuck-capacity`, with
64
+ `data` `{kind: "refusal" | "above-room", reason, estimatedTokens, room, hardLine, buildId}`. It is never sent to the model, and never appears in
65
+ summaries or recall.
66
+ - **A native-parity check in the settings stress test,** computed from Pi's own `shouldCompact` and compaction settings for the Pi version under
67
+ test. It fails a cell where pi-tuck refuses a request native Pi would send, or forces a cut below native's compaction point. Four refusals are
68
+ allowed:
69
+ - past the window;
70
+ - after a reply that did not fit;
71
+ - when the whole starting prompt cannot fit;
72
+ - when sending would leave the reply less than the floor.
73
+
74
+ ### Known cost
75
+
76
+ - **Between the room and the window, the reply reserve no longer cushions a wrong count.** At a cold start an undercount can reach the provider. A
77
+ provider that errors is caught and learned from; a server that truncates silently truncates, as it does for native Pi.
78
+ - **With a setup above the room, cuts are forced and frequent,** each freeing little. That beats a stranded session.
79
+
80
+ ### Release gates
81
+
82
+ - **Shipped before its Tier 1 live gate, under the stranded-session rule;** the gate runs right after release, and a fail rolls back to 1.0.21.
83
+ - **Replay matches 1.0.21,** read per request on 29 recordings: every differing request also differs between two runs of 1.0.21. 0 lost and 0 dead
84
+ tasks, the same 17 clips.
85
+ - **Stress on Pi 1.2.0 and 1.5.0,** 298 cells: answered 1,309 → 1,356, refused 360 → 313, stranded sessions 3 → 0, native-parity findings 53 → 0. The
86
+ old fresh-room finding passes. Tests (696) and CI.
87
+ - Sessions that are open keep the build they started with until they are restarted.
88
+
6
89
  ## [1.0.21] - 2026-11-09
7
90
 
8
91
  ### Fixed
package/README.md CHANGED
@@ -64,8 +64,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
64
64
 
65
65
  - 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.
66
66
  - On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once), and says once when it is set up. Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
67
- - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.20`.
68
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.20`.
67
+ - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.22`.
68
+ - To pin a version, add it: `pi install npm:pi-tuck@1.0.22`.
69
69
 
70
70
  ## Uninstall
71
71
 
@@ -125,10 +125,13 @@ The plan: [`benchmark/PREREG.md`](benchmark/PREREG.md), [`benchmark/PREREG-32K.m
125
125
  Known limits today:
126
126
 
127
127
  - 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.
128
- - 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. pi-tuck then refuses to cut rather than overflow. This made 1.0.1 refuse at a 32k window with Pi's default 16,384 reserve. Version 1.0.4 added a notice that says when the provider counts 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 fixed both. Version 1.0.8 clips a fresh tool result that is too large for the room (its head and tail, with a `tuck_recall` locator to the rest) instead of refusing the run. If a small window still refuses, pin a different upstream, lower the reserve, or use a larger window; see [Small windows](#small-windows).
128
+ - 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. pi-tuck then refuses to cut rather than overflow. This made 1.0.1 refuse at a 32k window with Pi's default 16,384 reserve. Version 1.0.4 added a notice that says when the provider counts 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 fixed both. Version 1.0.8 clips a fresh tool result that is too large for the room (its head and tail, with a `tuck_recall` locator to the rest) instead of refusing the run. If a small window still refuses, pin a different upstream or use a larger window; see [Small windows](#small-windows).
129
129
  - Small window, after a stopped run (1.0.8 and 1.0.9 only; fixed in 1.0.10): Pi's default `compaction.keepRecentTokens` (20,000) can exceed pi-tuck's input room, which was the window less the larger of `compaction.reserveTokens` and the model's maximum output (16,384 at a 32k window with the default reserve). After a run that is stopped (Esc, or a time limit) with the context above the room, every later prompt could be refused with "The request needs a cut and Pi has none to make (Nothing to compact (session too small))", and the session stayed 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 ([`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md)). If you are on 1.0.8 or 1.0.9, upgrade to 1.0.10. In 1.0.10 the cut a prompt needs is pi-tuck's own, so `keepRecentTokens` no longer leaves it with nothing to cut. A rerun of the same check on the 1.0.10 candidate had no refusal and no stuck session. It had two caveats: two of its tasks had a first request the local server never answered, and 8 of its 30 tasks began without their prompt, which the refusal counter cannot see (fixed in 1.0.12; see Benchmarks and [`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md)). Windows of 128k and up were not affected at the defaults.
130
130
  - A prompt could be lost, with no notice (1.0.10 and 1.0.11; fixed in 1.0.12). When a new prompt arrived over the cut point and pi-tuck's cut kept nothing of the conversation before it, which happens when everything since the last cut is one long turn, the prompt was dropped from the request and from the saved session. The model never saw it and usually went on with the previous task, so work you asked for could be lost, and could look answered. The next prompt ran normally. If you used 1.0.10 or 1.0.11 and a reply ignored what you had just asked, this is the likely cause. Upgrade to 1.0.12 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.12).
131
131
  - A host that patches a large section into the system prompt could get a refusal that a cut would have fit (1.0.0 to 1.0.16; fixed in 1.0.17). pi-tuck plans a prompt's cut when the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn could push that turn's first request over the room, and the final guard refused it. Now the guard plans the cut against the system prompt as finally sent, and refuses only if that cut does not fit either ([`CHANGELOG.md`](CHANGELOG.md) 1.0.17).
132
+ - Semantic recall never started in `pi -p` (1.0.0 to 1.0.20; fixed in 1.0.21). pi-tuck skipped semantic recall whenever Pi ran with no UI, to keep a one-shot run from starting the first-time download, and that also skipped a runtime that was already installed. Jobs run with `pi -p` searched by keyword only, while `tuck_search` said semantic recall was being set up. RPC mode was not affected ([`CHANGELOG.md`](CHANGELOG.md) 1.0.21).
133
+ - pi-tuck refused requests that fit the window, and could strand a session (1.0.0 to 1.0.21; fixed in 1.0.22). It refused at the room, the window less the full reply reserve, where native Pi still sends. With Pi's defaults and a large setup, a session could be refused at every prompt. The final refusal now sits at the window less a reply floor ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
134
+ - Cost of the 1.0.22 change: between the room and the window, the reply reserve no longer cushions a wrong count. At a cold start an undercount can reach the provider. A provider that errors is caught and learned from; a server that truncates silently truncates, as it does for native Pi. With a setup above the room, cuts are forced and frequent, each freeing little, which beats a stranded session ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
132
135
  - pi-tuck cut earlier than native Pi when a model's reply cap is above Pi's reserve (1.0.0 to 1.0.19; fixed in 1.0.20). It reserved the larger of Pi's `compaction.reserveTokens` and the model's `maxTokens`: with `maxTokens` 32,768 and a reserve of 16,384 on an 81,920 window, its room fell to 49,152 tokens, where native Pi compacts at 65,536 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.20).
133
136
  - A summary could drop the task a session was working on (1.0.0 to 1.0.17; fixed in 1.0.18). Until 1.0.15 a summary quoted only the session's first user turn, up to 12,000 characters, and the newest one, so in a long session the current task, an older turn followed by short steps, was clipped or dropped while a finished first task stayed whole, and agents acted on a days-old task. 1.0.16 added more quotes. From 1.0.13 to 1.0.17, separate rules decided what a summary quoted and what the kept tail held, and the prompt that set the current task, or the first prompt, could lose its opening after a cut: the stress test found it 40 times in 32 cells on 1.0.16 and 1.0.17. Since 1.0.18 one ranked list fills a cut's room, and the stress test finds no lost opening in 287 cells. Short sessions, and the SWE-bench chains behind our benchmarks, keep each task's prompt in the kept tail and never hit it. Upgrade to 1.0.18 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
134
137
  - pi-tuck 1.0.16 cost more in live chains than its notes first said. Against 1.0.13 at an 81,920 window, it took 1.48 times the turns on the tasks both resolved (95% interval 1.34 to 1.64), 74% more cuts and 1.66 times the tokens, with 56 of 60 tasks resolved against 55. Replay of single-task sessions cannot show this cost. 1.0.18 replaced its quote rules; in the 1.0.18 gate it took 22.27 turns per task against 29.35 for 1.0.16 and 20.22 for the 1.0.13 arm (different pairs of runs; reported, not claimed) ([`benchmark/RESULTS-V1016.md`](benchmark/RESULTS-V1016.md), [`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
@@ -187,13 +190,9 @@ A bad file is reported at session start, and pi-tuck uses the defaults.
187
190
 
188
191
  pi-tuck keeps room for the model's reply. Since 1.0.20 that room is Pi's `compaction.reserveTokens` (default 16,384), the same as native Pi. It rises to the model's `maxTokens` for the rest of the branch only after a reply on that branch did not fit: a reply that stopped for length, or a request refused as a context overflow. A transient error such as a 503 keeps the native room. This is read from the current branch at each decision, so resume, fork and `/tree` follow the branch, and a new session starts at `reserveTokens` again. A host that passes its own reserve keeps its own. Before 1.0.20, pi-tuck always reserved the larger of the two, so with a large `maxTokens` it cut earlier than native Pi.
189
192
 
190
- pi-tuck refuses a request that cannot fit, rather than let the provider cut it off. It estimates the request's size, and it scales that estimate by the worst gap it has seen between its count and the provider's. In a small window, an upstream that spends far more context on tool schemas (some OpenRouter upstreams do) can leave too little room. pi-tuck then refuses to cut rather than overflow. Lower the reserve for that model in Pi's `settings.json`, or use a larger window. For example, to lower it to 8,192:
193
+ Since 1.0.22, pi-tuck refuses a request only at the window less a reply floor. The floor is the larger of the biggest reply seen on the branch and 4,096 tokens, capped at the reserve. After a reply that stopped for length or was refused as a context overflow, the floor is the full reserve. Before it refuses, pi-tuck cuts to the band, cuts to the room, makes a smaller summary and clips a fresh tool result, in that order. A run's request always carries the whole prompt that started the run: if it cannot fit after every reduction, pi-tuck stops the run with a notice saying to shorten the prompt or use a larger window. Each refusal, and each request sent above the room, leaves one `pi-tuck-capacity` entry in the session; it is never sent to the model, and never appears in summaries or recall.
191
194
 
192
- ```json
193
- { "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 8192 } } } }
194
- ```
195
-
196
- Lowering `reserveTokens` takes effect even when `models.json` sets a larger `maxTokens` for that model, until a reply on the branch does not fit. After that, the larger of the two applies for the rest of the branch, so lower both if needed, and keep `maxTokens` above your largest expected reply.
195
+ pi-tuck estimates the request's size, and it scales that estimate by the worst gap it has seen between its count and the provider's. In a small window, an upstream that spends far more context on tool schemas (some OpenRouter upstreams do) can leave too little room. If a small window refuses, use a larger window, or pin one upstream as below.
197
196
 
198
197
  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:
199
198
 
@@ -213,7 +212,7 @@ The first time a session starts, pi-tuck downloads what it needs, once, about 39
213
212
  | llama.cpp release b11476 for macOS arm64 (12 MB) | github.com/ggml-org/llama.cpp |
214
213
  | CPython 3.13 (25 MB) and 16 Python packages for the tokenizer (9 MB) | github.com/astral-sh/python-build-standalone, pypi.org |
215
214
 
216
- Every file is pinned to a SHA-256 in [src/assets.ts](src/assets.ts) and checked before use. Everything goes to `~/.pi/agent/pi-tuck`; to remove it, delete that folder. Setup needs about 6.5 GB of free disk space while it runs, and uses 460 MB after. If it fails, for example offline, pi-tuck says so and tries again at the next session start. One-shot runs (`pi -p`) do not start the setup.
215
+ Every file is pinned to a SHA-256 in [src/assets.ts](src/assets.ts) and checked before use. Everything goes to `~/.pi/agent/pi-tuck`; to remove it, delete that folder. Setup needs about 6.5 GB of free disk space while it runs, and uses 460 MB after. If it fails, for example offline, pi-tuck says so and tries again at the next session start. The first-time setup needs one interactive start: one-shot runs (`pi -p`) do not start the download. Since 1.0.21, a `pi -p` run starts semantic search when the runtime is already installed. If it is not installed, pi-tuck says so and starts no download. With no UI, an off or failing semantic recall, or a bad settings file, is written once per message per session to stderr, never into the model's context.
217
216
 
218
217
  Indexing starts in the background the first time a session uses `tuck_search`, and catches up after each compaction. Semantic search is off when Pi runs in a folder that contains Pi's agent folder, such as your home folder, because its own files live there. Open Pi in a project folder.
219
218
 
@@ -1 +1 @@
1
- export declare const BUILD_ID = "82df9e9954f1d33eb2d6c6c653220c13bd3c36c2f0250afa2a17ab51295cfb0c";
1
+ export declare const BUILD_ID = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
@@ -1 +1 @@
1
- export const BUILD_ID = "82df9e9954f1d33eb2d6c6c653220c13bd3c36c2f0250afa2a17ab51295cfb0c";
1
+ export const BUILD_ID = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
@@ -32,15 +32,26 @@ export interface CompactionOptions {
32
32
  onSemanticSetting?: (enabled: boolean) => Promise<unknown>;
33
33
  semanticStatus?: () => unknown;
34
34
  diagnostics?: RecorderOptions;
35
+ /** Optional read-only observation of actual corrected budget checks; no request content. */
36
+ observeBudget?: (measurement: {
37
+ rawTokens: number;
38
+ tokens: number;
39
+ factor: number;
40
+ response: number;
41
+ room: number;
42
+ hardLine?: number;
43
+ context: ExtensionContext;
44
+ }) => void;
35
45
  /** Optional estimated complete-input ceiling, separate from physical output reservation.
36
46
  * The host owns final serialization/transport counting; this is not an exact wire-token cap. */
37
47
  activeInputTokens?: number | ((ctx: ExtensionContext) => number);
38
48
  /** An explicit cut point: a fraction of the input room, above 0 and at most 1. A cut is triggered at, and preferably planned to, the band,
39
- * which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The exact room
40
- * stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
49
+ * which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The room
50
+ * owns cut targets; standalone final capacity admission uses the branch reply floor. If omitted, the band adapts to the last cut. */
41
51
  cutAt?: number;
42
52
  /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
43
- * Standalone uses Pi's reserveTokens until a length stop or a context-overflow refusal on the current branch, then also holds the catalogue maximum.
53
+ * Standalone cuts against Pi's reserveTokens, then admits up to the window less Pi's safety margin and max(3,584, largest branch output), capped at that reserve.
54
+ * After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
44
55
  * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
45
56
  requestReserve?: (ctx: ExtensionContext) => {
46
57
  response: number;
@@ -1,3 +1,6 @@
1
+ import { textOf } from "./tuck/core/content.js";
2
+ import { requestReplyFloor, carriesPrompt, piContextSafetyMargin } from "./request-capacity.js";
3
+ import { BUILD_ID } from "./build-id.js";
1
4
  import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
2
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
3
6
  import { createHash } from "node:crypto";
@@ -7,7 +10,7 @@ import { assertNativeToolRounds } from "./protocol.js";
7
10
  import { projectedMessages } from "./omissions.js";
8
11
  import { applyClips, clipBudgets, clipFor } from "./clip.js";
9
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
10
- import { getCurrentSystemMessage, isContextOverflow } from "@earendil-works/pi-ai";
13
+ import { getCurrentSystemMessage, isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
11
14
  import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
12
15
  import { Type } from "typebox";
13
16
  import { Check } from "typebox/value";
@@ -101,6 +104,9 @@ export function seedWindow(branch, key) {
101
104
  return initialWindow(key);
102
105
  }
103
106
  export async function installCompaction(pi, options = {}) {
107
+ const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
108
+ const piClamp = options.requestReserve ? undefined : await import('@earendil-works/pi-ai/api/simple-options')
109
+ .then(module => module.clampMaxTokensToContext).catch(() => undefined);
104
110
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
105
111
  if (!validCutAt(cutAt))
106
112
  throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
@@ -115,7 +121,8 @@ export async function installCompaction(pi, options = {}) {
115
121
  const semanticPending = new Set();
116
122
  // Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
117
123
  // attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
118
- const semanticProvider = (ctx) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess);
124
+ // A prompt's cut reaches the requests at once but the session only at the end of the first turn: until then search sees the history as the model does.
125
+ const semanticProvider = (ctx) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess, promptCut ? promptCut.firstKeptEntryId || ctx.sessionManager.getBranch().findLast(e => e.type === "message" && e.message.role === "user")?.id : undefined);
119
126
  const recorder = new IncidentRecorder(options.diagnostics);
120
127
  let proposalId;
121
128
  const observe = (ctx) => recorder.measureInline(() => {
@@ -216,6 +223,10 @@ export async function installCompaction(pi, options = {}) {
216
223
  } });
217
224
  // Pi's native start/settled events bracket the run and its post-turn cuts.
218
225
  let admittedRun = false;
226
+ let runPrompt;
227
+ const protectedPrompt = (ctx) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt))?.id : undefined;
228
+ const promptRefusal = () => new Refusal("Prompt check failed: the whole starting prompt cannot fit after cutting to the band and room, shrinking the summary and clipping fresh results. Shorten the prompt or choose a model with a larger context window, then retry");
229
+ let estimatedTokens = 0;
219
230
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
220
231
  // start, or a model switch) starts with the window its branch's newest receipt recorded for that identity, else that receipt's factor, else the model's measured undercount or 1 (`seedWindow`).
221
232
  let identity = "", factor = 1, ratios = [];
@@ -235,6 +246,10 @@ export async function installCompaction(pi, options = {}) {
235
246
  const learn = (value) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW)
236
247
  ratios.shift(); factor = Math.max(1, ...ratios); };
237
248
  const corrected = (tokens, ctx) => { identityFor(ctx); return Math.ceil(tokens * factor); };
249
+ const observeBudget = (rawTokens, ctx) => {
250
+ if (options.observeBudget)
251
+ options.observeBudget({ rawTokens, tokens: corrected(rawTokens, ctx), factor, ...limits(ctx), context: ctx });
252
+ };
238
253
  // The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
239
254
  const planReserve = (target, room, band, mandatory, ctx) => {
240
255
  identityFor(ctx);
@@ -287,7 +302,7 @@ export async function installCompaction(pi, options = {}) {
287
302
  learn(ratio);
288
303
  });
289
304
  pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
290
- pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
305
+ pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
291
306
  const active = () => options.active?.() ?? true;
292
307
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
293
308
  // The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
@@ -311,10 +326,12 @@ export async function installCompaction(pi, options = {}) {
311
326
  throw new Error("Invalid active-input token ceiling");
312
327
  return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
313
328
  };
314
- // Every throw, fault and complete-context guard uses the exact room.
329
+ // Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
315
330
  const limits = (ctx) => {
316
331
  const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
317
- return { response, room };
332
+ const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
333
+ const hardLine = Math.min(Math.max(room, (ctx.model?.contextWindow ?? 0) - piMargin - floor), lastCeiling ?? Infinity);
334
+ return { response, room, hardLine, replyFloor: floor };
318
335
  };
319
336
  const decisionBand = (ctx, room, system) => {
320
337
  const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
@@ -349,12 +366,22 @@ export async function installCompaction(pi, options = {}) {
349
366
  // Where the branch ended when the prompt's cut was planned: the prompt is the entry Pi appends there. A cut that keeps nothing of the conversation
350
367
  // before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
351
368
  let promptCutAt = 0;
352
- const keptFrom = (cut, ctx) => cut.firstKeptEntryId || ctx.sessionManager.getBranch()[promptCutAt]?.id || "";
369
+ const keptFrom = (cut, ctx) => cut.firstKeptEntryId ||
370
+ ctx.sessionManager.getBranch().slice(promptCutAt).find(entry => entry.type !== "custom" || entry.customType !== "pi-tuck-capacity")?.id || "";
353
371
  let failure;
372
+ let refusalReceipted = false;
354
373
  let pendingBoundaryCommit = false;
355
374
  // 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
356
375
  // whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
357
376
  const clips = new Map();
377
+ const receipt = (kind, reason, ctx) => {
378
+ let room = null, hardLine = null;
379
+ try {
380
+ ({ room, hardLine } = limits(ctx));
381
+ }
382
+ catch { /* Invalid configuration has no usable capacity. Preserve its original failure. */ }
383
+ pi.appendEntry("pi-tuck-capacity", { kind, reason, estimatedTokens, room, hardLine, buildId: BUILD_ID });
384
+ };
358
385
  const fail = (error, ctx) => {
359
386
  recorder.stop("native-attempt-failed");
360
387
  // Every refusal says why, once per attempt: a capacity error with what the user can change, a cut Pi failed to commit with what that
@@ -367,6 +394,10 @@ export async function installCompaction(pi, options = {}) {
367
394
  pending = undefined;
368
395
  promptCut = undefined;
369
396
  try {
397
+ if (!refusalReceipted) {
398
+ refusalReceipted = true;
399
+ receipt("refusal", failure.message, ctx);
400
+ }
370
401
  options.fail?.(error);
371
402
  }
372
403
  finally {
@@ -383,7 +414,7 @@ export async function installCompaction(pi, options = {}) {
383
414
  };
384
415
  // The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
385
416
  // input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
386
- const forgetFailure = () => { if (!(failure instanceof CommitFault))
417
+ const forgetFailure = () => { refusalReceipted = false; if (!(failure instanceof CommitFault))
387
418
  failure = undefined; };
388
419
  const access = () => {
389
420
  if (failure)
@@ -450,7 +481,18 @@ export async function installCompaction(pi, options = {}) {
450
481
  // Bind the actual independent lexical offer, including attribution derived
451
482
  // from custom markers and receipts excluded from semantic indexing. Rebuild
452
483
  // under current caller policy after await, when the session moved; never send this block to the encoder.
453
- const wordOffer = () => tuckRecall(tuck, ctx.sessionManager.getEntries(), ctx.sessionManager.getBranch(), { query: input.query }, options.recallPolicy);
484
+ // Keyword search over a very long history can hit its time limit. That limit guards one call, not the semantic half: the agent still gets the
485
+ // semantic results, and is told the keyword half stopped (seen in the field on an 84.7 MB session, where the whole search failed instead).
486
+ const wordOffer = () => {
487
+ try {
488
+ return tuckRecall(tuck, ctx.sessionManager.getEntries(), ctx.sessionManager.getBranch(), { query: input.query }, options.recallPolicy);
489
+ }
490
+ catch (error) {
491
+ if (error instanceof Error && error.message.startsWith("Search aborted:"))
492
+ return { status: "keyword_timeout", notice: "Keyword search stopped at its time limit on this long history; the semantic results follow. For a keyword match, use more specific words or read an entry by index.", hits: [] };
493
+ throw error;
494
+ }
495
+ };
454
496
  // The key is taken in the same synchronous step as the first lexical pass. After the await the second pass runs only if it moved (see eligibilityKey); a moved key alone invalidates nothing.
455
497
  // The snapshot id check covers every in-contract change (any branch change moves the id); the key-gated second pass is defence in depth for out-of-contract callers.
456
498
  const provider = semanticProvider(ctx), initialSnapshot = provider(), initialKey = eligibilityKey(ctx), words = wordOffer(), wordText = JSON.stringify(words);
@@ -497,6 +539,7 @@ export async function installCompaction(pi, options = {}) {
497
539
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
498
540
  const mandatory = system + host, band = decisionBand(ctx, room, system);
499
541
  const before = plannerMeasureFor(ctx)(saved);
542
+ observeBudget(before + mandatory, ctx);
500
543
  // Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
501
544
  // 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
502
545
  // trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
@@ -509,19 +552,27 @@ export async function installCompaction(pi, options = {}) {
509
552
  thresholdOpportunistic = gate.opportunistic;
510
553
  }
511
554
  // Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
512
- const decision = chooseCut({
513
- before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
555
+ const decide = (targetRoom = room, targetBand = band) => chooseCut({
556
+ before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
514
557
  opportunistic: thresholdOpportunistic,
515
558
  unreduced: "Compaction did not reduce context within the estimated request budget",
516
559
  plan: (target) => {
517
- const reserve = planReserve(target, room, band, mandatory, ctx);
560
+ const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
518
561
  // 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
519
562
  // from any run that ended. A retry is inside its run.
520
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
563
+ const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
521
564
  return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
522
565
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
523
566
  },
524
567
  });
568
+ estimatedTokens = corrected(before + mandatory, ctx);
569
+ let decision = decide();
570
+ const { hardLine } = limits(ctx);
571
+ if (!operatorManual && hardLine > room && decision.kind === "fault") {
572
+ if (fits(before + mandatory, hardLine, ctx))
573
+ return { cancel: true };
574
+ decision = decide(hardLine, hardLine);
575
+ }
525
576
  if (decision.kind === "cut") {
526
577
  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 }) };
527
578
  }
@@ -530,6 +581,8 @@ export async function installCompaction(pi, options = {}) {
530
581
  return { cancel: true };
531
582
  recorder.stop("no-cut");
532
583
  ctx.ui.notify(decision.message, "warning");
584
+ if (operatorManual)
585
+ receipt("refusal", decision.message, ctx);
533
586
  if (!operatorManual)
534
587
  fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
535
588
  return { cancel: true };
@@ -544,7 +597,7 @@ export async function installCompaction(pi, options = {}) {
544
597
  }
545
598
  });
546
599
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
547
- 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"; });
600
+ pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; refusalReceipted = false; admittedRun = false; runPrompt = undefined; estimatedTokens = 0; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
548
601
  const historyToolsEnabled = (ctx) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
549
602
  const syncHistoryTools = (ctx) => { if (active())
550
603
  applyHistoryTools(pi, historyToolsEnabled(ctx)); };
@@ -585,22 +638,24 @@ export async function installCompaction(pi, options = {}) {
585
638
  const band = decisionBand(ctx, room, system);
586
639
  const host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
587
640
  const incoming = incomingPart + system, mandatory = incoming + host;
588
- if (!fits(incoming, room, ctx))
641
+ observeBudget(before + mandatory, ctx);
642
+ estimatedTokens = corrected(before + mandatory, ctx);
643
+ if (!fits(incoming, limits(ctx).hardLine, ctx))
589
644
  throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
590
645
  // A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
591
646
  // help: leave it to the complete-context guard, which fails closed.
592
647
  const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
593
- if (gate.kind !== "plan")
648
+ if (gate.kind === "none")
594
649
  return;
595
650
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
596
- const decision = chooseCut({
597
- before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic: gate.opportunistic,
651
+ const decide = (targetRoom, targetBand, opportunistic) => chooseCut({
652
+ before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic,
598
653
  unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
599
654
  plan: (target) => {
600
- const reserve = planReserve(target, room, band, mandatory, ctx);
655
+ const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
601
656
  // Only a due cut re-renders a summary that sits alone in the session: inside the band a cut is opportunistic, and a smaller summary
602
657
  // there buys nothing the request needed and costs history the next recall would have found.
603
- const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
658
+ const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
604
659
  if (!draft.compaction)
605
660
  return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
606
661
  const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
@@ -608,6 +663,15 @@ export async function installCompaction(pi, options = {}) {
608
663
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
609
664
  },
610
665
  });
666
+ let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" };
667
+ const { hardLine } = limits(ctx);
668
+ if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
669
+ if (fits(before + mandatory, hardLine, ctx))
670
+ return;
671
+ decision = fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
672
+ }
673
+ if (decision.kind === "defer")
674
+ return;
611
675
  // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
612
676
  if (decision.kind === "none")
613
677
  return;
@@ -619,6 +683,7 @@ export async function installCompaction(pi, options = {}) {
619
683
  pi.on("before_agent_start", async (event, ctx) => {
620
684
  const prompted = phase === "decided";
621
685
  phase = "starting";
686
+ runPrompt = event.messageType === "custom" ? undefined : event.prompt;
622
687
  try {
623
688
  syncHistoryTools(ctx);
624
689
  if (prompted)
@@ -706,20 +771,25 @@ export async function installCompaction(pi, options = {}) {
706
771
  // request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
707
772
  let previews = [];
708
773
  let floorKept;
709
- const decide = () => {
774
+ let wholePromptFloor;
775
+ const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
776
+ const decide = (targetRoom = room, targetBand = band) => {
710
777
  previews = [];
711
778
  floorKept = undefined;
779
+ wholePromptFloor = undefined;
712
780
  const before = measure(saved);
713
- const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
781
+ const gate = gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
714
782
  if (gate.kind !== "plan")
715
783
  return gate;
716
784
  return chooseCut({
717
- opportunistic: gate.opportunistic, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
785
+ opportunistic: gate.opportunistic, mandatory, before, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
718
786
  unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
719
787
  plan: (target) => {
720
- const reserve = planReserve(target, room, band, mandatory, ctx);
721
- const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
788
+ const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
789
+ const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary, protectedPromptEntryId: protectedId }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
722
790
  if (!draft.compaction) {
791
+ if (protectedId && draft.floorMessages)
792
+ wholePromptFloor = measure(draft.floorMessages) + mandatory;
723
793
  // The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
724
794
  if (draft.floorMessages && event.toolResults) {
725
795
  const fresh = new Set(event.toolResults.map(result => result.toolCallId));
@@ -747,18 +817,20 @@ export async function installCompaction(pi, options = {}) {
747
817
  // clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
748
818
  // built, so the second budget is the cut's own floor and not a new formula. When nothing is clipped or the decision still faults, the
749
819
  // refusal stands.
750
- if (decision.kind === "fault" && event.toolResults) {
820
+ const tryClips = (clipRoom, targetBand) => {
821
+ if (decision.kind !== "fault" || !event.toolResults)
822
+ return;
751
823
  const branch = ctx.sessionManager.getBranch();
752
824
  const entryIdOf = (toolCallId) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
753
825
  const fresh = new Set(event.toolResults.map(result => result.toolCallId));
754
826
  const kept = previews.length ? Math.max(...previews.map(preview => measure(preview.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId)))))) : floorKept;
755
- for (const budget of clipBudgets(room, factor, mandatory, kept)) {
827
+ for (const budget of clipBudgets(clipRoom, factor, mandatory, kept)) {
756
828
  const round = clipFor(event.toolResults, entryIdOf, budget);
757
829
  if (!round.length)
758
830
  continue;
759
831
  for (const clip of round)
760
832
  clips.set(clip.toolCallId, clip);
761
- const clippedDecision = decide();
833
+ const clippedDecision = decide(clipRoom, targetBand);
762
834
  if (clippedDecision.kind === "fault")
763
835
  for (const clip of round)
764
836
  clips.delete(clip.toolCallId);
@@ -767,10 +839,23 @@ export async function installCompaction(pi, options = {}) {
767
839
  break;
768
840
  }
769
841
  }
770
- }
842
+ };
843
+ tryClips(room, band);
771
844
  const before = measure(saved);
772
- if (decision.kind === "fault" && reason === "tool-loop")
845
+ observeBudget(before + mandatory, ctx);
846
+ estimatedTokens = corrected(before + mandatory, ctx);
847
+ const { hardLine } = limits(ctx);
848
+ if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
849
+ if (fits(before + mandatory, hardLine, ctx))
850
+ return;
851
+ decision = decide(hardLine, hardLine);
852
+ tryClips(hardLine, hardLine);
853
+ }
854
+ if (decision.kind === "fault" && reason === "tool-loop") {
855
+ if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx))
856
+ throw promptRefusal();
773
857
  throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
858
+ }
774
859
  if (decision.kind !== "cut")
775
860
  return;
776
861
  const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, reason, before, true, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
@@ -905,8 +990,20 @@ export async function installCompaction(pi, options = {}) {
905
990
  const messages = historyToolsEnabled(ctx) ? historyToolDeclarations(event.messages, toolShapes(pi.getActiveTools()), true) : event.messages;
906
991
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
907
992
  const tokens = system + conversationTokens(messages);
908
- if (!fits(tokens, limits(ctx).room, ctx))
993
+ observeBudget(tokens, ctx);
994
+ estimatedTokens = corrected(tokens, ctx);
995
+ const { room, hardLine, replyFloor } = limits(ctx);
996
+ if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
997
+ throw new Refusal("Prompt check failed: the request is missing the whole text of the prompt that started this run. Shorten the prompt or choose a model with a larger context window, then retry");
998
+ if (!fits(tokens, hardLine, ctx))
909
999
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
1000
+ // Pi's request estimator and its compaction estimator need not agree. The hard line is
1001
+ // necessary; above the room, also require the real request clamp to preserve the floor.
1002
+ if (!options.requestReserve && estimatedTokens > room && piClamp &&
1003
+ piClamp(ctx.model, normalizeContext({ messages: convertToLlm(messages) }), ctx.model.maxTokens) < replyFloor)
1004
+ throw new CapacityError("Pi's request clamp leaves less than the reply floor");
1005
+ if (estimatedTokens > room)
1006
+ receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
910
1007
  if (recorder.enabled && !recorder.waitingForCut)
911
1008
  recorder.measureInline(() => {
912
1009
  recorder.request();
@@ -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 = "82df9e9954f1d33eb2d6c6c653220c13bd3c36c2f0250afa2a17ab51295cfb0c";
3
+ export declare const DIAGNOSTIC_BUILD = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -15,5 +15,5 @@ export declare const eligibilityKeyMoved: (ctx: Context, since: readonly unknown
15
15
  * the same `SemanticAccessError` that `semanticEligibleSources` raises (the mapping below is that function's first statement, verbatim).
16
16
  * The policy is trusted by identity, so it must not change after install: a policy object mutated in place (a type added to
17
17
  * `privateCustomTypes`) or a `readAttemptMarker` with hidden state is not noticed until the history changes. */
18
- export declare function memoizedEligibleSources(ctx: Context, policy: RecallPolicy | undefined, access: () => void): Snapshot;
18
+ export declare function memoizedEligibleSources(ctx: Context, policy: RecallPolicy | undefined, access: () => void, pendingKeptId?: string): Snapshot;
19
19
  export {};
@@ -16,18 +16,18 @@ export const eligibilityKeyMoved = (ctx, since) => !same(since, eligibilityKey(c
16
16
  * the same `SemanticAccessError` that `semanticEligibleSources` raises (the mapping below is that function's first statement, verbatim).
17
17
  * The policy is trusted by identity, so it must not change after install: a policy object mutated in place (a type added to
18
18
  * `privateCustomTypes`) or a `readAttemptMarker` with hidden state is not noticed until the history changes. */
19
- export function memoizedEligibleSources(ctx, policy, access) {
19
+ export function memoizedEligibleSources(ctx, policy, access, pendingKeptId) {
20
20
  try {
21
21
  access();
22
22
  }
23
23
  catch (error) {
24
24
  throw Object.assign(new Error(error instanceof Error ? error.message : 'Source access denied'), { name: 'SemanticAccessError' });
25
25
  }
26
- const key = eligibilityKey(ctx), held = cache.get(ctx.sessionManager);
26
+ const key = [...eligibilityKey(ctx), pendingKeptId ?? null], held = cache.get(ctx.sessionManager);
27
27
  if (held && held.policy === policy && same(held.key, key))
28
28
  return { ...held.stored, units: [], complete: false, indexed: new Set() };
29
29
  // Access was checked on the line above with nothing awaited in between, so the rebuild does not check it a second time.
30
- const built = semanticEligibleSources(ctx, policy ?? {}, () => { });
30
+ const built = semanticEligibleSources(ctx, policy ?? {}, () => { }, pendingKeptId);
31
31
  cache.set(ctx.sessionManager, { policy, key, stored: { id: built.id, session: built.session, project: built.project, sources: built.sources, throughEntryId: built.throughEntryId, viewStart: built.viewStart } });
32
32
  return built;
33
33
  }