pi-tuck 1.0.1 → 1.0.4

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +65 -3
  2. package/README.md +138 -57
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +7 -4
  5. package/dist/src/budget.d.ts +17 -4
  6. package/dist/src/budget.js +33 -10
  7. package/dist/src/build-id.d.ts +1 -1
  8. package/dist/src/build-id.js +1 -1
  9. package/dist/src/compaction.d.ts +7 -0
  10. package/dist/src/compaction.js +32 -16
  11. package/dist/src/diagnostics.d.ts +2 -1
  12. package/dist/src/omissions.d.ts +1 -1
  13. package/dist/src/omissions.js +8 -8
  14. package/dist/src/tuck/core/brief.d.ts +13 -43
  15. package/dist/src/tuck/core/brief.js +41 -255
  16. package/dist/src/tuck/core/filter-noise.js +2 -1
  17. package/dist/src/tuck/core/format.d.ts +2 -6
  18. package/dist/src/tuck/core/format.js +4 -10
  19. package/dist/src/tuck/core/normalize.js +22 -50
  20. package/dist/src/tuck/core/shrink.d.ts +33 -0
  21. package/dist/src/tuck/core/shrink.js +218 -0
  22. package/dist/src/tuck/core/summarize.d.ts +15 -7
  23. package/dist/src/tuck/core/summarize.js +17 -22
  24. package/dist/src/tuck/core/transcript.d.ts +24 -13
  25. package/dist/src/tuck/core/transcript.js +251 -83
  26. package/dist/src/tuck/types.d.ts +4 -21
  27. package/dist/src/tuck.d.ts +8 -10
  28. package/dist/src/tuck.js +23 -25
  29. package/index.ts +8 -5
  30. package/package.json +1 -1
  31. package/src/budget.ts +29 -9
  32. package/src/build-id.ts +1 -1
  33. package/src/compaction.ts +33 -14
  34. package/src/diagnostics.ts +1 -1
  35. package/src/omissions.ts +8 -8
  36. package/src/tuck/core/brief.ts +48 -265
  37. package/src/tuck/core/filter-noise.ts +2 -1
  38. package/src/tuck/core/format.ts +4 -11
  39. package/src/tuck/core/normalize.ts +21 -50
  40. package/src/tuck/core/shrink.ts +217 -0
  41. package/src/tuck/core/summarize.ts +26 -24
  42. package/src/tuck/core/transcript.ts +238 -82
  43. package/src/tuck/types.ts +9 -20
  44. package/src/tuck.ts +30 -32
  45. package/dist/src/tuck/core/brief-meter.d.ts +0 -42
  46. package/dist/src/tuck/core/brief-meter.js +0 -273
  47. package/dist/src/tuck/core/rank.d.ts +0 -42
  48. package/dist/src/tuck/core/rank.js +0 -292
  49. package/src/tuck/core/brief-meter.ts +0 -235
  50. package/src/tuck/core/rank.ts +0 -331
