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 +83 -0
- package/README.md +9 -10
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +14 -3
- package/dist/src/compaction.js +127 -30
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/eligibility-cache.d.ts +1 -1
- package/dist/src/eligibility-cache.js +3 -3
- package/dist/src/request-capacity.d.ts +14 -0
- package/dist/src/request-capacity.js +57 -0
- package/dist/src/semantic-recall.d.ts +6 -1
- package/dist/src/semantic-recall.js +51 -24
- package/dist/src/tuck/core/search-entries.js +2 -2
- package/dist/src/tuck.d.ts +2 -1
- package/dist/src/tuck.js +8 -3
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +114 -34
- package/src/eligibility-cache.ts +3 -3
- package/src/request-capacity.ts +56 -0
- package/src/semantic-recall.ts +19 -8
- package/src/tuck/core/search-entries.ts +2 -2
- package/src/tuck.ts +8 -4
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.
|
|
68
|
-
- To pin a version, add it: `pi install npm:pi-tuck@1.0.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "37e6c0132f0638bca430bc165bef86ac4814e136c47e4672e06b086310a2de70";
|
package/dist/src/compaction.d.ts
CHANGED
|
@@ -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
|
|
40
|
-
*
|
|
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
|
|
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;
|
package/dist/src/compaction.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
648
|
+
if (gate.kind === "none")
|
|
594
649
|
return;
|
|
595
650
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
596
|
-
const
|
|
597
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, 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,
|
|
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: !
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
}
|