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.
@@ -5,7 +5,7 @@ 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 } from "./clip.js";
8
+ import { applyClips, clipBudgets, clipFor } from "./clip.js";
9
9
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt } from "./budget.js";
10
10
  import { isContextOverflow } from "@earendil-works/pi-ai";
11
11
  import { convertToLlm, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
@@ -52,6 +52,10 @@ class CapacityError extends Refusal {
52
52
  * likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
53
53
  export const refusalNotice = (message, factor) => message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
54
54
  (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.` : "");
55
+ /** 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
56
+ * 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. */
57
+ export const overflowNotice = (contextWindow, bound, learned) => `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` +
58
+ (learned ? "; the next request is planned at that." : "; too far out to be a low estimate, so the factor is unchanged.");
55
59
  /** 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. */
56
60
  export const CALIBRATION_CEILING = 1.5;
57
61
  /** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
@@ -253,10 +257,14 @@ export async function installCompaction(pi, options = {}) {
253
257
  return;
254
258
  // 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
255
259
  // 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.
260
+ // 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
261
+ // with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
256
262
  if (overflow) {
257
- const candidate = (ctx.model.contextWindow + 1) / paired.tokens;
258
- if (candidate <= CALIBRATION_CEILING)
259
- learn(candidate);
263
+ const bound = (ctx.model.contextWindow + 1) / paired.tokens;
264
+ const learned = bound <= CALIBRATION_CEILING;
265
+ ctx.ui.notify(overflowNotice(ctx.model.contextWindow, bound, learned), "warning");
266
+ if (learned)
267
+ learn(bound);
260
268
  return;
261
269
  }
262
270
  const usage = message.usage;
@@ -266,11 +274,14 @@ export async function installCompaction(pi, options = {}) {
266
274
  if (!counts.every(n => Number.isSafeInteger(n) && n >= 0))
267
275
  return;
268
276
  const input = counts.reduce((a, b) => a + b, 0), ratio = input / paired.tokens;
269
- if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio))
277
+ // A request that was answered cannot have held more than the model's window, so a report above it describes something that never happened.
278
+ // 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
279
+ // 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.
280
+ if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio))
270
281
  learn(ratio);
271
282
  });
272
283
  pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
273
- pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); });
284
+ pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
274
285
  const active = () => options.active?.() ?? true;
275
286
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
276
287
  // The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
@@ -312,8 +323,7 @@ export async function installCompaction(pi, options = {}) {
312
323
  const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
313
324
  // A missing model is configuration, not capacity.
314
325
  const missingModel = () => new Error("No context model for the request budget");
315
- // Scoped to one awaited initial-prompt cut; never a persisted controller. It carries that decision's snapshot.
316
- let pendingCut;
326
+ let promptCut;
317
327
  let failure;
318
328
  let pendingBoundaryCommit = false;
319
329
  // 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
@@ -321,19 +331,24 @@ export async function installCompaction(pi, options = {}) {
321
331
  const clips = new Map();
322
332
  const fail = (error, ctx) => {
323
333
  recorder.stop("native-attempt-failed");
324
- const notify = !failure && error instanceof CapacityError;
334
+ // 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
335
+ // means for the session, anything else as it is (an attempt that ended with no word looked like a hang).
336
+ const notify = !failure;
325
337
  // A CommitFault stays whatever fails after it.
326
338
  if (!(failure instanceof CommitFault))
327
339
  failure = error instanceof Error ? error : new Error(String(error));
328
340
  pendingBoundaryCommit = false;
329
341
  pending = undefined;
342
+ promptCut = undefined;
330
343
  try {
331
344
  options.fail?.(error);
332
345
  }
333
346
  finally {
334
347
  try {
335
348
  if (notify)
336
- ctx.ui.notify(refusalNotice(failure.message, factor), "error");
349
+ ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
350
+ ? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
351
+ : `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error");
337
352
  }
338
353
  finally {
339
354
  ctx.abort();
@@ -429,9 +444,15 @@ export async function installCompaction(pi, options = {}) {
429
444
  recorder.stop("cut-cancelled");
430
445
  return { cancel: true };
431
446
  }
432
- // Pi also labels the awaited idle incoming preflight "manual".
433
- const incoming = pendingCut;
434
- const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
447
+ // Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
448
+ // is the answer, and it is saved before the first request.
449
+ if (promptCut && !admittedRun && event.reason === "threshold") {
450
+ access();
451
+ const { type: _, ...compaction } = promptCut;
452
+ promptCut = undefined;
453
+ return { compaction };
454
+ }
455
+ const operatorManual = event.reason === "manual";
435
456
  if (operatorManual && !admittedRun)
436
457
  forgetFailure();
437
458
  access();
@@ -442,19 +463,18 @@ export async function installCompaction(pi, options = {}) {
442
463
  }
443
464
  const { response, room, band } = limits(ctx);
444
465
  const retain = operatorManual || admittedRun;
445
- // The incoming-prompt preflight brings its own snapshot. Any other cut (operator manual, Pi's threshold or overflow
446
- // recovery) charges what the request must carry whatever is cut: the system part and the host input.
466
+ // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
467
+ // cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
447
468
  const saved = ctx.sessionManager.buildSessionProjection().messages;
448
- const host = incoming ? incoming.host : hostTokens(ctx);
449
- const mandatory = incoming ? incoming.mandatory
450
- : systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
451
- const before = incoming ? incoming.before : plannerMeasureFor(ctx)(saved);
469
+ const host = hostTokens(ctx);
470
+ const mandatory = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
471
+ const before = plannerMeasureFor(ctx)(saved);
452
472
  // Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
453
473
  // 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
454
474
  // 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
475
  // cancel (Pi reports it as aborted), never a failure.
456
476
  let thresholdOpportunistic = false;
457
- if (!incoming && event.reason === "threshold") {
477
+ if (event.reason === "threshold") {
458
478
  const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
459
479
  if (gate.kind !== "plan")
460
480
  return { cancel: true };
@@ -463,21 +483,18 @@ export async function installCompaction(pi, options = {}) {
463
483
  // Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
464
484
  const decision = chooseCut({
465
485
  before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
466
- opportunistic: incoming?.opportunistic ?? thresholdOpportunistic,
467
- unreduced: incoming ? "Incoming Pi preflight compaction did not reduce context within the estimated request budget"
468
- : "Compaction did not reduce context within the estimated request budget",
486
+ opportunistic: thresholdOpportunistic,
487
+ unreduced: "Compaction did not reduce context within the estimated request budget",
469
488
  plan: (target) => {
470
489
  const reserve = planReserve(target, room, mandatory, ctx);
471
- // 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
472
- // 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.
473
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, Boolean(incoming) || operatorManual || !admittedRun);
490
+ // 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
491
+ // from any run that ended. A retry is inside its run.
492
+ const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
474
493
  return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
475
494
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
476
495
  },
477
496
  });
478
497
  if (decision.kind === "cut") {
479
- if (incoming)
480
- incoming.proposed = true;
481
498
  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 }) };
482
499
  }
483
500
  // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
@@ -499,12 +516,23 @@ export async function installCompaction(pi, options = {}) {
499
516
  }
500
517
  });
