pi-tuck 1.0.28 → 1.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,36 @@
3
3
  All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [1.0.29] - 2026-11-11
7
+
8
+ ### Fixed
9
+
10
+ - **When Pi's configured window was larger than the server's, a session could get stuck after one overflow.** pi-tuck learned the right
11
+ correction from the server's refusal, then went on refusing every prompt. It mistook a cut Pi had saved for one Pi had failed to save. It
12
+ now checks the session file before treating a save as failed.
13
+ - **A server that silently shrinks the reply to fit its real window went unnoticed** (mac-studio does this when prompt plus reply cap is
14
+ over its window). pi-tuck answered fewer prompts than plain Pi.
15
+ - A reply that stops for length below the reply cap Pi sent was cut off by the server, so its input plus output is the server's real
16
+ window. pi-tuck now learns that window, keeps it for the model in the session, plans with the smaller window, and retries that turn
17
+ once.
18
+ - A reply that stops at the cap Pi sent is still a genuine length stop, under 1.0.25's rules.
19
+
20
+ ### Added
21
+
22
+ - **Stress cells for a configured window (81,920) larger than the server's (68,000), each against plain Pi:**
23
+ - a prompt over the window, with mac-studio's exact refusal;
24
+ - a prompt plus reply cap over the window;
25
+ - the reply cut off at exactly the window.
26
+
27
+ pi-tuck now answers at least as many prompts as plain Pi in all three, loses no task and strands no session. Stress counts a reply as an
28
+ answer only when its request carries the task.
29
+
30
+ ### Release gates
31
+
32
+ - **Shipped without a live gate, because its replay matches 1.0.28:** 13,946 requests, 0 different beyond noise, 0 dead tasks, clips
33
+ 17 → 17.
34
+ - **Strict stress, 313 cells:** 0 findings. Tests (810) and CI. The packed tarball loaded in the real installed Pi.
35
+
6
36
  ## [1.0.28] - 2026-11-10
7
37
 
8
38
  ### Changed
@@ -1 +1 @@
1
- export declare const BUILD_ID = "e9c750b757776bf44ad79939b633f178ef9a8f14acfab1c8bb6311d692c79e61";
1
+ export declare const BUILD_ID = "7ff651306c4a9f937b9d1abdc44123ce28cc9a6892f0730b3bef7d8174574a94";
@@ -1 +1 @@
1
- export const BUILD_ID = "e9c750b757776bf44ad79939b633f178ef9a8f14acfab1c8bb6311d692c79e61";
1
+ export const BUILD_ID = "7ff651306c4a9f937b9d1abdc44123ce28cc9a6892f0730b3bef7d8174574a94";
@@ -51,7 +51,8 @@ export interface CompactionOptions {
51
51
  cutAt?: number;
52
52
  /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
53
53
  * 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.
54
- * After a length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
54
+ * After a genuine length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
55
+ * Server clipping below the sent reply cap instead learns the smaller window and preserves the normal reply floor.
55
56
  * Thinking-only exhaustion keeps Pi's reserve and requires the branch's largest observed reply instead.
56
57
  * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
57
58
  requestReserve?: (ctx: ExtensionContext) => {
@@ -1,10 +1,11 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
2
  import { PendingCut, StartingPrompt, createRun } from "./run.js";
3
- import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
3
+ import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath, seedContextWindow, WINDOW_RECEIPT } from "./request-capacity.js";
4
4
  import { BUILD_ID } from "./build-id.js";
5
5
  import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
6
6
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
7
7
  import { createHash } from "node:crypto";
8
+ import { readFileSync } from "node:fs";
8
9
  import { exportSource } from "./source-export.js";
9
10
  import { isDeepStrictEqual } from "node:util";
10
11
  import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
@@ -144,7 +145,7 @@ export async function installCompaction(pi, options = {}) {
144
145
  }
145
146
  });
