pi-tuck 1.0.9 → 1.0.11

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/src/compaction.ts CHANGED
@@ -5,10 +5,10 @@ import { exportSource } from "./source-export.js";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { assertNativeToolRounds } from "./protocol.js";
7
7
  import { projectedMessages } from "./omissions.js";
8
- import { applyClips, clipFor, type Clip } from "./clip.js";
8
+ import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
9
9
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt,
10
10
  type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
11
- import { isContextOverflow } from "@earendil-works/pi-ai";
11
+ import { isContextOverflow, type ToolResultMessage } from "@earendil-works/pi-ai";
12
12
  import { convertToLlm, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
13
13
  import { Type } from "typebox";
14
14
  import { Check } from "typebox/value";
@@ -54,6 +54,11 @@ class CapacityError extends Refusal {}
54
54
  export const refusalNotice = (message: string, factor: number): string =>
55
55
  message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
56
56
  (factor >= 1.5 ? ` This provider has counted up to ${factor.toFixed(1)}x the input pi-tuck estimates; through a router such as OpenRouter, a different upstream may count less.` : "");
57
+ /** What the operator hears when the provider refuses a request pi-tuck admitted as over its window: the bound that reply puts on the counting, and
58
+ * what pi-tuck did with it. A bound pi-tuck cannot carry (above the ceiling) says so rather than implying the next request is planned at it. */
59
+ export const overflowNotice = (contextWindow: number, bound: number, learned: boolean): string =>
60
+ `pi-tuck: the provider refused this request as over its ${contextWindow}-token window, so the input counted at least ${bound.toFixed(2)}x what pi-tuck estimated` +
61
+ (learned ? "; the next request is planned at that." : "; too far out to be a low estimate, so the factor is unchanged.");
57
62
  /** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
58
63
  export const CALIBRATION_CEILING = 1.5;
59
64
  /** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
@@ -232,16 +237,27 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
232
237
  !isDeepStrictEqual(convertToLlm(paired.messages), paired.snapshot)) return;
233
238
  // The provider refused this exact request for exceeding the window: its true count was at least window + 1, a lower bound on the ratio. A
234
239
  // bound above the ceiling says the estimate is not merely low, so it is not learned: the overflow ends the attempt as it does without it.
235
- if (overflow) { const candidate = (ctx.model.contextWindow + 1) / paired.tokens; if (candidate <= CALIBRATION_CEILING) learn(candidate); return; }
240
+ // Either way the operator hears it from pi-tuck. The estimate is what admitted the request, and a turn that ended in the provider's error
241
+ // with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
242
+ if (overflow) {
243
+ const bound = (ctx.model.contextWindow + 1) / paired.tokens;
244
+ const learned = bound <= CALIBRATION_CEILING;
245
+ ctx.ui.notify(overflowNotice(ctx.model.contextWindow, bound, learned), "warning");
246
+ if (learned) learn(bound);
247
+ return;
248
+ }
236
249
  const usage = message.usage;
237
250
  if (!usage) return;
238
251
  const counts = [usage.input, usage.cacheRead, usage.cacheWrite];
239
252
  if (!counts.every(n => Number.isSafeInteger(n) && n >= 0)) return;
240
253
  const input = counts.reduce((a, b) => a + b, 0), ratio = input / paired.tokens;
241
- if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
254
+ // A request that was answered cannot have held more than the model's window, so a report above it describes something that never happened.
255
+ // Learning from one sets the factor for the requests after it: a 60,000-token cache read reported in a 32,768-token window made a
256
+ // 6,000-token prompt refuse. The overflow branch above learns from a refusal, where the count is a lower bound, and the ceiling is unchanged.
257
+ if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
242
258
  });
243
259
  pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
244
- pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); });
260
+ pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
245
261
  const active = () => options.active?.() ?? true;
246
262
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
247
263
  // The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
@@ -284,8 +300,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
284
300
  const isNoWork = (message: string | undefined) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
285
301
  // A missing model is configuration, not capacity.
286
302
  const missingModel = () => new Error("No context model for the request budget");
287
- // Scoped to one awaited initial-prompt cut; never a persisted controller. It carries that decision's snapshot.
288
- let pendingCut: { mandatory: number; before: number; host: number; opportunistic: boolean; proposed: boolean } | undefined;
303
+ // A prompt's cut, until its run saves it at its first boundary: every request of that run carries it (the context hook). A run that ends
304
+ // before a boundary drops it, and the next prompt decides afresh.
305
+ type CutEntry = { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown };
306
+ let promptCut: CutEntry | undefined;
289
307
  let failure: Error | undefined;
290
308
  let pendingBoundaryCommit = false;
291
309
  // 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
@@ -293,13 +311,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
293
311
  const clips = new Map<string, Clip>();
294
312
  const fail = (error: unknown, ctx: ExtensionContext) => {
295
313
  recorder.stop("native-attempt-failed");
296
- const notify = !failure && error instanceof CapacityError;
314
+ // 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
315
+ // means for the session, anything else as it is (an attempt that ended with no word looked like a hang).
316
+ const notify = !failure;
297
317
  // A CommitFault stays whatever fails after it.
298
318
  if (!(failure instanceof CommitFault)) failure = error instanceof Error ? error : new Error(String(error));
299
319
  pendingBoundaryCommit = false;
300
320
  pending = undefined;
321
+ promptCut = undefined;
301
322
  try { options.fail?.(error); } finally {
302
- try { if (notify) ctx.ui.notify(refusalNotice(failure.message, factor), "error"); }
323
+ try { if (notify) ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
324
+ ? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
325
+ : `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error"); }
303
326
  finally { ctx.abort(); }
304
327
  }
305
328
  };
@@ -370,9 +393,15 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
370
393
  try {
371
394
  if (!active()) return { cancel: true };
372
395
  if (event.signal.aborted || ctx.signal?.aborted) { recorder.stop("cut-cancelled"); return { cancel: true }; }
373
- // Pi also labels the awaited idle incoming preflight "manual".
374
- const incoming = pendingCut;
375
- const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
396
+ // Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
397
+ // is the answer, and it is saved before the first request.
398
+ if (promptCut && !admittedRun && event.reason === "threshold") {
399
+ access();
400
+ const { type: _, ...compaction } = promptCut;
401
+ promptCut = undefined;
402
+ return { compaction };
403
+ }
404
+ const operatorManual = event.reason === "manual";
376
405
  if (operatorManual && !admittedRun) forgetFailure();
377
406
  access();
378
407
  if (event.customInstructions?.trim()) {
@@ -382,19 +411,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
382
411
  }
383
412
  const { response, room, band } = limits(ctx);
384
413
  const retain = operatorManual || admittedRun;
385
- // The incoming-prompt preflight brings its own snapshot. Any other cut (operator manual, Pi's threshold or overflow
386
- // recovery) charges what the request must carry whatever is cut: the system part and the host input.
414
+ // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
415
+ // cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
387
416
  const saved = ctx.sessionManager.buildSessionProjection().messages;
388
- const host = incoming ? incoming.host : hostTokens(ctx);
389
- const mandatory = incoming ? incoming.mandatory
390
- : systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
391
- const before = incoming ? incoming.before : plannerMeasureFor(ctx)(saved);
417
+ const host = hostTokens(ctx);
418
+ const mandatory = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
419
+ const before = plannerMeasureFor(ctx)(saved);
392
420
  // Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
393
421
  // 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
394
422
  // trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
395
423
  // cancel (Pi reports it as aborted), never a failure.
396
424
  let thresholdOpportunistic = false;
397
- if (!incoming && event.reason === "threshold") {
425
+ if (event.reason === "threshold") {
398
426
  const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
399
427
  if (gate.kind !== "plan") return { cancel: true };
400
428
  thresholdOpportunistic = gate.opportunistic;
@@ -402,19 +430,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
402
430
  // Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
403
431
  const decision = chooseCut({
404
432
  before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
405
- opportunistic: incoming?.opportunistic ?? thresholdOpportunistic,
406
- unreduced: incoming ? "Incoming Pi preflight compaction did not reduce context within the estimated request budget"
407
- : "Compaction did not reduce context within the estimated request budget",
433
+ opportunistic: thresholdOpportunistic,
434
+ unreduced: "Compaction did not reduce context within the estimated request budget",
408
435
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
409
436
  const reserve = planReserve(target, room, mandatory, ctx);
410
- // Every cut asked while no run is admitted (a prompt's preflight, an operator's cut, Pi's own cut before a run a custom message
411
- // starts) is planned for a run that has not begun: the session has moved on from any run that ended. A retry is inside its run.
412
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, Boolean(incoming) || operatorManual || !admittedRun);
437
+ // 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
438
+ // from any run that ended. A retry is inside its run.
439
+ const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
413
440
  return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
414
441
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
415
442
  },
416
443
  });
417
- if (decision.kind === "cut") { if (incoming) incoming.proposed = true; return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
444
+ if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
418
445
  retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
419
446
  // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
420
447
  if (decision.kind === "none") return { cancel: true };
@@ -427,57 +454,59 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
427
454
  }
428
455
  });
429
456
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
430
- pi.on("session_start", () => { clips.clear(); recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
431
- // The cut before a run. Pi 1.4 counts a run as started before before_agent_start, and ctx.compact() first aborts and waits for an
432
- // idle session, so a cut asked for there would wait on its own run. A prompt's cut is therefore made on `input`, while the session
433
- // is idle. before_agent_start decides only for a run no input started (one a custom message starts) and cuts only if the session is
434
- // still idle (Pi 1.1); otherwise it declines an opportunistic cut and fails a needed one closed.
457
+ 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"; });
458
+ // The session as it will be once a cut that is not saved yet is saved, and its conversation as the next request carries it.
459
+ const cutSession = (ctx: ExtensionContext, cut: CutEntry) => {
460
+ const header = ctx.sessionManager.getHeader();
461
+ if (!header) throw new Error("Missing native session header for the prompt's cut");
462
+ const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
463
+ preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
464
+ return preview;
465
+ };
466
+ const withCut = (ctx: ExtensionContext, cut: CutEntry) => cutSession(ctx, cut).buildSessionProjection().messages;
467
+ // The cut before a run, decided on `input` while the session is idle; before_agent_start decides only for a run no input started (one a
468
+ // custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
469
+ // keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
470
+ // Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
435
471
  const preflight = async (ctx: ExtensionContext, incomingPart: number, observedPrompt: string, tools: readonly string[]) => {
472
+ promptCut = undefined;
436
473
  if (!active()) return;
437
474
  access();
438
475
  if (!settingsFor().getCompactionSettings(ctx.model).enabled) return;
439
476
  if (!ctx.model) throw missingModel();
440
- const { room, band } = limits(ctx);
441
- // Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context
442
- // guard's responsibility. One snapshot per decision, taken again after the awaited cut.
443
- const snapshot = () => {
444
- const saved = ctx.sessionManager.buildSessionProjection().messages;
445
- const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
446
- const host = hostTokens(ctx);
447
- const before = measureFor(ctx)(saved);
448
- return { incoming: incomingPart + system, host, before, mandatory: incomingPart + system + host };
449
- };
450
- const first = snapshot(), total = first.before + first.mandatory;
451
- if (!fits(first.incoming, room, ctx))
477
+ const { response, room, band } = limits(ctx);
478
+ // Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
479
+ const saved = ctx.sessionManager.buildSessionProjection().messages;
480
+ const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
481
+ const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
482
+ const incoming = incomingPart + system, mandatory = incoming + host;
483
+ if (!fits(incoming, room, ctx))
452
484
  throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
453
485
  // A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
454
486
  // help: leave it to the complete-context guard, which fails closed.
455
- const gate = gateCut({ total, mandatory: first.mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
487
+ const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
456
488
  if (gate.kind !== "plan") return;
457
- if (!ctx.isIdle()) {
458
- if (gate.opportunistic) return;
459
- throw new CapacityError("The request needs a cut that Pi cannot make once the run has started");
460
- }
461
- const cut = pendingCut = { mandatory: first.mandatory, before: first.before, host: first.host, opportunistic: gate.opportunistic, proposed: false };
462
- try {
463
- // CompactOptions callbacks are Pi's supported terminal completion surface.
464
- await new Promise<void>((resolve, reject) => ctx.compact({ onComplete: () => resolve(),
465
- onError: error => {
466
- // An opportunistic cut is not needed to send the request, which is below the room: the guard decides. However it ends (the
467
- // planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
468
- // or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
469
- // A needed cut that Pi has nothing to make (no history older than its keepRecentTokens since the last cut) is a refusal of
470
- // this request, which ends with it: as a fault it stayed, and every later prompt of the session failed on it.
471
- if (cut.opportunistic && !failure && !cut.proposed) resolve();
472
- else if (!cut.proposed && isNoWork(error.message)) reject(new CapacityError(`The request needs a cut and Pi has none to make (${error.message})`));
473
- else reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : cut.proposed ? new CommitFault(error.message) : error);
474
- } }));
475
- } finally { pendingCut = undefined; }
476
- access();
477
- // The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
478
- const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
479
- if (!cut.opportunistic && (after >= total || !afterFits))
480
- throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
489
+ const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
490
+ const decision = chooseCut({
491
+ before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
492
+ unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
493
+ plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
494
+ const reserve = planReserve(target, room, mandatory, ctx);
495
+ // 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
496
+ // there buys nothing the request needed and costs history the next recall would have found.
497
+ const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
498
+ if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
499
+ const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
500
+ assertNativeToolRounds(messages);
501
+ return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
502
+ },
503
+ });
504
+ // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
505
+ if (decision.kind === "none") return;
506
+ if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
507
+ const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary,
508
+ { response, host, mandatory, target: decision.target, margin: room - band });
509
+ promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
481
510
  };
482
511
  pi.on("before_agent_start", async (event, ctx) => {
483
512
  const prompted = phase === "decided";
@@ -508,7 +537,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
508
537
  const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
509
538
  const mandatory = system + host + queued;
510
539
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
540
+ // The cut previews this decision has built, in the order it built them. The clip retry below measures them for what a cut leaves the
541
+ // request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
542
+ let previews: Messages[] = [];
543
+ let floorKept: number | undefined;
511
544
  const decide = () => {
545
+ previews = [];
546
+ floorKept = undefined;
512
547
  const before = measure(saved);
513
548
  return decideCut({
514
549
  total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
@@ -516,29 +551,46 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
516
551
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
517
552
  const reserve = planReserve(target, room, mandatory, ctx);
518
553
  const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
519
- if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
554
+ if (!draft.compaction) {
555
+ // The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
556
+ if (draft.floorMessages && event.toolResults) {
557
+ const fresh = new Set(event.toolResults.map(result => result.toolCallId));
558
+ const kept = measure(draft.floorMessages.filter(message =>
559
+ !(message.role === "toolResult" && fresh.has((message as ToolResultMessage).toolCallId))));
560
+ floorKept = Math.min(floorKept ?? kept, kept);
561
+ }
562
+ return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
563
+ }
520
564
  const header = ctx.sessionManager.getHeader();
521
565
  if (!header) throw new Error("Missing native session header at tool-loop boundary");
522
566
  const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
523
567
  preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
524
568
  const messages = preview.buildSessionProjection().messages;
525
569
  assertNativeToolRounds(messages);
570
+ previews.push(messages);
526
571
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
527
572
  },
528
573
  });
529
574
  };
530
575
  let decision = decide();
531
- // Only a request that would be refused is clipped. A fresh result is kept whole by every cut (it has not reached the model yet), so
532
- // a round larger than the room refuses the run. In their place the request carries the results' heads and tails, together at most
533
- // half of what the room leaves the conversation, each with a tuck_recall locator to its rest. When even that is refused the refusal stands.
576
+ // Only a request that would be refused is clipped. A fresh result is kept whole by every cut (it has not reached the model yet), so a round
577
+ // larger than the room refuses the run; in its place the request carries the result's head and tail with a tuck_recall locator to the rest.
578
+ // clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
579
+ // 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
580
+ // refusal stands.
534
581
  if (decision.kind === "fault" && event.toolResults) {
535
582
  const branch = ctx.sessionManager.getBranch();
536
583
  const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
537
- const round = clipFor(event.toolResults, entryIdOf, Math.floor((room - mandatory) / 2));
538
- if (round.length) {
584
+ const fresh = new Set(event.toolResults.map(result => result.toolCallId));
585
+ const kept = previews.length ? Math.min(...previews.map(preview => measure(preview.filter(message =>
586
+ !(message.role === "toolResult" && fresh.has((message as ToolResultMessage).toolCallId)))))) : floorKept;
587
+ for (const budget of clipBudgets(room, factor, mandatory, kept)) {
588
+ const round = clipFor(event.toolResults, entryIdOf, budget);
589
+ if (!round.length) continue;
539
590
  for (const clip of round) clips.set(clip.toolCallId, clip);
540
591
  const clippedDecision = decide();
541
- if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId); else decision = clippedDecision;
592
+ if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
593
+ else { decision = clippedDecision; break; }
542
594
  }
543
595
  }
544
596
  const before = measure(saved);
@@ -549,26 +601,51 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
549
601
  return { entries: [{ type: "compaction" as const, summary: compaction.summary,
550
602
  firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
551
603
  };
604
+ // A boundary's cut. The run's first boundary also saves the prompt's cut, after any entries an earlier handler proposed, and decides on
605
+ // the session as it is with that cut: a fresh result can still need a cut of its own there.
606
+ type Boundary = Parameters<typeof boundaryCut>[0];
607
+ const atBoundary = (event: Boundary, ctx: ExtensionContext, reason: "tool-loop" | "run-end", decide: boolean) => {
608
+ if (!promptCut) return decide ? boundaryCut(event, ctx, reason) : undefined;
609
+ const entry = promptCut;
610
+ promptCut = undefined;
611
+ const preview = cutSession(ctx, entry), view: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
612
+ const more = decide && !event.entries.length
613
+ ? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, view, reason) : undefined;
614
+ const { tokensBefore: _, ...saved } = entry;
615
+ // As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
616
+ setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
617
+ return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
618
+ };
552
619
  pi.on("turn_end", (event, ctx) => {
553
620
  try {
554
- if (!active() || !admittedRun || event.outcome !== "completed" || ctx.signal?.aborted ||
555
- !event.context.canContinue || !event.toolResults.length || !settingsFor().getCompactionSettings(ctx.model).enabled) return;
556
- const result = boundaryCut(event, ctx, "tool-loop");
621
+ if (!active() || !admittedRun) return;
622
+ const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
623
+ settingsFor().getCompactionSettings(ctx.model).enabled;
624
+ if (!promptCut && !decide) return;
625
+ const result = atBoundary(event, ctx, "tool-loop", decide);
557
626
  if (result) pendingBoundaryCommit = true;
558
627
  return result;
559
628
  } catch (error) { fail(error, ctx); }
560
629
  });
630
+ // A request of a run whose prompt's cut is not saved yet: the saved conversation in it, which other handlers may surround, is replaced by
631
+ // the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
632
+ // here. A request that does not carry the saved conversation as it stands cannot take the cut.
633
+ const cutRequest = <M,>(messages: M[], ctx: ExtensionContext, cut: CutEntry): M[] => {
634
+ const conversation = (all: Messages) => all.filter(message => message.role !== "system") as unknown[];
635
+ const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
636
+ for (let at = 0; at + saved.length <= messages.length; at++)
637
+ if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
638
+ return [...messages.slice(0, at), ...conversation(withCut(ctx, cut)) as M[], ...messages.slice(at + saved.length)];
639
+ throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
640
+ };
561
641
  pi.on("context", (event, ctx) => {
562
642
  try {
563
643
  if (!active()) return;
564
644
  access();
565
- return { messages: applyClips(projectedMessages(event.messages, ctx, options.recallPolicy), clips) };
645
+ return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
566
646
  } catch (error) { try { fail(error, ctx); } finally { return { messages: [] }; } }
567
647
  });
568
648
  pi.on("session_compact_failed", (event, ctx) => {
569
- // The end of an opportunistic cut that proposed nothing, however it came about, is no event for the recorder or for failure handling;
570
- // a latched failure still is, and so is the failure of a cut the compactor proposed.
571
- if (pendingCut?.opportunistic && !failure && !pendingCut.proposed) return;
572
649
  // This compactor's own refusal of a Pi threshold ask (the gate, or an opportunistic cut that cannot reach the band) reaches here as a
573
650
  // threshold compaction cancelled and not from an extension: no event for the recorder.
574
651
  if (event.reason === "threshold" && event.aborted && !event.fromExtension) return;
@@ -589,9 +666,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
589
666
  }
590
667
  // Pi commits these entries before the run settles.
591
668
  try {
592
- if (!active() || !admittedRun || event.outcome !== "completed" || ctx.signal?.aborted || failure ||
593
- !settingsFor().getCompactionSettings(ctx.model).enabled) return;
594
- return boundaryCut(event, ctx, "run-end");
669
+ if (!active() || !admittedRun || failure || !settingsFor().getCompactionSettings(ctx.model).enabled) return;
670
+ const decide = event.outcome === "completed" && !ctx.signal?.aborted;
671
+ if (promptCut ? event.outcome === "error" : !decide) return;
672
+ return atBoundary(event, ctx, "run-end", decide);
595
673
  } catch (error) { fail(error, ctx); }
596
674
  });
597
675
  pi.on("context_with_system", (event, ctx) => {
@@ -10,7 +10,7 @@ export const DIAGNOSTIC_BUILD = BUILD_ID;
10
10
  export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
11
11
  export function attachReceipt<T extends { details?: unknown; firstKeptEntryId: string; tokensBefore: number }>(draft: T, reason: string, reserveTokens: number, contextWindow: number, resolved: Partial<Receipt['settings']> = {}): T & { details: Record<string, unknown> & { diagnosticReceipt: Receipt } } {
12
12
  const details = draft.details && typeof draft.details === 'object' ? draft.details as Record<string, unknown> : {};
13
- const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
13
+ const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' || reason === 'prompt' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
14
14
  return { ...draft, details: { ...details, diagnosticReceipt: receipt } };
15
15
  }
16
16
  export function receiptId(entry: { details?: unknown }): string | undefined {
package/src/tuck.ts CHANGED
@@ -18,7 +18,8 @@ export type Measure = (messages: AgentMessage[]) => number;
18
18
  /** The summary view that produced a draft: the whole pre-cut dialogue, or the same dialogue shrunk to the allowance. Both carry the file ledger. */
19
19
  export type DraftMode = "dialogue" | "shrunk";
20
20
  type Draft = { compaction: Pick<Extract<SessionEntry, { type: "compaction" }>, "summary" | "firstKeptEntryId" | "tokensBefore" | "details">; after: number; mode: DraftMode; unavailable?: never; capacityRejected?: never }
21
- | { compaction?: never; mode?: never; unavailable: string; capacityRejected?: boolean };
21
+ // The actual floor projection lets the caller measure it without the fresh results it may clip.
22
+ | { compaction?: never; mode?: never; unavailable: string; capacityRejected?: boolean; floorMessages?: AgentMessage[] };
22
23
  interface SearchHit { index: number; role: string; summary: string }
23
24
  export interface RecallContinuation { query: string; throughEntryId: string; offset: number }
24
25
  /** Includes JSON escaping, hit metadata and continuation, not just excerpt text. */
@@ -63,8 +64,9 @@ export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: Ext
63
64
  }