501
518
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
502
- 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"; });
503
- // 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
504
- // 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
505
- // 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
506
- // still idle (Pi 1.1); otherwise it declines an opportunistic cut and fails a needed one closed.
519
+ 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"; });
520
+ // 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.
521
+ const cutSession = (ctx, cut) => {
522
+ const header = ctx.sessionManager.getHeader();
523
+ if (!header)
524
+ throw new Error("Missing native session header for the prompt's cut");
525
+ const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
526
+ preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
527
+ return preview;
528
+ };
529
+ const withCut = (ctx, cut) => cutSession(ctx, cut).buildSessionProjection().messages;
530
+ // 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
531
+ // 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
532
+ // keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
533
+ // Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
507
534
  const preflight = async (ctx, incomingPart, observedPrompt, tools) => {
535
+ promptCut = undefined;
508
536
  if (!active())
509
537
  return;
510
538
  access();
@@ -512,55 +540,42 @@ export async function installCompaction(pi, options = {}) {
512
540
  return;
513
541
  if (!ctx.model)
514
542
  throw missingModel();
515
- const { room, band } = limits(ctx);
516
- // Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context
517
- // guard's responsibility. One snapshot per decision, taken again after the awaited cut.
518
- const snapshot = () => {
519
- const saved = ctx.sessionManager.buildSessionProjection().messages;
520
- const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
521
- const host = hostTokens(ctx);
522
- const before = measureFor(ctx)(saved);
523
- return { incoming: incomingPart + system, host, before, mandatory: incomingPart + system + host };
524
- };
525
- const first = snapshot(), total = first.before + first.mandatory;
526
- if (!fits(first.incoming, room, ctx))
543
+ const { response, room, band } = limits(ctx);
544
+ // Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
545
+ const saved = ctx.sessionManager.buildSessionProjection().messages;
546
+ const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
547
+ const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
548
+ const incoming = incomingPart + system, mandatory = incoming + host;
549
+ if (!fits(incoming, room, ctx))
527
550
  throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
528
551
  // A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
529
552
  // help: leave it to the complete-context guard, which fails closed.
530
- const gate = gateCut({ total, mandatory: first.mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
553
+ const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
531
554
  if (gate.kind !== "plan")
532
555
  return;
533
- if (!ctx.isIdle()) {
534
- if (gate.opportunistic)
535
- return;
536
- throw new CapacityError("The request needs a cut that Pi cannot make once the run has started");
537
- }
538
- const cut = pendingCut = { mandatory: first.mandatory, before: first.before, host: first.host, opportunistic: gate.opportunistic, proposed: false };
539
- try {
540
- // CompactOptions callbacks are Pi's supported terminal completion surface.
541
- await new Promise((resolve, reject) => ctx.compact({ onComplete: () => resolve(),
542
- onError: error => {
543
- // An opportunistic cut is not needed to send the request, which is below the room: the guard decides. However it ends (the
544
- // planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
545
- // or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
546
- // A needed cut that Pi has nothing to make (no history older than its keepRecentTokens since the last cut) is a refusal of
547
- // this request, which ends with it: as a fault it stayed, and every later prompt of the session failed on it.
548
- if (cut.opportunistic && !failure && !cut.proposed)
549
- resolve();
550
- else if (!cut.proposed && isNoWork(error.message))
551
- reject(new CapacityError(`The request needs a cut and Pi has none to make (${error.message})`));
552
- else
553
- reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : cut.proposed ? new CommitFault(error.message) : error);
554
- } }));
555
- }
556
- finally {
557
- pendingCut = undefined;
558
- }
559
- access();
560
- // The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
561
- const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
562
- if (!cut.opportunistic && (after >= total || !afterFits))
563
- throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
556
+ const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
557
+ const decision = chooseCut({
558
+ before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
559
+ unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
560
+ plan: (target) => {
561
+ const reserve = planReserve(target, room, mandatory, ctx);
562
+ // 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
563
+ // there buys nothing the request needed and costs history the next recall would have found.
564
+ const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
565
+ if (!draft.compaction)
566
+ return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
567
+ const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
568
+ assertNativeToolRounds(messages);
569
+ return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
570
+ },
571
+ });
572
+ // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
573
+ if (decision.kind === "none")
574
+ return;
575
+ if (decision.kind === "fault")
576
+ throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
577
+ const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
578
+ promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
564
579
  };
565
580
  pi.on("before_agent_start", async (event, ctx) => {
566
581
  const prompted = phase === "decided";
@@ -597,7 +612,13 @@ export async function installCompaction(pi, options = {}) {
597
612
  const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages) : 0;
598
613
  const mandatory = system + host + queued;
599
614
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
615
+ // 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
616
+ // request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
617
+ let previews = [];
618
+ let floorKept;
600
619
  const decide = () => {
620
+ previews = [];
621
+ floorKept = undefined;
601
622
  const before = measure(saved);
602
623
  return decideCut({
603
624
  total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
@@ -605,8 +626,15 @@ export async function installCompaction(pi, options = {}) {
605
626
  plan: (target) => {
606
627
  const reserve = planReserve(target, room, mandatory, ctx);
607
628
  const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
608
- if (!draft.compaction)
629
+ if (!draft.compaction) {
630
+ // The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
631
+ if (draft.floorMessages && event.toolResults) {
632
+ const fresh = new Set(event.toolResults.map(result => result.toolCallId));
633
+ const kept = measure(draft.floorMessages.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId))));
634
+ floorKept = Math.min(floorKept ?? kept, kept);
635
+ }
609
636
  return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
637
+ }
610
638
  const header = ctx.sessionManager.getHeader();
611
639
  if (!header)
612
640
  throw new Error("Missing native session header at tool-loop boundary");
@@ -614,27 +642,36 @@ export async function installCompaction(pi, options = {}) {
614
642
  preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
615
643
  const messages = preview.buildSessionProjection().messages;
616
644
  assertNativeToolRounds(messages);
645
+ previews.push(messages);
617
646
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
618
647
  },
619
648
  });
620
649
  };
621
650
  let decision = decide();
622
- // 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
623
- // a round larger than the room refuses the run. In their place the request carries the results' heads and tails, together at most
624
- // 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.
651
+ // 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
652
+ // 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.
653
+ // clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
654
+ // 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
655
+ // refusal stands.
625
656
  if (decision.kind === "fault" && event.toolResults) {
626
657
  const branch = ctx.sessionManager.getBranch();
627
658
  const entryIdOf = (toolCallId) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
628
- const round = clipFor(event.toolResults, entryIdOf, Math.floor((room - mandatory) / 2));
629
- if (round.length) {
659
+ const fresh = new Set(event.toolResults.map(result => result.toolCallId));
660
+ const kept = previews.length ? Math.min(...previews.map(preview => measure(preview.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId)))))) : floorKept;
661
+ for (const budget of clipBudgets(room, factor, mandatory, kept)) {
662
+ const round = clipFor(event.toolResults, entryIdOf, budget);
663
+ if (!round.length)
664
+ continue;
630
665
  for (const clip of round)
631
666
  clips.set(clip.toolCallId, clip);
632
667
  const clippedDecision = decide();
633
668
  if (clippedDecision.kind === "fault")
634
669
  for (const clip of round)
635
670
  clips.delete(clip.toolCallId);
636
- else
671
+ else {
637
672
  decision = clippedDecision;
673
+ break;
674
+ }
638
675
  }
639
676
  }
640
677
  const before = measure(saved);
@@ -646,12 +683,28 @@ export async function installCompaction(pi, options = {}) {
646
683
  return { entries: [{ type: "compaction", summary: compaction.summary,
647
684
  firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
648
685
  };
686
+ const atBoundary = (event, ctx, reason, decide) => {
687
+ if (!promptCut)
688
+ return decide ? boundaryCut(event, ctx, reason) : undefined;
689
+ const entry = promptCut;
690
+ promptCut = undefined;
691
+ const preview = cutSession(ctx, entry), view = Object.create(ctx, { sessionManager: { value: preview } });
692
+ const more = decide && !event.entries.length
693
+ ? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, view, reason) : undefined;
694
+ const { tokensBefore: _, ...saved } = entry;
695
+ // As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
696
+ setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
697
+ return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
698
+ };
649
699
  pi.on("turn_end", (event, ctx) => {
650
700
  try {
651
- if (!active() || !admittedRun || event.outcome !== "completed" || ctx.signal?.aborted ||
652
- !event.context.canContinue || !event.toolResults.length || !settingsFor().getCompactionSettings(ctx.model).enabled)
701
+ if (!active() || !admittedRun)
702
+ return;
703
+ const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
704
+ settingsFor().getCompactionSettings(ctx.model).enabled;
705
+ if (!promptCut && !decide)
653
706
  return;
654
- const result = boundaryCut(event, ctx, "tool-loop");
707
+ const result = atBoundary(event, ctx, "tool-loop", decide);
655
708
  if (result)
656
709
  pendingBoundaryCommit = true;
657
710
  return result;
@@ -660,12 +713,23 @@ export async function installCompaction(pi, options = {}) {
660
713
  fail(error, ctx);
661
714
  }
662
715
  });
716
+ // 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
717
+ // the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
718
+ // here. A request that does not carry the saved conversation as it stands cannot take the cut.
719
+ const cutRequest = (messages, ctx, cut) => {
720
+ const conversation = (all) => all.filter(message => message.role !== "system");
721
+ const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
722
+ for (let at = 0; at + saved.length <= messages.length; at++)
723
+ if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
724
+ return [...messages.slice(0, at), ...conversation(withCut(ctx, cut)), ...messages.slice(at + saved.length)];
725
+ throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
726
+ };
663
727
  pi.on("context", (event, ctx) => {
664
728
  try {
665
729
  if (!active())
666
730
  return;
667
731
  access();
668
- return { messages: applyClips(projectedMessages(event.messages, ctx, options.recallPolicy), clips) };
732
+ return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
669
733
  }
670
734
  catch (error) {
671
735
  try {
@@ -677,10 +741,6 @@ export async function installCompaction(pi, options = {}) {
677
741
  }
678
742
  });
679
743
  pi.on("session_compact_failed", (event, ctx) => {
680
- // The end of an opportunistic cut that proposed nothing, however it came about, is no event for the recorder or for failure handling;
681
- // a latched failure still is, and so is the failure of a cut the compactor proposed.
682
- if (pendingCut?.opportunistic && !failure && !pendingCut.proposed)
683
- return;
684
744
  // 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
685
745
  // threshold compaction cancelled and not from an extension: no event for the recorder.
686
746
  if (event.reason === "threshold" && event.aborted && !event.fromExtension)
@@ -707,10 +767,12 @@ export async function installCompaction(pi, options = {}) {
707
767
  }
708
768
  // Pi commits these entries before the run settles.
709
769
  try {
710
- if (!active() || !admittedRun || event.outcome !== "completed" || ctx.signal?.aborted || failure ||
711
- !settingsFor().getCompactionSettings(ctx.model).enabled)
770
+ if (!active() || !admittedRun || failure || !settingsFor().getCompactionSettings(ctx.model).enabled)
771
+ return;
772
+ const decide = event.outcome === "completed" && !ctx.signal?.aborted;
773
+ if (promptCut ? event.outcome === "error" : !decide)
712
774
  return;
713
- return boundaryCut(event, ctx, "run-end");
775
+ return atBoundary(event, ctx, "run-end", decide);
714
776
  }
715
777
  catch (error) {
716
778
  fail(error, ctx);
@@ -24,6 +24,7 @@ export declare function inspectIncident(records: IncidentRecord[]): {
24
24
  mode?: never;
25
25
  unavailable: string;
26
26
  capacityRejected?: boolean;
27
+ floorMessages?: import("@earendil-works/pi-agent-core").AgentMessage[];
27
28
  };
28
29
  receiptId: string | null;
29
30
  matchesRecordedProposal: boolean | null;
@@ -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 = "c4e6e6034a54ff7a893ee62484e54cf471964be3435aa587af8a288dcbf41314";
3
+ export declare const DIAGNOSTIC_BUILD = "828ac4821689607af006d54a41fd5960bb910bc65aa43adbd81c2f6aac25d180";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -8,7 +8,7 @@ import { createRecall } from './recall.js';
8
8
  export const DIAGNOSTIC_BUILD = BUILD_ID;
9
9
  export function attachReceipt(draft, reason, reserveTokens, contextWindow, resolved = {}) {
10
10
  const details = draft.details && typeof draft.details === 'object' ? draft.details : {};
11
- const 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' } };
11
+ const 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' } };
12
12
  return { ...draft, details: { ...details, diagnosticReceipt: receipt } };
13
13
  }
14
14
  export function receiptId(entry) {
@@ -21,6 +21,7 @@ type Draft = {
21
21
  mode?: never;
22
22
  unavailable: string;
23
23
  capacityRejected?: boolean;
24
+ floorMessages?: AgentMessage[];
24
25
  };
25
26
  interface SearchHit {
26
27
  index: number;
@@ -84,13 +85,15 @@ export declare function historicalAllowance(window: number, reserve: number, bas
84
85
  export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean): Draft;
85
86
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
86
87
  /** `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
87
- * a run a custom message starts); the session has moved on from an ended run. */
88
+ * a run a custom message starts); the session has moved on from an ended run.
89
+ * `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
88
90
  export declare function tuckObservedDraft(tuck: Tuck, input: {
89
91
  branchEntries: SessionEntry[];
90
92
  tokensBefore: number;
91
93
  previousSummary?: string;
92
94
  operatorManual?: boolean;
93
95
  newPrompt?: boolean;
96
+ reRenderSummary?: boolean;
94
97
  }, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean): Draft;
95
98
  /** Numeric references use the original session index, then intersect with readable current-branch entries. */
96
99
  export declare function tuckRecall(tuck: Tuck, entries: readonly SessionEntry[], branch: readonly SessionEntry[], input: RecallOptions & {
package/dist/src/tuck.js CHANGED
@@ -31,7 +31,8 @@ export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {},
31
31
  }
32
32
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
33
33
  /** `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
34
- * a run a custom message starts); the session has moved on from an ended run. */
34
+ * a run a custom message starts); the session has moved on from an ended run.
35
+ * `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
35
36
  export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true) {
36
37
  const { isHistorySource, readable } = createRecall(policy);
37
38
  const projected = ctx.sessionManager.buildSessionProjection();
@@ -62,6 +63,12 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
62
63
  if (cut.ok)
63
64
  cut = tuck.applyTailBudget(entries, cut, { charsPerToken, maxTokens,
64
65
  oversizedFactor: Math.min(OVERSIZED_TAIL_FACTOR, inputRoom / Math.max(1, maxTokens)) });
66
+ // 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
67
+ // 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
68
+ // made smaller. Inside the band a cut is opportunistic, and re-rendering there shrinks the summary and costs history for nothing (replays
69
+ // 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.
70
+ else if (input.reRenderSummary && cut.reason === "no_live_messages" && entries.some(entry => entry.type === "compaction"))
71
+ cut = { ok: true, messages: [], selectedIds: [], firstKeptEntryId: "", compactAll: true, keptUserTurns: 0, keepFallbackToCompactAll: false };
65
72
  if (!cut.ok)
66
73
  return { unavailable: `pi-tuck cannot select a cut: ${cut.reason}` };
67
74
  // The latest call's trailing receipts have not reached a subsequent response.
@@ -196,7 +203,8 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
196
203
  const model = ctx.model;
197
204
  const floorAt = (keep) => {
198
205
  const ledger = [...actionRecords(actions, keep), ...ledgerRecords(items, keep)];
199
- return { ledger, base: measure(preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX)) };
206
+ const messages = preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX);
207
+ return { ledger, messages, base: measure(messages) };
200
208
  };
201
209
  const fits = (base) => Boolean(model) && base + reserveTokens < model.contextWindow && base < before;
202
210
  // When the whole floor cannot fit, the trail and the ledger keep only their headings and counts; only that can refuse the cut.
@@ -205,7 +213,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
205
213
  floor = floorAt(0);
206
214
  const { ledger, base } = floor;
207
215
  if (!model || !fits(base))
208
- return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow) };
216
+ return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow), floorMessages: floor.messages };
209
217
  const maxBriefChars = historicalAllowance(model.contextWindow, reserveTokens, base, before);
210
218
  const compileInput = { messages, sourceIndices, sourceEntryIds: originalCut.selectedIds, sourceTimes: originalCut.selectedIds.map(id => resolveSourceTime(entryById.get(id))), receiptContext,
211
219
  ranking: { maxBriefChars, mandatorySourceIndices: new Set(mandatory.flatMap(r => r.sourceIndex === undefined ? [] : [r.sourceIndex])) }, };
@@ -244,7 +252,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
244
252
  const { summary, after } = offer ?? assemble(tuck.compileShrunkDialogue(compileInput));
245
253
  // Draft reduction concerns conversation. Final request admission owns the effective system/tools.
246
254
  if (after + reserveTokens >= model.contextWindow || after >= before)
247
- return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow };
255
+ return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow, floorMessages: floor.messages };
248
256
  const mode = offer ? "dialogue" : "shrunk";
249
257
  return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "shrunk-dialogue", mode } }, after, mode };
250
258
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
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 = "c4e6e6034a54ff7a893ee62484e54cf471964be3435aa587af8a288dcbf41314";
1
+ export const BUILD_ID = "828ac4821689607af006d54a41fd5960bb910bc65aa43adbd81c2f6aac25d180";
package/src/clip.ts CHANGED
@@ -16,6 +16,19 @@ const clipped = (text: string, keep: number, entryId: string): ToolResultMessage
16
16
  `tuck_recall {"entryId":"${entryId}","offset":${head}}, up to ${MAX_RECALL_CHARACTERS} characters a call]\n` + text.slice(text.length - tail) }];
17
17
  };
18
18
 
19
+ /** The budgets to offer a fresh round the room cannot hold whole, in the units the round and the mandatory input are counted in, tried in order.
20
+ * The room is the provider's, so it is divided by the factor the provider's counts have been running at - the same conversion the planner's own
21
+ * reserve makes (budget.ts) - before the input no cut can remove is taken out; counting the provider's room against an estimate asks the round for
22
+ * more than the room holds at a factor above 1. The first try is half of what that leaves. `kept` is what the smallest cut leaves the request
23
+ * besides the fresh results - the summary floor and its quote of the prompt - measured from the planner's own preview, not a new formula: a half
24
+ * cannot spend it, so the second try is the whole free room less it. Omit it when the planner bailed before building a preview, and only the half
25
+ * is tried. Nothing is offered when the mandatory input alone leaves no room. */
26
+ export const clipBudgets = (room: number, factor: number, mandatory: number, kept?: number): number[] => {
27
+ const free = Math.floor(room / Math.max(1, factor)) - mandatory;
28
+ if (free <= 0) return [];
29
+ return [...new Set(kept === undefined ? [Math.floor(free / 2)] : [Math.floor(free / 2), free - kept - 1])].filter(budget => budget > 0);
30
+ };
31
+
19
32
  /** The round's text results within `budget` tokens together: each result smaller than an equal share of what is left stays whole, and the
20
33
  * rest are clipped to that share, keeping the most head and tail characters that fit it. None when the round already fits, or when a share
21
34
  * cannot hold even the locator. A round of one result gets the whole budget. Deterministic: the same round and budget always clip the same way. */