pi-tuck 1.0.3 → 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.
- package/CHANGELOG.md +22 -3
- package/README.md +137 -59
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +3 -0
- package/dist/src/compaction.js +24 -11
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/tuck/core/brief.js +5 -3
- package/dist/src/tuck/core/filter-noise.js +2 -1
- package/dist/src/tuck/core/normalize.js +3 -8
- package/dist/src/tuck/core/shrink.js +9 -12
- package/dist/src/tuck/core/summarize.js +14 -6
- package/dist/src/tuck/core/transcript.js +9 -1
- package/dist/src/tuck/types.d.ts +0 -7
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +22 -9
- package/src/tuck/core/brief.ts +5 -3
- package/src/tuck/core/filter-noise.ts +2 -1
- package/src/tuck/core/normalize.ts +2 -8
- package/src/tuck/core/shrink.ts +8 -9
- package/src/tuck/core/summarize.ts +13 -6
- package/src/tuck/core/transcript.ts +5 -1
- package/src/tuck/types.ts +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
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
|
+
|
|
6
23
|
## [1.0.3] - 2026-10-31
|
|
7
24
|
|
|
8
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
|
|
@@ -59,9 +76,10 @@ First release on npm. 1.0.0 was tagged but never published; its notes below desc
|
|
|
59
76
|
|
|
60
77
|
### Fixed
|
|
61
78
|
|
|
62
|
-
- A long session whose dialogue no longer fit the summary stalled for tens of seconds at every cut
|
|
63
|
-
the
|
|
64
|
-
|
|
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.
|
|
65
83
|
|
|
66
84
|
## [1.0.0] - 2026-10-30
|
|
67
85
|
|
|
@@ -103,6 +121,7 @@ Tagged, not published.
|
|
|
103
121
|
Source preview on GitHub, not published to npm. Deterministic compaction with `pi_tuck_recall` and `pi_tuck_search`; semantic search
|
|
104
122
|
opt-in, with a runtime the user had to supply.
|
|
105
123
|
|
|
124
|
+
[1.0.4]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.4
|
|
106
125
|
[1.0.3]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.3
|
|
107
126
|
[1.0.2]: https://github.com/galyavin/pi-tuck/tree/v1.0.2
|
|
108
127
|
[1.0.1]: https://github.com/galyavin/pi-tuck/releases/tag/v1.0.1
|
package/README.md
CHANGED
|
@@ -1,74 +1,166 @@
|
|
|
1
1
|
# pi-tuck
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
53
|
+
pi install npm:pi-tuck
|
|
18
54
|
```
|
|
19
55
|
|
|
20
|
-
|
|
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.
|
|
23
57
|
|
|
24
|
-
|
|
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`.
|
|
25
62
|
|
|
26
|
-
|
|
63
|
+
## How it works
|
|
27
64
|
|
|
28
|
-
|
|
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).
|
|
65
|
+
When context fills up, pi-tuck replaces the older turns with one summary. The summary keeps:
|
|
32
66
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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).
|
|
37
71
|
|
|
38
|
-
|
|
39
|
-
It can also write an original back out to a file.
|
|
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.
|
|
40
73
|
|
|
41
|
-
|
|
42
|
-
semantic search (see below) and returns the original text with pointers for `tuck_recall`.
|
|
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.
|
|
43
75
|
|
|
44
|
-
|
|
76
|
+
The agent gets two tools to get anything back:
|
|
77
|
+
|
|
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.
|
|
45
82
|
|
|
46
83
|
| Command | Does |
|
|
47
84
|
|---|---|
|
|
48
85
|
| `/tuck-semantic on\|off\|status` | Turns semantic search on or off for this session, or shows its state. |
|
|
49
86
|
| `/tuck-incident on\|next\|off\|status` | Records this session's compaction decisions to a file you can attach to a bug report. |
|
|
50
87
|
|
|
51
|
-
`/compact` works as usual
|
|
52
|
-
|
|
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:
|
|
53
97
|
|
|
54
|
-
**
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
101
|
+
|
|
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:
|
|
57
148
|
|
|
58
149
|
```json
|
|
59
|
-
{ "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens":
|
|
150
|
+
{ "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 8192 } } } }
|
|
60
151
|
```
|
|
61
152
|
|
|
62
|
-
|
|
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:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{ "providers": { "openrouter": { "compat": { "openRouterRouting": { "only": ["your-upstream"], "allow_fallbacks": false } } } } }
|
|
157
|
+
```
|
|
63
158
|
|
|
64
159
|
## Semantic search
|
|
65
160
|
|
|
66
|
-
Semantic search is on by default
|
|
67
|
-
[EmbeddingGemma-2](https://huggingface.co/google/embeddinggemma-2), served by [llama.cpp](https://github.com/ggml-org/llama.cpp) on the
|
|
68
|
-
CPU. No text leaves your machine.
|
|
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.
|
|
69
162
|
|
|
70
|
-
The first time a session starts, pi-tuck downloads what it needs, once, about 390 MB in the background
|
|
71
|
-
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:
|
|
72
164
|
|
|
73
165
|
| File | From |
|
|
74
166
|
|---|---|
|
|
@@ -76,21 +168,9 @@ search meanwhile:
|
|
|
76
168
|
| llama.cpp release b11476 for macOS arm64 (12 MB) | github.com/ggml-org/llama.cpp |
|
|
77
169
|
| CPython 3.13 (25 MB) and 16 Python packages for the tokenizer (9 MB) | github.com/astral-sh/python-build-standalone, pypi.org |
|
|
78
170
|
|
|
79
|
-
Every file is pinned to a SHA-256 in [src/assets.ts](src/assets.ts) and checked before use. Everything goes to `~/.pi/agent/pi-tuck`;
|
|
80
|
-
to remove it, delete that folder. Setup needs about 6.5 GB of free disk space while it runs and uses 460 MB after. If it fails, for
|
|
81
|
-
example offline, pi-tuck says so and tries again at the next session start. One-shot runs (`pi -p`) don't start the setup.
|
|
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.
|
|
82
172
|
|
|
83
|
-
Indexing starts in the background the first time a session uses `tuck_search`, and catches up after each compaction. Semantic search is off when Pi
|
|
84
|
-
runs in a folder that contains Pi's agent folder, such as your home folder, because its own files live there; open Pi in a project folder.
|
|
85
|
-
|
|
86
|
-
**Turn it off** by creating `~/.pi/agent/pi-tuck.json`:
|
|
87
|
-
|
|
88
|
-
```json
|
|
89
|
-
{ "semanticRecall": false }
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
Nothing is then downloaded, and `tuck_search` uses keywords only. If you set `PI_CODING_AGENT_DIR`, the file and the download folder are
|
|
93
|
-
in that folder instead.
|
|
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.
|
|
94
174
|
|
|
95
175
|
## Use from code
|
|
96
176
|
|
|
@@ -105,8 +185,7 @@ const handle = await installCompaction(pi, { semanticRecall });
|
|
|
105
185
|
await handle.setSemanticEnabled(true, ctx); // e.g. in a session_start handler
|
|
106
186
|
```
|
|
107
187
|
|
|
108
|
-
`installCompaction` options (reply reserve, host-injected messages, recall policy and others) are documented in
|
|
109
|
-
[src/compaction.ts](src/compaction.ts). `handle.close()` stops the semantic worker; call it before disposing a session.
|
|
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.
|
|
110
189
|
|
|
111
190
|
## Development
|
|
112
191
|
|
|
@@ -117,12 +196,11 @@ npm test # unit tests (Vitest) and the Python runtime tests
|
|
|
117
196
|
npm run build # dist/ for the library export
|
|
118
197
|
```
|
|
119
198
|
|
|
199
|
+
Changes are listed in [CHANGELOG.md](CHANGELOG.md). Releases are published from GitHub Actions by npm trusted publishing, with provenance.
|
|
200
|
+
|
|
120
201
|
## Credits
|
|
121
202
|
|
|
122
|
-
pi-tuck grew out of [pi-vcc](https://github.com/sting8k/pi-vcc) by sting8k and includes code from
|
|
123
|
-
[pi-blackhole](https://github.com/k0valik/pi-blackhole) by k0valik and from [Pi](https://github.com/earendil-works/pi) by Mario
|
|
124
|
-
Zechner, all MIT; their notices are in [LICENSE](LICENSE). The downloaded model is Apache-2.0, llama.cpp is MIT, and CPython is under the
|
|
125
|
-
PSF license.
|
|
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.
|
|
126
204
|
|
|
127
205
|
## License
|
|
128
206
|
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "fb0ab667f5bfcd22fd5cc6edb89cd859426fd7c28bb58c2bc7b9fbec6fcdd3da";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "fb0ab667f5bfcd22fd5cc6edb89cd859426fd7c28bb58c2bc7b9fbec6fcdd3da";
|
package/dist/src/compaction.d.ts
CHANGED
|
@@ -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. */
|
package/dist/src/compaction.js
CHANGED
|
@@ -36,8 +36,18 @@ const RecallInput = Type.Union([
|
|
|
36
36
|
]);
|
|
37
37
|
const SearchInput = Type.Object({ query, priorResults: Type.Optional(Type.Array(Type.Object({ entryId, sourceSha: Type.String({ minLength: 64, maxLength: 64 }), start: Type.Integer({ minimum: 0 }), end: Type.Integer({ minimum: 0 }) }, { additionalProperties: false }), { maxItems: 20 })) }, { additionalProperties: false });
|
|
38
38
|
const reply = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: {} });
|
|
39
|
-
|
|
39
|
+
/** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. It is a decision about the
|
|
40
|
+
* state as it was, so it ends with the attempt (`forgetRefusal`) and the next attempt decides afresh. Any other failure is a fault in the compactor
|
|
41
|
+
* or its host, and stays until the session restarts. */
|
|
42
|
+
class Refusal extends Error {
|
|
40
43
|
}
|
|
44
|
+
/** A refusal for want of room, which the user can remedy: it carries advice. */
|
|
45
|
+
class CapacityError extends Refusal {
|
|
46
|
+
}
|
|
47
|
+
/** What a capacity refusal tells the operator. When the provider has counted far more input than pi-tuck's estimate, the refusal most
|
|
48
|
+
* likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
|
|
49
|
+
export const refusalNotice = (message, factor) => message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
|
|
50
|
+
(factor >= 1.5 ? ` This provider has counted up to ${factor.toFixed(1)}x the input pi-tuck estimates; through a router such as OpenRouter, a different upstream may count less.` : "");
|
|
41
51
|
/** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
|
|
42
52
|
export const CALIBRATION_CEILING = 1.5;
|
|
43
53
|
/** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
|
|
@@ -249,7 +259,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
249
259
|
learn(ratio);
|
|
250
260
|
});
|
|
251
261
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
252
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; });
|
|
262
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetRefusal(); });
|
|
253
263
|
const active = () => options.active?.() ?? true;
|
|
254
264
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
255
265
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -307,13 +317,17 @@ export async function installCompaction(pi, options = {}) {
|
|
|
307
317
|
finally {
|
|
308
318
|
try {
|
|
309
319
|
if (notify)
|
|
310
|
-
ctx.ui.notify(failure.message
|
|
320
|
+
ctx.ui.notify(refusalNotice(failure.message, factor), "error");
|
|
311
321
|
}
|
|
312
322
|
finally {
|
|
313
323
|
ctx.abort();
|
|
314
324
|
}
|
|
315
325
|
}
|
|
316
326
|
};
|
|
327
|
+
// The one place a refusal is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
328
|
+
// input started (a custom message) has only the first of these.
|
|
329
|
+
const forgetRefusal = () => { if (failure instanceof Refusal)
|
|
330
|
+
failure = undefined; };
|
|
317
331
|
const access = () => {
|
|
318
332
|
if (failure)
|
|
319
333
|
throw failure;
|
|
@@ -329,8 +343,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
329
343
|
pi.on("input", async (event, ctx) => {
|
|
330
344
|
if (!ctx.isIdle() || phase === "starting")
|
|
331
345
|
return; // a steering or follow-up message joins the running request
|
|
332
|
-
|
|
333
|
-
failure = undefined;
|
|
346
|
+
forgetRefusal();
|
|
334
347
|
phase = "decided";
|
|
335
348
|
try {
|
|
336
349
|
await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools());
|
|
@@ -404,8 +417,8 @@ export async function installCompaction(pi, options = {}) {
|
|
|
404
417
|
// Pi also labels the awaited idle incoming preflight "manual".
|
|
405
418
|
const incoming = pendingCut;
|
|
406
419
|
const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
|
|
407
|
-
if (operatorManual && !admittedRun
|
|
408
|
-
|
|
420
|
+
if (operatorManual && !admittedRun)
|
|
421
|
+
forgetRefusal();
|
|
409
422
|
access();
|
|
410
423
|
if (event.customInstructions?.trim()) {
|
|
411
424
|
recorder.stop("no-cut-custom-instructions");
|
|
@@ -456,7 +469,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
456
469
|
recorder.stop("no-cut");
|
|
457
470
|
ctx.ui.notify(decision.message, "warning");
|
|
458
471
|
if (!operatorManual)
|
|
459
|
-
fail(decision.capacity ?
|
|
472
|
+
fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
|
|
460
473
|
return { cancel: true };
|
|
461
474
|
}
|
|
462
475
|
catch (error) {
|
|
@@ -516,7 +529,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
516
529
|
if (cut.opportunistic && !failure && !cut.proposed)
|
|
517
530
|
resolve();
|
|
518
531
|
else
|
|
519
|
-
reject(error.message === "Compaction cancelled" && failure instanceof
|
|
532
|
+
reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : error);
|
|
520
533
|
} }));
|
|
521
534
|
}
|
|
522
535
|
finally {
|
|
@@ -526,7 +539,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
526
539
|
// The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
|
|
527
540
|
const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
|
|
528
541
|
if (!cut.opportunistic && (after >= total || !afterFits))
|
|
529
|
-
throw new (afterFits ?
|
|
542
|
+
throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
|
|
530
543
|
};
|
|
531
544
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
532
545
|
const prompted = phase === "decided";
|
|
@@ -583,7 +596,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
583
596
|
},
|
|
584
597
|
});
|
|
585
598
|
if (decision.kind === "fault")
|
|
586
|
-
throw new (decision.capacity ? CapacityError :
|
|
599
|
+
throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
587
600
|
if (decision.kind !== "cut")
|
|
588
601
|
return;
|
|
589
602
|
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "tool-loop", before, true, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type RecallPolicy } from './recall.js';
|
|
2
2
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
export declare const DIAGNOSTIC_BUILD = "
|
|
3
|
+
export declare const DIAGNOSTIC_BUILD = "fb0ab667f5bfcd22fd5cc6edb89cd859426fd7c28bb58c2bc7b9fbec6fcdd3da";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
|
@@ -108,14 +108,16 @@ const analyze = (text) => {
|
|
|
108
108
|
const excerptRecords = (text, { passages, costs }, role, sourceIndex, headLimit, tailLimit, readFrom) => {
|
|
109
109
|
const charLimit = 1024;
|
|
110
110
|
const identity = { role, ...(sourceIndex !== undefined ? { sourceIndex } : {}) };
|
|
111
|
-
|
|
111
|
+
// A line carries at least as many characters as the text, so a long text is over the limit without counting its escapes.
|
|
112
|
+
const fits = (from, to) => to - from <= charLimit && escapedLength(text.slice(from, to)) <= charLimit;
|
|
113
|
+
if (fits(0, text.length) && costs.reduce((sum, cost) => sum + cost, 0) <= headLimit + tailLimit)
|
|
112
114
|
return [{ kind: "excerpt", ...identity, text }];
|
|
113
115
|
let head = 0, headCost = 0, tail = passages.length, tailCost = 0;
|
|
114
|
-
while (head < tail && headCost + costs[head] <= headLimit &&
|
|
116
|
+
while (head < tail && headCost + costs[head] <= headLimit && fits(0, passages[head].end)) {
|
|
115
117
|
headCost += costs[head++];
|
|
116
118
|
}
|
|
117
119
|
if (tailLimit > 0) {
|
|
118
|
-
while (tail > head && tailCost + costs[tail - 1] <= tailLimit &&
|
|
120
|
+
while (tail > head && tailCost + costs[tail - 1] <= tailLimit && fits(passages[tail - 1].start, text.length)) {
|
|
119
121
|
tailCost += costs[--tail];
|
|
120
122
|
}
|
|
121
123
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isSourceExport, isLookupTool, isRecallTool } from "./recall-operation.js";
|
|
2
|
+
import { isDialogue } from "./brief.js";
|
|
2
3
|
/** Suppress lookup echoes, retaining recorded export attempts and exactly paired outcomes. */
|
|
3
4
|
export const filterNoise = (blocks) => blocks.filter(b => !(b.kind === "tool_call" && isLookupTool(b.name) && !(isRecallTool(b.name) && isSourceExport(b.args))) &&
|
|
4
5
|
!(b.kind === "tool_result" && isLookupTool(b.name) &&
|
|
5
6
|
!(isRecallTool(b.name) && b.recallExport)) &&
|
|
6
|
-
!((b
|
|
7
|
+
!(isDialogue(b) && !b.text.trim()));
|
|
@@ -18,14 +18,10 @@ const normalizeOne = (msg, msgIndex) => {
|
|
|
18
18
|
}
|
|
19
19
|
return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
|
|
20
20
|
}
|
|
21
|
-
if (msg.role === "bashExecution")
|
|
22
|
-
|
|
23
|
-
const out = msg.output ?? "";
|
|
24
|
-
const exit = msg.exitCode;
|
|
25
|
-
return [{ kind: "bash", command: cmd, output: out, exitCode: exit, cancelled: msg.cancelled, truncated: msg.truncated, sourceIndex: msgIndex }];
|
|
26
|
-
}
|
|
21
|
+
if (msg.role === "bashExecution")
|
|
22
|
+
return [{ kind: "bash", sourceIndex: msgIndex }];
|
|
27
23
|
if (msg.role === "toolResult")
|
|
28
|
-
return [{ kind: "tool_result", name: msg.toolName,
|
|
24
|
+
return [{ kind: "tool_result", name: msg.toolName, sourceIndex: msgIndex }];
|
|
29
25
|
if (msg.role === "assistant") {
|
|
30
26
|
if (!msg.content)
|
|
31
27
|
return [];
|
|
@@ -46,7 +42,6 @@ const normalizeOne = (msg, msgIndex) => {
|
|
|
46
42
|
kind: "tool_call",
|
|
47
43
|
name: part.name,
|
|
48
44
|
args: part.arguments,
|
|
49
|
-
callId: part.id,
|
|
50
45
|
sourceIndex: msgIndex,
|
|
51
46
|
});
|
|
52
47
|
}
|
|
@@ -82,18 +82,16 @@ const createLane = (role, units, mandatory, decorate) => {
|
|
|
82
82
|
const empty = emptied[tier], counts = emptyCounts[tier];
|
|
83
83
|
count += counts[end] - counts[drop];
|
|
84
84
|
if (counts[end] > counts[drop]) {
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// Text blocks of one message share a locator: collect distinct ones, enough to tell whether the shown list is complete.
|
|
86
|
+
const extra = new Set();
|
|
87
|
+
for (let i = drop; i < end && (extra.size < 9 || !unlocated); i++)
|
|
87
88
|
if (empty[i]) {
|
|
88
89
|
const source = units[i].sourceIndex;
|
|
89
90
|
if (source === undefined)
|
|
90
91
|
unlocated = true;
|
|
91
|
-
else
|
|
92
|
-
extra.
|
|
92
|
+
else if (extra.size < 9)
|
|
93
|
+
extra.add(source);
|
|
93
94
|
}
|
|
94
|
-
for (let i = drop; i < end && !unlocated; i++)
|
|
95
|
-
if (empty[i] && units[i].sourceIndex === undefined)
|
|
96
|
-
unlocated = true;
|
|
97
95
|
sources = [...new Set([...sources, ...extra])].sort((a, b) => a - b).slice(0, 9);
|
|
98
96
|
}
|
|
99
97
|
}
|
|
@@ -119,10 +117,9 @@ const createDialogue = (input) => {
|
|
|
119
117
|
const dialogue = input.blocks.filter(isDialogue);
|
|
120
118
|
const times = dialogue.find(block => block.sourceTimes)?.sourceTimes;
|
|
121
119
|
const decorate = (record) => decorateRecord(record, times);
|
|
122
|
-
const
|
|
123
|
-
const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => roleOf(block) === role), input.mandatorySourceIndices, decorate)]));
|
|
120
|
+
const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => block.kind === role), input.mandatorySourceIndices, decorate)]));
|
|
124
121
|
const seen = { user: 0, assistant: 0, notice: 0 };
|
|
125
|
-
const order = dialogue.map(block => ({ role:
|
|
122
|
+
const order = dialogue.map(block => ({ role: block.kind, i: seen[block.kind]++ }));
|
|
126
123
|
const tools = input.blocks.length > dialogue.length ? rendered([TOOLS_OMITTED]) : undefined;
|
|
127
124
|
const overhead = TRANSCRIPT_HEADER.length + DIALOGUE_SUFFIX.length;
|
|
128
125
|
/** The length of the summary text of a configuration: its header, one line per record, and the closing section; 0 when it has no record. */
|
|
@@ -201,7 +198,7 @@ export const shrinkDialogue = (input, limit) => {
|
|
|
201
198
|
}
|
|
202
199
|
}
|
|
203
200
|
for (let d = 0; d <= recent.drop; d++)
|
|
204
|
-
if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d
|
|
201
|
+
if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d }, notice: clipped }))
|
|
205
202
|
return hit;
|
|
206
203
|
// From here users give way, and the assistant keeps only that recent third.
|
|
207
204
|
for (const t of tiers)
|
|
@@ -215,7 +212,7 @@ export const shrinkDialogue = (input, limit) => {
|
|
|
215
212
|
if (hit = found("E1", { user: gone(user), assistant: recent, notice: { drop: d, tier: 1, end: notice.n } }))
|
|
216
213
|
return hit;
|
|
217
214
|
for (let d = recent.drop; d <= assistant.n; d++)
|
|
218
|
-
if (hit = found("E2", { user: gone(user), assistant: {
|
|
215
|
+
if (hit = found("E2", { user: gone(user), assistant: { ...recent, drop: d }, notice: gone(notice) }))
|
|
219
216
|
return hit;
|
|
220
217
|
return { text: "", step: "none", size: 0 };
|
|
221
218
|
};
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { normalize } from "./normalize.js";
|
|
2
2
|
import { filterNoise } from "./filter-noise.js";
|
|
3
3
|
import { shrinkDialogue, wholeDialogue } from "./shrink.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
// A cut offers the whole dialogue and then, when it does not fit, the shrunk one, from the same input: normalize it once. The blocks are
|
|
5
|
+
// never mutated, and keeping the same objects also keeps their cached passage analyses.
|
|
6
|
+
const inputs = new WeakMap();
|
|
7
|
+
const dialogueInput = (input) => {
|
|
8
|
+
let prepared = inputs.get(input);
|
|
9
|
+
if (!prepared)
|
|
10
|
+
inputs.set(input, prepared = {
|
|
11
|
+
blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes)),
|
|
12
|
+
mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
|
|
13
|
+
sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
|
|
14
|
+
sourceEntryIds: input.sourceEntryIds,
|
|
15
|
+
});
|
|
16
|
+
return prepared;
|
|
17
|
+
};
|
|
10
18
|
/** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted.
|
|
11
19
|
* This disposable view grants neither present authority nor native tool execution. */
|
|
12
20
|
export const compileDialogue = (input) => wholeDialogue(dialogueInput(input));
|
|
@@ -81,7 +81,15 @@ const LEGACY_RECEIPT_RANGE_NOTICE = RECEIPT_RANGE_NOTICE.replace("tuck_recall",
|
|
|
81
81
|
*/
|
|
82
82
|
const escapeText = (text) => text.replace(/[\\\n\r]/g, c => c === "\\" ? "\\\\" : c === "\n" ? "\\n" : "\\r");
|
|
83
83
|
/** The length of `text` as a record line carries it: one character more for each "\", line feed and carriage return. */
|
|
84
|
-
export const escapedLength = (text) =>
|
|
84
|
+
export const escapedLength = (text) => {
|
|
85
|
+
let length = text.length;
|
|
86
|
+
for (let i = 0; i < text.length; i++) {
|
|
87
|
+
const c = text.charCodeAt(i);
|
|
88
|
+
if (c === 92 || c === 10 || c === 13)
|
|
89
|
+
length++;
|
|
90
|
+
}
|
|
91
|
+
return length;
|
|
92
|
+
};
|
|
85
93
|
const TITLE = /^[A-Z][A-Za-z0-9_-]*$/;
|
|
86
94
|
const refuse = (record) => { throw new Error(`Cannot write a ${record.kind} record as ${TRANSCRIPT_HEADER}`); };
|
|
87
95
|
/** One record as a line of TUCK-TRANSCRIPT/2 (without its newline). The records this build emits only: a record whose fields the grammar
|
package/dist/src/tuck/types.d.ts
CHANGED
|
@@ -42,20 +42,13 @@ export type NormalizedBlock = {
|
|
|
42
42
|
kind: "tool_call";
|
|
43
43
|
name: string;
|
|
44
44
|
args: Record<string, unknown>;
|
|
45
|
-
callId?: string;
|
|
46
45
|
sourceIndex?: number;
|
|
47
46
|
} | {
|
|
48
47
|
kind: "tool_result";
|
|
49
48
|
name: string;
|
|
50
|
-
text: string;
|
|
51
49
|
sourceIndex?: number;
|
|
52
50
|
recallExport?: true;
|
|
53
51
|
} | {
|
|
54
52
|
kind: "bash";
|
|
55
|
-
command: string;
|
|
56
|
-
output: string;
|
|
57
|
-
exitCode: number | undefined;
|
|
58
|
-
cancelled?: boolean;
|
|
59
|
-
truncated?: boolean;
|
|
60
53
|
sourceIndex?: number;
|
|
61
54
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tuck",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Deterministic compaction for Pi: compact summaries that keep the dialogue and the files touched, plus history recall by keyword and by meaning, all local.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "fb0ab667f5bfcd22fd5cc6edb89cd859426fd7c28bb58c2bc7b9fbec6fcdd3da";
|
package/src/compaction.ts
CHANGED
|
@@ -40,7 +40,17 @@ const RecallInput = Type.Union([
|
|
|
40
40
|
]);
|
|
41
41
|
const SearchInput = Type.Object({query,priorResults:Type.Optional(Type.Array(Type.Object({entryId,sourceSha:Type.String({minLength:64,maxLength:64}),start:Type.Integer({minimum:0}),end:Type.Integer({minimum:0})},{additionalProperties:false}),{maxItems:20}))},{additionalProperties:false});
|
|
42
42
|
const reply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }], details: {} });
|
|
43
|
-
|
|
43
|
+
/** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. It is a decision about the
|
|
44
|
+
* state as it was, so it ends with the attempt (`forgetRefusal`) and the next attempt decides afresh. Any other failure is a fault in the compactor
|
|
45
|
+
* or its host, and stays until the session restarts. */
|
|
46
|
+
class Refusal extends Error {}
|
|
47
|
+
/** A refusal for want of room, which the user can remedy: it carries advice. */
|
|
48
|
+
class CapacityError extends Refusal {}
|
|
49
|
+
/** What a capacity refusal tells the operator. When the provider has counted far more input than pi-tuck's estimate, the refusal most
|
|
50
|
+
* likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
|
|
51
|
+
export const refusalNotice = (message: string, factor: number): string =>
|
|
52
|
+
message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
|
|
53
|
+
(factor >= 1.5 ? ` This provider has counted up to ${factor.toFixed(1)}x the input pi-tuck estimates; through a router such as OpenRouter, a different upstream may count less.` : "");
|
|
44
54
|
/** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
|
|
45
55
|
export const CALIBRATION_CEILING = 1.5;
|
|
46
56
|
/** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
|
|
@@ -220,7 +230,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
220
230
|
if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
221
231
|
});
|
|
222
232
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
223
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; });
|
|
233
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetRefusal(); });
|
|
224
234
|
const active = () => options.active?.() ?? true;
|
|
225
235
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
226
236
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -274,10 +284,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
274
284
|
pendingBoundaryCommit = false;
|
|
275
285
|
pending = undefined;
|
|
276
286
|
try { options.fail?.(error); } finally {
|
|
277
|
-
try { if (notify) ctx.ui.notify(failure.message
|
|
287
|
+
try { if (notify) ctx.ui.notify(refusalNotice(failure.message, factor), "error"); }
|
|
278
288
|
finally { ctx.abort(); }
|
|
279
289
|
}
|
|
280
290
|
};
|
|
291
|
+
// The one place a refusal is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
292
|
+
// input started (a custom message) has only the first of these.
|
|
293
|
+
const forgetRefusal = () => { if (failure instanceof Refusal) failure = undefined; };
|
|
281
294
|
const access = () => {
|
|
282
295
|
if (failure) throw failure;
|
|
283
296
|
if (!active()) throw new Error("Compaction host is inactive");
|
|
@@ -290,7 +303,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
290
303
|
let phase: "idle" | "decided" | "starting" = "idle";
|
|
291
304
|
pi.on("input", async (event, ctx) => {
|
|
292
305
|
if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
|
|
293
|
-
|
|
306
|
+
forgetRefusal();
|
|
294
307
|
phase = "decided";
|
|
295
308
|
try { await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools()); }
|
|
296
309
|
catch (error) { fail(error, ctx); }
|
|
@@ -346,7 +359,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
346
359
|
// Pi also labels the awaited idle incoming preflight "manual".
|
|
347
360
|
const incoming = pendingCut;
|
|
348
361
|
const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
|
|
349
|
-
if (operatorManual && !admittedRun
|
|
362
|
+
if (operatorManual && !admittedRun) forgetRefusal();
|
|
350
363
|
access();
|
|
351
364
|
if (event.customInstructions?.trim()) {
|
|
352
365
|
recorder.stop("no-cut-custom-instructions");
|
|
@@ -391,7 +404,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
391
404
|
if (decision.kind === "none") return { cancel: true };
|
|
392
405
|
recorder.stop("no-cut");
|
|
393
406
|
ctx.ui.notify(decision.message, "warning");
|
|
394
|
-
if (!operatorManual) fail(decision.capacity ?
|
|
407
|
+
if (!operatorManual) fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
|
|
395
408
|
return { cancel: true };
|
|
396
409
|
} catch (error) {
|
|
397
410
|
try { fail(error, ctx); } finally { return { cancel: true }; }
|
|
@@ -438,14 +451,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
438
451
|
// planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
|
|
439
452
|
// or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
|
|
440
453
|
if (cut.opportunistic && !failure && !cut.proposed) resolve();
|
|
441
|
-
else reject(error.message === "Compaction cancelled" && failure instanceof
|
|
454
|
+
else reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : error);
|
|
442
455
|
} }));
|
|
443
456
|
} finally { pendingCut = undefined; }
|
|
444
457
|
access();
|
|
445
458
|
// The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
|
|
446
459
|
const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
|
|
447
460
|
if (!cut.opportunistic && (after >= total || !afterFits))
|
|
448
|
-
throw new (afterFits ?
|
|
461
|
+
throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
|
|
449
462
|
};
|
|
450
463
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
451
464
|
const prompted = phase === "decided";
|
|
@@ -492,7 +505,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
492
505
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
493
506
|
},
|
|
494
507
|
});
|
|
495
|
-
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError :
|
|
508
|
+
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
496
509
|
if (decision.kind !== "cut") return;
|
|
497
510
|
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "tool-loop", before, true, false, previousSummary,
|
|
498
511
|
{ response, host, mandatory, target: decision.target, margin: room - band });
|
package/src/tuck/core/brief.ts
CHANGED
|
@@ -98,12 +98,14 @@ const excerptRecords = (text: string, { passages, costs }: Analysis, role: "user
|
|
|
98
98
|
headLimit: number, tailLimit: number, readFrom: (offset: number) => string): TranscriptRecord[] => {
|
|
99
99
|
const charLimit = 1024;
|
|
100
100
|
const identity = { role, ...(sourceIndex !== undefined ? { sourceIndex } : {}) };
|
|
101
|
-
|
|
101
|
+
// A line carries at least as many characters as the text, so a long text is over the limit without counting its escapes.
|
|
102
|
+
const fits = (from: number, to: number) => to - from <= charLimit && escapedLength(text.slice(from, to)) <= charLimit;
|
|
103
|
+
if (fits(0, text.length) && costs.reduce((sum, cost) => sum + cost, 0) <= headLimit + tailLimit)
|
|
102
104
|
return [{ kind: "excerpt", ...identity, text }];
|
|
103
105
|
let head = 0, headCost = 0, tail = passages.length, tailCost = 0;
|
|
104
|
-
while (head < tail && headCost + costs[head]! <= headLimit &&
|
|
106
|
+
while (head < tail && headCost + costs[head]! <= headLimit && fits(0, passages[head]!.end)) { headCost += costs[head++]!; }
|
|
105
107
|
if (tailLimit > 0) {
|
|
106
|
-
while (tail > head && tailCost + costs[tail - 1]! <= tailLimit &&
|
|
108
|
+
while (tail > head && tailCost + costs[tail - 1]! <= tailLimit && fits(passages[tail - 1]!.start, text.length)) { tailCost += costs[--tail]!; }
|
|
107
109
|
}
|
|
108
110
|
const headEnd = head ? passages[head - 1]!.end : 0;
|
|
109
111
|
const tailStart = tail < passages.length ? passages[tail]!.start : text.length;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { NormalizedBlock } from "../types";
|
|
2
2
|
import { isSourceExport, isLookupTool, isRecallTool } from "./recall-operation";
|
|
3
|
+
import { isDialogue } from "./brief";
|
|
3
4
|
|
|
4
5
|
/** Suppress lookup echoes, retaining recorded export attempts and exactly paired outcomes. */
|
|
5
6
|
export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => blocks.filter(b =>
|
|
6
7
|
!(b.kind === "tool_call" && isLookupTool(b.name) && !(isRecallTool(b.name) && isSourceExport(b.args))) &&
|
|
7
8
|
!(b.kind === "tool_result" && isLookupTool(b.name) &&
|
|
8
9
|
!(isRecallTool(b.name) && b.recallExport)) &&
|
|
9
|
-
!((b
|
|
10
|
+
!(isDialogue(b) && !b.text.trim()));
|
|
@@ -19,14 +19,9 @@ const normalizeOne = (msg: CompilerMessage, msgIndex: number | undefined): Norma
|
|
|
19
19
|
return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
if (msg.role === "bashExecution") {
|
|
23
|
-
const cmd = msg.command ?? "";
|
|
24
|
-
const out = msg.output ?? "";
|
|
25
|
-
const exit = msg.exitCode;
|
|
26
|
-
return [{ kind: "bash", command: cmd, output: out, exitCode: exit, cancelled: msg.cancelled, truncated: msg.truncated, sourceIndex: msgIndex }];
|
|
27
|
-
}
|
|
22
|
+
if (msg.role === "bashExecution") return [{ kind: "bash", sourceIndex: msgIndex }];
|
|
28
23
|
|
|
29
|
-
if (msg.role === "toolResult") return [{ kind: "tool_result", name: msg.toolName,
|
|
24
|
+
if (msg.role === "toolResult") return [{ kind: "tool_result", name: msg.toolName, sourceIndex: msgIndex }];
|
|
30
25
|
|
|
31
26
|
if (msg.role === "assistant") {
|
|
32
27
|
if (!msg.content) return [];
|
|
@@ -47,7 +42,6 @@ const normalizeOne = (msg: CompilerMessage, msgIndex: number | undefined): Norma
|
|
|
47
42
|
kind: "tool_call",
|
|
48
43
|
name: part.name,
|
|
49
44
|
args: part.arguments,
|
|
50
|
-
callId: part.id,
|
|
51
45
|
sourceIndex: msgIndex,
|
|
52
46
|
});
|
|
53
47
|
}
|
package/src/tuck/core/shrink.ts
CHANGED
|
@@ -102,12 +102,12 @@ const createLane = (role: Role, units: DialogueBlock[], mandatory: ReadonlySet<n
|
|
|
102
102
|
const empty = emptied[tier]!, counts = emptyCounts[tier]!;
|
|
103
103
|
count += counts[end]! - counts[drop]!;
|
|
104
104
|
if (counts[end]! > counts[drop]!) {
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// Text blocks of one message share a locator: collect distinct ones, enough to tell whether the shown list is complete.
|
|
106
|
+
const extra = new Set<number>();
|
|
107
|
+
for (let i = drop; i < end && (extra.size < 9 || !unlocated); i++) if (empty[i]) {
|
|
107
108
|
const source = units[i]!.sourceIndex;
|
|
108
|
-
if (source === undefined) unlocated = true; else extra.
|
|
109
|
+
if (source === undefined) unlocated = true; else if (extra.size < 9) extra.add(source);
|
|
109
110
|
}
|
|
110
|
-
for (let i = drop; i < end && !unlocated; i++) if (empty[i] && units[i]!.sourceIndex === undefined) unlocated = true;
|
|
111
111
|
sources = [...new Set([...sources, ...extra])].sort((a, b) => a - b).slice(0, 9);
|
|
112
112
|
}
|
|
113
113
|
}
|
|
@@ -132,11 +132,10 @@ const createDialogue = (input: DialogueInput) => {
|
|
|
132
132
|
const dialogue = input.blocks.filter(isDialogue);
|
|
133
133
|
const times = dialogue.find(block => block.sourceTimes)?.sourceTimes;
|
|
134
134
|
const decorate = (record: TranscriptRecord) => decorateRecord(record, times);
|
|
135
|
-
const
|
|
136
|
-
const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => roleOf(block) === role), input.mandatorySourceIndices, decorate)])) as
|
|
135
|
+
const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => block.kind === role), input.mandatorySourceIndices, decorate)])) as
|
|
137
136
|
Record<Role, ReturnType<typeof createLane>>;
|
|
138
137
|
const seen: Record<Role, number> = { user: 0, assistant: 0, notice: 0 };
|
|
139
|
-
const order = dialogue.map(block => ({ role:
|
|
138
|
+
const order = dialogue.map(block => ({ role: block.kind, i: seen[block.kind]++ }));
|
|
140
139
|
const tools = input.blocks.length > dialogue.length ? rendered([TOOLS_OMITTED]) : undefined;
|
|
141
140
|
const overhead = TRANSCRIPT_HEADER.length + DIALOGUE_SUFFIX.length;
|
|
142
141
|
|
|
@@ -204,7 +203,7 @@ export const shrinkDialogue = (input: DialogueInput, limit: number): Shrunk => {
|
|
|
204
203
|
if (cost > 0 && cost <= limit / 3) { recent = { drop: assistant.n - 1, tier: t, end: assistant.n }; break; }
|
|
205
204
|
}
|
|
206
205
|
for (let d = 0; d <= recent.drop; d++)
|
|
207
|
-
if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d
|
|
206
|
+
if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d }, notice: clipped })) return hit;
|
|
208
207
|
// From here users give way, and the assistant keeps only that recent third.
|
|
209
208
|
for (const t of tiers) for (let u = 0; u <= user.n; u++)
|
|
210
209
|
if (hit = found(`C${t}`, { user: { drop: 0, tier: t, end: u }, assistant: recent, notice: clipped })) return hit;
|
|
@@ -213,6 +212,6 @@ export const shrinkDialogue = (input: DialogueInput, limit: number): Shrunk => {
|
|
|
213
212
|
for (let d = 0; d <= notice.n; d++)
|
|
214
213
|
if (hit = found("E1", { user: gone(user), assistant: recent, notice: { drop: d, tier: 1, end: notice.n } })) return hit;
|
|
215
214
|
for (let d = recent.drop; d <= assistant.n; d++)
|
|
216
|
-
if (hit = found("E2", { user: gone(user), assistant: {
|
|
215
|
+
if (hit = found("E2", { user: gone(user), assistant: { ...recent, drop: d }, notice: gone(notice) })) return hit;
|
|
217
216
|
return { text: "", step: "none", size: 0 };
|
|
218
217
|
};
|
|
@@ -20,12 +20,19 @@ export interface CompileInput {
|
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
// A cut offers the whole dialogue and then, when it does not fit, the shrunk one, from the same input: normalize it once. The blocks are
|
|
24
|
+
// never mutated, and keeping the same objects also keeps their cached passage analyses.
|
|
25
|
+
const inputs = new WeakMap<CompileInput, DialogueInput>();
|
|
26
|
+
const dialogueInput = (input: CompileInput): DialogueInput => {
|
|
27
|
+
let prepared = inputs.get(input);
|
|
28
|
+
if (!prepared) inputs.set(input, prepared = {
|
|
29
|
+
blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes)),
|
|
30
|
+
mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
|
|
31
|
+
sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
|
|
32
|
+
sourceEntryIds: input.sourceEntryIds,
|
|
33
|
+
});
|
|
34
|
+
return prepared;
|
|
35
|
+
};
|
|
29
36
|
|
|
30
37
|
/** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted.
|
|
31
38
|
* This disposable view grants neither present authority nor native tool execution. */
|
|
@@ -106,7 +106,11 @@ const LEGACY_RECEIPT_RANGE_NOTICE = RECEIPT_RANGE_NOTICE.replace("tuck_recall",
|
|
|
106
106
|
*/
|
|
107
107
|
const escapeText = (text: string): string => text.replace(/[\\\n\r]/g, c => c === "\\" ? "\\\\" : c === "\n" ? "\\n" : "\\r");
|
|
108
108
|
/** The length of `text` as a record line carries it: one character more for each "\", line feed and carriage return. */
|
|
109
|
-
export const escapedLength = (text: string): number =>
|
|
109
|
+
export const escapedLength = (text: string): number => {
|
|
110
|
+
let length = text.length;
|
|
111
|
+
for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if (c === 92 || c === 10 || c === 13) length++; }
|
|
112
|
+
return length;
|
|
113
|
+
};
|
|
110
114
|
const TITLE = /^[A-Z][A-Za-z0-9_-]*$/;
|
|
111
115
|
const refuse = (record: TranscriptRecord): never => { throw new Error(`Cannot write a ${record.kind} record as ${TRANSCRIPT_HEADER}`); };
|
|
112
116
|
|
package/src/tuck/types.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type NormalizedBlock = { sourceTimes?: Record<number, import("./core/sour
|
|
|
18
18
|
| { kind: "user"; text: string; originalText?: string; sourceIndex?: number; startsSource?: true }
|
|
19
19
|
| { kind: "assistant"; text: string; originalText?: string; sourceIndex?: number; startsSource?: true }
|
|
20
20
|
| { kind: "notice"; text: string; originalText?: string; sourceIndex?: number; sourceEntryId?: string; startsSource?: true }
|
|
21
|
-
|
|
22
|
-
| { kind: "
|
|
23
|
-
| { kind: "
|
|
21
|
+
// Tool history renders only as one omission notice, so its blocks keep what lookup-echo filtering reads.
|
|
22
|
+
| { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
|
|
23
|
+
| { kind: "tool_result"; name: string; sourceIndex?: number; recallExport?: true }
|
|
24
|
+
| { kind: "bash"; sourceIndex?: number });
|