146
147
  const captureDraft = (draft, ctx, reserve, reason, tokensBefore, retainCurrentInput, operatorManual, previousSummary, budget) => recorder.measureInline(() => {
147
- const attached = attachReceipt(draft, reason, reserve, ctx.model?.contextWindow ?? 0, { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, activeInputCeilingTokens: lastCeiling });
148
+ const attached = attachReceipt(draft, reason, reserve, contextWindow(ctx), { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, learnedContextWindow, activeInputCeilingTokens: lastCeiling });
148
149
  proposalId = receiptId(attached);
149
150
  if (recorder.enabled) {
150
151
  try {
@@ -241,6 +242,9 @@ export async function installCompaction(pi, options = {}) {
241
242
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
242
243
  // start, or a model switch) starts with the window its branch's newest receipt recorded for that identity, else that receipt's factor, else the model's measured undercount or 1 (`seedWindow`).
243
244
  let identity = "", factor = 1, ratios = [];
245
+ let learnedContextWindow;
246
+ const serverClipped = new WeakSet();
247
+ let windowObservation;
244
248
  let lastCeiling = null;
245
249
  let pending;
246
250
  const identityFor = (ctx) => {
@@ -249,10 +253,27 @@ export async function installCompaction(pi, options = {}) {
249
253
  identity = key;
250
254
  ratios = seedWindow(ctx.sessionManager.getBranch(), key);
251
255
  factor = Math.max(1, ...ratios);
256
+ learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), key);
252
257
  pending = undefined;
253
258
  }
254
259
  return key;
255
260
  };
261
+ const contextWindow = (ctx) => {
262
+ identityFor(ctx);
263
+ return Math.min(ctx.model?.contextWindow ?? 0, learnedContextWindow ?? Infinity);
264
+ };
265
+ // This is the cap in Pi's built provider payload, including its context clamp and thinking adjustments.
266
+ // An API with no explicit cap supplies no clipping evidence; never substitute the catalogue maximum.
267
+ pi.on("before_provider_request", event => {
268
+ if (!pending || !event.payload || typeof event.payload !== "object")
269
+ return;
270
+ const payload = event.payload;
271
+ // Read at reply time so later in-place payload edits are included too.
272
+ pending.requestedMaxTokens = () => {
273
+ const cap = payload.max_tokens ?? payload.max_completion_tokens ?? payload.max_output_tokens;
274
+ return typeof cap === "number" && Number.isSafeInteger(cap) && cap > 0 ? cap : undefined;
275
+ };
276
+ });
256
277
  // The factor is max(1, the last CALIBRATION_WINDOW paired ratios, overflow bounds and seed): it rises at once and a stale high ratio ages out.
257
278
  const learn = (value) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW)
258
279
  ratios.shift(); factor = Math.max(1, ...ratios); };
@@ -264,6 +285,7 @@ export async function installCompaction(pi, options = {}) {
264
285
  // The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
265
286
  const planReserve = (target, room, band, mandatory, ctx) => {
266
287
  identityFor(ctx);
288
+ // The compiler subtracts this reserve from the catalogue window. Targets already use the learned window.
267
289
  return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? band : room, factor, mandatory);
268
290
  };
269
291
  pi.on("message_end", (event, ctx) => {
@@ -292,9 +314,9 @@ export async function installCompaction(pi, options = {}) {
292
314
  // 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
293
315
  // with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
294
316
  if (overflow) {
295
- const bound = (ctx.model.contextWindow + 1) / paired.tokens;
317
+ const window = contextWindow(ctx), bound = (window + 1) / paired.tokens;
296
318
  const learned = bound <= CALIBRATION_CEILING;
297
- ctx.ui.notify(overflowNotice(ctx.model.contextWindow, bound, learned), "warning");
319
+ ctx.ui.notify(overflowNotice(window, bound, learned), "warning");
298
320
  if (learned)
299
321
  learn(bound);
300
322
  return;
@@ -306,12 +328,28 @@ export async function installCompaction(pi, options = {}) {
306
328
  if (!counts.every(n => Number.isSafeInteger(n) && n >= 0))
307
329
  return;
308
330
  const input = counts.reduce((a, b) => a + b, 0), ratio = input / paired.tokens;
331
+ const output = usage.output, bound = usage.input + usage.cacheRead + output, requested = paired.requestedMaxTokens?.();
332
+ if (message.stopReason === "length" && requested !== undefined &&
333
+ Number.isSafeInteger(output) && output >= 0 && output < requested &&
334
+ Number.isSafeInteger(bound) && bound > 0 && bound <= ctx.model.contextWindow) {
335
+ learnedContextWindow = Math.min(learnedContextWindow ?? Infinity, bound);
336
+ serverClipped.add(message);
337
+ windowObservation = { message, identity: key, window: learnedContextWindow };
338
+ }
309
339
  // A request that was answered cannot have held more than the model's window, so a report above it describes something that never happened.
310
340
  // 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
311
341
  // 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.
312
342
  if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio))
313
343
  learn(ratio);
314
344
  });
345
+ pi.on("turn_end", event => {
346
+ if (windowObservation?.message !== event.message)
347
+ return;
348
+ const observation = windowObservation;
349
+ windowObservation = undefined;
350
+ pi.appendEntry(WINDOW_RECEIPT, { calibrationIdentity: observation.identity, learnedContextWindow: observation.window,
351
+ serverClippedReplyId: event.messageEntryId });
352
+ });
315
353
  // The operator's length-stop notice is decided at settle and is not attempt state: a run reset before then (a tree move inside another
316
354
  // extension's agent_settled, a failure) must not erase it. Only the final assistant reply decides it; a successful retry clears it.
317
355
  let lengthStop = false;
@@ -357,13 +395,13 @@ export async function installCompaction(pi, options = {}) {
357
395
  lastCeiling = ceiling ?? null;
358
396
  if (ceiling !== undefined && (!Number.isSafeInteger(ceiling) || ceiling <= 0))
359
397
  throw new Error("Invalid active-input token ceiling");
360
- return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
398
+ return Math.min(contextWindow(ctx) - response, ceiling ?? Infinity);
361
399
  };
362
400
  // Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
363
401
  const limits = (ctx) => {
364
402
  const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
365
403
  const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
366
- const hardLine = Math.min(Math.max(room, (ctx.model?.contextWindow ?? 0) - piMargin - floor), lastCeiling ?? Infinity);
404
+ const hardLine = Math.min(Math.max(room, contextWindow(ctx) - piMargin - floor), lastCeiling ?? Infinity);
367
405
  return { response, room, hardLine, replyFloor: floor };
368
406
  };
369
407
  const decisionBand = (ctx, room, system, current = view(ctx)) => {
@@ -396,7 +434,7 @@ export async function installCompaction(pi, options = {}) {
396
434
  // whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
397
435
  const requestClamp = (messages, ctx) => {
398
436
  const context = normalizeContext({ messages: convertToLlm(messages) });
399
- const cap = piClamp?.(ctx.model, context, ctx.model.maxTokens);
437
+ const cap = piClamp?.({ ...ctx.model, contextWindow: contextWindow(ctx) }, context, ctx.model.maxTokens);
400
438
  return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
401
439
  estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
402
440
  };
@@ -432,7 +470,7 @@ export async function installCompaction(pi, options = {}) {
432
470
  return lastAttempt();
433
471
  // Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
434
472
  // use its existing band/room policy; admission below the room is deliberately unchanged.
435
- target = Math.max(room, Math.floor(target * (ctx.model.contextWindow - piMargin - limits(ctx).replyFloor) / check.estimate));
473
+ target = Math.max(room, Math.floor(target * (contextWindow(ctx) - piMargin - limits(ctx).replyFloor) / check.estimate));
436
474
  decision = replan(target);
437
475
  if (decision.kind === "defer" || decision.kind === "fault")
438
476
  return lastAttempt();
@@ -663,14 +701,14 @@ export async function installCompaction(pi, options = {}) {
663
701
  }
664
702
  });
665
703
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
666
- pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; });
704
+ pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; learnedContextWindow = undefined; windowObservation = undefined; });
667
705
  const historyToolsEnabled = (ctx) => view(ctx).hasTuckCut;
