pi-tuck 1.0.3 → 1.0.5

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,40 @@
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.5] - 2026-10-31
7
+
8
+ ### Fixed
9
+
10
+ - One oversize tool result no longer leaves a session unusable. A fresh tool round larger than the input room is still refused, since it must
11
+ reach the model whole; but after that run ended (its reply aborted, or hidden by Pi when it recovered from an overflow), the result was
12
+ still treated as not yet seen and kept whole, so
13
+ every later prompt was refused, and a manual `/compact` refused too. Once the session moves on (a new prompt, a manual `/compact`, or a
14
+ run a custom message starts) it is now ordinary history, and the next cut summarizes it; `tuck_recall` still reads it in full.
15
+ - A cut that is needed when Pi has nothing to compact (no history older than its `keepRecentTokens` since the last cut) now refuses that
16
+ request only. Before, Pi's "Nothing to compact" or "Already compacted" stayed as a fault, and every later prompt of the session failed on it.
17
+ - No failure outlives its attempt: after any refusal or fault, the next prompt, a manual cut or the end of the run plans afresh from the
18
+ session as it is. Before, a fault other than a refusal stayed until the session restarted. The one exception is a cut pi-tuck supplied that
19
+ Pi then failed to write: the session may hold a cut its file lacks, so that fault still stays until the session restarts.
20
+ - A refusal no longer turns semantic recall off. Background indexing read any compaction failure as the host revoking access, so it disabled
21
+ semantic recall and deleted its index for the rest of the session.
22
+
23
+ ## [1.0.4] - 2026-10-31
24
+
25
+ ### Changed
26
+
27
+ - When pi-tuck refuses a request because it cannot fit, and the provider has counted far more input than pi-tuck estimates (1.5 times or
28
+ more), the notice now says so and suggests that a different upstream may count less. Behind a router such as OpenRouter, one upstream can
29
+ spend about twice the context of others on tool schemas, which is what made pi-tuck refuse at a 32k window with a 16k reserve.
30
+
31
+ ### Fixed
32
+
33
+ - An omission notice lists each omitted message's locator once: when several text blocks of one message were shortened to nothing, repeats
34
+ could crowd the list and drop its "Locator list incomplete" flag.
35
+ - A refusal no longer outlives the attempt it was for. When pi-tuck refused a request (a host view or a fresh tool round larger than the
36
+ input room, or a cut it could not make), a later run that no prompt started, such as a custom message that triggers a turn, was aborted
37
+ before its first request until the session restarted, even once there was room, and it reported the old refusal. A refusal that was not for
38
+ want of room was held the same way and also aborted every later prompt. The next run, whatever starts it, now decides afresh.
39
+
6
40
  ## [1.0.3] - 2026-10-31
7
41
 
8
42
  First npm release since 1.0.1: it also carries everything listed under 1.0.2, which was tagged but not published to npm. Releases are
@@ -59,9 +93,10 @@ First release on npm. 1.0.0 was tagged but never published; its notes below desc
59
93
 
60
94
  ### Fixed
61
95
 
62
- - A long session whose dialogue no longer fit the summary stalled for tens of seconds at every cut (88 s at 3,900 entries), because
63
- the ranked view re-rendered the whole candidate summary for each block it considered. It now keeps the summary's size up to date
64
- as blocks join, so a cut takes well under a second and grows linearly with the session. Summaries are byte for byte the same.
96
+ - A long session whose dialogue no longer fit the summary stalled for tens of seconds at every cut, because the ranked view re-rendered
97
+ the whole candidate summary for each block it considered. It now keeps the summary's size up to date as blocks join, so a cut takes
98
+ well under a second and grows linearly with the session: 72 s before and 174 ms after at 3,888 entries, measured on one machine with the
99
+ same synthetic session. Summaries are byte for byte the same.
65
100
 
66
101
  ## [1.0.0] - 2026-10-30
67
102
 
@@ -103,6 +138,7 @@ Tagged, not published.
103
138
  Source preview on GitHub, not published to npm. Deterministic compaction with `pi_tuck_recall` and `pi_tuck_search`; semantic search
104
139
  opt-in, with a runtime the user had to supply.
105
140
 
141
+ [1.0.4]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.4
106
142
  [1.0.3]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.3
107
143
  [1.0.2]: https://github.com/galyavin/pi-tuck/tree/v1.0.2
108
144
  [1.0.1]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.1
package/README.md CHANGED
@@ -1,74 +1,169 @@
1
1
  # pi-tuck
2
2
 
