pi-tuck 1.0.12 → 1.0.13
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 +33 -0
- package/README.md +9 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/src/budget.d.ts +11 -6
- package/dist/src/budget.js +13 -9
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +2 -2
- package/dist/src/compaction.js +23 -14
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/omissions.d.ts +0 -1
- package/dist/src/omissions.js +9 -11
- package/dist/src/semantic-recall.js +1 -2
- package/dist/src/tuck/core/global-indices.d.ts +4 -2
- package/dist/src/tuck/core/global-indices.js +21 -35
- package/dist/src/tuck/core/normalize.d.ts +1 -1
- package/dist/src/tuck/core/normalize.js +1 -1
- package/dist/src/tuck/cut.js +10 -23
- package/dist/src/tuck.js +2 -2
- package/index.ts +1 -1
- package/package.json +1 -1
- package/src/budget.ts +15 -9
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +25 -16
- package/src/omissions.ts +9 -11
- package/src/semantic-recall.ts +1 -2
- package/src/tuck/core/global-indices.ts +19 -33
- package/src/tuck/core/normalize.ts +1 -1
- package/src/tuck/cut.ts +7 -16
- package/src/tuck.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,39 @@
|
|
|
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.13] - 2026-11-07
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- **The cut point adapts by itself.** With no `cutAt` set, the band now follows the session: what the setup and the last cut left, plus half a tail of room to
|
|
11
|
+
grow, never below 60% of the room and capped just under the room. Sessions at small windows, or with a large setup, no longer cut every round or two for
|
|
12
|
+
little gain. Larger windows with ordinary setups, such as 131,072 with a 24k setup, stay on 60% and behave almost exactly as before. An explicit `cutAt` keeps today's formula,
|
|
13
|
+
so remove it if you set one by hand for this reason.
|
|
14
|
+
- With no `cutAt` set, a cut that isn't due yet must free at least a tenth of the room.
|
|
15
|
+
- Internal cleanup: single-use helpers inlined and duplicate paths merged, with no change to any request.
|
|
16
|
+
|
|
17
|
+
### The trade
|
|
18
|
+
|
|
19
|
+
On windows that cut too often today, each request carries more context. Cuts, re-processing and request time fall, and billed tokens rise on paid models. On
|
|
20
|
+
replay of real sessions at an 81,920 window: 22.3 to 9.3 cuts per 100 requests, request time about 16-20% lower, billed tokens 16-19% higher. At a 65,536 window
|
|
21
|
+
it is the worst of these, with billed tokens up by as much as 35%.
|
|
22
|
+
|
|
23
|
+
### Known issues
|
|
24
|
+
|
|
25
|
+
- A very large setup at a small window, such as 40k at 65,536, leaves too little free room for any good rhythm: cuts stay frequent and free little.
|
|
26
|
+
- A fresh assistant message larger than the free room is refused: nothing can clip it yet. A short next prompt recovers. In an unattended `pi -p` run, a
|
|
27
|
+
refusal ends the run.
|
|
28
|
+
- A reply that reports more than the window reads to Pi as a silent overflow; when nothing can be cut, pi-tuck refuses the next prompt, and the prompt after that runs.
|
|
29
|
+
- If a provider keeps rejecting requests in a wording Pi does not class as a context overflow, or with a bare HTTP 413, pi-tuck does not recover on its own. Try
|
|
30
|
+
`/compact`.
|
|
31
|
+
|
|
32
|
+
### Release gates
|
|
33
|
+
|
|
34
|
+
- Passed: the paired live gate against 1.0.12 on `mac-studio/main` at an 81,920 window, 150 tasks on five chains: resolved 131 against 131, lower bound of the
|
|
35
|
+
difference -1.3 points against a bar of -5, 0 lost tasks ([`benchmark/RESULTS-ADAPTIVE.md`](benchmark/RESULTS-ADAPTIVE.md)). Tests; CI; the stress test on Pi 1.2.0
|
|
36
|
+
and the newest Pi, with 3 of 274 cells reporting the known issues above; frozen replay with 0 dead tasks and every clip read back.
|
|
37
|
+
- This release is the exact code the gate measured. Only the version and these notes changed.
|
|
38
|
+
|
|
6
39
|
## [1.0.12] - 2026-11-06
|
|
7
40
|
|
|
8
41
|
### 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). 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.12`.
|
|
68
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.12`.
|
|
69
69
|
|
|
70
70
|
## How it works
|
|
71
71
|
|
|
@@ -112,8 +112,10 @@ Known limits today:
|
|
|
112
112
|
|
|
113
113
|
- 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.
|
|
114
114
|
- 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).
|
|
115
|
-
- 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 is 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
|
|
116
|
-
-
|
|
115
|
+
- 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 is 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.
|
|
116
|
+
- 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).
|
|
117
|
+
- Cuts can come too often at small windows (1.0.11 and 1.0.12). With setups that leave a real summary and tail after a cut, pi-tuck cuts too often at small windows, freeing little each time: about 99 cuts in 200 tool rounds at an 81,920 window with a 24k setup. It costs time and extra work ([`CHANGELOG.md`](CHANGELOG.md) 1.0.12).
|
|
118
|
+
- Still applying from 1.0.11: a fresh assistant message larger than the free room is refused, and a short next prompt recovers (in an unattended `pi -p` run, a refusal ends the run). If a provider keeps rejecting requests in a wording Pi does not class as a context overflow, or with a bare HTTP 413, pi-tuck does not recover on its own: the session stays intact, and every prompt can fail until the request shrinks. Try `/compact` ([`CHANGELOG.md`](CHANGELOG.md) 1.0.11).
|
|
117
119
|
- Other extensions (1.0.10): a prompt's cut no longer goes through Pi's `session_before_compact`, so another extension's veto there no longer stops it. It never stopped a cut between tool rounds. Pi's own threshold and overflow cuts, and `/compact`, still ask other extensions as before.
|
|
118
120
|
- Semantic search (`tuck_search`) needs a Mac with Metal (Apple silicon). Elsewhere, compaction and `tuck_recall`'s word search work, there is no `tuck_search`, and nothing is downloaded.
|
|
119
121
|
|
|
@@ -123,10 +125,11 @@ Each row says what was measured, under which conditions, and where the data is.
|
|
|
123
125
|
|
|
124
126
|
| What | Result | Conditions | Source |
|
|
125
127
|
|---|---|---|---|
|
|
126
|
-
| Pi's default compaction settings, 32k window, pi-tuck 1.0.10 candidate | Passed as registered: 0 dead tasks and 0 stuck sessions on 30 tasks, on a slow local server (27 of 30 tasks hit the 30-minute wall cap, and 2 had a first request the server never answered). 6 resolved. A counter written later, which counts a task with no token as dead, would count those 2 | Candidate `a67ec30` of 1.0.10, `mac-studio/main`, window 32,768, output cap 16,384, Pi's default `reserveTokens` 16,384 and `keepRecentTokens` 20,000, one session of chain t06. One mid-run refusal (a round with 9,020 tokens of reasoning against the 16,384-token room). One session, one model
|
|
128
|
+
| Pi's default compaction settings, 32k window, pi-tuck 1.0.10 candidate | Passed as registered: 0 dead tasks and 0 stuck sessions on 30 tasks, on a slow local server (27 of 30 tasks hit the 30-minute wall cap, and 2 had a first request the server never answered). 6 resolved. A counter written later, which counts a task with no token as dead, would count those 2. **The registered counter counts refusals and cannot see a task that began without its prompt**: in 8 of the 30 tasks (4, 8, 9, 10, 13, 14, 17, 21) a cut at the prompt kept no turn, none of the 8 resolved, and 6 ended with an empty patch. So the pass shows no refusal, not that every task began with its prompt. The 1.0.11 candidate on the same test had 7 of 30 such tasks, 1 resolved. Fixed in 1.0.12 | Candidate `a67ec30` of 1.0.10, `mac-studio/main`, window 32,768, output cap 16,384, Pi's default `reserveTokens` 16,384 and `keepRecentTokens` 20,000, one session of chain t06. One mid-run refusal (a round with 9,020 tokens of reasoning against the 16,384-token room). One session per arm, one model. In 12 of the 15 tasks across both arms the model's own first messages show it lacked the prompt; the other 3 are not clear from the records | [`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md) |
|
|
127
129
|
| Pi's default compaction settings, 32k window, pi-tuck 1.0.8 | 28 of 30 tasks lost (tasks 3 to 30, each refused at its prompt); 1 resolved. Fails the pre-registered bar of 0 lost tasks | pi-tuck 1.0.8 (`86935e9`), `mac-studio/main`, window 32,768, output cap 16,384, `reserveTokens` 16,384 and `keepRecentTokens` 20,000 (larger than the room), one session of chain t06. One session, one model, one build: it shows that the failure occurs at these settings, not how often. Not comparable with other rows | [`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md) |
|
|
128
130
|
| Tasks resolved, 32k window | Pi's built-in compaction 94 of 150; pi-tuck 120 of 150 (+17.3 points, 95% lower bound +10.7); observation masking 117 of 150 (pi-tuck +2.0, lower bound −2.7: no difference shown). Turns per task: 9.4 (built-in), 24.0 (pi-tuck), 11.9 (masking). Tokens per session: 6.10 M, 6.39 M, 7.02 M. Spend per arm for the five sessions: $0.87, $0.55, $0.94 | pi-tuck 1.0.5 (commit `aa30824`, the 1.0.5 code; 1.0.6 and 1.0.7 are not measured). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, 5 chains of 30 SWE-bench Verified tasks, paired by task, one-sided bootstrap over tasks. **Limits:** only pi-tuck is held to the 16,384-token room (the built-in sent a request over it in 97 of 150 tasks, masking in 73, so the window is nominal for them). The difference by chain is +10.0, −3.3, +50.0, +16.7 and +13.3 points; leaving out chain t03 it is +9.2. The bootstrap resamples tasks, not chains. The pi-tuck arm ran at a different time from the others. One model, one window. pi-tuck refused to cut mid-run in 5 tasks, where one fresh tool result was larger than the room; they count as unresolved. In the same setup pi-tuck 1.0.3 resolved 72 of 150, with 52 dead tasks (tasks refused at their prompt after an earlier failure). In the pi-tuck arm of this row there was 1 dead task (t03 task 24: refused at its prompt with "Nothing to compact"; the next task ran normally), so this build missed the pre-registered acceptance bar of zero dead tasks by one task (the other part of the bar, no loss against 1.0.3 by more than 3 points, was met) | [`benchmark/RESULTS-32K-ACCEPT.md`](benchmark/RESULTS-32K-ACCEPT.md) |
|
|
129
|
-
| The
|
|
131
|
+
| The adaptive candidate against 1.0.12, 81,920 window | Registered bar (Tier 2) met: resolved 131 of 150 in both arms (+0.0 points, one-sided 95% lower bound −1.3, against a bar of above −5); 0 lost tasks in the candidate arm. The 0 counts dead tasks, tasks whose prompt was dropped at a cut, and tasks an audit found started without their prompt; all 11 tasks with a cut at a prompt, in both arms, were also read by hand, and in each the model's first messages work on that task | Pre-registered gate. Candidate `882f6f6` (not a release) against pi-tuck 1.0.12, `mac-studio/main` at xhigh, window 81,920, reserve 16,384, `keepRecentTokens` 20,000, 5 chains (t06 to t10) of 30 tasks, 150 paired tasks, one run per arm, one model throughout. The resolved result is a bound, not a gain. Turns, tokens and seconds are in the file and are not claimed here. Nothing about other windows, models or builds | [`benchmark/RESULTS-ADAPTIVE.md`](benchmark/RESULTS-ADAPTIVE.md) |
|
|
132
|
+
| The 1.0.10 candidate against 1.0.9, 32k window | 0 dead tasks in both arms; no mid-run refusal. Resolved 50 of 60 (1.0.9) and 49 of 60 (1.0.10 candidate): −1.7 points, 95% lower bound −8.3. **The gate failed as registered, by one task**; 1.0.10 shipped as an exception because of the small-window defect. 6 of the 1.0.10 candidate's 60 tasks began without their prompt (4 of them resolved), against 0 in the 1.0.9 arm; fixed in 1.0.12. Turns per task 25.08 and 29.12; cuts 731 and 884; total tokens 13.07 M and 15.12 M | Pre-registered gate. pi-tuck `a67ec30` against 1.0.9 (`cd4626b`). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, `keepRecentTokens` 3,333, chains t01 and t02, 60 paired tasks. One model, one window, one run per arm; 60 tasks cannot separate a one-task difference from run-to-run variation | [`benchmark/RESULTS-V110.md`](benchmark/RESULTS-V110.md) |
|
|
130
133
|
| The 1.0.8 candidate against 1.0.7, 32k window | 0 dead tasks in both arms. Resolved 46 of 60 in both (+0.0 points, 95% lower bound −6.7). No mid-run refusal in either arm. Turns per task 25.37 (1.0.7) and 26.10 (1.0.8) | Pre-registered gate, passed as registered. pi-tuck `a2e7d1a` (the tool-result clip and the cut at the end of a run, on 1.0.6) against 1.0.7. Released 1.0.8 is that build merged with 1.0.7's Metal-only recall; its replay requests are reported byte-identical to the gated build on 29 recordings (stated in the source file, not re-run for this README). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, chains t01 and t02, 60 paired tasks, both arms run together. One model, one window, one run per arm; 60 tasks cannot show a difference smaller than the bound. The run shows that the change introduced no refusal; no refusal occurred in the 1.0.7 arm for the clip to prevent | [`benchmark/RESULTS-V108.md`](benchmark/RESULTS-V108.md) |
|
|
131
134
|
| Cut time, live, 128k | pi-tuck median 0.081 s (46 cuts; 90th percentile 0.279 s). Pi's compaction median 235.3 s (48 cuts; 90th percentile 403.8 s). Context carried after a cut, median: 26,790 tokens (pi-tuck), 27,750 (Pi's compaction) | pi-tuck 1.0.1, Pi 1.4.0, SWE-bench Verified, 10 chains of 30 tasks, window 128,000, reply reserve 32,768, `mac-studio/main:xhigh` writing Pi's summaries. The built-in time depends on that model and on server load. pi-tuck's time is an upper bound, read from the session file: the time between the entry written just before the cut and the compaction entry, so it includes anything between the last message and the cut (pi-tuck's tool-loop cuts emit no compaction events). The 44 failed compaction attempts in chain s09-A have no compaction entry and are not in the counts | [`benchmark/RESULTS-MAIN.md`](benchmark/RESULTS-MAIN.md) test 3 |
|
|
132
135
|
| Cut time, paired replay, 128k | pi-tuck median 0.061 s (95 cuts). Pi's compaction prompts on mac-studio/main:xhigh 154.6 s (86 cuts). Pi's compaction prompts on GPT-6.1 Terra 53.2 s (87 cuts, 19 recordings). Context carried after a cut, median: 27,656, 25,790 and 22,784 tokens; median summary 85,561, 23,598 and 11,200 characters | The same recorded sessions cut at the same raw positions. pi-tuck 1.0.1. Terra's cuts ran through Codex, not on the local server | [`benchmark/RESULTS-MAIN.md`](benchmark/RESULTS-MAIN.md) test 3 |
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type { SemanticRecallConfig, PriorResult, SemanticState } from "./src/sem
|
|
|
7
7
|
export { ensureSemanticRuntime } from "./src/setup.js";
|
|
8
8
|
/** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
|
|
9
9
|
* says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. The same file's `"cutAt"`
|
|
10
|
-
* (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at,
|
|
10
|
+
* (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at, 0.6 by default. */
|
|
11
11
|
/** The oldest Pi with every hook and API pi-tuck uses, and the oldest its tests pass on (Pi 1.2 added the custom message that starts a
|
|
12
12
|
* run to before_agent_start, which the prompt's cut counts). */
|
|
13
13
|
export declare const PI_FLOOR = "1.2.0";
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ export { createSemanticRecall, SemanticRecallController } from "./src/semantic-r
|
|
|
12
12
|
export { ensureSemanticRuntime } from "./src/setup.js";
|
|
13
13
|
/** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
|
|
14
14
|
* says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. The same file's `"cutAt"`
|
|
15
|
-
* (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at,
|
|
15
|
+
* (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at, 0.6 by default. */
|
|
16
16
|
/** The oldest Pi with every hook and API pi-tuck uses, and the oldest its tests pass on (Pi 1.2 added the custom message that starts a
|
|
17
17
|
* run to before_agent_start, which the prompt's cut counts). */
|
|
18
18
|
export const PI_FLOOR = "1.2.0";
|
package/dist/src/budget.d.ts
CHANGED
|
@@ -19,14 +19,17 @@ export declare const TRIGGER_MARGIN_FRACTION = 0.02;
|
|
|
19
19
|
/** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
|
|
20
20
|
export declare const validCutAt: (value: unknown) => value is number;
|
|
21
21
|
/**
|
|
22
|
-
* The one owner of the band: the limit a cut is triggered at and preferably planned to.
|
|
23
|
-
*
|
|
24
|
-
* whatever the cut point. With `cutAt` 1 the band is the room less the margin alone.
|
|
22
|
+
* The one owner of the band: the limit a cut is triggered at and preferably planned to. An adaptive default may raise the cut point above
|
|
23
|
+
* 60% using the last cut's conversation size and current system size, both estimates, so they are converted to the room's units by the factor. Explicit cut points use the fraction alone. The margin caps both.
|
|
25
24
|
*/
|
|
26
|
-
/** The default cut point:
|
|
25
|
+
/** The initial default cut point: 60% of the room before a usable previous cut. Measured against 1 with paired replays (no answer loss) and billed
|
|
27
26
|
* input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
|
|
28
27
|
export declare const DEFAULT_CUT_AT = 0.6;
|
|
29
|
-
export declare const bandOf: (room: number, cutAt?: number
|
|
28
|
+
export declare const bandOf: (room: number, cutAt?: number, adaptive?: {
|
|
29
|
+
system: number;
|
|
30
|
+
tokensAfter: number;
|
|
31
|
+
factor: number;
|
|
32
|
+
}) => number;
|
|
30
33
|
/**
|
|
31
34
|
* Replayed reasoning that Pi's estimator does not see. On the OpenAI Responses APIs an assistant turn's reasoning item rides in its thinking
|
|
32
35
|
* block's `thinkingSignature` (encrypted) and is sent back with later requests (pi-ai openai-responses-shared; transform-messages skips
|
|
@@ -88,6 +91,8 @@ export type Decision<D extends object = {}> =
|
|
|
88
91
|
message: string;
|
|
89
92
|
};
|
|
90
93
|
export interface CutInput<D extends object = {}> {
|
|
94
|
+
/** The band adapts by itself (no explicit cut point), so a cut that isn't due must also free a tenth of the room. */
|
|
95
|
+
usefulOnly?: boolean;
|
|
91
96
|
/** Saved conversation, measured by the planner's own function. */
|
|
92
97
|
before: number;
|
|
93
98
|
/** Input that no cut can remove: system part, host input, queued messages, or the incoming prompt. */
|
|
@@ -116,7 +121,7 @@ export type Verdict<D extends object = {}> = Exclude<Decision<D>, {
|
|
|
116
121
|
* A lean band, one the cut point binds (`band < bandOf(room)`), is planned first and the room plan landing below it is not taken for that:
|
|
117
122
|
* the room plan keeps a native tail of up to half the room, so it can land just under a band that far below the room, and the next round
|
|
118
123
|
* would cut again, freeing little. The room is planned only where the rules above use it: to compare the mode, or when the band has no plan
|
|
119
|
-
* and a cut is due.
|
|
124
|
+
* and a cut is due. An adaptive band can also reach the margin, in which case the room plan is tried first.
|
|
120
125
|
* The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
|
|
121
126
|
* boundaries inside the band too, where no planner ran before the margin.
|
|
122
127
|
*/
|
package/dist/src/budget.js
CHANGED
|
@@ -2,21 +2,22 @@ import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { convertToLlm, estimateTokens } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { measurableMessages, projectedMessages } from "./omissions.js";
|
|
4
4
|
import { isDiscardedResponse } from "./protocol.js";
|
|
5
|
+
import { MAX_TAIL_TOKENS } from "./tuck/cut.js";
|
|
5
6
|
/** Triggers fire this fraction of the room early. The margin absorbs request growth a boundary cannot see; it never enters the calibration factor. */
|
|
6
7
|
export const TRIGGER_MARGIN_FRACTION = 0.02;
|
|
7
8
|
/** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
|
|
8
9
|
export const validCutAt = (value) => typeof value === "number" && value > 0 && value <= 1;
|
|
9
10
|
/**
|
|
10
|
-
* The one owner of the band: the limit a cut is triggered at and preferably planned to.
|
|
11
|
-
*
|
|
12
|
-
* whatever the cut point. With `cutAt` 1 the band is the room less the margin alone.
|
|
11
|
+
* The one owner of the band: the limit a cut is triggered at and preferably planned to. An adaptive default may raise the cut point above
|
|
12
|
+
* 60% using the last cut's conversation size and current system size, both estimates, so they are converted to the room's units by the factor. Explicit cut points use the fraction alone. The margin caps both.
|
|
13
13
|
*/
|
|
14
|
-
/** The default cut point:
|
|
14
|
+
/** The initial default cut point: 60% of the room before a usable previous cut. Measured against 1 with paired replays (no answer loss) and billed
|
|
15
15
|
* input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
|
|
16
16
|
export const DEFAULT_CUT_AT = 0.6;
|
|
17
|
-
export const bandOf = (room, cutAt = 1) => {
|
|
17
|
+
export const bandOf = (room, cutAt = 1, adaptive) => {
|
|
18
18
|
const positive = Math.max(0, room);
|
|
19
|
-
|
|
19
|
+
const base = Math.floor(positive * cutAt);
|
|
20
|
+
return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), adaptive ? Math.max(base, Math.ceil((adaptive.system + adaptive.tokensAfter + MAX_TAIL_TOKENS / 2) * adaptive.factor)) : base);
|
|
20
21
|
};
|
|
21
22
|
/** The APIs whose request converter (pi-ai openai-responses-shared) sends each signed thinking block back as an input reasoning item. */
|
|
22
23
|
const REPLAYING_APIS = new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]);
|
|
@@ -80,23 +81,26 @@ export const planningReserve = (window, targetRoom, factor, mandatory) => window
|
|
|
80
81
|
* A lean band, one the cut point binds (`band < bandOf(room)`), is planned first and the room plan landing below it is not taken for that:
|
|
81
82
|
* the room plan keeps a native tail of up to half the room, so it can land just under a band that far below the room, and the next round
|
|
82
83
|
* would cut again, freeing little. The room is planned only where the rules above use it: to compare the mode, or when the band has no plan
|
|
83
|
-
* and a cut is due.
|
|
84
|
+
* and a cut is due. An adaptive band can also reach the margin, in which case the room plan is tried first.
|
|
84
85
|
* The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
|
|
85
86
|
* boundaries inside the band too, where no planner ran before the margin.
|
|
86
87
|
*/
|
|
87
88
|
export function chooseCut(input) {
|
|
88
89
|
const { before, mandatory, room, band, fits, plan, unreduced } = input;
|
|
89
90
|
const lands = (p) => p.draft && p.after < before && fits(p.after + mandatory, band);
|
|
91
|
+
// A cut no one needs yet must be worth its summary: one that frees under a tenth of the room would trigger again within a round or two.
|
|
92
|
+
// Without this, a band at the cap takes the room plan on every round where free room is small. A due cut is not asked: it has to happen.
|
|
93
|
+
const worthwhile = (p) => !input.usefulOnly || !input.opportunistic || !fits(10 * (before - p.after), room);
|
|
90
94
|
let roomPlan;
|
|
91
95
|
const exact = () => roomPlan ??= plan("room");
|
|
92
96
|
if (band >= bandOf(room)) {
|
|
93
97
|
const e = exact();
|
|
94
|
-
if (lands(e))
|
|
98
|
+
if (lands(e) && worthwhile(e))
|
|
95
99
|
return { kind: "cut", target: "room", plan: e };
|
|
96
100
|
}
|
|
97
101
|
const preferred = plan("band");
|
|
98
102
|
if (input.opportunistic) {
|
|
99
|
-
if (!lands(preferred))
|
|
103
|
+
if (!lands(preferred) || !worthwhile(preferred))
|
|
100
104
|
return { kind: "none" };
|
|
101
105
|
const e = exact();
|
|
102
106
|
return e.draft && e.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "f7849922cdf60513ed72f7cf84d4d817e842a29beb2874f33e90843ea0e5aae6";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "f7849922cdf60513ed72f7cf84d4d817e842a29beb2874f33e90843ea0e5aae6";
|
package/dist/src/compaction.d.ts
CHANGED
|
@@ -35,9 +35,9 @@ export interface CompactionOptions {
|
|
|
35
35
|
/** Optional estimated complete-input ceiling, separate from physical output reservation.
|
|
36
36
|
* The host owns final serialization/transport counting; this is not an exact wire-token cap. */
|
|
37
37
|
activeInputTokens?: number | ((ctx: ExtensionContext) => number);
|
|
38
|
-
/**
|
|
38
|
+
/** 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
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.
|
|
40
|
+
* stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
|
|
41
41
|
cutAt?: number;
|
|
42
42
|
/** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
|
|
43
43
|
* catalogue maximum output, because a transport may omit the output cap and the reply can then grow to that maximum. A host that knows its
|
package/dist/src/compaction.js
CHANGED
|
@@ -230,9 +230,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
230
230
|
ratios.shift(); factor = Math.max(1, ...ratios); };
|
|
231
231
|
const corrected = (tokens, ctx) => { identityFor(ctx); return Math.ceil(tokens * factor); };
|
|
232
232
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
233
|
-
const planReserve = (target, room, mandatory, ctx) => {
|
|
233
|
+
const planReserve = (target, room, band, mandatory, ctx) => {
|
|
234
234
|
identityFor(ctx);
|
|
235
|
-
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ?
|
|
235
|
+
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? band : room, factor, mandatory);
|
|
236
236
|
};
|
|
237
237
|
pi.on("message_end", (event, ctx) => {
|
|
238
238
|
if (event.message.role !== "assistant")
|
|
@@ -303,10 +303,16 @@ export async function installCompaction(pi, options = {}) {
|
|
|
303
303
|
throw new Error("Invalid active-input token ceiling");
|
|
304
304
|
return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
|
|
305
305
|
};
|
|
306
|
-
//
|
|
306
|
+
// Every throw, fault and complete-context guard uses the exact room.
|
|
307
307
|
const limits = (ctx) => {
|
|
308
308
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
309
|
-
return { response, room
|
|
309
|
+
return { response, room };
|
|
310
|
+
};
|
|
311
|
+
const decisionBand = (ctx, room, system) => {
|
|
312
|
+
const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
|
|
313
|
+
const after = last?.type === "compaction" ? last.details?.tokensAfter : undefined;
|
|
314
|
+
return bandOf(room, cutAt, options.cutAt === undefined && typeof after === "number" && Number.isFinite(after) && after >= 0
|
|
315
|
+
? { system, tokensAfter: after, factor } : undefined);
|
|
310
316
|
};
|
|
311
317
|
const fits = (tokens, limit, ctx) => corrected(tokens, ctx) < limit;
|
|
312
318
|
// The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
|
|
@@ -465,13 +471,14 @@ export async function installCompaction(pi, options = {}) {
|
|
|
465
471
|
ctx.ui.notify("pi-tuck does not accept summarization instructions; use /compact without arguments.", "warning");
|
|
466
472
|
return { cancel: true };
|
|
467
473
|
}
|
|
468
|
-
const { response, room
|
|
474
|
+
const { response, room } = limits(ctx);
|
|
469
475
|
const retain = operatorManual || admittedRun;
|
|
470
476
|
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
471
477
|
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
472
478
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
473
479
|
const host = hostTokens(ctx);
|
|
474
|
-
const
|
|
480
|
+
const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
|
|
481
|
+
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
475
482
|
const before = plannerMeasureFor(ctx)(saved);
|
|
476
483
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
477
484
|
// 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
|
|
@@ -486,11 +493,11 @@ export async function installCompaction(pi, options = {}) {
|
|
|
486
493
|
}
|
|
487
494
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
488
495
|
const decision = chooseCut({
|
|
489
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
496
|
+
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
490
497
|
opportunistic: thresholdOpportunistic,
|
|
491
498
|
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
492
499
|
plan: (target) => {
|
|
493
|
-
const reserve = planReserve(target, room, mandatory, ctx);
|
|
500
|
+
const reserve = planReserve(target, room, band, mandatory, ctx);
|
|
494
501
|
// 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
|
|
495
502
|
// from any run that ended. A retry is inside its run.
|
|
496
503
|
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
@@ -545,10 +552,11 @@ export async function installCompaction(pi, options = {}) {
|
|
|
545
552
|
return;
|
|
546
553
|
if (!ctx.model)
|
|
547
554
|
throw missingModel();
|
|
548
|
-
const { response, room
|
|
555
|
+
const { response, room } = limits(ctx);
|
|
549
556
|
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
|
|
550
557
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
551
558
|
const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
|
|
559
|
+
const band = decisionBand(ctx, room, system);
|
|
552
560
|
const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
553
561
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
554
562
|
if (!fits(incoming, room, ctx))
|
|
@@ -560,10 +568,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
560
568
|
return;
|
|
561
569
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
562
570
|
const decision = chooseCut({
|
|
563
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
|
|
571
|
+
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic: gate.opportunistic,
|
|
564
572
|
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
565
573
|
plan: (target) => {
|
|
566
|
-
const reserve = planReserve(target, room, mandatory, ctx);
|
|
574
|
+
const reserve = planReserve(target, room, band, mandatory, ctx);
|
|
567
575
|
// 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
|
|
568
576
|
// there buys nothing the request needed and costs history the next recall would have found.
|
|
569
577
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
@@ -607,12 +615,13 @@ export async function installCompaction(pi, options = {}) {
|
|
|
607
615
|
return;
|
|
608
616
|
if (!ctx.model)
|
|
609
617
|
return;
|
|
610
|
-
const { response, room
|
|
618
|
+
const { response, room } = limits(ctx);
|
|
611
619
|
const measure = measureFor(ctx), saved = event.context.contextMessages;
|
|
612
620
|
// One snapshot per decision. Messages queued for the next request (steering, follow-ups) are in that request but not in the
|
|
613
621
|
// saved conversation. Pi's peek returns the steering queue, else the follow-up queue; a follow-up is not in the very next
|
|
614
622
|
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
615
623
|
const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
|
|
624
|
+
const band = decisionBand(ctx, room, system);
|
|
616
625
|
const host = hostTokens(ctx);
|
|
617
626
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages) : 0;
|
|
618
627
|
const mandatory = system + host + queued;
|
|
@@ -626,10 +635,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
626
635
|
floorKept = undefined;
|
|
627
636
|
const before = measure(saved);
|
|
628
637
|
return decideCut({
|
|
629
|
-
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
638
|
+
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
630
639
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
631
640
|
plan: (target) => {
|
|
632
|
-
const reserve = planReserve(target, room, mandatory, ctx);
|
|
641
|
+
const reserve = planReserve(target, room, band, mandatory, ctx);
|
|
633
642
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
634
643
|
if (!draft.compaction) {
|
|
635
644
|
// The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
|
|
@@ -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 = "f7849922cdf60513ed72f7cf84d4d817e842a29beb2874f33e90843ea0e5aae6";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
package/dist/src/omissions.d.ts
CHANGED
|
@@ -7,7 +7,6 @@ export declare function filterOmittedSummary(summary: string, omitted: ReadonlyS
|
|
|
7
7
|
/** Custom quotes carry native entry identity; no text matching or legacy fallback. */
|
|
8
8
|
export declare function filterCustomQuotes(summary: string, entries: readonly SessionEntry[], branch: readonly SessionEntry[], policy?: RecallPolicy): string;
|
|
9
9
|
type ProjectedMessages = ReturnType<ExtensionContext['sessionManager']['buildSessionProjection']>['messages'];
|
|
10
|
-
/** The saved conversation as this compactor delivers it: native omissions and ineligible custom quotes removed from summaries. */
|
|
11
10
|
export declare function projectedMessages(messages: ProjectedMessages, ctx: ExtensionContext, policy?: RecallPolicy): ProjectedMessages;
|
|
12
11
|
/**
|
|
13
12
|
* The same as `projectedMessages` for every summary in this compactor's format, and it never throws for any other: a summary of another
|
package/dist/src/omissions.js
CHANGED
|
@@ -4,14 +4,14 @@ import { createRecall } from './recall.js';
|
|
|
4
4
|
/** Native branch edits supply exclusion; original global indices supply provenance. */
|
|
5
5
|
export function omittedSourceIndices(entries, branch) {
|
|
6
6
|
const indices = buildGlobalIndexById(entries);
|
|
7
|
-
const edits = new
|
|
7
|
+
const edits = new Set();
|
|
8
8
|
for (const entry of branch)
|
|
9
9
|
if (entry.type === 'context_edit') {
|
|
10
10
|
if (entry.replacement !== null)
|
|
11
11
|
throw new Error('pi-tuck does not support replacement context edits');
|
|
12
|
-
edits.
|
|
12
|
+
edits.add(entry.targetId);
|
|
13
13
|
}
|
|
14
|
-
return new Set([...edits].flatMap(
|
|
14
|
+
return new Set([...edits].flatMap(id => indices.has(id) ? [indices.get(id)] : []));
|
|
15
15
|
}
|
|
16
16
|
/** Preserve surviving record bytes/order, and the summary's format (a v1 summary stays v1). No text matching or semantic inference. */
|
|
17
17
|
export function filterOmittedSummary(summary, omitted) {
|
|
@@ -59,22 +59,20 @@ export function filterCustomQuotes(summary, entries, branch, policy = {}) {
|
|
|
59
59
|
return transcriptHeader(version) + '\n' + retained.join('\n') + closing;
|
|
60
60
|
}
|
|
61
61
|
/** The saved conversation as this compactor delivers it: native omissions and ineligible custom quotes removed from summaries. */
|
|
62
|
-
|
|
62
|
+
function projectMessages(messages, ctx, policy, allowForeign) {
|
|
63
63
|
const entries = ctx.sessionManager.getEntries(), branch = ctx.sessionManager.getBranch();
|
|
64
64
|
const omitted = omittedSourceIndices(entries, branch);
|
|
65
|
-
return messages.map(message => message.role === 'compactionSummary'
|
|
65
|
+
return messages.map(message => message.role === 'compactionSummary' && (!allowForeign || splitSummary(message.summary) !== undefined)
|
|
66
66
|
? { ...message, summary: filterCustomQuotes(filterOmittedSummary(message.summary, omitted), entries, branch, policy) } : message);
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
export function projectedMessages(messages, ctx, policy) {
|
|
69
|
+
return projectMessages(messages, ctx, policy, false);
|
|
70
|
+
}
|
|
70
71
|
/**
|
|
71
72
|
* The same as `projectedMessages` for every summary in this compactor's format, and it never throws for any other: a summary of another
|
|
72
73
|
* format (a Pi default summary, an empty draft) cannot be filtered and is left as it is. For counting a conversation that a cut is about
|
|
73
74
|
* to replace, such as an operator's /compact on a session whose saved summary is foreign. The context hook and the triggers stay strict.
|
|
74
75
|
*/
|
|
75
76
|
export function measurableMessages(messages, ctx, policy) {
|
|
76
|
-
|
|
77
|
-
const omitted = omittedSourceIndices(entries, branch);
|
|
78
|
-
return messages.map(message => message.role === 'compactionSummary' && isTranscriptSummary(message.summary)
|
|
79
|
-
? { ...message, summary: filterCustomQuotes(filterOmittedSummary(message.summary, omitted), entries, branch, policy) } : message);
|
|
77
|
+
return projectMessages(messages, ctx, policy, true);
|
|
80
78
|
}
|
|
@@ -38,13 +38,12 @@ function physicalPath(path) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
const inside = (base, target) => { const rel = relative(base, target); return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); };
|
|
41
42
|
function outsideProject(project, path, kind) {
|
|
42
|
-
const inside = (base, target) => { const rel = relative(base, target); return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); };
|
|
43
43
|
if (inside(resolve(project), resolve(path)) || inside(physicalPath(project), physicalPath(path)))
|
|
44
44
|
throw new Error('Semantic ' + kind + ' must be outside project');
|
|
45
45
|
}
|
|
46
46
|
function outsideRuntime(runtime, path, kind) {
|
|
47
|
-
const inside = (base, target) => { const rel = relative(base, target); return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); };
|
|
48
47
|
if (inside(resolve(runtime), resolve(path)) || inside(physicalPath(runtime), physicalPath(path)))
|
|
49
48
|
throw new Error('Semantic ' + kind + ' must be outside immutable runtime');
|
|
50
49
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session-global message indices — the single definition of the `#N` index space.
|
|
3
3
|
*
|
|
4
|
-
* Recall
|
|
4
|
+
* Recall numbers every `type == "message"` entry
|
|
5
5
|
* in session-file order, counting across compaction windows and abandoned
|
|
6
6
|
* branches. Compaction summaries must emit the same numbers, but `normalize`
|
|
7
7
|
* historically numbered the selected window from zero. This module owns the
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
export declare const isCountedMessageEntry: (entry: any) => boolean;
|
|
14
14
|
/**
|
|
15
15
|
* Map session entry ids to their global message index (position among counted
|
|
16
|
-
* message entries in array order, which matches session-file order).
|
|
16
|
+
* message entries in array order, which matches session-file order). Entries
|
|
17
|
+
* without a usable id still occupy an index. Duplicate ids are dropped
|
|
18
|
+
* fail-closed so callers emit no ref instead of a wrong one.
|
|
17
19
|
*/
|
|
18
20
|
export declare const buildGlobalIndexById: (entries: readonly any[]) => Map<string, number>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session-global message indices — the single definition of the `#N` index space.
|
|
3
3
|
*
|
|
4
|
-
* Recall
|
|
4
|
+
* Recall numbers every `type == "message"` entry
|
|
5
5
|
* in session-file order, counting across compaction windows and abandoned
|
|
6
6
|
* branches. Compaction summaries must emit the same numbers, but `normalize`
|
|
7
7
|
* historically numbered the selected window from zero. This module owns the
|
|
@@ -12,43 +12,29 @@
|
|
|
12
12
|
/** Entries counted by the global `#N` index: persisted message entries. */
|
|
13
13
|
export const isCountedMessageEntry = (entry) => entry?.type === "message" && entry.message != null;
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
* in
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* produce no map entry. Duplicate ids are ambiguous — dropped fail-closed so
|
|
20
|
-
* callers emit no ref instead of a wrong one.
|
|
15
|
+
* Map session entry ids to their global message index (position among counted
|
|
16
|
+
* message entries in array order, which matches session-file order). Entries
|
|
17
|
+
* without a usable id still occupy an index. Duplicate ids are dropped
|
|
18
|
+
* fail-closed so callers emit no ref instead of a wrong one.
|
|
21
19
|
*/
|
|
22
|
-
const
|
|
20
|
+
export const buildGlobalIndexById = (entries) => {
|
|
23
21
|
const byId = new Map();
|
|
24
22
|
const ambiguous = new Set();
|
|
25
23
|
let messageIndex = 0;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
ambiguous.add(id);
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
byId.set(id, messageIndex);
|
|
38
|
-
}
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (!isCountedMessageEntry(entry))
|
|
26
|
+
continue;
|
|
27
|
+
const id = entry?.id;
|
|
28
|
+
if (typeof id === "string" && id.length > 0) {
|
|
29
|
+
if (byId.has(id) || ambiguous.has(id)) {
|
|
30
|
+
byId.delete(id);
|
|
31
|
+
ambiguous.add(id);
|
|
39
32
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
* message entries in array order, which matches session-file order).
|
|
48
|
-
*/
|
|
49
|
-
export const buildGlobalIndexById = (entries) => {
|
|
50
|
-
const b = createGlobalIndexBuilder();
|
|
51
|
-
for (const entry of entries)
|
|
52
|
-
b.add(entry);
|
|
53
|
-
return b.map();
|
|
33
|
+
else {
|
|
34
|
+
byId.set(id, messageIndex);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
messageIndex++;
|
|
38
|
+
}
|
|
39
|
+
return byId;
|
|
54
40
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CompilerMessage, NormalizedBlock } from "../types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Normalize messages to blocks. `sourceIndices`, when provided, supplies the
|
|
4
|
-
* session-global `#N` index per input position (see
|
|
4
|
+
* session-global `#N` index per input position (see global-indices.ts).
|
|
5
5
|
* A missing entry yields `sourceIndex: undefined`, which downstream renderers
|
|
6
6
|
* display as no ref — never a window-relative number. Omitted entirely, the
|
|
7
7
|
* legacy positional behavior is preserved (used by callers/tests that have no
|
|
@@ -52,7 +52,7 @@ const normalizeOne = (msg, msgIndex) => {
|
|
|
52
52
|
};
|
|
53
53
|
/**
|
|
54
54
|
* Normalize messages to blocks. `sourceIndices`, when provided, supplies the
|
|
55
|
-
* session-global `#N` index per input position (see
|
|
55
|
+
* session-global `#N` index per input position (see global-indices.ts).
|
|
56
56
|
* A missing entry yields `sourceIndex: undefined`, which downstream renderers
|
|
57
57
|
* display as no ref — never a window-relative number. Omitted entirely, the
|
|
58
58
|
* legacy positional behavior is preserved (used by callers/tests that have no
|
package/dist/src/tuck/cut.js
CHANGED
|
@@ -52,29 +52,16 @@ const collectLiveMessages = (branchEntries) => {
|
|
|
52
52
|
const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
|
|
53
53
|
// Collect live messages
|
|
54
54
|
const liveMessages = [];
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
else {
|
|
66
|
-
let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
|
|
67
|
-
for (const e of branchEntries) {
|
|
68
|
-
if (!foundKept && e.id === lastKeptId)
|
|
69
|
-
foundKept = true;
|
|
70
|
-
if (!foundKept)
|
|
71
|
-
continue;
|
|
72
|
-
if (e.type === "compaction")
|
|
73
|
-
continue;
|
|
74
|
-
const m = toLiveMessage(e);
|
|
75
|
-
if (m)
|
|
76
|
-
liveMessages.push({ entry: e, message: m });
|
|
77
|
-
}
|
|
55
|
+
let foundKept = orphanRecovery || !lastKeptId;
|
|
56
|
+
for (let i = orphanRecovery ? lastCompactionIdx + 1 : 0; i < branchEntries.length; i++) {
|
|
57
|
+
const e = branchEntries[i];
|
|
58
|
+
if (!foundKept && e.id === lastKeptId)
|
|
59
|
+
foundKept = true;
|
|
60
|
+
if (!foundKept || e.type === "compaction")
|
|
61
|
+
continue;
|
|
62
|
+
const m = toLiveMessage(e);
|
|
63
|
+
if (m)
|
|
64
|
+
liveMessages.push({ entry: e, message: m });
|
|
78
65
|
}
|
|
79
66
|
return liveMessages;
|
|
80
67
|
};
|
package/dist/src/tuck.js
CHANGED
|
@@ -53,8 +53,8 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
53
53
|
const chars = (calibration.ok ? calibration.messages.reduce((n, m) => n + estimateSourceMessageChars(m), 0) : 0)
|
|
54
54
|
+ (input.previousSummary?.length ?? 0);
|
|
55
55
|
const { charsPerToken } = tuck.calibrateCharsPerToken(chars, input.tokensBefore);
|
|
56
|
-
//
|
|
57
|
-
//
|
|
56
|
+
// The planner's reserve includes mandatory input (system, host and incoming prompt), so this is half of the target room *after* that
|
|
57
|
+
// fixed input, not half of the whole room. Current/fresh-source floors below may still require an explicit rejection.
|
|
58
58
|
const inputRoom = Math.floor(Math.max(0, (ctx.model?.contextWindow ?? 0) - reserveTokens - 1) / 2);
|
|
59
59
|
const maxTokens = Math.min(MAX_TAIL_TOKENS, inputRoom);
|
|
60
60
|
// Keep the current user turn native; older conversation competes in the
|
package/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { ensureSemanticRuntime } from "./src/setup";
|
|
|
16
16
|
|
|
17
17
|
/** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
|
|
18
18
|
* says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. The same file's `"cutAt"`
|
|
19
|
-
* (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at,
|
|
19
|
+
* (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at, 0.6 by default. */
|
|
20
20
|
/** The oldest Pi with every hook and API pi-tuck uses, and the oldest its tests pass on (Pi 1.2 added the custom message that starts a
|
|
21
21
|
* run to before_agent_start, which the prompt's cut counts). */
|
|
22
22
|
export const PI_FLOOR = "1.2.0";
|
package/package.json
CHANGED
package/src/budget.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { convertToLlm, estimateTokens, type BeforeAgentStartEvent, type Extensio
|
|
|
3
3
|
import { measurableMessages, projectedMessages } from "./omissions.js";
|
|
4
4
|
import type { RecallPolicy } from "./recall.js";
|
|
5
5
|
import { isDiscardedResponse } from "./protocol.js";
|
|
6
|
+
import { MAX_TAIL_TOKENS } from "./tuck/cut.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The one measure of the next request. The cut triggers, the cut planner (including the draft admission in tuck.ts), the post-cut
|
|
@@ -19,16 +20,16 @@ export const TRIGGER_MARGIN_FRACTION = 0.02;
|
|
|
19
20
|
/** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
|
|
20
21
|
export const validCutAt = (value: unknown): value is number => typeof value === "number" && value > 0 && value <= 1;
|
|
21
22
|
/**
|
|
22
|
-
* The one owner of the band: the limit a cut is triggered at and preferably planned to.
|
|
23
|
-
*
|
|
24
|
-
* whatever the cut point. With `cutAt` 1 the band is the room less the margin alone.
|
|
23
|
+
* The one owner of the band: the limit a cut is triggered at and preferably planned to. An adaptive default may raise the cut point above
|
|
24
|
+
* 60% using the last cut's conversation size and current system size, both estimates, so they are converted to the room's units by the factor. Explicit cut points use the fraction alone. The margin caps both.
|
|
25
25
|
*/
|
|
26
|
-
/** The default cut point:
|
|
26
|
+
/** The initial default cut point: 60% of the room before a usable previous cut. Measured against 1 with paired replays (no answer loss) and billed
|
|
27
27
|
* input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
|
|
28
28
|
export const DEFAULT_CUT_AT = 0.6;
|
|
29
|
-
export const bandOf = (room: number, cutAt = 1): number => {
|
|
29
|
+
export const bandOf = (room: number, cutAt = 1, adaptive?: { system: number; tokensAfter: number; factor: number }): number => {
|
|
30
30
|
const positive = Math.max(0, room);
|
|
31
|
-
|
|
31
|
+
const base = Math.floor(positive * cutAt);
|
|
32
|
+
return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), adaptive ? Math.max(base, Math.ceil((adaptive.system + adaptive.tokensAfter + MAX_TAIL_TOKENS / 2) * adaptive.factor)) : base);
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
/** The APIs whose request converter (pi-ai openai-responses-shared) sends each signed thinking block back as an input reasoning item. */
|
|
@@ -100,6 +101,8 @@ export type Decision<D extends object = {}> =
|
|
|
100
101
|
| { kind: "cut"; target: Target; plan: Extract<Plan<D>, { draft: true }> }
|
|
101
102
|
| { kind: "fault"; capacity: boolean; message: string };
|
|
102
103
|
export interface CutInput<D extends object = {}> {
|
|
104
|
+
/** The band adapts by itself (no explicit cut point), so a cut that isn't due must also free a tenth of the room. */
|
|
105
|
+
usefulOnly?: boolean;
|
|
103
106
|
/** Saved conversation, measured by the planner's own function. */
|
|
104
107
|
before: number;
|
|
105
108
|
/** Input that no cut can remove: system part, host input, queued messages, or the incoming prompt. */
|
|
@@ -126,19 +129,22 @@ export type Verdict<D extends object = {}> = Exclude<Decision<D>, { kind: "defer
|
|
|
126
129
|
* A lean band, one the cut point binds (`band < bandOf(room)`), is planned first and the room plan landing below it is not taken for that:
|
|
127
130
|
* the room plan keeps a native tail of up to half the room, so it can land just under a band that far below the room, and the next round
|
|
128
131
|
* would cut again, freeing little. The room is planned only where the rules above use it: to compare the mode, or when the band has no plan
|
|
129
|
-
* and a cut is due.
|
|
132
|
+
* and a cut is due. An adaptive band can also reach the margin, in which case the room plan is tried first.
|
|
130
133
|
* The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
|
|
131
134
|
* boundaries inside the band too, where no planner ran before the margin.
|
|
132
135
|
*/
|
|
133
136
|
export function chooseCut<D extends object = {}>(input: CutInput<D> & { opportunistic: boolean }): Verdict<D> {
|
|
134
137
|
const { before, mandatory, room, band, fits, plan, unreduced } = input;
|
|
135
138
|
const lands = (p: Plan<D>): p is Extract<Plan<D>, { draft: true }> => p.draft && p.after < before && fits(p.after + mandatory, band);
|
|
139
|
+
// A cut no one needs yet must be worth its summary: one that frees under a tenth of the room would trigger again within a round or two.
|
|
140
|
+
// Without this, a band at the cap takes the room plan on every round where free room is small. A due cut is not asked: it has to happen.
|
|
141
|
+
const worthwhile = (p: Extract<Plan<D>, { draft: true }>) => !input.usefulOnly || !input.opportunistic || !fits(10 * (before - p.after), room);
|
|
136
142
|
let roomPlan: Plan<D> | undefined;
|
|
137
143
|
const exact = () => roomPlan ??= plan("room");
|
|
138
|
-
if (band >= bandOf(room)) { const e = exact(); if (lands(e)) return { kind: "cut", target: "room", plan: e }; }
|
|
144
|
+
if (band >= bandOf(room)) { const e = exact(); if (lands(e) && worthwhile(e)) return { kind: "cut", target: "room", plan: e }; }
|
|
139
145
|
const preferred = plan("band");
|
|
140
146
|
if (input.opportunistic) {
|
|
141
|
-
if (!lands(preferred)) return { kind: "none" };
|
|
147
|
+
if (!lands(preferred) || !worthwhile(preferred)) return { kind: "none" };
|
|
142
148
|
const e = exact();
|
|
143
149
|
return e.draft && e.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
|
|
144
150
|
}
|
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "f7849922cdf60513ed72f7cf84d4d817e842a29beb2874f33e90843ea0e5aae6";
|
package/src/compaction.ts
CHANGED
|
@@ -103,9 +103,9 @@ export interface CompactionOptions {
|
|
|
103
103
|
/** Optional estimated complete-input ceiling, separate from physical output reservation.
|
|
104
104
|
* The host owns final serialization/transport counting; this is not an exact wire-token cap. */
|
|
105
105
|
activeInputTokens?: number | ((ctx: ExtensionContext) => number);
|
|
106
|
-
/**
|
|
106
|
+
/** 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,
|
|
107
107
|
* 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
|
|
108
|
-
* stays the limit of every throw, fault and the complete-context guard.
|
|
108
|
+
* stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
|
|
109
109
|
cutAt?: number;
|
|
110
110
|
/** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
|
|
111
111
|
* catalogue maximum output, because a transport may omit the output cap and the reply can then grow to that maximum. A host that knows its
|
|
@@ -216,9 +216,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
216
216
|
const learn = (value: number) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW) ratios.shift(); factor = Math.max(1, ...ratios); };
|
|
217
217
|
const corrected = (tokens: number, ctx: ExtensionContext) => { identityFor(ctx); return Math.ceil(tokens * factor); };
|
|
218
218
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
219
|
-
const planReserve = (target: Target, room: number, mandatory: number, ctx: ExtensionContext) => {
|
|
219
|
+
const planReserve = (target: Target, room: number, band: number, mandatory: number, ctx: ExtensionContext) => {
|
|
220
220
|
identityFor(ctx);
|
|
221
|
-
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ?
|
|
221
|
+
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? band : room, factor, mandatory);
|
|
222
222
|
};
|
|
223
223
|
pi.on("message_end", (event, ctx) => {
|
|
224
224
|
if (event.message.role !== "assistant") return;
|
|
@@ -280,10 +280,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
280
280
|
throw new Error("Invalid active-input token ceiling");
|
|
281
281
|
return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
|
|
282
282
|
};
|
|
283
|
-
//
|
|
283
|
+
// Every throw, fault and complete-context guard uses the exact room.
|
|
284
284
|
const limits = (ctx: ExtensionContext) => {
|
|
285
285
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
286
|
-
return { response, room
|
|
286
|
+
return { response, room };
|
|
287
|
+
};
|
|
288
|
+
const decisionBand = (ctx: ExtensionContext, room: number, system: number) => {
|
|
289
|
+
const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
|
|
290
|
+
const after = last?.type === "compaction" ? (last.details as { tokensAfter?: unknown } | undefined)?.tokensAfter : undefined;
|
|
291
|
+
return bandOf(room, cutAt, options.cutAt === undefined && typeof after === "number" && Number.isFinite(after) && after >= 0
|
|
292
|
+
? { system, tokensAfter: after, factor } : undefined);
|
|
287
293
|
};
|
|
288
294
|
const fits = (tokens: number, limit: number, ctx: ExtensionContext) => corrected(tokens, ctx) < limit;
|
|
289
295
|
// The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
|
|
@@ -413,13 +419,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
413
419
|
ctx.ui.notify("pi-tuck does not accept summarization instructions; use /compact without arguments.", "warning");
|
|
414
420
|
return { cancel: true };
|
|
415
421
|
}
|
|
416
|
-
const { response, room
|
|
422
|
+
const { response, room } = limits(ctx);
|
|
417
423
|
const retain = operatorManual || admittedRun;
|
|
418
424
|
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
419
425
|
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
420
426
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
421
427
|
const host = hostTokens(ctx);
|
|
422
|
-
const
|
|
428
|
+
const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
|
|
429
|
+
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
423
430
|
const before = plannerMeasureFor(ctx)(saved);
|
|
424
431
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
425
432
|
// 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
|
|
@@ -433,11 +440,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
433
440
|
}
|
|
434
441
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
435
442
|
const decision = chooseCut({
|
|
436
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
443
|
+
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
437
444
|
opportunistic: thresholdOpportunistic,
|
|
438
445
|
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
439
446
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
|
|
440
|
-
const reserve = planReserve(target, room, mandatory, ctx);
|
|
447
|
+
const reserve = planReserve(target, room, band, mandatory, ctx);
|
|
441
448
|
// 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
|
|
442
449
|
// from any run that ended. A retry is inside its run.
|
|
443
450
|
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
@@ -479,10 +486,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
479
486
|
access();
|
|
480
487
|
if (!settingsFor().getCompactionSettings(ctx.model).enabled) return;
|
|
481
488
|
if (!ctx.model) throw missingModel();
|
|
482
|
-
const { response, room
|
|
489
|
+
const { response, room } = limits(ctx);
|
|
483
490
|
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
|
|
484
491
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
485
492
|
const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
|
|
493
|
+
const band = decisionBand(ctx, room, system);
|
|
486
494
|
const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
487
495
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
488
496
|
if (!fits(incoming, room, ctx))
|
|
@@ -493,10 +501,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
493
501
|
if (gate.kind !== "plan") return;
|
|
494
502
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
495
503
|
const decision = chooseCut({
|
|
496
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
|
|
504
|
+
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic: gate.opportunistic,
|
|
497
505
|
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
498
506
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
499
|
-
const reserve = planReserve(target, room, mandatory, ctx);
|
|
507
|
+
const reserve = planReserve(target, room, band, mandatory, ctx);
|
|
500
508
|
// 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
|
|
501
509
|
// there buys nothing the request needed and costs history the next recall would have found.
|
|
502
510
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
@@ -532,12 +540,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
532
540
|
if (event.entries.length || !isDeepStrictEqual(event.context.contextMessages,
|
|
533
541
|
ctx.sessionManager.buildSessionProjection().messages)) return;
|
|
534
542
|
if (!ctx.model) return;
|
|
535
|
-
const { response, room
|
|
543
|
+
const { response, room } = limits(ctx);
|
|
536
544
|
const measure = measureFor(ctx), saved = event.context.contextMessages;
|
|
537
545
|
// One snapshot per decision. Messages queued for the next request (steering, follow-ups) are in that request but not in the
|
|
538
546
|
// saved conversation. Pi's peek returns the steering queue, else the follow-up queue; a follow-up is not in the very next
|
|
539
547
|
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
540
548
|
const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
|
|
549
|
+
const band = decisionBand(ctx, room, system);
|
|
541
550
|
const host = hostTokens(ctx);
|
|
542
551
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
|
|
543
552
|
const mandatory = system + host + queued;
|
|
@@ -551,10 +560,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
551
560
|
floorKept = undefined;
|
|
552
561
|
const before = measure(saved);
|
|
553
562
|
return decideCut({
|
|
554
|
-
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
563
|
+
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
555
564
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
556
565
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
557
|
-
const reserve = planReserve(target, room, mandatory, ctx);
|
|
566
|
+
const reserve = planReserve(target, room, band, mandatory, ctx);
|
|
558
567
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
559
568
|
if (!draft.compaction) {
|
|
560
569
|
// The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
|
package/src/omissions.ts
CHANGED
|
@@ -5,12 +5,12 @@ import { createRecall, type RecallPolicy } from './recall';
|
|
|
5
5
|
/** Native branch edits supply exclusion; original global indices supply provenance. */
|
|
6
6
|
export function omittedSourceIndices(entries: readonly SessionEntry[], branch: readonly SessionEntry[]): ReadonlySet<number> {
|
|
7
7
|
const indices = buildGlobalIndexById(entries);
|
|
8
|
-
const edits = new
|
|
8
|
+
const edits = new Set<string>();
|
|
9
9
|
for (const entry of branch) if (entry.type === 'context_edit') {
|
|
10
10
|
if (entry.replacement !== null) throw new Error('pi-tuck does not support replacement context edits');
|
|
11
|
-
edits.
|
|
11
|
+
edits.add(entry.targetId);
|
|
12
12
|
}
|
|
13
|
-
return new Set([...edits].flatMap(
|
|
13
|
+
return new Set([...edits].flatMap(id => indices.has(id) ? [indices.get(id)!] : []));
|
|
14
14
|
}
|
|
15
15
|
/** Preserve surviving record bytes/order, and the summary's format (a v1 summary stays v1). No text matching or semantic inference. */
|
|
16
16
|
export function filterOmittedSummary(summary: string, omitted: ReadonlySet<number>): string {
|
|
@@ -49,22 +49,20 @@ export function filterCustomQuotes(summary: string, entries: readonly SessionEnt
|
|
|
49
49
|
}
|
|
50
50
|
type ProjectedMessages = ReturnType<ExtensionContext['sessionManager']['buildSessionProjection']>['messages'];
|
|
51
51
|
/** The saved conversation as this compactor delivers it: native omissions and ineligible custom quotes removed from summaries. */
|
|
52
|
-
|
|
52
|
+
function projectMessages(messages: ProjectedMessages, ctx: ExtensionContext, policy: RecallPolicy | undefined, allowForeign: boolean): ProjectedMessages {
|
|
53
53
|
const entries = ctx.sessionManager.getEntries(), branch = ctx.sessionManager.getBranch();
|
|
54
54
|
const omitted = omittedSourceIndices(entries, branch);
|
|
55
|
-
return messages.map(message => message.role === 'compactionSummary'
|
|
55
|
+
return messages.map(message => message.role === 'compactionSummary' && (!allowForeign || splitSummary(message.summary) !== undefined)
|
|
56
56
|
? { ...message, summary: filterCustomQuotes(filterOmittedSummary(message.summary, omitted), entries, branch, policy) } : message);
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
export function projectedMessages(messages: ProjectedMessages, ctx: ExtensionContext, policy?: RecallPolicy): ProjectedMessages {
|
|
59
|
+
return projectMessages(messages, ctx, policy, false);
|
|
60
|
+
}
|
|
60
61
|
/**
|
|
61
62
|
* The same as `projectedMessages` for every summary in this compactor's format, and it never throws for any other: a summary of another
|
|
62
63
|
* format (a Pi default summary, an empty draft) cannot be filtered and is left as it is. For counting a conversation that a cut is about
|
|
63
64
|
* to replace, such as an operator's /compact on a session whose saved summary is foreign. The context hook and the triggers stay strict.
|
|
64
65
|
*/
|
|
65
66
|
export function measurableMessages(messages: ProjectedMessages, ctx: ExtensionContext, policy?: RecallPolicy): ProjectedMessages {
|
|
66
|
-
|
|
67
|
-
const omitted = omittedSourceIndices(entries, branch);
|
|
68
|
-
return messages.map(message => message.role === 'compactionSummary' && isTranscriptSummary(message.summary)
|
|
69
|
-
? { ...message, summary: filterCustomQuotes(filterOmittedSummary(message.summary, omitted), entries, branch, policy) } : message);
|
|
67
|
+
return projectMessages(messages, ctx, policy, true);
|
|
70
68
|
}
|
package/src/semantic-recall.ts
CHANGED
|
@@ -21,12 +21,11 @@ function physicalPath(path:string):string {
|
|
|
21
21
|
const next=dirname(parent);if(next===parent)throw error;parts.unshift(basename(parent));parent=next;
|
|
22
22
|
}}
|
|
23
23
|
}
|
|
24
|
+
const inside=(base:string,target:string)=>{const rel=relative(base,target);return rel!=='..'&&!rel.startsWith('..'+sep)&&!isAbsolute(rel);};
|
|
24
25
|
function outsideProject(project:string,path:string,kind:'configuration'|'index'|'runtime'|'scratch'){
|
|
25
|
-
const inside=(base:string,target:string)=>{const rel=relative(base,target);return rel!=='..'&&!rel.startsWith('..'+sep)&&!isAbsolute(rel);};
|
|
26
26
|
if(inside(resolve(project),resolve(path))||inside(physicalPath(project),physicalPath(path)))throw new Error('Semantic '+kind+' must be outside project');
|
|
27
27
|
}
|
|
28
28
|
function outsideRuntime(runtime:string,path:string,kind:'index'|'scratch'){
|
|
29
|
-
const inside=(base:string,target:string)=>{const rel=relative(base,target);return rel!=='..'&&!rel.startsWith('..'+sep)&&!isAbsolute(rel);};
|
|
30
29
|
if(inside(resolve(runtime),resolve(path))||inside(physicalPath(runtime),physicalPath(path)))throw new Error('Semantic '+kind+' must be outside immutable runtime');
|
|
31
30
|
}
|
|
32
31
|
function scratchParent(config:SemanticRecallConfig){return config.scratchDirectory??dirname(config.indexDirectory);}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session-global message indices — the single definition of the `#N` index space.
|
|
3
3
|
*
|
|
4
|
-
* Recall
|
|
4
|
+
* Recall numbers every `type == "message"` entry
|
|
5
5
|
* in session-file order, counting across compaction windows and abandoned
|
|
6
6
|
* branches. Compaction summaries must emit the same numbers, but `normalize`
|
|
7
7
|
* historically numbered the selected window from zero. This module owns the
|
|
@@ -15,41 +15,27 @@ export const isCountedMessageEntry = (entry: any): boolean =>
|
|
|
15
15
|
entry?.type === "message" && entry.message != null;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
* in
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* produce no map entry. Duplicate ids are ambiguous — dropped fail-closed so
|
|
23
|
-
* callers emit no ref instead of a wrong one.
|
|
18
|
+
* Map session entry ids to their global message index (position among counted
|
|
19
|
+
* message entries in array order, which matches session-file order). Entries
|
|
20
|
+
* without a usable id still occupy an index. Duplicate ids are dropped
|
|
21
|
+
* fail-closed so callers emit no ref instead of a wrong one.
|
|
24
22
|
*/
|
|
25
|
-
const
|
|
23
|
+
export const buildGlobalIndexById = (entries: readonly any[]): Map<string, number> => {
|
|
26
24
|
const byId = new Map<string, number>();
|
|
27
25
|
const ambiguous = new Set<string>();
|
|
28
26
|
let messageIndex = 0;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
byId.set(id, messageIndex);
|
|
39
|
-
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (!isCountedMessageEntry(entry)) continue;
|
|
29
|
+
const id = entry?.id;
|
|
30
|
+
if (typeof id === "string" && id.length > 0) {
|
|
31
|
+
if (byId.has(id) || ambiguous.has(id)) {
|
|
32
|
+
byId.delete(id);
|
|
33
|
+
ambiguous.add(id);
|
|
34
|
+
} else {
|
|
35
|
+
byId.set(id, messageIndex);
|
|
40
36
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Map session entry ids to their global message index (position among counted
|
|
49
|
-
* message entries in array order, which matches session-file order).
|
|
50
|
-
*/
|
|
51
|
-
export const buildGlobalIndexById = (entries: readonly any[]): Map<string, number> => {
|
|
52
|
-
const b = createGlobalIndexBuilder();
|
|
53
|
-
for (const entry of entries) b.add(entry);
|
|
54
|
-
return b.map();
|
|
37
|
+
}
|
|
38
|
+
messageIndex++;
|
|
39
|
+
}
|
|
40
|
+
return byId;
|
|
55
41
|
};
|
|
@@ -54,7 +54,7 @@ const normalizeOne = (msg: CompilerMessage, msgIndex: number | undefined): Norma
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Normalize messages to blocks. `sourceIndices`, when provided, supplies the
|
|
57
|
-
* session-global `#N` index per input position (see
|
|
57
|
+
* session-global `#N` index per input position (see global-indices.ts).
|
|
58
58
|
* A missing entry yields `sourceIndex: undefined`, which downstream renderers
|
|
59
59
|
* display as no ref — never a window-relative number. Omitted entirely, the
|
|
60
60
|
* legacy positional behavior is preserved (used by callers/tests that have no
|
package/src/tuck/cut.ts
CHANGED
|
@@ -75,22 +75,13 @@ const collectLiveMessages = (branchEntries: any[]): EntryWithMessage[] => {
|
|
|
75
75
|
|
|
76
76
|
// Collect live messages
|
|
77
77
|
const liveMessages: EntryWithMessage[] = [];
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
} else {
|
|
86
|
-
let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
|
|
87
|
-
for (const e of branchEntries) {
|
|
88
|
-
if (!foundKept && e.id === lastKeptId) foundKept = true;
|
|
89
|
-
if (!foundKept) continue;
|
|
90
|
-
if (e.type === "compaction") continue;
|
|
91
|
-
const m = toLiveMessage(e);
|
|
92
|
-
if (m) liveMessages.push({ entry: e, message: m });
|
|
93
|
-
}
|
|
78
|
+
let foundKept = orphanRecovery || !lastKeptId;
|
|
79
|
+
for (let i = orphanRecovery ? lastCompactionIdx + 1 : 0; i < branchEntries.length; i++) {
|
|
80
|
+
const e = branchEntries[i];
|
|
81
|
+
if (!foundKept && e.id === lastKeptId) foundKept = true;
|
|
82
|
+
if (!foundKept || e.type === "compaction") continue;
|
|
83
|
+
const m = toLiveMessage(e);
|
|
84
|
+
if (m) liveMessages.push({ entry: e, message: m });
|
|
94
85
|
}
|
|
95
86
|
return liveMessages;
|
|
96
87
|
};
|
package/src/tuck.ts
CHANGED
|
@@ -85,8 +85,8 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
85
85
|
const chars = (calibration.ok ? calibration.messages.reduce((n, m) => n + estimateSourceMessageChars(m), 0) : 0)
|
|
86
86
|
+ (input.previousSummary?.length ?? 0);
|
|
87
87
|
const { charsPerToken } = tuck.calibrateCharsPerToken(chars, input.tokensBefore);
|
|
88
|
-
//
|
|
89
|
-
//
|
|
88
|
+
// The planner's reserve includes mandatory input (system, host and incoming prompt), so this is half of the target room *after* that
|
|
89
|
+
// fixed input, not half of the whole room. Current/fresh-source floors below may still require an explicit rejection.
|
|
90
90
|
const inputRoom = Math.floor(Math.max(0, (ctx.model?.contextWindow ?? 0) - reserveTokens - 1) / 2);
|
|
91
91
|
const maxTokens = Math.min(MAX_TAIL_TOKENS, inputRoom);
|
|
92
92
|
// Keep the current user turn native; older conversation competes in the
|