pi-tuck 1.0.24 → 1.0.25

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.
@@ -9,6 +9,8 @@ export declare function statusText(input: {
9
9
  type: string;
10
10
  tokensBefore?: unknown;
11
11
  details?: unknown;
12
+ customType?: unknown;
13
+ data?: unknown;
12
14
  }[];
13
15
  cutAt: number | undefined;
14
16
  search: boolean;
@@ -21,6 +21,8 @@ export function statusText(input) {
21
21
  const cutLine = ours.length === 0 ? "Cuts: none yet in this session. The recall tools appear after the first cut."
22
22
  : `Cuts: ${ours.length} by pi-tuck in this session` + (typeof before === "number" && typeof after === "number" ? `, the last from ${count(before)} to ${count(after)} tokens.` : ".");
23
23
  const native = cuts.length - ours.length;
24
+ const retries = input.branch.filter(entry => entry.type === "custom" && entry.customType === "pi-tuck-capacity" &&
25
+ entry.data?.kind === "retry").length;
24
26
  const searchLine = !input.search ? "Search: by keyword (search by meaning needs macOS on Apple silicon)."
25
27
  : input.semantic.state === "ready" || input.semantic.state === "waiting" ? "Search: by meaning and keyword."
26
28
  : input.semantic.state === "preparing" ? "Search: by meaning and keyword; indexing history in the background."
@@ -28,6 +30,7 @@ export function statusText(input) {
28
30
  return [
29
31
  `pi-tuck ${input.version} (the version this session loaded; restart Pi after an update).`,
30
32
  cutLine,
33
+ ...(retries ? [`Retries: ${retries} after thinking-only length stops in this session.`] : []),
31
34
  ...(native > 0 ? [`Pi's own compaction cut ${native} time${native === 1 ? "" : "s"} in this session.`] : []),
32
35
  input.cutAt === undefined ? "Cut point: adapts by itself." : `Cut point: fixed at cutAt ${input.cutAt} of the input room. Remove cutAt from pi-tuck.json to let it adapt.`,
33
36
  searchLine,
@@ -87,6 +87,7 @@ export declare function tuckObservedDraft(tuck: Tuck, input: {
87
87
  newPrompt?: boolean;
88
88
  reRenderSummary?: boolean;
89
89
  protectedPromptEntryId?: string;
90
+ deepest?: boolean;
90
91
  }, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean): Draft;
91
92
  /** Numeric references use the original session index, then intersect with readable current-branch entries. */
92
93
  export declare function tuckRecall(tuck: Tuck, entries: readonly SessionEntry[], branch: readonly SessionEntry[], input: RecallOptions & {
package/dist/src/tuck.js CHANGED
@@ -311,6 +311,14 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
311
311
  };
312
312
  const calibrationHigh = highAt(floorBoundary, minimumOpeningLength);
313
313
  const floorSummary = render(calibrationHigh.rows), floorProjection = preview(floorSummary, floorBoundary);
314
+ // The last admission attempt and thinking-only retry share this exact floor: whole protected prompts, the smallest
315
+ // existing summary representation, and the required fresh/native suffix. No historical fill or room target spends reply space.
316
+ if (input.deepest) {
317
+ assertNativeToolRounds(floorProjection);
318
+ const after = measure(floorProjection);
319
+ return { compaction: { summary: floorSummary, firstKeptEntryId: entries[floorBoundary]?.id ?? "", tokensBefore: input.tokensBefore,
320
+ details: { ...detailsAt(floorBoundary), tokensAfter: after, carriedTarget: after, view: "shrunk-dialogue", mode: "shrunk", deepest: true } }, after, mode: "shrunk" };
321
+ }
314
322
  const offset = measure(floorProjection) - nativeAt(floorBoundary) - summaryTokens(floorSummary.length);
315
323
  const price = (characters, boundary) => offset + nativeAt(boundary) + summaryTokens(characters);
316
324
  const listCache = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
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 = "c3eefa3bb76e52635b1e8394a025d3ffd6a056fc5636ed9b35f89dfddc1a322a";
1
+ export const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
package/src/compaction.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { requestReplyFloor, carriesPrompt, piContextSafetyMargin } from "./request-capacity.js";
2
+ import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin } from "./request-capacity.js";
3
3
  import { BUILD_ID } from "./build-id.js";
4
4
  import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
5
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
6
6
  import { createHash } from "node:crypto";
7
7
  import { exportSource } from "./source-export.js";
8
8
  import { isDeepStrictEqual } from "node:util";
9
- import { assertNativeToolRounds } from "./protocol.js";
9
+ import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
10
10
  import { projectedMessages } from "./omissions.js";
11
11
  import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
12
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt,
13
- type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
13
+ type AgentMessage, type Decision, type Messages, type Plan, type Target } from "./budget.js";
14
14
  import { getCurrentSystemMessage, isContextOverflow, normalizeContext, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
15
- import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
15
+ import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
16
+ import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry, type SessionBoundaryDraft, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
16
17
  import { Type } from "typebox";
17
18
  import { Check } from "typebox/value";
18
19
  import type { RecallPolicy } from "./recall.js";
@@ -116,7 +117,8 @@ export interface CompactionOptions {
116
117
  cutAt?: number;
117
118
  /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
118
119
  * Standalone cuts against Pi's reserveTokens, then admits up to the window less Pi's safety margin and max(3,584, 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.
120
+ * After a length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
121
+ * Thinking-only exhaustion keeps Pi's reserve and requires the branch's largest observed reply instead.
120
122
  * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
121
123
  requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
122
124
  /** The exact messages the host's `context` hook adds to every final request beyond the saved conversation (for example a role
@@ -134,7 +136,7 @@ export interface CompactionOptions {
134
136
 
135
137
  export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
136
138
  const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
137
- const piClamp = options.requestReserve ? undefined : await import('@earendil-works/pi-ai/api/simple-options')
139
+ const piClamp = await import('@earendil-works/pi-ai/api/simple-options')
138
140
  .then(module => module.clampMaxTokensToContext).catch(() => undefined);
139
141
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
140
142
  if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
@@ -219,6 +221,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
219
221
  // Pi's native start/settled events bracket the run and its post-turn cuts.
220
222
  let admittedRun = false;
221
223
  let runPrompt: string | undefined;
224
+ let thinkingRetryPending = false;
222
225
  const protectedPrompt = (ctx: ExtensionContext) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry =>
223
226
  entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt!))?.id : undefined;
224
227
  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");
@@ -280,13 +283,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
280
283
  // 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.
281
284
  if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
282
285
  });
283
- pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
284
- pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
286
+ pi.on("agent_start", () => { admittedRun = true; thinkingRetryPending = false; pending = undefined; phase = "idle"; });
287
+ pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; thinkingRetryPending = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
285
288
  const active = () => options.active?.() ?? true;
286
289
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
287
290
  // The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
288
- // not fit (a length stop, or a refusal pi-ai classes as a context overflow), hold the catalogue maximum too. Other errors, such as a 503,
289
- // keep native's room. Read the whole branch, including history before compactions, each time: no flag to persist or reset on resume,
291
+ // not fit (a length stop with text/tools, or a refusal pi-ai classes as a context overflow), hold the catalogue maximum too. A thinking-only
292
+ // length stop uses branchReplyFloor's observed output instead; reserving the catalogue maximum would strand setups above the room.
293
+ // Other errors, such as a 503, keep native's room. Read the whole branch, including history before compactions, each time: no flag to persist or reset on resume,
290
294
  // fork, /tree or a new session.
291
295
  const requestReserve = (ctx: ExtensionContext) => {
292
296
  const host = options.requestReserve?.(ctx);
@@ -294,8 +298,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
294
298
  if (!Number.isSafeInteger(budget.response) || budget.response < 0)
295
299
  throw new Error("Invalid host request reserve");
296
300
  const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry =>
297
- entry.type === "message" && entry.message.role === "assistant" &&
298
- (entry.message.stopReason === "length" || (entry.message.stopReason === "error" && isContextOverflow(entry.message))));
301
+ entry.type === "message" && needsFullReplyReserve(entry.message));
299
302
  return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
300
303
  };
301
304
  const inputRoom = (response: number, ctx: ExtensionContext) => {
@@ -327,8 +330,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
327
330
  const plannerMeasureFor = (ctx: ExtensionContext) => Object.assign(
328
331
  (messages: Messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy),
329
332
  { messageTokens: (message: Messages[number]) => conversationTokens(applyClips([message], clips)) });
330
- const hostTokens = (ctx: ExtensionContext) => options.hostMessages ? conversationTokens(options.hostMessages(ctx)) : 0;
331
- const toolShapes = (names: Iterable<string>): ToolShape[] => {
333
+ const hostMessages = (ctx: ExtensionContext) => options.hostMessages?.(ctx) ?? [];
334
+ const toolShapes = (names: Iterable<string>) => {
332
335
  const selected = new Set(names);
333
336
  return pi.getAllTools().filter(tool => selected.has(tool.name)).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
334
337
  };
@@ -340,6 +343,46 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
340
343
  const future = saved.some(message => message.role === "system") ? historyToolDeclarations(saved, shapes, true) : saved;
341
344
  return systemPart(observedPrompt, shapes, future, guardPrompt);
342
345
  };
346
+ const clampMessage = "Pi's request clamp leaves less than the reply floor";
347
+ // Both the guard and the early projections ask Pi about the very same converted context. Pi's cap uses its own estimator,
348
+ // whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
349
+ const requestClamp = (messages: Messages, ctx: ExtensionContext) => {
350
+ const context = normalizeContext({ messages: convertToLlm(messages) });
351
+ const cap = piClamp?.(ctx.model!, context, ctx.model!.maxTokens);
352
+ return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
353
+ estimate: estimateContextTokens(context).tokens };
354
+ };
355
+ const projectedRequest = (messages: Messages, ctx: ExtensionContext, host: Messages, incoming: Messages, prompt: string, tools: readonly string[], cut: boolean) => {
356
+ const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
357
+ const saved = messages.some(message => message.role === "system") ? messages :
358
+ [{ role: "system" as const, content: prompt, toolsAdded: shapes, timestamp: 0 }, ...messages];
359
+ const declared = enabled ? historyToolDeclarations(saved, shapes, true) : saved;
360
+ return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips);
361
+ };
362
+ const clampAboveRoom = <D extends object>(decision: Decision<D>, ctx: ExtensionContext, room: number, target: number,
363
+ total: (decision: Decision<D>) => number, project: (decision: Decision<D>) => Messages, replan: (target: number) => Decision<D>, deepest: () => Decision<D>): Decision<D> => {
364
+ if (options.requestReserve || !piClamp) return decision;
365
+ const lastAttempt = (): Decision<D> => {
366
+ const previousClips = new Map(clips);
367
+ const candidate = deepest();
368
+ if (candidate.kind === "cut" && fits(total(candidate), limits(ctx).hardLine, ctx) && requestClamp(project(candidate), ctx).leavesFloor)
369
+ return candidate;
370
+ clips.clear(); for (const [id, clip] of previousClips) clips.set(id, clip);
371
+ return { kind: "fault", capacity: true, message: clampMessage };
372
+ };
373
+ for (let retry = 0; ; retry++) {
374
+ if (decision.kind !== "cut" && decision.kind !== "none") return decision;
375
+ if (corrected(total(decision), ctx) <= room) return decision;
376
+ const check = requestClamp(project(decision), ctx);
377
+ if (check.leavesFloor) return decision;
378
+ if (retry === 3) return lastAttempt();
379
+ // Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
380
+ // use its existing band/room policy; admission below the room is deliberately unchanged.
381
+ target = Math.max(room, Math.floor(target * (ctx.model!.contextWindow - piMargin - limits(ctx).replyFloor) / check.estimate));
382
+ decision = replan(target);
383
+ if (decision.kind === "defer" || decision.kind === "fault") return lastAttempt();
384
+ }
385
+ };
343
386
  // Pi reports a manual cut with nothing to do as a failed compaction; the thrown error lacks the event's "Compaction failed: " prefix.
344
387
  const isNoWork = (message: string | undefined) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
345
388
  // A missing model is configuration, not capacity.
@@ -347,6 +390,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
347
390
  // 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
348
391
  // before a boundary drops it, and the next prompt decides afresh.
349
392
  type CutEntry = { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown };
393
+ type CutPlan = { compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number; messages: Messages };
350
394
  let promptCut: CutEntry | undefined;
351
395
  // 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
352
396
  // before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
@@ -400,7 +444,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
400
444
  if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
401
445
  forgetFailure();
402
446
  phase = "decided";
403
- try { syncHistoryTools(ctx); await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools()); syncHistoryTools(ctx); }
447
+ try { syncHistoryTools(ctx); await preflight(ctx, incomingTokens(event.text, event.images),
448
+ [{ role: "user", content: [{ type: "text", text: event.text }, ...(event.images ?? [])], timestamp: 0 }], ctx.getSystemPrompt(), pi.getActiveTools()); syncHistoryTools(ctx); }
404
449
  catch (error) { fail(error, ctx); }
405
450
  });
406
451
  pi.registerTool({
@@ -473,7 +518,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
473
518
  // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
474
519
  // cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
475
520
  const saved = ctx.sessionManager.buildSessionProjection().messages;
476
- const host = hostTokens(ctx);
521
+ const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
477
522
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
478
523
  const mandatory = system + host, band = decisionBand(ctx, room, system);
479
524
  const before = plannerMeasureFor(ctx)(saved);
@@ -493,26 +538,31 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
493
538
  before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
494
539
  opportunistic: thresholdOpportunistic,
495
540
  unreduced: "Compaction did not reduce context within the estimated request budget",
496
- plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
541
+ plan: (target): Plan<CutPlan> => {
497
542
  const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
498
543
  // 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
499
544
  // from any run that ended. A retry is inside its run.
500
545
  const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
501
- return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
546
+ return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve,
547
+ messages: previewCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).buildSessionProjection().messages }
502
548
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
503
549
  },
504
550
  });
505
551
  estimatedTokens = corrected(before + mandatory, ctx);
506
- let decision = decide();
552
+ let decision: Decision<CutPlan> = decide();
507
553
  const { hardLine } = limits(ctx);
508
554
  if (!operatorManual && hardLine > room && decision.kind === "fault") {
509
- if (fits(before + mandatory, hardLine, ctx)) return { cancel: true };
510
- decision = decide(hardLine, hardLine);
555
+ decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } : decide(hardLine, hardLine);
556
+ decision = clampAboveRoom(decision, ctx, room, hardLine,
557
+ candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
558
+ candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"),
559
+ target => target <= room ? decide() : decide(target, target),
560
+ () => deepestDecision(ctx, operatorManual || !admittedRun, retain));
511
561
  }
512
562
  if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
513
563
  retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
514
564
  // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
515
- if (decision.kind === "none") return { cancel: true };
565
+ if (decision.kind === "none" || decision.kind === "defer") return { cancel: true };
516
566
  recorder.stop("no-cut");
517
567
  ctx.ui.notify(decision.message, "warning");
518
568
  if (operatorManual) receipt("refusal", decision.message, ctx);
@@ -533,20 +583,63 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
533
583
  pi.on("session_compact", (_event, ctx) => { if (!admittedRun) syncHistoryTools(ctx); });
534
584
  pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
535
585
  pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
586
+ // A reply that stopped at the output limit may be cut off, and Pi says nothing. Tell the operator once per run, on the operator's side only:
587
+ // the notice never enters the model's context. Decided at the end of the run, so a later reply in the same run (a retry or a continuation)
588
+ // that ends normally clears it.
589
+ let lengthStop = false;
590
+ pi.on("agent_start", () => { lengthStop = false; });
591
+ pi.on("message_end", event => { if (event.message.role === "assistant") lengthStop = event.message.stopReason === "length"; });
592
+ pi.on("agent_settled", (_event, ctx) => {
593
+ if (!lengthStop || !active()) return;
594
+ lengthStop = false;
595
+ const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
596
+ try { if (ctx.hasUI === false) process.stderr.write(message + "\n"); else ctx.ui.notify(message, "warning"); } catch { /* ended */ } // with no UI (pi -p), stderr is the operator's
597
+ });
536
598
  // 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.
537
- const cutSession = (ctx: ExtensionContext, cut: CutEntry) => {
599
+ const previewCut = (ctx: ExtensionContext, cut: CutEntry) => {
538
600
  const header = ctx.sessionManager.getHeader();
539
601
  if (!header) throw new Error("Missing native session header for the prompt's cut");
540
602
  const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
541
- preview.appendCompaction(cut.summary, keptFrom(cut, ctx), 0, cut.details, true);
603
+ preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
542
604
  return preview;
543
605
  };
606
+ const cutSession = (ctx: ExtensionContext, cut: CutEntry) => previewCut(ctx, { ...cut, firstKeptEntryId: keptFrom(cut, ctx) });
544
607
  const withCut = (ctx: ExtensionContext, cut: CutEntry) => cutSession(ctx, cut).buildSessionProjection().messages;
608
+ // The shared final planner: preserve the compiler's prompt/protocol floor and spend nothing on optional history. Fresh text results
609
+ // get the smallest existing head/tail + recall-locator representation; their originals remain in the session, as for ordinary clips.
610
+ const deepestDecision = (ctx: ExtensionContext, newPrompt: boolean, retain: boolean): Decision<CutPlan> => {
611
+ const saved = ctx.sessionManager.buildSessionProjection().messages;
612
+ if (!newPrompt) {
613
+ const at = saved.findLastIndex(message => message.role === "assistant" && !isDiscardedResponse(message));
614
+ const response = saved[at];
615
+ const calls = new Set(response?.role === "assistant" ? response.content.flatMap(block => block.type === "toolCall" ? [block.id] : []) : []);
616
+ const results = saved.slice(at + 1).filter((message): message is ToolResultMessage => message.role === "toolResult" && calls.has(message.toolCallId));
617
+ const entryIdOf = (id: string) => ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === id)?.id;
618
+ for (const result of results) {
619
+ let low = 0, high = conversationTokens([result]) - 1;
620
+ if (!clipFor([result], entryIdOf, high).length) continue;
621
+ while (low < high) {
622
+ const middle = Math.floor((low + high) / 2);
623
+ if (clipFor([result], entryIdOf, middle).length) high = middle; else low = middle + 1;
624
+ }
625
+ for (const clip of clipFor([result], entryIdOf, low)) clips.set(clip.toolCallId, clip);
626
+ }
627
+ }
628
+ const before = measureFor(ctx)(saved);
629
+ const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before,
630
+ previousSummary: saved.find(message => message.role === "compactionSummary")?.summary, newPrompt, reRenderSummary: true,
631
+ protectedPromptEntryId: newPrompt ? undefined : protectedPrompt(ctx), deepest: true }, ctx, 0, plannerMeasureFor(ctx), options.recallPolicy, retain);
632
+ if (!draft.compaction) return { kind: "fault", capacity: Boolean(draft.capacityRejected), message: draft.unavailable };
633
+ const messages = previewCut(ctx, { type: "compaction", ...draft.compaction }).buildSessionProjection().messages;
634
+ assertNativeToolRounds(messages);
635
+ return { kind: "cut", target: "room", plan: { draft: true, after: measureFor(ctx)(messages), mode: draft.mode,
636
+ compaction: draft.compaction, reserve: 0, messages } };
637
+ };
545
638
  // 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
546
639
  // 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
547
640
  // keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
548
641
  // Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
549
- const preflight = (ctx: ExtensionContext, incomingPart: number, observedPrompt: string, tools: readonly string[], hostSnapshot?: number) => {
642
+ const preflight = (ctx: ExtensionContext, incomingPart: number, incomingMessages: Messages, observedPrompt: string, tools: readonly string[], hostSnapshot?: Messages) => {
550
643
  promptCut = undefined;
551
644
  promptCutAt = ctx.sessionManager.getBranch().length;
552
645
  if (!active()) return;
@@ -558,7 +651,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
558
651
  const saved = ctx.sessionManager.buildSessionProjection().messages;
559
652
  const system = requestSystemPart(observedPrompt, tools, saved, ctx.getSystemPrompt());
560
653
  const band = decisionBand(ctx, room, system);
561
- const host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
654
+ const hostInput = hostSnapshot ?? hostMessages(ctx), host = conversationTokens(hostInput), measure = measureFor(ctx), before = measure(saved);
562
655
  const incoming = incomingPart + system, mandatory = incoming + host;
563
656
  observeBudget(before + mandatory, ctx);
564
657
  estimatedTokens = corrected(before + mandatory, ctx);
@@ -572,7 +665,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
572
665
  const decide = (targetRoom: number, targetBand: number, opportunistic: boolean) => chooseCut({
573
666
  before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic,
574
667
  unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
575
- plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
668
+ plan: (target): Plan<CutPlan> => {
576
669
  const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
577
670
  // 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
578
671
  // there buys nothing the request needed and costs history the next recall would have found.
@@ -580,14 +673,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
580
673
  if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
581
674
  const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
582
675
  assertNativeToolRounds(messages);
583
- return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
676
+ return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
584
677
  },
585
678
  });
586
- let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" as const };
679
+ let decision: Decision<CutPlan> = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" };
587
680
  const { hardLine } = limits(ctx);
588
681
  if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
589
- if (fits(before + mandatory, hardLine, ctx)) return;
590
- decision = fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
682
+ decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } :
683
+ fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
684
+ decision = clampAboveRoom(decision, ctx, room, hardLine,
685
+ candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
686
+ candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, incomingMessages, observedPrompt, tools, candidate.kind === "cut"),
687
+ target => decide(target, target <= room ? band : target, false),
688
+ () => deepestDecision(ctx, true, false));
591
689
  }