package/CHANGELOG.md CHANGED
@@ -3,6 +3,64 @@
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.4] - 2026-10-31
7
+
8
+ ### Changed
9
+
10
+ - 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
11
+ more), the notice now says so and suggests that a different upstream may count less. Behind a router such as OpenRouter, one upstream can
12
+ 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.
13
+
14
+ ### Fixed
15
+
16
+ - An omission notice lists each omitted message's locator once: when several text blocks of one message were shortened to nothing, repeats
17
+ could crowd the list and drop its "Locator list incomplete" flag.
18
+ - 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
19
+ 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
20
+ 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
21
+ want of room was held the same way and also aborted every later prompt. The next run, whatever starts it, now decides afresh.
22
+
23
+ ## [1.0.3] - 2026-10-31
24
+
25
+ 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
26
+ now published from GitHub Actions by npm trusted publishing, with provenance.
27
+
28
+ ### Added
29
+
30
+ - `"cutAt"` in `pi-tuck.json` (and the `cutAt` option of `installCompaction`) sets the fraction of the input room at which a cut is triggered and
31
+ planned: a number above 0 and at most 1. The room still decides every refusal. A cut's receipt records `cutAt` when it is not 1.
32
+
33
+ ### Changed
34
+
35
+ - Cuts happen once a request passes 60% of the input room (`cutAt` 0.6) instead of just under the whole room. Each cut leaves room to grow,
36
+ so fewer tokens are carried per request: in paired replays against cutting at the full room, answers held and billed input fell about 17%
37
+ at a 128k window and 10-14% at 32k, with prompt caching automatic or by explicit breakpoints. `{"cutAt": 1}` restores the earlier point.
38
+
39
+ ## [1.0.2] - 2026-10-31
40
+
41
+ Tagged, not published to npm; 1.0.3 ships it.
42
+
43
+ Measured before release with paired replays against 1.0.1 (same sessions, same model as agent and judge): no loss of answer accuracy at
44
+ a 128k or a 32k window, and time questions answered as before.
45
+
46
+ ### Changed
47
+
48
+ - A dialogue that does not fit the summary is now shortened, oldest assistant text first, instead of replaced by a ranked selection of
49
+ records. The newest assistant text that fits a third of the allowance is kept until user prompts have given way; a clip that would keep
50
+ no text is counted in one omission notice instead.
51
+ - Records name the time of user turns only; assistant, tool and notice records no longer carry one. Recall still returns every
52
+ source's time.
53
+ - Summaries are headed `TUCK-TRANSCRIPT/2` and hold one line of plain text per record (`#12 user @2026-10-30T14:00:00Z: text`) instead
54
+ of one JSON object, which makes them about 30 percent shorter on sessions where the assistant writes little and about 14 percent on
55
+ prose-heavy ones. A passage a clip leaves out names the call that reads it back (`tuck_recall {"index":12,"offset":523}`). Summaries
56
+ saved by 1.0.x (`TUCK-TRANSCRIPT/1`, and `VCC-TRANSCRIPT/1` before them) are read as before and keep their format when a native
57
+ omission filters them.
58
+
59
+ ### Fixed
60
+
61
+ - In small windows a session that writes little prose cut again almost every tool round (174 cuts in 300 rounds at 32k), because the
62
+ recent turns kept native could fill the whole input room. They now take at most half of it.
63
+
6
64
  ## [1.0.1] - 2026-10-30
7
65
 
8
66
  First release on npm. 1.0.0 was tagged but never published; its notes below describe what 1.0.1 ships besides these changes.
@@ -18,9 +76,10 @@ First release on npm. 1.0.0 was tagged but never published; its notes below desc
18
76
 
19
77
  ### Fixed
20
78
 
21
- - 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
22
- the ranked view re-rendered the whole candidate summary for each block it considered. It now keeps the summary's size up to date
23
- as blocks join, so a cut takes well under a second and grows linearly with the session. Summaries are byte for byte the same.
79
+ - A long session whose dialogue no longer fit the summary stalled for tens of seconds at every cut, because the ranked view re-rendered
80
+ 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
81
+ 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
82
+ same synthetic session. Summaries are byte for byte the same.
24
83
 
25
84
  ## [1.0.0] - 2026-10-30
26
85
 
@@ -62,6 +121,9 @@ Tagged, not published.
62
121
  Source preview on GitHub, not published to npm. Deterministic compaction with `pi_tuck_recall` and `pi_tuck_search`; semantic search
63
122
  opt-in, with a runtime the user had to supply.
64
123
 
124
+ [1.0.4]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.4
125
+ [1.0.3]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.3
126
+ [1.0.2]: https://github.com/galyavin/pi-tuck/tree/v1.0.2
65
127
  [1.0.1]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.1
66
128
  [1.0.0]: https://github.com/galyavin/pi-tuck/tree/v1.0.0
67
129
  [0.2.1]: https://github.com/galyavin/pi-tuck/tree/e00fa99
package/README.md CHANGED
@@ -1,71 +1,166 @@
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.** A cut takes about a tenth of a second. 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.1
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.
12
+
13
+ | | Pi's built-in | 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 | No | Yes |
19
+ | Can the agent get dropped detail back | No recall tool. 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 | Yes, a model wrote 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 from cutting early (simulated) | | 10–18% less than cutting at the full room |
24
+ | Answer quality against LLM compactors | | **Being measured. Not claimed yet.** |
25
+
26
+ The time and context rows are live runs on a local model with a 131k window: 27 cuts per arm over 5 chains, interim, pi-tuck 1.0.1. 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).
27
+
28
+ ## Ask your history
29
+
30
+ 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:
31
+
32
+ > "What was the reason we dropped the retry on the token refresh?"
33
+
34
+ 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:
35
+
36
+ ```json
37
+ { "query": "why did we drop the retry on token refresh" }
12
38
  ```
