pi-tuck 1.0.18 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [1.0.20] - 2026-11-09
7
+
8
+ ### Fixed
9
+
10
+ - **pi-tuck cut earlier than native Pi when a model's reply cap is above Pi's reserve.** It reserved the larger of Pi's `compaction.reserveTokens`
11
+ and the model's `maxTokens`. With `maxTokens` 32,768 and a reserve of 16,384 on an 81,920 window, its room fell to 49,152 tokens, where native Pi
12
+ compacts at 65,536. pi-tuck now reserves Pi's `reserveTokens`, native's point. Once a reply on the current branch did not fit, it holds the larger
13
+ of the two for the rest of that branch: a reply stopped for length, or a request pi-ai classes as a context overflow. A transient error such as a
14
+ 503 keeps native's room. This is read from the branch at each decision, so resume, fork and `/tree` follow the branch, and a new session starts
15
+ at `reserveTokens` again. A host that passes its own reserve keeps today's behaviour.
16
+ - Lowering `reserveTokens` now takes effect even when the model's `maxTokens` is larger, until a reply does not fit.
17
+
18
+ ### Release gates
19
+
20
+ - **Shipped without a live gate, because its replay matches 1.0.19:** 13,946 requests on 29 recordings, 0 differ, 0 lost and 0 dead tasks, the same
21
+ 17 clips. Every recording has the reply cap equal to the reserve, where nothing changes.
22
+ - New stress cells with the reply cap above the reserve, at 81,920 and 16,384, on a provider that clips the reply at the window end and one that
23
+ refuses prompt plus cap over the window, plus one with a transient 503. On Pi 1.2.0 and 1.5.0, 1.0.19 refuses in the four cap cells. This version
24
+ takes one clipped or refused reply in each and recovers, with no lost work and no compaction loop; after the 503 it keeps native's room. The full
25
+ stress test (292 cells) has 3 findings on each Pi, all also found on 1.0.19, and no cell is worse. Tests (676) and CI.
26
+ - Sessions that are open keep the build they started with until they are restarted.
27
+
28
+ ## [1.0.19] - 2026-11-09
29
+
30
+ ### Added
31
+
32
+ - **`/tuck` says in words what pi-tuck is doing in this session:** the version the session loaded (an open session keeps it until Pi restarts),
33
+ how many cuts pi-tuck made and the last one's size before and after, whether the cut point adapts or is fixed by `cutAt`, and how search works
34
+ here. It is a command for the operator; the model sees nothing new.
35
+ - **The first-time semantic setup says when it is done.** It announced its download and then said nothing; now it says once that semantic
36
+ recall is set up.
37
+
38
+ ### Changed
39
+
40
+ - **`/compact` with text after it now cuts.** pi-tuck builds its summary from the session itself, so the text cannot steer it. Before, the cut was
41
+ cancelled, which Pi showed as an error; now pi-tuck makes the cut plain `/compact` makes and says the text was not used.
42
+
43
+ ### Release gates
44
+
45
+ - **Shipped without a live gate, because its replay matches 1.0.18:** 13,946 requests on 29 recordings, 0 differ, 0 lost and 0 dead tasks, the same
46
+ 17 clips. It changes nothing the model sees: `/tuck`, the setup notice and the `/compact` notice are for the operator only. Tests (662), CI, and
47
+ the settings stress test (287 cells, none worse than 1.0.18, no cut over its target).
48
+ - Sessions that are open keep the build they started with until they are restarted.
49
+
6
50
  ## [1.0.18] - 2026-11-08
7
51
 
8
52
  ### Fixed
package/README.md CHANGED
@@ -63,18 +63,31 @@ pi install npm:pi-tuck
63
63
  Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, both automatic and `/compact`. You change no setting.
64
64
 
65
65
  - Needs Pi 1.2.0 or later (tested on 1.2.0 and 1.4.0), on macOS or Linux. On an older Pi, pi-tuck stays off, Pi's own compaction runs, and pi-tuck says so once. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
66
- - On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once). Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
67
- - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.17`.
68
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.17`.
66
+ - On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once), and says once when it is set up. Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
67
+ - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.19`.
68
+ - To pin a version, add it: `pi install npm:pi-tuck@1.0.19`.
69
+
70
+ ## Uninstall
71
+
72
+ ```sh
73
+ pi remove npm:pi-tuck
74
+ ```
75
+
76
+ Use the same source you installed with. This takes pi-tuck out of Pi's settings. It does not delete what the search setup downloaded: about 460 MB in the `pi-tuck` folder of Pi's agent folder (`~/.pi/agent/pi-tuck`, or the folder `PI_CODING_AGENT_DIR` names). To free the space, delete that folder:
77
+
78
+ ```sh
79
+ rm -rf ~/.pi/agent/pi-tuck
80
+ ```
81
+
82
+ On Linux and Intel Macs, nothing is downloaded, so there is nothing to delete.
69
83
 
70
84
  ## How it works
71
85
 
72
86
  When context fills up, pi-tuck replaces the older turns with one summary. The summary keeps:
73
87
 
74
- - **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.
88
+ - **The dialogue and your prompts, ranked.** Since 1.0.18, one ranked list decides what a cut carries, across the summary and the kept tail together: the current prompt, whole; the openings of the first prompt and the newest answered prompts, sized to the room; the newest part of the conversation, kept as it is; the file and action lists below; and the rest of the conversation. Dropped turns and tool results are counted with index ranges that `tuck_recall` reads back. Clipped text says how to read it in full.
75
89
  - **The files.** Files the agent read, wrote or edited, newest first (up to 60), each with a pointer to read it back.
76
90
  - **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.
77
- - **Your prompts, quoted from both ends** (1.0.16 and later): first the opening of every recent answered turn, then the first prompt's opening (up to 2,000 characters), then more of the newest turns, then the rest of the first prompt. A turn that does not fit keeps its opening and a locator to the rest. The budget is 15% of the room and never less than 12,000 characters. When room is tight, the quotes shrink step by step: the extensions first, then the openings, then the first prompt alone. Before 1.0.16 a summary quoted only the first prompt (up to 12,000 characters) and the newest turn; see Known limits.
78
91
 
79
92
  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.
80
93
 
@@ -85,14 +98,15 @@ The agent gets two tools to get anything back:
85
98
  - **`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.
