pi-tuck 1.0.13 → 1.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +7 -7
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/tuck/core/normalize.js +3 -5
- package/dist/src/tuck/cut.js +8 -22
- package/dist/src/tuck.js +9 -13
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/tuck/core/normalize.ts +5 -7
- package/src/tuck/cut.ts +8 -23
- package/src/tuck.ts +9 -13
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [1.0.14] - 2026-11-07
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- Internal cleanup across the code, tests and benchmark scripts, with no change to any request.
|
|
11
|
+
- The build id in a cut's receipt now follows all the source a build ships.
|
|
12
|
+
|
|
13
|
+
### Release gates
|
|
14
|
+
|
|
15
|
+
- **Shipped without a live gate, because its replay matches 1.0.13:** 13,946 requests on 29 recordings, 0 differ, 0 dead tasks, the same 28 clips. A build that changes no request gives a live test nothing to measure. Tests; CI; the stress test on Pi 1.2.0 and the newest Pi, with the same 3 of 274 cells as 1.0.13.
|
|
16
|
+
|
|
6
17
|
## [1.0.13] - 2026-11-07
|
|
7
18
|
|
|
8
19
|
### Changed
|
package/README.md
CHANGED
|
@@ -64,8 +64,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
|
|
|
64
64
|
|
|
65
65
|
- Needs Pi 1.2.0 or later (tested on 1.2.0 and 1.4.0), on macOS or Linux. On an older Pi, pi-tuck stays off, Pi's own compaction runs, and pi-tuck says so once. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
|
|
66
66
|
- On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once). Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
|
|
67
|
-
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.
|
|
68
|
-
- To pin a version, add it: `pi install npm:pi-tuck@1.0.
|
|
67
|
+
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.13`.
|
|
68
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.13`.
|
|
69
69
|
|
|
70
70
|
## How it works
|
|
71
71
|
|
|
@@ -114,8 +114,8 @@ Known limits today:
|
|
|
114
114
|
- Small windows: an upstream that spends far more context on tool schemas than others (some OpenRouter upstreams do) can leave a small input room too little space. pi-tuck then refuses to cut rather than overflow. This made 1.0.1 refuse at a 32k window with Pi's default 16,384 reserve. Version 1.0.4 added a notice that says when the provider counts far above pi-tuck's estimate. In 1.0.4, after an oversize tool result or a "Nothing to compact" error from Pi, a session could keep refusing later prompts; 1.0.5 fixed both. Version 1.0.8 clips a fresh tool result that is too large for the room (its head and tail, with a `tuck_recall` locator to the rest) instead of refusing the run. If a small window still refuses, pin a different upstream, lower the reserve, or use a larger window; see [Small windows](#small-windows).
|
|
115
115
|
- Small window, after a stopped run (1.0.8 and 1.0.9 only; fixed in 1.0.10): Pi's default `compaction.keepRecentTokens` (20,000) can exceed pi-tuck's input room, which is the window less the larger of `compaction.reserveTokens` and the model's maximum output (16,384 at a 32k window with the default reserve). After a run that is stopped (Esc, or a time limit) with the context above the room, every later prompt could be refused with "The request needs a cut and Pi has none to make (Nothing to compact (session too small))", and the session stayed stuck. In one check at these defaults (pi-tuck 1.0.8, 32k window, one session of 30 tasks, one local model), 28 tasks were lost, tasks 3 to 30 ([`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md)). If you are on 1.0.8 or 1.0.9, upgrade to 1.0.10. In 1.0.10 the cut a prompt needs is pi-tuck's own, so `keepRecentTokens` no longer leaves it with nothing to cut. A rerun of the same check on the 1.0.10 candidate had no refusal and no stuck session. It had two caveats: two of its tasks had a first request the local server never answered, and 8 of its 30 tasks began without their prompt, which the refusal counter cannot see (fixed in 1.0.12; see Benchmarks and [`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md)). Windows of 128k and up were not affected at the defaults.
|
|
116
116
|
- A prompt could be lost, with no notice (1.0.10 and 1.0.11; fixed in 1.0.12). When a new prompt arrived over the cut point and pi-tuck's cut kept nothing of the conversation before it, which happens when everything since the last cut is one long turn, the prompt was dropped from the request and from the saved session. The model never saw it and usually went on with the previous task, so work you asked for could be lost, and could look answered. The next prompt ran normally. If you used 1.0.10 or 1.0.11 and a reply ignored what you had just asked, this is the likely cause. Upgrade to 1.0.12 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.12).
|
|
117
|
-
-
|
|
118
|
-
- Still applying
|
|
117
|
+
- The cut point adapts by itself since 1.0.13; before it, pi-tuck cut too often at small windows or with a large setup (1.0.11 and 1.0.12). The trade, from the changelog: on windows that cut too often, each request now carries more context. Cuts, re-processing and request time fall, and billed tokens rise on paid models. On replay of real sessions at an 81,920 window, cuts went from 22.3 to 9.3 per 100 requests, request time was about 16 to 20% lower, and billed tokens were 16 to 19% higher; at a 65,536 window billed tokens rose by as much as 35%. A very large setup at a small window, such as 40k at 65,536, still leaves too little free room for a good rhythm: cuts stay frequent and free little ([`CHANGELOG.md`](CHANGELOG.md) 1.0.13).
|
|
118
|
+
- Still applying (1.0.11 to 1.0.13): a fresh assistant message larger than the free room is refused, and a short next prompt recovers (in an unattended `pi -p` run, a refusal ends the run). A reply that reports more than the window reads to Pi as a silent overflow; when nothing can be cut, pi-tuck refuses the next prompt, and the prompt after that runs. If a provider keeps rejecting requests in a wording Pi does not class as a context overflow, or with a bare HTTP 413, pi-tuck does not recover on its own: the session stays intact, and every prompt can fail until the request shrinks. Try `/compact` ([`CHANGELOG.md`](CHANGELOG.md) 1.0.13).
|
|
119
119
|
- Other extensions (1.0.10): a prompt's cut no longer goes through Pi's `session_before_compact`, so another extension's veto there no longer stops it. It never stopped a cut between tool rounds. Pi's own threshold and overflow cuts, and `/compact`, still ask other extensions as before.
|
|
120
120
|
- Semantic search (`tuck_search`) needs a Mac with Metal (Apple silicon). Elsewhere, compaction and `tuck_recall`'s word search work, there is no `tuck_search`, and nothing is downloaded.
|
|
121
121
|
|
|
@@ -128,7 +128,7 @@ Each row says what was measured, under which conditions, and where the data is.
|
|
|
128
128
|
| Pi's default compaction settings, 32k window, pi-tuck 1.0.10 candidate | Passed as registered: 0 dead tasks and 0 stuck sessions on 30 tasks, on a slow local server (27 of 30 tasks hit the 30-minute wall cap, and 2 had a first request the server never answered). 6 resolved. A counter written later, which counts a task with no token as dead, would count those 2. **The registered counter counts refusals and cannot see a task that began without its prompt**: in 8 of the 30 tasks (4, 8, 9, 10, 13, 14, 17, 21) a cut at the prompt kept no turn, none of the 8 resolved, and 6 ended with an empty patch. So the pass shows no refusal, not that every task began with its prompt. The 1.0.11 candidate on the same test had 7 of 30 such tasks, 1 resolved. Fixed in 1.0.12 | Candidate `a67ec30` of 1.0.10, `mac-studio/main`, window 32,768, output cap 16,384, Pi's default `reserveTokens` 16,384 and `keepRecentTokens` 20,000, one session of chain t06. One mid-run refusal (a round with 9,020 tokens of reasoning against the 16,384-token room). One session per arm, one model. In 12 of the 15 tasks across both arms the model's own first messages show it lacked the prompt; the other 3 are not clear from the records | [`benchmark/RESULTS-DEFAULTS-1010.md`](benchmark/RESULTS-DEFAULTS-1010.md) |
|
|
129
129
|
| Pi's default compaction settings, 32k window, pi-tuck 1.0.8 | 28 of 30 tasks lost (tasks 3 to 30, each refused at its prompt); 1 resolved. Fails the pre-registered bar of 0 lost tasks | pi-tuck 1.0.8 (`86935e9`), `mac-studio/main`, window 32,768, output cap 16,384, `reserveTokens` 16,384 and `keepRecentTokens` 20,000 (larger than the room), one session of chain t06. One session, one model, one build: it shows that the failure occurs at these settings, not how often. Not comparable with other rows | [`benchmark/RESULTS-DEFAULTS.md`](benchmark/RESULTS-DEFAULTS.md) |
|
|
130
130
|
| Tasks resolved, 32k window | Pi's built-in compaction 94 of 150; pi-tuck 120 of 150 (+17.3 points, 95% lower bound +10.7); observation masking 117 of 150 (pi-tuck +2.0, lower bound −2.7: no difference shown). Turns per task: 9.4 (built-in), 24.0 (pi-tuck), 11.9 (masking). Tokens per session: 6.10 M, 6.39 M, 7.02 M. Spend per arm for the five sessions: $0.87, $0.55, $0.94 | pi-tuck 1.0.5 (commit `aa30824`, the 1.0.5 code; 1.0.6 and 1.0.7 are not measured). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, 5 chains of 30 SWE-bench Verified tasks, paired by task, one-sided bootstrap over tasks. **Limits:** only pi-tuck is held to the 16,384-token room (the built-in sent a request over it in 97 of 150 tasks, masking in 73, so the window is nominal for them). The difference by chain is +10.0, −3.3, +50.0, +16.7 and +13.3 points; leaving out chain t03 it is +9.2. The bootstrap resamples tasks, not chains. The pi-tuck arm ran at a different time from the others. One model, one window. pi-tuck refused to cut mid-run in 5 tasks, where one fresh tool result was larger than the room; they count as unresolved. In the same setup pi-tuck 1.0.3 resolved 72 of 150, with 52 dead tasks (tasks refused at their prompt after an earlier failure). In the pi-tuck arm of this row there was 1 dead task (t03 task 24: refused at its prompt with "Nothing to compact"; the next task ran normally), so this build missed the pre-registered acceptance bar of zero dead tasks by one task (the other part of the bar, no loss against 1.0.3 by more than 3 points, was met) | [`benchmark/RESULTS-32K-ACCEPT.md`](benchmark/RESULTS-32K-ACCEPT.md) |
|
|
131
|
-
| The adaptive
|
|
131
|
+
| The adaptive cut point (1.0.13) against 1.0.12, 81,920 window | Registered bar (Tier 2) met: resolved 131 of 150 in both arms (+0.0 points, one-sided 95% lower bound −1.3, against a bar of above −5); 0 lost tasks in the candidate arm. The 0 counts dead tasks, tasks whose prompt was dropped at a cut, and tasks an audit found started without their prompt; all 11 tasks with a cut at a prompt, in both arms, were also read by hand, and in each the model's first messages work on that task | Pre-registered gate. pi-tuck 1.0.13 (`6f13685`) is the exact code the gate measured (`882f6f6`); only the version and the notes changed. It was measured against pi-tuck 1.0.12, `mac-studio/main` at xhigh, window 81,920, reserve 16,384, `keepRecentTokens` 20,000, 5 chains (t06 to t10) of 30 tasks, 150 paired tasks, one run per arm, one model throughout. The resolved result is a bound, not a gain. Turns, tokens and seconds are in the file and are not claimed here. Nothing about other windows, models or builds | [`benchmark/RESULTS-ADAPTIVE.md`](benchmark/RESULTS-ADAPTIVE.md) |
|
|
132
132
|
| The 1.0.10 candidate against 1.0.9, 32k window | 0 dead tasks in both arms; no mid-run refusal. Resolved 50 of 60 (1.0.9) and 49 of 60 (1.0.10 candidate): −1.7 points, 95% lower bound −8.3. **The gate failed as registered, by one task**; 1.0.10 shipped as an exception because of the small-window defect. 6 of the 1.0.10 candidate's 60 tasks began without their prompt (4 of them resolved), against 0 in the 1.0.9 arm; fixed in 1.0.12. Turns per task 25.08 and 29.12; cuts 731 and 884; total tokens 13.07 M and 15.12 M | Pre-registered gate. pi-tuck `a67ec30` against 1.0.9 (`cd4626b`). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, `keepRecentTokens` 3,333, chains t01 and t02, 60 paired tasks. One model, one window, one run per arm; 60 tasks cannot separate a one-task difference from run-to-run variation | [`benchmark/RESULTS-V110.md`](benchmark/RESULTS-V110.md) |
|
|
133
133
|
| The 1.0.8 candidate against 1.0.7, 32k window | 0 dead tasks in both arms. Resolved 46 of 60 in both (+0.0 points, 95% lower bound −6.7). No mid-run refusal in either arm. Turns per task 25.37 (1.0.7) and 26.10 (1.0.8) | Pre-registered gate, passed as registered. pi-tuck `a2e7d1a` (the tool-result clip and the cut at the end of a run, on 1.0.6) against 1.0.7. Released 1.0.8 is that build merged with 1.0.7's Metal-only recall; its replay requests are reported byte-identical to the gated build on 29 recordings (stated in the source file, not re-run for this README). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, chains t01 and t02, 60 paired tasks, both arms run together. One model, one window, one run per arm; 60 tasks cannot show a difference smaller than the bound. The run shows that the change introduced no refusal; no refusal occurred in the 1.0.7 arm for the clip to prevent | [`benchmark/RESULTS-V108.md`](benchmark/RESULTS-V108.md) |
|
|
134
134
|
| Cut time, live, 128k | pi-tuck median 0.081 s (46 cuts; 90th percentile 0.279 s). Pi's compaction median 235.3 s (48 cuts; 90th percentile 403.8 s). Context carried after a cut, median: 26,790 tokens (pi-tuck), 27,750 (Pi's compaction) | pi-tuck 1.0.1, Pi 1.4.0, SWE-bench Verified, 10 chains of 30 tasks, window 128,000, reply reserve 32,768, `mac-studio/main:xhigh` writing Pi's summaries. The built-in time depends on that model and on server load. pi-tuck's time is an upper bound, read from the session file: the time between the entry written just before the cut and the compaction entry, so it includes anything between the last message and the cut (pi-tuck's tool-loop cuts emit no compaction events). The 44 failed compaction attempts in chain s09-A have no compaction entry and are not in the counts | [`benchmark/RESULTS-MAIN.md`](benchmark/RESULTS-MAIN.md) test 3 |
|
|
@@ -154,10 +154,10 @@ Notes:
|
|
|
154
154
|
pi-tuck needs none. Two are optional. Put them in `pi-tuck.json` in Pi's agent folder (`~/.pi/agent`, or the folder `PI_CODING_AGENT_DIR` names):
|
|
155
155
|
|
|
156
156
|
```json
|
|
157
|
-
{ "cutAt": 0.
|
|
157
|
+
{ "cutAt": 0.8, "semanticRecall": true }
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
- **`cutAt`** (above 0, at most 1; default
|
|
160
|
+
- **`cutAt`** (above 0, at most 1; not set by default) is the part of the input room a request may fill before pi-tuck cuts. With no `cutAt`, the cut point adapts to the session since 1.0.13: it follows what the setup and the last cut left, plus half a tail of room to grow, never below 60% of the room and capped just under the room. Setting `cutAt` fixes it as a fraction of the room, and an explicit value keeps the earlier formula, so remove one you set by hand to stop frequent cuts. `{ "cutAt": 1 }` cuts at the full room. A lower value cuts earlier and smaller. The whole room still decides which request pi-tuck refuses.
|
|
161
161
|
- **`semanticRecall`** (`false` turns semantic search off, on Apple silicon). Nothing is then downloaded, and `tuck_search` uses keywords only.
|
|
162
162
|
|
|
163
163
|
A bad file is reported at session start, and pi-tuck uses the defaults.
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "9457180f73cad9d9bde254328dad70dd2abe8400e373a68e06ad455b5b66f203";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "9457180f73cad9d9bde254328dad70dd2abe8400e373a68e06ad455b5b66f203";
|
|
@@ -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 = "9457180f73cad9d9bde254328dad70dd2abe8400e373a68e06ad455b5b66f203";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
|
@@ -65,7 +65,6 @@ export const normalize = (messages, sourceIndices, receiptContext, sourceEntryId
|
|
|
65
65
|
if (sourceTimes && sourceTimes.length !== messages.length)
|
|
66
66
|
throw new Error("Original times must align with compiler messages");
|
|
67
67
|
const timeByIndex = Object.fromEntries((sourceIndices ?? messages.map((_, i) => i)).flatMap((index, i) => index === undefined || !sourceTimes ? [] : [[index, sourceTimes[i]]]));
|
|
68
|
-
const context = receiptContext ?? { messages, sourceIndices: sourceIndices ?? messages.map((_, i) => i) };
|
|
69
68
|
const pairs = new Map();
|
|
70
69
|
const group = (id) => {
|
|
71
70
|
if (typeof id !== "string" || !id)
|
|
@@ -77,17 +76,16 @@ export const normalize = (messages, sourceIndices, receiptContext, sourceEntryId
|
|
|
77
76
|
}
|
|
78
77
|
return pair;
|
|
79
78
|
};
|
|
80
|
-
|
|
79
|
+
(receiptContext?.messages ?? messages).forEach((msg, order) => {
|
|
81
80
|
if (msg.role === "assistant" && Array.isArray(msg.content))
|
|
82
81
|
for (const part of msg.content) {
|
|
83
82
|
if (part.type === "toolCall")
|
|
84
|
-
group(part.id).calls.push({ name: part.name, order,
|
|
83
|
+
group(part.id).calls.push({ name: part.name, order, args: part.arguments });
|
|
85
84
|
}
|
|
86
85
|
if (msg.role === "toolResult") {
|
|
87
86
|
if (typeof msg.isError !== "boolean")
|
|
88
87
|
throw new Error("Tool receipt lacks a recorded error status");
|
|
89
|
-
group(msg.toolCallId).receipts.push({ name: msg.toolName,
|
|
90
|
-
order, index: context.sourceIndices[order], isError: msg.isError, content: msg.content });
|
|
88
|
+
group(msg.toolCallId).receipts.push({ name: msg.toolName, order });
|
|
91
89
|
}
|
|
92
90
|
});
|
|
93
91
|
// A call paired with exactly one later receipt of the same tool has a recorded outcome. An export through recall keeps its outcome block.
|
package/dist/src/tuck/cut.js
CHANGED
|
@@ -34,30 +34,16 @@ const toLiveMessage = (entry) => {
|
|
|
34
34
|
return null;
|
|
35
35
|
};
|
|
36
36
|
const collectLiveMessages = (branchEntries) => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
lastKeptId = branchEntries[i].firstKeptEntryId;
|
|
44
|
-
break;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
// Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
|
|
48
|
-
// compact-all) OR set to an id that no longer exists in the branch. In both cases,
|
|
49
|
-
// start collecting from right after the last compaction entry.
|
|
50
|
-
const hasPriorCompaction = lastCompactionIdx >= 0;
|
|
51
|
-
const hasValidKeptId = !!lastKeptId && branchEntries.some((e) => e.id === lastKeptId);
|
|
52
|
-
const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
|
|
53
|
-
// Collect live messages
|
|
37
|
+
const lastCompactionIdx = branchEntries.findLastIndex(e => e.type === "compaction");
|
|
38
|
+
const lastKeptId = branchEntries[lastCompactionIdx]?.firstKeptEntryId;
|
|
39
|
+
const kept = lastKeptId ? branchEntries.findIndex(e => e.id === lastKeptId) : -1;
|
|
40
|
+
// An empty compact-all sentinel or a missing anchor resumes after the last
|
|
41
|
+
// compaction. With no prior compaction, collection starts at the beginning.
|
|
42
|
+
const start = kept >= 0 ? kept : lastCompactionIdx + 1;
|
|
54
43
|
const liveMessages = [];
|
|
55
|
-
let
|
|
56
|
-
for (let i = orphanRecovery ? lastCompactionIdx + 1 : 0; i < branchEntries.length; i++) {
|
|
44
|
+
for (let i = start; i < branchEntries.length; i++) {
|
|
57
45
|
const e = branchEntries[i];
|
|
58
|
-
if (
|
|
59
|
-
foundKept = true;
|
|
60
|
-
if (!foundKept || e.type === "compaction")
|
|
46
|
+
if (e.type === "compaction")
|
|
61
47
|
continue;
|
|
62
48
|
const m = toLiveMessage(e);
|
|
63
49
|
if (m)
|
package/dist/src/tuck.js
CHANGED
|
@@ -71,6 +71,11 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
71
71
|
cut = { ok: true, messages: [], selectedIds: [], firstKeptEntryId: "", compactAll: true, keptUserTurns: 0, keepFallbackToCompactAll: false };
|
|
72
72
|
if (!cut.ok)
|
|
73
73
|
return { unavailable: `pi-tuck cannot select a cut: ${cut.reason}` };
|
|
74
|
+
const cutBefore = (cut, boundary, prefix) => ({
|
|
75
|
+
...cut, messages: prefix.messages, selectedIds: prefix.selectedIds,
|
|
76
|
+
firstKeptEntryId: entries[boundary]?.id ?? "", compactAll: boundary === entries.length, keepFallbackToCompactAll: false,
|
|
77
|
+
keptUserTurns: entries.slice(boundary).filter(entry => entry.type === "message" && entry.message.role === "user").length,
|
|
78
|
+
});
|
|
74
79
|
// The latest call's trailing receipts have not reached a subsequent response.
|
|
75
80
|
// A preferred tail size must not erase this first exposure when the whole
|
|
76
81
|
// fresh round fits. Existing full-draft admission rejects an impossible round.
|
|
@@ -93,9 +98,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
93
98
|
const prefix = tuck.buildOwnCut(entries.slice(0, lastResponse), 0);
|
|
94
99
|
if (!prefix.ok)
|
|
95
100
|
return { unavailable: "No reducible history before the fresh tool round" };
|
|
96
|
-
cut =
|
|
97
|
-
firstKeptEntryId: response.id, compactAll: false, keepFallbackToCompactAll: false,
|
|
98
|
-
keptUserTurns: entries.slice(lastResponse).filter(entry => entry.type === "message" && entry.message.role === "user").length };
|
|
101
|
+
cut = cutBefore(cut, lastResponse, prefix);
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
// Direct shell output after the last response has not reached the model.
|
|
@@ -108,9 +111,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
108
111
|
const prefix = tuck.buildOwnCut(entries.slice(0, freshBash), 0);
|
|
109
112
|
if (!prefix.ok)
|
|
110
113
|
return { unavailable: "No reducible history before the fresh shell output" };
|
|
111
|
-
cut =
|
|
112
|
-
firstKeptEntryId: entries[freshBash].id, compactAll: false, keepFallbackToCompactAll: false,
|
|
113
|
-
keptUserTurns: entries.slice(freshBash).filter(entry => entry.type === "message" && entry.message.role === "user").length };
|
|
114
|
+
cut = cutBefore(cut, freshBash, prefix);
|
|
114
115
|
}
|
|
115
116
|
// Preserve the actual latest user source, never an inferred task/decision.
|
|
116
117
|
// Explicit omission of it must not promote an earlier user source instead.
|
|
@@ -131,9 +132,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
131
132
|
const prefix = tuck.buildOwnCut(entries.slice(0, userPosition), 0);
|
|
132
133
|
if (!prefix.ok)
|
|
133
134
|
return { unavailable: "No reducible history before the current user input" };
|
|
134
|
-
cut =
|
|
135
|
-
firstKeptEntryId: current.id, compactAll: false, keepFallbackToCompactAll: false,
|
|
136
|
-
keptUserTurns: entries.slice(userPosition).filter(entry => entry.type === "message" && entry.message.role === "user").length };
|
|
135
|
+
cut = cutBefore(cut, userPosition, prefix);
|
|
137
136
|
}
|
|
138
137
|
if (cut.messages.length !== cut.selectedIds.length || (!cut.compactAll && !entries.some((e) => e.id === cut.firstKeptEntryId)))
|
|
139
138
|
throw new Error("pi-tuck returned an invalid source boundary");
|
|
@@ -272,10 +271,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
272
271
|
const prefix = tuck.buildOwnCut(entries.slice(0, floorBoundary), 0);
|
|
273
272
|
if (!prefix.ok)
|
|
274
273
|
return preferred;
|
|
275
|
-
const retried = compileAtCut({ ...cut,
|
|
276
|
-
firstKeptEntryId: entries[floorBoundary]?.id ?? "", compactAll: floorBoundary === entries.length,
|
|
277
|
-
keepFallbackToCompactAll: false,
|
|
278
|
-
keptUserTurns: entries.slice(floorBoundary).filter(entry => entry.type === "message" && entry.message.role === "user").length });
|
|
274
|
+
const retried = compileAtCut({ ...cutBefore(cut, floorBoundary, prefix), budgetCut: undefined });
|
|
279
275
|
return preferred.compaction && retried.mode !== "dialogue" ? preferred : retried;
|
|
280
276
|
}
|
|
281
277
|
/** Numeric references use the original session index, then intersect with readable current-branch entries. */
|
package/package.json
CHANGED
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "9457180f73cad9d9bde254328dad70dd2abe8400e373a68e06ad455b5b66f203";
|
|
@@ -71,23 +71,21 @@ export const normalize = (
|
|
|
71
71
|
if (sourceEntryIds && sourceEntryIds.length !== messages.length) throw new Error("Original entry IDs must align with compiler messages");
|
|
72
72
|
if (sourceTimes && sourceTimes.length !== messages.length) throw new Error("Original times must align with compiler messages");
|
|
73
73
|
const timeByIndex = Object.fromEntries((sourceIndices ?? messages.map((_, i) => i)).flatMap((index, i) => index === undefined || !sourceTimes ? [] : [[index, sourceTimes[i]]]));
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
receipts: { name: string; order: number; index: number | undefined; isError: boolean; content: Extract<CompilerMessage, { role: "toolResult" }>["content"] }[] }>();
|
|
74
|
+
const pairs = new Map<string, { calls: { name: string; order: number; args: Record<string, unknown> }[];
|
|
75
|
+
receipts: { name: string; order: number }[] }>();
|
|
77
76
|
const group = (id: string) => {
|
|
78
77
|
if (typeof id !== "string" || !id) throw new Error("Tool history lacks a recorded call identity");
|
|
79
78
|
let pair = pairs.get(id);
|
|
80
79
|
if (!pair) { pair = { calls: [], receipts: [] }; pairs.set(id, pair); }
|
|
81
80
|
return pair;
|
|
82
81
|
};
|
|
83
|
-
|
|
82
|
+
(receiptContext?.messages ?? messages).forEach((msg, order) => {
|
|
84
83
|
if (msg.role === "assistant" && Array.isArray(msg.content)) for (const part of msg.content) {
|
|
85
|
-
if (part.type === "toolCall") group(part.id).calls.push({ name: part.name, order,
|
|
84
|
+
if (part.type === "toolCall") group(part.id).calls.push({ name: part.name, order, args: part.arguments });
|
|
86
85
|
}
|
|
87
86
|
if (msg.role === "toolResult") {
|
|
88
87
|
if (typeof msg.isError !== "boolean") throw new Error("Tool receipt lacks a recorded error status");
|
|
89
|
-
group(msg.toolCallId).receipts.push({ name: msg.toolName,
|
|
90
|
-
order, index: context.sourceIndices[order], isError: msg.isError, content: msg.content });
|
|
88
|
+
group(msg.toolCallId).receipts.push({ name: msg.toolName, order });
|
|
91
89
|
}
|
|
92
90
|
});
|
|
93
91
|
// A call paired with exactly one later receipt of the same tool has a recorded outcome. An export through recall keeps its outcome block.
|
package/src/tuck/cut.ts
CHANGED
|
@@ -55,31 +55,16 @@ export type OwnCutResult =
|
|
|
55
55
|
| { ok: false; reason: OwnCutCancelReason };
|
|
56
56
|
|
|
57
57
|
const collectLiveMessages = (branchEntries: any[]): EntryWithMessage[] => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
lastKeptId = branchEntries[i].firstKeptEntryId;
|
|
65
|
-
break;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
|
|
70
|
-
// compact-all) OR set to an id that no longer exists in the branch. In both cases,
|
|
71
|
-
// start collecting from right after the last compaction entry.
|
|
72
|
-
const hasPriorCompaction = lastCompactionIdx >= 0;
|
|
73
|
-
const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
|
|
74
|
-
const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
|
|
75
|
-
|
|
76
|
-
// Collect live messages
|
|
58
|
+
const lastCompactionIdx = branchEntries.findLastIndex(e => e.type === "compaction");
|
|
59
|
+
const lastKeptId = branchEntries[lastCompactionIdx]?.firstKeptEntryId;
|
|
60
|
+
const kept = lastKeptId ? branchEntries.findIndex(e => e.id === lastKeptId) : -1;
|
|
61
|
+
// An empty compact-all sentinel or a missing anchor resumes after the last
|
|
62
|
+
// compaction. With no prior compaction, collection starts at the beginning.
|
|
63
|
+
const start = kept >= 0 ? kept : lastCompactionIdx + 1;
|
|
77
64
|
const liveMessages: EntryWithMessage[] = [];
|
|
78
|
-
let
|
|
79
|
-
for (let i = orphanRecovery ? lastCompactionIdx + 1 : 0; i < branchEntries.length; i++) {
|
|
65
|
+
for (let i = start; i < branchEntries.length; i++) {
|
|
80
66
|
const e = branchEntries[i];
|
|
81
|
-
if (
|
|
82
|
-
if (!foundKept || e.type === "compaction") continue;
|
|
67
|
+
if (e.type === "compaction") continue;
|
|
83
68
|
const m = toLiveMessage(e);
|
|
84
69
|
if (m) liveMessages.push({ entry: e, message: m });
|
|
85
70
|
}
|
package/src/tuck.ts
CHANGED
|
@@ -101,6 +101,11 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
101
101
|
else if (input.reRenderSummary && cut.reason === "no_live_messages" && entries.some(entry => entry.type === "compaction"))
|
|
102
102
|
cut = { ok: true, messages: [], selectedIds: [], firstKeptEntryId: "", compactAll: true, keptUserTurns: 0, keepFallbackToCompactAll: false };
|
|
103
103
|
if (!cut.ok) return { unavailable: `pi-tuck cannot select a cut: ${cut.reason}` };
|
|
104
|
+
const cutBefore = (cut: Extract<Cut, { ok: true }>, boundary: number, prefix: Extract<Cut, { ok: true }>): Extract<Cut, { ok: true }> => ({
|
|
105
|
+
...cut, messages: prefix.messages, selectedIds: prefix.selectedIds,
|
|
106
|
+
firstKeptEntryId: entries[boundary]?.id ?? "", compactAll: boundary === entries.length, keepFallbackToCompactAll: false,
|
|
107
|
+
keptUserTurns: entries.slice(boundary).filter(entry => entry.type === "message" && entry.message.role === "user").length,
|
|
108
|
+
});
|
|
104
109
|
// The latest call's trailing receipts have not reached a subsequent response.
|
|
105
110
|
// A preferred tail size must not erase this first exposure when the whole
|
|
106
111
|
// fresh round fits. Existing full-draft admission rejects an impossible round.
|
|
@@ -122,9 +127,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
122
127
|
if (freshRound && lastResponse < chosenBoundary) {
|
|
123
128
|
const prefix = tuck.buildOwnCut(entries.slice(0, lastResponse), 0);
|
|
124
129
|
if (!prefix.ok) return { unavailable: "No reducible history before the fresh tool round" };
|
|
125
|
-
cut =
|
|
126
|
-
firstKeptEntryId: response.id, compactAll: false, keepFallbackToCompactAll: false,
|
|
127
|
-
keptUserTurns: entries.slice(lastResponse).filter(entry => entry.type === "message" && entry.message.role === "user").length };
|
|
130
|
+
cut = cutBefore(cut, lastResponse, prefix);
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
// Direct shell output after the last response has not reached the model.
|
|
@@ -136,9 +139,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
136
139
|
if (freshBash >= 0 && freshBash < shellBoundary) {
|
|
137
140
|
const prefix = tuck.buildOwnCut(entries.slice(0, freshBash), 0);
|
|
138
141
|
if (!prefix.ok) return { unavailable: "No reducible history before the fresh shell output" };
|
|
139
|
-
cut =
|
|
140
|
-
firstKeptEntryId: entries[freshBash]!.id, compactAll: false, keepFallbackToCompactAll: false,
|
|
141
|
-
keptUserTurns: entries.slice(freshBash).filter(entry => entry.type === "message" && entry.message.role === "user").length };
|
|
142
|
+
cut = cutBefore(cut, freshBash, prefix);
|
|
142
143
|
}
|
|
143
144
|
// Preserve the actual latest user source, never an inferred task/decision.
|
|
144
145
|
// Explicit omission of it must not promote an earlier user source instead.
|
|
@@ -158,9 +159,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
158
159
|
if (userPosition >= 0 && userPosition < selectedBoundary && (!textOnly || userPosition > lastResponse)) {
|
|
159
160
|
const prefix = tuck.buildOwnCut(entries.slice(0, userPosition), 0);
|
|
160
161
|
if (!prefix.ok) return { unavailable: "No reducible history before the current user input" };
|
|
161
|
-
cut =
|
|
162
|
-
firstKeptEntryId: current!.id, compactAll: false, keepFallbackToCompactAll: false,
|
|
163
|
-
keptUserTurns: entries.slice(userPosition).filter(entry => entry.type === "message" && entry.message.role === "user").length };
|
|
162
|
+
cut = cutBefore(cut, userPosition, prefix);
|
|
164
163
|
}
|
|
165
164
|
|
|
166
165
|
if (cut.messages.length !== cut.selectedIds.length || (!cut.compactAll && !entries.some((e) => e.id === cut.firstKeptEntryId)))
|
|
@@ -282,10 +281,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
282
281
|
if (floorBoundary <= preferredBoundary) return preferred;
|
|
283
282
|
const prefix = tuck.buildOwnCut(entries.slice(0, floorBoundary), 0);
|
|
284
283
|
if (!prefix.ok) return preferred;
|
|
285
|
-
const retried = compileAtCut({ ...cut,
|
|
286
|
-
firstKeptEntryId: entries[floorBoundary]?.id ?? "", compactAll: floorBoundary === entries.length,
|
|
287
|
-
keepFallbackToCompactAll: false,
|
|
288
|
-
keptUserTurns: entries.slice(floorBoundary).filter(entry => entry.type === "message" && entry.message.role === "user").length });
|
|
284
|
+
const retried = compileAtCut({ ...cutBefore(cut, floorBoundary, prefix), budgetCut: undefined });
|
|
289
285
|
return preferred.compaction && retried.mode !== "dialogue" ? preferred : retried;
|
|
290
286
|
|
|
291
287
|
}
|