pi-tuck 1.0.21 → 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 +44 -0
- package/README.md +4 -3
- 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/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,50 @@
|
|
|
3
3
|
All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [1.0.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
|
+
|
|
6
50
|
## [1.0.21] - 2026-11-09
|
|
7
51
|
|
|
8
52
|
### 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
|
|
|
@@ -129,6 +129,7 @@ Known limits today:
|
|
|
129
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).
|
|
132
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).
|
|
133
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).
|
|
134
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).
|
|
@@ -213,7 +214,7 @@ The first time a session starts, pi-tuck downloads what it needs, once, about 39
|
|
|
213
214
|
| llama.cpp release b11476 for macOS arm64 (12 MB) | github.com/ggml-org/llama.cpp |
|
|
214
215
|
| CPython 3.13 (25 MB) and 16 Python packages for the tokenizer (9 MB) | github.com/astral-sh/python-build-standalone, pypi.org |
|
|
215
216
|
|
|
216
|
-
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.
|
|
217
218
|
|
|
218
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.
|
|
219
220
|
|
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/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];
|