pi-tuck 1.0.26 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compaction.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
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
4
  import { BUILD_ID } from "./build-id.js";
4
5
  import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
5
6
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
@@ -12,7 +13,7 @@ import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
12
13
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt,
13
14
  type AgentMessage, type Decision, type Messages, type Plan, type Target } from "./budget.js";
14
15
  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, type SessionBoundaryDraft, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
16
+ import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, 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";
@@ -22,7 +23,7 @@ import { tuck, tuckDraft, tuckObservedDraft, tuckRecall } from "./tuck.js";
22
23
  import { LOADED_VERSION, statusText } from "./status.js";
23
24
  import { ImageRecallInput, historyImage, historyAround } from "./history-image.js";
24
25
  import { eligibilityKey, eligibilityKeyMoved, memoizedEligibleSources } from "./eligibility-cache";
25
- import { applyHistoryTools, hasTuckCut, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
26
+ import { applyHistoryTools, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
26
27
 
27
28
  const query = Type.String({ minLength: 1, maxLength: MAX_RECALL_QUERY_CHARACTERS });
28
29
  const entryId = Type.String({ minLength: 1, maxLength: MAX_RECALL_ENTRY_ID_CHARACTERS });
@@ -47,7 +48,7 @@ const RecallInput = Type.Union([
47
48
  const SearchInput = Type.Object({query,priorResults:Type.Optional(Type.Array(Type.Object({sourceSha:Type.String({minLength:64,maxLength:64}),read:Type.Object({entryId,offset:Type.Integer({minimum:0}),limit:Type.Integer({minimum:0})},{additionalProperties:false})},{additionalProperties:false}),{maxItems:20}))},{additionalProperties:false});
48
49
  const reply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }], details: {} });
49
50
  /** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. Like every failure but
50
- * a `CommitFault`, it ends with its attempt (`forgetFailure`), and the next attempt plans afresh from the session as it is. */
51
+ * a `CommitFault`, it ends with its attempt (`resetRun`), and the next attempt plans afresh from the session as it is. */
51
52
  class Refusal extends Error {}
52
53
  /** A cut this compactor supplied that Pi then failed to commit. Pi appends a cut to the session in memory before it writes the file, so the
53
54
  * session may hold a cut its file lacks; what is written after would build on it. The one failure that stays until the session restarts. */
@@ -150,11 +151,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
150
151
  // Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
151
152
  // attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
152
153
  // A prompt's cut reaches the requests at once but the session only at the end of the first turn: until then search sees the history as the model does.
153
- const semanticProvider = (ctx: ExtensionContext) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess, promptCut ? promptCut.firstKeptEntryId || ctx.sessionManager.getBranch().findLast(e => e.type === "message" && e.message.role === "user")?.id : undefined);
154
+ const view = (ctx: ExtensionContext, threshold = false) => (threshold && run?.expired ? liveCut : run?.cut ?? liveCut).view(ctx);
155
+ const liveCut = new PendingCut();
156
+ const semanticProvider = (ctx: ExtensionContext) => () => {
157
+ const current = view(ctx);
158
+ return memoizedEligibleSources(ctx, options.recallPolicy, hostAccess, current.pending ? current.keptFromId : undefined);
159
+ };
154
160
  const recorder = new IncidentRecorder(options.diagnostics);
155
161
  let proposalId: string | undefined;
156
162
  const observe = (ctx: ExtensionContext) => recorder.measureInline(() => {
157
- if (!active() || failure) return;
163
+ if (!active() || failure()) return;
158
164
  if (!proposalId) return;
159
165
  const found = findReceiptEntry(ctx.sessionManager.getBranch(), proposalId);
160
166
  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; }
@@ -214,15 +220,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
214
220
  ctx.ui.notify(JSON.stringify({...semanticStatus(),...(errors.length?{errors}:{})}),errors.length?"warning":"info");
215
221
  }});
216
222
  pi.registerCommand("tuck", { description: "pi-tuck's state in this session: version, cuts, cut point, search", handler: async (_args, ctx) => {
217
- const { state, lastError } = semantic.status();
218
- ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic: { state, lastError } }), "info");
223
+ const state = semantic.status();
224
+ ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic: state, pending: view(ctx).pending }), "info");
219
225
  } });
220
226
  // Pi's native start/settled events bracket the run and its post-turn cuts.
221
- let admittedRun = false;
222
- let runPrompt: string | undefined;
223
- let thinkingRetryPending = false;
224
- const protectedPrompt = (ctx: ExtensionContext) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry =>
225
- entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt!))?.id : undefined;
227
+ let run: Run | undefined;
228
+ // A native append may have mutated memory before its disk write failed. This session fault outlives the run.
229
+ let commitFault: Error | undefined;
230
+ const failure = () => commitFault ?? run?.failure;
231
+ const protectedPrompt = (ctx: ExtensionContext) => run?.prompt?.id(ctx.sessionManager.getBranch());
232
+ const clips = () => run?.clips ?? emptyClips;
233
+ const emptyClips = new Map<string, Clip>();
234
+ const resetRun = () => { run = undefined; pending = undefined; };
226
235
  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");