668
706
  const syncHistoryTools = (ctx) => { if (active())
669
707
  applyHistoryTools(pi, historyToolsEnabled(ctx)); };
670
708
  pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
671
709
  // A plan decided on `input` is for the branch it was decided on: moving in the tree leaves it planned for another branch, whose kept entry
672
710
  // may not be on this one.
673
- pi.on("session_tree", (_event, ctx) => { resetRun(); syncHistoryTools(ctx); });
711
+ pi.on("session_tree", (_event, ctx) => { resetRun(); windowObservation = undefined; learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), identityFor(ctx)); syncHistoryTools(ctx); });
674
712
  pi.on("session_compact", (_event, ctx) => { if (run?.phase !== "admitted")
675
713
  syncHistoryTools(ctx); });
676
714
  pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
@@ -871,7 +909,7 @@ export async function installCompaction(pi, options = {}) {
871
909
  if (forecast !== saved) {
872
910
  const head = getCurrentSystemMessage(convertToLlm(forecast));
873
911
  const native = (head ? estimateTokens(head) : 0) + saved.reduce((sum, message) => sum + (message.role === "system" ? 0 : estimateTokens(message)), 0);
874
- if (shouldCompact(native, ctx.model.contextWindow, settingsFor().getCompactionSettings(ctx.model)))
912
+ if (shouldCompact(native, contextWindow(ctx), settingsFor().getCompactionSettings(ctx.model)))
875
913
  densityTokens = native;
876
914
  }
877
915
  }