86
99
  - **`tuck_search`** (macOS with Apple silicon only) 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`.
87
100
 
88
- On Apple silicon, both tools cost about 1,050 tokens of tool description per request. Elsewhere only `tuck_recall` is offered.
101
+ pi-tuck's tools appear only after its first cut. Before that there is nothing out of view to recall, and a request is byte-identical to one without pi-tuck (254 of 254 requests on replay). After the first cut, on Apple silicon, both tools cost about 1,050 tokens of tool description per request. Elsewhere only `tuck_recall` is offered.
89
102
 
90
103
  | Command | Does |
91
104
  |---|---|
105
+ | `/tuck` | Says in words what pi-tuck is doing in this session: the version this session loaded (an open session keeps it until Pi restarts), how many cuts pi-tuck made and the last one's size before and after, whether the cut point adapts or is fixed by `cutAt`, and how search works here. It is for you; the model sees nothing new. |
92
106
  | `/tuck-semantic on\|off\|status` | Turns semantic search on or off for this session, or shows its state. |
93
107
  | `/tuck-incident on\|next\|off\|status` | Records this session's compaction decisions to a file you can attach to a bug report. |
94
108
 
95
- `/compact` works as usual, but pi-tuck does not take custom summary instructions. Pi's own setting `"compaction": { "enabled": false }` turns compaction off.
109
+ `/compact` works as usual. Text after it does not steer the summary, because pi-tuck builds the summary from the session itself: since 1.0.19 it cuts as plain `/compact` does and says the text was not used. Pi's own setting `"compaction": { "enabled": false }` turns compaction off.
96
110
 
97
111
  ## What is proven, and what is not
98
112
 
@@ -115,8 +129,9 @@ Known limits today:
115
129
  - Small window, after a stopped run (1.0.8 and 1.0.9 only; fixed in 1.0.10): Pi's default `compaction.keepRecentTokens` (20,000) can exceed pi-tuck's input room, which is the window less the larger of `compaction.reserveTokens` and the model's maximum output (16,384 at a 32k window with the default reserve). After a run that is stopped (Esc, or a time limit) with the context above the room, every later prompt could be refused with "The request needs a cut and Pi has none to make (Nothing to compact (session too small))", and the session stayed stuck. In one check at these defaults (pi-tuck 1.0.8, 32k window, one session of 30 tasks, one local model), 28 tasks were lost, tasks 3 to 30 ([`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md)). If you are on 1.0.8 or 1.0.9, upgrade to 1.0.10. In 1.0.10 the cut a prompt needs is pi-tuck's own, so `keepRecentTokens` no longer leaves it with nothing to cut. A rerun of the same check on the 1.0.10 candidate had no refusal and no stuck session. It had two caveats: two of its tasks had a first request the local server never answered, and 8 of its 30 tasks began without their prompt, which the refusal counter cannot see (fixed in 1.0.12; see Benchmarks and [`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md)). Windows of 128k and up were not affected at the defaults.
116
130
  - A prompt could be lost, with no notice (1.0.10 and 1.0.11; fixed in 1.0.12). When a new prompt arrived over the cut point and pi-tuck's cut kept nothing of the conversation before it, which happens when everything since the last cut is one long turn, the prompt was dropped from the request and from the saved session. The model never saw it and usually went on with the previous task, so work you asked for could be lost, and could look answered. The next prompt ran normally. If you used 1.0.10 or 1.0.11 and a reply ignored what you had just asked, this is the likely cause. Upgrade to 1.0.12 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.12).
117
131
  - A host that patches a large section into the system prompt could get a refusal that a cut would have fit (1.0.0 to 1.0.16; fixed in 1.0.17). pi-tuck plans a prompt's cut when the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn could push that turn's first request over the room, and the final guard refused it. Now the guard plans the cut against the system prompt as finally sent, and refuses only if that cut does not fit either ([`CHANGELOG.md`](CHANGELOG.md) 1.0.17).