3
- Deterministic compaction for [Pi](https://pi.dev). When a session's context fills up, pi-tuck replaces older turns with a summary built
4
- from the session itself, not written by a model: no extra model call, no cost, no invented history, and the same input always gives the
5
- same summary. Nothing is lost: the full history stays in the session file, and the agent gets two tools to read it back, by keyword and
6
- by meaning.
7
-
8
- ## Install
3
+ **Compaction for [Pi](https://pi.dev) with no model call, and a history the agent can search by keyword or by meaning, all on your machine.** In our live run, a cut took a median 0.09 s. It costs no tokens. The same session always gives the same summary. And nothing is lost: the agent can read back anything earlier, word for word, and on Apple silicon it can find it by meaning.
9
4
 
10
5
  ```sh
11
- pi install npm:pi-tuck@1.0.3
6
+ pi install npm:pi-tuck
7
+ ```
8
+
9
+ ## pi-tuck against Pi's built-in compaction
10
+
11
+ Pi's built-in compaction asks a model to write a summary. pi-tuck builds the summary from the session itself. The two time and context rows compare pi-tuck 1.0.1 with Pi 1.4.0's built-in compaction, where a local model writes the summary. Each measured result in this README names the pi-tuck build or version it was measured on; the release you install may be newer.
12
+
13
+ | | Pi's built-in (local model writes the summary) | pi-tuck |
14
+ |---|---|---|
15
+ | Model call per cut | One, to a model you pay for or wait on | None |
16
+ | Time per cut | **269 s** (median) | **0.09 s** (median) |
17
+ | Context carried after a cut | 29,200 tokens | 27,800 tokens (about the same) |
18
+ | Same session, same summary | Not guaranteed, since a model writes it | Yes |
19
+ | Can the agent get dropped detail back | No recall tool in Pi 1.4.0. The full history stays in the session file, but the agent has no tool made to search it | Yes. `tuck_recall` reads any original exactly |
20
+ | Search history by meaning | No | Yes, with `tuck_search`, on macOS with Apple silicon. It runs locally, and no text leaves your machine. Elsewhere it searches by keyword |
21
+ | Can the summary invent history | It can, since a model writes it | No, it holds only recorded text |
22
+ | What the summary leaves out | Whatever the model dropped | Tool output only; the agent reads it back exactly |
23
+ | Billed input at the default 60% cut point (simulated) | Not measured | 10–18% less than pi-tuck cutting at the full room. This is not a comparison with the built-in |
24
+ | Answer quality against Pi's own compaction on a local model (`mac-studio/main`) | | **Being measured. Not claimed yet.** |
25
+ | Answer quality against Pi's compaction prompts on a frontier model | | **Untested.** |
26
+
27
+ The time and context rows are live runs with a 131k window: 27 cuts per arm over 5 chains, interim. Every number comes from a file in this repository. Conditions, sample sizes and links are in [Benchmarks](#benchmarks). The last row is the one that matters most, and it is not finished: see [What is proven, and what is not](#what-is-proven-and-what-is-not).
28
+
29
+ ## Ask your history
30
+
31
+ A summary cannot hold everything. So pi-tuck lets the agent look things up instead of guess. Say you fixed a login bug two hours ago, and the summary no longer holds the details. You ask:
32
+
33
+ > "What was the reason we dropped the retry on the token refresh?"
34
+
35
+ The original message says "removing the backoff loop from the auth renewal". The words differ, so a keyword search finds nothing. The agent calls `tuck_search`, which also searches by meaning:
36
+
37
+ ```json
38
+ { "query": "why did we drop the retry on token refresh" }
12
39
  ```
13
40
 
14
- or straight from GitHub:
41
+ It gets back the original passage, with a pointer such as `#212`. It reads the whole message, exactly as it was written:
42
+
43
+ ```json
44
+ { "index": 212 }
45
+ ```
46
+
47
+ That is `tuck_recall`. The agent answers from the original text, not from a model's memory of it.
48
+
49
+ *This example is an illustration. It is not a recorded session.* The call shapes are real. **Semantic search has no measured quality numbers yet.** A test of it is pre-registered in [`benchmark/PREREG-RECALL.md`](benchmark/PREREG-RECALL.md); it has no result yet. The benchmarks below measure cut time, cost and answer retention after cuts, not how well `tuck_search` finds a passage. It runs only on macOS with Apple silicon. Elsewhere, `tuck_search` uses keywords, and the example above would not find its match.
50
+
51
+ ## Install
15
52
 
16
53
  ```sh
17
- pi install git:github.com/galyavin/pi-tuck@v1.0.3
54
+ pi install npm:pi-tuck
18
55
  ```
19
56
 
20
- Requires Pi 1.1 or later (tested on 1.1.0 and 1.4.0) on macOS or Linux. Semantic search (`tuck_search` by meaning) runs only on macOS
21
- on Apple silicon; elsewhere `tuck_search` uses keywords and everything else works.
22
- Start a new Pi session after installing.
57
+ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, both automatic and `/compact`. You change no setting.
58
+
59
+ - Needs Pi 1.1 or later (tested on 1.1.0 and 1.4.0), on macOS or Linux.
60
+ - 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).
61
+ - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.5`.
62
+ - To pin a version, add it: `pi install npm:pi-tuck@1.0.5`.
23
63
 
24
- ## What it does
64
+ ## How it works
25
65
 
26
- **Compaction.** pi-tuck takes over Pi's compaction, both automatic and `/compact`. A summary keeps:
66
+ When context fills up, pi-tuck replaces the older turns with one summary. The summary keeps:
27
67
 
28
- - the dialogue: what you asked and what the agent answered, in order;
29
- - the files the agent read, wrote or edited, newest first (up to 60), with pointers to read them back;
30
- - the agent's other tool calls, such as shell commands, newest first (up to 30), without their output but with pointers to it;
31
- - your first prompt in the session, word for word (up to 12,000 characters).
68
+ - **The dialogue.** What you asked and what the agent answered, in order, word for word. If it is too long for the room, it shrinks: the oldest assistant text is clipped first, then older prompts. Clipped text says how to read it in full.
69
+ - **The files.** Files the agent read, wrote or edited, newest first (up to 60), each with a pointer to read it back.
70
+ - **The action trail.** The agent's other tool calls, such as shell commands, newest first (up to 30). The output is left out. A pointer leads to it.
71
+ - **Your first prompt**, word for word (up to 12,000 characters).
32
72
 
33
- Tool output is left out of the summary; it stays in the session file. If the dialogue itself is too long for the room left, it is
34
- shortened, oldest assistant text first, instead of being replaced by a ranked selection. Each summary ends with a short instruction to
35
- the agent: re-read a file before editing it, and look things up rather than guess. The summary is plain text, one line per record
36
- (`#12 user: ...`, where 12 is the index `tuck_recall` takes); summaries written by earlier versions are still read.
73
+ Tool output stays in the session file, not in the summary. Each summary ends with a short instruction to the agent: re-read a file before you edit it, look things up, and treat anything not shown as unknown.
37
74
 
38
- **`tuck_recall`** finds earlier text by keyword and reads any original message or tool result exactly, by the index shown in a summary.
39
- It can also write an original back out to a file.
75
+ The summary is plain text, one line per record, such as `#12 user @2026-10-30T14:00:00Z: text`. The number is the index that `tuck_recall` takes. Summaries from earlier versions are still read.
40
76
 
41
- **`tuck_search`** finds earlier history related to a question even when the wording differs. It combines keyword hits with local
42
- semantic search (see below) and returns the original text with pointers for `tuck_recall`.
77
+ The agent gets two tools to get anything back:
43
78
 
44
- **Commands**
79
+ - **`tuck_recall`** finds earlier text by keyword. It reads any original message or tool result exactly, by the index shown in a summary. It can also write an original out to a file.
80
+ - **`tuck_search`** finds earlier history related to a question, even when the wording differs. It combines keyword hits with local semantic search and returns the original text with pointers for `tuck_recall`.
81
+
82
+ Both tools cost about 1,050 tokens of tool description per request.
45
83
 
46
84
  | Command | Does |
47
85
  |---|---|
48
86
  | `/tuck-semantic on\|off\|status` | Turns semantic search on or off for this session, or shows its state. |
49
87
  | `/tuck-incident on\|next\|off\|status` | Records this session's compaction decisions to a file you can attach to a bug report. |
50
88
 
51
- `/compact` works as usual; pi-tuck does not take custom summary instructions. Pi's own setting `"compaction": { "enabled": false }`
52
- turns compaction off.
89
+ `/compact` works as usual, but pi-tuck does not take custom summary instructions. Pi's own setting `"compaction": { "enabled": false }` turns compaction off.
90
+
91
+ ## What is proven, and what is not
92
+
93
+ **Measured so far:** in the live run, a pi-tuck cut took a median 0.09 s, where Pi's built-in compaction with a local model writing the summary took a median 269 s. A cut calls no model, so it costs no tokens. After a cut, pi-tuck carried about as many tokens as the built-in did, not fewer. In simulation, cutting at 60% of the room instead of 100% saved billed input for pi-tuck itself, and answers held in replay.
94
+
95
+ **Not proven yet: that the summary is as good as a model-written one.** The claim is that pi-tuck compacts as well as an LLM compactor, in far less time and at no cost. The comparator is Pi's own summarize prompts, run on a local model (`mac-studio/main`). The frontier comparison is untested: the model first named for it (`openai/gpt-6-astra`) was retired on 2026-10-31, and its recordings are a labelled partial view that feeds no test. A replacement, GPT-6.1 Terra, is pre-registered in [`benchmark/PREREG-TERRA.md`](benchmark/PREREG-TERRA.md). See [`benchmark/PINS.md`](benchmark/PINS.md). That claim has a pre-registered test. The test is running now. It was written down before any result came in, and a failed test will be reported as failed. When the result is final, it goes in [`benchmark/`](benchmark) and in this README. Until then, this README does not claim that pi-tuck is as good as, or better than, any LLM compactor.
96
+
97
+ The test has three parts:
98
+
99
+ 1. **A live run** at a 128k window on a local model, with pi-tuck 1.0.1. Ten chains of 30 SWE-bench Verified tasks per arm, with Pi's built-in compaction in one arm and pi-tuck in the other. Official grading.
100
+ 2. **A paired replay with probe questions.** The same cuts, made by pi-tuck and by Pi's summarize prompts on the local model, then questions about facts that fell before the cut. Pre-registered rule: the claim holds only if pi-tuck is no more than 3 points worse than the better compactor, with a 95% bound. The comparators are named above and in `benchmark/`.
101
+ 3. **A 32k suite**, where tasks are bigger than the room, so cuts must summarise work in progress.
53
102
 
54
- **Small context windows.** pi-tuck keeps room for the model's reply: the larger of the model's output limit and Pi's
55
- `compaction.reserveTokens` (default 16,384). It refuses a request that cannot fit rather than let the provider cut it off. For a model
56
- with a window of 32k tokens or less, lower the reserve for that model in Pi's `settings.json`:
103
+ The plan: [`benchmark/PREREG.md`](benchmark/PREREG.md), [`benchmark/PREREG-32K.md`](benchmark/PREREG-32K.md), [`benchmark/PREREG-TERRA.md`](benchmark/PREREG-TERRA.md), and every change after the freeze in [`benchmark/DEVIATIONS.md`](benchmark/DEVIATIONS.md).
104
+
105
+ Known limits today:
106
+
107
+ - 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.
108
+ - Small windows: an upstream that spends far more context on tool schemas than others (some OpenRouter upstreams do) can leave a small input room too little space, and pi-tuck then refuses to cut rather than overflow. This is what made 1.0.1 refuse at a 32k window with Pi's default 16,384 reserve. Since 1.0.4 the notice says when the provider is counting far above pi-tuck's estimate. In 1.0.4, after an oversize tool result or a "Nothing to compact" error from Pi, a session could keep refusing later prompts. 1.0.5 fixes both. Pin a different upstream, lower the reserve, or use a larger window; see [Small windows](#small-windows).
109
+ - Semantic search runs only on macOS with Apple silicon.
110
+
111
+ ## Benchmarks
112
+
113
+ Each row says what was measured, under which conditions, and where the data is. "Interim" means the run is not finished.
114
+
115
+ | What | Result | Conditions | Source |
116
+ |---|---|---|---|
117
+ | Cut time, live | pi-tuck median 0.087 s (90th percentile 0.205 s); Pi's built-in median 268.8 s (90th percentile 421.6 s). Context carried after a cut, median: 27,751 against 29,241 tokens | **Interim.** pi-tuck 1.0.1, Pi 1.4.0, SWE-bench Verified chains of 30 tasks, 131,072-token window, reply reserve 32,768, local model `mac-studio/main` writing Pi's summaries. 27 cuts per arm, 5 chains. The built-in time depends on that model and on server load. pi-tuck's time is an upper bound read from the session file | [`benchmark/RESULTS-INTERIM.md`](benchmark/RESULTS-INTERIM.md) section 1 |
118
+ | Cut time, paired replay | pi-tuck median 0.062 s (range 0.005 to 0.48 s); local-model summarizer 193.4 s (77.3 to 413.3 s); frontier summarizer 96.1 s (39.1 to 267.8 s; measured on the retired model `openai/gpt-6-astra`; feeds no test). Context carried after a cut, median: 28,006 (pi-tuck), 26,424 (local), 25,555 (frontier) tokens | **Interim.** The same recorded sessions cut at the same positions. pi-tuck: 10 recordings, 52 cuts. Summarizers: 6 recordings, 28 and 29 cuts. pi-tuck's time leaves out the background search index | [`benchmark/RESULTS-INTERIM.md`](benchmark/RESULTS-INTERIM.md) section 2 |
119
+ | Billed input, cutting at 60% of the room instead of 100% | −17.9% at 128k, −14.0% at 32k with automatic prompt caching; −17.0% and −9.6% with explicit cache breakpoints. Cuts go from 6 to 12 (128k) and from 84 to 157 (32k); none frees under a tenth of the room | Simulated billing, not provider bills. Prompt caching priced. Build `1bda64b` (the default shipped in 1.0.3). 5 recorded live sessions. 128k = 131,072 window, reserve 32,768. 32k = 32,768 window, reserve 16,384 | [`rnd/replay32/RESULT-lean.md`](rnd/replay32/RESULT-lean.md) |
120
+ | Answers after cutting at 60% instead of 100% | No more losses than the limit on all 4 sets. Losses / wins over n questions: 128k shared 3 / 7 of 99; 128k asymmetric 3 / 1 of 67; 32k shared 5 / 3 of 77; 32k asymmetric 9 / 5 of 343. In the 32k asymmetric set the losses lean against 60% (test_result 4 / 2, error_hit 3 / 1); it is the set to watch | Paired replay, same 5 sessions, DeepSeek flash as agent and judge. Limit, set in advance: losses minus wins at most max(2, 5% of n). Asymmetric = evidence the base keeps native and the variant has summarised | [`rnd/replay32/RESULT-lean.md`](rnd/replay32/RESULT-lean.md) |
121
+ | 1.0.2 changes (plain-text summary, shrinking dialogue view, time on user turns only) against 1.0.1 | No more losses than the limit on all 4 sets. Losses / wins: 128k 1 / 2 of 128; 128k time questions 0 / 0 of 48; 32k 4 / 2 of 127; 32k asymmetric 0 / 2 of 56 | Paired replay, 5 recorded live sessions, DeepSeek flash as agent and judge. 11 of 146 answer sessions hit a 15-minute wall limit and drop out of the pairing | [`rnd/replay32/RESULT-lean.md`](rnd/replay32/RESULT-lean.md), [`CHANGELOG.md`](CHANGELOG.md) |
122
+ | Summary size, plain-text format against the 1.0.1 JSON format | About 30% shorter where the agent writes little, about 14% shorter where it writes a lot | Same dialogue | [`CHANGELOG.md`](CHANGELOG.md) 1.0.2 |
123
+ | Small-window fix | A session that writes little prose cut 174 times in 300 tool rounds at 32k with a 16k reserve (1.0.1 code). With the fix, a test pins that no cut frees under a tenth of the room | Real planner on synthetic sessions | [`test/cut-yield.test.ts`](test/cut-yield.test.ts), which pins that every cut frees at least a tenth of the room |
124
+ | Stall fix: a long session's cut | A cut at 3,888 entries took 72 s in 1.0.0 and takes 174 ms since 1.0.1. Since 1.0.1 the cost grows linearly with the session | One machine, the same synthetic session for both numbers (little prose, 128k window, reserve 16,384, turn 644) | [`CHANGELOG.md`](CHANGELOG.md) 1.0.1 |
125
+
126
+ Notes:
127
+
128
+ - The billing figures come from a simulation that replays real sessions through the real cut trigger and prices every request. They are not bills.
129
+ - The answer replays use one cheap hosted model (DeepSeek flash) as agent and judge. They compare pi-tuck with other pi-tuck settings and versions. They do not compare pi-tuck with an LLM compactor.
130
+ - The live and replay time rows measure pi-tuck 1.0.1. The newer versions are listed in the [changelog](CHANGELOG.md). A cut still calls no model, but the eval has not yet re-timed 1.0.3.
131
+ - Everything here is from SWE-bench-style tasks on a small set of models. It may not carry to your work.
132
+
133
+ ## Settings
134
+
135
+ 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):
136
+
137
+ ```json
138
+ { "cutAt": 0.6, "semanticRecall": true }
139
+ ```
140
+
141
+ - **`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, so fewer tokens are carried and billed per request. `{ "cutAt": 1 }` cuts at the full room. A lower value cuts earlier and smaller. The whole room still decides which request pi-tuck refuses.
142
+ - **`semanticRecall`** (`false` turns semantic search off). Nothing is then downloaded, and `tuck_search` uses keywords only.
143
+
144
+ A bad file is reported at session start, and pi-tuck uses the defaults.
145
+
146
+ ### Small windows
147
+
148
+ pi-tuck keeps room for the model's reply: the larger of the model's output limit and Pi's `compaction.reserveTokens` (default 16,384). It refuses a request that cannot fit, rather than let the provider cut it off. It estimates the request's size, and it scales that estimate by the worst gap it has seen between its count and the provider's. In a small window, an upstream that spends far more context on tool schemas (some OpenRouter upstreams do) can leave too little room. pi-tuck then refuses to cut rather than overflow. Lower the reserve for that model in Pi's `settings.json`, or use a larger window. For example, to lower it to 8,192:
57
149
 
58
150
  ```json
59
- { "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 4096 } } } }
151
+ { "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 8192 } } } }
60
152
  ```
61
153
 
62
- **Cut point.** pi-tuck cuts once a request passes 60% of the input room, so each cut leaves room to grow and fewer tokens are carried (and billed) per request; measured against cutting at the full room, answers held and billed input fell 10-18%. Set `"cutAt"` in `pi-tuck.json` in Pi's agent folder (`~/.pi/agent`) to change it, a number above 0 and at most 1: `{ "cutAt": 1 }` restores cutting at the full room, a lower value cuts earlier and smaller. The guard is unaffected, because the whole room still decides which request is refused.
154
+ The reserve is the larger of `reserveTokens` and the model's `maxTokens`, so lowering `reserveTokens` alone does nothing when `models.json` sets a larger `maxTokens` for that model. Lower both, and keep `maxTokens` above your largest expected reply.
155
+
156
+ If you reach the model through OpenRouter, you can also pin one upstream, so that every request is served by the same one. Put this in `models.json` in Pi's agent folder, and name the upstream you want:
157
+
158
+ ```json
159
+ { "providers": { "openrouter": { "compat": { "openRouterRouting": { "only": ["your-upstream"], "allow_fallbacks": false } } } } }
160
+ ```
63
161
 
64
162
  ## Semantic search
65
163
 
66
- Semantic search is on by default and runs entirely on your Mac: an embedding model,
67
- [EmbeddingGemma-2](https://huggingface.co/google/embeddinggemma-2), served by [llama.cpp](https://github.com/ggml-org/llama.cpp) on the
68
- CPU. No text leaves your machine.
164
+ Semantic search is on by default on macOS with Apple silicon. It runs entirely on your Mac: an embedding model, [EmbeddingGemma-2](https://huggingface.co/google/embeddinggemma-2), served by [llama.cpp](https://github.com/ggml-org/llama.cpp) on the CPU. No text leaves your machine. Elsewhere, `tuck_search` uses keywords, and everything else works.
69
165
 
70
- The first time a session starts, pi-tuck downloads what it needs, once, about 390 MB in the background, and keeps working with keyword
71
- search meanwhile:
166
+ The first time a session starts, pi-tuck downloads what it needs, once, about 390 MB, in the background. Keyword search works meanwhile:
72
167
 
73
168
  | File | From |
74
169
  |---|---|
@@ -76,21 +171,9 @@ search meanwhile:
76
171
  | llama.cpp release b11476 for macOS arm64 (12 MB) | github.com/ggml-org/llama.cpp |
77
172
  | CPython 3.13 (25 MB) and 16 Python packages for the tokenizer (9 MB) | github.com/astral-sh/python-build-standalone, pypi.org |
78
173
 
79
- Every file is pinned to a SHA-256 in [src/assets.ts](src/assets.ts) and checked before use. Everything goes to `~/.pi/agent/pi-tuck`;
80
- to remove it, delete that folder. Setup needs about 6.5 GB of free disk space while it runs and uses 460 MB after. If it fails, for
81
- example offline, pi-tuck says so and tries again at the next session start. One-shot runs (`pi -p`) don't start the setup.
174
+ Every file is pinned to a SHA-256 in [src/assets.ts](src/assets.ts) and checked before use. Everything goes to `~/.pi/agent/pi-tuck`; to remove it, delete that folder. Setup needs about 6.5 GB of free disk space while it runs, and uses 460 MB after. If it fails, for example offline, pi-tuck says so and tries again at the next session start. One-shot runs (`pi -p`) do not start the setup.
82
175
 
83
- Indexing starts in the background the first time a session uses `tuck_search`, and catches up after each compaction. Semantic search is off when Pi
84
- runs in a folder that contains Pi's agent folder, such as your home folder, because its own files live there; open Pi in a project folder.
85
-
86
- **Turn it off** by creating `~/.pi/agent/pi-tuck.json`:
87
-
88
- ```json
89
- { "semanticRecall": false }
90
- ```
91
-
92
- Nothing is then downloaded, and `tuck_search` uses keywords only. If you set `PI_CODING_AGENT_DIR`, the file and the download folder are
93
- in that folder instead.
176
+ Indexing starts in the background the first time a session uses `tuck_search`, and catches up after each compaction. Semantic search is off when Pi runs in a folder that contains Pi's agent folder, such as your home folder, because its own files live there. Open Pi in a project folder.
94
177
 
95
178
  ## Use from code
96
179
 
@@ -105,8 +188,7 @@ const handle = await installCompaction(pi, { semanticRecall });
105
188
  await handle.setSemanticEnabled(true, ctx); // e.g. in a session_start handler
106
189
  ```
107
190
 
108
- `installCompaction` options (reply reserve, host-injected messages, recall policy and others) are documented in
109
- [src/compaction.ts](src/compaction.ts). `handle.close()` stops the semantic worker; call it before disposing a session.
191
+ `installCompaction` options (reply reserve, host-injected messages, recall policy, `cutAt` and others) are documented in [src/compaction.ts](src/compaction.ts). `handle.close()` stops the semantic worker; call it before disposing a session.
110
192
 
111
193
  ## Development
112
194
 
@@ -117,12 +199,11 @@ npm test # unit tests (Vitest) and the Python runtime tests
117
199
  npm run build # dist/ for the library export
118
200
  ```
119
201
 
202
+ Changes are listed in [CHANGELOG.md](CHANGELOG.md). Releases are published from GitHub Actions by npm trusted publishing, with provenance.
203
+
120
204
  ## Credits
121
205
 
122
- pi-tuck grew out of [pi-vcc](https://github.com/sting8k/pi-vcc) by sting8k and includes code from
123
- [pi-blackhole](https://github.com/k0valik/pi-blackhole) by k0valik and from [Pi](https://github.com/earendil-works/pi) by Mario
124
- Zechner, all MIT; their notices are in [LICENSE](LICENSE). The downloaded model is Apache-2.0, llama.cpp is MIT, and CPython is under the
125
- PSF license.
206
+ pi-tuck grew out of [pi-vcc](https://github.com/sting8k/pi-vcc) by sting8k and includes code from [pi-blackhole](https://github.com/k0valik/pi-blackhole) by k0valik and from [Pi](https://github.com/earendil-works/pi) by Mario Zechner, all MIT; their notices are in [LICENSE](LICENSE). The downloaded model is Apache-2.0, llama.cpp is MIT, and CPython is under the PSF license.
126
207
 
127
208
  ## License
128
209
 
@@ -1 +1 @@
1
- export declare const BUILD_ID = "72d28833b7180debd3ea6a7ca0308b000f8d27671b42c4d2b73dfaaa7bbdad59";
1
+ export declare const BUILD_ID = "6c391c76284110aae5bb375c04b2147d2fab17de3cb427448e8a869f5ad1e659";
@@ -1 +1 @@
1
- export const BUILD_ID = "72d28833b7180debd3ea6a7ca0308b000f8d27671b42c4d2b73dfaaa7bbdad59";
1
+ export const BUILD_ID = "6c391c76284110aae5bb375c04b2147d2fab17de3cb427448e8a869f5ad1e659";
@@ -3,6 +3,9 @@ import { type RecorderOptions } from "./diagnostics.js";
3
3
  import { type AgentMessage } from "./budget.js";
4
4
  import { SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
5
5
  import type { RecallPolicy } from "./recall.js";
6
+ /** What a capacity refusal tells the operator. When the provider has counted far more input than pi-tuck's estimate, the refusal most
7
+ * likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
8
+ export declare const refusalNotice: (message: string, factor: number) => string;
6
9
  /** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
7
10
  export declare const CALIBRATION_CEILING = 1.5;
8
11
  /** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
@@ -36,8 +36,21 @@ const RecallInput = Type.Union([
36
36
  ]);
37
37
  const SearchInput = Type.Object({ query, priorResults: Type.Optional(Type.Array(Type.Object({ entryId, sourceSha: Type.String({ minLength: 64, maxLength: 64 }), start: Type.Integer({ minimum: 0 }), end: Type.Integer({ minimum: 0 }) }, { additionalProperties: false }), { maxItems: 20 })) }, { additionalProperties: false });
38
38
  const reply = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: {} });
39
- class CapacityError extends Error {
39
+ /** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. Like every failure but
40
+ * a `CommitFault`, it ends with its attempt (`forgetFailure`), and the next attempt plans afresh from the session as it is. */
41
+ class Refusal extends Error {
40
42
  }
43
+ /** A cut this compactor supplied that Pi then failed to commit. Pi appends a cut to the session in memory before it writes the file, so the
44
+ * session may hold a cut its file lacks; what is written after would build on it. The one failure that stays until the session restarts. */
45
+ class CommitFault extends Error {
46
+ }
47
+ /** A refusal for want of room, which the user can remedy: it carries advice. */
48
+ class CapacityError extends Refusal {
49
+ }
50
+ /** What a capacity refusal tells the operator. When the provider has counted far more input than pi-tuck's estimate, the refusal most
51
+ * likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
52
+ export const refusalNotice = (message, factor) => message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
53
+ (factor >= 1.5 ? ` This provider has counted up to ${factor.toFixed(1)}x the input pi-tuck estimates; through a router such as OpenRouter, a different upstream may count less.` : "");
41
54
  /** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
42
55
  export const CALIBRATION_CEILING = 1.5;
43
56
  /** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
@@ -85,10 +98,17 @@ export async function installCompaction(pi, options = {}) {
85
98
  if (!validCutAt(cutAt))
86
99
  throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
87
100
  const semantic = options.semanticRecall ?? createSemanticRecall();
101
+ const hostAccess = () => {
102
+ if (!active())
103
+ throw new Error("Compaction host is inactive");
104
+ options.assertAccess?.();
105
+ };
88
106
  let semanticTransition = Promise.resolve();
89
107
  let semanticRequest = 0;
90
108
  const semanticPending = new Set();
91
- const semanticProvider = (ctx) => () => memoizedEligibleSources(ctx, options.recallPolicy, access);
109
+ // Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
110
+ // attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
111
+ const semanticProvider = (ctx) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess);
92
112
  const recorder = new IncidentRecorder(options.diagnostics);
93
113
  let proposalId;
94
114
  const observe = (ctx) => recorder.measureInline(() => {
@@ -249,7 +269,7 @@ export async function installCompaction(pi, options = {}) {
249
269
  learn(ratio);
250
270
  });
251
271
  pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
252
- pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; });
272
+ pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); });
253
273
  const active = () => options.active?.() ?? true;
254
274
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
255
275
  // The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
@@ -298,7 +318,9 @@ export async function installCompaction(pi, options = {}) {
298
318
  const fail = (error, ctx) => {
299
319
  recorder.stop("native-attempt-failed");
300
320
  const notify = !failure && error instanceof CapacityError;
301
- failure = error instanceof Error ? error : new Error(String(error));
321
+ // A CommitFault stays whatever fails after it.
322
+ if (!(failure instanceof CommitFault))
323
+ failure = error instanceof Error ? error : new Error(String(error));
302
324
  pendingBoundaryCommit = false;
303
325
  pending = undefined;
304
326
  try {
@@ -307,19 +329,21 @@ export async function installCompaction(pi, options = {}) {
307
329
  finally {
308
330
  try {
309
331
  if (notify)
310
- ctx.ui.notify(failure.message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry.", "error");
332
+ ctx.ui.notify(refusalNotice(failure.message, factor), "error");
311
333
  }
312
334
  finally {
313
335
  ctx.abort();
314
336
  }
315
337
  }
316
338
  };
339
+ // The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
340
+ // input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
341
+ const forgetFailure = () => { if (!(failure instanceof CommitFault))
342
+ failure = undefined; };
317
343
  const access = () => {
318
344
  if (failure)
319
345
  throw failure;
320
- if (!active())
321
- throw new Error("Compaction host is inactive");
322
- options.assertAccess?.();
346
+ hostAccess();
323
347
  };
324
348
  // Abort alone is insufficient: Pi starts a fresh abort controller after
325
349
  // before_agent_start. Keep this attempt blocked, then recheck on new idle input.
@@ -329,8 +353,7 @@ export async function installCompaction(pi, options = {}) {
329
353
  pi.on("input", async (event, ctx) => {
330
354
  if (!ctx.isIdle() || phase === "starting")
331
355
  return; // a steering or follow-up message joins the running request
332
- if (failure instanceof CapacityError)
333
- failure = undefined;
356
+ forgetFailure();
334
357
  phase = "decided";
335
358
  try {
336
359
  await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools());
@@ -404,8 +427,8 @@ export async function installCompaction(pi, options = {}) {
404
427
  // Pi also labels the awaited idle incoming preflight "manual".
405
428
  const incoming = pendingCut;
406
429
  const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
407
- if (operatorManual && !admittedRun && failure instanceof CapacityError)
408
- failure = undefined;
430
+ if (operatorManual && !admittedRun)
431
+ forgetFailure();
409
432
  access();
410
433
  if (event.customInstructions?.trim()) {
411
434
  recorder.stop("no-cut-custom-instructions");
@@ -440,7 +463,9 @@ export async function installCompaction(pi, options = {}) {
440
463
  : "Compaction did not reduce context within the estimated request budget",
441
464
  plan: (target) => {
442
465
  const reserve = planReserve(target, room, mandatory, ctx);
443
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual);
466
+ // Every cut asked while no run is admitted (a prompt's preflight, an operator's cut, Pi's own cut before a run a custom message
467
+ // starts) is planned for a run that has not begun: the session has moved on from any run that ended. A retry is inside its run.
468
+ const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, Boolean(incoming) || operatorManual || !admittedRun);
444
469
  return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
445
470
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
446
471
  },
@@ -456,7 +481,7 @@ export async function installCompaction(pi, options = {}) {
456
481
  recorder.stop("no-cut");
457
482
  ctx.ui.notify(decision.message, "warning");
458
483
  if (!operatorManual)
459
- fail(decision.capacity ? new CapacityError(decision.message) : new Error(decision.message), ctx);
484
+ fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
460
485
  return { cancel: true };
461
486
  }
462
487
  catch (error) {
@@ -513,10 +538,14 @@ export async function installCompaction(pi, options = {}) {
513
538
  // An opportunistic cut is not needed to send the request, which is below the room: the guard decides. However it ends (the
514
539
  // planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
515
540
  // or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
541
+ // A needed cut that Pi has nothing to make (no history older than its keepRecentTokens since the last cut) is a refusal of
542
+ // this request, which ends with it: as a fault it stayed, and every later prompt of the session failed on it.
516
543
  if (cut.opportunistic && !failure && !cut.proposed)
517
544
  resolve();
545
+ else if (!cut.proposed && isNoWork(error.message))
546
+ reject(new CapacityError(`The request needs a cut and Pi has none to make (${error.message})`));
518
547
  else
519
- reject(error.message === "Compaction cancelled" && failure instanceof CapacityError ? failure : error);
548
+ reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : cut.proposed ? new CommitFault(error.message) : error);
520
549
  } }));
521
550
  }
522
551
  finally {
@@ -526,7 +555,7 @@ export async function installCompaction(pi, options = {}) {
526
555
  // The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
527
556
  const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
528
557
  if (!cut.opportunistic && (after >= total || !afterFits))
529
- throw new (afterFits ? Error : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
558
+ throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
530
559
  };
531
560
  pi.on("before_agent_start", async (event, ctx) => {
532
561
  const prompted = phase === "decided";
@@ -583,7 +612,7 @@ export async function installCompaction(pi, options = {}) {
583
612
  },
584
613
  });
585
614
  if (decision.kind === "fault")
586
- throw new (decision.capacity ? CapacityError : Error)(decision.message);
615
+ throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
587
616
  if (decision.kind !== "cut")
588
617
  return;
589
618
  const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "tool-loop", before, true, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
@@ -632,13 +661,13 @@ export async function installCompaction(pi, options = {}) {
632
661
  if (event.reason === "manual" && !event.fromExtension && !event.aborted && isNoWork(event.errorMessage))
633
662
  return;
634
663
  if (active() && !event.aborted)
635
- fail(new Error(event.errorMessage ?? "Compaction failed"), ctx);
664
+ fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
636
665
  });
637
666
  pi.on("agent_before_settle", (event, ctx) => {
638
667
  if (!pendingBoundaryCommit)
639
668
  return;
640
669
  if (event.outcome === "error")
641
- fail(new Error("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
670
+ fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
642
671
  else
643
672
  pendingBoundaryCommit = false;
644
673
  });
@@ -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 = "72d28833b7180debd3ea6a7ca0308b000f8d27671b42c4d2b73dfaaa7bbdad59";
3
+ export declare const DIAGNOSTIC_BUILD = "6c391c76284110aae5bb375c04b2147d2fab17de3cb427448e8a869f5ad1e659";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -108,14 +108,16 @@ const analyze = (text) => {
108
108
  const excerptRecords = (text, { passages, costs }, role, sourceIndex, headLimit, tailLimit, readFrom) => {
109
109
  const charLimit = 1024;
110
110
  const identity = { role, ...(sourceIndex !== undefined ? { sourceIndex } : {}) };
111
- if (escapedLength(text) <= charLimit && costs.reduce((sum, cost) => sum + cost, 0) <= headLimit + tailLimit)
111
+ // A line carries at least as many characters as the text, so a long text is over the limit without counting its escapes.
112
+ const fits = (from, to) => to - from <= charLimit && escapedLength(text.slice(from, to)) <= charLimit;
113
+ if (fits(0, text.length) && costs.reduce((sum, cost) => sum + cost, 0) <= headLimit + tailLimit)
112
114
  return [{ kind: "excerpt", ...identity, text }];
113
115
  let head = 0, headCost = 0, tail = passages.length, tailCost = 0;
114
- while (head < tail && headCost + costs[head] <= headLimit && escapedLength(text.slice(0, passages[head].end)) <= charLimit) {
116
+ while (head < tail && headCost + costs[head] <= headLimit && fits(0, passages[head].end)) {
115
117
  headCost += costs[head++];
116
118
  }
117
119
  if (tailLimit > 0) {
118
- while (tail > head && tailCost + costs[tail - 1] <= tailLimit && escapedLength(text.slice(passages[tail - 1].start)) <= charLimit) {
120
+ while (tail > head && tailCost + costs[tail - 1] <= tailLimit && fits(passages[tail - 1].start, text.length)) {
119
121
  tailCost += costs[--tail];
120
122
  }
121
123
  }
@@ -1,6 +1,7 @@
1
1
  import { isSourceExport, isLookupTool, isRecallTool } from "./recall-operation.js";
2
+ import { isDialogue } from "./brief.js";
2
3
  /** Suppress lookup echoes, retaining recorded export attempts and exactly paired outcomes. */
3
4
  export const filterNoise = (blocks) => blocks.filter(b => !(b.kind === "tool_call" && isLookupTool(b.name) && !(isRecallTool(b.name) && isSourceExport(b.args))) &&
4
5
  !(b.kind === "tool_result" && isLookupTool(b.name) &&
5
6
  !(isRecallTool(b.name) && b.recallExport)) &&
6
- !((b.kind === "user" || b.kind === "assistant" || b.kind === "notice") && !b.text.trim()));
7
+ !(isDialogue(b) && !b.text.trim()));
@@ -18,14 +18,10 @@ const normalizeOne = (msg, msgIndex) => {
18
18
  }
19
19
  return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
20
20
  }
21
- if (msg.role === "bashExecution") {
22
- const cmd = msg.command ?? "";
23
- const out = msg.output ?? "";
24
- const exit = msg.exitCode;
25
- return [{ kind: "bash", command: cmd, output: out, exitCode: exit, cancelled: msg.cancelled, truncated: msg.truncated, sourceIndex: msgIndex }];
26
- }
21
+ if (msg.role === "bashExecution")
22
+ return [{ kind: "bash", sourceIndex: msgIndex }];
27
23
  if (msg.role === "toolResult")
28
- return [{ kind: "tool_result", name: msg.toolName, text: sanitize(textOf(msg.content)), sourceIndex: msgIndex }];
24
+ return [{ kind: "tool_result", name: msg.toolName, sourceIndex: msgIndex }];
29
25
  if (msg.role === "assistant") {
30
26
  if (!msg.content)
31
27
  return [];
@@ -46,7 +42,6 @@ const normalizeOne = (msg, msgIndex) => {
46
42
  kind: "tool_call",
47
43
  name: part.name,
48
44
  args: part.arguments,
49
- callId: part.id,
50
45
  sourceIndex: msgIndex,
51
46
  });
52
47
  }
@@ -82,18 +82,16 @@ const createLane = (role, units, mandatory, decorate) => {
82
82
  const empty = emptied[tier], counts = emptyCounts[tier];
83
83
  count += counts[end] - counts[drop];
84
84
  if (counts[end] > counts[drop]) {
85
- const extra = [];
86
- for (let i = drop; i < end && extra.length < 9; i++)
85
+ // Text blocks of one message share a locator: collect distinct ones, enough to tell whether the shown list is complete.
86
+ const extra = new Set();
87
+ for (let i = drop; i < end && (extra.size < 9 || !unlocated); i++)
87
88
  if (empty[i]) {
88
89
  const source = units[i].sourceIndex;
89
90
  if (source === undefined)
90
91
  unlocated = true;
91
- else
92
- extra.push(source);
92
+ else if (extra.size < 9)
93
+ extra.add(source);
93
94
  }
94
- for (let i = drop; i < end && !unlocated; i++)
95
- if (empty[i] && units[i].sourceIndex === undefined)
96
- unlocated = true;
97
95
  sources = [...new Set([...sources, ...extra])].sort((a, b) => a - b).slice(0, 9);
98
96
  }
99
97
  }
@@ -119,10 +117,9 @@ const createDialogue = (input) => {
119
117
  const dialogue = input.blocks.filter(isDialogue);
120
118
  const times = dialogue.find(block => block.sourceTimes)?.sourceTimes;
121
119
  const decorate = (record) => decorateRecord(record, times);
122
- const roleOf = (block) => block.kind;
123
- const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => roleOf(block) === role), input.mandatorySourceIndices, decorate)]));
120
+ const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => block.kind === role), input.mandatorySourceIndices, decorate)]));
124
121
  const seen = { user: 0, assistant: 0, notice: 0 };
125
- const order = dialogue.map(block => ({ role: roleOf(block), i: seen[roleOf(block)]++ }));
122
+ const order = dialogue.map(block => ({ role: block.kind, i: seen[block.kind]++ }));
126
123
  const tools = input.blocks.length > dialogue.length ? rendered([TOOLS_OMITTED]) : undefined;
127
124
  const overhead = TRANSCRIPT_HEADER.length + DIALOGUE_SUFFIX.length;
128
125
  /** The length of the summary text of a configuration: its header, one line per record, and the closing section; 0 when it has no record. */
@@ -201,7 +198,7 @@ export const shrinkDialogue = (input, limit) => {
201
198
  }
202
199
  }
203
200
  for (let d = 0; d <= recent.drop; d++)
204
- if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d, end: Math.max(recent.end, recent.drop) }, notice: clipped }))
201
+ if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d }, notice: clipped }))
205
202
  return hit;
206
203
  // From here users give way, and the assistant keeps only that recent third.
207
204
  for (const t of tiers)
@@ -215,7 +212,7 @@ export const shrinkDialogue = (input, limit) => {
215
212
  if (hit = found("E1", { user: gone(user), assistant: recent, notice: { drop: d, tier: 1, end: notice.n } }))
216
213
  return hit;
217
214
  for (let d = recent.drop; d <= assistant.n; d++)
218
- if (hit = found("E2", { user: gone(user), assistant: { drop: d, tier: recent.tier, end: Math.max(recent.end, d) }, notice: gone(notice) }))
215
+ if (hit = found("E2", { user: gone(user), assistant: { ...recent, drop: d }, notice: gone(notice) }))
219
216
  return hit;
220
217
  return { text: "", step: "none", size: 0 };
221
218
  };
@@ -1,12 +1,20 @@
1
1
  import { normalize } from "./normalize.js";
2
2
  import { filterNoise } from "./filter-noise.js";
3
3
  import { shrinkDialogue, wholeDialogue } from "./shrink.js";
4
- const dialogueInput = (input) => ({
5
- blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes)),
6
- mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
7
- sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
8
- sourceEntryIds: input.sourceEntryIds,
9
- });
4
+ // A cut offers the whole dialogue and then, when it does not fit, the shrunk one, from the same input: normalize it once. The blocks are
5
+ // never mutated, and keeping the same objects also keeps their cached passage analyses.
6
+ const inputs = new WeakMap();
7
+ const dialogueInput = (input) => {
8
+ let prepared = inputs.get(input);
9
+ if (!prepared)
10
+ inputs.set(input, prepared = {
11
+ blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes)),
12
+ mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
13
+ sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
14
+ sourceEntryIds: input.sourceEntryIds,
15
+ });
16
+ return prepared;
17
+ };
10
18
  /** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted.
11
19
  * This disposable view grants neither present authority nor native tool execution. */
12
20
  export const compileDialogue = (input) => wholeDialogue(dialogueInput(input));
@@ -81,7 +81,15 @@ const LEGACY_RECEIPT_RANGE_NOTICE = RECEIPT_RANGE_NOTICE.replace("tuck_recall",
81
81
  */
82
82
  const escapeText = (text) => text.replace(/[\\\n\r]/g, c => c === "\\" ? "\\\\" : c === "\n" ? "\\n" : "\\r");
83
83
  /** The length of `text` as a record line carries it: one character more for each "\", line feed and carriage return. */
84
- export const escapedLength = (text) => text.length + (text.match(/[\\\n\r]/g)?.length ?? 0);
84
+ export const escapedLength = (text) => {
85
+ let length = text.length;
86
+ for (let i = 0; i < text.length; i++) {
87
+ const c = text.charCodeAt(i);
88
+ if (c === 92 || c === 10 || c === 13)
89
+ length++;
90
+ }
91
+ return length;
92
+ };
85
93
  const TITLE = /^[A-Z][A-Za-z0-9_-]*$/;
86
94
  const refuse = (record) => { throw new Error(`Cannot write a ${record.kind} record as ${TRANSCRIPT_HEADER}`); };
87
95
  /** One record as a line of TUCK-TRANSCRIPT/2 (without its newline). The records this build emits only: a record whose fields the grammar
@@ -42,20 +42,13 @@ export type NormalizedBlock = {
42
42
  kind: "tool_call";
43
43
  name: string;
44
44
  args: Record<string, unknown>;
45
- callId?: string;
46
45
  sourceIndex?: number;
47
46
  } | {
48
47
  kind: "tool_result";
49
48
  name: string;
50
- text: string;
51
49
  sourceIndex?: number;
52
50
  recallExport?: true;
53
51
  } | {
54
52
  kind: "bash";
55
- command: string;
56
- output: string;
57
- exitCode: number | undefined;
58
- cancelled?: boolean;
59
- truncated?: boolean;
60
53
  sourceIndex?: number;
61
54
  });
@@ -81,13 +81,16 @@ export declare const tuck: Tuck;
81
81
  * initiating prompt, file ledger) are already charged in base. */
82
82
  export declare function historicalAllowance(window: number, reserve: number, base: number, before: number): number;
83
83
  /** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
84
- export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean): Draft;
84
+ export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean): Draft;
85
85
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
86
+ /** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
87
+ * a run a custom message starts); the session has moved on from an ended run. */
86
88
  export declare function tuckObservedDraft(tuck: Tuck, input: {
87
89
  branchEntries: SessionEntry[];
88
90
  tokensBefore: number;
89
91
  previousSummary?: string;
90
92
  operatorManual?: boolean;
93
+ newPrompt?: boolean;
91
94
  }, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean): Draft;
92
95
  /** Numeric references use the original session index, then intersect with readable current-branch entries. */
93
96
  export declare function tuckRecall(tuck: Tuck, entries: readonly SessionEntry[], branch: readonly SessionEntry[], input: RecallOptions & {
package/dist/src/tuck.js CHANGED
@@ -26,10 +26,12 @@ export function historicalAllowance(window, reserve, base, before) {
26
26
  return Math.max(0, Math.min(Math.floor(free / 4), before - base - 1)) * 4;
27
27
  }
28
28
  /** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
29
- export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true, operatorManual = false) {
30
- return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual }, ctx, reserveTokens, measure, policy, retainCurrentInput);
29
+ export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
30
+ return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
31
31
  }
32
32
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
33
+ /** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
34
+ * a run a custom message starts); the session has moved on from an ended run. */
33
35
  export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true) {
34
36
  const { isHistorySource, readable } = createRecall(policy);
35
37
  const projected = ctx.sessionManager.buildSessionProjection();
@@ -73,10 +75,14 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
73
75
  const calls = new Set(response.message.content.flatMap(block => block.type === "toolCall" ? [block.id] : []));
74
76
  const hasFreshReceipt = entries.slice(lastResponse + 1).some(entry => entry.type === "message" &&
75
77
  entry.message.role === "toolResult" && calls.has(entry.message.toolCallId));
76
- freshRound = hasFreshReceipt;
78
+ // Fresh means the receipts are the answer the next request of this run carries. Once the session has moved on (the cut is planned for a
79
+ // run that has not begun, or a prompt has arrived since them) they are ordinary history, whatever ended their run: a reply discarded
80
+ // or one Pi hid when it recovered. Kept native, a receipt that never fits would refuse every later prompt. A retry is inside its run.
81
+ const superseded = input.newPrompt || entries.slice(lastResponse + 1).some(entry => entry.type === "message" && entry.message.role === "user");
82
+ freshRound = hasFreshReceipt && !superseded;
77
83
  const keptId = cut.firstKeptEntryId;
78
84
  const chosenBoundary = cut.compactAll ? entries.length : entries.findIndex(entry => entry.id === keptId);
79
- if (hasFreshReceipt && lastResponse < chosenBoundary) {
85
+ if (freshRound && lastResponse < chosenBoundary) {
80
86
  const prefix = tuck.buildOwnCut(entries.slice(0, lastResponse), 0);
81
87
  if (!prefix.ok)
82
88
  return { unavailable: "No reducible history before the fresh tool round" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Deterministic compaction for Pi: compact summaries that keep the dialogue and the files touched, plus history recall by keyword and by meaning, all local.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "72d28833b7180debd3ea6a7ca0308b000f8d27671b42c4d2b73dfaaa7bbdad59";
1
+ export const BUILD_ID = "6c391c76284110aae5bb375c04b2147d2fab17de3cb427448e8a869f5ad1e659";
package/src/compaction.ts CHANGED
@@ -40,7 +40,19 @@ const RecallInput = Type.Union([
40
40
  ]);
41
41
  const SearchInput = Type.Object({query,priorResults:Type.Optional(Type.Array(Type.Object({entryId,sourceSha:Type.String({minLength:64,maxLength:64}),start:Type.Integer({minimum:0}),end:Type.Integer({minimum:0})},{additionalProperties:false}),{maxItems:20}))},{additionalProperties:false});
42
42
  const reply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }], details: {} });
43
- class CapacityError extends Error {}
43
+ /** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. Like every failure but
44
+ * a `CommitFault`, it ends with its attempt (`forgetFailure`), and the next attempt plans afresh from the session as it is. */
45
+ class Refusal extends Error {}
46
+ /** A cut this compactor supplied that Pi then failed to commit. Pi appends a cut to the session in memory before it writes the file, so the
47
+ * session may hold a cut its file lacks; what is written after would build on it. The one failure that stays until the session restarts. */
48
+ class CommitFault extends Error {}
49
+ /** A refusal for want of room, which the user can remedy: it carries advice. */
50
+ class CapacityError extends Refusal {}
51
+ /** What a capacity refusal tells the operator. When the provider has counted far more input than pi-tuck's estimate, the refusal most
52
+ * likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
53
+ export const refusalNotice = (message: string, factor: number): string =>
54
+ message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
55
+ (factor >= 1.5 ? ` This provider has counted up to ${factor.toFixed(1)}x the input pi-tuck estimates; through a router such as OpenRouter, a different upstream may count less.` : "");
44
56
  /** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
45
57
  export const CALIBRATION_CEILING = 1.5;
46
58
  /** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
@@ -108,10 +120,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
108
120
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
109
121
  if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
110
122
  const semantic = options.semanticRecall ?? createSemanticRecall();
123
+ const hostAccess = () => {
124
+ if (!active()) throw new Error("Compaction host is inactive");
125
+ options.assertAccess?.();
126
+ };
111
127
  let semanticTransition: Promise<void> = Promise.resolve();
112
128
  let semanticRequest = 0;
113
129
  const semanticPending = new Set<Promise<unknown>>();
114
- const semanticProvider = (ctx: ExtensionContext) => () => memoizedEligibleSources(ctx, options.recallPolicy, access);
130
+ // Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
131
+ // attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
132
+ const semanticProvider = (ctx: ExtensionContext) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess);
115
133
  const recorder = new IncidentRecorder(options.diagnostics);
116
134
  let proposalId: string | undefined;
117
135
  const observe = (ctx: ExtensionContext) => recorder.measureInline(() => {
@@ -220,7 +238,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
220
238
  if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
221
239
  });
222
240
  pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
223
- pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; });
241
+ pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); });
224
242
  const active = () => options.active?.() ?? true;
225
243
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
226
244
  // The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
@@ -270,18 +288,21 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
270
288
  const fail = (error: unknown, ctx: ExtensionContext) => {
271
289
  recorder.stop("native-attempt-failed");
272
290
  const notify = !failure && error instanceof CapacityError;
273
- failure = error instanceof Error ? error : new Error(String(error));
291
+ // A CommitFault stays whatever fails after it.
292
+ if (!(failure instanceof CommitFault)) failure = error instanceof Error ? error : new Error(String(error));
274
293
  pendingBoundaryCommit = false;
275
294
  pending = undefined;
276
295
  try { options.fail?.(error); } finally {
277
- try { if (notify) ctx.ui.notify(failure.message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry.", "error"); }
296
+ try { if (notify) ctx.ui.notify(refusalNotice(failure.message, factor), "error"); }
278
297
  finally { ctx.abort(); }
279
298
  }
280
299
  };
300
+ // The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
301
+ // input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
302
+ const forgetFailure = () => { if (!(failure instanceof CommitFault)) failure = undefined; };
281
303
  const access = () => {
282
304
  if (failure) throw failure;
283
- if (!active()) throw new Error("Compaction host is inactive");
284
- options.assertAccess?.();
305
+ hostAccess();
285
306
  };
286
307
  // Abort alone is insufficient: Pi starts a fresh abort controller after
287
308
  // before_agent_start. Keep this attempt blocked, then recheck on new idle input.
@@ -290,7 +311,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
290
311
  let phase: "idle" | "decided" | "starting" = "idle";
291
312
  pi.on("input", async (event, ctx) => {
292
313
  if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
293
- if (failure instanceof CapacityError) failure = undefined;
314
+ forgetFailure();
294
315
  phase = "decided";
295
316
  try { await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools()); }
296
317
  catch (error) { fail(error, ctx); }
@@ -346,7 +367,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
346
367
  // Pi also labels the awaited idle incoming preflight "manual".
347
368
  const incoming = pendingCut;
348
369
  const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
349
- if (operatorManual && !admittedRun && failure instanceof CapacityError) failure = undefined;
370
+ if (operatorManual && !admittedRun) forgetFailure();
350
371
  access();
351
372
  if (event.customInstructions?.trim()) {
352
373
  recorder.stop("no-cut-custom-instructions");
@@ -380,7 +401,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
380
401
  : "Compaction did not reduce context within the estimated request budget",
381
402
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
382
403
  const reserve = planReserve(target, room, mandatory, ctx);
383
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual);
404
+ // Every cut asked while no run is admitted (a prompt's preflight, an operator's cut, Pi's own cut before a run a custom message
405
+ // starts) is planned for a run that has not begun: the session has moved on from any run that ended. A retry is inside its run.
406
+ const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, Boolean(incoming) || operatorManual || !admittedRun);
384
407
  return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
385
408
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
386
409
  },
@@ -391,7 +414,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
391
414
  if (decision.kind === "none") return { cancel: true };
392
415
  recorder.stop("no-cut");
393
416
  ctx.ui.notify(decision.message, "warning");
394
- if (!operatorManual) fail(decision.capacity ? new CapacityError(decision.message) : new Error(decision.message), ctx);
417
+ if (!operatorManual) fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
395
418
  return { cancel: true };
396
419
  } catch (error) {
397
420
  try { fail(error, ctx); } finally { return { cancel: true }; }
@@ -437,15 +460,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
437
460
  // An opportunistic cut is not needed to send the request, which is below the room: the guard decides. However it ends (the
438
461
  // planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
439
462
  // or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
463
+ // A needed cut that Pi has nothing to make (no history older than its keepRecentTokens since the last cut) is a refusal of
464
+ // this request, which ends with it: as a fault it stayed, and every later prompt of the session failed on it.
440
465
  if (cut.opportunistic && !failure && !cut.proposed) resolve();
441
- else reject(error.message === "Compaction cancelled" && failure instanceof CapacityError ? failure : error);
466
+ else if (!cut.proposed && isNoWork(error.message)) reject(new CapacityError(`The request needs a cut and Pi has none to make (${error.message})`));
467
+ else reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : cut.proposed ? new CommitFault(error.message) : error);
442
468
  } }));
443
469
  } finally { pendingCut = undefined; }
444
470
  access();
445
471
  // The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
446
472
  const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
447
473
  if (!cut.opportunistic && (after >= total || !afterFits))
448
- throw new (afterFits ? Error : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
474
+ throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
449
475
  };
450
476
  pi.on("before_agent_start", async (event, ctx) => {
451
477
  const prompted = phase === "decided";
@@ -492,7 +518,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
492
518
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
493
519
  },
494
520
  });
495
- if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Error)(decision.message);
521
+ if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
496
522
  if (decision.kind !== "cut") return;
497
523
  const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "tool-loop", before, true, false, previousSummary,
498
524
  { response, host, mandatory, target: decision.target, margin: room - band });
@@ -523,11 +549,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
523
549
  if (exhausted) return;
524
550
  // Pi reports manual no-work as failures; an accepted pi-tuck draft cannot be a no-op.
525
551
  if (event.reason === "manual" && !event.fromExtension && !event.aborted && isNoWork(event.errorMessage)) return;
526
- if (active() && !event.aborted) fail(new Error(event.errorMessage ?? "Compaction failed"), ctx);
552
+ if (active() && !event.aborted) fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
527
553
  });
528
554
  pi.on("agent_before_settle", (event, ctx) => {
529
555
  if (!pendingBoundaryCommit) return;
530
- if (event.outcome === "error") fail(new Error("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
556
+ if (event.outcome === "error") fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
531
557
  else pendingBoundaryCommit = false;
532
558
  });
533
559
  pi.on("context_with_system", (event, ctx) => {
@@ -98,12 +98,14 @@ const excerptRecords = (text: string, { passages, costs }: Analysis, role: "user
98
98
  headLimit: number, tailLimit: number, readFrom: (offset: number) => string): TranscriptRecord[] => {
99
99
  const charLimit = 1024;
100
100
  const identity = { role, ...(sourceIndex !== undefined ? { sourceIndex } : {}) };
101
- if (escapedLength(text) <= charLimit && costs.reduce((sum, cost) => sum + cost, 0) <= headLimit + tailLimit)
101
+ // A line carries at least as many characters as the text, so a long text is over the limit without counting its escapes.
102
+ const fits = (from: number, to: number) => to - from <= charLimit && escapedLength(text.slice(from, to)) <= charLimit;
103
+ if (fits(0, text.length) && costs.reduce((sum, cost) => sum + cost, 0) <= headLimit + tailLimit)
102
104
  return [{ kind: "excerpt", ...identity, text }];
103
105
  let head = 0, headCost = 0, tail = passages.length, tailCost = 0;
104
- while (head < tail && headCost + costs[head]! <= headLimit && escapedLength(text.slice(0, passages[head]!.end)) <= charLimit) { headCost += costs[head++]!; }
106
+ while (head < tail && headCost + costs[head]! <= headLimit && fits(0, passages[head]!.end)) { headCost += costs[head++]!; }
105
107
  if (tailLimit > 0) {
106
- while (tail > head && tailCost + costs[tail - 1]! <= tailLimit && escapedLength(text.slice(passages[tail - 1]!.start)) <= charLimit) { tailCost += costs[--tail]!; }
108
+ while (tail > head && tailCost + costs[tail - 1]! <= tailLimit && fits(passages[tail - 1]!.start, text.length)) { tailCost += costs[--tail]!; }
107
109
  }
108
110
  const headEnd = head ? passages[head - 1]!.end : 0;
109
111
  const tailStart = tail < passages.length ? passages[tail]!.start : text.length;
@@ -1,9 +1,10 @@
1
1
  import type { NormalizedBlock } from "../types";
2
2
  import { isSourceExport, isLookupTool, isRecallTool } from "./recall-operation";
3
+ import { isDialogue } from "./brief";
3
4
 
4
5
  /** Suppress lookup echoes, retaining recorded export attempts and exactly paired outcomes. */
5
6
  export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => blocks.filter(b =>
6
7
  !(b.kind === "tool_call" && isLookupTool(b.name) && !(isRecallTool(b.name) && isSourceExport(b.args))) &&
7
8
  !(b.kind === "tool_result" && isLookupTool(b.name) &&
8
9
  !(isRecallTool(b.name) && b.recallExport)) &&
9
- !((b.kind === "user" || b.kind === "assistant" || b.kind === "notice") && !b.text.trim()));
10
+ !(isDialogue(b) && !b.text.trim()));
@@ -19,14 +19,9 @@ const normalizeOne = (msg: CompilerMessage, msgIndex: number | undefined): Norma
19
19
  return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
20
20
  }
21
21
 
22
- if (msg.role === "bashExecution") {
23
- const cmd = msg.command ?? "";
24
- const out = msg.output ?? "";
25
- const exit = msg.exitCode;
26
- return [{ kind: "bash", command: cmd, output: out, exitCode: exit, cancelled: msg.cancelled, truncated: msg.truncated, sourceIndex: msgIndex }];
27
- }
22
+ if (msg.role === "bashExecution") return [{ kind: "bash", sourceIndex: msgIndex }];
28
23
 
29
- if (msg.role === "toolResult") return [{ kind: "tool_result", name: msg.toolName, text: sanitize(textOf(msg.content)), sourceIndex: msgIndex }];
24
+ if (msg.role === "toolResult") return [{ kind: "tool_result", name: msg.toolName, sourceIndex: msgIndex }];
30
25
 
31
26
  if (msg.role === "assistant") {
32
27
  if (!msg.content) return [];
@@ -47,7 +42,6 @@ const normalizeOne = (msg: CompilerMessage, msgIndex: number | undefined): Norma
47
42
  kind: "tool_call",
48
43
  name: part.name,
49
44
  args: part.arguments,
50
- callId: part.id,
51
45
  sourceIndex: msgIndex,
52
46
  });
53
47
  }
@@ -102,12 +102,12 @@ const createLane = (role: Role, units: DialogueBlock[], mandatory: ReadonlySet<n
102
102
  const empty = emptied[tier]!, counts = emptyCounts[tier]!;
103
103
  count += counts[end]! - counts[drop]!;
104
104
  if (counts[end]! > counts[drop]!) {
105
- const extra: number[] = [];
106
- for (let i = drop; i < end && extra.length < 9; i++) if (empty[i]) {
105
+ // Text blocks of one message share a locator: collect distinct ones, enough to tell whether the shown list is complete.
106
+ const extra = new Set<number>();
107
+ for (let i = drop; i < end && (extra.size < 9 || !unlocated); i++) if (empty[i]) {
107
108
  const source = units[i]!.sourceIndex;
108
- if (source === undefined) unlocated = true; else extra.push(source);
109
+ if (source === undefined) unlocated = true; else if (extra.size < 9) extra.add(source);
109
110
  }
110
- for (let i = drop; i < end && !unlocated; i++) if (empty[i] && units[i]!.sourceIndex === undefined) unlocated = true;
111
111
  sources = [...new Set([...sources, ...extra])].sort((a, b) => a - b).slice(0, 9);
112
112
  }
113
113
  }
@@ -132,11 +132,10 @@ const createDialogue = (input: DialogueInput) => {
132
132
  const dialogue = input.blocks.filter(isDialogue);
133
133
  const times = dialogue.find(block => block.sourceTimes)?.sourceTimes;
134
134
  const decorate = (record: TranscriptRecord) => decorateRecord(record, times);
135
- const roleOf = (block: DialogueBlock): Role => block.kind;
136
- const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => roleOf(block) === role), input.mandatorySourceIndices, decorate)])) as
135
+ const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => block.kind === role), input.mandatorySourceIndices, decorate)])) as
137
136
  Record<Role, ReturnType<typeof createLane>>;
138
137
  const seen: Record<Role, number> = { user: 0, assistant: 0, notice: 0 };
139
- const order = dialogue.map(block => ({ role: roleOf(block), i: seen[roleOf(block)]++ }));
138
+ const order = dialogue.map(block => ({ role: block.kind, i: seen[block.kind]++ }));
140
139
  const tools = input.blocks.length > dialogue.length ? rendered([TOOLS_OMITTED]) : undefined;
141
140
  const overhead = TRANSCRIPT_HEADER.length + DIALOGUE_SUFFIX.length;
142
141
 
@@ -204,7 +203,7 @@ export const shrinkDialogue = (input: DialogueInput, limit: number): Shrunk => {
204
203
  if (cost > 0 && cost <= limit / 3) { recent = { drop: assistant.n - 1, tier: t, end: assistant.n }; break; }
205
204
  }
206
205
  for (let d = 0; d <= recent.drop; d++)
207
- if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d, end: Math.max(recent.end, recent.drop) }, notice: clipped })) return hit;
206
+ if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d }, notice: clipped })) return hit;
208
207
  // From here users give way, and the assistant keeps only that recent third.
209
208
  for (const t of tiers) for (let u = 0; u <= user.n; u++)
210
209
  if (hit = found(`C${t}`, { user: { drop: 0, tier: t, end: u }, assistant: recent, notice: clipped })) return hit;
@@ -213,6 +212,6 @@ export const shrinkDialogue = (input: DialogueInput, limit: number): Shrunk => {
213
212
  for (let d = 0; d <= notice.n; d++)
214
213
  if (hit = found("E1", { user: gone(user), assistant: recent, notice: { drop: d, tier: 1, end: notice.n } })) return hit;
215
214
  for (let d = recent.drop; d <= assistant.n; d++)
216
- if (hit = found("E2", { user: gone(user), assistant: { drop: d, tier: recent.tier, end: Math.max(recent.end, d) }, notice: gone(notice) })) return hit;
215
+ if (hit = found("E2", { user: gone(user), assistant: { ...recent, drop: d }, notice: gone(notice) })) return hit;
217
216
  return { text: "", step: "none", size: 0 };
218
217
  };
@@ -20,12 +20,19 @@ export interface CompileInput {
20
20
  };
21
21
  }
22
22
 
23
- const dialogueInput = (input: CompileInput): DialogueInput => ({
24
- blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes)),
25
- mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
26
- sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
27
- sourceEntryIds: input.sourceEntryIds,
28
- });
23
+ // A cut offers the whole dialogue and then, when it does not fit, the shrunk one, from the same input: normalize it once. The blocks are
24
+ // never mutated, and keeping the same objects also keeps their cached passage analyses.
25
+ const inputs = new WeakMap<CompileInput, DialogueInput>();
26
+ const dialogueInput = (input: CompileInput): DialogueInput => {
27
+ let prepared = inputs.get(input);
28
+ if (!prepared) inputs.set(input, prepared = {
29
+ blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes)),
30
+ mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
31
+ sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
32
+ sourceEntryIds: input.sourceEntryIds,
33
+ });
34
+ return prepared;
35
+ };
29
36
 
30
37
  /** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted.
31
38
  * This disposable view grants neither present authority nor native tool execution. */
@@ -106,7 +106,11 @@ const LEGACY_RECEIPT_RANGE_NOTICE = RECEIPT_RANGE_NOTICE.replace("tuck_recall",
106
106
  */
107
107
  const escapeText = (text: string): string => text.replace(/[\\\n\r]/g, c => c === "\\" ? "\\\\" : c === "\n" ? "\\n" : "\\r");
108
108
  /** The length of `text` as a record line carries it: one character more for each "\", line feed and carriage return. */
109
- export const escapedLength = (text: string): number => text.length + (text.match(/[\\\n\r]/g)?.length ?? 0);
109
+ export const escapedLength = (text: string): number => {
110
+ let length = text.length;
111
+ for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if (c === 92 || c === 10 || c === 13) length++; }
112
+ return length;
113
+ };
110
114
  const TITLE = /^[A-Z][A-Za-z0-9_-]*$/;
111
115
  const refuse = (record: TranscriptRecord): never => { throw new Error(`Cannot write a ${record.kind} record as ${TRANSCRIPT_HEADER}`); };
112
116
 
package/src/tuck/types.ts CHANGED
@@ -18,6 +18,7 @@ export type NormalizedBlock = { sourceTimes?: Record<number, import("./core/sour
18
18
  | { kind: "user"; text: string; originalText?: string; sourceIndex?: number; startsSource?: true }
19
19
  | { kind: "assistant"; text: string; originalText?: string; sourceIndex?: number; startsSource?: true }
20
20
  | { kind: "notice"; text: string; originalText?: string; sourceIndex?: number; sourceEntryId?: string; startsSource?: true }
21
- | { kind: "tool_call"; name: string; args: Record<string, unknown>; callId?: string; sourceIndex?: number }
22
- | { kind: "tool_result"; name: string; text: string; sourceIndex?: number; recallExport?: true }
23
- | { kind: "bash"; command: string; output: string; exitCode: number | undefined; cancelled?: boolean; truncated?: boolean; sourceIndex?: number });
21
+ // Tool history renders only as one omission notice, so its blocks keep what lookup-echo filtering reads.
22
+ | { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
23
+ | { kind: "tool_result"; name: string; sourceIndex?: number; recallExport?: true }
24
+ | { kind: "bash"; sourceIndex?: number });
package/src/tuck.ts CHANGED
@@ -58,11 +58,13 @@ export function historicalAllowance(window: number, reserve: number, base: numbe
58
58
  return Math.max(0, Math.min(Math.floor(free / 4), before - base - 1)) * 4;
59
59
  }
60
60
  /** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
61
- export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false) {
62
- return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual }, ctx, reserveTokens, measure, policy, retainCurrentInput);
61
+ export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
62
+ return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
63
63
  }
64
64
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
65
- export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean }, ctx: ExtensionContext,
65
+ /** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
66
+ * a run a custom message starts); the session has moved on from an ended run. */
67
+ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean }, ctx: ExtensionContext,
66
68
  reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true): Draft {
67
69
  const { isHistorySource, readable } = createRecall(policy);
68
70
  const projected = ctx.sessionManager.buildSessionProjection();
@@ -102,10 +104,14 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
102
104
  const calls = new Set(response.message.content.flatMap(block => block.type === "toolCall" ? [block.id] : []));
103
105
  const hasFreshReceipt = entries.slice(lastResponse + 1).some(entry => entry.type === "message" &&
104
106
  entry.message.role === "toolResult" && calls.has(entry.message.toolCallId));
105
- freshRound = hasFreshReceipt;
107
+ // Fresh means the receipts are the answer the next request of this run carries. Once the session has moved on (the cut is planned for a
108
+ // run that has not begun, or a prompt has arrived since them) they are ordinary history, whatever ended their run: a reply discarded
109
+ // or one Pi hid when it recovered. Kept native, a receipt that never fits would refuse every later prompt. A retry is inside its run.
110
+ const superseded = input.newPrompt || entries.slice(lastResponse + 1).some(entry => entry.type === "message" && entry.message.role === "user");
111
+ freshRound = hasFreshReceipt && !superseded;
106
112
  const keptId = cut.firstKeptEntryId;
107
113
  const chosenBoundary = cut.compactAll ? entries.length : entries.findIndex(entry => entry.id === keptId);
108
- if (hasFreshReceipt && lastResponse < chosenBoundary) {
114
+ if (freshRound && lastResponse < chosenBoundary) {
109
115
  const prefix = tuck.buildOwnCut(entries.slice(0, lastResponse), 0);
110
116
  if (!prefix.ok) return { unavailable: "No reducible history before the fresh tool round" };
111
117
  cut = { ...cut, messages: prefix.messages, selectedIds: prefix.selectedIds,