pi-tuck 1.0.16 → 1.0.18
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 +75 -0
- package/README.md +7 -4
- package/dist/src/budget.d.ts +14 -17
- package/dist/src/budget.js +22 -29
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.js +113 -20
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/history-tools.d.ts +16 -0
- package/dist/src/history-tools.js +59 -0
- package/dist/src/ledger.d.ts +0 -2
- package/dist/src/ledger.js +0 -2
- package/dist/src/omissions.d.ts +1 -1
- package/dist/src/omissions.js +88 -6
- package/dist/src/tuck/core/brief.d.ts +6 -3
- package/dist/src/tuck/core/brief.js +27 -9
- package/dist/src/tuck/core/filter-noise.d.ts +1 -1
- package/dist/src/tuck/core/filter-noise.js +1 -3
- package/dist/src/tuck/core/normalize.d.ts +2 -5
- package/dist/src/tuck/core/normalize.js +15 -33
- package/dist/src/tuck/core/shrink.d.ts +6 -14
- package/dist/src/tuck/core/shrink.js +162 -102
- package/dist/src/tuck/core/summarize.d.ts +3 -14
- package/dist/src/tuck/core/summarize.js +9 -20
- package/dist/src/tuck/core/transcript.d.ts +5 -0
- package/dist/src/tuck/core/transcript.js +27 -4
- package/dist/src/tuck/types.d.ts +1 -1
- package/dist/src/tuck.d.ts +9 -17
- package/dist/src/tuck.js +384 -212
- package/package.json +1 -1
- package/src/budget.ts +24 -26
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +102 -21
- package/src/history-tools.ts +57 -0
- package/src/ledger.ts +0 -2
- package/src/omissions.ts +64 -7
- package/src/tuck/core/brief.ts +25 -10
- package/src/tuck/core/filter-noise.ts +1 -3
- package/src/tuck/core/normalize.ts +11 -27
- package/src/tuck/core/shrink.ts +141 -91
- package/src/tuck/core/summarize.ts +9 -24
- package/src/tuck/core/transcript.ts +26 -4
- package/src/tuck/types.ts +3 -3
- package/src/tuck.ts +336 -185
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,81 @@
|
|
|
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.18] - 2026-11-08
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **A summary could drop the task a session was working on (1.0.13 to 1.0.17).** Separate rules decided what a summary quoted and what the kept tail
|
|
11
|
+
held. In a session with many turns, the prompt that set the current task, or the first prompt, could lose its opening after a cut. The settings stress
|
|
12
|
+
test found it 40 times in 32 of its cells on 1.0.16 and 1.0.17. A cut now fills its room by one ranked list, across the summary and the kept tail
|
|
13
|
+
together:
|
|
14
|
+
1. the current prompt, whole;
|
|
15
|
+
2. the openings of the first prompt and the newest answered prompts, sized to the room;
|
|
16
|
+
3. the newest part of the conversation, kept as it is;
|
|
17
|
+
4. the file and action lists;
|
|
18
|
+
5. the rest of the conversation.
|
|
19
|
+
|
|
20
|
+
The stress test now finds no lost opening in 287 cells. Dropped turns and tool results are counted with index ranges you can read back with
|
|
21
|
+
`tuck_recall`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **One number sets how much a cut carries, and the decision to cut reads only amounts.** The quote budget and its fallback steps are gone, and so are
|
|
26
|
+
the rules that compared two versions of a summary. The kept tail is at most half of the 25,000-token tail cap, the size the cut point already assumed.
|
|
27
|
+
- **pi-tuck's tools appear only after its first cut.** Before that, there is nothing out of view to recall. Until then a request is byte-identical to one
|
|
28
|
+
without pi-tuck: 254 of 254 requests on replay. Every cut plans room for the tools the requests after it carry.
|
|
29
|
+
- **A prompt's late cut is planned when the prompt joins the session,** after other extensions have changed the system prompt and before Pi fixes the
|
|
30
|
+
request's tools. It replaces 1.0.17's reduction in the final guard and keeps its effect: a host section added in any load order is cut, not refused.
|
|
31
|
+
- **Cuts compute faster:** about half the time of 1.0.17 on the regression sessions, and 76 ms against 159 ms at 3,888 entries.
|
|
32
|
+
- An answered prompt with an image keeps its text opening, and a current prompt of only spaces is kept.
|
|
33
|
+
|
|
34
|
+
### Correction to 1.0.16
|
|
35
|
+
|
|
36
|
+
- 1.0.16's notes said its cut rate and cost were unchanged. That held on replay of single-task sessions, but not in live 30-task chains at 81,920
|
|
37
|
+
against 1.0.13. There it took 1.48 times the turns (95% interval 1.34 to 1.64), 74% more cuts and 1.66 times the tokens, with resolved 56 against 55
|
|
38
|
+
([benchmark/RESULTS-V1016.md](benchmark/RESULTS-V1016.md)). Most of the extra cuts follow from the extra turns. Replay cannot show this cost:
|
|
39
|
+
replay puts 1.0.13 and 1.0.17 within 0.1% of each other.
|
|
40
|
+
|
|
41
|
+
### Known issues
|
|
42
|
+
|
|
43
|
+
- On replay of 17 recordings, estimated request time against 1.0.13 is 2.2% higher quiet and 4.2% loaded at 81,920, and 1.1% and 1.9% lower at
|
|
44
|
+
131,072. Billed input moves the same way. At 81,920 each cut carries about 600 more tokens.
|
|
45
|
+
- Two stress findings from earlier versions remain: a cut that frees little with a 40,000-token setup at 65,536, and a fresh assistant reply larger than
|
|
46
|
+
the free room.
|
|
47
|
+
- Sessions that are open keep the build they started with until they are restarted.
|
|
48
|
+
|
|
49
|
+
### Release gates
|
|
50
|
+
|
|
51
|
+
- **Shipped before its live gate, as a fix for lost work.** The gate runs right after release; if it fails, npm and this version are rolled back to
|
|
52
|
+
1.0.17.
|
|
53
|
+
- Replay on 29 recordings: 0 lost tasks, 0 dead tasks, 0 faults, every clip read back.
|
|
54
|
+
- Tests, CI, the settings stress test (287 cells, no cut over its target), and the shape checks (21 cells at 32,768, 81,920 and 131,072).
|
|
55
|
+
|
|
56
|
+
## [1.0.17] - 2026-11-08
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **A host that patches a large section into the system prompt could get a refusal a cut would fit (1.0.0 to 1.0.16).** pi-tuck plans a prompt's cut when
|
|
61
|
+
the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn, as an
|
|
62
|
+
orchestrator does for its stand-off, could push that turn's first request over the room, and the final guard refused it. The guard now reduces first:
|
|
63
|
+
when the run's first request is over the room and no cut is planned, it plans the prompt's cut there, against the system prompt as finally sent, and
|
|
64
|
+
refuses only if that cut does not fit either. It works whatever order the extensions load in.
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- Stress cells for a host that patches an 8k then 24k system section, at 65,536 and 81,920, registered before and after pi-tuck, with the section arriving
|
|
69
|
+
on a stand-off or on the first step turn after one and removed at the next stand-off. 1.0.16 refuses in the four cells at 81,920; 1.0.17 in none.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- When queued input alone exceeds the room, the refusal names it as incoming input that cannot fit, instead of complete context. It still fails closed
|
|
74
|
+
with no cut.
|
|
75
|
+
|
|
76
|
+
### Release gates
|
|
77
|
+
|
|
78
|
+
- Replay matches 1.0.16: 0 of 13,946 requests differ on 29 recordings, 0 lost or dead tasks. Shipped without a live gate as a replay-matching patch.
|
|
79
|
+
Tests; CI; the stress test, with no cell worse than 1.0.16.
|
|
80
|
+
|
|
6
81
|
## [1.0.16] - 2026-11-08
|
|
7
82
|
|
|
8
83
|
### Fixed
|
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.17`.
|
|
68
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.17`.
|
|
69
69
|
|
|
70
70
|
## How it works
|
|
71
71
|
|
|
@@ -74,7 +74,7 @@ When context fills up, pi-tuck replaces the older turns with one summary. The su
|
|
|
74
74
|
- **The dialogue.** What you asked and what the agent answered, in order, word for word. If it is too long for the room, it shrinks: the oldest assistant text is clipped first, then older prompts. Clipped text says how to read it in full.
|
|
75
75
|
- **The files.** Files the agent read, wrote or edited, newest first (up to 60), each with a pointer to read it back.
|
|
76
76
|
- **The action trail.** The agent's other tool calls, such as shell commands, newest first (up to 30). The output is left out. A pointer leads to it.
|
|
77
|
-
- **Your first prompt
|
|
77
|
+
- **Your prompts, quoted from both ends** (1.0.16 and later): first the opening of every recent answered turn, then the first prompt's opening (up to 2,000 characters), then more of the newest turns, then the rest of the first prompt. A turn that does not fit keeps its opening and a locator to the rest. The budget is 15% of the room and never less than 12,000 characters. When room is tight, the quotes shrink step by step: the extensions first, then the openings, then the first prompt alone. Before 1.0.16 a summary quoted only the first prompt (up to 12,000 characters) and the newest turn; see Known limits.
|
|
78
78
|
|
|
79
79
|
Tool output stays in the session file, not in the summary. Each summary ends with a short instruction to the agent: re-read a file before you edit it, look things up, and treat anything not shown as unknown.
|
|
80
80
|
|
|
@@ -114,8 +114,11 @@ 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
|
+
- A host that patches a large section into the system prompt could get a refusal that a cut would have fit (1.0.0 to 1.0.16; fixed in 1.0.17). pi-tuck plans a prompt's cut when the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn could push that turn's first request over the room, and the final guard refused it. Now the guard plans the cut against the system prompt as finally sent, and refuses only if that cut does not fit either ([`CHANGELOG.md`](CHANGELOG.md) 1.0.17).
|
|
118
|
+
- A long session could lose the task it was working on (1.0.0 to 1.0.15; fixed in 1.0.16). A summary quoted only the session's first user turn, up to 12,000 characters, and the newest one. Every other user turn competed in the shrunk dialogue, which in a long session clips user turns to a few words and drops the oldest. In a session that runs task after task, the current task is an older turn followed by short steps, so it was clipped or dropped while a finished first task stayed whole, and agents acted on a days-old task. Short sessions, and the SWE-bench chains behind our benchmarks, keep each task's prompt in the kept tail and never hit it. Upgrade to 1.0.16 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.16).
|
|
119
|
+
- At very small free room (a few thousand tokens or less), the quotes give way and an older prompt can still lose its opening, in 1.0.16: 40 findings in 32 stress cells, against 447 in 168 cells on 1.0.15 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.16).
|
|
117
120
|
- 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.
|
|
121
|
+
- Still applying (1.0.11 to 1.0.16): a fresh assistant message larger than the free room is refused, and a short next prompt recovers (in an unattended `pi -p` run, a refusal ends the run). A reply that reports more than the window reads to Pi as a silent overflow; when nothing can be cut, pi-tuck refuses the next prompt, and the prompt after that runs. If a provider keeps rejecting requests in a wording Pi does not class as a context overflow, or with a bare HTTP 413, pi-tuck does not recover on its own: the session stays intact, and every prompt can fail until the request shrinks. Try `/compact` ([`CHANGELOG.md`](CHANGELOG.md) 1.0.13).
|
|
119
122
|
- 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
123
|
- 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
124
|
|
package/dist/src/budget.d.ts
CHANGED
|
@@ -25,11 +25,19 @@ export declare const validCutAt: (value: unknown) => value is number;
|
|
|
25
25
|
/** The initial default cut point: 60% of the room before a usable previous cut. Measured against 1 with paired replays (no answer loss) and billed
|
|
26
26
|
* input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
|
|
27
27
|
export declare const DEFAULT_CUT_AT = 0.6;
|
|
28
|
+
/** Preferred native carry, in the planner's estimate: never more than the
|
|
29
|
+
* keep-recent cut offers or the tail amount the adaptive band assumes. */
|
|
30
|
+
export declare const carriedTailTokens: (offeredTokens: number) => number;
|
|
28
31
|
export declare const bandOf: (room: number, cutAt?: number, adaptive?: {
|
|
29
32
|
system: number;
|
|
30
33
|
tokensAfter: number;
|
|
31
34
|
factor: number;
|
|
32
35
|
}) => number;
|
|
36
|
+
/** The cut's one carried-context grant, in the planner's estimate. Combines
|
|
37
|
+
* the existing native/fixed base with the quarter-free historical amount;
|
|
38
|
+
* neither component remains an independent spending limit in the fill.
|
|
39
|
+
* The grant depends only on quantities. */
|
|
40
|
+
export declare function carriedTarget(window: number, reserve: number, base: number, before: number): number;
|
|
33
41
|
/**
|
|
34
42
|
* Replayed reasoning that Pi's estimator does not see. On the OpenAI Responses APIs an assistant turn's reasoning item rides in its thinking
|
|
35
43
|
* block's `thinkingSignature` (encrypted) and is sent back with later requests (pi-ai openai-responses-shared; transform-messages skips
|
|
@@ -61,11 +69,11 @@ export declare const measurableConversationTokens: (messages: Messages, ctx: Ext
|
|
|
61
69
|
/** Raw reserve for the cut planner: the window less the raw tokens that fit the target room, plus the input no cut can remove. */
|
|
62
70
|
export declare const planningReserve: (window: number, targetRoom: number, factor: number, mandatory: number) => number;
|
|
63
71
|
export type Target = "band" | "room";
|
|
64
|
-
/** A planned cut: the conversation it leaves
|
|
72
|
+
/** A planned cut: the conversation it leaves. The optional mode is diagnostic data, not a decision input. */
|
|
65
73
|
export type Plan<D extends object = {}> = ({
|
|
66
74
|
draft: true;
|
|
67
75
|
after: number;
|
|
68
|
-
mode
|
|
76
|
+
mode?: string;
|
|
69
77
|
} & D) | {
|
|
70
78
|
draft: false;
|
|
71
79
|
capacityRejected: boolean;
|
|
@@ -110,21 +118,10 @@ export interface CutInput<D extends object = {}> {
|
|
|
110
118
|
export type Verdict<D extends object = {}> = Exclude<Decision<D>, {
|
|
111
119
|
kind: "defer";
|
|
112
120
|
}>;
|
|
113
|
-
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* room (a cut that still lands in the band would trigger again every round); the exact room would cut nothing there, so a band cut is
|
|
118
|
-
* taken only when the room can plan a cut too and with the same mode (a lossy view the room could not even plan is a quality change with
|
|
119
|
-
* no fault to prevent). At or above the room, where a cut is due, a band plan of another mode gives way to the room plan (today's
|
|
120
|
-
* view), and a band plan stands when the room has none (a cut instead of a fault).
|
|
121
|
-
* A lean band, one the cut point binds (`band < bandOf(room)`), is planned first and the room plan landing below it is not taken for that:
|
|
122
|
-
* the room plan keeps a native tail of up to half the room, so it can land just under a band that far below the room, and the next round
|
|
123
|
-
* would cut again, freeing little. The room is planned only where the rules above use it: to compare the mode, or when the band has no plan
|
|
124
|
-
* and a cut is due. An adaptive band can also reach the margin, in which case the room plan is tried first.
|
|
125
|
-
* The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
|
|
126
|
-
* boundaries inside the band too, where no planner ran before the margin.
|
|
127
|
-
*/
|
|
121
|
+
/** Plan the band first at every cut point. An opportunistic cut must land
|
|
122
|
+
* below it and free the existing minimum. A due cut takes a band draft, or
|
|
123
|
+
* falls back to the room only when the band has no draft. Final reduction and
|
|
124
|
+
* room-fit checks own faults. Planner exceptions propagate. */
|
|
128
125
|
export declare function chooseCut<D extends object = {}>(input: CutInput<D> & {
|
|
129
126
|
opportunistic: boolean;
|
|
130
127
|
}): Verdict<D>;
|
package/dist/src/budget.js
CHANGED
|
@@ -14,11 +14,24 @@ export const validCutAt = (value) => typeof value === "number" && value > 0 && v
|
|
|
14
14
|
/** The initial default cut point: 60% of the room before a usable previous cut. Measured against 1 with paired replays (no answer loss) and billed
|
|
15
15
|
* input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
|
|
16
16
|
export const DEFAULT_CUT_AT = 0.6;
|
|
17
|
+
/** Preferred native carry, in the planner's estimate: never more than the
|
|
18
|
+
* keep-recent cut offers or the tail amount the adaptive band assumes. */
|
|
19
|
+
export const carriedTailTokens = (offeredTokens) => Math.min(offeredTokens, MAX_TAIL_TOKENS / 2);
|
|
17
20
|
export const bandOf = (room, cutAt = 1, adaptive) => {
|
|
18
21
|
const positive = Math.max(0, room);
|
|
19
22
|
const base = Math.floor(positive * cutAt);
|
|
20
|
-
return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), adaptive ? Math.max(base, Math.ceil((adaptive.system + adaptive.tokensAfter + MAX_TAIL_TOKENS
|
|
23
|
+
return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), adaptive ? Math.max(base, Math.ceil((adaptive.system + adaptive.tokensAfter + carriedTailTokens(MAX_TAIL_TOKENS)) * adaptive.factor)) : base);
|
|
21
24
|
};
|
|
25
|
+
/** The cut's one carried-context grant, in the planner's estimate. Combines
|
|
26
|
+
* the existing native/fixed base with the quarter-free historical amount;
|
|
27
|
+
* neither component remains an independent spending limit in the fill.
|
|
28
|
+
* The grant depends only on quantities. */
|
|
29
|
+
export function carriedTarget(window, reserve, base, before) {
|
|
30
|
+
const room = Math.max(0, window - reserve - 1);
|
|
31
|
+
const free = Math.max(0, window - reserve - base - 1);
|
|
32
|
+
const historical = Math.max(0, Math.min(Math.floor(free / 4), before - base - 1));
|
|
33
|
+
return Math.min(room, before - 1, base + historical);
|
|
34
|
+
}
|
|
22
35
|
/** The APIs whose request converter (pi-ai openai-responses-shared) sends each signed thinking block back as an input reasoning item. */
|
|
23
36
|
const REPLAYING_APIS = new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]);
|
|
24
37
|
/**
|
|
@@ -70,45 +83,25 @@ export const projectedConversationTokens = (messages, ctx, policy) => conversati
|
|
|
70
83
|
export const measurableConversationTokens = (messages, ctx, policy) => conversationTokens(measurableMessages(messages, ctx, policy));
|
|
71
84
|
/** Raw reserve for the cut planner: the window less the raw tokens that fit the target room, plus the input no cut can remove. */
|
|
72
85
|
export const planningReserve = (window, targetRoom, factor, mandatory) => window - Math.floor(targetRoom / factor) + mandatory;
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* room (a cut that still lands in the band would trigger again every round); the exact room would cut nothing there, so a band cut is
|
|
78
|
-
* taken only when the room can plan a cut too and with the same mode (a lossy view the room could not even plan is a quality change with
|
|
79
|
-
* no fault to prevent). At or above the room, where a cut is due, a band plan of another mode gives way to the room plan (today's
|
|
80
|
-
* view), and a band plan stands when the room has none (a cut instead of a fault).
|
|
81
|
-
* A lean band, one the cut point binds (`band < bandOf(room)`), is planned first and the room plan landing below it is not taken for that:
|
|
82
|
-
* the room plan keeps a native tail of up to half the room, so it can land just under a band that far below the room, and the next round
|
|
83
|
-
* would cut again, freeing little. The room is planned only where the rules above use it: to compare the mode, or when the band has no plan
|
|
84
|
-
* and a cut is due. An adaptive band can also reach the margin, in which case the room plan is tried first.
|
|
85
|
-
* The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
|
|
86
|
-
* boundaries inside the band too, where no planner ran before the margin.
|
|
87
|
-
*/
|
|
86
|
+
/** Plan the band first at every cut point. An opportunistic cut must land
|
|
87
|
+
* below it and free the existing minimum. A due cut takes a band draft, or
|
|
88
|
+
* falls back to the room only when the band has no draft. Final reduction and
|
|
89
|
+
* room-fit checks own faults. Planner exceptions propagate. */
|
|
88
90
|
export function chooseCut(input) {
|
|
89
91
|
const { before, mandatory, room, band, fits, plan, unreduced } = input;
|
|
90
92
|
const lands = (p) => p.draft && p.after < before && fits(p.after + mandatory, band);
|
|
91
93
|
// A cut no one needs yet must be worth its summary: one that frees under a tenth of the room would trigger again within a round or two.
|
|
92
|
-
//
|
|
94
|
+
// A due cut is not asked: it has to happen.
|
|
93
95
|
const worthwhile = (p) => !input.usefulOnly || !input.opportunistic || !fits(10 * (before - p.after), room);
|
|
94
|
-
let roomPlan;
|
|
95
|
-
const exact = () => roomPlan ??= plan("room");
|
|
96
|
-
if (band >= bandOf(room)) {
|
|
97
|
-
const e = exact();
|
|
98
|
-
if (lands(e) && worthwhile(e))
|
|
99
|
-
return { kind: "cut", target: "room", plan: e };
|
|
100
|
-
}
|
|
101
96
|
const preferred = plan("band");
|
|
102
97
|
if (input.opportunistic) {
|
|
103
98
|
if (!lands(preferred) || !worthwhile(preferred))
|
|
104
99
|
return { kind: "none" };
|
|
105
|
-
|
|
106
|
-
return e.draft && e.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
|
|
100
|
+
return { kind: "cut", target: "band", plan: preferred };
|
|
107
101
|
}
|
|
108
|
-
const e = exact();
|
|
109
102
|
let chosen = preferred, target = "band";
|
|
110
|
-
if (!preferred.draft
|
|
111
|
-
chosen =
|
|
103
|
+
if (!preferred.draft) {
|
|
104
|
+
chosen = plan("room");
|
|
112
105
|
target = "room";
|
|
113
106
|
}
|
|
114
107
|
if (!chosen.draft)
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "c53f4e367c1f45ac6b3865cbf25bf99992da4b7e961557e5510369dca81fb087";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "c53f4e367c1f45ac6b3865cbf25bf99992da4b7e961557e5510369dca81fb087";
|
package/dist/src/compaction.js
CHANGED
|
@@ -6,15 +6,16 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
6
6
|
import { assertNativeToolRounds } from "./protocol.js";
|
|
7
7
|
import { projectedMessages } from "./omissions.js";
|
|
8
8
|
import { applyClips, clipBudgets, clipFor } from "./clip.js";
|
|
9
|
-
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens,
|
|
10
|
-
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
11
|
-
import { convertToLlm, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
|
|
10
|
+
import { getCurrentSystemMessage, isContextOverflow } from "@earendil-works/pi-ai";
|
|
11
|
+
import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { Type } from "typebox";
|
|
13
13
|
import { Check } from "typebox/value";
|
|
14
14
|
import { MAX_RECALL_CHARACTERS, MAX_RECALL_ENTRY_ID_CHARACTERS, MAX_RECALL_QUERY_CHARACTERS } from "./recall.js";
|
|
15
15
|
import { tuck, tuckDraft, tuckObservedDraft, tuckRecall } from "./tuck.js";
|
|
16
16
|
import { ImageRecallInput, historyImage, historyAround } from "./history-image.js";
|
|
17
17
|
import { eligibilityKey, eligibilityKeyMoved, memoizedEligibleSources } from "./eligibility-cache.js";
|
|
18
|
+
import { applyHistoryTools, hasTuckCut, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
|
|
18
19
|
const query = Type.String({ minLength: 1, maxLength: MAX_RECALL_QUERY_CHARACTERS });
|
|
19
20
|
const entryId = Type.String({ minLength: 1, maxLength: MAX_RECALL_ENTRY_ID_CHARACTERS });
|
|
20
21
|
const paging = {
|
|
@@ -319,12 +320,20 @@ export async function installCompaction(pi, options = {}) {
|
|
|
319
320
|
const measureFor = (ctx) => (messages) => projectedConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
|
|
320
321
|
// The planner counts with the same function, except that it does not fail on a summary in another format: a cut can replace one
|
|
321
322
|
// (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
|
|
322
|
-
const plannerMeasureFor = (ctx) => (messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
|
|
323
|
+
const plannerMeasureFor = (ctx) => Object.assign((messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy), { messageTokens: (message) => conversationTokens(applyClips([message], clips)) });
|
|
323
324
|
const hostTokens = (ctx) => options.hostMessages ? conversationTokens(options.hostMessages(ctx)) : 0;
|
|
324
325
|
const toolShapes = (names) => {
|
|
325
326
|
const selected = new Set(names);
|
|
326
327
|
return pi.getAllTools().filter(tool => selected.has(tool.name)).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
|
|
327
328
|
};
|
|
329
|
+
// Every decision prices the declaration set a cut must carry, even before the first cut enables our tools on the wire.
|
|
330
|
+
const requestSystemPart = (observedPrompt, names, saved, guardPrompt) => {
|
|
331
|
+
const shapes = toolShapes(historyToolNames(pi, names, true));
|
|
332
|
+
// Keep the guard's observed schemas (Pi may enrich a host tool's schema), and add our future declarations to that same view.
|
|
333
|
+
// Taking max(current guard, future raw shapes) alone could lose the enrichment when the future shapes are larger.
|
|
334
|
+
const future = saved.some(message => message.role === "system") ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
335
|
+
return systemPart(observedPrompt, shapes, future, guardPrompt);
|
|
336
|
+
};
|
|
328
337
|
// Pi reports a manual cut with nothing to do as a failed compaction; the thrown error lacks the event's "Compaction failed: " prefix.
|
|
329
338
|
const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
330
339
|
// A missing model is configuration, not capacity.
|
|
@@ -385,7 +394,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
385
394
|
forgetFailure();
|
|
386
395
|
phase = "decided";
|
|
387
396
|
try {
|
|
397
|
+
syncHistoryTools(ctx);
|
|
388
398
|
await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools());
|
|
399
|
+
syncHistoryTools(ctx);
|
|
389
400
|
}
|
|
390
401
|
catch (error) {
|
|
391
402
|
fail(error, ctx);
|
|
@@ -477,7 +488,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
477
488
|
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
478
489
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
479
490
|
const host = hostTokens(ctx);
|
|
480
|
-
const system =
|
|
491
|
+
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
481
492
|
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
482
493
|
const before = plannerMeasureFor(ctx)(saved);
|
|
483
494
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
@@ -528,6 +539,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
528
539
|
});
|
|
529
540
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
530
541
|
pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
542
|
+
const historyToolsEnabled = (ctx) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
|
|
543
|
+
const syncHistoryTools = (ctx) => { if (active())
|
|
544
|
+
applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
545
|
+
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
546
|
+
pi.on("session_tree", (_event, ctx) => syncHistoryTools(ctx));
|
|
547
|
+
pi.on("session_compact", (_event, ctx) => { if (!admittedRun)
|
|
548
|
+
syncHistoryTools(ctx); });
|
|
549
|
+
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
550
|
+
pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
|
|
531
551
|
// The session as it will be once a cut that is not saved yet is saved, and its conversation as the next request carries it.
|
|
532
552
|
const cutSession = (ctx, cut) => {
|
|
533
553
|
const header = ctx.sessionManager.getHeader();
|
|
@@ -542,7 +562,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
542
562
|
// custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
|
|
543
563
|
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
544
564
|
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
545
|
-
const preflight =
|
|
565
|
+
const preflight = (ctx, incomingPart, observedPrompt, tools, hostSnapshot) => {
|
|
546
566
|
promptCut = undefined;
|
|
547
567
|
promptCutAt = ctx.sessionManager.getBranch().length;
|
|
548
568
|
if (!active())
|
|
@@ -555,9 +575,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
555
575
|
const { response, room } = limits(ctx);
|
|
556
576
|
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
|
|
557
577
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
558
|
-
const system =
|
|
578
|
+
const system = requestSystemPart(observedPrompt, tools, saved, ctx.getSystemPrompt());
|
|
559
579
|
const band = decisionBand(ctx, room, system);
|
|
560
|
-
const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
580
|
+
const host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
561
581
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
562
582
|
if (!fits(incoming, room, ctx))
|
|
563
583
|
throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
|
|
@@ -594,10 +614,43 @@ export async function installCompaction(pi, options = {}) {
|
|
|
594
614
|
const prompted = phase === "decided";
|
|
595
615
|
phase = "starting";
|
|
596
616
|
try {
|
|
617
|
+
syncHistoryTools(ctx);
|
|
597
618
|
if (prompted)
|
|
598
619
|
access(); // decided on input; a failure latched there still stops the run
|
|
599
620
|
else
|
|
600
|
-
await preflight(ctx, incomingEventTokens(event), event.systemPrompt, event.systemPromptOptions.selectedTools);
|
|
621
|
+
await preflight(ctx, incomingEventTokens(event), event.systemPrompt, historyToolNames(pi, event.systemPromptOptions.selectedTools, historyToolsEnabled(ctx)));
|
|
622
|
+
// Pi declares tool changes before turn_start, then snapshots executable tools in prepareRequest. Introduce our tools in
|
|
623
|
+
// the request projection: recording an activation message here would shift every later session-global recall index.
|
|
624
|
+
if (historyToolsEnabled(ctx))
|
|
625
|
+
stageHistoryTools(pi, ctx.sessionManager.buildSessionProjection().messages);
|
|
626
|
+
else
|
|
627
|
+
applyHistoryTools(pi, false);
|
|
628
|
+
const staged = pi.getActiveTools();
|
|
629
|
+
event.systemPromptOptions.selectedTools = historyToolNames(pi, event.systemPromptOptions.selectedTools, true).filter(name => name !== "tuck_recall" && name !== "tuck_search" || staged.includes(name));
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
fail(error, ctx);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
// The incoming user message is about to be appended (including steering inside a run), and later before_agent_start/turn_start
|
|
636
|
+
// handlers have run. Pi's prepareRequest has not yet snapshotted executable tools. A first cut discovered in context_with_system
|
|
637
|
+
// would be too late for that snapshot, even if its declarations were repaired there, so make the late prompt decision here.
|
|
638
|
+
pi.on("message_end", (event, ctx) => {
|
|
639
|
+
if (event.message.role !== "user" || !active() || !admittedRun || promptCut)
|
|
640
|
+
return;
|
|
641
|
+
try {
|
|
642
|
+
access();
|
|
643
|
+
if (!ctx.model)
|
|
644
|
+
throw missingModel();
|
|
645
|
+
const message = event.message;
|
|
646
|
+
const incoming = incomingTokens(typeof message.content === "string" ? message.content : message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"), typeof message.content === "string" ? [] : message.content.filter((b) => b.type === "image"));
|
|
647
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
648
|
+
const host = hostTokens(ctx);
|
|
649
|
+
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
650
|
+
if (fits(conversationTokens(saved) + system + incoming + host, limits(ctx).room, ctx))
|
|
651
|
+
return;
|
|
652
|
+
preflight(ctx, incoming, ctx.getSystemPrompt(), pi.getActiveTools(), host);
|
|
653
|
+
syncHistoryTools(ctx);
|
|
601
654
|
}
|
|
602
655
|
catch (error) {
|
|
603
656
|
fail(error, ctx);
|
|
@@ -620,11 +673,28 @@ export async function installCompaction(pi, options = {}) {
|
|
|
620
673
|
// One snapshot per decision. Messages queued for the next request (steering, follow-ups) are in that request but not in the
|
|
621
674
|
// saved conversation. Pi's peek returns the steering queue, else the follow-up queue; a follow-up is not in the very next
|
|
622
675
|
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
623
|
-
const system =
|
|
676
|
+
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
624
677
|
const band = decisionBand(ctx, room, system);
|
|
625
678
|
const host = hostTokens(ctx);
|
|
626
679
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages) : 0;
|
|
627
680
|
const mandatory = system + host + queued;
|
|
681
|
+
// Without usable provider usage, Pi's own threshold uses its unwrapped message estimate as the compiler's density input.
|
|
682
|
+
// Our declarations are request-only, so Pi can miss that threshold. Preserve the same density input at settlement; every
|
|
683
|
+
// budget decision below still uses `system` and the complete conversation measure, including the summary envelope.
|
|
684
|
+
let densityTokens;
|
|
685
|
+
const lastAssistant = saved.findLast(message => message.role === "assistant");
|
|
686
|
+
const lastCut = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
|
|
687
|
+
if (reason === "run-end" && lastAssistant && (!lastCut || lastAssistant.timestamp > Date.parse(lastCut.timestamp)) &&
|
|
688
|
+
!saved.some(message => message.role === "assistant" &&
|
|
689
|
+
!["error", "aborted"].includes(message.stopReason) && message.usage && calculateContextTokens(message.usage) > 0)) {
|
|
690
|
+
const forecast = historyToolDeclarations(saved, toolShapes(historyToolNames(pi, pi.getActiveTools(), true)), true);
|
|
691
|
+
if (forecast !== saved) {
|
|
692
|
+
const head = getCurrentSystemMessage(convertToLlm(forecast));
|
|
693
|
+
const native = (head ? estimateTokens(head) : 0) + saved.reduce((sum, message) => sum + (message.role === "system" ? 0 : estimateTokens(message)), 0);
|
|
694
|
+
if (shouldCompact(native, ctx.model.contextWindow, settingsFor().getCompactionSettings(ctx.model)))
|
|
695
|
+
densityTokens = native;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
628
698
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
629
699
|
// The cut previews this decision has built, in the order it built them. The clip retry below measures them for what a cut leaves the
|
|
630
700
|
// request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
|
|
@@ -634,18 +704,22 @@ export async function installCompaction(pi, options = {}) {
|
|
|
634
704
|
previews = [];
|
|
635
705
|
floorKept = undefined;
|
|
636
706
|
const before = measure(saved);
|
|
637
|
-
|
|
638
|
-
|
|
707
|
+
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
708
|
+
if (gate.kind !== "plan")
|
|
709
|
+
return gate;
|
|
710
|
+
return chooseCut({
|
|
711
|
+
opportunistic: gate.opportunistic, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
639
712
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
640
713
|
plan: (target) => {
|
|
641
714
|
const reserve = planReserve(target, room, band, mandatory, ctx);
|
|
642
|
-
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
715
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
643
716
|
if (!draft.compaction) {
|
|
644
717
|
// The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
|
|
645
718
|
if (draft.floorMessages && event.toolResults) {
|
|
646
719
|
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
647
720
|
const kept = measure(draft.floorMessages.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId))));
|
|
648
|
-
|
|
721
|
+
// Both targets must leave their protected openings beside the clip.
|
|
722
|
+
floorKept = Math.max(floorKept ?? kept, kept);
|
|
649
723
|
}
|
|
650
724
|
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
651
725
|
}
|
|
@@ -671,7 +745,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
671
745
|
const branch = ctx.sessionManager.getBranch();
|
|
672
746
|
const entryIdOf = (toolCallId) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
673
747
|
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
674
|
-
const kept = previews.length ? Math.
|
|
748
|
+
const kept = previews.length ? Math.max(...previews.map(preview => measure(preview.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId)))))) : floorKept;
|
|
675
749
|
for (const budget of clipBudgets(room, factor, mandatory, kept)) {
|
|
676
750
|
const round = clipFor(event.toolResults, entryIdOf, budget);
|
|
677
751
|
if (!round.length)
|
|
@@ -720,13 +794,24 @@ export async function installCompaction(pi, options = {}) {
|
|
|
720
794
|
if (!promptCut && !decide)
|
|
721
795
|
return;
|
|
722
796
|
const result = atBoundary(event, ctx, "tool-loop", decide);
|
|
723
|
-
if (result)
|
|
797
|
+
if (result) {
|
|
724
798
|
pendingBoundaryCommit = true;
|
|
799
|
+
}
|
|
725
800
|
return result;
|
|
726
801
|
}
|
|
727
802
|
catch (error) {
|
|
728
803
|
fail(error, ctx);
|
|
729
804
|
}
|
|
805
|
+
finally {
|
|
806
|
+
// Keep our part of Pi's next declaration snapshot unchanged; turn_start restores the branch-derived executable set before
|
|
807
|
+
// prepareRequest. This avoids an extra persisted system message at the first cut and on later rounds/runs.
|
|
808
|
+
if (active()) {
|
|
809
|
+
if (historyToolsEnabled(ctx))
|
|
810
|
+
stageHistoryTools(pi, ctx.sessionManager.buildSessionProjection().messages);
|
|
811
|
+
else
|
|
812
|
+
applyHistoryTools(pi, false);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
730
815
|
});
|
|
731
816
|
// A request of a run whose prompt's cut is not saved yet: the saved conversation in it, which other handlers may surround, is replaced by
|
|
732
817
|
// the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
|
|
@@ -743,6 +828,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
743
828
|
try {
|
|
744
829
|
if (!active())
|
|
745
830
|
return;
|
|
831
|
+
// Before a cut, Pi owns the whole request, including its object identities and system-message serialization.
|
|
832
|
+
if (!historyToolsEnabled(ctx))
|
|
833
|
+
return clips.size ? { messages: applyClips(event.messages, clips) } : undefined;
|
|
746
834
|
access();
|
|
747
835
|
return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
|
|
748
836
|
}
|
|
@@ -751,7 +839,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
751
839
|
fail(error, ctx);
|
|
752
840
|
}
|
|
753
841
|
finally {
|
|
754
|
-
return { messages: [] };
|
|
842
|
+
return historyToolsEnabled(ctx) ? { messages: [] } : undefined;
|
|
755
843
|
}
|
|
756
844
|
}
|
|
757
845
|
});
|
|
@@ -807,22 +895,27 @@ export async function installCompaction(pi, options = {}) {
|
|
|
807
895
|
throw missingModel();
|
|
808
896
|
// The complete request, counted after Pi's conversion (summary/custom/bash envelopes), with the effective system once.
|
|
809
897
|
// Pi applies a forced prompt after this hook; the public prompt already observes it.
|
|
810
|
-
|
|
898
|
+
syncHistoryTools(ctx);
|
|
899
|
+
const messages = historyToolsEnabled(ctx) ? historyToolDeclarations(event.messages, toolShapes(pi.getActiveTools()), true) : event.messages;
|
|
900
|
+
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
901
|
+
const tokens = system + conversationTokens(messages);
|
|
811
902
|
if (!fits(tokens, limits(ctx).room, ctx))
|
|
812
903
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
813
904
|
if (recorder.enabled && !recorder.waitingForCut)
|
|
814
905
|
recorder.measureInline(() => {
|
|
815
906
|
recorder.request();
|
|
816
907
|
// No system prompt or hidden assistant blocks; no provider payload/headers.
|
|
817
|
-
const permitted =
|
|
908
|
+
const permitted = messages.filter(m => m.role !== "system" &&
|
|
818
909
|
!(m.role === "assistant" && m.content.some(b => b.type === "thinking")) &&
|
|
819
910
|
!(m.role === "custom" && options.recallPolicy?.privateCustomTypes?.includes(m.customType)));
|
|
820
911
|
recorder.capture("prepared-request", "public-context-with-system-hook-before-later-transforms-not-wire", { messages: permitted, estimatedInputTokens: tokens }, permitted.length !== event.messages.length);
|
|
821
912
|
});
|
|
822
913
|
if (tokens > 0 && Number.isSafeInteger(tokens))
|
|
823
914
|
pending = {
|
|
824
|
-
identity: identityFor(ctx), tokens, messages
|
|
915
|
+
identity: identityFor(ctx), tokens, messages, snapshot: structuredClone(convertToLlm(messages)),
|
|
825
916
|
};
|
|
917
|
+
if (messages !== event.messages)
|
|
918
|
+
return { messages };
|
|
826
919
|
}
|
|
827
920
|
catch (error) {
|
|
828
921
|
fail(error, ctx);
|
|
@@ -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 = "c53f4e367c1f45ac6b3865cbf25bf99992da4b7e961557e5510369dca81fb087";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Messages, ToolShape } from "./budget.js";
|
|
3
|
+
/** Native Pi compactions do not make pi-tuck history available. No session-wide activation state. */
|
|
4
|
+
export declare const hasTuckCut: (branch: readonly {
|
|
5
|
+
type: string;
|
|
6
|
+
details?: unknown;
|
|
7
|
+
}[]) => boolean;
|
|
8
|
+
/** Preserve the host's loadout and its order; only our registered tools may change. */
|
|
9
|
+
export declare function historyToolNames(pi: ExtensionAPI, names: readonly string[], enabled: boolean): string[];
|
|
10
|
+
export declare function applyHistoryTools(pi: ExtensionAPI, enabled: boolean): void;
|
|
11
|
+
/** Match only our part of Pi's saved declaration snapshot, so its loop records no activation/removal message. Legacy sessions can
|
|
12
|
+
* already declare our tools; new sessions declare them only in the request projection. Executable tools are restored at turn_start. */
|
|
13
|
+
export declare function stageHistoryTools(pi: ExtensionAPI, messages: Messages): string[];
|
|
14
|
+
/** Pi has already declared the request's tools before queued user messages are appended. A prompt cut planned on such a message needs its declarations
|
|
15
|
+
* in that very request. Change only our declarations, leaving all other system content and tools in their original places. */
|
|
16
|
+
export declare function historyToolDeclarations(messages: Messages, tools: readonly ToolShape[], enabled: boolean): Messages;
|