118
- - A long session could lose the task it was working on (1.0.0 to 1.0.15; fixed in 1.0.16). A summary quoted only the session's first user turn, up to 12,000 characters, and the newest one. Every other user turn competed in the shrunk dialogue, which in a long session clips user turns to a few words and drops the oldest. In a session that runs task after task, the current task is an older turn followed by short steps, so it was clipped or dropped while a finished first task stayed whole, and agents acted on a days-old task. Short sessions, and the SWE-bench chains behind our benchmarks, keep each task's prompt in the kept tail and never hit it. Upgrade to 1.0.16 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.16).
119
- - At very small free room (a few thousand tokens or less), the quotes give way and an older prompt can still lose its opening, in 1.0.16: 40 findings in 32 stress cells, against 447 in 168 cells on 1.0.15 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.16).
132
+ - A summary could drop the task a session was working on (1.0.0 to 1.0.17; fixed in 1.0.18). Until 1.0.15 a summary quoted only the session's first user turn, up to 12,000 characters, and the newest one, so in a long session the current task, an older turn followed by short steps, was clipped or dropped while a finished first task stayed whole, and agents acted on a days-old task. 1.0.16 added more quotes. From 1.0.13 to 1.0.17, separate rules decided what a summary quoted and what the kept tail held, and the prompt that set the current task, or the first prompt, could lose its opening after a cut: the stress test found it 40 times in 32 cells on 1.0.16 and 1.0.17. Since 1.0.18 one ranked list fills a cut's room, and the stress test finds no lost opening in 287 cells. Short sessions, and the SWE-bench chains behind our benchmarks, keep each task's prompt in the kept tail and never hit it. Upgrade to 1.0.18 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
133
+ - pi-tuck 1.0.16 cost more in live chains than its notes first said. Against 1.0.13 at an 81,920 window, it took 1.48 times the turns on the tasks both resolved (95% interval 1.34 to 1.64), 74% more cuts and 1.66 times the tokens, with 56 of 60 tasks resolved against 55. Replay of single-task sessions cannot show this cost. 1.0.18 replaced its quote rules; in the 1.0.18 gate it took 22.27 turns per task against 29.35 for 1.0.16 and 20.22 for the 1.0.13 arm (different pairs of runs; reported, not claimed) ([`benchmark/RESULTS-V1016.md`](benchmark/RESULTS-V1016.md), [`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
134
+ - Since 1.0.18, on replay of 17 recordings, estimated request time against 1.0.13 is 2.2% higher quiet and 4.2% higher loaded at 81,920, and 1.1% and 1.9% lower at 131,072. Billed input moves the same way. At 81,920 each cut carries about 600 more tokens. Sessions that are open keep the build they started with until they are restarted ([`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
120
135
  - The cut point adapts by itself since 1.0.13; before it, pi-tuck cut too often at small windows or with a large setup (1.0.11 and 1.0.12). The trade, from the changelog: on windows that cut too often, each request now carries more context. Cuts, re-processing and request time fall, and billed tokens rise on paid models. On replay of real sessions at an 81,920 window, cuts went from 22.3 to 9.3 per 100 requests, request time was about 16 to 20% lower, and billed tokens were 16 to 19% higher; at a 65,536 window billed tokens rose by as much as 35%. A very large setup at a small window, such as 40k at 65,536, still leaves too little free room for a good rhythm: cuts stay frequent and free little ([`CHANGELOG.md`](CHANGELOG.md) 1.0.13).
121
136
  - Still applying (1.0.11 to 1.0.16): a fresh assistant message larger than the free room is refused, and a short next prompt recovers (in an unattended `pi -p` run, a refusal ends the run). A reply that reports more than the window reads to Pi as a silent overflow; when nothing can be cut, pi-tuck refuses the next prompt, and the prompt after that runs. If a provider keeps rejecting requests in a wording Pi does not class as a context overflow, or with a bare HTTP 413, pi-tuck does not recover on its own: the session stays intact, and every prompt can fail until the request shrinks. Try `/compact` ([`CHANGELOG.md`](CHANGELOG.md) 1.0.13).
122
137
  - Other extensions (1.0.10): a prompt's cut no longer goes through Pi's `session_before_compact`, so another extension's veto there no longer stops it. It never stopped a cut between tool rounds. Pi's own threshold and overflow cuts, and `/compact`, still ask other extensions as before.
@@ -131,6 +146,8 @@ Each row says what was measured, under which conditions, and where the data is.
131
146
  | Pi's default compaction settings, 32k window, pi-tuck 1.0.10 candidate | Passed as registered: 0 dead tasks and 0 stuck sessions on 30 tasks, on a slow local server (27 of 30 tasks hit the 30-minute wall cap, and 2 had a first request the server never answered). 6 resolved. A counter written later, which counts a task with no token as dead, would count those 2. **The registered counter counts refusals and cannot see a task that began without its prompt**: in 8 of the 30 tasks (4, 8, 9, 10, 13, 14, 17, 21) a cut at the prompt kept no turn, none of the 8 resolved, and 6 ended with an empty patch. So the pass shows no refusal, not that every task began with its prompt. The 1.0.11 candidate on the same test had 7 of 30 such tasks, 1 resolved. Fixed in 1.0.12 | Candidate `a67ec30` of 1.0.10, `mac-studio/main`, window 32,768, output cap 16,384, Pi's default `reserveTokens` 16,384 and `keepRecentTokens` 20,000, one session of chain t06. One mid-run refusal (a round with 9,020 tokens of reasoning against the 16,384-token room). One session per arm, one model. In 12 of the 15 tasks across both arms the model's own first messages show it lacked the prompt; the other 3 are not clear from the records | [`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md) |
132
147
  | Pi's default compaction settings, 32k window, pi-tuck 1.0.8 | 28 of 30 tasks lost (tasks 3 to 30, each refused at its prompt); 1 resolved. Fails the pre-registered bar of 0 lost tasks | pi-tuck 1.0.8 (`86935e9`), `mac-studio/main`, window 32,768, output cap 16,384, `reserveTokens` 16,384 and `keepRecentTokens` 20,000 (larger than the room), one session of chain t06. One session, one model, one build: it shows that the failure occurs at these settings, not how often. Not comparable with other rows | [`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md) |
133
148
  | Tasks resolved, 32k window | Pi's built-in compaction 94 of 150; pi-tuck 120 of 150 (+17.3 points, 95% lower bound +10.7); observation masking 117 of 150 (pi-tuck +2.0, lower bound −2.7: no difference shown). Turns per task: 9.4 (built-in), 24.0 (pi-tuck), 11.9 (masking). Tokens per session: 6.10 M, 6.39 M, 7.02 M. Spend per arm for the five sessions: $0.87, $0.55, $0.94 | pi-tuck 1.0.5 (commit `aa30824`, the 1.0.5 code; 1.0.6 and 1.0.7 are not measured). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, 5 chains of 30 SWE-bench Verified tasks, paired by task, one-sided bootstrap over tasks. **Limits:** only pi-tuck is held to the 16,384-token room (the built-in sent a request over it in 97 of 150 tasks, masking in 73, so the window is nominal for them). The difference by chain is +10.0, −3.3, +50.0, +16.7 and +13.3 points; leaving out chain t03 it is +9.2. The bootstrap resamples tasks, not chains. The pi-tuck arm ran at a different time from the others. One model, one window. pi-tuck refused to cut mid-run in 5 tasks, where one fresh tool result was larger than the room; they count as unresolved. In the same setup pi-tuck 1.0.3 resolved 72 of 150, with 52 dead tasks (tasks refused at their prompt after an earlier failure). In the pi-tuck arm of this row there was 1 dead task (t03 task 24: refused at its prompt with "Nothing to compact"; the next task ran normally), so this build missed the pre-registered acceptance bar of zero dead tasks by one task (the other part of the bar, no loss against 1.0.3 by more than 3 points, was met) | [`benchmark/RESULTS-32K-ACCEPT.md`](benchmark/RESULTS-32K-ACCEPT.md) |
149
+ | pi-tuck 1.0.18 against the recorded 1.0.16 arm, 81,920 window | Registered bar (Tier 1) met: both arms resolved the same 56 of 60 tasks (+0.0 points, one-sided 95% lower bound +0.0, against a bar of above −10); 0 lost tasks: dead tasks, mid-run refusals and dropped prompts are 0 in both arms, and the audit listed one candidate task (t06 task 21), which, with the two other candidate tasks that had a cut at a prompt (t06 task 8, t07 task 24), was read by hand and works on its own task. Readouts: turns per task 22.27 against 29.35, cuts 57 against 61, total tokens 40.3 M against 62.1 M. Against the recorded 1.0.13 arm of the adaptive gate, a different pair of runs a day apart, 1.0.18 is at 22.27 turns per task against 20.22 and 40.3 M tokens against 37.4 M | Pre-registered gate. pi-tuck 1.0.18 (`2d1f830`, tag `v1.0.18`), run from the unpacked npm tarball; its `src/` is byte-identical to `1ea1e1e`, the commit replay and the stress test measured, ran alone on chains t06 and t07 (60 tasks) against the recorded 1.0.16 arm; the arms did not run side by side, the base earlier the same day, so seconds are not compared. One model, one window, one run per arm. The resolved result is a bound, not a gain. The turn, cut and token readouts are reported, not claimed | [`benchmark/RESULTS-V1018.md`](benchmark/RESULTS-V1018.md) |
150
+ | pi-tuck 1.0.16 against the recorded 1.0.13 arm, 81,920 window | Registered bar (Tier 1) met: resolved 56 of 60 against 55 (+1.7 points, one-sided 95% lower bound +0.0, against a bar of above −10); 0 lost tasks. Readouts: turns per task 29.35 against 20.22 (1.48 times on the 55 tasks both resolved, 95% interval 1.34 to 1.64), cuts 61 against 35, total tokens 62.1 M against 37.4 M | Pre-registered gate. pi-tuck 1.0.16 (`dee0eab`) ran alone on chains t06 and t07 (60 tasks) against the recorded 1.0.13 arm from the adaptive gate; the two arms did not run side by side, the base a day earlier, so seconds are not compared. One model, one window, one run per arm. The resolved result is a bound, not a gain. The cost readouts are reported, not claimed. 1.0.18 replaced this build's quote rules | [`benchmark/RESULTS-V1016.md`](benchmark/RESULTS-V1016.md) |
134
151
  | The adaptive cut point (1.0.13) against 1.0.12, 81,920 window | Registered bar (Tier 2) met: resolved 131 of 150 in both arms (+0.0 points, one-sided 95% lower bound −1.3, against a bar of above −5); 0 lost tasks in the candidate arm. The 0 counts dead tasks, tasks whose prompt was dropped at a cut, and tasks an audit found started without their prompt; all 11 tasks with a cut at a prompt, in both arms, were also read by hand, and in each the model's first messages work on that task | Pre-registered gate. pi-tuck 1.0.13 (`6f13685`) is the exact code the gate measured (`882f6f6`); only the version and the notes changed. It was measured against pi-tuck 1.0.12, `mac-studio/main` at xhigh, window 81,920, reserve 16,384, `keepRecentTokens` 20,000, 5 chains (t06 to t10) of 30 tasks, 150 paired tasks, one run per arm, one model throughout. The resolved result is a bound, not a gain. Turns, tokens and seconds are in the file and are not claimed here. Nothing about other windows, models or builds | [`benchmark/RESULTS-ADAPTIVE.md`](benchmark/RESULTS-ADAPTIVE.md) |
135
152
  | The 1.0.10 candidate against 1.0.9, 32k window | 0 dead tasks in both arms; no mid-run refusal. Resolved 50 of 60 (1.0.9) and 49 of 60 (1.0.10 candidate): −1.7 points, 95% lower bound −8.3. **The gate failed as registered, by one task**; 1.0.10 shipped as an exception because of the small-window defect. 6 of the 1.0.10 candidate's 60 tasks began without their prompt (4 of them resolved), against 0 in the 1.0.9 arm; fixed in 1.0.12. Turns per task 25.08 and 29.12; cuts 731 and 884; total tokens 13.07 M and 15.12 M | Pre-registered gate. pi-tuck `a67ec30` against 1.0.9 (`cd4626b`). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, `keepRecentTokens` 3,333, chains t01 and t02, 60 paired tasks. One model, one window, one run per arm; 60 tasks cannot separate a one-task difference from run-to-run variation | [`benchmark/RESULTS-V110.md`](benchmark/RESULTS-V110.md) |
136
153
  | The 1.0.8 candidate against 1.0.7, 32k window | 0 dead tasks in both arms. Resolved 46 of 60 in both (+0.0 points, 95% lower bound −6.7). No mid-run refusal in either arm. Turns per task 25.37 (1.0.7) and 26.10 (1.0.8) | Pre-registered gate, passed as registered. pi-tuck `a2e7d1a` (the tool-result clip and the cut at the end of a run, on 1.0.6) against 1.0.7. Released 1.0.8 is that build merged with 1.0.7's Metal-only recall; its replay requests are reported byte-identical to the gated build on 29 recordings (stated in the source file, not re-run for this README). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, chains t01 and t02, 60 paired tasks, both arms run together. One model, one window, one run per arm; 60 tasks cannot show a difference smaller than the bound. The run shows that the change introduced no refusal; no refusal occurred in the 1.0.7 arm for the clip to prevent | [`benchmark/RESULTS-V108.md`](benchmark/RESULTS-V108.md) |
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ export default async function (pi) {
50
50
  const handle = await installCompaction(pi, { semanticRecall, search: appleSilicon, cutAt: settings.cutAt, onSemanticSetting: async (enabled) => { off = !enabled; } });
51
51
  if (settings.semanticRecall === false)
52
52
  return;
53
- let session = 0, said = "", setup;
53
+ let session = 0, said = "", setup, announced = false;
54
54
  // A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
55
55
  const say = (ctx, message, level) => { try {
56
56
  ctx.ui.notify(message, level);
@@ -76,12 +76,17 @@ export default async function (pi) {
76
76
  return; // a one-shot run (pi -p) would end before a first-time setup could
77
77
  if (!semanticRecall.status().configured)
78
78
  semanticRecall.refuse("Semantic recall is being set up; word recall works meanwhile");
79
- setup ??= ensureSemanticRuntime(defaultRoot(), message => say(ctx, message, "info"));
79
+ setup ??= ensureSemanticRuntime(defaultRoot(), message => { announced = true; say(ctx, message, "info"); });
80
80
  setup.then(async () => {
81
81
  if (current !== session || off)
82
82
  return;
83
83
  try {
84
84
  await handle.setSemanticEnabled(true, ctx); // reads the runtime's configuration through the outside-project check
85
+ // The download was announced, so its end is too: once, when semantic recall first starts on it.
86
+ if (announced && current === session) {
87
+ announced = false;
88
+ say(ctx, "pi-tuck: semantic recall is set up. Search by meaning works from the first cut on.", "info");
89
+ }
85
90
  }
86
91
  catch (error) {
87
92
  if (current === session)
@@ -1 +1 @@
1
- export declare const BUILD_ID = "c53f4e367c1f45ac6b3865cbf25bf99992da4b7e961557e5510369dca81fb087";
1
+ export declare const BUILD_ID = "27846aedbe111110535eeed5998c572182cd15de0efede0fe4df1571166a838e";
@@ -1 +1 @@
1
- export const BUILD_ID = "c53f4e367c1f45ac6b3865cbf25bf99992da4b7e961557e5510369dca81fb087";
1
+ export const BUILD_ID = "27846aedbe111110535eeed5998c572182cd15de0efede0fe4df1571166a838e";
@@ -39,9 +39,9 @@ export interface CompactionOptions {
39
39
  * which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The exact room
40
40
  * stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
41
41
  cutAt?: number;
42
- /** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
43
- * 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
44
- * transport's behavior declares an exact reserve with `responseExact: true`, and the room is then the window less `response` exactly. */
42
+ /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
43
+ * Standalone uses Pi's reserveTokens until a length stop or a context-overflow refusal on the current branch, then also holds the catalogue maximum.
44
+ * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
45
45
  requestReserve?: (ctx: ExtensionContext) => {
46
46
  response: number;
47
47
  responseExact?: true;
@@ -13,6 +13,7 @@ import { Type } from "typebox";
13
13
  import { Check } from "typebox/value";
14
14
  import { MAX_RECALL_CHARACTERS, MAX_RECALL_ENTRY_ID_CHARACTERS, MAX_RECALL_QUERY_CHARACTERS } from "./recall.js";
15
15
  import { tuck, tuckDraft, tuckObservedDraft, tuckRecall } from "./tuck.js";
16
+ import { LOADED_VERSION, statusText } from "./status.js";
16
17
  import { ImageRecallInput, historyImage, historyAround } from "./history-image.js";
17
18
  import { eligibilityKey, eligibilityKeyMoved, memoizedEligibleSources } from "./eligibility-cache.js";
18
19
  import { applyHistoryTools, hasTuckCut, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
@@ -209,6 +210,10 @@ export async function installCompaction(pi, options = {}) {
209
210
  }
210
211
  ctx.ui.notify(JSON.stringify({ ...semanticStatus(), ...(errors.length ? { errors } : {}) }), errors.length ? "warning" : "info");
211
212
  } });
213
+ pi.registerCommand("tuck", { description: "pi-tuck's state in this session: version, cuts, cut point, search", handler: async (_args, ctx) => {
214
+ const { state, lastError } = semantic.status();
215
+ ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic: { state, lastError } }), "info");
216
+ } });
212
217
  // Pi's native start/settled events bracket the run and its post-turn cuts.
213
218
  let admittedRun = false;
214
219
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
@@ -285,16 +290,18 @@ export async function installCompaction(pi, options = {}) {
285
290
  pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
286
291
  const active = () => options.active?.() ?? true;
287
292
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
288
- // The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
289
- // reserve keeps input plus any reply inside the window.
290
- const reserveFor = (ctx) => Math.max(settingsFor().getCompactionSettings(ctx.model).reserveTokens, ctx.model?.maxTokens ?? 0);
291
- // The one place the response reserve is decided; every consumer takes its room from here. A host that knows how its transport caps
292
- // a reply declares an exact reserve (`responseExact: true`) and is held to it; without that flag the catalogue maximum is the floor.
293
+ // The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
294
+ // not fit (a length stop, or a refusal pi-ai classes as a context overflow), hold the catalogue maximum too. Other errors, such as a 503,
295
+ // keep native's room. Read the whole branch, including history before compactions, each time: no flag to persist or reset on resume,
296
+ // fork, /tree or a new session.
293
297
  const requestReserve = (ctx) => {
294
- const budget = options.requestReserve?.(ctx) ?? { response: reserveFor(ctx) };
298
+ const host = options.requestReserve?.(ctx);
299
+ const budget = host ?? { response: settingsFor().getCompactionSettings(ctx.model).reserveTokens };
295
300
  if (!Number.isSafeInteger(budget.response) || budget.response < 0)
296
301
  throw new Error("Invalid host request reserve");
297
- return { response: budget.responseExact === true ? budget.response : Math.max(budget.response, ctx.model?.maxTokens ?? 0) };
302
+ const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry => entry.type === "message" && entry.message.role === "assistant" &&
303
+ (entry.message.stopReason === "length" || (entry.message.stopReason === "error" && isContextOverflow(entry.message))));
304
+ return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
298
305
  };
299
306
  const inputRoom = (response, ctx) => {
300
307
  const configured = options.activeInputTokens;
@@ -477,11 +484,10 @@ export async function installCompaction(pi, options = {}) {
477
484
  if (operatorManual && !admittedRun)
478
485
  forgetFailure();
479
486
  access();
480
- if (event.customInstructions?.trim()) {
481
- recorder.stop("no-cut-custom-instructions");
482
- ctx.ui.notify("pi-tuck does not accept summarization instructions; use /compact without arguments.", "warning");
483
- return { cancel: true };
484
- }
487
+ // pi-tuck's summary is built from the session itself, so text after /compact cannot steer it. The operator asked for a cut: make the one
488
+ // plain /compact makes and say the text was not used, rather than cancel (which Pi shows as an error, with no cut).
489
+ if (event.customInstructions?.trim())
490
+ ctx.ui.notify("pi-tuck: /compact ignores the text after it, since pi-tuck builds its summary from the session itself. Cutting as plain /compact does.", "info");
485
491
  const { response, room } = limits(ctx);
486
492
  const retain = operatorManual || admittedRun;
487
493
  // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
@@ -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 = "c53f4e367c1f45ac6b3865cbf25bf99992da4b7e961557e5510369dca81fb087";
3
+ export declare const DIAGNOSTIC_BUILD = "27846aedbe111110535eeed5998c572182cd15de0efede0fe4df1571166a838e";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -0,0 +1,19 @@
1
+ import type { SemanticState } from "./semantic-recall.js";
2
+ /** The version of the package this code was loaded from: the open session keeps it until Pi restarts, whatever is installed since. */
3
+ export declare const LOADED_VERSION: string;
4
+ /** `/tuck`: what an operator needs to know in words, from what is already recorded. It reads; it changes nothing the model sees. */
5
+ export declare function statusText(input: {
6
+ version: string;
7
+ active: boolean;
8
+ branch: readonly {
9
+ type: string;
10
+ tokensBefore?: unknown;
11
+ details?: unknown;
12
+ }[];
13
+ cutAt: number | undefined;
14
+ search: boolean;
15
+ semantic: {
16
+ state: SemanticState;
17
+ lastError: string | null;
18
+ };
19
+ }): string;
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ /** The version of the package this code was loaded from: the open session keeps it until Pi restarts, whatever is installed since. */
4
+ export const LOADED_VERSION = (() => {
5
+ try {
6
+ const path = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "../package.json" : "../../package.json", import.meta.url));
7
+ const version = JSON.parse(readFileSync(path, "utf8")).version;
8
+ return typeof version === "string" ? version : "unknown";
9
+ }
10
+ catch {
11
+ return "unknown";
12
+ }
13
+ })();
14
+ /** `/tuck`: what an operator needs to know in words, from what is already recorded. It reads; it changes nothing the model sees. */
15
+ export function statusText(input) {
16
+ if (!input.active)
17
+ return `pi-tuck ${input.version} is loaded but inactive in this host; Pi's own compaction runs.`;
18
+ const cuts = input.branch.filter(e => e.type === "compaction"), ours = cuts.filter(e => e.details?.compactor === "pi-tuck");
19
+ const last = ours.at(-1), before = last?.tokensBefore, after = last?.details?.tokensAfter;
20
+ const count = (n) => n.toLocaleString("en-US");
21
+ const cutLine = ours.length === 0 ? "Cuts: none yet in this session. The recall tools appear after the first cut."
22
+ : `Cuts: ${ours.length} by pi-tuck in this session` + (typeof before === "number" && typeof after === "number" ? `, the last from ${count(before)} to ${count(after)} tokens.` : ".");
23
+ const native = cuts.length - ours.length;
24
+ const searchLine = !input.search ? "Search: by keyword (search by meaning needs macOS on Apple silicon)."
25
+ : input.semantic.state === "ready" || input.semantic.state === "waiting" ? "Search: by meaning and keyword."
26
+ : input.semantic.state === "preparing" ? "Search: by meaning and keyword; indexing history in the background."
27
+ : `Search: by keyword; ${lower(input.semantic.lastError ?? "semantic recall is off in this session (/tuck-semantic on)")}.`.replace(/\.\.$/, ".");
28
+ return [
29
+ `pi-tuck ${input.version} (the version this session loaded; restart Pi after an update).`,
30
+ cutLine,
31
+ ...(native > 0 ? [`Pi's own compaction cut ${native} time${native === 1 ? "" : "s"} in this session.`] : []),
32
+ input.cutAt === undefined ? "Cut point: adapts by itself." : `Cut point: fixed at cutAt ${input.cutAt} of the input room. Remove cutAt from pi-tuck.json to let it adapt.`,
33
+ searchLine,
34
+ ].join("\n");
35
+ }
36
+ const lower = (text) => text.charAt(0).toLowerCase() + text.slice(1);
package/index.ts CHANGED
@@ -47,7 +47,7 @@ export default async function (pi: ExtensionAPI) {
47
47
  const appleSilicon = process.platform === "darwin" && process.arch === "arm64";
48
48
  const handle = await installCompaction(pi, { semanticRecall, search: appleSilicon, cutAt: settings.cutAt, onSemanticSetting: async enabled => { off = !enabled; } });
49
49
  if (settings.semanticRecall === false) return;
50
- let session = 0, said = "", setup: Promise<unknown> | undefined;
50
+ let session = 0, said = "", setup: Promise<unknown> | undefined, announced = false;
51
51
  // A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
52
52
  const say = (ctx: ExtensionContext, message: string, level: "info" | "warning") => { try { ctx.ui.notify(message, level); } catch { /* ended */ } };
53
53
  const once = (ctx: ExtensionContext, message: string, level: "info" | "warning") => { if (said !== message) { said = message; say(ctx, message, level); } };
@@ -64,11 +64,13 @@ export default async function (pi: ExtensionAPI) {
64
64
  return once(ctx, "pi-tuck: semantic recall is off here because Pi's agent folder is inside this folder; open Pi in a project folder to use it.", "info");
65
65
  if (!ctx.hasUI) return; // a one-shot run (pi -p) would end before a first-time setup could
66
66
  if (!semanticRecall.status().configured) semanticRecall.refuse("Semantic recall is being set up; word recall works meanwhile");
67
- setup ??= ensureSemanticRuntime(defaultRoot(), message => say(ctx, message, "info"));
67
+ setup ??= ensureSemanticRuntime(defaultRoot(), message => { announced = true; say(ctx, message, "info"); });
68
68
  setup.then(async () => {
69
69
  if (current !== session || off) return;
70
70
  try {
71
71
  await handle.setSemanticEnabled(true, ctx); // reads the runtime's configuration through the outside-project check
72
+ // The download was announced, so its end is too: once, when semantic recall first starts on it.
73
+ if (announced && current === session) { announced = false; say(ctx, "pi-tuck: semantic recall is set up. Search by meaning works from the first cut on.", "info"); }
72
74
  } catch (error) {
73
75
  if (current === session) say(ctx, `pi-tuck: semantic recall could not start (${reason(error)}); it tries again when a session starts.`, "warning");
74
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "c53f4e367c1f45ac6b3865cbf25bf99992da4b7e961557e5510369dca81fb087";
1
+ export const BUILD_ID = "27846aedbe111110535eeed5998c572182cd15de0efede0fe4df1571166a838e";
package/src/compaction.ts CHANGED
@@ -16,6 +16,7 @@ import type { RecallPolicy } from "./recall.js";
16
16
  import { MAX_RECALL_CHARACTERS, MAX_RECALL_ENTRY_ID_CHARACTERS, MAX_RECALL_QUERY_CHARACTERS } from "./recall.js";
17
17
 
18
18
  import { tuck, tuckDraft, tuckObservedDraft, tuckRecall } from "./tuck.js";
19
+ import { LOADED_VERSION, statusText } from "./status.js";
19
20
  import { ImageRecallInput, historyImage, historyAround } from "./history-image.js";
20
21
  import { eligibilityKey, eligibilityKeyMoved, memoizedEligibleSources } from "./eligibility-cache";
21
22
  import { applyHistoryTools, hasTuckCut, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
@@ -108,9 +109,9 @@ export interface CompactionOptions {
108
109
  * 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
109
110
  * stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
110
111
  cutAt?: number;
111
- /** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
112
- * 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
113
- * transport's behavior declares an exact reserve with `responseExact: true`, and the room is then the window less `response` exactly. */
112
+ /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
113
+ * Standalone uses Pi's reserveTokens until a length stop or a context-overflow refusal on the current branch, then also holds the catalogue maximum.
114
+ * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
114
115
  requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
115
116
  /** The exact messages the host's `context` hook adds to every final request beyond the saved conversation (for example a role
116
117
  * view), in the form the hook injects them. Cut triggers, the cut planner and the post-cut checks count them with the same
@@ -201,6 +202,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
201
202
  }
202
203
  ctx.ui.notify(JSON.stringify({...semanticStatus(),...(errors.length?{errors}:{})}),errors.length?"warning":"info");
203
204
  }});
205
+ pi.registerCommand("tuck", { description: "pi-tuck's state in this session: version, cuts, cut point, search", handler: async (_args, ctx) => {
206
+ const { state, lastError } = semantic.status();
207
+ ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic: { state, lastError } }), "info");
208
+ } });
204
209
  // Pi's native start/settled events bracket the run and its post-turn cuts.
205
210
  let admittedRun = false;
206
211
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
@@ -261,17 +266,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
261
266
  pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
262
267
  const active = () => options.active?.() ?? true;
263
268
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
264
- // The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
265
- // reserve keeps input plus any reply inside the window.
266
- const reserveFor = (ctx: ExtensionContext) => Math.max(
267
- settingsFor().getCompactionSettings(ctx.model).reserveTokens, ctx.model?.maxTokens ?? 0);
268
- // The one place the response reserve is decided; every consumer takes its room from here. A host that knows how its transport caps
269
- // a reply declares an exact reserve (`responseExact: true`) and is held to it; without that flag the catalogue maximum is the floor.
269
+ // The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
270
+ // not fit (a length stop, or a refusal pi-ai classes as a context overflow), hold the catalogue maximum too. Other errors, such as a 503,
271
+ // keep native's room. Read the whole branch, including history before compactions, each time: no flag to persist or reset on resume,
272
+ // fork, /tree or a new session.
270
273
  const requestReserve = (ctx: ExtensionContext) => {
271
- const budget: { response: number; responseExact?: true } = options.requestReserve?.(ctx) ?? { response: reserveFor(ctx) };
274
+ const host = options.requestReserve?.(ctx);
275
+ const budget = host ?? { response: settingsFor().getCompactionSettings(ctx.model).reserveTokens };
272
276
  if (!Number.isSafeInteger(budget.response) || budget.response < 0)
273
277
  throw new Error("Invalid host request reserve");
274
- return { response: budget.responseExact === true ? budget.response : Math.max(budget.response, ctx.model?.maxTokens ?? 0) };
278
+ const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry =>
279
+ entry.type === "message" && entry.message.role === "assistant" &&
280
+ (entry.message.stopReason === "length" || (entry.message.stopReason === "error" && isContextOverflow(entry.message))));
281
+ return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
275
282
  };
276
283
  const inputRoom = (response: number, ctx: ExtensionContext) => {
277
284
  const configured = options.activeInputTokens;
@@ -425,11 +432,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
425
432
  const operatorManual = event.reason === "manual";
426
433
  if (operatorManual && !admittedRun) forgetFailure();
427
434
  access();
428
- if (event.customInstructions?.trim()) {
429
- recorder.stop("no-cut-custom-instructions");
430
- ctx.ui.notify("pi-tuck does not accept summarization instructions; use /compact without arguments.", "warning");
431
- return { cancel: true };
432
- }
435
+ // pi-tuck's summary is built from the session itself, so text after /compact cannot steer it. The operator asked for a cut: make the one
436
+ // plain /compact makes and say the text was not used, rather than cancel (which Pi shows as an error, with no cut).
437
+ if (event.customInstructions?.trim()) ctx.ui.notify("pi-tuck: /compact ignores the text after it, since pi-tuck builds its summary from the session itself. Cutting as plain /compact does.", "info");
433
438
  const { response, room } = limits(ctx);
434
439
  const retain = operatorManual || admittedRun;
435
440
  // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
package/src/status.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { SemanticState } from "./semantic-recall";
4
+
5
+ /** The version of the package this code was loaded from: the open session keeps it until Pi restarts, whatever is installed since. */
6
+ export const LOADED_VERSION: string = (() => {
7
+ try {
8
+ const path = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "../package.json" : "../../package.json", import.meta.url));
9
+ const version = (JSON.parse(readFileSync(path, "utf8")) as { version?: unknown }).version;
10
+ return typeof version === "string" ? version : "unknown";
11
+ } catch { return "unknown"; }
12
+ })();
13
+
14
+ /** `/tuck`: what an operator needs to know in words, from what is already recorded. It reads; it changes nothing the model sees. */
15
+ export function statusText(input: {
16
+ version: string;
17
+ active: boolean;
18
+ branch: readonly { type: string; tokensBefore?: unknown; details?: unknown }[];
19
+ cutAt: number | undefined;
20
+ search: boolean;
21
+ semantic: { state: SemanticState; lastError: string | null };
22
+ }): string {
23
+ if (!input.active) return `pi-tuck ${input.version} is loaded but inactive in this host; Pi's own compaction runs.`;
24
+ const cuts = input.branch.filter(e => e.type === "compaction"), ours = cuts.filter(e => (e.details as { compactor?: unknown } | null | undefined)?.compactor === "pi-tuck");
25
+ const last = ours.at(-1), before = last?.tokensBefore, after = (last?.details as { tokensAfter?: unknown } | undefined)?.tokensAfter;
26
+ const count = (n: number) => n.toLocaleString("en-US");
27
+ const cutLine = ours.length === 0 ? "Cuts: none yet in this session. The recall tools appear after the first cut."
28
+ : `Cuts: ${ours.length} by pi-tuck in this session` + (typeof before === "number" && typeof after === "number" ? `, the last from ${count(before)} to ${count(after)} tokens.` : ".");
29
+ const native = cuts.length - ours.length;
30
+ const searchLine = !input.search ? "Search: by keyword (search by meaning needs macOS on Apple silicon)."
31
+ : input.semantic.state === "ready" || input.semantic.state === "waiting" ? "Search: by meaning and keyword."
32
+ : input.semantic.state === "preparing" ? "Search: by meaning and keyword; indexing history in the background."
33
+ : `Search: by keyword; ${lower(input.semantic.lastError ?? "semantic recall is off in this session (/tuck-semantic on)")}.`.replace(/\.\.$/, ".");
34
+ return [
35
+ `pi-tuck ${input.version} (the version this session loaded; restart Pi after an update).`,
36
+ cutLine,
37
+ ...(native > 0 ? [`Pi's own compaction cut ${native} time${native === 1 ? "" : "s"} in this session.`] : []),
38
+ input.cutAt === undefined ? "Cut point: adapts by itself." : `Cut point: fixed at cutAt ${input.cutAt} of the input room. Remove cutAt from pi-tuck.json to let it adapt.`,
39
+ searchLine,
40
+ ].join("\n");
41
+ }
42
+
43
+ const lower = (text: string) => text.charAt(0).toLowerCase() + text.slice(1);