227
236
  let estimatedTokens = 0;
228
237
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
@@ -257,7 +266,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
257
266
  const paired = pending; pending = undefined;
258
267
  const key = identityFor(ctx), message = event.message;
259
268
  const overflow = message.stopReason === "error" && isContextOverflow(message);
260
- if (!paired || !key || paired.identity !== key || !active() || failure || ctx.signal?.aborted || (message.errorMessage && !overflow) ||
269
+ if (!paired || !key || paired.identity !== key || !active() || failure() || ctx.signal?.aborted || (message.errorMessage && !overflow) ||
261
270
  message.provider !== ctx.model?.provider || message.model !== ctx.model.id || message.api !== ctx.model.api ||
262
271
  (!overflow && !["stop", "toolUse", "length"].includes(message.stopReason)) ||
263
272
  !isDeepStrictEqual(convertToLlm(paired.messages), paired.snapshot)) return;
@@ -282,8 +291,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
282
291
  // 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.
283
292
  if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
284
293
  });
285
- pi.on("agent_start", () => { admittedRun = true; thinkingRetryPending = false; pending = undefined; phase = "idle"; });
286
- pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; thinkingRetryPending = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
294
+ // 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
+ // extension's agent_settled, a failure) must not erase it. Only the final assistant reply decides it; a successful retry clears it.
296
+ let lengthStop = false;
297
+ pi.on("agent_start", () => { run ??= createRun("admitted"); run.phase = "admitted"; run.thinkingRetryPending = false; lengthStop = false; pending = undefined; });
298
+ pi.on("message_end", event => { if (event.message.role === "assistant") lengthStop = event.message.stopReason === "length"; });
299
+ pi.on("agent_settled", (_event, ctx) => {
300
+ try {
301
+ if (!lengthStop || !active()) return;
302
+ lengthStop = false;
303
+ const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
304
+ try { if (ctx.hasUI === false) process.stderr.write(message + "\n"); else ctx.ui.notify(message, "warning"); } catch { /* ended */ }
305
+ } finally { resetRun(); }
306
+ });
287
307
  const active = () => options.active?.() ?? true;
288
308
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
289
309
  // The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
@@ -296,8 +316,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
296
316
  const budget = host ?? { response: settingsFor().getCompactionSettings(ctx.model).reserveTokens };
297
317
  if (!Number.isSafeInteger(budget.response) || budget.response < 0)
298
318
  throw new Error("Invalid host request reserve");
299
- const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry =>
300
- entry.type === "message" && needsFullReplyReserve(entry.message));
319
+ const floor = host ? host.responseExact !== true : replyFit(ctx.sessionManager.getBranch()).failed;
301
320
  return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
302
321
  };
303
322
  const inputRoom = (response: number, ctx: ExtensionContext) => {
@@ -315,20 +334,20 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
315
334
  const hardLine = Math.min(Math.max(room, (ctx.model?.contextWindow ?? 0) - piMargin - floor), lastCeiling ?? Infinity);
316
335
  return { response, room, hardLine, replyFloor: floor };
317
336
  };
318
- const decisionBand = (ctx: ExtensionContext, room: number, system: number) => {
319
- const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
337
+ const decisionBand = (ctx: ExtensionContext, room: number, system: number, current = view(ctx)) => {
338
+ const last = current.session.getBranch().findLast(entry => entry.type === "compaction");
320
339
  const after = last?.type === "compaction" ? (last.details as { tokensAfter?: unknown } | undefined)?.tokensAfter : undefined;
321
340
  return bandOf(room, cutAt, options.cutAt === undefined && typeof after === "number" && Number.isFinite(after) && after >= 0
322
341
  ? { system, tokensAfter: after, factor } : undefined);
323
342
  };
324
343
  const fits = (tokens: number, limit: number, ctx: ExtensionContext) => corrected(tokens, ctx) < limit;
325
344
  // The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
326
- const measureFor = (ctx: ExtensionContext) => (messages: Messages) => projectedConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
345
+ const measureFor = (ctx: ExtensionContext) => (messages: Messages) => projectedConversationTokens(applyClips(messages, clips()), ctx, options.recallPolicy);
327
346
  // The planner counts with the same function, except that it does not fail on a summary in another format: a cut can replace one
328
347
  // (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
329
348
  const plannerMeasureFor = (ctx: ExtensionContext) => Object.assign(
330
- (messages: Messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy),
331
- { messageTokens: (message: Messages[number]) => conversationTokens(applyClips([message], clips)) });
349
+ (messages: Messages) => measurableConversationTokens(applyClips(messages, clips()), ctx, options.recallPolicy),
350
+ { messageTokens: (message: Messages[number]) => conversationTokens(applyClips([message], clips())) });
332
351
  const hostMessages = (ctx: ExtensionContext) => options.hostMessages?.(ctx) ?? [];
