pi-tuck 1.0.20 → 1.0.22
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 +63 -0
- package/README.md +10 -6
- package/dist/index.js +30 -9
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +14 -3
- package/dist/src/compaction.js +104 -27
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/request-capacity.d.ts +6 -0
- package/dist/src/request-capacity.js +32 -0
- package/dist/src/tuck.d.ts +2 -1
- package/dist/src/tuck.js +8 -3
- package/index.ts +19 -5
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +99 -31
- package/src/request-capacity.ts +32 -0
- package/src/tuck.ts +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,69 @@
|
|
|
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.22] - 2026-11-10
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **pi-tuck refused requests that fit the window, and could strand a session (1.0.0 to 1.0.21).** It refused at the room (the window less the full
|
|
11
|
+
reply reserve), where native Pi still sends. With Pi's defaults and a large setup, a session could be refused at every prompt. The final refusal
|
|
12
|
+
now sits at the window less a reply floor:
|
|
13
|
+
- the floor is the larger of the biggest reply seen on the branch and 4,096 tokens, capped at the reserve;
|
|
14
|
+
- after a reply stopped for length or a context-overflow refusal, the floor is the full reserve, as in 1.0.20;
|
|
15
|
+
- everything pi-tuck does before refusing (cut to the band, cut to the room, a smaller summary, clipping a fresh result) is unchanged and still
|
|
16
|
+
comes first;
|
|
17
|
+
- a session that never reaches the room sends the same requests as 1.0.21.
|
|
18
|
+
- **A run's request always carries the whole prompt that started the run.** If it cannot fit after every reduction, pi-tuck stops the run with a
|
|
19
|
+
notice saying to shorten the prompt or use a larger window, instead of sending part of it. In-run cuts now keep the run's starting prompt whole
|
|
20
|
+
beside a later steering message.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Each pi-tuck refusal, and each request sent above the room, leaves one session entry:** type `custom`, `customType` `pi-tuck-capacity`, with
|
|
25
|
+
`data` `{kind: "refusal" | "above-room", reason, estimatedTokens, room, hardLine, buildId}`. It is never sent to the model, and never appears in
|
|
26
|
+
summaries or recall.
|
|
27
|
+
- **A native-parity check in the settings stress test,** computed from Pi's own `shouldCompact` and compaction settings for the Pi version under
|
|
28
|
+
test. It fails a cell where pi-tuck refuses a request native Pi would send, or forces a cut below native's compaction point. Four refusals are
|
|
29
|
+
allowed:
|
|
30
|
+
- past the window;
|
|
31
|
+
- after a reply that did not fit;
|
|
32
|
+
- when the whole starting prompt cannot fit;
|
|
33
|
+
- when sending would leave the reply less than the floor.
|
|
34
|
+
|
|
35
|
+
### Known cost
|
|
36
|
+
|
|
37
|
+
- **Between the room and the window, the reply reserve no longer cushions a wrong count.** At a cold start an undercount can reach the provider. A
|
|
38
|
+
provider that errors is caught and learned from; a server that truncates silently truncates, as it does for native Pi.
|
|
39
|
+
- **With a setup above the room, cuts are forced and frequent,** each freeing little. That beats a stranded session.
|
|
40
|
+
|
|
41
|
+
### Release gates
|
|
42
|
+
|
|
43
|
+
- **Shipped before its Tier 1 live gate, under the stranded-session rule;** the gate runs right after release, and a fail rolls back to 1.0.21.
|
|
44
|
+
- **Replay matches 1.0.21,** read per request on 29 recordings: every differing request also differs between two runs of 1.0.21. 0 lost and 0 dead
|
|
45
|
+
tasks, the same 17 clips.
|
|
46
|
+
- **Stress on Pi 1.2.0 and 1.5.0,** 298 cells: answered 1,309 → 1,356, refused 360 → 313, stranded sessions 3 → 0, native-parity findings 53 → 0. The
|
|
47
|
+
old fresh-room finding passes. Tests (696) and CI.
|
|
48
|
+
- Sessions that are open keep the build they started with until they are restarted.
|
|
49
|
+
|
|
50
|
+
## [1.0.21] - 2026-11-09
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **Semantic recall never started in `pi -p` (1.0.0 to 1.0.20).** pi-tuck skipped semantic recall whenever Pi ran with no UI, to keep a one-shot
|
|
55
|
+
run from starting the first-time download. That also skipped a runtime that was already installed. So jobs run with `pi -p` searched by keyword
|
|
56
|
+
only, while `tuck_search` said semantic recall was being set up. With no UI, an installed runtime now starts as it does in an interactive session.
|
|
57
|
+
Without one, pi-tuck says it is not set up and starts no download. RPC mode, which has a UI, was not affected.
|
|
58
|
+
- **An off or failing semantic recall, or a bad settings file, is no longer silent in `pi -p`.** Pi shows no notices there, so pi-tuck writes each
|
|
59
|
+
such message once per session to stderr, never into the model's context.
|
|
60
|
+
|
|
61
|
+
### Release gates
|
|
62
|
+
|
|
63
|
+
- **Shipped without a live gate, because its replay matches 1.0.20.** Replay does not load the changed file (`index.ts`). In the replay, 43 requests
|
|
64
|
+
of one recording (live-s08-A) differed once. Three more runs of that recording on each build were identical, so it was noise under machine load.
|
|
65
|
+
0 lost and 0 dead tasks, the same 17 clips. Tests and CI; the settings stress test, none worse than 1.0.20.
|
|
66
|
+
- Checked in real Pi 1.4.0: with `pi -p` and no runtime, one stderr line; with an installed runtime, semantic recall starts.
|
|
67
|
+
- Sessions that are open keep the build they started with until they are restarted.
|
|
68
|
+
|
|
6
69
|
## [1.0.20] - 2026-11-09
|
|
7
70
|
|
|
8
71
|
### 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), and says once when it is set up. Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
|
|
67
|
-
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.
|
|
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.21`.
|
|
68
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.21`.
|
|
69
69
|
|
|
70
70
|
## Uninstall
|
|
71
71
|
|
|
@@ -126,9 +126,11 @@ Known limits today:
|
|
|
126
126
|
|
|
127
127
|
- 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.
|
|
128
128
|
- 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).
|
|
129
|
-
- Small window, after a stopped run (1.0.8 and 1.0.9 only; fixed in 1.0.10): Pi's default `compaction.keepRecentTokens` (20,000) can exceed pi-tuck's input room, which
|
|
129
|
+
- Small window, after a stopped run (1.0.8 and 1.0.9 only; fixed in 1.0.10): Pi's default `compaction.keepRecentTokens` (20,000) can exceed pi-tuck's input room, which was 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.
|
|
130
130
|
- A prompt could be lost, with no notice (1.0.10 and 1.0.11; fixed in 1.0.12). When a new prompt arrived over the cut point and pi-tuck's cut kept nothing of the conversation before it, which happens when everything since the last cut is one long turn, the prompt was dropped from the request and from the saved session. The model never saw it and usually went on with the previous task, so work you asked for could be lost, and could look answered. The next prompt ran normally. If you used 1.0.10 or 1.0.11 and a reply ignored what you had just asked, this is the likely cause. Upgrade to 1.0.12 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.12).
|
|
131
131
|
- A host that patches a large section into the system prompt could get a refusal that a cut would have fit (1.0.0 to 1.0.16; fixed in 1.0.17). pi-tuck plans a prompt's cut when the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn could push that turn's first request over the room, and the final guard refused it. Now the guard plans the cut against the system prompt as finally sent, and refuses only if that cut does not fit either ([`CHANGELOG.md`](CHANGELOG.md) 1.0.17).
|
|
132
|
+
- Semantic recall never started in `pi -p` (1.0.0 to 1.0.20; fixed in 1.0.21). pi-tuck skipped semantic recall whenever Pi ran with no UI, to keep a one-shot run from starting the first-time download, and that also skipped a runtime that was already installed. Jobs run with `pi -p` searched by keyword only, while `tuck_search` said semantic recall was being set up. RPC mode was not affected ([`CHANGELOG.md`](CHANGELOG.md) 1.0.21).
|
|
133
|
+
- pi-tuck cut earlier than native Pi when a model's reply cap is above Pi's reserve (1.0.0 to 1.0.19; fixed in 1.0.20). It reserved the larger of Pi's `compaction.reserveTokens` and the model's `maxTokens`: with `maxTokens` 32,768 and a reserve of 16,384 on an 81,920 window, its room fell to 49,152 tokens, where native Pi compacts at 65,536 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.20).
|
|
132
134
|
- A summary could drop the task a session was working on (1.0.0 to 1.0.17; fixed in 1.0.18). Until 1.0.15 a summary quoted only the session's first user turn, up to 12,000 characters, and the newest one, so in a long session the current task, an older turn followed by short steps, was clipped or dropped while a finished first task stayed whole, and agents acted on a days-old task. 1.0.16 added more quotes. From 1.0.13 to 1.0.17, separate rules decided what a summary quoted and what the kept tail held, and the prompt that set the current task, or the first prompt, could lose its opening after a cut: the stress test found it 40 times in 32 cells on 1.0.16 and 1.0.17. Since 1.0.18 one ranked list fills a cut's room, and the stress test finds no lost opening in 287 cells. Short sessions, and the SWE-bench chains behind our benchmarks, keep each task's prompt in the kept tail and never hit it. Upgrade to 1.0.18 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
|
|
133
135
|
- pi-tuck 1.0.16 cost more in live chains than its notes first said. Against 1.0.13 at an 81,920 window, it took 1.48 times the turns on the tasks both resolved (95% interval 1.34 to 1.64), 74% more cuts and 1.66 times the tokens, with 56 of 60 tasks resolved against 55. Replay of single-task sessions cannot show this cost. 1.0.18 replaced its quote rules; in the 1.0.18 gate it took 22.27 turns per task against 29.35 for 1.0.16 and 20.22 for the 1.0.13 arm (different pairs of runs; reported, not claimed) ([`benchmark/RESULTS-V1016.md`](benchmark/RESULTS-V1016.md), [`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
|
|
134
136
|
- Since 1.0.18, on replay of 17 recordings, estimated request time against 1.0.13 is 2.2% higher quiet and 4.2% higher loaded at 81,920, and 1.1% and 1.9% lower at 131,072. Billed input moves the same way. At 81,920 each cut carries about 600 more tokens. Sessions that are open keep the build they started with until they are restarted ([`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
|
|
@@ -184,13 +186,15 @@ A bad file is reported at session start, and pi-tuck uses the defaults.
|
|
|
184
186
|
|
|
185
187
|
### Small windows
|
|
186
188
|
|
|
187
|
-
pi-tuck keeps room for the model's reply
|
|
189
|
+
pi-tuck keeps room for the model's reply. Since 1.0.20 that room is Pi's `compaction.reserveTokens` (default 16,384), the same as native Pi. It rises to the model's `maxTokens` for the rest of the branch only after a reply on that branch did not fit: a reply that stopped for length, or a request refused as a context overflow. A transient error such as a 503 keeps the native room. This is read from the current branch at each decision, so resume, fork and `/tree` follow the branch, and a new session starts at `reserveTokens` again. A host that passes its own reserve keeps its own. Before 1.0.20, pi-tuck always reserved the larger of the two, so with a large `maxTokens` it cut earlier than native Pi.
|
|
190
|
+
|
|
191
|
+
pi-tuck 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:
|
|
188
192
|
|
|
189
193
|
```json
|
|
190
194
|
{ "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 8192 } } } }
|
|
191
195
|
```
|
|
192
196
|
|
|
193
|
-
|
|
197
|
+
Lowering `reserveTokens` takes effect even when `models.json` sets a larger `maxTokens` for that model, until a reply on the branch does not fit. After that, the larger of the two applies for the rest of the branch, so lower both if needed, and keep `maxTokens` above your largest expected reply.
|
|
194
198
|
|
|
195
199
|
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:
|
|
196
200
|
|
|
@@ -210,7 +214,7 @@ The first time a session starts, pi-tuck downloads what it needs, once, about 39
|
|
|
210
214
|
| llama.cpp release b11476 for macOS arm64 (12 MB) | github.com/ggml-org/llama.cpp |
|
|
211
215
|
| CPython 3.13 (25 MB) and 16 Python packages for the tokenizer (9 MB) | github.com/astral-sh/python-build-standalone, pypi.org |
|
|
212
216
|
|
|
213
|
-
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.
|
|
217
|
+
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. The first-time setup needs one interactive start: one-shot runs (`pi -p`) do not start the download. Since 1.0.21, a `pi -p` run starts semantic search when the runtime is already installed. If it is not installed, pi-tuck says so and starts no download. With no UI, an off or failing semantic recall, or a bad settings file, is written once per message per session to stderr, never into the model's context.
|
|
214
218
|
|
|
215
219
|
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.
|
|
216
220
|
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join, sep } from "node:path";
|
|
4
4
|
import { installCompaction } from "./src/compaction.js";
|
|
5
5
|
import { validCutAt } from "./src/budget.js";
|
|
6
6
|
import { createSemanticRecall } from "./src/semantic-recall.js";
|
|
7
|
-
import { defaultRoot } from "./src/assets.js";
|
|
7
|
+
import { defaultRoot, runtimeConfigPath } from "./src/assets.js";
|
|
8
8
|
import { ensureSemanticRuntime } from "./src/setup.js";
|
|
9
9
|
export { installCompaction } from "./src/compaction.js";
|
|
10
10
|
export { inspectIncident, readableIncident } from "./src/diagnostic-replay.js";
|
|
@@ -52,10 +52,25 @@ export default async function (pi) {
|
|
|
52
52
|
return;
|
|
53
53
|
let session = 0, said = "", setup, announced = false;
|
|
54
54
|
// A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
// With no UI (pi -p), Pi shows no notices, so a settings error or an off or failing semantic recall would go unsaid: write each to stderr
|
|
56
|
+
// instead, once per session. stderr is the operator's; it never enters the model's context.
|
|
57
|
+
const stderrSaid = new Set();
|
|
58
|
+
const say = (ctx, message, level) => {
|
|
59
|
+
if (!ctx.hasUI) {
|
|
60
|
+
if (!stderrSaid.has(message)) {
|
|
61
|
+
stderrSaid.add(message);
|
|
62
|
+
try {
|
|
63
|
+
process.stderr.write(message + "\n");
|
|
64
|
+
}
|
|
65
|
+
catch { /* closed */ }
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
ctx.ui.notify(message, level);
|
|
71
|
+
}
|
|
72
|
+
catch { /* ended */ }
|
|
73
|
+
};
|
|
59
74
|
const once = (ctx, message, level) => { if (said !== message) {
|
|
60
75
|
said = message;
|
|
61
76
|
say(ctx, message, level);
|
|
@@ -64,6 +79,7 @@ export default async function (pi) {
|
|
|
64
79
|
pi.on("session_start", (_event, ctx) => {
|
|
65
80
|
const current = ++session;
|
|
66
81
|
off = false;
|
|
82
|
+
stderrSaid.clear();
|
|
67
83
|
if (settings.error)
|
|
68
84
|
once(ctx, `pi-tuck: ${settings.error}; using the defaults.`, "warning");
|
|
69
85
|
if (!appleSilicon)
|
|
@@ -72,10 +88,15 @@ export default async function (pi) {
|
|
|
72
88
|
// agent folder, such as the home folder.
|
|
73
89
|
if (within(real(getAgentDir()), real(ctx.cwd)))
|
|
74
90
|
return once(ctx, "pi-tuck: semantic recall is off here because Pi's agent folder is inside this folder; open Pi in a project folder to use it.", "info");
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
// A run with no UI (pi -p, RPC, JSON) uses a runtime that is already installed: that needs no download. It does not start a first-time
|
|
92
|
+
// setup, which would outlast a one-shot run, and it says why, so tuck_search and /tuck name the real state.
|
|
93
|
+
const installed = existsSync(runtimeConfigPath(defaultRoot()));
|
|
94
|
+
if (!ctx.hasUI && !installed) {
|
|
95
|
+
semanticRecall.refuse("Semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works meanwhile");
|
|
96
|
+
return say(ctx, "pi-tuck: semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works.", "warning");
|
|
97
|
+
}
|
|
77
98
|
if (!semanticRecall.status().configured)
|
|
78
|
-
semanticRecall.refuse("Semantic recall is being set up; word recall works meanwhile");
|
|
99
|
+
semanticRecall.refuse(installed ? "Semantic recall is starting; word recall works meanwhile" : "Semantic recall is being set up; word recall works meanwhile");
|
|
79
100
|
setup ??= ensureSemanticRuntime(defaultRoot(), message => { announced = true; say(ctx, message, "info"); });
|
|
80
101
|
setup.then(async () => {
|
|
81
102
|
if (current !== session || off)
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
|
package/dist/src/compaction.d.ts
CHANGED
|
@@ -32,15 +32,26 @@ export interface CompactionOptions {
|
|
|
32
32
|
onSemanticSetting?: (enabled: boolean) => Promise<unknown>;
|
|
33
33
|
semanticStatus?: () => unknown;
|
|
34
34
|
diagnostics?: RecorderOptions;
|
|
35
|
+
/** Optional read-only observation of actual corrected budget checks; no request content. */
|
|
36
|
+
observeBudget?: (measurement: {
|
|
37
|
+
rawTokens: number;
|
|
38
|
+
tokens: number;
|
|
39
|
+
factor: number;
|
|
40
|
+
response: number;
|
|
41
|
+
room: number;
|
|
42
|
+
hardLine?: number;
|
|
43
|
+
context: ExtensionContext;
|
|
44
|
+
}) => void;
|
|
35
45
|
/** Optional estimated complete-input ceiling, separate from physical output reservation.
|
|
36
46
|
* The host owns final serialization/transport counting; this is not an exact wire-token cap. */
|
|
37
47
|
activeInputTokens?: number | ((ctx: ExtensionContext) => number);
|
|
38
48
|
/** An explicit cut point: a fraction of the input room, above 0 and at most 1. A cut is triggered at, and preferably planned to, the band,
|
|
39
|
-
* which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The
|
|
40
|
-
*
|
|
49
|
+
* which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The room
|
|
50
|
+
* owns cut targets; standalone final capacity admission uses the branch reply floor. If omitted, the band adapts to the last cut. */
|
|
41
51
|
cutAt?: number;
|
|
42
52
|
/** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
|
|
43
|
-
* Standalone
|
|
53
|
+
* Standalone cuts against Pi's reserveTokens, then admits up to the window less max(8192, largest branch output), capped at that reserve.
|
|
54
|
+
* After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
44
55
|
* Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
|
|
45
56
|
requestReserve?: (ctx: ExtensionContext) => {
|
|
46
57
|
response: number;
|
package/dist/src/compaction.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { textOf } from "./tuck/core/content.js";
|
|
2
|
+
import { branchReplyFloor, carriesPrompt } from "./request-capacity.js";
|
|
3
|
+
import { BUILD_ID } from "./build-id.js";
|
|
1
4
|
import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
|
|
2
5
|
import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
|
|
3
6
|
import { createHash } from "node:crypto";
|
|
@@ -216,6 +219,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
216
219
|
} });
|
|
217
220
|
// Pi's native start/settled events bracket the run and its post-turn cuts.
|
|
218
221
|
let admittedRun = false;
|
|
222
|
+
let runPrompt;
|
|
223
|
+
const protectedPrompt = (ctx) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt))?.id : undefined;
|
|
224
|
+
const promptRefusal = () => new Refusal("Prompt check failed: the whole starting prompt cannot fit after cutting to the band and room, shrinking the summary and clipping fresh results. Shorten the prompt or choose a model with a larger context window, then retry");
|
|
225
|
+
let estimatedTokens = 0;
|
|
219
226
|
// One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
|
|
220
227
|
// start, or a model switch) starts with the window its branch's newest receipt recorded for that identity, else that receipt's factor, else the model's measured undercount or 1 (`seedWindow`).
|
|
221
228
|
let identity = "", factor = 1, ratios = [];
|
|
@@ -235,6 +242,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
235
242
|
const learn = (value) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW)
|
|
236
243
|
ratios.shift(); factor = Math.max(1, ...ratios); };
|
|
237
244
|
const corrected = (tokens, ctx) => { identityFor(ctx); return Math.ceil(tokens * factor); };
|
|
245
|
+
const observeBudget = (rawTokens, ctx) => {
|
|
246
|
+
if (options.observeBudget)
|
|
247
|
+
options.observeBudget({ rawTokens, tokens: corrected(rawTokens, ctx), factor, ...limits(ctx), context: ctx });
|
|
248
|
+
};
|
|
238
249
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
239
250
|
const planReserve = (target, room, band, mandatory, ctx) => {
|
|
240
251
|
identityFor(ctx);
|
|
@@ -287,7 +298,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
287
298
|
learn(ratio);
|
|
288
299
|
});
|
|
289
300
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
290
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
301
|
+
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
291
302
|
const active = () => options.active?.() ?? true;
|
|
292
303
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
293
304
|
// The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
|
|
@@ -311,10 +322,12 @@ export async function installCompaction(pi, options = {}) {
|
|
|
311
322
|
throw new Error("Invalid active-input token ceiling");
|
|
312
323
|
return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
|
|
313
324
|
};
|
|
314
|
-
//
|
|
325
|
+
// Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
|
|
315
326
|
const limits = (ctx) => {
|
|
316
327
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
317
|
-
|
|
328
|
+
const floor = options.requestReserve ? response : branchReplyFloor(ctx.sessionManager.getBranch(), response);
|
|
329
|
+
const hardLine = Math.min((ctx.model?.contextWindow ?? 0) - floor, lastCeiling ?? Infinity);
|
|
330
|
+
return { response, room, hardLine };
|
|
318
331
|
};
|
|
319
332
|
const decisionBand = (ctx, room, system) => {
|
|
320
333
|
const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
|
|
@@ -349,12 +362,22 @@ export async function installCompaction(pi, options = {}) {
|
|
|
349
362
|
// Where the branch ended when the prompt's cut was planned: the prompt is the entry Pi appends there. A cut that keeps nothing of the conversation
|
|
350
363
|
// before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
|
|
351
364
|
let promptCutAt = 0;
|
|
352
|
-
const keptFrom = (cut, ctx) => cut.firstKeptEntryId ||
|
|
365
|
+
const keptFrom = (cut, ctx) => cut.firstKeptEntryId ||
|
|
366
|
+
ctx.sessionManager.getBranch().slice(promptCutAt).find(entry => entry.type !== "custom" || entry.customType !== "pi-tuck-capacity")?.id || "";
|
|
353
367
|
let failure;
|
|
368
|
+
let refusalReceipted = false;
|
|
354
369
|
let pendingBoundaryCommit = false;
|
|
355
370
|
// Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
|
|
356
371
|
// whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
|
|
357
372
|
const clips = new Map();
|
|
373
|
+
const receipt = (kind, reason, ctx) => {
|
|
374
|
+
let room = null, hardLine = null;
|
|
375
|
+
try {
|
|
376
|
+
({ room, hardLine } = limits(ctx));
|
|
377
|
+
}
|
|
378
|
+
catch { /* Invalid configuration has no usable capacity. Preserve its original failure. */ }
|
|
379
|
+
pi.appendEntry("pi-tuck-capacity", { kind, reason, estimatedTokens, room, hardLine, buildId: BUILD_ID });
|
|
380
|
+
};
|
|
358
381
|
const fail = (error, ctx) => {
|
|
359
382
|
recorder.stop("native-attempt-failed");
|
|
360
383
|
// Every refusal says why, once per attempt: a capacity error with what the user can change, a cut Pi failed to commit with what that
|
|
@@ -367,6 +390,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
367
390
|
pending = undefined;
|
|
368
391
|
promptCut = undefined;
|
|
369
392
|
try {
|
|
393
|
+
if (!refusalReceipted) {
|
|
394
|
+
refusalReceipted = true;
|
|
395
|
+
receipt("refusal", failure.message, ctx);
|
|
396
|
+
}
|
|
370
397
|
options.fail?.(error);
|
|
371
398
|
}
|
|
372
399
|
finally {
|
|
@@ -383,7 +410,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
383
410
|
};
|
|
384
411
|
// The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
385
412
|
// input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
|
|
386
|
-
const forgetFailure = () => { if (!(failure instanceof CommitFault))
|
|
413
|
+
const forgetFailure = () => { refusalReceipted = false; if (!(failure instanceof CommitFault))
|
|
387
414
|
failure = undefined; };
|
|
388
415
|
const access = () => {
|
|
389
416
|
if (failure)
|
|
@@ -497,6 +524,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
497
524
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
498
525
|
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
499
526
|
const before = plannerMeasureFor(ctx)(saved);
|
|
527
|
+
observeBudget(before + mandatory, ctx);
|
|
500
528
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
501
529
|
// it: a basis this compactor does not plan on. It may ask for a cut on a request that fits. This compactor cuts only where its own
|
|
502
530
|
// trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
|
|
@@ -509,19 +537,27 @@ export async function installCompaction(pi, options = {}) {
|
|
|
509
537
|
thresholdOpportunistic = gate.opportunistic;
|
|
510
538
|
}
|
|
511
539
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
512
|
-
const
|
|
513
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
540
|
+
const decide = (targetRoom = room, targetBand = band) => chooseCut({
|
|
541
|
+
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
514
542
|
opportunistic: thresholdOpportunistic,
|
|
515
543
|
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
516
544
|
plan: (target) => {
|
|
517
|
-
const reserve = planReserve(target,
|
|
545
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
518
546
|
// Every cut asked while no run is admitted (an operator's cut) is planned for a run that has not begun: the session has moved on
|
|
519
547
|
// from any run that ended. A retry is inside its run.
|
|
520
|
-
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
548
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
|
|
521
549
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
522
550
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
523
551
|
},
|
|
524
552
|
});
|
|
553
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
554
|
+
let decision = decide();
|
|
555
|
+
const { hardLine } = limits(ctx);
|
|
556
|
+
if (!operatorManual && hardLine > room && decision.kind === "fault") {
|
|
557
|
+
if (fits(before + mandatory, hardLine, ctx))
|
|
558
|
+
return { cancel: true };
|
|
559
|
+
decision = decide(hardLine, hardLine);
|
|
560
|
+
}
|
|
525
561
|
if (decision.kind === "cut") {
|
|
526
562
|
return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore, retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) };
|
|
527
563
|
}
|
|
@@ -530,6 +566,8 @@ export async function installCompaction(pi, options = {}) {
|
|
|
530
566
|
return { cancel: true };
|
|
531
567
|
recorder.stop("no-cut");
|
|
532
568
|
ctx.ui.notify(decision.message, "warning");
|
|
569
|
+
if (operatorManual)
|
|
570
|
+
receipt("refusal", decision.message, ctx);
|
|
533
571
|
if (!operatorManual)
|
|
534
572
|
fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
|
|
535
573
|
return { cancel: true };
|
|
@@ -544,7 +582,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
544
582
|
}
|
|
545
583
|
});
|
|
546
584
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
547
|
-
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"; });
|
|
585
|
+
pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; refusalReceipted = false; admittedRun = false; runPrompt = undefined; estimatedTokens = 0; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
548
586
|
const historyToolsEnabled = (ctx) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
|
|
549
587
|
const syncHistoryTools = (ctx) => { if (active())
|
|
550
588
|
applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
@@ -585,22 +623,24 @@ export async function installCompaction(pi, options = {}) {
|
|
|
585
623
|
const band = decisionBand(ctx, room, system);
|
|
586
624
|
const host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
587
625
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
588
|
-
|
|
626
|
+
observeBudget(before + mandatory, ctx);
|
|
627
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
628
|
+
if (!fits(incoming, limits(ctx).hardLine, ctx))
|
|
589
629
|
throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
|
|
590
630
|
// A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
|
|
591
631
|
// help: leave it to the complete-context guard, which fails closed.
|
|
592
632
|
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
593
|
-
if (gate.kind
|
|
633
|
+
if (gate.kind === "none")
|
|
594
634
|
return;
|
|
595
635
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
596
|
-
const
|
|
597
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic
|
|
636
|
+
const decide = (targetRoom, targetBand, opportunistic) => chooseCut({
|
|
637
|
+
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic,
|
|
598
638
|
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
599
639
|
plan: (target) => {
|
|
600
|
-
const reserve = planReserve(target,
|
|
640
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
601
641
|
// Only a due cut re-renders a summary that sits alone in the session: inside the band a cut is opportunistic, and a smaller summary
|
|
602
642
|
// there buys nothing the request needed and costs history the next recall would have found.
|
|
603
|
-
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !
|
|
643
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
604
644
|
if (!draft.compaction)
|
|
605
645
|
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
606
646
|
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
@@ -608,6 +648,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
608
648
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
609
649
|
},
|
|
610
650
|
});
|
|
651
|
+
let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" };
|
|
652
|
+
const { hardLine } = limits(ctx);
|
|
653
|
+
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
654
|
+
if (fits(before + mandatory, hardLine, ctx))
|
|
655
|
+
return;
|
|
656
|
+
decision = fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
|
|
657
|
+
}
|
|
658
|
+
if (decision.kind === "defer")
|
|
659
|
+
return;
|
|
611
660
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
612
661
|
if (decision.kind === "none")
|
|
613
662
|
return;
|
|
@@ -619,6 +668,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
619
668
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
620
669
|
const prompted = phase === "decided";
|
|
621
670
|
phase = "starting";
|
|
671
|
+
runPrompt = event.messageType === "custom" ? undefined : event.prompt;
|
|
622
672
|
try {
|
|
623
673
|
syncHistoryTools(ctx);
|
|
624
674
|
if (prompted)
|
|
@@ -706,20 +756,25 @@ export async function installCompaction(pi, options = {}) {
|
|
|
706
756
|
// request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
|
|
707
757
|
let previews = [];
|
|
708
758
|
let floorKept;
|
|
709
|
-
|
|
759
|
+
let wholePromptFloor;
|
|
760
|
+
const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
|
|
761
|
+
const decide = (targetRoom = room, targetBand = band) => {
|
|
710
762
|
previews = [];
|
|
711
763
|
floorKept = undefined;
|
|
764
|
+
wholePromptFloor = undefined;
|
|
712
765
|
const before = measure(saved);
|
|
713
|
-
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
766
|
+
const gate = gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
714
767
|
if (gate.kind !== "plan")
|
|
715
768
|
return gate;
|
|
716
769
|
return chooseCut({
|
|
717
|
-
opportunistic: gate.opportunistic, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
770
|
+
opportunistic: gate.opportunistic, mandatory, before, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
718
771
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
719
772
|
plan: (target) => {
|
|
720
|
-
const reserve = planReserve(target,
|
|
721
|
-
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
773
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
774
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary, protectedPromptEntryId: protectedId }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
722
775
|
if (!draft.compaction) {
|
|
776
|
+
if (protectedId && draft.floorMessages)
|
|
777
|
+
wholePromptFloor = measure(draft.floorMessages) + mandatory;
|
|
723
778
|
// The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
|
|
724
779
|
if (draft.floorMessages && event.toolResults) {
|
|
725
780
|
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
@@ -747,18 +802,20 @@ export async function installCompaction(pi, options = {}) {
|
|
|
747
802
|
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
748
803
|
// built, so the second budget is the cut's own floor and not a new formula. When nothing is clipped or the decision still faults, the
|
|
749
804
|
// refusal stands.
|
|
750
|
-
|
|
805
|
+
const tryClips = (clipRoom, targetBand) => {
|
|
806
|
+
if (decision.kind !== "fault" || !event.toolResults)
|
|
807
|
+
return;
|
|
751
808
|
const branch = ctx.sessionManager.getBranch();
|
|
752
809
|
const entryIdOf = (toolCallId) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
753
810
|
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
754
811
|
const kept = previews.length ? Math.max(...previews.map(preview => measure(preview.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId)))))) : floorKept;
|
|
755
|
-
for (const budget of clipBudgets(
|
|
812
|
+
for (const budget of clipBudgets(clipRoom, factor, mandatory, kept)) {
|
|
756
813
|
const round = clipFor(event.toolResults, entryIdOf, budget);
|
|
757
814
|
if (!round.length)
|
|
758
815
|
continue;
|
|
759
816
|
for (const clip of round)
|
|
760
817
|
clips.set(clip.toolCallId, clip);
|
|
761
|
-
const clippedDecision = decide();
|
|
818
|
+
const clippedDecision = decide(clipRoom, targetBand);
|
|
762
819
|
if (clippedDecision.kind === "fault")
|
|
763
820
|
for (const clip of round)
|
|
764
821
|
clips.delete(clip.toolCallId);
|
|
@@ -767,10 +824,23 @@ export async function installCompaction(pi, options = {}) {
|
|
|
767
824
|
break;
|
|
768
825
|
}
|
|
769
826
|
}
|
|
770
|
-
}
|
|
827
|
+
};
|
|
828
|
+
tryClips(room, band);
|
|
771
829
|
const before = measure(saved);
|
|
772
|
-
|
|
830
|
+
observeBudget(before + mandatory, ctx);
|
|
831
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
832
|
+
const { hardLine } = limits(ctx);
|
|
833
|
+
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
834
|
+
if (fits(before + mandatory, hardLine, ctx))
|
|
835
|
+
return;
|
|
836
|
+
decision = decide(hardLine, hardLine);
|
|
837
|
+
tryClips(hardLine, hardLine);
|
|
838
|
+
}
|
|
839
|
+
if (decision.kind === "fault" && reason === "tool-loop") {
|
|
840
|
+
if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx))
|
|
841
|
+
throw promptRefusal();
|
|
773
842
|
throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
843
|
+
}
|
|
774
844
|
if (decision.kind !== "cut")
|
|
775
845
|
return;
|
|
776
846
|
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, reason, before, true, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
|
|
@@ -905,8 +975,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
905
975
|
const messages = historyToolsEnabled(ctx) ? historyToolDeclarations(event.messages, toolShapes(pi.getActiveTools()), true) : event.messages;
|
|
906
976
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
907
977
|
const tokens = system + conversationTokens(messages);
|
|
908
|
-
|
|
978
|
+
observeBudget(tokens, ctx);
|
|
979
|
+
estimatedTokens = corrected(tokens, ctx);
|
|
980
|
+
const { room, hardLine } = limits(ctx);
|
|
981
|
+
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
982
|
+
throw new Refusal("Prompt check failed: the request is missing the whole text of the prompt that started this run. Shorten the prompt or choose a model with a larger context window, then retry");
|
|
983
|
+
if (!fits(tokens, hardLine, ctx))
|
|
909
984
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
985
|
+
if (estimatedTokens > room)
|
|
986
|
+
receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
910
987
|
if (recorder.enabled && !recorder.waitingForCut)
|
|
911
988
|
recorder.measureInline(() => {
|
|
912
989
|
recorder.request();
|
|
@@ -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 = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { Messages } from './budget.js';
|
|
3
|
+
/** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
|
|
4
|
+
export declare function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number;
|
|
5
|
+
/** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
|
|
6
|
+
export declare function carriesPrompt(messages: Messages, prompt: string): boolean;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { isContextOverflow } from '@earendil-works/pi-ai';
|
|
2
|
+
import { parseRecords, splitSummary } from './tuck/core/transcript.js';
|
|
3
|
+
/** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
|
|
4
|
+
export function branchReplyFloor(branch, reserve) {
|
|
5
|
+
let largest = 4_096;
|
|
6
|
+
for (const entry of branch) {
|
|
7
|
+
if (entry.type !== 'message' || entry.message.role !== 'assistant')
|
|
8
|
+
continue;
|
|
9
|
+
const message = entry.message;
|
|
10
|
+
if (message.stopReason === 'length' || message.stopReason === 'error' && isContextOverflow(message))
|
|
11
|
+
return reserve;
|
|
12
|
+
const output = message.usage?.output;
|
|
13
|
+
if (typeof output === 'number' && Number.isFinite(output) && output > largest)
|
|
14
|
+
largest = output;
|
|
15
|
+
}
|
|
16
|
+
return Math.min(reserve, Math.ceil(largest));
|
|
17
|
+
}
|
|
18
|
+
/** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
|
|
19
|
+
export function carriesPrompt(messages, prompt) {
|
|
20
|
+
if (!prompt.length)
|
|
21
|
+
return true;
|
|
22
|
+
return messages.some(message => {
|
|
23
|
+
if (message.role === 'user') {
|
|
24
|
+
const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
|
|
25
|
+
return text.includes(prompt);
|
|
26
|
+
}
|
|
27
|
+
if (message.role !== 'compactionSummary')
|
|
28
|
+
return false;
|
|
29
|
+
const split = splitSummary(message.summary);
|
|
30
|
+
return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record => record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
|
|
31
|
+
});
|
|
32
|
+
}
|
package/dist/src/tuck.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export interface Tuck {
|
|
|
74
74
|
import { compileDialogue } from "./tuck/core/summarize.js";
|
|
75
75
|
export declare const tuck: Tuck;
|
|
76
76
|
/** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
|
|
77
|
-
export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean): Draft;
|
|
77
|
+
export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean, protectedPromptEntryId?: string): Draft;
|
|
78
78
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
79
79
|
/** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
|
|
80
80
|
* a run a custom message starts); the session has moved on from an ended run.
|
|
@@ -86,6 +86,7 @@ export declare function tuckObservedDraft(tuck: Tuck, input: {
|
|
|
86
86
|
operatorManual?: boolean;
|
|
87
87
|
newPrompt?: boolean;
|
|
88
88
|
reRenderSummary?: boolean;
|
|
89
|
+
protectedPromptEntryId?: string;
|
|
89
90
|
}, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean): Draft;
|
|
90
91
|
/** Numeric references use the original session index, then intersect with readable current-branch entries. */
|
|
91
92
|
export declare function tuckRecall(tuck: Tuck, entries: readonly SessionEntry[], branch: readonly SessionEntry[], input: RecallOptions & {
|
package/dist/src/tuck.js
CHANGED
|
@@ -19,8 +19,8 @@ import { prepareKeywordQuery } from "./tuck/core/keyword-query.js";
|
|
|
19
19
|
import { searchEntriesDetailed } from "./tuck/core/search-entries.js";
|
|
20
20
|
export const tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById, compileDialogue, prepareKeywordQuery, searchEntriesDetailed };
|
|
21
21
|
/** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
|
|
22
|
-
export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
|
|
23
|
-
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
22
|
+
export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false, protectedPromptEntryId) {
|
|
23
|
+
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt, protectedPromptEntryId }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
24
24
|
}
|
|
25
25
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
26
26
|
/** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
|
|
@@ -115,6 +115,9 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
115
115
|
const current = entries[userPosition];
|
|
116
116
|
const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
|
|
117
117
|
const textOnly = isTextOnly(userContent);
|
|
118
|
+
// Steering can become the latest user source while the run still owns its original prompt.
|
|
119
|
+
const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId && entry.type === "message" && entry.message.role === "user");
|
|
120
|
+
const starting = entries[startingPosition];
|
|
118
121
|
// Anchor the actual first source only; a native omission never promotes a later prompt.
|
|
119
122
|
const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
|
|
120
123
|
const firstUserPosition = entries.findIndex(entry => entry.id === firstUser?.id && entry.type === "message");
|
|
@@ -277,8 +280,10 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
277
280
|
};
|
|
278
281
|
if (userPosition >= 0 && userPosition < boundary && textOnly)
|
|
279
282
|
add(current, Infinity);
|
|
283
|
+
if (startingPosition >= 0 && startingPosition < boundary && starting?.id !== current?.id)
|
|
284
|
+
add(starting, Infinity);
|
|
280
285
|
for (const entry of anchors)
|
|
281
|
-
if (entry.id !== current?.id && positions.get(entry.id) < boundary)
|
|
286
|
+
if (entry.id !== current?.id && entry.id !== starting?.id && positions.get(entry.id) < boundary)
|
|
282
287
|
add(entry, length);
|
|
283
288
|
const omissions = [];
|
|
284
289
|
for (const role of ["user", "assistant", "toolResult"]) {
|
package/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { VERSION, getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join, sep } from "node:path";
|
|
4
4
|
import { installCompaction } from "./src/compaction";
|
|
5
5
|
import { validCutAt } from "./src/budget";
|
|
6
6
|
import { createSemanticRecall } from "./src/semantic-recall";
|
|
7
|
-
import { defaultRoot } from "./src/assets";
|
|
7
|
+
import { defaultRoot, runtimeConfigPath } from "./src/assets";
|
|
8
8
|
import { ensureSemanticRuntime } from "./src/setup";
|
|
9
9
|
|
|
10
10
|
export type { CompactionOptions } from "./src/compaction";
|
|
@@ -49,12 +49,19 @@ export default async function (pi: ExtensionAPI) {
|
|
|
49
49
|
if (settings.semanticRecall === false) return;
|
|
50
50
|
let session = 0, said = "", setup: Promise<unknown> | undefined, announced = false;
|
|
51
51
|
// A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
|
|
52
|
-
|
|
52
|
+
// With no UI (pi -p), Pi shows no notices, so a settings error or an off or failing semantic recall would go unsaid: write each to stderr
|
|
53
|
+
// instead, once per session. stderr is the operator's; it never enters the model's context.
|
|
54
|
+
const stderrSaid = new Set<string>();
|
|
55
|
+
const say = (ctx: ExtensionContext, message: string, level: "info" | "warning") => {
|
|
56
|
+
if (!ctx.hasUI) { if (!stderrSaid.has(message)) { stderrSaid.add(message); try { process.stderr.write(message + "\n"); } catch { /* closed */ } } return; }
|
|
57
|
+
try { ctx.ui.notify(message, level); } catch { /* ended */ }
|
|
58
|
+
};
|
|
53
59
|
const once = (ctx: ExtensionContext, message: string, level: "info" | "warning") => { if (said !== message) { said = message; say(ctx, message, level); } };
|
|
54
60
|
pi.on("session_shutdown", () => { session++; });
|
|
55
61
|
pi.on("session_start", (_event, ctx) => {
|
|
56
62
|
const current = ++session;
|
|
57
63
|
off = false;
|
|
64
|
+
stderrSaid.clear();
|
|
58
65
|
if (settings.error) once(ctx, `pi-tuck: ${settings.error}; using the defaults.`, "warning");
|
|
59
66
|
if (!appleSilicon)
|
|
60
67
|
return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon; compaction and word recall work.", "info");
|
|
@@ -62,8 +69,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
62
69
|
// agent folder, such as the home folder.
|
|
63
70
|
if (within(real(getAgentDir()), real(ctx.cwd)))
|
|
64
71
|
return once(ctx, "pi-tuck: semantic recall is off here because Pi's agent folder is inside this folder; open Pi in a project folder to use it.", "info");
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
// A run with no UI (pi -p, RPC, JSON) uses a runtime that is already installed: that needs no download. It does not start a first-time
|
|
73
|
+
// setup, which would outlast a one-shot run, and it says why, so tuck_search and /tuck name the real state.
|
|
74
|
+
const installed = existsSync(runtimeConfigPath(defaultRoot()));
|
|
75
|
+
if (!ctx.hasUI && !installed) {
|
|
76
|
+
semanticRecall.refuse("Semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works meanwhile");
|
|
77
|
+
return say(ctx, "pi-tuck: semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works.", "warning");
|
|
78
|
+
}
|
|
79
|
+
if (!semanticRecall.status().configured)
|
|
80
|
+
semanticRecall.refuse(installed ? "Semantic recall is starting; word recall works meanwhile" : "Semantic recall is being set up; word recall works meanwhile");
|
|
67
81
|
setup ??= ensureSemanticRuntime(defaultRoot(), message => { announced = true; say(ctx, message, "info"); });
|
|
68
82
|
setup.then(async () => {
|
|
69
83
|
if (current !== session || off) return;
|
package/package.json
CHANGED
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
|
package/src/compaction.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { textOf } from "./tuck/core/content.js";
|
|
2
|
+
import { branchReplyFloor, carriesPrompt } from "./request-capacity.js";
|
|
3
|
+
import { BUILD_ID } from "./build-id.js";
|
|
1
4
|
import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
|
|
2
5
|
import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
|
|
3
6
|
import { createHash } from "node:crypto";
|
|
@@ -102,15 +105,18 @@ export interface CompactionOptions {
|
|
|
102
105
|
onSemanticSetting?: (enabled: boolean) => Promise<unknown>;
|
|
103
106
|
semanticStatus?: () => unknown;
|
|
104
107
|
diagnostics?: RecorderOptions;
|
|
108
|
+
/** Optional read-only observation of actual corrected budget checks; no request content. */
|
|
109
|
+
observeBudget?: (measurement: { rawTokens: number; tokens: number; factor: number; response: number; room: number; hardLine?: number; context: ExtensionContext }) => void;
|
|
105
110
|
/** Optional estimated complete-input ceiling, separate from physical output reservation.
|
|
106
111
|
* The host owns final serialization/transport counting; this is not an exact wire-token cap. */
|
|
107
112
|
activeInputTokens?: number | ((ctx: ExtensionContext) => number);
|
|
108
113
|
/** An explicit cut point: a fraction of the input room, above 0 and at most 1. A cut is triggered at, and preferably planned to, the band,
|
|
109
|
-
* which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The
|
|
110
|
-
*
|
|
114
|
+
* which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The room
|
|
115
|
+
* owns cut targets; standalone final capacity admission uses the branch reply floor. If omitted, the band adapts to the last cut. */
|
|
111
116
|
cutAt?: number;
|
|
112
117
|
/** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
|
|
113
|
-
* Standalone
|
|
118
|
+
* Standalone cuts against Pi's reserveTokens, then admits up to the window less max(8192, largest branch output), capped at that reserve.
|
|
119
|
+
* After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
114
120
|
* Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
|
|
115
121
|
requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
|
|
116
122
|
/** The exact messages the host's `context` hook adds to every final request beyond the saved conversation (for example a role
|
|
@@ -208,6 +214,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
208
214
|
} });
|
|
209
215
|
// Pi's native start/settled events bracket the run and its post-turn cuts.
|
|
210
216
|
let admittedRun = false;
|
|
217
|
+
let runPrompt: string | undefined;
|
|
218
|
+
const protectedPrompt = (ctx: ExtensionContext) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry =>
|
|
219
|
+
entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt!))?.id : undefined;
|
|
220
|
+
const promptRefusal = () => new Refusal("Prompt check failed: the whole starting prompt cannot fit after cutting to the band and room, shrinking the summary and clipping fresh results. Shorten the prompt or choose a model with a larger context window, then retry");
|
|
221
|
+
let estimatedTokens = 0;
|
|
211
222
|
// One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
|
|
212
223
|
// start, or a model switch) starts with the window its branch's newest receipt recorded for that identity, else that receipt's factor, else the model's measured undercount or 1 (`seedWindow`).
|
|
213
224
|
let identity = "", factor = 1, ratios: number[] = [];
|
|
@@ -221,6 +232,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
221
232
|
// The factor is max(1, the last CALIBRATION_WINDOW paired ratios, overflow bounds and seed): it rises at once and a stale high ratio ages out.
|
|
222
233
|
const learn = (value: number) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW) ratios.shift(); factor = Math.max(1, ...ratios); };
|
|
223
234
|
const corrected = (tokens: number, ctx: ExtensionContext) => { identityFor(ctx); return Math.ceil(tokens * factor); };
|
|
235
|
+
const observeBudget = (rawTokens: number, ctx: ExtensionContext) => {
|
|
236
|
+
if (options.observeBudget) options.observeBudget({ rawTokens, tokens: corrected(rawTokens, ctx), factor, ...limits(ctx), context: ctx });
|
|
237
|
+
};
|
|
224
238
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
225
239
|
const planReserve = (target: Target, room: number, band: number, mandatory: number, ctx: ExtensionContext) => {
|
|
226
240
|
identityFor(ctx);
|
|
@@ -263,7 +277,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
263
277
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
264
278
|
});
|
|
265
279
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
266
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
280
|
+
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
267
281
|
const active = () => options.active?.() ?? true;
|
|
268
282
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
269
283
|
// The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
|
|
@@ -288,10 +302,12 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
288
302
|
throw new Error("Invalid active-input token ceiling");
|
|
289
303
|
return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
|
|
290
304
|
};
|
|
291
|
-
//
|
|
305
|
+
// Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
|
|
292
306
|
const limits = (ctx: ExtensionContext) => {
|
|
293
307
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
294
|
-
|
|
308
|
+
const floor = options.requestReserve ? response : branchReplyFloor(ctx.sessionManager.getBranch(), response);
|
|
309
|
+
const hardLine = Math.min((ctx.model?.contextWindow ?? 0) - floor, lastCeiling ?? Infinity);
|
|
310
|
+
return { response, room, hardLine };
|
|
295
311
|
};
|
|
296
312
|
const decisionBand = (ctx: ExtensionContext, room: number, system: number) => {
|
|
297
313
|
const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
|
|
@@ -331,12 +347,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
331
347
|
// Where the branch ended when the prompt's cut was planned: the prompt is the entry Pi appends there. A cut that keeps nothing of the conversation
|
|
332
348
|
// before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
|
|
333
349
|
let promptCutAt = 0;
|
|
334
|
-
const keptFrom = (cut: CutEntry, ctx: ExtensionContext) => cut.firstKeptEntryId ||
|
|
350
|
+
const keptFrom = (cut: CutEntry, ctx: ExtensionContext) => cut.firstKeptEntryId ||
|
|
351
|
+
ctx.sessionManager.getBranch().slice(promptCutAt).find(entry => entry.type !== "custom" || entry.customType !== "pi-tuck-capacity")?.id || "";
|
|
335
352
|
let failure: Error | undefined;
|
|
353
|
+
let refusalReceipted = false;
|
|
336
354
|
let pendingBoundaryCommit = false;
|
|
337
355
|
// Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
|
|
338
356
|
// whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
|
|
339
357
|
const clips = new Map<string, Clip>();
|
|
358
|
+
const receipt = (kind: "refusal" | "above-room", reason: string, ctx: ExtensionContext) => {
|
|
359
|
+
let room: number | null = null, hardLine: number | null = null;
|
|
360
|
+
try { ({ room, hardLine } = limits(ctx)); } catch { /* Invalid configuration has no usable capacity. Preserve its original failure. */ }
|
|
361
|
+
pi.appendEntry("pi-tuck-capacity", { kind, reason, estimatedTokens, room, hardLine, buildId: BUILD_ID });
|
|
362
|
+
};
|
|
340
363
|
const fail = (error: unknown, ctx: ExtensionContext) => {
|
|
341
364
|
recorder.stop("native-attempt-failed");
|
|
342
365
|
// Every refusal says why, once per attempt: a capacity error with what the user can change, a cut Pi failed to commit with what that
|
|
@@ -347,7 +370,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
347
370
|
pendingBoundaryCommit = false;
|
|
348
371
|
pending = undefined;
|
|
349
372
|
promptCut = undefined;
|
|
350
|
-
try {
|
|
373
|
+
try {
|
|
374
|
+
if (!refusalReceipted) { refusalReceipted = true; receipt("refusal", failure.message, ctx); }
|
|
375
|
+
options.fail?.(error);
|
|
376
|
+
} finally {
|
|
351
377
|
try { if (notify) ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
|
|
352
378
|
? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
|
|
353
379
|
: `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error"); }
|
|
@@ -356,7 +382,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
356
382
|
};
|
|
357
383
|
// The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
358
384
|
// input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
|
|
359
|
-
const forgetFailure = () => { if (!(failure instanceof CommitFault)) failure = undefined; };
|
|
385
|
+
const forgetFailure = () => { refusalReceipted = false; if (!(failure instanceof CommitFault)) failure = undefined; };
|
|
360
386
|
const access = () => {
|
|
361
387
|
if (failure) throw failure;
|
|
362
388
|
hostAccess();
|
|
@@ -444,6 +470,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
444
470
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
445
471
|
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
446
472
|
const before = plannerMeasureFor(ctx)(saved);
|
|
473
|
+
observeBudget(before + mandatory, ctx);
|
|
447
474
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
448
475
|
// it: a basis this compactor does not plan on. It may ask for a cut on a request that fits. This compactor cuts only where its own
|
|
449
476
|
// trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
|
|
@@ -455,25 +482,33 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
455
482
|
thresholdOpportunistic = gate.opportunistic;
|
|
456
483
|
}
|
|
457
484
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
458
|
-
const
|
|
459
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
485
|
+
const decide = (targetRoom = room, targetBand = band) => chooseCut({
|
|
486
|
+
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
460
487
|
opportunistic: thresholdOpportunistic,
|
|
461
488
|
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
462
489
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
|
|
463
|
-
const reserve = planReserve(target,
|
|
490
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
464
491
|
// Every cut asked while no run is admitted (an operator's cut) is planned for a run that has not begun: the session has moved on
|
|
465
492
|
// from any run that ended. A retry is inside its run.
|
|
466
|
-
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
493
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
|
|
467
494
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
468
495
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
469
496
|
},
|
|
470
497
|
});
|
|
498
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
499
|
+
let decision = decide();
|
|
500
|
+
const { hardLine } = limits(ctx);
|
|
501
|
+
if (!operatorManual && hardLine > room && decision.kind === "fault") {
|
|
502
|
+
if (fits(before + mandatory, hardLine, ctx)) return { cancel: true };
|
|
503
|
+
decision = decide(hardLine, hardLine);
|
|
504
|
+
}
|
|
471
505
|
if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
|
|
472
506
|
retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
|
|
473
507
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
474
508
|
if (decision.kind === "none") return { cancel: true };
|
|
475
509
|
recorder.stop("no-cut");
|
|
476
510
|
ctx.ui.notify(decision.message, "warning");
|
|
511
|
+
if (operatorManual) receipt("refusal", decision.message, ctx);
|
|
477
512
|
if (!operatorManual) fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
|
|
478
513
|
return { cancel: true };
|
|
479
514
|
} catch (error) {
|
|
@@ -481,7 +516,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
481
516
|
}
|
|
482
517
|
});
|
|
483
518
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
484
|
-
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"; });
|
|
519
|
+
pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; refusalReceipted = false; admittedRun = false; runPrompt = undefined; estimatedTokens = 0; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
485
520
|
const historyToolsEnabled = (ctx: ExtensionContext) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
|
|
486
521
|
const syncHistoryTools = (ctx: ExtensionContext) => { if (active()) applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
487
522
|
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
@@ -516,27 +551,36 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
516
551
|
const band = decisionBand(ctx, room, system);
|
|
517
552
|
const host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
518
553
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
519
|
-
|
|
554
|
+
observeBudget(before + mandatory, ctx);
|
|
555
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
556
|
+
if (!fits(incoming, limits(ctx).hardLine, ctx))
|
|
520
557
|
throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
|
|
521
558
|
// A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
|
|
522
559
|
// help: leave it to the complete-context guard, which fails closed.
|
|
523
560
|
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
524
|
-
if (gate.kind
|
|
561
|
+
if (gate.kind === "none") return;
|
|
525
562
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
526
|
-
const
|
|
527
|
-
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic
|
|
563
|
+
const decide = (targetRoom: number, targetBand: number, opportunistic: boolean) => chooseCut({
|
|
564
|
+
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic,
|
|
528
565
|
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
529
566
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
530
|
-
const reserve = planReserve(target,
|
|
567
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
531
568
|
// Only a due cut re-renders a summary that sits alone in the session: inside the band a cut is opportunistic, and a smaller summary
|
|
532
569
|
// there buys nothing the request needed and costs history the next recall would have found.
|
|
533
|
-
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !
|
|
570
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
534
571
|
if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
535
572
|
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
536
573
|
assertNativeToolRounds(messages);
|
|
537
574
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
538
575
|
},
|
|
539
576
|
});
|
|
577
|
+
let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" as const };
|
|
578
|
+
const { hardLine } = limits(ctx);
|
|
579
|
+
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
580
|
+
if (fits(before + mandatory, hardLine, ctx)) return;
|
|
581
|
+
decision = fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
|
|
582
|
+
}
|
|
583
|
+
if (decision.kind === "defer") return;
|
|
540
584
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
541
585
|
if (decision.kind === "none") return;
|
|
542
586
|
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
@@ -547,6 +591,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
547
591
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
548
592
|
const prompted = phase === "decided";
|
|
549
593
|
phase = "starting";
|
|
594
|
+
runPrompt = event.messageType === "custom" ? undefined : event.prompt;
|
|
550
595
|
try {
|
|
551
596
|
syncHistoryTools(ctx);
|
|
552
597
|
if (prompted) access(); // decided on input; a failure latched there still stops the run
|
|
@@ -622,19 +667,23 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
622
667
|
// request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
|
|
623
668
|
let previews: Messages[] = [];
|
|
624
669
|
let floorKept: number | undefined;
|
|
625
|
-
|
|
670
|
+
let wholePromptFloor: number | undefined;
|
|
671
|
+
const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
|
|
672
|
+
const decide = (targetRoom = room, targetBand = band) => {
|
|
626
673
|
previews = [];
|
|
627
674
|
floorKept = undefined;
|
|
675
|
+
wholePromptFloor = undefined;
|
|
628
676
|
const before = measure(saved);
|
|
629
|
-
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
677
|
+
const gate = gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
630
678
|
if (gate.kind !== "plan") return gate;
|
|
631
679
|
return chooseCut({
|
|
632
|
-
opportunistic: gate.opportunistic, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
680
|
+
opportunistic: gate.opportunistic, mandatory, before, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
633
681
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
634
682
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
635
|
-
const reserve = planReserve(target,
|
|
636
|
-
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
683
|
+
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
684
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary, protectedPromptEntryId: protectedId }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
637
685
|
if (!draft.compaction) {
|
|
686
|
+
if (protectedId && draft.floorMessages) wholePromptFloor = measure(draft.floorMessages) + mandatory;
|
|
638
687
|
// The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
|
|
639
688
|
if (draft.floorMessages && event.toolResults) {
|
|
640
689
|
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
@@ -662,23 +711,36 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
662
711
|
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
663
712
|
// built, so the second budget is the cut's own floor and not a new formula. When nothing is clipped or the decision still faults, the
|
|
664
713
|
// refusal stands.
|
|
665
|
-
|
|
714
|
+
const tryClips = (clipRoom: number, targetBand: number) => {
|
|
715
|
+
if (decision.kind !== "fault" || !event.toolResults) return;
|
|
666
716
|
const branch = ctx.sessionManager.getBranch();
|
|
667
717
|
const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
668
718
|
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
669
719
|
const kept = previews.length ? Math.max(...previews.map(preview => measure(preview.filter(message =>
|
|
670
720
|
!(message.role === "toolResult" && fresh.has((message as ToolResultMessage).toolCallId)))))) : floorKept;
|
|
671
|
-
for (const budget of clipBudgets(
|
|
721
|
+
for (const budget of clipBudgets(clipRoom, factor, mandatory, kept)) {
|
|
672
722
|
const round = clipFor(event.toolResults, entryIdOf, budget);
|
|
673
723
|
if (!round.length) continue;
|
|
674
724
|
for (const clip of round) clips.set(clip.toolCallId, clip);
|
|
675
|
-
const clippedDecision = decide();
|
|
725
|
+
const clippedDecision = decide(clipRoom, targetBand);
|
|
676
726
|
if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
|
|
677
727
|
else { decision = clippedDecision; break; }
|
|
678
728
|
}
|
|
679
|
-
}
|
|
729
|
+
};
|
|
730
|
+
tryClips(room, band);
|
|
680
731
|
const before = measure(saved);
|
|
681
|
-
|
|
732
|
+
observeBudget(before + mandatory, ctx);
|
|
733
|
+
estimatedTokens = corrected(before + mandatory, ctx);
|
|
734
|
+
const { hardLine } = limits(ctx);
|
|
735
|
+
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
736
|
+
if (fits(before + mandatory, hardLine, ctx)) return;
|
|
737
|
+
decision = decide(hardLine, hardLine);
|
|
738
|
+
tryClips(hardLine, hardLine);
|
|
739
|
+
}
|
|
740
|
+
if (decision.kind === "fault" && reason === "tool-loop") {
|
|
741
|
+
if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx)) throw promptRefusal();
|
|
742
|
+
throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
743
|
+
}
|
|
682
744
|
if (decision.kind !== "cut") return;
|
|
683
745
|
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, reason, before, true, false, previousSummary,
|
|
684
746
|
{ response, host, mandatory, target: decision.target, margin: room - band });
|
|
@@ -785,8 +847,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
785
847
|
const messages = historyToolsEnabled(ctx) ? historyToolDeclarations(event.messages, toolShapes(pi.getActiveTools()), true) : event.messages;
|
|
786
848
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
787
849
|
const tokens = system + conversationTokens(messages);
|
|
788
|
-
|
|
850
|
+
observeBudget(tokens, ctx);
|
|
851
|
+
estimatedTokens = corrected(tokens, ctx);
|
|
852
|
+
const { room, hardLine } = limits(ctx);
|
|
853
|
+
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
854
|
+
throw new Refusal("Prompt check failed: the request is missing the whole text of the prompt that started this run. Shorten the prompt or choose a model with a larger context window, then retry");
|
|
855
|
+
if (!fits(tokens, hardLine, ctx))
|
|
789
856
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
857
|
+
if (estimatedTokens > room) receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
790
858
|
if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
|
|
791
859
|
recorder.request();
|
|
792
860
|
// No system prompt or hidden assistant blocks; no provider payload/headers.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { isContextOverflow } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { Messages } from './budget.js';
|
|
4
|
+
import { parseRecords, splitSummary } from './tuck/core/transcript.js';
|
|
5
|
+
|
|
6
|
+
/** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
|
|
7
|
+
export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
|
|
8
|
+
let largest = 4_096;
|
|
9
|
+
for (const entry of branch) {
|
|
10
|
+
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
|
|
11
|
+
const message = entry.message;
|
|
12
|
+
if (message.stopReason === 'length' || message.stopReason === 'error' && isContextOverflow(message)) return reserve;
|
|
13
|
+
const output = message.usage?.output;
|
|
14
|
+
if (typeof output === 'number' && Number.isFinite(output) && output > largest) largest = output;
|
|
15
|
+
}
|
|
16
|
+
return Math.min(reserve, Math.ceil(largest));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
|
|
20
|
+
export function carriesPrompt(messages: Messages, prompt: string): boolean {
|
|
21
|
+
if (!prompt.length) return true;
|
|
22
|
+
return messages.some(message => {
|
|
23
|
+
if (message.role === 'user') {
|
|
24
|
+
const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
|
|
25
|
+
return text.includes(prompt);
|
|
26
|
+
}
|
|
27
|
+
if (message.role !== 'compactionSummary') return false;
|
|
28
|
+
const split = splitSummary(message.summary);
|
|
29
|
+
return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record =>
|
|
30
|
+
record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
|
|
31
|
+
});
|
|
32
|
+
}
|
package/src/tuck.ts
CHANGED
|
@@ -52,14 +52,14 @@ import { prepareKeywordQuery } from "./tuck/core/keyword-query";
|
|
|
52
52
|
import { searchEntriesDetailed } from "./tuck/core/search-entries";
|
|
53
53
|
export const tuck: Tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById, compileDialogue, prepareKeywordQuery, searchEntriesDetailed };
|
|
54
54
|
/** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
|
|
55
|
-
export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
|
|
56
|
-
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
55
|
+
export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false, protectedPromptEntryId?: string) {
|
|
56
|
+
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt, protectedPromptEntryId }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
57
57
|
}
|
|
58
58
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
59
59
|
/** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
|
|
60
60
|
* a run a custom message starts); the session has moved on from an ended run.
|
|
61
61
|
* `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
|
|
62
|
-
export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean; reRenderSummary?: boolean }, ctx: ExtensionContext,
|
|
62
|
+
export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean; reRenderSummary?: boolean; protectedPromptEntryId?: string }, ctx: ExtensionContext,
|
|
63
63
|
reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true): Draft {
|
|
64
64
|
const { isHistorySource, readable } = createRecall(policy);
|
|
65
65
|
const projected = ctx.sessionManager.buildSessionProjection();
|
|
@@ -143,6 +143,9 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
143
143
|
const current = entries[userPosition];
|
|
144
144
|
const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
|
|
145
145
|
const textOnly = isTextOnly(userContent);
|
|
146
|
+
// Steering can become the latest user source while the run still owns its original prompt.
|
|
147
|
+
const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId && entry.type === "message" && entry.message.role === "user");
|
|
148
|
+
const starting = entries[startingPosition];
|
|
146
149
|
// Anchor the actual first source only; a native omission never promotes a later prompt.
|
|
147
150
|
const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
|
|
148
151
|
const firstUserPosition = entries.findIndex(entry => entry.id === firstUser?.id && entry.type === "message");
|
|
@@ -280,7 +283,8 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
280
283
|
if (index !== undefined && quoted.records.length) { selected.add(index); if (quoted.whole) whole.add(index); }
|
|
281
284
|
};
|
|
282
285
|
if (userPosition >= 0 && userPosition < boundary && textOnly) add(current!, Infinity);
|
|
283
|
-
|
|
286
|
+
if (startingPosition >= 0 && startingPosition < boundary && starting?.id !== current?.id) add(starting!, Infinity);
|
|
287
|
+
for (const entry of anchors) if (entry.id !== current?.id && entry.id !== starting?.id && positions.get(entry.id)! < boundary) add(entry, length);
|
|
284
288
|
const omissions: TranscriptRecord[] = [];
|
|
285
289
|
for (const role of ["user", "assistant", "toolResult"] as const) {
|
|
286
290
|
const list = units[role], prefix = prefixes[role];
|