pi-tuck 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
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.14] - 2026-11-07
7
+
8
+ ### Changed
9
+
10
+ - Internal cleanup across the code, tests and benchmark scripts, with no change to any request.
11
+ - The build id in a cut's receipt now follows all the source a build ships.
12
+
13
+ ### Release gates
14
+
15
+ - **Shipped without a live gate, because its replay matches 1.0.13:** 13,946 requests on 29 recordings, 0 differ, 0 dead tasks, the same 28 clips. A build that changes no request gives a live test nothing to measure. Tests; CI; the stress test on Pi 1.2.0 and the newest Pi, with the same 3 of 274 cells as 1.0.13.
16
+
17
+ ## [1.0.13] - 2026-11-07
18
+
19
+ ### Changed
20
+
21
+ - **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
22
+ 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
23
+ 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,
24
+ so remove it if you set one by hand for this reason.
25
+ - With no `cutAt` set, a cut that isn't due yet must free at least a tenth of the room.
26
+ - Internal cleanup: single-use helpers inlined and duplicate paths merged, with no change to any request.
27
+
28
+ ### The trade
29
+
30
+ 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
31
+ 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
32
+ it is the worst of these, with billed tokens up by as much as 35%.
33
+
34
+ ### Known issues
35
+
36
+ - 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.
37
+ - 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
38
+ refusal ends the run.
39
+ - 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.
40
+ - 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
41
+ `/compact`.
42
+
43
+ ### Release gates
44
+
45
+ - 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
46
+ 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
47
+ 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.
48
+ - This release is the exact code the gate measured. Only the version and these notes changed.
49
+
6
50
  ## [1.0.12] - 2026-11-06
7
51
 
8
52
  ### 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.10`.
68
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.10`.
67
+ - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.13`.
68
+ - To pin a version, add it: `pi install npm:pi-tuck@1.0.13`.
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 lost no task and got stuck nowhere, with the caveat that two of its tasks had a first request the local server never answered, which a literal no-token count would call lost ([`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md)). Windows of 128k and up were not affected at the defaults.
116
- - Small window, a large prompt after a summary-only cut (1.0.10): when the previous summary alone fills the room and a large prompt arrives, the prompt is dropped, because a cut cannot yet shrink a summary on its own. 1.0.10 drops it with no message, where 1.0.9 refused it with one. The next prompt runs normally. Seen once, on a 25,160-character prompt at a 32k window ([`benchmark/RESULTS-CUTLATER.md`](benchmark/RESULTS-CUTLATER.md), t03 task 24). A fix is planned for 1.0.11.
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
+ - The cut point adapts by itself since 1.0.13; before it, pi-tuck cut too often at small windows or with a large setup (1.0.11 and 1.0.12). The trade, from the changelog: on windows that cut too often, each request now carries more context. Cuts, re-processing and request time fall, and billed tokens rise on paid models. On replay of real sessions at an 81,920 window, cuts went from 22.3 to 9.3 per 100 requests, request time was about 16 to 20% lower, and billed tokens were 16 to 19% higher; at a 65,536 window billed tokens rose by as much as 35%. A very large setup at a small window, such as 40k at 65,536, still leaves too little free room for a good rhythm: cuts stay frequent and free little ([`CHANGELOG.md`](CHANGELOG.md) 1.0.13).
118
+ - Still applying (1.0.11 to 1.0.13): 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). 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. 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.13).
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; a pass shows no lost task in these 30 tasks, not that none can occur | [`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md) |
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 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. 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) |
131
+ | The adaptive cut point (1.0.13) 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. pi-tuck 1.0.13 (`6f13685`) is the exact code the gate measured (`882f6f6`); only the version and the notes changed. It was measured 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 |
@@ -151,10 +154,10 @@ Notes:
151
154
  pi-tuck needs none. Two are optional. Put them in `pi-tuck.json` in Pi's agent folder (`~/.pi/agent`, or the folder `PI_CODING_AGENT_DIR` names):
152
155
 
153
156
  ```json
154
- { "cutAt": 0.6, "semanticRecall": true }
157
+ { "cutAt": 0.8, "semanticRecall": true }
155
158
  ```
156
159
 