592
690
  if (decision.kind === "defer") return;
593
691
  // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
@@ -606,7 +704,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
606
704
  try {
607
705
  syncHistoryTools(ctx);
608
706
  if (prompted) access(); // decided on input; a failure latched there still stops the run
609
- else await preflight(ctx, incomingEventTokens(event), event.systemPrompt,
707
+ else await preflight(ctx, incomingEventTokens(event), [{ role: "user", timestamp: 0,
708
+ content: event.messageType === "custom" ? event.message.content : [{ type: "text", text: event.prompt }, ...(event.images ?? [])] }], event.systemPrompt,
610
709
  historyToolNames(pi, event.systemPromptOptions.selectedTools, historyToolsEnabled(ctx)));
611
710
  // Pi declares tool changes before turn_start, then snapshots executable tools in prepareRequest. Introduce our tools in
612
711
  // the request projection: recording an activation message here would shift every later session-global recall index.
@@ -629,10 +728,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
629
728
  const incoming = incomingTokens(typeof message.content === "string" ? message.content : message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"),
630
729
  typeof message.content === "string" ? [] : message.content.filter((b): b is ImageContent => b.type === "image"));
631
730
  const saved = ctx.sessionManager.buildSessionProjection().messages;
632
- const host = hostTokens(ctx);
731
+ const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
633
732
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
634
733
  if (fits(conversationTokens(saved) + system + incoming + host, limits(ctx).room, ctx)) return;
635
- preflight(ctx, incoming, ctx.getSystemPrompt(), pi.getActiveTools(), host);
734
+ preflight(ctx, incoming, [message], ctx.getSystemPrompt(), pi.getActiveTools(), hostInput);
636
735
  syncHistoryTools(ctx);
637
736
  } catch (error) { fail(error, ctx); }
638
737
  });