64
65
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
65
66
  /** `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
66
- * a run a custom message starts); the session has moved on from an ended run. */
67
- export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean }, ctx: ExtensionContext,
67
+ * a run a custom message starts); the session has moved on from an ended run.
68
+ * `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
69
+ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean; reRenderSummary?: boolean }, ctx: ExtensionContext,
68
70
  reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true): Draft {
69
71
  const { isHistorySource, readable } = createRecall(policy);
70
72
  const projected = ctx.sessionManager.buildSessionProjection();
@@ -92,6 +94,12 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
92
94
  let cut = tuck.buildOwnCut(entries, 1);
93
95
  if (cut.ok) cut = tuck.applyTailBudget(entries, cut, { charsPerToken, maxTokens,
94
96
  oversizedFactor: Math.min(OVERSIZED_TAIL_FACTOR, inputRoom / Math.max(1, maxTokens)) });
97
+ // Nothing since the last cut: its summary alone is the conversation, and only a cut that is due can re-render it. A summary is compiled from
98
+ // the original sources, so the cut takes the whole history again under this cut's allowance, and a summary that leaves the request no room is
99
+ // made smaller. Inside the band a cut is opportunistic, and re-rendering there shrinks the summary and costs history for nothing (replays
100
+ // s04-B task 13, s08-B task 23: a 13,008-token first request became 3,336), so an opportunistic cut refuses as it did before.
101
+ else if (input.reRenderSummary && cut.reason === "no_live_messages" && entries.some(entry => entry.type === "compaction"))
102
+ cut = { ok: true, messages: [], selectedIds: [], firstKeptEntryId: "", compactAll: true, keptUserTurns: 0, keepFallbackToCompactAll: false };
95
103
  if (!cut.ok) return { unavailable: `pi-tuck cannot select a cut: ${cut.reason}` };
96
104
  // The latest call's trailing receipts have not reached a subsequent response.
97
105
  // A preferred tail size must not erase this first exposure when the whole
@@ -213,7 +221,8 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
213
221
  const model = ctx.model;
214
222
  const floorAt = (keep: number) => {
215
223
  const ledger = [...actionRecords(actions, keep), ...ledgerRecords(items, keep)];
216
- return { ledger, base: measure(preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX)) };
224
+ const messages = preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX);
225
+ return { ledger, messages, base: measure(messages) };
217
226
  };
218
227
  const fits = (base: number) => Boolean(model) && base + reserveTokens < model!.contextWindow && base < before;
219
228
  // When the whole floor cannot fit, the trail and the ledger keep only their headings and counts; only that can refuse the cut.
@@ -221,7 +230,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
221
230
  if (!fits(floor.base) && (items.length || actions.length)) floor = floorAt(0);
222
231
  const { ledger, base } = floor;
223
232
  if (!model || !fits(base))
224
- return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow) };
233
+ return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow), floorMessages: floor.messages };
225
234
  const maxBriefChars = historicalAllowance(model.contextWindow, reserveTokens, base, before);
226
235
  const compileInput = { messages, sourceIndices, sourceEntryIds: originalCut.selectedIds, sourceTimes: originalCut.selectedIds.map(id => resolveSourceTime(entryById.get(id)!)), receiptContext,
227
236
  ranking: { maxBriefChars, mandatorySourceIndices: new Set(mandatory.flatMap(r => r.sourceIndex === undefined ? [] : [r.sourceIndex])) },
@@ -259,7 +268,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
259
268
  const { summary, after } = offer ?? assemble(tuck.compileShrunkDialogue(compileInput));
260
269
  // Draft reduction concerns conversation. Final request admission owns the effective system/tools.
261
270
  if (after + reserveTokens >= model.contextWindow || after >= before)
262
- return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow };
271
+ return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow, floorMessages: floor.messages };
263
272
  const mode: DraftMode = offer ? "dialogue" : "shrunk";
264
273
  return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "shrunk-dialogue", mode } }, after, mode };
265
274
  };