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 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.20`.
68
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.20`.
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. One-shot runs (`pi -p`) do not start the setup.
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
 
@@ -1 +1 @@
1
- export declare const BUILD_ID = "82df9e9954f1d33eb2d6c6c653220c13bd3c36c2f0250afa2a17ab51295cfb0c";
1
+ export declare const BUILD_ID = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
@@ -1 +1 @@
1
- export const BUILD_ID = "82df9e9954f1d33eb2d6c6c653220c13bd3c36c2f0250afa2a17ab51295cfb0c";
1
+ export const BUILD_ID = "37e955d83a1e9237381f09fad8cd2aad1f2c6489ddbe51c58af227dbb46ddf3c";
@@ -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 exact room
40
- * stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
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 uses Pi's reserveTokens until a length stop or a context-overflow refusal on the current branch, then also holds the catalogue maximum.
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;
@@ -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
- // Every throw, fault and complete-context guard uses the exact room.
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
- return { response, room };
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 || ctx.sessionManager.getBranch()[promptCutAt]?.id || "";
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 decision = chooseCut({
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, room, band, mandatory, ctx);
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
- if (!fits(incoming, room, ctx))
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 !== "plan")
633
+ if (gate.kind === "none")
594
634
  return;
595
635
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
596
- const decision = chooseCut({
597
- before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic: gate.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, room, band, mandatory, ctx);
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: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
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
- const decide = () => {
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, room, band, mandatory, ctx);
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
- if (decision.kind === "fault" && event.toolResults) {
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(room, factor, mandatory, kept)) {
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
- if (decision.kind === "fault" && reason === "tool-loop")
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
- if (!fits(tokens, limits(ctx).room, ctx))
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 = "82df9e9954f1d33eb2d6c6c653220c13bd3c36c2f0250afa2a17ab51295cfb0c";
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "82df9e9954f1d33eb2d6c6c653220c13bd3c36c2f0250afa2a17ab51295cfb0c";
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 exact room
110
- * stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
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 uses Pi's reserveTokens until a length stop or a context-overflow refusal on the current branch, then also holds the catalogue maximum.
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
- // Every throw, fault and complete-context guard uses the exact room.
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
- return { response, room };
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 || ctx.sessionManager.getBranch()[promptCutAt]?.id || "";
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 { options.fail?.(error); } finally {
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 decision = chooseCut({
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, room, band, mandatory, ctx);
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
- if (!fits(incoming, room, ctx))
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 !== "plan") return;
561
+ if (gate.kind === "none") return;
525
562
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
526
- const decision = chooseCut({
527
- before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic: gate.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, room, band, mandatory, ctx);
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: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
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
- const decide = () => {
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, room, band, mandatory, ctx);
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
- if (decision.kind === "fault" && event.toolResults) {
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(room, factor, mandatory, kept)) {
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
- if (decision.kind === "fault" && reason === "tool-loop") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
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
- if (!fits(tokens, limits(ctx).room, ctx))
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
- for (const entry of anchors) if (entry.id !== current?.id && positions.get(entry.id)! < boundary) add(entry, length);
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];