@@ -654,7 +753,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
654
753
  // request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
655
754
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
656
755
  const band = decisionBand(ctx, room, system);
657
- const host = hostTokens(ctx);
756
+ const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
658
757
  const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
659
758
  const mandatory = system + host + queued;
660
759
  // Without usable provider usage, Pi's own threshold uses its unwrapped message estimate as the compiler's density input.
@@ -680,17 +779,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
680
779
  let floorKept: number | undefined;
681
780
  let wholePromptFloor: number | undefined;
682
781
  const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
683
- const decide = (targetRoom = room, targetBand = band) => {
782
+ const decide = (targetRoom = room, targetBand = band, required = false) => {
684
783
  previews = [];
685
784
  floorKept = undefined;
686
785
  wholePromptFloor = undefined;
687
786
  const before = measure(saved);
688
- const gate = gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
787
+ const gate = required && fits(mandatory, targetRoom, ctx) ? { kind: "plan" as const, opportunistic: false } :
788
+ gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
689
789
  if (gate.kind !== "plan") return gate;
690
790
  return chooseCut({
691
791
  opportunistic: gate.opportunistic, mandatory, before, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
692
792
  unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
693
- plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
793
+ plan: (target): Plan<CutPlan> => {
694
794
  const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
695
795
  const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary, protectedPromptEntryId: protectedId }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
696
796
  if (!draft.compaction) {
@@ -712,17 +812,17 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
712
812
  const messages = preview.buildSessionProjection().messages;
713
813
  assertNativeToolRounds(messages);
714
814
  previews.push(messages);
715
- return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
815
+ return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
716
816
  },
717
817
  });
718
818
  };
719
- let decision = decide();
819
+ let decision: Decision<CutPlan> = decide();
720
820
  // 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
721
821
  // 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.
722
822
  // clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
723
823
  // 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
724
824
  // refusal stands.
725
- const tryClips = (clipRoom: number, targetBand: number) => {
825
+ const tryClips = (clipRoom: number, targetBand: number, required = false) => {
726
826
  if (decision.kind !== "fault" || !event.toolResults) return;
727
827
  const branch = ctx.sessionManager.getBranch();
728
828
  const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
@@ -733,7 +833,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
733
833
  const round = clipFor(event.toolResults, entryIdOf, budget);
734
834
  if (!round.length) continue;
735
835
  for (const clip of round) clips.set(clip.toolCallId, clip);
736
- const clippedDecision = decide(clipRoom, targetBand);
836
+ const clippedDecision = decide(clipRoom, targetBand, required);
737
837
  if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
738
838
  else { decision = clippedDecision; break; }
739
839
  }
@@ -744,9 +844,17 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
744
844
  estimatedTokens = corrected(before + mandatory, ctx);
745
845
  const { hardLine } = limits(ctx);
746
846
  if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
747
- if (fits(before + mandatory, hardLine, ctx)) return;
748
- decision = decide(hardLine, hardLine);
749
- tryClips(hardLine, hardLine);
847
+ if (fits(before + mandatory, hardLine, ctx)) decision = { kind: "none" };
848
+ else { decision = decide(hardLine, hardLine); tryClips(hardLine, hardLine); }
849
+ decision = clampAboveRoom(decision, ctx, room, hardLine,
850
+ candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
851
+ candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, event.context.pendingMessages as Messages, ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"),
852
+ target => {
853
+ const targetBand = target <= room ? band : target;
854
+ decision = decide(target, targetBand, target > room);
855
+ tryClips(target, targetBand, target > room);
856
+ return decision;
857
+ }, () => deepestDecision(ctx, false, true));
750
858
  }
751
859
  if (decision.kind === "fault" && reason === "tool-loop") {
752
860
  if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx)) throw promptRefusal();