13
39
 
14
- or straight from GitHub:
40
+ It gets back the original passage, with a pointer such as `#212`. It reads the whole message, exactly as it was written:
41
+
42
+ ```json
43
+ { "index": 212 }
44
+ ```
45
+
46
+ That is `tuck_recall`. The agent answers from the original text, not from a model's memory of it.
47
+
48
+ *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.
49
+
50
+ ## Install
15
51
 
16
52
  ```sh
17
- pi install git:github.com/galyavin/pi-tuck@v1.0.1
53
+ pi install npm:pi-tuck
18
54
  ```
19
55
 
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.
56
+ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, both automatic and `/compact`. You change no setting.
57
+
58
+ - Needs Pi 1.1 or later (tested on 1.1.0 and 1.4.0), on macOS or Linux.
59
+ - 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).
60
+ - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.4`.
61
+ - To pin a version, add it: `pi install npm:pi-tuck@1.0.4`.
23
62
 
24
- ## What it does
63
+ ## How it works
25
64
 
26
- **Compaction.** pi-tuck takes over Pi's compaction, both automatic and `/compact`. A summary keeps:
65
+ When context fills up, pi-tuck replaces the older turns with one summary. The summary keeps:
27
66
 
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).
67
+ - **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.
68
+ - **The files.** Files the agent read, wrote or edited, newest first (up to 60), each with a pointer to read it back.
69
+ - **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.
70
+ - **Your first prompt**, word for word (up to 12,000 characters).
32
71
 
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, the summary
34
- keeps a ranked selection of records instead. Each summary ends with a short instruction to the agent: re-read a file before editing it,
35
- and look things up rather than guess.
72
+ 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.
36
73
 
37
- **`tuck_recall`** finds earlier text by keyword and reads any original message or tool result exactly, by the index shown in a summary.
38
- It can also write an original back out to a file.
74
+ 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.
39
75
 
40
- **`tuck_search`** finds earlier history related to a question even when the wording differs. It combines keyword hits with local
41
- semantic search (see below) and returns the original text with pointers for `tuck_recall`.
76
+ The agent gets two tools to get anything back:
42
77
 
43
- **Commands**
78
+ - **`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.
79
+ - **`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`.
80
+
81
+ Both tools cost about 1,050 tokens of tool description per request.
44
82
 
45
83
  | Command | Does |
46
84
  |---|---|
47
85
  | `/tuck-semantic on\|off\|status` | Turns semantic search on or off for this session, or shows its state. |
48
86
  | `/tuck-incident on\|next\|off\|status` | Records this session's compaction decisions to a file you can attach to a bug report. |
49
87
 
50
- `/compact` works as usual; pi-tuck does not take custom summary instructions. Pi's own setting `"compaction": { "enabled": false }`
51
- turns compaction off.
88
+ `/compact` works as usual, but pi-tuck does not take custom summary instructions. Pi's own setting `"compaction": { "enabled": false }` turns compaction off.
89
+
90
+ ## What is proven, and what is not
91
+
92
+ **Measured so far:** a cut takes a fraction of a second, where a model-written summary takes minutes on a local model. A cut calls no model, so it costs no tokens. pi-tuck carries about as many tokens after a cut as the built-in does, not fewer. Cutting early saves billed input in simulation, and answers held in replay.
93
+
94
+ **Not proven yet: that the summary is as good as a model-written one.** The claim is that pi-tuck compacts as well as the best LLM compactors, in far less time and at no cost. 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, an LLM compactor.
95
+
96
+ The test has three parts:
97
+
98
+ 1. **A live run** at a 128k window on a local model. 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.
99
+ 2. **A paired replay with probe questions.** The same cuts, made by pi-tuck and by LLM compactors, 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 LLM compactor, with a 95% bound.
100
+ 3. **A 32k suite**, where tasks are bigger than the room, so cuts must summarise work in progress.
52
101
 