333
352
  const toolShapes = (names: Iterable<string>) => {
334
353
  const selected = new Set(names);
@@ -356,17 +375,17 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
356
375
  const saved = messages.some(message => message.role === "system") ? messages :
357
376
  [{ role: "system" as const, content: prompt, toolsAdded: shapes, timestamp: 0 }, ...messages];
358
377
  const declared = enabled ? historyToolDeclarations(saved, shapes, true) : saved;
359
- return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips);
378
+ return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips());
360
379
  };
361
380
  const clampAboveRoom = <D extends object>(decision: Decision<D>, ctx: ExtensionContext, room: number, target: number,
362
381
  total: (decision: Decision<D>) => number, project: (decision: Decision<D>) => Messages, replan: (target: number) => Decision<D>, deepest: () => Decision<D>): Decision<D> => {
363
382
  if (options.requestReserve || !piClamp) return decision;
364
383
  const lastAttempt = (): Decision<D> => {
365
- const previousClips = new Map(clips);
384
+ const previousClips = new Map(clips());
366
385
  const candidate = deepest();
367
386
  if (candidate.kind === "cut" && fits(total(candidate), limits(ctx).hardLine, ctx) && requestClamp(project(candidate), ctx).leavesFloor)
368
387
  return candidate;
369
- clips.clear(); for (const [id, clip] of previousClips) clips.set(id, clip);
388
+ clips().clear(); for (const [id, clip] of previousClips) clips().set(id, clip);
370
389
  return { kind: "fault", capacity: true, message: clampMessage };
371
390
  };
372
391
  for (let retry = 0; ; retry++) {
@@ -386,22 +405,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
386
405
  const isNoWork = (message: string | undefined) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
387
406
  // A missing model is configuration, not capacity.
388
407
  const missingModel = () => new Error("No context model for the request budget");
389
- // 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
390
- // before a boundary drops it, and the next prompt decides afresh.
391
- type CutEntry = { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown };
392
408
  type CutPlan = { compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number; messages: Messages };
393
- let promptCut: CutEntry | undefined;
394
- // 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
395
- // before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
396
- let promptCutAt = 0;
397
- const keptFrom = (cut: CutEntry, ctx: ExtensionContext) => cut.firstKeptEntryId ||
398
- ctx.sessionManager.getBranch().slice(promptCutAt).find(entry => entry.type !== "custom" || entry.customType !== "pi-tuck-capacity")?.id || "";
399
- let failure: Error | undefined;
400
- let refusalReceipted = false;
401
- let pendingBoundaryCommit = false;
402
- // Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
403
- // whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
404
- const clips = new Map<string, Clip>();
405
409
  const receipt = (kind: "refusal" | "above-room", reason: string, ctx: ExtensionContext) => {
406
410
  let room: number | null = null, hardLine: number | null = null;
407
411
  try { ({ room, hardLine } = limits(ctx)); } catch { /* Invalid configuration has no usable capacity. Preserve its original failure. */ }
@@ -411,42 +415,40 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
411
415
  recorder.stop("native-attempt-failed");
412
416
  // Every refusal says why, once per attempt: a capacity error with what the user can change, a cut Pi failed to commit with what that
413
417
  // means for the session, anything else as it is (an attempt that ended with no word looked like a hang).
414
- const notify = !failure;
415
- // A CommitFault stays whatever fails after it.
416
- if (!(failure instanceof CommitFault)) failure = error instanceof Error ? error : new Error(String(error));
417
- pendingBoundaryCommit = false;
418
- pending = undefined;
419
- promptCut = undefined;
420
- try {
421
- if (!refusalReceipted) { refusalReceipted = true; receipt("refusal", failure.message, ctx); }
418
+ const previous = failure(), notify = !previous;
419
+ const fault = previous instanceof CommitFault ? previous : error instanceof Error ? error : new Error(String(error));
420
+ const receipted = run?.refusalReceipted;
421
+ resetRun();
422
+ // Keep only a terminal attempt record until Pi settles: its fresh abort controller must still see the refusal.
423
+ run = createRun("failed"); run.failure = fault; run.refusalReceipted = true;
424
+ if (fault instanceof CommitFault) commitFault = fault;
425
+ try {
426
+ if (!receipted) receipt("refusal", fault.message, ctx);
422
427
  options.fail?.(error);
423
428
  } finally {
424
- try { if (notify) ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
425
- ? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
426
- : `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error"); }
429
+ try { if (notify) ctx.ui.notify(fault instanceof CapacityError ? refusalNotice(fault.message, factor) : fault instanceof CommitFault
430
+ ? `pi-tuck: ${fault.message}; the session holds a cut its file may lack, so start a new session.`
431
+ : `pi-tuck could not plan this request (${fault.message}); the next prompt is planned afresh.`, "error"); }
427
432
  finally { ctx.abort(); }
428
433
  }
429
- };
430
- // The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
431
- // input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
432
- const forgetFailure = () => { refusalReceipted = false; if (!(failure instanceof CommitFault)) failure = undefined; };
434
+ };
435
+
433
436
  const access = () => {
434
- if (failure) throw failure;
437
+ if (failure()) throw failure();
435
438
  hostAccess();
436
439
  };
437
- // Abort alone is insufficient: Pi starts a fresh abort controller after
438
- // before_agent_start. Keep this attempt blocked, then recheck on new idle input.
439
- // "decided": `input` has decided for the run it starts, so that run's before_agent_start does not decide again. "starting": a run is
440
- // between before_agent_start and agent_start, where Pi 1.1 still reports idle but a message joins that run.
441
- let phase: "idle" | "decided" | "starting" = "idle";
442
440
  pi.on("input", async (event, ctx) => {
443
- if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
444
- forgetFailure();
445
- phase = "decided";
441
+ if (!ctx.isIdle() || run?.phase === "starting") return; // steering joins the existing run
442
+ run = createRun("decided");
443
+ const decision = run;
446
444
  try { syncHistoryTools(ctx); await preflight(ctx, incomingTokens(event.text, event.images),
447
445
  [{ role: "user", content: [{ type: "text", text: event.text }, ...(event.images ?? [])], timestamp: 0 }], ctx.getSystemPrompt(), pi.getActiveTools()); syncHistoryTools(ctx); }
448
446
  catch (error) { fail(error, ctx); }
447
+ // Pi normally reaches its pre-run threshold check within microtasks. Auth I/O or a later extension's input I/O can make a
448
+ // real prompt expire too; safely fall back to carrying its cut in the request and saving it at the first boundary.
449
+ setImmediate(() => { if (run === decision && !decision.claimed) decision.expired = true; });
449
450
  });
451
+
450
452
  pi.registerTool({
451
453
  name: "tuck_recall", label: "pi-tuck history", parameters: RecallInput,
452
454
  description: "Keyword lookup locates particular text in your current branch. {query} returns ranked passages with read coordinates; punctuation separates words. Pass the returned next as {continuation} unchanged. " + (search ? "Use tuck_search for historical information relevant to a question, including different wording and qualifications. " : "") + "Knowing an identifier does not itself justify narrowing discovery. {entryId} or {index} (a summary sourceIndex or #N) reads one source, optionally with query, offset and limit in UTF-16 characters (limit defaults to 8000). {} browses history; {around} expands neighbors; {entryId,image} reads an image; outputPath writes an original to a file with the native write tool. Hidden reasoning and system records are excluded.",
@@ -500,26 +502,28 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
500
502
  if (event.signal.aborted || ctx.signal?.aborted) { recorder.stop("cut-cancelled"); return { cancel: true }; }
501
503
  // Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
502
504
  // is the answer, and it is saved before the first request.
503
- if (promptCut && !admittedRun && event.reason === "threshold") {
504
- access();
505
- const { type: _, ...compaction } = promptCut;
506
- promptCut = undefined;
507
- return { compaction };
505
+ if (run?.phase === "decided" && !run.expired && event.reason === "threshold") {
506
+ run.claimed = true;
507
+ if (view(ctx).pending) {
508
+ access();
509
+ const current = view(ctx), { type: _, ...compaction } = run.cut.take()!;
510
+ return { compaction: { ...compaction, firstKeptEntryId: current.keptFromId! } };
511
+ }
508
512
  }
509
513
  const operatorManual = event.reason === "manual";
510
- if (operatorManual && !admittedRun) forgetFailure();
514
+ if (operatorManual && run?.phase !== "admitted") resetRun();
511
515
  access();
512
516
  // pi-tuck's summary is built from the session itself, so text after /compact cannot steer it. The operator asked for a cut: make the one
513
517
  // plain /compact makes and say the text was not used, rather than cancel (which Pi shows as an error, with no cut).
514
518
  if (event.customInstructions?.trim()) ctx.ui.notify("pi-tuck: /compact ignores the text after it, since pi-tuck builds its summary from the session itself. Cutting as plain /compact does.", "info");
515
519
  const { response, room } = limits(ctx);
516
- const retain = operatorManual || admittedRun;
520
+ const retain = operatorManual || run?.phase === "admitted";
517
521
  // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
518
522
  // cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
519
523
  const saved = ctx.sessionManager.buildSessionProjection().messages;
520
524
  const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
521
525
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
522
- const mandatory = system + host, band = decisionBand(ctx, room, system);
526
+ const mandatory = system + host, band = decisionBand(ctx, room, system, view(ctx, event.reason === "threshold"));
523
527
  const before = plannerMeasureFor(ctx)(saved);
524
528
  observeBudget(before + mandatory, ctx);
525
529
  // Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
@@ -541,9 +545,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
541
545
  const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
542
546
  // Every cut asked while no run is admitted (an operator's cut) is planned for a run that has not begun: the session has moved on
543
547
  // from any run that ended. A retry is inside its run.
544
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
548
+ const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || run?.phase !== "admitted", run?.phase === "admitted" ? protectedPrompt(ctx) : undefined);
545
549
  return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve,
546
- messages: previewCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).buildSessionProjection().messages }
550
+ messages: plannedView(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).session.buildSessionProjection().messages }
547
551
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
548
552
  },
549
553
  });
@@ -556,7 +560,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
556
560
  candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
557
561
  candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"),
558
562
  target => target <= room ? decide() : decide(target, target),
559
- () => deepestDecision(ctx, operatorManual || !admittedRun, retain));
563
+ () => deepestDecision(ctx, operatorManual || run?.phase !== "admitted", retain));
560
564
  }
561
565
  if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
562
566
  retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
@@ -572,38 +576,21 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
572
576
  }
573
577
  });
574
578
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
575
- pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; refusalReceipted = false; admittedRun = false; runPrompt = undefined; estimatedTokens = 0; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
576
- const historyToolsEnabled = (ctx: ExtensionContext) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
579
+ pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; });
580
+ const historyToolsEnabled = (ctx: ExtensionContext) => view(ctx).hasTuckCut;
577
581
  const syncHistoryTools = (ctx: ExtensionContext) => { if (active()) applyHistoryTools(pi, historyToolsEnabled(ctx)); };
578
582
  pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
579
583
  // 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
580
584
  // may not be on this one.
581
- pi.on("session_tree", (_event, ctx) => { promptCut = undefined; phase = "idle"; syncHistoryTools(ctx); });
582
- pi.on("session_compact", (_event, ctx) => { if (!admittedRun) syncHistoryTools(ctx); });
585
+ pi.on("session_tree", (_event, ctx) => { resetRun(); syncHistoryTools(ctx); });
586
+ pi.on("session_compact", (_event, ctx) => { if (run?.phase !== "admitted") syncHistoryTools(ctx); });
583
587
  pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
584
588
  pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
585
- // 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:
586
- // 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)
587
- // that ends normally clears it.
588
- let lengthStop = false;
589
- pi.on("agent_start", () => { lengthStop = false; });
590
- pi.on("message_end", event => { if (event.message.role === "assistant") lengthStop = event.message.stopReason === "length"; });
591
- pi.on("agent_settled", (_event, ctx) => {
592
- if (!lengthStop || !active()) return;
593
- lengthStop = false;
594
- const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
595
- 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
596
- });
597
- // 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.
598
- const previewCut = (ctx: ExtensionContext, cut: CutEntry) => {
599
- const header = ctx.sessionManager.getHeader();
600
- if (!header) throw new Error("Missing native session header for the prompt's cut");
601
- const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
602
- preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
603
- return preview;
589
+ const plannedView = (ctx: ExtensionContext, cut: CutEntry) => {
590
+ const pending = new PendingCut();
591
+ pending.plan(cut, ctx.sessionManager.getBranch().length);
592
+ return pending.view(ctx);
604
593
  };
605
- const cutSession = (ctx: ExtensionContext, cut: CutEntry) => previewCut(ctx, { ...cut, firstKeptEntryId: keptFrom(cut, ctx) });
606
- const withCut = (ctx: ExtensionContext, cut: CutEntry) => cutSession(ctx, cut).buildSessionProjection().messages;
607
594
  // The shared final planner: preserve the compiler's prompt/protocol floor and spend nothing on optional history. Fresh text results
608
595
  // get the smallest existing head/tail + recall-locator representation; their originals remain in the session, as for ordinary clips.
609
596
  const deepestDecision = (ctx: ExtensionContext, newPrompt: boolean, retain: boolean): Decision<CutPlan> => {
@@ -621,7 +608,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
621
608
  const middle = Math.floor((low + high) / 2);
622
609
  if (clipFor([result], entryIdOf, middle).length) high = middle; else low = middle + 1;
623
610
  }
624
- for (const clip of clipFor([result], entryIdOf, low)) clips.set(clip.toolCallId, clip);
611
+ for (const clip of clipFor([result], entryIdOf, low)) clips().set(clip.toolCallId, clip);
625
612
  }
626
613
  }
627
614
  const before = measureFor(ctx)(saved);
@@ -629,7 +616,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
629
616
  previousSummary: saved.find(message => message.role === "compactionSummary")?.summary, newPrompt, reRenderSummary: true,
630
617
  protectedPromptEntryId: newPrompt ? undefined : protectedPrompt(ctx), deepest: true }, ctx, 0, plannerMeasureFor(ctx), options.recallPolicy, retain);
631
618
  if (!draft.compaction) return { kind: "fault", capacity: Boolean(draft.capacityRejected), message: draft.unavailable };
632
- const messages = previewCut(ctx, { type: "compaction", ...draft.compaction }).buildSessionProjection().messages;
619
+ const messages = plannedView(ctx, { type: "compaction", ...draft.compaction }).session.buildSessionProjection().messages;
633
620
  assertNativeToolRounds(messages);
634
621
  return { kind: "cut", target: "room", plan: { draft: true, after: measureFor(ctx)(messages), mode: draft.mode,
635
622
  compaction: draft.compaction, reserve: 0, messages } };
@@ -639,8 +626,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
639
626
  // keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
640
627
  // Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
641
628
  const preflight = (ctx: ExtensionContext, incomingPart: number, incomingMessages: Messages, observedPrompt: string, tools: readonly string[], hostSnapshot?: Messages) => {
642
- promptCut = undefined;
643
- promptCutAt = ctx.sessionManager.getBranch().length;
629
+ run!.cut.clear();
630
+ const branchLength = ctx.sessionManager.getBranch().length;
644
631
  if (!active()) return;
645
632
  access();
646
633
  if (!settingsFor().getCompactionSettings(ctx.model).enabled) return;
@@ -670,7 +657,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
670
657
  // there buys nothing the request needed and costs history the next recall would have found.
671
658
  const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
672
659
  if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
673
- const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
660
+ const candidate = new PendingCut();
661
+ candidate.plan({ type: "compaction", ...draft.compaction, tokensBefore: before }, branchLength);
662
+ const messages = candidate.view(ctx).session.buildSessionProjection().messages;
674
663
  assertNativeToolRounds(messages);
675
664
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
676
665
  },
@@ -692,14 +681,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
692
681
  if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
693
682
  const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary,
694
683
  { response, host, mandatory, target: decision.target, margin: room - band });
695
- promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
684
+ run!.cut.plan({ type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details }, branchLength);
696
685
  };
697
686
  pi.on("before_agent_start", async (event, ctx) => {
698
687
  // A decided plan is for the prompt `input` saw. A run that a custom message starts is not that run (another extension handled the
699
688
  // input, so no run started for it): plan afresh.
700
- const prompted = phase === "decided" && event.messageType !== "custom";
701
- phase = "starting";
702
- runPrompt = event.messageType === "custom" ? undefined : event.prompt;
689
+ const prompted = run?.phase === "decided" && event.messageType !== "custom";
690
+ if (!prompted && run?.phase !== "failed") run = createRun("starting");
691
+ run ??= createRun("starting");
692
+ run.claimed = true;
693
+ run.phase = "starting";
694
+ const prompt = event.messageType === "custom" ? textOf(event.message.content) : event.prompt;
695
+ run.prompt = prompt ? new StartingPrompt(prompt, ctx.sessionManager.getBranch().length, event.messageType) : undefined;
703
696
  try {
704
697
  syncHistoryTools(ctx);
705
698
  if (prompted) access(); // decided on input; a failure latched there still stops the run
@@ -719,7 +712,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
719
712
  // handlers have run. Pi's prepareRequest has not yet snapshotted executable tools. A first cut discovered in context_with_system
720
713
  // would be too late for that snapshot, even if its declarations were repaired there, so make the late prompt decision here.
721
714
  pi.on("message_end", (event, ctx) => {
722
- if (event.message.role !== "user" || !active() || !admittedRun || promptCut) return;
715
+ if (event.message.role !== "user" || !active() || run?.phase !== "admitted" || view(ctx).pending) return;
723
716
  try {
724
717
  access();
725
718
  if (!ctx.model) throw missingModel();
@@ -804,11 +797,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
804
797
  }
805
798
  return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
806
799
  }
807
- const header = ctx.sessionManager.getHeader();
808
- if (!header) throw new Error("Missing native session header at tool-loop boundary");
809
- const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
810
- preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
811
- const messages = preview.buildSessionProjection().messages;
800
+ const messages = plannedView(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).session.buildSessionProjection().messages;
812
801
  assertNativeToolRounds(messages);
813
802
  previews.push(messages);
814
803
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
@@ -831,9 +820,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
831
820
  for (const budget of clipBudgets(clipRoom, factor, mandatory, kept)) {
832
821
  const round = clipFor(event.toolResults, entryIdOf, budget);
833
822
  if (!round.length) continue;
834
- for (const clip of round) clips.set(clip.toolCallId, clip);
823
+ for (const clip of round) run!.clips.set(clip.toolCallId, clip);
835
824
  const clippedDecision = decide(clipRoom, targetBand, required);
836
- if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
825
+ if (clippedDecision.kind === "fault") for (const clip of round) run!.clips.delete(clip.toolCallId);
837
826
  else { decision = clippedDecision; break; }
838
827
  }
839
828
  };
@@ -869,14 +858,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
869
858
  // the session as it is with that cut: a fresh result can still need a cut of its own there.
870
859
  type Boundary = Parameters<typeof boundaryCut>[0];
871
860
  const atBoundary = (event: Boundary, ctx: ExtensionContext, reason: "tool-loop" | "run-end", decide: boolean) => {
872
- if (!promptCut) return decide ? boundaryCut(event, ctx, reason) : undefined;
873
- const entry = promptCut;
874
- promptCut = undefined;
875
- const preview = cutSession(ctx, entry), view: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
861
+ const current = view(ctx);
862
+ if (!current.pending) return decide ? boundaryCut(event, ctx, reason) : undefined;
863
+ const entry = run!.cut.take()!;
864
+ const preview = current.session, context: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
876
865
  const more = decide && !event.entries.length
877
- ? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, view, reason) : undefined;
866
+ ? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, context, reason) : undefined;
878
867
  const { tokensBefore: _, ...cut } = entry;
879
- const saved = { ...cut, firstKeptEntryId: keptFrom(entry, ctx) };
868
+ const saved = { ...cut, firstKeptEntryId: current.keptFromId! };
880
869
  // As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
881
870
  setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
882
871
  return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
@@ -886,44 +875,36 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
886
875
  // Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
887
876
  const entries: SessionBoundaryDraft[] = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
888
877
  { type: "context_edit", targetId: event.messageEntryId, replacement: null }];
889
- if (thinkingRetryPending) return { entries, continue: false };
890
- const header = ctx.sessionManager.getHeader();
891
- if (!header) throw new Error("Missing native session header for the thinking-only retry");
892
- const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
893
- for (const entry of entries) {
894
- if (entry.type === "context_edit") preview.appendContextEdit(entry.targetId, entry.replacement);
895
- else if (entry.type === "compaction") preview.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
896
- else if (entry.type === "custom") preview.appendCustomEntry(entry.customType, entry.data);
897
- else preview.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
898
- }
878
+ if (run!.thinkingRetryPending) return { entries, continue: false };
879
+ const preview = liveCut.view(ctx, entries).session;
899
880
  const view: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
900
881
  const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
901
882
  // 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
902
883
  // as large as the deepest cut's, the turn is retried uncut, and no history is given up for nothing.
903
884
  const asIs = projectedRequest(preview.buildSessionProjection().messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), false);
904
885
  const asIsCheck = requestClamp(asIs, view);
905
- const previousClips = new Map(clips);
886
+ const previousClips = new Map(clips());
906
887
  const decision = deepestDecision(view, false, true);
907
888
  if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
908
889
  requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap! <= asIsCheck.cap) {
909
- clips.clear(); for (const [id, clip] of previousClips) clips.set(id, clip);
890
+ clips().clear(); for (const [id, clip] of previousClips) clips().set(id, clip);
910
891
  const { room, hardLine } = limits(view);
911
- thinkingRetryPending = true;
892
+ run!.thinkingRetryPending = true;
912
893
  return { entries: [...entries, { type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
913
894
  stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: false,
914
895
  piEstimateTokens: asIsCheck.estimate, piCap: asIsCheck.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
915
896
  }
916
897
  if (decision.kind !== "cut") throw new CapacityError(clampMessage);
917
898
  const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
918
- if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt)) throw promptRefusal();
899
+ if (run?.prompt && !carriesPrompt(messages, run.prompt.text(ctx.sessionManager.getBranch()), run.prompt.role)) throw promptRefusal();
919
900
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
920
901
  const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
921
902
  const { response, room, hardLine } = limits(view);
922
903
  if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor) throw new CapacityError(clampMessage);
923
904
  const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined,
924
905
  { response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
925
- thinkingRetryPending = true;
926
- pendingBoundaryCommit = true;
906
+ run!.thinkingRetryPending = true;
907
+ run!.pendingBoundaryCommit = true;
927
908
  return { entries: [...entries, { type: "compaction" as const, summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
928
909
  { type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
929
910
  stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
@@ -931,16 +912,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
931
912
  };
932
913
  pi.on("turn_end", (event, ctx) => {
933
914
  try {
934
- if (!active() || !admittedRun) return;
935
- if (thinkingOnlyLength(event.message) && !failure && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
915
+ if (!active() || run?.phase !== "admitted") return;
916
+ if (thinkingOnlyLength(event.message) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
936
917
  return retryThinking(event, ctx);
937
- thinkingRetryPending = false;
918
+ run.thinkingRetryPending = false;
938
919
  const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
939
920
  settingsFor().getCompactionSettings(ctx.model).enabled;
940
- if (!promptCut && !decide) return;
921
+ if (!view(ctx).pending && !decide) return;
941
922
  const result = atBoundary(event, ctx, "tool-loop", decide);
942
923
  if (result) {
943
- pendingBoundaryCommit = true;
924
+ run!.pendingBoundaryCommit = true;
944
925
  }
945
926
  return result;
946
927
  } catch (error) { fail(error, ctx); }
@@ -956,21 +937,22 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
956
937
  // A request of a run whose prompt's cut is not saved yet: the saved conversation in it, which other handlers may surround, is replaced by
957
938
  // the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
958
939
  // here. A request that does not carry the saved conversation as it stands cannot take the cut.
959
- const cutRequest = <M,>(messages: M[], ctx: ExtensionContext, cut: CutEntry): M[] => {
940
+ const cutRequest = <M,>(messages: M[], ctx: ExtensionContext): M[] => {
960
941
  const conversation = (all: Messages) => all.filter(message => message.role !== "system") as unknown[];
961
942
  const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
962
943
  for (let at = 0; at + saved.length <= messages.length; at++)
963
944
  if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
964
- return [...messages.slice(0, at), ...conversation(withCut(ctx, cut)) as M[], ...messages.slice(at + saved.length)];
945
+ return [...messages.slice(0, at), ...conversation(view(ctx).session.buildSessionProjection().messages) as M[], ...messages.slice(at + saved.length)];
965
946
  throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
966
947
  };
967
948
  pi.on("context", (event, ctx) => {
968
949
  try {
969
950
  if (!active()) return;
951
+ run?.prompt?.id(ctx.sessionManager.getBranch());
970
952
  // Before a cut, Pi owns the whole request, including its object identities and system-message serialization.
971
- if (!historyToolsEnabled(ctx)) return clips.size ? { messages: applyClips(event.messages, clips) } : undefined;
953
+ if (!historyToolsEnabled(ctx)) return clips().size ? { messages: applyClips(event.messages, clips()) } : undefined;
972
954
  access();
973
- return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
955
+ return { messages: applyClips(projectedMessages(view(ctx).pending ? cutRequest(event.messages, ctx) : event.messages, ctx, options.recallPolicy), clips()) };
974
956
  } catch (error) { try { fail(error, ctx); } finally { return historyToolsEnabled(ctx) ? { messages: [] } : undefined; } }
975
957
  });
976
958
  pi.on("session_compact_failed", (event, ctx) => {
@@ -988,21 +970,21 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
988
970
  if (active() && !event.aborted) fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
989
971
  });
990
972
  pi.on("agent_before_settle", (event, ctx) => {
991
- if (pendingBoundaryCommit) {
973
+ if (run?.pendingBoundaryCommit) {
992
974
  if (event.outcome === "error") return void fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
993
- pendingBoundaryCommit = false;
975
+ if (run) run.pendingBoundaryCommit = false;
994
976
  }
995
977
  // Pi commits these entries before the run settles.
996
978
  try {
997
- if (!active() || !admittedRun || failure || !settingsFor().getCompactionSettings(ctx.model).enabled) return;
979
+ if (!active() || run?.phase !== "admitted" || failure() || !settingsFor().getCompactionSettings(ctx.model).enabled) return;
998
980
  const decide = event.outcome === "completed" && !ctx.signal?.aborted;
999
- if (promptCut ? event.outcome === "error" : !decide) return;
981
+ if (view(ctx).pending ? event.outcome === "error" : !decide) return;
1000
982
  return atBoundary(event, ctx, "run-end", decide);
1001
983
  } catch (error) { fail(error, ctx); }
1002
984
  });
1003
985
  pi.on("context_with_system", (event, ctx) => {
1004
986
  // Reaching the next request boundary proves native commit returned, not durable storage.
1005
- pendingBoundaryCommit = false;
987
+ if (run) run.pendingBoundaryCommit = false;
1006
988
  pending = undefined;
1007
989
  try {
1008
990
  if (!active()) return;
@@ -1019,7 +1001,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
1019
1001
  observeBudget(tokens, ctx);
1020
1002
  estimatedTokens = corrected(tokens, ctx);
1021
1003
  const { room, hardLine } = limits(ctx);
1022
- if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
1004
+ if (run?.prompt && !carriesPrompt(messages, run.prompt.text(ctx.sessionManager.getBranch()), run.prompt.role))
1023
1005
  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");
1024
1006
  if (!fits(tokens, hardLine, ctx))
1025
1007
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");