@@ -774,9 +882,60 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
774
882
  setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
775
883
  return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
776
884
  };
885
+ const retryThinking = (event: TurnEndEvent, ctx: ExtensionContext) => {
886
+ // Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
887
+ // Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
888
+ const entries: SessionBoundaryDraft[] = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
889
+ { type: "context_edit", targetId: event.messageEntryId, replacement: null }];
890
+ if (thinkingRetryPending) return { entries, continue: false };
891
+ const header = ctx.sessionManager.getHeader();
892
+ if (!header) throw new Error("Missing native session header for the thinking-only retry");
893
+ const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
894
+ for (const entry of entries) {
895
+ if (entry.type === "context_edit") preview.appendContextEdit(entry.targetId, entry.replacement);
896
+ else if (entry.type === "compaction") preview.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
897
+ else if (entry.type === "custom") preview.appendCustomEntry(entry.customType, entry.data);
898
+ else preview.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
899
+ }
900
+ const view: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
901
+ const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
902
+ // The deepest cut only when it buys reply room: where Pi's cap on the request as it stands (the stopped reply left out) is already
903
+ // as large as the deepest cut's, the turn is retried uncut, and no history is given up for nothing.
904
+ const asIs = projectedRequest(preview.buildSessionProjection().messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), false);
905
+ const asIsCheck = requestClamp(asIs, view);
906
+ const previousClips = new Map(clips);
907
+ const decision = deepestDecision(view, false, true);
908
+ if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
909
+ requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap! <= asIsCheck.cap) {
910
+ clips.clear(); for (const [id, clip] of previousClips) clips.set(id, clip);
911
+ const { room, hardLine } = limits(view);
912
+ thinkingRetryPending = true;
913
+ return { entries: [...entries, { type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
914
+ stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: false,
915
+ piEstimateTokens: asIsCheck.estimate, piCap: asIsCheck.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
916
+ }
917
+ if (decision.kind !== "cut") throw new CapacityError(clampMessage);
918
+ const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
919
+ if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt)) throw promptRefusal();
920
+ const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
921
+ const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
922
+ const { response, room, hardLine } = limits(view);
923
+ if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor) throw new CapacityError(clampMessage);
924
+ const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined,
925
+ { response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
926
+ thinkingRetryPending = true;
927
+ pendingBoundaryCommit = true;
928
+ return { entries: [...entries, { type: "compaction" as const, summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
929
+ { type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
930
+ stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
931
+ estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
932
+ };
777
933
  pi.on("turn_end", (event, ctx) => {
778
934
  try {
779
935
  if (!active() || !admittedRun) return;
936
+ if (thinkingOnlyLength(event.message) && !failure && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
937
+ return retryThinking(event, ctx);
938
+ thinkingRetryPending = false;
780
939
  const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
781
940
  settingsFor().getCompactionSettings(ctx.model).enabled;
782
941
  if (!promptCut && !decide) return;
@@ -860,16 +1019,15 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
860
1019
  const tokens = system + conversationTokens(messages);
861
1020
  observeBudget(tokens, ctx);
862
1021
  estimatedTokens = corrected(tokens, ctx);
863
- const { room, hardLine, replyFloor } = limits(ctx);
1022
+ const { room, hardLine } = limits(ctx);
864
1023
  if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
865
1024
  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");
866
1025
  if (!fits(tokens, hardLine, ctx))
867
1026
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
868
1027
  // Pi's request estimator and its compaction estimator need not agree. The hard line is
869
1028
  // necessary; above the room, also require the real request clamp to preserve the floor.
870
- if (!options.requestReserve && estimatedTokens > room && piClamp &&
871
- piClamp(ctx.model, normalizeContext({ messages: convertToLlm(messages) }), ctx.model.maxTokens) < replyFloor)
872
- throw new CapacityError("Pi's request clamp leaves less than the reply floor");
1029
+ if (!options.requestReserve && estimatedTokens > room && piClamp && !requestClamp(messages, ctx).leavesFloor)
1030
+ throw new CapacityError(clampMessage);
873
1031
  if (estimatedTokens > room) receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
874
1032
  if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
875
1033
  recorder.request();
@@ -1,6 +1,6 @@
1
1
  import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
2
2
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
- import type { Messages } from './budget.js';
3
+ import type { AgentMessage, Messages } from './budget.js';
4
4
  import { parseRecords, splitSummary } from './tuck/core/transcript.js';
5
5
 
6
6
  /** Read Pi's private safety allowance through its public clamp, rather than copying it.
@@ -21,17 +21,28 @@ export async function piContextSafetyMargin(): Promise<number> {
21
21
  /** The smallest reply room a request above the room must leave after Pi's own clamp: above the user's p99 reply (3,270 tokens). */
22
22
  export const REPLY_FLOOR_MIN = 3_584;
23
23
 
24
+ /** A spent thinking budget that produced neither a visible answer nor work. Empty/text/tool replies keep the existing length recovery. */
25
+ export const thinkingOnlyLength = (message: AgentMessage): boolean => message.role === 'assistant' && message.stopReason === 'length' &&
26
+ message.content.length > 0 && message.content.every(block => block.type === 'thinking');
27
+
28
+ /** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
29
+ export const needsFullReplyReserve = (message: AgentMessage): boolean => message.role === 'assistant' &&
30
+ (message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
31
+
24
32
  /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
25
33
  export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
26
- let largest = REPLY_FLOOR_MIN;
34
+ let largest = REPLY_FLOOR_MIN, thinkingStop = false;
27
35
  for (const entry of branch) {
28
36
  if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
29
37
  const message = entry.message;
30
- if (message.stopReason === 'length' || message.stopReason === 'error' && isContextOverflow(message)) return reserve;
38
+ if (needsFullReplyReserve(message)) return reserve;
39
+ thinkingStop ||= thinkingOnlyLength(message);
31
40
  const output = message.usage?.output;
32
41
  if (typeof output === 'number' && Number.isFinite(output) && output > largest) largest = output;
33
42
  }
34
- return Math.min(reserve, Math.ceil(largest));
43
+ // Include the exhausted thinking reply itself, even across later cuts/restarts. Its observed output must fit again;
44
+ // requestReplyFloor applies the model cap. Ordinary branches retain their existing reserve cap.
45
+ return thinkingStop ? Math.ceil(largest) : Math.min(reserve, Math.ceil(largest));
35
46
  }
36
47
 
37
48
  /** Admission never promises more output than a model with a positive reply cap can produce. */