53
- **Small context windows.** pi-tuck keeps room for the model's reply: the larger of the model's output limit and Pi's
54
- `compaction.reserveTokens` (default 16,384). It refuses a request that cannot fit rather than let the provider cut it off. For a model
55
- with a window of 32k tokens or less, lower the reserve for that model in Pi's `settings.json`:
102
+ The plan: [`benchmark/PREREG.md`](benchmark/PREREG.md), [`benchmark/PREREG-32K.md`](benchmark/PREREG-32K.md), and every change after the freeze in [`benchmark/DEVIATIONS.md`](benchmark/DEVIATIONS.md).
103
+
104
+ Known limits today:
105
+
106
+ - 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.
107
+ - 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, and a refusal no longer affects the next prompt. Pin a different upstream, lower the reserve, or use a larger window; see [Small windows](#small-windows).
108
+ - Semantic search runs only on macOS with Apple silicon.
109
+
110
+ ## Benchmarks
111
+
112
+ Each row says what was measured, under which conditions, and where the data is. "Interim" means the run is not finished.
113
+
114
+ | What | Result | Conditions | Source |
115
+ |---|---|---|---|
116
+ | 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 |
117
+ | 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). 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. Frontier is `openai/gpt-6-astra`. pi-tuck's time leaves out the background search index | [`benchmark/RESULTS-INTERIM.md`](benchmark/RESULTS-INTERIM.md) section 2 |
118
+ | 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) |
119
+ | 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) |
120
+ | 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) |
121
+ | 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 |
122
+ | 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 (R&D's planner replay) | [`test/cut-yield.test.ts`](test/cut-yield.test.ts), which pins that every cut frees at least a tenth of the room |
123
+ | 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 |
124
+
125
+ Notes:
126
+
127
+ - The billing figures come from a simulation that replays real sessions through the real cut trigger and prices every request. They are not bills.
128
+ - 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.
129
+ - 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.
130
+ - Everything here is from SWE-bench-style tasks on a small set of models. It may not carry to your work.
131
+
132
+ ## Settings
133
+
134
+ 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):
135
+
136
+ ```json
137
+ { "cutAt": 0.6, "semanticRecall": true }
138
+ ```
139
+
140
+ - **`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.
141
+ - **`semanticRecall`** (`false` turns semantic search off). Nothing is then downloaded, and `tuck_search` uses keywords only.
142
+
143
+ A bad file is reported at session start, and pi-tuck uses the defaults.
144
+
145
+ ### Small windows
146
+
147
+ 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:
148
+
149
+ ```json
150
+ { "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 8192 } } } }
151
+ ```
152
+
153
+ 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:
56
154
 
57
155
  ```json
58
- { "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 4096 } } } }
156
+ { "providers": { "openrouter": { "compat": { "openRouterRouting": { "only": ["your-upstream"], "allow_fallbacks": false } } } } }
59
157
  ```
60
158
 
61
159
  ## Semantic search
62
160
 
63
- Semantic search is on by default and runs entirely on your Mac: an embedding model,
64
- [EmbeddingGemma-2](https://huggingface.co/google/embeddinggemma-2), served by [llama.cpp](https://github.com/ggml-org/llama.cpp) on the
65
- CPU. No text leaves your machine.
161
+ 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.
66
162
 
67
- The first time a session starts, pi-tuck downloads what it needs, once, about 390 MB in the background, and keeps working with keyword
68
- search meanwhile:
163
+ The first time a session starts, pi-tuck downloads what it needs, once, about 390 MB, in the background. Keyword search works meanwhile:
69
164
 
70
165
  | File | From |
71
166
  |---|---|
@@ -73,21 +168,9 @@ search meanwhile:
73
168
  | llama.cpp release b11476 for macOS arm64 (12 MB) | github.com/ggml-org/llama.cpp |
74
169
  | CPython 3.13 (25 MB) and 16 Python packages for the tokenizer (9 MB) | github.com/astral-sh/python-build-standalone, pypi.org |
75
170
 
76
- 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`;
77
- 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
78
- example offline, pi-tuck says so and tries again at the next session start. One-shot runs (`pi -p`) don't start the setup.
171
+ 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.
79
172
 
80
- 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
81
- 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.
82
-
83
- **Turn it off** by creating `~/.pi/agent/pi-tuck.json`:
84
-
85
- ```json
86
- { "semanticRecall": false }
87
- ```
88
-
89
- Nothing is then downloaded, and `tuck_search` uses keywords only. If you set `PI_CODING_AGENT_DIR`, the file and the download folder are
90
- in that folder instead.
173
+ 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.
91
174
 
92
175
  ## Use from code
93
176
 
@@ -102,8 +185,7 @@ const handle = await installCompaction(pi, { semanticRecall });
102
185
  await handle.setSemanticEnabled(true, ctx); // e.g. in a session_start handler
103
186
  ```
104
187
 
105
- `installCompaction` options (reply reserve, host-injected messages, recall policy and others) are documented in
106
- [src/compaction.ts](src/compaction.ts). `handle.close()` stops the semantic worker; call it before disposing a session.
188
+ `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.
107
189
 
108
190
  ## Development
109
191
 
@@ -114,12 +196,11 @@ npm test # unit tests (Vitest) and the Python runtime tests
114
196
  npm run build # dist/ for the library export
115
197
  ```
116
198
 
199
+ Changes are listed in [CHANGELOG.md](CHANGELOG.md). Releases are published from GitHub Actions by npm trusted publishing, with provenance.
200
+
117
201
  ## Credits
118
202
 
119
- pi-tuck grew out of [pi-vcc](https://github.com/sting8k/pi-vcc) by sting8k and includes code from
120
- [pi-blackhole](https://github.com/k0valik/pi-blackhole) by k0valik and from [Pi](https://github.com/earendil-works/pi) by Mario
121
- 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
122
- PSF license.
203
+ 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.
123
204
 
124
205
  ## License
125
206
 
package/dist/index.d.ts CHANGED
@@ -6,5 +6,6 @@ export { createSemanticRecall, SemanticRecallController } from "./src/semantic-r
6
6
  export type { SemanticRecallConfig, PriorResult, SemanticState } from "./src/semantic-recall.js";
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
- * says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. */
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
11
  export default function (pi: ExtensionAPI): Promise<void>;
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { join, sep } from "node:path";
4
4
  import { installCompaction } from "./src/compaction.js";
5
+ import { validCutAt } from "./src/budget.js";
5
6
  import { createSemanticRecall } from "./src/semantic-recall.js";
6
7
  import { defaultRoot } from "./src/assets.js";
7
8
  import { ensureSemanticRuntime } from "./src/setup.js";
@@ -10,13 +11,14 @@ export { inspectIncident, readableIncident } from "./src/diagnostic-replay.js";
10
11
  export { createSemanticRecall, SemanticRecallController } from "./src/semantic-recall.js";
11
12
  export { ensureSemanticRuntime } from "./src/setup.js";
12
13
  /** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
13
- * says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. */
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. */
14
16
  export default async function (pi) {
15
17
  const semanticRecall = createSemanticRecall();
16
18
  // `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
17
19
  let off = false;
18
- const handle = await installCompaction(pi, { semanticRecall, onSemanticSetting: async (enabled) => { off = !enabled; } });
19
20
  const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
21
+ const handle = await installCompaction(pi, { semanticRecall, cutAt: settings.cutAt, onSemanticSetting: async (enabled) => { off = !enabled; } });
20
22
  if (settings.semanticRecall === false)
21
23
  return;
22
24
  let session = 0, said = "", setup;
@@ -74,11 +76,12 @@ function readSettings(path) {
74
76
  }
75
77
  try {
76
78
  const value = JSON.parse(text);
77
- if (value && typeof value === "object" && (value.semanticRecall === undefined || typeof value.semanticRecall === "boolean"))
79
+ if (value && typeof value === "object" && (value.semanticRecall === undefined || typeof value.semanticRecall === "boolean") &&
80
+ (value.cutAt === undefined || validCutAt(value.cutAt)))
78
81
  return value;
79
82
  }
80
83
  catch { /* reported below */ }
81
- return { error: `${path} should be a JSON object like {"semanticRecall": false}` };
84
+ return { error: `${path} should be a JSON object like {"semanticRecall": false, "cutAt": 0.8}, with cutAt a number above 0 and at most 1` };
82
85
  }
83
86
  const reason = (error) => error instanceof Error ? error.message : String(error);
84
87
  const within = (path, folder) => path === folder || path.startsWith(folder.endsWith(sep) ? folder : folder + sep);
@@ -16,8 +16,17 @@ export interface ToolShape {
16
16
  }
17
17
  /** Triggers fire this fraction of the room early. The margin absorbs request growth a boundary cannot see; it never enters the calibration factor. */
18
18
  export declare const TRIGGER_MARGIN_FRACTION = 0.02;
19
- /** The limit a cut is triggered at and preferably planned to; the exact room stays the limit of every throw and fault. */
20
- export declare const bandOf: (room: number) => number;
19
+ /** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
20
+ export declare const validCutAt: (value: unknown) => value is number;
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.
25
+ */
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
27
+ * input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
28
+ export declare const DEFAULT_CUT_AT = 0.6;
29
+ export declare const bandOf: (room: number, cutAt?: number) => number;
21
30
  /**
22
31
  * Replayed reasoning that Pi's estimator does not see. On the OpenAI Responses APIs an assistant turn's reasoning item rides in its thinking
23
32
  * block's `thinkingSignature` (encrypted) and is sent back with later requests (pi-ai openai-responses-shared; transform-messages skips
@@ -49,7 +58,7 @@ export declare const measurableConversationTokens: (messages: Messages, ctx: Ext
49
58
  /** Raw reserve for the cut planner: the window less the raw tokens that fit the target room, plus the input no cut can remove. */
50
59
  export declare const planningReserve: (window: number, targetRoom: number, factor: number, mandatory: number) => number;
51
60
  export type Target = "band" | "room";
52
- /** A planned cut: the conversation it leaves, and the summary view that produced it (whole dialogue or ranked). */
61
+ /** A planned cut: the conversation it leaves, and the summary view that produced it (whole dialogue or shrunk). */
53
62
  export type Plan<D extends object = {}> = ({
54
63
  draft: true;
55
64
  after: number;
@@ -98,12 +107,16 @@ export type Verdict<D extends object = {}> = Exclude<Decision<D>, {
98
107
  }>;
99
108
  /**
100
109
  * Rule 4. The exact room is planned first. When its plan already lands below the band it is taken as it is (target "room"): it keeps the
101
- * room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is 2% smaller. Only
110
+ * room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is smaller. Only
102
111
  * otherwise is the band planned. Inside the band a cut is opportunistic, so a failed plan is never a fault and never falls back to the
103
112
  * room (a cut that still lands in the band would trigger again every round); the exact room would cut nothing there, so a band cut is
104
113
  * taken only when the room can plan a cut too and with the same mode (a lossy view the room could not even plan is a quality change with
105
114
  * no fault to prevent). At or above the room, where a cut is due, a band plan of another mode gives way to the room plan (today's
106
115
  * view), and a band plan stands when the room has none (a cut instead of a fault).
116
+ * 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
+ * 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
+ * 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.
107
120
  * The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
108
121
  * boundaries inside the band too, where no planner ran before the margin.
109
122
  */
@@ -4,8 +4,20 @@ import { measurableMessages, projectedMessages } from "./omissions.js";
4
4
  import { isDiscardedResponse } from "./protocol.js";
5
5
  /** Triggers fire this fraction of the room early. The margin absorbs request growth a boundary cannot see; it never enters the calibration factor. */
6
6
  export const TRIGGER_MARGIN_FRACTION = 0.02;
7
- /** The limit a cut is triggered at and preferably planned to; the exact room stays the limit of every throw and fault. */
8
- export const bandOf = (room) => room - Math.ceil(Math.max(0, room) * TRIGGER_MARGIN_FRACTION);
7
+ /** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
8
+ export const validCutAt = (value) => typeof value === "number" && value > 0 && value <= 1;
9
+ /**
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.
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
15
+ * input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
16
+ export const DEFAULT_CUT_AT = 0.6;
17
+ export const bandOf = (room, cutAt = 1) => {
18
+ const positive = Math.max(0, room);
19
+ return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), Math.floor(positive * cutAt));
20
+ };
9
21
  /** The APIs whose request converter (pi-ai openai-responses-shared) sends each signed thinking block back as an input reasoning item. */
10
22
  const REPLAYING_APIS = new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]);
11
23
  /**
@@ -59,29 +71,40 @@ export const measurableConversationTokens = (messages, ctx, policy) => conversat
59
71
  export const planningReserve = (window, targetRoom, factor, mandatory) => window - Math.floor(targetRoom / factor) + mandatory;
60
72
  /**
61
73
  * Rule 4. The exact room is planned first. When its plan already lands below the band it is taken as it is (target "room"): it keeps the
62
- * room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is 2% smaller. Only
74
+ * room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is smaller. Only
63
75
  * otherwise is the band planned. Inside the band a cut is opportunistic, so a failed plan is never a fault and never falls back to the
64
76
  * room (a cut that still lands in the band would trigger again every round); the exact room would cut nothing there, so a band cut is
65
77
  * taken only when the room can plan a cut too and with the same mode (a lossy view the room could not even plan is a quality change with
66
78
  * no fault to prevent). At or above the room, where a cut is due, a band plan of another mode gives way to the room plan (today's
67
79
  * view), and a band plan stands when the room has none (a cut instead of a fault).
80
+ * 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
+ * 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
+ * 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.
68
84
  * The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
69
85
  * boundaries inside the band too, where no planner ran before the margin.
70
86
  */
71
87
  export function chooseCut(input) {
72
88
  const { before, mandatory, room, band, fits, plan, unreduced } = input;
73
- const exact = plan("room");
74
- if (exact.draft && exact.after < before && fits(exact.after + mandatory, band))
75
- return { kind: "cut", target: "room", plan: exact };
89
+ const lands = (p) => p.draft && p.after < before && fits(p.after + mandatory, band);
90
+ let roomPlan;
91
+ const exact = () => roomPlan ??= plan("room");
92
+ if (band >= bandOf(room)) {
93
+ const e = exact();
94
+ if (lands(e))
95
+ return { kind: "cut", target: "room", plan: e };
96
+ }
76
97
  const preferred = plan("band");
77
98
  if (input.opportunistic) {
78
- if (!(preferred.draft && preferred.after < before && fits(preferred.after + mandatory, band)))
99
+ if (!lands(preferred))
79
100
  return { kind: "none" };
80
- return exact.draft && exact.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
101
+ const e = exact();
102
+ return e.draft && e.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
81
103
  }
104
+ const e = exact();
82
105
  let chosen = preferred, target = "band";
83
- if (!preferred.draft || (exact.draft && exact.mode !== preferred.mode)) {
84
- chosen = exact;
106
+ if (!preferred.draft || (e.draft && e.mode !== preferred.mode)) {
107
+ chosen = e;
85
108
  target = "room";
86
109
  }
87
110
  if (!chosen.draft)
@@ -1 +1 @@
1
- export declare const BUILD_ID = "2a0ebe00760f2226d888fbca503dbde44d32623cb644186a6dd2eef59b65fbe5";
1
+ export declare const BUILD_ID = "fb0ab667f5bfcd22fd5cc6edb89cd859426fd7c28bb58c2bc7b9fbec6fcdd3da";
@@ -1 +1 @@
1
- export const BUILD_ID = "2a0ebe00760f2226d888fbca503dbde44d32623cb644186a6dd2eef59b65fbe5";
1
+ export const BUILD_ID = "fb0ab667f5bfcd22fd5cc6edb89cd859426fd7c28bb58c2bc7b9fbec6fcdd3da";
@@ -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. */
@@ -27,6 +30,10 @@ export interface CompactionOptions {
27
30
  /** Optional estimated complete-input ceiling, separate from physical output reservation.
28
31
  * The host owns final serialization/transport counting; this is not an exact wire-token cap. */
29
32
  activeInputTokens?: number | ((ctx: ExtensionContext) => number);
33
+ /** 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,
34
+ * 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
35
+ * stays the limit of every throw, fault and the complete-context guard. Anything else is rejected when the compactor is installed. */
36
+ cutAt?: number;
30
37
  /** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
31
38
  * 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
32
39
  * transport's behavior declares an exact reserve with `responseExact: true`, and the room is then the window less `response` exactly. */