@@ -975,6 +1013,20 @@ export async function installCompaction(pi, options = {}) {
975
1013
  return { entries: [{ type: "compaction", summary: compaction.summary,
976
1014
  firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
977
1015
  };
1016
+ const markBoundary = (entries) => {
1017
+ if (!run)
1018
+ return;
1019
+ run.pendingBoundaryReceipts = entries.flatMap(entry => entry.type === "compaction" ? receiptId(entry) ?? [] : []);
1020
+ run.pendingBoundaryCommit = run.pendingBoundaryReceipts.length > 0;
1021
+ };
1022
+ const boundarySaved = (ctx) => {
1023
+ const wanted = run?.pendingBoundaryReceipts ?? [];
1024
+ const file = ctx.sessionManager.getSessionFile();
1025
+ // appendCompaction mutates memory before writing. On an error boundary, only the persisted entries prove a file-backed cut saved.
1026
+ const entries = file ? readFileSync(file, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)) : ctx.sessionManager.getBranch();
1027
+ const saved = new Set(entries.flatMap(entry => entry.type === "compaction" ? receiptId(entry) ?? [] : []));
1028
+ return wanted.every(id => saved.has(id));
1029
+ };
978
1030
  const atBoundary = (event, ctx, reason, decide) => {
979
1031
  const current = view(ctx);
980
1032
  if (!current.pending)
@@ -989,7 +1041,7 @@ export async function installCompaction(pi, options = {}) {
989
1041
  setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
990
1042
  return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
991
1043
  };
992
- const retryThinking = (event, ctx) => {
1044
+ const retryLength = (event, ctx) => {
993
1045
  // Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
994
1046
  // Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
995
1047
  const entries = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
@@ -1005,7 +1057,7 @@ export async function installCompaction(pi, options = {}) {
1005
1057
  const asIsCheck = requestClamp(asIs, view);
1006
1058
  const previousClips = new Map(clips());
1007
1059
  const decision = deepestDecision(view, false, true);
1008
- if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
1060
+ if (!serverClipped.has(event.message) && decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
1009
1061
  requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap <= asIsCheck.cap) {
1010
1062
  clips().clear();
1011
1063
  for (const [id, clip] of previousClips)
@@ -1024,22 +1076,26 @@ export async function installCompaction(pi, options = {}) {
1024
1076
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
1025
1077
  const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
1026
1078
  const { response, room, hardLine } = limits(view);
1027
- if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor)
1079
+ // A learned server boundary uses the same admission rule as the guard: below the room the whole reserve fits in provider units.
1080
+ // Keep the thinking-only rule's unconditional native-clamp floor unchanged.
1081
+ const needsClampFloor = !serverClipped.has(event.message) || corrected(tokens, view) > room;
1082
+ if (!fits(tokens, hardLine, view) || check.cap === undefined || needsClampFloor && !check.leavesFloor)
1028
1083
  throw new CapacityError(clampMessage);
1029
1084
  const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined, { response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
1030
1085
  run.thinkingRetryPending = true;
1031
- run.pendingBoundaryCommit = true;
1032
- return { entries: [...entries, { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
1033
- { type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
1034
- stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
1035
- estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
1086
+ const drafts = [...entries, { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
1087
+ { type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
1088
+ stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
1089
+ estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }];
1090
+ markBoundary(drafts);
1091
+ return { entries: drafts, continue: true };
1036
1092
  };
1037
1093
  pi.on("turn_end", (event, ctx) => {
1038
1094
  try {
1039
1095
  if (!active() || run?.phase !== "admitted")
1040
1096
  return;
1041
- if (thinkingOnlyLength(event.message) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
1042
- return retryThinking(event, ctx);
1097
+ if ((thinkingOnlyLength(event.message) || serverClipped.has(event.message)) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
1098
+ return retryLength(event, ctx);
1043
1099
  run.thinkingRetryPending = false;
1044
1100
  const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
1045
1101
  settingsFor().getCompactionSettings(ctx.model).enabled;
@@ -1047,7 +1103,7 @@ export async function installCompaction(pi, options = {}) {
1047
1103
  return;
1048
1104
  const result = atBoundary(event, ctx, "tool-loop", decide);
1049
1105
  if (result) {
1050
- run.pendingBoundaryCommit = true;
1106
+ markBoundary(result.entries);
1051
1107
  }
1052
1108
  return result;
1053
1109
  }
@@ -1117,10 +1173,19 @@ export async function installCompaction(pi, options = {}) {
1117
1173
  });
1118
1174
  pi.on("agent_before_settle", (event, ctx) => {
1119
1175
  if (run?.pendingBoundaryCommit) {
1120
- if (event.outcome === "error")
1121
- return void fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
1122
- if (run)
1176
+ if (event.outcome === "error") {
1177
+ try {
1178
+ if (!boundarySaved(ctx))
1179
+ throw new Error("Boundary receipt absent from the session file");
1180
+ }
1181
+ catch {
1182
+ return void fail(new CommitFault("Native tool-loop boundary cut was not saved in the session file"), ctx);
1183
+ }
1184
+ }
1185
+ if (run) {
1123
1186
  run.pendingBoundaryCommit = false;
1187
+ run.pendingBoundaryReceipts = [];
1188
+ }
1124
1189
  }
1125
1190
  // Pi commits these entries before the run settles.
1126
1191
  try {
@@ -1137,8 +1202,10 @@ export async function installCompaction(pi, options = {}) {
1137
1202
  });
1138
1203
  pi.on("context_with_system", (event, ctx) => {
1139
1204
  // Reaching the next request boundary proves native commit returned, not durable storage.
1140
- if (run)
1205
+ if (run) {
1141
1206
  run.pendingBoundaryCommit = false;
1207
+ run.pendingBoundaryReceipts = [];
1208
+ }
1142
1209
  pending = undefined;
1143
1210
  try {
1144
1211
  if (!active())
@@ -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 = "e9c750b757776bf44ad79939b633f178ef9a8f14acfab1c8bb6311d692c79e61";
3
+ export declare const DIAGNOSTIC_BUILD = "7ff651306c4a9f937b9d1abdc44123ce28cc9a6892f0730b3bef7d8174574a94";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -19,6 +19,7 @@ export interface Receipt {
19
19
  calibrationFactor?: number;
20
20
  calibrationWindow?: number[];
21
21
  calibrationIdentity?: string;
22
+ learnedContextWindow?: number;
22
23
  activeInputCeilingTokens?: number | null;
23
24
  };
24
25
  before: {
@@ -22,7 +22,15 @@ export declare const REPLY_FLOOR_MIN = 3584;
22
22
  export declare const thinkingOnlyLength: (message: AgentMessage) => boolean;
23
23
  /** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
24
24
  export declare const needsFullReplyReserve: (message: AgentMessage) => boolean;
25
- /** One branch reader for the reserve override and reply floor, including history before cuts. */
25
+ /** Saved immediately at the reply's turn boundary, even when recovery cannot make a compaction. */
26
+ export interface WindowReceipt {
27
+ calibrationIdentity: string;
28
+ learnedContextWindow: number;
29
+ serverClippedReplyId: string;
30
+ }
31
+ export declare const WINDOW_RECEIPT = "pi-tuck-window";
32
+ export declare function seedContextWindow(branch: readonly SessionEntry[], identity: string): number | undefined;
33
+ /** One branch reader for the reserve override and reply floor, including history before cuts. A server-clipping receipt follows its reply. */
26
34
  export declare function replyFit(branch: readonly SessionEntry[]): {
27
35
  failed: boolean;
28
36
  largestOutput: number;
@@ -64,14 +64,38 @@ export const thinkingOnlyLength = (message) => message.role === 'assistant' && m
64
64
  /** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
65
65
  export const needsFullReplyReserve = (message) => message.role === 'assistant' &&
66
66
  (message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
67
- /** One branch reader for the reserve override and reply floor, including history before cuts. */
67
+ export const WINDOW_RECEIPT = 'pi-tuck-window';
68
+ function windowSettings(entry) {
69
+ if (entry.type === 'custom' && entry.customType === WINDOW_RECEIPT)
70
+ return entry.data;
71
+ if (entry.type === 'compaction')
72
+ return entry.details?.diagnosticReceipt?.settings;
73
+ return undefined;
74
+ }
75
+ export function seedContextWindow(branch, identity) {
76
+ for (let i = branch.length; i--;) {
77
+ const settings = windowSettings(branch[i]);
78
+ if (settings?.calibrationIdentity !== identity)
79
+ continue;
80
+ const window = settings.learnedContextWindow;
81
+ return Number.isSafeInteger(window) && window > 0 ? window : undefined;
82
+ }
83
+ return undefined;
84
+ }
85
+ /** One branch reader for the reserve override and reply floor, including history before cuts. A server-clipping receipt follows its reply. */
68
86
  export function replyFit(branch) {
69
87
  let failed = false, largestOutput = 0, thinkingStop = false;
70
- for (const entry of branch) {
88
+ const clipped = new Set();
89
+ for (let i = branch.length; i--;) {
90
+ const entry = branch[i];
91
+ const receipt = entry.type === 'custom' && entry.customType === WINDOW_RECEIPT ? windowSettings(entry) : undefined;
92
+ if (receipt && Number.isSafeInteger(receipt.learnedContextWindow) && receipt.learnedContextWindow > 0 &&
93
+ typeof receipt.serverClippedReplyId === 'string')
94
+ clipped.add(receipt.serverClippedReplyId);
71
95
  if (entry.type !== 'message' || entry.message.role !== 'assistant')
72
96
  continue;
73
97
  const message = entry.message;
74
- failed ||= needsFullReplyReserve(message);
98
+ failed ||= !clipped.has(entry.id) && needsFullReplyReserve(message);
75
99
  thinkingStop ||= thinkingOnlyLength(message);
76
100
  const output = message.usage?.output;
77
101
  if (typeof output === 'number' && Number.isFinite(output) && output > largestOutput)
package/dist/src/run.d.ts CHANGED
@@ -44,6 +44,7 @@ export interface Run {
44
44
  cut: PendingCut;
45
45
  clips: Map<string, Clip>;
46
46
  pendingBoundaryCommit: boolean;
47
+ pendingBoundaryReceipts: string[];
47
48
  thinkingRetryPending: boolean;
48
49
  failure?: Error;
49
50
  refusalReceipted: boolean;
package/dist/src/run.js CHANGED
@@ -86,4 +86,4 @@ export class StartingPrompt {
86
86
  }
87
87
  }
88
88
  export const createRun = (phase) => ({ phase, claimed: false, expired: false,
89
- cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, thinkingRetryPending: false, refusalReceipted: false });
89
+ cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, pendingBoundaryReceipts: [], thinkingRetryPending: false, refusalReceipted: false });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
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 = "e9c750b757776bf44ad79939b633f178ef9a8f14acfab1c8bb6311d692c79e61";
1
+ export const BUILD_ID = "7ff651306c4a9f937b9d1abdc44123ce28cc9a6892f0730b3bef7d8174574a94";
package/src/compaction.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
2
  import { PendingCut, StartingPrompt, createRun, type CutEntry, type Run } from "./run.js";
3
- import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
3
+ import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath, seedContextWindow, WINDOW_RECEIPT, type WindowReceipt } from "./request-capacity.js";
4
4
  import { BUILD_ID } from "./build-id.js";
5
5
  import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
6
6
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
7
7
  import { createHash } from "node:crypto";
8
+ import { readFileSync } from "node:fs";
8
9
  import { exportSource } from "./source-export.js";
9
10
  import { isDeepStrictEqual } from "node:util";
10
11
  import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
@@ -117,7 +118,8 @@ export interface CompactionOptions {
117
118
  cutAt?: number;
118
119
  /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
119
120
  * 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.
120
- * After a length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
121
+ * After a genuine length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
122
+ * Server clipping below the sent reply cap instead learns the smaller window and preserves the normal reply floor.
121
123
  * Thinking-only exhaustion keeps Pi's reserve and requires the branch's largest observed reply instead.
122
124
  * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
123
125
  requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
@@ -166,7 +168,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
166
168
  if (found) { recorder.capture("observed-entry", "current-native-branch-memory-not-durable", { entryId: found.id, diagnosticReceiptId: proposalId, details: found.type === "compaction" ? found.details : undefined }); recorder.cut(); proposalId = undefined; }
167
169
  });
168
170
  const captureDraft = (draft: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>, ctx: ExtensionContext, reserve: number, reason: string, tokensBefore: number, retainCurrentInput: boolean, operatorManual: boolean, previousSummary?: string, budget?: { response: number; host: number; mandatory: number; target: Target; margin: number }) => recorder.measureInline(() => {
169
- const attached = attachReceipt(draft, reason, reserve, ctx.model?.contextWindow ?? 0, { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, activeInputCeilingTokens: lastCeiling });
171
+ const attached = attachReceipt(draft, reason, reserve, contextWindow(ctx), { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, learnedContextWindow, activeInputCeilingTokens: lastCeiling });
170
172
  proposalId = receiptId(attached);
171
173
  if (recorder.enabled) { try {
172
174
  const filtered = permittedEntries(ctx.sessionManager.getEntries(), options.recallPolicy);
@@ -237,13 +239,31 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
237
239
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
238
240
  // start, or a model switch) starts with the window its branch's newest receipt recorded for that identity, else that receipt's factor, else the model's measured undercount or 1 (`seedWindow`).
239
241
  let identity = "", factor = 1, ratios: number[] = [];
242
+ let learnedContextWindow: number | undefined;
243
+ const serverClipped = new WeakSet<object>();
244
+ let windowObservation: { message: AgentMessage; identity: string; window: number } | undefined;
240
245
  let lastCeiling: number | null = null;
241
- let pending: { identity: string; tokens: number; messages: Parameters<typeof convertToLlm>[0]; snapshot: ReturnType<typeof convertToLlm> } | undefined;
246
+ let pending: { identity: string; tokens: number; messages: Parameters<typeof convertToLlm>[0]; snapshot: ReturnType<typeof convertToLlm>; requestedMaxTokens?: () => number | undefined } | undefined;
242
247
  const identityFor = (ctx: ExtensionContext) => {
243
248
  const key = ctx.model ? JSON.stringify([ctx.model.provider, ctx.model.api, ctx.model.id]) : "";
244
- if (key !== identity) { identity = key; ratios = seedWindow(ctx.sessionManager.getBranch(), key); factor = Math.max(1, ...ratios); pending = undefined; }
249
+ if (key !== identity) { identity = key; ratios = seedWindow(ctx.sessionManager.getBranch(), key); factor = Math.max(1, ...ratios); learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), key); pending = undefined; }
245
250
  return key;
246
251
  };
252
+ const contextWindow = (ctx: ExtensionContext) => {
253
+ identityFor(ctx);
254
+ return Math.min(ctx.model?.contextWindow ?? 0, learnedContextWindow ?? Infinity);
255
+ };
256
+ // This is the cap in Pi's built provider payload, including its context clamp and thinking adjustments.
257
+ // An API with no explicit cap supplies no clipping evidence; never substitute the catalogue maximum.
258
+ pi.on("before_provider_request", event => {
259
+ if (!pending || !event.payload || typeof event.payload !== "object") return;
260
+ const payload = event.payload as Record<string, unknown>;
261
+ // Read at reply time so later in-place payload edits are included too.
262
+ pending.requestedMaxTokens = () => {
263
+ const cap = payload.max_tokens ?? payload.max_completion_tokens ?? payload.max_output_tokens;
264
+ return typeof cap === "number" && Number.isSafeInteger(cap) && cap > 0 ? cap : undefined;
265
+ };
266
+ });
247
267
  // The factor is max(1, the last CALIBRATION_WINDOW paired ratios, overflow bounds and seed): it rises at once and a stale high ratio ages out.
248
268
  const learn = (value: number) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW) ratios.shift(); factor = Math.max(1, ...ratios); };
249
269
  const corrected = (tokens: number, ctx: ExtensionContext) => { identityFor(ctx); return Math.ceil(tokens * factor); };
@@ -253,6 +273,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
253
273
  // The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
254
274
  const planReserve = (target: Target, room: number, band: number, mandatory: number, ctx: ExtensionContext) => {
255
275
  identityFor(ctx);
276
+ // The compiler subtracts this reserve from the catalogue window. Targets already use the learned window.
256
277
  return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? band : room, factor, mandatory);
257
278
  };
258
279
  pi.on("message_end", (event, ctx) => {
@@ -275,9 +296,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
275
296
  // 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
276
297
  // with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
277
298
  if (overflow) {
278
- const bound = (ctx.model.contextWindow + 1) / paired.tokens;
299
+ const window = contextWindow(ctx), bound = (window + 1) / paired.tokens;
279
300
  const learned = bound <= CALIBRATION_CEILING;
280
- ctx.ui.notify(overflowNotice(ctx.model.contextWindow, bound, learned), "warning");
301
+ ctx.ui.notify(overflowNotice(window, bound, learned), "warning");
281
302
  if (learned) learn(bound);
282
303
  return;
283
304
  }
@@ -286,11 +307,25 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
286
307
  const counts = [usage.input, usage.cacheRead, usage.cacheWrite];
287
308
  if (!counts.every(n => Number.isSafeInteger(n) && n >= 0)) return;
288
309
  const input = counts.reduce((a, b) => a + b, 0), ratio = input / paired.tokens;
310
+ const output = usage.output, bound = usage.input + usage.cacheRead + output, requested = paired.requestedMaxTokens?.();
311
+ if (message.stopReason === "length" && requested !== undefined &&
312
+ Number.isSafeInteger(output) && output >= 0 && output < requested &&
313
+ Number.isSafeInteger(bound) && bound > 0 && bound <= ctx.model.contextWindow) {
314
+ learnedContextWindow = Math.min(learnedContextWindow ?? Infinity, bound);
315
+ serverClipped.add(message);
316
+ windowObservation = { message, identity: key, window: learnedContextWindow };
317
+ }
289
318
  // A request that was answered cannot have held more than the model's window, so a report above it describes something that never happened.
290
319
  // 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
291
320
  // 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.
292
321
  if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
293
322
  });
323
+ pi.on("turn_end", event => {
324
+ if (windowObservation?.message !== event.message) return;
325
+ const observation = windowObservation; windowObservation = undefined;
326
+ pi.appendEntry(WINDOW_RECEIPT, { calibrationIdentity: observation.identity, learnedContextWindow: observation.window,
327
+ serverClippedReplyId: event.messageEntryId } satisfies WindowReceipt);
328
+ });
294
329
  // The operator's length-stop notice is decided at settle and is not attempt state: a run reset before then (a tree move inside another
295
330
  // extension's agent_settled, a failure) must not erase it. Only the final assistant reply decides it; a successful retry clears it.
296
331
  let lengthStop = false;
@@ -325,13 +360,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
325
360
  lastCeiling = ceiling ?? null;
326
361
  if (ceiling !== undefined && (!Number.isSafeInteger(ceiling) || ceiling <= 0))
327
362
  throw new Error("Invalid active-input token ceiling");
328
- return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
363
+ return Math.min(contextWindow(ctx) - response, ceiling ?? Infinity);
329
364
  };
330
365
  // Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
331
366
  const limits = (ctx: ExtensionContext) => {
332
367
  const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
333
368
  const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
334
- const hardLine = Math.min(Math.max(room, (ctx.model?.contextWindow ?? 0) - piMargin - floor), lastCeiling ?? Infinity);
369
+ const hardLine = Math.min(Math.max(room, contextWindow(ctx) - piMargin - floor), lastCeiling ?? Infinity);
335
370
  return { response, room, hardLine, replyFloor: floor };
336
371
  };
337
372
  const decisionBand = (ctx: ExtensionContext, room: number, system: number, current = view(ctx)) => {
@@ -366,7 +401,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
366
401
  // whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
367
402
  const requestClamp = (messages: Messages, ctx: ExtensionContext) => {
368
403
  const context = normalizeContext({ messages: convertToLlm(messages) });
369
- const cap = piClamp?.(ctx.model!, context, ctx.model!.maxTokens);
404
+ const cap = piClamp?.({ ...ctx.model!, contextWindow: contextWindow(ctx) }, context, ctx.model!.maxTokens);
370
405
  return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
371
406
  estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
372
407
  };
@@ -396,7 +431,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
396
431
  if (retry === 3) return lastAttempt();
397
432
  // Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
398
433
  // use its existing band/room policy; admission below the room is deliberately unchanged.
399
- target = Math.max(room, Math.floor(target * (ctx.model!.contextWindow - piMargin - limits(ctx).replyFloor) / check.estimate));
434
+ target = Math.max(room, Math.floor(target * (contextWindow(ctx) - piMargin - limits(ctx).replyFloor) / check.estimate));
400
435
  decision = replan(target);
401
436
  if (decision.kind === "defer" || decision.kind === "fault") return lastAttempt();
402
437
  }
@@ -576,13 +611,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
576
611
  }
577
612
  });
578
613
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
579
- pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; });
614
+ pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; learnedContextWindow = undefined; windowObservation = undefined; });
580
615
  const historyToolsEnabled = (ctx: ExtensionContext) => view(ctx).hasTuckCut;
581
616
  const syncHistoryTools = (ctx: ExtensionContext) => { if (active()) applyHistoryTools(pi, historyToolsEnabled(ctx)); };
582
617
  pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
583
618
  // A plan decided on `input` is for the branch it was decided on: moving in the tree leaves it planned for another branch, whose kept entry
584
619
  // may not be on this one.
585
- pi.on("session_tree", (_event, ctx) => { resetRun(); syncHistoryTools(ctx); });
620
+ pi.on("session_tree", (_event, ctx) => { resetRun(); windowObservation = undefined; learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), identityFor(ctx)); syncHistoryTools(ctx); });
586
621
  pi.on("session_compact", (_event, ctx) => { if (run?.phase !== "admitted") syncHistoryTools(ctx); });
587
622
  pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
588
623
  pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
@@ -761,7 +796,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
761
796
  if (forecast !== saved) {
762
797
  const head = getCurrentSystemMessage(convertToLlm(forecast));
763
798
  const native = (head ? estimateTokens(head) : 0) + saved.reduce((sum, message) => sum + (message.role === "system" ? 0 : estimateTokens(message)), 0);
764
- if (shouldCompact(native, ctx.model.contextWindow, settingsFor().getCompactionSettings(ctx.model))) densityTokens = native;
799
+ if (shouldCompact(native, contextWindow(ctx), settingsFor().getCompactionSettings(ctx.model))) densityTokens = native;
765
800
  }
766
801
  }
767
802
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
@@ -857,6 +892,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
857
892
  // A boundary's cut. The run's first boundary also saves the prompt's cut, after any entries an earlier handler proposed, and decides on
858
893
  // the session as it is with that cut: a fresh result can still need a cut of its own there.
859
894
  type Boundary = Parameters<typeof boundaryCut>[0];
895
+ const markBoundary = (entries: readonly SessionBoundaryDraft[]) => {
896
+ if (!run) return;
897
+ run.pendingBoundaryReceipts = entries.flatMap(entry => entry.type === "compaction" ? receiptId(entry) ?? [] : []);
898
+ run.pendingBoundaryCommit = run.pendingBoundaryReceipts.length > 0;
899
+ };
900
+ const boundarySaved = (ctx: ExtensionContext) => {
901
+ const wanted = run?.pendingBoundaryReceipts ?? [];
902
+ const file = ctx.sessionManager.getSessionFile();
903
+ // appendCompaction mutates memory before writing. On an error boundary, only the persisted entries prove a file-backed cut saved.
904
+ const entries: SessionEntry[] = file ? readFileSync(file, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)) : ctx.sessionManager.getBranch();
905
+ const saved = new Set(entries.flatMap(entry => entry.type === "compaction" ? receiptId(entry) ?? [] : []));
906
+ return wanted.every(id => saved.has(id));
907
+ };
860
908
  const atBoundary = (event: Boundary, ctx: ExtensionContext, reason: "tool-loop" | "run-end", decide: boolean) => {
861
909
  const current = view(ctx);
862
910
  if (!current.pending) return decide ? boundaryCut(event, ctx, reason) : undefined;
@@ -870,7 +918,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
870
918
  setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
871
919
  return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
872
920
  };
873
- const retryThinking = (event: TurnEndEvent, ctx: ExtensionContext) => {
921
+ const retryLength = (event: TurnEndEvent, ctx: ExtensionContext) => {
874
922
  // Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
875
923
  // Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
876
924
  const entries: SessionBoundaryDraft[] = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
@@ -885,7 +933,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
885
933
  const asIsCheck = requestClamp(asIs, view);
886
934
  const previousClips = new Map(clips());
887
935
  const decision = deepestDecision(view, false, true);
888
- if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
936
+ if (!serverClipped.has(event.message) && decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
889
937
  requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap! <= asIsCheck.cap) {
890
938
  clips().clear(); for (const [id, clip] of previousClips) clips().set(id, clip);
891
939
  const { room, hardLine } = limits(view);
@@ -900,28 +948,32 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
900
948
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
901
949
  const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
902
950
  const { response, room, hardLine } = limits(view);
903
- if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor) throw new CapacityError(clampMessage);
951
+ // A learned server boundary uses the same admission rule as the guard: below the room the whole reserve fits in provider units.
952
+ // Keep the thinking-only rule's unconditional native-clamp floor unchanged.
953
+ const needsClampFloor = !serverClipped.has(event.message) || corrected(tokens, view) > room;
954
+ if (!fits(tokens, hardLine, view) || check.cap === undefined || needsClampFloor && !check.leavesFloor) throw new CapacityError(clampMessage);
904
955
  const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined,
905
956
  { response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
906
957
  run!.thinkingRetryPending = true;
907
- run!.pendingBoundaryCommit = true;
908
- return { entries: [...entries, { type: "compaction" as const, summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
958
+ const drafts = [...entries, { type: "compaction" as const, summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
909
959
  { type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
910
960
  stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
911
- estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
961
+ estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }];
962
+ markBoundary(drafts);
963
+ return { entries: drafts, continue: true };
912
964
  };
913
965
  pi.on("turn_end", (event, ctx) => {
914
966
  try {
915
967
  if (!active() || run?.phase !== "admitted") return;
916
- if (thinkingOnlyLength(event.message) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
917
- return retryThinking(event, ctx);
968
+ if ((thinkingOnlyLength(event.message) || serverClipped.has(event.message)) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
969
+ return retryLength(event, ctx);
918
970
  run.thinkingRetryPending = false;
919
971
  const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
920
972
  settingsFor().getCompactionSettings(ctx.model).enabled;
921
973
  if (!view(ctx).pending && !decide) return;
922
974
  const result = atBoundary(event, ctx, "tool-loop", decide);
923
975
  if (result) {
924
- run!.pendingBoundaryCommit = true;
976
+ markBoundary(result.entries);
925
977
  }
926
978
  return result;
927
979
  } catch (error) { fail(error, ctx); }
@@ -971,8 +1023,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
971
1023
  });
972
1024
  pi.on("agent_before_settle", (event, ctx) => {
973
1025
  if (run?.pendingBoundaryCommit) {
974
- if (event.outcome === "error") return void fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
975
- if (run) run.pendingBoundaryCommit = false;
1026
+ if (event.outcome === "error") {
1027
+ try { if (!boundarySaved(ctx)) throw new Error("Boundary receipt absent from the session file"); }
1028
+ catch { return void fail(new CommitFault("Native tool-loop boundary cut was not saved in the session file"), ctx); }
1029
+ }
1030
+ if (run) { run.pendingBoundaryCommit = false; run.pendingBoundaryReceipts = []; }
976
1031
  }
977
1032
  // Pi commits these entries before the run settles.
978
1033
  try {
@@ -984,7 +1039,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
984
1039
  });
985
1040
  pi.on("context_with_system", (event, ctx) => {
986
1041
  // Reaching the next request boundary proves native commit returned, not durable storage.
987
- if (run) run.pendingBoundaryCommit = false;
1042
+ if (run) { run.pendingBoundaryCommit = false; run.pendingBoundaryReceipts = []; }
988
1043
  pending = undefined;
989
1044
  try {
990
1045
  if (!active()) return;
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
7
7
  import { createRecall, type RecallPolicy } from './recall.js';
8
8
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
9
9
  export const DIAGNOSTIC_BUILD = BUILD_ID;
10
- export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
10
+ export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; learnedContextWindow?: number; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
11
11
  export function attachReceipt<T extends { details?: unknown; firstKeptEntryId: string; tokensBefore: number }>(draft: T, reason: string, reserveTokens: number, contextWindow: number, resolved: Partial<Receipt['settings']> = {}): T & { details: Record<string, unknown> & { diagnosticReceipt: Receipt } } {
12
12
  const details = draft.details && typeof draft.details === 'object' ? draft.details as Record<string, unknown> : {};
13
13
  const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' || reason === 'prompt' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
@@ -62,13 +62,36 @@ export const thinkingOnlyLength = (message: AgentMessage): boolean => message.ro
62
62
  export const needsFullReplyReserve = (message: AgentMessage): boolean => message.role === 'assistant' &&
63
63
  (message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
64
64
 
65
- /** One branch reader for the reserve override and reply floor, including history before cuts. */
65
+ /** Saved immediately at the reply's turn boundary, even when recovery cannot make a compaction. */
66
+ export interface WindowReceipt { calibrationIdentity: string; learnedContextWindow: number; serverClippedReplyId: string }
67
+ export const WINDOW_RECEIPT = 'pi-tuck-window';
68
+ function windowSettings(entry: SessionEntry): Partial<WindowReceipt> | undefined {
69
+ if (entry.type === 'custom' && entry.customType === WINDOW_RECEIPT) return entry.data as Partial<WindowReceipt> | undefined;
70
+ if (entry.type === 'compaction') return (entry.details as { diagnosticReceipt?: { settings?: Partial<WindowReceipt> } } | undefined)?.diagnosticReceipt?.settings;
71
+ return undefined;
72
+ }
73
+ export function seedContextWindow(branch: readonly SessionEntry[], identity: string): number | undefined {
74
+ for (let i = branch.length; i--;) {
75
+ const settings = windowSettings(branch[i]!);
76
+ if (settings?.calibrationIdentity !== identity) continue;
77
+ const window = settings.learnedContextWindow;
78
+ return Number.isSafeInteger(window) && window! > 0 ? window : undefined;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /** One branch reader for the reserve override and reply floor, including history before cuts. A server-clipping receipt follows its reply. */
66
84
  export function replyFit(branch: readonly SessionEntry[]): { failed: boolean; largestOutput: number; thinkingStop: boolean } {
67
85
  let failed = false, largestOutput = 0, thinkingStop = false;
68
- for (const entry of branch) {
86
+ const clipped = new Set<string>();
87
+ for (let i = branch.length; i--;) {
88
+ const entry = branch[i]!;
89
+ const receipt = entry.type === 'custom' && entry.customType === WINDOW_RECEIPT ? windowSettings(entry) : undefined;
90
+ if (receipt && Number.isSafeInteger(receipt.learnedContextWindow) && receipt.learnedContextWindow! > 0 &&
91
+ typeof receipt.serverClippedReplyId === 'string') clipped.add(receipt.serverClippedReplyId);
69
92
  if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
70
93
  const message = entry.message;
71
- failed ||= needsFullReplyReserve(message);
94
+ failed ||= !clipped.has(entry.id) && needsFullReplyReserve(message);
72
95
  thinkingStop ||= thinkingOnlyLength(message);
73
96
  const output = message.usage?.output;
74
97
  if (typeof output === 'number' && Number.isFinite(output) && output > largestOutput) largestOutput = output;
package/src/run.ts CHANGED
@@ -83,9 +83,10 @@ export interface Run {
83
83
  cut: PendingCut;
84
84
  clips: Map<string, Clip>;
85
85
  pendingBoundaryCommit: boolean;
86
+ pendingBoundaryReceipts: string[];
86
87
  thinkingRetryPending: boolean;
87
88
  failure?: Error;
88
89
  refusalReceipted: boolean;
89
90
  }
90
91
  export const createRun = (phase: Run['phase']): Run => ({ phase, claimed: false, expired: false,
91
- cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, thinkingRetryPending: false, refusalReceipted: false });
92
+ cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, pendingBoundaryReceipts: [], thinkingRetryPending: false, refusalReceipted: false });