157
- - **`cutAt`** (above 0, at most 1; default 0.6) is the part of the input room a request may fill before pi-tuck cuts. At 0.6, each cut leaves room to grow. `{ "cutAt": 1 }` cuts at the full room. A lower value cuts earlier and smaller. The whole room still decides which request pi-tuck refuses.
160
+ - **`cutAt`** (above 0, at most 1; not set by default) is the part of the input room a request may fill before pi-tuck cuts. With no `cutAt`, the cut point adapts to the session since 1.0.13: it follows what the setup and the last cut left, plus half a tail of room to grow, never below 60% of the room and capped just under the room. Setting `cutAt` fixes it as a fraction of the room, and an explicit value keeps the earlier formula, so remove one you set by hand to stop frequent cuts. `{ "cutAt": 1 }` cuts at the full room. A lower value cuts earlier and smaller. The whole room still decides which request pi-tuck refuses.
158
161
  - **`semanticRecall`** (`false` turns semantic search off, on Apple silicon). Nothing is then downloaded, and `tuck_search` uses keywords only.
159
162
 
160
163
  A bad file is reported at session start, and pi-tuck uses the defaults.
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, 1 by default. */
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, 1 by default. */
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";
@@ -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. It is the room less the margin, or the cut point
23
- * `cutAt` (a fraction of the room) when that is lower. The exact room stays the limit of every throw, fault and the complete-context guard,
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: a cut once a request passes 60% of the room. Measured against 1 with paired replays (no answer loss) and billed
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) => 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. With the default cut point the band is never lean and the order and the decisions are those above.
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
  */
@@ -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. It is the room less the margin, or the cut point
11
- * `cutAt` (a fraction of the room) when that is lower. The exact room stays the limit of every throw, fault and the complete-context guard,
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: a cut once a request passes 60% of the room. Measured against 1 with paired replays (no answer loss) and billed
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
- return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), Math.floor(positive * cutAt));
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. With the default cut point the band is never lean and the order and the decisions are those above.
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" };
@@ -1 +1 @@
1
- export declare const BUILD_ID = "f637995e7992c76737b37ae27dee5bb6bf47e959fd0f9677c3ae4d0c7530442c";
1
+ export declare const BUILD_ID = "9457180f73cad9d9bde254328dad70dd2abe8400e373a68e06ad455b5b66f203";
@@ -1 +1 @@
1
- export const BUILD_ID = "f637995e7992c76737b37ae27dee5bb6bf47e959fd0f9677c3ae4d0c7530442c";
1
+ export const BUILD_ID = "9457180f73cad9d9bde254328dad70dd2abe8400e373a68e06ad455b5b66f203";
@@ -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
- /** The cut point: a fraction of the input room, above 0 and at most 1 (default 1). A cut is triggered at, and preferably planned to, the band,
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. Anything else is rejected when the compactor is installed. */
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
@@ -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" ? bandOf(room, cutAt) : room, factor, mandatory);
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
- // The exact room, and the band a cut is triggered at (budget.ts, from this session's cutAt); every throw and every fault uses the exact room.
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, band: bandOf(room, cutAt) };
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, band } = limits(ctx);
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 mandatory = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
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, band } = limits(ctx);
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, band } = limits(ctx);
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 = "f637995e7992c76737b37ae27dee5bb6bf47e959fd0f9677c3ae4d0c7530442c";
3
+ export declare const DIAGNOSTIC_BUILD = "9457180f73cad9d9bde254328dad70dd2abe8400e373a68e06ad455b5b66f203";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -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
@@ -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 Map();
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.set(entry.targetId, true);
12
+ edits.add(entry.targetId);
13
13
  }
14
- return new Set([...edits].flatMap(([id, omitted]) => omitted && indices.has(id) ? [indices.get(id)] : []));
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
- export function projectedMessages(messages, ctx, policy) {
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
- /** Whether a compaction summary is in this compactor's own format. */
69
- const isTranscriptSummary = (summary) => splitSummary(summary) !== undefined;
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
- const entries = ctx.sessionManager.getEntries(), branch = ctx.sessionManager.getBranch();
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 (`src/core/load-messages.ts`) numbers every `type == "message"` entry
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 (`src/core/load-messages.ts`) numbers every `type == "message"` entry
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
- * Accumulates the id → global-index mapping one entry at a time so both the
16
- * in-memory array path and the streaming file path share one counting rule.
17
- *
18
- * Entries without a usable id are still counted (they occupy an index) but
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 createGlobalIndexBuilder = () => {
20
+ export const buildGlobalIndexById = (entries) => {
23
21
  const byId = new Map();
24
22
  const ambiguous = new Set();
25
23
  let messageIndex = 0;
26
- return {
27
- add(entry) {
28
- if (!isCountedMessageEntry(entry))
29
- return;
30
- const id = entry?.id;
31
- if (typeof id === "string" && id.length > 0) {
32
- if (byId.has(id) || ambiguous.has(id)) {
33
- byId.delete(id);
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
- messageIndex++;
41
- },
42
- map: () => byId,
43
- };
44
- };
45
- /**
46
- * Map session entry ids to their global message index (position among counted
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 src/core/global-indices.ts).
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